Package portable grid agent operation (#134)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"integrated": false,
|
||||
"split": false,
|
||||
"llm": {
|
||||
|
||||
20
config/grid-agent.integrated.example.json
Normal file
20
config/grid-agent.integrated.example.json
Normal file
@@ -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"
|
||||
}
|
||||
25
config/grid-agent.split.example.json
Normal file
25
config/grid-agent.split.example.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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<String>;
|
||||
}
|
||||
|
||||
#[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<u32>,
|
||||
integrated: Option<bool>,
|
||||
split: Option<bool>,
|
||||
llm: RawLlm,
|
||||
@@ -801,6 +863,14 @@ fn resolve<E: Environment>(
|
||||
environment: &E,
|
||||
config_path: Option<&Path>,
|
||||
) -> Result<AgentConfig, ConfigError> {
|
||||
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<E: Environment>(
|
||||
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<SecretString, ConfigError> {
|
||||
#[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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,6 +35,7 @@ enum Operation {
|
||||
RunOnce,
|
||||
Tui,
|
||||
TuiClient,
|
||||
PrintPaths,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -56,10 +57,14 @@ fn options() -> Result<Option<Options>, 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<Option<Options>, 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<dyn Error>> {
|
||||
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()?;
|
||||
|
||||
103
crates/metacrate-grid-agent/tests/operations_packaging.rs
Normal file
103
crates/metacrate-grid-agent/tests/operations_packaging.rs
Normal file
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
262
docs/grid-agent-operations.md
Normal file
262
docs/grid-agent-operations.md
Normal file
@@ -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).
|
||||
34
docs/grid-agent-release-evidence.md
Normal file
34
docs/grid-agent-release-evidence.md
Normal file
@@ -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.
|
||||
15
packaging/metacrate-grid-agent/install.ps1
Normal file
15
packaging/metacrate-grid-agent/install.ps1
Normal file
@@ -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.
|
||||
18
packaging/metacrate-grid-agent/install.sh
Executable file
18
packaging/metacrate-grid-agent/install.sh
Executable file
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user