fix(grid-agent): move setup into YAML preferences (#135)
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m46s
CI / required (push) Failing after 54s

This commit is contained in:
2026-08-18 21:34:14 +02:00
parent b56b28043f
commit db25a977b7
27 changed files with 1242 additions and 482 deletions

26
Cargo.lock generated
View File

@@ -2282,6 +2282,7 @@ dependencies = [
"rustls", "rustls",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml_ng",
"sha2 0.11.0", "sha2 0.11.0",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
@@ -3405,6 +3406,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -3559,6 +3566,19 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.7" version = "0.10.7"
@@ -4290,6 +4310,12 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.7.1" version = "0.7.1"

View File

@@ -1,75 +0,0 @@
{
"schema_version": 1,
"integrated": false,
"split": false,
"llm": {
"endpoint_url": "https://llm.example.invalid/v1/chat/completions",
"api_key": "<set-via-environment-or-restricted-secret-file>"
},
"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
}
}

View File

@@ -0,0 +1,52 @@
schema_version: 1
integrated: false
split: false
llm:
endpoint_url: https://llm.example.invalid/v1/chat/completions
api_key: <replace-in-private-config>
model: <endpoint-model-name>
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

View File

@@ -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"
}

View File

@@ -0,0 +1,15 @@
schema_version: 1
integrated: true
split: false
llm:
endpoint_url: https://llm.example.invalid/v1/chat/completions
api_key: <replace-in-private-config>
model: <endpoint-model-name>
grid:
login_url: https://grid.example.invalid/login
avatar_name: Service Avatar
password: <replace-in-private-config>
# These UUIDs are the only users allowed to request privileged mutations.
authorized_avatar_uuids:
- 00000000-0000-4000-8000-000000000001
storage_path: data/grid-agent

View File

@@ -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"
}
}

View File

@@ -0,0 +1,18 @@
schema_version: 1
integrated: false
split: true
llm:
endpoint_url: https://llm.example.invalid/v1/chat/completions
api_key: <replace-in-private-config>
model: <endpoint-model-name>
grid:
login_url: https://grid.example.invalid/login
avatar_name: Service Avatar
password: <replace-in-private-config>
authorized_avatar_uuids:
- 00000000-0000-4000-8000-000000000001
storage_path: data/grid-agent
control:
listen: 127.0.0.1:9764
operator_token: <replace-in-private-config>
observer_token: <replace-in-private-config-with-distinct-value>

View File

@@ -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"] } rustls = { version = "0.23.43", default-features = false, features = ["aws_lc_rs", "std", "tls12"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml_ng = "0.10.0"
sha2 = "0.11" sha2 = "0.11"
tokio = { version = "1.53.1", features = ["macros", "rt", "sync", "time"] } tokio = { version = "1.53.1", features = ["macros", "rt", "sync", "time"] }
tokio-rustls = { version = "0.26.4", default-features = false, features = ["aws_lc_rs", "tls12"] } tokio-rustls = { version = "0.26.4", default-features = false, features = ["aws_lc_rs", "tls12"] }

View File

@@ -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 client's native `NetworkManager` for login, event-queue readiness, disconnect
notifications, and logout instead of adding a protocol client. notifications, and logout instead of adding a protocol client.
The LLM connection identity has exactly two resolved fields: The LLM connection identity has an exact `llm.endpoint_url`, `llm.api_key`, and
`llm.endpoint_url` and `llm.api_key`. The endpoint is used exactly as supplied; optional `llm.model`. The endpoint is used exactly as supplied;
there are no providers, presets, base-URL rewrites, model catalogs, discovery, there are no providers, presets, base-URL rewrites, model catalogs, discovery,
or provider SDKs. `Debug`/`Display` output removes API keys, grid passwords, URL or provider SDKs. `Debug`/`Display` output removes API keys, grid passwords, URL
user information, and URL query values. Secret wrappers are not serializable. user information, and URL query values. Secret wrappers are not serializable.
Configuration precedence, from lowest to highest, is built-in defaults, an Persistent setup lives in the platform `config.yml` (normally
optional JSON file, its referenced secret files, then environment values (an `~/.config/metacrate/config.yml` on Linux). The file contains grid/AI
environment-referenced secret file is below a direct environment secret). connectivity and the exact privileged avatar UUID list; it is written with mode
Supported secret environment variables are 0600 on Unix. `--preferences` opens the setup panel without starting the
`METACRATE_AGENT_LLM_API_KEY[_FILE]` and service. `--import-env .env` performs a one-time import of the legacy
`METACRATE_AGENT_GRID_PASSWORD[_FILE]`. Split control uses the separate `GRID_*`/`OPENAPI_*` values; runtime configuration does not read `.env` or
`METACRATE_AGENT_CONTROL_OPERATOR_TOKEN[_FILE]` and optional connection environment variables. JSON files and referenced secret files are
`METACRATE_AGENT_CONTROL_OBSERVER_TOKEN[_FILE]`, plus accepted only as a legacy migration input.
`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.
Run the focused offline gate with: Run the focused offline gate with:
@@ -36,9 +32,9 @@ Run the focused offline gate with:
cargo test --locked -p metacrate-grid-agent cargo test --locked -p metacrate-grid-agent
cargo clippy --locked -p metacrate-grid-agent --all-targets -- -D warnings cargo clippy --locked -p metacrate-grid-agent --all-targets -- -D warnings
cargo run --locked -p metacrate-grid-agent -- \ 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 -- \ 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) 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, secret rotation, backup/upgrade/rollback, failure playbooks, resource defaults,
and unsupported-operation list are in and unsupported-operation list are in
[`../../docs/grid-agent-operations.md`](../../docs/grid-agent-operations.md). [`../../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 are defined in
[`../../docs/grid-agent-acceptance.md`](../../docs/grid-agent-acceptance.md). [`../../docs/grid-agent-acceptance.md`](../../docs/grid-agent-acceptance.md).

View File

@@ -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)] #![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<String>) -> 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<Self, AcceptanceError> {
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<String>, name: &str, literal: &str) -> bool {
get(name).is_some_and(|value| value == literal)
}
struct AcceptanceResponder; struct AcceptanceResponder;
impl InteractionResponder for AcceptanceResponder { impl InteractionResponder for AcceptanceResponder {

View File

@@ -78,7 +78,7 @@ pub trait WorldMutator: AuthorizedToolBackend {}
/// Inert deterministic backend used by the foundational offline service. /// Inert deterministic backend used by the foundational offline service.
/// ///
/// It performs no login or network operation and is available without the /// 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)] #[derive(Clone, Copy, Debug, Default)]
pub struct OfflineGridBackend; pub struct OfflineGridBackend;

View File

@@ -2,11 +2,12 @@
use crate::types::{MAX_BODY_BYTES, MAX_CONVERSATION_MESSAGES, MAX_MESSAGE_BYTES, MAX_TOOL_CALLS}; use crate::types::{MAX_BODY_BYTES, MAX_CONVERSATION_MESSAGES, MAX_MESSAGE_BYTES, MAX_TOOL_CALLS};
use libremetaverse_types::UUID; use libremetaverse_types::UUID;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::error::Error; use std::error::Error;
use std::fmt; use std::fmt;
use std::fs; use std::fs;
use std::io::Write as _;
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -21,22 +22,10 @@ const MAX_QUEUE_CAPACITY: usize = 8_192;
const MAX_BACKGROUND_TASKS: usize = 2; const MAX_BACKGROUND_TASKS: usize = 2;
pub const CONFIG_SCHEMA_VERSION: u32 = 1; pub const CONFIG_SCHEMA_VERSION: u32 = 1;
const ENV_INTEGRATED: &str = "METACRATE_AGENT_INTEGRATED"; #[cfg(test)]
const ENV_SPLIT: &str = "METACRATE_AGENT_SPLIT";
const ENV_LLM_ENDPOINT: &str = "METACRATE_AGENT_LLM_ENDPOINT_URL"; 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: &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 /// Wrapper that never reveals its contents through `Debug` or `Display` and
/// deliberately does not implement serialization. /// deliberately does not implement serialization.
@@ -160,6 +149,7 @@ pub enum OperatingMode {
pub struct LlmConnection { pub struct LlmConnection {
pub endpoint_url: EndpointUrl, pub endpoint_url: EndpointUrl,
pub api_key: SecretString, pub api_key: SecretString,
pub model: Option<String>,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -345,6 +335,7 @@ impl AgentConfig {
llm: RawLlm { llm: RawLlm {
endpoint_url: Some(endpoint_url.into()), endpoint_url: Some(endpoint_url.into()),
api_key: Some(api_key.into()), api_key: Some(api_key.into()),
..RawLlm::default()
}, },
..FileConfig::default() ..FileConfig::default()
}; };
@@ -455,7 +446,7 @@ impl PlatformPaths {
.get("LOCALAPPDATA") .get("LOCALAPPDATA")
.map_or_else(|| PathBuf::from("data"), PathBuf::from); .map_or_else(|| PathBuf::from("data"), PathBuf::from);
return Self { return Self {
config_file: config.join("MetaCrate/grid-agent.json"), config_file: config.join("MetaCrate/config.yml"),
data_directory: data.join("MetaCrate/grid-agent"), data_directory: data.join("MetaCrate/grid-agent"),
}; };
} }
@@ -464,7 +455,7 @@ impl PlatformPaths {
.get("HOME") .get("HOME")
.map_or_else(|| PathBuf::from("."), PathBuf::from); .map_or_else(|| PathBuf::from("."), PathBuf::from);
return Self { 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"), data_directory: home.join("Library/Application Support/MetaCrate/grid-agent"),
}; };
} }
@@ -478,7 +469,7 @@ impl PlatformPaths {
.get("XDG_DATA_HOME") .get("XDG_DATA_HOME")
.map_or_else(|| home.join(".local/share"), PathBuf::from); .map_or_else(|| home.join(".local/share"), PathBuf::from);
Self { Self {
config_file: config.join("metacrate/grid-agent.json"), config_file: config.join("metacrate/config.yml"),
data_directory: data.join("metacrate/grid-agent"), data_directory: data.join("metacrate/grid-agent"),
} }
} }
@@ -710,6 +701,322 @@ impl fmt::Display for ConfigError {
impl Error 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<u32>,
pub integrated: Option<bool>,
pub split: Option<bool>,
pub llm: ConnectionPreferences,
pub grid: GridPreferences,
pub authorized_avatar_uuids: Option<Vec<String>>,
#[serde(flatten)]
extra: BTreeMap<String, serde_yaml_ng::Value>,
}
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<String>,
pub api_key: Option<String>,
pub model: Option<String>,
#[serde(flatten)]
extra: BTreeMap<String, serde_yaml_ng::Value>,
}
#[derive(Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct GridPreferences {
pub login_url: Option<String>,
pub avatar_name: Option<String>,
pub password: Option<String>,
#[serde(flatten)]
extra: BTreeMap<String, serde_yaml_ng::Value>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PreferencesSummary {
pub mode: OperatingMode,
pub llm_endpoint: Option<String>,
pub llm_api_key_configured: bool,
pub llm_model: Option<String>,
pub grid_login_url: Option<String>,
pub grid_avatar_name: Option<String>,
pub grid_password_configured: bool,
pub privileged_users: Vec<String>,
}
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<Path>) -> Result<Self, ConfigError> {
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<String>) {
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<String>) {
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<String>) {
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<Path>) -> 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<Path>) -> 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)] #[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
struct FileConfig { struct FileConfig {
@@ -735,6 +1042,7 @@ struct FileConfig {
struct RawLlm { struct RawLlm {
endpoint_url: Option<String>, endpoint_url: Option<String>,
api_key: Option<String>, api_key: Option<String>,
model: Option<String>,
} }
#[derive(Clone, Default, Deserialize)] #[derive(Clone, Default, Deserialize)]
@@ -851,9 +1159,25 @@ struct RawInteraction {
fn read_config(path: &Path) -> Result<FileConfig, ConfigError> { fn read_config(path: &Path) -> Result<FileConfig, ConfigError> {
let bytes = read_bounded_regular_file("configuration file", path, MAX_CONFIG_BYTES)?; 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<T: serde::de::DeserializeOwned>(
bytes: &[u8],
path: &Path,
) -> Result<T, ConfigError> {
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(), path: path.to_owned(),
reason: error.to_string(), reason,
}) })
} }
@@ -871,12 +1195,8 @@ fn resolve<E: Environment>(
supported: CONFIG_SCHEMA_VERSION, supported: CONFIG_SCHEMA_VERSION,
}); });
} }
let integrated = environment_boolean(environment, ENV_INTEGRATED)? let integrated = raw.integrated.unwrap_or(false);
.or(raw.integrated) let split = raw.split.unwrap_or(false);
.unwrap_or(false);
let split = environment_boolean(environment, ENV_SPLIT)?
.or(raw.split)
.unwrap_or(false);
let mode = match (integrated, split) { let mode = match (integrated, split) {
(true, true) => return Err(ConfigError::ConflictingModes), (true, true) => return Err(ConfigError::ConflictingModes),
(true, false) => OperatingMode::Integrated, (true, false) => OperatingMode::Integrated,
@@ -887,16 +1207,13 @@ fn resolve<E: Environment>(
let base = config_path let base = config_path
.and_then(Path::parent) .and_then(Path::parent)
.unwrap_or_else(|| Path::new(".")); .unwrap_or_else(|| Path::new("."));
let endpoint = environment let endpoint = raw.llm.endpoint_url.ok_or(ConfigError::Missing {
.get(ENV_LLM_ENDPOINT)
.or(raw.llm.endpoint_url)
.ok_or(ConfigError::Missing {
field: "llm.endpoint_url", field: "llm.endpoint_url",
required_for: "all modes", required_for: "all modes",
})?; })?;
let api_key = secret_from_layers( let api_key = secret_from_layers(
environment.get(ENV_LLM_API_KEY), None,
environment.get(ENV_LLM_API_KEY_FILE).map(PathBuf::from), None,
raw.llm.api_key, raw.llm.api_key,
raw.secret_files.llm_api_key, raw.secret_files.llm_api_key,
base, base,
@@ -905,19 +1222,32 @@ fn resolve<E: Environment>(
let llm = LlmConnection { let llm = LlmConnection {
endpoint_url: EndpointUrl::parse("llm.endpoint_url", &endpoint)?, endpoint_url: EndpointUrl::parse("llm.endpoint_url", &endpoint)?,
api_key, 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 login_url = raw.grid.login_url;
let avatar_name = environment let avatar_name = raw.grid.avatar_name;
.get(ENV_GRID_AVATAR_NAME) let direct_password = raw.grid.password;
.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 grid = if mode == OperatingMode::OfflineFake let grid = if mode == OperatingMode::OfflineFake
&& login_url.is_none() && login_url.is_none()
&& avatar_name.is_none() && avatar_name.is_none()
&& direct_password.is_none() && direct_password.is_none()
&& environment_password_file.is_none()
&& raw.secret_files.grid_password.is_none() && raw.secret_files.grid_password.is_none()
{ {
None None
@@ -941,7 +1271,7 @@ fn resolve<E: Environment>(
avatar_name, avatar_name,
password: secret_from_layers( password: secret_from_layers(
direct_password, direct_password,
environment_password_file, None,
None, None,
raw.secret_files.grid_password, raw.secret_files.grid_password,
base, base,
@@ -950,11 +1280,7 @@ fn resolve<E: Environment>(
}) })
}; };
let authorized_values = environment let authorized_values = raw.authorized_avatar_uuids.unwrap_or_default();
.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_avatar_uuids = parse_authorized_avatars(authorized_values)?; let authorized_avatar_uuids = parse_authorized_avatars(authorized_values)?;
let defaults = Limits::default(); let defaults = Limits::default();
@@ -1128,28 +1454,24 @@ fn resolve<E: Environment>(
.unwrap_or(interaction_defaults.max_debounce_fragments), .unwrap_or(interaction_defaults.max_debounce_fragments),
}; };
let control_defaults = ControlSettings::default(); let control_defaults = ControlSettings::default();
let listen_text = environment let listen_text = raw
.get(ENV_CONTROL_LISTEN) .control
.or(raw.control.listen) .listen
.unwrap_or_else(|| control_defaults.listen.to_string()); .unwrap_or_else(|| control_defaults.listen.to_string());
let listen = listen_text let listen = listen_text
.parse::<SocketAddr>() .parse::<SocketAddr>()
.map_err(|_| ConfigError::InvalidControl)?; .map_err(|_| ConfigError::InvalidControl)?;
let operator_token = optional_secret_from_layers( let operator_token = optional_secret_from_layers(
environment.get(ENV_CONTROL_OPERATOR_TOKEN), None,
environment None,
.get(ENV_CONTROL_OPERATOR_TOKEN_FILE)
.map(PathBuf::from),
raw.control.operator_token, raw.control.operator_token,
raw.secret_files.control_operator_token, raw.secret_files.control_operator_token,
base, base,
"control.operator_token", "control.operator_token",
)?; )?;
let observer_token = optional_secret_from_layers( let observer_token = optional_secret_from_layers(
environment.get(ENV_CONTROL_OBSERVER_TOKEN), None,
environment None,
.get(ENV_CONTROL_OBSERVER_TOKEN_FILE)
.map(PathBuf::from),
raw.control.observer_token, raw.control.observer_token,
raw.secret_files.control_observer_token, raw.secret_files.control_observer_token,
base, base,
@@ -1186,10 +1508,8 @@ fn resolve<E: Environment>(
authorized_avatar_uuids, authorized_avatar_uuids,
timeouts, timeouts,
limits, limits,
storage_path: environment storage_path: raw
.get(ENV_STORAGE_PATH) .storage_path
.map(PathBuf::from)
.or(raw.storage_path)
.unwrap_or_else(|| PlatformPaths::from_environment(environment).data_directory), .unwrap_or_else(|| PlatformPaths::from_environment(environment).data_directory),
behavior: BehaviorSettings { behavior: BehaviorSettings {
heartbeat: checked_duration( heartbeat: checked_duration(
@@ -1425,20 +1745,6 @@ fn validate_secret(field: &'static str, value: &str) -> Result<(), ConfigError>
Ok(()) Ok(())
} }
fn environment_boolean<E: Environment>(
environment: &E,
field: &'static str,
) -> Result<Option<bool>, 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( fn checked_duration(
field: &'static str, field: &'static str,
seconds: u64, seconds: u64,
@@ -1523,7 +1829,6 @@ fn check_limit(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::io::Write as _;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(1); static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(1);
@@ -1559,7 +1864,7 @@ mod tests {
} }
#[test] #[test]
fn environment_overrides_file_and_secrets_are_redacted() { fn file_configuration_is_authoritative_and_secrets_are_redacted() {
let path = temporary_file( let path = temporary_file(
"precedence.json", "precedence.json",
r#"{ r#"{
@@ -1572,13 +1877,13 @@ mod tests {
let loader_diagnostic = format!("{loader:?}"); let loader_diagnostic = format!("{loader:?}");
assert!(!loader_diagnostic.contains("environment-key")); assert!(!loader_diagnostic.contains("environment-key"));
let config = loader.load().expect("valid layered configuration"); 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!( assert!(
config config
.llm .llm
.endpoint_url .endpoint_url
.expose_url() .expose_url()
.contains("llm.example.invalid") .contains("file.invalid")
); );
let diagnostic = format!("{config:?}"); let diagnostic = format!("{config:?}");
assert!(!diagnostic.contains("environment-key")); assert!(!diagnostic.contains("environment-key"));
@@ -1587,7 +1892,7 @@ mod tests {
} }
#[test] #[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 secret = temporary_file("precedence-api-key", "secret-file-key\n");
let document = serde_json::json!({ let document = serde_json::json!({
"llm": { "llm": {
@@ -1615,7 +1920,7 @@ mod tests {
.with_environment(direct_environment) .with_environment(direct_environment)
.load() .load()
.expect("direct environment layer resolves"); .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(secret);
let _ = fs::remove_file(config_path); let _ = fs::remove_file(config_path);
} }
@@ -1636,19 +1941,19 @@ mod tests {
supported: CONFIG_SCHEMA_VERSION supported: CONFIG_SCHEMA_VERSION
}) })
)); ));
let environment = MapEnvironment::from_pairs([ let configured = temporary_file(
(ENV_LLM_ENDPOINT, "https://llm.invalid/chat"), "storage.yml",
(ENV_LLM_API_KEY, "placeholder"), "llm:\n endpoint_url: https://llm.invalid/chat\n api_key: placeholder\nstorage_path: operator-data\n",
(ENV_STORAGE_PATH, "operator-data"), );
("HOME", "/operator"), let environment = MapEnvironment::from_pairs([("HOME", "/operator")]);
]);
let config = ConfigLoader::new() let config = ConfigLoader::new()
.with_file(configured)
.with_environment(environment.clone()) .with_environment(environment.clone())
.load() .load()
.unwrap(); .unwrap();
assert_eq!(config.storage_path, PathBuf::from("operator-data")); assert_eq!(config.storage_path, PathBuf::from("operator-data"));
let paths = PlatformPaths::from_environment(&environment); 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")); assert!(paths.data_directory.ends_with("grid-agent"));
} }
@@ -1682,65 +1987,52 @@ mod tests {
fn invalid_urls_uuids_limits_and_wildcards_fail_fast() { fn invalid_urls_uuids_limits_and_wildcards_fail_fast() {
assert!(AgentConfig::offline("file:///tmp/socket", "key").is_err()); assert!(AgentConfig::offline("file:///tmp/socket", "key").is_err());
let mut wildcard = offline_environment();
wildcard.insert(ENV_AUTHORIZED_AVATARS, "*");
assert!(matches!( assert!(matches!(
ConfigLoader::new().with_environment(wildcard).load(), parse_authorized_avatars(vec!["*".into()]),
Err(ConfigError::InvalidUuid { .. }) Err(ConfigError::InvalidUuid { .. })
)); ));
let mut malformed = offline_environment();
malformed.insert(ENV_AUTHORIZED_AVATARS, "not-a-uuid");
assert!(matches!( assert!(matches!(
ConfigLoader::new().with_environment(malformed).load(), parse_authorized_avatars(vec!["not-a-uuid".into()]),
Err(ConfigError::InvalidUuid { .. }) 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!( assert!(matches!(
ConfigLoader::new() ConfigLoader::new().with_file(&path).load(),
.with_file(&path)
.with_environment(offline_environment())
.load(),
Err(ConfigError::UnsafeLimit { .. }) Err(ConfigError::UnsafeLimit { .. })
)); ));
let _ = fs::remove_file(path); let _ = fs::remove_file(path);
let reconnect = temporary_file( let reconnect = temporary_file(
"unsafe-reconnect.json", "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!( assert!(matches!(
ConfigLoader::new() ConfigLoader::new().with_file(&reconnect).load(),
.with_file(&reconnect)
.with_environment(offline_environment())
.load(),
Err(ConfigError::InvalidReconnect) Err(ConfigError::InvalidReconnect)
)); ));
let _ = fs::remove_file(reconnect); let _ = fs::remove_file(reconnect);
let conversation = temporary_file( let conversation = temporary_file(
"unsafe-conversation.json", "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!( assert!(matches!(
ConfigLoader::new() ConfigLoader::new().with_file(&conversation).load(),
.with_file(&conversation)
.with_environment(offline_environment())
.load(),
Err(ConfigError::InvalidConversationMemory) Err(ConfigError::InvalidConversationMemory)
)); ));
let _ = fs::remove_file(conversation); let _ = fs::remove_file(conversation);
let interaction = temporary_file( let interaction = temporary_file(
"unsafe-interaction.json", "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!( assert!(matches!(
ConfigLoader::new() ConfigLoader::new().with_file(&interaction).load(),
.with_file(&interaction)
.with_environment(offline_environment())
.load(),
Err(ConfigError::InvalidInteraction) Err(ConfigError::InvalidInteraction)
)); ));
let _ = fs::remove_file(interaction); let _ = fs::remove_file(interaction);
@@ -1748,24 +2040,21 @@ mod tests {
#[test] #[test]
fn conflicting_live_modes_and_missing_live_fields_are_rejected() { fn conflicting_live_modes_and_missing_live_fields_are_rejected() {
let both = MapEnvironment::from_pairs([ let both = temporary_file(
(ENV_INTEGRATED, "true"), "both.yml",
(ENV_SPLIT, "true"), "integrated: true\nsplit: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: key }\n",
(ENV_LLM_ENDPOINT, "https://llm.invalid/chat"), );
(ENV_LLM_API_KEY, "key"),
]);
assert_eq!( assert_eq!(
ConfigLoader::new().with_environment(both).load(), ConfigLoader::new().with_file(both).load(),
Err(ConfigError::ConflictingModes) Err(ConfigError::ConflictingModes)
); );
let missing_grid = MapEnvironment::from_pairs([ let missing_grid = temporary_file(
(ENV_INTEGRATED, "true"), "missing-grid.yml",
(ENV_LLM_ENDPOINT, "https://llm.invalid/chat"), "integrated: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: key }\n",
(ENV_LLM_API_KEY, "key"), );
]);
assert!(matches!( assert!(matches!(
ConfigLoader::new().with_environment(missing_grid).load(), ConfigLoader::new().with_file(missing_grid).load(),
Err(ConfigError::Missing { Err(ConfigError::Missing {
field: "grid.login_url", field: "grid.login_url",
.. ..
@@ -1775,18 +2064,12 @@ mod tests {
#[test] #[test]
fn split_control_requires_distinct_tokens_and_tls_for_remote_bind() { fn split_control_requires_distinct_tokens_and_tls_for_remote_bind() {
let split = MapEnvironment::from_pairs([ let split = temporary_file(
(ENV_SPLIT, "true"), "split.yml",
(ENV_LLM_ENDPOINT, "https://llm.invalid/chat"), "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",
(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 config = ConfigLoader::new() let config = ConfigLoader::new()
.with_environment(split.clone()) .with_file(split)
.load() .load()
.expect("bounded loopback split control"); .expect("bounded loopback split control");
assert_eq!(config.mode, OperatingMode::SplitService); assert_eq!(config.mode, OperatingMode::SplitService);
@@ -1800,19 +2083,21 @@ mod tests {
assert!(!diagnostic.contains(secret)); assert!(!diagnostic.contains(secret));
} }
let mut reused = split.clone(); let reused = temporary_file(
reused.insert(ENV_CONTROL_OPERATOR_TOKEN, "llm-key"); "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!( assert_eq!(
ConfigLoader::new().with_environment(reused).load(), ConfigLoader::new().with_file(reused).load(),
Err(ConfigError::InvalidControl) Err(ConfigError::InvalidControl)
); );
let mut remote_plaintext = split; let remote_plaintext = temporary_file(
remote_plaintext.insert(ENV_CONTROL_LISTEN, "0.0.0.0:7943"); "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!( assert_eq!(
ConfigLoader::new() ConfigLoader::new().with_file(remote_plaintext).load(),
.with_environment(remote_plaintext)
.load(),
Err(ConfigError::InvalidControl) Err(ConfigError::InvalidControl)
); );
} }
@@ -1820,11 +2105,10 @@ mod tests {
#[test] #[test]
fn secret_file_is_bounded_regular_and_trailing_newline_is_removed() { fn secret_file_is_bounded_regular_and_trailing_newline_is_removed() {
let secret = temporary_file("api-key", "secret-from-file\n"); let secret = temporary_file("api-key", "secret-from-file\n");
let mut environment = let document = serde_json::json!({"llm":{"endpoint_url":"https://llm.example.invalid/chat"},"secret_files":{"llm_api_key":secret}});
MapEnvironment::from_pairs([(ENV_LLM_ENDPOINT, "https://llm.example.invalid/chat")]); let config_path = temporary_file("secret-file.json", &document.to_string());
environment.insert(ENV_LLM_API_KEY_FILE, secret.to_string_lossy());
let config = ConfigLoader::new() let config = ConfigLoader::new()
.with_environment(environment) .with_file(config_path)
.load() .load()
.expect("secret file loads"); .expect("secret file loads");
assert_eq!(config.llm.api_key.expose_secret(), "secret-from-file"); assert_eq!(config.llm.api_key.expose_secret(), "secret-from-file");

View File

@@ -58,8 +58,7 @@ mod vision_tests;
pub use acceptance::{ pub use acceptance::{
ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets, AcceptanceError, ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets, AcceptanceError,
AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, LiveGridOptIns, AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, run_deterministic_acceptance,
run_deterministic_acceptance,
}; };
pub use backend::{ pub use backend::{
AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend, AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend,
@@ -85,9 +84,10 @@ pub use build::{
BuildService, BuildShape, BuildToolBackend, BuildValidation, PrimReceipt, build_policy_tools, BuildService, BuildShape, BuildToolBackend, BuildValidation, PrimReceipt, build_policy_tools,
}; };
pub use config::{ pub use config::{
AgentConfig, BehaviorSettings, CONFIG_SCHEMA_VERSION, ConfigError, ConfigLoader, AgentConfig, AgentPreferences, BehaviorSettings, CONFIG_SCHEMA_VERSION, ConfigError,
ControlSettings, ConversationSettings, EndpointUrl, Environment, GridConnection, Limits, ConfigLoader, ConnectionPreferences, ControlSettings, ConversationSettings, EndpointUrl,
LlmConnection, MapEnvironment, OperatingMode, PlatformPaths, RemoteTlsSettings, SecretString, Environment, GridConnection, GridPreferences, Limits, LlmConnection, MapEnvironment,
OperatingMode, PlatformPaths, PreferencesSummary, RemoteTlsSettings, SecretString,
StdEnvironment, Timeouts, StdEnvironment, Timeouts,
}; };
pub use control_plane::{ pub use control_plane::{
@@ -175,8 +175,8 @@ pub use tool_loop::{
}; };
pub use tui::{ pub use tui::{
CommandConfirmation, EventFilter, OperatorCommand, OperatorScreen, OperatorSnapshot, CommandConfirmation, EventFilter, OperatorCommand, OperatorScreen, OperatorSnapshot,
OperatorTui, ReconnectingTcpTransport, TuiAction, TuiError, TuiInput, TuiRenderOptions, OperatorTui, PreferenceField, PreferencesPanel, ReconnectingTcpTransport, TuiAction, TuiError,
TuiTransport, TuiInput, TuiRenderOptions, TuiTransport,
}; };
pub use types::{ pub use types::{
BoundaryError, BoundedText, BoundedVec, ControlCommand, Conversation, ConversationMessage, BoundaryError, BoundedText, BoundedVec, ControlCommand, Conversation, ConversationMessage,

View File

@@ -401,7 +401,7 @@ impl LlmClient {
.iter() .iter()
.any(|part| matches!(part, ContentPart::Image { .. })) .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 { if body.len() > self.limits.max_prompt_bytes {
return Err(LlmError::PromptTooLarge); return Err(LlmError::PromptTooLarge);
} }
@@ -567,6 +567,7 @@ async fn read_bounded(
fn request_body( fn request_body(
messages: &[CompletionMessage], messages: &[CompletionMessage],
tools: &[ToolDefinition], tools: &[ToolDefinition],
model: Option<&str>,
) -> Result<Vec<u8>, LlmError> { ) -> Result<Vec<u8>, LlmError> {
let messages = messages.iter().map(message_wire_value).collect::<Vec<_>>(); let messages = messages.iter().map(message_wire_value).collect::<Vec<_>>();
let tools = tools let tools = tools
@@ -582,8 +583,11 @@ fn request_body(
}) })
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
serde_json::to_vec(&json!({"messages":messages, "tools":tools})) let mut request = json!({"messages":messages, "tools":tools});
.map_err(|_| LlmError::MalformedJson) 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 { fn message_wire_value(message: &CompletionMessage) -> Value {

View File

@@ -37,7 +37,8 @@ enum Operation {
TuiClient, TuiClient,
PrintPaths, PrintPaths,
Acceptance, Acceptance,
CheckLiveOptIns, Preferences,
ImportEnv,
} }
#[derive(Default)] #[derive(Default)]
@@ -45,6 +46,7 @@ struct Options {
config: Option<PathBuf>, config: Option<PathBuf>,
operation: Operation, operation: Operation,
evidence: Option<PathBuf>, evidence: Option<PathBuf>,
import_env: Option<PathBuf>,
} }
fn options() -> Result<Option<Options>, CliError> { fn options() -> Result<Option<Options>, CliError> {
@@ -60,7 +62,7 @@ fn options() -> Result<Option<Options>, CliError> {
} }
if argument == "--help" || argument == "-h" { if argument == "--help" || argument == "-h" {
println!( 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\ Configuration precedence: defaults < JSON < secret files < environment.\n\
With no --config, the platform default is used when it exists." With no --config, the platform default is used when it exists."
); );
@@ -86,8 +88,15 @@ fn options() -> Result<Option<Options>, CliError> {
.ok_or_else(|| CliError("--acceptance-evidence requires a new path".into()))?; .ok_or_else(|| CliError("--acceptance-evidence requires a new path".into()))?;
count += 1; count += 1;
result.evidence = Some(PathBuf::from(path)); result.evidence = Some(PathBuf::from(path));
} else if argument == "--check-live-opt-ins" { } else if argument == "--preferences" {
set_operation(&mut result, Operation::CheckLiveOptIns)?; 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" { } else if argument == "--config" {
let path = arguments let path = arguments
.next() .next()
@@ -140,28 +149,31 @@ async fn main() -> Result<(), Box<dyn Error>> {
); );
return Ok(()); return Ok(());
} }
if options.operation == Operation::CheckLiveOptIns { let mut loader = ConfigLoader::new();
let opt_ins = let default_config_path = platform_paths.config_file;
metacrate_grid_agent::LiveGridOptIns::from_environment(|name| std::env::var(name).ok()) let config_path = options.config.or_else(|| {
.validate()?; 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!( println!(
"live opt-ins: login={} chat_im={} script={} landmarks={} build={} visual={}", "imported grid and AI settings into {}",
opt_ins.login, preferences_path.display()
opt_ins.chat_and_im,
opt_ins.script_delivery,
opt_ins.landmarks_and_roaming,
opt_ins.reversible_build,
opt_ins.visual_capture
); );
return Ok(()); 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 { if let Some(path) = config_path {
loader = loader.with_file(path); loader = loader.with_file(path);
} }
@@ -181,7 +193,11 @@ async fn main() -> Result<(), Box<dyn Error>> {
token.clone(), token.clone(),
config.control.limits, config.control.limits,
); );
metacrate_grid_agent::tui::run_terminal(std::sync::Arc::new(client), !color_disabled()) metacrate_grid_agent::tui::run_terminal_with_preferences(
std::sync::Arc::new(client),
!color_disabled(),
Some(preferences_path),
)
.await?; .await?;
return Ok(()); return Ok(());
} }
@@ -191,6 +207,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
config, config,
options.operation == Operation::RunOnce, options.operation == Operation::RunOnce,
options.operation == Operation::Tui, options.operation == Operation::Tui,
preferences_path,
) )
.await; .await;
#[cfg(not(feature = "live-grid"))] #[cfg(not(feature = "live-grid"))]
@@ -252,6 +269,7 @@ async fn run_live(
config: metacrate_grid_agent::AgentConfig, config: metacrate_grid_agent::AgentConfig,
run_once: bool, run_once: bool,
tui: bool, tui: bool,
preferences_path: PathBuf,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
use metacrate_grid_agent::{ use metacrate_grid_agent::{
AgentControlTarget, BehaviorObservation, ControlEventKind, ControlPlane, ControlTarget, AgentControlTarget, BehaviorObservation, ControlEventKind, ControlPlane, ControlTarget,
@@ -402,10 +420,13 @@ async fn run_live(
let client = integrated_client.ok_or_else(|| { let client = integrated_client.ok_or_else(|| {
CliError("--tui requires integrated mode; use --tui-client for split mode".into()) CliError("--tui requires integrated mode; use --tui-client for split mode".into())
})?; })?;
Some(tokio::spawn(metacrate_grid_agent::tui::run_terminal( Some(tokio::spawn(
metacrate_grid_agent::tui::run_terminal_with_preferences(
Arc::new(client), Arc::new(client),
!color_disabled(), !color_disabled(),
))) Some(preferences_path),
),
))
} else { } else {
None None
}; };

View File

@@ -12,13 +12,14 @@ use crate::control_plane::{
SessionMetadataView, TcpControlClient, SessionMetadataView, TcpControlClient,
}; };
use crate::observability::{EventSeverity, MetricsSnapshot, StructuredEvent}; 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 crossterm::{cursor, event, execute, queue, style, terminal};
use std::collections::VecDeque; use std::collections::VecDeque;
use std::fmt; use std::fmt;
use std::future::Future; use std::future::Future;
use std::io::{self, Write}; use std::io::{self, Write};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::PathBuf;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -39,10 +40,11 @@ pub enum OperatorScreen {
Health, Health,
Errors, Errors,
Diagnostics, Diagnostics,
Preferences,
} }
impl OperatorScreen { impl OperatorScreen {
const ALL: [Self; 9] = [ const ALL: [Self; 10] = [
Self::Overview, Self::Overview,
Self::Sessions, Self::Sessions,
Self::QueuesAndBudgets, Self::QueuesAndBudgets,
@@ -52,6 +54,7 @@ impl OperatorScreen {
Self::Health, Self::Health,
Self::Errors, Self::Errors,
Self::Diagnostics, Self::Diagnostics,
Self::Preferences,
]; ];
const fn title(self) -> &'static str { const fn title(self) -> &'static str {
@@ -65,10 +68,246 @@ impl OperatorScreen {
Self::Health => "Health & metrics", Self::Health => "Health & metrics",
Self::Errors => "Recent errors", Self::Errors => "Recent errors",
Self::Diagnostics => "Diagnostics", 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<Self, TuiError> {
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<String>) {
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("<unset>")
),
format!(
"LLM API key: {}",
configured(summary.llm_api_key_configured)
),
format!(
"LLM model: {}",
summary.llm_model.as_deref().unwrap_or("<endpoint default>")
),
format!(
"grid login URL: {}",
summary.grid_login_url.as_deref().unwrap_or("<unset>")
),
format!(
"grid avatar: {}",
summary.grid_avatar_name.as_deref().unwrap_or("<unset>")
),
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)] #[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct EventFilter { pub struct EventFilter {
pub query: String, pub query: String,
@@ -227,6 +466,13 @@ pub enum TuiInput {
Confirm, Confirm,
Reject, Reject,
Quit, Quit,
PreferenceNext,
PreferencePrevious,
PreferenceEditOrCommit,
PreferenceCharacter(char),
PreferenceBackspace,
PreferenceSave,
PreferenceCancel,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -364,6 +610,7 @@ pub struct OperatorTui {
scroll: usize, scroll: usize,
status: String, status: String,
errors: VecDeque<String>, errors: VecDeque<String>,
preferences: Option<PreferencesPanel>,
} }
impl Default for OperatorTui { impl Default for OperatorTui {
@@ -377,11 +624,19 @@ impl Default for OperatorTui {
scroll: 0, scroll: 0,
status: "connecting".into(), status: "connecting".into(),
errors: VecDeque::with_capacity(MAX_ERRORS), errors: VecDeque::with_capacity(MAX_ERRORS),
preferences: None,
} }
} }
} }
impl OperatorTui { impl OperatorTui {
pub fn with_preferences(path: PathBuf) -> Result<Self, TuiError> {
Ok(Self {
preferences: Some(PreferencesPanel::open(path)?),
..Self::default()
})
}
#[must_use] #[must_use]
pub fn command_shortcut(&self, key: char) -> Option<OperatorCommand> { pub fn command_shortcut(&self, key: char) -> Option<OperatorCommand> {
match key { match key {
@@ -456,6 +711,56 @@ impl OperatorTui {
} }
TuiInput::Refresh => TuiAction::Refresh, TuiInput::Refresh => TuiAction::Refresh,
TuiInput::Quit => TuiAction::Exit, 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 => { TuiInput::Command(command) if command.confirmation() == CommandConfirmation::None => {
TuiAction::Execute(command) TuiAction::Execute(command)
} }
@@ -587,6 +892,7 @@ impl OperatorTui {
} }
#[must_use] #[must_use]
#[allow(clippy::too_many_lines)]
pub fn render(&self, options: TuiRenderOptions) -> String { pub fn render(&self, options: TuiRenderOptions) -> String {
let width = usize::from(options.width.max(20)); let width = usize::from(options.width.max(20));
let height = usize::from(options.height.max(5)); 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); let body_height = height.saturating_sub(1);
lines 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::<Vec<_>>()
.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 /// Runs an event-driven keyboard UI. Terminal state is restored on normal
/// return, errors, Ctrl-C, and unwinding because restoration is guard-owned. /// return, errors, Ctrl-C, and unwinding because restoration is guard-owned.
pub async fn run_terminal(client: Arc<dyn TuiTransport>, color: bool) -> Result<(), TuiError> { pub async fn run_terminal(client: Arc<dyn TuiTransport>, 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<dyn TuiTransport>,
color: bool,
preferences_path: Option<PathBuf>,
) -> Result<(), TuiError> {
let _guard = TerminalGuard::enter().map_err(|e| TuiError(e.to_string()))?; let _guard = TerminalGuard::enter().map_err(|e| TuiError(e.to_string()))?;
let (input_tx, mut input_rx) = tokio::sync::mpsc::channel(64); let (input_tx, mut input_rx) = tokio::sync::mpsc::channel(64);
std::thread::spawn(move || { std::thread::spawn(move || {
@@ -808,7 +1189,11 @@ pub async fn run_terminal(client: Arc<dyn TuiTransport>, 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?; app.refresh(client.as_ref()).await?;
let mut refresh = tokio::time::interval(Duration::from_secs(1)); let mut refresh = tokio::time::interval(Duration::from_secs(1));
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
@@ -845,6 +1230,35 @@ pub async fn run_terminal(client: Arc<dyn TuiTransport>, color: bool) -> Result<
}; };
let input = match value { let input = match value {
event::Event::Resize(_, _) => TuiInput::Refresh, 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::Event::Key(key) if key.kind == event::KeyEventKind::Press => match key.code {
event::KeyCode::Tab | event::KeyCode::Right => TuiInput::NextScreen, event::KeyCode::Tab | event::KeyCode::Right => TuiInput::NextScreen,
event::KeyCode::BackTab | event::KeyCode::Left => TuiInput::PreviousScreen, event::KeyCode::BackTab | event::KeyCode::Left => TuiInput::PreviousScreen,

View File

@@ -130,6 +130,7 @@ fn every_screen_renders_without_a_terminal_at_small_unicode_and_mono_sizes() {
OperatorScreen::Health, OperatorScreen::Health,
OperatorScreen::Errors, OperatorScreen::Errors,
OperatorScreen::Diagnostics, OperatorScreen::Diagnostics,
OperatorScreen::Preferences,
] { ] {
while app.screen != screen { while app.screen != screen {
let _ = app.reduce(TuiInput::NextScreen); 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() { fn navigation_wraps_and_every_management_command_has_risk_confirmation() {
let mut app = OperatorTui::default(); let mut app = OperatorTui::default();
assert_eq!(app.reduce(TuiInput::PreviousScreen), TuiAction::None); assert_eq!(app.reduce(TuiInput::PreviousScreen), TuiAction::None);
assert_eq!(app.screen, OperatorScreen::Diagnostics); assert_eq!(app.screen, OperatorScreen::Preferences);
let commands = [ let commands = [
OperatorCommand::Pause, OperatorCommand::Pause,
OperatorCommand::Resume, OperatorCommand::Resume,

View File

@@ -1,9 +1,7 @@
use metacrate_grid_agent::{ use metacrate_grid_agent::{
ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets, ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets,
AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, LiveGridOptIns, AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, run_deterministic_acceptance,
run_deterministic_acceptance,
}; };
use std::collections::BTreeMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH}; 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(); 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] #[test]
fn evidence_writer_rejects_secret_markers_and_schema_is_committed() { fn evidence_writer_rejects_secret_markers_and_schema_is_committed() {
let path = temporary("unsafe.jsonl"); let path = temporary("unsafe.jsonl");

View File

@@ -2,7 +2,7 @@ use std::collections::BTreeSet;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
const ALLOWED_DEPENDENCIES: [&str; 17] = [ const ALLOWED_DEPENDENCIES: [&str; 18] = [
"base64", "base64",
"crossterm", "crossterm",
"libremetaverse", "libremetaverse",
@@ -15,6 +15,7 @@ const ALLOWED_DEPENDENCIES: [&str; 17] = [
"rustls", "rustls",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml_ng",
"sha2", "sha2",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",

View File

@@ -1,5 +1,5 @@
use metacrate_grid_agent::{CONFIG_SCHEMA_VERSION, ConfigLoader}; use metacrate_grid_agent::{CONFIG_SCHEMA_VERSION, ConfigLoader};
use serde_json::Value; use serde_yaml_ng::Value;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; 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() { fn examples_are_versioned_placeholder_only_and_offline_validation_has_no_io_peer() {
let root = workspace(); let root = workspace();
for name in [ for name in [
"grid-agent.example.json", "grid-agent.example.yml",
"grid-agent.integrated.example.json", "grid-agent.integrated.example.yml",
"grid-agent.split.example.json", "grid-agent.split.example.yml",
] { ] {
let bytes = fs::read(root.join("config").join(name)).expect("example"); let bytes = fs::read(root.join("config").join(name)).expect("example");
let value: Value = serde_json::from_slice(&bytes).expect("valid JSON"); let value: Value = serde_yaml_ng::from_slice(&bytes).expect("valid YAML");
assert_eq!(value["schema_version"], CONFIG_SCHEMA_VERSION); assert_eq!(
value["schema_version"].as_u64(),
Some(u64::from(CONFIG_SCHEMA_VERSION))
);
let text = String::from_utf8(bytes).unwrap(); let text = String::from_utf8(bytes).unwrap();
for forbidden in ["Bearer ", "sk-", "password123", "SECRET_CANARY"] { for forbidden in ["Bearer ", "sk-", "password123", "SECRET_CANARY"] {
assert!(!text.contains(forbidden), "{name} contains {forbidden}"); 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(); let config = ConfigLoader::new().with_file(offline).load().unwrap();
assert!(config.grid.is_none()); assert!(config.grid.is_none());
} }
@@ -46,10 +49,11 @@ fn service_and_installers_preserve_state_secrets_and_graceful_shutdown() {
"ProtectSystem=strict", "ProtectSystem=strict",
"NoNewPrivileges=true", "NoNewPrivileges=true",
"ReadWritePaths=/var/lib/metacrate/grid-agent", "ReadWritePaths=/var/lib/metacrate/grid-agent",
"/etc/metacrate/config.yml",
] { ] {
assert!(unit.contains(required), "unit lacks {required}"); 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}"); assert!(!unit.contains(forbidden), "unit embeds {forbidden}");
} }
let shell = fs::read_to_string(root.join("packaging/metacrate-grid-agent/install.sh")).unwrap(); let shell = fs::read_to_string(root.join("packaging/metacrate-grid-agent/install.sh")).unwrap();

View File

@@ -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();
}

View File

@@ -29,7 +29,7 @@ transport failures, visual fallback, and bounded-load cases.
The evidence stages are `configuration_and_bounds`, `headless_startup`, The evidence stages are `configuration_and_bounds`, `headless_startup`,
`control_conformance`, `chat_scheduling`, `clean_shutdown`, `maintenance_reconnect`, and `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 `passed` records and leaves zero service tasks, grid sessions, or loopback
sockets. Headless startup never creates a TUI. Integrated and split UI clients sockets. Headless startup never creates a TUI. Integrated and split UI clients
exercise the same versioned control protocol, commands, event model, and 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 Reference deterministic run on 2026-08-18: all 137 library scenarios, 37
integration scenarios, and one compile-fail documentation case passed with all integration scenarios, and one compile-fail documentation case passed with all
features; the seven-stage evidence command passed with zero leaked tasks, 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 bytes with SHA-256
`b2c93a6acd6fa856669fdb4e08110d700218255f57ec2f7908977eeabea7c470`, `1ccbd11baf69372d76767c419b59319447ecf088bc0ae83ce1640709f6507ad0`,
under the 40-MiB budget. The record identifies the package, pinned Rust toolchain, under the 40-MiB budget. The record identifies the package, pinned Rust toolchain,
source revision when supplied through `METACRATE_SOURCE_COMMIT`, exact generic source revision when supplied through `METACRATE_SOURCE_COMMIT`, exact generic
command, fake/live profile, grid type, endpoint capability profile, timestamp, 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. | | 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. | | 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 32512. | | Any queue | 8,192 items | Matches the hard configuration ceiling; defaults are 32512. |
| 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. | | Journal retention | 1 GiB | Operator-configured segment and total limits remain mandatory. |
Startup, control, reconnect, and shutdown are measured by monotonic time. 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-grid matrix
Live validation is optional and requires a dedicated avatar, controlled land, Live validation uses a dedicated avatar, controlled land, and an
and an operator-supplied OpenAI-compatible endpoint. Credentials alone grant no operator-supplied OpenAI-compatible endpoint configured in the private
consent. Inspect exact confirmations without contacting either service: platform `config.yml`. There are no action-specific environment switches.
Authorization follows the production policy model:
```sh | Origin | Available behavior |
metacrate-grid-agent --check-live-opt-ins
```
Each capability has a separate exact-value environment opt-in:
| Capability | Variable and required literal |
| --- | --- | | --- | --- |
| Login/relogin | `METACRATE_AGENT_LIVE_LOGIN=LOGIN` | | Everyone/public chat | Informational responses and explicitly public-safe tools. |
| Public mention and authorized/unprivileged IM | `METACRATE_AGENT_LIVE_CHAT_IM=CHAT-IM` | | Ordinary IM | Private conversation without privileged mutation. |
| Controlled LSL delivery | `METACRATE_AGENT_LIVE_SCRIPT=SCRIPT` | | UUID listed in `authorized_avatar_uuids` | Policy-gated privileged tools, with approval where required. |
| Landmark offer, teleport, and bounded roaming | `METACRATE_AGENT_LIVE_LANDMARKS=LANDMARKS` | | Local operator/control role | Pause, approve/cancel, reconnect, configuration, and shutdown controls. |
| Reversible prim build and cleanup | `METACRATE_AGENT_LIVE_BUILD=BUILD-CLEANUP` |
| Synthetic visual capture and visual question | `METACRATE_AGENT_LIVE_VISUAL=VISUAL` |
Any action opt-in without the login opt-in fails closed. A misspelled value is Landmarks, scripts, builds, cleanup, and visual questions are capabilities,
false. Store the grid password and endpoint key in restrictive `_FILE` inputs not configuration modes. They remain governed by tool origin, authenticated
described by [the operations guide](grid-agent-operations.md), never in these UUID, land/ownership checks, bounded resources, and approval policy.
variables or a command line.
For an authorized live run, start split mode so another terminal can reconnect 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, 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. exact rollover, facing/attention event, and perception queries.
3. Pause, cancel, approve, resume, and force reconnect from the control client; 3. Pause, cancel, approve, resume, and force reconnect from the control client;
disconnect/reconnect the TUI and verify the service remains headless-safe. 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 the returned inventory ID and permissions. Advanced mutation requires an
explicit approval and conservative script size/runtime limits. 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 object recovery IDs locally, verify no currency operation exists, and delete
every created prim through the ownership-checked cleanup path. every created prim through the ownership-checked cleanup path.
6. With the landmark opt-in, accept a controlled offer, use a short bounded 6. Accept a controlled landmark offer, use a short bounded folder schedule,
folder schedule, teleport, then disable the schedule. With visual opt-in, teleport, then disable the schedule. Capture the synthetic scene and ask
capture the synthetic scene and ask one visual question. Record the endpoint one visual question. Record the endpoint
capability fallback if image input is rejected. capability fallback if image input is rejected.
7. Gracefully stop. Confirm no pending approvals, scheduled jobs, inventory 7. Gracefully stop. Confirm no pending approvals, scheduled jobs, inventory
offers, owned test prims, tasks, sockets, or sessions. List any unavoidable 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 ## Remaining limitations
No public CI runner performs live actions, holds credentials, measures portable No public CI runner performs live actions, holds credentials, measures portable
RSS, or proves a particular provider's image capability. Live evidence is only RSS, or proves a particular endpoint's image capability. Live evidence is only
credible when an operator supplies all exact opt-ins and completes the matrix credible when an operator completes the matrix on a dedicated account. The deterministic gate is therefore the required CI
on a dedicated account. The deterministic gate is therefore the required CI
acceptance record; a live report supplements it and must state any skipped acceptance record; a live report supplements it and must state any skipped
capability, endpoint fallback, or manually recoverable artifact explicitly. capability, endpoint fallback, or manually recoverable artifact explicitly.

View File

@@ -70,7 +70,7 @@ cleanup, fencing old events and late LLM/tool results. See
## Trust boundaries ## 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, oversized files, invalid booleans, conflicting modes, non-HTTP(S) URLs,
URL fragments, malformed/noncanonical/nil UUIDs, wildcard authorization, URL fragments, malformed/noncanonical/nil UUIDs, wildcard authorization,
multiline secrets, and unsafe limits fail before startup. multiline secrets, and unsafe limits fail before startup.

View File

@@ -13,11 +13,11 @@ grid or LLM:
```sh ```sh
cargo build --locked --release -p metacrate-grid-agent 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.yml --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 --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. 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 The example is fake/offline mode (`integrated=false`, `split=false`) and all
credential-looking values are placeholders. credential-looking values are placeholders.
@@ -26,16 +26,16 @@ For live foreground or an embedded TUI, build intentionally:
```sh ```sh
cargo build --locked --release -p metacrate-grid-agent --features live-grid 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/config.yml
target/release/metacrate-grid-agent --config /path/to/grid-agent.json --tui target/release/metacrate-grid-agent --config /path/to/config.yml --tui
``` ```
Set `integrated=true` for the foreground and embedded-TUI commands. Set 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: `split=true` for a headless service, then run the same binary as the TUI client:
```sh ```sh
metacrate-grid-agent --config /path/to/grid-agent.json metacrate-grid-agent --config /path/to/config.yml
metacrate-grid-agent --config /path/to/grid-agent.json --tui-client metacrate-grid-agent --config /path/to/config.yml --tui-client
``` ```
The TUI client and service must use the same control address and operator token. 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 | | 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` | | 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/grid-agent.json` | `$HOME/Library/Application Support/MetaCrate/grid-agent` | | macOS | `$HOME/Library/Application Support/MetaCrate/config.yml` | `$HOME/Library/Application Support/MetaCrate/grid-agent` |
| Windows | `%APPDATA%\MetaCrate\grid-agent.json` | `%LOCALAPPDATA%\MetaCrate\grid-agent` | | Windows | `%APPDATA%\MetaCrate\config.yml` | `%LOCALAPPDATA%\MetaCrate/grid-agent` |
`METACRATE_AGENT_STORAGE_PATH` overrides the state directory. Relative paths in Relative paths in a YAML document resolve relative to that document where specified; service
a JSON document resolve relative to that document where specified; service
deployments should use absolute paths. deployments should use absolute paths.
## Endpoint, grid identity, and authority ## 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 `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 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 provider, or rewrite query parameters. `llm.api_key` is sent as the bearer key
only to that exact origin; redirects are refused. Prefer only to that exact origin; redirects are refused. Set `llm.model` when the
`METACRATE_AGENT_LLM_API_KEY_FILE` over inline JSON or direct environment text. 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 Live modes require `grid.login_url`, `grid.avatar_name`, and `grid.password`.
`METACRATE_AGENT_GRID_PASSWORD_FILE`. `authorized_avatar_uuids` contains exact `authorized_avatar_uuids` contains exact
grid UUIDs, never display names. Text claiming an authorized identity grants no grid UUIDs, never display names. Text claiming an authorized identity grants no
authority. Public chat can request bounded informational work and safe public authority. Public chat can request bounded informational work and safe public
LSL delivery; movement, teleport, building, roaming changes, and administration 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 ## 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 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 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 release notes/example, validate the copy, then atomically select it. Unknown
fields fail closed. Installers never overwrite or migrate operator files. fields fail closed. Installers never overwrite or migrate operator files.
Precedence, lowest to highest, is: Resolution order is:
1. bounded built-in defaults; 1. bounded built-in defaults;
2. JSON configuration; 2. the platform `config.yml` or explicit `--config` document;
3. secret files named by JSON; 3. legacy secret files referenced by that document.
4. environment-referenced secret files;
5. direct environment values. 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 Mode, endpoints, credentials, authorization UUIDs, TLS, storage, queue/resource
limits, reconnect policy, behavior, and interaction settings are restart-only. 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 FAT/network filesystems may not enforce them. Treat an unverifiable filesystem
as unsuitable for unattended secrets. as unsuitable for unattended secrets.
Rotate one credential at a time: write a new restricted file beside the old Rotate one credential at a time in `--preferences` (or an ACL-restricted copy
one, atomically replace or repoint the `_FILE` setting, run `--check-config`, of `config.yml`), save, run `--check-config`, then restart. Revoke the old credential only after readiness. Control observer
then restart. Revoke the old credential only after readiness. Control observer
and operator tokens must differ from each other and from grid/LLM credentials. and operator tokens must differ from each other and from grid/LLM credentials.
## Linux systemd ## Linux systemd
@@ -130,16 +131,15 @@ and operator tokens must differ from each other and from grid/LLM credentials.
The hardened example is The hardened example is
[`../packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service`](../packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service). [`../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 Create the unprivileged `metacrate-agent` identity, copy (do not overwrite) an
operator configuration to `/etc/metacrate/grid-agent.json`, create operator configuration to `/etc/metacrate/config.yml` and create
`/var/lib/metacrate/grid-agent`, and copy the environment-file example. The `/var/lib/metacrate/grid-agent`. Keep the configuration owned by the service
environment file contains only secret *paths*. Put the actual secret files in identity with mode `0600`; do not put credentials in the unit environment.
`/etc/metacrate/secrets`, owned by the service identity with mode `0600`.
Install the unit, inspect the sandbox, validate, then start: Install the unit, inspect the sandbox, validate, then start:
```sh ```sh
sudo systemd-analyze security packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service 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 daemon-reload
sudo systemctl enable --now metacrate-grid-agent.service sudo systemctl enable --now metacrate-grid-agent.service
``` ```
@@ -150,19 +150,18 @@ writes only under the state directory.
## Windows service operation ## 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: foreground operation in PowerShell is the baseline:
```powershell ```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\config.yml' --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'
``` ```
For unattended use, configure a maintained Windows service wrapper (for For unattended use, configure a maintained Windows service wrapper (for
example WinSW) to launch exactly that command as a dedicated low-privilege 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. 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 Keep `config.yml` ACL-restricted to the service identity. Configure restart-on-failure, not unconditional rapid
the wrapper environment. Configure restart-on-failure, not unconditional rapid
restart. Validate as the service identity before registration. The wrapper must restart. Validate as the service identity before registration. The wrapper must
not capture environment values or command output into a world-readable log. not capture environment values or command output into a world-readable log.
The supplied PowerShell installer replaces only the executable and never state. The supplied PowerShell installer replaces only the executable and never state.

View File

@@ -8,21 +8,29 @@ Run an embedded UI with a live-grid integrated configuration:
```sh ```sh
cargo run --locked -p metacrate-grid-agent --features live-grid -- \ 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: Run a separate UI against a loopback split service:
```sh ```sh
cargo run --locked -p metacrate-grid-agent -- \ 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 The split client reads its operator capability from the ACL-restricted
or environment configuration. It never displays or copies that value. Remote `config.yml`. It never displays or copies that value. Remote
TLS clients must embed `TuiTransport` with their explicitly configured trust TLS clients must embed `TuiTransport` with their explicitly configured trust
roots; the command-line client intentionally accepts loopback TCP only. 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 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 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 approval; `x` cancels the newest visible action; `e` expires the first visible

View File

@@ -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

View File

@@ -8,9 +8,8 @@ After=network-online.target
Type=simple Type=simple
User=metacrate-agent User=metacrate-agent
Group=metacrate-agent Group=metacrate-agent
EnvironmentFile=-/etc/metacrate/grid-agent.env ExecStartPre=/usr/local/bin/metacrate-grid-agent --config /etc/metacrate/config.yml --check-config
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/config.yml
ExecStart=/usr/local/bin/metacrate-grid-agent --config /etc/metacrate/grid-agent.json
KillSignal=SIGINT KillSignal=SIGINT
TimeoutStopSec=30s TimeoutStopSec=30s
Restart=on-failure Restart=on-failure