Package portable grid agent operation (#134)
This commit is contained in:
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user