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

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

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
notifications, and logout instead of adding a protocol client.
The LLM connection identity has exactly two resolved fields:
`llm.endpoint_url` and `llm.api_key`. The endpoint is used exactly as supplied;
The LLM connection identity has an exact `llm.endpoint_url`, `llm.api_key`, and
optional `llm.model`. The endpoint is used exactly as supplied;
there are no providers, presets, base-URL rewrites, model catalogs, discovery,
or provider SDKs. `Debug`/`Display` output removes API keys, grid passwords, URL
user information, and URL query values. Secret wrappers are not serializable.
Configuration precedence, from lowest to highest, is built-in defaults, an
optional JSON file, its referenced secret files, then environment values (an
environment-referenced secret file is below a direct environment secret).
Supported secret environment variables are
`METACRATE_AGENT_LLM_API_KEY[_FILE]` and
`METACRATE_AGENT_GRID_PASSWORD[_FILE]`. Split control uses the separate
`METACRATE_AGENT_CONTROL_OPERATOR_TOKEN[_FILE]` and optional
`METACRATE_AGENT_CONTROL_OBSERVER_TOKEN[_FILE]`, plus
`METACRATE_AGENT_CONTROL_LISTEN`. Secret files must be bounded regular,
non-symlink UTF-8 files containing one line. Operators must restrict their OS
ACLs to the service identity; the core uses only portable `std::fs` checks and
does not assume Unix permission bits.
Persistent setup lives in the platform `config.yml` (normally
`~/.config/metacrate/config.yml` on Linux). The file contains grid/AI
connectivity and the exact privileged avatar UUID list; it is written with mode
0600 on Unix. `--preferences` opens the setup panel without starting the
service. `--import-env .env` performs a one-time import of the legacy
`GRID_*`/`OPENAPI_*` values; runtime configuration does not read `.env` or
connection environment variables. JSON files and referenced secret files are
accepted only as a legacy migration input.
Run the focused offline gate with:
@@ -36,9 +32,9 @@ Run the focused offline gate with:
cargo test --locked -p metacrate-grid-agent
cargo clippy --locked -p metacrate-grid-agent --all-targets -- -D warnings
cargo run --locked -p metacrate-grid-agent -- \
--config config/grid-agent.example.json --check-config
--config config/grid-agent.example.yml --check-config
cargo run --locked -p metacrate-grid-agent -- \
--config config/grid-agent.example.json --run-once
--config config/grid-agent.example.yml --run-once
```
See [`../../docs/grid-agent-architecture.md`](../../docs/grid-agent-architecture.md)
@@ -68,6 +64,6 @@ The complete quick start, platform paths, systemd/Windows service operation,
secret rotation, backup/upgrade/rollback, failure playbooks, resource defaults,
and unsupported-operation list are in
[`../../docs/grid-agent-operations.md`](../../docs/grid-agent-operations.md).
Milestone acceptance, resource budgets, evidence, and opt-in live validation
Milestone acceptance, resource budgets, evidence, and live-grid validation
are defined in
[`../../docs/grid-agent-acceptance.md`](../../docs/grid-agent-acceptance.md).

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)]
@@ -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;
impl InteractionResponder for AcceptanceResponder {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,13 +12,14 @@ use crate::control_plane::{
SessionMetadataView, TcpControlClient,
};
use crate::observability::{EventSeverity, MetricsSnapshot, StructuredEvent};
use crate::{ControlLimits, SecretString};
use crate::{AgentPreferences, ControlLimits, OperatingMode, PreferencesSummary, SecretString};
use crossterm::{cursor, event, execute, queue, style, terminal};
use std::collections::VecDeque;
use std::fmt;
use std::future::Future;
use std::io::{self, Write};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
@@ -39,10 +40,11 @@ pub enum OperatorScreen {
Health,
Errors,
Diagnostics,
Preferences,
}
impl OperatorScreen {
const ALL: [Self; 9] = [
const ALL: [Self; 10] = [
Self::Overview,
Self::Sessions,
Self::QueuesAndBudgets,
@@ -52,6 +54,7 @@ impl OperatorScreen {
Self::Health,
Self::Errors,
Self::Diagnostics,
Self::Preferences,
];
const fn title(self) -> &'static str {
@@ -65,10 +68,246 @@ impl OperatorScreen {
Self::Health => "Health & metrics",
Self::Errors => "Recent errors",
Self::Diagnostics => "Diagnostics",
Self::Preferences => "Preferences",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PreferenceField {
Mode,
LlmEndpoint,
LlmApiKey,
LlmModel,
GridLoginUrl,
GridAvatarName,
GridPassword,
PrivilegedUsers,
}
impl PreferenceField {
const ALL: [Self; 8] = [
Self::Mode,
Self::LlmEndpoint,
Self::LlmApiKey,
Self::LlmModel,
Self::GridLoginUrl,
Self::GridAvatarName,
Self::GridPassword,
Self::PrivilegedUsers,
];
const fn secret(self) -> bool {
matches!(self, Self::LlmApiKey | Self::GridPassword)
}
}
pub struct PreferencesPanel {
path: PathBuf,
preferences: AgentPreferences,
selected: usize,
editing: bool,
buffer: String,
status: String,
}
impl fmt::Debug for PreferencesPanel {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PreferencesPanel")
.field("path", &self.path)
.field("selected", &self.selected)
.field("editing", &self.editing)
.field("buffer_bytes", &self.buffer.len())
.finish_non_exhaustive()
}
}
impl PreferencesPanel {
pub fn open(path: PathBuf) -> Result<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)]
pub struct EventFilter {
pub query: String,
@@ -227,6 +466,13 @@ pub enum TuiInput {
Confirm,
Reject,
Quit,
PreferenceNext,
PreferencePrevious,
PreferenceEditOrCommit,
PreferenceCharacter(char),
PreferenceBackspace,
PreferenceSave,
PreferenceCancel,
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -364,6 +610,7 @@ pub struct OperatorTui {
scroll: usize,
status: String,
errors: VecDeque<String>,
preferences: Option<PreferencesPanel>,
}
impl Default for OperatorTui {
@@ -377,11 +624,19 @@ impl Default for OperatorTui {
scroll: 0,
status: "connecting".into(),
errors: VecDeque::with_capacity(MAX_ERRORS),
preferences: None,
}
}
}
impl OperatorTui {
pub fn with_preferences(path: PathBuf) -> Result<Self, TuiError> {
Ok(Self {
preferences: Some(PreferencesPanel::open(path)?),
..Self::default()
})
}
#[must_use]
pub fn command_shortcut(&self, key: char) -> Option<OperatorCommand> {
match key {
@@ -456,6 +711,56 @@ impl OperatorTui {
}
TuiInput::Refresh => TuiAction::Refresh,
TuiInput::Quit => TuiAction::Exit,
TuiInput::PreferenceNext => {
if let Some(panel) = &mut self.preferences {
panel.next_field();
}
TuiAction::None
}
TuiInput::PreferencePrevious => {
if let Some(panel) = &mut self.preferences {
panel.previous_field();
}
TuiAction::None
}
TuiInput::PreferenceEditOrCommit => {
if let Some(panel) = &mut self.preferences {
if panel.editing {
if let Err(error) = panel.commit_edit() {
self.record_error(error.to_string());
}
} else {
panel.begin_edit();
}
}
TuiAction::None
}
TuiInput::PreferenceCharacter(value) => {
if let Some(panel) = &mut self.preferences {
panel.push(value);
}
TuiAction::None
}
TuiInput::PreferenceBackspace => {
if let Some(panel) = &mut self.preferences {
panel.backspace();
}
TuiAction::None
}
TuiInput::PreferenceSave => {
if let Some(panel) = &mut self.preferences
&& let Err(error) = panel.save()
{
self.record_error(error.to_string());
}
TuiAction::None
}
TuiInput::PreferenceCancel => {
if let Some(panel) = &mut self.preferences {
panel.cancel_edit();
}
TuiAction::None
}
TuiInput::Command(command) if command.confirmation() == CommandConfirmation::None => {
TuiAction::Execute(command)
}
@@ -587,6 +892,7 @@ impl OperatorTui {
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn render(&self, options: TuiRenderOptions) -> String {
let width = usize::from(options.width.max(20));
let height = usize::from(options.height.max(5));
@@ -673,6 +979,13 @@ impl OperatorTui {
));
}
}
OperatorScreen::Preferences => {
if let Some(panel) = &self.preferences {
panel.render(&mut lines);
} else {
lines.push("preferences unavailable: no local config path".into());
}
}
}
let body_height = height.saturating_sub(1);
lines
@@ -796,9 +1109,77 @@ impl Drop for TerminalGuard {
}
}
/// Opens the local configuration preferences panel without starting or
/// connecting to the agent service. Secret fields are rendered as bullets.
pub fn run_preferences_terminal(path: PathBuf, _color: bool) -> Result<(), TuiError> {
let _guard = TerminalGuard::enter().map_err(|error| TuiError(error.to_string()))?;
let mut panel = PreferencesPanel::open(path)?;
loop {
let (width, height) = terminal::size().unwrap_or((80, 24));
let mut lines = vec!["MetaCrate agent | Preferences".to_owned()];
panel.render(&mut lines);
let text = lines
.into_iter()
.take(usize::from(height.max(5)))
.map(|line| truncate_width(&line, usize::from(width.max(20))))
.collect::<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
/// return, errors, Ctrl-C, and unwinding because restoration is guard-owned.
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 (input_tx, mut input_rx) = tokio::sync::mpsc::channel(64);
std::thread::spawn(move || {
@@ -808,7 +1189,11 @@ pub async fn run_terminal(client: Arc<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?;
let mut refresh = tokio::time::interval(Duration::from_secs(1));
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
@@ -845,6 +1230,35 @@ pub async fn run_terminal(client: Arc<dyn TuiTransport>, color: bool) -> Result<
};
let input = match value {
event::Event::Resize(_, _) => TuiInput::Refresh,
event::Event::Key(key)
if key.kind == event::KeyEventKind::Press
&& app.screen == OperatorScreen::Preferences
&& app.preferences.as_ref().is_some_and(|panel| panel.editing) =>
{
match key.code {
event::KeyCode::Enter => TuiInput::PreferenceEditOrCommit,
event::KeyCode::Backspace => TuiInput::PreferenceBackspace,
event::KeyCode::Esc => TuiInput::PreferenceCancel,
event::KeyCode::Char(value) => TuiInput::PreferenceCharacter(value),
_ => continue,
}
}
event::Event::Key(key)
if key.kind == event::KeyEventKind::Press
&& app.screen == OperatorScreen::Preferences =>
{
match key.code {
event::KeyCode::Up => TuiInput::PreferencePrevious,
event::KeyCode::Down => TuiInput::PreferenceNext,
event::KeyCode::Enter => TuiInput::PreferenceEditOrCommit,
event::KeyCode::Char('w') => TuiInput::PreferenceSave,
event::KeyCode::Esc => TuiInput::PreferenceCancel,
event::KeyCode::Tab | event::KeyCode::Right => TuiInput::NextScreen,
event::KeyCode::BackTab | event::KeyCode::Left => TuiInput::PreviousScreen,
event::KeyCode::Char('q') => TuiInput::Quit,
_ => continue,
}
}
event::Event::Key(key) if key.kind == event::KeyEventKind::Press => match key.code {
event::KeyCode::Tab | event::KeyCode::Right => TuiInput::NextScreen,
event::KeyCode::BackTab | event::KeyCode::Left => TuiInput::PreviousScreen,

View File

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

View File

@@ -1,9 +1,7 @@
use metacrate_grid_agent::{
ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets,
AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, LiveGridOptIns,
run_deterministic_acceptance,
AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, run_deterministic_acceptance,
};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -62,23 +60,6 @@ async fn deterministic_gate_writes_ordered_redacted_evidence_and_drains() {
fs::remove_file(path).unwrap();
}
#[test]
fn live_actions_require_exact_independent_confirmations() {
let mut values = BTreeMap::new();
values.insert("METACRATE_AGENT_LIVE_CHAT_IM", "CHAT-IM");
let action_without_login =
LiveGridOptIns::from_environment(|name| values.get(name).map(ToString::to_string));
assert!(action_without_login.validate().is_err());
values.insert("METACRATE_AGENT_LIVE_LOGIN", "LOGIN");
values.insert("METACRATE_AGENT_LIVE_BUILD", "wrong");
let confirmed =
LiveGridOptIns::from_environment(|name| values.get(name).map(ToString::to_string))
.validate()
.unwrap();
assert!(confirmed.login && confirmed.chat_and_im);
assert!(!confirmed.reversible_build);
}
#[test]
fn evidence_writer_rejects_secret_markers_and_schema_is_committed() {
let path = temporary("unsafe.jsonl");

View File

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

View File

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

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