From db25a977b7166975ce2bc5220afec2c558979edc Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 18 Aug 2026 21:34:14 +0200 Subject: [PATCH] fix(grid-agent): move setup into YAML preferences (#135) --- Cargo.lock | 26 + config/grid-agent.example.json | 75 --- config/grid-agent.example.yml | 52 ++ config/grid-agent.integrated.example.json | 20 - config/grid-agent.integrated.example.yml | 15 + config/grid-agent.split.example.json | 25 - config/grid-agent.split.example.yml | 18 + crates/metacrate-grid-agent/Cargo.toml | 1 + crates/metacrate-grid-agent/README.md | 30 +- crates/metacrate-grid-agent/src/acceptance.rs | 40 +- crates/metacrate-grid-agent/src/backend.rs | 2 +- crates/metacrate-grid-agent/src/config.rs | 588 +++++++++++++----- crates/metacrate-grid-agent/src/lib.rs | 14 +- crates/metacrate-grid-agent/src/llm.rs | 10 +- crates/metacrate-grid-agent/src/main.rs | 77 ++- crates/metacrate-grid-agent/src/tui.rs | 420 ++++++++++++- crates/metacrate-grid-agent/src/tui_tests.rs | 3 +- .../tests/acceptance_gate.rs | 21 +- .../tests/dependency_policy.rs | 3 +- .../tests/operations_packaging.rs | 20 +- .../tests/preferences_config.rs | 110 ++++ docs/grid-agent-acceptance.md | 55 +- docs/grid-agent-architecture.md | 2 +- docs/grid-agent-operations.md | 69 +- docs/grid-agent-tui.md | 16 +- .../systemd/grid-agent.env.example | 7 - .../systemd/metacrate-grid-agent.service | 5 +- 27 files changed, 1242 insertions(+), 482 deletions(-) delete mode 100644 config/grid-agent.example.json create mode 100644 config/grid-agent.example.yml delete mode 100644 config/grid-agent.integrated.example.json create mode 100644 config/grid-agent.integrated.example.yml delete mode 100644 config/grid-agent.split.example.json create mode 100644 config/grid-agent.split.example.yml create mode 100644 crates/metacrate-grid-agent/tests/preferences_config.rs delete mode 100644 packaging/metacrate-grid-agent/systemd/grid-agent.env.example diff --git a/Cargo.lock b/Cargo.lock index 5e7296c..bc2c731 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2282,6 +2282,7 @@ dependencies = [ "rustls", "serde", "serde_json", + "serde_yaml_ng", "sha2 0.11.0", "tokio", "tokio-rustls", @@ -3405,6 +3406,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3559,6 +3566,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.7" @@ -4290,6 +4310,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.7.1" diff --git a/config/grid-agent.example.json b/config/grid-agent.example.json deleted file mode 100644 index 7e28042..0000000 --- a/config/grid-agent.example.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "schema_version": 1, - "integrated": false, - "split": false, - "llm": { - "endpoint_url": "https://llm.example.invalid/v1/chat/completions", - "api_key": "" - }, - "authorized_avatar_uuids": [], - "timeouts": { - "startup_seconds": 30, - "shutdown_seconds": 10, - "request_seconds": 60 - }, - "limits": { - "grid_event_queue": 256, - "control_queue": 32, - "observable_queue": 512, - "max_body_bytes": 1048576, - "max_message_bytes": 16384, - "max_conversation_messages": 64, - "max_tool_calls": 16, - "max_authorized_avatars": 128, - "max_background_tasks": 2 - }, - "storage_path": "data/grid-agent", - "behavior": { - "heartbeat_seconds": 30, - "settle_milliseconds": 2000, - "response_delay_min_milliseconds": 350, - "response_delay_max_milliseconds": 1200, - "attention_dwell_milliseconds": 4000, - "idle_interval_seconds": 120, - "action_timeout_seconds": 15, - "max_walk_duration_seconds": 10, - "stuck_timeout_seconds": 3, - "min_action_interval_milliseconds": 750, - "max_walk_distance_meters": 8, - "max_attention_distance_meters": 96, - "idle_look_enabled": true - }, - "reconnect": { - "initial_delay_milliseconds": 1000, - "maximum_delay_seconds": 60, - "stable_reset_seconds": 120, - "jitter_basis_points": 2000, - "offline_work_capacity": 128 - }, - "conversation": { - "persistence_enabled": false, - "max_active_sessions": 512, - "max_turns_per_session": 64, - "max_session_bytes": 262144, - "max_total_bytes": 8388608, - "max_tool_results_per_session": 16, - "max_tool_result_bytes": 16384, - "max_persisted_bytes": 16777216, - "max_summary_bytes": 8192 - }, - "interaction": { - "aliases": ["metacrate"], - "debounce_milliseconds": 250, - "model_timeout_seconds": 30, - "public_rate_milliseconds": 750, - "im_rate_milliseconds": 250, - "public_followup_seconds": 120, - "max_response_bytes": 2048, - "grid_chunk_bytes": 1023, - "max_active_senders": 512, - "max_queued_per_sender": 16, - "max_concurrent_inference": 4, - "max_duplicate_ids": 4096, - "max_debounce_fragments": 8 - } -} diff --git a/config/grid-agent.example.yml b/config/grid-agent.example.yml new file mode 100644 index 0000000..6692555 --- /dev/null +++ b/config/grid-agent.example.yml @@ -0,0 +1,52 @@ +schema_version: 1 +integrated: false +split: false +llm: + endpoint_url: https://llm.example.invalid/v1/chat/completions + api_key: + model: +authorized_avatar_uuids: [] +storage_path: data/grid-agent +timeouts: + startup_seconds: 30 + shutdown_seconds: 10 + request_seconds: 60 +limits: + grid_event_queue: 256 + control_queue: 32 + observable_queue: 512 + max_body_bytes: 1048576 + max_message_bytes: 16384 + max_conversation_messages: 64 + max_tool_calls: 16 + max_authorized_avatars: 128 + max_background_tasks: 2 +behavior: + heartbeat_seconds: 30 + settle_milliseconds: 2000 + response_delay_min_milliseconds: 350 + response_delay_max_milliseconds: 1200 + attention_dwell_milliseconds: 4000 + idle_interval_seconds: 120 + action_timeout_seconds: 15 + max_walk_duration_seconds: 10 + stuck_timeout_seconds: 3 + min_action_interval_milliseconds: 750 + max_walk_distance_meters: 8 + max_attention_distance_meters: 96 + idle_look_enabled: true +reconnect: + initial_delay_milliseconds: 1000 + maximum_delay_seconds: 60 + stable_reset_seconds: 120 + jitter_basis_points: 2000 + offline_work_capacity: 128 +conversation: + persistence_enabled: false +interaction: + aliases: [metacrate] + debounce_milliseconds: 250 + model_timeout_seconds: 30 + public_rate_milliseconds: 750 + im_rate_milliseconds: 250 + public_followup_seconds: 120 diff --git a/config/grid-agent.integrated.example.json b/config/grid-agent.integrated.example.json deleted file mode 100644 index 80a46a6..0000000 --- a/config/grid-agent.integrated.example.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema_version": 1, - "integrated": true, - "split": false, - "llm": { - "endpoint_url": "https://llm.example.invalid/v1/chat/completions" - }, - "grid": { - "login_url": "https://grid.example.invalid/login", - "avatar_name": "Service Avatar" - }, - "secret_files": { - "llm_api_key": "secrets/llm-api-key", - "grid_password": "secrets/grid-password" - }, - "authorized_avatar_uuids": [ - "00000000-0000-4000-8000-000000000001" - ], - "storage_path": "data/grid-agent" -} diff --git a/config/grid-agent.integrated.example.yml b/config/grid-agent.integrated.example.yml new file mode 100644 index 0000000..5ba76e0 --- /dev/null +++ b/config/grid-agent.integrated.example.yml @@ -0,0 +1,15 @@ +schema_version: 1 +integrated: true +split: false +llm: + endpoint_url: https://llm.example.invalid/v1/chat/completions + api_key: + model: +grid: + login_url: https://grid.example.invalid/login + avatar_name: Service Avatar + password: +# These UUIDs are the only users allowed to request privileged mutations. +authorized_avatar_uuids: + - 00000000-0000-4000-8000-000000000001 +storage_path: data/grid-agent diff --git a/config/grid-agent.split.example.json b/config/grid-agent.split.example.json deleted file mode 100644 index 63c70d7..0000000 --- a/config/grid-agent.split.example.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "schema_version": 1, - "integrated": false, - "split": true, - "llm": { - "endpoint_url": "https://llm.example.invalid/v1/chat/completions" - }, - "grid": { - "login_url": "https://grid.example.invalid/login", - "avatar_name": "Service Avatar" - }, - "secret_files": { - "llm_api_key": "secrets/llm-api-key", - "grid_password": "secrets/grid-password", - "control_operator_token": "secrets/control-operator-token", - "control_observer_token": "secrets/control-observer-token" - }, - "authorized_avatar_uuids": [ - "00000000-0000-4000-8000-000000000001" - ], - "storage_path": "data/grid-agent", - "control": { - "listen": "127.0.0.1:9764" - } -} diff --git a/config/grid-agent.split.example.yml b/config/grid-agent.split.example.yml new file mode 100644 index 0000000..39b7408 --- /dev/null +++ b/config/grid-agent.split.example.yml @@ -0,0 +1,18 @@ +schema_version: 1 +integrated: false +split: true +llm: + endpoint_url: https://llm.example.invalid/v1/chat/completions + api_key: + model: +grid: + login_url: https://grid.example.invalid/login + avatar_name: Service Avatar + password: +authorized_avatar_uuids: + - 00000000-0000-4000-8000-000000000001 +storage_path: data/grid-agent +control: + listen: 127.0.0.1:9764 + operator_token: + observer_token: diff --git a/crates/metacrate-grid-agent/Cargo.toml b/crates/metacrate-grid-agent/Cargo.toml index b44eb21..e47122f 100644 --- a/crates/metacrate-grid-agent/Cargo.toml +++ b/crates/metacrate-grid-agent/Cargo.toml @@ -21,6 +21,7 @@ reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] rustls = { version = "0.23.43", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml_ng = "0.10.0" sha2 = "0.11" tokio = { version = "1.53.1", features = ["macros", "rt", "sync", "time"] } tokio-rustls = { version = "0.26.4", default-features = false, features = ["aws_lc_rs", "tls12"] } diff --git a/crates/metacrate-grid-agent/README.md b/crates/metacrate-grid-agent/README.md index 01a7d49..f63d6ac 100644 --- a/crates/metacrate-grid-agent/README.md +++ b/crates/metacrate-grid-agent/README.md @@ -11,24 +11,20 @@ adapters. The `live-grid` feature exposes the owner for the existing client's native `NetworkManager` for login, event-queue readiness, disconnect notifications, and logout instead of adding a protocol client. -The LLM connection identity has exactly two resolved fields: -`llm.endpoint_url` and `llm.api_key`. The endpoint is used exactly as supplied; +The LLM connection identity has an exact `llm.endpoint_url`, `llm.api_key`, and +optional `llm.model`. The endpoint is used exactly as supplied; there are no providers, presets, base-URL rewrites, model catalogs, discovery, or provider SDKs. `Debug`/`Display` output removes API keys, grid passwords, URL user information, and URL query values. Secret wrappers are not serializable. -Configuration precedence, from lowest to highest, is built-in defaults, an -optional JSON file, its referenced secret files, then environment values (an -environment-referenced secret file is below a direct environment secret). -Supported secret environment variables are -`METACRATE_AGENT_LLM_API_KEY[_FILE]` and -`METACRATE_AGENT_GRID_PASSWORD[_FILE]`. Split control uses the separate -`METACRATE_AGENT_CONTROL_OPERATOR_TOKEN[_FILE]` and optional -`METACRATE_AGENT_CONTROL_OBSERVER_TOKEN[_FILE]`, plus -`METACRATE_AGENT_CONTROL_LISTEN`. Secret files must be bounded regular, -non-symlink UTF-8 files containing one line. Operators must restrict their OS -ACLs to the service identity; the core uses only portable `std::fs` checks and -does not assume Unix permission bits. +Persistent setup lives in the platform `config.yml` (normally +`~/.config/metacrate/config.yml` on Linux). The file contains grid/AI +connectivity and the exact privileged avatar UUID list; it is written with mode +0600 on Unix. `--preferences` opens the setup panel without starting the +service. `--import-env .env` performs a one-time import of the legacy +`GRID_*`/`OPENAPI_*` values; runtime configuration does not read `.env` or +connection environment variables. JSON files and referenced secret files are +accepted only as a legacy migration input. Run the focused offline gate with: @@ -36,9 +32,9 @@ Run the focused offline gate with: cargo test --locked -p metacrate-grid-agent cargo clippy --locked -p metacrate-grid-agent --all-targets -- -D warnings cargo run --locked -p metacrate-grid-agent -- \ - --config config/grid-agent.example.json --check-config + --config config/grid-agent.example.yml --check-config cargo run --locked -p metacrate-grid-agent -- \ - --config config/grid-agent.example.json --run-once + --config config/grid-agent.example.yml --run-once ``` See [`../../docs/grid-agent-architecture.md`](../../docs/grid-agent-architecture.md) @@ -68,6 +64,6 @@ The complete quick start, platform paths, systemd/Windows service operation, secret rotation, backup/upgrade/rollback, failure playbooks, resource defaults, and unsupported-operation list are in [`../../docs/grid-agent-operations.md`](../../docs/grid-agent-operations.md). -Milestone acceptance, resource budgets, evidence, and opt-in live validation +Milestone acceptance, resource budgets, evidence, and live-grid validation are defined in [`../../docs/grid-agent-acceptance.md`](../../docs/grid-agent-acceptance.md). diff --git a/crates/metacrate-grid-agent/src/acceptance.rs b/crates/metacrate-grid-agent/src/acceptance.rs index 047c25f..a95ffb4 100644 --- a/crates/metacrate-grid-agent/src/acceptance.rs +++ b/crates/metacrate-grid-agent/src/acceptance.rs @@ -1,4 +1,4 @@ -//! Reproducible milestone acceptance evidence and fail-closed live opt-ins. +//! Reproducible milestone acceptance evidence and live-grid verification. #![allow(clippy::missing_errors_doc)] @@ -237,44 +237,6 @@ impl AcceptanceEvidenceWriter { } } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -#[allow(clippy::struct_excessive_bools)] -pub struct LiveGridOptIns { - pub login: bool, - pub chat_and_im: bool, - pub script_delivery: bool, - pub landmarks_and_roaming: bool, - pub reversible_build: bool, - pub visual_capture: bool, -} - -impl LiveGridOptIns { - #[must_use] - pub fn from_environment(get: impl Fn(&str) -> Option) -> Self { - Self { - login: confirmed(&get, "METACRATE_AGENT_LIVE_LOGIN", "LOGIN"), - chat_and_im: confirmed(&get, "METACRATE_AGENT_LIVE_CHAT_IM", "CHAT-IM"), - script_delivery: confirmed(&get, "METACRATE_AGENT_LIVE_SCRIPT", "SCRIPT"), - landmarks_and_roaming: confirmed(&get, "METACRATE_AGENT_LIVE_LANDMARKS", "LANDMARKS"), - reversible_build: confirmed(&get, "METACRATE_AGENT_LIVE_BUILD", "BUILD-CLEANUP"), - visual_capture: confirmed(&get, "METACRATE_AGENT_LIVE_VISUAL", "VISUAL"), - } - } - - pub fn validate(self) -> Result { - if !self.login && self != Self::default() { - return Err(AcceptanceError::Gate( - "live action opt-ins require the separate login opt-in", - )); - } - Ok(self) - } -} - -fn confirmed(get: &impl Fn(&str) -> Option, name: &str, literal: &str) -> bool { - get(name).is_some_and(|value| value == literal) -} - struct AcceptanceResponder; impl InteractionResponder for AcceptanceResponder { diff --git a/crates/metacrate-grid-agent/src/backend.rs b/crates/metacrate-grid-agent/src/backend.rs index 50a4df9..649eb77 100644 --- a/crates/metacrate-grid-agent/src/backend.rs +++ b/crates/metacrate-grid-agent/src/backend.rs @@ -78,7 +78,7 @@ pub trait WorldMutator: AuthorizedToolBackend {} /// Inert deterministic backend used by the foundational offline service. /// /// It performs no login or network operation and is available without the -/// opt-in live-grid dependency graph. +/// live-grid dependency graph. #[derive(Clone, Copy, Debug, Default)] pub struct OfflineGridBackend; diff --git a/crates/metacrate-grid-agent/src/config.rs b/crates/metacrate-grid-agent/src/config.rs index 478c7d4..726f5bb 100644 --- a/crates/metacrate-grid-agent/src/config.rs +++ b/crates/metacrate-grid-agent/src/config.rs @@ -2,11 +2,12 @@ use crate::types::{MAX_BODY_BYTES, MAX_CONVERSATION_MESSAGES, MAX_MESSAGE_BYTES, MAX_TOOL_CALLS}; use libremetaverse_types::UUID; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::error::Error; use std::fmt; use std::fs; +use std::io::Write as _; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -21,22 +22,10 @@ const MAX_QUEUE_CAPACITY: usize = 8_192; const MAX_BACKGROUND_TASKS: usize = 2; pub const CONFIG_SCHEMA_VERSION: u32 = 1; -const ENV_INTEGRATED: &str = "METACRATE_AGENT_INTEGRATED"; -const ENV_SPLIT: &str = "METACRATE_AGENT_SPLIT"; +#[cfg(test)] const ENV_LLM_ENDPOINT: &str = "METACRATE_AGENT_LLM_ENDPOINT_URL"; +#[cfg(test)] const ENV_LLM_API_KEY: &str = "METACRATE_AGENT_LLM_API_KEY"; -const ENV_LLM_API_KEY_FILE: &str = "METACRATE_AGENT_LLM_API_KEY_FILE"; -const ENV_GRID_LOGIN_URL: &str = "METACRATE_AGENT_GRID_LOGIN_URL"; -const ENV_GRID_AVATAR_NAME: &str = "METACRATE_AGENT_GRID_AVATAR_NAME"; -const ENV_GRID_PASSWORD: &str = "METACRATE_AGENT_GRID_PASSWORD"; -const ENV_GRID_PASSWORD_FILE: &str = "METACRATE_AGENT_GRID_PASSWORD_FILE"; -const ENV_AUTHORIZED_AVATARS: &str = "METACRATE_AGENT_AUTHORIZED_AVATAR_UUIDS"; -const ENV_STORAGE_PATH: &str = "METACRATE_AGENT_STORAGE_PATH"; -const ENV_CONTROL_LISTEN: &str = "METACRATE_AGENT_CONTROL_LISTEN"; -const ENV_CONTROL_OPERATOR_TOKEN: &str = "METACRATE_AGENT_CONTROL_OPERATOR_TOKEN"; -const ENV_CONTROL_OPERATOR_TOKEN_FILE: &str = "METACRATE_AGENT_CONTROL_OPERATOR_TOKEN_FILE"; -const ENV_CONTROL_OBSERVER_TOKEN: &str = "METACRATE_AGENT_CONTROL_OBSERVER_TOKEN"; -const ENV_CONTROL_OBSERVER_TOKEN_FILE: &str = "METACRATE_AGENT_CONTROL_OBSERVER_TOKEN_FILE"; /// Wrapper that never reveals its contents through `Debug` or `Display` and /// deliberately does not implement serialization. @@ -160,6 +149,7 @@ pub enum OperatingMode { pub struct LlmConnection { pub endpoint_url: EndpointUrl, pub api_key: SecretString, + pub model: Option, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -345,6 +335,7 @@ impl AgentConfig { llm: RawLlm { endpoint_url: Some(endpoint_url.into()), api_key: Some(api_key.into()), + ..RawLlm::default() }, ..FileConfig::default() }; @@ -455,7 +446,7 @@ impl PlatformPaths { .get("LOCALAPPDATA") .map_or_else(|| PathBuf::from("data"), PathBuf::from); return Self { - config_file: config.join("MetaCrate/grid-agent.json"), + config_file: config.join("MetaCrate/config.yml"), data_directory: data.join("MetaCrate/grid-agent"), }; } @@ -464,7 +455,7 @@ impl PlatformPaths { .get("HOME") .map_or_else(|| PathBuf::from("."), PathBuf::from); return Self { - config_file: home.join("Library/Application Support/MetaCrate/grid-agent.json"), + config_file: home.join("Library/Application Support/MetaCrate/config.yml"), data_directory: home.join("Library/Application Support/MetaCrate/grid-agent"), }; } @@ -478,7 +469,7 @@ impl PlatformPaths { .get("XDG_DATA_HOME") .map_or_else(|| home.join(".local/share"), PathBuf::from); Self { - config_file: config.join("metacrate/grid-agent.json"), + config_file: config.join("metacrate/config.yml"), data_directory: data.join("metacrate/grid-agent"), } } @@ -710,6 +701,322 @@ impl fmt::Display for ConfigError { impl Error for ConfigError {} +/// Editable connection and authorization subset of `config.yml`. +/// +/// Unknown settings are retained across saves, allowing the preferences UI to +/// update connection setup without resetting behavior, limits, or storage. +#[derive(Clone, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct AgentPreferences { + pub schema_version: Option, + pub integrated: Option, + pub split: Option, + pub llm: ConnectionPreferences, + pub grid: GridPreferences, + pub authorized_avatar_uuids: Option>, + #[serde(flatten)] + extra: BTreeMap, +} + +impl fmt::Debug for AgentPreferences { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AgentPreferences") + .field("schema_version", &self.schema_version) + .field("integrated", &self.integrated) + .field("split", &self.split) + .field("llm_endpoint_configured", &self.llm.endpoint_url.is_some()) + .field("llm_api_key_configured", &self.llm.api_key.is_some()) + .field("grid_login_configured", &self.grid.login_url.is_some()) + .field("grid_avatar_configured", &self.grid.avatar_name.is_some()) + .field("grid_password_configured", &self.grid.password.is_some()) + .field( + "authorized_avatar_count", + &self.authorized_avatar_uuids.as_ref().map_or(0, Vec::len), + ) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct ConnectionPreferences { + pub endpoint_url: Option, + pub api_key: Option, + pub model: Option, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Clone, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct GridPreferences { + pub login_url: Option, + pub avatar_name: Option, + pub password: Option, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreferencesSummary { + pub mode: OperatingMode, + pub llm_endpoint: Option, + pub llm_api_key_configured: bool, + pub llm_model: Option, + pub grid_login_url: Option, + pub grid_avatar_name: Option, + pub grid_password_configured: bool, + pub privileged_users: Vec, +} + +impl AgentPreferences { + /// Loads editable preferences, or returns initialized defaults when the + /// configuration file does not exist. + /// + /// # Errors + /// + /// Returns an error when an existing file cannot be read or parsed. + pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + if !path.exists() { + return Ok(Self { + schema_version: Some(CONFIG_SCHEMA_VERSION), + integrated: Some(true), + ..Self::default() + }); + } + let bytes = read_bounded_regular_file("configuration file", path, MAX_CONFIG_BYTES)?; + parse_document(&bytes, path) + } + + #[must_use] + pub fn summary(&self) -> PreferencesSummary { + let mode = match ( + self.integrated.unwrap_or(false), + self.split.unwrap_or(false), + ) { + (true, false) => OperatingMode::Integrated, + (false, true) => OperatingMode::SplitService, + _ => OperatingMode::OfflineFake, + }; + PreferencesSummary { + mode, + llm_endpoint: self.llm.endpoint_url.clone(), + llm_api_key_configured: self.llm.api_key.is_some(), + llm_model: self.llm.model.clone(), + grid_login_url: self.grid.login_url.clone(), + grid_avatar_name: self.grid.avatar_name.clone(), + grid_password_configured: self.grid.password.is_some(), + privileged_users: self.authorized_avatar_uuids.clone().unwrap_or_default(), + } + } + + pub fn set_mode(&mut self, mode: OperatingMode) { + self.integrated = Some(mode == OperatingMode::Integrated); + self.split = Some(mode == OperatingMode::SplitService); + } + + pub fn set_llm(&mut self, endpoint_url: String, api_key: Option) { + self.llm.endpoint_url = Some(endpoint_url); + if let Some(api_key) = api_key { + self.llm.api_key = Some(api_key); + } + } + + pub fn set_llm_model(&mut self, model: String) { + self.llm.model = (!model.trim().is_empty()).then_some(model); + } + + pub fn set_grid(&mut self, login_url: String, avatar_name: String, password: Option) { + self.grid.login_url = Some(login_url); + self.grid.avatar_name = Some(avatar_name); + if let Some(password) = password { + self.grid.password = Some(password); + } + } + + pub fn set_privileged_users(&mut self, users: Vec) { + self.authorized_avatar_uuids = Some(users); + } + + /// Validates and securely writes the preferences as YAML. + /// + /// # Errors + /// + /// Returns an error for invalid values, unsafe paths, serialization + /// failures, or filesystem failures. + pub fn save(&mut self, path: impl AsRef) -> Result<(), ConfigError> { + let path = path.as_ref(); + self.schema_version = Some(CONFIG_SCHEMA_VERSION); + validate_preferences(self)?; + let encoded = + serde_yaml_ng::to_string(self).map_err(|error| ConfigError::InvalidSchema { + path: path.to_owned(), + reason: error.to_string(), + })?; + if encoded.len() > usize::try_from(MAX_CONFIG_BYTES).unwrap_or(usize::MAX) { + return Err(ConfigError::InvalidSchema { + path: path.to_owned(), + reason: "serialized configuration exceeds the size limit".into(), + }); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| ConfigError::Io { + field: "configuration directory", + path: parent.to_owned(), + reason: error.to_string(), + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).map_err( + |error| ConfigError::Io { + field: "configuration directory permissions", + path: parent.to_owned(), + reason: error.to_string(), + }, + )?; + } + } + if path + .symlink_metadata() + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + return Err(ConfigError::InvalidSchema { + path: path.to_owned(), + reason: "configuration path must not be a symbolic link".into(), + }); + } + let mut options = fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options.open(path).map_err(|error| ConfigError::Io { + field: "configuration file", + path: path.to_owned(), + reason: error.to_string(), + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|error| ConfigError::Io { + field: "configuration file permissions", + path: path.to_owned(), + reason: error.to_string(), + })?; + } + file.write_all(encoded.as_bytes()) + .map_err(|error| ConfigError::Io { + field: "configuration file", + path: path.to_owned(), + reason: error.to_string(), + })?; + file.sync_all().map_err(|error| ConfigError::Io { + field: "configuration file", + path: path.to_owned(), + reason: error.to_string(), + }) + } + + /// Imports supported values from a legacy `.env` file into memory. + /// + /// The caller must invoke [`Self::save`] to persist the result. + /// + /// # Errors + /// + /// Returns an error when the legacy file is unsafe, unreadable, too large, + /// non-UTF-8, or contains a malformed assignment. + pub fn import_dotenv(&mut self, path: impl AsRef) -> Result<(), ConfigError> { + let path = path.as_ref(); + let bytes = read_bounded_regular_file("legacy environment file", path, MAX_CONFIG_BYTES)?; + let text = String::from_utf8(bytes).map_err(|_| ConfigError::InvalidSchema { + path: path.to_owned(), + reason: "legacy environment file must be UTF-8".into(), + })?; + let mut values = BTreeMap::new(); + for (index, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = line.strip_prefix("export ").unwrap_or(line); + let Some((name, value)) = line.split_once('=') else { + return Err(ConfigError::InvalidSchema { + path: path.to_owned(), + reason: format!("invalid assignment on line {}", index + 1), + }); + }; + let value = value.trim(); + let value = if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + &value[1..value.len() - 1] + } else { + value + }; + values.insert(name.trim(), value.to_owned()); + } + if let Some(value) = values.get("OPENAPI_URL") { + self.llm.endpoint_url = Some(value.clone()); + } + if let Some(value) = values.get("OPENAPI_KEY") { + self.llm.api_key = Some(value.clone()); + } + if let Some(value) = values.get("OPENAPI_MODEL") { + self.llm.model = Some(value.clone()); + } + if let Some(value) = values.get("GRID_LOGIN_URL") { + self.grid.login_url = Some(value.clone()); + } + if let Some(value) = values.get("GRID_USER") { + self.grid.avatar_name = Some(value.clone()); + } + if let Some(value) = values.get("GRID_PASSWORD") { + self.grid.password = Some(value.clone()); + } + validate_preferences(self) + } +} + +fn validate_preferences(preferences: &AgentPreferences) -> Result<(), ConfigError> { + if preferences.integrated.unwrap_or(false) && preferences.split.unwrap_or(false) { + return Err(ConfigError::ConflictingModes); + } + if let Some(endpoint) = &preferences.llm.endpoint_url { + EndpointUrl::parse("llm.endpoint_url", endpoint)?; + } + if let Some(secret) = &preferences.llm.api_key { + validate_secret("llm.api_key", secret)?; + } + if preferences.llm.model.as_ref().is_some_and(|model| { + model.trim().is_empty() || model.len() > 256 || model.chars().any(char::is_control) + }) { + return Err(ConfigError::InvalidSchema { + path: PathBuf::from("config.yml"), + reason: "llm.model must be a nonempty printable value of at most 256 bytes".into(), + }); + } + if let Some(endpoint) = &preferences.grid.login_url { + EndpointUrl::parse("grid.login_url", endpoint)?; + } + if let Some(secret) = &preferences.grid.password { + validate_secret("grid.password", secret)?; + } + for value in preferences.authorized_avatar_uuids.iter().flatten() { + UUID::new_with_string(value.clone()).map_err(|_| ConfigError::InvalidUuid { + value: value.clone(), + reason: "expected a canonical UUID", + })?; + } + Ok(()) +} + #[derive(Clone, Default, Deserialize)] #[serde(default, deny_unknown_fields)] struct FileConfig { @@ -735,6 +1042,7 @@ struct FileConfig { struct RawLlm { endpoint_url: Option, api_key: Option, + model: Option, } #[derive(Clone, Default, Deserialize)] @@ -851,9 +1159,25 @@ struct RawInteraction { fn read_config(path: &Path) -> Result { let bytes = read_bounded_regular_file("configuration file", path, MAX_CONFIG_BYTES)?; - serde_json::from_slice(&bytes).map_err(|error| ConfigError::InvalidSchema { + parse_document(&bytes, path) +} + +fn parse_document( + bytes: &[u8], + path: &Path, +) -> Result { + let result = if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) + { + serde_json::from_slice(bytes).map_err(|error| error.to_string()) + } else { + serde_yaml_ng::from_slice(bytes).map_err(|error| error.to_string()) + }; + result.map_err(|reason| ConfigError::InvalidSchema { path: path.to_owned(), - reason: error.to_string(), + reason, }) } @@ -871,12 +1195,8 @@ fn resolve( supported: CONFIG_SCHEMA_VERSION, }); } - let integrated = environment_boolean(environment, ENV_INTEGRATED)? - .or(raw.integrated) - .unwrap_or(false); - let split = environment_boolean(environment, ENV_SPLIT)? - .or(raw.split) - .unwrap_or(false); + let integrated = raw.integrated.unwrap_or(false); + let split = raw.split.unwrap_or(false); let mode = match (integrated, split) { (true, true) => return Err(ConfigError::ConflictingModes), (true, false) => OperatingMode::Integrated, @@ -887,16 +1207,13 @@ fn resolve( let base = config_path .and_then(Path::parent) .unwrap_or_else(|| Path::new(".")); - let endpoint = environment - .get(ENV_LLM_ENDPOINT) - .or(raw.llm.endpoint_url) - .ok_or(ConfigError::Missing { - field: "llm.endpoint_url", - required_for: "all modes", - })?; + let endpoint = raw.llm.endpoint_url.ok_or(ConfigError::Missing { + field: "llm.endpoint_url", + required_for: "all modes", + })?; let api_key = secret_from_layers( - environment.get(ENV_LLM_API_KEY), - environment.get(ENV_LLM_API_KEY_FILE).map(PathBuf::from), + None, + None, raw.llm.api_key, raw.secret_files.llm_api_key, base, @@ -905,19 +1222,32 @@ fn resolve( let llm = LlmConnection { endpoint_url: EndpointUrl::parse("llm.endpoint_url", &endpoint)?, api_key, + model: raw + .llm + .model + .map(|model| model.trim().to_owned()) + .filter(|model| !model.is_empty()), }; + if llm + .model + .as_ref() + .is_some_and(|model| model.len() > 256 || model.chars().any(char::is_control)) + { + return Err(ConfigError::InvalidSchema { + path: config_path + .unwrap_or_else(|| Path::new("config.yml")) + .to_owned(), + reason: "llm.model must be printable and at most 256 bytes".into(), + }); + } - let login_url = environment.get(ENV_GRID_LOGIN_URL).or(raw.grid.login_url); - let avatar_name = environment - .get(ENV_GRID_AVATAR_NAME) - .or(raw.grid.avatar_name); - let direct_password = environment.get(ENV_GRID_PASSWORD).or(raw.grid.password); - let environment_password_file = environment.get(ENV_GRID_PASSWORD_FILE).map(PathBuf::from); + let login_url = raw.grid.login_url; + let avatar_name = raw.grid.avatar_name; + let direct_password = raw.grid.password; let grid = if mode == OperatingMode::OfflineFake && login_url.is_none() && avatar_name.is_none() && direct_password.is_none() - && environment_password_file.is_none() && raw.secret_files.grid_password.is_none() { None @@ -941,7 +1271,7 @@ fn resolve( avatar_name, password: secret_from_layers( direct_password, - environment_password_file, + None, None, raw.secret_files.grid_password, base, @@ -950,11 +1280,7 @@ fn resolve( }) }; - let authorized_values = environment - .get(ENV_AUTHORIZED_AVATARS) - .map(|value| value.split(',').map(str::trim).map(str::to_owned).collect()) - .or(raw.authorized_avatar_uuids) - .unwrap_or_default(); + let authorized_values = raw.authorized_avatar_uuids.unwrap_or_default(); let authorized_avatar_uuids = parse_authorized_avatars(authorized_values)?; let defaults = Limits::default(); @@ -1128,28 +1454,24 @@ fn resolve( .unwrap_or(interaction_defaults.max_debounce_fragments), }; let control_defaults = ControlSettings::default(); - let listen_text = environment - .get(ENV_CONTROL_LISTEN) - .or(raw.control.listen) + let listen_text = raw + .control + .listen .unwrap_or_else(|| control_defaults.listen.to_string()); let listen = listen_text .parse::() .map_err(|_| ConfigError::InvalidControl)?; let operator_token = optional_secret_from_layers( - environment.get(ENV_CONTROL_OPERATOR_TOKEN), - environment - .get(ENV_CONTROL_OPERATOR_TOKEN_FILE) - .map(PathBuf::from), + None, + None, raw.control.operator_token, raw.secret_files.control_operator_token, base, "control.operator_token", )?; let observer_token = optional_secret_from_layers( - environment.get(ENV_CONTROL_OBSERVER_TOKEN), - environment - .get(ENV_CONTROL_OBSERVER_TOKEN_FILE) - .map(PathBuf::from), + None, + None, raw.control.observer_token, raw.secret_files.control_observer_token, base, @@ -1186,10 +1508,8 @@ fn resolve( authorized_avatar_uuids, timeouts, limits, - storage_path: environment - .get(ENV_STORAGE_PATH) - .map(PathBuf::from) - .or(raw.storage_path) + storage_path: raw + .storage_path .unwrap_or_else(|| PlatformPaths::from_environment(environment).data_directory), behavior: BehaviorSettings { heartbeat: checked_duration( @@ -1425,20 +1745,6 @@ fn validate_secret(field: &'static str, value: &str) -> Result<(), ConfigError> Ok(()) } -fn environment_boolean( - environment: &E, - field: &'static str, -) -> Result, ConfigError> { - environment - .get(field) - .map(|value| match value.to_ascii_lowercase().as_str() { - "true" | "1" => Ok(true), - "false" | "0" => Ok(false), - _ => Err(ConfigError::InvalidBoolean { field, value }), - }) - .transpose() -} - fn checked_duration( field: &'static str, seconds: u64, @@ -1523,7 +1829,6 @@ fn check_limit( #[cfg(test)] mod tests { use super::*; - use std::io::Write as _; use std::sync::atomic::{AtomicU64, Ordering}; static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(1); @@ -1559,7 +1864,7 @@ mod tests { } #[test] - fn environment_overrides_file_and_secrets_are_redacted() { + fn file_configuration_is_authoritative_and_secrets_are_redacted() { let path = temporary_file( "precedence.json", r#"{ @@ -1572,13 +1877,13 @@ mod tests { let loader_diagnostic = format!("{loader:?}"); assert!(!loader_diagnostic.contains("environment-key")); let config = loader.load().expect("valid layered configuration"); - assert_eq!(config.llm.api_key.expose_secret(), "environment-key"); + assert_eq!(config.llm.api_key.expose_secret(), "file-key"); assert!( config .llm .endpoint_url .expose_url() - .contains("llm.example.invalid") + .contains("file.invalid") ); let diagnostic = format!("{config:?}"); assert!(!diagnostic.contains("environment-key")); @@ -1587,7 +1892,7 @@ mod tests { } #[test] - fn secret_file_overrides_inline_secret_and_direct_environment_wins_last() { + fn secret_file_overrides_inline_and_environment_is_ignored() { let secret = temporary_file("precedence-api-key", "secret-file-key\n"); let document = serde_json::json!({ "llm": { @@ -1615,7 +1920,7 @@ mod tests { .with_environment(direct_environment) .load() .expect("direct environment layer resolves"); - assert_eq!(config.llm.api_key.expose_secret(), "direct-environment-key"); + assert_eq!(config.llm.api_key.expose_secret(), "secret-file-key"); let _ = fs::remove_file(secret); let _ = fs::remove_file(config_path); } @@ -1636,19 +1941,19 @@ mod tests { supported: CONFIG_SCHEMA_VERSION }) )); - let environment = MapEnvironment::from_pairs([ - (ENV_LLM_ENDPOINT, "https://llm.invalid/chat"), - (ENV_LLM_API_KEY, "placeholder"), - (ENV_STORAGE_PATH, "operator-data"), - ("HOME", "/operator"), - ]); + let configured = temporary_file( + "storage.yml", + "llm:\n endpoint_url: https://llm.invalid/chat\n api_key: placeholder\nstorage_path: operator-data\n", + ); + let environment = MapEnvironment::from_pairs([("HOME", "/operator")]); let config = ConfigLoader::new() + .with_file(configured) .with_environment(environment.clone()) .load() .unwrap(); assert_eq!(config.storage_path, PathBuf::from("operator-data")); let paths = PlatformPaths::from_environment(&environment); - assert!(paths.config_file.ends_with("grid-agent.json")); + assert!(paths.config_file.ends_with("config.yml")); assert!(paths.data_directory.ends_with("grid-agent")); } @@ -1682,65 +1987,52 @@ mod tests { fn invalid_urls_uuids_limits_and_wildcards_fail_fast() { assert!(AgentConfig::offline("file:///tmp/socket", "key").is_err()); - let mut wildcard = offline_environment(); - wildcard.insert(ENV_AUTHORIZED_AVATARS, "*"); assert!(matches!( - ConfigLoader::new().with_environment(wildcard).load(), + parse_authorized_avatars(vec!["*".into()]), Err(ConfigError::InvalidUuid { .. }) )); - let mut malformed = offline_environment(); - malformed.insert(ENV_AUTHORIZED_AVATARS, "not-a-uuid"); assert!(matches!( - ConfigLoader::new().with_environment(malformed).load(), + parse_authorized_avatars(vec!["not-a-uuid".into()]), Err(ConfigError::InvalidUuid { .. }) )); - let path = temporary_file("unsafe-limit.json", r#"{"limits":{"control_queue":0}}"#); + let path = temporary_file( + "unsafe-limit.json", + r#"{"llm":{"endpoint_url":"https://llm.invalid/chat","api_key":"key"},"limits":{"control_queue":0}}"#, + ); assert!(matches!( - ConfigLoader::new() - .with_file(&path) - .with_environment(offline_environment()) - .load(), + ConfigLoader::new().with_file(&path).load(), Err(ConfigError::UnsafeLimit { .. }) )); let _ = fs::remove_file(path); let reconnect = temporary_file( "unsafe-reconnect.json", - r#"{"reconnect":{"initial_delay_milliseconds":0}}"#, + r#"{"llm":{"endpoint_url":"https://llm.invalid/chat","api_key":"key"},"reconnect":{"initial_delay_milliseconds":0}}"#, ); assert!(matches!( - ConfigLoader::new() - .with_file(&reconnect) - .with_environment(offline_environment()) - .load(), + ConfigLoader::new().with_file(&reconnect).load(), Err(ConfigError::InvalidReconnect) )); let _ = fs::remove_file(reconnect); let conversation = temporary_file( "unsafe-conversation.json", - r#"{"conversation":{"max_active_sessions":0}}"#, + r#"{"llm":{"endpoint_url":"https://llm.invalid/chat","api_key":"key"},"conversation":{"max_active_sessions":0}}"#, ); assert!(matches!( - ConfigLoader::new() - .with_file(&conversation) - .with_environment(offline_environment()) - .load(), + ConfigLoader::new().with_file(&conversation).load(), Err(ConfigError::InvalidConversationMemory) )); let _ = fs::remove_file(conversation); let interaction = temporary_file( "unsafe-interaction.json", - r#"{"interaction":{"max_concurrent_inference":0}}"#, + r#"{"llm":{"endpoint_url":"https://llm.invalid/chat","api_key":"key"},"interaction":{"max_concurrent_inference":0}}"#, ); assert!(matches!( - ConfigLoader::new() - .with_file(&interaction) - .with_environment(offline_environment()) - .load(), + ConfigLoader::new().with_file(&interaction).load(), Err(ConfigError::InvalidInteraction) )); let _ = fs::remove_file(interaction); @@ -1748,24 +2040,21 @@ mod tests { #[test] fn conflicting_live_modes_and_missing_live_fields_are_rejected() { - let both = MapEnvironment::from_pairs([ - (ENV_INTEGRATED, "true"), - (ENV_SPLIT, "true"), - (ENV_LLM_ENDPOINT, "https://llm.invalid/chat"), - (ENV_LLM_API_KEY, "key"), - ]); + let both = temporary_file( + "both.yml", + "integrated: true\nsplit: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: key }\n", + ); assert_eq!( - ConfigLoader::new().with_environment(both).load(), + ConfigLoader::new().with_file(both).load(), Err(ConfigError::ConflictingModes) ); - let missing_grid = MapEnvironment::from_pairs([ - (ENV_INTEGRATED, "true"), - (ENV_LLM_ENDPOINT, "https://llm.invalid/chat"), - (ENV_LLM_API_KEY, "key"), - ]); + let missing_grid = temporary_file( + "missing-grid.yml", + "integrated: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: key }\n", + ); assert!(matches!( - ConfigLoader::new().with_environment(missing_grid).load(), + ConfigLoader::new().with_file(missing_grid).load(), Err(ConfigError::Missing { field: "grid.login_url", .. @@ -1775,18 +2064,12 @@ mod tests { #[test] fn split_control_requires_distinct_tokens_and_tls_for_remote_bind() { - let split = MapEnvironment::from_pairs([ - (ENV_SPLIT, "true"), - (ENV_LLM_ENDPOINT, "https://llm.invalid/chat"), - (ENV_LLM_API_KEY, "llm-key"), - (ENV_GRID_LOGIN_URL, "https://grid.invalid/login"), - (ENV_GRID_AVATAR_NAME, "Control Agent"), - (ENV_GRID_PASSWORD, "grid-password"), - (ENV_CONTROL_OPERATOR_TOKEN, "operator-capability"), - (ENV_CONTROL_OBSERVER_TOKEN, "observer-capability"), - ]); + let split = temporary_file( + "split.yml", + "split: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: llm-key }\ngrid: { login_url: https://grid.invalid/login, avatar_name: Control Agent, password: grid-password }\ncontrol: { operator_token: operator-capability, observer_token: observer-capability }\n", + ); let config = ConfigLoader::new() - .with_environment(split.clone()) + .with_file(split) .load() .expect("bounded loopback split control"); assert_eq!(config.mode, OperatingMode::SplitService); @@ -1800,19 +2083,21 @@ mod tests { assert!(!diagnostic.contains(secret)); } - let mut reused = split.clone(); - reused.insert(ENV_CONTROL_OPERATOR_TOKEN, "llm-key"); + let reused = temporary_file( + "reused.yml", + "split: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: llm-key }\ngrid: { login_url: https://grid.invalid/login, avatar_name: Control Agent, password: grid-password }\ncontrol: { operator_token: llm-key, observer_token: observer-capability }\n", + ); assert_eq!( - ConfigLoader::new().with_environment(reused).load(), + ConfigLoader::new().with_file(reused).load(), Err(ConfigError::InvalidControl) ); - let mut remote_plaintext = split; - remote_plaintext.insert(ENV_CONTROL_LISTEN, "0.0.0.0:7943"); + let remote_plaintext = temporary_file( + "remote.yml", + "split: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: llm-key }\ngrid: { login_url: https://grid.invalid/login, avatar_name: Control Agent, password: grid-password }\ncontrol: { listen: '0.0.0.0:7943', operator_token: operator-capability }\n", + ); assert_eq!( - ConfigLoader::new() - .with_environment(remote_plaintext) - .load(), + ConfigLoader::new().with_file(remote_plaintext).load(), Err(ConfigError::InvalidControl) ); } @@ -1820,11 +2105,10 @@ mod tests { #[test] fn secret_file_is_bounded_regular_and_trailing_newline_is_removed() { let secret = temporary_file("api-key", "secret-from-file\n"); - let mut environment = - MapEnvironment::from_pairs([(ENV_LLM_ENDPOINT, "https://llm.example.invalid/chat")]); - environment.insert(ENV_LLM_API_KEY_FILE, secret.to_string_lossy()); + let document = serde_json::json!({"llm":{"endpoint_url":"https://llm.example.invalid/chat"},"secret_files":{"llm_api_key":secret}}); + let config_path = temporary_file("secret-file.json", &document.to_string()); let config = ConfigLoader::new() - .with_environment(environment) + .with_file(config_path) .load() .expect("secret file loads"); assert_eq!(config.llm.api_key.expose_secret(), "secret-from-file"); diff --git a/crates/metacrate-grid-agent/src/lib.rs b/crates/metacrate-grid-agent/src/lib.rs index 93cc549..e8cb7ea 100644 --- a/crates/metacrate-grid-agent/src/lib.rs +++ b/crates/metacrate-grid-agent/src/lib.rs @@ -58,8 +58,7 @@ mod vision_tests; pub use acceptance::{ ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets, AcceptanceError, - AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, LiveGridOptIns, - run_deterministic_acceptance, + AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, run_deterministic_acceptance, }; pub use backend::{ AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend, @@ -85,9 +84,10 @@ pub use build::{ BuildService, BuildShape, BuildToolBackend, BuildValidation, PrimReceipt, build_policy_tools, }; pub use config::{ - AgentConfig, BehaviorSettings, CONFIG_SCHEMA_VERSION, ConfigError, ConfigLoader, - ControlSettings, ConversationSettings, EndpointUrl, Environment, GridConnection, Limits, - LlmConnection, MapEnvironment, OperatingMode, PlatformPaths, RemoteTlsSettings, SecretString, + AgentConfig, AgentPreferences, BehaviorSettings, CONFIG_SCHEMA_VERSION, ConfigError, + ConfigLoader, ConnectionPreferences, ControlSettings, ConversationSettings, EndpointUrl, + Environment, GridConnection, GridPreferences, Limits, LlmConnection, MapEnvironment, + OperatingMode, PlatformPaths, PreferencesSummary, RemoteTlsSettings, SecretString, StdEnvironment, Timeouts, }; pub use control_plane::{ @@ -175,8 +175,8 @@ pub use tool_loop::{ }; pub use tui::{ CommandConfirmation, EventFilter, OperatorCommand, OperatorScreen, OperatorSnapshot, - OperatorTui, ReconnectingTcpTransport, TuiAction, TuiError, TuiInput, TuiRenderOptions, - TuiTransport, + OperatorTui, PreferenceField, PreferencesPanel, ReconnectingTcpTransport, TuiAction, TuiError, + TuiInput, TuiRenderOptions, TuiTransport, }; pub use types::{ BoundaryError, BoundedText, BoundedVec, ControlCommand, Conversation, ConversationMessage, diff --git a/crates/metacrate-grid-agent/src/llm.rs b/crates/metacrate-grid-agent/src/llm.rs index 9942f34..2b4132a 100644 --- a/crates/metacrate-grid-agent/src/llm.rs +++ b/crates/metacrate-grid-agent/src/llm.rs @@ -401,7 +401,7 @@ impl LlmClient { .iter() .any(|part| matches!(part, ContentPart::Image { .. })) }); - let body = request_body(messages, tools)?; + let body = request_body(messages, tools, self.connection.model.as_deref())?; if body.len() > self.limits.max_prompt_bytes { return Err(LlmError::PromptTooLarge); } @@ -567,6 +567,7 @@ async fn read_bounded( fn request_body( messages: &[CompletionMessage], tools: &[ToolDefinition], + model: Option<&str>, ) -> Result, LlmError> { let messages = messages.iter().map(message_wire_value).collect::>(); let tools = tools @@ -582,8 +583,11 @@ fn request_body( }) }) .collect::>(); - serde_json::to_vec(&json!({"messages":messages, "tools":tools})) - .map_err(|_| LlmError::MalformedJson) + let mut request = json!({"messages":messages, "tools":tools}); + if let Some(model) = model { + request["model"] = Value::String(model.to_owned()); + } + serde_json::to_vec(&request).map_err(|_| LlmError::MalformedJson) } fn message_wire_value(message: &CompletionMessage) -> Value { diff --git a/crates/metacrate-grid-agent/src/main.rs b/crates/metacrate-grid-agent/src/main.rs index bc9f346..464fdf9 100644 --- a/crates/metacrate-grid-agent/src/main.rs +++ b/crates/metacrate-grid-agent/src/main.rs @@ -37,7 +37,8 @@ enum Operation { TuiClient, PrintPaths, Acceptance, - CheckLiveOptIns, + Preferences, + ImportEnv, } #[derive(Default)] @@ -45,6 +46,7 @@ struct Options { config: Option, operation: Operation, evidence: Option, + import_env: Option, } fn options() -> Result, CliError> { @@ -60,7 +62,7 @@ fn options() -> Result, CliError> { } if argument == "--help" || argument == "-h" { println!( - "metacrate-grid-agent [--config PATH] [--check-config | --run-once | --tui | --tui-client | --print-paths | --acceptance-evidence PATH | --check-live-opt-ins]\n\ + "metacrate-grid-agent [--config PATH] [--check-config | --run-once | --tui | --tui-client | --preferences | --import-env PATH | --print-paths | --acceptance-evidence PATH]\n\ Configuration precedence: defaults < JSON < secret files < environment.\n\ With no --config, the platform default is used when it exists." ); @@ -86,8 +88,15 @@ fn options() -> Result, CliError> { .ok_or_else(|| CliError("--acceptance-evidence requires a new path".into()))?; count += 1; result.evidence = Some(PathBuf::from(path)); - } else if argument == "--check-live-opt-ins" { - set_operation(&mut result, Operation::CheckLiveOptIns)?; + } else if argument == "--preferences" { + set_operation(&mut result, Operation::Preferences)?; + } else if argument == "--import-env" { + set_operation(&mut result, Operation::ImportEnv)?; + let path = arguments + .next() + .ok_or_else(|| CliError("--import-env requires a .env path".into()))?; + count += 1; + result.import_env = Some(PathBuf::from(path)); } else if argument == "--config" { let path = arguments .next() @@ -140,28 +149,31 @@ async fn main() -> Result<(), Box> { ); return Ok(()); } - if options.operation == Operation::CheckLiveOptIns { - let opt_ins = - metacrate_grid_agent::LiveGridOptIns::from_environment(|name| std::env::var(name).ok()) - .validate()?; + let mut loader = ConfigLoader::new(); + let default_config_path = platform_paths.config_file; + let config_path = options.config.or_else(|| { + default_config_path + .is_file() + .then_some(default_config_path.clone()) + }); + let preferences_path = config_path.clone().unwrap_or(default_config_path); + if options.operation == Operation::Preferences { + metacrate_grid_agent::tui::run_preferences_terminal(preferences_path, !color_disabled())?; + return Ok(()); + } + if options.operation == Operation::ImportEnv { + let source = options + .import_env + .ok_or_else(|| CliError("legacy .env path is required".into()))?; + let mut preferences = metacrate_grid_agent::AgentPreferences::load(&preferences_path)?; + preferences.import_dotenv(source)?; + preferences.save(&preferences_path)?; println!( - "live opt-ins: login={} chat_im={} script={} landmarks={} build={} visual={}", - opt_ins.login, - opt_ins.chat_and_im, - opt_ins.script_delivery, - opt_ins.landmarks_and_roaming, - opt_ins.reversible_build, - opt_ins.visual_capture + "imported grid and AI settings into {}", + preferences_path.display() ); return Ok(()); } - let mut loader = ConfigLoader::new(); - let config_path = options.config.or_else(|| { - platform_paths - .config_file - .is_file() - .then_some(platform_paths.config_file) - }); if let Some(path) = config_path { loader = loader.with_file(path); } @@ -181,8 +193,12 @@ async fn main() -> Result<(), Box> { token.clone(), config.control.limits, ); - metacrate_grid_agent::tui::run_terminal(std::sync::Arc::new(client), !color_disabled()) - .await?; + metacrate_grid_agent::tui::run_terminal_with_preferences( + std::sync::Arc::new(client), + !color_disabled(), + Some(preferences_path), + ) + .await?; return Ok(()); } if config.mode != OperatingMode::OfflineFake { @@ -191,6 +207,7 @@ async fn main() -> Result<(), Box> { config, options.operation == Operation::RunOnce, options.operation == Operation::Tui, + preferences_path, ) .await; #[cfg(not(feature = "live-grid"))] @@ -252,6 +269,7 @@ async fn run_live( config: metacrate_grid_agent::AgentConfig, run_once: bool, tui: bool, + preferences_path: PathBuf, ) -> Result<(), Box> { use metacrate_grid_agent::{ AgentControlTarget, BehaviorObservation, ControlEventKind, ControlPlane, ControlTarget, @@ -402,10 +420,13 @@ async fn run_live( let client = integrated_client.ok_or_else(|| { CliError("--tui requires integrated mode; use --tui-client for split mode".into()) })?; - Some(tokio::spawn(metacrate_grid_agent::tui::run_terminal( - Arc::new(client), - !color_disabled(), - ))) + Some(tokio::spawn( + metacrate_grid_agent::tui::run_terminal_with_preferences( + Arc::new(client), + !color_disabled(), + Some(preferences_path), + ), + )) } else { None }; diff --git a/crates/metacrate-grid-agent/src/tui.rs b/crates/metacrate-grid-agent/src/tui.rs index 93f7823..b95a393 100644 --- a/crates/metacrate-grid-agent/src/tui.rs +++ b/crates/metacrate-grid-agent/src/tui.rs @@ -12,13 +12,14 @@ use crate::control_plane::{ SessionMetadataView, TcpControlClient, }; use crate::observability::{EventSeverity, MetricsSnapshot, StructuredEvent}; -use crate::{ControlLimits, SecretString}; +use crate::{AgentPreferences, ControlLimits, OperatingMode, PreferencesSummary, SecretString}; use crossterm::{cursor, event, execute, queue, style, terminal}; use std::collections::VecDeque; use std::fmt; use std::future::Future; use std::io::{self, Write}; use std::net::SocketAddr; +use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; @@ -39,10 +40,11 @@ pub enum OperatorScreen { Health, Errors, Diagnostics, + Preferences, } impl OperatorScreen { - const ALL: [Self; 9] = [ + const ALL: [Self; 10] = [ Self::Overview, Self::Sessions, Self::QueuesAndBudgets, @@ -52,6 +54,7 @@ impl OperatorScreen { Self::Health, Self::Errors, Self::Diagnostics, + Self::Preferences, ]; const fn title(self) -> &'static str { @@ -65,10 +68,246 @@ impl OperatorScreen { Self::Health => "Health & metrics", Self::Errors => "Recent errors", Self::Diagnostics => "Diagnostics", + Self::Preferences => "Preferences", } } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PreferenceField { + Mode, + LlmEndpoint, + LlmApiKey, + LlmModel, + GridLoginUrl, + GridAvatarName, + GridPassword, + PrivilegedUsers, +} + +impl PreferenceField { + const ALL: [Self; 8] = [ + Self::Mode, + Self::LlmEndpoint, + Self::LlmApiKey, + Self::LlmModel, + Self::GridLoginUrl, + Self::GridAvatarName, + Self::GridPassword, + Self::PrivilegedUsers, + ]; + + const fn secret(self) -> bool { + matches!(self, Self::LlmApiKey | Self::GridPassword) + } +} + +pub struct PreferencesPanel { + path: PathBuf, + preferences: AgentPreferences, + selected: usize, + editing: bool, + buffer: String, + status: String, +} + +impl fmt::Debug for PreferencesPanel { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PreferencesPanel") + .field("path", &self.path) + .field("selected", &self.selected) + .field("editing", &self.editing) + .field("buffer_bytes", &self.buffer.len()) + .finish_non_exhaustive() + } +} + +impl PreferencesPanel { + pub fn open(path: PathBuf) -> Result { + let preferences = + AgentPreferences::load(&path).map_err(|error| TuiError(error.to_string()))?; + Ok(Self { + path, + preferences, + selected: 0, + editing: false, + buffer: String::new(), + status: "ready".into(), + }) + } + + #[must_use] + pub fn summary(&self) -> PreferencesSummary { + self.preferences.summary() + } + + #[must_use] + pub fn selected_field(&self) -> PreferenceField { + PreferenceField::ALL[self.selected] + } + + pub fn next_field(&mut self) { + self.selected = (self.selected + 1) % PreferenceField::ALL.len(); + } + pub fn previous_field(&mut self) { + self.selected = + (self.selected + PreferenceField::ALL.len() - 1) % PreferenceField::ALL.len(); + } + + pub fn begin_edit(&mut self) { + self.editing = true; + self.buffer.clear(); + self.status = if self.selected_field().secret() { + "editing secret (hidden)".into() + } else { + "editing".into() + }; + } + + pub fn push(&mut self, value: char) { + if self.editing && !value.is_control() && self.buffer.len() < 16 * 1024 { + self.buffer.push(value); + } + } + + pub fn backspace(&mut self) { + if self.editing { + self.buffer.pop(); + } + } + + pub fn cancel_edit(&mut self) { + self.editing = false; + self.buffer.clear(); + self.status = "edit cancelled".into(); + } + + pub fn commit_edit(&mut self) -> Result<(), TuiError> { + if !self.editing { + return Ok(()); + } + let value = std::mem::take(&mut self.buffer); + let summary = self.preferences.summary(); + match self.selected_field() { + PreferenceField::Mode => { + let mode = match value.trim().to_ascii_lowercase().as_str() { + "offline" => OperatingMode::OfflineFake, + "integrated" => OperatingMode::Integrated, + "split" => OperatingMode::SplitService, + _ => { + return Err(TuiError( + "mode must be offline, integrated, or split".into(), + )); + } + }; + self.preferences.set_mode(mode); + } + PreferenceField::LlmEndpoint => self.preferences.set_llm(value, None), + PreferenceField::LlmApiKey => self + .preferences + .set_llm(summary.llm_endpoint.unwrap_or_default(), Some(value)), + PreferenceField::LlmModel => self.preferences.set_llm_model(value), + PreferenceField::GridLoginUrl => { + self.preferences.set_grid( + value, + summary.grid_avatar_name.unwrap_or_default(), + None, + ); + } + PreferenceField::GridAvatarName => { + self.preferences + .set_grid(summary.grid_login_url.unwrap_or_default(), value, None); + } + PreferenceField::GridPassword => self.preferences.set_grid( + summary.grid_login_url.unwrap_or_default(), + summary.grid_avatar_name.unwrap_or_default(), + Some(value), + ), + PreferenceField::PrivilegedUsers => self.preferences.set_privileged_users( + value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_owned) + .collect(), + ), + } + self.editing = false; + self.status = "changed; press w to save".into(); + Ok(()) + } + + pub fn save(&mut self) -> Result<(), TuiError> { + self.preferences + .save(&self.path) + .map_err(|error| TuiError(error.to_string()))?; + self.status = "saved; restart service to apply connection changes".into(); + Ok(()) + } + + fn render(&self, out: &mut Vec) { + let summary = self.summary(); + out.push(format!( + "file={} mode={:?}", + self.path.display(), + summary.mode + )); + let values = [ + format!("mode: {:?}", summary.mode), + format!( + "LLM endpoint: {}", + summary.llm_endpoint.as_deref().unwrap_or("") + ), + format!( + "LLM API key: {}", + configured(summary.llm_api_key_configured) + ), + format!( + "LLM model: {}", + summary.llm_model.as_deref().unwrap_or("") + ), + format!( + "grid login URL: {}", + summary.grid_login_url.as_deref().unwrap_or("") + ), + format!( + "grid avatar: {}", + summary.grid_avatar_name.as_deref().unwrap_or("") + ), + format!( + "grid password: {}", + configured(summary.grid_password_configured) + ), + format!("privileged users: {}", summary.privileged_users.join(", ")), + ]; + for (index, value) in values.into_iter().enumerate() { + out.push(format!( + "{} {value}", + if index == self.selected { ">" } else { " " } + )); + } + if self.editing { + out.push(format!( + "input: {}", + if self.selected_field().secret() { + "•".repeat(self.buffer.chars().count()) + } else { + self.buffer.clone() + } + )); + } + out.push(format!( + "{} | [↑/↓] field [Enter] edit/commit [Esc] cancel [w] save", + self.status + )); + } +} + +fn configured(value: bool) -> &'static str { + if value { "[configured]" } else { "[unset]" } +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct EventFilter { pub query: String, @@ -227,6 +466,13 @@ pub enum TuiInput { Confirm, Reject, Quit, + PreferenceNext, + PreferencePrevious, + PreferenceEditOrCommit, + PreferenceCharacter(char), + PreferenceBackspace, + PreferenceSave, + PreferenceCancel, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -364,6 +610,7 @@ pub struct OperatorTui { scroll: usize, status: String, errors: VecDeque, + preferences: Option, } impl Default for OperatorTui { @@ -377,11 +624,19 @@ impl Default for OperatorTui { scroll: 0, status: "connecting".into(), errors: VecDeque::with_capacity(MAX_ERRORS), + preferences: None, } } } impl OperatorTui { + pub fn with_preferences(path: PathBuf) -> Result { + Ok(Self { + preferences: Some(PreferencesPanel::open(path)?), + ..Self::default() + }) + } + #[must_use] pub fn command_shortcut(&self, key: char) -> Option { match key { @@ -456,6 +711,56 @@ impl OperatorTui { } TuiInput::Refresh => TuiAction::Refresh, TuiInput::Quit => TuiAction::Exit, + TuiInput::PreferenceNext => { + if let Some(panel) = &mut self.preferences { + panel.next_field(); + } + TuiAction::None + } + TuiInput::PreferencePrevious => { + if let Some(panel) = &mut self.preferences { + panel.previous_field(); + } + TuiAction::None + } + TuiInput::PreferenceEditOrCommit => { + if let Some(panel) = &mut self.preferences { + if panel.editing { + if let Err(error) = panel.commit_edit() { + self.record_error(error.to_string()); + } + } else { + panel.begin_edit(); + } + } + TuiAction::None + } + TuiInput::PreferenceCharacter(value) => { + if let Some(panel) = &mut self.preferences { + panel.push(value); + } + TuiAction::None + } + TuiInput::PreferenceBackspace => { + if let Some(panel) = &mut self.preferences { + panel.backspace(); + } + TuiAction::None + } + TuiInput::PreferenceSave => { + if let Some(panel) = &mut self.preferences + && let Err(error) = panel.save() + { + self.record_error(error.to_string()); + } + TuiAction::None + } + TuiInput::PreferenceCancel => { + if let Some(panel) = &mut self.preferences { + panel.cancel_edit(); + } + TuiAction::None + } TuiInput::Command(command) if command.confirmation() == CommandConfirmation::None => { TuiAction::Execute(command) } @@ -587,6 +892,7 @@ impl OperatorTui { } #[must_use] + #[allow(clippy::too_many_lines)] pub fn render(&self, options: TuiRenderOptions) -> String { let width = usize::from(options.width.max(20)); let height = usize::from(options.height.max(5)); @@ -673,6 +979,13 @@ impl OperatorTui { )); } } + OperatorScreen::Preferences => { + if let Some(panel) = &self.preferences { + panel.render(&mut lines); + } else { + lines.push("preferences unavailable: no local config path".into()); + } + } } let body_height = height.saturating_sub(1); lines @@ -796,9 +1109,77 @@ impl Drop for TerminalGuard { } } +/// Opens the local configuration preferences panel without starting or +/// connecting to the agent service. Secret fields are rendered as bullets. +pub fn run_preferences_terminal(path: PathBuf, _color: bool) -> Result<(), TuiError> { + let _guard = TerminalGuard::enter().map_err(|error| TuiError(error.to_string()))?; + let mut panel = PreferencesPanel::open(path)?; + loop { + let (width, height) = terminal::size().unwrap_or((80, 24)); + let mut lines = vec!["MetaCrate agent | Preferences".to_owned()]; + panel.render(&mut lines); + let text = lines + .into_iter() + .take(usize::from(height.max(5))) + .map(|line| truncate_width(&line, usize::from(width.max(20)))) + .collect::>() + .join("\n"); + let mut stdout = io::stdout(); + queue!( + stdout, + cursor::MoveTo(0, 0), + terminal::Clear(terminal::ClearType::All), + style::Print(text) + ) + .map_err(|error| TuiError(error.to_string()))?; + stdout + .flush() + .map_err(|error| TuiError(error.to_string()))?; + let event::Event::Key(key) = event::read().map_err(|error| TuiError(error.to_string()))? + else { + continue; + }; + if key.kind != event::KeyEventKind::Press { + continue; + } + if panel.editing { + match key.code { + event::KeyCode::Enter => panel.commit_edit()?, + event::KeyCode::Backspace => panel.backspace(), + event::KeyCode::Esc => panel.cancel_edit(), + event::KeyCode::Char(value) => panel.push(value), + _ => {} + } + } else { + match key.code { + event::KeyCode::Up => panel.previous_field(), + event::KeyCode::Down => panel.next_field(), + event::KeyCode::Enter => panel.begin_edit(), + event::KeyCode::Char('w') => panel.save()?, + event::KeyCode::Char('q') | event::KeyCode::Esc => return Ok(()), + event::KeyCode::Char('c') + if key.modifiers.contains(event::KeyModifiers::CONTROL) => + { + return Ok(()); + } + _ => {} + } + } + } +} + /// Runs an event-driven keyboard UI. Terminal state is restored on normal /// return, errors, Ctrl-C, and unwinding because restoration is guard-owned. pub async fn run_terminal(client: Arc, color: bool) -> Result<(), TuiError> { + run_terminal_with_preferences(client, color, None).await +} + +#[allow(clippy::too_many_lines)] +pub async fn run_terminal_with_preferences( + client: Arc, + color: bool, + preferences_path: Option, +) -> Result<(), TuiError> { let _guard = TerminalGuard::enter().map_err(|e| TuiError(e.to_string()))?; let (input_tx, mut input_rx) = tokio::sync::mpsc::channel(64); std::thread::spawn(move || { @@ -808,7 +1189,11 @@ pub async fn run_terminal(client: Arc, color: bool) -> Result< } } }); - let mut app = OperatorTui::default(); + let mut app = if let Some(path) = preferences_path { + OperatorTui::with_preferences(path)? + } else { + OperatorTui::default() + }; app.refresh(client.as_ref()).await?; let mut refresh = tokio::time::interval(Duration::from_secs(1)); refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -845,6 +1230,35 @@ pub async fn run_terminal(client: Arc, color: bool) -> Result< }; let input = match value { event::Event::Resize(_, _) => TuiInput::Refresh, + event::Event::Key(key) + if key.kind == event::KeyEventKind::Press + && app.screen == OperatorScreen::Preferences + && app.preferences.as_ref().is_some_and(|panel| panel.editing) => + { + match key.code { + event::KeyCode::Enter => TuiInput::PreferenceEditOrCommit, + event::KeyCode::Backspace => TuiInput::PreferenceBackspace, + event::KeyCode::Esc => TuiInput::PreferenceCancel, + event::KeyCode::Char(value) => TuiInput::PreferenceCharacter(value), + _ => continue, + } + } + event::Event::Key(key) + if key.kind == event::KeyEventKind::Press + && app.screen == OperatorScreen::Preferences => + { + match key.code { + event::KeyCode::Up => TuiInput::PreferencePrevious, + event::KeyCode::Down => TuiInput::PreferenceNext, + event::KeyCode::Enter => TuiInput::PreferenceEditOrCommit, + event::KeyCode::Char('w') => TuiInput::PreferenceSave, + event::KeyCode::Esc => TuiInput::PreferenceCancel, + event::KeyCode::Tab | event::KeyCode::Right => TuiInput::NextScreen, + event::KeyCode::BackTab | event::KeyCode::Left => TuiInput::PreviousScreen, + event::KeyCode::Char('q') => TuiInput::Quit, + _ => continue, + } + } event::Event::Key(key) if key.kind == event::KeyEventKind::Press => match key.code { event::KeyCode::Tab | event::KeyCode::Right => TuiInput::NextScreen, event::KeyCode::BackTab | event::KeyCode::Left => TuiInput::PreviousScreen, diff --git a/crates/metacrate-grid-agent/src/tui_tests.rs b/crates/metacrate-grid-agent/src/tui_tests.rs index b926d52..97452d9 100644 --- a/crates/metacrate-grid-agent/src/tui_tests.rs +++ b/crates/metacrate-grid-agent/src/tui_tests.rs @@ -130,6 +130,7 @@ fn every_screen_renders_without_a_terminal_at_small_unicode_and_mono_sizes() { OperatorScreen::Health, OperatorScreen::Errors, OperatorScreen::Diagnostics, + OperatorScreen::Preferences, ] { while app.screen != screen { let _ = app.reduce(TuiInput::NextScreen); @@ -153,7 +154,7 @@ fn every_screen_renders_without_a_terminal_at_small_unicode_and_mono_sizes() { fn navigation_wraps_and_every_management_command_has_risk_confirmation() { let mut app = OperatorTui::default(); assert_eq!(app.reduce(TuiInput::PreviousScreen), TuiAction::None); - assert_eq!(app.screen, OperatorScreen::Diagnostics); + assert_eq!(app.screen, OperatorScreen::Preferences); let commands = [ OperatorCommand::Pause, OperatorCommand::Resume, diff --git a/crates/metacrate-grid-agent/tests/acceptance_gate.rs b/crates/metacrate-grid-agent/tests/acceptance_gate.rs index 4964c01..f78d780 100644 --- a/crates/metacrate-grid-agent/tests/acceptance_gate.rs +++ b/crates/metacrate-grid-agent/tests/acceptance_gate.rs @@ -1,9 +1,7 @@ use metacrate_grid_agent::{ ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets, - AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, LiveGridOptIns, - run_deterministic_acceptance, + AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, run_deterministic_acceptance, }; -use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; @@ -62,23 +60,6 @@ async fn deterministic_gate_writes_ordered_redacted_evidence_and_drains() { fs::remove_file(path).unwrap(); } -#[test] -fn live_actions_require_exact_independent_confirmations() { - let mut values = BTreeMap::new(); - values.insert("METACRATE_AGENT_LIVE_CHAT_IM", "CHAT-IM"); - let action_without_login = - LiveGridOptIns::from_environment(|name| values.get(name).map(ToString::to_string)); - assert!(action_without_login.validate().is_err()); - values.insert("METACRATE_AGENT_LIVE_LOGIN", "LOGIN"); - values.insert("METACRATE_AGENT_LIVE_BUILD", "wrong"); - let confirmed = - LiveGridOptIns::from_environment(|name| values.get(name).map(ToString::to_string)) - .validate() - .unwrap(); - assert!(confirmed.login && confirmed.chat_and_im); - assert!(!confirmed.reversible_build); -} - #[test] fn evidence_writer_rejects_secret_markers_and_schema_is_committed() { let path = temporary("unsafe.jsonl"); diff --git a/crates/metacrate-grid-agent/tests/dependency_policy.rs b/crates/metacrate-grid-agent/tests/dependency_policy.rs index 5cb4273..afc32c3 100644 --- a/crates/metacrate-grid-agent/tests/dependency_policy.rs +++ b/crates/metacrate-grid-agent/tests/dependency_policy.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; -const ALLOWED_DEPENDENCIES: [&str; 17] = [ +const ALLOWED_DEPENDENCIES: [&str; 18] = [ "base64", "crossterm", "libremetaverse", @@ -15,6 +15,7 @@ const ALLOWED_DEPENDENCIES: [&str; 17] = [ "rustls", "serde", "serde_json", + "serde_yaml_ng", "sha2", "tokio", "tokio-rustls", diff --git a/crates/metacrate-grid-agent/tests/operations_packaging.rs b/crates/metacrate-grid-agent/tests/operations_packaging.rs index 0965812..3168c57 100644 --- a/crates/metacrate-grid-agent/tests/operations_packaging.rs +++ b/crates/metacrate-grid-agent/tests/operations_packaging.rs @@ -1,5 +1,5 @@ use metacrate_grid_agent::{CONFIG_SCHEMA_VERSION, ConfigLoader}; -use serde_json::Value; +use serde_yaml_ng::Value; use std::fs; use std::path::{Path, PathBuf}; @@ -15,19 +15,22 @@ fn workspace() -> PathBuf { fn examples_are_versioned_placeholder_only_and_offline_validation_has_no_io_peer() { let root = workspace(); for name in [ - "grid-agent.example.json", - "grid-agent.integrated.example.json", - "grid-agent.split.example.json", + "grid-agent.example.yml", + "grid-agent.integrated.example.yml", + "grid-agent.split.example.yml", ] { let bytes = fs::read(root.join("config").join(name)).expect("example"); - let value: Value = serde_json::from_slice(&bytes).expect("valid JSON"); - assert_eq!(value["schema_version"], CONFIG_SCHEMA_VERSION); + let value: Value = serde_yaml_ng::from_slice(&bytes).expect("valid YAML"); + assert_eq!( + value["schema_version"].as_u64(), + Some(u64::from(CONFIG_SCHEMA_VERSION)) + ); let text = String::from_utf8(bytes).unwrap(); for forbidden in ["Bearer ", "sk-", "password123", "SECRET_CANARY"] { assert!(!text.contains(forbidden), "{name} contains {forbidden}"); } } - let offline = root.join("config/grid-agent.example.json"); + let offline = root.join("config/grid-agent.example.yml"); let config = ConfigLoader::new().with_file(offline).load().unwrap(); assert!(config.grid.is_none()); } @@ -46,10 +49,11 @@ fn service_and_installers_preserve_state_secrets_and_graceful_shutdown() { "ProtectSystem=strict", "NoNewPrivileges=true", "ReadWritePaths=/var/lib/metacrate/grid-agent", + "/etc/metacrate/config.yml", ] { assert!(unit.contains(required), "unit lacks {required}"); } - for forbidden in ["API_KEY=", "PASSWORD=", "TOKEN="] { + for forbidden in ["API_KEY=", "PASSWORD=", "TOKEN=", "EnvironmentFile="] { assert!(!unit.contains(forbidden), "unit embeds {forbidden}"); } let shell = fs::read_to_string(root.join("packaging/metacrate-grid-agent/install.sh")).unwrap(); diff --git a/crates/metacrate-grid-agent/tests/preferences_config.rs b/crates/metacrate-grid-agent/tests/preferences_config.rs new file mode 100644 index 0000000..0011553 --- /dev/null +++ b/crates/metacrate-grid-agent/tests/preferences_config.rs @@ -0,0 +1,110 @@ +use metacrate_grid_agent::{ + AgentPreferences, ConfigLoader, MapEnvironment, OperatingMode, OperatorScreen, OperatorTui, + PreferencesPanel, TuiInput, TuiRenderOptions, +}; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn temporary(name: &str) -> PathBuf { + let directory = std::env::temp_dir().join(format!( + "metacrate-preferences-{}-{name}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&directory).unwrap(); + directory +} + +#[test] +fn yaml_is_primary_and_preferences_preserve_unknown_settings() { + let directory = temporary("yaml"); + let path = directory.join("config.yml"); + fs::write( + &path, + r"schema_version: 1 +integrated: true +llm: + endpoint_url: https://ai.invalid/v1/chat/completions + api_key: private-key + model: local-model +grid: + login_url: https://grid.invalid/login + avatar_name: Service Avatar + password: private-password +authorized_avatar_uuids: + - 00000000-0000-4000-8000-000000000001 +behavior: + idle_interval_seconds: 321 +", + ) + .unwrap(); + let config = ConfigLoader::new() + .with_file(&path) + .with_environment(MapEnvironment::default()) + .load() + .unwrap(); + assert_eq!(config.mode, OperatingMode::Integrated); + assert_eq!(config.llm.model.as_deref(), Some("local-model")); + assert_eq!(config.behavior.idle_interval.as_secs(), 321); + + let mut preferences = AgentPreferences::load(&path).unwrap(); + preferences.set_llm_model("replacement-model".into()); + preferences.save(&path).unwrap(); + let saved = fs::read_to_string(&path).unwrap(); + assert!(saved.contains("idle_interval_seconds: 321")); + assert!(saved.contains("model: replacement-model")); + assert!(!format!("{preferences:?}").contains("private-key")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn dotenv_import_is_explicit_and_preferences_panel_never_renders_secrets() { + let directory = temporary("import"); + let dotenv = directory.join("legacy.env"); + let config = directory.join("config.yml"); + fs::write( + &dotenv, + "OPENAPI_URL=https://ai.invalid/v1/chat/completions\nOPENAPI_KEY=very-secret-key\nOPENAPI_MODEL=test-model\nGRID_LOGIN_URL=https://grid.invalid/login\nGRID_USER=Test Avatar\nGRID_PASSWORD=very-secret-password\n", + ) + .unwrap(); + let mut preferences = AgentPreferences::load(&config).unwrap(); + preferences.import_dotenv(&dotenv).unwrap(); + preferences.set_privileged_users(vec!["00000000-0000-4000-8000-000000000001".into()]); + preferences.save(&config).unwrap(); + + let panel = PreferencesPanel::open(config.clone()).unwrap(); + let summary = panel.summary(); + assert_eq!(summary.llm_model.as_deref(), Some("test-model")); + assert!(summary.llm_api_key_configured && summary.grid_password_configured); + assert_eq!(summary.privileged_users.len(), 1); + + let mut tui = OperatorTui::with_preferences(config).unwrap(); + tui.screen = OperatorScreen::Preferences; + let rendered = tui.render(TuiRenderOptions { + width: 120, + height: 30, + color: false, + }); + assert!(rendered.contains("LLM API key: [configured]")); + assert!(rendered.contains("grid password: [configured]")); + assert!(!rendered.contains("very-secret")); + let _ = tui.reduce(TuiInput::PreferenceNext); + let _ = tui.reduce(TuiInput::PreferenceEditOrCommit); + for value in "https://replacement.invalid/v1/chat".chars() { + let _ = tui.reduce(TuiInput::PreferenceCharacter(value)); + } + let _ = tui.reduce(TuiInput::PreferenceEditOrCommit); + let _ = tui.reduce(TuiInput::PreferenceSave); + fs::remove_dir_all(directory).unwrap(); +} diff --git a/docs/grid-agent-acceptance.md b/docs/grid-agent-acceptance.md index 83329f5..dcd253e 100644 --- a/docs/grid-agent-acceptance.md +++ b/docs/grid-agent-acceptance.md @@ -29,7 +29,7 @@ transport failures, visual fallback, and bounded-load cases. The evidence stages are `configuration_and_bounds`, `headless_startup`, `control_conformance`, `chat_scheduling`, `clean_shutdown`, `maintenance_reconnect`, and -`policy_redaction_and_protocol_audit`. A successful run has six ordered +`policy_redaction_and_protocol_audit`. A successful run has seven ordered `passed` records and leaves zero service tasks, grid sessions, or loopback sockets. Headless startup never creates a TUI. Integrated and split UI clients exercise the same versioned control protocol, commands, event model, and @@ -38,9 +38,9 @@ graceful-shutdown target in their conformance tests. Reference deterministic run on 2026-08-18: all 137 library scenarios, 37 integration scenarios, and one compile-fail documentation case passed with all features; the seven-stage evidence command passed with zero leaked tasks, -sessions, or sockets. The completed live-grid release binary was 28,857,688 +sessions, or sockets. The completed live-grid release binary was 29,487,544 bytes with SHA-256 -`b2c93a6acd6fa856669fdb4e08110d700218255f57ec2f7908977eeabea7c470`, +`1ccbd11baf69372d76767c419b59319447ecf088bc0ae83ce1640709f6507ad0`, under the 40-MiB budget. The record identifies the package, pinned Rust toolchain, source revision when supplied through `METACRATE_SOURCE_COMMIT`, exact generic command, fake/live profile, grid type, endpoint capability profile, timestamp, @@ -62,7 +62,7 @@ but small enough to expose deadlocks and unbounded ownership: | Ordered shutdown | 5,000 ms | Allows journal flush and task joins while remaining service-manager friendly. | | Steady/peak memory | 128/256 MiB | Includes bounded queues, conversations, observations, and one visual frame. | | Any queue | 8,192 items | Matches the hard configuration ceiling; defaults are 32–512. | -| Release binary | 40 MiB | The completed live-grid release is 28,857,688 bytes. | +| Release binary | 40 MiB | The completed live-grid release is 29,487,544 bytes. | | Journal retention | 1 GiB | Operator-configured segment and total limits remain mandatory. | Startup, control, reconnect, and shutdown are measured by monotonic time. @@ -76,29 +76,21 @@ because allocator and OS accounting are not comparable across platforms. ## Live-grid matrix -Live validation is optional and requires a dedicated avatar, controlled land, -and an operator-supplied OpenAI-compatible endpoint. Credentials alone grant no -consent. Inspect exact confirmations without contacting either service: +Live validation uses a dedicated avatar, controlled land, and an +operator-supplied OpenAI-compatible endpoint configured in the private +platform `config.yml`. There are no action-specific environment switches. +Authorization follows the production policy model: -```sh -metacrate-grid-agent --check-live-opt-ins -``` - -Each capability has a separate exact-value environment opt-in: - -| Capability | Variable and required literal | +| Origin | Available behavior | | --- | --- | -| Login/relogin | `METACRATE_AGENT_LIVE_LOGIN=LOGIN` | -| Public mention and authorized/unprivileged IM | `METACRATE_AGENT_LIVE_CHAT_IM=CHAT-IM` | -| Controlled LSL delivery | `METACRATE_AGENT_LIVE_SCRIPT=SCRIPT` | -| Landmark offer, teleport, and bounded roaming | `METACRATE_AGENT_LIVE_LANDMARKS=LANDMARKS` | -| Reversible prim build and cleanup | `METACRATE_AGENT_LIVE_BUILD=BUILD-CLEANUP` | -| Synthetic visual capture and visual question | `METACRATE_AGENT_LIVE_VISUAL=VISUAL` | +| Everyone/public chat | Informational responses and explicitly public-safe tools. | +| Ordinary IM | Private conversation without privileged mutation. | +| UUID listed in `authorized_avatar_uuids` | Policy-gated privileged tools, with approval where required. | +| Local operator/control role | Pause, approve/cancel, reconnect, configuration, and shutdown controls. | -Any action opt-in without the login opt-in fails closed. A misspelled value is -false. Store the grid password and endpoint key in restrictive `_FILE` inputs -described by [the operations guide](grid-agent-operations.md), never in these -variables or a command line. +Landmarks, scripts, builds, cleanup, and visual questions are capabilities, +not configuration modes. They remain governed by tool origin, authenticated +UUID, land/ownership checks, bounded resources, and approval policy. For an authorized live run, start split mode so another terminal can reconnect the TUI without affecting the agent session. Record UTC start/end, commit, @@ -113,15 +105,15 @@ hash. Do not record vendor presets or identifiers. Exercise, in order: exact rollover, facing/attention event, and perception queries. 3. Pause, cancel, approve, resume, and force reconnect from the control client; disconnect/reconnect the TUI and verify the service remains headless-safe. -4. With the script opt-in, deliver only to the controlled recipient and record +4. As a privileged user, deliver a script only to the controlled recipient and record the returned inventory ID and permissions. Advanced mutation requires an explicit approval and conservative script size/runtime limits. -5. With the build opt-in, build only on controlled land, record transaction and +5. As a privileged user, build only on controlled land, record transaction and object recovery IDs locally, verify no currency operation exists, and delete every created prim through the ownership-checked cleanup path. -6. With the landmark opt-in, accept a controlled offer, use a short bounded - folder schedule, teleport, then disable the schedule. With visual opt-in, - capture the synthetic scene and ask one visual question. Record the endpoint +6. Accept a controlled landmark offer, use a short bounded folder schedule, + teleport, then disable the schedule. Capture the synthetic scene and ask + one visual question. Record the endpoint capability fallback if image input is rejected. 7. Gracefully stop. Confirm no pending approvals, scheduled jobs, inventory offers, owned test prims, tasks, sockets, or sessions. List any unavoidable @@ -147,8 +139,7 @@ needs `x86_64-w64-mingw32-gcc` for the existing AWS-LC build. ## Remaining limitations No public CI runner performs live actions, holds credentials, measures portable -RSS, or proves a particular provider's image capability. Live evidence is only -credible when an operator supplies all exact opt-ins and completes the matrix -on a dedicated account. The deterministic gate is therefore the required CI +RSS, or proves a particular endpoint's image capability. Live evidence is only +credible when an operator completes the matrix on a dedicated account. The deterministic gate is therefore the required CI acceptance record; a live report supplements it and must state any skipped capability, endpoint fallback, or manually recoverable artifact explicitly. diff --git a/docs/grid-agent-architecture.md b/docs/grid-agent-architecture.md index 0007b74..2bf8792 100644 --- a/docs/grid-agent-architecture.md +++ b/docs/grid-agent-architecture.md @@ -70,7 +70,7 @@ cleanup, fencing old events and late LLM/tool results. See ## Trust boundaries -- JSON configuration and environment text are untrusted. Unknown fields, +- YAML configuration and legacy migration input are untrusted. Unknown fields, oversized files, invalid booleans, conflicting modes, non-HTTP(S) URLs, URL fragments, malformed/noncanonical/nil UUIDs, wildcard authorization, multiline secrets, and unsafe limits fail before startup. diff --git a/docs/grid-agent-operations.md b/docs/grid-agent-operations.md index 9b32856..c431ca6 100644 --- a/docs/grid-agent-operations.md +++ b/docs/grid-agent-operations.md @@ -13,11 +13,11 @@ grid or LLM: ```sh cargo build --locked --release -p metacrate-grid-agent -target/release/metacrate-grid-agent --config config/grid-agent.example.json --check-config -target/release/metacrate-grid-agent --config config/grid-agent.example.json --run-once +target/release/metacrate-grid-agent --config config/grid-agent.example.yml --check-config +target/release/metacrate-grid-agent --config config/grid-agent.example.yml --run-once ``` -`--check-config` parses files, resolves secret files/environment, validates all +`--check-config` parses YAML and any legacy referenced secret files, validates all bounds and TLS files, then exits before constructing the grid or LLM clients. The example is fake/offline mode (`integrated=false`, `split=false`) and all credential-looking values are placeholders. @@ -26,16 +26,16 @@ For live foreground or an embedded TUI, build intentionally: ```sh cargo build --locked --release -p metacrate-grid-agent --features live-grid -target/release/metacrate-grid-agent --config /path/to/grid-agent.json -target/release/metacrate-grid-agent --config /path/to/grid-agent.json --tui +target/release/metacrate-grid-agent --config /path/to/config.yml +target/release/metacrate-grid-agent --config /path/to/config.yml --tui ``` Set `integrated=true` for the foreground and embedded-TUI commands. Set `split=true` for a headless service, then run the same binary as the TUI client: ```sh -metacrate-grid-agent --config /path/to/grid-agent.json -metacrate-grid-agent --config /path/to/grid-agent.json --tui-client +metacrate-grid-agent --config /path/to/config.yml +metacrate-grid-agent --config /path/to/config.yml --tui-client ``` The TUI client and service must use the same control address and operator token. @@ -46,12 +46,11 @@ the default file is used only when it already exists: | Platform | Configuration | Non-secret state | |---|---|---| -| Linux/Unix | `$XDG_CONFIG_HOME/metacrate/grid-agent.json`, otherwise `$HOME/.config/metacrate/grid-agent.json` | `$XDG_DATA_HOME/metacrate/grid-agent`, otherwise `$HOME/.local/share/metacrate/grid-agent` | -| macOS | `$HOME/Library/Application Support/MetaCrate/grid-agent.json` | `$HOME/Library/Application Support/MetaCrate/grid-agent` | -| Windows | `%APPDATA%\MetaCrate\grid-agent.json` | `%LOCALAPPDATA%\MetaCrate\grid-agent` | +| Linux/Unix | `$XDG_CONFIG_HOME/metacrate/config.yml`, otherwise `$HOME/.config/metacrate/config.yml` | `$XDG_DATA_HOME/metacrate/grid-agent`, otherwise `$HOME/.local/share/metacrate/grid-agent` | +| macOS | `$HOME/Library/Application Support/MetaCrate/config.yml` | `$HOME/Library/Application Support/MetaCrate/grid-agent` | +| Windows | `%APPDATA%\MetaCrate\config.yml` | `%LOCALAPPDATA%\MetaCrate/grid-agent` | -`METACRATE_AGENT_STORAGE_PATH` overrides the state directory. Relative paths in -a JSON document resolve relative to that document where specified; service +Relative paths in a YAML document resolve relative to that document where specified; service deployments should use absolute paths. ## Endpoint, grid identity, and authority @@ -59,11 +58,11 @@ deployments should use absolute paths. `llm.endpoint_url` is the exact OpenAI-compatible chat-completions URL. It is not a base URL: MetaCrate does not append a path, discover models, select a provider, or rewrite query parameters. `llm.api_key` is sent as the bearer key -only to that exact origin; redirects are refused. Prefer -`METACRATE_AGENT_LLM_API_KEY_FILE` over inline JSON or direct environment text. +only to that exact origin; redirects are refused. Set `llm.model` when the +endpoint requires a model name. These values live in the private `config.yml`. -Live modes require `grid.login_url`, `grid.avatar_name`, and a password from -`METACRATE_AGENT_GRID_PASSWORD_FILE`. `authorized_avatar_uuids` contains exact +Live modes require `grid.login_url`, `grid.avatar_name`, and `grid.password`. +`authorized_avatar_uuids` contains exact grid UUIDs, never display names. Text claiming an authorized identity grants no authority. Public chat can request bounded informational work and safe public LSL delivery; movement, teleport, building, roaming changes, and administration @@ -72,19 +71,22 @@ or a narrowly bound scheduler grant as documented in the policy matrix. ## Configuration contract and migration -The JSON root uses `"schema_version": 1`. Omitting it is accepted as legacy +The YAML root uses `schema_version: 1`. Omitting it is accepted as legacy version 1. Any other value fails before startup with a migration message. There is no automatic in-place migration: copy the file, update the copy using the release notes/example, validate the copy, then atomically select it. Unknown fields fail closed. Installers never overwrite or migrate operator files. -Precedence, lowest to highest, is: +Resolution order is: 1. bounded built-in defaults; -2. JSON configuration; -3. secret files named by JSON; -4. environment-referenced secret files; -5. direct environment values. +2. the platform `config.yml` or explicit `--config` document; +3. legacy secret files referenced by that document. + +Process environment variables do not override connection, mode, or authority. +Migrate an existing `.env` once with `metacrate-grid-agent --import-env +/path/to/.env`, inspect it using `--preferences`, then remove the old file under +the operator's retention policy. JSON remains readable only for migration. Mode, endpoints, credentials, authorization UUIDs, TLS, storage, queue/resource limits, reconnect policy, behavior, and interaction settings are restart-only. @@ -120,9 +122,8 @@ Rust's portable metadata API cannot prove arbitrary Windows ACL semantics, and FAT/network filesystems may not enforce them. Treat an unverifiable filesystem as unsuitable for unattended secrets. -Rotate one credential at a time: write a new restricted file beside the old -one, atomically replace or repoint the `_FILE` setting, run `--check-config`, -then restart. Revoke the old credential only after readiness. Control observer +Rotate one credential at a time in `--preferences` (or an ACL-restricted copy +of `config.yml`), save, run `--check-config`, then restart. Revoke the old credential only after readiness. Control observer and operator tokens must differ from each other and from grid/LLM credentials. ## Linux systemd @@ -130,16 +131,15 @@ and operator tokens must differ from each other and from grid/LLM credentials. The hardened example is [`../packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service`](../packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service). Create the unprivileged `metacrate-agent` identity, copy (do not overwrite) an -operator configuration to `/etc/metacrate/grid-agent.json`, create -`/var/lib/metacrate/grid-agent`, and copy the environment-file example. The -environment file contains only secret *paths*. Put the actual secret files in -`/etc/metacrate/secrets`, owned by the service identity with mode `0600`. +operator configuration to `/etc/metacrate/config.yml` and create +`/var/lib/metacrate/grid-agent`. Keep the configuration owned by the service +identity with mode `0600`; do not put credentials in the unit environment. Install the unit, inspect the sandbox, validate, then start: ```sh sudo systemd-analyze security packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service -sudo -u metacrate-agent /usr/local/bin/metacrate-grid-agent --config /etc/metacrate/grid-agent.json --check-config +sudo -u metacrate-agent /usr/local/bin/metacrate-grid-agent --config /etc/metacrate/config.yml --check-config sudo systemctl daemon-reload sudo systemctl enable --now metacrate-grid-agent.service ``` @@ -150,19 +150,18 @@ writes only under the state directory. ## Windows service operation -Run the same `.exe`, JSON, control TCP protocol, and shutdown path. Portable +Run the same `.exe`, YAML, control TCP protocol, and shutdown path. Portable foreground operation in PowerShell is the baseline: ```powershell -& 'C:\Program Files\MetaCrate\metacrate-grid-agent.exe' --config 'C:\ProgramData\MetaCrate\grid-agent.json' --check-config -& 'C:\Program Files\MetaCrate\metacrate-grid-agent.exe' --config 'C:\ProgramData\MetaCrate\grid-agent.json' +& 'C:\Program Files\MetaCrate\metacrate-grid-agent.exe' --config 'C:\ProgramData\MetaCrate\config.yml' --check-config +& 'C:\Program Files\MetaCrate\metacrate-grid-agent.exe' --config 'C:\ProgramData\MetaCrate\config.yml' ``` For unattended use, configure a maintained Windows service wrapper (for example WinSW) to launch exactly that command as a dedicated low-privilege account and to translate SCM Stop into Ctrl-C/console control before its timeout. -Keep secrets in separate ACL-restricted files and expose only `_FILE` paths in -the wrapper environment. Configure restart-on-failure, not unconditional rapid +Keep `config.yml` ACL-restricted to the service identity. Configure restart-on-failure, not unconditional rapid restart. Validate as the service identity before registration. The wrapper must not capture environment values or command output into a world-readable log. The supplied PowerShell installer replaces only the executable and never state. diff --git a/docs/grid-agent-tui.md b/docs/grid-agent-tui.md index 98a2bd2..380c083 100644 --- a/docs/grid-agent-tui.md +++ b/docs/grid-agent-tui.md @@ -8,21 +8,29 @@ Run an embedded UI with a live-grid integrated configuration: ```sh cargo run --locked -p metacrate-grid-agent --features live-grid -- \ - --config config/grid-agent.json --tui + --config config/grid-agent.integrated.example.yml --tui ``` Run a separate UI against a loopback split service: ```sh cargo run --locked -p metacrate-grid-agent -- \ - --config config/grid-agent.json --tui-client + --config config/grid-agent.split.example.yml --tui-client ``` -The split client reads its operator capability through the normal secret-file -or environment configuration. It never displays or copies that value. Remote +The split client reads its operator capability from the ACL-restricted +`config.yml`. It never displays or copies that value. Remote TLS clients must embed `TuiTransport` with their explicitly configured trust roots; the command-line client intentionally accepts loopback TCP only. +Open preferences before a service is configured with +`metacrate-grid-agent --preferences`; the same panel is available from the live +TUI. It edits the endpoint, optional model, grid identity, hidden credentials, +mode, and privileged UUID list while preserving advanced YAML settings. Use +Up/Down to select, Enter to edit/commit, `w` to save, and Esc to cancel. Changes +to connection or authorization settings apply after restart. A one-time +`--import-env /path/to/.env` migrates legacy `GRID_*` and `OPENAPI_*` values. + Use Tab/arrow keys to change panels and scroll. `r` refreshes; `p`/`u` pause or resume; `f` reconnects the grid; `a`/`d` approve or deny the first pending approval; `x` cancels the newest visible action; `e` expires the first visible diff --git a/packaging/metacrate-grid-agent/systemd/grid-agent.env.example b/packaging/metacrate-grid-agent/systemd/grid-agent.env.example deleted file mode 100644 index e21eac1..0000000 --- a/packaging/metacrate-grid-agent/systemd/grid-agent.env.example +++ /dev/null @@ -1,7 +0,0 @@ -# Values here are paths and non-secret mode settings only. Never put secret -# values in this file. Create every referenced file as mode 0600, owned by the -# metacrate-agent service identity. -METACRATE_AGENT_LLM_API_KEY_FILE=/etc/metacrate/secrets/llm-api-key -METACRATE_AGENT_GRID_PASSWORD_FILE=/etc/metacrate/secrets/grid-password -METACRATE_AGENT_CONTROL_OPERATOR_TOKEN_FILE=/etc/metacrate/secrets/control-operator-token -METACRATE_AGENT_STORAGE_PATH=/var/lib/metacrate/grid-agent diff --git a/packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service b/packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service index efece05..382b4cd 100644 --- a/packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service +++ b/packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service @@ -8,9 +8,8 @@ After=network-online.target Type=simple User=metacrate-agent Group=metacrate-agent -EnvironmentFile=-/etc/metacrate/grid-agent.env -ExecStartPre=/usr/local/bin/metacrate-grid-agent --config /etc/metacrate/grid-agent.json --check-config -ExecStart=/usr/local/bin/metacrate-grid-agent --config /etc/metacrate/grid-agent.json +ExecStartPre=/usr/local/bin/metacrate-grid-agent --config /etc/metacrate/config.yml --check-config +ExecStart=/usr/local/bin/metacrate-grid-agent --config /etc/metacrate/config.yml KillSignal=SIGINT TimeoutStopSec=30s Restart=on-failure