Package portable grid agent operation (#134)
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m48s
CI / required (push) Failing after 51s

This commit is contained in:
2026-08-18 12:27:07 +02:00
parent fe8d0119ef
commit 00707fd8df
14 changed files with 693 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View 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"))
);
}
}