fix(grid-agent): move setup into YAML preferences (#135)
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
110
crates/metacrate-grid-agent/tests/preferences_config.rs
Normal file
110
crates/metacrate-grid-agent/tests/preferences_config.rs
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user