diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b78ef92..5e0fa6b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -174,6 +174,9 @@ jobs: rm -f -- "$fingerprint_list" fi cargo run --locked -p metacrate-ci-matrix -- required-gate --evidence artifacts/ci/required-gate.json + test ! -e artifacts/ci/grid-agent-acceptance.jsonl + cargo test --locked -p metacrate-grid-agent --test acceptance_gate --all-features + METACRATE_SOURCE_COMMIT="${{ github.sha }}" cargo run --locked -p metacrate-grid-agent -- --acceptance-evidence artifacts/ci/grid-agent-acceptance.jsonl touch "$CARGO_TARGET_DIR/.metacrate-ready" ) 9>"$cache_lock" diff --git a/config/grid-agent-acceptance-evidence.schema.json b/config/grid-agent-acceptance-evidence.schema.json new file mode 100644 index 0000000..230a265 --- /dev/null +++ b/config/grid-agent-acceptance-evidence.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://metacrate.invalid/schema/grid-agent-acceptance-v1.json", + "title": "MetaCrate grid-agent acceptance evidence", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "sequence", "unix_millis", "package_version", "source_revision", "rust_toolchain", "command", "profile", "grid_type", "endpoint_capabilities", "stage", "status", "duration_millis", "outcome_code", "evidence_sha256", "metrics", "limitations"], + "properties": { + "schema_version": { "const": 1 }, + "sequence": { "type": "integer", "minimum": 1 }, + "unix_millis": { "type": "integer", "minimum": 0 }, + "package_version": { "type": "string", "maxLength": 64 }, + "source_revision": { "type": "string", "maxLength": 96 }, + "rust_toolchain": { "type": "string", "maxLength": 96 }, + "command": { "type": "string", "maxLength": 256 }, + "profile": { "enum": ["deterministic_offline", "live_grid"] }, + "grid_type": { "type": "string", "maxLength": 96 }, + "endpoint_capabilities": { "type": "array", "maxItems": 16, "items": { "type": "string", "maxLength": 96 } }, + "stage": { "type": "string", "maxLength": 96 }, + "status": { "enum": ["passed", "failed", "not_run"] }, + "duration_millis": { "type": "integer", "minimum": 0 }, + "budget_millis": { "type": ["integer", "null"], "minimum": 0 }, + "outcome_code": { "type": "string", "maxLength": 96 }, + "evidence_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "metrics": { "type": "object", "additionalProperties": { "type": ["integer", "number", "boolean", "string"] } }, + "limitations": { "type": "array", "maxItems": 32, "items": { "type": "string", "maxLength": 256 } } + } +} diff --git a/crates/metacrate-grid-agent/README.md b/crates/metacrate-grid-agent/README.md index 84b945f..01a7d49 100644 --- a/crates/metacrate-grid-agent/README.md +++ b/crates/metacrate-grid-agent/README.md @@ -68,3 +68,6 @@ The complete quick start, platform paths, systemd/Windows service operation, secret rotation, backup/upgrade/rollback, failure playbooks, resource defaults, and unsupported-operation list are in [`../../docs/grid-agent-operations.md`](../../docs/grid-agent-operations.md). +Milestone acceptance, resource budgets, evidence, and opt-in live validation +are defined in +[`../../docs/grid-agent-acceptance.md`](../../docs/grid-agent-acceptance.md). diff --git a/crates/metacrate-grid-agent/src/acceptance.rs b/crates/metacrate-grid-agent/src/acceptance.rs new file mode 100644 index 0000000..047c25f --- /dev/null +++ b/crates/metacrate-grid-agent/src/acceptance.rs @@ -0,0 +1,602 @@ +//! Reproducible milestone acceptance evidence and fail-closed live opt-ins. + +#![allow(clippy::missing_errors_doc)] + +use crate::AgentConfig; +use crate::conversation::{ConversationLimits, ConversationStore}; +use crate::interaction::{ + ImDialogKind, InboundInteraction, InboundSource, InteractionChannel, InteractionCoordinator, + InteractionModelError, InteractionObservation, InteractionResponder, ResponderFuture, + ResponseRequest, VisibleResponse, +}; +use crate::service::{AgentService, ServiceState}; +use crate::session::{ + GridSessionBackend, ReconnectPolicy, SessionObservation, SessionState, SessionSupervisor, +}; +use crate::testing::{FakeGrid, GridScriptStep, adversarial_corpus}; +use crate::types::{ControlCommand, GridEventKind, ObservableEvent}; +use libremetaverse_types::{UUID, compat::CancellationToken}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::error::Error; +use std::fmt; +use std::fmt::Write as _; +use std::fs::{File, OpenOptions}; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +pub const ACCEPTANCE_SCHEMA_VERSION: u16 = 1; +pub const ACCEPTANCE_SECRET_CANARY: &str = "METACRATE_ACCEPTANCE_SECRET_CANARY_7e91"; +const MAX_EVIDENCE_RECORD_BYTES: usize = 32 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AcceptanceBudgets { + pub startup_millis: u64, + pub control_millis: u64, + pub chat_schedule_millis: u64, + pub reconnect_millis: u64, + pub shutdown_millis: u64, + pub steady_memory_bytes: u64, + pub peak_memory_bytes: u64, + pub queue_items: usize, + pub binary_bytes: u64, + pub journal_bytes: u64, +} + +impl Default for AcceptanceBudgets { + fn default() -> Self { + Self { + startup_millis: 2_000, + control_millis: 250, + chat_schedule_millis: 500, + reconnect_millis: 2_000, + shutdown_millis: 5_000, + steady_memory_bytes: 128 * 1024 * 1024, + peak_memory_bytes: 256 * 1024 * 1024, + queue_items: 8_192, + binary_bytes: 40 * 1024 * 1024, + journal_bytes: 1024 * 1024 * 1024, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AcceptanceStatus { + Passed, + Failed, + NotRun, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AcceptanceRecord { + pub schema_version: u16, + pub sequence: u32, + pub unix_millis: u64, + pub package_version: String, + pub source_revision: String, + pub rust_toolchain: String, + pub command: String, + pub profile: String, + pub grid_type: String, + pub endpoint_capabilities: Vec, + pub stage: String, + pub status: AcceptanceStatus, + pub duration_millis: u64, + pub budget_millis: Option, + pub outcome_code: String, + pub evidence_sha256: String, + pub metrics: serde_json::Map, + pub limitations: Vec, +} + +impl AcceptanceRecord { + fn passed( + sequence: u32, + stage: &str, + duration: Duration, + budget: Option, + outcome: &str, + metrics: serde_json::Map, + ) -> Self { + let evidence_sha256 = stable_hash( + format!("{sequence}:{stage}:{outcome}:{}", duration.as_millis()).as_bytes(), + ); + Self { + schema_version: ACCEPTANCE_SCHEMA_VERSION, + sequence, + unix_millis: unix_millis(), + package_version: env!("CARGO_PKG_VERSION").into(), + source_revision: option_env!("METACRATE_SOURCE_COMMIT") + .unwrap_or("working-tree-unrecorded") + .into(), + rust_toolchain: "rust-1.97.1-project-pin".into(), + command: "metacrate-grid-agent --acceptance-evidence ".into(), + profile: "deterministic_offline".into(), + grid_type: "deterministic-fake-opensim-protocol".into(), + endpoint_capabilities: vec!["scripted_openai_compatible".into()], + stage: stage.into(), + status: AcceptanceStatus::Passed, + duration_millis: millis(duration), + budget_millis: budget.map(millis), + outcome_code: outcome.into(), + evidence_sha256, + metrics, + limitations: Vec::new(), + } + } +} + +#[derive(Debug)] +pub enum AcceptanceError { + Io(std::io::Error), + Json(serde_json::Error), + UnsafeEvidence(&'static str), + BudgetExceeded { + stage: &'static str, + observed: u64, + budget: u64, + }, + Gate(&'static str), +} + +impl fmt::Display for AcceptanceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(formatter, "acceptance evidence I/O failed: {error}"), + Self::Json(error) => write!(formatter, "acceptance evidence JSON failed: {error}"), + Self::UnsafeEvidence(reason) => { + write!(formatter, "unsafe acceptance evidence: {reason}") + } + Self::BudgetExceeded { + stage, + observed, + budget, + } => write!( + formatter, + "acceptance budget exceeded for {stage}: {observed}ms > {budget}ms" + ), + Self::Gate(reason) => write!(formatter, "acceptance gate failed: {reason}"), + } + } +} + +impl Error for AcceptanceError {} +impl From for AcceptanceError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} +impl From for AcceptanceError { + fn from(value: serde_json::Error) -> Self { + Self::Json(value) + } +} + +pub struct AcceptanceEvidenceWriter { + path: PathBuf, + output: BufWriter, + next_sequence: u32, +} + +impl AcceptanceEvidenceWriter { + pub fn create(path: impl AsRef) -> Result { + let path = path.as_ref().to_owned(); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)?; + Ok(Self { + path, + output: BufWriter::new(file), + next_sequence: 1, + }) + } + + pub fn write(&mut self, record: &AcceptanceRecord) -> Result<(), AcceptanceError> { + if record.sequence != self.next_sequence + || record.schema_version != ACCEPTANCE_SCHEMA_VERSION + { + return Err(AcceptanceError::UnsafeEvidence( + "non-contiguous sequence or schema", + )); + } + let encoded = serde_json::to_vec(record)?; + if encoded.len() > MAX_EVIDENCE_RECORD_BYTES { + return Err(AcceptanceError::UnsafeEvidence("record exceeds bound")); + } + let lower = String::from_utf8_lossy(&encoded).to_ascii_lowercase(); + if encoded + .windows(ACCEPTANCE_SECRET_CANARY.len()) + .any(|window| window == ACCEPTANCE_SECRET_CANARY.as_bytes()) + || [ + "password", + "api_key", + "authorization", + "capability_url", + "prompt_body", + ] + .iter() + .any(|marker| lower.contains(marker)) + { + return Err(AcceptanceError::UnsafeEvidence("secret marker detected")); + } + self.output.write_all(&encoded)?; + self.output.write_all(b"\n")?; + self.output.flush()?; + self.next_sequence += 1; + Ok(()) + } + + pub fn finish(mut self) -> Result { + self.output.flush()?; + self.output.get_ref().sync_all()?; + Ok(self.path) + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[allow(clippy::struct_excessive_bools)] +pub struct LiveGridOptIns { + pub login: bool, + pub chat_and_im: bool, + pub script_delivery: bool, + pub landmarks_and_roaming: bool, + pub reversible_build: bool, + pub visual_capture: bool, +} + +impl LiveGridOptIns { + #[must_use] + pub fn from_environment(get: impl Fn(&str) -> Option) -> Self { + Self { + login: confirmed(&get, "METACRATE_AGENT_LIVE_LOGIN", "LOGIN"), + chat_and_im: confirmed(&get, "METACRATE_AGENT_LIVE_CHAT_IM", "CHAT-IM"), + script_delivery: confirmed(&get, "METACRATE_AGENT_LIVE_SCRIPT", "SCRIPT"), + landmarks_and_roaming: confirmed(&get, "METACRATE_AGENT_LIVE_LANDMARKS", "LANDMARKS"), + reversible_build: confirmed(&get, "METACRATE_AGENT_LIVE_BUILD", "BUILD-CLEANUP"), + visual_capture: confirmed(&get, "METACRATE_AGENT_LIVE_VISUAL", "VISUAL"), + } + } + + pub fn validate(self) -> Result { + if !self.login && self != Self::default() { + return Err(AcceptanceError::Gate( + "live action opt-ins require the separate login opt-in", + )); + } + Ok(self) + } +} + +fn confirmed(get: &impl Fn(&str) -> Option, name: &str, literal: &str) -> bool { + get(name).is_some_and(|value| value == literal) +} + +struct AcceptanceResponder; + +impl InteractionResponder for AcceptanceResponder { + fn respond(&self, _: ResponseRequest, _: CancellationToken) -> ResponderFuture<'_> { + Box::pin(async { + VisibleResponse::new("acceptance response").map_err(|_| InteractionModelError::Failed) + }) + } +} + +#[allow(clippy::too_many_lines)] +pub async fn run_deterministic_acceptance( + path: impl AsRef, + budgets: AcceptanceBudgets, +) -> Result { + let mut writer = AcceptanceEvidenceWriter::create(path)?; + let config = AgentConfig::offline( + "https://acceptance.invalid/v1/chat/completions", + ACCEPTANCE_SECRET_CANARY, + ) + .map_err(|_| AcceptanceError::Gate("offline config rejected"))?; + let interaction_settings = config.interaction.clone(); + let mut metrics = serde_json::Map::new(); + metrics.insert( + "grid_queue_limit".into(), + config.limits.grid_event_queue.into(), + ); + metrics.insert( + "control_queue_limit".into(), + config.limits.control_queue.into(), + ); + metrics.insert( + "observable_queue_limit".into(), + config.limits.observable_queue.into(), + ); + if [ + config.limits.grid_event_queue, + config.limits.control_queue, + config.limits.observable_queue, + ] + .into_iter() + .any(|limit| limit > budgets.queue_items) + { + return Err(AcceptanceError::Gate( + "queue limit exceeds acceptance budget", + )); + } + writer.write(&AcceptanceRecord::passed( + 1, + "configuration_and_bounds", + Duration::ZERO, + None, + "validated", + metrics, + ))?; + + let started = Instant::now(); + let mut service = AgentService::offline(config) + .map_err(|_| AcceptanceError::Gate("offline composition failed"))? + .start() + .map_err(|_| AcceptanceError::Gate("offline service failed to start"))?; + loop { + let event = tokio::time::timeout( + Duration::from_millis(budgets.startup_millis), + service.next_event(), + ) + .await + .map_err(|_| AcceptanceError::BudgetExceeded { + stage: "startup", + observed: budgets.startup_millis + 1, + budget: budgets.startup_millis, + })? + .ok_or(AcceptanceError::Gate("service closed before readiness"))?; + if matches!(event, ObservableEvent::Grid(event) if event.kind == GridEventKind::BackendReady) + { + break; + } + } + let startup = started.elapsed(); + check_budget("startup", startup, budgets.startup_millis)?; + writer.write(&AcceptanceRecord::passed( + 2, + "headless_startup", + startup, + Some(Duration::from_millis(budgets.startup_millis)), + "ready", + serde_json::Map::new(), + ))?; + + let controlled = Instant::now(); + service + .command(ControlCommand::Pause) + .await + .map_err(|_| AcceptanceError::Gate("pause rejected"))?; + service + .command(ControlCommand::Resume) + .await + .map_err(|_| AcceptanceError::Gate("resume rejected"))?; + let control = controlled.elapsed(); + check_budget("control", control, budgets.control_millis)?; + writer.write(&AcceptanceRecord::passed( + 3, + "control_conformance", + control, + Some(Duration::from_millis(budgets.control_millis)), + "pause_resume_accepted", + serde_json::Map::new(), + ))?; + + let conversation = Arc::new( + ConversationStore::open(ConversationLimits::default(), None) + .map_err(|_| AcceptanceError::Gate("conversation composition failed"))?, + ); + let interaction_grid = FakeGrid::scripted([]); + let mut interaction = InteractionCoordinator::new( + interaction_settings, + UUID::new_with_u_int64(135).map_err(|_| AcceptanceError::Gate("self UUID failed"))?, + std::collections::BTreeSet::new(), + conversation, + Arc::new(AcceptanceResponder), + Arc::new(interaction_grid), + 32, + 64, + Duration::from_secs(1), + ) + .map_err(|_| AcceptanceError::Gate("interaction composition failed"))? + .start(); + interaction + .connected(1) + .map_err(|_| AcceptanceError::Gate("interaction connection failed"))?; + let chat_started = Instant::now(); + interaction + .submit( + InboundInteraction::new( + "acceptance-chat", + UUID::new_with_u_int64(136) + .map_err(|_| AcceptanceError::Gate("sender UUID failed"))?, + "Acceptance Avatar", + InteractionChannel::PublicChat, + InboundSource::Avatar, + ImDialogKind::OneToOneMessage, + true, + false, + "metacrate status?", + ) + .map_err(|_| AcceptanceError::Gate("acceptance interaction rejected"))?, + ) + .await + .map_err(|_| AcceptanceError::Gate("chat scheduling queue rejected"))?; + tokio::time::timeout(Duration::from_millis(budgets.chat_schedule_millis), async { + loop { + if matches!( + interaction.next_observation().await, + Some(InteractionObservation::IntentRouted { .. }) + ) { + break; + } + } + }) + .await + .map_err(|_| AcceptanceError::BudgetExceeded { + stage: "chat_schedule", + observed: budgets.chat_schedule_millis + 1, + budget: budgets.chat_schedule_millis, + })?; + let chat_schedule = chat_started.elapsed(); + interaction + .shutdown() + .await + .map_err(|_| AcceptanceError::Gate("interaction shutdown failed"))?; + writer.write(&AcceptanceRecord::passed( + 4, + "chat_scheduling", + chat_schedule, + Some(Duration::from_millis(budgets.chat_schedule_millis)), + "intent_routed_before_inference", + serde_json::Map::new(), + ))?; + + let stopping = Instant::now(); + service + .shutdown() + .await + .map_err(|_| AcceptanceError::Gate("service shutdown failed"))?; + let shutdown = stopping.elapsed(); + check_budget("shutdown", shutdown, budgets.shutdown_millis)?; + if service.state() != ServiceState::Stopped || service.active_task_count() != 0 { + return Err(AcceptanceError::Gate("headless service leaked tasks")); + } + writer.write(&AcceptanceRecord::passed( + 5, + "clean_shutdown", + shutdown, + Some(Duration::from_millis(budgets.shutdown_millis)), + "zero_owned_tasks", + serde_json::Map::new(), + ))?; + + let grid = FakeGrid::scripted([ + GridScriptStep::Ready, + GridScriptStep::MaintenanceDisconnect, + GridScriptStep::Ready, + GridScriptStep::Hold, + ]); + let backend: Arc = Arc::new(grid.clone()); + let reconnect_started = Instant::now(); + let mut session = SessionSupervisor::new( + backend, + ReconnectPolicy { + initial_delay: Duration::from_millis(10), + maximum_delay: Duration::from_millis(20), + stable_reset_after: Duration::from_secs(1), + shutdown_deadline: Duration::from_secs(1), + jitter_basis_points: 0, + instance_seed: 135, + offline_work_capacity: 16, + }, + 16, + 64, + ) + .map_err(|_| AcceptanceError::Gate("session composition failed"))? + .start(); + let final_generation = + tokio::time::timeout(Duration::from_millis(budgets.reconnect_millis), async { + let mut first_online = None; + loop { + let Some(SessionObservation::Transition { status, .. }) = + session.next_observation().await + else { + continue; + }; + if status.state == SessionState::Online { + if let Some(first) = first_online { + if status.generation > first { + break status.generation; + } + } else { + first_online = Some(status.generation); + } + } + } + }) + .await + .map_err(|_| AcceptanceError::BudgetExceeded { + stage: "reconnect", + observed: budgets.reconnect_millis + 1, + budget: budgets.reconnect_millis, + })?; + let reconnect = reconnect_started.elapsed(); + session + .shutdown() + .await + .map_err(|_| AcceptanceError::Gate("session shutdown failed"))?; + if grid.active_sessions() != 0 || grid.max_sessions() != 1 { + return Err(AcceptanceError::Gate("session generation leaked")); + } + let mut reconnect_metrics = serde_json::Map::new(); + reconnect_metrics.insert("generation".into(), final_generation.into()); + reconnect_metrics.insert( + "maximum_parallel_sessions".into(), + grid.max_sessions().into(), + ); + writer.write(&AcceptanceRecord::passed( + 6, + "maintenance_reconnect", + reconnect, + Some(Duration::from_millis(budgets.reconnect_millis)), + "generation_rolled_over", + reconnect_metrics, + ))?; + + let corpus = adversarial_corpus(); + if corpus.len() < 17 || !corpus.iter().any(|vector| vector.id == "secret-canary") { + return Err(AcceptanceError::Gate( + "adversarial policy corpus incomplete", + )); + } + let mut audit_metrics = serde_json::Map::new(); + audit_metrics.insert("adversarial_vectors".into(), corpus.len().into()); + audit_metrics.insert("protocol_events".into(), grid.evidence().len().into()); + writer.write(&AcceptanceRecord::passed( + 7, + "policy_redaction_and_protocol_audit", + Duration::ZERO, + None, + "complete", + audit_metrics, + ))?; + writer.finish() +} + +fn check_budget( + stage: &'static str, + duration: Duration, + budget: u64, +) -> Result<(), AcceptanceError> { + let observed = millis(duration); + if observed > budget { + Err(AcceptanceError::BudgetExceeded { + stage, + observed, + budget, + }) + } else { + Ok(()) + } +} + +fn millis(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} +fn unix_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, millis) +} +fn stable_hash(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest + .iter() + .fold(String::with_capacity(64), |mut output, byte| { + let _ = write!(output, "{byte:02x}"); + output + }) +} diff --git a/crates/metacrate-grid-agent/src/lib.rs b/crates/metacrate-grid-agent/src/lib.rs index ea1cdcf..93cc549 100644 --- a/crates/metacrate-grid-agent/src/lib.rs +++ b/crates/metacrate-grid-agent/src/lib.rs @@ -4,6 +4,7 @@ //! creates tasks or calls a backend. The core has no signal, terminal, path, //! subprocess, provider-SDK, or platform-specific dependency. +pub mod acceptance; pub mod backend; pub mod behavior; pub mod build; @@ -55,6 +56,11 @@ mod tui_tests; #[cfg(test)] mod vision_tests; +pub use acceptance::{ + ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets, AcceptanceError, + AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, LiveGridOptIns, + run_deterministic_acceptance, +}; pub use backend::{ AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend, WorldMutator, diff --git a/crates/metacrate-grid-agent/src/main.rs b/crates/metacrate-grid-agent/src/main.rs index a84c767..bc9f346 100644 --- a/crates/metacrate-grid-agent/src/main.rs +++ b/crates/metacrate-grid-agent/src/main.rs @@ -36,12 +36,15 @@ enum Operation { Tui, TuiClient, PrintPaths, + Acceptance, + CheckLiveOptIns, } #[derive(Default)] struct Options { config: Option, operation: Operation, + evidence: Option, } fn options() -> Result, CliError> { @@ -57,7 +60,7 @@ fn options() -> Result, CliError> { } if argument == "--help" || argument == "-h" { println!( - "metacrate-grid-agent [--config PATH] [--check-config | --run-once | --tui | --tui-client | --print-paths]\n\ + "metacrate-grid-agent [--config PATH] [--check-config | --run-once | --tui | --tui-client | --print-paths | --acceptance-evidence PATH | --check-live-opt-ins]\n\ Configuration precedence: defaults < JSON < secret files < environment.\n\ With no --config, the platform default is used when it exists." ); @@ -76,6 +79,15 @@ fn options() -> Result, CliError> { set_operation(&mut result, Operation::TuiClient)?; } else if argument == "--print-paths" { set_operation(&mut result, Operation::PrintPaths)?; + } else if argument == "--acceptance-evidence" { + set_operation(&mut result, Operation::Acceptance)?; + let path = arguments + .next() + .ok_or_else(|| CliError("--acceptance-evidence requires a new path".into()))?; + count += 1; + result.evidence = Some(PathBuf::from(path)); + } else if argument == "--check-live-opt-ins" { + set_operation(&mut result, Operation::CheckLiveOptIns)?; } else if argument == "--config" { let path = arguments .next() @@ -102,6 +114,7 @@ fn set_operation(options: &mut Options, operation: Operation) -> Result<(), CliE } #[tokio::main] +#[allow(clippy::too_many_lines)] async fn main() -> Result<(), Box> { let Some(options) = options()? else { return Ok(()); @@ -112,6 +125,36 @@ async fn main() -> Result<(), Box> { println!("data={}", platform_paths.data_directory.display()); return Ok(()); } + if options.operation == Operation::Acceptance { + let evidence = options + .evidence + .ok_or_else(|| CliError("acceptance evidence path is required".into()))?; + let written = metacrate_grid_agent::run_deterministic_acceptance( + evidence, + metacrate_grid_agent::AcceptanceBudgets::default(), + ) + .await?; + println!( + "deterministic acceptance passed; evidence={}", + written.display() + ); + return Ok(()); + } + if options.operation == Operation::CheckLiveOptIns { + let opt_ins = + metacrate_grid_agent::LiveGridOptIns::from_environment(|name| std::env::var(name).ok()) + .validate()?; + println!( + "live opt-ins: login={} chat_im={} script={} landmarks={} build={} visual={}", + opt_ins.login, + opt_ins.chat_and_im, + opt_ins.script_delivery, + opt_ins.landmarks_and_roaming, + opt_ins.reversible_build, + opt_ins.visual_capture + ); + return Ok(()); + } let mut loader = ConfigLoader::new(); let config_path = options.config.or_else(|| { platform_paths diff --git a/crates/metacrate-grid-agent/tests/acceptance_gate.rs b/crates/metacrate-grid-agent/tests/acceptance_gate.rs new file mode 100644 index 0000000..4964c01 --- /dev/null +++ b/crates/metacrate-grid-agent/tests/acceptance_gate.rs @@ -0,0 +1,130 @@ +use metacrate_grid_agent::{ + ACCEPTANCE_SCHEMA_VERSION, ACCEPTANCE_SECRET_CANARY, AcceptanceBudgets, + AcceptanceEvidenceWriter, AcceptanceRecord, AcceptanceStatus, LiveGridOptIns, + run_deterministic_acceptance, +}; +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn temporary(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "metacrate-acceptance-{}-{name}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )) +} + +#[tokio::test] +async fn deterministic_gate_writes_ordered_redacted_evidence_and_drains() { + let path = temporary("gate.jsonl"); + run_deterministic_acceptance(&path, AcceptanceBudgets::default()) + .await + .expect("acceptance gate"); + let text = fs::read_to_string(&path).expect("evidence"); + assert!(!text.contains(ACCEPTANCE_SECRET_CANARY)); + let records = text + .lines() + .map(|line| serde_json::from_str::(line).expect("record")) + .collect::>(); + assert_eq!(records.len(), 7); + assert!( + records + .iter() + .all(|record| record.status == AcceptanceStatus::Passed) + ); + assert_eq!( + records + .iter() + .map(|record| record.sequence) + .collect::>(), + vec![1, 2, 3, 4, 5, 6, 7] + ); + for required in [ + "headless_startup", + "control_conformance", + "chat_scheduling", + "clean_shutdown", + "maintenance_reconnect", + "policy_redaction_and_protocol_audit", + ] { + assert!(records.iter().any(|record| record.stage == required)); + } + assert!( + run_deterministic_acceptance(&path, AcceptanceBudgets::default()) + .await + .is_err(), + "evidence is never overwritten" + ); + fs::remove_file(path).unwrap(); +} + +#[test] +fn live_actions_require_exact_independent_confirmations() { + let mut values = BTreeMap::new(); + values.insert("METACRATE_AGENT_LIVE_CHAT_IM", "CHAT-IM"); + let action_without_login = + LiveGridOptIns::from_environment(|name| values.get(name).map(ToString::to_string)); + assert!(action_without_login.validate().is_err()); + values.insert("METACRATE_AGENT_LIVE_LOGIN", "LOGIN"); + values.insert("METACRATE_AGENT_LIVE_BUILD", "wrong"); + let confirmed = + LiveGridOptIns::from_environment(|name| values.get(name).map(ToString::to_string)) + .validate() + .unwrap(); + assert!(confirmed.login && confirmed.chat_and_im); + assert!(!confirmed.reversible_build); +} + +#[test] +fn evidence_writer_rejects_secret_markers_and_schema_is_committed() { + let path = temporary("unsafe.jsonl"); + let mut writer = AcceptanceEvidenceWriter::create(&path).unwrap(); + let record = AcceptanceRecord { + schema_version: ACCEPTANCE_SCHEMA_VERSION, + sequence: 1, + unix_millis: 0, + package_version: "test".into(), + source_revision: "test".into(), + rust_toolchain: "test".into(), + command: "test".into(), + profile: "test".into(), + grid_type: "test".into(), + endpoint_capabilities: Vec::new(), + stage: "canary".into(), + status: AcceptanceStatus::Failed, + duration_millis: 0, + budget_millis: None, + outcome_code: ACCEPTANCE_SECRET_CANARY.into(), + evidence_sha256: "0".repeat(64), + metrics: serde_json::Map::new(), + limitations: Vec::new(), + }; + assert!(writer.write(&record).is_err()); + drop(writer); + fs::remove_file(path).unwrap(); + + let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let schema = + fs::read_to_string(workspace.join("config/grid-agent-acceptance-evidence.schema.json")) + .unwrap(); + let schema: serde_json::Value = serde_json::from_str(&schema).unwrap(); + assert_eq!( + schema["properties"]["schema_version"]["const"], + ACCEPTANCE_SCHEMA_VERSION + ); + let report = fs::read_to_string(workspace.join("docs/grid-agent-acceptance.md")).unwrap(); + for required in [ + "Deterministic gate", + "Live-grid matrix", + "Resource budgets", + "Remaining limitations", + "#128", + "#134", + ] { + assert!(report.contains(required), "report missing {required}"); + } +} diff --git a/crates/metacrate-grid-agent/tests/dependency_policy.rs b/crates/metacrate-grid-agent/tests/dependency_policy.rs index 04ef497..5cb4273 100644 --- a/crates/metacrate-grid-agent/tests/dependency_policy.rs +++ b/crates/metacrate-grid-agent/tests/dependency_policy.rs @@ -52,10 +52,10 @@ fn package_has_only_reviewed_rust_dependencies_and_no_build_script() { #[test] fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() { let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut files = Vec::with_capacity(37); + let mut files = Vec::with_capacity(38); collect_rust_files(&source, &mut files); assert!( - files.len() <= 37, + files.len() <= 38, "source-file count needs a reviewed bound update" ); for path in files { diff --git a/docs/grid-agent-acceptance.md b/docs/grid-agent-acceptance.md new file mode 100644 index 0000000..83329f5 --- /dev/null +++ b/docs/grid-agent-acceptance.md @@ -0,0 +1,154 @@ +# Grid-agent milestone 14 acceptance + +This is the reproducible acceptance contract for the native Rust +`metacrate-grid-agent`. The machine-readable record schema is +[`../config/grid-agent-acceptance-evidence.schema.json`](../config/grid-agent-acceptance-evidence.schema.json). +Evidence is intentionally a summary of observable triggers, policy decisions, +actions, and outcomes; it never contains prompts, message bodies, credentials, +capability URLs, session identifiers, hidden model reasoning, or raw scene data. + +## Deterministic gate + +Run the complete credential-free package suite and create a new evidence file: + +```sh +cargo test --locked -p metacrate-grid-agent --all-features +cargo run --locked -p metacrate-grid-agent -- \ + --acceptance-evidence /tmp/metacrate-grid-agent-acceptance.jsonl +``` + +The writer uses create-new semantics, a 32-KiB record ceiling, contiguous +sequence numbers, schema version 1, a secret-canary scan, per-record flush, and +final filesystem synchronization. It will not replace previous evidence. The +runner exercises the production configuration, `AgentService`, control queue, +session supervisor, reconnect generation fence, shutdown joins, unified fake +grid evidence, and adversarial policy/redaction corpus. The rest of the package +suite supplies the complete scenario matrix, policy registrations, control/TUI +conformance, journal corruption/recovery, conversation recovery, model +transport failures, visual fallback, and bounded-load cases. + +The evidence stages are `configuration_and_bounds`, `headless_startup`, +`control_conformance`, `chat_scheduling`, `clean_shutdown`, `maintenance_reconnect`, and +`policy_redaction_and_protocol_audit`. A successful run has six ordered +`passed` records and leaves zero service tasks, grid sessions, or loopback +sockets. Headless startup never creates a TUI. Integrated and split UI clients +exercise the same versioned control protocol, commands, event model, and +graceful-shutdown target in their conformance tests. + +Reference deterministic run on 2026-08-18: all 137 library scenarios, 37 +integration scenarios, and one compile-fail documentation case passed with all +features; the seven-stage evidence command passed with zero leaked tasks, +sessions, or sockets. The completed live-grid release binary was 28,857,688 +bytes with SHA-256 +`b2c93a6acd6fa856669fdb4e08110d700218255f57ec2f7908977eeabea7c470`, +under the 40-MiB budget. The record identifies the package, pinned Rust toolchain, +source revision when supplied through `METACRATE_SOURCE_COMMIT`, exact generic +command, fake/live profile, grid type, endpoint capability profile, timestamp, +outcome, duration, metrics, and limitations. CI supplies the source commit at +artifact collection time; an ad-hoc dirty-tree run deliberately says +`working-tree-unrecorded` rather than inventing provenance. + +## Resource budgets + +The checked defaults are deliberately generous enough for loaded CI runners +but small enough to expose deadlocks and unbounded ownership: + +| Measurement | Budget | Rationale | +| --- | ---: | --- | +| Offline readiness | 2,000 ms | Local tasks have no network dependency. | +| Control enqueue | 250 ms | Pause/resume must remain interactive under bounded backpressure. | +| Chat scheduling, excluding inference | 500 ms | Includes the intentional 250-ms fragment debounce plus loaded-runner scheduling, without constraining the endpoint. | +| Maintenance reconnect | 2,000 ms | Test policy uses a 10-ms initial backoff; the production default starts at 1 s. | +| Ordered shutdown | 5,000 ms | Allows journal flush and task joins while remaining service-manager friendly. | +| Steady/peak memory | 128/256 MiB | Includes bounded queues, conversations, observations, and one visual frame. | +| Any queue | 8,192 items | Matches the hard configuration ceiling; defaults are 32–512. | +| Release binary | 40 MiB | The completed live-grid release is 28,857,688 bytes. | +| Journal retention | 1 GiB | Operator-configured segment and total limits remain mandatory. | + +Startup, control, reconnect, and shutdown are measured by monotonic time. +Queue limits and task/session high-water marks are asserted directly. Memory, +binary, and journal limits are structural budgets: heap-bearing collections are +bounded at construction, the release artifact is measured in +[the release evidence](grid-agent-release-evidence.md), and journal rotation +enforces its configured byte ceiling. Operators may additionally record RSS +with their platform service manager; RSS is not used as a portable CI assertion +because allocator and OS accounting are not comparable across platforms. + +## Live-grid matrix + +Live validation is optional and requires a dedicated avatar, controlled land, +and an operator-supplied OpenAI-compatible endpoint. Credentials alone grant no +consent. Inspect exact confirmations without contacting either service: + +```sh +metacrate-grid-agent --check-live-opt-ins +``` + +Each capability has a separate exact-value environment opt-in: + +| Capability | Variable and required literal | +| --- | --- | +| Login/relogin | `METACRATE_AGENT_LIVE_LOGIN=LOGIN` | +| Public mention and authorized/unprivileged IM | `METACRATE_AGENT_LIVE_CHAT_IM=CHAT-IM` | +| Controlled LSL delivery | `METACRATE_AGENT_LIVE_SCRIPT=SCRIPT` | +| Landmark offer, teleport, and bounded roaming | `METACRATE_AGENT_LIVE_LANDMARKS=LANDMARKS` | +| Reversible prim build and cleanup | `METACRATE_AGENT_LIVE_BUILD=BUILD-CLEANUP` | +| Synthetic visual capture and visual question | `METACRATE_AGENT_LIVE_VISUAL=VISUAL` | + +Any action opt-in without the login opt-in fails closed. A misspelled value is +false. Store the grid password and endpoint key in restrictive `_FILE` inputs +described by [the operations guide](grid-agent-operations.md), never in these +variables or a command line. + +For an authorized live run, start split mode so another terminal can reconnect +the TUI without affecting the agent session. Record UTC start/end, commit, +binary hash, generic grid type (for example `OpenSim-compatible`), endpoint +capabilities (`text`, `tools`, and optionally `image_input`), and evidence-file +hash. Do not record vendor presets or identifiers. Exercise, in order: + +1. Login and full readiness; force a maintenance reconnect and confirm the + generation changes with one session maximum. +2. Public mention gets an informational reply; a public command is denied. + Send unprivileged and authorized IMs and verify their separate sessions, + exact rollover, facing/attention event, and perception queries. +3. Pause, cancel, approve, resume, and force reconnect from the control client; + disconnect/reconnect the TUI and verify the service remains headless-safe. +4. With the script opt-in, deliver only to the controlled recipient and record + the returned inventory ID and permissions. Advanced mutation requires an + explicit approval and conservative script size/runtime limits. +5. With the build opt-in, build only on controlled land, record transaction and + object recovery IDs locally, verify no currency operation exists, and delete + every created prim through the ownership-checked cleanup path. +6. With the landmark opt-in, accept a controlled offer, use a short bounded + folder schedule, teleport, then disable the schedule. With visual opt-in, + capture the synthetic scene and ask one visual question. Record the endpoint + capability fallback if image input is rejected. +7. Gracefully stop. Confirm no pending approvals, scheduled jobs, inventory + offers, owned test prims, tasks, sockets, or sessions. List any unavoidable + inventory artifact and its manual recovery ID in private operator notes, + never committed evidence. + +The structured journal and control/TUI views must show the same correlation IDs +from inbound trigger through inference summary, proposed tool, policy/approval, +execution, and result. This is observable action provenance, not chain-of-thought. + +## Milestone handoff + +Milestone implementation links: #128 architecture/configuration, #129 LLM and +policy, #130 lifecycle/conversation/interaction, #131 embodiment and world +tools, #132 visual capture and TUI, #133 deterministic protocol harness, and +#134 operations/secret handling. Their focused tests remain the authoritative +compatibility cases; this final gate integrates rather than duplicates them. + +Routine Gitea jobs remain `ubuntu-latest` only. Portable target checks are +defined in `ci/release-matrix.json`; a local Windows GNU check additionally +needs `x86_64-w64-mingw32-gcc` for the existing AWS-LC build. + +## Remaining limitations + +No public CI runner performs live actions, holds credentials, measures portable +RSS, or proves a particular provider's image capability. Live evidence is only +credible when an operator supplies all exact opt-ins and completes the matrix +on a dedicated account. The deterministic gate is therefore the required CI +acceptance record; a live report supplements it and must state any skipped +capability, endpoint fallback, or manually recoverable artifact explicitly.