diff --git a/Cargo.lock b/Cargo.lock index a2d2a9d..8b818d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1690,7 +1690,7 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tree-ring-memory-cli" -version = "0.15.11" +version = "0.15.12" dependencies = [ "chrono", "clap", @@ -1709,7 +1709,7 @@ dependencies = [ [[package]] name = "tree-ring-memory-core" -version = "0.15.11" +version = "0.15.12" dependencies = [ "chrono", "libc", @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "tree-ring-memory-sqlite" -version = "0.15.11" +version = "0.15.12" dependencies = [ "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index f20ac82..3413bca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.15.11" +version = "0.15.12" edition = "2021" license = "MIT" authors = ["TerminallyLazy"] diff --git a/crates/tree-ring-memory-cli/src/activation/AGENTS.md b/crates/tree-ring-memory-cli/src/activation/AGENTS.md index 2d2388b..03a7059 100644 --- a/crates/tree-ring-memory-cli/src/activation/AGENTS.md +++ b/crates/tree-ring-memory-cli/src/activation/AGENTS.md @@ -16,6 +16,11 @@ Use SessionStart/SubagentStart for recall and Stop/SubagentStop for bounded agen Keep root AGENTS.md references separate from .tree-ring guidance. Never scrape transcripts, manufacture receipts, or treat an Agent Zero marker as installed plugin capability. +Agent Zero capability contracts pair an exact plugin version with its minimum +runtime and minor series. Coordinate the core allowlist and plugin descriptor +before publishing either release; verify the actual pair with CLI activation, +preflight, and receipt-backed status. A passing version probe alone is insufficient. + When creating root AGENTS.md, record ownership of only the marked Tree Ring block so surrounding project instructions remain editable. Preserve legacy complete-file ownership until explicitly reconciled; never migrate an existing activation manifest automatically. ## Verification diff --git a/crates/tree-ring-memory-cli/src/activation/adapters.rs b/crates/tree-ring-memory-cli/src/activation/adapters.rs index 894d8a5..cdea55b 100644 --- a/crates/tree-ring-memory-cli/src/activation/adapters.rs +++ b/crates/tree-ring-memory-cli/src/activation/adapters.rs @@ -22,6 +22,7 @@ const AGENT_ZERO_CAPABILITY_CONTRACTS: &[(&str, &str, &str)] = &[ ("3.3.1", "0.15.3", "0.15"), ("3.4.0", "0.15.5", "0.15"), ("3.4.1", "0.15.7", "0.15"), + ("3.4.2", "0.15.12", "0.15"), ]; const MAX_AGENT_ZERO_CAPABILITY_BYTES: u64 = 16 * 1024; diff --git a/crates/tree-ring-memory-cli/tests/agent_zero_342_acceptance.rs b/crates/tree-ring-memory-cli/tests/agent_zero_342_acceptance.rs new file mode 100644 index 0000000..26c52f2 --- /dev/null +++ b/crates/tree-ring-memory-cli/tests/agent_zero_342_acceptance.rs @@ -0,0 +1,243 @@ +#![cfg(unix)] + +use serde_json::{json, Value}; +use std::{ + collections::BTreeMap, + fs, + io::Write, + path::{Path, PathBuf}, + process::{Command, Output, Stdio}, +}; +use tempfile::{tempdir, TempDir}; + +struct Project { + _temp: TempDir, + root: PathBuf, + descriptor: PathBuf, +} + +impl Project { + fn new() -> Self { + let temp = tempdir().unwrap(); + let root = temp.path().join("Agent Zero Project"); + let plugin = temp.path().join("installed-plugin"); + fs::create_dir(&root).unwrap(); + fs::create_dir(&plugin).unwrap(); + let project = Self { + _temp: temp, + root, + descriptor: plugin.join("activation-capability.json"), + }; + project.plugin("3.4.2", "3.4.2", "0.15.12", true); + project + } + + fn plugin( + &self, + manifest_version: &str, + descriptor_version: &str, + minimum: &str, + enabled: bool, + ) { + fs::write( + self.descriptor.with_file_name("plugin.yaml"), + format!("name: tree_ring_memory\nversion: {manifest_version}\n"), + ) + .unwrap(); + fs::write( + &self.descriptor, + serde_json::to_vec(&json!({ + "schema_version": 1, + "kind": "tree-ring-agent-zero-plugin-capability", + "plugin_id": "tree_ring_memory", + "plugin_version": descriptor_version, + "activation_protocol_version": 1, + "tree_ring_version": {"min": minimum, "minor": "0.15"}, + "enabled": enabled + })) + .unwrap(), + ) + .unwrap(); + } + + fn command(&self, args: &[&str]) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_tree-ring")); + command + .current_dir(&self.root) + .env("PATH", "") + .env("HOME", self.root.join("fixture-home")) + .env_remove("TREE_RING_AGENT_PROFILE") + .env_remove("TREE_RING_WORKFLOW_ID") + .env_remove("TREE_RING_SESSION_ID") + .env_remove("TREE_RING_OPERATION_ID") + .env_remove("TREE_RING_COORDINATOR_TOKEN") + .env("TREE_RING_AGENT_ZERO_PLUGIN_MANIFEST", &self.descriptor) + .args(["--json", "--root", ".tree-ring"]) + .args(args); + command + } + + fn run(&self, args: &[&str]) -> Value { + successful_json(&self.command(args).output().unwrap()) + } + + fn preflight(&self, session: &str) -> Output { + let mut child = self + .command(&[ + "integrations", + "preflight", + "--harness", + "agent-zero", + "--input-json-stdin", + "--context-format", + "json", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all( + &serde_json::to_vec(&json!({ + "agent_profile": "agent-zero-342-worker", + "workflow_id": "agent-zero-342-flow", + "session_id": session + })) + .unwrap(), + ) + .unwrap(); + child.wait_with_output().unwrap() + } + + fn receipts(&self) -> BTreeMap> { + fn collect(path: &Path, receipts: &mut BTreeMap>) { + let Ok(entries) = fs::read_dir(path) else { + return; + }; + for entry in entries { + let path = entry.unwrap().path(); + if path.is_dir() { + collect(&path, receipts); + } else { + receipts.insert(path.clone(), fs::read(path).unwrap()); + } + } + } + let mut receipts = BTreeMap::new(); + collect( + &self.root.join(".tree-ring/activation/receipts"), + &mut receipts, + ); + receipts + } +} + +fn successful_json(output: &Output) -> Value { + assert!( + output.status.success(), + "stdout={}; stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() +} + +fn agent_zero_state(report: &Value) -> &str { + report["integrations"] + .as_array() + .unwrap() + .iter() + .find(|record| record["id"] == "agent-zero" || record["harness_id"] == "agent-zero") + .unwrap()["state"] + .as_str() + .unwrap() +} + +#[test] +fn agent_zero_342_with_01512_contract_activates_only_after_real_preflight() { + let project = Project::new(); + assert_eq!( + agent_zero_state(&project.run(&["init"])), + "configured-awaiting-proof" + ); + let activation = project.run(&["integrations", "activate", "--harness", "agent-zero"]); + assert_eq!(activation["state"], "needs-plugin"); + assert!(project.receipts().is_empty()); + assert_eq!( + agent_zero_state(&project.run(&["integrations", "status", "--verbose"])), + "configured-awaiting-proof" + ); + let manifest_path = project.root.join(".tree-ring/activation.json"); + let manifest_before = fs::read(&manifest_path).unwrap(); + let manifest: Value = serde_json::from_slice(&manifest_before).unwrap(); + assert_eq!(manifest["harnesses"]["agent-zero"]["state"], "needs-plugin"); + + let preflight = successful_json(&project.preflight("valid-session")); + assert_eq!(preflight["state"], "active"); + assert_eq!(preflight["receipt"]["harness_id"], "agent-zero"); + assert!(preflight["receipt"].get("session").is_none()); + let receipts = project.receipts(); + assert_eq!(receipts.len(), 1); + let persisted: Value = serde_json::from_slice(receipts.values().next().unwrap()).unwrap(); + assert_eq!( + persisted["session"]["agent_profile"], + "agent-zero-342-worker" + ); + assert_eq!(persisted["session"]["workflow_id"], "agent-zero-342-flow"); + assert_eq!(persisted["session"]["session_id"], "valid-session"); + assert_eq!(persisted["receipt_id"], preflight["receipt"]["receipt_id"]); + assert_eq!(persisted["store_id"], preflight["receipt"]["store_id"]); + assert_eq!(persisted["state"], "active"); + assert_eq!(fs::read(manifest_path).unwrap(), manifest_before); + assert_eq!( + agent_zero_state(&project.run(&["integrations", "status", "--verbose"])), + "active" + ); +} + +#[test] +fn mismatched_or_disabled_342_plugin_cannot_reuse_or_create_activation_proof() { + let project = Project::new(); + project.run(&["init"]); + successful_json(&project.preflight("previous-valid-session")); + let receipts = project.receipts(); + assert_eq!(receipts.len(), 1); + + for (name, manifest_version, descriptor_version, minimum, enabled) in [ + ("sibling-version", "3.4.1", "3.4.2", "0.15.12", true), + ("unsupported-version", "3.4.3", "3.4.3", "0.15.12", true), + ("older-minimum", "3.4.2", "3.4.2", "0.15.11", true), + ("different-minimum", "3.4.2", "3.4.2", "0.15.13", true), + ("disabled", "3.4.2", "3.4.2", "0.15.12", false), + ] { + project.plugin(manifest_version, descriptor_version, minimum, enabled); + assert_eq!( + agent_zero_state(&project.run(&["init"])), + "needs-plugin", + "{name}" + ); + assert_eq!( + project.run(&["integrations", "activate", "--harness", "agent-zero"])["state"], + "needs-plugin", + "{name}" + ); + assert_eq!( + agent_zero_state(&project.run(&["integrations", "status", "--verbose"])), + "needs-plugin", + "{name}" + ); + let output = project.preflight(name); + assert!(!output.status.success(), "{name}"); + assert!(output.stdout.is_empty(), "{name}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("not eligible for preflight"), + "{name}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(project.receipts(), receipts, "{name}"); + } +}