diff --git a/config/grid-agent.example.json b/config/grid-agent.example.json index 78df48b..7e28042 100644 --- a/config/grid-agent.example.json +++ b/config/grid-agent.example.json @@ -1,4 +1,5 @@ { + "schema_version": 1, "integrated": false, "split": false, "llm": { diff --git a/config/grid-agent.integrated.example.json b/config/grid-agent.integrated.example.json new file mode 100644 index 0000000..80a46a6 --- /dev/null +++ b/config/grid-agent.integrated.example.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "integrated": true, + "split": false, + "llm": { + "endpoint_url": "https://llm.example.invalid/v1/chat/completions" + }, + "grid": { + "login_url": "https://grid.example.invalid/login", + "avatar_name": "Service Avatar" + }, + "secret_files": { + "llm_api_key": "secrets/llm-api-key", + "grid_password": "secrets/grid-password" + }, + "authorized_avatar_uuids": [ + "00000000-0000-4000-8000-000000000001" + ], + "storage_path": "data/grid-agent" +} diff --git a/config/grid-agent.split.example.json b/config/grid-agent.split.example.json new file mode 100644 index 0000000..63c70d7 --- /dev/null +++ b/config/grid-agent.split.example.json @@ -0,0 +1,25 @@ +{ + "schema_version": 1, + "integrated": false, + "split": true, + "llm": { + "endpoint_url": "https://llm.example.invalid/v1/chat/completions" + }, + "grid": { + "login_url": "https://grid.example.invalid/login", + "avatar_name": "Service Avatar" + }, + "secret_files": { + "llm_api_key": "secrets/llm-api-key", + "grid_password": "secrets/grid-password", + "control_operator_token": "secrets/control-operator-token", + "control_observer_token": "secrets/control-observer-token" + }, + "authorized_avatar_uuids": [ + "00000000-0000-4000-8000-000000000001" + ], + "storage_path": "data/grid-agent", + "control": { + "listen": "127.0.0.1:9764" + } +} diff --git a/crates/metacrate-grid-agent/README.md b/crates/metacrate-grid-agent/README.md index 7fdbf86..84b945f 100644 --- a/crates/metacrate-grid-agent/README.md +++ b/crates/metacrate-grid-agent/README.md @@ -64,3 +64,7 @@ contract are specified in The embedded/split cross-platform operator interface, keyboard controls, privacy boundary, and focused gates are documented in [`../../docs/grid-agent-tui.md`](../../docs/grid-agent-tui.md). +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). diff --git a/crates/metacrate-grid-agent/src/config.rs b/crates/metacrate-grid-agent/src/config.rs index 383ed6a..478c7d4 100644 --- a/crates/metacrate-grid-agent/src/config.rs +++ b/crates/metacrate-grid-agent/src/config.rs @@ -19,6 +19,7 @@ const MAX_TLS_DER_BYTES: u64 = 1024 * 1024; const MAX_AUTHORIZED_AVATARS: usize = 1_024; 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"; @@ -30,6 +31,7 @@ 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"; @@ -431,6 +433,57 @@ pub trait Environment { fn get(&self, name: &str) -> Option; } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlatformPaths { + pub config_file: PathBuf, + pub data_directory: PathBuf, +} + +impl PlatformPaths { + #[must_use] + pub fn discover() -> Self { + Self::from_environment(&StdEnvironment) + } + + #[must_use] + pub fn from_environment(environment: &impl Environment) -> Self { + if cfg!(windows) { + let config = environment + .get("APPDATA") + .map_or_else(|| PathBuf::from("config"), PathBuf::from); + let data = environment + .get("LOCALAPPDATA") + .map_or_else(|| PathBuf::from("data"), PathBuf::from); + return Self { + config_file: config.join("MetaCrate/grid-agent.json"), + data_directory: data.join("MetaCrate/grid-agent"), + }; + } + if cfg!(target_os = "macos") { + let home = environment + .get("HOME") + .map_or_else(|| PathBuf::from("."), PathBuf::from); + return Self { + config_file: home.join("Library/Application Support/MetaCrate/grid-agent.json"), + data_directory: home.join("Library/Application Support/MetaCrate/grid-agent"), + }; + } + let home = environment + .get("HOME") + .map_or_else(|| PathBuf::from("."), PathBuf::from); + let config = environment + .get("XDG_CONFIG_HOME") + .map_or_else(|| home.join(".config"), PathBuf::from); + let data = environment + .get("XDG_DATA_HOME") + .map_or_else(|| home.join(".local/share"), PathBuf::from); + Self { + config_file: config.join("metacrate/grid-agent.json"), + data_directory: data.join("metacrate/grid-agent"), + } + } +} + #[derive(Clone, Copy, Debug, Default)] pub struct StdEnvironment; @@ -553,6 +606,10 @@ pub enum ConfigError { path: PathBuf, reason: String, }, + UnsupportedSchemaVersion { + found: u32, + supported: u32, + }, Missing { field: &'static str, required_for: &'static str, @@ -606,6 +663,10 @@ impl fmt::Display for ConfigError { path.display() ) } + Self::UnsupportedSchemaVersion { found, supported } => write!( + formatter, + "configuration schema version {found} is unsupported; this binary accepts version {supported}; migrate a copy before replacing the active configuration" + ), Self::Missing { field, required_for, @@ -652,6 +713,7 @@ impl Error for ConfigError {} #[derive(Clone, Default, Deserialize)] #[serde(default, deny_unknown_fields)] struct FileConfig { + schema_version: Option, integrated: Option, split: Option, llm: RawLlm, @@ -801,6 +863,14 @@ fn resolve( environment: &E, config_path: Option<&Path>, ) -> Result { + if let Some(found) = raw.schema_version + && found != CONFIG_SCHEMA_VERSION + { + return Err(ConfigError::UnsupportedSchemaVersion { + found, + supported: CONFIG_SCHEMA_VERSION, + }); + } let integrated = environment_boolean(environment, ENV_INTEGRATED)? .or(raw.integrated) .unwrap_or(false); @@ -1116,9 +1186,11 @@ fn resolve( authorized_avatar_uuids, timeouts, limits, - storage_path: raw - .storage_path - .unwrap_or_else(|| PathBuf::from("data/grid-agent")), + storage_path: environment + .get(ENV_STORAGE_PATH) + .map(PathBuf::from) + .or(raw.storage_path) + .unwrap_or_else(|| PlatformPaths::from_environment(environment).data_directory), behavior: BehaviorSettings { heartbeat: checked_duration( "behavior.heartbeat_seconds", @@ -1272,6 +1344,21 @@ fn resolve_path(base: &Path, path: &Path) -> PathBuf { } fn read_secret(field: &'static str, path: &Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + let metadata = fs::symlink_metadata(path).map_err(|error| ConfigError::Io { + field, + path: path.to_owned(), + reason: error.to_string(), + })?; + if metadata.mode() & 0o077 != 0 { + return Err(ConfigError::InvalidSecret { + field, + reason: "secret file must not grant group or other permissions (use mode 0600 or stricter)", + }); + } + } let bytes = read_bounded_regular_file(field, path, MAX_SECRET_BYTES)?; let value = String::from_utf8(bytes).map_err(|_| ConfigError::InvalidSecret { field, @@ -1462,6 +1549,12 @@ mod tests { let mut file = fs::File::create(&path).expect("create test file"); file.write_all(contents.as_bytes()) .expect("write test file"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) + .expect("restrict test file"); + } path } @@ -1527,6 +1620,50 @@ mod tests { let _ = fs::remove_file(config_path); } + #[test] + fn schema_storage_precedence_and_platform_paths_are_explicit() { + let unsupported = temporary_file( + "unsupported-schema.json", + r#"{"schema_version":2,"llm":{"endpoint_url":"https://llm.invalid/chat","api_key":"placeholder"}}"#, + ); + assert!(matches!( + ConfigLoader::new() + .with_file(&unsupported) + .with_environment(MapEnvironment::default()) + .load(), + Err(ConfigError::UnsupportedSchemaVersion { + found: 2, + 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 config = ConfigLoader::new() + .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.data_directory.ends_with("grid-agent")); + } + + #[cfg(unix)] + #[test] + fn group_readable_secret_files_fail_closed() { + use std::os::unix::fs::PermissionsExt as _; + let secret = temporary_file("insecure-secret", "secret"); + fs::set_permissions(&secret, fs::Permissions::from_mode(0o640)).unwrap(); + assert!(matches!( + read_secret("test.secret", &secret), + Err(ConfigError::InvalidSecret { .. }) + )); + } + #[test] fn endpoint_debug_redacts_credentials_and_query() { let endpoint = EndpointUrl::parse( diff --git a/crates/metacrate-grid-agent/src/lib.rs b/crates/metacrate-grid-agent/src/lib.rs index 4308c6e..ea1cdcf 100644 --- a/crates/metacrate-grid-agent/src/lib.rs +++ b/crates/metacrate-grid-agent/src/lib.rs @@ -79,9 +79,10 @@ pub use build::{ BuildService, BuildShape, BuildToolBackend, BuildValidation, PrimReceipt, build_policy_tools, }; pub use config::{ - AgentConfig, BehaviorSettings, ConfigError, ConfigLoader, ControlSettings, - ConversationSettings, EndpointUrl, Environment, GridConnection, Limits, LlmConnection, - MapEnvironment, OperatingMode, RemoteTlsSettings, SecretString, StdEnvironment, Timeouts, + AgentConfig, BehaviorSettings, CONFIG_SCHEMA_VERSION, ConfigError, ConfigLoader, + ControlSettings, ConversationSettings, EndpointUrl, Environment, GridConnection, Limits, + LlmConnection, MapEnvironment, OperatingMode, PlatformPaths, RemoteTlsSettings, SecretString, + StdEnvironment, Timeouts, }; pub use control_plane::{ AuditEventView, CONTROL_PROTOCOL_VERSION, ControlContext, ControlError, ControlErrorBody, diff --git a/crates/metacrate-grid-agent/src/main.rs b/crates/metacrate-grid-agent/src/main.rs index bdd9a12..a84c767 100644 --- a/crates/metacrate-grid-agent/src/main.rs +++ b/crates/metacrate-grid-agent/src/main.rs @@ -35,6 +35,7 @@ enum Operation { RunOnce, Tui, TuiClient, + PrintPaths, } #[derive(Default)] @@ -56,10 +57,14 @@ fn options() -> Result, CliError> { } if argument == "--help" || argument == "-h" { println!( - "metacrate-grid-agent [--config PATH] [--check-config | --run-once | --tui | --tui-client]\n\ - Configuration precedence: defaults < JSON < secret files < environment." + "metacrate-grid-agent [--config PATH] [--check-config | --run-once | --tui | --tui-client | --print-paths]\n\ + Configuration precedence: defaults < JSON < secret files < environment.\n\ + With no --config, the platform default is used when it exists." ); return Ok(None); + } else if argument == "--version" || argument == "-V" { + println!("metacrate-grid-agent {}", env!("CARGO_PKG_VERSION")); + return Ok(None); } if argument == "--check-config" { set_operation(&mut result, Operation::CheckConfig)?; @@ -69,6 +74,8 @@ fn options() -> Result, CliError> { set_operation(&mut result, Operation::Tui)?; } else if argument == "--tui-client" { set_operation(&mut result, Operation::TuiClient)?; + } else if argument == "--print-paths" { + set_operation(&mut result, Operation::PrintPaths)?; } else if argument == "--config" { let path = arguments .next() @@ -99,8 +106,20 @@ async fn main() -> Result<(), Box> { let Some(options) = options()? else { return Ok(()); }; + let platform_paths = metacrate_grid_agent::PlatformPaths::discover(); + if options.operation == Operation::PrintPaths { + println!("config={}", platform_paths.config_file.display()); + println!("data={}", platform_paths.data_directory.display()); + return Ok(()); + } let mut loader = ConfigLoader::new(); - if let Some(path) = options.config { + 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); } let config = loader.load()?; diff --git a/crates/metacrate-grid-agent/tests/operations_packaging.rs b/crates/metacrate-grid-agent/tests/operations_packaging.rs new file mode 100644 index 0000000..0965812 --- /dev/null +++ b/crates/metacrate-grid-agent/tests/operations_packaging.rs @@ -0,0 +1,103 @@ +use metacrate_grid_agent::{CONFIG_SCHEMA_VERSION, ConfigLoader}; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +fn workspace() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace") + .to_path_buf() +} + +#[test] +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", + ] { + 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 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 config = ConfigLoader::new().with_file(offline).load().unwrap(); + assert!(config.grid.is_none()); +} + +#[test] +fn service_and_installers_preserve_state_secrets_and_graceful_shutdown() { + let root = workspace(); + let unit = fs::read_to_string( + root.join("packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service"), + ) + .unwrap(); + for required in [ + "ExecStartPre=", + "--check-config", + "KillSignal=SIGINT", + "ProtectSystem=strict", + "NoNewPrivileges=true", + "ReadWritePaths=/var/lib/metacrate/grid-agent", + ] { + assert!(unit.contains(required), "unit lacks {required}"); + } + for forbidden in ["API_KEY=", "PASSWORD=", "TOKEN="] { + assert!(!unit.contains(forbidden), "unit embeds {forbidden}"); + } + let shell = fs::read_to_string(root.join("packaging/metacrate-grid-agent/install.sh")).unwrap(); + let powershell = + fs::read_to_string(root.join("packaging/metacrate-grid-agent/install.ps1")).unwrap(); + assert!(shell.contains("metacrate-grid-agent.new")); + assert!(powershell.contains("metacrate-grid-agent.new.exe")); + for forbidden in ["grid-agent.json\"", "conversations/", "audit/", "secrets/"] { + assert!(!shell.contains(forbidden)); + assert!(!powershell.contains(forbidden)); + } +} + +#[test] +fn runbook_release_evidence_and_gitea_platform_policy_are_complete() { + let root = workspace(); + let runbook = fs::read_to_string(root.join("docs/grid-agent-operations.md")).unwrap(); + for required in [ + "Quick start", + "Windows service operation", + "Linux systemd", + "Threat", + "privacy", + "Resource defaults", + "Failure playbooks", + "unsupported", + "upgrade", + "rollback", + "orphan", + "rotation", + "firewall", + "readiness", + ] { + assert!( + runbook.to_lowercase().contains(&required.to_lowercase()), + "runbook lacks {required}" + ); + } + let evidence = fs::read_to_string(root.join("docs/grid-agent-release-evidence.md")).unwrap(); + assert!(evidence.contains("28,658,000 bytes")); + assert!(evidence.contains("192 unique Cargo")); + assert!(evidence.contains("no CLR/Mono/.NET")); + for workflow in ["ci.yml", "release.yml"] { + let text = fs::read_to_string(root.join(".gitea/workflows").join(workflow)).unwrap(); + assert!( + text.lines() + .filter(|line| line.contains("runs-on:")) + .all(|line| line.contains("ubuntu-latest")) + ); + } +} diff --git a/docs/grid-agent-operations.md b/docs/grid-agent-operations.md new file mode 100644 index 0000000..9b32856 --- /dev/null +++ b/docs/grid-agent-operations.md @@ -0,0 +1,262 @@ +# MetaCrate grid-agent operations + +This is the operator runbook for the portable `metacrate-grid-agent` binary. +The binary has no dependency on systemd, a Windows service manager, a CLR, +Python, Node.js, a provider SDK, or a shell at runtime. Linux service files and +install helpers are packaging conveniences around the same foreground process +and versioned control protocol. + +## Quick start and modes + +Build the credential-free binary and validate the example without contacting a +grid or LLM: + +```sh +cargo build --locked --release -p metacrate-grid-agent +target/release/metacrate-grid-agent --config config/grid-agent.example.json --check-config +target/release/metacrate-grid-agent --config config/grid-agent.example.json --run-once +``` + +`--check-config` parses files, resolves secret files/environment, validates all +bounds and TLS files, then exits before constructing the grid or LLM clients. +The example is fake/offline mode (`integrated=false`, `split=false`) and all +credential-looking values are placeholders. + +For live foreground or an embedded TUI, build intentionally: + +```sh +cargo build --locked --release -p metacrate-grid-agent --features live-grid +target/release/metacrate-grid-agent --config /path/to/grid-agent.json +target/release/metacrate-grid-agent --config /path/to/grid-agent.json --tui +``` + +Set `integrated=true` for the foreground and embedded-TUI commands. Set +`split=true` for a headless service, then run the same binary as the TUI client: + +```sh +metacrate-grid-agent --config /path/to/grid-agent.json +metacrate-grid-agent --config /path/to/grid-agent.json --tui-client +``` + +The TUI client and service must use the same control address and operator token. +`--tui-client` never needs the grid password or LLM key. `--run-once` performs +one supervised readiness/login and clean logout cycle. `--print-paths` prints +the platform defaults without loading configuration. If `--config` is omitted, +the default file is used only when it already exists: + +| Platform | Configuration | Non-secret state | +|---|---|---| +| Linux/Unix | `$XDG_CONFIG_HOME/metacrate/grid-agent.json`, otherwise `$HOME/.config/metacrate/grid-agent.json` | `$XDG_DATA_HOME/metacrate/grid-agent`, otherwise `$HOME/.local/share/metacrate/grid-agent` | +| macOS | `$HOME/Library/Application Support/MetaCrate/grid-agent.json` | `$HOME/Library/Application Support/MetaCrate/grid-agent` | +| Windows | `%APPDATA%\MetaCrate\grid-agent.json` | `%LOCALAPPDATA%\MetaCrate\grid-agent` | + +`METACRATE_AGENT_STORAGE_PATH` overrides the state directory. Relative paths in +a JSON document resolve relative to that document where specified; service +deployments should use absolute paths. + +## Endpoint, grid identity, and authority + +`llm.endpoint_url` is the exact OpenAI-compatible chat-completions URL. It is +not a base URL: MetaCrate does not append a path, discover models, select a +provider, or rewrite query parameters. `llm.api_key` is sent as the bearer key +only to that exact origin; redirects are refused. Prefer +`METACRATE_AGENT_LLM_API_KEY_FILE` over inline JSON or direct environment text. + +Live modes require `grid.login_url`, `grid.avatar_name`, and a password from +`METACRATE_AGENT_GRID_PASSWORD_FILE`. `authorized_avatar_uuids` contains exact +grid UUIDs, never display names. Text claiming an authorized identity grants no +authority. Public chat can request bounded informational work and safe public +LSL delivery; movement, teleport, building, roaming changes, and administration +remain policy-gated and require an authenticated authorized IM, operator action, +or a narrowly bound scheduler grant as documented in the policy matrix. + +## Configuration contract and migration + +The JSON root uses `"schema_version": 1`. Omitting it is accepted as legacy +version 1. Any other value fails before startup with a migration message. There +is no automatic in-place migration: copy the file, update the copy using the +release notes/example, validate the copy, then atomically select it. Unknown +fields fail closed. Installers never overwrite or migrate operator files. + +Precedence, lowest to highest, is: + +1. bounded built-in defaults; +2. JSON configuration; +3. secret files named by JSON; +4. environment-referenced secret files; +5. direct environment values. + +Mode, endpoints, credentials, authorization UUIDs, TLS, storage, queue/resource +limits, reconnect policy, behavior, and interaction settings are restart-only. +Runtime control can pause/resume autonomy, toggle the roaming job, decide an +existing approval, cancel an active action, expire/delete conversation state, +inject an operator message, reconnect, or shut down; it does not silently +rewrite the configuration. A future reloadable field must be explicitly added +to the versioned control/config contract. + +## Secrets and privacy + +Threat model: grid residents, object/avatar/inventory metadata, capability +replies, model output, public chat, IM text, and persisted files are untrusted. +Remote peers may inject instructions, spoof names, replay approvals/call IDs, +flood queues, delay or truncate replies, and attempt secret exfiltration. The +service identity, local config/secret ACLs, authenticated UUID/control role, +opaque policy authorization, generation fencing, bounded queues, and audit sink +are trust boundaries. Host/root compromise, a malicious binary/dependency, and +an operator deliberately approving a harmful action are outside the process +sandbox and require OS/supply-chain/operational controls. + +Never place API keys, grid passwords, or control tokens in command arguments, +unit files, wrapper XML, logs, crash-report commands, issue reports, or TUI +screens. Secret wrappers redact `Debug` and `Display`, are not serializable, and +normal observability stores pseudonymous correlation IDs, result codes, bounds, +and hashes—not prompt bodies, credentials, visual pixels, or raw tool arguments. + +Secret files must be regular, non-symlink, bounded UTF-8 files containing one +line. On Unix, startup rejects group/other permission bits; use mode `0600` or +stricter and ownership by the service identity. On Windows, set an NTFS ACL that +grants only the service identity and Administrators, for example with `icacls`; +Rust's portable metadata API cannot prove arbitrary Windows ACL semantics, and +FAT/network filesystems may not enforce them. Treat an unverifiable filesystem +as unsuitable for unattended secrets. + +Rotate one credential at a time: write a new restricted file beside the old +one, atomically replace or repoint the `_FILE` setting, run `--check-config`, +then restart. Revoke the old credential only after readiness. Control observer +and operator tokens must differ from each other and from grid/LLM credentials. + +## Linux systemd + +The hardened example is +[`../packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service`](../packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service). +Create the unprivileged `metacrate-agent` identity, copy (do not overwrite) an +operator configuration to `/etc/metacrate/grid-agent.json`, create +`/var/lib/metacrate/grid-agent`, and copy the environment-file example. The +environment file contains only secret *paths*. Put the actual secret files in +`/etc/metacrate/secrets`, owned by the service identity with mode `0600`. + +Install the unit, inspect the sandbox, validate, then start: + +```sh +sudo systemd-analyze security packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service +sudo -u metacrate-agent /usr/local/bin/metacrate-grid-agent --config /etc/metacrate/grid-agent.json --check-config +sudo systemctl daemon-reload +sudo systemctl enable --now metacrate-grid-agent.service +``` + +The unit uses `SIGINT`, matching foreground Ctrl-C and the owned graceful +shutdown path. Do not replace it with `SIGKILL`. `ProtectSystem=strict` permits +writes only under the state directory. + +## Windows service operation + +Run the same `.exe`, JSON, control TCP protocol, and shutdown path. Portable +foreground operation in PowerShell is the baseline: + +```powershell +& 'C:\Program Files\MetaCrate\metacrate-grid-agent.exe' --config 'C:\ProgramData\MetaCrate\grid-agent.json' --check-config +& 'C:\Program Files\MetaCrate\metacrate-grid-agent.exe' --config 'C:\ProgramData\MetaCrate\grid-agent.json' +``` + +For unattended use, configure a maintained Windows service wrapper (for +example WinSW) to launch exactly that command as a dedicated low-privilege +account and to translate SCM Stop into Ctrl-C/console control before its timeout. +Keep secrets in separate ACL-restricted files and expose only `_FILE` paths in +the wrapper environment. Configure restart-on-failure, not unconditional rapid +restart. Validate as the service identity before registration. The wrapper must +not capture environment values or command output into a world-readable log. +The supplied PowerShell installer replaces only the executable and never state. + +## Control, network, and health + +Split control defaults to loopback TCP. A non-loopback bind is rejected unless +an explicit Rustls certificate/private key is configured. Use host firewalls to +allow only operator networks, protect token files, and prefer loopback plus an +authenticated tunnel. Observer tokens are read-only. Protocol framing, role +permissions, cancellation, event gaps, and limits are in the control-plane doc. + +Readiness is stricter than a TCP connection: the runtime view must show +`transport_connected=true` and `agent_ready=true`. The TUI Overview and +observer Runtime request are health/readiness checks; process existence alone +is not readiness. Alert on authentication-blocked, sustained degraded/backoff, +queue saturation/dropped-event counters, audit failure, orphan builds, and a +stale generation. + +## Retention, backup, upgrade, and rollback + +Conversation snapshots, landmark state, and audit journals are non-secret but +privacy-sensitive. Stop or pause mutation, take a filesystem-consistent backup +of the configured data directory, and encrypt/restrict the backup. Do not back +up secrets with ordinary state. Journal rotation is bounded by the configured +observability policy; ship rotated files to restricted storage before deletion +when retention policy requires it. Replay is diagnostic and never executes an +action. + +Release installation replaces only the binary. The POSIX and PowerShell helpers +stage a temporary executable then move it into place; neither touches config, +secrets, conversations, landmarks, or journals. For upgrade: back up state, +install the new binary, validate the existing config, stop gracefully, start, +and verify readiness. For rollback: stop gracefully, restore the prior binary, +restore state only when the new version changed it incompatibly, validate, and +start. Never run two service generations against one writable data directory. + +## Failure playbooks + +- Authentication blocked: pause retries, verify login URL/avatar and rotate the + password file; never paste it into logs. Force reconnect after correction. +- LLM unavailable/rate limited: autonomy remains bounded; verify the exact URL, + firewall/DNS, and key. Multimodal rejection falls back to the textual scene + summary without resending the large image. +- Maintenance/disconnect: allow generation fencing and bounded backoff. Stale + inference/mutation results are discarded; do not bypass reconnect controls. +- Emergency: use operator Pause first, Cancel the exact active action when + appropriate, then Graceful Shutdown. Ctrl-C/SIGINT follows the same cleanup. +- Orphan build: keep the reported object IDs, inspect ownership in-world, and + manually recover only those IDs. Never bulk-delete by name or proximity. +- Corrupt persistence: preserve the quarantined file for restricted diagnosis; + the service recovers an older valid generation or starts fail-closed. Do not + hand-edit a live journal. +- Full disk/audit backpressure: pause autonomy, free space according to retention + policy, and restart only after the audit path is writable. Policy fails closed + when required audit records cannot be accepted. + +## Resource defaults and unsupported operations + +The example records all current queue, message, conversation, tool, behavior, +reconnect, and interaction defaults. Important defaults include 256 grid events, +32 control commands, 512 observations, four concurrent inference requests, +16 tool calls, 512 active senders/sessions, a 1 MiB transport body, and bounded +10-second shutdown. Vision defaults to a 320x180 synthetic image with bounded +entities, triangles, texture work, PNG bytes, time, and concurrency. + +Unsupported by design: arbitrary raw packets or agent-control flags, arbitrary +shell/subprocess execution, provider SDK/model discovery, remote plaintext +control, unauthenticated mutation, unrestricted walking/teleport/touch/follow, +automatic config migration, persistence of viewport pixels, framebuffer/screen +capture, and treating untrusted grid/LLM text as instructions or authority. + +## Release evidence + +Build the intentional graphs and retain the commands/output with the release: + +```sh +cargo build --locked --release -p metacrate-grid-agent +cargo build --locked --release -p metacrate-grid-agent --features live-grid +cargo tree --locked -p metacrate-grid-agent --features live-grid -e normal,build +cargo audit +``` + +Record the binary byte size (`stat -c %s` on Linux or `Get-Item ... .Length` on +PowerShell) and the `cargo tree` inventory. Reject CLR/scripting/provider SDK, +subprocess adapters, Skia/GPU, or undeclared native libraries on the agent path. +The consolidated Gitea gate runs only on `ubuntu-latest`; Windows portability is +proved by the existing cross-target compile/static gate rather than a Windows +Gitea runner. + +The milestone baseline is recorded in +[grid-agent-release-evidence.md](grid-agent-release-evidence.md). + +Related contracts: [architecture](grid-agent-architecture.md), +[policy](grid-agent-policy.md), [session](grid-agent-session.md), +[control](grid-agent-control-plane.md), [observability](grid-agent-observability.md), +[TUI](grid-agent-tui.md), and [conversation storage](grid-agent-conversation.md). diff --git a/docs/grid-agent-release-evidence.md b/docs/grid-agent-release-evidence.md new file mode 100644 index 0000000..b85d471 --- /dev/null +++ b/docs/grid-agent-release-evidence.md @@ -0,0 +1,34 @@ +# Grid-agent release evidence + +This checked-in measurement is the packaging baseline for milestone 14. It is +not a downloadable artifact or a substitute for regenerating evidence for each +release candidate. + +- Measured: 2026-08-18 +- Host: `aarch64-unknown-linux-gnu` +- Rust: `rustc 1.97.1 (8bab26f4f 2026-07-14)` +- Command: `cargo build --locked --release -p metacrate-grid-agent --features live-grid` +- Profile: workspace `release` (`opt-level=1`, `debug=0`, incremental disabled, + 256 codegen units to stay within the packaging memory ceiling) +- Binary: `target/release/metacrate-grid-agent` +- Size: 28,658,000 bytes +- SHA-256: `c8ed694c6817ce9a897897f521e174fc8d90378ddf46565336c747e22d876dfc` +- Normal/build dependency inventory: 192 unique Cargo package/version/path + identities from `cargo tree --locked -p metacrate-grid-agent --features + live-grid -e normal,build` +- Dynamic ELF dependencies: the AArch64 loader, `libc`, `libm`, and `libgcc_s` + only. TLS's declared `aws-lc-sys` component is statically linked. + +The audited tree contains the MetaCrate agent, the original compatibility +crates it consumes, the pure-Rust J2K/meshing/rendering path, Tokio, Reqwest, +Rustls, AWS-LC, terminal/serialization/bounds utilities, and their build-time +Rust tooling. It contains no CLR/Mono/.NET runtime, Python/Node/Lua scripting +runtime, provider SDK, Skia/GPU stack, OpenJPEG feature, shell/subprocess +adapter, or dynamically loaded undeclared application library. + +Regenerate for a release candidate with the commands in +[grid-agent-operations.md](grid-agent-operations.md), retain the full unabridged +`cargo tree` output as CI/release evidence, compare size intentionally, and run +the dependency-policy and consolidated required gates. Binary hashes and sizes +will change when source, compiler, target, profile, or dependency versions +change. diff --git a/packaging/metacrate-grid-agent/install.ps1 b/packaging/metacrate-grid-agent/install.ps1 new file mode 100644 index 0000000..6853646 --- /dev/null +++ b/packaging/metacrate-grid-agent/install.ps1 @@ -0,0 +1,15 @@ +param( + [Parameter(Mandatory = $true)][string]$BuiltBinary, + [Parameter(Mandatory = $true)][string]$DestinationDirectory +) +$ErrorActionPreference = "Stop" +if (-not (Test-Path -LiteralPath $BuiltBinary -PathType Leaf)) { + throw "BuiltBinary is not a regular file" +} +New-Item -ItemType Directory -Force -Path $DestinationDirectory | Out-Null +$temporary = Join-Path $DestinationDirectory ".metacrate-grid-agent.new.exe" +Copy-Item -LiteralPath $BuiltBinary -Destination $temporary -Force +Move-Item -LiteralPath $temporary -Destination (Join-Path $DestinationDirectory "metacrate-grid-agent.exe") -Force + +# Configuration, secrets, conversations, landmarks, and journals live outside +# this directory and are deliberately never created, replaced, or removed. diff --git a/packaging/metacrate-grid-agent/install.sh b/packaging/metacrate-grid-agent/install.sh new file mode 100755 index 0000000..3b478b1 --- /dev/null +++ b/packaging/metacrate-grid-agent/install.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + echo "usage: install.sh BUILT_BINARY DESTINATION_PREFIX" >&2 + exit 2 +fi + +source_binary=$1 +destination_prefix=$2 +test -f "$source_binary" +install -d -m 0755 "$destination_prefix/bin" +temporary="$destination_prefix/bin/.metacrate-grid-agent.new" +install -m 0755 "$source_binary" "$temporary" +mv -f "$temporary" "$destination_prefix/bin/metacrate-grid-agent" + +# Deliberately do not create, replace, migrate, or remove configuration, +# secrets, conversations, landmarks, or audit journals. diff --git a/packaging/metacrate-grid-agent/systemd/grid-agent.env.example b/packaging/metacrate-grid-agent/systemd/grid-agent.env.example new file mode 100644 index 0000000..e21eac1 --- /dev/null +++ b/packaging/metacrate-grid-agent/systemd/grid-agent.env.example @@ -0,0 +1,7 @@ +# Values here are paths and non-secret mode settings only. Never put secret +# values in this file. Create every referenced file as mode 0600, owned by the +# metacrate-agent service identity. +METACRATE_AGENT_LLM_API_KEY_FILE=/etc/metacrate/secrets/llm-api-key +METACRATE_AGENT_GRID_PASSWORD_FILE=/etc/metacrate/secrets/grid-password +METACRATE_AGENT_CONTROL_OPERATOR_TOKEN_FILE=/etc/metacrate/secrets/control-operator-token +METACRATE_AGENT_STORAGE_PATH=/var/lib/metacrate/grid-agent diff --git a/packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service b/packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service new file mode 100644 index 0000000..efece05 --- /dev/null +++ b/packaging/metacrate-grid-agent/systemd/metacrate-grid-agent.service @@ -0,0 +1,38 @@ +[Unit] +Description=MetaCrate OpenSim grid agent +Documentation=https://git.rfc1437.de/hugo/MetaCrate +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=metacrate-agent +Group=metacrate-agent +EnvironmentFile=-/etc/metacrate/grid-agent.env +ExecStartPre=/usr/local/bin/metacrate-grid-agent --config /etc/metacrate/grid-agent.json --check-config +ExecStart=/usr/local/bin/metacrate-grid-agent --config /etc/metacrate/grid-agent.json +KillSignal=SIGINT +TimeoutStopSec=30s +Restart=on-failure +RestartSec=5s +NoNewPrivileges=true +PrivateDevices=true +PrivateTmp=true +ProtectClock=true +ProtectControlGroups=true +ProtectHome=true +ProtectHostname=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectSystem=strict +ReadOnlyPaths=/etc/metacrate +ReadWritePaths=/var/lib/metacrate/grid-agent +RestrictAddressFamilies=AF_INET AF_INET6 +RestrictNamespaces=true +RestrictRealtime=true +SystemCallArchitectures=native +UMask=0077 + +[Install] +WantedBy=multi-user.target