From 8d2293a1e7070560c5bafbd647f70dc6a602f26b Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 13 Aug 2026 21:41:20 -0400 Subject: [PATCH 01/31] docs: design harness activation proof --- ...-13-tree-ring-harness-activation-design.md | 392 ++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-13-tree-ring-harness-activation-design.md diff --git a/docs/superpowers/specs/2026-08-13-tree-ring-harness-activation-design.md b/docs/superpowers/specs/2026-08-13-tree-ring-harness-activation-design.md new file mode 100644 index 0000000..29e1294 --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-tree-ring-harness-activation-design.md @@ -0,0 +1,392 @@ +# Tree Ring Harness Activation And Behavioral Proof Design + +## Status + +Approved direction: `tree-ring init` becomes a one-command, project-local +activation flow. It must make supported coding harnesses aware of Tree Ring, +verify that a new harness session actually performs a recall before substantive +work, and preserve the existing same-host multi-agent safety model. + +## Relationship To Earlier Bridge Design + +This design replaces the activation semantics in +`2026-07-06-tree-ring-agent-mediated-bridges-design.md`. + +`integrations link` remains a useful low-level and dry-run operation, but a +normal `tree-ring init` must now invoke safe project-level activation itself. +Generating `.tree-ring` guidance or finding a marker directory is not enough to +call a harness active. + +## Intent + +After a user runs this from a project root: + +```bash +tree-ring init +``` + +Tree Ring should do the ordinary setup automatically: + +1. Initialize the project-local memory root and canonical guidance. +2. Discover compatible harnesses through versioned adapters. +3. Install safe, project-local native bridge files or managed instruction + blocks. +4. Record exactly what was configured and what still requires platform consent. +5. On the first new session for each configured harness, run a privacy-safe, + scoped recall preflight and record a behavioral receipt. + +The user should not need to copy skills, edit configuration, select worker IDs, +or run a separate certification command for ordinary use. Platform-owned +consent, such as trusting a Pi project or installing the separate Agent Zero +plugin, is the only expected user action. Those cases must be one concise, +actionable next step rather than a configuration tutorial. + +## Goals + +- Make project-local Tree Ring activation automatic for each compatible + detected harness. +- Reserve the status `active` for a verified preflight receipt, never a marker + scan or generated file alone. +- Preserve `.tree-ring` as the canonical project guidance and storage root. +- Support Codex, Claude Code, Pi, Agent Zero, and other harnesses through a + versioned adapter contract rather than one fragile universal configuration. +- Keep an extensible path for Hermes, OpenCode, Goose, and future harnesses. +- Give multi-agent workers distinct identity and prove whether they share the + same project store. +- Keep durable writes explicit and preserve the existing coordinator policy for + shared promotion, lifecycle mutations, and fan-in. +- Make activation, receipts, and deactivation reversible without deleting + memories. +- Keep the default experience to one command and quiet session startup. + +## Non-Goals + +- Do not change Agent Zero core. Agent Zero integration extends the separate + `tree_ring_memory` plugin only. +- Do not create a daemon, capture transcripts, or persist prompts simply to + prove activation. +- Do not write global harness configuration during project initialization. +- Do not overwrite user-owned harness configuration or instruction files. +- Do not claim that local SQLite is safe across hosts or network filesystems. +- Do not treat a usage receipt as a security boundary against a user who can + alter local files or process state. +- Do not silently create a separate Agent Zero store and call it shared with the + project root. + +## Activation Vocabulary + +| State | Meaning | +| --- | --- | +| `active` | A native bridge exists and a current-session receipt proves scoped recall succeeded. | +| `configured-awaiting-proof` | Tree Ring safely installed the bridge; no qualifying new session has yet reported a receipt. | +| `active-isolated` | A harness completed preflight against its own accessible store, but that store does not match the project store. It is not a cross-harness shared workflow. | +| `needs-trust` | The harness requires its own user trust or approval before project resources can load. | +| `needs-project-mount` | The harness can run Tree Ring but cannot reach the canonical project store. | +| `needs-plugin` | The Agent Zero adapter requires the separate plugin to be installed or enabled. | +| `needs-user-review` | Existing unmanaged configuration makes automatic modification unsafe. | +| `unsupported` | No maintained adapter can prove this harness integration. | +| `failed` | Detection, bridge installation, preflight, or receipt verification failed with a concrete diagnostic. | + +`configured-awaiting-proof`, `active-isolated`, and every `needs-*` state are +deliberately distinct from `active`. + +## Command Experience + +### Default Flow + +`tree-ring init` performs canonical initialization plus safe activation. Its +normal human output is short: list each detected harness, its state, and at +most one required action. It does not print a bridge tutorial. + +The intended supporting commands are: + +```bash +tree-ring init +tree-ring integrations status +tree-ring integrations status --verbose +tree-ring integrations certify +tree-ring integrations deactivate --harness +``` + +`status --verbose` exposes bridge paths, adapter version, receipt age, root +topology, and an actionable error. `certify` produces durable JSON and Markdown +evidence; it is not required for ordinary initialization. `deactivate` removes +only Tree Ring-owned bridge files or bounded managed blocks. + +### Advanced Flow + +Advanced users may request a dry-run, an individual harness, an explicit +recheck, or coordinated policy. Those options must not be required for default +activation: + +```bash +tree-ring init --dry-run +tree-ring integrations activate --harness claude-code --dry-run +tree-ring integrations certify --live +tree-ring --root .tree-ring policy enable --coordinator release-coordinator +``` + +The policy command remains explicit because it emits a one-time capability. +Initialization must not nominate a coordinator or expose that capability. + +## Components + +### Canonical Project Root + +`.tree-ring/` remains the source of truth for: + +- `memory.sqlite` and its SQLite sidecars; +- generated `AGENTS.md`, `SKILL.md`, and `CLI.md` guidance; +- `activation.json`, the versioned activation manifest; +- bounded, non-sensitive activation receipts under `activation/receipts/`. + +`activation.json` contains a stable `store_id`, project-root fingerprint, +Tree Ring CLI version, activation schema version, and one record per adapter. +The `store_id` is created once for a new project root and lets a container path +and a host path prove they refer to the same store without recording either +path in receipts. + +### Adapter Registry + +The CLI owns a versioned registry of harness adapters. Every adapter implements +the following conceptual operations: + +1. **Detect** exact project signals, installed version, and activation + capability. A directory by itself is never sufficient. +2. **Plan** all bridge writes and explain any platform requirement. +3. **Install** only owned files or Tree Ring managed blocks. +4. **Preflight** a new harness session using native hooks, configuration, or an + approved launch wrapper. +5. **Verify** the resulting receipt and shared-store fingerprint. +6. **Deactivate** only the files and blocks it owns. + +An adapter declares one of three evidence capabilities: + +- `native-preflight`: it can run and report recall from a harness lifecycle + boundary; this can become `active`. +- `wrapper-preflight`: it can prove recall only when the harness is launched by + Tree Ring's optional wrapper; this is active for wrapper-launched sessions. +- `guidance-only`: it can install portable instructions but cannot prove use; + it remains `configured-awaiting-proof` or `unsupported`, never `active`. + +Third-party adapters use the same contract and fixtures. This is the practical +meaning of broad harness support: every harness can gain a first-class adapter, +but no unsupported harness receives a false universal-compatibility claim. + +### Native Bridge Ownership + +The generated bridge is intentionally thin. It points to canonical +`.tree-ring/SKILL.md`, `.tree-ring/AGENTS.md`, and `.tree-ring/CLI.md` rather +than copying long instructions or memory data. + +Safe automatic writes include absent Tree Ring-owned paths such as a dedicated +skill directory. Existing files can be changed only inside a clearly bounded +Tree Ring managed block. Parsed settings files, such as Pi project settings, +must preserve unrelated valid content. Invalid, conflicting, or unmanaged +configurations result in `needs-user-review` with a precise diff preview. + +The initial maintained adapters have these targets: + +- **Codex:** a project skill bridge under `.agents/skills/tree-ring-memory/` + plus a safe project instruction bridge when the adapter's live capability + probe requires it. +- **Claude Code:** `.claude/skills/tree-ring-memory/` plus an owned project + instruction or hook configuration only when it can be merged safely. +- **Pi:** the portable `.agents/skills` bridge and project `.pi` resource + configuration, subject to Pi's own project-trust decision. +- **Agent Zero:** the separate `tree_ring_memory` plugin and its per-project, + per-agent configuration; no generic `.a0` marker bridge is a substitute. +- **Hermes, OpenCode, Goose, and future harnesses:** a maintained native adapter + when available, otherwise an explicit non-active state and adapter authoring + path. + +## Preflight And Receipt Flow + +For the first substantive task in a new session, the native bridge executes: + +1. Read `activation.json` and verify the adapter version and store state. +2. Derive project, worker, workflow, and session identity from the harness, not + model-provided values. +3. Build a non-persisted, redacted task hint when available; otherwise use the + stable query `project startup constraints`. +4. Run scoped recall with sensitive results excluded by default. +5. Inject only the safe recall result into the harness startup context. +6. Atomically write a receipt after successful command completion and context + injection. + +The receipt stores the adapter/harness versions, `store_id`, project identity, +worker/workflow/session identifiers, query class rather than raw prompt text, +result count, a digest of selected memory IDs, duration, and status. It never +stores prompt text, recalled summaries, sensitive values, secrets, or +coordinator capabilities. + +A zero-result recall is a valid receipt: it proves the agent checked Tree Ring +without fabricating context. A failed command, failed context injection, +timeout, or mismatched store ID creates no active receipt. + +Receipts are operational observability, not durable memories. Retain at most +the most recent 100 receipts per harness/worker and no receipt older than 30 +days. Receipt writes use atomic replace semantics. + +## Agent Zero Integration + +The existing public `tree_ring_memory` Agent Zero plugin is the sole Agent Zero +runtime adapter. It already owns Agent Zero context mapping, per-project and +per-agent configuration, Rust CLI compatibility, version checks, automatic +bootstrap, safe paths, and coordinator-aware tools. This feature adds a +matching activation protocol to that plugin; it does not duplicate its runtime +or touch Agent Zero core. + +When `tree-ring init` detects Agent Zero: + +1. Verify the plugin manifest and compatible activation-protocol version. +2. If the plugin is absent or disabled, return `needs-plugin` with the one + installation/enable action. +3. If it is present, create or update the owned project binding. +4. The plugin derives server-side Agent Zero identity, runs the preflight, and + writes/verifies the receipt through the project mount. + +Agent Zero can be `active-isolated` if its configured store is not the +canonical project root. It becomes shared only when its plugin configuration +reaches the mounted project `.tree-ring` root and emits the same `store_id` as +the host harnesses. Tree Ring must never copy data into or out of the plugin's +default store merely to make this status appear green. + +The plugin must remain version-pinned to a compatible Tree Ring CLI and retain +its current migration gate. An incompatible or upgrade-required store is +`failed` or `needs-user-review`, not initialized during activation. + +## Multi-Agent Coordination + +Every bridge derives or receives a unique `agent_profile`, a shared +`workflow_id` for fan-out/fan-in, and a unique `session_id` for each real +attempt. Each worker preflights independently and emits its own receipt. + +Open policy remains backward compatible. In coordinated policy: + +- ordinary workers write only non-heartwood agent-scoped observations; +- a designated coordinator performs wider fan-in recall; +- shared project/workflow publication, evidence promotion, destructive + lifecycle actions, imports, consolidation, and applied maintenance require + the existing coordinator capability; +- the capability stays in the authorized host environment and never appears in + bridges, receipts, logs, API input, or memory. + +The activation report marks a workflow `shared` only when every participating +receipt reports the canonical project's `store_id`. The supported concurrency +claim stays bounded to concurrent processes on one host and a local filesystem. + +## Error Handling And Lifecycle + +- Missing or incompatible CLI/plugin: report `failed` with the required + version and exact detected value. +- Platform trust required: report `needs-trust`; do not alter global trust + settings or bypass a native approval prompt. +- Project store unavailable to a container: report `needs-project-mount`; do + not create a covert replacement store. +- Existing unmanaged configuration: report `needs-user-review`, show a + non-destructive plan, and leave it unchanged. +- Missing, expired, malformed, or failed receipt: remain + `configured-awaiting-proof`; never reuse an old receipt after an adapter, + harness, or store change. +- Existing incompatible SQLite schema: preserve the current explicit offline + upgrade and backup workflow; `init` must not migrate it as part of activation. +- Adapter update or root fingerprint change: atomically update its manifest + record and invalidate prior receipts. +- `deactivate`: remove only paths and bounded managed blocks recorded in the + manifest; retain the database, canonical guidance, and historical receipts. + +No activation failure writes durable memory or exposes sensitive recalled data. + +## Verification Strategy + +### Unit And Contract Tests + +Add focused Rust tests for: + +- adapter capability/version detection, including rejection of marker-only + false positives; +- idempotent owned-file creation, managed-block updates, parsed settings merge, + and no-overwrite behavior; +- activation manifest and receipt validation, expiry, bounded retention, and + atomic updates; +- `store_id` agreement and intentional isolated-store classification; +- redaction of prompts, recall content, secrets, and capabilities from receipts; +- deactivation that removes only owned bridge material; +- worker identity propagation, concurrent receipt writes, and coordinator-only + fan-in/promotion in coordinated mode. + +### Harness Fixtures + +Every maintained adapter supplies a hermetic launch fixture that proves: + +1. a fresh project runs `tree-ring init`; +2. the adapter installs its native bridge safely; +3. a new harness session executes preflight; +4. seeded project memory is injected as safe context; +5. the receipt verifies against the same canonical `store_id`. + +Also test zero-result recall, expired receipt, denied trust, unavailable mount, +unmanaged configuration, and unknown harness. CI fixtures must not call a +marker scan a behavioral pass. + +### Live Evidence + +`tree-ring integrations certify` emits machine-readable records and a concise +compatibility report. It distinguishes configured from active and shared from +isolated. A `--live` mode may run installed harnesses where the maintainer has +them available; absence of an optional local harness is a skip, not evidence of +support. + +The Agent Zero proof remains split across repositories: + +- this repository verifies the activation protocol and adapter contract; +- `tree-ring-memory-agent-zero` verifies plugin configuration, server-derived + identity, preflight/receipt behavior, and a live Agent Zero runtime path. + +## Documentation Requirements + +After implementation proves the exact command surface, update: + +- README quick start and compatibility matrix; +- `docs/integrations/agent-skill.md`; +- generated CLI, skill, and agent guidance; +- CLI help and JSON schemas; +- Agent Zero plugin README and marketplace-facing compatibility statement. + +Documentation must distinguish `active`, `configured-awaiting-proof`, +`active-isolated`, and all blocking states. It must say that a receipt proves +preflight usage, not an adversarial security guarantee or automatic durable +memory writes. + +## Acceptance Criteria + +1. `tree-ring init` performs safe project-local adapter activation without a + separate required link command. +2. A marker directory alone can never make a harness `active` or pass + certification. +3. Every supported adapter has a versioned detection rule, owned bridge plan, + preflight mechanism, receipt verifier, and fixture. +4. A harness becomes `active` only after a new session completes scoped recall, + safe context injection, and receipt validation. +5. A zero-result recall produces a valid usage receipt without inventing + context. +6. Receipts contain no raw prompt, recalled summary, secret, sensitive value, + or coordinator capability. +7. Existing unmanaged harness files are not changed; ambiguous setup is + `needs-user-review`. +8. All automatic writes remain within the project; global configuration is + unchanged. +9. Missing platform trust, plugin installation, or project mount yields a + single concise action and never a false active status. +10. Agent Zero uses the existing plugin only and does not require an Agent Zero + core change. +11. Multi-agent receipts carry distinct worker identity and prove shared status + only for the same project `store_id` on a same-host local filesystem. +12. Coordinated-policy authorization remains explicit and coordinator-only. +13. `deactivate` removes only manifest-owned bridge material and preserves + memories. +14. `integrations certify` produces JSON and Markdown evidence that separates + configured, active, isolated, blocked, skipped, and failed states. +15. Focused unit, fixture, multi-agent, Agent Zero plugin, formatting, and + diff-whitespace checks pass before documentation claims behavioral support. From cb78801bdd338ef797aac0cb8e4886c721d752cc Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 13 Aug 2026 22:18:06 -0400 Subject: [PATCH 02/31] feat: add harness activation manifest --- Cargo.lock | 8 +- Cargo.toml | 2 +- crates/tree-ring-memory-cli/Cargo.toml | 6 +- .../src/activation/manifest.rs | 439 ++++++++++++++++++ .../src/activation/mod.rs | 42 ++ crates/tree-ring-memory-cli/src/lib.rs | 1 + crates/tree-ring-memory-sqlite/Cargo.toml | 2 +- 7 files changed, 493 insertions(+), 7 deletions(-) create mode 100644 crates/tree-ring-memory-cli/src/activation/manifest.rs create mode 100644 crates/tree-ring-memory-cli/src/activation/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 7eb0d24..7ddd5b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1690,7 +1690,7 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tree-ring-memory-cli" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "clap", @@ -1698,14 +1698,16 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2", "tempfile", "tree-ring-memory-core", "tree-ring-memory-sqlite", + "uuid", ] [[package]] name = "tree-ring-memory-core" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "libc", @@ -1721,7 +1723,7 @@ dependencies = [ [[package]] name = "tree-ring-memory-sqlite" -version = "0.13.0" +version = "0.14.0" dependencies = [ "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index 055386e..6735592 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.13.0" +version = "0.14.0" edition = "2021" license = "MIT" authors = ["TerminallyLazy"] diff --git a/crates/tree-ring-memory-cli/Cargo.toml b/crates/tree-ring-memory-cli/Cargo.toml index fd7e503..a2df5d2 100644 --- a/crates/tree-ring-memory-cli/Cargo.toml +++ b/crates/tree-ring-memory-cli/Cargo.toml @@ -21,8 +21,10 @@ clap.workspace = true ratatui.workspace = true serde.workspace = true serde_json.workspace = true -tree-ring-memory-core = { path = "../tree-ring-memory-core", version = "0.13.0" } -tree-ring-memory-sqlite = { path = "../tree-ring-memory-sqlite", version = "0.13.0" } +sha2.workspace = true +uuid.workspace = true +tree-ring-memory-core = { path = "../tree-ring-memory-core", version = "0.14.0" } +tree-ring-memory-sqlite = { path = "../tree-ring-memory-sqlite", version = "0.14.0" } [dev-dependencies] rusqlite.workspace = true diff --git a/crates/tree-ring-memory-cli/src/activation/manifest.rs b/crates/tree-ring-memory-cli/src/activation/manifest.rs new file mode 100644 index 0000000..1256caa --- /dev/null +++ b/crates/tree-ring-memory-cli/src/activation/manifest.rs @@ -0,0 +1,439 @@ +use super::{ + ActivationState, AdapterCapability, SessionIdentity, ACTIVATION_PROTOCOL_VERSION, + ACTIVATION_SCHEMA_VERSION, RECEIPT_RETENTION_DAYS, RECEIPT_RETENTION_PER_WORKER, +}; +use chrono::{DateTime, Duration, Utc}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + fs::{self, OpenOptions}, + io::Write, + path::{Component, Path, PathBuf}, +}; +use tree_ring_memory_core::SensitivityGuard; +use uuid::Uuid; + +const ACTIVATION_MANIFEST_FILE: &str = "activation.json"; +const RECEIPTS_DIRECTORY: &str = "activation/receipts"; + +/// The persisted activation configuration for one local Tree Ring store. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActivationManifest { + pub schema_version: u16, + pub protocol_version: u16, + pub store_id: String, + pub project_root_fingerprint: String, + pub cli_version: String, + pub harnesses: BTreeMap, +} + +/// Configuration owned by a harness. Bridge paths are project-relative only. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HarnessActivation { + pub state: ActivationState, + pub adapter_capability: AdapterCapability, + #[serde(skip_serializing_if = "Option::is_none")] + pub bridge_path: Option, +} + +/// A deliberately minimal, non-sensitive record that an activation occurred. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActivationReceipt { + pub schema_version: u16, + pub protocol_version: u16, + pub receipt_id: String, + pub harness_id: String, + pub worker_key_fingerprint: String, + pub session: SessionIdentity, + pub state: ActivationState, + pub recorded_at: DateTime, +} + +/// Loads the persisted manifest without creating a directory or file. +pub fn load_manifest(memory_root: &Path) -> Result { + validate_memory_root(memory_root)?; + let path = manifest_path(memory_root); + let manifest: ActivationManifest = read_json(&path)?; + validate_manifest(&manifest)?; + Ok(manifest) +} + +/// Loads an existing manifest or creates the first manifest for a store. +pub fn load_or_create_manifest( + memory_root: &Path, + project_root: &Path, + cli_version: &str, +) -> Result { + validate_memory_root(memory_root)?; + validate_identifier("cli version", cli_version)?; + let path = manifest_path(memory_root); + if path.exists() { + return load_manifest(memory_root); + } + + let manifest = ActivationManifest { + schema_version: ACTIVATION_SCHEMA_VERSION, + protocol_version: ACTIVATION_PROTOCOL_VERSION, + store_id: Uuid::new_v4().hyphenated().to_string(), + project_root_fingerprint: fingerprint_path(project_root), + cli_version: cli_version.to_owned(), + harnesses: BTreeMap::new(), + }; + validate_manifest(&manifest)?; + atomic_write_json(&path, &manifest)?; + Ok(manifest) +} + +/// Writes a receipt beneath the store's activation receipt directory. +pub fn write_receipt(memory_root: &Path, receipt: &ActivationReceipt) -> Result { + validate_memory_root(memory_root)?; + validate_receipt(receipt)?; + let path = receipt_path( + memory_root, + &receipt.harness_id, + &receipt.worker_key_fingerprint, + ) + .join(format!("{}.json", receipt.receipt_id)); + atomic_write_json(&path, receipt)?; + Ok(path) +} + +/// Removes expired receipts and keeps at most 100 receipts for a harness/worker pair. +pub fn prune_receipts( + memory_root: &Path, + harness_id: &str, + worker_key: &str, + now: DateTime, +) -> Result { + validate_memory_root(memory_root)?; + validate_identifier("harness id", harness_id)?; + validate_identifier("worker key", worker_key)?; + let directory = receipt_path(memory_root, harness_id, &fingerprint(worker_key)); + if !directory.exists() { + return Ok(0); + } + + let expiry = now - Duration::days(RECEIPT_RETENTION_DAYS); + let mut retained = Vec::new(); + let mut removed = 0; + for entry in fs::read_dir(&directory).map_err(|err| io_error(&directory, err))? { + let entry = entry.map_err(|err| io_error(&directory, err))?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let receipt: ActivationReceipt = read_json(&path)?; + validate_receipt(&receipt)?; + if receipt.recorded_at < expiry { + fs::remove_file(&path).map_err(|err| io_error(&path, err))?; + removed += 1; + } else { + retained.push((receipt.recorded_at, path)); + } + } + retained.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1))); + for (_, path) in retained.into_iter().skip(RECEIPT_RETENTION_PER_WORKER) { + fs::remove_file(&path).map_err(|err| io_error(&path, err))?; + removed += 1; + } + Ok(removed) +} + +fn manifest_path(memory_root: &Path) -> PathBuf { + memory_root.join(ACTIVATION_MANIFEST_FILE) +} + +fn receipt_path(memory_root: &Path, harness_id: &str, worker_fingerprint: &str) -> PathBuf { + memory_root + .join(RECEIPTS_DIRECTORY) + .join(harness_id) + .join(worker_fingerprint) +} + +fn read_json(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|err| io_error(path, err))?; + serde_json::from_slice(&bytes) + .map_err(|err| format!("invalid activation JSON at {}: {err}", path.display())) +} + +fn atomic_write_json(path: &Path, value: &T) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(value) + .map_err(|err| format!("failed to serialize activation JSON: {err}"))?; + let parent = path + .parent() + .ok_or_else(|| format!("activation output path has no parent: {}", path.display()))?; + fs::create_dir_all(parent).map_err(|err| io_error(parent, err))?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + format!( + "activation output path has no UTF-8 file name: {}", + path.display() + ) + })?; + let temp_path = parent.join(format!(".{file_name}.{}.tmp", Uuid::new_v4())); + let result = (|| { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp_path) + .map_err(|err| io_error(&temp_path, err))?; + file.write_all(&bytes) + .map_err(|err| io_error(&temp_path, err))?; + file.sync_all().map_err(|err| io_error(&temp_path, err))?; + drop(file); + fs::rename(&temp_path, path).map_err(|err| io_error(path, err)) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp_path); + } + result +} + +fn validate_memory_root(memory_root: &Path) -> Result<(), String> { + if memory_root.file_name().and_then(|name| name.to_str()) == Some(".tree-ring") { + Ok(()) + } else { + Err(format!( + "activation metadata root must be a project .tree-ring directory: {}", + memory_root.display() + )) + } +} + +fn validate_manifest(manifest: &ActivationManifest) -> Result<(), String> { + if manifest.schema_version != ACTIVATION_SCHEMA_VERSION { + return Err(format!( + "unsupported activation schema version: {}", + manifest.schema_version + )); + } + if manifest.protocol_version != ACTIVATION_PROTOCOL_VERSION { + return Err(format!( + "unsupported activation protocol version: {}", + manifest.protocol_version + )); + } + validate_identifier("store id", &manifest.store_id)?; + if !is_sha256(&manifest.project_root_fingerprint) { + return Err("project root fingerprint must be a SHA-256 hex digest".to_string()); + } + validate_identifier("cli version", &manifest.cli_version)?; + for (harness_id, activation) in &manifest.harnesses { + validate_identifier("harness id", harness_id)?; + if let Some(bridge_path) = &activation.bridge_path { + validate_project_relative_path(bridge_path)?; + } + } + Ok(()) +} + +fn validate_receipt(receipt: &ActivationReceipt) -> Result<(), String> { + if receipt.schema_version != ACTIVATION_SCHEMA_VERSION { + return Err(format!( + "unsupported receipt schema version: {}", + receipt.schema_version + )); + } + if receipt.protocol_version != ACTIVATION_PROTOCOL_VERSION { + return Err(format!( + "unsupported receipt protocol version: {}", + receipt.protocol_version + )); + } + validate_identifier("receipt id", &receipt.receipt_id)?; + validate_identifier("harness id", &receipt.harness_id)?; + if !is_sha256(&receipt.worker_key_fingerprint) { + return Err("worker key fingerprint must be a SHA-256 hex digest".to_string()); + } + validate_identifier("agent profile", &receipt.session.agent_profile)?; + validate_identifier("workflow id", &receipt.session.workflow_id)?; + validate_identifier("session id", &receipt.session.session_id) +} + +fn validate_identifier(label: &str, value: &str) -> Result<(), String> { + if value.trim().is_empty() + || value.len() > 256 + || value.chars().any(char::is_control) + || value.contains(['/', '\\']) + || matches!(value, "." | "..") + { + return Err(format!("invalid {label}")); + } + if value.contains("trcap_v1_") + || SensitivityGuard::default().inspect(value).sensitivity == "secret" + { + return Err(format!("unsafe {label}")); + } + Ok(()) +} + +fn validate_project_relative_path(value: &str) -> Result<(), String> { + if value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) { + return Err("invalid bridge path".to_string()); + } + let path = Path::new(value); + if path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err("bridge path must be project-relative".to_string()); + } + Ok(()) +} + +fn fingerprint_path(project_root: &Path) -> String { + let path = fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf()); + fingerprint(&path.to_string_lossy()) +} + +fn fingerprint(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn is_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn io_error(path: &Path, error: std::io::Error) -> String { + format!("{}: {error}", path.display()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn fixture_receipt(recorded_at: DateTime) -> ActivationReceipt { + ActivationReceipt { + schema_version: ACTIVATION_SCHEMA_VERSION, + protocol_version: ACTIVATION_PROTOCOL_VERSION, + receipt_id: format!("receipt-{}", Uuid::new_v4()), + harness_id: "codex".to_string(), + worker_key_fingerprint: fingerprint("worker-1"), + session: SessionIdentity { + agent_profile: "implementer".to_string(), + workflow_id: "activation".to_string(), + session_id: "session-1".to_string(), + }, + state: ActivationState::Active, + recorded_at, + } + } + + #[test] + fn manifest_assigns_a_stable_store_id_and_fingerprints_the_project() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project/.tree-ring"); + let project = temp.path().join("project"); + let first = load_or_create_manifest(&root, &project, "0.14.0").unwrap(); + let second = load_or_create_manifest(&root, &project, "0.14.0").unwrap(); + assert_eq!(first.store_id, second.store_id); + assert_eq!( + first.project_root_fingerprint, + second.project_root_fingerprint + ); + assert!(!first + .project_root_fingerprint + .contains(project.to_str().unwrap())); + } + + #[test] + fn receipt_json_excludes_prompt_context_and_capability_material() { + let receipt = fixture_receipt(Utc::now()); + let json = serde_json::to_string(&receipt).unwrap(); + assert!(!json.contains("user prompt")); + assert!(!json.contains("recalled summary")); + assert!(!json.contains("trcap_v1_")); + } + + #[test] + fn malformed_manifest_fails_without_replacing_the_file() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project/.tree-ring"); + fs::create_dir_all(&root).unwrap(); + let path = manifest_path(&root); + fs::write(&path, b"not json").unwrap(); + assert!(load_or_create_manifest(&root, temp.path(), "0.14.0").is_err()); + assert_eq!(fs::read(&path).unwrap(), b"not json"); + } + + #[test] + fn loading_a_missing_manifest_does_not_create_store_metadata() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project/.tree-ring"); + assert!(load_manifest(&root).is_err()); + assert!(!root.exists()); + } + + #[test] + fn atomic_overwrite_replaces_complete_json_without_temp_files() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project/.tree-ring"); + let project = temp.path().join("project"); + let manifest = load_or_create_manifest(&root, &project, "0.14.0").unwrap(); + let path = manifest_path(&root); + atomic_write_json(&path, &manifest).unwrap(); + assert_eq!(load_manifest(&root).unwrap(), manifest); + assert!(fs::read_dir(&root).unwrap().all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .ends_with(".tmp"))); + } + + #[test] + fn prune_receipts_expires_old_records_and_keeps_the_latest_hundred() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project/.tree-ring"); + let now = Utc::now(); + let mut expired = fixture_receipt(now - Duration::days(RECEIPT_RETENTION_DAYS + 1)); + expired.receipt_id = "expired".to_string(); + write_receipt(&root, &expired).unwrap(); + for offset in 0..101 { + let mut receipt = fixture_receipt(now - Duration::seconds(offset)); + receipt.receipt_id = format!("receipt-{offset}"); + write_receipt(&root, &receipt).unwrap(); + } + assert_eq!(prune_receipts(&root, "codex", "worker-1", now).unwrap(), 2); + let directory = receipt_path(&root, "codex", &fingerprint("worker-1")); + assert_eq!(fs::read_dir(directory).unwrap().count(), 100); + } + + #[test] + fn rejects_unsafe_identifiers_and_absolute_bridge_paths() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project/.tree-ring"); + let mut receipt = fixture_receipt(Utc::now()); + receipt.harness_id = "\n".to_string(); + assert!(write_receipt(&root, &receipt).is_err()); + receipt.harness_id = "codex".to_string(); + receipt.session.session_id = format!("trcap_v1_{}", "a".repeat(64)); + assert!(write_receipt(&root, &receipt).is_err()); + + let mut manifest = load_or_create_manifest(&root, temp.path(), "0.14.0").unwrap(); + manifest.harnesses.insert( + "codex".to_string(), + HarnessActivation { + state: ActivationState::Active, + adapter_capability: AdapterCapability::NativePreflight, + bridge_path: Some("bridges/codex.sh".to_string()), + }, + ); + assert!(validate_manifest(&manifest).is_ok()); + manifest.harnesses.get_mut("codex").unwrap().bridge_path = + Some(temp.path().display().to_string()); + assert!(validate_manifest(&manifest).is_err()); + } +} diff --git a/crates/tree-ring-memory-cli/src/activation/mod.rs b/crates/tree-ring-memory-cli/src/activation/mod.rs new file mode 100644 index 0000000..d9850d1 --- /dev/null +++ b/crates/tree-ring-memory-cli/src/activation/mod.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +pub const ACTIVATION_SCHEMA_VERSION: u16 = 1; +pub const ACTIVATION_PROTOCOL_VERSION: u16 = 1; +pub const RECEIPT_RETENTION_PER_WORKER: usize = 100; +pub const RECEIPT_RETENTION_DAYS: i64 = 30; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ActivationState { + Active, + ConfiguredAwaitingProof, + ActiveIsolated, + NeedsTrust, + NeedsProjectMount, + NeedsPlugin, + NeedsUserReview, + Unsupported, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum AdapterCapability { + NativePreflight, + WrapperPreflight, + GuidanceOnly, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionIdentity { + pub agent_profile: String, + pub workflow_id: String, + pub session_id: String, +} + +pub mod manifest; + +pub use manifest::{ + load_manifest, load_or_create_manifest, prune_receipts, write_receipt, ActivationManifest, + ActivationReceipt, HarnessActivation, +}; diff --git a/crates/tree-ring-memory-cli/src/lib.rs b/crates/tree-ring-memory-cli/src/lib.rs index 872e7d4..d17e833 100644 --- a/crates/tree-ring-memory-cli/src/lib.rs +++ b/crates/tree-ring-memory-cli/src/lib.rs @@ -1 +1,2 @@ +pub mod activation; pub mod workflow_proof; diff --git a/crates/tree-ring-memory-sqlite/Cargo.toml b/crates/tree-ring-memory-sqlite/Cargo.toml index 0abbeaf..62cf2d8 100644 --- a/crates/tree-ring-memory-sqlite/Cargo.toml +++ b/crates/tree-ring-memory-sqlite/Cargo.toml @@ -16,7 +16,7 @@ rusqlite.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true -tree-ring-memory-core = { path = "../tree-ring-memory-core", version = "0.13.0" } +tree-ring-memory-core = { path = "../tree-ring-memory-core", version = "0.14.0" } uuid.workspace = true [dev-dependencies] From cb78e0149d390dabf1cebcaaaa86000103bd61d1 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 13 Aug 2026 22:25:58 -0400 Subject: [PATCH 03/31] fix: harden activation manifest persistence --- .../src/activation/manifest.rs | 122 +++++++++++++++--- 1 file changed, 106 insertions(+), 16 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/activation/manifest.rs b/crates/tree-ring-memory-cli/src/activation/manifest.rs index 1256caa..f446f94 100644 --- a/crates/tree-ring-memory-cli/src/activation/manifest.rs +++ b/crates/tree-ring-memory-cli/src/activation/manifest.rs @@ -84,8 +84,11 @@ pub fn load_or_create_manifest( harnesses: BTreeMap::new(), }; validate_manifest(&manifest)?; - atomic_write_json(&path, &manifest)?; - Ok(manifest) + match atomic_write_json(&path, &manifest, AtomicWriteMode::Create) { + Ok(()) => Ok(manifest), + Err(_) if path.exists() => load_manifest(memory_root), + Err(error) => Err(error), + } } /// Writes a receipt beneath the store's activation receipt directory. @@ -98,7 +101,7 @@ pub fn write_receipt(memory_root: &Path, receipt: &ActivationReceipt) -> Result< &receipt.worker_key_fingerprint, ) .join(format!("{}.json", receipt.receipt_id)); - atomic_write_json(&path, receipt)?; + atomic_write_json(&path, receipt, AtomicWriteMode::Replace)?; Ok(path) } @@ -126,8 +129,17 @@ pub fn prune_receipts( if path.extension().and_then(|extension| extension.to_str()) != Some("json") { continue; } - let receipt: ActivationReceipt = read_json(&path)?; - validate_receipt(&receipt)?; + let receipt: ActivationReceipt = match read_json(&path).and_then(|receipt| { + validate_receipt(&receipt)?; + Ok(receipt) + }) { + Ok(receipt) => receipt, + Err(_) => { + fs::remove_file(&path).map_err(|err| io_error(&path, err))?; + removed += 1; + continue; + } + }; if receipt.recorded_at < expiry { fs::remove_file(&path).map_err(|err| io_error(&path, err))?; removed += 1; @@ -160,7 +172,17 @@ fn read_json(path: &Path) -> Result { .map_err(|err| format!("invalid activation JSON at {}: {err}", path.display())) } -fn atomic_write_json(path: &Path, value: &T) -> Result<(), String> { +#[derive(Clone, Copy)] +enum AtomicWriteMode { + Replace, + Create, +} + +fn atomic_write_json( + path: &Path, + value: &T, + mode: AtomicWriteMode, +) -> Result<(), String> { let bytes = serde_json::to_vec_pretty(value) .map_err(|err| format!("failed to serialize activation JSON: {err}"))?; let parent = path @@ -187,7 +209,15 @@ fn atomic_write_json(path: &Path, value: &T) -> Result<(), String> .map_err(|err| io_error(&temp_path, err))?; file.sync_all().map_err(|err| io_error(&temp_path, err))?; drop(file); - fs::rename(&temp_path, path).map_err(|err| io_error(path, err)) + match mode { + AtomicWriteMode::Replace => { + fs::rename(&temp_path, path).map_err(|err| io_error(path, err)) + } + AtomicWriteMode::Create => { + fs::hard_link(&temp_path, path).map_err(|err| io_error(path, err))?; + fs::remove_file(&temp_path).map_err(|err| io_error(&temp_path, err)) + } + } })(); if result.is_err() { let _ = fs::remove_file(&temp_path); @@ -251,9 +281,17 @@ fn validate_receipt(receipt: &ActivationReceipt) -> Result<(), String> { if !is_sha256(&receipt.worker_key_fingerprint) { return Err("worker key fingerprint must be a SHA-256 hex digest".to_string()); } - validate_identifier("agent profile", &receipt.session.agent_profile)?; - validate_identifier("workflow id", &receipt.session.workflow_id)?; - validate_identifier("session id", &receipt.session.session_id) + validate_receipt_identity("agent profile", &receipt.session.agent_profile)?; + validate_receipt_identity("workflow id", &receipt.session.workflow_id)?; + validate_receipt_identity("session id", &receipt.session.session_id) +} + +fn validate_receipt_identity(label: &str, value: &str) -> Result<(), String> { + validate_identifier(label, value)?; + if SensitivityGuard::default().inspect(value).sensitivity != "normal" { + return Err(format!("sensitive {label}")); + } + Ok(()) } fn validate_identifier(label: &str, value: &str) -> Result<(), String> { @@ -313,7 +351,11 @@ fn io_error(path: &Path, error: std::io::Error) -> String { #[cfg(test)] mod tests { use super::*; - use std::fs; + use std::{ + fs, + sync::{Arc, Barrier}, + thread, + }; fn fixture_receipt(recorded_at: DateTime) -> ActivationReceipt { ActivationReceipt { @@ -358,6 +400,28 @@ mod tests { assert!(!json.contains("trcap_v1_")); } + #[test] + fn receipt_identity_rejects_every_non_normal_sensitivity_classification() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project/.tree-ring"); + for (field, value) in [ + ("agent_profile", "medical"), + ("workflow_id", "bank account"), + ("session_id", "lawsuit"), + ("agent_profile", "passport"), + ("workflow_id", "sk-proj-aaaaaaaaaaaaaaaaaaaa"), + ] { + let mut receipt = fixture_receipt(Utc::now()); + match field { + "agent_profile" => receipt.session.agent_profile = value.to_string(), + "workflow_id" => receipt.session.workflow_id = value.to_string(), + "session_id" => receipt.session.session_id = value.to_string(), + _ => unreachable!(), + } + assert!(write_receipt(&root, &receipt).is_err(), "{field}: {value}"); + } + } + #[test] fn malformed_manifest_fails_without_replacing_the_file() { let temp = tempfile::tempdir().unwrap(); @@ -377,6 +441,30 @@ mod tests { assert!(!root.exists()); } + #[test] + fn concurrent_first_create_returns_the_persisted_manifest_identity() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project/.tree-ring"); + let project = temp.path().join("project"); + let barrier = Arc::new(Barrier::new(8)); + let mut workers = Vec::new(); + for _ in 0..8 { + let root = root.clone(); + let project = project.clone(); + let barrier = Arc::clone(&barrier); + workers.push(thread::spawn(move || { + barrier.wait(); + load_or_create_manifest(&root, &project, "0.14.0").unwrap() + })); + } + let manifests = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect::>(); + let persisted = load_manifest(&root).unwrap(); + assert!(manifests.iter().all(|manifest| manifest == &persisted)); + } + #[test] fn atomic_overwrite_replaces_complete_json_without_temp_files() { let temp = tempfile::tempdir().unwrap(); @@ -384,7 +472,7 @@ mod tests { let project = temp.path().join("project"); let manifest = load_or_create_manifest(&root, &project, "0.14.0").unwrap(); let path = manifest_path(&root); - atomic_write_json(&path, &manifest).unwrap(); + atomic_write_json(&path, &manifest, AtomicWriteMode::Replace).unwrap(); assert_eq!(load_manifest(&root).unwrap(), manifest); assert!(fs::read_dir(&root).unwrap().all(|entry| !entry .unwrap() @@ -394,21 +482,23 @@ mod tests { } #[test] - fn prune_receipts_expires_old_records_and_keeps_the_latest_hundred() { + fn prune_receipts_removes_malformed_and_expired_records_and_keeps_the_latest_hundred() { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project/.tree-ring"); let now = Utc::now(); let mut expired = fixture_receipt(now - Duration::days(RECEIPT_RETENTION_DAYS + 1)); expired.receipt_id = "expired".to_string(); write_receipt(&root, &expired).unwrap(); + let directory = receipt_path(&root, "codex", &fingerprint("worker-1")); + fs::write(directory.join("malformed.json"), b"not a receipt").unwrap(); for offset in 0..101 { let mut receipt = fixture_receipt(now - Duration::seconds(offset)); receipt.receipt_id = format!("receipt-{offset}"); write_receipt(&root, &receipt).unwrap(); } - assert_eq!(prune_receipts(&root, "codex", "worker-1", now).unwrap(), 2); - let directory = receipt_path(&root, "codex", &fingerprint("worker-1")); - assert_eq!(fs::read_dir(directory).unwrap().count(), 100); + assert_eq!(prune_receipts(&root, "codex", "worker-1", now).unwrap(), 3); + assert_eq!(fs::read_dir(&directory).unwrap().count(), 100); + assert!(!directory.join("malformed.json").exists()); } #[test] From 5ade5cad49b61a6507b93cca777d859aabba71bb Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 13 Aug 2026 22:36:17 -0400 Subject: [PATCH 04/31] feat: add versioned harness adapter registry --- .../src/actions/integrations.rs | 2 +- .../src/activation/adapters.rs | 705 ++++++++++++++++++ .../src/activation/mod.rs | 2 + .../src/harness_evidence.rs | 26 +- .../tree-ring-memory-cli/src/integrations.rs | 299 -------- crates/tree-ring-memory-cli/src/main.rs | 19 +- crates/tree-ring-memory-cli/src/tui/app.rs | 5 +- crates/tree-ring-memory-cli/src/tui/render.rs | 12 +- 8 files changed, 748 insertions(+), 322 deletions(-) create mode 100644 crates/tree-ring-memory-cli/src/activation/adapters.rs delete mode 100644 crates/tree-ring-memory-cli/src/integrations.rs diff --git a/crates/tree-ring-memory-cli/src/actions/integrations.rs b/crates/tree-ring-memory-cli/src/actions/integrations.rs index 864f216..996958a 100644 --- a/crates/tree-ring-memory-cli/src/actions/integrations.rs +++ b/crates/tree-ring-memory-cli/src/actions/integrations.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use crate::integrations::{scan_integrations, IntegrationScanReport}; +use tree_ring_memory_cli::activation::adapters::{scan_integrations, IntegrationScanReport}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct IntegrationScanRequest { diff --git a/crates/tree-ring-memory-cli/src/activation/adapters.rs b/crates/tree-ring-memory-cli/src/activation/adapters.rs new file mode 100644 index 0000000..463820a --- /dev/null +++ b/crates/tree-ring-memory-cli/src/activation/adapters.rs @@ -0,0 +1,705 @@ +use super::{ActivationState, AdapterCapability, ACTIVATION_PROTOCOL_VERSION}; +use serde::Serialize; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; + +pub const AGENT_ZERO_PLUGIN_ID: &str = "tree_ring_memory"; + +/// Project paths used by activation. Adapter plans always target this root. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActivationProject { + pub project_root: PathBuf, + pub memory_root: PathBuf, +} + +impl ActivationProject { + pub fn from_memory_root(memory_root: impl Into) -> Result { + let memory_root = memory_root.into(); + let project_root = memory_root.parent().ok_or_else(|| { + format!( + "memory root has no project parent: {}", + memory_root.display() + ) + })?; + let project_root = if project_root.as_os_str().is_empty() { + PathBuf::from(".") + } else { + project_root.to_path_buf() + }; + Ok(Self { + project_root, + memory_root, + }) + } + + pub fn from_project_root(project_root: impl Into) -> Self { + let project_root = project_root.into(); + Self { + memory_root: project_root.join(".tree-ring"), + project_root, + } + } +} + +/// Environment operations required for detection. Implementations must not write. +pub trait HarnessEnvironment { + fn executable_version(&self, command: &str) -> Option; + fn project_path_exists(&self, relative: &Path) -> bool; + fn read_project_file(&self, relative: &Path) -> Result, String>; + fn agent_zero_plugin_manifest(&self) -> Option; +} + +/// Minimal compatibility information supplied by the separate Agent Zero plugin. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AgentZeroPluginManifest { + pub plugin_id: String, + pub protocol_version: u16, + pub enabled: bool, +} + +impl AgentZeroPluginManifest { + pub fn compatible() -> Self { + Self { + plugin_id: AGENT_ZERO_PLUGIN_ID.to_string(), + protocol_version: ACTIVATION_PROTOCOL_VERSION, + enabled: true, + } + } + + fn is_compatible(&self) -> bool { + self.enabled + && self.plugin_id == AGENT_ZERO_PLUGIN_ID + && self.protocol_version == ACTIVATION_PROTOCOL_VERSION + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct IntegrationMarker { + pub path: String, + pub origin: MarkerOrigin, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MarkerOrigin { + Home, + Project, +} + +impl MarkerOrigin { + pub fn as_str(self) -> &'static str { + match self { + Self::Project => "project", + Self::Home => "home", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IntegrationStatus { + Detected, + Available, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BridgeWrite { + pub path: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManagedBlockUpdate { + pub path: PathBuf, + pub block_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PlannedWrite { + BridgeWrite(BridgeWrite), + ManagedBlockUpdate(ManagedBlockUpdate), +} + +/// A declarative plan; applying it is intentionally owned by a later bridge layer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AdapterPlan { + pub harness_id: String, + pub state: ActivationState, + pub writes: Vec, + pub next_step: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DeactivationPlan { + pub harness_id: String, + pub state: ActivationState, + pub owned_paths: Vec, + pub next_step: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AdapterDetection { + pub id: String, + pub name: String, + pub capability: AdapterCapability, + #[serde(skip_serializing_if = "Option::is_none")] + pub executable_version: Option, + pub status: IntegrationStatus, + pub state: ActivationState, + pub markers: Vec, + pub plan: AdapterPlan, + pub next_step: String, +} + +impl AdapterDetection { + pub fn is_candidate(&self) -> bool { + self.status == IntegrationStatus::Detected + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct IntegrationScanReport { + pub root: PathBuf, + pub detected_count: usize, + pub integrations: Vec, +} + +impl IntegrationScanReport { + pub fn by_id(&self, id: &str) -> Option<&AdapterDetection> { + self.integrations + .iter() + .find(|detection| detection.id == id) + } +} + +pub type AgentIntegration = AdapterDetection; + +pub trait HarnessAdapter: Sync { + fn id(&self) -> &'static str; + fn display_name(&self) -> &'static str; + fn capability(&self) -> AdapterCapability; + fn detect(&self, project: &ActivationProject, env: &dyn HarnessEnvironment) + -> AdapterDetection; + fn plan(&self, project: &ActivationProject, detection: &AdapterDetection) -> AdapterPlan; +} + +#[derive(Debug, Clone, Copy)] +struct DeclarativeAdapter { + id: &'static str, + display_name: &'static str, + command: &'static str, + capability: AdapterCapability, + markers: &'static [&'static str], + support: AdapterSupport, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AdapterSupport { + Maintained, + AgentZero, + Unsupported, +} + +const ADAPTERS: [DeclarativeAdapter; 7] = [ + DeclarativeAdapter { + id: "codex", + display_name: "Codex", + command: "codex", + capability: AdapterCapability::WrapperPreflight, + markers: &[".codex", "AGENTS.md"], + support: AdapterSupport::Maintained, + }, + DeclarativeAdapter { + id: "claude-code", + display_name: "Claude Code", + command: "claude", + capability: AdapterCapability::NativePreflight, + markers: &[".claude", "CLAUDE.md"], + support: AdapterSupport::Maintained, + }, + DeclarativeAdapter { + id: "pi", + display_name: "Pi", + command: "pi", + capability: AdapterCapability::NativePreflight, + markers: &[".pi", "pi.toml"], + support: AdapterSupport::Maintained, + }, + DeclarativeAdapter { + id: "agent-zero", + display_name: "Agent Zero / A0", + command: "agent-zero", + capability: AdapterCapability::NativePreflight, + markers: &[".a0", "agent-zero", "a0"], + support: AdapterSupport::AgentZero, + }, + DeclarativeAdapter { + id: "hermes", + display_name: "Hermes", + command: "hermes", + capability: AdapterCapability::GuidanceOnly, + markers: &[".hermes", "hermes.toml"], + support: AdapterSupport::Unsupported, + }, + DeclarativeAdapter { + id: "opencode", + display_name: "OpenCode", + command: "opencode", + capability: AdapterCapability::GuidanceOnly, + markers: &[".opencode", "opencode.json", "opencode.toml"], + support: AdapterSupport::Unsupported, + }, + DeclarativeAdapter { + id: "goose", + display_name: "Goose", + command: "goose", + capability: AdapterCapability::GuidanceOnly, + markers: &[".goose", "goosehints"], + support: AdapterSupport::Unsupported, + }, +]; + +/// The four adapters with a maintained activation contract. +pub fn maintained_adapters() -> Vec<&'static dyn HarnessAdapter> { + ADAPTERS + .iter() + .filter(|adapter| adapter.support != AdapterSupport::Unsupported) + .map(|adapter| adapter as &dyn HarnessAdapter) + .collect() +} + +fn registered_adapters() -> impl Iterator { + ADAPTERS.iter() +} + +/// Detect candidate harnesses without mutating either the project or its store. +pub fn detect_adapters( + project: &ActivationProject, + env: &dyn HarnessEnvironment, +) -> IntegrationScanReport { + let integrations = registered_adapters() + .map(|adapter| adapter.detect(project, env)) + .collect::>(); + let detected_count = integrations + .iter() + .filter(|detection| detection.is_candidate()) + .count(); + IntegrationScanReport { + root: project.project_root.clone(), + detected_count, + integrations, + } +} + +/// Read-only compatibility alias for `tree-ring integrations scan`. +pub fn scan_integrations(root: &Path) -> IntegrationScanReport { + let project = ActivationProject::from_project_root(root); + detect_adapters( + &project, + &LocalHarnessEnvironment::new(project.project_root.clone()), + ) +} + +pub fn format_markers(markers: &[IntegrationMarker]) -> String { + markers + .iter() + .map(|marker| format!("{}:{}", marker.origin.as_str(), marker.path)) + .collect::>() + .join(", ") +} + +pub fn plan_activation(id: &str, project: &ActivationProject) -> Result { + plan_activation_with_environment(id, project, &EmptyHarnessEnvironment) +} + +pub fn plan_activation_with_environment( + id: &str, + project: &ActivationProject, + env: &dyn HarnessEnvironment, +) -> Result { + let adapter = registered_adapters() + .find(|adapter| adapter.id == id) + .ok_or_else(|| format!("unknown harness adapter: {id}"))?; + let detection = adapter.detect(project, env); + Ok(adapter.plan(project, &detection)) +} + +pub fn plan_deactivation( + id: &str, + _project: &ActivationProject, +) -> Result { + let adapter = registered_adapters() + .find(|adapter| adapter.id == id) + .ok_or_else(|| format!("unknown harness adapter: {id}"))?; + let state = if adapter.support == AdapterSupport::Unsupported { + ActivationState::Unsupported + } else { + ActivationState::ConfiguredAwaitingProof + }; + Ok(DeactivationPlan { + harness_id: id.to_string(), + state, + owned_paths: adapter_owned_paths(adapter), + next_step: "A later bridge layer removes only manifest-recorded Tree Ring-owned material." + .to_string(), + }) +} + +impl HarnessAdapter for DeclarativeAdapter { + fn id(&self) -> &'static str { + self.id + } + + fn display_name(&self) -> &'static str { + self.display_name + } + + fn capability(&self) -> AdapterCapability { + self.capability + } + + fn detect( + &self, + project: &ActivationProject, + env: &dyn HarnessEnvironment, + ) -> AdapterDetection { + let mut markers = self + .markers + .iter() + .filter(|marker| env.project_path_exists(Path::new(marker))) + .map(|marker| IntegrationMarker { + path: project.project_root.join(marker).display().to_string(), + origin: MarkerOrigin::Project, + }) + .collect::>(); + markers.sort(); + markers.dedup(); + + let state = match self.support { + AdapterSupport::Unsupported => ActivationState::Unsupported, + AdapterSupport::AgentZero => match env.agent_zero_plugin_manifest() { + Some(manifest) if manifest.is_compatible() => { + ActivationState::ConfiguredAwaitingProof + } + _ => ActivationState::NeedsPlugin, + }, + AdapterSupport::Maintained => ActivationState::ConfiguredAwaitingProof, + }; + let status = if markers.is_empty() { + IntegrationStatus::Available + } else { + IntegrationStatus::Detected + }; + let mut detection = AdapterDetection { + id: self.id.to_string(), + name: self.display_name.to_string(), + capability: self.capability, + executable_version: env.executable_version(self.command), + status, + state, + markers, + plan: AdapterPlan { + harness_id: self.id.to_string(), + state, + writes: Vec::new(), + next_step: next_step(self.support, state).to_string(), + }, + next_step: next_step(self.support, state).to_string(), + }; + detection.plan = self.plan(project, &detection); + detection + } + + fn plan(&self, _project: &ActivationProject, detection: &AdapterDetection) -> AdapterPlan { + let writes = match detection.state { + ActivationState::Unsupported | ActivationState::NeedsPlugin => Vec::new(), + _ => adapter_writes(self), + }; + AdapterPlan { + harness_id: self.id.to_string(), + state: detection.state, + writes, + next_step: next_step(self.support, detection.state).to_string(), + } + } +} + +fn next_step(support: AdapterSupport, state: ActivationState) -> &'static str { + match (support, state) { + (AdapterSupport::AgentZero, ActivationState::NeedsPlugin) => { + "Install or enable the compatible separate tree_ring_memory Agent Zero plugin." + } + (AdapterSupport::Unsupported, _) => { + "Author and register a native harness adapter before claiming Tree Ring activation." + } + _ => "Apply the reviewed bridge plan, then complete adapter-specific preflight and receipt verification.", + } +} + +fn adapter_writes(adapter: &DeclarativeAdapter) -> Vec { + match adapter.id { + "codex" => vec![ + bridge_write(".agents/skills/tree-ring-memory/SKILL.md"), + managed_block("AGENTS.md", "codex"), + ], + "claude-code" => vec![ + bridge_write(".claude/skills/tree-ring-memory/SKILL.md"), + managed_block(".claude/settings.json", "claude-code"), + ], + "pi" => vec![ + bridge_write(".agents/skills/tree-ring-memory/SKILL.md"), + bridge_write(".pi/extensions/tree-ring-memory.ts"), + ], + "agent-zero" => vec![bridge_write(".tree-ring/activation/agent-zero.json")], + _ => Vec::new(), + } +} + +fn adapter_owned_paths(adapter: &DeclarativeAdapter) -> Vec { + adapter_writes(adapter) + .into_iter() + .map(|write| match write { + PlannedWrite::BridgeWrite(write) => write.path, + PlannedWrite::ManagedBlockUpdate(write) => write.path, + }) + .collect() +} + +fn bridge_write(path: &str) -> PlannedWrite { + PlannedWrite::BridgeWrite(BridgeWrite { + path: normalized_relative_path(path).expect("static bridge paths are normalized"), + }) +} + +fn managed_block(path: &str, block_id: &str) -> PlannedWrite { + PlannedWrite::ManagedBlockUpdate(ManagedBlockUpdate { + path: normalized_relative_path(path).expect("static bridge paths are normalized"), + block_id: block_id.to_string(), + }) +} + +fn normalized_relative_path(path: &str) -> Result { + let path = Path::new(path); + if path.as_os_str().is_empty() + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err("adapter plan paths must be normalized project-relative paths".to_string()); + } + Ok(path.to_path_buf()) +} + +struct EmptyHarnessEnvironment; + +impl HarnessEnvironment for EmptyHarnessEnvironment { + fn executable_version(&self, _command: &str) -> Option { + None + } + + fn project_path_exists(&self, _relative: &Path) -> bool { + false + } + + fn read_project_file(&self, _relative: &Path) -> Result, String> { + Ok(None) + } + + fn agent_zero_plugin_manifest(&self) -> Option { + None + } +} + +struct LocalHarnessEnvironment { + project_root: PathBuf, +} + +impl LocalHarnessEnvironment { + fn new(project_root: PathBuf) -> Self { + Self { project_root } + } +} + +impl HarnessEnvironment for LocalHarnessEnvironment { + fn executable_version(&self, command: &str) -> Option { + Command::new(command) + .arg("--version") + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|version| version.trim().to_string()) + .filter(|version| !version.is_empty()) + } + + fn project_path_exists(&self, relative: &Path) -> bool { + self.project_root.join(relative).exists() + } + + fn read_project_file(&self, relative: &Path) -> Result, String> { + let path = self.project_root.join(relative); + match std::fs::read_to_string(&path) { + Ok(content) => Ok(Some(content)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("failed to read {}: {error}", path.display())), + } + } + + fn agent_zero_plugin_manifest(&self) -> Option { + // Core does not infer a plugin manifest from a generic project marker. + // The separate plugin supplies this capability through a future runtime + // environment; until then scan must remain conservatively needs-plugin. + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::{BTreeMap, BTreeSet}; + + #[derive(Default)] + struct FakeEnvironment { + executable_versions: BTreeMap, + paths: BTreeSet, + files: BTreeMap, + agent_zero: Option, + } + + impl HarnessEnvironment for FakeEnvironment { + fn executable_version(&self, command: &str) -> Option { + self.executable_versions.get(command).cloned() + } + + fn project_path_exists(&self, relative: &Path) -> bool { + self.paths.contains(relative) + } + + fn read_project_file(&self, relative: &Path) -> Result, String> { + Ok(self.files.get(relative).cloned()) + } + + fn agent_zero_plugin_manifest(&self) -> Option { + self.agent_zero.clone() + } + } + + fn project() -> ActivationProject { + ActivationProject::from_memory_root("/tmp/tree-ring-project/.tree-ring").unwrap() + } + + #[test] + fn an_empty_codex_marker_is_detected_as_a_candidate_but_never_active() { + let mut env = FakeEnvironment::default(); + env.paths.insert(PathBuf::from(".codex")); + + let report = detect_adapters(&project(), &env); + let codex = report.by_id("codex").unwrap(); + + assert!(codex.is_candidate()); + assert_ne!(codex.state, ActivationState::Active); + assert!(!codex.plan.writes.is_empty()); + } + + #[test] + fn an_available_executable_never_transitions_a_harness_to_active() { + let mut env = FakeEnvironment::default(); + env.executable_versions + .insert("codex".to_string(), "1.2.3".to_string()); + + let report = detect_adapters(&project(), &env); + let codex = report.by_id("codex").unwrap(); + + assert_eq!(codex.executable_version.as_deref(), Some("1.2.3")); + assert_ne!(codex.state, ActivationState::Active); + assert_eq!(codex.state, ActivationState::ConfiguredAwaitingProof); + } + + #[test] + fn unknown_harnesses_are_explicitly_unsupported_without_bridge_writes() { + let plan = plan_activation("hermes", &project()).unwrap(); + assert_eq!(plan.state, ActivationState::Unsupported); + assert!(plan.writes.is_empty()); + } + + #[test] + fn missing_agent_zero_plugin_requires_the_separate_plugin_without_core_mutation() { + let detection = detect_adapters(&project(), &FakeEnvironment::default()); + let agent_zero = detection.by_id("agent-zero").unwrap(); + assert_eq!(agent_zero.state, ActivationState::NeedsPlugin); + assert!(agent_zero.plan.writes.is_empty()); + } + + #[test] + fn a_compatible_agent_zero_plugin_only_receives_its_protocol_binding_plan() { + let env = FakeEnvironment { + agent_zero: Some(AgentZeroPluginManifest::compatible()), + ..FakeEnvironment::default() + }; + let agent_zero = detect_adapters(&project(), &env) + .by_id("agent-zero") + .unwrap() + .clone(); + assert_eq!(agent_zero.state, ActivationState::ConfiguredAwaitingProof); + assert_eq!( + agent_zero.plan.writes, + vec![bridge_write(".tree-ring/activation/agent-zero.json")] + ); + } + + #[test] + fn registry_has_exactly_four_maintained_adapters_and_three_explicitly_unsupported_ones() { + let maintained = maintained_adapters(); + assert_eq!(maintained.len(), 4); + assert_eq!( + maintained + .iter() + .map(|adapter| adapter.id()) + .collect::>(), + vec!["codex", "claude-code", "pi", "agent-zero"] + ); + for id in ["hermes", "opencode", "goose"] { + assert_eq!( + plan_activation(id, &project()).unwrap().state, + ActivationState::Unsupported + ); + } + } + + #[test] + fn project_root_is_derived_from_the_memory_root() { + let project = ActivationProject::from_memory_root("workspace/.tree-ring").unwrap(); + assert_eq!(project.project_root, PathBuf::from("workspace")); + assert_eq!( + ActivationProject::from_memory_root(".tree-ring") + .unwrap() + .project_root, + PathBuf::from(".") + ); + assert_eq!( + ActivationProject::from_memory_root("/").unwrap_err(), + "memory root has no project parent: /" + ); + } + + #[test] + fn plans_only_use_normalized_project_relative_paths() { + for adapter in maintained_adapters() { + let plan = plan_activation(adapter.id(), &project()).unwrap(); + for write in plan.writes { + let path = match write { + PlannedWrite::BridgeWrite(write) => write.path, + PlannedWrite::ManagedBlockUpdate(write) => write.path, + }; + assert!(!path.is_absolute()); + assert!(!path + .components() + .any(|component| component == Component::ParentDir)); + } + } + } +} diff --git a/crates/tree-ring-memory-cli/src/activation/mod.rs b/crates/tree-ring-memory-cli/src/activation/mod.rs index d9850d1..ea6241f 100644 --- a/crates/tree-ring-memory-cli/src/activation/mod.rs +++ b/crates/tree-ring-memory-cli/src/activation/mod.rs @@ -34,8 +34,10 @@ pub struct SessionIdentity { pub session_id: String, } +pub mod adapters; pub mod manifest; +#[allow(unused_imports)] pub use manifest::{ load_manifest, load_or_create_manifest, prune_receipts, write_receipt, ActivationManifest, ActivationReceipt, HarnessActivation, diff --git a/crates/tree-ring-memory-cli/src/harness_evidence.rs b/crates/tree-ring-memory-cli/src/harness_evidence.rs index d5e4fe6..0de365e 100644 --- a/crates/tree-ring-memory-cli/src/harness_evidence.rs +++ b/crates/tree-ring-memory-cli/src/harness_evidence.rs @@ -1,11 +1,13 @@ use crate::evidence::{ atomic_write, publish_indexed_evidence, rollup_index_status, EvidenceRecordRef, EvidenceStatus, }; -use crate::integrations::{scan_integrations, IntegrationMarker, MarkerOrigin}; use chrono::{SecondsFormat, Utc}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; +use tree_ring_memory_cli::activation::adapters::{ + scan_integrations, AdapterDetection, IntegrationMarker, MarkerOrigin, +}; pub const CERTIFIED_HARNESS_IDS: &[&str] = &[ "codex", @@ -108,7 +110,7 @@ pub fn certify_harnesses( } fn probe_record( - integration: &crate::integrations::AgentIntegration, + integration: &AdapterDetection, source_root: &Path, generated_at: &str, guidance: &HarnessGuidanceEvidence, @@ -430,16 +432,24 @@ mod tests { recall_guidance: false, remember_guidance: false, }; - let integration = crate::integrations::AgentIntegration { - id: "claude-code", - name: "Claude Code", - status: crate::integrations::IntegrationStatus::Detected, - confidence: 0.7, + let integration = AdapterDetection { + id: "claude-code".to_string(), + name: "Claude Code".to_string(), + capability: tree_ring_memory_cli::activation::AdapterCapability::NativePreflight, + executable_version: None, + status: tree_ring_memory_cli::activation::adapters::IntegrationStatus::Detected, + state: tree_ring_memory_cli::activation::ActivationState::ConfiguredAwaitingProof, markers: vec![IntegrationMarker { path: "/Users/test/.claude".to_string(), origin: MarkerOrigin::Home, }], - next_step: "Reference `.tree-ring/SKILL.md` from `CLAUDE.md` or `.claude` project instructions.", + plan: tree_ring_memory_cli::activation::adapters::AdapterPlan { + harness_id: "claude-code".to_string(), + state: tree_ring_memory_cli::activation::ActivationState::ConfiguredAwaitingProof, + writes: Vec::new(), + next_step: "Reference `.tree-ring/SKILL.md` from `CLAUDE.md` or `.claude` project instructions.".to_string(), + }, + next_step: "Reference `.tree-ring/SKILL.md` from `CLAUDE.md` or `.claude` project instructions.".to_string(), }; let record = probe_record(&integration, source_root, generated_at, &guidance); diff --git a/crates/tree-ring-memory-cli/src/integrations.rs b/crates/tree-ring-memory-cli/src/integrations.rs deleted file mode 100644 index 5031686..0000000 --- a/crates/tree-ring-memory-cli/src/integrations.rs +++ /dev/null @@ -1,299 +0,0 @@ -use serde::Serialize; -use std::env; -use std::path::{Path, PathBuf}; - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct IntegrationScanReport { - pub root: PathBuf, - pub detected_count: usize, - pub integrations: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct AgentIntegration { - pub id: &'static str, - pub name: &'static str, - pub status: IntegrationStatus, - pub confidence: f64, - pub markers: Vec, - pub next_step: &'static str, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] -pub struct IntegrationMarker { - pub path: String, - pub origin: MarkerOrigin, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum MarkerOrigin { - Home, - Project, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum IntegrationStatus { - Detected, - Available, -} - -pub fn scan_integrations(root: &Path) -> IntegrationScanReport { - let home = env::var_os("HOME").map(PathBuf::from); - scan_integrations_with_home(root, home.as_deref()) -} - -pub fn format_markers(markers: &[IntegrationMarker]) -> String { - markers - .iter() - .map(|marker| format!("{}:{}", marker.origin.as_str(), marker.path)) - .collect::>() - .join(", ") -} - -fn scan_integrations_with_home(root: &Path, home: Option<&Path>) -> IntegrationScanReport { - let mut integrations = vec![ - detect( - "dox", - "DOX / AGENTS.md", - root, - home, - &["AGENTS.md"], - &[], - "Run `tree-ring dox sync --source-root . --dry-run`, then sync when the preview looks right.", - ), - detect( - "revolve", - "Revolve", - root, - home, - &["revolve", ".revolve"], - &[], - "Run `tree-ring revolve sync --source-root revolve --dry-run` to import promoted lessons, scars, and seeds.", - ), - detect( - "codex", - "Codex", - root, - home, - &[".codex", "AGENTS.md"], - &[".codex"], - "Reference `.tree-ring/SKILL.md` and `.tree-ring/CLI.md` from project guidance.", - ), - detect( - "claude-code", - "Claude Code", - root, - home, - &[".claude", "CLAUDE.md"], - &[".claude"], - "Reference `.tree-ring/SKILL.md` from `CLAUDE.md` or `.claude` project instructions.", - ), - detect( - "agent-zero", - "Agent Zero / A0", - root, - home, - &["usr/plugins", "a0", "agent-zero", ".a0"], - &[".a0"], - "Use the generated skill/CLI guidance, or bridge via an Agent Zero plugin without modifying core code.", - ), - detect( - "goose", - "Goose", - root, - home, - &[".goose", "goosehints"], - &[".goose"], - "Add Tree Ring Memory recall and remember commands to Goose project instructions.", - ), - detect( - "opencode", - "OpenCode", - root, - home, - &[".opencode", "opencode.json", "opencode.toml"], - &[".opencode"], - "Reference the Tree Ring CLI from OpenCode project configuration or instructions.", - ), - detect( - "hermes", - "Hermes", - root, - home, - &[".hermes", "hermes.toml"], - &[".hermes"], - "Reference Tree Ring Memory as a local CLI memory lifecycle layer.", - ), - detect( - "pi", - "Pi", - root, - home, - &[".pi", "pi.toml"], - &[".pi"], - "Use the generated portable skill and CLI reference as project instructions.", - ), - ]; - integrations.sort_by(|left, right| { - right - .confidence - .partial_cmp(&left.confidence) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| left.name.cmp(right.name)) - }); - let detected_count = integrations - .iter() - .filter(|integration| integration.status == IntegrationStatus::Detected) - .count(); - IntegrationScanReport { - root: root.to_path_buf(), - detected_count, - integrations, - } -} - -impl MarkerOrigin { - pub fn as_str(self) -> &'static str { - match self { - Self::Project => "project", - Self::Home => "home", - } - } -} - -fn detect( - id: &'static str, - name: &'static str, - root: &Path, - home: Option<&Path>, - project_markers: &[&str], - home_markers: &[&str], - next_step: &'static str, -) -> AgentIntegration { - let mut markers = Vec::new(); - for marker in project_markers { - let path = root.join(marker); - if path.exists() { - markers.push(IntegrationMarker { - path: path.display().to_string(), - origin: MarkerOrigin::Project, - }); - } - } - if let Some(home) = home { - for marker in home_markers { - let path = home.join(marker); - if path.exists() { - markers.push(IntegrationMarker { - path: path.display().to_string(), - origin: MarkerOrigin::Home, - }); - } - } - } - markers.sort(); - markers.dedup(); - let confidence = if markers.is_empty() { - 0.0 - } else { - (0.55 + markers.len() as f64 * 0.15).min(0.95) - }; - AgentIntegration { - id, - name, - status: if markers.is_empty() { - IntegrationStatus::Available - } else { - IntegrationStatus::Detected - }, - confidence, - markers, - next_step, - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use tempfile::tempdir; - - use super::*; - - #[test] - fn detects_dox_revolve_and_claude_project_markers() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("AGENTS.md"), "# Project rules").unwrap(); - fs::write(dir.path().join("CLAUDE.md"), "# Claude instructions").unwrap(); - fs::create_dir_all(dir.path().join("revolve")).unwrap(); - - let report = scan_integrations(dir.path()); - - assert!(report.detected_count >= 3); - assert!(detected(&report, "dox")); - assert!(detected(&report, "revolve")); - assert!(detected(&report, "claude-code")); - } - - #[test] - fn marks_project_and_home_marker_origins() { - let project = tempdir().unwrap(); - let home = tempdir().unwrap(); - fs::write(project.path().join("CLAUDE.md"), "# Claude instructions").unwrap(); - fs::create_dir_all(project.path().join(".codex")).unwrap(); - fs::create_dir_all(home.path().join(".claude")).unwrap(); - - let report = scan_integrations_with_home(project.path(), Some(home.path())); - - let claude = integration(&report, "claude-code"); - assert!(claude - .markers - .iter() - .any(|marker| marker.origin == MarkerOrigin::Project - && marker.path.ends_with("CLAUDE.md"))); - assert!(claude - .markers - .iter() - .any(|marker| marker.origin == MarkerOrigin::Home && marker.path.ends_with(".claude"))); - - let codex = integration(&report, "codex"); - assert!(codex.markers.iter().any( - |marker| marker.origin == MarkerOrigin::Project && marker.path.ends_with(".codex") - )); - assert!(!codex - .markers - .iter() - .any(|marker| marker.origin == MarkerOrigin::Home)); - } - - #[test] - fn unavailable_integrations_still_include_onboarding_next_steps() { - let dir = tempdir().unwrap(); - - let report = scan_integrations(dir.path()); - - let opencode = report - .integrations - .iter() - .find(|integration| integration.id == "opencode") - .unwrap(); - assert_eq!(opencode.status, IntegrationStatus::Available); - assert!(opencode.next_step.contains("Tree Ring")); - } - - fn detected(report: &IntegrationScanReport, id: &str) -> bool { - report.integrations.iter().any(|integration| { - integration.id == id && integration.status == IntegrationStatus::Detected - }) - } - - fn integration<'a>(report: &'a IntegrationScanReport, id: &str) -> &'a AgentIntegration { - report - .integrations - .iter() - .find(|integration| integration.id == id) - .unwrap() - } -} diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs index d347642..55273a9 100644 --- a/crates/tree-ring-memory-cli/src/main.rs +++ b/crates/tree-ring-memory-cli/src/main.rs @@ -1,6 +1,7 @@ use clap::{Parser, Subcommand}; use std::ffi::OsString; use std::path::{Path, PathBuf}; +use tree_ring_memory_cli::activation; use tree_ring_memory_core::sensitivity::SensitivityGuard; use tree_ring_memory_core::{ AuditReport, ConsolidationReport, DoxSyncReport, MaintenanceReport, RevolveSyncReport, @@ -34,7 +35,6 @@ mod agent_awareness; mod commands; mod evidence; mod harness_evidence; -mod integrations; mod recall_quality; mod ring_mark; mod tui; @@ -1408,7 +1408,7 @@ fn print_revolve_report( } fn print_integration_report( - report: &integrations::IntegrationScanReport, + report: &activation::adapters::IntegrationScanReport, json_output: bool, ) -> Result<(), String> { if json_output { @@ -1427,13 +1427,13 @@ fn print_integration_report( ); for integration in &report.integrations { println!( - "{} [{:?}] confidence={:.2}", - integration.name, integration.status, integration.confidence + "{} [{:?}] activation={:?}", + integration.name, integration.status, integration.state ); if !integration.markers.is_empty() { println!( " markers: {}", - integrations::format_markers(&integration.markers) + activation::adapters::format_markers(&integration.markers) ); } println!(" next: {}", integration.next_step); @@ -2226,6 +2226,7 @@ mod tests { let dir = tempdir().unwrap(); fs::write(dir.path().join("AGENTS.md"), "# Rules").unwrap(); fs::create_dir_all(dir.path().join("revolve")).unwrap(); + fs::create_dir_all(dir.path().join(".codex")).unwrap(); let root = dir.path().join(".tree-ring"); run(Cli { @@ -2240,8 +2241,12 @@ mod tests { .unwrap(); assert!(!root.exists()); - let report = integrations::scan_integrations(dir.path()); - assert!(report.detected_count >= 2); + let report = activation::adapters::scan_integrations(dir.path()); + assert_eq!(report.detected_count, 1); + assert_ne!( + report.by_id("codex").unwrap().state, + activation::ActivationState::Active + ); } #[test] diff --git a/crates/tree-ring-memory-cli/src/tui/app.rs b/crates/tree-ring-memory-cli/src/tui/app.rs index a7a4d8c..0faf99e 100644 --- a/crates/tree-ring-memory-cli/src/tui/app.rs +++ b/crates/tree-ring-memory-cli/src/tui/app.rs @@ -8,7 +8,7 @@ use crate::actions::export_import::{export_jsonl, ExportActionRequest}; use crate::actions::integrations::{scan as scan_integrations_action, IntegrationScanRequest}; use crate::actions::remember::{remember, RememberRequest}; use crate::evidence::{certification_dir_for_project, load_snapshot, EvidenceSnapshot}; -use crate::integrations::IntegrationScanReport; +use tree_ring_memory_cli::activation::adapters::IntegrationScanReport; use super::actions::{ActionKind, PendingAction}; use super::input::{parse_slash_command, SlashCommand}; @@ -932,13 +932,14 @@ mod tests { let dir = tempdir().unwrap(); fs::write(dir.path().join("AGENTS.md"), "# Rules").unwrap(); fs::create_dir_all(dir.path().join("revolve")).unwrap(); + fs::create_dir_all(dir.path().join(".codex")).unwrap(); let mut app = app(&dir); app.execute_slash_command("/integrations").unwrap(); assert_eq!(app.mode, AppMode::Integrations); let report = app.integration_report.as_ref().unwrap(); - assert!(report.detected_count >= 2); + assert_eq!(report.detected_count, 1); assert!(app.status.contains("integration scan")); } diff --git a/crates/tree-ring-memory-cli/src/tui/render.rs b/crates/tree-ring-memory-cli/src/tui/render.rs index 4946af9..e807ef5 100644 --- a/crates/tree-ring-memory-cli/src/tui/render.rs +++ b/crates/tree-ring-memory-cli/src/tui/render.rs @@ -283,8 +283,8 @@ fn render_integrations(frame: &mut Frame<'_>, area: Rect, app: &App) { .integrations .iter() .map(|integration| { - let detected = - integration.status == crate::integrations::IntegrationStatus::Detected; + let detected = integration.status + == tree_ring_memory_cli::activation::adapters::IntegrationStatus::Detected; let marker = if detected { "*" } else { " " }; let style = if detected { theme::selected() @@ -295,7 +295,7 @@ fn render_integrations(frame: &mut Frame<'_>, area: Rect, app: &App) { Span::styled(marker, theme::secondary_accent()), Span::styled(format!(" {:<18}", integration.name), style), Span::styled( - format!(" {:?} {:.2}", integration.status, integration.confidence), + format!(" {:?} {:?}", integration.status, integration.state), theme::dim(), ), ])) @@ -461,13 +461,15 @@ fn render_detail(frame: &mut Frame<'_>, area: Rect, app: &App) { Span::styled(format!("{} ", integration.name), theme::brand()), Span::styled(format!("{:?}", integration.status), theme::dim()), ])); - lines.push(Line::from(truncate(integration.next_step, 140))); + lines.push(Line::from(truncate(&integration.next_step, 140))); if !integration.markers.is_empty() { lines.push(Line::from(Span::styled( truncate( &format!( "markers: {}", - crate::integrations::format_markers(&integration.markers) + tree_ring_memory_cli::activation::adapters::format_markers( + &integration.markers, + ) ), 140, ), From 94f1c6ab725d8c60655ebe04c313b4cee30da5da Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 13 Aug 2026 22:44:29 -0400 Subject: [PATCH 05/31] fix: preserve non-active harness boundaries --- .../src/activation/adapters.rs | 117 +++++++++++++++--- .../src/harness_evidence.rs | 92 +++++++++++++- crates/tree-ring-memory-cli/src/main.rs | 2 +- 3 files changed, 188 insertions(+), 23 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/activation/adapters.rs b/crates/tree-ring-memory-cli/src/activation/adapters.rs index 463820a..cb852c0 100644 --- a/crates/tree-ring-memory-cli/src/activation/adapters.rs +++ b/crates/tree-ring-memory-cli/src/activation/adapters.rs @@ -133,10 +133,18 @@ pub struct AdapterPlan { pub struct DeactivationPlan { pub harness_id: String, pub state: ActivationState, - pub owned_paths: Vec, + pub operations: Vec, pub next_step: String, } +/// Removal work retained with the same ownership granularity as activation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum DeactivationOperation { + BridgeWrite(BridgeWrite), + ManagedBlockUpdate(ManagedBlockUpdate), +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct AdapterDetection { pub id: String, @@ -326,20 +334,30 @@ pub fn plan_activation_with_environment( pub fn plan_deactivation( id: &str, - _project: &ActivationProject, + project: &ActivationProject, +) -> Result { + plan_deactivation_with_environment(id, project, &EmptyHarnessEnvironment) +} + +pub fn plan_deactivation_with_environment( + id: &str, + project: &ActivationProject, + env: &dyn HarnessEnvironment, ) -> Result { let adapter = registered_adapters() .find(|adapter| adapter.id == id) .ok_or_else(|| format!("unknown harness adapter: {id}"))?; - let state = if adapter.support == AdapterSupport::Unsupported { - ActivationState::Unsupported - } else { - ActivationState::ConfiguredAwaitingProof - }; + let detection = adapter.detect(project, env); + let operations = adapter + .plan(project, &detection) + .writes + .into_iter() + .map(DeactivationOperation::from) + .collect(); Ok(DeactivationPlan { harness_id: id.to_string(), - state, - owned_paths: adapter_owned_paths(adapter), + state: detection.state, + operations, next_step: "A later bridge layer removes only manifest-recorded Tree Ring-owned material." .to_string(), }) @@ -368,7 +386,10 @@ impl HarnessAdapter for DeclarativeAdapter { .iter() .filter(|marker| env.project_path_exists(Path::new(marker))) .map(|marker| IntegrationMarker { - path: project.project_root.join(marker).display().to_string(), + path: normalized_relative_path(marker) + .expect("static marker paths are normalized") + .display() + .to_string(), origin: MarkerOrigin::Project, }) .collect::>(); @@ -455,14 +476,13 @@ fn adapter_writes(adapter: &DeclarativeAdapter) -> Vec { } } -fn adapter_owned_paths(adapter: &DeclarativeAdapter) -> Vec { - adapter_writes(adapter) - .into_iter() - .map(|write| match write { - PlannedWrite::BridgeWrite(write) => write.path, - PlannedWrite::ManagedBlockUpdate(write) => write.path, - }) - .collect() +impl From for DeactivationOperation { + fn from(write: PlannedWrite) -> Self { + match write { + PlannedWrite::BridgeWrite(write) => Self::BridgeWrite(write), + PlannedWrite::ManagedBlockUpdate(write) => Self::ManagedBlockUpdate(write), + } + } } fn bridge_write(path: &str) -> PlannedWrite { @@ -702,4 +722,65 @@ mod tests { } } } + + #[test] + fn detection_exposes_project_relative_markers_for_an_absolute_project_root() { + let mut env = FakeEnvironment::default(); + env.paths.insert(PathBuf::from(".codex")); + let project = + ActivationProject::from_memory_root("/private/tmp/tree-ring-project/.tree-ring") + .unwrap(); + + let report = detect_adapters(&project, &env); + let marker = report.by_id("codex").unwrap().markers.first().unwrap(); + + assert_eq!(marker.path, ".codex"); + assert!(!Path::new(&marker.path).is_absolute()); + } + + #[test] + fn deactivation_retains_managed_block_ownership() { + let plan = plan_deactivation("codex", &project()).unwrap(); + + assert_eq!(plan.state, ActivationState::ConfiguredAwaitingProof); + assert!(plan.operations.iter().any(|operation| matches!( + operation, + DeactivationOperation::BridgeWrite(BridgeWrite { path }) + if path == Path::new(".agents/skills/tree-ring-memory/SKILL.md") + ))); + assert!(plan.operations.iter().any(|operation| matches!( + operation, + DeactivationOperation::ManagedBlockUpdate(ManagedBlockUpdate { path, block_id }) + if path == Path::new("AGENTS.md") && block_id == "codex" + ))); + assert!(!plan.operations.iter().any(|operation| matches!( + operation, + DeactivationOperation::BridgeWrite(BridgeWrite { path }) if path == Path::new("AGENTS.md") + ))); + } + + #[test] + fn missing_agent_zero_plugin_blocks_deactivation_without_operations() { + let plan = plan_deactivation("agent-zero", &project()).unwrap(); + + assert_eq!(plan.state, ActivationState::NeedsPlugin); + assert!(plan.operations.is_empty()); + } + + #[test] + fn compatible_agent_zero_plugin_plans_only_its_binding_deactivation() { + let env = FakeEnvironment { + agent_zero: Some(AgentZeroPluginManifest::compatible()), + ..FakeEnvironment::default() + }; + let plan = plan_deactivation_with_environment("agent-zero", &project(), &env).unwrap(); + + assert_eq!(plan.state, ActivationState::ConfiguredAwaitingProof); + assert_eq!( + plan.operations, + vec![DeactivationOperation::BridgeWrite(BridgeWrite { + path: PathBuf::from(".tree-ring/activation/agent-zero.json"), + })] + ); + } } diff --git a/crates/tree-ring-memory-cli/src/harness_evidence.rs b/crates/tree-ring-memory-cli/src/harness_evidence.rs index 0de365e..08744a7 100644 --- a/crates/tree-ring-memory-cli/src/harness_evidence.rs +++ b/crates/tree-ring-memory-cli/src/harness_evidence.rs @@ -124,7 +124,19 @@ fn probe_record( .iter() .any(|marker| marker.origin == MarkerOrigin::Home); let guidance_ready = guidance.recall_guidance && guidance.remember_guidance; - let (status, summary, next_step) = if project_marker && guidance_ready { + let (status, summary, next_step) = if project_marker + && guidance_ready + && !activation_state_allows_compatibility_pass(integration.state) + { + ( + EvidenceStatus::Skip, + format!( + "{} has activation state {:?}; no active harness compatibility claim is made.", + integration.name, integration.state + ), + integration.next_step.to_string(), + ) + } else if project_marker && guidance_ready { ( EvidenceStatus::Pass, format!( @@ -177,6 +189,12 @@ fn probe_record( } } +fn activation_state_allows_compatibility_pass( + state: tree_ring_memory_cli::activation::ActivationState, +) -> bool { + state == tree_ring_memory_cli::activation::ActivationState::Active +} + fn inspect_guidance(source_root: &Path) -> HarnessGuidanceEvidence { let agents_md = existing_path(source_root.join(".tree-ring/AGENTS.md")); let skill_md = existing_path(source_root.join(".tree-ring/SKILL.md")); @@ -285,7 +303,7 @@ mod tests { } #[test] - fn harness_certification_passes_project_marker_with_generated_guidance() { + fn harness_certification_skips_non_active_project_marker_with_generated_guidance() { let dir = tempdir().unwrap(); std::fs::create_dir_all(dir.path().join(".codex")).unwrap(); std::fs::create_dir_all(dir.path().join(".tree-ring")).unwrap(); @@ -317,8 +335,11 @@ mod tests { .iter() .find(|record| record.harness_id == "codex") .unwrap(); - assert_eq!(codex.status, EvidenceStatus::Pass); - assert!(codex.summary.contains("project marker")); + assert_eq!(codex.status, EvidenceStatus::Skip); + assert!(codex.summary.contains("activation state")); + assert!(codex + .summary + .contains("no active harness compatibility claim")); assert!(codex.guidance.recall_guidance); assert!(codex.guidance.remember_guidance); assert!(evidence_dir.join("harness/codex.json").exists()); @@ -469,6 +490,69 @@ mod tests { ); } + #[test] + fn blocking_adapter_states_never_certify_marker_and_guidance_as_active() { + let guidance = HarnessGuidanceEvidence { + agents_md: Some(PathBuf::from(".tree-ring/AGENTS.md")), + skill_md: Some(PathBuf::from(".tree-ring/SKILL.md")), + cli_md: Some(PathBuf::from(".tree-ring/CLI.md")), + recall_guidance: true, + remember_guidance: true, + }; + for (id, state) in [ + ( + "agent-zero", + tree_ring_memory_cli::activation::ActivationState::NeedsPlugin, + ), + ( + "opencode", + tree_ring_memory_cli::activation::ActivationState::Unsupported, + ), + ( + "goose", + tree_ring_memory_cli::activation::ActivationState::Unsupported, + ), + ( + "codex", + tree_ring_memory_cli::activation::ActivationState::NeedsTrust, + ), + ( + "claude-code", + tree_ring_memory_cli::activation::ActivationState::ConfiguredAwaitingProof, + ), + ] { + let integration = AdapterDetection { + id: id.to_string(), + name: id.to_string(), + capability: tree_ring_memory_cli::activation::AdapterCapability::GuidanceOnly, + executable_version: None, + status: tree_ring_memory_cli::activation::adapters::IntegrationStatus::Detected, + state, + markers: vec![IntegrationMarker { + path: format!(".{id}"), + origin: MarkerOrigin::Project, + }], + plan: tree_ring_memory_cli::activation::adapters::AdapterPlan { + harness_id: id.to_string(), + state, + writes: Vec::new(), + next_step: "Resolve activation before certification.".to_string(), + }, + next_step: "Resolve activation before certification.".to_string(), + }; + + let record = probe_record(&integration, Path::new("/tmp/project"), "now", &guidance); + + assert_eq!(record.status, EvidenceStatus::Skip, "{id}"); + assert!(!record + .summary + .contains("has a project marker and generated")); + assert!(record + .summary + .contains("no active harness compatibility claim")); + } + } + #[test] fn harness_certification_rollup_keeps_certification_status_when_only_skips_are_present() { let index = crate::evidence::EvidenceIndex { diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs index 55273a9..864da38 100644 --- a/crates/tree-ring-memory-cli/src/main.rs +++ b/crates/tree-ring-memory-cli/src/main.rs @@ -1732,7 +1732,7 @@ mod tests { let index = fs::read_to_string(out_dir.join("evidence-index.json")).unwrap(); assert!(index.contains("\"codex\"")); let parsed: serde_json::Value = serde_json::from_str(&index).unwrap(); - assert_eq!(parsed["harness"]["codex"]["status"], "pass"); + assert_eq!(parsed["harness"]["codex"]["status"], "skip"); } #[test] From ec8953e75d4a0e6b8a49dae3ab550f4780d237f1 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 13 Aug 2026 22:59:23 -0400 Subject: [PATCH 06/31] feat: install reversible project harness bridges --- .../src/activation/adapters.rs | 1 + .../src/activation/bridge.rs | 1667 +++++++++++++++++ .../src/activation/manifest.rs | 53 +- .../src/activation/mod.rs | 5 +- 4 files changed, 1722 insertions(+), 4 deletions(-) create mode 100644 crates/tree-ring-memory-cli/src/activation/bridge.rs diff --git a/crates/tree-ring-memory-cli/src/activation/adapters.rs b/crates/tree-ring-memory-cli/src/activation/adapters.rs index cb852c0..522b192 100644 --- a/crates/tree-ring-memory-cli/src/activation/adapters.rs +++ b/crates/tree-ring-memory-cli/src/activation/adapters.rs @@ -404,6 +404,7 @@ impl HarnessAdapter for DeclarativeAdapter { } _ => ActivationState::NeedsPlugin, }, + AdapterSupport::Maintained if self.id == "pi" => ActivationState::NeedsTrust, AdapterSupport::Maintained => ActivationState::ConfiguredAwaitingProof, }; let status = if markers.is_empty() { diff --git a/crates/tree-ring-memory-cli/src/activation/bridge.rs b/crates/tree-ring-memory-cli/src/activation/bridge.rs new file mode 100644 index 0000000..83c128a --- /dev/null +++ b/crates/tree-ring-memory-cli/src/activation/bridge.rs @@ -0,0 +1,1667 @@ +use super::{ + adapters::{ActivationProject, AdapterPlan, ManagedBlockUpdate, PlannedWrite}, + manifest::{ + save_manifest, validate_manifest, validate_project_relative_path, ActivationManifest, + HarnessActivation, OwnedBridgeFile, OwnedManagedBlock, + }, + ActivationState, AdapterCapability, ACTIVATION_PROTOCOL_VERSION, +}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Component, Path, PathBuf}, +}; +use uuid::Uuid; + +const CODEX_RETRY: &str = "tree-ring integrations activate --harness codex --accept-managed-block"; +const CLAUDE_DESCRIPTION: &str = "Tree Ring Memory managed preflight v1"; +const CLAUDE_COMMAND: &str = "tree-ring --root .tree-ring integrations preflight --harness claude-code --input-json-stdin --context-format claude-session-start"; + +const SKILL_BRIDGE: &str = r#"--- +name: tree-ring-memory +description: Use the canonical project-local Tree Ring Memory guidance and preflight contract. +--- + +# Tree Ring Memory + +Read `.tree-ring/SKILL.md`, `.tree-ring/AGENTS.md`, and `.tree-ring/CLI.md` from this project. Before substantive work, use the project-local Tree Ring preflight interface for this harness. Treat configured guidance as non-active until a receipt-producing preflight succeeds. +"#; + +const CODEX_BLOCK_BODY: &str = r#"## Tree Ring Memory + +Read `.tree-ring/AGENTS.md`, `.tree-ring/SKILL.md`, and `.tree-ring/CLI.md` before substantive work. Use `tree-ring --root .tree-ring integrations preflight --harness codex` with the later preflight identity interface, and do not claim memory is active without a valid project-local recall receipt. +"#; + +const PI_EXTENSION: &str = r#"import { spawn } from "node:child_process"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +type PreflightResponse = { context: string }; + +function invokePreflight(cwd: string, input: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + "tree-ring", + [ + "--root", + ".tree-ring", + "integrations", + "preflight", + "--harness", + "pi", + "--input-json-stdin", + "--context-format", + "pi-before-agent-start", + ], + { cwd, stdio: ["pipe", "pipe", "pipe"] }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0) { + reject(new Error(stderr || `Tree Ring preflight exited with status ${code}`)); + return; + } + try { + const response = JSON.parse(stdout) as PreflightResponse; + if (typeof response.context !== "string") throw new Error("missing context"); + resolve(response); + } catch (error) { + reject(new Error(`Invalid Tree Ring preflight response: ${String(error)}`)); + } + }); + child.stdin.end(input); + }); +} + +function sensitivityRejected(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("sensitive-task-hint"); +} + +async function runPreflightWithStdin(cwd: string, input: string): Promise { + try { + return await invokePreflight(cwd, input); + } catch (error) { + if (!sensitivityRejected(error)) throw error; + const safeInput = { + ...(JSON.parse(input) as Record), + task_hint: "project startup constraints", + }; + return invokePreflight(cwd, JSON.stringify(safeInput)); + } +} + +export default function treeRingMemory(pi: ExtensionAPI) { + pi.on("before_agent_start", async (event, ctx) => { + const input = JSON.stringify({ + agent_profile: "pi", + workflow_id: ctx.sessionManager.getSessionFile() ?? "pi-ephemeral", + session_id: ctx.sessionManager.getSessionFile() ?? "pi-ephemeral", + task_hint: event.prompt, + }); + const response = await runPreflightWithStdin(ctx.cwd, input); + return { message: { customType: "tree-ring-preflight", content: response.context, display: false } }; + }); +} +"#; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgePlanResult { + pub state: ActivationState, + pub changed_paths: Vec, + pub next_step: String, +} + +#[derive(Debug, Clone)] +struct PreparedFile { + relative: PathBuf, + before: Option>, + after: Option>, +} + +#[derive(Debug)] +enum Preparation { + Ready { + files: Vec, + activation: HarnessActivation, + }, + Review { + next_step: String, + }, +} + +/// Applies an adapter plan after every target has passed ownership and format +/// checks. A review result is non-mutating and never leaves a partial bridge. +pub fn apply_bridge_plan( + project: &ActivationProject, + manifest: &mut ActivationManifest, + plan: AdapterPlan, + accept_managed_block: bool, +) -> Result { + validate_project(project)?; + validate_manifest(manifest)?; + validate_plan(&plan)?; + + if matches!( + plan.state, + ActivationState::NeedsPlugin | ActivationState::Unsupported + ) { + if !plan.writes.is_empty() { + return Err(format!( + "{} plan cannot write while in state {:?}", + plan.harness_id, plan.state + )); + } + let mut next_manifest = manifest.clone(); + next_manifest.harnesses.insert( + plan.harness_id.clone(), + HarnessActivation { + state: plan.state, + adapter_capability: capability_for(&plan.harness_id)?, + bridge_path: None, + owned_files: Vec::new(), + managed_blocks: Vec::new(), + }, + ); + save_manifest(&project.memory_root, &next_manifest)?; + *manifest = next_manifest; + return Ok(BridgePlanResult { + state: plan.state, + changed_paths: Vec::new(), + next_step: plan.next_step, + }); + } + + let prepared = prepare_apply(project, manifest, &plan, accept_managed_block)?; + let Preparation::Ready { files, activation } = prepared else { + let Preparation::Review { next_step } = prepared else { + unreachable!() + }; + return Ok(BridgePlanResult { + state: ActivationState::NeedsUserReview, + changed_paths: Vec::new(), + next_step, + }); + }; + + let mut next_manifest = manifest.clone(); + next_manifest + .harnesses + .insert(plan.harness_id.clone(), activation); + validate_manifest(&next_manifest)?; + let changed_paths = files + .iter() + .filter(|file| file.before != file.after) + .map(|file| file.relative.clone()) + .collect::>(); + commit_files_and_manifest(project, manifest, next_manifest, &files)?; + + let state = applied_state(&plan.harness_id, plan.state); + Ok(BridgePlanResult { + state, + changed_paths, + next_step: plan.next_step, + }) +} + +/// Produces the same validated plan as apply without writing files or the +/// activation manifest. +pub fn preview_bridge_plan( + project: &ActivationProject, + manifest: &ActivationManifest, + plan: AdapterPlan, + accept_managed_block: bool, +) -> Result { + validate_project(project)?; + validate_manifest(manifest)?; + validate_plan(&plan)?; + if matches!( + plan.state, + ActivationState::NeedsPlugin | ActivationState::Unsupported + ) { + if !plan.writes.is_empty() { + return Err(format!( + "{} plan cannot write while in state {:?}", + plan.harness_id, plan.state + )); + } + return Ok(BridgePlanResult { + state: plan.state, + changed_paths: Vec::new(), + next_step: plan.next_step, + }); + } + match prepare_apply(project, manifest, &plan, accept_managed_block)? { + Preparation::Review { next_step } => Ok(BridgePlanResult { + state: ActivationState::NeedsUserReview, + changed_paths: Vec::new(), + next_step, + }), + Preparation::Ready { files, .. } => Ok(BridgePlanResult { + state: applied_state(&plan.harness_id, plan.state), + changed_paths: files + .into_iter() + .filter(|file| file.before != file.after) + .map(|file| file.relative) + .collect(), + next_step: plan.next_step, + }), + } +} + +/// Removes only hash-matching complete files and exact managed blocks recorded +/// for one harness. Canonical store files, receipts, and other harness owners +/// are never targets. +pub fn deactivate_bridge_plan( + project: &ActivationProject, + manifest: &mut ActivationManifest, + harness_id: &str, +) -> Result { + validate_project(project)?; + validate_manifest(manifest)?; + let Some(current) = manifest.harnesses.get(harness_id).cloned() else { + return Ok(BridgePlanResult { + state: ActivationState::ConfiguredAwaitingProof, + changed_paths: Vec::new(), + next_step: "No manifest-recorded Tree Ring bridge material was present.".to_string(), + }); + }; + + let mut files = Vec::new(); + let mut retained_files = Vec::new(); + for owned in ¤t.owned_files { + let relative = PathBuf::from(&owned.path); + ensure_local_target(project, &relative)?; + let before = read_optional(&project.project_root.join(&relative))?; + let other_owner = manifest.harnesses.iter().any(|(other_id, activation)| { + other_id != harness_id + && activation + .owned_files + .iter() + .any(|other| other.path == owned.path && other.sha256 == owned.sha256) + }); + match before { + Some(bytes) if sha256(&bytes) == owned.sha256 && !other_owner => { + files.push(PreparedFile { + relative, + before: Some(bytes), + after: None, + }); + } + Some(bytes) if sha256(&bytes) == owned.sha256 && other_owner => {} + None => {} + _ => retained_files.push(owned.clone()), + } + } + + let mut retained_blocks = Vec::new(); + for owned in ¤t.managed_blocks { + let relative = PathBuf::from(&owned.path); + ensure_local_target(project, &relative)?; + let before = read_optional(&project.project_root.join(&relative))?; + let Some(before_bytes) = before else { + continue; + }; + let removal = if relative == Path::new("AGENTS.md") { + remove_markdown_block(&before_bytes, &owned.block_id, &owned.sha256)? + } else if relative == Path::new(".claude/settings.json") { + remove_claude_handler(&before_bytes, &owned.sha256)? + } else { + return Err(format!( + "unsupported managed bridge target in manifest: {}", + relative.display() + )); + }; + match removal { + Some(after) => files.push(PreparedFile { + relative, + before: Some(before_bytes), + after: Some(after), + }), + None => retained_blocks.push(owned.clone()), + } + } + + let mut next_manifest = manifest.clone(); + let next = next_manifest + .harnesses + .get_mut(harness_id) + .expect("cloned manifest retained harness"); + next.state = ActivationState::ConfiguredAwaitingProof; + next.bridge_path = None; + next.owned_files = retained_files; + next.managed_blocks = retained_blocks; + if let Some(first) = next + .owned_files + .first() + .map(|owned| owned.path.clone()) + .or_else(|| next.managed_blocks.first().map(|owned| owned.path.clone())) + { + next.bridge_path = Some(first); + } + + let changed_paths = files + .iter() + .filter(|file| file.before != file.after) + .map(|file| file.relative.clone()) + .collect::>(); + commit_files_and_manifest(project, manifest, next_manifest, &files)?; + let needs_review = !next_manifest_ownership_empty(manifest, harness_id); + Ok(BridgePlanResult { + state: if needs_review { + ActivationState::NeedsUserReview + } else { + ActivationState::ConfiguredAwaitingProof + }, + changed_paths, + next_step: if needs_review { + "Tree Ring preserved changed or shared bridge material for explicit review.".to_string() + } else { + "Tree Ring-owned bridge material was removed; store guidance and receipts were retained." + .to_string() + }, + }) +} + +fn prepare_apply( + project: &ActivationProject, + manifest: &ActivationManifest, + plan: &AdapterPlan, + accept_managed_block: bool, +) -> Result { + let existing = manifest.harnesses.get(&plan.harness_id); + let mut files = Vec::new(); + let mut owned_files = existing + .map(|activation| activation.owned_files.clone()) + .unwrap_or_default(); + let mut managed_blocks = existing + .map(|activation| activation.managed_blocks.clone()) + .unwrap_or_default(); + + for write in &plan.writes { + let prepared = match write { + PlannedWrite::BridgeWrite(write) => { + let desired = render_complete_file(&plan.harness_id, &write.path, manifest)?; + prepare_complete_file( + project, + manifest, + &plan.harness_id, + &write.path, + desired, + &mut owned_files, + )? + } + PlannedWrite::ManagedBlockUpdate(write) if write.path == Path::new("AGENTS.md") => { + prepare_markdown_file( + project, + &plan.harness_id, + write, + accept_managed_block, + &mut owned_files, + &mut managed_blocks, + )? + } + PlannedWrite::ManagedBlockUpdate(write) + if write.path == Path::new(".claude/settings.json") => + { + prepare_claude_settings(project, write, &mut owned_files, &mut managed_blocks)? + } + PlannedWrite::ManagedBlockUpdate(write) => { + return Err(format!( + "unsupported managed bridge target: {}", + write.path.display() + )); + } + }; + let Some(prepared) = prepared else { + return Ok(Preparation::Review { + next_step: review_step(&plan.harness_id), + }); + }; + files.push(prepared); + } + + owned_files.sort_by(|left, right| left.path.cmp(&right.path)); + owned_files.dedup_by(|left, right| left.path == right.path); + managed_blocks.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.block_id.cmp(&right.block_id)) + }); + managed_blocks + .dedup_by(|left, right| left.path == right.path && left.block_id == right.block_id); + let bridge_path = owned_files + .first() + .map(|owned| owned.path.clone()) + .or_else(|| managed_blocks.first().map(|owned| owned.path.clone())); + Ok(Preparation::Ready { + files, + activation: HarnessActivation { + state: applied_state(&plan.harness_id, plan.state), + adapter_capability: capability_for(&plan.harness_id)?, + bridge_path, + owned_files, + managed_blocks, + }, + }) +} + +fn prepare_complete_file( + project: &ActivationProject, + manifest: &ActivationManifest, + harness_id: &str, + relative: &Path, + desired: Vec, + owned_files: &mut Vec, +) -> Result, String> { + ensure_local_target(project, relative)?; + let before = read_optional(&project.project_root.join(relative))?; + let path = relative_string(relative)?; + let desired_hash = sha256(&desired); + let own = owned_files.iter().find(|owned| owned.path == path).cloned(); + let other_owners = manifest + .harnesses + .iter() + .filter(|(other_id, _)| other_id.as_str() != harness_id) + .filter_map(|(_, activation)| { + activation + .owned_files + .iter() + .find(|owned| owned.path == path) + }) + .collect::>(); + + let permitted = match (&before, own.as_ref()) { + (None, None) => true, + (None, Some(_)) => false, + (Some(bytes), Some(owned)) => { + sha256(bytes) == owned.sha256 + && (other_owners.is_empty() + || other_owners + .iter() + .all(|other| other.sha256 == owned.sha256 && desired_hash == owned.sha256)) + } + (Some(bytes), None) => { + !other_owners.is_empty() + && other_owners + .iter() + .all(|other| sha256(bytes) == other.sha256 && desired_hash == other.sha256) + } + }; + if !permitted { + return Ok(None); + } + upsert_owned_file(owned_files, path, desired_hash); + Ok(Some(PreparedFile { + relative: relative.to_path_buf(), + before, + after: Some(desired), + })) +} + +fn prepare_markdown_file( + project: &ActivationProject, + harness_id: &str, + write: &ManagedBlockUpdate, + accept_managed_block: bool, + owned_files: &mut Vec, + managed_blocks: &mut Vec, +) -> Result, String> { + ensure_local_target(project, &write.path)?; + let before = read_optional(&project.project_root.join(&write.path))?; + let path = relative_string(&write.path)?; + let block = markdown_block(harness_id); + if before.is_none() { + let bytes = block.into_bytes(); + upsert_owned_file(owned_files, path, sha256(&bytes)); + return Ok(Some(PreparedFile { + relative: write.path.clone(), + before: None, + after: Some(bytes), + })); + } + if let Some(owned) = owned_files.iter().find(|owned| owned.path == path) { + let bytes = before.as_ref().expect("checked above"); + if sha256(bytes) != owned.sha256 { + return Ok(None); + } + let desired = block.into_bytes(); + upsert_owned_file(owned_files, path, sha256(&desired)); + return Ok(Some(PreparedFile { + relative: write.path.clone(), + before, + after: Some(desired), + })); + } + + let before_bytes = before.as_ref().expect("checked above"); + let before_text = std::str::from_utf8(before_bytes).map_err(|_| { + format!( + "managed Markdown bridge is not UTF-8: {}", + write.path.display() + ) + })?; + let markers = locate_markdown_block(before_text, harness_id)?; + let after = if let Some((start, end)) = markers { + let existing_block = &before_text[start..end]; + let recorded = managed_blocks + .iter() + .find(|owned| owned.path == path && owned.block_id == write.block_id); + if recorded.is_some_and(|owned| sha256(existing_block.as_bytes()) != owned.sha256) { + return Ok(None); + } + replace_range(before_text, start, end, &block).into_bytes() + } else { + if !accept_managed_block { + return Ok(None); + } + append_markdown_block(before_text, &block).into_bytes() + }; + upsert_managed_block( + managed_blocks, + path, + write.block_id.clone(), + sha256(block.as_bytes()), + ); + Ok(Some(PreparedFile { + relative: write.path.clone(), + before, + after: Some(after), + })) +} + +fn prepare_claude_settings( + project: &ActivationProject, + write: &ManagedBlockUpdate, + owned_files: &mut Vec, + managed_blocks: &mut Vec, +) -> Result, String> { + ensure_local_target(project, &write.path)?; + let before = read_optional(&project.project_root.join(&write.path))?; + let path = relative_string(&write.path)?; + let handler = claude_handler(); + let handler_hash = sha256(&serde_json::to_vec(&handler).map_err(json_error)?); + if before.is_none() { + let mut root = Map::new(); + insert_claude_handler(&mut root, handler)?; + let bytes = pretty_json(&Value::Object(root))?; + upsert_owned_file(owned_files, path, sha256(&bytes)); + return Ok(Some(PreparedFile { + relative: write.path.clone(), + before: None, + after: Some(bytes), + })); + } + if let Some(owned) = owned_files.iter().find(|owned| owned.path == path) { + let bytes = before.as_ref().expect("checked above"); + if sha256(bytes) != owned.sha256 { + return Ok(None); + } + let mut root = parse_json_object(bytes)?; + let state = match inspect_claude_handler(&root) { + Ok(state) => state, + Err(_) => return Ok(None), + }; + match state { + ClaudeHandlerState::Exact => {} + ClaudeHandlerState::Absent => { + if insert_claude_handler(&mut root, handler).is_err() { + return Ok(None); + } + } + ClaudeHandlerState::Conflict => return Ok(None), + } + let desired = pretty_json(&Value::Object(root))?; + upsert_owned_file(owned_files, path, sha256(&desired)); + return Ok(Some(PreparedFile { + relative: write.path.clone(), + before, + after: Some(desired), + })); + } + + let before_bytes = before.as_ref().expect("checked above"); + let mut root = match parse_json_object(before_bytes) { + Ok(root) => root, + Err(_) => return Ok(None), + }; + let state = match inspect_claude_handler(&root) { + Ok(state) => state, + Err(_) => return Ok(None), + }; + match state { + ClaudeHandlerState::Conflict => return Ok(None), + ClaudeHandlerState::Exact => {} + ClaudeHandlerState::Absent => { + if insert_claude_handler(&mut root, handler).is_err() { + return Ok(None); + } + } + } + let after = pretty_json(&Value::Object(root))?; + upsert_managed_block(managed_blocks, path, write.block_id.clone(), handler_hash); + Ok(Some(PreparedFile { + relative: write.path.clone(), + before, + after: Some(after), + })) +} + +fn render_complete_file( + harness_id: &str, + path: &Path, + manifest: &ActivationManifest, +) -> Result, String> { + match (harness_id, path.to_str()) { + ("codex" | "pi", Some(".agents/skills/tree-ring-memory/SKILL.md")) + | ("claude-code", Some(".claude/skills/tree-ring-memory/SKILL.md")) => { + Ok(SKILL_BRIDGE.as_bytes().to_vec()) + } + ("pi", Some(".pi/extensions/tree-ring-memory.ts")) => Ok(PI_EXTENSION.as_bytes().to_vec()), + ("agent-zero", Some(".tree-ring/activation/agent-zero.json")) => { + let binding = json!({ + "protocol_version": ACTIVATION_PROTOCOL_VERSION, + "store_id": manifest.store_id, + "project_root_fingerprint": manifest.project_root_fingerprint, + "memory_root": ".tree-ring", + "command_protocol": { + "command": "tree-ring", + "arguments": [ + "--root", ".tree-ring", "integrations", "preflight", + "--harness", "agent-zero", "--input-json-stdin", + "--context-format", "json" + ], + "stdin": "json", + "stdout": "json" + } + }); + pretty_json(&binding) + } + _ => Err(format!( + "unexpected complete bridge target for {harness_id}: {}", + path.display() + )), + } +} + +fn validate_plan(plan: &AdapterPlan) -> Result<(), String> { + let expected = if matches!( + plan.state, + ActivationState::NeedsPlugin | ActivationState::Unsupported + ) { + Vec::new() + } else { + match plan.harness_id.as_str() { + "codex" => vec![ + ("file", ".agents/skills/tree-ring-memory/SKILL.md", ""), + ("block", "AGENTS.md", "codex"), + ], + "claude-code" => vec![ + ("file", ".claude/skills/tree-ring-memory/SKILL.md", ""), + ("block", ".claude/settings.json", "claude-code"), + ], + "pi" => vec![ + ("file", ".agents/skills/tree-ring-memory/SKILL.md", ""), + ("file", ".pi/extensions/tree-ring-memory.ts", ""), + ], + "agent-zero" => vec![("file", ".tree-ring/activation/agent-zero.json", "")], + "hermes" | "opencode" | "goose" => Vec::new(), + other => return Err(format!("unknown harness adapter: {other}")), + } + }; + let actual = plan + .writes + .iter() + .map(|write| match write { + PlannedWrite::BridgeWrite(write) => { + validate_relative_path_buf(&write.path)?; + Ok(("file", relative_str(&write.path)?, "")) + } + PlannedWrite::ManagedBlockUpdate(write) => { + validate_relative_path_buf(&write.path)?; + Ok(("block", relative_str(&write.path)?, write.block_id.as_str())) + } + }) + .collect::, String>>()?; + if actual != expected { + return Err(format!("unexpected bridge writes for {}", plan.harness_id)); + } + Ok(()) +} + +fn validate_project(project: &ActivationProject) -> Result<(), String> { + if project.memory_root != project.project_root.join(".tree-ring") { + return Err( + "activation project memory root must be the project-local .tree-ring".to_string(), + ); + } + ensure_local_target(project, Path::new(".tree-ring/activation.json")) +} + +fn ensure_local_target(project: &ActivationProject, relative: &Path) -> Result<(), String> { + validate_relative_path_buf(relative)?; + let mut current = project.project_root.clone(); + for component in relative.components() { + let Component::Normal(part) = component else { + return Err("bridge path must be normalized and project-relative".to_string()); + }; + current.push(part); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(format!( + "bridge path traverses a symlink: {}", + current.display() + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error(¤t, error)), + } + } + Ok(()) +} + +fn validate_relative_path_buf(path: &Path) -> Result<(), String> { + let value = path + .to_str() + .ok_or_else(|| "bridge path must be valid UTF-8".to_string())?; + validate_project_relative_path(value) +} + +fn relative_str(path: &Path) -> Result<&str, String> { + path.to_str() + .ok_or_else(|| "bridge path must be valid UTF-8".to_string()) +} + +fn relative_string(path: &Path) -> Result { + Ok(relative_str(path)?.to_string()) +} + +fn markdown_block(harness_id: &str) -> String { + format!( + "\n{CODEX_BLOCK_BODY}\n" + ) +} + +fn locate_markdown_block( + content: &str, + harness_id: &str, +) -> Result, String> { + let begin = format!(""); + let end = format!(""); + let begins = content.match_indices(&begin).collect::>(); + let ends = content.match_indices(&end).collect::>(); + match (begins.as_slice(), ends.as_slice()) { + ([], []) => Ok(None), + ([(start, _)], [(end_start, _)]) if start < end_start => { + let mut end_offset = end_start + end.len(); + if content[end_offset..].starts_with('\n') { + end_offset += 1; + } + Ok(Some((*start, end_offset))) + } + _ => Err(format!( + "conflicting or incomplete Tree Ring managed markers for {harness_id}" + )), + } +} + +fn append_markdown_block(content: &str, block: &str) -> String { + if content.is_empty() { + return block.to_string(); + } + if content.ends_with("\n\n") { + format!("{content}{block}") + } else if content.ends_with('\n') { + format!("{content}\n{block}") + } else { + format!("{content}\n\n{block}") + } +} + +fn replace_range(content: &str, start: usize, end: usize, replacement: &str) -> String { + format!("{}{}{}", &content[..start], replacement, &content[end..]) +} + +fn remove_markdown_block( + bytes: &[u8], + block_id: &str, + expected_hash: &str, +) -> Result>, String> { + let content = match std::str::from_utf8(bytes) { + Ok(content) => content, + Err(_) => return Ok(None), + }; + let Some((start, end)) = locate_markdown_block(content, block_id)? else { + return Ok(None); + }; + if sha256(&content.as_bytes()[start..end]) != expected_hash { + return Ok(None); + } + let mut result = replace_range(content, start, end, ""); + while result.ends_with("\n\n") { + result.pop(); + } + if !result.is_empty() && !result.ends_with('\n') { + result.push('\n'); + } + Ok(Some(result.into_bytes())) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ClaudeHandlerState { + Absent, + Exact, + Conflict, +} + +fn claude_handler() -> Value { + json!({ + "type": "command", + "command": CLAUDE_COMMAND, + "description": CLAUDE_DESCRIPTION + }) +} + +fn inspect_claude_handler(root: &Map) -> Result { + let expected = claude_handler(); + let mut exact = 0usize; + let mut conflict = false; + inspect_tree_ring_values( + &Value::Object(root.clone()), + &expected, + &mut exact, + &mut conflict, + ); + if conflict || exact > 1 { + Ok(ClaudeHandlerState::Conflict) + } else if exact == 1 { + Ok(ClaudeHandlerState::Exact) + } else { + validate_claude_hook_shape(root)?; + Ok(ClaudeHandlerState::Absent) + } +} + +fn inspect_tree_ring_values( + value: &Value, + expected: &Value, + exact: &mut usize, + conflict: &mut bool, +) { + match value { + Value::Object(object) => { + let description = object.get("description").and_then(Value::as_str); + let command = object.get("command").and_then(Value::as_str); + let claims_description = description == Some(CLAUDE_DESCRIPTION); + let claims_command = command.is_some_and(|command| { + command.contains("tree-ring") + && command.contains("integrations preflight") + && command.contains("--harness claude-code") + }); + if claims_description || claims_command { + if Value::Object(object.clone()) == *expected { + *exact += 1; + } else { + *conflict = true; + } + } + for child in object.values() { + inspect_tree_ring_values(child, expected, exact, conflict); + } + } + Value::Array(values) => { + for child in values { + inspect_tree_ring_values(child, expected, exact, conflict); + } + } + _ => {} + } +} + +fn validate_claude_hook_shape(root: &Map) -> Result<(), String> { + let Some(hooks) = root.get("hooks") else { + return Ok(()); + }; + let hooks = hooks + .as_object() + .ok_or_else(|| "Claude settings hooks must be an object".to_string())?; + if let Some(session_start) = hooks.get("SessionStart") { + session_start + .as_array() + .ok_or_else(|| "Claude SessionStart hooks must be an array".to_string())?; + } + Ok(()) +} + +fn insert_claude_handler(root: &mut Map, handler: Value) -> Result<(), String> { + if !root.contains_key("hooks") { + root.insert("hooks".to_string(), Value::Object(Map::new())); + } + let hooks = root + .get_mut("hooks") + .and_then(Value::as_object_mut) + .ok_or_else(|| "Claude settings hooks must be an object".to_string())?; + if !hooks.contains_key("SessionStart") { + hooks.insert("SessionStart".to_string(), Value::Array(Vec::new())); + } + let session_start = hooks + .get_mut("SessionStart") + .and_then(Value::as_array_mut) + .ok_or_else(|| "Claude SessionStart hooks must be an array".to_string())?; + session_start.push(json!({"matcher": "", "hooks": [handler]})); + Ok(()) +} + +fn remove_claude_handler(bytes: &[u8], expected_hash: &str) -> Result>, String> { + let mut root = match parse_json_object(bytes) { + Ok(root) => root, + Err(_) => return Ok(None), + }; + let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else { + return Ok(None); + }; + let Some(entries) = hooks.get_mut("SessionStart").and_then(Value::as_array_mut) else { + return Ok(None); + }; + let mut removed = 0usize; + for entry in entries.iter_mut() { + let Some(handlers) = entry.get_mut("hooks").and_then(Value::as_array_mut) else { + continue; + }; + handlers.retain(|handler| { + let matches = handler.get("description").and_then(Value::as_str) + == Some(CLAUDE_DESCRIPTION) + && serde_json::to_vec(handler) + .map(|bytes| sha256(&bytes) == expected_hash) + .unwrap_or(false); + if matches { + removed += 1; + } + !matches + }); + } + if removed != 1 { + return Ok(None); + } + entries.retain(|entry| { + entry + .get("hooks") + .and_then(Value::as_array) + .is_none_or(|handlers| !handlers.is_empty()) + }); + if entries.is_empty() { + hooks.remove("SessionStart"); + } + if hooks.is_empty() { + root.remove("hooks"); + } + pretty_json(&Value::Object(root)).map(Some) +} + +fn parse_json_object(bytes: &[u8]) -> Result, String> { + let value: Value = serde_json::from_slice(bytes) + .map_err(|error| format!("invalid Claude settings JSON: {error}"))?; + value + .as_object() + .cloned() + .ok_or_else(|| "Claude settings root must be an object".to_string()) +} + +fn pretty_json(value: &Value) -> Result, String> { + let mut bytes = serde_json::to_vec_pretty(value).map_err(json_error)?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn json_error(error: serde_json::Error) -> String { + format!("failed to render bridge JSON: {error}") +} + +fn upsert_owned_file(owned: &mut Vec, path: String, digest: String) { + if let Some(existing) = owned.iter_mut().find(|owned| owned.path == path) { + existing.sha256 = digest; + } else { + owned.push(OwnedBridgeFile { + path, + sha256: digest, + }); + } +} + +fn upsert_managed_block( + owned: &mut Vec, + path: String, + block_id: String, + digest: String, +) { + if let Some(existing) = owned + .iter_mut() + .find(|owned| owned.path == path && owned.block_id == block_id) + { + existing.sha256 = digest; + } else { + owned.push(OwnedManagedBlock { + path, + block_id, + sha256: digest, + }); + } +} + +fn commit_files_and_manifest( + project: &ActivationProject, + manifest: &mut ActivationManifest, + next_manifest: ActivationManifest, + files: &[PreparedFile], +) -> Result<(), String> { + let original_manifest = manifest.clone(); + let mut applied = Vec::new(); + for file in files.iter().filter(|file| file.before != file.after) { + if let Err(error) = commit_prepared_file(project, file) { + rollback_files(project, &applied); + return Err(error); + } + applied.push(file.clone()); + } + if let Err(error) = save_manifest(&project.memory_root, &next_manifest) { + rollback_files(project, &applied); + *manifest = original_manifest; + return Err(error); + } + *manifest = next_manifest; + Ok(()) +} + +fn commit_prepared_file(project: &ActivationProject, file: &PreparedFile) -> Result<(), String> { + ensure_local_target(project, &file.relative)?; + let path = project.project_root.join(&file.relative); + let current = read_optional(&path)?; + if current != file.before { + return Err(format!( + "bridge target changed after validation: {}", + file.relative.display() + )); + } + match &file.after { + Some(bytes) => atomic_write_bytes(&path, bytes, file.before.is_none()), + None => match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(io_error(&path, error)), + }, + } +} + +fn rollback_files(project: &ActivationProject, files: &[PreparedFile]) { + for file in files.iter().rev() { + let path = project.project_root.join(&file.relative); + match &file.before { + Some(bytes) => { + let _ = atomic_write_bytes(&path, bytes, false); + } + None => { + let _ = fs::remove_file(&path); + } + } + } +} + +fn atomic_write_bytes(path: &Path, bytes: &[u8], create_only: bool) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("bridge output has no parent: {}", path.display()))?; + fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("bridge output has no UTF-8 file name: {}", path.display()))?; + let temp = parent.join(format!(".{file_name}.{}.tmp", Uuid::new_v4())); + let result = (|| { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp) + .map_err(|error| io_error(&temp, error))?; + file.write_all(bytes) + .map_err(|error| io_error(&temp, error))?; + file.sync_all().map_err(|error| io_error(&temp, error))?; + drop(file); + if create_only { + fs::hard_link(&temp, path).map_err(|error| io_error(path, error))?; + fs::remove_file(&temp).map_err(|error| io_error(&temp, error)) + } else { + fs::rename(&temp, path).map_err(|error| io_error(path, error)) + } + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +fn read_optional(path: &Path) -> Result>, String> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(io_error(path, error)), + } +} + +fn sha256(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +fn capability_for(harness_id: &str) -> Result { + match harness_id { + "codex" => Ok(AdapterCapability::WrapperPreflight), + "claude-code" | "pi" | "agent-zero" => Ok(AdapterCapability::NativePreflight), + "hermes" | "opencode" | "goose" => Ok(AdapterCapability::GuidanceOnly), + other => Err(format!("unknown harness adapter: {other}")), + } +} + +fn applied_state(harness_id: &str, planned: ActivationState) -> ActivationState { + if harness_id == "pi" { + ActivationState::NeedsTrust + } else { + planned + } +} + +fn review_step(harness_id: &str) -> String { + if harness_id == "codex" { + CODEX_RETRY.to_string() + } else { + format!("Review the existing {harness_id} bridge configuration before retrying activation.") + } +} + +fn next_manifest_ownership_empty(manifest: &ActivationManifest, harness_id: &str) -> bool { + manifest.harnesses.get(harness_id).is_none_or(|activation| { + activation.owned_files.is_empty() && activation.managed_blocks.is_empty() + }) +} + +fn io_error(path: &Path, error: std::io::Error) -> String { + format!("{}: {error}", path.display()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::activation::{ + adapters::{plan_activation, ActivationProject, AdapterPlan, BridgeWrite, PlannedWrite}, + manifest::ActivationManifest, + ActivationState, ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, + }; + use serde_json::{json, Value}; + use std::{collections::BTreeMap, fs, path::Path}; + use tempfile::TempDir; + + fn fixture() -> (TempDir, ActivationProject, ActivationManifest) { + let temp = tempfile::tempdir().unwrap(); + let project = ActivationProject::from_project_root(temp.path()); + fs::create_dir_all(&project.memory_root).unwrap(); + fs::write( + project.memory_root.join("AGENTS.md"), + "# Canonical guidance\n", + ) + .unwrap(); + fs::write(project.memory_root.join("SKILL.md"), "# Canonical skill\n").unwrap(); + fs::write(project.memory_root.join("CLI.md"), "# Canonical CLI\n").unwrap(); + let manifest = ActivationManifest { + schema_version: ACTIVATION_SCHEMA_VERSION, + protocol_version: ACTIVATION_PROTOCOL_VERSION, + store_id: "store-test".to_string(), + project_root_fingerprint: "a".repeat(64), + cli_version: "0.14.0".to_string(), + harnesses: BTreeMap::new(), + }; + (temp, project, manifest) + } + + fn read(path: impl AsRef) -> String { + fs::read_to_string(path).unwrap() + } + + fn write(path: impl AsRef, content: &str) { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, content).unwrap(); + } + + fn plan(harness: &str, project: &ActivationProject) -> AdapterPlan { + plan_activation(harness, project).unwrap() + } + + #[test] + fn unmanaged_agents_file_requires_explicit_review_without_partial_writes() { + let (_temp, project, mut manifest) = fixture(); + write(project.project_root.join("AGENTS.md"), "# Team contract\n"); + + let result = + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), false).unwrap(); + + assert_eq!(result.state, ActivationState::NeedsUserReview); + assert_eq!( + read(project.project_root.join("AGENTS.md")), + "# Team contract\n" + ); + assert!(!project + .project_root + .join(".agents/skills/tree-ring-memory/SKILL.md") + .exists()); + assert_eq!( + result.next_step, + "tree-ring integrations activate --harness codex --accept-managed-block" + ); + assert!(manifest.harnesses.is_empty()); + } + + #[test] + fn absent_agents_file_becomes_hash_recorded_owned_file_and_retries_idempotently() { + let (_temp, project, mut manifest) = fixture(); + let codex = plan("codex", &project); + + let first = apply_bridge_plan(&project, &mut manifest, codex.clone(), false).unwrap(); + let agents = read(project.project_root.join("AGENTS.md")); + let skill = read( + project + .project_root + .join(".agents/skills/tree-ring-memory/SKILL.md"), + ); + let serialized = serde_json::to_value(&manifest).unwrap(); + let second = apply_bridge_plan(&project, &mut manifest, codex, false).unwrap(); + + assert_eq!(first.state, ActivationState::ConfiguredAwaitingProof); + assert_eq!(second.state, ActivationState::ConfiguredAwaitingProof); + assert!(second.changed_paths.is_empty()); + assert_eq!( + agents.matches("").count(), + 1 + ); + assert!(skill.contains(".tree-ring/SKILL.md")); + assert_eq!( + serialized["harnesses"]["codex"]["owned_files"] + .as_array() + .unwrap() + .len(), + 2 + ); + for owned in serialized["harnesses"]["codex"]["owned_files"] + .as_array() + .unwrap() + { + assert_eq!(owned["sha256"].as_str().unwrap().len(), 64); + } + } + + #[test] + fn accepted_agents_block_preserves_unrelated_text_and_records_exact_block_id() { + let (_temp, project, mut manifest) = fixture(); + write(project.project_root.join("AGENTS.md"), "# Team contract\n"); + + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), true).unwrap(); + let agents = read(project.project_root.join("AGENTS.md")); + let serialized = serde_json::to_value(&manifest).unwrap(); + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), false).unwrap(); + + assert!(agents.starts_with("# Team contract\n")); + assert_eq!( + agents.matches("").count(), + 1 + ); + assert_eq!(agents.matches("").count(), 1); + assert_eq!( + serialized["harnesses"]["codex"]["managed_blocks"][0]["block_id"], + "codex" + ); + } + + #[test] + fn changed_owned_file_is_never_overwritten_on_retry() { + let (_temp, project, mut manifest) = fixture(); + apply_bridge_plan(&project, &mut manifest, plan("pi", &project), false).unwrap(); + let extension = project + .project_root + .join(".pi/extensions/tree-ring-memory.ts"); + write(&extension, "// owner changed this file\n"); + + let result = + apply_bridge_plan(&project, &mut manifest, plan("pi", &project), false).unwrap(); + + assert_eq!(result.state, ActivationState::NeedsUserReview); + assert_eq!(read(extension), "// owner changed this file\n"); + } + + #[test] + fn claude_settings_merge_preserves_every_unrelated_json_value_exactly() { + let (_temp, project, mut manifest) = fixture(); + let settings_path = project.project_root.join(".claude/settings.json"); + let original = json!({ + "permissions": {"allow": ["Read", "Bash(git status)"]}, + "hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "guard"}]}]}, + "custom": [1, {"nested": true}] + }); + write( + &settings_path, + &serde_json::to_string_pretty(&original).unwrap(), + ); + + apply_bridge_plan( + &project, + &mut manifest, + plan("claude-code", &project), + false, + ) + .unwrap(); + let merged: Value = serde_json::from_str(&read(&settings_path)).unwrap(); + let first_bytes = read(&settings_path); + let retry = apply_bridge_plan( + &project, + &mut manifest, + plan("claude-code", &project), + false, + ) + .unwrap(); + + assert_eq!(merged["permissions"], original["permissions"]); + assert_eq!( + merged["hooks"]["PreToolUse"], + original["hooks"]["PreToolUse"] + ); + assert_eq!(merged["custom"], original["custom"]); + let session_hooks = merged["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(session_hooks.len(), 1); + assert_eq!( + session_hooks[0]["hooks"][0]["description"], + "Tree Ring Memory managed preflight v1" + ); + assert_eq!( + session_hooks[0]["hooks"][0]["command"], + "tree-ring --root .tree-ring integrations preflight --harness claude-code --input-json-stdin --context-format claude-session-start" + ); + assert!(retry.changed_paths.is_empty()); + assert_eq!(read(settings_path), first_bytes); + } + + #[test] + fn invalid_or_conflicting_claude_settings_require_review_without_any_write() { + for contents in [ + "not json".to_string(), + "[]".to_string(), + serde_json::to_string(&json!({"hooks": 5})).unwrap(), + serde_json::to_string(&json!({ + "hooks": {"SessionStart": [{"hooks": [{ + "type": "command", + "command": "some-other-command", + "description": "Tree Ring Memory managed preflight v1" + }]}]} + })) + .unwrap(), + ] { + let (_temp, project, mut manifest) = fixture(); + let settings_path = project.project_root.join(".claude/settings.json"); + write(&settings_path, &contents); + + let result = apply_bridge_plan( + &project, + &mut manifest, + plan("claude-code", &project), + false, + ) + .unwrap(); + + assert_eq!(result.state, ActivationState::NeedsUserReview); + assert_eq!(read(settings_path), contents); + assert!(!project + .project_root + .join(".claude/skills/tree-ring-memory/SKILL.md") + .exists()); + assert!(manifest.harnesses.is_empty()); + } + } + + #[test] + fn preview_is_a_zero_write_dry_run() { + let (_temp, project, manifest) = fixture(); + let before = serde_json::to_value(&manifest).unwrap(); + let pi = plan("pi", &project); + + assert_eq!(pi.state, ActivationState::NeedsTrust); + let result = preview_bridge_plan(&project, &manifest, pi, false).unwrap(); + + assert_eq!(result.state, ActivationState::NeedsTrust); + assert_eq!(result.changed_paths.len(), 2); + assert_eq!(serde_json::to_value(&manifest).unwrap(), before); + assert!(!project.project_root.join(".agents").exists()); + assert!(!project.project_root.join(".pi").exists()); + } + + #[test] + fn pi_extension_uses_stdin_fallback_and_returns_non_display_context() { + let (_temp, project, mut manifest) = fixture(); + + let result = + apply_bridge_plan(&project, &mut manifest, plan("pi", &project), false).unwrap(); + let source = read( + project + .project_root + .join(".pi/extensions/tree-ring-memory.ts"), + ); + + assert_eq!(result.state, ActivationState::NeedsTrust); + assert!(source.contains("pi.on(\"before_agent_start\", async (event, ctx) =>")); + assert!(source.contains("task_hint: event.prompt")); + assert!(source.contains("child.stdin.end(input)")); + assert!(source.contains("task_hint: \"project startup constraints\"")); + assert!(source.contains("customType: \"tree-ring-preflight\"")); + assert!(source.contains("content: response.context")); + assert!(source.contains("display: false")); + assert!(!source.contains("npm install")); + assert!(!source.contains("detached: true")); + } + + #[test] + fn agent_zero_requires_verified_plugin_plan_and_writes_only_protocol_binding() { + let (_temp, project, mut manifest) = fixture(); + let blocked = plan("agent-zero", &project); + + let blocked_result = apply_bridge_plan(&project, &mut manifest, blocked, false).unwrap(); + assert_eq!(blocked_result.state, ActivationState::NeedsPlugin); + assert!(blocked_result.changed_paths.is_empty()); + assert!(!project + .project_root + .join(".tree-ring/activation/agent-zero.json") + .exists()); + + let verified = AdapterPlan { + harness_id: "agent-zero".to_string(), + state: ActivationState::ConfiguredAwaitingProof, + writes: vec![PlannedWrite::BridgeWrite(BridgeWrite { + path: ".tree-ring/activation/agent-zero.json".into(), + })], + next_step: "verified separate plugin".to_string(), + }; + apply_bridge_plan(&project, &mut manifest, verified, false).unwrap(); + let binding: Value = serde_json::from_str(&read( + project + .project_root + .join(".tree-ring/activation/agent-zero.json"), + )) + .unwrap(); + + assert_eq!(binding["protocol_version"], ACTIVATION_PROTOCOL_VERSION); + assert_eq!(binding["store_id"], "store-test"); + assert_eq!(binding["project_root_fingerprint"], "a".repeat(64)); + assert_eq!(binding["memory_root"], ".tree-ring"); + assert_eq!(binding["command_protocol"]["stdin"], "json"); + assert_eq!(binding["command_protocol"]["stdout"], "json"); + assert!(!project.project_root.join(".a0").exists()); + } + + #[test] + fn deactivation_preserves_non_tree_ring_text_canonical_store_and_receipts() { + let (_temp, project, mut manifest) = fixture(); + write(project.project_root.join("AGENTS.md"), "# Team contract\n"); + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), true).unwrap(); + let receipt_path = project + .memory_root + .join("activation/receipts/codex/worker/receipt.json"); + write(&receipt_path, "{}\n"); + + let result = deactivate_bridge_plan(&project, &mut manifest, "codex").unwrap(); + + assert_eq!(result.state, ActivationState::ConfiguredAwaitingProof); + assert_eq!( + read(project.project_root.join("AGENTS.md")), + "# Team contract\n" + ); + assert!(receipt_path.exists()); + assert!(project.memory_root.join("AGENTS.md").exists()); + assert!(project.memory_root.join("SKILL.md").exists()); + assert!(project.memory_root.join("CLI.md").exists()); + assert!(project.memory_root.join("activation.json").exists()); + assert!(!project + .project_root + .join(".agents/skills/tree-ring-memory/SKILL.md") + .exists()); + assert!(manifest.harnesses.contains_key("codex")); + } + + #[test] + fn deactivation_never_deletes_changed_owned_or_unrecorded_lookalike_files() { + let (_source_temp, source_project, mut source_manifest) = fixture(); + apply_bridge_plan( + &source_project, + &mut source_manifest, + plan("pi", &source_project), + false, + ) + .unwrap(); + let generated = read( + source_project + .project_root + .join(".pi/extensions/tree-ring-memory.ts"), + ); + + let (_temp, project, mut manifest) = fixture(); + let extension = project + .project_root + .join(".pi/extensions/tree-ring-memory.ts"); + write(&extension, &generated); + deactivate_bridge_plan(&project, &mut manifest, "pi").unwrap(); + assert_eq!(read(&extension), generated); + + let review = + apply_bridge_plan(&project, &mut manifest, plan("pi", &project), false).unwrap(); + assert_eq!(review.state, ActivationState::NeedsUserReview); + fs::remove_file(&extension).unwrap(); + apply_bridge_plan(&project, &mut manifest, plan("pi", &project), false).unwrap(); + write(&extension, "// user changed owned bridge\n"); + deactivate_bridge_plan(&project, &mut manifest, "pi").unwrap(); + assert_eq!(read(extension), "// user changed owned bridge\n"); + } + + #[test] + fn shared_skill_file_survives_deactivation_of_one_recorded_owner() { + let (_temp, project, mut manifest) = fixture(); + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), false).unwrap(); + apply_bridge_plan(&project, &mut manifest, plan("pi", &project), false).unwrap(); + + let result = deactivate_bridge_plan(&project, &mut manifest, "codex").unwrap(); + + assert_eq!(result.state, ActivationState::ConfiguredAwaitingProof); + assert!(project + .project_root + .join(".agents/skills/tree-ring-memory/SKILL.md") + .exists()); + assert!(!project.project_root.join("AGENTS.md").exists()); + assert!(manifest.harnesses["codex"].owned_files.is_empty()); + } + + #[test] + fn claude_deactivation_removes_only_the_exact_recorded_handler() { + let (_temp, project, mut manifest) = fixture(); + let settings_path = project.project_root.join(".claude/settings.json"); + let original = json!({ + "permissions": {"allow": ["Read"]}, + "hooks": {"SessionStart": [{"matcher": "compact", "hooks": [{"type": "command", "command": "keep-me"}]}]} + }); + write( + &settings_path, + &serde_json::to_string_pretty(&original).unwrap(), + ); + apply_bridge_plan( + &project, + &mut manifest, + plan("claude-code", &project), + false, + ) + .unwrap(); + + let result = deactivate_bridge_plan(&project, &mut manifest, "claude-code").unwrap(); + let after: Value = serde_json::from_str(&read(settings_path)).unwrap(); + + assert_eq!(result.state, ActivationState::ConfiguredAwaitingProof); + assert_eq!(after, original); + assert!(!project + .project_root + .join(".claude/skills/tree-ring-memory/SKILL.md") + .exists()); + } + + #[test] + fn unsafe_or_unexpected_plan_paths_are_rejected_before_writes() { + let (_temp, project, mut manifest) = fixture(); + for path in ["../outside", "/tmp/outside", "AGENTS.md"] { + let malicious = AdapterPlan { + harness_id: "pi".to_string(), + state: ActivationState::ConfiguredAwaitingProof, + writes: vec![PlannedWrite::BridgeWrite(BridgeWrite { path: path.into() })], + next_step: String::new(), + }; + assert!(apply_bridge_plan(&project, &mut manifest, malicious, false).is_err()); + } + assert!(!project.project_root.join(".agents").exists()); + assert!(!Path::new("/tmp/outside").exists()); + } + + #[cfg(unix)] + #[test] + fn project_local_manifest_never_follows_a_tree_ring_symlink() { + use std::os::unix::fs::symlink; + + let project_temp = tempfile::tempdir().unwrap(); + let outside_temp = tempfile::tempdir().unwrap(); + symlink(outside_temp.path(), project_temp.path().join(".tree-ring")).unwrap(); + let project = ActivationProject::from_project_root(project_temp.path()); + let mut manifest = ActivationManifest { + schema_version: ACTIVATION_SCHEMA_VERSION, + protocol_version: ACTIVATION_PROTOCOL_VERSION, + store_id: "store-test".to_string(), + project_root_fingerprint: "a".repeat(64), + cli_version: "0.14.0".to_string(), + harnesses: BTreeMap::new(), + }; + + let error = + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), false).unwrap_err(); + + assert!(error.contains("symlink")); + assert!(!outside_temp.path().join("activation.json").exists()); + assert!(!project_temp.path().join("AGENTS.md").exists()); + } +} diff --git a/crates/tree-ring-memory-cli/src/activation/manifest.rs b/crates/tree-ring-memory-cli/src/activation/manifest.rs index f446f94..517cb1b 100644 --- a/crates/tree-ring-memory-cli/src/activation/manifest.rs +++ b/crates/tree-ring-memory-cli/src/activation/manifest.rs @@ -37,6 +37,29 @@ pub struct HarnessActivation { pub adapter_capability: AdapterCapability, #[serde(skip_serializing_if = "Option::is_none")] pub bridge_path: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub owned_files: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub managed_blocks: Vec, +} + +/// A complete project-local bridge file that Tree Ring may replace or remove +/// only while its bytes still match this digest. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OwnedBridgeFile { + pub path: String, + pub sha256: String, +} + +/// A bounded block inside an otherwise user-owned file. The block identifier +/// selects the exact adapter-owned markers or structured JSON handler. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OwnedManagedBlock { + pub path: String, + pub block_id: String, + pub sha256: String, } /// A deliberately minimal, non-sensitive record that an activation occurred. @@ -91,6 +114,17 @@ pub fn load_or_create_manifest( } } +/// Atomically persists a validated activation manifest. +pub fn save_manifest(memory_root: &Path, manifest: &ActivationManifest) -> Result<(), String> { + validate_memory_root(memory_root)?; + validate_manifest(manifest)?; + atomic_write_json( + &manifest_path(memory_root), + manifest, + AtomicWriteMode::Replace, + ) +} + /// Writes a receipt beneath the store's activation receipt directory. pub fn write_receipt(memory_root: &Path, receipt: &ActivationReceipt) -> Result { validate_memory_root(memory_root)?; @@ -236,7 +270,7 @@ fn validate_memory_root(memory_root: &Path) -> Result<(), String> { } } -fn validate_manifest(manifest: &ActivationManifest) -> Result<(), String> { +pub(crate) fn validate_manifest(manifest: &ActivationManifest) -> Result<(), String> { if manifest.schema_version != ACTIVATION_SCHEMA_VERSION { return Err(format!( "unsupported activation schema version: {}", @@ -259,6 +293,19 @@ fn validate_manifest(manifest: &ActivationManifest) -> Result<(), String> { if let Some(bridge_path) = &activation.bridge_path { validate_project_relative_path(bridge_path)?; } + for owned in &activation.owned_files { + validate_project_relative_path(&owned.path)?; + if !is_sha256(&owned.sha256) { + return Err("owned bridge file digest must be a SHA-256 hex digest".to_string()); + } + } + for owned in &activation.managed_blocks { + validate_project_relative_path(&owned.path)?; + validate_identifier("managed block id", &owned.block_id)?; + if !is_sha256(&owned.sha256) { + return Err("managed block digest must be a SHA-256 hex digest".to_string()); + } + } } Ok(()) } @@ -311,7 +358,7 @@ fn validate_identifier(label: &str, value: &str) -> Result<(), String> { Ok(()) } -fn validate_project_relative_path(value: &str) -> Result<(), String> { +pub(crate) fn validate_project_relative_path(value: &str) -> Result<(), String> { if value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) { return Err("invalid bridge path".to_string()); } @@ -519,6 +566,8 @@ mod tests { state: ActivationState::Active, adapter_capability: AdapterCapability::NativePreflight, bridge_path: Some("bridges/codex.sh".to_string()), + owned_files: Vec::new(), + managed_blocks: Vec::new(), }, ); assert!(validate_manifest(&manifest).is_ok()); diff --git a/crates/tree-ring-memory-cli/src/activation/mod.rs b/crates/tree-ring-memory-cli/src/activation/mod.rs index ea6241f..a8fc4ee 100644 --- a/crates/tree-ring-memory-cli/src/activation/mod.rs +++ b/crates/tree-ring-memory-cli/src/activation/mod.rs @@ -35,10 +35,11 @@ pub struct SessionIdentity { } pub mod adapters; +pub mod bridge; pub mod manifest; #[allow(unused_imports)] pub use manifest::{ - load_manifest, load_or_create_manifest, prune_receipts, write_receipt, ActivationManifest, - ActivationReceipt, HarnessActivation, + load_manifest, load_or_create_manifest, prune_receipts, save_manifest, write_receipt, + ActivationManifest, ActivationReceipt, HarnessActivation, OwnedBridgeFile, OwnedManagedBlock, }; From 3c2d15c4595d6379b82b0cd0da088610d9e7adca Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 13 Aug 2026 23:19:50 -0400 Subject: [PATCH 07/31] fix: harden reversible harness bridge ownership --- Cargo.lock | 1 + crates/tree-ring-memory-cli/Cargo.toml | 1 + .../src/activation/bridge.rs | 1187 ++++++++++++++--- .../src/activation/manifest.rs | 87 +- 4 files changed, 1061 insertions(+), 215 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ddd5b9..d726ddf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1694,6 +1694,7 @@ version = "0.14.0" dependencies = [ "chrono", "clap", + "libc", "ratatui", "rusqlite", "serde", diff --git a/crates/tree-ring-memory-cli/Cargo.toml b/crates/tree-ring-memory-cli/Cargo.toml index a2df5d2..6a84f5d 100644 --- a/crates/tree-ring-memory-cli/Cargo.toml +++ b/crates/tree-ring-memory-cli/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" [dependencies] chrono.workspace = true clap.workspace = true +libc.workspace = true ratatui.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/tree-ring-memory-cli/src/activation/bridge.rs b/crates/tree-ring-memory-cli/src/activation/bridge.rs index 83c128a..7d08f08 100644 --- a/crates/tree-ring-memory-cli/src/activation/bridge.rs +++ b/crates/tree-ring-memory-cli/src/activation/bridge.rs @@ -1,20 +1,27 @@ use super::{ adapters::{ActivationProject, AdapterPlan, ManagedBlockUpdate, PlannedWrite}, manifest::{ - save_manifest, validate_manifest, validate_project_relative_path, ActivationManifest, - HarnessActivation, OwnedBridgeFile, OwnedManagedBlock, + validate_manifest, validate_project_relative_path, ActivationManifest, HarnessActivation, + OwnedBridgeFile, OwnedManagedBlock, }, ActivationState, AdapterCapability, ACTIVATION_PROTOCOL_VERSION, }; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; use std::{ - fs::{self, OpenOptions}, - io::Write, + ffi::{CStr, CString, OsStr}, + fs::{self, File}, + io::{Read, Write}, path::{Component, Path, PathBuf}, }; use uuid::Uuid; +#[cfg(unix)] +use std::os::{ + fd::{AsRawFd, FromRawFd}, + unix::ffi::OsStrExt, +}; + const CODEX_RETRY: &str = "tree-ring integrations activate --harness codex --accept-managed-block"; const CLAUDE_DESCRIPTION: &str = "Tree Ring Memory managed preflight v1"; const CLAUDE_COMMAND: &str = "tree-ring --root .tree-ring integrations preflight --harness claude-code --input-json-stdin --context-format claude-session-start"; @@ -126,6 +133,413 @@ struct PreparedFile { after: Option>, } +#[cfg(unix)] +#[derive(Debug)] +struct AppliedFile { + prepared: PreparedFile, + target: ResolvedTarget, +} + +#[cfg(unix)] +#[derive(Debug)] +struct ProjectFs { + root: File, +} + +#[cfg(unix)] +#[derive(Debug)] +struct ResolvedTarget { + parent: File, + name: CString, + display: PathBuf, +} + +#[cfg(unix)] +#[derive(Debug)] +struct ManifestLock { + file: File, +} + +#[cfg(unix)] +impl Drop for ManifestLock { + fn drop(&mut self) { + unsafe { + // SAFETY: the lock file descriptor is owned and remains open for this guard's life. + libc::flock(self.file.as_raw_fd(), libc::LOCK_UN); + } + } +} + +#[cfg(unix)] +impl ProjectFs { + fn open(project: &ActivationProject) -> Result { + validate_project_shape(project)?; + let metadata = fs::symlink_metadata(&project.project_root) + .map_err(|error| io_error(&project.project_root, error))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("project root must be a real directory, not a symlink".to_string()); + } + let physical_root = fs::canonicalize(&project.project_root) + .map_err(|error| io_error(&project.project_root, error))?; + let root = open_directory_path_no_follow(&physical_root)?; + Ok(Self { root }) + } + + fn lock_manifest(&self) -> Result { + let file = self + .root + .try_clone() + .map_err(|error| io_error(Path::new("."), error))?; + let result = unsafe { + // SAFETY: `file` owns the stable project-root directory descriptor and remains alive + // for the returned guard, so path replacement cannot split concurrent writers. + libc::flock(file.as_raw_fd(), libc::LOCK_EX) + }; + if result != 0 { + return Err(io_error(Path::new("."), std::io::Error::last_os_error())); + } + Ok(ManifestLock { file }) + } + + fn read_optional(&self, relative: &Path) -> Result>, String> { + let Some(target) = self.resolve_target_optional(relative, false)? else { + return Ok(None); + }; + target.read_optional() + } + + fn resolve_target( + &self, + relative: &Path, + create_parents: bool, + ) -> Result { + self.resolve_target_optional(relative, create_parents)? + .ok_or_else(|| format!("bridge target parent is missing: {}", relative.display())) + } + + fn resolve_target_optional( + &self, + relative: &Path, + create_parents: bool, + ) -> Result, String> { + validate_relative_path_buf(relative)?; + let mut components = relative.components().peekable(); + let mut directory = self + .root + .try_clone() + .map_err(|error| io_error(relative, error))?; + while let Some(component) = components.next() { + let Component::Normal(segment) = component else { + return Err("bridge path must be normalized and project-relative".to_string()); + }; + if components.peek().is_none() { + return Ok(Some(ResolvedTarget { + parent: directory, + name: component_c_string(segment)?, + display: relative.to_path_buf(), + })); + } + match open_child_directory(&directory, segment) { + Ok(next) => directory = next, + Err(error) if error.kind() == std::io::ErrorKind::NotFound && !create_parents => { + return Ok(None); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + create_child_directory(&directory, segment)?; + directory = open_child_directory(&directory, segment) + .map_err(|error| io_error(relative, error))?; + } + Err(error) => { + return Err(format!( + "bridge path parent is a symlink or non-directory at {}: {error}", + relative.display() + )); + } + } + } + Err("bridge path must include a file name".to_string()) + } +} + +#[cfg(unix)] +impl ResolvedTarget { + fn read_optional(&self) -> Result>, String> { + let descriptor = unsafe { + // SAFETY: the parent descriptor and single-component name are valid for this call. + libc::openat( + self.parent.as_raw_fd(), + self.name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK, + ) + }; + if descriptor < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::NotFound { + return Ok(None); + } + return Err(io_error(&self.display, error)); + } + let mut file = owned_file_descriptor(descriptor, &self.display)?; + if !file + .metadata() + .map_err(|error| io_error(&self.display, error))? + .is_file() + { + return Err(format!( + "bridge target is not a regular file: {}", + self.display.display() + )); + } + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|error| io_error(&self.display, error))?; + Ok(Some(bytes)) + } + + fn atomic_write(&self, bytes: &[u8], create_only: bool) -> Result<(), String> { + let temp_name = CString::new(format!( + ".{}.{}.tmp", + self.name.to_string_lossy(), + Uuid::new_v4() + )) + .map_err(|_| "temporary bridge name contains NUL".to_string())?; + let descriptor = unsafe { + // SAFETY: parent and temp name are valid; O_EXCL creates one owned temp file. + libc::openat( + self.parent.as_raw_fd(), + temp_name.as_ptr(), + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW, + 0o600, + ) + }; + let mut temp = owned_file_descriptor(descriptor, &self.display)?; + let result = (|| { + temp.write_all(bytes) + .map_err(|error| io_error(&self.display, error))?; + temp.sync_all() + .map_err(|error| io_error(&self.display, error))?; + drop(temp); + let status = unsafe { + if create_only { + // SAFETY: both names are relative to the same retained parent descriptor. + libc::linkat( + self.parent.as_raw_fd(), + temp_name.as_ptr(), + self.parent.as_raw_fd(), + self.name.as_ptr(), + 0, + ) + } else { + // SAFETY: both names are relative to the same retained parent descriptor. + libc::renameat( + self.parent.as_raw_fd(), + temp_name.as_ptr(), + self.parent.as_raw_fd(), + self.name.as_ptr(), + ) + } + }; + if status != 0 { + return Err(io_error(&self.display, std::io::Error::last_os_error())); + } + if create_only { + unlink_at(&self.parent, &temp_name, &self.display)?; + } + sync_directory(&self.parent, &self.display) + })(); + if result.is_err() { + let _ = unlink_at(&self.parent, &temp_name, &self.display); + } + result + } + + fn remove_file(&self) -> Result<(), String> { + unlink_at(&self.parent, &self.name, &self.display)?; + sync_directory(&self.parent, &self.display) + } +} + +#[cfg(unix)] +fn open_directory_path_no_follow(path: &Path) -> Result { + if path.as_os_str().is_empty() { + return Err("project root is empty".to_string()); + } + let absolute = path.is_absolute(); + let anchor = CString::new(if absolute { "/" } else { "." }).expect("static anchor"); + let descriptor = unsafe { + // SAFETY: anchor is a valid static C string and the descriptor is immediately owned. + libc::open( + anchor.as_ptr(), + libc::O_RDONLY + | libc::O_CLOEXEC + | libc::O_DIRECTORY + | libc::O_NOFOLLOW + | libc::O_NONBLOCK, + ) + }; + let mut directory = owned_file_descriptor(descriptor, path)?; + for component in path.components() { + match component { + Component::RootDir if absolute => {} + Component::CurDir => {} + Component::Normal(segment) => { + directory = open_child_directory(&directory, segment) + .map_err(|error| io_error(path, error))?; + } + _ => return Err("project root must not contain parent traversal".to_string()), + } + } + Ok(directory) +} + +#[cfg(unix)] +fn open_child_directory(directory: &File, segment: &OsStr) -> std::io::Result { + let segment = component_c_string(segment) + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + let descriptor = unsafe { + // SAFETY: directory owns a live descriptor and segment is one path component. + libc::openat( + directory.as_raw_fd(), + segment.as_ptr(), + libc::O_RDONLY + | libc::O_CLOEXEC + | libc::O_DIRECTORY + | libc::O_NOFOLLOW + | libc::O_NONBLOCK, + ) + }; + if descriptor < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(unsafe { + // SAFETY: openat returned a new owned descriptor. + File::from_raw_fd(descriptor) + }) + } +} + +#[cfg(unix)] +fn create_child_directory(directory: &File, segment: &OsStr) -> Result<(), String> { + let segment = component_c_string(segment)?; + let status = unsafe { + // SAFETY: directory owns a live descriptor and segment is one path component. + libc::mkdirat(directory.as_raw_fd(), segment.as_ptr(), 0o755) + }; + if status == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::AlreadyExists { + Ok(()) + } else { + Err(format!("failed to create bridge directory: {error}")) + } +} + +#[cfg(unix)] +fn component_c_string(segment: &OsStr) -> Result { + CString::new(segment.as_bytes()).map_err(|_| "bridge path component contains NUL".to_string()) +} + +#[cfg(unix)] +fn owned_file_descriptor(descriptor: libc::c_int, path: &Path) -> Result { + if descriptor < 0 { + return Err(io_error(path, std::io::Error::last_os_error())); + } + Ok(unsafe { + // SAFETY: the successful libc call returned a new owned descriptor. + File::from_raw_fd(descriptor) + }) +} + +#[cfg(unix)] +fn unlink_at(parent: &File, name: &CStr, display: &Path) -> Result<(), String> { + let status = unsafe { + // SAFETY: parent owns a live descriptor and name is one child component. + libc::unlinkat(parent.as_raw_fd(), name.as_ptr(), 0) + }; + if status == 0 { + Ok(()) + } else { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(io_error(display, error)) + } + } +} + +#[cfg(unix)] +fn sync_directory(directory: &File, display: &Path) -> Result<(), String> { + let status = unsafe { + // SAFETY: directory owns a live directory descriptor. + libc::fsync(directory.as_raw_fd()) + }; + if status == 0 { + Ok(()) + } else { + Err(io_error(display, std::io::Error::last_os_error())) + } +} + +#[cfg(not(unix))] +#[derive(Debug)] +struct ProjectFs; + +#[cfg(not(unix))] +#[derive(Debug)] +struct ResolvedTarget; + +#[cfg(not(unix))] +#[derive(Debug)] +struct ManifestLock; + +#[cfg(not(unix))] +#[derive(Debug)] +struct AppliedFile { + prepared: PreparedFile, + target: ResolvedTarget, +} + +#[cfg(not(unix))] +impl ProjectFs { + fn open(_project: &ActivationProject) -> Result { + Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) + } + + fn lock_manifest(&self) -> Result { + Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) + } + + fn read_optional(&self, _relative: &Path) -> Result>, String> { + Err("bridge access requires descriptor-relative no-follow filesystem support".to_string()) + } + + fn resolve_target( + &self, + _relative: &Path, + _create_parents: bool, + ) -> Result { + Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) + } +} + +#[cfg(not(unix))] +impl ResolvedTarget { + fn read_optional(&self) -> Result>, String> { + Err("bridge access requires descriptor-relative no-follow filesystem support".to_string()) + } + + fn atomic_write(&self, _bytes: &[u8], _create_only: bool) -> Result<(), String> { + Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) + } + + fn remove_file(&self) -> Result<(), String> { + Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) + } +} + #[derive(Debug)] enum Preparation { Ready { @@ -145,9 +559,11 @@ pub fn apply_bridge_plan( plan: AdapterPlan, accept_managed_block: bool, ) -> Result { - validate_project(project)?; validate_manifest(manifest)?; validate_plan(&plan)?; + let project_fs = ProjectFs::open(project)?; + let _manifest_lock = project_fs.lock_manifest()?; + let (current_manifest, expected_persisted) = reconcile_manifest(&project_fs, manifest)?; if matches!( plan.state, @@ -159,19 +575,31 @@ pub fn apply_bridge_plan( plan.harness_id, plan.state )); } - let mut next_manifest = manifest.clone(); - next_manifest.harnesses.insert( - plan.harness_id.clone(), - HarnessActivation { + let mut next_manifest = current_manifest.clone(); + let mut activation = next_manifest + .harnesses + .get(&plan.harness_id) + .cloned() + .unwrap_or(HarnessActivation { state: plan.state, adapter_capability: capability_for(&plan.harness_id)?, bridge_path: None, owned_files: Vec::new(), managed_blocks: Vec::new(), - }, - ); - save_manifest(&project.memory_root, &next_manifest)?; - *manifest = next_manifest; + }); + activation.state = plan.state; + activation.adapter_capability = capability_for(&plan.harness_id)?; + next_manifest + .harnesses + .insert(plan.harness_id.clone(), activation); + commit_files_and_manifest( + &project_fs, + manifest, + ¤t_manifest, + expected_persisted.as_ref(), + next_manifest, + &[], + )?; return Ok(BridgePlanResult { state: plan.state, changed_paths: Vec::new(), @@ -179,7 +607,7 @@ pub fn apply_bridge_plan( }); } - let prepared = prepare_apply(project, manifest, &plan, accept_managed_block)?; + let prepared = prepare_apply(&project_fs, ¤t_manifest, &plan, accept_managed_block)?; let Preparation::Ready { files, activation } = prepared else { let Preparation::Review { next_step } = prepared else { unreachable!() @@ -191,7 +619,7 @@ pub fn apply_bridge_plan( }); }; - let mut next_manifest = manifest.clone(); + let mut next_manifest = current_manifest.clone(); next_manifest .harnesses .insert(plan.harness_id.clone(), activation); @@ -201,7 +629,14 @@ pub fn apply_bridge_plan( .filter(|file| file.before != file.after) .map(|file| file.relative.clone()) .collect::>(); - commit_files_and_manifest(project, manifest, next_manifest, &files)?; + commit_files_and_manifest( + &project_fs, + manifest, + ¤t_manifest, + expected_persisted.as_ref(), + next_manifest, + &files, + )?; let state = applied_state(&plan.harness_id, plan.state); Ok(BridgePlanResult { @@ -219,9 +654,9 @@ pub fn preview_bridge_plan( plan: AdapterPlan, accept_managed_block: bool, ) -> Result { - validate_project(project)?; validate_manifest(manifest)?; validate_plan(&plan)?; + let project_fs = ProjectFs::open(project)?; if matches!( plan.state, ActivationState::NeedsPlugin | ActivationState::Unsupported @@ -238,7 +673,7 @@ pub fn preview_bridge_plan( next_step: plan.next_step, }); } - match prepare_apply(project, manifest, &plan, accept_managed_block)? { + match prepare_apply(&project_fs, manifest, &plan, accept_managed_block)? { Preparation::Review { next_step } => Ok(BridgePlanResult { state: ActivationState::NeedsUserReview, changed_paths: Vec::new(), @@ -264,11 +699,13 @@ pub fn deactivate_bridge_plan( manifest: &mut ActivationManifest, harness_id: &str, ) -> Result { - validate_project(project)?; validate_manifest(manifest)?; - let Some(current) = manifest.harnesses.get(harness_id).cloned() else { + let project_fs = ProjectFs::open(project)?; + let _manifest_lock = project_fs.lock_manifest()?; + let (current_manifest, expected_persisted) = reconcile_manifest(&project_fs, manifest)?; + let Some(current) = current_manifest.harnesses.get(harness_id).cloned() else { return Ok(BridgePlanResult { - state: ActivationState::ConfiguredAwaitingProof, + state: deactivated_state(harness_id), changed_paths: Vec::new(), next_step: "No manifest-recorded Tree Ring bridge material was present.".to_string(), }); @@ -278,15 +715,17 @@ pub fn deactivate_bridge_plan( let mut retained_files = Vec::new(); for owned in ¤t.owned_files { let relative = PathBuf::from(&owned.path); - ensure_local_target(project, &relative)?; - let before = read_optional(&project.project_root.join(&relative))?; - let other_owner = manifest.harnesses.iter().any(|(other_id, activation)| { - other_id != harness_id - && activation - .owned_files - .iter() - .any(|other| other.path == owned.path && other.sha256 == owned.sha256) - }); + let before = project_fs.read_optional(&relative)?; + let other_owner = current_manifest + .harnesses + .iter() + .any(|(other_id, activation)| { + other_id != harness_id + && activation + .owned_files + .iter() + .any(|other| other.path == owned.path && other.sha256 == owned.sha256) + }); match before { Some(bytes) if sha256(&bytes) == owned.sha256 && !other_owner => { files.push(PreparedFile { @@ -304,13 +743,17 @@ pub fn deactivate_bridge_plan( let mut retained_blocks = Vec::new(); for owned in ¤t.managed_blocks { let relative = PathBuf::from(&owned.path); - ensure_local_target(project, &relative)?; - let before = read_optional(&project.project_root.join(&relative))?; + let before = project_fs.read_optional(&relative)?; let Some(before_bytes) = before else { continue; }; let removal = if relative == Path::new("AGENTS.md") { - remove_markdown_block(&before_bytes, &owned.block_id, &owned.sha256)? + remove_markdown_block( + &before_bytes, + &owned.block_id, + &owned.sha256, + &owned.leading_separator, + )? } else if relative == Path::new(".claude/settings.json") { remove_claude_handler(&before_bytes, &owned.sha256)? } else { @@ -329,12 +772,12 @@ pub fn deactivate_bridge_plan( } } - let mut next_manifest = manifest.clone(); + let mut next_manifest = current_manifest.clone(); let next = next_manifest .harnesses .get_mut(harness_id) .expect("cloned manifest retained harness"); - next.state = ActivationState::ConfiguredAwaitingProof; + next.state = deactivated_state(harness_id); next.bridge_path = None; next.owned_files = retained_files; next.managed_blocks = retained_blocks; @@ -352,13 +795,20 @@ pub fn deactivate_bridge_plan( .filter(|file| file.before != file.after) .map(|file| file.relative.clone()) .collect::>(); - commit_files_and_manifest(project, manifest, next_manifest, &files)?; + commit_files_and_manifest( + &project_fs, + manifest, + ¤t_manifest, + expected_persisted.as_ref(), + next_manifest, + &files, + )?; let needs_review = !next_manifest_ownership_empty(manifest, harness_id); Ok(BridgePlanResult { state: if needs_review { ActivationState::NeedsUserReview } else { - ActivationState::ConfiguredAwaitingProof + deactivated_state(harness_id) }, changed_paths, next_step: if needs_review { @@ -371,7 +821,7 @@ pub fn deactivate_bridge_plan( } fn prepare_apply( - project: &ActivationProject, + project_fs: &ProjectFs, manifest: &ActivationManifest, plan: &AdapterPlan, accept_managed_block: bool, @@ -390,7 +840,7 @@ fn prepare_apply( PlannedWrite::BridgeWrite(write) => { let desired = render_complete_file(&plan.harness_id, &write.path, manifest)?; prepare_complete_file( - project, + project_fs, manifest, &plan.harness_id, &write.path, @@ -400,7 +850,7 @@ fn prepare_apply( } PlannedWrite::ManagedBlockUpdate(write) if write.path == Path::new("AGENTS.md") => { prepare_markdown_file( - project, + project_fs, &plan.harness_id, write, accept_managed_block, @@ -411,7 +861,7 @@ fn prepare_apply( PlannedWrite::ManagedBlockUpdate(write) if write.path == Path::new(".claude/settings.json") => { - prepare_claude_settings(project, write, &mut owned_files, &mut managed_blocks)? + prepare_claude_settings(project_fs, write, &mut owned_files, &mut managed_blocks)? } PlannedWrite::ManagedBlockUpdate(write) => { return Err(format!( @@ -454,15 +904,14 @@ fn prepare_apply( } fn prepare_complete_file( - project: &ActivationProject, + project_fs: &ProjectFs, manifest: &ActivationManifest, harness_id: &str, relative: &Path, desired: Vec, owned_files: &mut Vec, ) -> Result, String> { - ensure_local_target(project, relative)?; - let before = read_optional(&project.project_root.join(relative))?; + let before = project_fs.read_optional(relative)?; let path = relative_string(relative)?; let desired_hash = sha256(&desired); let own = owned_files.iter().find(|owned| owned.path == path).cloned(); @@ -507,18 +956,24 @@ fn prepare_complete_file( } fn prepare_markdown_file( - project: &ActivationProject, + project_fs: &ProjectFs, harness_id: &str, write: &ManagedBlockUpdate, accept_managed_block: bool, owned_files: &mut Vec, managed_blocks: &mut Vec, ) -> Result, String> { - ensure_local_target(project, &write.path)?; - let before = read_optional(&project.project_root.join(&write.path))?; + let before = project_fs.read_optional(&write.path)?; let path = relative_string(&write.path)?; let block = markdown_block(harness_id); if before.is_none() { + if owned_files.iter().any(|owned| owned.path == path) + || managed_blocks + .iter() + .any(|owned| owned.path == path && owned.block_id == write.block_id) + { + return Ok(None); + } let bytes = block.into_bytes(); upsert_owned_file(owned_files, path, sha256(&bytes)); return Ok(Some(PreparedFile { @@ -549,7 +1004,7 @@ fn prepare_markdown_file( ) })?; let markers = locate_markdown_block(before_text, harness_id)?; - let after = if let Some((start, end)) = markers { + let (after, leading_separator) = if let Some((start, end)) = markers { let existing_block = &before_text[start..end]; let recorded = managed_blocks .iter() @@ -557,18 +1012,25 @@ fn prepare_markdown_file( if recorded.is_some_and(|owned| sha256(existing_block.as_bytes()) != owned.sha256) { return Ok(None); } - replace_range(before_text, start, end, &block).into_bytes() + ( + replace_range(before_text, start, end, &block).into_bytes(), + recorded + .map(|owned| owned.leading_separator.clone()) + .unwrap_or_default(), + ) } else { if !accept_managed_block { return Ok(None); } - append_markdown_block(before_text, &block).into_bytes() + let (content, separator) = append_markdown_block(before_text, &block); + (content.into_bytes(), separator) }; upsert_managed_block( managed_blocks, path, write.block_id.clone(), sha256(block.as_bytes()), + leading_separator, ); Ok(Some(PreparedFile { relative: write.path.clone(), @@ -578,17 +1040,23 @@ fn prepare_markdown_file( } fn prepare_claude_settings( - project: &ActivationProject, + project_fs: &ProjectFs, write: &ManagedBlockUpdate, owned_files: &mut Vec, managed_blocks: &mut Vec, ) -> Result, String> { - ensure_local_target(project, &write.path)?; - let before = read_optional(&project.project_root.join(&write.path))?; + let before = project_fs.read_optional(&write.path)?; let path = relative_string(&write.path)?; let handler = claude_handler(); let handler_hash = sha256(&serde_json::to_vec(&handler).map_err(json_error)?); if before.is_none() { + if owned_files.iter().any(|owned| owned.path == path) + || managed_blocks + .iter() + .any(|owned| owned.path == path && owned.block_id == write.block_id) + { + return Ok(None); + } let mut root = Map::new(); insert_claude_handler(&mut root, handler)?; let bytes = pretty_json(&Value::Object(root))?; @@ -646,7 +1114,13 @@ fn prepare_claude_settings( } } let after = pretty_json(&Value::Object(root))?; - upsert_managed_block(managed_blocks, path, write.block_id.clone(), handler_hash); + upsert_managed_block( + managed_blocks, + path, + write.block_id.clone(), + handler_hash, + String::new(), + ); Ok(Some(PreparedFile { relative: write.path.clone(), before, @@ -736,35 +1210,12 @@ fn validate_plan(plan: &AdapterPlan) -> Result<(), String> { Ok(()) } -fn validate_project(project: &ActivationProject) -> Result<(), String> { +fn validate_project_shape(project: &ActivationProject) -> Result<(), String> { if project.memory_root != project.project_root.join(".tree-ring") { return Err( "activation project memory root must be the project-local .tree-ring".to_string(), ); } - ensure_local_target(project, Path::new(".tree-ring/activation.json")) -} - -fn ensure_local_target(project: &ActivationProject, relative: &Path) -> Result<(), String> { - validate_relative_path_buf(relative)?; - let mut current = project.project_root.clone(); - for component in relative.components() { - let Component::Normal(part) = component else { - return Err("bridge path must be normalized and project-relative".to_string()); - }; - current.push(part); - match fs::symlink_metadata(¤t) { - Ok(metadata) if metadata.file_type().is_symlink() => { - return Err(format!( - "bridge path traverses a symlink: {}", - current.display() - )); - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(io_error(¤t, error)), - } - } Ok(()) } @@ -813,17 +1264,21 @@ fn locate_markdown_block( } } -fn append_markdown_block(content: &str, block: &str) -> String { +fn append_markdown_block(content: &str, block: &str) -> (String, String) { if content.is_empty() { - return block.to_string(); + return (block.to_string(), String::new()); } - if content.ends_with("\n\n") { - format!("{content}{block}") + let separator = if content.ends_with("\n\n") { + "" } else if content.ends_with('\n') { - format!("{content}\n{block}") + "\n" } else { - format!("{content}\n\n{block}") - } + "\n\n" + }; + ( + format!("{content}{separator}{block}"), + separator.to_string(), + ) } fn replace_range(content: &str, start: usize, end: usize, replacement: &str) -> String { @@ -834,6 +1289,7 @@ fn remove_markdown_block( bytes: &[u8], block_id: &str, expected_hash: &str, + leading_separator: &str, ) -> Result>, String> { let content = match std::str::from_utf8(bytes) { Ok(content) => content, @@ -845,14 +1301,13 @@ fn remove_markdown_block( if sha256(&content.as_bytes()[start..end]) != expected_hash { return Ok(None); } - let mut result = replace_range(content, start, end, ""); - while result.ends_with("\n\n") { - result.pop(); - } - if !result.is_empty() && !result.ends_with('\n') { - result.push('\n'); + let removal_start = start.saturating_sub(leading_separator.len()); + if &content[removal_start..start] != leading_separator { + return Ok(None); } - Ok(Some(result.into_bytes())) + Ok(Some( + replace_range(content, removal_start, end, "").into_bytes(), + )) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -871,61 +1326,52 @@ fn claude_handler() -> Value { } fn inspect_claude_handler(root: &Map) -> Result { + validate_claude_hook_shape(root)?; let expected = claude_handler(); let mut exact = 0usize; let mut conflict = false; - inspect_tree_ring_values( - &Value::Object(root.clone()), - &expected, - &mut exact, - &mut conflict, - ); + if let Some(entries) = root + .get("hooks") + .and_then(Value::as_object) + .and_then(|hooks| hooks.get("SessionStart")) + .and_then(Value::as_array) + { + for entry in entries { + let handlers = entry + .get("hooks") + .and_then(Value::as_array) + .expect("validated SessionStart handler array"); + for handler in handlers { + let object = handler + .as_object() + .expect("validated SessionStart handler object"); + let description = object.get("description").and_then(Value::as_str); + let command = object.get("command").and_then(Value::as_str); + let claims_description = description == Some(CLAUDE_DESCRIPTION); + let claims_command = command.is_some_and(|command| { + command.contains("tree-ring") + && command.contains("integrations preflight") + && command.contains("--harness claude-code") + }); + if claims_description || claims_command { + if handler == &expected { + exact += 1; + } else { + conflict = true; + } + } + } + } + } if conflict || exact > 1 { Ok(ClaudeHandlerState::Conflict) } else if exact == 1 { Ok(ClaudeHandlerState::Exact) } else { - validate_claude_hook_shape(root)?; Ok(ClaudeHandlerState::Absent) } } -fn inspect_tree_ring_values( - value: &Value, - expected: &Value, - exact: &mut usize, - conflict: &mut bool, -) { - match value { - Value::Object(object) => { - let description = object.get("description").and_then(Value::as_str); - let command = object.get("command").and_then(Value::as_str); - let claims_description = description == Some(CLAUDE_DESCRIPTION); - let claims_command = command.is_some_and(|command| { - command.contains("tree-ring") - && command.contains("integrations preflight") - && command.contains("--harness claude-code") - }); - if claims_description || claims_command { - if Value::Object(object.clone()) == *expected { - *exact += 1; - } else { - *conflict = true; - } - } - for child in object.values() { - inspect_tree_ring_values(child, expected, exact, conflict); - } - } - Value::Array(values) => { - for child in values { - inspect_tree_ring_values(child, expected, exact, conflict); - } - } - _ => {} - } -} - fn validate_claude_hook_shape(root: &Map) -> Result<(), String> { let Some(hooks) = root.get("hooks") else { return Ok(()); @@ -934,9 +1380,21 @@ fn validate_claude_hook_shape(root: &Map) -> Result<(), String> { .as_object() .ok_or_else(|| "Claude settings hooks must be an object".to_string())?; if let Some(session_start) = hooks.get("SessionStart") { - session_start + let entries = session_start .as_array() .ok_or_else(|| "Claude SessionStart hooks must be an array".to_string())?; + for entry in entries { + let entry = entry + .as_object() + .ok_or_else(|| "Claude SessionStart entry must be an object".to_string())?; + let handlers = entry + .get("hooks") + .and_then(Value::as_array) + .ok_or_else(|| "Claude SessionStart entry hooks must be an array".to_string())?; + if handlers.iter().any(|handler| !handler.is_object()) { + return Err("Claude SessionStart command handler must be an object".to_string()); + } + } } Ok(()) } @@ -965,6 +1423,9 @@ fn remove_claude_handler(bytes: &[u8], expected_hash: &str) -> Result root, Err(_) => return Ok(None), }; + if validate_claude_hook_shape(&root).is_err() { + return Ok(None); + } let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else { return Ok(None); }; @@ -1041,49 +1502,113 @@ fn upsert_managed_block( path: String, block_id: String, digest: String, + leading_separator: String, ) { if let Some(existing) = owned .iter_mut() .find(|owned| owned.path == path && owned.block_id == block_id) { existing.sha256 = digest; + existing.leading_separator = leading_separator; } else { owned.push(OwnedManagedBlock { path, block_id, sha256: digest, + leading_separator, }); } } +fn reconcile_manifest( + project_fs: &ProjectFs, + supplied: &ActivationManifest, +) -> Result<(ActivationManifest, Option), String> { + let persisted = load_persisted_manifest(project_fs)?; + let Some(persisted_manifest) = persisted else { + return Ok((supplied.clone(), None)); + }; + if persisted_manifest.schema_version != supplied.schema_version + || persisted_manifest.protocol_version != supplied.protocol_version + || persisted_manifest.store_id != supplied.store_id + || persisted_manifest.project_root_fingerprint != supplied.project_root_fingerprint + { + return Err( + "activation manifest identity changed while preparing bridge update".to_string(), + ); + } + Ok((persisted_manifest.clone(), Some(persisted_manifest))) +} + +fn load_persisted_manifest(project_fs: &ProjectFs) -> Result, String> { + let Some(bytes) = project_fs.read_optional(Path::new(".tree-ring/activation.json"))? else { + return Ok(None); + }; + let manifest: ActivationManifest = serde_json::from_slice(&bytes) + .map_err(|error| format!("invalid activation JSON: {error}"))?; + validate_manifest(&manifest)?; + Ok(Some(manifest)) +} + +fn ensure_expected_manifest( + project_fs: &ProjectFs, + expected: Option<&ActivationManifest>, +) -> Result<(), String> { + let current = load_persisted_manifest(project_fs)?; + if current.as_ref() == expected { + Ok(()) + } else { + Err("activation manifest changed concurrently; bridge update was not committed".to_string()) + } +} + +fn persist_manifest( + project_fs: &ProjectFs, + expected: Option<&ActivationManifest>, + manifest: &ActivationManifest, +) -> Result<(), String> { + validate_manifest(manifest)?; + ensure_expected_manifest(project_fs, expected)?; + let bytes = serde_json::to_vec_pretty(manifest) + .map_err(|error| format!("failed to serialize activation JSON: {error}"))?; + let target = project_fs.resolve_target(Path::new(".tree-ring/activation.json"), true)?; + target.atomic_write(&bytes, expected.is_none()) +} + fn commit_files_and_manifest( - project: &ActivationProject, + project_fs: &ProjectFs, manifest: &mut ActivationManifest, + original_manifest: &ActivationManifest, + expected_persisted: Option<&ActivationManifest>, next_manifest: ActivationManifest, files: &[PreparedFile], ) -> Result<(), String> { - let original_manifest = manifest.clone(); + ensure_expected_manifest(project_fs, expected_persisted)?; let mut applied = Vec::new(); for file in files.iter().filter(|file| file.before != file.after) { - if let Err(error) = commit_prepared_file(project, file) { - rollback_files(project, &applied); - return Err(error); + match commit_prepared_file(project_fs, file) { + Ok(applied_file) => applied.push(applied_file), + Err(error) => { + rollback_files(&applied); + return Err(error); + } } - applied.push(file.clone()); } - if let Err(error) = save_manifest(&project.memory_root, &next_manifest) { - rollback_files(project, &applied); - *manifest = original_manifest; + if let Err(error) = persist_manifest(project_fs, expected_persisted, &next_manifest) { + rollback_files(&applied); + *manifest = original_manifest.clone(); return Err(error); } *manifest = next_manifest; Ok(()) } -fn commit_prepared_file(project: &ActivationProject, file: &PreparedFile) -> Result<(), String> { - ensure_local_target(project, &file.relative)?; - let path = project.project_root.join(&file.relative); - let current = read_optional(&path)?; +fn commit_prepared_file( + project_fs: &ProjectFs, + file: &PreparedFile, +) -> Result { + let target = project_fs.resolve_target(&file.relative, file.after.is_some())?; + let current = target.read_optional()?; if current != file.before { return Err(format!( "bridge target changed after validation: {}", @@ -1091,70 +1616,28 @@ fn commit_prepared_file(project: &ActivationProject, file: &PreparedFile) -> Res )); } match &file.after { - Some(bytes) => atomic_write_bytes(&path, bytes, file.before.is_none()), - None => match fs::remove_file(&path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(io_error(&path, error)), - }, - } + Some(bytes) => target.atomic_write(bytes, file.before.is_none())?, + None => target.remove_file()?, + }; + Ok(AppliedFile { + prepared: file.clone(), + target, + }) } -fn rollback_files(project: &ActivationProject, files: &[PreparedFile]) { +fn rollback_files(files: &[AppliedFile]) { for file in files.iter().rev() { - let path = project.project_root.join(&file.relative); - match &file.before { + match &file.prepared.before { Some(bytes) => { - let _ = atomic_write_bytes(&path, bytes, false); + let _ = file.target.atomic_write(bytes, false); } None => { - let _ = fs::remove_file(&path); + let _ = file.target.remove_file(); } } } } -fn atomic_write_bytes(path: &Path, bytes: &[u8], create_only: bool) -> Result<(), String> { - let parent = path - .parent() - .ok_or_else(|| format!("bridge output has no parent: {}", path.display()))?; - fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| format!("bridge output has no UTF-8 file name: {}", path.display()))?; - let temp = parent.join(format!(".{file_name}.{}.tmp", Uuid::new_v4())); - let result = (|| { - let mut file = OpenOptions::new() - .create_new(true) - .write(true) - .open(&temp) - .map_err(|error| io_error(&temp, error))?; - file.write_all(bytes) - .map_err(|error| io_error(&temp, error))?; - file.sync_all().map_err(|error| io_error(&temp, error))?; - drop(file); - if create_only { - fs::hard_link(&temp, path).map_err(|error| io_error(path, error))?; - fs::remove_file(&temp).map_err(|error| io_error(&temp, error)) - } else { - fs::rename(&temp, path).map_err(|error| io_error(path, error)) - } - })(); - if result.is_err() { - let _ = fs::remove_file(&temp); - } - result -} - -fn read_optional(path: &Path) -> Result>, String> { - match fs::read(path) { - Ok(bytes) => Ok(Some(bytes)), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(io_error(path, error)), - } -} - fn sha256(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); @@ -1178,6 +1661,14 @@ fn applied_state(harness_id: &str, planned: ActivationState) -> ActivationState } } +fn deactivated_state(harness_id: &str) -> ActivationState { + if harness_id == "pi" { + ActivationState::NeedsTrust + } else { + ActivationState::ConfiguredAwaitingProof + } +} + fn review_step(harness_id: &str) -> String { if harness_id == "codex" { CODEX_RETRY.to_string() @@ -1201,11 +1692,17 @@ mod tests { use super::*; use crate::activation::{ adapters::{plan_activation, ActivationProject, AdapterPlan, BridgeWrite, PlannedWrite}, - manifest::ActivationManifest, - ActivationState, ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, + manifest::{ActivationManifest, HarnessActivation, OwnedBridgeFile, OwnedManagedBlock}, + ActivationState, AdapterCapability, ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, }; use serde_json::{json, Value}; - use std::{collections::BTreeMap, fs, path::Path}; + use std::{ + collections::BTreeMap, + fs, + path::Path, + sync::{Arc, Barrier}, + thread, + }; use tempfile::TempDir; fn fixture() -> (TempDir, ActivationProject, ActivationManifest) { @@ -1246,6 +1743,17 @@ mod tests { plan_activation(harness, project).unwrap() } + fn verified_agent_zero_plan() -> AdapterPlan { + AdapterPlan { + harness_id: "agent-zero".to_string(), + state: ActivationState::ConfiguredAwaitingProof, + writes: vec![PlannedWrite::BridgeWrite(BridgeWrite { + path: ".tree-ring/activation/agent-zero.json".into(), + })], + next_step: "verified separate plugin".to_string(), + } + } + #[test] fn unmanaged_agents_file_requires_explicit_review_without_partial_writes() { let (_temp, project, mut manifest) = fixture(); @@ -1487,15 +1995,7 @@ mod tests { .join(".tree-ring/activation/agent-zero.json") .exists()); - let verified = AdapterPlan { - harness_id: "agent-zero".to_string(), - state: ActivationState::ConfiguredAwaitingProof, - writes: vec![PlannedWrite::BridgeWrite(BridgeWrite { - path: ".tree-ring/activation/agent-zero.json".into(), - })], - next_step: "verified separate plugin".to_string(), - }; - apply_bridge_plan(&project, &mut manifest, verified, false).unwrap(); + apply_bridge_plan(&project, &mut manifest, verified_agent_zero_plan(), false).unwrap(); let binding: Value = serde_json::from_str(&read( project .project_root @@ -1664,4 +2164,263 @@ mod tests { assert!(!outside_temp.path().join("activation.json").exists()); assert!(!project_temp.path().join("AGENTS.md").exists()); } + + #[test] + fn malformed_manifest_ownership_can_never_delete_non_adapter_or_store_material() { + for relative in [ + ".tree-ring/AGENTS.md", + ".tree-ring/SKILL.md", + ".tree-ring/CLI.md", + ".tree-ring/activation/receipts/codex/worker/receipt.json", + "unrelated.txt", + ] { + let (_temp, project, mut manifest) = fixture(); + let path = project.project_root.join(relative); + write(&path, "must survive\n"); + manifest.harnesses.insert( + "codex".to_string(), + HarnessActivation { + state: ActivationState::ConfiguredAwaitingProof, + adapter_capability: AdapterCapability::WrapperPreflight, + bridge_path: Some(relative.to_string()), + owned_files: vec![OwnedBridgeFile { + path: relative.to_string(), + sha256: sha256(b"must survive\n"), + }], + managed_blocks: Vec::new(), + }, + ); + + let error = deactivate_bridge_plan(&project, &mut manifest, "codex").unwrap_err(); + + assert!(error.contains("ownership") || error.contains("bridge")); + assert_eq!(read(path), "must survive\n", "{relative}"); + } + + let (_temp, project, mut manifest) = fixture(); + manifest.harnesses.insert( + "codex".to_string(), + HarnessActivation { + state: ActivationState::ConfiguredAwaitingProof, + adapter_capability: AdapterCapability::WrapperPreflight, + bridge_path: Some(".tree-ring/AGENTS.md".to_string()), + owned_files: Vec::new(), + managed_blocks: vec![OwnedManagedBlock { + path: ".tree-ring/AGENTS.md".to_string(), + block_id: "codex".to_string(), + sha256: "a".repeat(64), + leading_separator: String::new(), + }], + }, + ); + assert!(deactivate_bridge_plan(&project, &mut manifest, "codex").is_err()); + assert!(project.memory_root.join("AGENTS.md").exists()); + } + + #[test] + fn agent_zero_missing_plugin_preserves_the_owned_binding_for_safe_deactivation() { + let (_temp, project, mut manifest) = fixture(); + apply_bridge_plan(&project, &mut manifest, verified_agent_zero_plan(), false).unwrap(); + let binding = project + .project_root + .join(".tree-ring/activation/agent-zero.json"); + let ownership = manifest.harnesses["agent-zero"].owned_files.clone(); + + let result = + apply_bridge_plan(&project, &mut manifest, plan("agent-zero", &project), false) + .unwrap(); + + assert_eq!(result.state, ActivationState::NeedsPlugin); + assert!(binding.exists()); + assert_eq!(manifest.harnesses["agent-zero"].owned_files, ownership); + assert_eq!( + manifest.harnesses["agent-zero"].bridge_path.as_deref(), + Some(".tree-ring/activation/agent-zero.json") + ); + } + + #[test] + fn missing_formerly_owned_agents_and_claude_settings_require_review_without_recreation() { + let (_temp, project, mut manifest) = fixture(); + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), false).unwrap(); + fs::remove_file(project.project_root.join("AGENTS.md")).unwrap(); + let result = + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), false).unwrap(); + assert_eq!(result.state, ActivationState::NeedsUserReview); + assert!(!project.project_root.join("AGENTS.md").exists()); + + let (_temp, project, mut manifest) = fixture(); + write(project.project_root.join("AGENTS.md"), "# Team\n"); + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), true).unwrap(); + fs::remove_file(project.project_root.join("AGENTS.md")).unwrap(); + let result = + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), false).unwrap(); + assert_eq!(result.state, ActivationState::NeedsUserReview); + assert!(!project.project_root.join("AGENTS.md").exists()); + + let (_temp, project, mut manifest) = fixture(); + apply_bridge_plan( + &project, + &mut manifest, + plan("claude-code", &project), + false, + ) + .unwrap(); + fs::remove_file(project.project_root.join(".claude/settings.json")).unwrap(); + let result = apply_bridge_plan( + &project, + &mut manifest, + plan("claude-code", &project), + false, + ) + .unwrap(); + assert_eq!(result.state, ActivationState::NeedsUserReview); + assert!(!project.project_root.join(".claude/settings.json").exists()); + + let (_temp, project, mut manifest) = fixture(); + let settings = project.project_root.join(".claude/settings.json"); + write(&settings, "{\"permissions\": {\"allow\": [\"Read\"]}}"); + apply_bridge_plan( + &project, + &mut manifest, + plan("claude-code", &project), + false, + ) + .unwrap(); + fs::remove_file(&settings).unwrap(); + let result = apply_bridge_plan( + &project, + &mut manifest, + plan("claude-code", &project), + false, + ) + .unwrap(); + assert_eq!(result.state, ActivationState::NeedsUserReview); + assert!(!settings.exists()); + } + + #[test] + fn claude_only_recognizes_handlers_in_the_exact_session_start_hook_location() { + let (_temp, project, mut manifest) = fixture(); + let settings = project.project_root.join(".claude/settings.json"); + write( + &settings, + &serde_json::to_string_pretty(&json!({ + "custom": claude_handler(), + "hooks": {"SessionStart": []} + })) + .unwrap(), + ); + + apply_bridge_plan( + &project, + &mut manifest, + plan("claude-code", &project), + false, + ) + .unwrap(); + let merged: Value = serde_json::from_str(&read(&settings)).unwrap(); + + assert_eq!(merged["custom"], claude_handler()); + assert_eq!(merged["hooks"]["SessionStart"].as_array().unwrap().len(), 1); + + let (_temp, project, mut manifest) = fixture(); + let settings = project.project_root.join(".claude/settings.json"); + let malformed = serde_json::to_string(&json!({ + "custom": claude_handler(), + "hooks": {"SessionStart": [{"hooks": "not-an-array"}]} + })) + .unwrap(); + write(&settings, &malformed); + let result = apply_bridge_plan( + &project, + &mut manifest, + plan("claude-code", &project), + false, + ) + .unwrap(); + assert_eq!(result.state, ActivationState::NeedsUserReview); + assert_eq!(read(settings), malformed); + } + + #[test] + fn concurrent_disjoint_activations_merge_both_manifest_records() { + let (_temp, project, manifest) = fixture(); + let barrier = Arc::new(Barrier::new(2)); + let mut workers = Vec::new(); + for harness in ["codex", "pi"] { + let project = project.clone(); + let mut worker_manifest = manifest.clone(); + let barrier = Arc::clone(&barrier); + workers.push(thread::spawn(move || { + let adapter_plan = plan(harness, &project); + barrier.wait(); + apply_bridge_plan(&project, &mut worker_manifest, adapter_plan, false).unwrap(); + })); + } + for worker in workers { + worker.join().unwrap(); + } + + let persisted = crate::activation::load_manifest(&project.memory_root).unwrap(); + assert!(persisted.harnesses.contains_key("codex")); + assert!(persisted.harnesses.contains_key("pi")); + } + + #[cfg(unix)] + #[test] + fn descriptor_relative_commit_and_rollback_ignore_a_replaced_parent_path() { + use std::os::unix::fs::symlink; + + let (_temp, project, _manifest) = fixture(); + let target_path = project + .project_root + .join(".agents/skills/tree-ring-memory/SKILL.md"); + write(&target_path, "before\n"); + let project_fs = ProjectFs::open(&project).unwrap(); + let target = project_fs + .resolve_target(Path::new(".agents/skills/tree-ring-memory/SKILL.md"), false) + .unwrap(); + target.atomic_write(b"committed\n", false).unwrap(); + + let parked = project.project_root.join(".agents-parked"); + fs::rename(project.project_root.join(".agents"), &parked).unwrap(); + let outside = tempfile::tempdir().unwrap(); + symlink(outside.path(), project.project_root.join(".agents")).unwrap(); + target.atomic_write(b"before\n", false).unwrap(); + + assert!(!outside + .path() + .join("skills/tree-ring-memory/SKILL.md") + .exists()); + assert_eq!( + read(parked.join("skills/tree-ring-memory/SKILL.md")), + "before\n" + ); + } + + #[test] + fn pi_deactivation_and_no_record_state_remain_needs_trust() { + let (_temp, project, mut manifest) = fixture(); + let absent = deactivate_bridge_plan(&project, &mut manifest, "pi").unwrap(); + assert_eq!(absent.state, ActivationState::NeedsTrust); + + apply_bridge_plan(&project, &mut manifest, plan("pi", &project), false).unwrap(); + let deactivated = deactivate_bridge_plan(&project, &mut manifest, "pi").unwrap(); + assert_eq!(deactivated.state, ActivationState::NeedsTrust); + assert_eq!(manifest.harnesses["pi"].state, ActivationState::NeedsTrust); + } + + #[test] + fn codex_deactivation_preserves_preexisting_trailing_blank_lines_exactly() { + for original in ["# Team\n", "# Team\n\n", "# Team\n\n\n"] { + let (_temp, project, mut manifest) = fixture(); + write(project.project_root.join("AGENTS.md"), original); + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), true).unwrap(); + + deactivate_bridge_plan(&project, &mut manifest, "codex").unwrap(); + + assert_eq!(read(project.project_root.join("AGENTS.md")), original); + } + } } diff --git a/crates/tree-ring-memory-cli/src/activation/manifest.rs b/crates/tree-ring-memory-cli/src/activation/manifest.rs index 517cb1b..ab62141 100644 --- a/crates/tree-ring-memory-cli/src/activation/manifest.rs +++ b/crates/tree-ring-memory-cli/src/activation/manifest.rs @@ -60,6 +60,8 @@ pub struct OwnedManagedBlock { pub path: String, pub block_id: String, pub sha256: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub leading_separator: String, } /// A deliberately minimal, non-sensitive record that an activation occurred. @@ -292,9 +294,28 @@ pub(crate) fn validate_manifest(manifest: &ActivationManifest) -> Result<(), Str validate_identifier("harness id", harness_id)?; if let Some(bridge_path) = &activation.bridge_path { validate_project_relative_path(bridge_path)?; + if !activation + .owned_files + .iter() + .any(|owned| owned.path == *bridge_path) + && !activation + .managed_blocks + .iter() + .any(|owned| owned.path == *bridge_path) + { + return Err(format!( + "bridge path is not recorded ownership for {harness_id}" + )); + } } for owned in &activation.owned_files { validate_project_relative_path(&owned.path)?; + if !allowed_owned_file(harness_id, &owned.path) { + return Err(format!( + "invalid complete-file ownership target for {harness_id}: {}", + owned.path + )); + } if !is_sha256(&owned.sha256) { return Err("owned bridge file digest must be a SHA-256 hex digest".to_string()); } @@ -302,9 +323,73 @@ pub(crate) fn validate_manifest(manifest: &ActivationManifest) -> Result<(), Str for owned in &activation.managed_blocks { validate_project_relative_path(&owned.path)?; validate_identifier("managed block id", &owned.block_id)?; + if !allowed_managed_block(harness_id, &owned.path, &owned.block_id) { + return Err(format!( + "invalid managed-block ownership target for {harness_id}: {}#{}", + owned.path, owned.block_id + )); + } if !is_sha256(&owned.sha256) { return Err("managed block digest must be a SHA-256 hex digest".to_string()); } + if !matches!(owned.leading_separator.as_str(), "" | "\n" | "\n\n") { + return Err("invalid managed block leading separator".to_string()); + } + } + reject_duplicate_ownership(harness_id, activation)?; + } + Ok(()) +} + +fn allowed_owned_file(harness_id: &str, path: &str) -> bool { + match harness_id { + "codex" => matches!( + path, + ".agents/skills/tree-ring-memory/SKILL.md" | "AGENTS.md" + ), + "claude-code" => matches!( + path, + ".claude/skills/tree-ring-memory/SKILL.md" | ".claude/settings.json" + ), + "pi" => matches!( + path, + ".agents/skills/tree-ring-memory/SKILL.md" | ".pi/extensions/tree-ring-memory.ts" + ), + "agent-zero" => path == ".tree-ring/activation/agent-zero.json", + _ => false, + } +} + +fn allowed_managed_block(harness_id: &str, path: &str, block_id: &str) -> bool { + matches!( + (harness_id, path, block_id), + ("codex", "AGENTS.md", "codex") | ("claude-code", ".claude/settings.json", "claude-code") + ) +} + +fn reject_duplicate_ownership( + harness_id: &str, + activation: &HarnessActivation, +) -> Result<(), String> { + let mut files = std::collections::BTreeSet::new(); + for owned in &activation.owned_files { + if !files.insert(&owned.path) { + return Err(format!( + "duplicate complete-file ownership for {harness_id}" + )); + } + } + let mut blocks = std::collections::BTreeSet::new(); + for owned in &activation.managed_blocks { + if !blocks.insert((&owned.path, &owned.block_id)) { + return Err(format!( + "duplicate managed-block ownership for {harness_id}" + )); + } + if files.contains(&owned.path) { + return Err(format!( + "conflicting complete-file and managed-block ownership for {harness_id}" + )); } } Ok(()) @@ -565,7 +650,7 @@ mod tests { HarnessActivation { state: ActivationState::Active, adapter_capability: AdapterCapability::NativePreflight, - bridge_path: Some("bridges/codex.sh".to_string()), + bridge_path: None, owned_files: Vec::new(), managed_blocks: Vec::new(), }, From aad19d7d2d129631c8750f19c10e453b9d9a994e Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 13 Aug 2026 23:45:05 -0400 Subject: [PATCH 08/31] fix: close bridge publication races --- .../src/activation/bridge.rs | 832 ++++++++++++++++-- 1 file changed, 737 insertions(+), 95 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/activation/bridge.rs b/crates/tree-ring-memory-cli/src/activation/bridge.rs index 7d08f08..d3a9731 100644 --- a/crates/tree-ring-memory-cli/src/activation/bridge.rs +++ b/crates/tree-ring-memory-cli/src/activation/bridge.rs @@ -8,19 +8,24 @@ use super::{ }; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +#[cfg(test)] +use std::cell::RefCell; + +#[cfg(unix)] use std::{ ffi::{CStr, CString, OsStr}, - fs::{self, File}, + fs::File, io::{Read, Write}, - path::{Component, Path, PathBuf}, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::{ffi::OsStrExt, fs::MetadataExt}, + }, + path::Component, }; -use uuid::Uuid; - #[cfg(unix)] -use std::os::{ - fd::{AsRawFd, FromRawFd}, - unix::ffi::OsStrExt, -}; +use uuid::Uuid; const CODEX_RETRY: &str = "tree-ring integrations activate --harness codex --accept-managed-block"; const CLAUDE_DESCRIPTION: &str = "Tree Ring Memory managed preflight v1"; @@ -119,6 +124,62 @@ export default function treeRingMemory(pi: ExtensionAPI) { } "#; +#[cfg(test)] +#[derive(Default)] +struct TestMutationHooks { + after_project_root_opened: Option>, + after_target_snapshot: Option>, + fail_directory_sync_at: Option, + directory_syncs: usize, +} + +#[cfg(test)] +thread_local! { + static TEST_MUTATION_HOOKS: RefCell = RefCell::new(TestMutationHooks::default()); +} + +#[cfg(test)] +fn after_project_root_opened() { + let action = + TEST_MUTATION_HOOKS.with(|hooks| hooks.borrow_mut().after_project_root_opened.take()); + if let Some(action) = action { + action(); + } +} + +#[cfg(not(test))] +fn after_project_root_opened() {} + +#[cfg(test)] +fn after_target_snapshot() { + let action = TEST_MUTATION_HOOKS.with(|hooks| hooks.borrow_mut().after_target_snapshot.take()); + if let Some(action) = action { + action(); + } +} + +#[cfg(not(test))] +fn after_target_snapshot() {} + +#[cfg(test)] +fn fail_directory_sync_now() -> bool { + TEST_MUTATION_HOOKS.with(|hooks| { + let mut hooks = hooks.borrow_mut(); + hooks.directory_syncs += 1; + if hooks.fail_directory_sync_at == Some(hooks.directory_syncs) { + hooks.fail_directory_sync_at = None; + true + } else { + false + } + }) +} + +#[cfg(not(test))] +fn fail_directory_sync_now() -> bool { + false +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct BridgePlanResult { pub state: ActivationState, @@ -133,17 +194,81 @@ struct PreparedFile { after: Option>, } -#[cfg(unix)] #[derive(Debug)] struct AppliedFile { prepared: PreparedFile, target: ResolvedTarget, + publication: Publication, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FileIdentity { + device: u64, + inode: u64, +} + +#[derive(Debug)] +struct TargetSnapshot { + identity: FileIdentity, + bytes: Vec, +} + +#[derive(Debug, Clone, Copy)] +enum Publication { + Present { identity: FileIdentity }, + Absent, +} + +#[derive(Debug)] +struct MutationError { + message: String, + publication: Option, +} + +impl MutationError { + fn before_publication(message: String) -> Self { + Self { + message, + publication: None, + } + } + + fn after_publication(message: String, publication: Publication) -> Self { + Self { + message, + publication: Some(publication), + } + } +} + +#[derive(Debug)] +struct CommitPreparedError { + message: String, + applied: Option>, +} + +impl CommitPreparedError { + fn before_publication(message: String) -> Self { + Self { + message, + applied: None, + } + } + + fn after_publication(message: String, applied: AppliedFile) -> Self { + Self { + message, + applied: Some(Box::new(applied)), + } + } } #[cfg(unix)] #[derive(Debug)] struct ProjectFs { root: File, + project_root: PathBuf, + root_identity: FileIdentity, } #[cfg(unix)] @@ -174,15 +299,20 @@ impl Drop for ManifestLock { impl ProjectFs { fn open(project: &ActivationProject) -> Result { validate_project_shape(project)?; - let metadata = fs::symlink_metadata(&project.project_root) + let root = open_project_root_no_follow(&project.project_root)?; + let metadata = root + .metadata() .map_err(|error| io_error(&project.project_root, error))?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { + if !metadata.is_dir() { return Err("project root must be a real directory, not a symlink".to_string()); } - let physical_root = fs::canonicalize(&project.project_root) - .map_err(|error| io_error(&project.project_root, error))?; - let root = open_directory_path_no_follow(&physical_root)?; - Ok(Self { root }) + let project_fs = Self { + root, + project_root: project.project_root.clone(), + root_identity: file_identity(&metadata), + }; + after_project_root_opened(); + Ok(project_fs) } fn lock_manifest(&self) -> Result { @@ -208,6 +338,18 @@ impl ProjectFs { target.read_optional() } + fn ensure_root_binding(&self) -> Result<(), String> { + let current = open_project_root_no_follow(&self.project_root) + .map_err(|_| "activation project root changed during bridge update".to_string())?; + let metadata = current + .metadata() + .map_err(|error| io_error(&self.project_root, error))?; + if !metadata.is_dir() || file_identity(&metadata) != self.root_identity { + return Err("activation project root changed during bridge update".to_string()); + } + Ok(()) + } + fn resolve_target( &self, relative: &Path, @@ -264,6 +406,10 @@ impl ProjectFs { #[cfg(unix)] impl ResolvedTarget { fn read_optional(&self) -> Result>, String> { + Ok(self.snapshot_optional()?.map(|snapshot| snapshot.bytes)) + } + + fn snapshot_optional(&self) -> Result, String> { let descriptor = unsafe { // SAFETY: the parent descriptor and single-component name are valid for this call. libc::openat( @@ -293,16 +439,28 @@ impl ResolvedTarget { let mut bytes = Vec::new(); file.read_to_end(&mut bytes) .map_err(|error| io_error(&self.display, error))?; - Ok(Some(bytes)) + let metadata = file + .metadata() + .map_err(|error| io_error(&self.display, error))?; + Ok(Some(TargetSnapshot { + identity: file_identity(&metadata), + bytes, + })) } - fn atomic_write(&self, bytes: &[u8], create_only: bool) -> Result<(), String> { + fn publish_write( + &self, + bytes: &[u8], + expected: Option, + ) -> Result { let temp_name = CString::new(format!( ".{}.{}.tmp", self.name.to_string_lossy(), Uuid::new_v4() )) - .map_err(|_| "temporary bridge name contains NUL".to_string())?; + .map_err(|_| { + MutationError::before_publication("temporary bridge name contains NUL".to_string()) + })?; let descriptor = unsafe { // SAFETY: parent and temp name are valid; O_EXCL creates one owned temp file. libc::openat( @@ -312,15 +470,26 @@ impl ResolvedTarget { 0o600, ) }; - let mut temp = owned_file_descriptor(descriptor, &self.display)?; - let result = (|| { - temp.write_all(bytes) - .map_err(|error| io_error(&self.display, error))?; - temp.sync_all() - .map_err(|error| io_error(&self.display, error))?; + let mut temp = owned_file_descriptor(descriptor, &self.display) + .map_err(MutationError::before_publication)?; + let result = (|| -> Result { + temp.write_all(bytes).map_err(|error| { + MutationError::before_publication(io_error(&self.display, error)) + })?; + temp.sync_all().map_err(|error| { + MutationError::before_publication(io_error(&self.display, error)) + })?; + let metadata = temp.metadata().map_err(|error| { + MutationError::before_publication(io_error(&self.display, error)) + })?; + let publication = Publication::Present { + identity: file_identity(&metadata), + }; drop(temp); + self.ensure_entry_identity(expected) + .map_err(MutationError::before_publication)?; let status = unsafe { - if create_only { + if expected.is_none() { // SAFETY: both names are relative to the same retained parent descriptor. libc::linkat( self.parent.as_raw_fd(), @@ -340,36 +509,103 @@ impl ResolvedTarget { } }; if status != 0 { - return Err(io_error(&self.display, std::io::Error::last_os_error())); + return Err(MutationError::before_publication(io_error( + &self.display, + std::io::Error::last_os_error(), + ))); } - if create_only { - unlink_at(&self.parent, &temp_name, &self.display)?; + if expected.is_none() { + if let Err(error) = unlink_at_if_present(&self.parent, &temp_name, &self.display) { + return Err(MutationError::after_publication(error, publication)); + } } - sync_directory(&self.parent, &self.display) + Ok(publication) })(); if result.is_err() { - let _ = unlink_at(&self.parent, &temp_name, &self.display); + let _ = unlink_at_if_present(&self.parent, &temp_name, &self.display); } result } - fn remove_file(&self) -> Result<(), String> { - unlink_at(&self.parent, &self.name, &self.display)?; + fn publish_removal(&self, expected: FileIdentity) -> Result { + self.ensure_entry_identity(Some(expected)) + .map_err(MutationError::before_publication)?; + unlink_at(&self.parent, &self.name, &self.display) + .map_err(MutationError::before_publication)?; + Ok(Publication::Absent) + } + + fn sync_parent(&self) -> Result<(), String> { sync_directory(&self.parent, &self.display) } + + fn ensure_entry_identity(&self, expected: Option) -> Result<(), String> { + let current = self.entry_identity_optional()?; + if current == expected { + Ok(()) + } else { + Err(format!( + "bridge target changed after validation: {}", + self.display.display() + )) + } + } + + fn entry_identity_optional(&self) -> Result, String> { + let mut stat = unsafe { std::mem::zeroed::() }; + let status = unsafe { + // SAFETY: parent descriptor and one-component name remain valid for the call. + libc::fstatat( + self.parent.as_raw_fd(), + self.name.as_ptr(), + &mut stat, + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if status != 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::NotFound { + return Ok(None); + } + return Err(io_error(&self.display, error)); + } + if stat.st_mode & libc::S_IFMT != libc::S_IFREG { + return Err(format!( + "bridge target is not a regular file: {}", + self.display.display() + )); + } + Ok(Some(FileIdentity { + device: stat.st_dev as u64, + inode: stat.st_ino as u64, + })) + } + + #[cfg(test)] + fn atomic_write(&self, bytes: &[u8], create_only: bool) -> Result<(), String> { + let current = self.snapshot_optional()?; + if create_only != current.is_none() { + return Err("test write creation mode did not match the target state".to_string()); + } + self.publish_write(bytes, current.as_ref().map(|snapshot| snapshot.identity)) + .map_err(|error| error.message)?; + self.sync_parent() + } } #[cfg(unix)] -fn open_directory_path_no_follow(path: &Path) -> Result { +fn open_project_root_no_follow(path: &Path) -> Result { if path.as_os_str().is_empty() { return Err("project root is empty".to_string()); } - let absolute = path.is_absolute(); - let anchor = CString::new(if absolute { "/" } else { "." }).expect("static anchor"); + let display = path.to_path_buf(); + let path = CString::new(display.as_os_str().as_bytes()) + .map_err(|_| "project root contains NUL".to_string())?; let descriptor = unsafe { - // SAFETY: anchor is a valid static C string and the descriptor is immediately owned. + // SAFETY: the full project-root path is NUL-free and O_NOFOLLOW protects its final + // component while the returned descriptor pins the directory identity. libc::open( - anchor.as_ptr(), + path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY @@ -377,19 +613,15 @@ fn open_directory_path_no_follow(path: &Path) -> Result { | libc::O_NONBLOCK, ) }; - let mut directory = owned_file_descriptor(descriptor, path)?; - for component in path.components() { - match component { - Component::RootDir if absolute => {} - Component::CurDir => {} - Component::Normal(segment) => { - directory = open_child_directory(&directory, segment) - .map_err(|error| io_error(path, error))?; - } - _ => return Err("project root must not contain parent traversal".to_string()), - } + owned_file_descriptor(descriptor, &display) +} + +#[cfg(unix)] +fn file_identity(metadata: &std::fs::Metadata) -> FileIdentity { + FileIdentity { + device: metadata.dev(), + inode: metadata.ino(), } - Ok(directory) } #[cfg(unix)] @@ -461,17 +693,35 @@ fn unlink_at(parent: &File, name: &CStr, display: &Path) -> Result<(), String> { if status == 0 { Ok(()) } else { - let error = std::io::Error::last_os_error(); - if error.kind() == std::io::ErrorKind::NotFound { - Ok(()) - } else { - Err(io_error(display, error)) - } + Err(io_error(display, std::io::Error::last_os_error())) + } +} + +#[cfg(unix)] +fn unlink_at_if_present(parent: &File, name: &CStr, display: &Path) -> Result<(), String> { + let status = unsafe { + // SAFETY: parent owns a live descriptor and name is one child component. + libc::unlinkat(parent.as_raw_fd(), name.as_ptr(), 0) + }; + if status == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(io_error(display, error)) } } #[cfg(unix)] fn sync_directory(directory: &File, display: &Path) -> Result<(), String> { + if fail_directory_sync_now() { + return Err(format!( + "{}: injected directory sync failure", + display.display() + )); + } let status = unsafe { // SAFETY: directory owns a live directory descriptor. libc::fsync(directory.as_raw_fd()) @@ -495,13 +745,6 @@ struct ResolvedTarget; #[derive(Debug)] struct ManifestLock; -#[cfg(not(unix))] -#[derive(Debug)] -struct AppliedFile { - prepared: PreparedFile, - target: ResolvedTarget, -} - #[cfg(not(unix))] impl ProjectFs { fn open(_project: &ActivationProject) -> Result { @@ -516,6 +759,10 @@ impl ProjectFs { Err("bridge access requires descriptor-relative no-follow filesystem support".to_string()) } + fn ensure_root_binding(&self) -> Result<(), String> { + Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) + } + fn resolve_target( &self, _relative: &Path, @@ -531,11 +778,27 @@ impl ResolvedTarget { Err("bridge access requires descriptor-relative no-follow filesystem support".to_string()) } - fn atomic_write(&self, _bytes: &[u8], _create_only: bool) -> Result<(), String> { - Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) + fn snapshot_optional(&self) -> Result, String> { + Err("bridge access requires descriptor-relative no-follow filesystem support".to_string()) + } + + fn publish_write( + &self, + _bytes: &[u8], + _expected: Option, + ) -> Result { + Err(MutationError::before_publication( + "bridge mutation requires descriptor-relative no-follow filesystem support".to_string(), + )) + } + + fn publish_removal(&self, _expected: FileIdentity) -> Result { + Err(MutationError::before_publication( + "bridge mutation requires descriptor-relative no-follow filesystem support".to_string(), + )) } - fn remove_file(&self) -> Result<(), String> { + fn sync_parent(&self) -> Result<(), String> { Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) } } @@ -1566,13 +1829,36 @@ fn persist_manifest( project_fs: &ProjectFs, expected: Option<&ActivationManifest>, manifest: &ActivationManifest, -) -> Result<(), String> { - validate_manifest(manifest)?; - ensure_expected_manifest(project_fs, expected)?; - let bytes = serde_json::to_vec_pretty(manifest) - .map_err(|error| format!("failed to serialize activation JSON: {error}"))?; - let target = project_fs.resolve_target(Path::new(".tree-ring/activation.json"), true)?; - target.atomic_write(&bytes, expected.is_none()) +) -> Result, CommitPreparedError> { + validate_manifest(manifest).map_err(CommitPreparedError::before_publication)?; + let bytes = serde_json::to_vec_pretty(manifest).map_err(|error| { + CommitPreparedError::before_publication(format!( + "failed to serialize activation JSON: {error}" + )) + })?; + let relative = PathBuf::from(".tree-ring/activation.json"); + let before = project_fs + .read_optional(&relative) + .map_err(CommitPreparedError::before_publication)?; + let current = match before.as_deref() { + Some(bytes) => Some(serde_json::from_slice::(bytes).map_err( + |error| { + CommitPreparedError::before_publication(format!("invalid activation JSON: {error}")) + }, + )?), + None => None, + }; + if current.as_ref() != expected { + return Err(CommitPreparedError::before_publication( + "activation manifest changed concurrently; bridge update was not committed".to_string(), + )); + } + let prepared = PreparedFile { + relative, + before, + after: Some(bytes), + }; + commit_prepared_file(project_fs, &prepared) } fn commit_files_and_manifest( @@ -1583,18 +1869,43 @@ fn commit_files_and_manifest( next_manifest: ActivationManifest, files: &[PreparedFile], ) -> Result<(), String> { + project_fs.ensure_root_binding()?; ensure_expected_manifest(project_fs, expected_persisted)?; let mut applied = Vec::new(); for file in files.iter().filter(|file| file.before != file.after) { match commit_prepared_file(project_fs, file) { - Ok(applied_file) => applied.push(applied_file), + Ok(Some(applied_file)) => applied.push(applied_file), + Ok(None) => {} Err(error) => { + if let Some(applied_file) = error.applied { + applied.push(*applied_file); + } rollback_files(&applied); - return Err(error); + *manifest = original_manifest.clone(); + return Err(error.message); } } } - if let Err(error) = persist_manifest(project_fs, expected_persisted, &next_manifest) { + if let Err(error) = project_fs.ensure_root_binding() { + rollback_files(&applied); + *manifest = original_manifest.clone(); + return Err(error); + } + let manifest_applied = match persist_manifest(project_fs, expected_persisted, &next_manifest) { + Ok(applied_file) => applied_file, + Err(error) => { + if let Some(applied_file) = error.applied { + rollback_file(&applied_file); + } + rollback_files(&applied); + *manifest = original_manifest.clone(); + return Err(error.message); + } + }; + if let Err(error) = project_fs.ensure_root_binding() { + if let Some(applied_file) = &manifest_applied { + rollback_file(applied_file); + } rollback_files(&applied); *manifest = original_manifest.clone(); return Err(error); @@ -1606,35 +1917,104 @@ fn commit_files_and_manifest( fn commit_prepared_file( project_fs: &ProjectFs, file: &PreparedFile, -) -> Result { - let target = project_fs.resolve_target(&file.relative, file.after.is_some())?; - let current = target.read_optional()?; - if current != file.before { - return Err(format!( +) -> Result, CommitPreparedError> { + if file.before == file.after { + return Ok(None); + } + project_fs + .ensure_root_binding() + .map_err(CommitPreparedError::before_publication)?; + let target = project_fs + .resolve_target(&file.relative, file.after.is_some()) + .map_err(CommitPreparedError::before_publication)?; + let current = target + .snapshot_optional() + .map_err(CommitPreparedError::before_publication)?; + if current.as_ref().map(|snapshot| &snapshot.bytes) != file.before.as_ref() { + return Err(CommitPreparedError::before_publication(format!( "bridge target changed after validation: {}", file.relative.display() - )); - } - match &file.after { - Some(bytes) => target.atomic_write(bytes, file.before.is_none())?, - None => target.remove_file()?, + ))); + } + let expected = current.as_ref().map(|snapshot| snapshot.identity); + after_target_snapshot(); + let publication = match &file.after { + Some(bytes) => target.publish_write(bytes, expected), + None => { + let expected = expected.ok_or_else(|| { + CommitPreparedError::before_publication(format!( + "bridge target disappeared before removal: {}", + file.relative.display() + )) + })?; + target.publish_removal(expected) + } }; - Ok(AppliedFile { + let publication = match publication { + Ok(publication) => publication, + Err(error) => { + let applied = error.publication.map(|publication| AppliedFile { + prepared: file.clone(), + target, + publication, + }); + return Err(CommitPreparedError { + message: error.message, + applied: applied.map(Box::new), + }); + } + }; + let applied = AppliedFile { prepared: file.clone(), target, - }) + publication, + }; + if let Err(error) = applied.target.sync_parent() { + return Err(CommitPreparedError::after_publication(error, applied)); + } + Ok(Some(applied)) } fn rollback_files(files: &[AppliedFile]) { for file in files.iter().rev() { - match &file.prepared.before { - Some(bytes) => { - let _ = file.target.atomic_write(bytes, false); - } - None => { - let _ = file.target.remove_file(); - } - } + rollback_file(file); + } +} + +fn rollback_file(file: &AppliedFile) { + let should_restore = match file.publication { + Publication::Present { identity } => file + .target + .snapshot_optional() + .map(|current| { + current.is_some_and(|current| { + current.identity == identity + && file.prepared.after.as_deref() == Some(current.bytes.as_slice()) + }) + }) + .unwrap_or(false), + Publication::Absent => file + .target + .snapshot_optional() + .map(|current| current.is_none()) + .unwrap_or(false), + }; + if !should_restore { + return; + } + let expected = match file.publication { + Publication::Present { identity } => Some(identity), + Publication::Absent => None, + }; + let restored = match &file.prepared.before { + Some(bytes) => file.target.publish_write(bytes, expected), + None => match expected { + Some(identity) => file.target.publish_removal(identity), + None => return, + }, + }; + if restored.is_ok() { + let _ = file.target.sync_parent(); } } @@ -1739,6 +2119,26 @@ mod tests { fs::write(path, content).unwrap(); } + fn after_project_root_opened_for_test(action: impl FnOnce() + 'static) { + TEST_MUTATION_HOOKS.with(|hooks| { + hooks.borrow_mut().after_project_root_opened = Some(Box::new(action)); + }); + } + + fn after_target_snapshot_for_test(action: impl FnOnce() + 'static) { + TEST_MUTATION_HOOKS.with(|hooks| { + hooks.borrow_mut().after_target_snapshot = Some(Box::new(action)); + }); + } + + fn fail_directory_sync_at_for_test(number: usize) { + TEST_MUTATION_HOOKS.with(|hooks| { + let mut hooks = hooks.borrow_mut(); + hooks.fail_directory_sync_at = Some(number); + hooks.directory_syncs = 0; + }); + } + fn plan(harness: &str, project: &ActivationProject) -> AdapterPlan { plan_activation(harness, project).unwrap() } @@ -2423,4 +2823,246 @@ mod tests { assert_eq!(read(project.project_root.join("AGENTS.md")), original); } } + + #[cfg(unix)] + #[test] + fn final_entry_replacement_after_snapshot_is_never_followed_or_overwritten() { + use std::os::unix::fs::symlink; + + let (_temp, project, _manifest) = fixture(); + let target_path = project.project_root.join("managed-target"); + write(&target_path, "before\n"); + let outside = tempfile::tempdir().unwrap(); + let outside_marker = outside.path().join("must-not-change"); + write(&outside_marker, "outside\n"); + let project_fs = ProjectFs::open(&project).unwrap(); + let prepared = PreparedFile { + relative: PathBuf::from("managed-target"), + before: Some(b"before\n".to_vec()), + after: Some(b"tree-ring\n".to_vec()), + }; + let replacement = target_path.clone(); + let outside_path = outside.path().to_path_buf(); + after_target_snapshot_for_test(move || { + fs::remove_file(&replacement).unwrap(); + symlink(&outside_path, &replacement).unwrap(); + }); + + let error = commit_prepared_file(&project_fs, &prepared).unwrap_err(); + + assert!(error.message.contains("changed") || error.message.contains("regular file")); + assert!(fs::symlink_metadata(&target_path) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(read(outside_marker), "outside\n"); + } + + #[cfg(unix)] + #[test] + fn final_entry_replacement_after_snapshot_is_never_removed() { + use std::os::unix::fs::symlink; + + let (_temp, project, _manifest) = fixture(); + let target_path = project.project_root.join("managed-target"); + write(&target_path, "before\n"); + let outside = tempfile::tempdir().unwrap(); + let outside_marker = outside.path().join("must-not-change"); + write(&outside_marker, "outside\n"); + let project_fs = ProjectFs::open(&project).unwrap(); + let prepared = PreparedFile { + relative: PathBuf::from("managed-target"), + before: Some(b"before\n".to_vec()), + after: None, + }; + let replacement = target_path.clone(); + let outside_path = outside.path().to_path_buf(); + after_target_snapshot_for_test(move || { + fs::remove_file(&replacement).unwrap(); + symlink(&outside_path, &replacement).unwrap(); + }); + + let error = commit_prepared_file(&project_fs, &prepared).unwrap_err(); + + assert!(error.message.contains("changed") || error.message.contains("regular file")); + assert!(fs::symlink_metadata(&target_path) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(read(outside_marker), "outside\n"); + } + + #[cfg(unix)] + #[test] + fn root_swap_after_open_is_rejected_without_following_the_replacement() { + use std::os::unix::fs::symlink; + + let (_temp, project, mut manifest) = fixture(); + let root = project.project_root.clone(); + let parked = root + .parent() + .unwrap() + .join(format!("tree-ring-parked-{}", Uuid::new_v4())); + let parked_for_hook = parked.clone(); + let outside = tempfile::tempdir().unwrap(); + let outside_path = outside.path().to_path_buf(); + after_project_root_opened_for_test(move || { + fs::rename(&root, &parked_for_hook).unwrap(); + symlink(&outside_path, &root).unwrap(); + }); + + let error = + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), false).unwrap_err(); + + assert!(error.contains("project root changed")); + assert!(!outside.path().join(".agents").exists()); + assert!(!outside.path().join("AGENTS.md").exists()); + assert!(!outside.path().join(".tree-ring/activation.json").exists()); + fs::remove_file(&project.project_root).unwrap(); + fs::remove_dir_all(parked).unwrap(); + } + + #[test] + fn rollback_preserves_a_user_edit_after_tree_ring_publication() { + let (_temp, project, _manifest) = fixture(); + let target_path = project.project_root.join("managed-target"); + write(&target_path, "before\n"); + let project_fs = ProjectFs::open(&project).unwrap(); + let prepared = PreparedFile { + relative: PathBuf::from("managed-target"), + before: Some(b"before\n".to_vec()), + after: Some(b"tree-ring\n".to_vec()), + }; + let applied = commit_prepared_file(&project_fs, &prepared) + .unwrap() + .expect("the file changed"); + write(&target_path, "user edit\n"); + + rollback_files(&[applied]); + + assert_eq!(read(target_path), "user edit\n"); + } + + #[test] + fn linkat_publication_with_failed_directory_sync_is_rolled_back() { + let (_temp, project, mut manifest) = fixture(); + let project_fs = ProjectFs::open(&project).unwrap(); + let original = manifest.clone(); + let file = PreparedFile { + relative: PathBuf::from("sync-link-target"), + before: None, + after: Some(b"tree-ring\n".to_vec()), + }; + fail_directory_sync_at_for_test(1); + + let error = commit_files_and_manifest( + &project_fs, + &mut manifest, + &original, + None, + original.clone(), + &[file], + ) + .unwrap_err(); + + assert!(error.contains("injected directory sync failure")); + assert!(!project.project_root.join("sync-link-target").exists()); + assert!(!project.memory_root.join("activation.json").exists()); + assert_eq!(manifest, original); + } + + #[test] + fn renameat_publication_with_failed_directory_sync_is_rolled_back() { + let (_temp, project, mut manifest) = fixture(); + let target_path = project.project_root.join("sync-rename-target"); + write(&target_path, "before\n"); + let project_fs = ProjectFs::open(&project).unwrap(); + let original = manifest.clone(); + let file = PreparedFile { + relative: PathBuf::from("sync-rename-target"), + before: Some(b"before\n".to_vec()), + after: Some(b"tree-ring\n".to_vec()), + }; + fail_directory_sync_at_for_test(1); + + let error = commit_files_and_manifest( + &project_fs, + &mut manifest, + &original, + None, + original.clone(), + &[file], + ) + .unwrap_err(); + + assert!(error.contains("injected directory sync failure")); + assert_eq!(read(target_path), "before\n"); + assert!(!project.memory_root.join("activation.json").exists()); + assert_eq!(manifest, original); + } + + #[test] + fn unlinkat_publication_with_failed_directory_sync_is_rolled_back() { + let (_temp, project, mut manifest) = fixture(); + let target_path = project.project_root.join("sync-unlink-target"); + write(&target_path, "before\n"); + let project_fs = ProjectFs::open(&project).unwrap(); + let original = manifest.clone(); + let file = PreparedFile { + relative: PathBuf::from("sync-unlink-target"), + before: Some(b"before\n".to_vec()), + after: None, + }; + fail_directory_sync_at_for_test(1); + + let error = commit_files_and_manifest( + &project_fs, + &mut manifest, + &original, + None, + original.clone(), + &[file], + ) + .unwrap_err(); + + assert!(error.contains("injected directory sync failure")); + assert_eq!(read(target_path), "before\n"); + assert!(!project.memory_root.join("activation.json").exists()); + assert_eq!(manifest, original); + } + + #[test] + fn late_manifest_sync_failure_restores_published_manifest_and_bridge_file() { + let (_temp, project, mut manifest) = fixture(); + let original = manifest.clone(); + let original_bytes = serde_json::to_vec_pretty(&original).unwrap(); + fs::write(project.memory_root.join("activation.json"), &original_bytes).unwrap(); + let project_fs = ProjectFs::open(&project).unwrap(); + let mut next = original.clone(); + next.cli_version = "0.14.0-test".to_string(); + let file = PreparedFile { + relative: PathBuf::from("sync-manifest-target"), + before: None, + after: Some(b"tree-ring\n".to_vec()), + }; + fail_directory_sync_at_for_test(2); + + let error = commit_files_and_manifest( + &project_fs, + &mut manifest, + &original, + Some(&original), + next, + &[file], + ) + .unwrap_err(); + + assert!(error.contains("injected directory sync failure")); + assert!(!project.project_root.join("sync-manifest-target").exists()); + assert_eq!( + fs::read(project.memory_root.join("activation.json")).unwrap(), + original_bytes + ); + assert_eq!(manifest, original); + } } From 34ae082ee29da9365b76c6e63864401e73d04e89 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 13 Aug 2026 23:57:22 -0400 Subject: [PATCH 09/31] feat: record harness preflight receipts --- .../src/activation/adapters.rs | 24 + .../src/activation/bridge.rs | 47 +- .../src/activation/manifest.rs | 277 +++++- .../src/activation/mod.rs | 7 + .../src/activation/preflight.rs | 857 ++++++++++++++++++ crates/tree-ring-memory-sqlite/src/lib.rs | 37 +- 6 files changed, 1232 insertions(+), 17 deletions(-) create mode 100644 crates/tree-ring-memory-cli/src/activation/preflight.rs diff --git a/crates/tree-ring-memory-cli/src/activation/adapters.rs b/crates/tree-ring-memory-cli/src/activation/adapters.rs index 522b192..10306e3 100644 --- a/crates/tree-ring-memory-cli/src/activation/adapters.rs +++ b/crates/tree-ring-memory-cli/src/activation/adapters.rs @@ -184,6 +184,7 @@ pub type AgentIntegration = AdapterDetection; pub trait HarnessAdapter: Sync { fn id(&self) -> &'static str; + fn version(&self) -> &'static str; fn display_name(&self) -> &'static str; fn capability(&self) -> AdapterCapability; fn detect(&self, project: &ActivationProject, env: &dyn HarnessEnvironment) @@ -194,6 +195,7 @@ pub trait HarnessAdapter: Sync { #[derive(Debug, Clone, Copy)] struct DeclarativeAdapter { id: &'static str, + version: &'static str, display_name: &'static str, command: &'static str, capability: AdapterCapability, @@ -211,6 +213,7 @@ enum AdapterSupport { const ADAPTERS: [DeclarativeAdapter; 7] = [ DeclarativeAdapter { id: "codex", + version: "1", display_name: "Codex", command: "codex", capability: AdapterCapability::WrapperPreflight, @@ -219,6 +222,7 @@ const ADAPTERS: [DeclarativeAdapter; 7] = [ }, DeclarativeAdapter { id: "claude-code", + version: "1", display_name: "Claude Code", command: "claude", capability: AdapterCapability::NativePreflight, @@ -227,6 +231,7 @@ const ADAPTERS: [DeclarativeAdapter; 7] = [ }, DeclarativeAdapter { id: "pi", + version: "1", display_name: "Pi", command: "pi", capability: AdapterCapability::NativePreflight, @@ -235,6 +240,7 @@ const ADAPTERS: [DeclarativeAdapter; 7] = [ }, DeclarativeAdapter { id: "agent-zero", + version: "1", display_name: "Agent Zero / A0", command: "agent-zero", capability: AdapterCapability::NativePreflight, @@ -243,6 +249,7 @@ const ADAPTERS: [DeclarativeAdapter; 7] = [ }, DeclarativeAdapter { id: "hermes", + version: "0", display_name: "Hermes", command: "hermes", capability: AdapterCapability::GuidanceOnly, @@ -251,6 +258,7 @@ const ADAPTERS: [DeclarativeAdapter; 7] = [ }, DeclarativeAdapter { id: "opencode", + version: "0", display_name: "OpenCode", command: "opencode", capability: AdapterCapability::GuidanceOnly, @@ -259,6 +267,7 @@ const ADAPTERS: [DeclarativeAdapter; 7] = [ }, DeclarativeAdapter { id: "goose", + version: "0", display_name: "Goose", command: "goose", capability: AdapterCapability::GuidanceOnly, @@ -276,6 +285,13 @@ pub fn maintained_adapters() -> Vec<&'static dyn HarnessAdapter> { .collect() } +/// Returns the exact activation adapter version registered for a harness. +pub fn adapter_version(id: &str) -> Option<&'static str> { + registered_adapters() + .find(|adapter| adapter.id == id) + .map(|adapter| adapter.version) +} + fn registered_adapters() -> impl Iterator { ADAPTERS.iter() } @@ -368,6 +384,10 @@ impl HarnessAdapter for DeclarativeAdapter { self.id } + fn version(&self) -> &'static str { + self.version + } + fn display_name(&self) -> &'static str { self.display_name } @@ -683,6 +703,10 @@ mod tests { .collect::>(), vec!["codex", "claude-code", "pi", "agent-zero"] ); + assert!(maintained.iter().all(|adapter| adapter.version() == "1")); + assert!(maintained + .iter() + .all(|adapter| adapter_version(adapter.id()) == Some("1"))); for id in ["hermes", "opencode", "goose"] { assert_eq!( plan_activation(id, &project()).unwrap().state, diff --git a/crates/tree-ring-memory-cli/src/activation/bridge.rs b/crates/tree-ring-memory-cli/src/activation/bridge.rs index 7d08f08..602bb47 100644 --- a/crates/tree-ring-memory-cli/src/activation/bridge.rs +++ b/crates/tree-ring-memory-cli/src/activation/bridge.rs @@ -1,8 +1,8 @@ use super::{ - adapters::{ActivationProject, AdapterPlan, ManagedBlockUpdate, PlannedWrite}, + adapters::{adapter_version, ActivationProject, AdapterPlan, ManagedBlockUpdate, PlannedWrite}, manifest::{ - validate_manifest, validate_project_relative_path, ActivationManifest, HarnessActivation, - OwnedBridgeFile, OwnedManagedBlock, + bridge_fingerprint, validate_manifest, validate_project_relative_path, ActivationManifest, + HarnessActivation, OwnedBridgeFile, OwnedManagedBlock, }, ActivationState, AdapterCapability, ACTIVATION_PROTOCOL_VERSION, }; @@ -583,12 +583,16 @@ pub fn apply_bridge_plan( .unwrap_or(HarnessActivation { state: plan.state, adapter_capability: capability_for(&plan.harness_id)?, + adapter_version: adapter_version_for(&plan.harness_id)?.to_string(), + bridge_fingerprint: String::new(), bridge_path: None, owned_files: Vec::new(), managed_blocks: Vec::new(), }); activation.state = plan.state; activation.adapter_capability = capability_for(&plan.harness_id)?; + activation.adapter_version = adapter_version_for(&plan.harness_id)?.to_string(); + activation.bridge_fingerprint = bridge_fingerprint(&plan.harness_id, &activation); next_manifest .harnesses .insert(plan.harness_id.clone(), activation); @@ -789,6 +793,8 @@ pub fn deactivate_bridge_plan( { next.bridge_path = Some(first); } + next.adapter_version = adapter_version_for(harness_id)?.to_string(); + next.bridge_fingerprint = bridge_fingerprint(harness_id, next); let changed_paths = files .iter() @@ -891,16 +897,17 @@ fn prepare_apply( .first() .map(|owned| owned.path.clone()) .or_else(|| managed_blocks.first().map(|owned| owned.path.clone())); - Ok(Preparation::Ready { - files, - activation: HarnessActivation { - state: applied_state(&plan.harness_id, plan.state), - adapter_capability: capability_for(&plan.harness_id)?, - bridge_path, - owned_files, - managed_blocks, - }, - }) + let mut activation = HarnessActivation { + state: applied_state(&plan.harness_id, plan.state), + adapter_capability: capability_for(&plan.harness_id)?, + adapter_version: adapter_version_for(&plan.harness_id)?.to_string(), + bridge_fingerprint: String::new(), + bridge_path, + owned_files, + managed_blocks, + }; + activation.bridge_fingerprint = bridge_fingerprint(&plan.harness_id, &activation); + Ok(Preparation::Ready { files, activation }) } fn prepare_complete_file( @@ -1653,6 +1660,10 @@ fn capability_for(harness_id: &str) -> Result { } } +fn adapter_version_for(harness_id: &str) -> Result<&'static str, String> { + adapter_version(harness_id).ok_or_else(|| format!("unknown harness adapter: {harness_id}")) +} + fn applied_state(harness_id: &str, planned: ActivationState) -> ActivationState { if harness_id == "pi" { ActivationState::NeedsTrust @@ -1996,6 +2007,12 @@ mod tests { .exists()); apply_bridge_plan(&project, &mut manifest, verified_agent_zero_plan(), false).unwrap(); + let activation = manifest.harnesses.get("agent-zero").unwrap(); + assert_eq!(activation.adapter_version, "1"); + assert_eq!( + activation.bridge_fingerprint, + bridge_fingerprint("agent-zero", activation) + ); let binding: Value = serde_json::from_str(&read( project .project_root @@ -2182,6 +2199,8 @@ mod tests { HarnessActivation { state: ActivationState::ConfiguredAwaitingProof, adapter_capability: AdapterCapability::WrapperPreflight, + adapter_version: "1".to_string(), + bridge_fingerprint: String::new(), bridge_path: Some(relative.to_string()), owned_files: vec![OwnedBridgeFile { path: relative.to_string(), @@ -2203,6 +2222,8 @@ mod tests { HarnessActivation { state: ActivationState::ConfiguredAwaitingProof, adapter_capability: AdapterCapability::WrapperPreflight, + adapter_version: "1".to_string(), + bridge_fingerprint: String::new(), bridge_path: Some(".tree-ring/AGENTS.md".to_string()), owned_files: Vec::new(), managed_blocks: vec![OwnedManagedBlock { diff --git a/crates/tree-ring-memory-cli/src/activation/manifest.rs b/crates/tree-ring-memory-cli/src/activation/manifest.rs index ab62141..3b10016 100644 --- a/crates/tree-ring-memory-cli/src/activation/manifest.rs +++ b/crates/tree-ring-memory-cli/src/activation/manifest.rs @@ -35,6 +35,10 @@ pub struct ActivationManifest { pub struct HarnessActivation { pub state: ActivationState, pub adapter_capability: AdapterCapability, + #[serde(default = "default_adapter_version")] + pub adapter_version: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub bridge_fingerprint: String, #[serde(skip_serializing_if = "Option::is_none")] pub bridge_path: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -72,12 +76,25 @@ pub struct ActivationReceipt { pub protocol_version: u16, pub receipt_id: String, pub harness_id: String, + pub adapter_version: String, + pub bridge_fingerprint: String, + pub store_id: String, + pub project_root_fingerprint: String, pub worker_key_fingerprint: String, pub session: SessionIdentity, pub state: ActivationState, + pub query_class: String, + pub result_count: usize, + pub selected_memory_ids_sha256: String, + pub duration_ms: u64, + pub status: String, pub recorded_at: DateTime, } +fn default_adapter_version() -> String { + "1".to_string() +} + /// Loads the persisted manifest without creating a directory or file. pub fn load_manifest(memory_root: &Path) -> Result { validate_memory_root(memory_root)?; @@ -191,6 +208,115 @@ pub fn prune_receipts( Ok(removed) } +/// Removes receipts that no longer prove the current adapter/store contract. +pub fn invalidate_receipts_for_adapter( + memory_root: &Path, + harness_id: &str, + adapter_version: &str, + bridge_fingerprint: &str, + project_root_fingerprint: &str, + store_id: &str, +) -> Result { + validate_memory_root(memory_root)?; + validate_identifier("harness id", harness_id)?; + let directory = memory_root.join(RECEIPTS_DIRECTORY).join(harness_id); + if !directory.exists() { + return Ok(0); + } + let mut removed = 0; + for worker in fs::read_dir(&directory).map_err(|err| io_error(&directory, err))? { + let worker = worker.map_err(|err| io_error(&directory, err))?; + let worker_path = worker.path(); + if !worker_path.is_dir() { + continue; + } + for entry in fs::read_dir(&worker_path).map_err(|err| io_error(&worker_path, err))? { + let entry = entry.map_err(|err| io_error(&worker_path, err))?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let current = read_json::(&path) + .and_then(|receipt| { + validate_receipt(&receipt)?; + Ok(receipt) + }) + .is_ok_and(|receipt| { + receipt.adapter_version == adapter_version + && receipt.bridge_fingerprint == bridge_fingerprint + && receipt.project_root_fingerprint == project_root_fingerprint + && receipt.store_id == store_id + }); + if !current { + fs::remove_file(&path).map_err(|err| io_error(&path, err))?; + removed += 1; + } + } + } + Ok(removed) +} + +/// Stable digest of only the project-relative, adapter-owned bridge contract. +pub fn bridge_fingerprint(harness_id: &str, activation: &HarnessActivation) -> String { + let mut hasher = Sha256::new(); + hash_part(&mut hasher, harness_id); + hash_part(&mut hasher, &activation.adapter_version); + let mut files = activation.owned_files.clone(); + files.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then(left.sha256.cmp(&right.sha256)) + }); + for owned in files { + hash_part(&mut hasher, "file"); + hash_part(&mut hasher, &owned.path); + hash_part(&mut hasher, &owned.sha256); + } + let mut blocks = activation.managed_blocks.clone(); + blocks.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then(left.block_id.cmp(&right.block_id)) + .then(left.sha256.cmp(&right.sha256)) + .then(left.leading_separator.cmp(&right.leading_separator)) + }); + for owned in blocks { + hash_part(&mut hasher, "block"); + hash_part(&mut hasher, &owned.path); + hash_part(&mut hasher, &owned.block_id); + hash_part(&mut hasher, &owned.sha256); + hash_part(&mut hasher, &owned.leading_separator); + } + format!("{:x}", hasher.finalize()) +} + +fn hash_part(hasher: &mut Sha256, value: &str) { + hasher.update(value.len().to_be_bytes()); + hasher.update(value.as_bytes()); +} + +#[cfg(test)] +pub(crate) fn receipt_files(memory_root: &Path) -> Vec { + let directory = memory_root.join(RECEIPTS_DIRECTORY); + let mut files = Vec::new(); + let mut pending = vec![directory]; + while let Some(path) = pending.pop() { + let Ok(entries) = fs::read_dir(path) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + pending.push(path); + } else if path.extension().and_then(|extension| extension.to_str()) == Some("json") { + files.push(path); + } + } + } + files.sort(); + files +} + fn manifest_path(memory_root: &Path) -> PathBuf { memory_root.join(ACTIVATION_MANIFEST_FILE) } @@ -336,6 +462,10 @@ pub(crate) fn validate_manifest(manifest: &ActivationManifest) -> Result<(), Str return Err("invalid managed block leading separator".to_string()); } } + validate_identifier("adapter version", &activation.adapter_version)?; + if !activation.bridge_fingerprint.is_empty() && !is_sha256(&activation.bridge_fingerprint) { + return Err("bridge fingerprint must be a SHA-256 hex digest".to_string()); + } reject_duplicate_ownership(harness_id, activation)?; } Ok(()) @@ -410,12 +540,42 @@ fn validate_receipt(receipt: &ActivationReceipt) -> Result<(), String> { } validate_identifier("receipt id", &receipt.receipt_id)?; validate_identifier("harness id", &receipt.harness_id)?; + validate_identifier("adapter version", &receipt.adapter_version)?; + if !is_sha256(&receipt.bridge_fingerprint) { + return Err("receipt bridge fingerprint must be a SHA-256 hex digest".to_string()); + } + validate_identifier("store id", &receipt.store_id)?; + if !is_sha256(&receipt.project_root_fingerprint) { + return Err("receipt project root fingerprint must be a SHA-256 hex digest".to_string()); + } if !is_sha256(&receipt.worker_key_fingerprint) { return Err("worker key fingerprint must be a SHA-256 hex digest".to_string()); } validate_receipt_identity("agent profile", &receipt.session.agent_profile)?; validate_receipt_identity("workflow id", &receipt.session.workflow_id)?; - validate_receipt_identity("session id", &receipt.session.session_id) + validate_receipt_identity("session id", &receipt.session.session_id)?; + if !matches!( + receipt.state, + ActivationState::Active | ActivationState::ActiveIsolated + ) { + return Err("receipt state is not active".to_string()); + } + if !matches!( + receipt.query_class.as_str(), + "task_hint" | "startup_fallback" + ) { + return Err("invalid receipt query class".to_string()); + } + if receipt.result_count > 8 { + return Err("receipt result count exceeds preflight limit".to_string()); + } + if !is_sha256(&receipt.selected_memory_ids_sha256) { + return Err("selected memory IDs digest must be a SHA-256 hex digest".to_string()); + } + if receipt.status != "success" { + return Err("invalid receipt status".to_string()); + } + Ok(()) } fn validate_receipt_identity(label: &str, value: &str) -> Result<(), String> { @@ -461,12 +621,12 @@ pub(crate) fn validate_project_relative_path(value: &str) -> Result<(), String> Ok(()) } -fn fingerprint_path(project_root: &Path) -> String { +pub(crate) fn fingerprint_path(project_root: &Path) -> String { let path = fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf()); fingerprint(&path.to_string_lossy()) } -fn fingerprint(value: &str) -> String { +pub(crate) fn fingerprint(value: &str) -> String { let mut hasher = Sha256::new(); hasher.update(value.as_bytes()); format!("{:x}", hasher.finalize()) @@ -495,6 +655,10 @@ mod tests { protocol_version: ACTIVATION_PROTOCOL_VERSION, receipt_id: format!("receipt-{}", Uuid::new_v4()), harness_id: "codex".to_string(), + adapter_version: "1".to_string(), + bridge_fingerprint: "b".repeat(64), + store_id: "store-test".to_string(), + project_root_fingerprint: "a".repeat(64), worker_key_fingerprint: fingerprint("worker-1"), session: SessionIdentity { agent_profile: "implementer".to_string(), @@ -502,6 +666,11 @@ mod tests { session_id: "session-1".to_string(), }, state: ActivationState::Active, + query_class: "task_hint".to_string(), + result_count: 1, + selected_memory_ids_sha256: "c".repeat(64), + duration_ms: 1, + status: "success".to_string(), recorded_at, } } @@ -613,6 +782,95 @@ mod tests { .ends_with(".tmp"))); } + #[test] + fn preexisting_activation_records_default_version_and_require_new_bridge_proof() { + let json = serde_json::json!({ + "schema_version": ACTIVATION_SCHEMA_VERSION, + "protocol_version": ACTIVATION_PROTOCOL_VERSION, + "store_id": "store-test", + "project_root_fingerprint": "a".repeat(64), + "cli_version": "0.14.0", + "harnesses": { + "codex": { + "state": "configured-awaiting-proof", + "adapter_capability": "wrapper-preflight", + "bridge_path": null, + "owned_files": [], + "managed_blocks": [] + } + } + }); + + let manifest: ActivationManifest = serde_json::from_value(json).unwrap(); + let activation = manifest.harnesses.get("codex").unwrap(); + + assert_eq!(activation.adapter_version, "1"); + assert!(activation.bridge_fingerprint.is_empty()); + assert!(validate_manifest(&manifest).is_ok()); + } + + #[test] + fn bridge_fingerprint_is_order_independent_and_version_bound() { + let mut activation = HarnessActivation { + state: ActivationState::ConfiguredAwaitingProof, + adapter_capability: AdapterCapability::NativePreflight, + adapter_version: "1".to_string(), + bridge_fingerprint: String::new(), + bridge_path: Some(".pi/extensions/tree-ring-memory.ts".to_string()), + owned_files: vec![ + OwnedBridgeFile { + path: ".pi/extensions/tree-ring-memory.ts".to_string(), + sha256: "a".repeat(64), + }, + OwnedBridgeFile { + path: ".agents/skills/tree-ring-memory/SKILL.md".to_string(), + sha256: "b".repeat(64), + }, + ], + managed_blocks: Vec::new(), + }; + let first = bridge_fingerprint("pi", &activation); + activation.owned_files.reverse(); + assert_eq!(bridge_fingerprint("pi", &activation), first); + activation.adapter_version = "2".to_string(); + assert_ne!(bridge_fingerprint("pi", &activation), first); + } + + #[test] + fn receipt_invalidation_removes_only_stale_adapter_contracts() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project/.tree-ring"); + let receipt = fixture_receipt(Utc::now()); + write_receipt(&root, &receipt).unwrap(); + assert_eq!(receipt_files(&root).len(), 1); + assert_eq!( + invalidate_receipts_for_adapter( + &root, + "codex", + &receipt.adapter_version, + &receipt.bridge_fingerprint, + &receipt.project_root_fingerprint, + &receipt.store_id, + ) + .unwrap(), + 0 + ); + assert_eq!(receipt_files(&root).len(), 1); + assert_eq!( + invalidate_receipts_for_adapter( + &root, + "codex", + "2", + &"d".repeat(64), + &receipt.project_root_fingerprint, + &receipt.store_id, + ) + .unwrap(), + 1 + ); + assert!(receipt_files(&root).is_empty()); + } + #[test] fn prune_receipts_removes_malformed_and_expired_records_and_keeps_the_latest_hundred() { let temp = tempfile::tempdir().unwrap(); @@ -650,6 +908,19 @@ mod tests { HarnessActivation { state: ActivationState::Active, adapter_capability: AdapterCapability::NativePreflight, + adapter_version: "1".to_string(), + bridge_fingerprint: bridge_fingerprint( + "codex", + &HarnessActivation { + state: ActivationState::Active, + adapter_capability: AdapterCapability::NativePreflight, + adapter_version: "1".to_string(), + bridge_fingerprint: String::new(), + bridge_path: None, + owned_files: Vec::new(), + managed_blocks: Vec::new(), + }, + ), bridge_path: None, owned_files: Vec::new(), managed_blocks: Vec::new(), diff --git a/crates/tree-ring-memory-cli/src/activation/mod.rs b/crates/tree-ring-memory-cli/src/activation/mod.rs index a8fc4ee..fdc5236 100644 --- a/crates/tree-ring-memory-cli/src/activation/mod.rs +++ b/crates/tree-ring-memory-cli/src/activation/mod.rs @@ -37,9 +37,16 @@ pub struct SessionIdentity { pub mod adapters; pub mod bridge; pub mod manifest; +pub mod preflight; #[allow(unused_imports)] pub use manifest::{ load_manifest, load_or_create_manifest, prune_receipts, save_manifest, write_receipt, ActivationManifest, ActivationReceipt, HarnessActivation, OwnedBridgeFile, OwnedManagedBlock, }; +#[allow(unused_imports)] +pub use preflight::{ + parse_adapter_stdin, render_claude_session_start, render_json_context, render_pi_context, + run_preflight, ActivationError, ActivationReceiptSummary, PreflightContextFormat, + PreflightRequest, PreflightResponse, +}; diff --git a/crates/tree-ring-memory-cli/src/activation/preflight.rs b/crates/tree-ring-memory-cli/src/activation/preflight.rs new file mode 100644 index 0000000..d795a5f --- /dev/null +++ b/crates/tree-ring-memory-cli/src/activation/preflight.rs @@ -0,0 +1,857 @@ +use super::{ + adapters::{adapter_version, ActivationProject}, + manifest::{ + bridge_fingerprint, fingerprint, fingerprint_path, invalidate_receipts_for_adapter, + load_manifest, prune_receipts, validate_manifest, write_receipt, ActivationManifest, + ActivationReceipt, + }, + ActivationState, SessionIdentity, ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{fmt, path::Path, time::Instant}; +use tree_ring_memory_core::SensitivityGuard; +use tree_ring_memory_sqlite::{MemoryRetriever, RecallOptions, RecallResult, SQLiteMemoryStore}; +use uuid::Uuid; + +const FALLBACK_QUERY: &str = "project startup constraints"; +const MAX_RESULTS: usize = 8; +const MAX_CONTEXT_BYTES: usize = 32 * 1024; +const PREFLIGHT_TIMEOUT_MS: u64 = 10_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PreflightContextFormat { + ClaudeSessionStart, + PiBeforeAgentStart, + Json, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreflightRequest { + pub harness_id: String, + pub identity: SessionIdentity, + pub task_hint: Option, + pub context_format: PreflightContextFormat, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActivationReceiptSummary { + pub schema_version: u16, + pub protocol_version: u16, + pub receipt_id: String, + pub harness_id: String, + pub adapter_version: String, + pub bridge_fingerprint: String, + pub store_id: String, + pub project_root_fingerprint: String, + pub worker_key_fingerprint: String, + pub query_class: String, + pub result_count: usize, + pub selected_memory_ids_sha256: String, + pub duration_ms: u64, + pub status: String, + pub recorded_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreflightResponse { + pub state: ActivationState, + pub context: String, + pub receipt: ActivationReceiptSummary, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActivationError(String); + +impl ActivationError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for ActivationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for ActivationError {} + +impl From for ActivationError { + fn from(error: String) -> Self { + Self(error) + } +} + +/// Runs bounded, identity-scoped recall and records proof only after output renders. +pub fn run_preflight( + store: &SQLiteMemoryStore, + project: &ActivationProject, + manifest: &ActivationManifest, + request: PreflightRequest, +) -> Result { + let started = Instant::now(); + validate_manifest(manifest).map_err(ActivationError::from)?; + ensure_store_path_matches(store, project)?; + ensure_persisted_manifest_matches(project, manifest)?; + validate_identity(&request.identity)?; + + let activation = manifest + .harnesses + .get(&request.harness_id) + .ok_or_else(|| ActivationError::new("harness has no activation record"))?; + let current_version = adapter_version(&request.harness_id) + .ok_or_else(|| ActivationError::new("unknown harness adapter"))?; + let current_bridge_fingerprint = bridge_fingerprint(&request.harness_id, activation); + invalidate_receipts_for_adapter( + &project.memory_root, + &request.harness_id, + &activation.adapter_version, + ¤t_bridge_fingerprint, + &manifest.project_root_fingerprint, + &manifest.store_id, + ) + .map_err(ActivationError::from)?; + if activation.adapter_version != current_version { + return Err(ActivationError::new("stale adapter version")); + } + if activation.bridge_fingerprint != current_bridge_fingerprint { + return Err(ActivationError::new("stale bridge fingerprint")); + } + if !matches!( + activation.state, + ActivationState::ConfiguredAwaitingProof + | ActivationState::NeedsTrust + | ActivationState::Active + | ActivationState::ActiveIsolated + ) { + return Err(ActivationError::new( + "harness is not eligible for preflight", + )); + } + + let state = preflight_state(project, manifest)?; + let (query, query_class) = safe_query(request.task_hint.as_deref()); + let project_name = configured_identity_root(project)? + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()); + let results = MemoryRetriever::new(store) + .recall_with_options( + query, + &RecallOptions { + project: project_name, + agent_profile: Some(&request.identity.agent_profile), + workflow_id: Some(&request.identity.workflow_id), + session_id: Some(&request.identity.session_id), + scope: None, + rings: None, + event_types: None, + include_sensitive: false, + include_superseded: false, + limit: MAX_RESULTS, + explain_ranking: false, + }, + ) + .map_err(|_| ActivationError::new("scoped recall failed"))?; + let safe_results = safe_results(results); + let context = render_safe_recall_context(&safe_results)?; + let selected_memory_ids_sha256 = selected_memory_ids_digest(&safe_results); + let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + if duration_ms > PREFLIGHT_TIMEOUT_MS { + return Err(ActivationError::new("preflight timeout")); + } + + let receipt = ActivationReceipt { + schema_version: ACTIVATION_SCHEMA_VERSION, + protocol_version: ACTIVATION_PROTOCOL_VERSION, + receipt_id: format!("receipt-{}", Uuid::new_v4()), + harness_id: request.harness_id, + adapter_version: activation.adapter_version.clone(), + bridge_fingerprint: activation.bridge_fingerprint.clone(), + store_id: manifest.store_id.clone(), + project_root_fingerprint: manifest.project_root_fingerprint.clone(), + worker_key_fingerprint: fingerprint(&request.identity.agent_profile), + session: request.identity, + state, + query_class: query_class.to_string(), + result_count: safe_results.len(), + selected_memory_ids_sha256, + duration_ms, + status: "success".to_string(), + recorded_at: Utc::now(), + }; + let response = PreflightResponse { + state, + context, + receipt: receipt_summary(&receipt), + }; + + // Force construction of the complete adapter payload before any receipt exists. + render_for_format(&response, request.context_format)?; + prune_receipts( + &project.memory_root, + &receipt.harness_id, + &receipt.session.agent_profile, + Utc::now(), + ) + .map_err(ActivationError::from)?; + write_receipt(&project.memory_root, &receipt).map_err(ActivationError::from)?; + Ok(response) +} + +pub fn render_claude_session_start( + response: &PreflightResponse, +) -> Result { + serde_json::to_string(&serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": response.context, + } + })) + .map_err(|_| ActivationError::new("failed to serialize context")) +} + +pub fn render_pi_context(response: &PreflightResponse) -> Result { + render_structured_context(response) +} + +pub fn render_json_context(response: &PreflightResponse) -> Result { + render_structured_context(response) +} + +fn render_structured_context(response: &PreflightResponse) -> Result { + serde_json::to_string(&serde_json::json!({ + "context": response.context, + "state": response.state, + "receipt": response.receipt, + })) + .map_err(|_| ActivationError::new("failed to serialize context")) +} + +fn render_for_format( + response: &PreflightResponse, + format: PreflightContextFormat, +) -> Result { + match format { + PreflightContextFormat::ClaudeSessionStart => render_claude_session_start(response), + PreflightContextFormat::PiBeforeAgentStart => render_pi_context(response), + PreflightContextFormat::Json => render_json_context(response), + } +} + +/// Parses only harness-owned JSON stdin. Codex uses direct identity flags instead. +pub fn parse_adapter_stdin( + harness_id: &str, + context_format: PreflightContextFormat, + input: &str, +) -> Result { + if harness_id == "codex" { + return Err(ActivationError::new( + "codex preflight requires direct identity flags", + )); + } + let value: serde_json::Value = serde_json::from_str(input) + .map_err(|_| ActivationError::new("invalid adapter preflight stdin"))?; + let object = value + .as_object() + .ok_or_else(|| ActivationError::new("invalid adapter preflight stdin"))?; + let string = |name: &str| { + object + .get(name) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| ActivationError::new("invalid adapter preflight stdin")) + }; + let task_hint = match object.get("task_hint") { + Some(serde_json::Value::String(value)) => Some(value.clone()), + Some(serde_json::Value::Null) | None => None, + Some(_) => return Err(ActivationError::new("invalid adapter preflight stdin")), + }; + let identity = match harness_id { + "claude-code" => { + let session_id = string("session_id")?; + let workflow_id = object + .get("workflow_id") + .and_then(serde_json::Value::as_str) + .unwrap_or(session_id); + SessionIdentity { + agent_profile: "claude-code".to_string(), + workflow_id: normalize_stdin_identity("workflow", workflow_id), + session_id: normalize_stdin_identity("session", session_id), + } + } + "pi" | "agent-zero" => SessionIdentity { + agent_profile: normalize_stdin_identity("agent", string("agent_profile")?), + workflow_id: normalize_stdin_identity("workflow", string("workflow_id")?), + session_id: normalize_stdin_identity("session", string("session_id")?), + }, + _ => return Err(ActivationError::new("unknown harness adapter")), + }; + Ok(PreflightRequest { + harness_id: harness_id.to_string(), + identity, + task_hint, + context_format, + }) +} + +fn normalize_stdin_identity(label: &str, value: &str) -> String { + if identity_component_is_safe(value) { + value.to_string() + } else { + format!("{label}-{}", &fingerprint(value)[..24]) + } +} + +fn validate_identity(identity: &SessionIdentity) -> Result<(), ActivationError> { + for value in [ + &identity.agent_profile, + &identity.workflow_id, + &identity.session_id, + ] { + if !identity_component_is_safe(value) { + return Err(ActivationError::new("unsafe preflight identity")); + } + } + Ok(()) +} + +fn identity_component_is_safe(value: &str) -> bool { + !value.trim().is_empty() + && value.len() <= 256 + && !value.chars().any(char::is_control) + && !value.contains(['/', '\\']) + && !matches!(value, "." | "..") + && SensitivityGuard::default().inspect(value).sensitivity == "normal" +} + +fn ensure_persisted_manifest_matches( + project: &ActivationProject, + manifest: &ActivationManifest, +) -> Result<(), ActivationError> { + let persisted = load_manifest(&project.memory_root) + .map_err(|_| ActivationError::new("activation manifest is unavailable"))?; + if persisted.schema_version != manifest.schema_version + || persisted.protocol_version != manifest.protocol_version + || persisted.store_id != manifest.store_id + || persisted.project_root_fingerprint != manifest.project_root_fingerprint + || persisted.harnesses != manifest.harnesses + { + return Err(ActivationError::new("activation manifest/store mismatch")); + } + Ok(()) +} + +fn ensure_store_path_matches( + store: &SQLiteMemoryStore, + project: &ActivationProject, +) -> Result<(), ActivationError> { + let actual = store + .database_path() + .map_err(|_| ActivationError::new("configured memory store mismatch"))?; + let actual = std::fs::canonicalize(actual) + .map_err(|_| ActivationError::new("configured memory store mismatch"))?; + let expected = std::fs::canonicalize(project.memory_root.join("memory.sqlite")) + .map_err(|_| ActivationError::new("configured memory store mismatch"))?; + if actual != expected { + return Err(ActivationError::new("configured memory store mismatch")); + } + Ok(()) +} + +fn preflight_state( + project: &ActivationProject, + manifest: &ActivationManifest, +) -> Result { + let configured_root = canonical_or_original(&project.memory_root); + let mounted_root = canonical_or_original(&project.project_root.join(".tree-ring")); + let identity_root = configured_identity_root(project)?; + if fingerprint_path(identity_root) != manifest.project_root_fingerprint { + return Err(ActivationError::new("project root mismatch")); + } + Ok(if configured_root == mounted_root { + ActivationState::Active + } else { + ActivationState::ActiveIsolated + }) +} + +fn configured_identity_root(project: &ActivationProject) -> Result<&Path, ActivationError> { + let configured_root = canonical_or_original(&project.memory_root); + let mounted_root = canonical_or_original(&project.project_root.join(".tree-ring")); + if configured_root == mounted_root { + Ok(&project.project_root) + } else { + project + .memory_root + .parent() + .ok_or_else(|| ActivationError::new("configured memory root mismatch")) + } +} + +fn canonical_or_original(path: &Path) -> std::path::PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn safe_query(task_hint: Option<&str>) -> (&str, &'static str) { + task_hint + .map(str::trim) + .filter(|hint| !hint.is_empty()) + .filter(|hint| SensitivityGuard::default().inspect(hint).sensitivity == "normal") + .map_or((FALLBACK_QUERY, "startup_fallback"), |hint| { + (hint, "task_hint") + }) +} + +fn safe_results(mut results: Vec) -> Vec { + let guard = SensitivityGuard::default(); + results.retain(|result| { + result.memory.sensitivity == "normal" + && identity_component_is_safe(&result.memory.id) + && guard.inspect(&result.memory.summary).sensitivity == "normal" + && !result.memory.summary.chars().any(char::is_control) + }); + results.sort_by(|left, right| left.memory.id.cmp(&right.memory.id)); + results +} + +fn render_safe_recall_context(results: &[RecallResult]) -> Result { + let mut context = String::from("Tree Ring Memory scoped preflight recall:\n"); + if results.is_empty() { + context.push_str("- No safe memories matched this scoped query.\n"); + } else { + for result in results { + context.push_str("- ["); + context.push_str(&result.memory.id); + context.push_str("] "); + context.push_str(&result.memory.summary); + if safe_source_reference(&result.memory.source.ref_) { + context.push_str(" (source: "); + context.push_str(&result.memory.source.ref_); + context.push(')'); + } + context.push('\n'); + } + } + context.push_str( + "Project source and instructions remain authoritative; verify recalled guidance against them.", + ); + if context.len() > MAX_CONTEXT_BYTES { + return Err(ActivationError::new( + "context exceeds safe serialization limit", + )); + } + Ok(context) +} + +fn safe_source_reference(reference: &str) -> bool { + !reference.is_empty() + && reference.len() <= 512 + && !Path::new(reference).is_absolute() + && !reference.chars().any(char::is_control) + && SensitivityGuard::default().inspect(reference).sensitivity == "normal" +} + +fn selected_memory_ids_digest(results: &[RecallResult]) -> String { + let mut ids = results + .iter() + .map(|result| result.memory.id.as_str()) + .collect::>(); + ids.sort_unstable(); + ids.dedup(); + let mut hasher = Sha256::new(); + for id in ids { + hasher.update(id.len().to_be_bytes()); + hasher.update(id.as_bytes()); + } + format!("{:x}", hasher.finalize()) +} + +fn receipt_summary(receipt: &ActivationReceipt) -> ActivationReceiptSummary { + ActivationReceiptSummary { + schema_version: receipt.schema_version, + protocol_version: receipt.protocol_version, + receipt_id: receipt.receipt_id.clone(), + harness_id: receipt.harness_id.clone(), + adapter_version: receipt.adapter_version.clone(), + bridge_fingerprint: receipt.bridge_fingerprint.clone(), + store_id: receipt.store_id.clone(), + project_root_fingerprint: receipt.project_root_fingerprint.clone(), + worker_key_fingerprint: receipt.worker_key_fingerprint.clone(), + query_class: receipt.query_class.clone(), + result_count: receipt.result_count, + selected_memory_ids_sha256: receipt.selected_memory_ids_sha256.clone(), + duration_ms: receipt.duration_ms, + status: receipt.status.clone(), + recorded_at: receipt.recorded_at, + } +} + +#[cfg(test)] +pub(crate) fn project_fingerprint(project_root: &Path) -> String { + fingerprint_path(project_root) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::activation::{ + adapters::ActivationProject, + manifest::{ + bridge_fingerprint, receipt_files, save_manifest, ActivationManifest, + HarnessActivation, OwnedBridgeFile, + }, + ActivationState, AdapterCapability, SessionIdentity, ACTIVATION_PROTOCOL_VERSION, + ACTIVATION_SCHEMA_VERSION, + }; + use std::{collections::BTreeMap, fs}; + use tree_ring_memory_core::{MemoryEvent, MemorySource}; + use tree_ring_memory_sqlite::SQLiteMemoryStore; + + fn fixture() -> ( + tempfile::TempDir, + ActivationProject, + SQLiteMemoryStore, + ActivationManifest, + ) { + let temp = tempfile::tempdir().unwrap(); + let project_root = temp.path().join("project"); + let project = ActivationProject::from_project_root(&project_root); + fs::create_dir_all(&project.memory_root).unwrap(); + let mut activation = HarnessActivation { + state: ActivationState::ConfiguredAwaitingProof, + adapter_capability: AdapterCapability::NativePreflight, + adapter_version: "1".to_string(), + bridge_fingerprint: String::new(), + bridge_path: Some(".agents/skills/tree-ring-memory/SKILL.md".to_string()), + owned_files: vec![OwnedBridgeFile { + path: ".agents/skills/tree-ring-memory/SKILL.md".to_string(), + sha256: "b".repeat(64), + }], + managed_blocks: Vec::new(), + }; + activation.bridge_fingerprint = bridge_fingerprint("pi", &activation); + let manifest = ActivationManifest { + schema_version: ACTIVATION_SCHEMA_VERSION, + protocol_version: ACTIVATION_PROTOCOL_VERSION, + store_id: "store-test".to_string(), + project_root_fingerprint: project_fingerprint(&project_root), + cli_version: env!("CARGO_PKG_VERSION").to_string(), + harnesses: BTreeMap::from([("pi".to_string(), activation)]), + }; + save_manifest(&project.memory_root, &manifest).unwrap(); + let mut store = SQLiteMemoryStore::open(project.memory_root.join("memory.sqlite")).unwrap(); + let mut safe = + MemoryEvent::new("project startup constraint uses local receipts", "lesson").unwrap(); + safe.project = Some("project".to_string()); + safe.agent_profile = Some("pi".to_string()); + safe.workflow_id = Some("workflow-1".to_string()); + safe.session_id = Some("session-1".to_string()); + safe.scope = "agent".to_string(); + safe.source = MemorySource { + source_type: "agent".to_string(), + ref_: "docs/constraints.md".to_string(), + quote: String::new(), + }; + store.put(&safe).unwrap(); + let mut sensitive = safe.clone(); + sensitive.id = "mem-sensitive-fixture".to_string(); + sensitive.summary = "sensitive fixture project startup constraint".to_string(); + sensitive.sensitivity = "health".to_string(); + store.put(&sensitive).unwrap(); + (temp, project, store, manifest) + } + + fn fixture_request() -> PreflightRequest { + PreflightRequest { + harness_id: "pi".to_string(), + identity: SessionIdentity { + agent_profile: "pi".to_string(), + workflow_id: "workflow-1".to_string(), + session_id: "session-1".to_string(), + }, + task_hint: Some("project startup constraints".to_string()), + context_format: PreflightContextFormat::PiBeforeAgentStart, + } + } + + #[test] + fn preflight_injects_only_safe_recall_and_writes_a_matching_receipt() { + let (_temp, project, store, manifest) = fixture(); + + let response = run_preflight(&store, &project, &manifest, fixture_request()).unwrap(); + + assert!(response.context.contains("project startup constraint")); + assert!(!response.context.contains("sensitive fixture")); + assert_eq!(response.receipt.query_class, "task_hint"); + assert_eq!(response.receipt.result_count, 1); + assert_eq!(response.receipt.store_id, manifest.store_id); + assert_eq!(receipt_files(&project.memory_root).len(), 1); + } + + #[test] + fn sensitive_task_hint_uses_exact_fallback_without_persisting_input() { + let (_temp, project, store, manifest) = fixture(); + let secret = "Use sk-proj-abcdefghijklmnopqrstuvwxyz1234567890"; + let mut request = fixture_request(); + request.task_hint = Some(secret.to_string()); + + let response = run_preflight(&store, &project, &manifest, request).unwrap(); + + assert_eq!(response.receipt.query_class, "startup_fallback"); + let receipt_json = fs::read_to_string(&receipt_files(&project.memory_root)[0]).unwrap(); + assert!(!receipt_json.contains(secret)); + assert!(!receipt_json.contains("project startup constraint uses local receipts")); + } + + #[test] + fn zero_result_recall_writes_valid_proof_without_inventing_memory_context() { + let (_temp, project, store, manifest) = fixture(); + let mut request = fixture_request(); + request.identity.session_id = "session-with-no-memories".to_string(); + + let response = run_preflight(&store, &project, &manifest, request).unwrap(); + + assert_eq!(response.receipt.result_count, 0); + assert!(response.context.contains("No safe memories matched")); + assert_eq!(receipt_files(&project.memory_root).len(), 1); + } + + #[test] + fn adapter_renderers_emit_exact_single_json_values_without_identity_fields() { + let (_temp, project, store, manifest) = fixture(); + let response = run_preflight(&store, &project, &manifest, fixture_request()).unwrap(); + + let claude: serde_json::Value = + serde_json::from_str(&render_claude_session_start(&response).unwrap()).unwrap(); + assert_eq!( + claude, + serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": response.context, + } + }) + ); + let pi = render_pi_context(&response).unwrap(); + let json = render_json_context(&response).unwrap(); + assert_eq!(pi, json); + let structured: serde_json::Value = serde_json::from_str(&pi).unwrap(); + assert_eq!(structured["state"], "active"); + assert_eq!(structured["receipt"]["result_count"], 1); + assert!(structured.get("session").is_none()); + assert!(!pi.contains("workflow-1")); + assert!(!pi.contains("session-1")); + } + + #[test] + fn malformed_adapter_stdin_is_rejected_without_echo_or_receipt() { + let (_temp, project, _store, _manifest) = fixture(); + let malformed = r#"{"task_hint":"private prompt","session_id":7}"#; + + let error = + parse_adapter_stdin("pi", PreflightContextFormat::PiBeforeAgentStart, malformed) + .unwrap_err(); + + assert_eq!(error.to_string(), "invalid adapter preflight stdin"); + assert!(!error.to_string().contains("private prompt")); + assert!(receipt_files(&project.memory_root).is_empty()); + } + + #[test] + fn adapter_stdin_fingerprints_unsafe_identity_and_never_persists_raw_paths() { + let (_temp, project, store, mut manifest) = fixture(); + let raw_path = "/private/tmp/pi/session.jsonl"; + let input = serde_json::json!({ + "agent_profile": "pi", + "workflow_id": raw_path, + "session_id": raw_path, + "task_hint": "project startup constraints", + }) + .to_string(); + let request = + parse_adapter_stdin("pi", PreflightContextFormat::PiBeforeAgentStart, &input).unwrap(); + assert!(request.identity.workflow_id.starts_with("workflow-")); + assert!(!request.identity.workflow_id.contains('/')); + manifest.harnesses.get_mut("pi").unwrap().state = ActivationState::ConfiguredAwaitingProof; + save_manifest(&project.memory_root, &manifest).unwrap(); + + run_preflight(&store, &project, &manifest, request).unwrap(); + + let receipt_json = fs::read_to_string(&receipt_files(&project.memory_root)[0]).unwrap(); + assert!(!receipt_json.contains(raw_path)); + assert!(!receipt_json.contains("/private/")); + } + + #[test] + fn oversized_context_fails_before_any_receipt_is_written() { + let (_temp, project, mut store, manifest) = fixture(); + let mut huge = MemoryEvent::new( + format!( + "project startup constraints {}", + "x".repeat(MAX_CONTEXT_BYTES) + ), + "lesson", + ) + .unwrap(); + huge.project = Some("project".to_string()); + huge.agent_profile = Some("pi".to_string()); + huge.workflow_id = Some("workflow-1".to_string()); + huge.session_id = Some("session-1".to_string()); + huge.scope = "agent".to_string(); + store.put(&huge).unwrap(); + + let error = run_preflight(&store, &project, &manifest, fixture_request()).unwrap_err(); + + assert!(error.to_string().contains("context")); + assert!(receipt_files(&project.memory_root).is_empty()); + } + + #[test] + fn stale_adapter_version_invalidates_prior_receipts_and_fails_closed() { + let (_temp, project, store, mut manifest) = fixture(); + run_preflight(&store, &project, &manifest, fixture_request()).unwrap(); + assert_eq!(receipt_files(&project.memory_root).len(), 1); + let activation = manifest.harnesses.get_mut("pi").unwrap(); + activation.adapter_version = "2".to_string(); + activation.bridge_fingerprint = bridge_fingerprint("pi", activation); + save_manifest(&project.memory_root, &manifest).unwrap(); + + let error = run_preflight(&store, &project, &manifest, fixture_request()).unwrap_err(); + + assert!(error.to_string().contains("stale adapter version")); + assert!(receipt_files(&project.memory_root).is_empty()); + } + + #[test] + fn supplied_store_identity_mismatch_writes_no_receipt() { + let (_temp, project, store, manifest) = fixture(); + let mut mismatched = manifest.clone(); + mismatched.store_id = "different-store".to_string(); + + let error = run_preflight(&store, &project, &mismatched, fixture_request()).unwrap_err(); + + assert!(error.to_string().contains("manifest/store mismatch")); + assert!(receipt_files(&project.memory_root).is_empty()); + } + + #[test] + fn sqlite_store_path_mismatch_writes_no_receipt() { + let (_temp, project, _store, manifest) = fixture(); + let other = tempfile::tempdir().unwrap(); + let other_store = SQLiteMemoryStore::open(other.path().join("memory.sqlite")).unwrap(); + + let error = + run_preflight(&other_store, &project, &manifest, fixture_request()).unwrap_err(); + + assert!(error.to_string().contains("memory store mismatch")); + assert!(receipt_files(&project.memory_root).is_empty()); + } + + #[test] + fn pathless_sqlite_store_writes_no_receipt() { + let (_temp, project, _store, manifest) = fixture(); + let in_memory = SQLiteMemoryStore::open(":memory:").unwrap(); + + let error = run_preflight(&in_memory, &project, &manifest, fixture_request()).unwrap_err(); + + assert!(error.to_string().contains("memory store mismatch")); + assert!(receipt_files(&project.memory_root).is_empty()); + } + + #[test] + fn project_root_mismatch_writes_no_receipt() { + let (_temp, project, store, mut manifest) = fixture(); + manifest.project_root_fingerprint = "d".repeat(64); + save_manifest(&project.memory_root, &manifest).unwrap(); + + let error = run_preflight(&store, &project, &manifest, fixture_request()).unwrap_err(); + + assert!(error.to_string().contains("project root mismatch")); + assert!(receipt_files(&project.memory_root).is_empty()); + } + + #[test] + fn configured_nonmounted_store_is_active_isolated_and_never_copies_data() { + let temp = tempfile::tempdir().unwrap(); + let mounted_root = temp.path().join("mounted-project"); + fs::create_dir_all(&mounted_root).unwrap(); + let isolated_project_root = temp.path().join("agent-zero-store"); + let memory_root = isolated_project_root.join(".tree-ring"); + fs::create_dir_all(&memory_root).unwrap(); + let project = ActivationProject { + project_root: mounted_root.clone(), + memory_root: memory_root.clone(), + }; + let mut activation = HarnessActivation { + state: ActivationState::ConfiguredAwaitingProof, + adapter_capability: AdapterCapability::NativePreflight, + adapter_version: "1".to_string(), + bridge_fingerprint: String::new(), + bridge_path: Some(".tree-ring/activation/agent-zero.json".to_string()), + owned_files: vec![OwnedBridgeFile { + path: ".tree-ring/activation/agent-zero.json".to_string(), + sha256: "e".repeat(64), + }], + managed_blocks: Vec::new(), + }; + activation.bridge_fingerprint = bridge_fingerprint("agent-zero", &activation); + let manifest = ActivationManifest { + schema_version: ACTIVATION_SCHEMA_VERSION, + protocol_version: ACTIVATION_PROTOCOL_VERSION, + store_id: "isolated-store".to_string(), + project_root_fingerprint: project_fingerprint(&isolated_project_root), + cli_version: env!("CARGO_PKG_VERSION").to_string(), + harnesses: BTreeMap::from([("agent-zero".to_string(), activation)]), + }; + save_manifest(&memory_root, &manifest).unwrap(); + let mut store = SQLiteMemoryStore::open(memory_root.join("memory.sqlite")).unwrap(); + let mut local = MemoryEvent::new("isolated local startup constraint", "lesson").unwrap(); + local.project = Some("agent-zero-store".to_string()); + local.agent_profile = Some("agent-zero".to_string()); + local.workflow_id = Some("workflow-1".to_string()); + local.session_id = Some("session-1".to_string()); + local.scope = "agent".to_string(); + store.put(&local).unwrap(); + let request = PreflightRequest { + harness_id: "agent-zero".to_string(), + identity: SessionIdentity { + agent_profile: "agent-zero".to_string(), + workflow_id: "workflow-1".to_string(), + session_id: "session-1".to_string(), + }, + task_hint: None, + context_format: PreflightContextFormat::Json, + }; + + let response = run_preflight(&store, &project, &manifest, request).unwrap(); + + assert_eq!(response.state, ActivationState::ActiveIsolated); + assert_eq!(response.receipt.result_count, 1); + assert!(response + .context + .contains("isolated local startup constraint")); + assert_eq!(receipt_files(&memory_root).len(), 1); + assert!(!mounted_root.join(".tree-ring").exists()); + } + + #[test] + fn preflight_prunes_expired_receipt_before_recording_new_proof() { + let (_temp, project, store, manifest) = fixture(); + run_preflight(&store, &project, &manifest, fixture_request()).unwrap(); + let path = receipt_files(&project.memory_root).pop().unwrap(); + let mut receipt: crate::activation::ActivationReceipt = + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + receipt.recorded_at = Utc::now() - chrono::Duration::days(31); + fs::write(&path, serde_json::to_vec_pretty(&receipt).unwrap()).unwrap(); + + run_preflight(&store, &project, &manifest, fixture_request()).unwrap(); + + let receipts = receipt_files(&project.memory_root); + assert_eq!(receipts.len(), 1); + assert_ne!(receipts[0], path); + } +} diff --git a/crates/tree-ring-memory-sqlite/src/lib.rs b/crates/tree-ring-memory-sqlite/src/lib.rs index 6aaefe1..614cc4e 100644 --- a/crates/tree-ring-memory-sqlite/src/lib.rs +++ b/crates/tree-ring-memory-sqlite/src/lib.rs @@ -3,7 +3,7 @@ use rusqlite::{ TransactionBehavior, }; use std::collections::HashSet; -use std::path::Path; +use std::path::{Path, PathBuf}; use tree_ring_memory_core::models::{sqlite_error, MemoryEvent, TreeRingError, TreeRingResult}; use tree_ring_memory_core::recall::{search_queries, RecallScorer}; @@ -125,6 +125,22 @@ impl SQLiteMemoryStore { }) } + /// Returns the filesystem path SQLite reports for the main database. + /// In-memory and otherwise pathless databases fail closed for callers that + /// must bind behavior to one configured local store. + pub fn database_path(&self) -> TreeRingResult { + let path: String = self + .connection + .query_row("PRAGMA database_list", [], |row| row.get(2)) + .map_err(sqlite_error_from_rusqlite)?; + if path.is_empty() { + return Err(TreeRingError::Validation( + "main SQLite database has no filesystem path".to_string(), + )); + } + Ok(PathBuf::from(path)) + } + #[cfg(test)] pub fn connection_for_testing(&self) -> &Connection { &self.connection @@ -1647,6 +1663,25 @@ mod tests { use tempfile::tempdir; use tree_ring_memory_core::models::MemorySource; + #[test] + fn database_path_reports_the_main_filesystem_store() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("memory.sqlite"); + let store = SQLiteMemoryStore::open(&db_path).unwrap(); + + assert_eq!( + std::fs::canonicalize(store.database_path().unwrap()).unwrap(), + std::fs::canonicalize(db_path).unwrap() + ); + } + + #[test] + fn database_path_rejects_pathless_in_memory_stores() { + let store = SQLiteMemoryStore::open(":memory:").unwrap(); + + assert!(store.database_path().is_err()); + } + #[test] fn public_store_facade_still_covers_write_search_export_import_and_maintenance() { let dir = tempdir().unwrap(); From 6517cc51941694d417ff73fa5a0aaa08be3c6bc3 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 00:12:29 -0400 Subject: [PATCH 10/31] fix: fail closed on bridge publication uncertainty --- .../src/activation/bridge.rs | 860 +++++++++--------- 1 file changed, 428 insertions(+), 432 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/activation/bridge.rs b/crates/tree-ring-memory-cli/src/activation/bridge.rs index d3a9731..51a63e9 100644 --- a/crates/tree-ring-memory-cli/src/activation/bridge.rs +++ b/crates/tree-ring-memory-cli/src/activation/bridge.rs @@ -127,9 +127,9 @@ export default function treeRingMemory(pi: ExtensionAPI) { #[cfg(test)] #[derive(Default)] struct TestMutationHooks { - after_project_root_opened: Option>, - after_target_snapshot: Option>, - fail_directory_sync_at: Option, + after_final_root_binding: Option>, + after_final_target_validation: Option>, + fail_directory_sync_at: Vec, directory_syncs: usize, } @@ -139,35 +139,40 @@ thread_local! { } #[cfg(test)] -fn after_project_root_opened() { +fn after_final_root_binding() { let action = - TEST_MUTATION_HOOKS.with(|hooks| hooks.borrow_mut().after_project_root_opened.take()); + TEST_MUTATION_HOOKS.with(|hooks| hooks.borrow_mut().after_final_root_binding.take()); if let Some(action) = action { action(); } } #[cfg(not(test))] -fn after_project_root_opened() {} +fn after_final_root_binding() {} #[cfg(test)] -fn after_target_snapshot() { - let action = TEST_MUTATION_HOOKS.with(|hooks| hooks.borrow_mut().after_target_snapshot.take()); +fn after_final_target_validation() { + let action = + TEST_MUTATION_HOOKS.with(|hooks| hooks.borrow_mut().after_final_target_validation.take()); if let Some(action) = action { action(); } } #[cfg(not(test))] -fn after_target_snapshot() {} +fn after_final_target_validation() {} #[cfg(test)] fn fail_directory_sync_now() -> bool { TEST_MUTATION_HOOKS.with(|hooks| { let mut hooks = hooks.borrow_mut(); hooks.directory_syncs += 1; - if hooks.fail_directory_sync_at == Some(hooks.directory_syncs) { - hooks.fail_directory_sync_at = None; + if let Some(position) = hooks + .fail_directory_sync_at + .iter() + .position(|number| *number == hooks.directory_syncs) + { + hooks.fail_directory_sync_at.remove(position); true } else { false @@ -196,9 +201,8 @@ struct PreparedFile { #[derive(Debug)] struct AppliedFile { - prepared: PreparedFile, target: ResolvedTarget, - publication: Publication, + publication: FileIdentity, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -209,20 +213,13 @@ struct FileIdentity { #[derive(Debug)] struct TargetSnapshot { - identity: FileIdentity, bytes: Vec, } -#[derive(Debug, Clone, Copy)] -enum Publication { - Present { identity: FileIdentity }, - Absent, -} - #[derive(Debug)] struct MutationError { message: String, - publication: Option, + publication: Option, } impl MutationError { @@ -233,7 +230,7 @@ impl MutationError { } } - fn after_publication(message: String, publication: Publication) -> Self { + fn after_publication(message: String, publication: FileIdentity) -> Self { Self { message, publication: Some(publication), @@ -311,7 +308,6 @@ impl ProjectFs { project_root: project.project_root.clone(), root_identity: file_identity(&metadata), }; - after_project_root_opened(); Ok(project_fs) } @@ -439,20 +435,13 @@ impl ResolvedTarget { let mut bytes = Vec::new(); file.read_to_end(&mut bytes) .map_err(|error| io_error(&self.display, error))?; - let metadata = file - .metadata() - .map_err(|error| io_error(&self.display, error))?; - Ok(Some(TargetSnapshot { - identity: file_identity(&metadata), - bytes, - })) + Ok(Some(TargetSnapshot { bytes })) } - fn publish_write( - &self, - bytes: &[u8], - expected: Option, - ) -> Result { + /// Publishes only a new final entry. POSIX has no inode-and-bytes + /// conditional replacement or unlink primitive, so this writer never + /// replaces or removes an existing bridge path. + fn publish_creation(&self, bytes: &[u8]) -> Result { let temp_name = CString::new(format!( ".{}.{}.tmp", self.name.to_string_lossy(), @@ -472,7 +461,7 @@ impl ResolvedTarget { }; let mut temp = owned_file_descriptor(descriptor, &self.display) .map_err(MutationError::before_publication)?; - let result = (|| -> Result { + let result = (|| -> Result { temp.write_all(bytes).map_err(|error| { MutationError::before_publication(io_error(&self.display, error)) })?; @@ -482,31 +471,24 @@ impl ResolvedTarget { let metadata = temp.metadata().map_err(|error| { MutationError::before_publication(io_error(&self.display, error)) })?; - let publication = Publication::Present { - identity: file_identity(&metadata), - }; + let publication = file_identity(&metadata); drop(temp); - self.ensure_entry_identity(expected) + self.ensure_entry_absent() .map_err(MutationError::before_publication)?; + // `linkat` below is create-only. This test hook is deliberately + // after the final absence check so an adversarial replacement is + // exercised at the only publication operation, not before it. + after_final_target_validation(); let status = unsafe { - if expected.is_none() { - // SAFETY: both names are relative to the same retained parent descriptor. - libc::linkat( - self.parent.as_raw_fd(), - temp_name.as_ptr(), - self.parent.as_raw_fd(), - self.name.as_ptr(), - 0, - ) - } else { - // SAFETY: both names are relative to the same retained parent descriptor. - libc::renameat( - self.parent.as_raw_fd(), - temp_name.as_ptr(), - self.parent.as_raw_fd(), - self.name.as_ptr(), - ) - } + // SAFETY: both names are relative to the same retained parent descriptor. + // `linkat` fails with EEXIST instead of replacing a concurrent final entry. + libc::linkat( + self.parent.as_raw_fd(), + temp_name.as_ptr(), + self.parent.as_raw_fd(), + self.name.as_ptr(), + 0, + ) }; if status != 0 { return Err(MutationError::before_publication(io_error( @@ -514,10 +496,8 @@ impl ResolvedTarget { std::io::Error::last_os_error(), ))); } - if expected.is_none() { - if let Err(error) = unlink_at_if_present(&self.parent, &temp_name, &self.display) { - return Err(MutationError::after_publication(error, publication)); - } + if let Err(error) = unlink_at_if_present(&self.parent, &temp_name, &self.display) { + return Err(MutationError::after_publication(error, publication)); } Ok(publication) })(); @@ -527,25 +507,16 @@ impl ResolvedTarget { result } - fn publish_removal(&self, expected: FileIdentity) -> Result { - self.ensure_entry_identity(Some(expected)) - .map_err(MutationError::before_publication)?; - unlink_at(&self.parent, &self.name, &self.display) - .map_err(MutationError::before_publication)?; - Ok(Publication::Absent) - } - fn sync_parent(&self) -> Result<(), String> { sync_directory(&self.parent, &self.display) } - fn ensure_entry_identity(&self, expected: Option) -> Result<(), String> { - let current = self.entry_identity_optional()?; - if current == expected { + fn ensure_entry_absent(&self) -> Result<(), String> { + if self.entry_identity_optional()?.is_none() { Ok(()) } else { Err(format!( - "bridge target changed after validation: {}", + "bridge target already exists and cannot be safely replaced: {}", self.display.display() )) } @@ -580,17 +551,6 @@ impl ResolvedTarget { inode: stat.st_ino as u64, })) } - - #[cfg(test)] - fn atomic_write(&self, bytes: &[u8], create_only: bool) -> Result<(), String> { - let current = self.snapshot_optional()?; - if create_only != current.is_none() { - return Err("test write creation mode did not match the target state".to_string()); - } - self.publish_write(bytes, current.as_ref().map(|snapshot| snapshot.identity)) - .map_err(|error| error.message)?; - self.sync_parent() - } } #[cfg(unix)] @@ -684,19 +644,6 @@ fn owned_file_descriptor(descriptor: libc::c_int, path: &Path) -> Result Result<(), String> { - let status = unsafe { - // SAFETY: parent owns a live descriptor and name is one child component. - libc::unlinkat(parent.as_raw_fd(), name.as_ptr(), 0) - }; - if status == 0 { - Ok(()) - } else { - Err(io_error(display, std::io::Error::last_os_error())) - } -} - #[cfg(unix)] fn unlink_at_if_present(parent: &File, name: &CStr, display: &Path) -> Result<(), String> { let status = unsafe { @@ -782,17 +729,7 @@ impl ResolvedTarget { Err("bridge access requires descriptor-relative no-follow filesystem support".to_string()) } - fn publish_write( - &self, - _bytes: &[u8], - _expected: Option, - ) -> Result { - Err(MutationError::before_publication( - "bridge mutation requires descriptor-relative no-follow filesystem support".to_string(), - )) - } - - fn publish_removal(&self, _expected: FileIdentity) -> Result { + fn publish_creation(&self, _bytes: &[u8]) -> Result { Err(MutationError::before_publication( "bridge mutation requires descriptor-relative no-follow filesystem support".to_string(), )) @@ -855,6 +792,9 @@ pub fn apply_bridge_plan( next_manifest .harnesses .insert(plan.harness_id.clone(), activation); + if manifest_update_requires_replacement(expected_persisted.as_ref(), &next_manifest) { + return Ok(creation_only_review_result(&plan.harness_id)); + } commit_files_and_manifest( &project_fs, manifest, @@ -887,11 +827,24 @@ pub fn apply_bridge_plan( .harnesses .insert(plan.harness_id.clone(), activation); validate_manifest(&next_manifest)?; + if files_require_existing_entry_mutation(&files) + || manifest_update_requires_replacement(expected_persisted.as_ref(), &next_manifest) + { + return Ok(creation_only_review_result(&plan.harness_id)); + } let changed_paths = files .iter() .filter(|file| file.before != file.after) .map(|file| file.relative.clone()) .collect::>(); + if changed_paths.is_empty() && next_manifest == current_manifest { + *manifest = current_manifest; + return Ok(BridgePlanResult { + state: applied_state(&plan.harness_id, plan.state), + changed_paths, + next_step: plan.next_step, + }); + } commit_files_and_manifest( &project_fs, manifest, @@ -920,6 +873,7 @@ pub fn preview_bridge_plan( validate_manifest(manifest)?; validate_plan(&plan)?; let project_fs = ProjectFs::open(project)?; + let (current_manifest, expected_persisted) = reconcile_manifest(&project_fs, manifest)?; if matches!( plan.state, ActivationState::NeedsPlugin | ActivationState::Unsupported @@ -930,27 +884,58 @@ pub fn preview_bridge_plan( plan.harness_id, plan.state )); } + let mut next_manifest = current_manifest.clone(); + let mut activation = next_manifest + .harnesses + .get(&plan.harness_id) + .cloned() + .unwrap_or(HarnessActivation { + state: plan.state, + adapter_capability: capability_for(&plan.harness_id)?, + bridge_path: None, + owned_files: Vec::new(), + managed_blocks: Vec::new(), + }); + activation.state = plan.state; + activation.adapter_capability = capability_for(&plan.harness_id)?; + next_manifest + .harnesses + .insert(plan.harness_id.clone(), activation); + if manifest_update_requires_replacement(expected_persisted.as_ref(), &next_manifest) { + return Ok(creation_only_review_result(&plan.harness_id)); + } return Ok(BridgePlanResult { state: plan.state, changed_paths: Vec::new(), next_step: plan.next_step, }); } - match prepare_apply(&project_fs, manifest, &plan, accept_managed_block)? { + match prepare_apply(&project_fs, ¤t_manifest, &plan, accept_managed_block)? { Preparation::Review { next_step } => Ok(BridgePlanResult { state: ActivationState::NeedsUserReview, changed_paths: Vec::new(), next_step, }), - Preparation::Ready { files, .. } => Ok(BridgePlanResult { - state: applied_state(&plan.harness_id, plan.state), - changed_paths: files - .into_iter() - .filter(|file| file.before != file.after) - .map(|file| file.relative) - .collect(), - next_step: plan.next_step, - }), + Preparation::Ready { files, activation } => { + let mut next_manifest = current_manifest.clone(); + next_manifest + .harnesses + .insert(plan.harness_id.clone(), activation); + if files_require_existing_entry_mutation(&files) + || manifest_update_requires_replacement(expected_persisted.as_ref(), &next_manifest) + { + return Ok(creation_only_review_result(&plan.harness_id)); + } + Ok(BridgePlanResult { + state: applied_state(&plan.harness_id, plan.state), + changed_paths: files + .into_iter() + .filter(|file| file.before != file.after) + .map(|file| file.relative) + .collect(), + next_step: plan.next_step, + }) + } } } @@ -1053,6 +1038,11 @@ pub fn deactivate_bridge_plan( next.bridge_path = Some(first); } + if files_require_existing_entry_mutation(&files) + || manifest_update_requires_replacement(expected_persisted.as_ref(), &next_manifest) + { + return Ok(creation_only_review_result(harness_id)); + } let changed_paths = files .iter() .filter(|file| file.before != file.after) @@ -1853,6 +1843,16 @@ fn persist_manifest( "activation manifest changed concurrently; bridge update was not committed".to_string(), )); } + if before.is_some() { + if current.as_ref() == Some(manifest) { + // Preserve an already-valid manifest byte-for-byte. In particular, + // do not rewrite a user-formatted but semantically unchanged file. + return Ok(None); + } + return Err(CommitPreparedError::before_publication( + "activation manifest already exists and cannot be safely replaced".to_string(), + )); + } let prepared = PreparedFile { relative, before, @@ -1871,6 +1871,18 @@ fn commit_files_and_manifest( ) -> Result<(), String> { project_fs.ensure_root_binding()?; ensure_expected_manifest(project_fs, expected_persisted)?; + if files_require_existing_entry_mutation(files) { + return Err( + "bridge writer will not replace or remove an existing final entry; explicit review is required" + .to_string(), + ); + } + if manifest_update_requires_replacement(expected_persisted, &next_manifest) { + return Err( + "activation manifest already exists and cannot be safely replaced; explicit review is required" + .to_string(), + ); + } let mut applied = Vec::new(); for file in files.iter().filter(|file| file.before != file.after) { match commit_prepared_file(project_fs, file) { @@ -1880,35 +1892,67 @@ fn commit_files_and_manifest( if let Some(applied_file) = error.applied { applied.push(*applied_file); } - rollback_files(&applied); - *manifest = original_manifest.clone(); - return Err(error.message); + if applied.is_empty() { + *manifest = original_manifest.clone(); + return Err(error.message); + } + return Err(indeterminate_commit_error( + manifest, + original_manifest, + &next_manifest, + error.message, + &applied, + )); } } } if let Err(error) = project_fs.ensure_root_binding() { - rollback_files(&applied); - *manifest = original_manifest.clone(); - return Err(error); + if applied.is_empty() { + *manifest = original_manifest.clone(); + return Err(error); + } + return Err(indeterminate_commit_error( + manifest, + original_manifest, + &next_manifest, + error, + &applied, + )); } let manifest_applied = match persist_manifest(project_fs, expected_persisted, &next_manifest) { Ok(applied_file) => applied_file, Err(error) => { if let Some(applied_file) = error.applied { - rollback_file(&applied_file); + applied.push(*applied_file); } - rollback_files(&applied); - *manifest = original_manifest.clone(); - return Err(error.message); + if applied.is_empty() { + *manifest = original_manifest.clone(); + return Err(error.message); + } + return Err(indeterminate_commit_error( + manifest, + original_manifest, + &next_manifest, + error.message, + &applied, + )); } }; if let Err(error) = project_fs.ensure_root_binding() { - if let Some(applied_file) = &manifest_applied { - rollback_file(applied_file); + if let Some(applied_file) = manifest_applied { + applied.push(applied_file); + } + if applied.is_empty() { + *manifest = original_manifest.clone(); + return Err(error); } - rollback_files(&applied); - *manifest = original_manifest.clone(); - return Err(error); + return Err(indeterminate_commit_error( + manifest, + original_manifest, + &next_manifest, + error, + &applied, + )); } *manifest = next_manifest; Ok(()) @@ -1921,6 +1965,12 @@ fn commit_prepared_file( if file.before == file.after { return Ok(None); } + if file.before.is_some() || file.after.is_none() { + return Err(CommitPreparedError::before_publication(format!( + "bridge writer will not replace or remove an existing final entry: {}", + file.relative.display() + ))); + } project_fs .ensure_root_binding() .map_err(CommitPreparedError::before_publication)?; @@ -1930,31 +1980,28 @@ fn commit_prepared_file( let current = target .snapshot_optional() .map_err(CommitPreparedError::before_publication)?; - if current.as_ref().map(|snapshot| &snapshot.bytes) != file.before.as_ref() { + if current.is_some() { return Err(CommitPreparedError::before_publication(format!( - "bridge target changed after validation: {}", + "bridge target already exists and cannot be safely replaced: {}", file.relative.display() ))); } - let expected = current.as_ref().map(|snapshot| snapshot.identity); - after_target_snapshot(); - let publication = match &file.after { - Some(bytes) => target.publish_write(bytes, expected), - None => { - let expected = expected.ok_or_else(|| { - CommitPreparedError::before_publication(format!( - "bridge target disappeared before removal: {}", - file.relative.display() - )) - })?; - target.publish_removal(expected) - } - }; + project_fs + .ensure_root_binding() + .map_err(CommitPreparedError::before_publication)?; + after_final_root_binding(); + project_fs + .ensure_root_binding() + .map_err(CommitPreparedError::before_publication)?; + let bytes = file + .after + .as_deref() + .expect("creation-only file has desired bytes"); + let publication = target.publish_creation(bytes); let publication = match publication { Ok(publication) => publication, Err(error) => { let applied = error.publication.map(|publication| AppliedFile { - prepared: file.clone(), target, publication, }); @@ -1965,7 +2012,6 @@ fn commit_prepared_file( } }; let applied = AppliedFile { - prepared: file.clone(), target, publication, }; @@ -1975,46 +2021,59 @@ fn commit_prepared_file( Ok(Some(applied)) } -fn rollback_files(files: &[AppliedFile]) { - for file in files.iter().rev() { - rollback_file(file); +/// Never roll a published path back: no POSIX primitive can condition that +/// remove/replace on the exact published inode and bytes. A second directory +/// sync is non-destructive and lets us surface a separate recovery durability +/// failure without pretending the original in-memory manifest still matches +/// disk. +fn preserve_published_files(files: &[AppliedFile]) -> Result<(), String> { + let failures = files + .iter() + .rev() + .filter_map(|file| { + file.target.sync_parent().err().map(|error| { + format!( + "{} (published inode {}:{})", + error, file.publication.device, file.publication.inode + ) + }) + }) + .collect::>(); + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("; ")) } } -fn rollback_file(file: &AppliedFile) { - let should_restore = match file.publication { - Publication::Present { identity } => file - .target - .snapshot_optional() - .map(|current| { - current.is_some_and(|current| { - current.identity == identity - && file.prepared.after.as_deref() == Some(current.bytes.as_slice()) - }) - }) - .unwrap_or(false), - Publication::Absent => file - .target - .snapshot_optional() - .map(|current| current.is_none()) - .unwrap_or(false), - }; - if !should_restore { - return; +fn indeterminate_manifest( + original: &ActivationManifest, + next: &ActivationManifest, +) -> ActivationManifest { + let mut indeterminate = next.clone(); + for (harness_id, activation) in &mut indeterminate.harnesses { + if original.harnesses.get(harness_id) != Some(activation) { + activation.state = ActivationState::NeedsUserReview; + } } - let expected = match file.publication { - Publication::Present { identity } => Some(identity), - Publication::Absent => None, - }; - let restored = match &file.prepared.before { - Some(bytes) => file.target.publish_write(bytes, expected), - None => match expected { - Some(identity) => file.target.publish_removal(identity), - None => return, - }, - }; - if restored.is_ok() { - let _ = file.target.sync_parent(); + indeterminate +} + +fn indeterminate_commit_error( + manifest: &mut ActivationManifest, + original: &ActivationManifest, + next: &ActivationManifest, + primary_error: String, + applied: &[AppliedFile], +) -> String { + *manifest = indeterminate_manifest(original, next); + match preserve_published_files(applied) { + Ok(()) => format!( + "{primary_error}; published bridge material was preserved because safe rollback is unavailable; bridge state is indeterminate and requires user review" + ), + Err(reconciliation_error) => format!( + "{primary_error}; recovery durability check also failed: {reconciliation_error}; published bridge material was preserved and bridge state is indeterminate" + ), } } @@ -2057,6 +2116,34 @@ fn review_step(harness_id: &str) -> String { } } +/// There is no portable POSIX operation that says "replace/remove this final +/// pathname only if it is still this exact inode with these exact bytes". +/// Keep the bridge writer creation-only until such a primitive is available. +fn files_require_existing_entry_mutation(files: &[PreparedFile]) -> bool { + files + .iter() + .any(|file| file.before != file.after && (file.before.is_some() || file.after.is_none())) +} + +/// The activation manifest is also a final entry. Treat its replacement with +/// the same fail-closed boundary as a bridge file. +fn manifest_update_requires_replacement( + persisted: Option<&ActivationManifest>, + next: &ActivationManifest, +) -> bool { + persisted.is_some_and(|current| current != next) +} + +fn creation_only_review_result(harness_id: &str) -> BridgePlanResult { + BridgePlanResult { + state: ActivationState::NeedsUserReview, + changed_paths: Vec::new(), + next_step: format!( + "Tree Ring will not replace or remove an existing {harness_id} bridge entry; review and reconcile it before retrying." + ), + } +} + fn next_manifest_ownership_empty(manifest: &ActivationManifest, harness_id: &str) -> bool { manifest.harnesses.get(harness_id).is_none_or(|activation| { activation.owned_files.is_empty() && activation.managed_blocks.is_empty() @@ -2119,22 +2206,26 @@ mod tests { fs::write(path, content).unwrap(); } - fn after_project_root_opened_for_test(action: impl FnOnce() + 'static) { + fn after_final_root_binding_for_test(action: impl FnOnce() + 'static) { TEST_MUTATION_HOOKS.with(|hooks| { - hooks.borrow_mut().after_project_root_opened = Some(Box::new(action)); + hooks.borrow_mut().after_final_root_binding = Some(Box::new(action)); }); } - fn after_target_snapshot_for_test(action: impl FnOnce() + 'static) { + fn after_final_target_validation_for_test(action: impl FnOnce() + 'static) { TEST_MUTATION_HOOKS.with(|hooks| { - hooks.borrow_mut().after_target_snapshot = Some(Box::new(action)); + hooks.borrow_mut().after_final_target_validation = Some(Box::new(action)); }); } fn fail_directory_sync_at_for_test(number: usize) { + fail_directory_syncs_at_for_test(&[number]); + } + + fn fail_directory_syncs_at_for_test(numbers: &[usize]) { TEST_MUTATION_HOOKS.with(|hooks| { let mut hooks = hooks.borrow_mut(); - hooks.fail_directory_sync_at = Some(number); + hooks.fail_directory_sync_at = numbers.to_vec(); hooks.directory_syncs = 0; }); } @@ -2217,25 +2308,19 @@ mod tests { } #[test] - fn accepted_agents_block_preserves_unrelated_text_and_records_exact_block_id() { + fn accepted_agents_block_requires_review_when_the_final_file_already_exists() { let (_temp, project, mut manifest) = fixture(); write(project.project_root.join("AGENTS.md"), "# Team contract\n"); - apply_bridge_plan(&project, &mut manifest, plan("codex", &project), true).unwrap(); - let agents = read(project.project_root.join("AGENTS.md")); - let serialized = serde_json::to_value(&manifest).unwrap(); - apply_bridge_plan(&project, &mut manifest, plan("codex", &project), false).unwrap(); + let result = + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), true).unwrap(); - assert!(agents.starts_with("# Team contract\n")); - assert_eq!( - agents.matches("").count(), - 1 - ); - assert_eq!(agents.matches("").count(), 1); + assert_eq!(result.state, ActivationState::NeedsUserReview); assert_eq!( - serialized["harnesses"]["codex"]["managed_blocks"][0]["block_id"], - "codex" + read(project.project_root.join("AGENTS.md")), + "# Team contract\n" ); + assert!(manifest.harnesses.is_empty()); } #[test] @@ -2255,7 +2340,7 @@ mod tests { } #[test] - fn claude_settings_merge_preserves_every_unrelated_json_value_exactly() { + fn claude_settings_merge_is_planned_but_existing_json_is_never_replaced() { let (_temp, project, mut manifest) = fixture(); let settings_path = project.project_root.join(".claude/settings.json"); let original = json!({ @@ -2268,16 +2353,7 @@ mod tests { &serde_json::to_string_pretty(&original).unwrap(), ); - apply_bridge_plan( - &project, - &mut manifest, - plan("claude-code", &project), - false, - ) - .unwrap(); - let merged: Value = serde_json::from_str(&read(&settings_path)).unwrap(); - let first_bytes = read(&settings_path); - let retry = apply_bridge_plan( + let result = apply_bridge_plan( &project, &mut manifest, plan("claude-code", &project), @@ -2285,24 +2361,15 @@ mod tests { ) .unwrap(); - assert_eq!(merged["permissions"], original["permissions"]); - assert_eq!( - merged["hooks"]["PreToolUse"], - original["hooks"]["PreToolUse"] - ); - assert_eq!(merged["custom"], original["custom"]); - let session_hooks = merged["hooks"]["SessionStart"].as_array().unwrap(); - assert_eq!(session_hooks.len(), 1); - assert_eq!( - session_hooks[0]["hooks"][0]["description"], - "Tree Ring Memory managed preflight v1" - ); + assert_eq!(result.state, ActivationState::NeedsUserReview); assert_eq!( - session_hooks[0]["hooks"][0]["command"], - "tree-ring --root .tree-ring integrations preflight --harness claude-code --input-json-stdin --context-format claude-session-start" + serde_json::from_str::(&read(&settings_path)).unwrap(), + original ); - assert!(retry.changed_paths.is_empty()); - assert_eq!(read(settings_path), first_bytes); + assert!(!project + .project_root + .join(".claude/skills/tree-ring-memory/SKILL.md") + .exists()); } #[test] @@ -2395,6 +2462,7 @@ mod tests { .join(".tree-ring/activation/agent-zero.json") .exists()); + let (_temp, project, mut manifest) = fixture(); apply_bridge_plan(&project, &mut manifest, verified_agent_zero_plan(), false).unwrap(); let binding: Value = serde_json::from_str(&read( project @@ -2413,18 +2481,18 @@ mod tests { } #[test] - fn deactivation_preserves_non_tree_ring_text_canonical_store_and_receipts() { + fn creation_only_review_preserves_non_tree_ring_text_canonical_store_and_receipts() { let (_temp, project, mut manifest) = fixture(); write(project.project_root.join("AGENTS.md"), "# Team contract\n"); - apply_bridge_plan(&project, &mut manifest, plan("codex", &project), true).unwrap(); let receipt_path = project .memory_root .join("activation/receipts/codex/worker/receipt.json"); write(&receipt_path, "{}\n"); - let result = deactivate_bridge_plan(&project, &mut manifest, "codex").unwrap(); + let result = + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), true).unwrap(); - assert_eq!(result.state, ActivationState::ConfiguredAwaitingProof); + assert_eq!(result.state, ActivationState::NeedsUserReview); assert_eq!( read(project.project_root.join("AGENTS.md")), "# Team contract\n" @@ -2433,12 +2501,12 @@ mod tests { assert!(project.memory_root.join("AGENTS.md").exists()); assert!(project.memory_root.join("SKILL.md").exists()); assert!(project.memory_root.join("CLI.md").exists()); - assert!(project.memory_root.join("activation.json").exists()); + assert!(!project.memory_root.join("activation.json").exists()); assert!(!project .project_root .join(".agents/skills/tree-ring-memory/SKILL.md") .exists()); - assert!(manifest.harnesses.contains_key("codex")); + assert!(manifest.harnesses.is_empty()); } #[test] @@ -2476,20 +2544,19 @@ mod tests { } #[test] - fn shared_skill_file_survives_deactivation_of_one_recorded_owner() { + fn creation_only_deactivation_preserves_recorded_bridge_material_for_review() { let (_temp, project, mut manifest) = fixture(); apply_bridge_plan(&project, &mut manifest, plan("codex", &project), false).unwrap(); - apply_bridge_plan(&project, &mut manifest, plan("pi", &project), false).unwrap(); let result = deactivate_bridge_plan(&project, &mut manifest, "codex").unwrap(); - assert_eq!(result.state, ActivationState::ConfiguredAwaitingProof); + assert_eq!(result.state, ActivationState::NeedsUserReview); assert!(project .project_root .join(".agents/skills/tree-ring-memory/SKILL.md") .exists()); - assert!(!project.project_root.join("AGENTS.md").exists()); - assert!(manifest.harnesses["codex"].owned_files.is_empty()); + assert!(project.project_root.join("AGENTS.md").exists()); + assert!(!manifest.harnesses["codex"].owned_files.is_empty()); } #[test] @@ -2618,7 +2685,8 @@ mod tests { } #[test] - fn agent_zero_missing_plugin_preserves_the_owned_binding_for_safe_deactivation() { + fn agent_zero_missing_plugin_preserves_the_owned_binding_when_manifest_replacement_is_refused() + { let (_temp, project, mut manifest) = fixture(); apply_bridge_plan(&project, &mut manifest, verified_agent_zero_plan(), false).unwrap(); let binding = project @@ -2630,7 +2698,7 @@ mod tests { apply_bridge_plan(&project, &mut manifest, plan("agent-zero", &project), false) .unwrap(); - assert_eq!(result.state, ActivationState::NeedsPlugin); + assert_eq!(result.state, ActivationState::NeedsUserReview); assert!(binding.exists()); assert_eq!(manifest.harnesses["agent-zero"].owned_files, ownership); assert_eq!( @@ -2649,15 +2717,6 @@ mod tests { assert_eq!(result.state, ActivationState::NeedsUserReview); assert!(!project.project_root.join("AGENTS.md").exists()); - let (_temp, project, mut manifest) = fixture(); - write(project.project_root.join("AGENTS.md"), "# Team\n"); - apply_bridge_plan(&project, &mut manifest, plan("codex", &project), true).unwrap(); - fs::remove_file(project.project_root.join("AGENTS.md")).unwrap(); - let result = - apply_bridge_plan(&project, &mut manifest, plan("codex", &project), false).unwrap(); - assert_eq!(result.state, ActivationState::NeedsUserReview); - assert!(!project.project_root.join("AGENTS.md").exists()); - let (_temp, project, mut manifest) = fixture(); apply_bridge_plan( &project, @@ -2676,31 +2735,10 @@ mod tests { .unwrap(); assert_eq!(result.state, ActivationState::NeedsUserReview); assert!(!project.project_root.join(".claude/settings.json").exists()); - - let (_temp, project, mut manifest) = fixture(); - let settings = project.project_root.join(".claude/settings.json"); - write(&settings, "{\"permissions\": {\"allow\": [\"Read\"]}}"); - apply_bridge_plan( - &project, - &mut manifest, - plan("claude-code", &project), - false, - ) - .unwrap(); - fs::remove_file(&settings).unwrap(); - let result = apply_bridge_plan( - &project, - &mut manifest, - plan("claude-code", &project), - false, - ) - .unwrap(); - assert_eq!(result.state, ActivationState::NeedsUserReview); - assert!(!settings.exists()); } #[test] - fn claude_only_recognizes_handlers_in_the_exact_session_start_hook_location() { + fn claude_only_plans_handlers_in_the_exact_session_start_hook_location() { let (_temp, project, mut manifest) = fixture(); let settings = project.project_root.join(".claude/settings.json"); write( @@ -2712,7 +2750,7 @@ mod tests { .unwrap(), ); - apply_bridge_plan( + let result = apply_bridge_plan( &project, &mut manifest, plan("claude-code", &project), @@ -2721,8 +2759,9 @@ mod tests { .unwrap(); let merged: Value = serde_json::from_str(&read(&settings)).unwrap(); + assert_eq!(result.state, ActivationState::NeedsUserReview); assert_eq!(merged["custom"], claude_handler()); - assert_eq!(merged["hooks"]["SessionStart"].as_array().unwrap().len(), 1); + assert_eq!(merged["hooks"]["SessionStart"].as_array().unwrap().len(), 0); let (_temp, project, mut manifest) = fixture(); let settings = project.project_root.join(".claude/settings.json"); @@ -2744,7 +2783,7 @@ mod tests { } #[test] - fn concurrent_disjoint_activations_merge_both_manifest_records() { + fn concurrent_disjoint_activations_preserve_the_published_manifest_and_review_the_second() { let (_temp, project, manifest) = fixture(); let barrier = Arc::new(Barrier::new(2)); let mut workers = Vec::new(); @@ -2755,39 +2794,37 @@ mod tests { workers.push(thread::spawn(move || { let adapter_plan = plan(harness, &project); barrier.wait(); - apply_bridge_plan(&project, &mut worker_manifest, adapter_plan, false).unwrap(); + apply_bridge_plan(&project, &mut worker_manifest, adapter_plan, false).unwrap() })); } - for worker in workers { - worker.join().unwrap(); - } + let states = workers + .into_iter() + .map(|worker| worker.join().unwrap().state) + .collect::>(); let persisted = crate::activation::load_manifest(&project.memory_root).unwrap(); - assert!(persisted.harnesses.contains_key("codex")); - assert!(persisted.harnesses.contains_key("pi")); + assert_eq!(persisted.harnesses.len(), 1); + assert!(states.contains(&ActivationState::NeedsUserReview)); } #[cfg(unix)] #[test] - fn descriptor_relative_commit_and_rollback_ignore_a_replaced_parent_path() { + fn creation_only_publication_stays_on_the_retained_parent_descriptor() { use std::os::unix::fs::symlink; let (_temp, project, _manifest) = fixture(); - let target_path = project - .project_root - .join(".agents/skills/tree-ring-memory/SKILL.md"); - write(&target_path, "before\n"); + fs::create_dir_all(project.project_root.join(".agents/skills/tree-ring-memory")).unwrap(); let project_fs = ProjectFs::open(&project).unwrap(); let target = project_fs .resolve_target(Path::new(".agents/skills/tree-ring-memory/SKILL.md"), false) .unwrap(); - target.atomic_write(b"committed\n", false).unwrap(); - let parked = project.project_root.join(".agents-parked"); fs::rename(project.project_root.join(".agents"), &parked).unwrap(); let outside = tempfile::tempdir().unwrap(); symlink(outside.path(), project.project_root.join(".agents")).unwrap(); - target.atomic_write(b"before\n", false).unwrap(); + + target.publish_creation(b"tree-ring\n").unwrap(); + target.sync_parent().unwrap(); assert!(!outside .path() @@ -2795,62 +2832,64 @@ mod tests { .exists()); assert_eq!( read(parked.join("skills/tree-ring-memory/SKILL.md")), - "before\n" + "tree-ring\n" ); } #[test] - fn pi_deactivation_and_no_record_state_remain_needs_trust() { + fn pi_deactivation_keeps_needs_trust_when_creation_only_safety_requires_review() { let (_temp, project, mut manifest) = fixture(); let absent = deactivate_bridge_plan(&project, &mut manifest, "pi").unwrap(); assert_eq!(absent.state, ActivationState::NeedsTrust); apply_bridge_plan(&project, &mut manifest, plan("pi", &project), false).unwrap(); let deactivated = deactivate_bridge_plan(&project, &mut manifest, "pi").unwrap(); - assert_eq!(deactivated.state, ActivationState::NeedsTrust); + assert_eq!(deactivated.state, ActivationState::NeedsUserReview); assert_eq!(manifest.harnesses["pi"].state, ActivationState::NeedsTrust); } #[test] - fn codex_deactivation_preserves_preexisting_trailing_blank_lines_exactly() { + fn codex_block_removal_keeps_preexisting_trailing_blank_lines_exactly() { for original in ["# Team\n", "# Team\n\n", "# Team\n\n\n"] { - let (_temp, project, mut manifest) = fixture(); - write(project.project_root.join("AGENTS.md"), original); - apply_bridge_plan(&project, &mut manifest, plan("codex", &project), true).unwrap(); - - deactivate_bridge_plan(&project, &mut manifest, "codex").unwrap(); - - assert_eq!(read(project.project_root.join("AGENTS.md")), original); + let block = markdown_block("codex"); + let (with_block, separator) = append_markdown_block(original, &block); + let removed = remove_markdown_block( + with_block.as_bytes(), + "codex", + &sha256(block.as_bytes()), + &separator, + ) + .unwrap() + .unwrap(); + assert_eq!(String::from_utf8(removed).unwrap(), original); } } #[cfg(unix)] #[test] - fn final_entry_replacement_after_snapshot_is_never_followed_or_overwritten() { + fn final_entry_replacement_at_create_publication_is_never_followed_or_overwritten() { use std::os::unix::fs::symlink; let (_temp, project, _manifest) = fixture(); let target_path = project.project_root.join("managed-target"); - write(&target_path, "before\n"); let outside = tempfile::tempdir().unwrap(); let outside_marker = outside.path().join("must-not-change"); write(&outside_marker, "outside\n"); let project_fs = ProjectFs::open(&project).unwrap(); let prepared = PreparedFile { relative: PathBuf::from("managed-target"), - before: Some(b"before\n".to_vec()), + before: None, after: Some(b"tree-ring\n".to_vec()), }; let replacement = target_path.clone(); - let outside_path = outside.path().to_path_buf(); - after_target_snapshot_for_test(move || { - fs::remove_file(&replacement).unwrap(); + let outside_path = outside_marker.clone(); + after_final_target_validation_for_test(move || { symlink(&outside_path, &replacement).unwrap(); }); let error = commit_prepared_file(&project_fs, &prepared).unwrap_err(); - assert!(error.message.contains("changed") || error.message.contains("regular file")); + assert!(error.message.contains("File exists")); assert!(fs::symlink_metadata(&target_path) .unwrap() .file_type() @@ -2858,46 +2897,33 @@ mod tests { assert_eq!(read(outside_marker), "outside\n"); } - #[cfg(unix)] #[test] - fn final_entry_replacement_after_snapshot_is_never_removed() { - use std::os::unix::fs::symlink; - + fn existing_entry_replacement_and_removal_are_refused_before_publication() { let (_temp, project, _manifest) = fixture(); let target_path = project.project_root.join("managed-target"); write(&target_path, "before\n"); - let outside = tempfile::tempdir().unwrap(); - let outside_marker = outside.path().join("must-not-change"); - write(&outside_marker, "outside\n"); let project_fs = ProjectFs::open(&project).unwrap(); - let prepared = PreparedFile { - relative: PathBuf::from("managed-target"), - before: Some(b"before\n".to_vec()), - after: None, - }; - let replacement = target_path.clone(); - let outside_path = outside.path().to_path_buf(); - after_target_snapshot_for_test(move || { - fs::remove_file(&replacement).unwrap(); - symlink(&outside_path, &replacement).unwrap(); - }); - - let error = commit_prepared_file(&project_fs, &prepared).unwrap_err(); - - assert!(error.message.contains("changed") || error.message.contains("regular file")); - assert!(fs::symlink_metadata(&target_path) - .unwrap() - .file_type() - .is_symlink()); - assert_eq!(read(outside_marker), "outside\n"); + for after in [Some(b"tree-ring\n".to_vec()), None] { + let error = commit_prepared_file( + &project_fs, + &PreparedFile { + relative: PathBuf::from("managed-target"), + before: Some(b"before\n".to_vec()), + after, + }, + ) + .unwrap_err(); + assert!(error.message.contains("will not replace or remove")); + assert_eq!(read(&target_path), "before\n"); + } } #[cfg(unix)] #[test] - fn root_swap_after_open_is_rejected_without_following_the_replacement() { + fn root_swap_after_final_binding_aborts_before_create_publication() { use std::os::unix::fs::symlink; - let (_temp, project, mut manifest) = fixture(); + let (_temp, project, _manifest) = fixture(); let root = project.project_root.clone(); let parked = root .parent() @@ -2906,133 +2932,110 @@ mod tests { let parked_for_hook = parked.clone(); let outside = tempfile::tempdir().unwrap(); let outside_path = outside.path().to_path_buf(); - after_project_root_opened_for_test(move || { + let project_fs = ProjectFs::open(&project).unwrap(); + let prepared = PreparedFile { + relative: PathBuf::from("root-race-target"), + before: None, + after: Some(b"tree-ring\n".to_vec()), + }; + after_final_root_binding_for_test(move || { fs::rename(&root, &parked_for_hook).unwrap(); symlink(&outside_path, &root).unwrap(); }); - let error = - apply_bridge_plan(&project, &mut manifest, plan("codex", &project), false).unwrap_err(); + let error = commit_prepared_file(&project_fs, &prepared).unwrap_err(); - assert!(error.contains("project root changed")); - assert!(!outside.path().join(".agents").exists()); - assert!(!outside.path().join("AGENTS.md").exists()); - assert!(!outside.path().join(".tree-ring/activation.json").exists()); + assert!(error.message.contains("project root changed")); + assert!(!outside.path().join("root-race-target").exists()); + assert!(!parked.join("root-race-target").exists()); fs::remove_file(&project.project_root).unwrap(); fs::remove_dir_all(parked).unwrap(); } #[test] - fn rollback_preserves_a_user_edit_after_tree_ring_publication() { + fn preservation_after_publication_never_overwrites_a_user_edit() { let (_temp, project, _manifest) = fixture(); - let target_path = project.project_root.join("managed-target"); - write(&target_path, "before\n"); + let target_path = project.project_root.join("created-target"); let project_fs = ProjectFs::open(&project).unwrap(); let prepared = PreparedFile { - relative: PathBuf::from("managed-target"), - before: Some(b"before\n".to_vec()), + relative: PathBuf::from("created-target"), + before: None, after: Some(b"tree-ring\n".to_vec()), }; let applied = commit_prepared_file(&project_fs, &prepared) .unwrap() - .expect("the file changed"); + .expect("the file was created"); write(&target_path, "user edit\n"); - rollback_files(&[applied]); + preserve_published_files(&[applied]).unwrap(); assert_eq!(read(target_path), "user edit\n"); } #[test] - fn linkat_publication_with_failed_directory_sync_is_rolled_back() { + fn bridge_sync_failure_preserves_publication_and_marks_manifest_indeterminate() { let (_temp, project, mut manifest) = fixture(); - let project_fs = ProjectFs::open(&project).unwrap(); - let original = manifest.clone(); - let file = PreparedFile { - relative: PathBuf::from("sync-link-target"), - before: None, - after: Some(b"tree-ring\n".to_vec()), - }; fail_directory_sync_at_for_test(1); - let error = commit_files_and_manifest( - &project_fs, - &mut manifest, - &original, - None, - original.clone(), - &[file], - ) - .unwrap_err(); + let error = + apply_bridge_plan(&project, &mut manifest, plan("pi", &project), false).unwrap_err(); assert!(error.contains("injected directory sync failure")); - assert!(!project.project_root.join("sync-link-target").exists()); + assert!(error.contains("indeterminate")); + assert!(project + .project_root + .join(".agents/skills/tree-ring-memory/SKILL.md") + .exists()); + assert_eq!( + manifest.harnesses["pi"].state, + ActivationState::NeedsUserReview + ); assert!(!project.memory_root.join("activation.json").exists()); - assert_eq!(manifest, original); } #[test] - fn renameat_publication_with_failed_directory_sync_is_rolled_back() { + fn bridge_sync_and_recovery_sync_failures_are_both_reported_without_resetting_manifest() { let (_temp, project, mut manifest) = fixture(); - let target_path = project.project_root.join("sync-rename-target"); - write(&target_path, "before\n"); - let project_fs = ProjectFs::open(&project).unwrap(); - let original = manifest.clone(); - let file = PreparedFile { - relative: PathBuf::from("sync-rename-target"), - before: Some(b"before\n".to_vec()), - after: Some(b"tree-ring\n".to_vec()), - }; - fail_directory_sync_at_for_test(1); + fail_directory_syncs_at_for_test(&[1, 2]); - let error = commit_files_and_manifest( - &project_fs, - &mut manifest, - &original, - None, - original.clone(), - &[file], - ) - .unwrap_err(); + let error = + apply_bridge_plan(&project, &mut manifest, plan("pi", &project), false).unwrap_err(); - assert!(error.contains("injected directory sync failure")); - assert_eq!(read(target_path), "before\n"); - assert!(!project.memory_root.join("activation.json").exists()); - assert_eq!(manifest, original); + assert_eq!(error.matches("injected directory sync failure").count(), 2); + assert!(error.contains("recovery durability check also failed")); + assert!(project + .project_root + .join(".agents/skills/tree-ring-memory/SKILL.md") + .exists()); + assert_eq!( + manifest.harnesses["pi"].state, + ActivationState::NeedsUserReview + ); } #[test] - fn unlinkat_publication_with_failed_directory_sync_is_rolled_back() { + fn manifest_sync_and_recovery_sync_failures_preserve_published_manifest_and_mark_memory_review() + { let (_temp, project, mut manifest) = fixture(); - let target_path = project.project_root.join("sync-unlink-target"); - write(&target_path, "before\n"); - let project_fs = ProjectFs::open(&project).unwrap(); - let original = manifest.clone(); - let file = PreparedFile { - relative: PathBuf::from("sync-unlink-target"), - before: Some(b"before\n".to_vec()), - after: None, - }; - fail_directory_sync_at_for_test(1); + // Pi publishes two bridge entries before the activation manifest. + fail_directory_syncs_at_for_test(&[3, 4]); - let error = commit_files_and_manifest( - &project_fs, - &mut manifest, - &original, - None, - original.clone(), - &[file], - ) - .unwrap_err(); + let error = + apply_bridge_plan(&project, &mut manifest, plan("pi", &project), false).unwrap_err(); - assert!(error.contains("injected directory sync failure")); - assert_eq!(read(target_path), "before\n"); - assert!(!project.memory_root.join("activation.json").exists()); - assert_eq!(manifest, original); + assert_eq!(error.matches("injected directory sync failure").count(), 2); + assert!(error.contains("recovery durability check also failed")); + assert!(project.memory_root.join("activation.json").exists()); + assert_eq!( + manifest.harnesses["pi"].state, + ActivationState::NeedsUserReview + ); + let on_disk = crate::activation::load_manifest(&project.memory_root).unwrap(); + assert_eq!(on_disk.harnesses["pi"].state, ActivationState::NeedsTrust); } #[test] - fn late_manifest_sync_failure_restores_published_manifest_and_bridge_file() { + fn existing_manifest_replacement_is_refused_without_changing_disk_or_memory() { let (_temp, project, mut manifest) = fixture(); let original = manifest.clone(); let original_bytes = serde_json::to_vec_pretty(&original).unwrap(); @@ -3040,12 +3043,6 @@ mod tests { let project_fs = ProjectFs::open(&project).unwrap(); let mut next = original.clone(); next.cli_version = "0.14.0-test".to_string(); - let file = PreparedFile { - relative: PathBuf::from("sync-manifest-target"), - before: None, - after: Some(b"tree-ring\n".to_vec()), - }; - fail_directory_sync_at_for_test(2); let error = commit_files_and_manifest( &project_fs, @@ -3053,12 +3050,11 @@ mod tests { &original, Some(&original), next, - &[file], + &[], ) .unwrap_err(); - assert!(error.contains("injected directory sync failure")); - assert!(!project.project_root.join("sync-manifest-target").exists()); + assert!(error.contains("cannot be safely replaced")); assert_eq!( fs::read(project.memory_root.join("activation.json")).unwrap(), original_bytes From 091e6c3ed8a75586df8bd7b92dc210ffb5e026c9 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 00:29:32 -0400 Subject: [PATCH 11/31] fix: harden preflight receipt commits --- .../src/activation/bridge.rs | 144 ++++- .../src/activation/manifest.rs | 317 ++++++++-- .../src/activation/preflight.rs | 594 +++++++++++++++--- 3 files changed, 889 insertions(+), 166 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/activation/bridge.rs b/crates/tree-ring-memory-cli/src/activation/bridge.rs index 602bb47..2540b9b 100644 --- a/crates/tree-ring-memory-cli/src/activation/bridge.rs +++ b/crates/tree-ring-memory-cli/src/activation/bridge.rs @@ -9,7 +9,7 @@ use super::{ use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; use std::{ - ffi::{CStr, CString, OsStr}, + ffi::{CStr, CString, OsStr, OsString}, fs::{self, File}, io::{Read, Write}, path::{Component, Path, PathBuf}, @@ -18,8 +18,8 @@ use uuid::Uuid; #[cfg(unix)] use std::os::{ - fd::{AsRawFd, FromRawFd}, - unix::ffi::OsStrExt, + fd::{AsRawFd, FromRawFd, IntoRawFd}, + unix::ffi::{OsStrExt, OsStringExt}, }; const CODEX_RETRY: &str = "tree-ring integrations activate --harness codex --accept-managed-block"; @@ -142,13 +142,13 @@ struct AppliedFile { #[cfg(unix)] #[derive(Debug)] -struct ProjectFs { +pub(crate) struct ProjectFs { root: File, } #[cfg(unix)] #[derive(Debug)] -struct ResolvedTarget { +pub(crate) struct ResolvedTarget { parent: File, name: CString, display: PathBuf, @@ -156,7 +156,7 @@ struct ResolvedTarget { #[cfg(unix)] #[derive(Debug)] -struct ManifestLock { +pub(crate) struct ManifestLock { file: File, } @@ -172,7 +172,7 @@ impl Drop for ManifestLock { #[cfg(unix)] impl ProjectFs { - fn open(project: &ActivationProject) -> Result { + pub(crate) fn open(project: &ActivationProject) -> Result { validate_project_shape(project)?; let metadata = fs::symlink_metadata(&project.project_root) .map_err(|error| io_error(&project.project_root, error))?; @@ -185,7 +185,7 @@ impl ProjectFs { Ok(Self { root }) } - fn lock_manifest(&self) -> Result { + pub(crate) fn lock_manifest(&self) -> Result { let file = self .root .try_clone() @@ -201,14 +201,14 @@ impl ProjectFs { Ok(ManifestLock { file }) } - fn read_optional(&self, relative: &Path) -> Result>, String> { + pub(crate) fn read_optional(&self, relative: &Path) -> Result>, String> { let Some(target) = self.resolve_target_optional(relative, false)? else { return Ok(None); }; target.read_optional() } - fn resolve_target( + pub(crate) fn resolve_target( &self, relative: &Path, create_parents: bool, @@ -259,11 +259,60 @@ impl ProjectFs { } Err("bridge path must include a file name".to_string()) } + + /// Lists direct children through a retained directory descriptor. Every + /// component is opened with `O_NOFOLLOW`; a missing directory is distinct + /// from a symlink or non-directory failure. + pub(crate) fn directory_entries( + &self, + relative: &Path, + ) -> Result>, String> { + let Some(directory) = self.resolve_directory_optional(relative)? else { + return Ok(None); + }; + list_directory_entries(&directory).map(Some) + } + + fn resolve_directory_optional(&self, relative: &Path) -> Result, String> { + validate_relative_path_buf(relative)?; + let mut directory = self + .root + .try_clone() + .map_err(|error| io_error(relative, error))?; + for component in relative.components() { + let Component::Normal(segment) = component else { + return Err("bridge path must be normalized and project-relative".to_string()); + }; + match open_child_directory(&directory, segment) { + Ok(next) => directory = next, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "bridge path parent is a symlink or non-directory at {}: {error}", + relative.display() + )); + } + } + } + Ok(Some(directory)) + } + + /// Deletes only a file that was re-opened as a regular no-follow target. + pub(crate) fn remove_validated_regular_file(&self, relative: &Path) -> Result { + let Some(target) = self.resolve_target_optional(relative, false)? else { + return Ok(false); + }; + if target.read_optional()?.is_none() { + return Ok(false); + } + target.remove_file()?; + Ok(true) + } } #[cfg(unix)] impl ResolvedTarget { - fn read_optional(&self) -> Result>, String> { + pub(crate) fn read_optional(&self) -> Result>, String> { let descriptor = unsafe { // SAFETY: the parent descriptor and single-component name are valid for this call. libc::openat( @@ -296,7 +345,7 @@ impl ResolvedTarget { Ok(Some(bytes)) } - fn atomic_write(&self, bytes: &[u8], create_only: bool) -> Result<(), String> { + pub(crate) fn atomic_write(&self, bytes: &[u8], create_only: bool) -> Result<(), String> { let temp_name = CString::new(format!( ".{}.{}.tmp", self.name.to_string_lossy(), @@ -359,6 +408,48 @@ impl ResolvedTarget { } } +#[cfg(unix)] +fn list_directory_entries(directory: &File) -> Result, String> { + let cloned = directory + .try_clone() + .map_err(|error| io_error(Path::new("."), error))?; + let stream = unsafe { + // SAFETY: fdopendir assumes ownership of the duplicated descriptor. + libc::fdopendir(cloned.into_raw_fd()) + }; + if stream.is_null() { + return Err(io_error(Path::new("."), std::io::Error::last_os_error())); + } + let mut entries = Vec::new(); + loop { + let entry = unsafe { + // SAFETY: stream remains valid until the matching closedir below. + libc::readdir(stream) + }; + if entry.is_null() { + break; + } + let name = unsafe { + // SAFETY: d_name is NUL-terminated for a successful readdir entry. + CStr::from_ptr((*entry).d_name.as_ptr()) + }; + let bytes = name.to_bytes(); + if bytes == b"." || bytes == b".." { + continue; + } + entries.push(OsString::from_vec(bytes.to_vec())); + } + let close_status = unsafe { + // SAFETY: closes the stream and its owned duplicated descriptor exactly once. + libc::closedir(stream) + }; + if close_status != 0 { + return Err(io_error(Path::new("."), std::io::Error::last_os_error())); + } + entries.sort(); + Ok(entries) +} + #[cfg(unix)] fn open_directory_path_no_follow(path: &Path) -> Result { if path.as_os_str().is_empty() { @@ -485,15 +576,15 @@ fn sync_directory(directory: &File, display: &Path) -> Result<(), String> { #[cfg(not(unix))] #[derive(Debug)] -struct ProjectFs; +pub(crate) struct ProjectFs; #[cfg(not(unix))] #[derive(Debug)] -struct ResolvedTarget; +pub(crate) struct ResolvedTarget; #[cfg(not(unix))] #[derive(Debug)] -struct ManifestLock; +pub(crate) struct ManifestLock; #[cfg(not(unix))] #[derive(Debug)] @@ -504,34 +595,45 @@ struct AppliedFile { #[cfg(not(unix))] impl ProjectFs { - fn open(_project: &ActivationProject) -> Result { + pub(crate) fn open(_project: &ActivationProject) -> Result { Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) } - fn lock_manifest(&self) -> Result { + pub(crate) fn lock_manifest(&self) -> Result { Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) } - fn read_optional(&self, _relative: &Path) -> Result>, String> { + pub(crate) fn read_optional(&self, _relative: &Path) -> Result>, String> { Err("bridge access requires descriptor-relative no-follow filesystem support".to_string()) } - fn resolve_target( + pub(crate) fn resolve_target( &self, _relative: &Path, _create_parents: bool, ) -> Result { Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) } + + pub(crate) fn directory_entries( + &self, + _relative: &Path, + ) -> Result>, String> { + Err("bridge access requires descriptor-relative no-follow filesystem support".to_string()) + } + + pub(crate) fn remove_validated_regular_file(&self, _relative: &Path) -> Result { + Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) + } } #[cfg(not(unix))] impl ResolvedTarget { - fn read_optional(&self) -> Result>, String> { + pub(crate) fn read_optional(&self) -> Result>, String> { Err("bridge access requires descriptor-relative no-follow filesystem support".to_string()) } - fn atomic_write(&self, _bytes: &[u8], _create_only: bool) -> Result<(), String> { + pub(crate) fn atomic_write(&self, _bytes: &[u8], _create_only: bool) -> Result<(), String> { Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) } diff --git a/crates/tree-ring-memory-cli/src/activation/manifest.rs b/crates/tree-ring-memory-cli/src/activation/manifest.rs index 3b10016..8e82256 100644 --- a/crates/tree-ring-memory-cli/src/activation/manifest.rs +++ b/crates/tree-ring-memory-cli/src/activation/manifest.rs @@ -1,6 +1,7 @@ use super::{ - ActivationState, AdapterCapability, SessionIdentity, ACTIVATION_PROTOCOL_VERSION, - ACTIVATION_SCHEMA_VERSION, RECEIPT_RETENTION_DAYS, RECEIPT_RETENTION_PER_WORKER, + adapters::ActivationProject, bridge::ProjectFs, ActivationState, AdapterCapability, + SessionIdentity, ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, + RECEIPT_RETENTION_DAYS, RECEIPT_RETENTION_PER_WORKER, }; use chrono::{DateTime, Duration, Utc}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -146,16 +147,14 @@ pub fn save_manifest(memory_root: &Path, manifest: &ActivationManifest) -> Resul /// Writes a receipt beneath the store's activation receipt directory. pub fn write_receipt(memory_root: &Path, receipt: &ActivationReceipt) -> Result { - validate_memory_root(memory_root)?; validate_receipt(receipt)?; - let path = receipt_path( - memory_root, - &receipt.harness_id, - &receipt.worker_key_fingerprint, - ) - .join(format!("{}.json", receipt.receipt_id)); - atomic_write_json(&path, receipt, AtomicWriteMode::Replace)?; - Ok(path) + let project_fs = open_receipt_store(memory_root)?; + let _manifest_lock = project_fs.lock_manifest()?; + let relative = write_receipt_locked(&project_fs, receipt)?; + let suffix = relative + .strip_prefix(".tree-ring") + .expect("receipt relative paths are rooted at .tree-ring"); + Ok(memory_root.join(suffix)) } /// Removes expired receipts and keeps at most 100 receipts for a harness/worker pair. @@ -165,45 +164,67 @@ pub fn prune_receipts( worker_key: &str, now: DateTime, ) -> Result { - validate_memory_root(memory_root)?; validate_identifier("harness id", harness_id)?; validate_identifier("worker key", worker_key)?; - let directory = receipt_path(memory_root, harness_id, &fingerprint(worker_key)); - if !directory.exists() { + let project_fs = open_receipt_store(memory_root)?; + let _manifest_lock = project_fs.lock_manifest()?; + prune_receipts_locked(&project_fs, harness_id, worker_key, now) +} + +/// Removes expired receipts while the caller holds the activation-contract +/// lock. All receipt traversal remains descriptor-relative to the verified +/// project root, so a replaced directory cannot redirect deletion. +pub(crate) fn prune_receipts_locked( + project_fs: &ProjectFs, + harness_id: &str, + worker_key: &str, + now: DateTime, +) -> Result { + validate_identifier("harness id", harness_id)?; + validate_identifier("worker key", worker_key)?; + let directory = receipt_relative_directory(harness_id, &fingerprint(worker_key)); + let Some(entries) = project_fs.directory_entries(&directory)? else { return Ok(0); - } + }; let expiry = now - Duration::days(RECEIPT_RETENTION_DAYS); let mut retained = Vec::new(); let mut removed = 0; - for entry in fs::read_dir(&directory).map_err(|err| io_error(&directory, err))? { - let entry = entry.map_err(|err| io_error(&directory, err))?; - let path = entry.path(); - if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + for name in entries { + let path = directory.join(&name); + if Path::new(&name) + .extension() + .and_then(|extension| extension.to_str()) + != Some("json") + { continue; } - let receipt: ActivationReceipt = match read_json(&path).and_then(|receipt| { - validate_receipt(&receipt)?; - Ok(receipt) - }) { - Ok(receipt) => receipt, - Err(_) => { - fs::remove_file(&path).map_err(|err| io_error(&path, err))?; + let receipt: ActivationReceipt = + match read_receipt_locked(project_fs, &path).and_then(|receipt| { + validate_receipt(&receipt)?; + Ok(receipt) + }) { + Ok(receipt) => receipt, + Err(_) => { + if project_fs.remove_validated_regular_file(&path)? { + removed += 1; + } + continue; + } + }; + if receipt.recorded_at < expiry { + if project_fs.remove_validated_regular_file(&path)? { removed += 1; - continue; } - }; - if receipt.recorded_at < expiry { - fs::remove_file(&path).map_err(|err| io_error(&path, err))?; - removed += 1; } else { retained.push((receipt.recorded_at, path)); } } retained.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1))); for (_, path) in retained.into_iter().skip(RECEIPT_RETENTION_PER_WORKER) { - fs::remove_file(&path).map_err(|err| io_error(&path, err))?; - removed += 1; + if project_fs.remove_validated_regular_file(&path)? { + removed += 1; + } } Ok(removed) } @@ -217,26 +238,55 @@ pub fn invalidate_receipts_for_adapter( project_root_fingerprint: &str, store_id: &str, ) -> Result { - validate_memory_root(memory_root)?; validate_identifier("harness id", harness_id)?; - let directory = memory_root.join(RECEIPTS_DIRECTORY).join(harness_id); - if !directory.exists() { - return Ok(0); + let project_fs = open_receipt_store(memory_root)?; + let _manifest_lock = project_fs.lock_manifest()?; + invalidate_receipts_for_adapter_locked( + &project_fs, + harness_id, + adapter_version, + bridge_fingerprint, + project_root_fingerprint, + store_id, + ) +} + +/// Removes receipt evidence that does not match the exact adapter contract. +/// This is intentionally called under the same lock used by bridge mutation. +pub(crate) fn invalidate_receipts_for_adapter_locked( + project_fs: &ProjectFs, + harness_id: &str, + adapter_version: &str, + bridge_fingerprint: &str, + project_root_fingerprint: &str, + store_id: &str, +) -> Result { + validate_identifier("harness id", harness_id)?; + validate_identifier("adapter version", adapter_version)?; + if !is_sha256(bridge_fingerprint) || !is_sha256(project_root_fingerprint) { + return Err("receipt contract fingerprint is invalid".to_string()); } + validate_identifier("store id", store_id)?; + let directory = receipt_harness_directory(harness_id); + let Some(workers) = project_fs.directory_entries(&directory)? else { + return Ok(0); + }; let mut removed = 0; - for worker in fs::read_dir(&directory).map_err(|err| io_error(&directory, err))? { - let worker = worker.map_err(|err| io_error(&directory, err))?; - let worker_path = worker.path(); - if !worker_path.is_dir() { + for worker in workers { + let worker_directory = directory.join(&worker); + let Some(entries) = project_fs.directory_entries(&worker_directory)? else { continue; - } - for entry in fs::read_dir(&worker_path).map_err(|err| io_error(&worker_path, err))? { - let entry = entry.map_err(|err| io_error(&worker_path, err))?; - let path = entry.path(); - if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + }; + for name in entries { + let path = worker_directory.join(&name); + if Path::new(&name) + .extension() + .and_then(|extension| extension.to_str()) + != Some("json") + { continue; } - let current = read_json::(&path) + let current = read_receipt_locked(project_fs, &path) .and_then(|receipt| { validate_receipt(&receipt)?; Ok(receipt) @@ -247,8 +297,7 @@ pub fn invalidate_receipts_for_adapter( && receipt.project_root_fingerprint == project_root_fingerprint && receipt.store_id == store_id }); - if !current { - fs::remove_file(&path).map_err(|err| io_error(&path, err))?; + if !current && project_fs.remove_validated_regular_file(&path)? { removed += 1; } } @@ -256,6 +305,116 @@ pub fn invalidate_receipts_for_adapter( Ok(removed) } +/// Removes every receipt for a harness through the same no-follow traversal. +/// A manifest whose adapter record is itself stale cannot leave an otherwise +/// matching historical receipt as activation proof. +pub(crate) fn invalidate_all_receipts_for_adapter_locked( + project_fs: &ProjectFs, + harness_id: &str, +) -> Result { + validate_identifier("harness id", harness_id)?; + let directory = receipt_harness_directory(harness_id); + let Some(workers) = project_fs.directory_entries(&directory)? else { + return Ok(0); + }; + let mut removed = 0; + for worker in workers { + let worker_directory = directory.join(&worker); + let Some(entries) = project_fs.directory_entries(&worker_directory)? else { + continue; + }; + for name in entries { + if Path::new(&name) + .extension() + .and_then(|extension| extension.to_str()) + != Some("json") + { + continue; + } + if project_fs.remove_validated_regular_file(&worker_directory.join(name))? { + removed += 1; + } + } + } + Ok(removed) +} + +/// Reads a persisted manifest through a descriptor-relative no-follow path. +/// Callers that need an activation decision should hold `ProjectFs::lock_manifest`. +pub(crate) fn load_manifest_locked(project_fs: &ProjectFs) -> Result { + let bytes = project_fs + .read_optional(Path::new(".tree-ring/activation.json"))? + .ok_or_else(|| "activation manifest is unavailable".to_string())?; + let manifest: ActivationManifest = serde_json::from_slice(&bytes) + .map_err(|error| format!("invalid activation JSON: {error}"))?; + validate_manifest(&manifest)?; + Ok(manifest) +} + +/// Writes a validated receipt using an O_NOFOLLOW, descriptor-relative target. +/// The caller must hold the activation-contract lock. +pub(crate) fn write_receipt_locked( + project_fs: &ProjectFs, + receipt: &ActivationReceipt, +) -> Result { + validate_receipt(receipt)?; + let relative = receipt_relative_path(receipt)?; + let bytes = serde_json::to_vec_pretty(receipt) + .map_err(|error| format!("failed to serialize activation receipt: {error}"))?; + let target = project_fs.resolve_target(&relative, true)?; + if target.read_optional()?.is_some() { + return Err("activation receipt already exists".to_string()); + } + target.atomic_write(&bytes, true)?; + Ok(relative) +} + +fn read_receipt_locked( + project_fs: &ProjectFs, + relative: &Path, +) -> Result { + let bytes = project_fs + .read_optional(relative)? + .ok_or_else(|| "activation receipt disappeared".to_string())?; + serde_json::from_slice(&bytes) + .map_err(|error| format!("invalid activation receipt JSON: {error}")) +} + +fn open_receipt_store(memory_root: &Path) -> Result { + validate_memory_root(memory_root)?; + let project = ActivationProject::from_memory_root(memory_root.to_path_buf())?; + let project_fs = ProjectFs::open(&project)?; + if project_fs + .directory_entries(Path::new(".tree-ring"))? + .is_none() + { + return Err("activation metadata root is unavailable".to_string()); + } + Ok(project_fs) +} + +fn receipt_harness_directory(harness_id: &str) -> PathBuf { + PathBuf::from(".tree-ring") + .join(RECEIPTS_DIRECTORY) + .join(harness_id) +} + +fn receipt_relative_directory(harness_id: &str, worker_fingerprint: &str) -> PathBuf { + receipt_harness_directory(harness_id).join(worker_fingerprint) +} + +fn receipt_relative_path(receipt: &ActivationReceipt) -> Result { + validate_identifier("harness id", &receipt.harness_id)?; + validate_identifier("receipt id", &receipt.receipt_id)?; + if !is_sha256(&receipt.worker_key_fingerprint) { + return Err("receipt worker key fingerprint is invalid".to_string()); + } + Ok( + receipt_relative_directory(&receipt.harness_id, &receipt.worker_key_fingerprint) + .join(format!("{}.json", receipt.receipt_id)), + ) +} + /// Stable digest of only the project-relative, adapter-owned bridge contract. pub fn bridge_fingerprint(harness_id: &str, activation: &HarnessActivation) -> String { let mut hasher = Sha256::new(); @@ -321,6 +480,7 @@ fn manifest_path(memory_root: &Path) -> PathBuf { memory_root.join(ACTIVATION_MANIFEST_FILE) } +#[cfg(test)] fn receipt_path(memory_root: &Path, harness_id: &str, worker_fingerprint: &str) -> PathBuf { memory_root .join(RECEIPTS_DIRECTORY) @@ -643,6 +803,8 @@ fn io_error(path: &Path, error: std::io::Error) -> String { #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] + use std::os::unix::fs::symlink; use std::{ fs, sync::{Arc, Barrier}, @@ -840,6 +1002,7 @@ mod tests { fn receipt_invalidation_removes_only_stale_adapter_contracts() { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project/.tree-ring"); + fs::create_dir_all(&root).unwrap(); let receipt = fixture_receipt(Utc::now()); write_receipt(&root, &receipt).unwrap(); assert_eq!(receipt_files(&root).len(), 1); @@ -871,10 +1034,66 @@ mod tests { assert!(receipt_files(&root).is_empty()); } + #[cfg(unix)] + #[test] + fn receipt_operations_reject_symlinked_directory_components_without_escape() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project/.tree-ring"); + fs::create_dir_all(&root).unwrap(); + let outside = temp.path().join("outside"); + fs::create_dir_all(&outside).unwrap(); + let sentinel = outside.join("sentinel.json"); + fs::write(&sentinel, b"outside sentinel").unwrap(); + symlink(&outside, root.join("activation")).unwrap(); + let receipt = fixture_receipt(Utc::now()); + + assert!(write_receipt(&root, &receipt).is_err()); + assert!(prune_receipts(&root, "codex", "worker-1", Utc::now()).is_err()); + assert!(invalidate_receipts_for_adapter( + &root, + "codex", + &receipt.adapter_version, + &receipt.bridge_fingerprint, + &receipt.project_root_fingerprint, + &receipt.store_id, + ) + .is_err()); + assert_eq!(fs::read(&sentinel).unwrap(), b"outside sentinel"); + assert!(!outside.join("receipts").exists()); + } + + #[cfg(unix)] + #[test] + fn receipt_file_symlink_is_never_read_or_deleted() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project/.tree-ring"); + fs::create_dir_all(&root).unwrap(); + let receipt = fixture_receipt(Utc::now()); + let directory = receipt_path(&root, "codex", &fingerprint("worker-1")); + fs::create_dir_all(&directory).unwrap(); + let sentinel = temp.path().join("outside-receipt.json"); + fs::write(&sentinel, b"outside receipt").unwrap(); + symlink(&sentinel, directory.join("symlink.json")).unwrap(); + + assert!(prune_receipts(&root, "codex", "worker-1", Utc::now()).is_err()); + assert!(invalidate_receipts_for_adapter( + &root, + "codex", + &receipt.adapter_version, + &receipt.bridge_fingerprint, + &receipt.project_root_fingerprint, + &receipt.store_id, + ) + .is_err()); + assert_eq!(fs::read(&sentinel).unwrap(), b"outside receipt"); + assert!(directory.join("symlink.json").exists()); + } + #[test] fn prune_receipts_removes_malformed_and_expired_records_and_keeps_the_latest_hundred() { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project/.tree-ring"); + fs::create_dir_all(&root).unwrap(); let now = Utc::now(); let mut expired = fixture_receipt(now - Duration::days(RECEIPT_RETENTION_DAYS + 1)); expired.receipt_id = "expired".to_string(); diff --git a/crates/tree-ring-memory-cli/src/activation/preflight.rs b/crates/tree-ring-memory-cli/src/activation/preflight.rs index d795a5f..0cfe8f5 100644 --- a/crates/tree-ring-memory-cli/src/activation/preflight.rs +++ b/crates/tree-ring-memory-cli/src/activation/preflight.rs @@ -1,16 +1,18 @@ use super::{ adapters::{adapter_version, ActivationProject}, + bridge::ProjectFs, manifest::{ - bridge_fingerprint, fingerprint, fingerprint_path, invalidate_receipts_for_adapter, - load_manifest, prune_receipts, validate_manifest, write_receipt, ActivationManifest, - ActivationReceipt, + bridge_fingerprint, fingerprint, fingerprint_path, + invalidate_all_receipts_for_adapter_locked, invalidate_receipts_for_adapter_locked, + load_manifest_locked, prune_receipts_locked, validate_manifest, write_receipt_locked, + ActivationManifest, ActivationReceipt, }, ActivationState, SessionIdentity, ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::{fmt, path::Path, time::Instant}; +use std::{ffi::OsStr, fmt, path::Path, time::Instant}; use tree_ring_memory_core::SensitivityGuard; use tree_ring_memory_sqlite::{MemoryRetriever, RecallOptions, RecallResult, SQLiteMemoryStore}; use uuid::Uuid; @@ -19,6 +21,8 @@ const FALLBACK_QUERY: &str = "project startup constraints"; const MAX_RESULTS: usize = 8; const MAX_CONTEXT_BYTES: usize = 32 * 1024; const PREFLIGHT_TIMEOUT_MS: u64 = 10_000; +const STORAGE_ERROR: &str = "activation preflight storage unavailable"; +const CONTRACT_ERROR: &str = "invalid preflight harness contract"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -34,6 +38,47 @@ pub struct PreflightRequest { pub identity: SessionIdentity, pub task_hint: Option, pub context_format: PreflightContextFormat, + input_contract: PreflightInputContract, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PreflightInputContract { + DirectIdentityFlags, + AdapterStdin, +} + +impl PreflightRequest { + /// Constructs the direct identity request supported by the Codex wrapper. + /// Adapter-owned hooks must go through `parse_adapter_stdin` instead. + pub fn direct( + harness_id: impl Into, + identity: SessionIdentity, + task_hint: Option, + context_format: PreflightContextFormat, + ) -> Self { + Self { + harness_id: harness_id.into(), + identity, + task_hint, + context_format, + input_contract: PreflightInputContract::DirectIdentityFlags, + } + } + + fn adapter_stdin( + harness_id: impl Into, + identity: SessionIdentity, + task_hint: Option, + context_format: PreflightContextFormat, + ) -> Self { + Self { + harness_id: harness_id.into(), + identity, + task_hint, + context_format, + input_contract: PreflightInputContract::AdapterStdin, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -79,12 +124,6 @@ impl fmt::Display for ActivationError { impl std::error::Error for ActivationError {} -impl From for ActivationError { - fn from(error: String) -> Self { - Self(error) - } -} - /// Runs bounded, identity-scoped recall and records proof only after output renders. pub fn run_preflight( store: &SQLiteMemoryStore, @@ -92,57 +131,24 @@ pub fn run_preflight( manifest: &ActivationManifest, request: PreflightRequest, ) -> Result { - let started = Instant::now(); - validate_manifest(manifest).map_err(ActivationError::from)?; - ensure_store_path_matches(store, project)?; - ensure_persisted_manifest_matches(project, manifest)?; + validate_request_contract(&request)?; validate_identity(&request.identity)?; + validate_manifest(manifest) + .map_err(|_| ActivationError::new("activation manifest is invalid"))?; + ensure_store_path_matches(store, project)?; - let activation = manifest - .harnesses - .get(&request.harness_id) - .ok_or_else(|| ActivationError::new("harness has no activation record"))?; - let current_version = adapter_version(&request.harness_id) - .ok_or_else(|| ActivationError::new("unknown harness adapter"))?; - let current_bridge_fingerprint = bridge_fingerprint(&request.harness_id, activation); - invalidate_receipts_for_adapter( - &project.memory_root, - &request.harness_id, - &activation.adapter_version, - ¤t_bridge_fingerprint, - &manifest.project_root_fingerprint, - &manifest.store_id, - ) - .map_err(ActivationError::from)?; - if activation.adapter_version != current_version { - return Err(ActivationError::new("stale adapter version")); - } - if activation.bridge_fingerprint != current_bridge_fingerprint { - return Err(ActivationError::new("stale bridge fingerprint")); - } - if !matches!( - activation.state, - ActivationState::ConfiguredAwaitingProof - | ActivationState::NeedsTrust - | ActivationState::Active - | ActivationState::ActiveIsolated - ) { - return Err(ActivationError::new( - "harness is not eligible for preflight", - )); - } + let receipt_project = + ActivationProject::from_memory_root(project.memory_root.clone()).map_err(storage_error)?; + let project_fs = ProjectFs::open(&receipt_project).map_err(storage_error)?; + let snapshot = prepare_preflight_contract(&project_fs, project, manifest, &request)?; - let state = preflight_state(project, manifest)?; + let started = Instant::now(); let (query, query_class) = safe_query(request.task_hint.as_deref()); - let project_name = configured_identity_root(project)? - .file_name() - .and_then(|name| name.to_str()) - .filter(|name| !name.is_empty()); let results = MemoryRetriever::new(store) .recall_with_options( query, &RecallOptions { - project: project_name, + project: Some(&snapshot.project_name), agent_profile: Some(&request.identity.agent_profile), workflow_id: Some(&request.identity.workflow_id), session_id: Some(&request.identity.session_id), @@ -168,14 +174,14 @@ pub fn run_preflight( schema_version: ACTIVATION_SCHEMA_VERSION, protocol_version: ACTIVATION_PROTOCOL_VERSION, receipt_id: format!("receipt-{}", Uuid::new_v4()), - harness_id: request.harness_id, - adapter_version: activation.adapter_version.clone(), - bridge_fingerprint: activation.bridge_fingerprint.clone(), - store_id: manifest.store_id.clone(), - project_root_fingerprint: manifest.project_root_fingerprint.clone(), + harness_id: request.harness_id.clone(), + adapter_version: snapshot.contract.adapter_version.clone(), + bridge_fingerprint: snapshot.contract.bridge_fingerprint.clone(), + store_id: snapshot.manifest.store_id.clone(), + project_root_fingerprint: snapshot.manifest.project_root_fingerprint.clone(), worker_key_fingerprint: fingerprint(&request.identity.agent_profile), - session: request.identity, - state, + session: request.identity.clone(), + state: snapshot.state, query_class: query_class.to_string(), result_count: safe_results.len(), selected_memory_ids_sha256, @@ -184,22 +190,192 @@ pub fn run_preflight( recorded_at: Utc::now(), }; let response = PreflightResponse { - state, + state: snapshot.state, context, receipt: receipt_summary(&receipt), }; // Force construction of the complete adapter payload before any receipt exists. render_for_format(&response, request.context_format)?; - prune_receipts( - &project.memory_root, + run_pre_commit_hook(&request); + commit_receipt( + &project_fs, + store, + project, + manifest, + &snapshot, + &request, + &receipt, + )?; + Ok(response) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CurrentActivationContract { + adapter_version: String, + bridge_fingerprint: String, + manifest_matches_registry: bool, + eligible_for_preflight: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PreflightSnapshot { + manifest: ActivationManifest, + contract: CurrentActivationContract, + state: ActivationState, + project_name: String, +} + +fn prepare_preflight_contract( + project_fs: &ProjectFs, + project: &ActivationProject, + supplied: &ActivationManifest, + request: &PreflightRequest, +) -> Result { + let _manifest_lock = project_fs.lock_manifest().map_err(storage_error)?; + let persisted = load_manifest_locked(project_fs).map_err(storage_error)?; + let contract = current_activation_contract(&persisted, &request.harness_id)?; + invalidate_current_receipts(project_fs, &persisted, &request.harness_id, &contract)?; + ensure_current_and_eligible(project_fs, &request.harness_id, &contract)?; + let state = match preflight_state(project, &persisted) { + Ok(state) => state, + Err(error) => { + invalidate_all_receipts_for_adapter_locked(project_fs, &request.harness_id) + .map_err(storage_error)?; + return Err(error); + } + }; + let project_name = match project_scope_name(project) { + Ok(name) => name, + Err(error) => { + invalidate_all_receipts_for_adapter_locked(project_fs, &request.harness_id) + .map_err(storage_error)?; + return Err(error); + } + }; + ensure_persisted_manifest_matches(&persisted, supplied)?; + Ok(PreflightSnapshot { + manifest: persisted, + contract, + state, + project_name, + }) +} + +fn commit_receipt( + project_fs: &ProjectFs, + store: &SQLiteMemoryStore, + project: &ActivationProject, + supplied: &ActivationManifest, + snapshot: &PreflightSnapshot, + request: &PreflightRequest, + receipt: &ActivationReceipt, +) -> Result<(), ActivationError> { + let _manifest_lock = project_fs.lock_manifest().map_err(storage_error)?; + let persisted = load_manifest_locked(project_fs).map_err(storage_error)?; + ensure_store_path_matches(store, project)?; + let contract = current_activation_contract(&persisted, &request.harness_id)?; + invalidate_current_receipts(project_fs, &persisted, &request.harness_id, &contract)?; + ensure_current_and_eligible(project_fs, &request.harness_id, &contract)?; + let state = match preflight_state(project, &persisted) { + Ok(state) => state, + Err(error) => { + invalidate_all_receipts_for_adapter_locked(project_fs, &request.harness_id) + .map_err(storage_error)?; + return Err(error); + } + }; + if persisted != *supplied + || persisted != snapshot.manifest + || contract != snapshot.contract + || state != snapshot.state + || receipt.adapter_version != contract.adapter_version + || receipt.bridge_fingerprint != contract.bridge_fingerprint + || receipt.store_id != persisted.store_id + || receipt.project_root_fingerprint != persisted.project_root_fingerprint + { + return Err(ActivationError::new( + "activation contract changed while preparing receipt", + )); + } + prune_receipts_locked( + project_fs, &receipt.harness_id, &receipt.session.agent_profile, Utc::now(), ) - .map_err(ActivationError::from)?; - write_receipt(&project.memory_root, &receipt).map_err(ActivationError::from)?; - Ok(response) + .map_err(storage_error)?; + write_receipt_locked(project_fs, receipt).map_err(storage_error)?; + Ok(()) +} + +fn current_activation_contract( + manifest: &ActivationManifest, + harness_id: &str, +) -> Result { + let activation = manifest + .harnesses + .get(harness_id) + .ok_or_else(|| ActivationError::new("harness has no activation record"))?; + let adapter_version = adapter_version(harness_id) + .ok_or_else(|| ActivationError::new("unknown harness adapter"))? + .to_string(); + let mut registry_activation = activation.clone(); + registry_activation.adapter_version = adapter_version.clone(); + let bridge_fingerprint = bridge_fingerprint(harness_id, ®istry_activation); + Ok(CurrentActivationContract { + manifest_matches_registry: activation.adapter_version == adapter_version + && activation.bridge_fingerprint == bridge_fingerprint, + eligible_for_preflight: matches!( + activation.state, + ActivationState::ConfiguredAwaitingProof + | ActivationState::NeedsTrust + | ActivationState::Active + | ActivationState::ActiveIsolated + ), + adapter_version, + bridge_fingerprint, + }) +} + +fn invalidate_current_receipts( + project_fs: &ProjectFs, + manifest: &ActivationManifest, + harness_id: &str, + contract: &CurrentActivationContract, +) -> Result<(), ActivationError> { + invalidate_receipts_for_adapter_locked( + project_fs, + harness_id, + &contract.adapter_version, + &contract.bridge_fingerprint, + &manifest.project_root_fingerprint, + &manifest.store_id, + ) + .map_err(storage_error)?; + Ok(()) +} + +fn ensure_current_and_eligible( + project_fs: &ProjectFs, + harness_id: &str, + contract: &CurrentActivationContract, +) -> Result<(), ActivationError> { + if !contract.manifest_matches_registry { + invalidate_all_receipts_for_adapter_locked(project_fs, harness_id) + .map_err(storage_error)?; + return Err(ActivationError::new( + "stale adapter version or bridge fingerprint", + )); + } + if !contract.eligible_for_preflight { + invalidate_all_receipts_for_adapter_locked(project_fs, harness_id) + .map_err(storage_error)?; + return Err(ActivationError::new( + "harness is not eligible for preflight", + )); + } + Ok(()) } pub fn render_claude_session_start( @@ -248,10 +424,8 @@ pub fn parse_adapter_stdin( context_format: PreflightContextFormat, input: &str, ) -> Result { - if harness_id == "codex" { - return Err(ActivationError::new( - "codex preflight requires direct identity flags", - )); + if !matches_adapter_stdin_contract(harness_id, context_format) { + return Err(ActivationError::new(CONTRACT_ERROR)); } let value: serde_json::Value = serde_json::from_str(input) .map_err(|_| ActivationError::new("invalid adapter preflight stdin"))?; @@ -290,12 +464,35 @@ pub fn parse_adapter_stdin( }, _ => return Err(ActivationError::new("unknown harness adapter")), }; - Ok(PreflightRequest { - harness_id: harness_id.to_string(), + Ok(PreflightRequest::adapter_stdin( + harness_id, identity, task_hint, context_format, - }) + )) +} + +fn validate_request_contract(request: &PreflightRequest) -> Result<(), ActivationError> { + let valid = match request.input_contract { + PreflightInputContract::DirectIdentityFlags => { + request.harness_id == "codex" && request.context_format == PreflightContextFormat::Json + } + PreflightInputContract::AdapterStdin => { + matches_adapter_stdin_contract(&request.harness_id, request.context_format) + } + }; + valid + .then_some(()) + .ok_or_else(|| ActivationError::new(CONTRACT_ERROR)) +} + +fn matches_adapter_stdin_contract(harness_id: &str, format: PreflightContextFormat) -> bool { + matches!( + (harness_id, format), + ("claude-code", PreflightContextFormat::ClaudeSessionStart) + | ("pi", PreflightContextFormat::PiBeforeAgentStart) + | ("agent-zero", PreflightContextFormat::Json) + ) } fn normalize_stdin_identity(label: &str, value: &str) -> String { @@ -329,22 +526,19 @@ fn identity_component_is_safe(value: &str) -> bool { } fn ensure_persisted_manifest_matches( - project: &ActivationProject, - manifest: &ActivationManifest, + persisted: &ActivationManifest, + supplied: &ActivationManifest, ) -> Result<(), ActivationError> { - let persisted = load_manifest(&project.memory_root) - .map_err(|_| ActivationError::new("activation manifest is unavailable"))?; - if persisted.schema_version != manifest.schema_version - || persisted.protocol_version != manifest.protocol_version - || persisted.store_id != manifest.store_id - || persisted.project_root_fingerprint != manifest.project_root_fingerprint - || persisted.harnesses != manifest.harnesses - { + if persisted != supplied { return Err(ActivationError::new("activation manifest/store mismatch")); } Ok(()) } +fn storage_error(_error: String) -> ActivationError { + ActivationError::new(STORAGE_ERROR) +} + fn ensure_store_path_matches( store: &SQLiteMemoryStore, project: &ActivationProject, @@ -392,6 +586,15 @@ fn configured_identity_root(project: &ActivationProject) -> Result<&Path, Activa } } +fn project_scope_name(project: &ActivationProject) -> Result { + configured_identity_root(project)? + .file_name() + .and_then(OsStr::to_str) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .ok_or_else(|| ActivationError::new("project identity is unavailable")) +} + fn canonical_or_original(path: &Path) -> std::path::PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } @@ -490,6 +693,43 @@ fn receipt_summary(receipt: &ActivationReceipt) -> ActivationReceiptSummary { } } +#[cfg(not(test))] +fn run_pre_commit_hook(_request: &PreflightRequest) {} + +#[cfg(test)] +type PreCommitHook = Box; + +#[cfg(test)] +static PRE_COMMIT_HOOK: std::sync::Mutex> = + std::sync::Mutex::new(None); + +#[cfg(test)] +fn run_pre_commit_hook(request: &PreflightRequest) { + let hook = { + let mut guard = PRE_COMMIT_HOOK + .lock() + .expect("pre-commit hook mutex poisoned"); + if guard + .as_ref() + .is_some_and(|(session_id, _)| session_id == &request.identity.session_id) + { + guard.take().map(|(_, hook)| hook) + } else { + None + } + }; + if let Some(hook) = hook { + hook(); + } +} + +#[cfg(test)] +fn install_pre_commit_hook(session_id: impl Into, hook: impl FnOnce() + Send + 'static) { + *PRE_COMMIT_HOOK + .lock() + .expect("pre-commit hook mutex poisoned") = Some((session_id.into(), Box::new(hook))); +} + #[cfg(test)] pub(crate) fn project_fingerprint(project_root: &Path) -> String { fingerprint_path(project_root) @@ -508,6 +748,11 @@ mod tests { ACTIVATION_SCHEMA_VERSION, }; use std::{collections::BTreeMap, fs}; + #[cfg(unix)] + use std::{ + ffi::OsString, + os::unix::{ffi::OsStringExt, fs::symlink}, + }; use tree_ring_memory_core::{MemoryEvent, MemorySource}; use tree_ring_memory_sqlite::SQLiteMemoryStore; @@ -566,16 +811,16 @@ mod tests { } fn fixture_request() -> PreflightRequest { - PreflightRequest { - harness_id: "pi".to_string(), - identity: SessionIdentity { + PreflightRequest::adapter_stdin( + "pi", + SessionIdentity { agent_profile: "pi".to_string(), workflow_id: "workflow-1".to_string(), session_id: "session-1".to_string(), }, - task_hint: Some("project startup constraints".to_string()), - context_format: PreflightContextFormat::PiBeforeAgentStart, - } + Some("project startup constraints".to_string()), + PreflightContextFormat::PiBeforeAgentStart, + ) } #[test] @@ -661,6 +906,68 @@ mod tests { assert!(receipt_files(&project.memory_root).is_empty()); } + #[test] + fn harness_contract_mismatch_fails_before_recall_or_receipt() { + let (_temp, project, store, manifest) = fixture(); + let request = PreflightRequest::direct( + "pi", + SessionIdentity { + agent_profile: "pi".to_string(), + workflow_id: "workflow-1".to_string(), + session_id: "session-1".to_string(), + }, + Some("project startup constraints".to_string()), + PreflightContextFormat::Json, + ); + + let error = run_preflight(&store, &project, &manifest, request).unwrap_err(); + + assert_eq!(error.to_string(), CONTRACT_ERROR); + assert!(receipt_files(&project.memory_root).is_empty()); + } + + #[test] + fn parser_binds_each_adapter_to_its_owned_event_format() { + let pi = r#"{"agent_profile":"pi","workflow_id":"workflow","session_id":"session"}"#; + let agent_zero = + r#"{"agent_profile":"agent-zero","workflow_id":"workflow","session_id":"session"}"#; + let claude = r#"{"session_id":"session"}"#; + + assert!(parse_adapter_stdin( + "claude-code", + PreflightContextFormat::ClaudeSessionStart, + claude, + ) + .is_ok()); + assert!(parse_adapter_stdin("pi", PreflightContextFormat::PiBeforeAgentStart, pi).is_ok()); + assert!( + parse_adapter_stdin("agent-zero", PreflightContextFormat::Json, agent_zero).is_ok() + ); + assert_eq!( + parse_adapter_stdin("pi", PreflightContextFormat::Json, pi) + .unwrap_err() + .to_string(), + CONTRACT_ERROR + ); + assert_eq!( + parse_adapter_stdin("codex", PreflightContextFormat::Json, "{}") + .unwrap_err() + .to_string(), + CONTRACT_ERROR + ); + assert!(validate_request_contract(&PreflightRequest::direct( + "codex", + SessionIdentity { + agent_profile: "codex".to_string(), + workflow_id: "workflow".to_string(), + session_id: "session".to_string(), + }, + None, + PreflightContextFormat::Json, + )) + .is_ok()); + } + #[test] fn adapter_stdin_fingerprints_unsafe_identity_and_never_persists_raw_paths() { let (_temp, project, store, mut manifest) = fixture(); @@ -711,7 +1018,7 @@ mod tests { } #[test] - fn stale_adapter_version_invalidates_prior_receipts_and_fails_closed() { + fn registry_version_mismatch_invalidates_prior_receipts_and_fails_closed() { let (_temp, project, store, mut manifest) = fixture(); run_preflight(&store, &project, &manifest, fixture_request()).unwrap(); assert_eq!(receipt_files(&project.memory_root).len(), 1); @@ -726,6 +1033,52 @@ mod tests { assert!(receipt_files(&project.memory_root).is_empty()); } + #[test] + fn bridge_mutation_between_prepare_and_commit_invalidates_old_receipts() { + let (_temp, project, store, manifest) = fixture(); + run_preflight(&store, &project, &manifest, fixture_request()).unwrap(); + assert_eq!(receipt_files(&project.memory_root).len(), 1); + + let memory_root = project.memory_root.clone(); + install_pre_commit_hook("mutation-between-prepare-and-commit", move || { + let lock_project = ActivationProject::from_memory_root(memory_root.clone()).unwrap(); + let project_fs = ProjectFs::open(&lock_project).unwrap(); + let _activation_lock = project_fs.lock_manifest().unwrap(); + let mut changed = crate::activation::load_manifest(&memory_root).unwrap(); + let activation = changed.harnesses.get_mut("pi").unwrap(); + activation.owned_files[0].sha256 = "c".repeat(64); + activation.bridge_fingerprint = bridge_fingerprint("pi", activation); + save_manifest(&memory_root, &changed).unwrap(); + }); + let mut request = fixture_request(); + request.identity.session_id = "mutation-between-prepare-and-commit".to_string(); + + let error = run_preflight(&store, &project, &manifest, request).unwrap_err(); + + assert_eq!( + error.to_string(), + "activation contract changed while preparing receipt" + ); + assert!(receipt_files(&project.memory_root).is_empty()); + } + + #[test] + fn persisted_bridge_contract_mismatch_removes_old_receipt_before_recall() { + let (_temp, project, store, manifest) = fixture(); + run_preflight(&store, &project, &manifest, fixture_request()).unwrap(); + assert_eq!(receipt_files(&project.memory_root).len(), 1); + let mut changed = manifest.clone(); + let activation = changed.harnesses.get_mut("pi").unwrap(); + activation.owned_files[0].sha256 = "d".repeat(64); + activation.bridge_fingerprint = bridge_fingerprint("pi", activation); + save_manifest(&project.memory_root, &changed).unwrap(); + + let error = run_preflight(&store, &project, &manifest, fixture_request()).unwrap_err(); + + assert_eq!(error.to_string(), "activation manifest/store mismatch"); + assert!(receipt_files(&project.memory_root).is_empty()); + } + #[test] fn supplied_store_identity_mismatch_writes_no_receipt() { let (_temp, project, store, manifest) = fixture(); @@ -762,6 +1115,55 @@ mod tests { assert!(receipt_files(&project.memory_root).is_empty()); } + #[cfg(unix)] + #[test] + fn symlinked_receipt_directory_fails_closed_without_touching_outside() { + let (temp, project, store, manifest) = fixture(); + let outside = temp.path().join("outside"); + fs::create_dir_all(&outside).unwrap(); + let sentinel = outside.join("sentinel.json"); + fs::write(&sentinel, b"outside receipt sentinel").unwrap(); + symlink(&outside, project.memory_root.join("activation")).unwrap(); + + let error = run_preflight(&store, &project, &manifest, fixture_request()).unwrap_err(); + + assert_eq!(error.to_string(), STORAGE_ERROR); + assert_eq!(fs::read(&sentinel).unwrap(), b"outside receipt sentinel"); + assert!(!outside.join("receipts").exists()); + } + + #[test] + fn storage_failures_use_a_fixed_path_free_adapter_diagnostic() { + let (_temp, project, store, manifest) = fixture(); + fs::write(project.memory_root.join("activation"), b"not a directory").unwrap(); + + let error = run_preflight(&store, &project, &manifest, fixture_request()).unwrap_err(); + + assert_eq!(error.to_string(), STORAGE_ERROR); + assert!(!error + .to_string() + .contains(&project.memory_root.display().to_string())); + assert!(receipt_files(&project.memory_root).is_empty()); + } + + #[cfg(unix)] + #[test] + fn non_utf8_project_basename_fails_closed_before_unscoped_recall() { + // APFS rejects invalid byte sequences, so exercise the trust boundary + // before filesystem access rather than relying on filesystem-specific + // creation behavior. + let project_root = std::path::PathBuf::from(OsString::from_vec(b"project-\xff".to_vec())); + let project = ActivationProject { + memory_root: project_root.join(".tree-ring"), + project_root, + }; + + assert_eq!( + project_scope_name(&project).unwrap_err().to_string(), + "project identity is unavailable" + ); + } + #[test] fn project_root_mismatch_writes_no_receipt() { let (_temp, project, store, mut manifest) = fixture(); @@ -816,16 +1218,16 @@ mod tests { local.session_id = Some("session-1".to_string()); local.scope = "agent".to_string(); store.put(&local).unwrap(); - let request = PreflightRequest { - harness_id: "agent-zero".to_string(), - identity: SessionIdentity { + let request = PreflightRequest::adapter_stdin( + "agent-zero", + SessionIdentity { agent_profile: "agent-zero".to_string(), workflow_id: "workflow-1".to_string(), session_id: "session-1".to_string(), }, - task_hint: None, - context_format: PreflightContextFormat::Json, - }; + None, + PreflightContextFormat::Json, + ); let response = run_preflight(&store, &project, &manifest, request).unwrap(); From f079852be042ad4beb77c47d94db335f931f6310 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 13 Aug 2026 23:29:30 -0400 Subject: [PATCH 12/31] docs: explain verified harness activation --- README.md | 74 ++++++++++++++--------------- docs/integrations/agent-skill.md | 49 +++++++++++++------ docs/protocol/harness-activation.md | 74 +++++++++++++++++++++++++++++ skills/tree-ring-memory/SKILL.md | 35 ++++++++++++++ 4 files changed, 179 insertions(+), 53 deletions(-) create mode 100644 docs/protocol/harness-activation.md diff --git a/README.md b/README.md index f241a12..eff6a6e 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,21 @@ guidance. ## Quick Start +### Activate project-local harnesses + +```bash +tree-ring init +tree-ring integrations status +``` + +Default `init` creates the canonical project-local store and safely activates +maintained adapters. It does not require copying a skill or manually running +`integrations link`. A harness is `active` only after a fresh matching receipt +shows a new session completed scoped recall and safe context injection; markers +and generated guidance are never enough. See the [harness activation +protocol](docs/protocol/harness-activation.md) for states, receipts, advanced +commands, and runtime boundaries. + Source install (requires Rust and Cargo): ```bash @@ -454,17 +469,17 @@ test, or run log remains authoritative. In Coordinated mode, persisting either adapter's result requires `TREE_RING_COORDINATOR_TOKEN`; dry-run discovery does not. -Framework discovery is read-only: +Harness activation starts with the project-local default flow above. For +diagnostic evidence, use: ```bash -tree-ring integrations scan --source-root . -tree-ring integrations certify --source-root . +tree-ring integrations status --verbose +tree-ring integrations certify ``` -- `integrations certify` writes non-mutating harness evidence under - `target/tree-ring-certification/harness/` and updates - `target/tree-ring-certification/evidence-index.json`. Pass, fail, and skip - states are evidence records, not broad compatibility claims. +- `integrations certify` writes JSON and Markdown evidence that keeps + configured, active, isolated, blocked, skipped, and failed results distinct. + A marker-only result cannot pass certification. - `recall-quality` writes non-private recall diagnostics under `target/tree-ring-certification/recall-quality/default-fixture-v1.json` and merges the result into @@ -476,21 +491,10 @@ tree-ring integrations certify --source-root . tree-ring recall-quality --source-root . ``` -It looks for local markers for DOX, Revolve, Codex, Claude Code, Agent Zero/A0, -Goose, OpenCode, Hermes, and Pi, then suggests next steps without editing those -tools' configuration. JSON output records whether each marker came from the -project or from the user's home configuration, so harness readiness is not -overstated when only global config exists. - -Agent-mediated bridge linking is the planned next step after read-only -discovery. The design keeps `.tree-ring` as the canonical memory root while -adding small project-level bridge files that tell the active agent to read -`.tree-ring/SKILL.md` and `.tree-ring/CLI.md`. Project bridges are preferred -because they travel with the repo; global bridges affect every project and must -remain explicit opt-in. Until `tree-ring integrations link` is implemented, add -those references manually in the harness startup context or project instruction -file instead of expecting `tree-ring init` to modify Codex, Claude, Pi, -OpenCode, or other agent configuration. +`tree-ring integrations scan --source-root .` remains a read-only diagnostic; +it cannot establish runtime use. `integrations link` remains an advanced alias +for controlled bridge work. Default `init` handles safe project-local adapter +activation, while global configuration remains explicit opt-in. `tree-ring export` writes newline-delimited JSON. The first line is a `tree_ring_memory_export` header with schema and plugin version metadata; each @@ -665,34 +669,26 @@ must build from source. Historical migration and planning documents are retained under `docs/superpowers/` and `docs/feature/`. Some of those records describe earlier Python prototype or binding options that have since been superseded by the Rust-native runtime. -The current bridge-linking design is tracked in -`docs/superpowers/specs/2026-07-06-tree-ring-agent-mediated-bridges-design.md`. +The current activation protocol is documented in +[`docs/protocol/harness-activation.md`](docs/protocol/harness-activation.md). ## Agent Workflow Integration - `skills/tree-ring-memory/SKILL.md` gives agents portable guidance for when to recall, remember, redact, forget, or avoid memory capture. - `templates/dox/AGENTS.md` is a DOX-style project contract template for repos that want Tree Ring Memory rules alongside source code. - `docs/integrations/agent-skill.md` explains how to use both without making memory more authoritative than local project docs. -- `tree-ring init` and `tree-ring welcome --init` copy local guidance into `.tree-ring/AGENTS.md`, `.tree-ring/SKILL.md`, and `.tree-ring/CLI.md` without overwriting existing files. +- `tree-ring init` creates canonical guidance and safely activates maintained + project-local adapters; status remains receipt-backed. For DOX-style project awareness, merge the relevant generated `.tree-ring/AGENTS.md` sections into the project root `AGENTS.md`. The CLI intentionally does not rewrite root project contracts automatically. -For agent harnesses that support local skills or instruction packs, point them -at `.tree-ring/SKILL.md` or the repository copy under `skills/`. For -DOX-aware agents, make sure the project root `AGENTS.md` tells the agent to read -Tree Ring Memory guidance when memory is initialized. For CLI-driven agents, -include `.tree-ring/CLI.md` in the harness prompt or startup context so the -agent knows the exact local commands. - -Bridge files should be short discovery pointers, not duplicate memory stores. -Recommended project-level bridge targets are `.agents/skills/tree-ring-memory/SKILL.md` -for Codex/Gemini-style skill loaders, `.claude/skills/tree-ring-memory/SKILL.md` -plus `CLAUDE.md` references for Claude Code, root `AGENTS.md` references for -OpenCode/DOX-style agents, and `.pi/settings.json` resource references for Pi. -Global bridge files under home directories are useful only when the user wants -Tree Ring visible in every project. +`tree-ring init` installs managed project-local bridges where an adapter can do +so safely. A bridge is not proof of runtime use. `tree-ring integrations status` +reports the exact non-active state and one action, such as Pi trust, an Agent +Zero plugin, a missing mount, or unmanaged-file review. Do not treat Hermes or +another unverified runtime as active. Memory updates are agent-mediated. Bridge files tell the active agent when to call `tree-ring recall`, `tree-ring remember`, `tree-ring evidence`, diff --git a/docs/integrations/agent-skill.md b/docs/integrations/agent-skill.md index 0a79f73..11187a1 100644 --- a/docs/integrations/agent-skill.md +++ b/docs/integrations/agent-skill.md @@ -5,14 +5,16 @@ Tree Ring Memory ships two integration aids for agent workflows: - `skills/tree-ring-memory/SKILL.md`: a portable agent skill that teaches an agent when to recall, remember, redact, forget, and avoid memory capture. - `templates/dox/AGENTS.md`: a DOX-style project contract template for repos that want Tree Ring Memory guidance alongside source code. -`tree-ring init` and `tree-ring welcome --init` also create local copies in the -configured memory root: +`tree-ring init` creates canonical project guidance and safely activates +maintained project-local harness adapters. The resulting local guidance is: - `.tree-ring/AGENTS.md` - `.tree-ring/SKILL.md` - `.tree-ring/CLI.md` -Existing files are not overwritten. +Existing unmanaged files are not overwritten. A generated bridge is a pointer, +not proof that a runtime used Tree Ring; see +[the harness activation protocol](../protocol/harness-activation.md). These generated files are the canonical project-local guidance. Harness-native bridge files should point back to them rather than copying memory data or @@ -336,13 +338,18 @@ Coordinated mode, that persisted rerun requires ## Agent Harness Notes -Tree Ring Memory is framework-agnostic. For agent harnesses that support local -skills, add `skills/tree-ring-memory/SKILL.md` or the generated -`.tree-ring/SKILL.md` to startup context. For DOX-aware harnesses, merge the -generated `.tree-ring/AGENTS.md` guidance into the project root `AGENTS.md` -when you want agents to see memory rules before entering the memory directory. -For CLI-only harnesses, include `.tree-ring/CLI.md` in startup context and call -`tree-ring --help` when command flags are uncertain. +Tree Ring Memory is framework-agnostic. The default is `tree-ring init`, not a +manual bridge-copying workflow. It installs only owned project-local material +that a maintained adapter can safely manage. Then run: + +```bash +tree-ring integrations status +``` + +`active` needs a fresh matching receipt from a new session's scoped recall and +safe context injection. `configured-awaiting-proof`, `active-isolated`, +`needs-trust`, `needs-project-mount`, `needs-plugin`, and +`needs-user-review` are deliberately non-active states. Recommended project-level bridge targets: @@ -367,10 +374,24 @@ or autonomously persist chat transcripts. Durable writes happen only when a user, agent, adapter, import, TUI action, consolidation command, or explicit maintenance command calls the CLI. -`tree-ring integrations scan --source-root .` is read-only today. The planned -`tree-ring integrations link --scope project --harness auto --dry-run` command -will preview bridge writes first, then write only missing files or safe managed -blocks. Until that command is implemented, add the bridge references manually. +`integrations link` is an advanced alias for controlled bridge work; it is not +required after default initialization. `tree-ring integrations activate --harness + --dry-run`, `integrations certify`, and `integrations deactivate --harness +` are advanced operations. Certification creates JSON and Markdown evidence; +a marker-only result cannot pass. + +Pi trust is the user's platform decision: report `needs-trust` rather than +changing global trust. Agent Zero uses only its separate `tree_ring_memory` +plugin: a missing plugin is `needs-plugin`, an inaccessible canonical root is +`needs-project-mount`, and a different store is `active-isolated`. Tree Ring +does not modify Agent Zero core or create another store to imply sharing. Hermes +and any unverified runtime remain non-active. + +Receipts are privacy-redacted operational evidence, not raw prompts, recalled +content, secrets, sensitive values, paths, or coordinator capabilities. They +prove preflight usage, not a security boundary. Shared status is limited to +same-host processes on a local filesystem with the same project `store_id`; +it does not apply across hosts or network filesystems. ## Safety Rule diff --git a/docs/protocol/harness-activation.md b/docs/protocol/harness-activation.md new file mode 100644 index 0000000..248d794 --- /dev/null +++ b/docs/protocol/harness-activation.md @@ -0,0 +1,74 @@ +# Harness Activation Protocol + +Tree Ring uses `ACTIVATION_PROTOCOL_VERSION = 1`. Its default project-local +flow is: + +```bash +tree-ring init +tree-ring integrations status +``` + +`init` creates the canonical `.tree-ring/` root, discovers maintained adapters, +and installs only project-local owned bridges or bounded managed blocks. It does +not write global settings, overwrite unmanaged files, or require users to copy a +bridge or run `integrations link`. That command is an advanced alias for +controlled bridge work, not the default journey. + +Advanced commands are `tree-ring integrations status --verbose`, +`tree-ring integrations activate --harness --dry-run`, +`tree-ring integrations certify`, and `tree-ring integrations deactivate +--harness `. Certification records JSON and Markdown evidence; it is not +required for initialization. + +## States and proof + +| State | Meaning | +| --- | --- | +| `active` | A maintained bridge has a fresh matching receipt for a new session's scoped recall and safe context injection. | +| `configured-awaiting-proof` | A safe bridge is installed, but no qualifying fresh receipt exists. | +| `active-isolated` | Preflight succeeded against a store that does not match this project's canonical store. | +| `needs-trust` | The runtime needs its own user approval before project resources load. | +| `needs-project-mount` | The runtime cannot reach the canonical project root. | +| `needs-plugin` | Agent Zero needs its separate `tree_ring_memory` plugin installed or enabled. | +| `needs-user-review` | Existing unmanaged configuration makes automatic change unsafe. | +| `unsupported` | No maintained adapter can prove the integration. | +| `failed` | Detection, installation, preflight, or receipt verification has a concrete diagnostic. | + +A marker, copied skill, or scan never makes a harness `active`. Missing, +expired, malformed, or mismatched receipts remain `configured-awaiting-proof`. +A zero-result recall can be a valid receipt: it proves the check occurred +without inventing context. Hermes and any runtime without a maintained verified +adapter remain non-active. + +## Artifacts and privacy + +`.tree-ring/activation.json` is the versioned manifest. It contains the schema +and protocol versions, stable `store_id`, project-root fingerprint, CLI version, +and adapter records with state, capability, bridge path, owned files, and managed +blocks. Receipts live under `.tree-ring/activation/receipts/`; they contain +version and harness IDs, fingerprinted worker identity, harness-derived +agent/workflow/session IDs, state, timestamp, and optional query class, result +count, memory-ID digest, duration, and matching-store evidence. They never retain +raw prompts, recalled content, secrets, sensitive values, absolute paths, or +coordinator capabilities. Keep at most 100 receipts per harness/worker and none +older than 30 days. + +A receipt proves a privacy-safe preflight check, not durable memory creation or +an adversarial security boundary. Durable writes remain explicit. + +## Runtime and shared-store boundaries + +Pi project trust is a user decision: report `needs-trust` and leave global +trust unchanged. Agent Zero uses only the separate `tree_ring_memory` plugin; +Tree Ring does not modify Agent Zero core, and a generic `.a0` marker is not an +adapter. An absent plugin is `needs-plugin`; an inaccessible project root is +`needs-project-mount`; a different accessible store is `active-isolated`. + +Shared status requires every receipt to match the canonical project `store_id`. +It is supported only for concurrent processes on the same host and a local +filesystem, not across hosts, NFS/network filesystems, or containers on +different hosts. Use per-host roots and explicit evidence-preserving fan-in. + +Maintained adapters detect capability, install only owned material, preflight a +new session, verify its receipt, and deactivate only what they own. Conflicting +or unmanaged files remain unchanged and report `needs-user-review`. diff --git a/skills/tree-ring-memory/SKILL.md b/skills/tree-ring-memory/SKILL.md index 4a161a5..16a6706 100644 --- a/skills/tree-ring-memory/SKILL.md +++ b/skills/tree-ring-memory/SKILL.md @@ -96,6 +96,41 @@ as a pointer only. Read the project-local `.tree-ring/SKILL.md` and Do not assume a global Tree Ring setup applies to the current repo unless the user explicitly configured it. +## Harness Activation + +For a new project, begin with the safe, project-local default: + +```bash +tree-ring init +tree-ring integrations status +``` + +Do not ask the user to copy a bridge or run `integrations link` for ordinary +setup. `init` manages only safe project-local adapter material. A bridge, +marker, or generated skill is not activation proof: `active` requires a fresh, +matching receipt from a new session's scoped recall and safe context injection. +Treat `configured-awaiting-proof`, `active-isolated`, `needs-trust`, +`needs-project-mount`, `needs-plugin`, `needs-user-review`, `unsupported`, +and `failed` as their exact non-active outcomes. Never say Hermes or another +unverified runtime is active. + +Pi trust is the user's decision: report `needs-trust` rather than changing +global trust. Agent Zero is separate: the existing `tree_ring_memory` plugin +must be installed/enabled and able to mount the canonical `.tree-ring` root. +Never treat a generic marker as its adapter, modify Agent Zero core, or call a +different plugin store shared. A different reachable store is +`active-isolated`; an unavailable root is `needs-project-mount`. + +Receipts prove a privacy-safe preflight check, not durable memory creation or a +security boundary. They exclude raw prompts, recalled content, secrets, +sensitive values, paths, and coordinator capabilities. Shared-store claims are +limited to same-host local-filesystem processes whose receipts match the +canonical project `store_id`; they do not apply across hosts or network +filesystems. For diagnostics use `tree-ring integrations status --verbose`; +for advanced controlled work use `integrations activate --harness +--dry-run`, `integrations certify`, or `integrations deactivate --harness +`. + Evidence outcome mapping: - `promoted`: durable heartwood from supported evidence From 82b118e5cac3ae9616ce7f3733494055a785cbff Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 13 Aug 2026 23:33:33 -0400 Subject: [PATCH 13/31] docs: specify activation protocol receipts --- README.md | 5 + docs/protocol/harness-activation.md | 204 ++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) diff --git a/README.md b/README.md index eff6a6e..3c30575 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,11 @@ and generated guidance are never enough. See the [harness activation protocol](docs/protocol/harness-activation.md) for states, receipts, advanced commands, and runtime boundaries. +Shared Tree Ring activation is supported only for concurrent processes on the +same-host local filesystem with matching project-store identity. It does not +claim safe SQLite sharing across hosts, NFS/network filesystems, or containers +on different hosts. + Source install (requires Rust and Cargo): ```bash diff --git a/docs/protocol/harness-activation.md b/docs/protocol/harness-activation.md index 248d794..3f7f42a 100644 --- a/docs/protocol/harness-activation.md +++ b/docs/protocol/harness-activation.md @@ -56,6 +56,210 @@ older than 30 days. A receipt proves a privacy-safe preflight check, not durable memory creation or an adversarial security boundary. Durable writes remain explicit. +## Canonical wire shapes + +The following JSON shapes are the version-1 interoperability contract. All +fingerprints are lowercase, 64-character SHA-256 hex digests. Paths, when they +are present in a manifest, are project-relative. These examples deliberately +use synthetic IDs and no prompt, recalled context, capability, or absolute path. + +### Activation manifest + +```json +{ + "schema_version": 1, + "protocol_version": 1, + "store_id": "01234567-89ab-4def-8123-456789abcdef", + "project_root_fingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "cli_version": "0.14.0", + "harnesses": { + "claude-code": { + "state": "configured-awaiting-proof", + "adapter_version": "1", + "harness_version": "1.0.0", + "adapter_capability": "native-preflight", + "bridge_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "bridge_path": ".claude/settings.json", + "owned_files": [ + { + "path": ".claude/skills/tree-ring-memory/SKILL.md", + "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + ], + "managed_blocks": [ + { + "path": ".claude/settings.json", + "block_id": "tree-ring-session-start-v1", + "sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } + ] + } + } +} +``` + +The adapter record is keyed by its canonical `harness_id`. `adapter_version`, +`harness_version`, and `bridge_fingerprint` are required for receipt-backed +activation. For a bridge with more than one owned file or managed block, +`bridge_fingerprint` is the SHA-256 of the UTF-8 canonical JSON array of its +components. Each component has exactly `path`, `kind`, and `sha256`; sort +the array by `path`, then `kind`, and serialize object keys in lexical order +with no insignificant whitespace. `kind` is either `file` or +`managed-block:`. This lets every adapter calculate the same +fingerprint without recording the project root. + +### Redacted receipt + +```json +{ + "schema_version": 1, + "protocol_version": 1, + "receipt_id": "receipt-01", + "harness_id": "claude-code", + "harness_version": "1.0.0", + "adapter_version": "1", + "store_id": "01234567-89ab-4def-8123-456789abcdef", + "project_root_fingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bridge_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "worker_key_fingerprint": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "session": { + "agent_profile": "claude-code", + "workflow_id": "workflow-01", + "session_id": "session-01" + }, + "query_class": "project-startup-constraints", + "result_count": 0, + "selected_memory_ids_digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "duration_ms": 18, + "state": "active", + "recorded_at": "2026-08-13T12:00:00Z", + "expires_at": "2026-09-12T12:00:00Z" +} +``` + +`query_class` is an approved stable category, never a raw task hint. A +zero-result receipt has `result_count: 0` and a digest of the empty selected-ID +set; it is still valid only after the adapter successfully injects safe context. + +### Claude Code SessionStart output + +Claude's managed `SessionStart` command reads its event input from stdin and +writes exactly this JSON object to stdout on successful preflight: + +```json +{ + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": "Tree Ring preflight completed; safe project context was injected." + } +} +``` + +`additionalContext` contains only the safe context produced for the live +session. It must not echo hook input, task prompts, receipt JSON, capabilities, +or unredacted recalled content. The command fails without emitting a successful +SessionStart object if receipt verification fails. + +### Pi and Agent Zero JSON preflight responses + +Pi's `before_agent_start` extension and the Agent Zero +`tree_ring_memory` plugin both invoke the project-local preflight command with +JSON stdin and consume this response shape. Their harness ID and +`context_format` differ, but the JSON result is identical: + +```json +{ + "protocol_version": 1, + "status": "ok", + "context": "Tree Ring preflight completed; safe project context was injected.", + "receipt": { + "receipt_id": "receipt-01", + "harness_id": "pi", + "harness_version": "1.0.0", + "adapter_version": "1", + "store_id": "01234567-89ab-4def-8123-456789abcdef", + "project_root_fingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bridge_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "worker_key_fingerprint": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "session": { + "agent_profile": "pi", + "workflow_id": "workflow-01", + "session_id": "session-01" + }, + "query_class": "project-startup-constraints", + "result_count": 0, + "selected_memory_ids_digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "duration_ms": 18, + "state": "active", + "recorded_at": "2026-08-13T12:00:00Z", + "expires_at": "2026-09-12T12:00:00Z" + } +} +``` + +Agent Zero returns this complete shape through its separate plugin: + +```json +{ + "protocol_version": 1, + "status": "ok", + "context": "Tree Ring preflight completed; safe project context was injected.", + "receipt": { + "receipt_id": "receipt-02", + "harness_id": "agent-zero", + "harness_version": "1.0.0", + "adapter_version": "1", + "store_id": "01234567-89ab-4def-8123-456789abcdef", + "project_root_fingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bridge_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "worker_key_fingerprint": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "session": { + "agent_profile": "agent-zero-worker", + "workflow_id": "workflow-01", + "session_id": "session-01" + }, + "query_class": "project-startup-constraints", + "result_count": 0, + "selected_memory_ids_digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "duration_ms": 18, + "state": "active", + "recorded_at": "2026-08-13T12:00:00Z", + "expires_at": "2026-09-12T12:00:00Z" + } +} +``` + +The plugin derives the Agent Zero identity server-side; it must not accept a +model-supplied identity. Its binding carries the same protocol version, +`store_id`, project-root fingerprint, relative `memory_root`, and JSON +stdin/stdout command contract. An error response has `status: "error"`, a +bounded `error_code`, and no `context` or `receipt`. + +## Receipt verification + +An adapter may classify a harness as `active` only after it verifies the +manifest, bridge, and receipt as one tuple. It must reject the receipt and +report the exact non-active state if any of these equality requirements fail: + +1. `receipt.protocol_version == manifest.protocol_version == 1`. +2. `receipt.harness_id` is exactly the adapter's canonical harness ID, and + `receipt.harness_version == manifest.harnesses[harness_id].harness_version`. +3. `receipt.adapter_version == manifest.harnesses[harness_id].adapter_version`. +4. `receipt.store_id == manifest.store_id`. +5. `receipt.project_root_fingerprint == manifest.project_root_fingerprint`. +6. `receipt.bridge_fingerprint == manifest.harnesses[harness_id].bridge_fingerprint`, + after recomputing that fingerprint from the installed owned material. +7. `receipt.session.session_id` is the currently running harness session and + its recorded worker/workflow identity matches the adapter-derived identity. +8. `receipt.state == "active"`, `recorded_at` is not in the future, and + `expires_at - recorded_at == 2,592,000 seconds` (30 days). The receipt is + fresh only while `recorded_at <= now < expires_at`; it is never reused for + a later session even if its TTL has not elapsed. + +Any adapter, harness, root, or bridge change invalidates prior receipts. A +matching receipt for another accessible store is `active-isolated`, never +`active` for the canonical shared project. + ## Runtime and shared-store boundaries Pi project trust is a user decision: report `needs-trust` and leave global From 81fe76bc1702aaa15d79f29e5b3a3338f045c3e5 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 13 Aug 2026 23:36:12 -0400 Subject: [PATCH 14/31] docs: define preflight input contracts --- docs/protocol/harness-activation.md | 68 +++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/docs/protocol/harness-activation.md b/docs/protocol/harness-activation.md index 3f7f42a..085ebaa 100644 --- a/docs/protocol/harness-activation.md +++ b/docs/protocol/harness-activation.md @@ -141,6 +141,25 @@ fingerprint without recording the project root. zero-result receipt has `result_count: 0` and a digest of the empty selected-ID set; it is still valid only after the adapter successfully injects safe context. +### Claude Code SessionStart input + +The managed Claude command consumes only this privacy-safe projection of the +host's SessionStart event: + +```json +{ + "session_id": "session-01", + "cwd": ".", + "agent_type": "claude-code" +} +``` + +`session_id` and `cwd` are required; `agent_type` is optional and defaults +to `claude-code`. `cwd` is resolved by the running hook and is not copied into +a receipt. Claude may include `transcript_path` in its original event, but Tree +Ring ignores it completely: it is not read, forwarded, logged, persisted, or +included in recall. No other event field is part of the preflight request. + ### Claude Code SessionStart output Claude's managed `SessionStart` command reads its event input from stdin and @@ -160,6 +179,55 @@ session. It must not echo hook input, task prompts, receipt JSON, capabilities, or unredacted recalled content. The command fails without emitting a successful SessionStart object if receipt verification fails. +### Pi JSON stdin request + +Pi's `before_agent_start` extension sends local runtime identity to the CLI on +stdin only: + +```json +{ + "agent_profile": "pi", + "workflow_id": "workflow-01", + "session_id": "session-01", + "task_hint": "project startup constraints" +} +``` + +`agent_profile`, `workflow_id`, and `session_id` come from Pi's local +session manager, not model text. `task_hint` is optional; when supplied it is +sent only on stdin for the current preflight, subject to sensitivity rejection +and fallback to `project startup constraints`. The raw value is never written +to a receipt, log, memory, bridge, or response. + +### Agent Zero JSON stdin request + +The separate `tree_ring_memory` plugin derives Agent Zero identity on the +server and sends only this request to the project-local command: + +```json +{ + "agent_profile": "agent-zero-worker", + "workflow_id": "workflow-01", + "session_id": "session-01" +} +``` + +Agent Zero does not send a task hint, prompt, coordinator capability, token, or +model-supplied identity. The plugin reads the relative project binding and +derives the three identity fields before invoking the command. + +### Input validation and handling + +Each adapter validates the field whitelist above before preflight. Benign host +metadata outside that whitelist is ignored and never forwarded. Unknown +capability-bearing fields (including `capability`, `token`, +`authorization`, `coordinator_capability`, or +`TREE_RING_COORDINATOR_TOKEN`) are rejected rather than ignored. Inputs that +claim a store, root, bridge fingerprint, harness identity, or receipt state are +also rejected; those values come only from the local manifest and adapter. +Raw stdin and SessionStart input are transient: Tree Ring does not persist or +log them, regardless of whether validation succeeds. + ### Pi and Agent Zero JSON preflight responses Pi's `before_agent_start` extension and the Agent Zero From cc257d3aa620803a53a46d27d0577f8c38d588d4 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 00:30:35 -0400 Subject: [PATCH 15/31] docs: clarify create-only activation lifecycle --- README.md | 40 +++++++++++++++++++---------- docs/integrations/agent-skill.md | 21 +++++++++++---- docs/protocol/harness-activation.md | 35 +++++++++++++++++++------ skills/tree-ring-memory/SKILL.md | 14 +++++++--- 4 files changed, 80 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 3c30575..0d3dc76 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,14 @@ tree-ring init tree-ring integrations status ``` -Default `init` creates the canonical project-local store and safely activates -maintained adapters. It does not require copying a skill or manually running -`integrations link`. A harness is `active` only after a fresh matching receipt -shows a new session completed scoped recall and safe context injection; markers -and generated guidance are never enough. See the [harness activation +Default `init` creates the canonical project-local store and configures +maintained adapters where new project-local bridge and manifest entries can be +created safely. It does not require copying a skill or manually running +`integrations link`. Existing entries are never overwritten or removed; a +contested bridge or manifest reports `needs-user-review`. A harness is `active` +only after a fresh matching receipt shows a new session completed scoped recall +and safe context injection; `init`, markers, and generated guidance establish +configuration, not activation. See the [harness activation protocol](docs/protocol/harness-activation.md) for states, receipts, advanced commands, and runtime boundaries. @@ -262,7 +265,9 @@ The CLI stores memory in `.tree-ring/` by default. Command ownership is Rust-native: -- `init` creates the SQLite store plus `.tree-ring/AGENTS.md`, `.tree-ring/SKILL.md`, and `.tree-ring/CLI.md` without overwriting existing files. +- `init` creates the SQLite store plus `.tree-ring/AGENTS.md`, + `.tree-ring/SKILL.md`, and `.tree-ring/CLI.md` without overwriting existing + files, then attempts create-only project harness configuration. - `remember`, `recall`, and `forget` cover direct memory capture, retrieval, redaction, and deletion. - `evidence` is the Revolve-inspired improvement-loop entry point for evaluated outcomes. - `dox sync` and `revolve sync` are read-only source adapters that summarize and point back to authoritative files. @@ -499,7 +504,9 @@ tree-ring recall-quality --source-root . `tree-ring integrations scan --source-root .` remains a read-only diagnostic; it cannot establish runtime use. `integrations link` remains an advanced alias for controlled bridge work. Default `init` handles safe project-local adapter -activation, while global configuration remains explicit opt-in. +configuration, while global configuration remains explicit opt-in. Neither +command replaces or removes an existing final bridge or activation manifest; +entries that require mutation remain unchanged for explicit review. `tree-ring export` writes newline-delimited JSON. The first line is a `tree_ring_memory_export` header with schema and plugin version metadata; each @@ -682,18 +689,23 @@ The current activation protocol is documented in - `skills/tree-ring-memory/SKILL.md` gives agents portable guidance for when to recall, remember, redact, forget, or avoid memory capture. - `templates/dox/AGENTS.md` is a DOX-style project contract template for repos that want Tree Ring Memory rules alongside source code. - `docs/integrations/agent-skill.md` explains how to use both without making memory more authoritative than local project docs. -- `tree-ring init` creates canonical guidance and safely activates maintained - project-local adapters; status remains receipt-backed. +- `tree-ring init` creates canonical guidance and safely configures maintained + project-local adapters when create-only publication is possible; status + remains receipt-backed. For DOX-style project awareness, merge the relevant generated `.tree-ring/AGENTS.md` sections into the project root `AGENTS.md`. The CLI intentionally does not rewrite root project contracts automatically. -`tree-ring init` installs managed project-local bridges where an adapter can do -so safely. A bridge is not proof of runtime use. `tree-ring integrations status` -reports the exact non-active state and one action, such as Pi trust, an Agent -Zero plugin, a missing mount, or unmanaged-file review. Do not treat Hermes or -another unverified runtime as active. +`tree-ring init` creates managed project-local bridges where an adapter can do +so safely. It never replaces or removes an existing bridge or activation +manifest; contested entries are preserved as `needs-user-review`. If durability +becomes indeterminate after publication, published disk material is preserved, +the in-memory changed harness state remains marked for review, and any manifest +already published on disk is left intact. A bridge is not proof of runtime use. +`tree-ring integrations status` reports the exact non-active state and one +action, such as Pi trust, an Agent Zero plugin, a missing mount, or unmanaged-file +review. Do not treat Hermes or another unverified runtime as active. Memory updates are agent-mediated. Bridge files tell the active agent when to call `tree-ring recall`, `tree-ring remember`, `tree-ring evidence`, diff --git a/docs/integrations/agent-skill.md b/docs/integrations/agent-skill.md index 11187a1..4d01416 100644 --- a/docs/integrations/agent-skill.md +++ b/docs/integrations/agent-skill.md @@ -5,15 +5,18 @@ Tree Ring Memory ships two integration aids for agent workflows: - `skills/tree-ring-memory/SKILL.md`: a portable agent skill that teaches an agent when to recall, remember, redact, forget, and avoid memory capture. - `templates/dox/AGENTS.md`: a DOX-style project contract template for repos that want Tree Ring Memory guidance alongside source code. -`tree-ring init` creates canonical project guidance and safely activates -maintained project-local harness adapters. The resulting local guidance is: +`tree-ring init` creates canonical project guidance and safely configures +maintained project-local harness adapters where create-only publication is +possible. The resulting local guidance is: - `.tree-ring/AGENTS.md` - `.tree-ring/SKILL.md` - `.tree-ring/CLI.md` -Existing unmanaged files are not overwritten. A generated bridge is a pointer, -not proof that a runtime used Tree Ring; see +Existing bridge and activation-manifest entries are never overwritten or +removed. If an entry would need mutation, Tree Ring preserves it and reports +`needs-user-review`. A generated bridge is a pointer, not proof that a runtime +used Tree Ring; see [the harness activation protocol](../protocol/harness-activation.md). These generated files are the canonical project-local guidance. Harness-native @@ -340,7 +343,9 @@ Coordinated mode, that persisted rerun requires Tree Ring Memory is framework-agnostic. The default is `tree-ring init`, not a manual bridge-copying workflow. It installs only owned project-local material -that a maintained adapter can safely manage. Then run: +that a maintained adapter can publish to an absent final path. It never replaces +or removes an existing bridge or activation manifest, including during +deactivation. Then run: ```bash tree-ring integrations status @@ -351,6 +356,12 @@ safe context injection. `configured-awaiting-proof`, `active-isolated`, `needs-trust`, `needs-project-mount`, `needs-plugin`, and `needs-user-review` are deliberately non-active states. +If an entry is already present or has changed, leave it untouched and follow the +reported `needs-user-review` reconciliation step. If durability becomes +indeterminate after publication, preserve the published disk material; Tree +Ring keeps changed harnesses in the returned in-memory manifest marked for +review and leaves any manifest already published on disk intact. + Recommended project-level bridge targets: - Codex and Gemini-style skill loaders: `.agents/skills/tree-ring-memory/SKILL.md` diff --git a/docs/protocol/harness-activation.md b/docs/protocol/harness-activation.md index 085ebaa..c9a4cbb 100644 --- a/docs/protocol/harness-activation.md +++ b/docs/protocol/harness-activation.md @@ -9,10 +9,12 @@ tree-ring integrations status ``` `init` creates the canonical `.tree-ring/` root, discovers maintained adapters, -and installs only project-local owned bridges or bounded managed blocks. It does -not write global settings, overwrite unmanaged files, or require users to copy a -bridge or run `integrations link`. That command is an advanced alias for -controlled bridge work, not the default journey. +and attempts create-only publication of project-local owned bridges or bounded +managed blocks plus the activation manifest. It does not write global settings, +replace or remove an existing final entry, or require users to copy a bridge or +run `integrations link`. An existing bridge or manifest that would need mutation +is preserved and reported as `needs-user-review`. `integrations link` is an +advanced alias for controlled bridge work, not the default journey. Advanced commands are `tree-ring integrations status --verbose`, `tree-ring integrations activate --harness --dry-run`, @@ -30,7 +32,7 @@ required for initialization. | `needs-trust` | The runtime needs its own user approval before project resources load. | | `needs-project-mount` | The runtime cannot reach the canonical project root. | | `needs-plugin` | Agent Zero needs its separate `tree_ring_memory` plugin installed or enabled. | -| `needs-user-review` | Existing unmanaged configuration makes automatic change unsafe. | +| `needs-user-review` | An existing or changed bridge/manifest cannot be safely replaced or removed, or publication durability is indeterminate. | | `unsupported` | No maintained adapter can prove the integration. | | `failed` | Detection, installation, preflight, or receipt verification has a concrete diagnostic. | @@ -56,6 +58,20 @@ older than 30 days. A receipt proves a privacy-safe preflight check, not durable memory creation or an adversarial security boundary. Durable writes remain explicit. +Bridge lifecycle writes fail closed. Publication creates an absent final path; +it never overwrites or removes an existing bridge or activation manifest, even +when the existing bytes were previously recorded as Tree Ring-owned. +Semantically unchanged manifests are preserved byte-for-byte. Activation or +deactivation that would mutate a final entry makes no such change and returns +`needs-user-review` with a reconciliation step. + +If a directory durability check fails after publication, Tree Ring does not try +to roll the path back because it cannot safely condition removal on the exact +published inode and bytes. It preserves all published disk material, marks each +changed harness `needs-user-review` in the returned in-memory manifest, and +leaves any manifest that already reached disk intact. Disk and memory can +therefore differ until the user reviews and reconciles the indeterminate state. + ## Canonical wire shapes The following JSON shapes are the version-1 interoperability contract. All @@ -341,6 +357,9 @@ It is supported only for concurrent processes on the same host and a local filesystem, not across hosts, NFS/network filesystems, or containers on different hosts. Use per-host roots and explicit evidence-preserving fan-in. -Maintained adapters detect capability, install only owned material, preflight a -new session, verify its receipt, and deactivate only what they own. Conflicting -or unmanaged files remain unchanged and report `needs-user-review`. +Maintained adapters detect capability, create only absent owned material, +preflight a new session, and verify its receipt. Deactivation is also +creation-only at the writer boundary: existing final entries are not removed or +replaced automatically, including recorded owned material. Conflicting, +changed, or otherwise contested bridge and manifest entries remain unchanged +and report `needs-user-review`. diff --git a/skills/tree-ring-memory/SKILL.md b/skills/tree-ring-memory/SKILL.md index 16a6706..9865ed0 100644 --- a/skills/tree-ring-memory/SKILL.md +++ b/skills/tree-ring-memory/SKILL.md @@ -106,14 +106,22 @@ tree-ring integrations status ``` Do not ask the user to copy a bridge or run `integrations link` for ordinary -setup. `init` manages only safe project-local adapter material. A bridge, -marker, or generated skill is not activation proof: `active` requires a fresh, -matching receipt from a new session's scoped recall and safe context injection. +setup. `init` configures only safe project-local adapter material by creating +absent final bridge and manifest paths. It never replaces or removes an existing +entry, including during deactivation; contested entries stay untouched and +report `needs-user-review`. A bridge, marker, generated skill, or successful +`init` is not activation proof: `active` requires a fresh, matching receipt from +a new session's scoped recall and safe context injection. Treat `configured-awaiting-proof`, `active-isolated`, `needs-trust`, `needs-project-mount`, `needs-plugin`, `needs-user-review`, `unsupported`, and `failed` as their exact non-active outcomes. Never say Hermes or another unverified runtime is active. +If publication durability becomes indeterminate, do not delete or rewrite the +published path. Preserve disk material, keep changed harnesses marked +`needs-user-review` in the returned in-memory manifest, and leave any activation +manifest already published on disk intact for explicit reconciliation. + Pi trust is the user's decision: report `needs-trust` rather than changing global trust. Agent Zero is separate: the existing `tree_ring_memory` plugin must be installed/enabled and able to mount the canonical `.tree-ring` root. From fb7b913951459a7fc0b58037a08c2d8303ebd061 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 01:00:34 -0400 Subject: [PATCH 16/31] feat: add Claude preflight launch wrapper --- .../src/activation/adapters.rs | 28 +- .../src/activation/bridge.rs | 76 ++- .../src/activation/launcher.rs | 571 ++++++++++++++++++ .../src/activation/mod.rs | 1 + .../src/activation/preflight.rs | 95 ++- crates/tree-ring-memory-cli/src/main.rs | 89 ++- .../tests/harness_activation_acceptance.rs | 141 +++++ 7 files changed, 989 insertions(+), 12 deletions(-) create mode 100644 crates/tree-ring-memory-cli/src/activation/launcher.rs create mode 100644 crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs diff --git a/crates/tree-ring-memory-cli/src/activation/adapters.rs b/crates/tree-ring-memory-cli/src/activation/adapters.rs index 10306e3..c27e0e2 100644 --- a/crates/tree-ring-memory-cli/src/activation/adapters.rs +++ b/crates/tree-ring-memory-cli/src/activation/adapters.rs @@ -216,7 +216,7 @@ const ADAPTERS: [DeclarativeAdapter; 7] = [ version: "1", display_name: "Codex", command: "codex", - capability: AdapterCapability::WrapperPreflight, + capability: AdapterCapability::GuidanceOnly, markers: &[".codex", "AGENTS.md"], support: AdapterSupport::Maintained, }, @@ -225,7 +225,7 @@ const ADAPTERS: [DeclarativeAdapter; 7] = [ version: "1", display_name: "Claude Code", command: "claude", - capability: AdapterCapability::NativePreflight, + capability: AdapterCapability::WrapperPreflight, markers: &[".claude", "CLAUDE.md"], support: AdapterSupport::Maintained, }, @@ -292,6 +292,13 @@ pub fn adapter_version(id: &str) -> Option<&'static str> { .map(|adapter| adapter.version) } +/// Returns the activation capability registered for a harness. +pub(crate) fn adapter_capability(id: &str) -> Option { + registered_adapters() + .find(|adapter| adapter.id == id) + .map(|adapter| adapter.capability) +} + fn registered_adapters() -> impl Iterator { ADAPTERS.iter() } @@ -715,6 +722,23 @@ mod tests { } } + #[test] + fn only_claude_code_advertises_a_tested_wrapper_preflight() { + let report = detect_adapters(&project(), &FakeEnvironment::default()); + + assert_eq!( + report.by_id("claude-code").unwrap().capability, + AdapterCapability::WrapperPreflight + ); + for harness_id in ["codex", "pi", "agent-zero"] { + assert_ne!( + report.by_id(harness_id).unwrap().capability, + AdapterCapability::WrapperPreflight, + "{harness_id} must not advertise a generic launch wrapper" + ); + } + } + #[test] fn project_root_is_derived_from_the_memory_root() { let project = ActivationProject::from_memory_root("workspace/.tree-ring").unwrap(); diff --git a/crates/tree-ring-memory-cli/src/activation/bridge.rs b/crates/tree-ring-memory-cli/src/activation/bridge.rs index f8b3088..c7cbdbf 100644 --- a/crates/tree-ring-memory-cli/src/activation/bridge.rs +++ b/crates/tree-ring-memory-cli/src/activation/bridge.rs @@ -389,6 +389,30 @@ impl ProjectFs { self.ensure_root_binding() } + /// Creates one private launcher context at the fixed activation runtime + /// location. The receipt id determines the name; callers cannot select an + /// arbitrary filesystem target or replace an existing entry. + pub(crate) fn create_runtime_context_file( + &self, + receipt_id: &str, + bytes: &[u8], + ) -> Result { + let relative = runtime_context_target(receipt_id)?; + let target = self.resolve_target(&relative, true)?; + if target.read_optional()?.is_some() { + return Err("existing runtime context requires user review".to_string()); + } + self.ensure_root_binding()?; + let publication = target + .publish_creation(bytes) + .map_err(|error| error.message)?; + if let Err(error) = target.sync_parent() { + return Err(MutationError::after_publication(error, publication).message); + } + self.ensure_root_binding()?; + Ok(relative) + } + fn resolve_target_optional( &self, relative: &Path, @@ -487,6 +511,22 @@ impl ProjectFs { self.ensure_root_binding()?; Ok(removed) } + + /// Removes only the fixed private runtime file for this receipt id. + pub(crate) fn remove_runtime_context_file(&self, receipt_id: &str) -> Result { + let relative = runtime_context_target(receipt_id)?; + self.ensure_root_binding()?; + let Some(target) = self.resolve_target_optional(&relative, false)? else { + return Ok(false); + }; + if target.snapshot_optional()?.is_none() { + return Ok(false); + } + self.ensure_root_binding()?; + let removed = target.remove_validated_regular_file()?; + self.ensure_root_binding()?; + Ok(removed) + } } #[cfg(unix)] @@ -792,6 +832,19 @@ fn validate_receipt_target(relative: &Path) -> Result<(), String> { } } +#[cfg(unix)] +fn runtime_context_target(receipt_id: &str) -> Result { + let valid_id = receipt_id.starts_with("receipt-") + && receipt_id.len() <= 128 + && receipt_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); + if !valid_id { + return Err("invalid launcher receipt id".to_string()); + } + Ok(PathBuf::from(".tree-ring/activation/runtime").join(format!("{receipt_id}.md"))) +} + #[cfg(unix)] fn owned_file_descriptor(descriptor: libc::c_int, path: &Path) -> Result { if descriptor < 0 { @@ -885,6 +938,17 @@ impl ProjectFs { Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) } + pub(crate) fn create_runtime_context_file( + &self, + _receipt_id: &str, + _bytes: &[u8], + ) -> Result { + Err( + "launcher mutation requires descriptor-relative no-follow filesystem support" + .to_string(), + ) + } + pub(crate) fn directory_entries( &self, _relative: &Path, @@ -895,6 +959,13 @@ impl ProjectFs { pub(crate) fn remove_validated_receipt_file(&self, _relative: &Path) -> Result { Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) } + + pub(crate) fn remove_runtime_context_file(&self, _receipt_id: &str) -> Result { + Err( + "launcher mutation requires descriptor-relative no-follow filesystem support" + .to_string(), + ) + } } #[cfg(not(unix))] @@ -2274,8 +2345,9 @@ fn sha256(bytes: &[u8]) -> String { fn capability_for(harness_id: &str) -> Result { match harness_id { - "codex" => Ok(AdapterCapability::WrapperPreflight), - "claude-code" | "pi" | "agent-zero" => Ok(AdapterCapability::NativePreflight), + "codex" => Ok(AdapterCapability::GuidanceOnly), + "claude-code" => Ok(AdapterCapability::WrapperPreflight), + "pi" | "agent-zero" => Ok(AdapterCapability::NativePreflight), "hermes" | "opencode" | "goose" => Ok(AdapterCapability::GuidanceOnly), other => Err(format!("unknown harness adapter: {other}")), } diff --git a/crates/tree-ring-memory-cli/src/activation/launcher.rs b/crates/tree-ring-memory-cli/src/activation/launcher.rs new file mode 100644 index 0000000..48476bb --- /dev/null +++ b/crates/tree-ring-memory-cli/src/activation/launcher.rs @@ -0,0 +1,571 @@ +use super::{ + adapters::{adapter_capability, ActivationProject}, + bridge::ProjectFs, + manifest::ActivationManifest, + preflight::{commit_prepared_preflight, prepare_preflight, ActivationError, PreflightRequest}, + AdapterCapability, +}; +use std::{ + ffi::OsString, + fmt, io, + path::{Path, PathBuf}, + process::{Child, Command}, +}; +use tree_ring_memory_sqlite::SQLiteMemoryStore; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchRequest { + harness_id: String, + task_hint: Option, +} + +impl LaunchRequest { + pub fn new(harness_id: impl Into, task_hint: Option) -> Self { + Self { + harness_id: harness_id.into(), + task_hint, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchError(String); + +impl LaunchError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for LaunchError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for LaunchError {} + +impl From for LaunchError { + fn from(error: ActivationError) -> Self { + Self(error.to_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ChildOutcome { + exit_code: Option, +} + +trait LaunchedChild { + fn wait(&mut self) -> io::Result; +} + +trait ClaudeSpawner { + type Child: LaunchedChild; + + fn spawn(&mut self, command: &mut Command) -> io::Result; +} + +struct SystemClaudeSpawner; + +struct SystemClaudeChild(Child); + +impl ClaudeSpawner for SystemClaudeSpawner { + type Child = SystemClaudeChild; + + fn spawn(&mut self, command: &mut Command) -> io::Result { + command.spawn().map(SystemClaudeChild) + } +} + +impl LaunchedChild for SystemClaudeChild { + fn wait(&mut self) -> io::Result { + self.0.wait().map(|status| ChildOutcome { + exit_code: status.code(), + }) + } +} + +/// Launches the fixed Claude Code executable with one private, prepared Tree +/// Ring context file and returns Claude's exit code. +pub fn launch_with_preflight( + store: &SQLiteMemoryStore, + project: &ActivationProject, + manifest: &ActivationManifest, + request: LaunchRequest, + arguments: &[OsString], +) -> Result { + launch_with_spawner( + store, + project, + manifest, + request, + arguments, + &mut SystemClaudeSpawner, + ) +} + +fn launch_with_spawner( + store: &SQLiteMemoryStore, + project: &ActivationProject, + manifest: &ActivationManifest, + request: LaunchRequest, + arguments: &[OsString], + spawner: &mut S, +) -> Result { + if adapter_capability(&request.harness_id) != Some(AdapterCapability::WrapperPreflight) { + return Err(LaunchError::new(format!( + "harness {} does not provide a wrapper preflight", + request.harness_id + ))); + } + if request.harness_id != "claude-code" { + return Err(LaunchError::new( + "only the Claude Code wrapper is supported", + )); + } + + let prepared = prepare_preflight( + store, + project, + manifest, + PreflightRequest::claude_wrapper(request.task_hint), + )?; + let receipt_id = prepared.receipt_id().to_string(); + let runtime_project = ActivationProject::from_memory_root(project.memory_root.clone()) + .map_err(LaunchError::new)?; + let project_fs = ProjectFs::open(&runtime_project).map_err(LaunchError::new)?; + let relative_context = + match project_fs.create_runtime_context_file(&receipt_id, prepared.context().as_bytes()) { + Ok(path) => path, + Err(error) => { + return match project_fs.remove_runtime_context_file(&receipt_id) { + Ok(_) => Err(LaunchError::new(format!( + "failed to create private Claude context: {error}" + ))), + Err(cleanup_error) => Err(cleanup_review_error(error, cleanup_error)), + }; + } + }; + let context_path = absolute_context_path(&runtime_project.project_root, &relative_context); + let mut command = claude_command(&project.project_root, &context_path, arguments); + + let mut child = match spawner.spawn(&mut command) { + Ok(child) => child, + Err(error) => { + cleanup_runtime(&project_fs, &receipt_id)?; + return Err(LaunchError::new(format!( + "failed to spawn Claude Code: {error}" + ))); + } + }; + + let commit_result = commit_prepared_preflight(store, project, manifest, prepared).map_err( + |error| { + LaunchError::new(format!( + "failed to commit launch receipt; receipt state may be indeterminate and requires user review: {error}" + )) + }, + ); + let wait_result = child.wait(); + let cleanup_result = cleanup_runtime(&project_fs, &receipt_id); + + cleanup_result?; + commit_result?; + let outcome = wait_result + .map_err(|error| LaunchError::new(format!("failed to wait for Claude Code: {error}")))?; + outcome + .exit_code + .ok_or_else(|| LaunchError::new("Claude Code terminated without an exit code")) +} + +fn claude_command(project_root: &Path, context_path: &Path, arguments: &[OsString]) -> Command { + let mut command = Command::new("claude"); + command + .current_dir(project_root) + .arg("--append-system-prompt-file") + .arg(context_path) + .arg("--") + .args(arguments); + command +} + +fn absolute_context_path(runtime_root: &Path, relative: &Path) -> PathBuf { + if runtime_root.is_absolute() { + runtime_root.join(relative) + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(runtime_root) + .join(relative) + } +} + +fn cleanup_runtime(project_fs: &ProjectFs, receipt_id: &str) -> Result<(), LaunchError> { + project_fs + .remove_runtime_context_file(receipt_id) + .map(|_| ()) + .map_err(|error| { + LaunchError::new(format!( + "private Claude context cleanup failed; runtime state is indeterminate and requires user review: {error}" + )) + }) +} + +fn cleanup_review_error(primary: String, cleanup: String) -> LaunchError { + LaunchError::new(format!( + "failed to create private Claude context: {primary}; cleanup failed and runtime state is indeterminate and requires user review: {cleanup}" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::activation::{ + adapters::ActivationProject, + manifest::{ + bridge_fingerprint, receipt_files, save_manifest, ActivationManifest, + HarnessActivation, OwnedBridgeFile, + }, + preflight::project_fingerprint, + ActivationState, AdapterCapability, ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, + }; + use std::{ + cell::RefCell, collections::BTreeMap, ffi::OsString, fs, io, os::unix::fs::PermissionsExt, + path::PathBuf, process::Command, rc::Rc, + }; + use tree_ring_memory_core::{MemoryEvent, MemorySource}; + use tree_ring_memory_sqlite::SQLiteMemoryStore; + + struct Fixture { + _temp: tempfile::TempDir, + project: ActivationProject, + store: SQLiteMemoryStore, + manifest: ActivationManifest, + } + + fn fixture() -> Fixture { + let temp = tempfile::tempdir().unwrap(); + let project_root = temp.path().join("project"); + let project = ActivationProject::from_project_root(&project_root); + fs::create_dir_all(&project.memory_root).unwrap(); + let mut activation = HarnessActivation { + state: ActivationState::ConfiguredAwaitingProof, + adapter_capability: AdapterCapability::WrapperPreflight, + adapter_version: "1".to_string(), + bridge_fingerprint: String::new(), + bridge_path: Some(".claude/skills/tree-ring-memory/SKILL.md".to_string()), + owned_files: vec![OwnedBridgeFile { + path: ".claude/skills/tree-ring-memory/SKILL.md".to_string(), + sha256: "b".repeat(64), + }], + managed_blocks: Vec::new(), + }; + activation.bridge_fingerprint = bridge_fingerprint("claude-code", &activation); + let manifest = ActivationManifest { + schema_version: ACTIVATION_SCHEMA_VERSION, + protocol_version: ACTIVATION_PROTOCOL_VERSION, + store_id: "store-launcher-test".to_string(), + project_root_fingerprint: project_fingerprint(&project_root), + cli_version: env!("CARGO_PKG_VERSION").to_string(), + harnesses: BTreeMap::from([("claude-code".to_string(), activation)]), + }; + save_manifest(&project.memory_root, &manifest).unwrap(); + let mut store = SQLiteMemoryStore::open(project.memory_root.join("memory.sqlite")).unwrap(); + let mut memory = MemoryEvent::new( + "Tree Ring preflight context must stay out of process arguments", + "lesson", + ) + .unwrap(); + memory.project = Some("project".to_string()); + memory.agent_profile = Some("claude-code".to_string()); + memory.workflow_id = Some("workflow-1".to_string()); + memory.session_id = Some("session-1".to_string()); + memory.scope = "agent".to_string(); + memory.source = MemorySource { + source_type: "agent".to_string(), + ref_: "docs/constraints.md".to_string(), + quote: String::new(), + }; + store.put(&memory).unwrap(); + Fixture { + _temp: temp, + project, + store, + manifest, + } + } + + fn request_for(harness_id: &str) -> LaunchRequest { + LaunchRequest::new(harness_id, Some("Tree Ring preflight context".to_string())) + } + + fn runtime_context_files(project: &ActivationProject) -> Vec { + let directory = project.memory_root.join("activation/runtime"); + let Ok(entries) = fs::read_dir(directory) else { + return Vec::new(); + }; + entries.map(|entry| entry.unwrap().path()).collect() + } + + #[derive(Debug, Clone, PartialEq, Eq)] + struct CapturedCommand { + program: OsString, + args: Vec, + current_dir: Option, + context_path: PathBuf, + context: String, + mode: u32, + receipt_count_at_spawn: usize, + } + + struct FakeChild { + wait_result: io::Result, + } + + impl LaunchedChild for FakeChild { + fn wait(&mut self) -> io::Result { + self.wait_result + .as_ref() + .map(Clone::clone) + .map_err(|error| io::Error::new(error.kind(), error.to_string())) + } + } + + struct CapturingSpawner { + project: ActivationProject, + captured: Rc>>, + spawn_error: Option, + wait_result: io::Result, + on_spawn: Option>, + } + + impl ClaudeSpawner for CapturingSpawner { + type Child = FakeChild; + + fn spawn(&mut self, command: &mut Command) -> io::Result { + let args = command.get_args().map(OsString::from).collect::>(); + let context_index = args + .iter() + .position(|arg| arg == "--append-system-prompt-file") + .expect("launcher must include the context flag"); + let context_path = PathBuf::from(&args[context_index + 1]); + let metadata = fs::metadata(&context_path).unwrap(); + *self.captured.borrow_mut() = Some(CapturedCommand { + program: command.get_program().to_os_string(), + args, + current_dir: command.get_current_dir().map(PathBuf::from), + context: fs::read_to_string(&context_path).unwrap(), + mode: metadata.permissions().mode() & 0o777, + context_path, + receipt_count_at_spawn: receipt_files(&self.project.memory_root).len(), + }); + if let Some(on_spawn) = self.on_spawn.take() { + on_spawn(); + } + if let Some(kind) = self.spawn_error { + return Err(io::Error::new(kind, "injected spawn failure")); + } + Ok(FakeChild { + wait_result: self + .wait_result + .as_ref() + .map(Clone::clone) + .map_err(|error| io::Error::new(error.kind(), error.to_string())), + }) + } + } + + fn spawner( + project: &ActivationProject, + wait_result: io::Result, + ) -> (CapturingSpawner, Rc>>) { + let captured = Rc::new(RefCell::new(None)); + ( + CapturingSpawner { + project: project.clone(), + captured: Rc::clone(&captured), + spawn_error: None, + wait_result, + on_spawn: None, + }, + captured, + ) + } + + #[test] + fn claude_launcher_injects_preflight_context_from_a_private_file() { + let fixture = fixture(); + let (mut spawner, captured) = + spawner(&fixture.project, Ok(ChildOutcome { exit_code: Some(0) })); + + let exit_code = launch_with_spawner( + &fixture.store, + &fixture.project, + &fixture.manifest, + request_for("claude-code"), + &[OsString::from("--model"), OsString::from("sonnet")], + &mut spawner, + ) + .unwrap(); + + let child = captured.borrow().clone().unwrap(); + assert_eq!(exit_code, 0); + assert_eq!(child.program, OsString::from("claude")); + assert_eq!( + child.current_dir.as_deref(), + Some(fixture.project.project_root.as_path()) + ); + let context_argument = child.context_path.as_os_str(); + assert!(child.args.windows(2).any(|pair| { + pair[0] == "--append-system-prompt-file" && pair[1] == context_argument + })); + assert_eq!( + &child.args[child.args.len() - 3..], + [ + OsString::from("--"), + OsString::from("--model"), + OsString::from("sonnet") + ] + ); + assert_eq!(child.mode, 0o600); + assert!(child + .context + .contains("Tree Ring Memory scoped preflight recall")); + assert!(!child.args.iter().any(|arg| arg + .to_string_lossy() + .contains("must stay out of process arguments"))); + assert_eq!(child.receipt_count_at_spawn, 0); + assert_eq!(receipt_files(&fixture.project.memory_root).len(), 1); + assert!(runtime_context_files(&fixture.project).is_empty()); + } + + #[test] + fn wrapper_rejects_an_unsupported_harness_without_writing_context() { + let fixture = fixture(); + let (mut spawner, _captured) = + spawner(&fixture.project, Ok(ChildOutcome { exit_code: Some(0) })); + + for harness_id in ["codex", "pi", "agent-zero"] { + let error = launch_with_spawner( + &fixture.store, + &fixture.project, + &fixture.manifest, + request_for(harness_id), + &[], + &mut spawner, + ) + .unwrap_err(); + + assert!(error + .to_string() + .contains("does not provide a wrapper preflight")); + } + assert!(runtime_context_files(&fixture.project).is_empty()); + assert!(receipt_files(&fixture.project.memory_root).is_empty()); + } + + #[test] + fn spawn_failure_removes_context_and_writes_no_receipt() { + let fixture = fixture(); + let (mut spawner, _captured) = + spawner(&fixture.project, Ok(ChildOutcome { exit_code: Some(0) })); + spawner.spawn_error = Some(io::ErrorKind::NotFound); + + let error = launch_with_spawner( + &fixture.store, + &fixture.project, + &fixture.manifest, + request_for("claude-code"), + &[], + &mut spawner, + ) + .unwrap_err(); + + assert!(error.to_string().contains("failed to spawn Claude Code")); + assert!(runtime_context_files(&fixture.project).is_empty()); + assert!(receipt_files(&fixture.project.memory_root).is_empty()); + } + + #[test] + fn abnormal_child_outcome_removes_context_and_surfaces_failure() { + let fixture = fixture(); + let (mut spawner, _captured) = + spawner(&fixture.project, Ok(ChildOutcome { exit_code: None })); + + let error = launch_with_spawner( + &fixture.store, + &fixture.project, + &fixture.manifest, + request_for("claude-code"), + &[], + &mut spawner, + ) + .unwrap_err(); + + assert!(error + .to_string() + .contains("terminated without an exit code")); + assert!(runtime_context_files(&fixture.project).is_empty()); + assert_eq!(receipt_files(&fixture.project.memory_root).len(), 1); + } + + #[test] + fn child_wait_error_removes_context_and_surfaces_failure() { + let fixture = fixture(); + let (mut spawner, _captured) = spawner( + &fixture.project, + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "injected wait failure", + )), + ); + + let error = launch_with_spawner( + &fixture.store, + &fixture.project, + &fixture.manifest, + request_for("claude-code"), + &[], + &mut spawner, + ) + .unwrap_err(); + + assert!(error.to_string().contains("failed to wait for Claude Code")); + assert!(runtime_context_files(&fixture.project).is_empty()); + assert_eq!(receipt_files(&fixture.project.memory_root).len(), 1); + } + + #[test] + fn contract_change_after_spawn_blocks_receipt_and_removes_context() { + let fixture = fixture(); + let (mut spawner, _captured) = + spawner(&fixture.project, Ok(ChildOutcome { exit_code: Some(0) })); + let memory_root = fixture.project.memory_root.clone(); + let mut changed = fixture.manifest.clone(); + changed + .harnesses + .get_mut("claude-code") + .unwrap() + .adapter_capability = AdapterCapability::NativePreflight; + spawner.on_spawn = Some(Box::new(move || { + save_manifest(&memory_root, &changed).unwrap(); + })); + + let error = launch_with_spawner( + &fixture.store, + &fixture.project, + &fixture.manifest, + request_for("claude-code"), + &[], + &mut spawner, + ) + .unwrap_err(); + + assert!(error + .to_string() + .contains("failed to commit launch receipt")); + assert!(runtime_context_files(&fixture.project).is_empty()); + assert!(receipt_files(&fixture.project.memory_root).is_empty()); + } +} diff --git a/crates/tree-ring-memory-cli/src/activation/mod.rs b/crates/tree-ring-memory-cli/src/activation/mod.rs index fdc5236..60d4182 100644 --- a/crates/tree-ring-memory-cli/src/activation/mod.rs +++ b/crates/tree-ring-memory-cli/src/activation/mod.rs @@ -36,6 +36,7 @@ pub struct SessionIdentity { pub mod adapters; pub mod bridge; +pub mod launcher; pub mod manifest; pub mod preflight; diff --git a/crates/tree-ring-memory-cli/src/activation/preflight.rs b/crates/tree-ring-memory-cli/src/activation/preflight.rs index 0cfe8f5..fb8e9b6 100644 --- a/crates/tree-ring-memory-cli/src/activation/preflight.rs +++ b/crates/tree-ring-memory-cli/src/activation/preflight.rs @@ -1,5 +1,5 @@ use super::{ - adapters::{adapter_version, ActivationProject}, + adapters::{adapter_capability, adapter_version, ActivationProject}, bridge::ProjectFs, manifest::{ bridge_fingerprint, fingerprint, fingerprint_path, @@ -45,6 +45,7 @@ pub struct PreflightRequest { enum PreflightInputContract { DirectIdentityFlags, AdapterStdin, + ClaudeWrapper, } impl PreflightRequest { @@ -79,6 +80,23 @@ impl PreflightRequest { input_contract: PreflightInputContract::AdapterStdin, } } + + /// Constructs the launcher-owned Claude Code request. The wrapper binds a + /// fresh trusted identity rather than widening the direct Codex contract. + pub(crate) fn claude_wrapper(task_hint: Option) -> Self { + let invocation_id = Uuid::new_v4().hyphenated().to_string(); + Self { + harness_id: "claude-code".to_string(), + identity: SessionIdentity { + agent_profile: "claude-code".to_string(), + workflow_id: format!("claude-launch-{invocation_id}"), + session_id: format!("claude-session-{invocation_id}"), + }, + task_hint, + context_format: PreflightContextFormat::Json, + input_contract: PreflightInputContract::ClaudeWrapper, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -131,6 +149,37 @@ pub fn run_preflight( manifest: &ActivationManifest, request: PreflightRequest, ) -> Result { + let prepared = prepare_preflight(store, project, manifest, request)?; + commit_prepared_preflight(store, project, manifest, prepared) +} + +/// Opaque preflight material that has passed recall and render checks but has +/// not yet produced a durable activation receipt. +pub(crate) struct PreparedPreflight { + request: PreflightRequest, + snapshot: PreflightSnapshot, + receipt: ActivationReceipt, + response: PreflightResponse, +} + +impl PreparedPreflight { + pub(crate) fn context(&self) -> &str { + &self.response.context + } + + pub(crate) fn receipt_id(&self) -> &str { + &self.receipt.receipt_id + } +} + +/// Prepares safe, rendered preflight material without persisting a receipt. +/// Only sibling activation integrations can cross this transactional boundary. +pub(crate) fn prepare_preflight( + store: &SQLiteMemoryStore, + project: &ActivationProject, + manifest: &ActivationManifest, + request: PreflightRequest, +) -> Result { validate_request_contract(&request)?; validate_identity(&request.identity)?; validate_manifest(manifest) @@ -197,21 +246,41 @@ pub fn run_preflight( // Force construction of the complete adapter payload before any receipt exists. render_for_format(&response, request.context_format)?; - run_pre_commit_hook(&request); + Ok(PreparedPreflight { + request, + snapshot, + receipt, + response, + }) +} + +/// Revalidates the complete activation contract and commits the receipt for +/// previously prepared material. +pub(crate) fn commit_prepared_preflight( + store: &SQLiteMemoryStore, + project: &ActivationProject, + manifest: &ActivationManifest, + prepared: PreparedPreflight, +) -> Result { + let receipt_project = + ActivationProject::from_memory_root(project.memory_root.clone()).map_err(storage_error)?; + let project_fs = ProjectFs::open(&receipt_project).map_err(storage_error)?; + run_pre_commit_hook(&prepared.request); commit_receipt( &project_fs, store, project, manifest, - &snapshot, - &request, - &receipt, + &prepared.snapshot, + &prepared.request, + &prepared.receipt, )?; - Ok(response) + Ok(prepared.response) } #[derive(Debug, Clone, PartialEq, Eq)] struct CurrentActivationContract { + adapter_capability: super::AdapterCapability, adapter_version: String, bridge_fingerprint: String, manifest_matches_registry: bool, @@ -320,11 +389,15 @@ fn current_activation_contract( let adapter_version = adapter_version(harness_id) .ok_or_else(|| ActivationError::new("unknown harness adapter"))? .to_string(); + let adapter_capability = adapter_capability(harness_id) + .ok_or_else(|| ActivationError::new("unknown harness adapter"))?; let mut registry_activation = activation.clone(); registry_activation.adapter_version = adapter_version.clone(); + registry_activation.adapter_capability = adapter_capability; let bridge_fingerprint = bridge_fingerprint(harness_id, ®istry_activation); Ok(CurrentActivationContract { - manifest_matches_registry: activation.adapter_version == adapter_version + manifest_matches_registry: activation.adapter_capability == adapter_capability + && activation.adapter_version == adapter_version && activation.bridge_fingerprint == bridge_fingerprint, eligible_for_preflight: matches!( activation.state, @@ -333,6 +406,7 @@ fn current_activation_contract( | ActivationState::Active | ActivationState::ActiveIsolated ), + adapter_capability, adapter_version, bridge_fingerprint, }) @@ -480,6 +554,13 @@ fn validate_request_contract(request: &PreflightRequest) -> Result<(), Activatio PreflightInputContract::AdapterStdin => { matches_adapter_stdin_contract(&request.harness_id, request.context_format) } + PreflightInputContract::ClaudeWrapper => { + request.harness_id == "claude-code" + && request.context_format == PreflightContextFormat::Json + && request.identity.agent_profile == "claude-code" + && request.identity.workflow_id.starts_with("claude-launch-") + && request.identity.session_id.starts_with("claude-session-") + } }; valid .then_some(()) diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs index 864da38..1eddcdd 100644 --- a/crates/tree-ring-memory-cli/src/main.rs +++ b/crates/tree-ring-memory-cli/src/main.rs @@ -377,6 +377,15 @@ enum IntegrationCommand { )] out_dir: Option, }, + #[command(about = "launch Claude Code with a private Tree Ring preflight context")] + Launch { + #[arg(long, help = "harness id; only claude-code provides a launch wrapper")] + harness: String, + #[arg(long, help = "optional non-sensitive recall hint")] + task_hint: Option, + #[arg(last = true, allow_hyphen_values = true)] + arguments: Vec, + }, } #[derive(Debug, Subcommand)] @@ -413,7 +422,52 @@ fn main() -> std::process::ExitCode { if let Some((root, json_output)) = global_welcome_request(&args) { return exit_from_result(welcome::run(&root, false, false, json_output)); } - exit_from_result(run(Cli::parse_from(args))) + let cli = Cli::parse_from(args); + if let Some(result) = run_launch_command(&cli) { + return exit_from_launch_result(result); + } + exit_from_result(run(cli)) +} + +fn run_launch_command(cli: &Cli) -> Option> { + let Command::Integrations { + command: + IntegrationCommand::Launch { + harness, + task_hint, + arguments, + }, + } = &cli.command + else { + return None; + }; + Some((|| { + if cli.json { + return Err("--json is not supported with integrations launch".to_string()); + } + let project = activation::adapters::ActivationProject::from_memory_root(cli.root.clone())?; + let manifest = activation::load_manifest(&cli.root)?; + let store = SQLiteMemoryStore::open_read_only(cli.root.join("memory.sqlite")) + .map_err(|error| error.to_string())?; + activation::launcher::launch_with_preflight( + &store, + &project, + &manifest, + activation::launcher::LaunchRequest::new(harness, task_hint.clone()), + arguments, + ) + .map_err(|error| error.to_string()) + })()) +} + +fn exit_from_launch_result(result: Result) -> std::process::ExitCode { + match result { + Ok(code) => std::process::ExitCode::from(u8::try_from(code).unwrap_or(2)), + Err(error) => { + eprintln!("{error}"); + std::process::ExitCode::from(2) + } + } } fn exit_from_result(result: Result<(), String>) -> std::process::ExitCode { @@ -1553,6 +1607,39 @@ mod tests { use tree_ring_memory_core::{ConsolidationPeriod, ConsolidationRequest}; use tree_ring_memory_sqlite::MemoryRetriever; + #[test] + fn integrations_launch_parses_only_a_claude_harness_and_trailing_arguments() { + let cli = Cli::try_parse_from([ + "tree-ring", + "integrations", + "launch", + "--harness", + "claude-code", + "--", + "--model", + "sonnet", + ]) + .unwrap(); + + let Command::Integrations { + command: + IntegrationCommand::Launch { + harness, + task_hint, + arguments, + }, + } = cli.command + else { + panic!("expected integrations launch command"); + }; + assert_eq!(harness, "claude-code"); + assert_eq!(task_hint, None); + assert_eq!( + arguments, + vec![OsString::from("--model"), OsString::from("sonnet")] + ); + } + #[test] fn cli_init_creates_store() { let dir = tempdir().unwrap(); diff --git a/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs b/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs new file mode 100644 index 0000000..f517f48 --- /dev/null +++ b/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs @@ -0,0 +1,141 @@ +#![cfg(unix)] + +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + ffi::OsString, + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::Command, +}; +use tree_ring_memory_cli::activation::{ + manifest::{bridge_fingerprint, save_manifest}, + ActivationManifest, ActivationState, AdapterCapability, HarnessActivation, OwnedBridgeFile, + ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, +}; +use tree_ring_memory_sqlite::SQLiteMemoryStore; + +#[test] +fn claude_launch_cli_forwards_only_the_private_context_path_and_child_arguments() { + let temp = tempfile::tempdir().unwrap(); + let project_root = temp.path().join("project"); + let memory_root = project_root.join(".tree-ring"); + fs::create_dir_all(project_root.join(".claude/skills/tree-ring-memory")).unwrap(); + fs::create_dir_all(&memory_root).unwrap(); + fs::write( + project_root.join(".claude/skills/tree-ring-memory/SKILL.md"), + "Tree Ring bridge fixture\n", + ) + .unwrap(); + drop(SQLiteMemoryStore::open(memory_root.join("memory.sqlite")).unwrap()); + + let mut activation = HarnessActivation { + state: ActivationState::ConfiguredAwaitingProof, + adapter_capability: AdapterCapability::WrapperPreflight, + adapter_version: "1".to_string(), + bridge_fingerprint: String::new(), + bridge_path: Some(".claude/skills/tree-ring-memory/SKILL.md".to_string()), + owned_files: vec![OwnedBridgeFile { + path: ".claude/skills/tree-ring-memory/SKILL.md".to_string(), + sha256: "b".repeat(64), + }], + managed_blocks: Vec::new(), + }; + activation.bridge_fingerprint = bridge_fingerprint("claude-code", &activation); + let manifest = ActivationManifest { + schema_version: ACTIVATION_SCHEMA_VERSION, + protocol_version: ACTIVATION_PROTOCOL_VERSION, + store_id: "store-cli-launch-test".to_string(), + project_root_fingerprint: fingerprint_path(&project_root), + cli_version: env!("CARGO_PKG_VERSION").to_string(), + harnesses: BTreeMap::from([("claude-code".to_string(), activation)]), + }; + save_manifest(&memory_root, &manifest).unwrap(); + + let fake_bin = temp.path().join("bin"); + fs::create_dir_all(&fake_bin).unwrap(); + let fake_claude = fake_bin.join("claude"); + fs::write( + &fake_claude, + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$TREE_RING_LAUNCH_ARGS\"\nif [ ! -f \"$2\" ]; then exit 91; fi\nhead -n 1 \"$2\" > \"$TREE_RING_LAUNCH_CONTEXT\"\nexit 7\n", + ) + .unwrap(); + fs::set_permissions(&fake_claude, fs::Permissions::from_mode(0o755)).unwrap(); + let captured_args = temp.path().join("args.txt"); + let captured_context = temp.path().join("context.txt"); + let path = prepend_path(&fake_bin); + + let output = Command::new(env!("CARGO_BIN_EXE_tree-ring")) + .env("PATH", path) + .env("TREE_RING_LAUNCH_ARGS", &captured_args) + .env("TREE_RING_LAUNCH_CONTEXT", &captured_context) + .arg("--root") + .arg(&memory_root) + .arg("integrations") + .arg("launch") + .arg("--harness") + .arg("claude-code") + .arg("--") + .arg("--model") + .arg("sonnet") + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(7)); + let args = fs::read_to_string(captured_args).unwrap(); + let lines = args.lines().collect::>(); + assert_eq!(lines[0], "--append-system-prompt-file"); + assert!(lines[1].ends_with(".md")); + assert_eq!(&lines[2..], ["--", "--model", "sonnet"]); + assert_eq!( + fs::read_to_string(captured_context).unwrap().trim(), + "Tree Ring Memory scoped preflight recall:" + ); + assert!(directory_files(&memory_root.join("activation/runtime")).is_empty()); + assert_eq!( + json_files_below(&memory_root.join("activation/receipts")), + 1 + ); +} + +fn fingerprint_path(path: &Path) -> String { + let canonical = fs::canonicalize(path).unwrap(); + let mut hasher = Sha256::new(); + hasher.update(canonical.to_string_lossy().as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn prepend_path(directory: &Path) -> OsString { + let mut entries = vec![directory.to_path_buf()]; + if let Some(existing) = std::env::var_os("PATH") { + entries.extend(std::env::split_paths(&existing)); + } + std::env::join_paths(entries).unwrap() +} + +fn directory_files(directory: &Path) -> Vec { + let Ok(entries) = fs::read_dir(directory) else { + return Vec::new(); + }; + entries.map(|entry| entry.unwrap().path()).collect() +} + +fn json_files_below(directory: &Path) -> usize { + let Ok(entries) = fs::read_dir(directory) else { + return 0; + }; + entries + .map(|entry| entry.unwrap().path()) + .map(|path| { + if path.is_dir() { + json_files_below(&path) + } else { + usize::from( + path.extension() + .is_some_and(|extension| extension == "json"), + ) + } + }) + .sum() +} From ddb144040c11aa7bd082d9ee512fbc904221fab3 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 01:16:59 -0400 Subject: [PATCH 17/31] feat: activate harnesses during tree-ring init --- .../src/actions/integrations.rs | 608 +++++++++++++++- .../src/agent_awareness.rs | 54 ++ crates/tree-ring-memory-cli/src/main.rs | 658 +++++++++++++++++- 3 files changed, 1286 insertions(+), 34 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/actions/integrations.rs b/crates/tree-ring-memory-cli/src/actions/integrations.rs index 996958a..965b919 100644 --- a/crates/tree-ring-memory-cli/src/actions/integrations.rs +++ b/crates/tree-ring-memory-cli/src/actions/integrations.rs @@ -1,6 +1,21 @@ -use std::path::PathBuf; - -use tree_ring_memory_cli::activation::adapters::{scan_integrations, IntegrationScanReport}; +use chrono::{Duration, Utc}; +use serde::Serialize; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use tree_ring_memory_cli::activation::{ + self, + adapters::{scan_integrations, ActivationProject, IntegrationScanReport}, + bridge::{apply_bridge_plan, deactivate_bridge_plan}, + manifest::{bridge_fingerprint, ActivationManifest, ActivationReceipt}, + ActivationState, AdapterCapability, PreflightContextFormat, PreflightRequest, SessionIdentity, + ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, RECEIPT_RETENTION_DAYS, +}; +use tree_ring_memory_core::sensitivity::SensitivityGuard; +use tree_ring_memory_sqlite::SQLiteMemoryStore; +use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq)] pub struct IntegrationScanRequest { @@ -12,18 +27,552 @@ pub struct IntegrationScanActionReport { pub report: IntegrationScanReport, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IntegrationStatusRequest { + pub source_root: PathBuf, + pub memory_root: PathBuf, + pub verbose: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct IntegrationStatusEntry { + pub id: String, + pub name: String, + pub state: ActivationState, + pub capability: AdapterCapability, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub managed_paths: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub receipt_age_seconds: Option, + pub next_step: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct IntegrationStatusActionReport { + #[serde(skip_serializing_if = "Option::is_none")] + pub store_id: Option, + pub integrations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IntegrationActivationRequest { + pub harness_id: String, + pub source_root: PathBuf, + pub memory_root: PathBuf, + pub dry_run: bool, + pub accept_managed_block: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct IntegrationLifecycleActionReport { + pub harness_id: String, + pub state: ActivationState, + pub changed_paths: Vec, + pub dry_run: bool, + pub next_step: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreflightIdentityInput { + pub agent_profile: Option, + pub workflow_id: Option, + pub session_id: Option, +} + pub fn scan(request: IntegrationScanRequest) -> IntegrationScanActionReport { IntegrationScanActionReport { report: scan_integrations(&request.source_root), } } +/// Reads activation state without opening or migrating the SQLite store. +pub fn status(request: IntegrationStatusRequest) -> Result { + let scan = scan_integrations(&request.source_root); + let manifest = optional_manifest(&request.memory_root)?.filter(|manifest| { + manifest.project_root_fingerprint == path_fingerprint(&request.source_root) + }); + let now = Utc::now(); + let integrations = scan + .integrations + .into_iter() + .map(|detected| { + let activation = manifest + .as_ref() + .and_then(|manifest| manifest.harnesses.get(&detected.id)); + let receipt = manifest.as_ref().and_then(|manifest| { + activation.and_then(|activation| { + freshest_matching_receipt( + &request.memory_root, + &detected.id, + manifest, + activation, + ) + }) + }); + let state = receipt + .as_ref() + .map(|receipt| receipt.state) + .or_else(|| activation.map(|activation| activation.state)) + .unwrap_or(detected.state); + let managed_paths = if request.verbose { + activation + .map(|activation| { + activation + .owned_files + .iter() + .map(|owned| owned.path.clone()) + .chain( + activation + .managed_blocks + .iter() + .map(|owned| owned.path.clone()), + ) + .collect::>() + .into_iter() + .collect() + }) + .unwrap_or_default() + } else { + Vec::new() + }; + let receipt_age_seconds = request + .verbose + .then(|| { + receipt.map(|receipt| { + now.signed_duration_since(receipt.recorded_at) + .num_seconds() + .max(0) + }) + }) + .flatten(); + IntegrationStatusEntry { + id: detected.id, + name: detected.name, + state, + capability: detected.capability, + managed_paths, + receipt_age_seconds, + next_step: next_step_for_state(state, &detected.next_step), + } + }) + .collect(); + Ok(IntegrationStatusActionReport { + store_id: manifest.map(|manifest| manifest.store_id), + integrations, + }) +} + +/// Applies a single adapter plan, or reports its validated read-only dry-run. +pub fn activate( + request: IntegrationActivationRequest, +) -> Result { + let project = ActivationProject { + project_root: request.source_root.clone(), + memory_root: request.memory_root.clone(), + }; + let detection = scan_integrations(&request.source_root) + .by_id(&request.harness_id) + .cloned() + .ok_or_else(|| format!("unknown harness adapter: {}", request.harness_id))?; + if request.dry_run { + return Ok(IntegrationLifecycleActionReport { + harness_id: detection.id, + state: detection.plan.state, + changed_paths: detection + .plan + .writes + .iter() + .map(planned_path) + .collect::, _>>()?, + dry_run: true, + next_step: detection.plan.next_step, + }); + } + let mut manifest = activation::load_manifest(&request.memory_root).map_err(|_| { + "activation manifest is unavailable; run `tree-ring init` before activation".to_string() + })?; + ensure_manifest_project(&manifest, &request.source_root)?; + let result = apply_bridge_plan( + &project, + &mut manifest, + detection.plan, + request.accept_managed_block, + )?; + Ok(IntegrationLifecycleActionReport { + harness_id: request.harness_id, + state: result.state, + changed_paths: relative_paths(result.changed_paths)?, + dry_run: false, + next_step: result.next_step, + }) +} + +pub fn deactivate( + harness_id: &str, + source_root: PathBuf, + memory_root: PathBuf, +) -> Result { + let project = ActivationProject { + project_root: source_root, + memory_root: memory_root.clone(), + }; + let mut manifest = activation::load_manifest(&memory_root).map_err(|_| { + "activation manifest is unavailable; run `tree-ring init` before deactivation".to_string() + })?; + ensure_manifest_project(&manifest, &project.project_root)?; + let result = deactivate_bridge_plan(&project, &mut manifest, harness_id)?; + Ok(IntegrationLifecycleActionReport { + harness_id: harness_id.to_string(), + state: result.state, + changed_paths: relative_paths(result.changed_paths)?, + dry_run: false, + next_step: result.next_step, + }) +} + +/// Creates an in-memory manifest suitable for the first create-only bridge +/// publication. The bridge layer persists it atomically with the safe plan. +pub fn new_manifest(project_root: &Path) -> ActivationManifest { + ActivationManifest { + schema_version: ACTIVATION_SCHEMA_VERSION, + protocol_version: ACTIVATION_PROTOCOL_VERSION, + store_id: Uuid::new_v4().hyphenated().to_string(), + project_root_fingerprint: path_fingerprint(project_root), + cli_version: env!("CARGO_PKG_VERSION").to_string(), + harnesses: Default::default(), + } +} + +/// Validates the complete input contract before callers open the store. +pub fn resolve_preflight_request( + project: &ActivationProject, + harness_id: &str, + identity: PreflightIdentityInput, + input_json: Option<&str>, + context_format: PreflightContextFormat, +) -> Result { + if let Some(input) = input_json { + if identity.agent_profile.is_some() + || identity.workflow_id.is_some() + || identity.session_id.is_some() + { + return Err( + "--input-json-stdin cannot be combined with direct identity flags".to_string(), + ); + } + return resolve_adapter_stdin(project, harness_id, context_format, input); + } + + let (Some(agent_profile), Some(workflow_id), Some(session_id)) = ( + identity.agent_profile, + identity.workflow_id, + identity.session_id, + ) else { + return Err( + "direct preflight requires --agent-profile, --workflow-id, and --session-id" + .to_string(), + ); + }; + if harness_id != "codex" || context_format != PreflightContextFormat::Json { + return Err( + "direct identity flags are supported only for Codex JSON preflight".to_string(), + ); + } + Ok(PreflightRequest::direct( + harness_id, + SessionIdentity { + agent_profile, + workflow_id, + session_id, + }, + None, + context_format, + )) +} + +pub fn run_preflight( + store: &SQLiteMemoryStore, + project: &ActivationProject, + manifest: &ActivationManifest, + request: PreflightRequest, + context_format: PreflightContextFormat, +) -> Result { + let response = activation::run_preflight(store, project, manifest, request) + .map_err(|error| error.to_string())?; + match context_format { + PreflightContextFormat::ClaudeSessionStart => { + activation::render_claude_session_start(&response) + } + PreflightContextFormat::PiBeforeAgentStart => activation::render_pi_context(&response), + PreflightContextFormat::Json => activation::render_json_context(&response), + } + .map_err(|error| error.to_string()) +} + +fn resolve_adapter_stdin( + project: &ActivationProject, + harness_id: &str, + context_format: PreflightContextFormat, + input: &str, +) -> Result { + let value: Value = + serde_json::from_str(input).map_err(|_| "invalid adapter preflight stdin".to_string())?; + let object = value + .as_object() + .ok_or_else(|| "invalid adapter preflight stdin".to_string())?; + reject_derived_or_capability_fields(object)?; + let sanitized = match harness_id { + "claude-code" => sanitize_claude_input(project, object)?, + "pi" => sanitize_pi_input(object)?, + "agent-zero" => sanitize_agent_zero_input(object)?, + _ => return Err("unknown harness adapter".to_string()), + }; + activation::parse_adapter_stdin( + harness_id, + context_format, + &serde_json::to_string(&sanitized).map_err(|_| "invalid adapter preflight stdin")?, + ) + .map_err(|error| error.to_string()) +} + +fn sanitize_claude_input( + project: &ActivationProject, + object: &Map, +) -> Result { + let session_id = required_string(object, "session_id")?; + let cwd = required_string(object, "cwd")?; + if let Some(agent_type) = object.get("agent_type") { + let agent_type = agent_type + .as_str() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "invalid adapter preflight stdin".to_string())?; + if agent_type != "claude-code" { + return Err("ambiguous Claude Code adapter identity".to_string()); + } + } + ensure_cwd_inside_project(project, cwd)?; + Ok(serde_json::json!({ + "session_id": session_id, + "workflow_id": session_id, + })) +} + +fn sanitize_pi_input(object: &Map) -> Result { + reject_unknown_fields( + object, + &["agent_profile", "workflow_id", "session_id", "task_hint"], + )?; + let mut sanitized = serde_json::json!({ + "agent_profile": required_string(object, "agent_profile")?, + "workflow_id": required_string(object, "workflow_id")?, + "session_id": required_string(object, "session_id")?, + }); + if let Some(Value::String(hint)) = object.get("task_hint") { + if !hint.trim().is_empty() + && SensitivityGuard::default().inspect(hint).sensitivity == "normal" + { + sanitized["task_hint"] = Value::String(hint.clone()); + } + } else if object + .get("task_hint") + .is_some_and(|value| !value.is_null()) + { + return Err("invalid adapter preflight stdin".to_string()); + } + Ok(sanitized) +} + +fn sanitize_agent_zero_input(object: &Map) -> Result { + reject_unknown_fields(object, &["agent_profile", "workflow_id", "session_id"])?; + Ok(serde_json::json!({ + "agent_profile": required_string(object, "agent_profile")?, + "workflow_id": required_string(object, "workflow_id")?, + "session_id": required_string(object, "session_id")?, + })) +} + +fn reject_unknown_fields(object: &Map, allowed: &[&str]) -> Result<(), String> { + if object.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err("invalid adapter preflight stdin fields".to_string()); + } + Ok(()) +} + +fn reject_derived_or_capability_fields(object: &Map) -> Result<(), String> { + const BLOCKED: &[&str] = &[ + "authorization", + "bridge_fingerprint", + "capability", + "capabilities", + "coordinator_capability", + "harness_id", + "memory_root", + "project_root", + "project_root_fingerprint", + "receipt", + "receipt_id", + "state", + "store_id", + "token", + "tree_ring_coordinator_token", + ]; + fn contains_blocked(value: &Value, blocked: &[&str]) -> bool { + match value { + Value::Object(object) => object.iter().any(|(key, value)| { + let key = key.to_ascii_lowercase().replace('-', "_"); + blocked.contains(&key.as_str()) || contains_blocked(value, blocked) + }), + Value::Array(values) => values.iter().any(|value| contains_blocked(value, blocked)), + _ => false, + } + } + if contains_blocked(&Value::Object(object.clone()), BLOCKED) { + return Err("adapter preflight stdin contains a forbidden field".to_string()); + } + Ok(()) +} + +fn required_string<'a>(object: &'a Map, name: &str) -> Result<&'a str, String> { + object + .get(name) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "invalid adapter preflight stdin".to_string()) +} + +fn ensure_cwd_inside_project(project: &ActivationProject, cwd: &str) -> Result<(), String> { + let project_root = fs::canonicalize(&project.project_root) + .map_err(|_| "Claude Code project root is unavailable".to_string())?; + let candidate = Path::new(cwd); + let candidate = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + project_root.join(candidate) + }; + let candidate = + fs::canonicalize(candidate).map_err(|_| "Claude Code cwd is unavailable".to_string())?; + if !candidate.starts_with(&project_root) { + return Err("Claude Code cwd is outside the activation project".to_string()); + } + Ok(()) +} + +fn optional_manifest(memory_root: &Path) -> Result, String> { + if !memory_root.join("activation.json").exists() { + return Ok(None); + } + activation::load_manifest(memory_root).map(Some) +} + +fn ensure_manifest_project( + manifest: &ActivationManifest, + source_root: &Path, +) -> Result<(), String> { + if manifest.project_root_fingerprint != path_fingerprint(source_root) { + return Err("activation manifest does not belong to the requested project".to_string()); + } + Ok(()) +} + +fn freshest_matching_receipt( + memory_root: &Path, + harness_id: &str, + manifest: &ActivationManifest, + harness: &activation::HarnessActivation, +) -> Option { + if harness.bridge_fingerprint != bridge_fingerprint(harness_id, harness) { + return None; + } + let mut pending = vec![memory_root.join("activation/receipts").join(harness_id)]; + let mut receipts = Vec::new(); + while let Some(path) = pending.pop() { + let Ok(entries) = fs::read_dir(path) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + pending.push(path); + } else if path.extension().and_then(|value| value.to_str()) == Some("json") { + if let Ok(bytes) = fs::read(&path) { + if let Ok(receipt) = serde_json::from_slice::(&bytes) { + receipts.push(receipt); + } + } + } + } + } + let now = Utc::now(); + receipts + .into_iter() + .filter(|receipt| { + receipt.harness_id == harness_id + && receipt.protocol_version == manifest.protocol_version + && receipt.adapter_version == harness.adapter_version + && receipt.bridge_fingerprint == harness.bridge_fingerprint + && receipt.store_id == manifest.store_id + && receipt.project_root_fingerprint == manifest.project_root_fingerprint + && receipt.status == "success" + && matches!( + receipt.state, + ActivationState::Active | ActivationState::ActiveIsolated + ) + && receipt.recorded_at <= now + && receipt.recorded_at > now - Duration::days(RECEIPT_RETENTION_DAYS) + }) + .max_by_key(|receipt| receipt.recorded_at) +} + +fn next_step_for_state(state: ActivationState, detected_next_step: &str) -> String { + match state { + ActivationState::Active => "No action required for the receipt-backed session.".to_string(), + ActivationState::ActiveIsolated => { + "Bind the harness to this project's canonical store before claiming shared use." + .to_string() + } + ActivationState::ConfiguredAwaitingProof => { + "Run the adapter preflight at the start of a new harness session.".to_string() + } + _ => detected_next_step.to_string(), + } +} + +fn planned_path(write: &activation::adapters::PlannedWrite) -> Result { + let path = match write { + activation::adapters::PlannedWrite::BridgeWrite(write) => &write.path, + activation::adapters::PlannedWrite::ManagedBlockUpdate(write) => &write.path, + }; + relative_path(path) +} + +fn relative_paths(paths: Vec) -> Result, String> { + paths.into_iter().map(|path| relative_path(&path)).collect() +} + +fn relative_path(path: &Path) -> Result { + if path.is_absolute() + || path + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + return Err("activation report path must be project-relative".to_string()); + } + path.to_str() + .map(str::to_string) + .ok_or_else(|| "activation report path must be UTF-8".to_string()) +} + +fn path_fingerprint(path: &Path) -> String { + let path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let mut hasher = Sha256::new(); + hasher.update(path.to_string_lossy().as_bytes()); + format!("{:x}", hasher.finalize()) +} + #[cfg(test)] mod tests { - use std::fs; - use tempfile::tempdir; - use super::*; + use tempfile::tempdir; #[test] fn integration_action_scans_project_markers() { @@ -36,4 +585,51 @@ mod tests { assert!(report.report.detected_count > 0); } + + #[test] + fn agent_zero_stdin_rejects_task_hints_and_capabilities() { + let dir = tempdir().unwrap(); + let project = ActivationProject::from_project_root(dir.path()); + for input in [ + r#"{"agent_profile":"a","workflow_id":"w","session_id":"s","task_hint":"prompt"}"#, + r#"{"agent_profile":"a","workflow_id":"w","session_id":"s","capability":"secret"}"#, + ] { + assert!(resolve_preflight_request( + &project, + "agent-zero", + PreflightIdentityInput { + agent_profile: None, + workflow_id: None, + session_id: None, + }, + Some(input), + PreflightContextFormat::Json, + ) + .is_err()); + } + } + + #[test] + fn claude_stdin_rejects_an_out_of_project_cwd() { + let project_dir = tempdir().unwrap(); + let outside = tempdir().unwrap(); + let project = ActivationProject::from_project_root(project_dir.path()); + let input = serde_json::json!({ + "session_id": "session-1", + "cwd": outside.path(), + "transcript_path": "/ignored/private/transcript", + }); + assert!(resolve_preflight_request( + &project, + "claude-code", + PreflightIdentityInput { + agent_profile: None, + workflow_id: None, + session_id: None, + }, + Some(&input.to_string()), + PreflightContextFormat::ClaudeSessionStart, + ) + .is_err()); + } } diff --git a/crates/tree-ring-memory-cli/src/agent_awareness.rs b/crates/tree-ring-memory-cli/src/agent_awareness.rs index fb53799..8f6e5b0 100644 --- a/crates/tree-ring-memory-cli/src/agent_awareness.rs +++ b/crates/tree-ring-memory-cli/src/agent_awareness.rs @@ -20,6 +20,23 @@ const SKILL_QUALITY_GATES_HEADING: &str = "## Memory Quality Gates"; const SKILL_QUALITY_GATES_ANCHOR: &str = "## Ring Selection"; const SKILL_POLICY_HEADING: &str = "## Coordinated Write Policy"; const SKILL_POLICY_ANCHOR: &str = "## Agent-Mediated Updates"; +const PREFLIGHT_HEADING: &str = "## Harness Preflight"; +const PREFLIGHT_GUIDANCE: &str = r#"## Harness Preflight + +Before substantive project work in a new harness session, read this canonical +guidance and run the project-local preflight with harness-derived identity: + +```bash +tree-ring --root .tree-ring integrations preflight --harness codex --agent-profile --workflow-id --session-id --context-format json +``` + +If adapter preflight is unavailable, use the safe fallback +`tree-ring --root .tree-ring recall "project startup constraints"`; this +fallback is useful context but does not create activation proof. A matching +receipt proves that scoped preflight recall ran for that session. It does not +prove durable memory creation and is not an adversarial security boundary. + +"#; const CLI_REFERENCE: &str = r#"# Tree Ring Memory CLI Quick Reference Tree Ring Memory is a local-first memory lifecycle layer for AI agents. @@ -180,6 +197,27 @@ pub fn ensure_agent_awareness(root: &Path) -> Result, + #[arg(long)] + workflow_id: Option, + #[arg(long)] + session_id: Option, + #[arg(long, help = "read one adapter-owned JSON request from stdin")] + input_json_stdin: bool, + #[arg(long, value_enum, default_value_t = CliPreflightContextFormat::Json)] + context_format: CliPreflightContextFormat, + }, #[command(about = "write non-mutating harness certification evidence")] Certify { #[arg(long, default_value = ".", help = "project root to certify")] @@ -376,6 +436,8 @@ enum IntegrationCommand { help = "evidence output directory; defaults to /target/tree-ring-certification" )] out_dir: Option, + #[arg(long, help = "include installed-harness live checks when available")] + live: bool, }, #[command(about = "launch Claude Code with a private Tree Ring preflight context")] Launch { @@ -386,6 +448,20 @@ enum IntegrationCommand { #[arg(last = true, allow_hyphen_values = true)] arguments: Vec, }, + #[command(about = "deactivate only manifest-recorded Tree Ring bridge material")] + Deactivate { + #[arg(long)] + harness: String, + #[arg(long, default_value = ".", help = "project root to deactivate")] + source_root: PathBuf, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +enum CliPreflightContextFormat { + ClaudeSessionStart, + PiBeforeAgentStart, + Json, } #[derive(Debug, Subcommand)] @@ -445,8 +521,12 @@ fn run_launch_command(cli: &Cli) -> Option> { if cli.json { return Err("--json is not supported with integrations launch".to_string()); } + if harness != "claude-code" { + return Err("only the Claude Code launch wrapper is supported".to_string()); + } let project = activation::adapters::ActivationProject::from_memory_root(cli.root.clone())?; let manifest = activation::load_manifest(&cli.root)?; + ensure_manifest_preflight_ready(&manifest, harness)?; let store = SQLiteMemoryStore::open_read_only(cli.root.join("memory.sqlite")) .map_err(|error| error.to_string())?; activation::launcher::launch_with_preflight( @@ -515,6 +595,10 @@ fn run(cli: Cli) -> Result<(), String> { return welcome::run(&cli.root, *init, *no_animation, cli.json); } + if let Command::Init { dry_run } = &cli.command { + return run_init(&cli.root, *dry_run, cli.json); + } + if let Command::Integrations { command: IntegrationCommand::Scan { source_root }, } = &cli.command @@ -526,11 +610,69 @@ fn run(cli: Cli) -> Result<(), String> { return Ok(()); } + if let Command::Integrations { + command: + IntegrationCommand::Status { + source_root, + verbose, + }, + } = &cli.command + { + let report = integration_status_action(IntegrationStatusRequest { + source_root: source_root.clone(), + memory_root: cli.root.clone(), + verbose: *verbose || cli.json, + })?; + print_integration_status_report(&report, cli.json)?; + return Ok(()); + } + + if let Command::Integrations { + command: + IntegrationCommand::Activate { + harness, + source_root, + dry_run, + accept_managed_block, + } + | IntegrationCommand::Link { + harness, + source_root, + dry_run, + accept_managed_block, + }, + } = &cli.command + { + let report = integration_activate_action(IntegrationActivationRequest { + harness_id: harness.clone(), + source_root: source_root.clone(), + memory_root: cli.root.clone(), + dry_run: *dry_run, + accept_managed_block: *accept_managed_block, + })?; + print_integration_lifecycle_report(&report, cli.json)?; + return Ok(()); + } + + if let Command::Integrations { + command: + IntegrationCommand::Deactivate { + harness, + source_root, + }, + } = &cli.command + { + let report = integration_deactivate_action(harness, source_root.clone(), cli.root.clone())?; + print_integration_lifecycle_report(&report, cli.json)?; + return Ok(()); + } + if let Command::Integrations { command: IntegrationCommand::Certify { source_root, out_dir, + live: _, }, } = &cli.command { @@ -545,6 +687,63 @@ fn run(cli: Cli) -> Result<(), String> { return Ok(()); } + if let Command::Integrations { + command: + IntegrationCommand::Preflight { + harness, + agent_profile, + workflow_id, + session_id, + input_json_stdin, + context_format, + }, + } = &cli.command + { + let project = activation::adapters::ActivationProject { + project_root: cli + .root + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(), + memory_root: cli.root.clone(), + }; + let format = activation_context_format(*context_format); + let input = if *input_json_stdin { + let mut input = String::new(); + std::io::stdin() + .take(1024 * 1024 + 1) + .read_to_string(&mut input) + .map_err(|_| "failed to read adapter preflight stdin".to_string())?; + if input.len() > 1024 * 1024 { + return Err("adapter preflight stdin exceeds 1 MiB".to_string()); + } + Some(input) + } else { + None + }; + let request = resolve_preflight_request( + &project, + harness, + PreflightIdentityInput { + agent_profile: agent_profile.clone(), + workflow_id: workflow_id.clone(), + session_id: session_id.clone(), + }, + input.as_deref(), + format, + )?; + let manifest = activation::load_manifest(&cli.root)?; + ensure_manifest_preflight_ready(&manifest, harness)?; + let store = + SQLiteMemoryStore::open_read_only(&db_path).map_err(|error| error.to_string())?; + println!( + "{}", + integration_preflight_action(&store, &project, &manifest, request, format)? + ); + return Ok(()); + } + if let Command::RecallQuality { source_root, out_dir, @@ -726,26 +925,7 @@ fn run(cli: Cli) -> Result<(), String> { SQLiteMemoryStore::open_with_context(&db_path, context).map_err(|err| err.to_string())?; match cli.command { - Command::Init => { - let awareness = agent_awareness::ensure_agent_awareness(&cli.root) - .map_err(|err| err.to_string())?; - if cli.json { - println!( - "{}", - json!({ - "ok": true, - "root": cli.root, - "sqlite_path": db_path, - "message": "Tree Ring Memory initialized", - "agent_awareness": awareness, - }) - ); - } else { - println!("Tree Ring Memory initialized at {}", cli.root.display()); - println!("No cloud sync; secret-like memory is blocked by default."); - print_agent_awareness_summary(&awareness); - } - } + Command::Init { .. } => unreachable!("init returns before the shared store route"), Command::Remember { summary, event_type, @@ -1018,6 +1198,226 @@ fn run(cli: Cli) -> Result<(), String> { Ok(()) } +fn run_init(root: &Path, dry_run: bool, json_output: bool) -> Result<(), String> { + let project = activation::adapters::ActivationProject::from_memory_root(root.to_path_buf())?; + let scan = integration_scan_action(IntegrationScanRequest { + source_root: project.project_root.clone(), + }); + let candidates = scan + .report + .integrations + .iter() + .filter(|integration| integration.is_candidate()) + .cloned() + .collect::>(); + + if dry_run { + let reports = candidates + .into_iter() + .map( + |detection| actions::integrations::IntegrationLifecycleActionReport { + harness_id: detection.id, + state: detection.plan.state, + changed_paths: detection + .plan + .writes + .iter() + .map(|write| match write { + activation::adapters::PlannedWrite::BridgeWrite(write) => &write.path, + activation::adapters::PlannedWrite::ManagedBlockUpdate(write) => { + &write.path + } + }) + .map(|path| path.to_string_lossy().to_string()) + .collect(), + dry_run: true, + next_step: detection.plan.next_step, + }, + ) + .collect::>(); + if json_output { + println!( + "{}", + json!({"ok": true, "dry_run": true, "integrations": reports}) + ); + } else if reports.is_empty() { + println!("Tree Ring Memory init dry-run: no detected harness adapters."); + } else { + for report in &reports { + println!( + "{}: {}", + report.harness_id, + activation_state_name(report.state) + ); + println!(" next: {}", report.next_step); + } + } + return Ok(()); + } + + let awareness = agent_awareness::ensure_agent_awareness(root)?; + let context = write_context(None, "cli:init")?; + let store = SQLiteMemoryStore::open_with_context(root.join("memory.sqlite"), context) + .map_err(|error| error.to_string())?; + drop(store); + + let mut manifest = if root.join("activation.json").exists() { + activation::load_manifest(root)? + } else { + new_activation_manifest(&project.project_root) + }; + let mut outcomes = Vec::new(); + for detection in candidates { + let result = + activation::bridge::apply_bridge_plan(&project, &mut manifest, detection.plan, false)?; + outcomes.push((detection.id, result.state, result.next_step)); + } + let manifest = activation::load_or_create_manifest( + root, + &project.project_root, + env!("CARGO_PKG_VERSION"), + )?; + let mut status = integration_status_action(IntegrationStatusRequest { + source_root: project.project_root, + memory_root: root.to_path_buf(), + verbose: true, + })?; + status.store_id = Some(manifest.store_id); + for (id, state, next_step) in outcomes { + if let Some(entry) = status.integrations.iter_mut().find(|entry| entry.id == id) { + if state == activation::ActivationState::NeedsUserReview { + entry.state = state; + entry.next_step = next_step; + } + } + } + status.integrations.retain(|entry| { + scan.report + .by_id(&entry.id) + .is_some_and(|item| item.is_candidate()) + }); + + if json_output { + println!( + "{}", + json!({ + "ok": true, + "dry_run": false, + "store_id": status.store_id, + "integrations": status.integrations, + "agent_awareness": { + "created_count": awareness.created.len(), + "existing_count": awareness.existing.len(), + }, + }) + ); + } else { + println!("Tree Ring Memory initialized."); + for entry in &status.integrations { + println!("{}: {}", entry.name, activation_state_name(entry.state)); + if entry.state != activation::ActivationState::Active { + println!(" next: {}", entry.next_step); + } + } + if status.integrations.is_empty() { + print_agent_awareness_summary(&awareness); + } + } + Ok(()) +} + +fn activation_context_format( + format: CliPreflightContextFormat, +) -> activation::PreflightContextFormat { + match format { + CliPreflightContextFormat::ClaudeSessionStart => { + activation::PreflightContextFormat::ClaudeSessionStart + } + CliPreflightContextFormat::PiBeforeAgentStart => { + activation::PreflightContextFormat::PiBeforeAgentStart + } + CliPreflightContextFormat::Json => activation::PreflightContextFormat::Json, + } +} + +fn ensure_manifest_preflight_ready( + manifest: &activation::ActivationManifest, + harness_id: &str, +) -> Result<(), String> { + let activation = manifest.harnesses.get(harness_id).ok_or_else(|| { + "harness has no activation record; run `tree-ring init` first".to_string() + })?; + if !matches!( + activation.state, + activation::ActivationState::ConfiguredAwaitingProof + | activation::ActivationState::NeedsTrust + | activation::ActivationState::Active + | activation::ActivationState::ActiveIsolated + ) { + return Err("harness activation state is not eligible for preflight".to_string()); + } + Ok(()) +} + +fn activation_state_name(state: activation::ActivationState) -> &'static str { + match state { + activation::ActivationState::Active => "active", + activation::ActivationState::ConfiguredAwaitingProof => "configured-awaiting-proof", + activation::ActivationState::ActiveIsolated => "active-isolated", + activation::ActivationState::NeedsTrust => "needs-trust", + activation::ActivationState::NeedsProjectMount => "needs-project-mount", + activation::ActivationState::NeedsPlugin => "needs-plugin", + activation::ActivationState::NeedsUserReview => "needs-user-review", + activation::ActivationState::Unsupported => "unsupported", + activation::ActivationState::Failed => "failed", + } +} + +fn print_integration_status_report( + report: &IntegrationStatusActionReport, + json_output: bool, +) -> Result<(), String> { + if json_output { + println!( + "{}", + serde_json::to_string(report).map_err(|error| error.to_string())? + ); + } else { + for entry in &report.integrations { + println!("{}: {}", entry.name, activation_state_name(entry.state)); + if let Some(age) = entry.receipt_age_seconds { + println!(" receipt-age-seconds: {age}"); + } + if !entry.managed_paths.is_empty() { + println!(" managed: {}", entry.managed_paths.join(", ")); + } + println!(" next: {}", entry.next_step); + } + } + Ok(()) +} + +fn print_integration_lifecycle_report( + report: &actions::integrations::IntegrationLifecycleActionReport, + json_output: bool, +) -> Result<(), String> { + if json_output { + println!( + "{}", + serde_json::to_string(report).map_err(|error| error.to_string())? + ); + } else { + println!( + "{}: {}{}", + report.harness_id, + activation_state_name(report.state), + if report.dry_run { " (dry-run)" } else { "" } + ); + println!(" next: {}", report.next_step); + } + Ok(()) +} + const COORDINATOR_TOKEN_ENV: &str = "TREE_RING_COORDINATOR_TOKEN"; fn open_policy_read_only(db_path: &Path) -> Result { @@ -1059,7 +1459,7 @@ fn command_actor_profile(command: &Command) -> Option { fn command_origin(command: &Command) -> &'static str { match command { - Command::Init => "cli:init", + Command::Init { .. } => "cli:init", Command::Remember { .. } => "cli:remember", Command::Evidence { .. } => "cli:evidence", Command::Recall { .. } => "cli:recall", @@ -1470,7 +1870,8 @@ fn print_integration_report( "{}", json!({ "ok": true, - "report": report, + "detected_count": report.detected_count, + "integrations": report.integrations, }) ); } else { @@ -1520,9 +1921,28 @@ fn format_harness_certification_report( json_output: bool, ) -> String { if json_output { + let records = report + .records + .iter() + .map(|record| { + json!({ + "harness_id": record.harness_id, + "name": record.name, + "status": record.status, + "markers": record.markers, + "summary": record.summary, + "next_step": record.next_step, + }) + }) + .collect::>(); json!({ "ok": true, - "report": report, + "report": { + "pass_count": report.pass_count, + "fail_count": report.fail_count, + "skip_count": report.skip_count, + "records": records, + }, }) .to_string() } else { @@ -1607,6 +2027,188 @@ mod tests { use tree_ring_memory_core::{ConsolidationPeriod, ConsolidationRequest}; use tree_ring_memory_sqlite::MemoryRetriever; + #[test] + fn integrations_preflight_parses_direct_codex_identity() { + let cli = Cli::try_parse_from([ + "tree-ring", + "integrations", + "preflight", + "--harness", + "codex", + "--agent-profile", + "worker-a", + "--workflow-id", + "fanout-1", + "--session-id", + "session-1", + ]) + .unwrap(); + + assert!(matches!( + cli.command, + Command::Integrations { + command: IntegrationCommand::Preflight { + harness, + agent_profile: Some(agent_profile), + workflow_id: Some(workflow_id), + session_id: Some(session_id), + input_json_stdin: false, + context_format: CliPreflightContextFormat::Json, + } + } if harness == "codex" + && agent_profile == "worker-a" + && workflow_id == "fanout-1" + && session_id == "session-1" + )); + } + + #[test] + fn init_dry_run_parser_sets_dry_run_without_creating_artifacts() { + let dir = tempdir().unwrap(); + let root = dir.path().join(".tree-ring"); + let cli = Cli::try_parse_from([ + "tree-ring", + "--root", + root.to_str().unwrap(), + "init", + "--dry-run", + ]) + .unwrap(); + assert!(matches!(cli.command, Command::Init { dry_run: true })); + + run(cli).unwrap(); + assert!(!root.join("memory.sqlite").exists()); + assert!(!root.join("activation.json").exists()); + } + + #[test] + fn integrations_status_and_activation_dry_run_do_not_create_a_store() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".codex")).unwrap(); + let root = dir.path().join(".tree-ring"); + + for arguments in [ + vec![ + "tree-ring", + "--root", + root.to_str().unwrap(), + "integrations", + "status", + "--source-root", + dir.path().to_str().unwrap(), + ], + vec![ + "tree-ring", + "--root", + root.to_str().unwrap(), + "integrations", + "activate", + "--harness", + "codex", + "--source-root", + dir.path().to_str().unwrap(), + "--dry-run", + ], + ] { + run(Cli::parse_from(arguments)).unwrap(); + assert!(!root.join("memory.sqlite").exists()); + assert!(!root.join("activation.json").exists()); + } + } + + #[test] + fn invalid_direct_preflight_fails_before_store_open() { + let dir = tempdir().unwrap(); + let root = dir.path().join(".tree-ring"); + let error = run(Cli::parse_from([ + "tree-ring", + "--root", + root.to_str().unwrap(), + "integrations", + "preflight", + "--harness", + "codex", + "--agent-profile", + "worker-a", + ])) + .unwrap_err(); + + assert!(error.contains("requires --agent-profile, --workflow-id, and --session-id")); + assert!(!root.join("memory.sqlite").exists()); + assert!(!root.join("activation.json").exists()); + } + + #[test] + fn ordinary_init_installs_safe_codex_bridge_but_awaits_receipt_proof() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".codex")).unwrap(); + let root = dir.path().join(".tree-ring"); + + run(Cli::parse_from([ + "tree-ring", + "--root", + root.to_str().unwrap(), + "init", + ])) + .unwrap(); + + assert!(root.join("memory.sqlite").exists()); + assert!(root.join("activation.json").exists()); + assert!(dir + .path() + .join(".agents/skills/tree-ring-memory/SKILL.md") + .exists()); + let report = integration_status_action(IntegrationStatusRequest { + source_root: dir.path().to_path_buf(), + memory_root: root.clone(), + verbose: true, + }) + .unwrap(); + assert_eq!( + report + .integrations + .iter() + .find(|entry| entry.id == "codex") + .unwrap() + .state, + activation::ActivationState::ConfiguredAwaitingProof + ); + + run(Cli::parse_from([ + "tree-ring", + "--root", + root.to_str().unwrap(), + "integrations", + "preflight", + "--harness", + "codex", + "--agent-profile", + "worker-a", + "--workflow-id", + "workflow-a", + "--session-id", + "session-a", + "--context-format", + "json", + ])) + .unwrap(); + let active = integration_status_action(IntegrationStatusRequest { + source_root: dir.path().to_path_buf(), + memory_root: root, + verbose: true, + }) + .unwrap(); + assert_eq!( + active + .integrations + .iter() + .find(|entry| entry.id == "codex") + .unwrap() + .state, + activation::ActivationState::Active + ); + } + #[test] fn integrations_launch_parses_only_a_claude_harness_and_trailing_arguments() { let cli = Cli::try_parse_from([ @@ -1647,7 +2249,7 @@ mod tests { run(Cli { root: root.clone(), json: false, - command: Command::Init, + command: Command::Init { dry_run: false }, }) .unwrap(); @@ -1706,7 +2308,7 @@ mod tests { run(Cli { root: root.clone(), json: false, - command: Command::Init, + command: Command::Init { dry_run: false }, }) .unwrap(); @@ -2497,7 +3099,7 @@ mod tests { run(Cli { root: root.clone(), json: false, - command: Command::Init, + command: Command::Init { dry_run: false }, }) .unwrap(); let connection = rusqlite::Connection::open(&db_path).unwrap(); From fc677ed34d0cb8fe1d4208096812910c2433ab20 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 01:30:34 -0400 Subject: [PATCH 18/31] feat: certify harness behavior with receipts --- .../src/actions/integrations.rs | 203 ++++- crates/tree-ring-memory-cli/src/evidence.rs | 2 + .../src/harness_evidence.rs | 785 ++++++++++++++++-- crates/tree-ring-memory-cli/src/main.rs | 68 +- 4 files changed, 933 insertions(+), 125 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/actions/integrations.rs b/crates/tree-ring-memory-cli/src/actions/integrations.rs index 965b919..46c8b17 100644 --- a/crates/tree-ring-memory-cli/src/actions/integrations.rs +++ b/crates/tree-ring-memory-cli/src/actions/integrations.rs @@ -72,6 +72,24 @@ pub struct IntegrationLifecycleActionReport { pub next_step: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReceiptVerificationStatus { + Valid, + Missing, + Invalid, +} + +/// Receipt validation shared by status and certification. The receipt remains +/// process-local; callers must serialize only the bounded metadata below. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ReceiptVerification { + pub status: ReceiptVerificationStatus, + pub receipt: Option, + pub store_id_matches: bool, + pub project_root_matches: bool, + pub diagnostic: &'static str, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PreflightIdentityInput { pub agent_profile: Option, @@ -99,16 +117,24 @@ pub fn status(request: IntegrationStatusRequest) -> Result Option { +) -> ReceiptVerification { + verify_activation_receipts_at(memory_root, harness_id, manifest, harness, Utc::now()) +} + +fn verify_activation_receipts_at( + memory_root: &Path, + harness_id: &str, + manifest: &ActivationManifest, + harness: &activation::HarnessActivation, + now: chrono::DateTime, +) -> ReceiptVerification { if harness.bridge_fingerprint != bridge_fingerprint(harness_id, harness) { - return None; + return invalid_receipt(None, "bridge fingerprint does not match adapter contract"); } let mut pending = vec![memory_root.join("activation/receipts").join(harness_id)]; let mut receipts = Vec::new(); + let mut found_json = false; while let Some(path) = pending.pop() { - let Ok(entries) = fs::read_dir(path) else { - continue; + let entries = match fs::read_dir(path) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return invalid_receipt(None, "activation receipt directory is unreadable"), }; - for entry in entries.flatten() { + for entry in entries { + let Ok(entry) = entry else { + return invalid_receipt(None, "activation receipt directory is unreadable"); + }; let path = entry.path(); if path.is_dir() { pending.push(path); } else if path.extension().and_then(|value| value.to_str()) == Some("json") { + found_json = true; if let Ok(bytes) = fs::read(&path) { if let Ok(receipt) = serde_json::from_slice::(&bytes) { receipts.push(receipt); @@ -502,25 +545,117 @@ fn freshest_matching_receipt( } } } - let now = Utc::now(); - receipts + + if let Some(receipt) = receipts + .iter() + .filter(|receipt| receipt_matches(harness_id, manifest, harness, receipt, now)) + .max_by_key(|receipt| receipt.recorded_at) + .cloned() + { + return ReceiptVerification { + status: ReceiptVerificationStatus::Valid, + store_id_matches: true, + project_root_matches: true, + receipt: Some(receipt), + diagnostic: "fresh matching activation receipt", + }; + } + + if let Some(receipt) = receipts .into_iter() - .filter(|receipt| { - receipt.harness_id == harness_id - && receipt.protocol_version == manifest.protocol_version - && receipt.adapter_version == harness.adapter_version - && receipt.bridge_fingerprint == harness.bridge_fingerprint - && receipt.store_id == manifest.store_id - && receipt.project_root_fingerprint == manifest.project_root_fingerprint - && receipt.status == "success" - && matches!( - receipt.state, - ActivationState::Active | ActivationState::ActiveIsolated - ) - && receipt.recorded_at <= now - && receipt.recorded_at > now - Duration::days(RECEIPT_RETENTION_DAYS) - }) .max_by_key(|receipt| receipt.recorded_at) + { + let diagnostic = receipt_mismatch(harness_id, manifest, harness, &receipt, now); + return ReceiptVerification { + status: ReceiptVerificationStatus::Invalid, + store_id_matches: receipt.store_id == manifest.store_id, + project_root_matches: receipt.project_root_fingerprint + == manifest.project_root_fingerprint, + receipt: Some(receipt), + diagnostic, + }; + } + if found_json { + return invalid_receipt(None, "activation receipt is malformed"); + } + ReceiptVerification { + status: ReceiptVerificationStatus::Missing, + receipt: None, + store_id_matches: false, + project_root_matches: false, + diagnostic: "no activation receipt", + } +} + +fn receipt_matches( + harness_id: &str, + manifest: &ActivationManifest, + harness: &activation::HarnessActivation, + receipt: &ActivationReceipt, + now: chrono::DateTime, +) -> bool { + receipt.schema_version == ACTIVATION_SCHEMA_VERSION + && receipt.harness_id == harness_id + && receipt.protocol_version == manifest.protocol_version + && receipt.adapter_version == harness.adapter_version + && receipt.bridge_fingerprint == harness.bridge_fingerprint + && receipt.store_id == manifest.store_id + && receipt.project_root_fingerprint == manifest.project_root_fingerprint + && receipt.status == "success" + && matches!( + receipt.state, + ActivationState::Active | ActivationState::ActiveIsolated + ) + && receipt.recorded_at <= now + && receipt.recorded_at > now - Duration::days(RECEIPT_RETENTION_DAYS) +} + +fn receipt_mismatch( + harness_id: &str, + manifest: &ActivationManifest, + harness: &activation::HarnessActivation, + receipt: &ActivationReceipt, + now: chrono::DateTime, +) -> &'static str { + if receipt.schema_version != ACTIVATION_SCHEMA_VERSION { + "activation receipt schema version mismatch" + } else if receipt.harness_id != harness_id { + "activation receipt harness mismatch" + } else if receipt.protocol_version != manifest.protocol_version { + "activation receipt protocol mismatch" + } else if receipt.adapter_version != harness.adapter_version { + "activation receipt adapter version mismatch" + } else if receipt.bridge_fingerprint != harness.bridge_fingerprint { + "activation receipt bridge fingerprint mismatch" + } else if receipt.store_id != manifest.store_id { + "activation receipt store mismatch" + } else if receipt.project_root_fingerprint != manifest.project_root_fingerprint { + "activation receipt project root mismatch" + } else if receipt.status != "success" { + "activation receipt does not record success" + } else if !matches!( + receipt.state, + ActivationState::Active | ActivationState::ActiveIsolated + ) { + "activation receipt state is not active" + } else if receipt.recorded_at > now { + "activation receipt timestamp is in the future" + } else { + "activation receipt is expired" + } +} + +fn invalid_receipt( + receipt: Option, + diagnostic: &'static str, +) -> ReceiptVerification { + ReceiptVerification { + status: ReceiptVerificationStatus::Invalid, + receipt, + store_id_matches: false, + project_root_matches: false, + diagnostic, + } } fn next_step_for_state(state: ActivationState, detected_next_step: &str) -> String { @@ -569,6 +704,10 @@ fn path_fingerprint(path: &Path) -> String { format!("{:x}", hasher.finalize()) } +pub(crate) fn project_root_fingerprint(path: &Path) -> String { + path_fingerprint(path) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tree-ring-memory-cli/src/evidence.rs b/crates/tree-ring-memory-cli/src/evidence.rs index 23f5ea2..a8cfb71 100644 --- a/crates/tree-ring-memory-cli/src/evidence.rs +++ b/crates/tree-ring-memory-cli/src/evidence.rs @@ -8,6 +8,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; static ATOMIC_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0); +pub(crate) const HARNESS_ACTIVATION_SUMMARY_FILE: &str = "harness-activation-summary.md"; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum EvidenceStatus { diff --git a/crates/tree-ring-memory-cli/src/harness_evidence.rs b/crates/tree-ring-memory-cli/src/harness_evidence.rs index 08744a7..71733d1 100644 --- a/crates/tree-ring-memory-cli/src/harness_evidence.rs +++ b/crates/tree-ring-memory-cli/src/harness_evidence.rs @@ -1,13 +1,18 @@ +use crate::actions::integrations::{ + project_root_fingerprint, verify_activation_receipts, ReceiptVerificationStatus, +}; use crate::evidence::{ atomic_write, publish_indexed_evidence, rollup_index_status, EvidenceRecordRef, EvidenceStatus, + HARNESS_ACTIVATION_SUMMARY_FILE, }; use chrono::{SecondsFormat, Utc}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; use tree_ring_memory_cli::activation::adapters::{ - scan_integrations, AdapterDetection, IntegrationMarker, MarkerOrigin, + adapter_version, scan_integrations, AdapterDetection, IntegrationMarker, MarkerOrigin, }; +use tree_ring_memory_cli::activation::{ActivationManifest, ActivationState, AdapterCapability}; pub const CERTIFIED_HARNESS_IDS: &[&str] = &[ "codex", @@ -21,7 +26,9 @@ pub const CERTIFIED_HARNESS_IDS: &[&str] = &[ #[derive(Debug, Clone, PartialEq, Eq)] pub struct HarnessCertificationRequest { pub source_root: PathBuf, + pub memory_root: PathBuf, pub evidence_dir: PathBuf, + pub live: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -45,12 +52,25 @@ pub struct HarnessProbeRecord { pub generated_at: String, pub source_root: PathBuf, pub command: String, + pub activation: HarnessActivationEvidence, pub markers: Vec, pub guidance: HarnessGuidanceEvidence, pub summary: String, pub next_step: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HarnessActivationEvidence { + pub adapter_version: String, + pub adapter_capability: AdapterCapability, + pub state: ActivationState, + pub receipt_recorded_at: Option, + pub receipt_age_seconds: Option, + pub store_id_matches: bool, + pub project_root_matches: bool, + pub diagnostic: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HarnessProbeMarker { pub path: String, @@ -72,6 +92,7 @@ pub fn certify_harnesses( let generated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); let report = scan_integrations(&request.source_root); let guidance = inspect_guidance(&request.source_root); + let manifest = inspect_manifest(&request.memory_root); let mut records = Vec::new(); for harness_id in CERTIFIED_HARNESS_IDS { let integration = report @@ -79,7 +100,15 @@ pub fn certify_harnesses( .iter() .find(|integration| integration.id == *harness_id) .ok_or_else(|| format!("missing integration definition for {harness_id}"))?; - let record = probe_record(integration, &request.source_root, &generated_at, &guidance); + let record = probe_record( + integration, + &request.source_root, + &request.memory_root, + &generated_at, + &guidance, + &manifest, + request.live, + ); records.push(record); } @@ -112,76 +141,222 @@ pub fn certify_harnesses( fn probe_record( integration: &AdapterDetection, source_root: &Path, + memory_root: &Path, generated_at: &str, guidance: &HarnessGuidanceEvidence, + manifest: &ManifestInspection, + live: bool, ) -> HarnessProbeRecord { - let project_marker = integration - .markers - .iter() - .any(|marker| marker.origin == MarkerOrigin::Project); - let home_marker = integration - .markers - .iter() - .any(|marker| marker.origin == MarkerOrigin::Home); - let guidance_ready = guidance.recall_guidance && guidance.remember_guidance; - let (status, summary, next_step) = if project_marker - && guidance_ready - && !activation_state_allows_compatibility_pass(integration.state) - { - ( + let expected_adapter_version = adapter_version(&integration.id).unwrap_or("unknown"); + let expected_project_fingerprint = project_root_fingerprint(source_root); + let result = if live && integration.executable_version.is_none() { + activation_result( EvidenceStatus::Skip, - format!( - "{} has activation state {:?}; no active harness compatibility claim is made.", - integration.name, integration.state - ), - integration.next_step.to_string(), - ) - } else if project_marker && guidance_ready { - ( - EvidenceStatus::Pass, - format!( - "{} has a project marker and generated Tree Ring recall/remember guidance.", - integration.name - ), - integration.next_step.to_string(), - ) - } else if project_marker { - ( - EvidenceStatus::Fail, - format!( - "{} has a project marker but is missing generated Tree Ring guidance.", - integration.name - ), - "Run `tree-ring init`, then reference `.tree-ring/SKILL.md` and `.tree-ring/CLI.md` from the harness project instructions.".to_string(), - ) - } else if home_marker { - ( - EvidenceStatus::Skip, - format!( - "{} only has user-home markers; this project is not certified for that harness.", - integration.name - ), - "Add a project-level harness marker or project instruction file, then rerun `tree-ring integrations certify`.".to_string(), + expected_adapter_version, + integration.capability, + integration.state, + None, + [false, false], + "not installed locally", ) } else { - ( - EvidenceStatus::Skip, - format!( - "{} was not detected for this project, so no compatibility claim is made.", - integration.name + match manifest { + ManifestInspection::Missing => activation_result( + EvidenceStatus::Skip, + expected_adapter_version, + integration.capability, + configured_state_without_receipt(integration.state), + None, + [false, false], + "activation manifest not found", ), - integration.next_step.to_string(), - ) + ManifestInspection::Invalid => activation_result( + EvidenceStatus::Fail, + expected_adapter_version, + integration.capability, + ActivationState::Failed, + None, + [false, false], + "activation manifest is malformed or invalid", + ), + ManifestInspection::Valid(manifest) => { + let root_matches = + manifest.project_root_fingerprint == expected_project_fingerprint; + let Some(harness) = manifest.harnesses.get(&integration.id) else { + return finish_record( + integration, + generated_at, + guidance, + activation_result( + EvidenceStatus::Skip, + expected_adapter_version, + integration.capability, + configured_state_without_receipt(integration.state), + None, + [false, root_matches], + "harness has no activation record", + ), + ); + }; + if !root_matches { + activation_result( + EvidenceStatus::Fail, + &harness.adapter_version, + harness.adapter_capability, + ActivationState::Failed, + None, + [false, false], + "activation manifest belongs to a different project root", + ) + } else if harness.adapter_version != expected_adapter_version { + activation_result( + EvidenceStatus::Fail, + &harness.adapter_version, + harness.adapter_capability, + ActivationState::Failed, + None, + [false, true], + "activation adapter version does not match the registered adapter", + ) + } else if harness.adapter_capability != integration.capability { + activation_result( + EvidenceStatus::Fail, + &harness.adapter_version, + harness.adapter_capability, + ActivationState::Failed, + None, + [false, true], + "activation adapter capability does not match the registered adapter", + ) + } else if integration.state == ActivationState::Unsupported { + activation_result( + EvidenceStatus::Skip, + &harness.adapter_version, + harness.adapter_capability, + ActivationState::Unsupported, + None, + [false, true], + "adapter is unsupported", + ) + } else { + let verification = + verify_activation_receipts(memory_root, &integration.id, manifest, harness); + let receipt_metadata = verification.receipt.as_ref().map(|receipt| { + ( + receipt + .recorded_at + .to_rfc3339_opts(SecondsFormat::Secs, true), + Utc::now() + .signed_duration_since(receipt.recorded_at) + .num_seconds() + .max(0), + receipt.state, + ) + }); + match verification.status { + ReceiptVerificationStatus::Valid => { + let state = receipt_metadata + .as_ref() + .map(|(_, _, state)| *state) + .unwrap_or(ActivationState::Failed); + let evidence_status = if state == ActivationState::Active { + EvidenceStatus::Pass + } else { + EvidenceStatus::Skip + }; + activation_result( + evidence_status, + &harness.adapter_version, + harness.adapter_capability, + state, + receipt_metadata, + [ + verification.store_id_matches, + verification.project_root_matches, + ], + if state == ActivationState::ActiveIsolated { + "fresh receipt proves isolated activation only" + } else { + verification.diagnostic + }, + ) + } + ReceiptVerificationStatus::Missing => activation_result( + EvidenceStatus::Skip, + &harness.adapter_version, + harness.adapter_capability, + configured_state_without_receipt(harness.state), + None, + [false, true], + verification.diagnostic, + ), + ReceiptVerificationStatus::Invalid => activation_result( + EvidenceStatus::Fail, + &harness.adapter_version, + harness.adapter_capability, + ActivationState::Failed, + receipt_metadata, + [ + verification.store_id_matches, + verification.project_root_matches, + ], + verification.diagnostic, + ), + } + } + } + } }; + finish_record(integration, generated_at, guidance, result) +} + +type ActivationResult = (EvidenceStatus, HarnessActivationEvidence); + +fn activation_result( + status: EvidenceStatus, + adapter_version: &str, + adapter_capability: AdapterCapability, + state: ActivationState, + receipt: Option<(String, i64, ActivationState)>, + contract_matches: [bool; 2], + diagnostic: &'static str, +) -> ActivationResult { + let receipt_recorded_at = receipt.as_ref().map(|(timestamp, _, _)| timestamp.clone()); + let receipt_age_seconds = receipt.map(|(_, age, _)| age); + ( + status, + HarnessActivationEvidence { + adapter_version: adapter_version.to_string(), + adapter_capability, + state, + receipt_recorded_at, + receipt_age_seconds, + store_id_matches: contract_matches[0], + project_root_matches: contract_matches[1], + diagnostic: diagnostic.to_string(), + }, + ) +} + +fn finish_record( + integration: &AdapterDetection, + generated_at: &str, + guidance: &HarnessGuidanceEvidence, + result: ActivationResult, +) -> HarnessProbeRecord { + let (status, activation) = result; + let summary = format!("{}: {}.", integration.name, activation.diagnostic); + let next_step = next_step_for_state(activation.state, &integration.next_step); HarnessProbeRecord { schema_version: 1, harness_id: integration.id.to_string(), name: integration.name.to_string(), status, generated_at: generated_at.to_string(), - source_root: source_root.to_path_buf(), + source_root: PathBuf::from(""), command: "tree-ring integrations certify --source-root ".to_string(), + activation, markers: integration.markers.iter().map(marker_from_scan).collect(), guidance: guidance.clone(), summary, @@ -189,20 +364,53 @@ fn probe_record( } } -fn activation_state_allows_compatibility_pass( - state: tree_ring_memory_cli::activation::ActivationState, -) -> bool { - state == tree_ring_memory_cli::activation::ActivationState::Active +fn configured_state_without_receipt(state: ActivationState) -> ActivationState { + match state { + ActivationState::Active | ActivationState::ActiveIsolated => { + ActivationState::ConfiguredAwaitingProof + } + state => state, + } +} + +fn next_step_for_state(state: ActivationState, detected: &str) -> String { + match state { + ActivationState::Active => "No action required for the receipt-backed session.".to_string(), + ActivationState::ActiveIsolated => { + "Bind the harness to this project's canonical store before claiming shared use." + .to_string() + } + ActivationState::ConfiguredAwaitingProof => { + "Run the adapter preflight at the start of a new harness session.".to_string() + } + _ => detected.to_string(), + } +} + +enum ManifestInspection { + Missing, + Valid(ActivationManifest), + Invalid, +} + +fn inspect_manifest(memory_root: &Path) -> ManifestInspection { + if !memory_root.join("activation.json").exists() { + return ManifestInspection::Missing; + } + match tree_ring_memory_cli::activation::load_manifest(memory_root) { + Ok(manifest) => ManifestInspection::Valid(manifest), + Err(_) => ManifestInspection::Invalid, + } } fn inspect_guidance(source_root: &Path) -> HarnessGuidanceEvidence { - let agents_md = existing_path(source_root.join(".tree-ring/AGENTS.md")); - let skill_md = existing_path(source_root.join(".tree-ring/SKILL.md")); - let cli_md = existing_path(source_root.join(".tree-ring/CLI.md")); + let agents_md = existing_relative_path(source_root, ".tree-ring/AGENTS.md"); + let skill_md = existing_relative_path(source_root, ".tree-ring/SKILL.md"); + let cli_md = existing_relative_path(source_root, ".tree-ring/CLI.md"); let combined = [agents_md.as_ref(), skill_md.as_ref(), cli_md.as_ref()] .into_iter() .flatten() - .filter_map(|path| fs::read_to_string(path).ok()) + .filter_map(|path| fs::read_to_string(source_root.join(path)).ok()) .collect::>() .join("\n"); let combined_lower = combined.to_lowercase(); @@ -215,13 +423,27 @@ fn inspect_guidance(source_root: &Path) -> HarnessGuidanceEvidence { } } -fn existing_path(path: PathBuf) -> Option { - path.exists().then_some(path) +fn existing_relative_path(source_root: &Path, relative: &str) -> Option { + source_root + .join(relative) + .exists() + .then(|| PathBuf::from(relative)) } fn marker_from_scan(marker: &IntegrationMarker) -> HarnessProbeMarker { HarnessProbeMarker { - path: marker.path.clone(), + path: if marker.origin == MarkerOrigin::Home { + marker + .path + .rsplit(['/', '\\']) + .next() + .map(|name| format!("/{name}")) + .unwrap_or_else(|| "".to_string()) + } else if Path::new(&marker.path).is_absolute() { + "".to_string() + } else { + marker.path.clone() + }, origin: marker.origin.as_str().to_string(), } } @@ -239,6 +461,10 @@ fn publish_harness_evidence( let json = serde_json::to_string_pretty(record).map_err(|err| err.to_string())?; atomic_write(&path, json.as_bytes())?; } + atomic_write( + &evidence_dir.join(HARNESS_ACTIVATION_SUMMARY_FILE), + render_harness_summary(generated_at, records).as_bytes(), + )?; index.generated_at = generated_at.to_string(); for record in records { @@ -249,7 +475,7 @@ fn publish_harness_evidence( status: record.status, label: record.name.clone(), path: PathBuf::from(format!("harness/{}.json", record.harness_id)), - summary_path: None, + summary_path: Some(PathBuf::from(HARNESS_ACTIVATION_SUMMARY_FILE)), generated_at: record.generated_at.clone(), }, ); @@ -267,12 +493,325 @@ fn publish_harness_evidence( }) } +fn render_harness_summary(generated_at: &str, records: &[HarnessProbeRecord]) -> String { + let mut lines = vec![ + "# Harness activation evidence".to_string(), + String::new(), + format!("Generated: {generated_at}"), + String::new(), + "| Harness | Evidence | Activation state | Diagnostic |".to_string(), + "| --- | --- | --- | --- |".to_string(), + ]; + for record in records { + lines.push(format!( + "| {} | {} | {:?} | {} |", + record.name, + record.status.as_str(), + record.activation.state, + record.activation.diagnostic.replace('|', "-") + )); + } + lines.push(String::new()); + lines.push( + "Markers are detection context only. A pass requires a fresh receipt matching the current adapter and project store contract." + .to_string(), + ); + lines.push(String::new()); + lines.join("\n") +} + #[cfg(test)] mod tests { use super::*; use crate::evidence::certification_dir_for_project; + use chrono::Duration; use std::collections::BTreeMap; use tempfile::tempdir; + use tree_ring_memory_cli::activation::manifest::bridge_fingerprint; + use tree_ring_memory_cli::activation::{ + save_manifest, write_receipt, ActivationReceipt, HarnessActivation, SessionIdentity, + ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, + }; + + fn request(project: &Path) -> HarnessCertificationRequest { + HarnessCertificationRequest { + source_root: project.to_path_buf(), + memory_root: project.join(".tree-ring"), + evidence_dir: certification_dir_for_project(project), + live: false, + } + } + + fn write_activation( + project: &Path, + harness_id: &str, + state: ActivationState, + ) -> ActivationManifest { + let memory_root = project.join(".tree-ring"); + fs::create_dir_all(&memory_root).unwrap(); + let detection = scan_integrations(project) + .by_id(harness_id) + .cloned() + .unwrap(); + let mut harness = HarnessActivation { + state, + adapter_capability: detection.capability, + adapter_version: adapter_version(harness_id).unwrap().to_string(), + bridge_fingerprint: String::new(), + bridge_path: None, + owned_files: Vec::new(), + managed_blocks: Vec::new(), + }; + harness.bridge_fingerprint = bridge_fingerprint(harness_id, &harness); + let manifest = ActivationManifest { + schema_version: ACTIVATION_SCHEMA_VERSION, + protocol_version: ACTIVATION_PROTOCOL_VERSION, + store_id: "test-store".to_string(), + project_root_fingerprint: project_root_fingerprint(project), + cli_version: env!("CARGO_PKG_VERSION").to_string(), + harnesses: BTreeMap::from([(harness_id.to_string(), harness)]), + }; + save_manifest(&memory_root, &manifest).unwrap(); + manifest + } + + fn matching_receipt( + manifest: &ActivationManifest, + harness_id: &str, + state: ActivationState, + ) -> ActivationReceipt { + let harness = manifest.harnesses.get(harness_id).unwrap(); + ActivationReceipt { + schema_version: ACTIVATION_SCHEMA_VERSION, + protocol_version: manifest.protocol_version, + receipt_id: "receipt-1".to_string(), + harness_id: harness_id.to_string(), + adapter_version: harness.adapter_version.clone(), + bridge_fingerprint: harness.bridge_fingerprint.clone(), + store_id: manifest.store_id.clone(), + project_root_fingerprint: manifest.project_root_fingerprint.clone(), + worker_key_fingerprint: "a".repeat(64), + session: SessionIdentity { + agent_profile: "worker-a".to_string(), + workflow_id: "workflow-a".to_string(), + session_id: "session-a".to_string(), + }, + state, + query_class: "startup_fallback".to_string(), + result_count: 1, + selected_memory_ids_sha256: "b".repeat(64), + duration_ms: 1, + status: "success".to_string(), + recorded_at: Utc::now() - Duration::seconds(5), + } + } + + fn record<'a>( + report: &'a HarnessCertificationReport, + harness_id: &str, + ) -> &'a HarnessProbeRecord { + report + .records + .iter() + .find(|record| record.harness_id == harness_id) + .unwrap() + } + + #[test] + fn harness_certification_passes_only_a_fresh_matching_receipt() { + let dir = tempdir().unwrap(); + let manifest = write_activation( + dir.path(), + "claude-code", + ActivationState::ConfiguredAwaitingProof, + ); + write_receipt( + &dir.path().join(".tree-ring"), + &matching_receipt(&manifest, "claude-code", ActivationState::Active), + ) + .unwrap(); + + let report = certify_harnesses(request(dir.path())).unwrap(); + let claude = record(&report, "claude-code"); + + assert_eq!(claude.status, EvidenceStatus::Pass); + assert_eq!(claude.activation.state, ActivationState::Active); + assert!(claude.activation.store_id_matches); + assert!(claude.activation.project_root_matches); + assert!(claude.activation.receipt_age_seconds.is_some()); + assert_eq!( + claude.activation.diagnostic, + "fresh matching activation receipt" + ); + } + + #[test] + fn harness_certification_rejects_expired_or_mismatched_receipts() { + enum Mutation { + Expired, + Adapter, + Fingerprint, + Store, + Root, + } + for mutation in [ + Mutation::Expired, + Mutation::Adapter, + Mutation::Fingerprint, + Mutation::Store, + Mutation::Root, + ] { + let dir = tempdir().unwrap(); + let manifest = write_activation( + dir.path(), + "codex", + ActivationState::ConfiguredAwaitingProof, + ); + let mut receipt = matching_receipt(&manifest, "codex", ActivationState::Active); + match mutation { + Mutation::Expired => receipt.recorded_at = Utc::now() - Duration::days(31), + Mutation::Adapter => receipt.adapter_version = "wrong-adapter".to_string(), + Mutation::Fingerprint => receipt.bridge_fingerprint = "c".repeat(64), + Mutation::Store => receipt.store_id = "wrong-store".to_string(), + Mutation::Root => receipt.project_root_fingerprint = "d".repeat(64), + } + write_receipt(&dir.path().join(".tree-ring"), &receipt).unwrap(); + + let report = certify_harnesses(request(dir.path())).unwrap(); + let codex = record(&report, "codex"); + assert_eq!(codex.status, EvidenceStatus::Fail); + assert_eq!(codex.activation.state, ActivationState::Failed); + assert!(!codex + .activation + .diagnostic + .contains(dir.path().to_str().unwrap())); + } + } + + #[test] + fn harness_certification_fails_malformed_receipt_with_redacted_cause() { + let dir = tempdir().unwrap(); + write_activation( + dir.path(), + "codex", + ActivationState::ConfiguredAwaitingProof, + ); + let receipt_dir = dir + .path() + .join(".tree-ring/activation/receipts/codex/worker"); + fs::create_dir_all(&receipt_dir).unwrap(); + fs::write(receipt_dir.join("broken.json"), b"{not-json").unwrap(); + + let report = certify_harnesses(request(dir.path())).unwrap(); + let codex = record(&report, "codex"); + + assert_eq!(codex.status, EvidenceStatus::Fail); + assert_eq!( + codex.activation.diagnostic, + "activation receipt is malformed" + ); + let serialized = serde_json::to_string(codex).unwrap(); + assert!(!serialized.contains(dir.path().to_str().unwrap())); + assert!(!serialized.contains("not-json")); + } + + #[test] + fn harness_certification_downgrades_manifest_active_without_receipt() { + let dir = tempdir().unwrap(); + write_activation(dir.path(), "codex", ActivationState::Active); + + let report = certify_harnesses(request(dir.path())).unwrap(); + let codex = record(&report, "codex"); + + assert_eq!(codex.status, EvidenceStatus::Skip); + assert_eq!( + codex.activation.state, + ActivationState::ConfiguredAwaitingProof + ); + assert_eq!(codex.activation.diagnostic, "no activation receipt"); + } + + #[test] + fn harness_certification_keeps_active_isolated_distinct_and_nonpassing() { + let dir = tempdir().unwrap(); + let manifest = write_activation( + dir.path(), + "agent-zero", + ActivationState::ConfiguredAwaitingProof, + ); + write_receipt( + &dir.path().join(".tree-ring"), + &matching_receipt(&manifest, "agent-zero", ActivationState::ActiveIsolated), + ) + .unwrap(); + + let report = certify_harnesses(request(dir.path())).unwrap(); + let agent_zero = record(&report, "agent-zero"); + + assert_eq!(agent_zero.status, EvidenceStatus::Skip); + assert_eq!(agent_zero.activation.state, ActivationState::ActiveIsolated); + assert_eq!( + agent_zero.activation.diagnostic, + "fresh receipt proves isolated activation only" + ); + } + + #[test] + fn harness_certification_live_missing_executable_is_exact_skip() { + let integration = AdapterDetection { + id: "claude-code".to_string(), + name: "Claude Code".to_string(), + capability: AdapterCapability::WrapperPreflight, + executable_version: None, + status: tree_ring_memory_cli::activation::adapters::IntegrationStatus::Available, + state: ActivationState::ConfiguredAwaitingProof, + markers: Vec::new(), + plan: tree_ring_memory_cli::activation::adapters::AdapterPlan { + harness_id: "claude-code".to_string(), + state: ActivationState::ConfiguredAwaitingProof, + writes: Vec::new(), + next_step: "Install the optional executable.".to_string(), + }, + next_step: "Install the optional executable.".to_string(), + }; + let record = probe_record( + &integration, + Path::new("/project"), + Path::new("/project/.tree-ring"), + "2026-08-14T00:00:00Z", + &HarnessGuidanceEvidence { + agents_md: None, + skill_md: None, + cli_md: None, + recall_guidance: false, + remember_guidance: false, + }, + &ManifestInspection::Missing, + true, + ); + + assert_eq!(record.status, EvidenceStatus::Skip); + assert_eq!(record.activation.diagnostic, "not installed locally"); + assert_eq!(record.summary, "Claude Code: not installed locally."); + } + + #[test] + fn harness_certification_publishes_redacted_markdown_summary() { + let dir = tempdir().unwrap(); + let report = certify_harnesses(request(dir.path())).unwrap(); + let summary_path = report.evidence_dir.join(HARNESS_ACTIVATION_SUMMARY_FILE); + let summary = fs::read_to_string(summary_path).unwrap(); + + assert!(summary.contains("# Harness activation evidence")); + assert!(summary.contains("Markers are detection context only")); + assert!(!summary.contains(dir.path().to_str().unwrap())); + let index: crate::evidence::EvidenceIndex = + serde_json::from_str(&fs::read_to_string(report.index_path).unwrap()).unwrap(); + assert_eq!( + index.harness["codex"].summary_path, + Some(PathBuf::from(HARNESS_ACTIVATION_SUMMARY_FILE)) + ); + } #[test] fn harness_certification_skips_absent_project_markers_and_indexes_records() { @@ -281,7 +820,9 @@ mod tests { let report = certify_harnesses(HarnessCertificationRequest { source_root: dir.path().to_path_buf(), + memory_root: dir.path().join(".tree-ring"), evidence_dir: evidence_dir.clone(), + live: false, }) .unwrap(); @@ -326,7 +867,9 @@ mod tests { let report = certify_harnesses(HarnessCertificationRequest { source_root: dir.path().to_path_buf(), + memory_root: dir.path().join(".tree-ring"), evidence_dir: evidence_dir.clone(), + live: false, }) .unwrap(); @@ -336,24 +879,27 @@ mod tests { .find(|record| record.harness_id == "codex") .unwrap(); assert_eq!(codex.status, EvidenceStatus::Skip); - assert!(codex.summary.contains("activation state")); - assert!(codex - .summary - .contains("no active harness compatibility claim")); + assert_eq!( + codex.activation.state, + ActivationState::ConfiguredAwaitingProof + ); + assert_eq!(codex.activation.diagnostic, "activation manifest not found"); assert!(codex.guidance.recall_guidance); assert!(codex.guidance.remember_guidance); assert!(evidence_dir.join("harness/codex.json").exists()); } #[test] - fn harness_certification_fails_project_marker_without_generated_guidance() { + fn harness_certification_marker_without_generated_guidance_still_awaits_receipt() { let dir = tempdir().unwrap(); std::fs::write(dir.path().join("CLAUDE.md"), "# Claude instructions").unwrap(); let evidence_dir = certification_dir_for_project(dir.path()); let report = certify_harnesses(HarnessCertificationRequest { source_root: dir.path().to_path_buf(), + memory_root: dir.path().join(".tree-ring"), evidence_dir, + live: false, }) .unwrap(); @@ -362,11 +908,15 @@ mod tests { .iter() .find(|record| record.harness_id == "claude-code") .unwrap(); - assert_eq!(claude.status, EvidenceStatus::Fail); - assert!(claude - .summary - .contains("missing generated Tree Ring guidance")); - assert!(claude.next_step.contains("tree-ring init")); + assert_eq!(claude.status, EvidenceStatus::Skip); + assert_eq!( + claude.activation.state, + ActivationState::ConfiguredAwaitingProof + ); + assert_eq!( + claude.activation.diagnostic, + "activation manifest not found" + ); } #[test] @@ -411,7 +961,9 @@ mod tests { certify_harnesses(HarnessCertificationRequest { source_root: dir.path().to_path_buf(), + memory_root: dir.path().join(".tree-ring"), evidence_dir: evidence_dir.clone(), + live: false, }) .unwrap(); @@ -473,16 +1025,24 @@ mod tests { next_step: "Reference `.tree-ring/SKILL.md` from `CLAUDE.md` or `.claude` project instructions.".to_string(), }; - let record = probe_record(&integration, source_root, generated_at, &guidance); + let record = probe_record( + &integration, + source_root, + Path::new("/tmp/example project/.tree-ring"), + generated_at, + &guidance, + &ManifestInspection::Missing, + false, + ); assert_eq!(record.status, EvidenceStatus::Skip); assert_eq!( record.summary, - "Claude Code only has user-home markers; this project is not certified for that harness." + "Claude Code: activation manifest not found." ); assert_eq!( record.next_step, - "Add a project-level harness marker or project instruction file, then rerun `tree-ring integrations certify`." + "Run the adapter preflight at the start of a new harness session." ); assert_eq!( record.command, @@ -541,18 +1101,67 @@ mod tests { next_step: "Resolve activation before certification.".to_string(), }; - let record = probe_record(&integration, Path::new("/tmp/project"), "now", &guidance); + let record = probe_record( + &integration, + Path::new("/tmp/project"), + Path::new("/tmp/project/.tree-ring"), + "now", + &guidance, + &ManifestInspection::Missing, + false, + ); assert_eq!(record.status, EvidenceStatus::Skip, "{id}"); - assert!(!record - .summary - .contains("has a project marker and generated")); - assert!(record - .summary - .contains("no active harness compatibility claim")); + assert_ne!(record.activation.state, ActivationState::Active, "{id}"); + assert_eq!( + record.activation.diagnostic, "activation manifest not found", + "{id}" + ); } } + #[test] + fn harness_certification_never_passes_active_marker_without_receipt() { + let guidance = HarnessGuidanceEvidence { + agents_md: Some(PathBuf::from(".tree-ring/AGENTS.md")), + skill_md: Some(PathBuf::from(".tree-ring/SKILL.md")), + cli_md: Some(PathBuf::from(".tree-ring/CLI.md")), + recall_guidance: true, + remember_guidance: true, + }; + let integration = AdapterDetection { + id: "codex".to_string(), + name: "Codex".to_string(), + capability: tree_ring_memory_cli::activation::AdapterCapability::GuidanceOnly, + executable_version: Some("1.0".to_string()), + status: tree_ring_memory_cli::activation::adapters::IntegrationStatus::Detected, + state: tree_ring_memory_cli::activation::ActivationState::Active, + markers: vec![IntegrationMarker { + path: ".codex".to_string(), + origin: MarkerOrigin::Project, + }], + plan: tree_ring_memory_cli::activation::adapters::AdapterPlan { + harness_id: "codex".to_string(), + state: tree_ring_memory_cli::activation::ActivationState::Active, + writes: Vec::new(), + next_step: "Run preflight.".to_string(), + }, + next_step: "Run preflight.".to_string(), + }; + + let record = probe_record( + &integration, + Path::new("/tmp/project"), + Path::new("/tmp/project/.tree-ring"), + "2026-08-14T00:00:00Z", + &guidance, + &ManifestInspection::Missing, + false, + ); + + assert_ne!(record.status, EvidenceStatus::Pass); + } + #[test] fn harness_certification_rollup_keeps_certification_status_when_only_skips_are_present() { let index = crate::evidence::EvidenceIndex { diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs index 8043842..4239d32 100644 --- a/crates/tree-ring-memory-cli/src/main.rs +++ b/crates/tree-ring-memory-cli/src/main.rs @@ -672,7 +672,7 @@ fn run(cli: Cli) -> Result<(), String> { IntegrationCommand::Certify { source_root, out_dir, - live: _, + live, }, } = &cli.command { @@ -681,7 +681,9 @@ fn run(cli: Cli) -> Result<(), String> { .unwrap_or_else(|| evidence::certification_dir_for_project(source_root)); let report = certify_harnesses(HarnessCertificationRequest { source_root: source_root.clone(), + memory_root: cli.root.clone(), evidence_dir, + live: *live, })?; print_harness_certification_report(&report, cli.json)?; return Ok(()); @@ -1929,6 +1931,7 @@ fn format_harness_certification_report( "harness_id": record.harness_id, "name": record.name, "status": record.status, + "activation": record.activation, "markers": record.markers, "summary": record.summary, "next_step": record.next_step, @@ -1955,9 +1958,10 @@ fn format_harness_certification_report( )]; for record in &report.records { lines.push(format!( - "{} [{}] {}", + "{} [{}] activation={:?} {}", record.name, record.status.as_str(), + record.activation.state, record.summary )); lines.push(format!(" next: {}", record.next_step)); @@ -2027,6 +2031,21 @@ mod tests { use tree_ring_memory_core::{ConsolidationPeriod, ConsolidationRequest}; use tree_ring_memory_sqlite::MemoryRetriever; + fn harness_activation_fixture( + state: tree_ring_memory_cli::activation::ActivationState, + ) -> crate::harness_evidence::HarnessActivationEvidence { + crate::harness_evidence::HarnessActivationEvidence { + adapter_version: "1".to_string(), + adapter_capability: tree_ring_memory_cli::activation::AdapterCapability::GuidanceOnly, + state, + receipt_recorded_at: None, + receipt_age_seconds: None, + store_id_matches: state == tree_ring_memory_cli::activation::ActivationState::Active, + project_root_matches: true, + diagnostic: "fixture diagnostic".to_string(), + } + } + #[test] fn integrations_preflight_parses_direct_codex_identity() { let cli = Cli::try_parse_from([ @@ -2418,6 +2437,7 @@ mod tests { assert!(!root.join("memory.sqlite").exists()); assert!(out_dir.join("harness/codex.json").exists()); + assert!(out_dir.join("harness-activation-summary.md").exists()); let index = fs::read_to_string(out_dir.join("evidence-index.json")).unwrap(); assert!(index.contains("\"codex\"")); let parsed: serde_json::Value = serde_json::from_str(&index).unwrap(); @@ -2443,6 +2463,26 @@ mod tests { .exists()); } + #[test] + fn integrations_certify_live_uses_existing_parser_route() { + let cli = Cli::try_parse_from([ + "tree-ring", + "integrations", + "certify", + "--source-root", + "/tmp/project", + "--live", + ]) + .unwrap(); + + assert!(matches!( + cli.command, + Command::Integrations { + command: IntegrationCommand::Certify { live: true, .. } + } + )); + } + #[test] fn recall_quality_json_output_contract() { let report = RecallQualityReport { @@ -2590,6 +2630,9 @@ mod tests { source_root: PathBuf::from("/tmp/project"), command: "tree-ring integrations certify --source-root " .to_string(), + activation: harness_activation_fixture( + tree_ring_memory_cli::activation::ActivationState::Active, + ), markers: vec![crate::harness_evidence::HarnessProbeMarker { path: ".codex".to_string(), origin: "project".to_string(), @@ -2613,6 +2656,9 @@ mod tests { source_root: PathBuf::from("/tmp/project"), command: "tree-ring integrations certify --source-root " .to_string(), + activation: harness_activation_fixture( + tree_ring_memory_cli::activation::ActivationState::Failed, + ), markers: vec![], guidance: crate::harness_evidence::HarnessGuidanceEvidence { agents_md: None, @@ -2633,6 +2679,9 @@ mod tests { source_root: PathBuf::from("/tmp/project"), command: "tree-ring integrations certify --source-root " .to_string(), + activation: harness_activation_fixture( + tree_ring_memory_cli::activation::ActivationState::NeedsTrust, + ), markers: vec![], guidance: crate::harness_evidence::HarnessGuidanceEvidence { agents_md: None, @@ -2679,6 +2728,9 @@ mod tests { source_root: PathBuf::from("/tmp/project"), command: "tree-ring integrations certify --source-root " .to_string(), + activation: harness_activation_fixture( + tree_ring_memory_cli::activation::ActivationState::Active, + ), markers: vec![], guidance: crate::harness_evidence::HarnessGuidanceEvidence { agents_md: None, @@ -2699,6 +2751,9 @@ mod tests { source_root: PathBuf::from("/tmp/project"), command: "tree-ring integrations certify --source-root " .to_string(), + activation: harness_activation_fixture( + tree_ring_memory_cli::activation::ActivationState::Failed, + ), markers: vec![], guidance: crate::harness_evidence::HarnessGuidanceEvidence { agents_md: None, @@ -2719,6 +2774,9 @@ mod tests { source_root: PathBuf::from("/tmp/project"), command: "tree-ring integrations certify --source-root " .to_string(), + activation: harness_activation_fixture( + tree_ring_memory_cli::activation::ActivationState::NeedsTrust, + ), markers: vec![], guidance: crate::harness_evidence::HarnessGuidanceEvidence { agents_md: None, @@ -2739,19 +2797,19 @@ mod tests { "Tree Ring Memory harness certification: pass=1 fail=1 skip=1 evidence=/tmp/project/target/tree-ring-certification" )); assert!(output.contains( - "Codex [pass] Codex has a project marker and generated Tree Ring recall/remember guidance." + "Codex [pass] activation=Active Codex has a project marker and generated Tree Ring recall/remember guidance." )); assert!(output.contains( " next: Merge the generated Tree Ring guidance into the active Codex instructions." )); assert!(output.contains( - "Goose [fail] Goose has a project marker but is missing generated Tree Ring guidance." + "Goose [fail] activation=Failed Goose has a project marker but is missing generated Tree Ring guidance." )); assert!(output.contains( " next: Run `tree-ring init`, then reference `.tree-ring/SKILL.md` and `.tree-ring/CLI.md` from the harness project instructions." )); assert!(output.contains( - "PI [skip] PI was not detected for this project, so no compatibility claim is made." + "PI [skip] activation=NeedsTrust PI was not detected for this project, so no compatibility claim is made." )); assert!(output.contains( " next: Add a project-level harness marker or project instruction file, then rerun `tree-ring integrations certify`." From 8c58a03fddb43dcd1c0be7003d420b10971e196d Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 02:09:20 -0400 Subject: [PATCH 19/31] fix: batch harness init and expose isolated preflight --- .../src/activation/bridge.rs | 436 +++++++++++++++++- crates/tree-ring-memory-cli/src/main.rs | 91 ++-- .../tests/harness_activation_acceptance.rs | 114 ++++- 3 files changed, 605 insertions(+), 36 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/activation/bridge.rs b/crates/tree-ring-memory-cli/src/activation/bridge.rs index c7cbdbf..e73bd69 100644 --- a/crates/tree-ring-memory-cli/src/activation/bridge.rs +++ b/crates/tree-ring-memory-cli/src/activation/bridge.rs @@ -1,14 +1,17 @@ use super::{ adapters::{adapter_version, ActivationProject, AdapterPlan, ManagedBlockUpdate, PlannedWrite}, manifest::{ - bridge_fingerprint, validate_manifest, validate_project_relative_path, ActivationManifest, - HarnessActivation, OwnedBridgeFile, OwnedManagedBlock, + bridge_fingerprint, fingerprint_path, validate_manifest, validate_project_relative_path, + ActivationManifest, HarnessActivation, OwnedBridgeFile, OwnedManagedBlock, }, ActivationState, AdapterCapability, ACTIVATION_PROTOCOL_VERSION, }; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; -use std::path::{Path, PathBuf}; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; #[cfg(test)] use std::cell::RefCell; @@ -17,7 +20,7 @@ use std::cell::RefCell; use std::ffi::OsString; #[cfg(unix)] use std::{ - ffi::{CStr, CString, OsStr, OsString}, + ffi::{CStr, CString, OsString}, fs::File, io::{Read, Write}, os::{ @@ -197,6 +200,12 @@ pub struct BridgePlanResult { pub next_step: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgeBatchResult { + pub harness_id: String, + pub result: BridgePlanResult, +} + #[derive(Debug, Clone)] struct PreparedFile { relative: PathBuf, @@ -335,6 +344,12 @@ impl ProjectFs { Ok(manifest_lock) } + fn same_root_identity(&self, other: &Self) -> Result { + self.ensure_root_binding()?; + other.ensure_root_binding()?; + Ok(self.root_identity == other.root_identity) + } + pub(crate) fn read_optional(&self, relative: &Path) -> Result>, String> { self.ensure_root_binding()?; let Some(target) = self.resolve_target_optional(relative, false)? else { @@ -914,6 +929,10 @@ impl ProjectFs { Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) } + fn same_root_identity(&self, _other: &Self) -> Result { + Err("bridge access requires descriptor-relative no-follow filesystem support".to_string()) + } + pub(crate) fn read_optional(&self, _relative: &Path) -> Result>, String> { Err("bridge access requires descriptor-relative no-follow filesystem support".to_string()) } @@ -1115,6 +1134,221 @@ pub fn apply_bridge_plan( }) } +/// Applies every safe adapter plan beneath one pinned project root and publishes +/// one creation-only activation manifest after all bridge files are ready. +/// +/// This is deliberately narrower than repeated `apply_bridge_plan` calls: it +/// exists for first-run init, where publishing the manifest after the first +/// adapter would make later adapters require replacement of a trusted final +/// entry. Existing manifests are accepted only when the complete batch is +/// already semantically identical. +pub fn apply_bridge_plans_create_only( + project: &ActivationProject, + manifest: &mut ActivationManifest, + mut plans: Vec, +) -> Result, String> { + validate_manifest(manifest)?; + plans.sort_by(|left, right| left.harness_id.cmp(&right.harness_id)); + for plan in &plans { + validate_plan(plan)?; + } + if plans + .windows(2) + .any(|pair| pair[0].harness_id == pair[1].harness_id) + { + return Err("duplicate harness plan in activation batch".to_string()); + } + + let project_fs = ProjectFs::open(project)?; + let _manifest_lock = project_fs.lock_manifest()?; + let (current_manifest, expected_persisted) = reconcile_manifest(&project_fs, manifest)?; + let mut next_manifest = current_manifest.clone(); + let mut files = Vec::::new(); + let mut outcomes = Vec::with_capacity(plans.len()); + + for plan in plans { + if matches!( + plan.state, + ActivationState::NeedsPlugin | ActivationState::Unsupported + ) { + if !plan.writes.is_empty() { + return Err(format!( + "{} plan cannot write while in state {:?}", + plan.harness_id, plan.state + )); + } + let mut activation = next_manifest + .harnesses + .get(&plan.harness_id) + .cloned() + .unwrap_or(HarnessActivation { + state: plan.state, + adapter_capability: capability_for(&plan.harness_id)?, + adapter_version: adapter_version_for(&plan.harness_id)?.to_string(), + bridge_fingerprint: String::new(), + bridge_path: None, + owned_files: Vec::new(), + managed_blocks: Vec::new(), + }); + activation.state = plan.state; + activation.adapter_capability = capability_for(&plan.harness_id)?; + activation.adapter_version = adapter_version_for(&plan.harness_id)?.to_string(); + activation.bridge_fingerprint = bridge_fingerprint(&plan.harness_id, &activation); + next_manifest + .harnesses + .insert(plan.harness_id.clone(), activation); + outcomes.push(BridgeBatchResult { + harness_id: plan.harness_id, + result: BridgePlanResult { + state: plan.state, + changed_paths: Vec::new(), + next_step: plan.next_step, + }, + }); + continue; + } + + match prepare_apply(&project_fs, &next_manifest, &plan, false)? { + Preparation::Review { next_step } => outcomes.push(BridgeBatchResult { + harness_id: plan.harness_id, + result: BridgePlanResult { + state: ActivationState::NeedsUserReview, + changed_paths: Vec::new(), + next_step, + }, + }), + Preparation::Ready { + files: plan_files, + activation, + } => { + let changed_paths = plan_files + .iter() + .filter(|file| file.before != file.after) + .map(|file| file.relative.clone()) + .collect::>(); + for file in plan_files { + if let Some(existing) = files + .iter() + .find(|existing| existing.relative == file.relative) + { + if existing.before != file.before || existing.after != file.after { + return Err( + "incompatible shared bridge target in activation batch".to_string() + ); + } + } else { + files.push(file); + } + } + next_manifest + .harnesses + .insert(plan.harness_id.clone(), activation); + let state = applied_state(&plan.harness_id, plan.state); + outcomes.push(BridgeBatchResult { + harness_id: plan.harness_id, + result: BridgePlanResult { + state, + changed_paths, + next_step: plan.next_step, + }, + }); + } + } + } + + validate_manifest(&next_manifest)?; + if files_require_existing_entry_mutation(&files) { + return Err( + "bridge writer will not replace or remove an existing final entry; explicit review is required" + .to_string(), + ); + } + if manifest_update_requires_replacement(expected_persisted.as_ref(), &next_manifest) { + for outcome in &mut outcomes { + if current_manifest.harnesses.get(&outcome.harness_id) + != next_manifest.harnesses.get(&outcome.harness_id) + { + outcome.result = creation_only_review_result(&outcome.harness_id); + } else { + outcome.result.changed_paths.clear(); + } + } + *manifest = current_manifest; + return Ok(outcomes); + } + if expected_persisted.is_some() + && files.iter().all(|file| file.before == file.after) + && next_manifest == current_manifest + { + *manifest = current_manifest; + for outcome in &mut outcomes { + outcome.result.changed_paths.clear(); + } + return Ok(outcomes); + } + + commit_files_and_manifest( + &project_fs, + manifest, + ¤t_manifest, + expected_persisted.as_ref(), + next_manifest, + &files, + )?; + Ok(outcomes) +} + +/// Validates the two-root preflight topology without following project-root or +/// manifest symlinks. The selected manifest is returned for the subsequent +/// isolated-store preflight; the canonical manifest is read only to prove that +/// the explicit project root is itself a valid Tree Ring project. +pub fn validate_isolated_preflight_roots( + selected_memory_root: &Path, + canonical_project_root: &Path, +) -> Result { + fn validate( + selected_memory_root: &Path, + canonical_project_root: &Path, + ) -> Result { + if selected_memory_root.file_name() != Some(OsStr::new(".tree-ring")) { + return Err("selected memory root is not project-local".to_string()); + } + let selected_project = ActivationProject::from_memory_root(selected_memory_root)?; + let canonical_project = ActivationProject::from_project_root(canonical_project_root); + let selected_fs = ProjectFs::open(&selected_project)?; + let canonical_fs = ProjectFs::open(&canonical_project)?; + let selected = load_persisted_manifest(&selected_fs)? + .ok_or_else(|| "selected activation manifest is unavailable".to_string())?; + let canonical = load_persisted_manifest(&canonical_fs)? + .ok_or_else(|| "canonical activation manifest is unavailable".to_string())?; + + if selected.project_root_fingerprint != fingerprint_path(&selected_project.project_root) + || canonical.project_root_fingerprint + != fingerprint_path(&canonical_project.project_root) + { + return Err("activation manifest project identity mismatch".to_string()); + } + if selected_fs.same_root_identity(&canonical_fs)? || selected.store_id == canonical.store_id + { + return Err("preflight roots are not isolated".to_string()); + } + Ok(selected) + } + + validate(selected_memory_root, canonical_project_root) + .map_err(|_| "isolated preflight roots are invalid".to_string()) +} + +/// Reads the init manifest through the pinned project descriptor. A missing +/// manifest is returned as `None`; callers may then construct the first +/// in-memory identity for creation-only batch publication. +pub fn load_init_manifest_no_follow( + project: &ActivationProject, +) -> Result, String> { + let project_fs = ProjectFs::open(project)?; + load_persisted_manifest(&project_fs) +} + /// Produces the same validated plan as apply without writing files or the /// activation manifest. pub fn preview_bridge_plan( @@ -2572,6 +2806,200 @@ mod tests { } } + #[test] + fn init_batch_publishes_all_safe_adapters_in_one_deterministic_manifest() { + let (_temp, project, mut manifest) = fixture(); + let plans = vec![ + plan("pi", &project), + plan("codex", &project), + plan("claude-code", &project), + ]; + + let first = apply_bridge_plans_create_only(&project, &mut manifest, plans.clone()).unwrap(); + + assert_eq!( + first + .iter() + .map(|outcome| outcome.harness_id.as_str()) + .collect::>(), + ["claude-code", "codex", "pi"] + ); + assert_eq!( + manifest + .harnesses + .keys() + .map(String::as_str) + .collect::>(), + ["claude-code", "codex", "pi"] + ); + assert_eq!(manifest.harnesses["pi"].state, ActivationState::NeedsTrust); + let shared = ".agents/skills/tree-ring-memory/SKILL.md"; + assert!(manifest.harnesses["codex"] + .owned_files + .iter() + .any(|owned| owned.path == shared)); + assert!(manifest.harnesses["pi"] + .owned_files + .iter() + .any(|owned| owned.path == shared)); + assert_eq!( + crate::activation::load_manifest(&project.memory_root).unwrap(), + manifest + ); + + let bytes = fs::read(project.memory_root.join("activation.json")).unwrap(); + let second = apply_bridge_plans_create_only(&project, &mut manifest, plans).unwrap(); + assert!(second + .iter() + .all(|outcome| outcome.result.changed_paths.is_empty())); + assert_eq!( + fs::read(project.memory_root.join("activation.json")).unwrap(), + bytes + ); + } + + #[test] + fn empty_init_batch_still_creation_publishes_the_store_manifest() { + let (_temp, project, mut manifest) = fixture(); + + let outcomes = apply_bridge_plans_create_only(&project, &mut manifest, Vec::new()).unwrap(); + + assert!(outcomes.is_empty()); + assert_eq!( + crate::activation::load_manifest(&project.memory_root).unwrap(), + manifest + ); + } + + #[test] + fn init_batch_never_extends_a_preexisting_partial_manifest() { + let (_temp, project, mut manifest) = fixture(); + apply_bridge_plan(&project, &mut manifest, plan("codex", &project), false).unwrap(); + let manifest_bytes = fs::read(project.memory_root.join("activation.json")).unwrap(); + + let outcomes = apply_bridge_plans_create_only( + &project, + &mut manifest, + vec![ + plan("pi", &project), + plan("codex", &project), + plan("claude-code", &project), + ], + ) + .unwrap(); + + assert_eq!(outcomes[1].harness_id, "codex"); + assert_eq!( + outcomes[1].result.state, + ActivationState::ConfiguredAwaitingProof + ); + for harness in ["claude-code", "pi"] { + assert_eq!( + outcomes + .iter() + .find(|outcome| outcome.harness_id == harness) + .unwrap() + .result + .state, + ActivationState::NeedsUserReview + ); + } + assert_eq!( + fs::read(project.memory_root.join("activation.json")).unwrap(), + manifest_bytes + ); + assert!(!project + .project_root + .join(".claude/skills/tree-ring-memory/SKILL.md") + .exists()); + assert!(!project + .project_root + .join(".pi/extensions/tree-ring-memory.ts") + .exists()); + } + + #[test] + fn init_batch_rejects_duplicate_adapter_plans_before_writing() { + let (_temp, project, mut manifest) = fixture(); + let codex = plan("codex", &project); + + let error = + apply_bridge_plans_create_only(&project, &mut manifest, vec![codex.clone(), codex]) + .unwrap_err(); + + assert_eq!(error, "duplicate harness plan in activation batch"); + assert!(!project.memory_root.join("activation.json").exists()); + assert!(manifest.harnesses.is_empty()); + } + + #[test] + fn init_batch_late_durability_failure_marks_every_staged_adapter_for_review() { + let (_temp, project, mut manifest) = fixture(); + fail_directory_sync_at_for_test(2); + + let error = apply_bridge_plans_create_only( + &project, + &mut manifest, + vec![ + plan("codex", &project), + plan("claude-code", &project), + plan("pi", &project), + ], + ) + .unwrap_err(); + + assert!(error.contains("injected directory sync failure")); + assert!(error.contains("indeterminate")); + assert!(!project.memory_root.join("activation.json").exists()); + for harness in ["claude-code", "codex", "pi"] { + assert_eq!( + manifest.harnesses[harness].state, + ActivationState::NeedsUserReview + ); + } + } + + #[test] + fn isolated_preflight_roots_require_two_valid_project_local_manifests() { + let temp = tempfile::tempdir().unwrap(); + let canonical_project = ActivationProject::from_project_root(temp.path().join("canonical")); + let selected_project = ActivationProject::from_project_root(temp.path().join("selected")); + for project in [&canonical_project, &selected_project] { + fs::create_dir_all(&project.memory_root).unwrap(); + } + let make_manifest = |project: &ActivationProject, store_id: &str| ActivationManifest { + schema_version: ACTIVATION_SCHEMA_VERSION, + protocol_version: ACTIVATION_PROTOCOL_VERSION, + store_id: store_id.to_string(), + project_root_fingerprint: fingerprint_path(&project.project_root), + cli_version: "0.14.0".to_string(), + harnesses: BTreeMap::new(), + }; + let canonical_manifest = make_manifest(&canonical_project, "canonical-store"); + let selected_manifest = make_manifest(&selected_project, "selected-store"); + crate::activation::save_manifest(&canonical_project.memory_root, &canonical_manifest) + .unwrap(); + crate::activation::save_manifest(&selected_project.memory_root, &selected_manifest) + .unwrap(); + + assert_eq!( + validate_isolated_preflight_roots( + &selected_project.memory_root, + &canonical_project.project_root, + ) + .unwrap(), + selected_manifest + ); + assert_eq!( + validate_isolated_preflight_roots( + &selected_project.project_root, + &canonical_project.project_root, + ) + .unwrap_err(), + "isolated preflight roots are invalid" + ); + } + #[test] fn accepted_agents_block_requires_review_when_the_final_file_already_exists() { let (_temp, project, mut manifest) = fixture(); diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs index 4239d32..acf3b2f 100644 --- a/crates/tree-ring-memory-cli/src/main.rs +++ b/crates/tree-ring-memory-cli/src/main.rs @@ -416,6 +416,11 @@ enum IntegrationCommand { Preflight { #[arg(long)] harness: String, + #[arg( + long, + help = "explicit canonical project root for a direct isolated-store preflight" + )] + canonical_project_root: Option, #[arg(long)] agent_profile: Option, #[arg(long)] @@ -693,6 +698,7 @@ fn run(cli: Cli) -> Result<(), String> { command: IntegrationCommand::Preflight { harness, + canonical_project_root, agent_profile, workflow_id, session_id, @@ -701,14 +707,26 @@ fn run(cli: Cli) -> Result<(), String> { }, } = &cli.command { - let project = activation::adapters::ActivationProject { - project_root: cli - .root - .parent() - .filter(|path| !path.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")) - .to_path_buf(), - memory_root: cli.root.clone(), + if canonical_project_root.is_some() && *input_json_stdin { + return Err( + "--canonical-project-root is supported only with direct identity flags".to_string(), + ); + } + let project = if let Some(canonical_project_root) = canonical_project_root { + activation::adapters::ActivationProject { + project_root: canonical_project_root.clone(), + memory_root: cli.root.clone(), + } + } else { + activation::adapters::ActivationProject { + project_root: cli + .root + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(), + memory_root: cli.root.clone(), + } }; let format = activation_context_format(*context_format); let input = if *input_json_stdin { @@ -735,10 +753,22 @@ fn run(cli: Cli) -> Result<(), String> { input.as_deref(), format, )?; - let manifest = activation::load_manifest(&cli.root)?; + let manifest = if let Some(canonical_project_root) = canonical_project_root { + activation::bridge::validate_isolated_preflight_roots( + &cli.root, + canonical_project_root, + )? + } else { + activation::load_manifest(&cli.root)? + }; ensure_manifest_preflight_ready(&manifest, harness)?; - let store = - SQLiteMemoryStore::open_read_only(&db_path).map_err(|error| error.to_string())?; + let store = SQLiteMemoryStore::open_read_only(&db_path).map_err(|error| { + if canonical_project_root.is_some() { + "isolated preflight storage unavailable".to_string() + } else { + error.to_string() + } + })?; println!( "{}", integration_preflight_action(&store, &project, &manifest, request, format)? @@ -1263,21 +1293,15 @@ fn run_init(root: &Path, dry_run: bool, json_output: bool) -> Result<(), String> .map_err(|error| error.to_string())?; drop(store); - let mut manifest = if root.join("activation.json").exists() { - activation::load_manifest(root)? - } else { - new_activation_manifest(&project.project_root) - }; - let mut outcomes = Vec::new(); - for detection in candidates { - let result = - activation::bridge::apply_bridge_plan(&project, &mut manifest, detection.plan, false)?; - outcomes.push((detection.id, result.state, result.next_step)); - } - let manifest = activation::load_or_create_manifest( - root, - &project.project_root, - env!("CARGO_PKG_VERSION"), + let mut manifest = activation::bridge::load_init_manifest_no_follow(&project)? + .unwrap_or_else(|| new_activation_manifest(&project.project_root)); + let outcomes = activation::bridge::apply_bridge_plans_create_only( + &project, + &mut manifest, + candidates + .iter() + .map(|detection| detection.plan.clone()) + .collect(), )?; let mut status = integration_status_action(IntegrationStatusRequest { source_root: project.project_root, @@ -1285,11 +1309,15 @@ fn run_init(root: &Path, dry_run: bool, json_output: bool) -> Result<(), String> verbose: true, })?; status.store_id = Some(manifest.store_id); - for (id, state, next_step) in outcomes { - if let Some(entry) = status.integrations.iter_mut().find(|entry| entry.id == id) { - if state == activation::ActivationState::NeedsUserReview { - entry.state = state; - entry.next_step = next_step; + for outcome in outcomes { + if let Some(entry) = status + .integrations + .iter_mut() + .find(|entry| entry.id == outcome.harness_id) + { + if outcome.result.state == activation::ActivationState::NeedsUserReview { + entry.state = outcome.result.state; + entry.next_step = outcome.result.next_step; } } } @@ -2068,6 +2096,7 @@ mod tests { Command::Integrations { command: IntegrationCommand::Preflight { harness, + canonical_project_root: None, agent_profile: Some(agent_profile), workflow_id: Some(workflow_id), session_id: Some(session_id), diff --git a/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs b/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs index f517f48..38ce882 100644 --- a/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs +++ b/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs @@ -1,11 +1,12 @@ #![cfg(unix)] +use serde_json::Value; use sha2::{Digest, Sha256}; use std::{ collections::BTreeMap, ffi::OsString, fs, - os::unix::fs::PermissionsExt, + os::unix::fs::{MetadataExt, PermissionsExt}, path::{Path, PathBuf}, process::Command, }; @@ -99,6 +100,117 @@ fn claude_launch_cli_forwards_only_the_private_context_path_and_child_arguments( ); } +#[test] +fn direct_cli_reports_active_isolated_without_touching_the_canonical_store() { + let temp = tempfile::tempdir().unwrap(); + let canonical_project = temp.path().join("canonical-project"); + let isolated_project = temp.path().join("isolated-store"); + let empty_path = temp.path().join("empty-path"); + let home = temp.path().join("home"); + for project in [&canonical_project, &isolated_project] { + fs::create_dir_all(project.join(".codex")).unwrap(); + } + fs::create_dir_all(&empty_path).unwrap(); + fs::create_dir_all(&home).unwrap(); + let canonical_root = canonical_project.join(".tree-ring"); + let isolated_root = isolated_project.join(".tree-ring"); + + for (name, project, root) in [ + ("canonical", &canonical_project, &canonical_root), + ("isolated", &isolated_project, &isolated_root), + ] { + let output = tree_ring(root, project, &empty_path, &home) + .arg("init") + .output() + .unwrap(); + assert!( + output.status.success(), + "{name} init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + let canonical_preflight = tree_ring(&canonical_root, &canonical_project, &empty_path, &home) + .args([ + "integrations", + "preflight", + "--harness", + "codex", + "--agent-profile", + "canonical-worker", + "--workflow-id", + "workflow", + "--session-id", + "canonical-session", + ]) + .output() + .unwrap(); + assert!(canonical_preflight.status.success()); + + let canonical_manifest_before = fs::read(canonical_root.join("activation.json")).unwrap(); + let canonical_sqlite_before = fs::read(canonical_root.join("memory.sqlite")).unwrap(); + let canonical_receipts_before = json_files_below(&canonical_root.join("activation/receipts")); + let canonical_meta = fs::metadata(canonical_root.join("memory.sqlite")).unwrap(); + let isolated_meta = fs::metadata(isolated_root.join("memory.sqlite")).unwrap(); + assert_ne!( + (canonical_meta.dev(), canonical_meta.ino()), + (isolated_meta.dev(), isolated_meta.ino()) + ); + + let isolated_preflight = tree_ring(&isolated_root, &isolated_project, &empty_path, &home) + .arg("integrations") + .arg("preflight") + .arg("--harness") + .arg("codex") + .arg("--canonical-project-root") + .arg(&canonical_project) + .arg("--agent-profile") + .arg("isolated-worker") + .arg("--workflow-id") + .arg("workflow") + .arg("--session-id") + .arg("isolated-session") + .output() + .unwrap(); + assert!( + isolated_preflight.status.success(), + "isolated preflight failed: {}", + String::from_utf8_lossy(&isolated_preflight.stderr) + ); + let response: Value = serde_json::from_slice(&isolated_preflight.stdout).unwrap(); + assert_eq!(response["state"], "active-isolated"); + assert_eq!( + fs::read(canonical_root.join("activation.json")).unwrap(), + canonical_manifest_before + ); + assert_eq!( + fs::read(canonical_root.join("memory.sqlite")).unwrap(), + canonical_sqlite_before + ); + assert_eq!( + json_files_below(&canonical_root.join("activation/receipts")), + canonical_receipts_before + ); + assert_eq!( + json_files_below(&isolated_root.join("activation/receipts")), + 1 + ); + let output_text = String::from_utf8(isolated_preflight.stdout).unwrap(); + assert!(!output_text.contains(&canonical_project.to_string_lossy().to_string())); + assert!(!output_text.contains(&isolated_project.to_string_lossy().to_string())); +} + +fn tree_ring(root: &Path, project: &Path, path: &Path, home: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_tree-ring")); + command + .current_dir(project) + .env("PATH", path) + .env("HOME", home) + .arg("--root") + .arg(root) + .arg("--json"); + command +} + fn fingerprint_path(path: &Path) -> String { let canonical = fs::canonicalize(path).unwrap(); let mut hasher = Sha256::new(); From f5a4dad1d21ed5f2d72bb3e219d241ef762fe48f Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 02:12:26 -0400 Subject: [PATCH 20/31] test: prove multi-agent harness activation --- .../tests/harness_activation_acceptance.rs | 920 ++++++++++++++---- .../tests/multi_agent_acceptance.rs | 93 +- fixtures/harness-activation/agent-zero.json | 19 + fixtures/harness-activation/claude-code.json | 20 + fixtures/harness-activation/codex.json | 27 + fixtures/harness-activation/pi.json | 20 + 6 files changed, 896 insertions(+), 203 deletions(-) create mode 100644 fixtures/harness-activation/agent-zero.json create mode 100644 fixtures/harness-activation/claude-code.json create mode 100644 fixtures/harness-activation/codex.json create mode 100644 fixtures/harness-activation/pi.json diff --git a/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs b/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs index 38ce882..fcda62b 100644 --- a/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs +++ b/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs @@ -1,253 +1,769 @@ #![cfg(unix)] -use serde_json::Value; -use sha2::{Digest, Sha256}; +use serde_json::{json, Value}; use std::{ - collections::BTreeMap, - ffi::OsString, + collections::{BTreeMap, BTreeSet}, + ffi::{OsStr, OsString}, fs, - os::unix::fs::{MetadataExt, PermissionsExt}, + io::Write, + os::unix::fs::{symlink, MetadataExt}, path::{Path, PathBuf}, - process::Command, + process::{Command, Output, Stdio}, }; -use tree_ring_memory_cli::activation::{ - manifest::{bridge_fingerprint, save_manifest}, - ActivationManifest, ActivationState, AdapterCapability, HarnessActivation, OwnedBridgeFile, - ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, -}; -use tree_ring_memory_sqlite::SQLiteMemoryStore; +use tempfile::tempdir; + +const FIXTURE_TEXTS: [&str; 4] = [ + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/harness-activation/codex.json" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/harness-activation/claude-code.json" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/harness-activation/pi.json" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/harness-activation/agent-zero.json" + )), +]; +const PROJECT_NAME: &str = "fixture-project"; +const WORKFLOW_ID: &str = "fixture-flow"; +const SEEDED_MEMORY: &str = + "fixture project startup constraints require deterministic source safe evidence"; +const RAW_TASK_HINT: &str = "fixture project startup constraints"; +const CAPABILITY_SENTINEL: &str = "fixture-coordinator-capability-must-not-persist"; #[test] -fn claude_launch_cli_forwards_only_the_private_context_path_and_child_arguments() { - let temp = tempfile::tempdir().unwrap(); - let project_root = temp.path().join("project"); - let memory_root = project_root.join(".tree-ring"); - fs::create_dir_all(project_root.join(".claude/skills/tree-ring-memory")).unwrap(); - fs::create_dir_all(&memory_root).unwrap(); - fs::write( - project_root.join(".claude/skills/tree-ring-memory/SKILL.md"), - "Tree Ring bridge fixture\n", - ) - .unwrap(); - drop(SQLiteMemoryStore::open(memory_root.join("memory.sqlite")).unwrap()); - - let mut activation = HarnessActivation { - state: ActivationState::ConfiguredAwaitingProof, - adapter_capability: AdapterCapability::WrapperPreflight, - adapter_version: "1".to_string(), - bridge_fingerprint: String::new(), - bridge_path: Some(".claude/skills/tree-ring-memory/SKILL.md".to_string()), - owned_files: vec![OwnedBridgeFile { - path: ".claude/skills/tree-ring-memory/SKILL.md".to_string(), - sha256: "b".repeat(64), - }], - managed_blocks: Vec::new(), - }; - activation.bridge_fingerprint = bridge_fingerprint("claude-code", &activation); - let manifest = ActivationManifest { - schema_version: ACTIVATION_SCHEMA_VERSION, - protocol_version: ACTIVATION_PROTOCOL_VERSION, - store_id: "store-cli-launch-test".to_string(), - project_root_fingerprint: fingerprint_path(&project_root), - cli_version: env!("CARGO_PKG_VERSION").to_string(), - harnesses: BTreeMap::from([("claude-code".to_string(), activation)]), - }; - save_manifest(&memory_root, &manifest).unwrap(); - - let fake_bin = temp.path().join("bin"); - fs::create_dir_all(&fake_bin).unwrap(); - let fake_claude = fake_bin.join("claude"); - fs::write( - &fake_claude, - "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$TREE_RING_LAUNCH_ARGS\"\nif [ ! -f \"$2\" ]; then exit 91; fi\nhead -n 1 \"$2\" > \"$TREE_RING_LAUNCH_CONTEXT\"\nexit 7\n", - ) - .unwrap(); - fs::set_permissions(&fake_claude, fs::Permissions::from_mode(0o755)).unwrap(); - let captured_args = temp.path().join("args.txt"); - let captured_context = temp.path().join("context.txt"); - let path = prepend_path(&fake_bin); - - let output = Command::new(env!("CARGO_BIN_EXE_tree-ring")) - .env("PATH", path) - .env("TREE_RING_LAUNCH_ARGS", &captured_args) - .env("TREE_RING_LAUNCH_CONTEXT", &captured_context) - .arg("--root") - .arg(&memory_root) - .arg("integrations") - .arg("launch") - .arg("--harness") - .arg("claude-code") - .arg("--") - .arg("--model") - .arg("sonnet") +fn shipped_fixtures_declare_only_project_local_versioned_activation_contracts() { + let fixture_map = fixtures(); + assert_eq!( + fixture_map.keys().map(String::as_str).collect::>(), + ["agent-zero", "claude-code", "codex", "pi"] + ); + + let expected = BTreeMap::from([ + ( + "agent-zero", + ( + "needs-plugin", + vec![".tree-ring/activation/agent-zero.json"], + ), + ), + ( + "claude-code", + ( + "configured-awaiting-proof", + vec![ + ".claude/skills/tree-ring-memory/SKILL.md", + ".claude/settings.json", + ], + ), + ), + ( + "codex", + ( + "configured-awaiting-proof", + vec![".agents/skills/tree-ring-memory/SKILL.md", "AGENTS.md"], + ), + ), + ( + "pi", + ( + "needs-trust", + vec![ + ".agents/skills/tree-ring-memory/SKILL.md", + ".pi/extensions/tree-ring-memory.ts", + ], + ), + ), + ]); + + for (id, fixture) in fixture_map { + assert_eq!(fixture["schema_version"], 1, "{id}"); + assert_eq!(fixture["harness_id"], id, "{id}"); + assert_eq!(fixture["adapter_version"], "1", "{id}"); + assert_eq!( + fixture["expected_activation_state_before_proof"], + expected[id.as_str()].0, + "{id}" + ); + assert_eq!( + strings(&fixture["expected_bridge_paths"]), + expected[id.as_str()].1, + "{id}" + ); + assert_eq!(fixture["seeded_memory"]["summary"], SEEDED_MEMORY); + + let serialized = serde_json::to_string(&fixture).unwrap(); + for forbidden in ["/Users/", "${HOME}", "$HOME", "~/", "\\Users\\"] { + assert!( + !serialized.contains(forbidden), + "{id} depends on {forbidden}" + ); + } + for path in strings(&fixture["marker_paths"]) + .into_iter() + .chain(strings(&fixture["expected_bridge_paths"])) + { + assert!( + !Path::new(path).is_absolute(), + "{id} path is not local: {path}" + ); + assert!(!path.split('/').any(|part| part == ".."), "{id}: {path}"); + } + let live_env = fixture["live_executable_env"].as_str().unwrap(); + assert!(live_env.starts_with("TREE_RING_LIVE_"), "{id}"); + assert!(live_env.ends_with("_EXECUTABLE"), "{id}"); + } + + let isolated_fixtures = fixtures(); + let codex = &isolated_fixtures["codex"]; + assert_eq!(codex["isolated_root"]["selected_project"], "isolated-store"); + assert_eq!(codex["isolated_root"]["memory_root"], ".tree-ring"); + assert_eq!( + codex["isolated_root"]["canonical_project_arg"], + "--canonical-project-root" + ); + assert_eq!(codex["isolated_root"]["expected_state"], "active-isolated"); + assert_eq!(codex["isolated_root"]["copy_sqlite"], false); +} + +#[test] +fn init_preflight_status_and_certify_prove_same_store_workers_without_private_receipt_data() { + let temp = tempdir().unwrap(); + let project = temp.path().join(PROJECT_NAME); + let empty_path = temp.path().join("empty-path"); + fs::create_dir_all(&project).unwrap(); + fs::create_dir_all(&empty_path).unwrap(); + install_fixture_markers(&project); + + let root = project.join(".tree-ring"); + let init = tree_ring(&root, &project, &empty_path) + .arg("init") .output() .unwrap(); + assert_success("canonical init", &init); + let init_json = output_json("canonical init", &init); + assert_eq!(init_json["ok"], true); + assert_initial_states(&init_json["integrations"]); + assert_manifest_contracts(&root); + let before_proof_status = integration_status(&root, &project, &empty_path); + assert_status_state(&before_proof_status, "agent-zero", "needs-plugin"); + + seed_for_identity( + &root, + &project, + &empty_path, + "worker-a", + WORKFLOW_ID, + "codex-session-a", + "seed-codex-a", + ); + seed_for_identity( + &root, + &project, + &empty_path, + "worker-b", + WORKFLOW_ID, + "codex-session-b", + "seed-codex-b", + ); + seed_for_identity( + &root, + &project, + &empty_path, + "claude-code", + "claude-session-b", + "claude-session-b", + "seed-claude-b", + ); + seed_for_identity( + &root, + &project, + &empty_path, + "pi-worker-b", + WORKFLOW_ID, + "pi-session-b", + "seed-pi-b", + ); - assert_eq!(output.status.code(), Some(7)); - let args = fs::read_to_string(captured_args).unwrap(); - let lines = args.lines().collect::>(); - assert_eq!(lines[0], "--append-system-prompt-file"); - assert!(lines[1].ends_with(".md")); - assert_eq!(&lines[2..], ["--", "--model", "sonnet"]); + let codex_a = codex_preflight(&root, &project, &empty_path, "worker-a", "codex-session-a"); + let codex_b = codex_preflight(&root, &project, &empty_path, "worker-b", "codex-session-b"); + assert_eq!(codex_a["state"], "active"); + assert_eq!(codex_b["state"], "active"); + assert!(codex_a["context"].as_str().unwrap().contains(SEEDED_MEMORY)); + assert!(codex_b["context"].as_str().unwrap().contains(SEEDED_MEMORY)); + assert_ne!( + codex_a["receipt"]["receipt_id"], + codex_b["receipt"]["receipt_id"] + ); assert_eq!( - fs::read_to_string(captured_context).unwrap().trim(), - "Tree Ring Memory scoped preflight recall:" + codex_a["receipt"]["store_id"], + codex_b["receipt"]["store_id"] ); - assert!(directory_files(&memory_root.join("activation/runtime")).is_empty()); + + let claude_input = json!({ + "session_id": "claude-session-b", + "cwd": project, + "agent_type": "claude-code", + "transcript_path": "/ignored/by/adapter" + }); + let claude = adapter_preflight( + &root, + &project, + &empty_path, + "claude-code", + "claude-session-start", + &claude_input, + ); + assert!(claude["hookSpecificOutput"]["additionalContext"] + .as_str() + .unwrap() + .contains(SEEDED_MEMORY)); + + let pi_input = json!({ + "agent_profile": "pi-worker-b", + "workflow_id": WORKFLOW_ID, + "session_id": "pi-session-b", + "task_hint": RAW_TASK_HINT + }); + let pi = adapter_preflight( + &root, + &project, + &empty_path, + "pi", + "pi-before-agent-start", + &pi_input, + ); + assert_eq!(pi["state"], "active"); + assert!(pi["context"].as_str().unwrap().contains(SEEDED_MEMORY)); + + let before_agent_zero = receipt_documents(&root); + let agent_zero_input = json!({ + "agent_profile": "agent-zero-worker", + "workflow_id": WORKFLOW_ID, + "session_id": "agent-zero-session", + "capability": CAPABILITY_SENTINEL + }); + let rejected = adapter_preflight_output( + &root, + &project, + &empty_path, + "agent-zero", + "json", + &agent_zero_input, + ); + assert!(!rejected.status.success()); + assert!(String::from_utf8_lossy(&rejected.stderr).contains("forbidden field")); + assert_eq!(receipt_documents(&root), before_agent_zero); + + let receipts = receipt_documents(&root); + assert_eq!(receipts.len(), 4); + let persisted = receipts + .values() + .map(|document| serde_json::from_str::(document).unwrap()) + .collect::>(); + let codex_receipts = persisted + .iter() + .filter(|receipt| receipt["harness_id"] == "codex") + .collect::>(); + assert_eq!(codex_receipts.len(), 2); + assert_ne!( + codex_receipts[0]["session"]["session_id"], + codex_receipts[1]["session"]["session_id"] + ); + assert_ne!( + codex_receipts[0]["worker_key_fingerprint"], + codex_receipts[1]["worker_key_fingerprint"] + ); + let store_ids = persisted + .iter() + .map(|receipt| receipt["store_id"].as_str().unwrap()) + .collect::>(); assert_eq!( - json_files_below(&memory_root.join("activation/receipts")), - 1 + store_ids.len(), + 1, + "all proven harnesses use one canonical store" ); + for document in receipts.values() { + for forbidden in [ + SEEDED_MEMORY, + RAW_TASK_HINT, + CAPABILITY_SENTINEL, + "transcript_path", + ] { + assert!(!document.contains(forbidden), "receipt leaked {forbidden}"); + } + let receipt: Value = serde_json::from_str(document).unwrap(); + assert!(receipt.get("task_hint").is_none()); + assert!(receipt.get("capability").is_none()); + assert!(receipt.get("capabilities").is_none()); + } + + let status = integration_status(&root, &project, &empty_path); + assert_status_state(&status, "codex", "active"); + assert_status_state(&status, "claude-code", "active"); + assert_status_state(&status, "pi", "active"); + assert_status_state(&status, "agent-zero", "needs-plugin"); + + let evidence = temp.path().join("evidence"); + let certification = tree_ring(&root, &project, &empty_path) + .arg("integrations") + .arg("certify") + .arg("--source-root") + .arg(&project) + .arg("--out-dir") + .arg(&evidence) + .output() + .unwrap(); + assert_success("certify", &certification); + let certification = output_json("certify", &certification); + for id in ["codex", "claude-code", "pi"] { + let record = record_by_id(&certification["report"]["records"], id); + assert_eq!(record["status"], "pass", "{id}: {record}"); + assert_eq!(record["activation"]["state"], "active", "{id}"); + assert_eq!(record["activation"]["store_id_matches"], true, "{id}"); + } + let agent_zero = record_by_id(&certification["report"]["records"], "agent-zero"); + assert_eq!(agent_zero["status"], "skip"); + assert_eq!(agent_zero["activation"]["state"], "needs-plugin"); } #[test] -fn direct_cli_reports_active_isolated_without_touching_the_canonical_store() { - let temp = tempfile::tempdir().unwrap(); - let canonical_project = temp.path().join("canonical-project"); - let isolated_project = temp.path().join("isolated-store"); +fn alternate_memory_root_is_active_isolated_without_copying_or_mutating_canonical_store() { + let temp = tempdir().unwrap(); + let project = temp.path().join(PROJECT_NAME); let empty_path = temp.path().join("empty-path"); - let home = temp.path().join("home"); - for project in [&canonical_project, &isolated_project] { - fs::create_dir_all(project.join(".codex")).unwrap(); - } + fs::create_dir_all(&project).unwrap(); fs::create_dir_all(&empty_path).unwrap(); - fs::create_dir_all(&home).unwrap(); - let canonical_root = canonical_project.join(".tree-ring"); - let isolated_root = isolated_project.join(".tree-ring"); - - for (name, project, root) in [ - ("canonical", &canonical_project, &canonical_root), - ("isolated", &isolated_project, &isolated_root), - ] { - let output = tree_ring(root, project, &empty_path, &home) - .arg("init") - .output() - .unwrap(); - assert!( - output.status.success(), - "{name} init failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - let canonical_preflight = tree_ring(&canonical_root, &canonical_project, &empty_path, &home) - .args([ - "integrations", - "preflight", - "--harness", - "codex", - "--agent-profile", - "canonical-worker", - "--workflow-id", - "workflow", - "--session-id", - "canonical-session", - ]) + install_fixture_markers(&project); + + let canonical = project.join(".tree-ring"); + let init = tree_ring(&canonical, &project, &empty_path) + .arg("init") + .output() + .unwrap(); + assert_success("canonical init", &init); + codex_preflight( + &canonical, + &project, + &empty_path, + "canonical-worker", + "canonical-session", + ); + let canonical_manifest_before = fs::read(canonical.join("activation.json")).unwrap(); + let canonical_sqlite_before = fs::read(canonical.join("memory.sqlite")).unwrap(); + let canonical_receipts_before = receipt_documents(&canonical); + let canonical_store_id = manifest(&canonical)["store_id"] + .as_str() + .unwrap() + .to_string(); + + let isolated_project = temp.path().join("isolated-store"); + fs::create_dir_all(isolated_project.join(".codex")).unwrap(); + let isolated = isolated_project.join(".tree-ring"); + let init = tree_ring(&isolated, &isolated_project, &empty_path) + .arg("init") .output() .unwrap(); - assert!(canonical_preflight.status.success()); + assert_success("isolated init", &init); + let isolated_store_id = manifest(&isolated)["store_id"] + .as_str() + .unwrap() + .to_string(); + assert_ne!(isolated_store_id, canonical_store_id); - let canonical_manifest_before = fs::read(canonical_root.join("activation.json")).unwrap(); - let canonical_sqlite_before = fs::read(canonical_root.join("memory.sqlite")).unwrap(); - let canonical_receipts_before = json_files_below(&canonical_root.join("activation/receipts")); - let canonical_meta = fs::metadata(canonical_root.join("memory.sqlite")).unwrap(); - let isolated_meta = fs::metadata(isolated_root.join("memory.sqlite")).unwrap(); + let canonical_meta = fs::metadata(canonical.join("memory.sqlite")).unwrap(); + let isolated_meta = fs::metadata(isolated.join("memory.sqlite")).unwrap(); assert_ne!( (canonical_meta.dev(), canonical_meta.ino()), - (isolated_meta.dev(), isolated_meta.ino()) + (isolated_meta.dev(), isolated_meta.ino()), + "isolated init must create an independent store, not copy or link canonical SQLite" + ); + + let isolated_preflight = codex_isolated_preflight( + &isolated, + &isolated_project, + &project, + &empty_path, + "isolated-worker", + "isolated-session", + ); + assert_eq!(isolated_preflight["state"], "active-isolated"); + let isolated_output = serde_json::to_string(&isolated_preflight).unwrap(); + for private_path in [&project, &isolated_project] { + assert!(!isolated_output.contains(private_path.to_string_lossy().as_ref())); + } + + assert_eq!( + fs::read(canonical.join("activation.json")).unwrap(), + canonical_manifest_before + ); + assert_eq!( + fs::read(canonical.join("memory.sqlite")).unwrap(), + canonical_sqlite_before ); + assert_eq!(receipt_documents(&canonical), canonical_receipts_before); + assert_eq!(receipt_documents(&isolated).len(), 1); +} + +#[test] +fn live_harness_detection_requires_opt_in_and_explicit_executables() { + if std::env::var("TREE_RING_LIVE_HARNESS_TESTS").as_deref() != Ok("1") { + return; + } + + let temp = tempdir().unwrap(); + let project = temp.path().join(PROJECT_NAME); + let explicit_bin = temp.path().join("explicit-bin"); + fs::create_dir_all(&project).unwrap(); + fs::create_dir_all(&explicit_bin).unwrap(); + install_fixture_markers(&project); + + let mut explicitly_available = BTreeSet::new(); + for fixture in fixtures().into_values() { + let id = fixture["harness_id"].as_str().unwrap(); + let env_name = fixture["live_executable_env"].as_str().unwrap(); + let Ok(candidate) = std::env::var(env_name) else { + continue; + }; + let candidate = PathBuf::from(candidate); + if !candidate.is_absolute() || !candidate.is_file() { + continue; + } + let command_name = match id { + "claude-code" => "claude", + "agent-zero" => "agent-zero", + other => other, + }; + symlink(&candidate, explicit_bin.join(command_name)).unwrap(); + explicitly_available.insert(id.to_string()); + } + + let root = project.join(".tree-ring"); + let init = tree_ring(&root, &project, &explicit_bin) + .arg("init") + .output() + .unwrap(); + assert_success("live-mode init", &init); + let output = tree_ring(&root, &project, &explicit_bin) + .arg("integrations") + .arg("certify") + .arg("--source-root") + .arg(&project) + .arg("--out-dir") + .arg(temp.path().join("live-evidence")) + .arg("--live") + .output() + .unwrap(); + assert_success("live-mode certification", &output); + let report = output_json("live-mode certification", &output); + for fixture in fixtures().into_values() { + let id = fixture["harness_id"].as_str().unwrap(); + if explicitly_available.contains(id) { + continue; + } + let record = record_by_id(&report["report"]["records"], id); + assert_eq!(record["status"], "skip", "{id}: {record}"); + assert_eq!( + record["activation"]["diagnostic"], "not installed locally", + "{id}: {record}" + ); + } +} + +fn fixtures() -> BTreeMap { + FIXTURE_TEXTS + .iter() + .map(|text| { + let fixture: Value = serde_json::from_str(text).unwrap(); + (fixture["harness_id"].as_str().unwrap().to_string(), fixture) + }) + .collect() +} + +fn strings(value: &Value) -> Vec<&str> { + value + .as_array() + .unwrap() + .iter() + .map(|entry| entry.as_str().unwrap()) + .collect() +} + +fn install_fixture_markers(project: &Path) { + for fixture in fixtures().into_values() { + for marker in strings(&fixture["marker_paths"]) { + fs::create_dir_all(project.join(marker)).unwrap(); + } + } +} + +fn assert_initial_states(integrations: &Value) { + for fixture in fixtures().into_values() { + let id = fixture["harness_id"].as_str().unwrap(); + if fixture["marker_paths"].as_array().unwrap().is_empty() { + continue; + } + let integration = record_by_id(integrations, id); + assert_eq!( + integration["state"], fixture["expected_activation_state_before_proof"], + "{id}: {integration}" + ); + } +} - let isolated_preflight = tree_ring(&isolated_root, &isolated_project, &empty_path, &home) +fn assert_manifest_contracts(root: &Path) { + let manifest = manifest(root); + for fixture in fixtures().into_values() { + let id = fixture["harness_id"].as_str().unwrap(); + let Some(activation) = manifest["harnesses"].get(id) else { + assert_eq!(id, "agent-zero", "missing manifest contract for {id}"); + assert_eq!( + fixture["expected_activation_state_before_proof"], + "needs-plugin" + ); + continue; + }; + assert_eq!( + activation["adapter_version"], fixture["adapter_version"], + "{id}" + ); + assert_eq!( + activation["state"], fixture["expected_activation_state_before_proof"], + "{id}" + ); + let actual_paths = activation["owned_files"] + .as_array() + .into_iter() + .flatten() + .chain( + activation["managed_blocks"] + .as_array() + .into_iter() + .flatten(), + ) + .map(|owned| owned["path"].as_str().unwrap()) + .collect::>(); + let expected_paths = strings(&fixture["expected_bridge_paths"]) + .into_iter() + .collect::>(); + assert_eq!(actual_paths, expected_paths, "{id}"); + } +} + +fn manifest(root: &Path) -> Value { + serde_json::from_slice(&fs::read(root.join("activation.json")).unwrap()).unwrap() +} + +fn seed_for_identity( + root: &Path, + project: &Path, + path: &Path, + agent_profile: &str, + workflow_id: &str, + session_id: &str, + operation_id: &str, +) { + let output = tree_ring(root, project, path) + .arg("remember") + .arg(SEEDED_MEMORY) + .arg("--event-type") + .arg("lesson") + .arg("--scope") + .arg("project") + .arg("--project") + .arg(PROJECT_NAME) + .arg("--agent-profile") + .arg(agent_profile) + .arg("--workflow-id") + .arg(workflow_id) + .arg("--session-id") + .arg(session_id) + .arg("--operation-id") + .arg(operation_id) + .arg("--source-ref") + .arg(format!("fixture://harness-activation/{operation_id}")) + .output() + .unwrap(); + assert_success(operation_id, &output); +} + +fn codex_preflight(root: &Path, project: &Path, path: &Path, worker: &str, session: &str) -> Value { + let output = tree_ring(root, project, path) + .env("TREE_RING_COORDINATOR_TOKEN", CAPABILITY_SENTINEL) + .arg("integrations") + .arg("preflight") + .arg("--harness") + .arg("codex") + .arg("--agent-profile") + .arg(worker) + .arg("--workflow-id") + .arg(WORKFLOW_ID) + .arg("--session-id") + .arg(session) + .output() + .unwrap(); + assert_success("Codex preflight", &output); + output_json("Codex preflight", &output) +} + +fn codex_isolated_preflight( + root: &Path, + selected_project: &Path, + canonical_project: &Path, + path: &Path, + worker: &str, + session: &str, +) -> Value { + let output = tree_ring(root, selected_project, path) .arg("integrations") .arg("preflight") .arg("--harness") .arg("codex") .arg("--canonical-project-root") - .arg(&canonical_project) + .arg(canonical_project) .arg("--agent-profile") - .arg("isolated-worker") + .arg(worker) .arg("--workflow-id") - .arg("workflow") + .arg(WORKFLOW_ID) .arg("--session-id") - .arg("isolated-session") + .arg(session) .output() .unwrap(); - assert!( - isolated_preflight.status.success(), - "isolated preflight failed: {}", - String::from_utf8_lossy(&isolated_preflight.stderr) - ); - let response: Value = serde_json::from_slice(&isolated_preflight.stdout).unwrap(); - assert_eq!(response["state"], "active-isolated"); - assert_eq!( - fs::read(canonical_root.join("activation.json")).unwrap(), - canonical_manifest_before - ); - assert_eq!( - fs::read(canonical_root.join("memory.sqlite")).unwrap(), - canonical_sqlite_before - ); - assert_eq!( - json_files_below(&canonical_root.join("activation/receipts")), - canonical_receipts_before - ); - assert_eq!( - json_files_below(&isolated_root.join("activation/receipts")), - 1 - ); - let output_text = String::from_utf8(isolated_preflight.stdout).unwrap(); - assert!(!output_text.contains(&canonical_project.to_string_lossy().to_string())); - assert!(!output_text.contains(&isolated_project.to_string_lossy().to_string())); + assert_success("isolated Codex preflight", &output); + output_json("isolated Codex preflight", &output) } -fn tree_ring(root: &Path, project: &Path, path: &Path, home: &Path) -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_tree-ring")); - command - .current_dir(project) - .env("PATH", path) - .env("HOME", home) - .arg("--root") - .arg(root) - .arg("--json"); +fn adapter_preflight( + root: &Path, + project: &Path, + path: &Path, + harness: &str, + context_format: &str, + input: &Value, +) -> Value { + let output = adapter_preflight_output(root, project, path, harness, context_format, input); + assert_success(&format!("{harness} preflight"), &output); + output_json(&format!("{harness} preflight"), &output) +} + +fn adapter_preflight_output( + root: &Path, + project: &Path, + path: &Path, + harness: &str, + context_format: &str, + input: &Value, +) -> Output { + let mut command = tree_ring(root, project, path); command + .arg("integrations") + .arg("preflight") + .arg("--harness") + .arg(harness) + .arg("--input-json-stdin") + .arg("--context-format") + .arg(context_format) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(serde_json::to_string(input).unwrap().as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() } -fn fingerprint_path(path: &Path) -> String { - let canonical = fs::canonicalize(path).unwrap(); - let mut hasher = Sha256::new(); - hasher.update(canonical.to_string_lossy().as_bytes()); - format!("{:x}", hasher.finalize()) +fn integration_status(root: &Path, project: &Path, path: &Path) -> Value { + let output = tree_ring(root, project, path) + .arg("integrations") + .arg("status") + .arg("--source-root") + .arg(project) + .arg("--verbose") + .output() + .unwrap(); + assert_success("integration status", &output); + output_json("integration status", &output) } -fn prepend_path(directory: &Path) -> OsString { - let mut entries = vec![directory.to_path_buf()]; - if let Some(existing) = std::env::var_os("PATH") { - entries.extend(std::env::split_paths(&existing)); - } - std::env::join_paths(entries).unwrap() +fn assert_status_state(status: &Value, harness: &str, expected: &str) { + let entry = record_by_id(&status["integrations"], harness); + assert_eq!(entry["state"], expected, "{harness}: {entry}"); } -fn directory_files(directory: &Path) -> Vec { - let Ok(entries) = fs::read_dir(directory) else { - return Vec::new(); - }; - entries.map(|entry| entry.unwrap().path()).collect() +fn record_by_id<'a>(records: &'a Value, id: &str) -> &'a Value { + records + .as_array() + .unwrap() + .iter() + .find(|entry| entry["harness_id"] == id || entry["id"] == id) + .unwrap_or_else(|| panic!("missing record for {id}: {records}")) } -fn json_files_below(directory: &Path) -> usize { - let Ok(entries) = fs::read_dir(directory) else { - return 0; - }; - entries - .map(|entry| entry.unwrap().path()) - .map(|path| { +fn receipt_documents(root: &Path) -> BTreeMap { + fn collect(directory: &Path, root: &Path, output: &mut BTreeMap) { + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + for entry in entries { + let path = entry.unwrap().path(); if path.is_dir() { - json_files_below(&path) - } else { - usize::from( - path.extension() - .is_some_and(|extension| extension == "json"), - ) + collect(&path, root, output); + } else if path.extension() == Some(OsStr::new("json")) { + output.insert( + path.strip_prefix(root).unwrap().to_path_buf(), + fs::read_to_string(path).unwrap(), + ); } - }) - .sum() + } + } + + let mut documents = BTreeMap::new(); + collect(&root.join("activation/receipts"), root, &mut documents); + documents +} + +fn tree_ring(root: &Path, project: &Path, path: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_tree-ring")); + command + .current_dir(project) + .env("PATH", OsString::from(path.as_os_str())) + .env("HOME", project.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_COORDINATOR_TOKEN") + .arg("--root") + .arg(root) + .arg("--json"); + command +} + +fn output_json(context: &str, output: &Output) -> Value { + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "{context} did not emit JSON: {error}; stdout={}; stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }) +} + +fn assert_success(context: &str, output: &Output) { + assert!( + output.status.success(), + "{context} failed with {:?}; stdout={}; stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); } diff --git a/crates/tree-ring-memory-cli/tests/multi_agent_acceptance.rs b/crates/tree-ring-memory-cli/tests/multi_agent_acceptance.rs index 0604f10..9b6ef04 100644 --- a/crates/tree-ring-memory-cli/tests/multi_agent_acceptance.rs +++ b/crates/tree-ring-memory-cli/tests/multi_agent_acceptance.rs @@ -1,5 +1,7 @@ use std::{ - path::Path, + collections::BTreeSet, + fs, + path::{Path, PathBuf}, process::{Child, Command, Output, Stdio}, thread, time::{Duration, Instant}, @@ -43,12 +45,76 @@ impl WriteSpec { fn real_cli_processes_preserve_multi_agent_isolation_and_idempotency() { let temp = tempdir().unwrap(); let root = temp.path().join(".tree-ring"); + fs::create_dir_all(temp.path().join(".codex")).unwrap(); let init = base_command(&root).arg("init").output().unwrap(); assert_success("init", &init); let init_json = parse_json("init", &init); assert_eq!(init_json["ok"], true); + let mut response_receipt_ids = BTreeSet::new(); + let mut response_store_ids = BTreeSet::new(); + for index in 0..WORKER_COUNT { + let agent_profile = format!("worker-{index}"); + let session_id = format!("preflight-session-{index}"); + let output = base_command(&root) + .arg("integrations") + .arg("preflight") + .arg("--harness") + .arg("codex") + .arg("--agent-profile") + .arg(&agent_profile) + .arg("--workflow-id") + .arg(WORKFLOW) + .arg("--session-id") + .arg(&session_id) + .output() + .unwrap(); + assert_success(&format!("preflight {agent_profile}"), &output); + let response = parse_json(&format!("preflight {agent_profile}"), &output); + assert_eq!(response["state"], "active"); + response_receipt_ids.insert( + response["receipt"]["receipt_id"] + .as_str() + .expect("preflight response must carry a receipt id") + .to_string(), + ); + response_store_ids.insert( + response["receipt"]["store_id"] + .as_str() + .expect("preflight response must carry a store id") + .to_string(), + ); + } + assert_eq!(response_receipt_ids.len(), WORKER_COUNT); + assert_eq!(response_store_ids.len(), 1); + + let persisted_receipts = activation_receipts(&root); + assert_eq!(persisted_receipts.len(), WORKER_COUNT); + assert_eq!( + persisted_receipts + .iter() + .map(|receipt| receipt["session"]["agent_profile"].as_str().unwrap()) + .collect::>() + .len(), + WORKER_COUNT + ); + assert_eq!( + persisted_receipts + .iter() + .map(|receipt| receipt["session"]["session_id"].as_str().unwrap()) + .collect::>() + .len(), + WORKER_COUNT + ); + assert!(persisted_receipts.iter().all(|receipt| { + receipt["harness_id"] == "codex" + && receipt["store_id"].as_str() == response_store_ids.first().map(String::as_str) + })); + let serialized_receipts = serde_json::to_string(&persisted_receipts).unwrap(); + assert!(!serialized_receipts.contains(QUERY_TOKEN)); + assert!(!serialized_receipts.contains(COORDINATOR_TOKEN_ENV)); + let worker_specs = (0..WORKER_COUNT).map(WriteSpec::worker).collect::>(); let lock_holder = rusqlite::Connection::open(root.join("memory.sqlite")).unwrap(); lock_holder.execute_batch("BEGIN IMMEDIATE").unwrap(); @@ -454,6 +520,7 @@ fn coordinated_policy_guards_shared_mutations_across_real_cli_processes() { fn base_command(root: &Path) -> Command { let mut command = Command::new(env!("CARGO_BIN_EXE_tree-ring")); command + .env("PATH", "") .env_remove("TREE_RING_AGENT_PROFILE") .env_remove("TREE_RING_WORKFLOW_ID") .env_remove("TREE_RING_SESSION_ID") @@ -464,6 +531,30 @@ fn base_command(root: &Path) -> Command { command } +fn activation_receipts(root: &Path) -> Vec { + fn collect(directory: &Path, files: &mut Vec) { + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + for entry in entries { + let path = entry.unwrap().path(); + if path.is_dir() { + collect(&path, files); + } else if path.extension().and_then(|value| value.to_str()) == Some("json") { + files.push(path); + } + } + } + + let mut files = Vec::new(); + collect(&root.join("activation/receipts"), &mut files); + files.sort(); + files + .into_iter() + .map(|path| serde_json::from_slice(&fs::read(path).unwrap()).unwrap()) + .collect() +} + fn remember_command(root: &Path, spec: &WriteSpec) -> Command { let mut command = base_command(root); command diff --git a/fixtures/harness-activation/agent-zero.json b/fixtures/harness-activation/agent-zero.json new file mode 100644 index 0000000..04a3886 --- /dev/null +++ b/fixtures/harness-activation/agent-zero.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "harness_id": "agent-zero", + "adapter_version": "1", + "marker_paths": [], + "expected_bridge_paths": [ + ".tree-ring/activation/agent-zero.json" + ], + "expected_activation_state_before_proof": "needs-plugin", + "preflight": { + "mode": "adapter-stdin", + "context_format": "json" + }, + "seeded_memory": { + "summary": "fixture project startup constraints require deterministic source safe evidence", + "source_ref": "fixture://harness-activation/agent-zero" + }, + "live_executable_env": "TREE_RING_LIVE_AGENT_ZERO_EXECUTABLE" +} diff --git a/fixtures/harness-activation/claude-code.json b/fixtures/harness-activation/claude-code.json new file mode 100644 index 0000000..f8a7e62 --- /dev/null +++ b/fixtures/harness-activation/claude-code.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "harness_id": "claude-code", + "adapter_version": "1", + "marker_paths": [".claude"], + "expected_bridge_paths": [ + ".claude/skills/tree-ring-memory/SKILL.md", + ".claude/settings.json" + ], + "expected_activation_state_before_proof": "configured-awaiting-proof", + "preflight": { + "mode": "adapter-stdin", + "context_format": "claude-session-start" + }, + "seeded_memory": { + "summary": "fixture project startup constraints require deterministic source safe evidence", + "source_ref": "fixture://harness-activation/claude-code" + }, + "live_executable_env": "TREE_RING_LIVE_CLAUDE_CODE_EXECUTABLE" +} diff --git a/fixtures/harness-activation/codex.json b/fixtures/harness-activation/codex.json new file mode 100644 index 0000000..bd16e84 --- /dev/null +++ b/fixtures/harness-activation/codex.json @@ -0,0 +1,27 @@ +{ + "schema_version": 1, + "harness_id": "codex", + "adapter_version": "1", + "marker_paths": [".codex"], + "expected_bridge_paths": [ + ".agents/skills/tree-ring-memory/SKILL.md", + "AGENTS.md" + ], + "expected_activation_state_before_proof": "configured-awaiting-proof", + "preflight": { + "mode": "direct-identity", + "context_format": "json" + }, + "isolated_root": { + "selected_project": "isolated-store", + "memory_root": ".tree-ring", + "canonical_project_arg": "--canonical-project-root", + "expected_state": "active-isolated", + "copy_sqlite": false + }, + "seeded_memory": { + "summary": "fixture project startup constraints require deterministic source safe evidence", + "source_ref": "fixture://harness-activation/codex" + }, + "live_executable_env": "TREE_RING_LIVE_CODEX_EXECUTABLE" +} diff --git a/fixtures/harness-activation/pi.json b/fixtures/harness-activation/pi.json new file mode 100644 index 0000000..2e7018a --- /dev/null +++ b/fixtures/harness-activation/pi.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "harness_id": "pi", + "adapter_version": "1", + "marker_paths": [".pi"], + "expected_bridge_paths": [ + ".agents/skills/tree-ring-memory/SKILL.md", + ".pi/extensions/tree-ring-memory.ts" + ], + "expected_activation_state_before_proof": "needs-trust", + "preflight": { + "mode": "adapter-stdin", + "context_format": "pi-before-agent-start" + }, + "seeded_memory": { + "summary": "fixture project startup constraints require deterministic source safe evidence", + "source_ref": "fixture://harness-activation/pi" + }, + "live_executable_env": "TREE_RING_LIVE_PI_EXECUTABLE" +} From d311ceb5bfff34e5d3f61fed21bf143f8071d595 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 02:34:34 -0400 Subject: [PATCH 21/31] fix activation filesystem safety review --- .../src/activation/bridge.rs | 440 ++++++++++++++---- .../src/activation/manifest.rs | 11 +- .../src/activation/mod.rs | 4 +- 3 files changed, 366 insertions(+), 89 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/activation/bridge.rs b/crates/tree-ring-memory-cli/src/activation/bridge.rs index e73bd69..98092c1 100644 --- a/crates/tree-ring-memory-cli/src/activation/bridge.rs +++ b/crates/tree-ring-memory-cli/src/activation/bridge.rs @@ -1,5 +1,8 @@ use super::{ - adapters::{adapter_version, ActivationProject, AdapterPlan, ManagedBlockUpdate, PlannedWrite}, + adapters::{ + adapter_capability, adapter_version, ActivationProject, AdapterPlan, ManagedBlockUpdate, + PlannedWrite, + }, manifest::{ bridge_fingerprint, fingerprint_path, validate_manifest, validate_project_relative_path, ActivationManifest, HarnessActivation, OwnedBridgeFile, OwnedManagedBlock, @@ -139,6 +142,10 @@ struct TestMutationHooks { after_final_target_validation: Option>, fail_directory_sync_at: Vec, directory_syncs: usize, + fail_fdopendir: bool, + failed_fdopendir_fd: Option, + fail_readdir: bool, + fail_closedir: bool, } #[cfg(test)] @@ -193,6 +200,92 @@ fn fail_directory_sync_now() -> bool { false } +#[cfg(all(unix, test))] +fn fdopendir_for_listing(descriptor: libc::c_int) -> *mut libc::DIR { + let fail = TEST_MUTATION_HOOKS.with(|hooks| { + let mut hooks = hooks.borrow_mut(); + if hooks.fail_fdopendir { + hooks.fail_fdopendir = false; + hooks.failed_fdopendir_fd = Some(descriptor); + true + } else { + false + } + }); + if fail { + set_errno(libc::EIO); + std::ptr::null_mut() + } else { + unsafe { + // SAFETY: the caller transfers ownership of a valid duplicated descriptor. + libc::fdopendir(descriptor) + } + } +} + +#[cfg(all(unix, not(test)))] +fn fdopendir_for_listing(descriptor: libc::c_int) -> *mut libc::DIR { + unsafe { + // SAFETY: the caller transfers ownership of a valid duplicated descriptor. + libc::fdopendir(descriptor) + } +} + +#[cfg(all(unix, test))] +fn readdir_for_listing(stream: *mut libc::DIR) -> *mut libc::dirent { + let fail = TEST_MUTATION_HOOKS.with(|hooks| { + let mut hooks = hooks.borrow_mut(); + let fail = hooks.fail_readdir; + hooks.fail_readdir = false; + fail + }); + if fail { + set_errno(libc::EIO); + std::ptr::null_mut() + } else { + unsafe { + // SAFETY: the caller keeps the directory stream open for this call. + libc::readdir(stream) + } + } +} + +#[cfg(all(unix, not(test)))] +fn readdir_for_listing(stream: *mut libc::DIR) -> *mut libc::dirent { + unsafe { + // SAFETY: the caller keeps the directory stream open for this call. + libc::readdir(stream) + } +} + +#[cfg(all(unix, test))] +fn closedir_for_listing(stream: *mut libc::DIR) -> libc::c_int { + let status = unsafe { + // SAFETY: the caller transfers ownership of its open directory stream. + libc::closedir(stream) + }; + let fail = TEST_MUTATION_HOOKS.with(|hooks| { + let mut hooks = hooks.borrow_mut(); + let fail = hooks.fail_closedir; + hooks.fail_closedir = false; + fail + }); + if status == 0 && fail { + set_errno(libc::EIO); + -1 + } else { + status + } +} + +#[cfg(all(unix, not(test)))] +fn closedir_for_listing(stream: *mut libc::DIR) -> libc::c_int { + unsafe { + // SAFETY: the caller transfers ownership of its open directory stream. + libc::closedir(stream) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct BridgePlanResult { pub state: ActivationState, @@ -712,20 +805,33 @@ fn list_directory_entries(directory: &File) -> Result, String> { let cloned = directory .try_clone() .map_err(|error| io_error(Path::new("."), error))?; - let stream = unsafe { - // SAFETY: fdopendir assumes ownership of the duplicated descriptor. - libc::fdopendir(cloned.into_raw_fd()) - }; + let descriptor = cloned.into_raw_fd(); + let stream = fdopendir_for_listing(descriptor); if stream.is_null() { - return Err(io_error(Path::new("."), std::io::Error::last_os_error())); + let open_error = std::io::Error::last_os_error(); + let close_status = unsafe { + // SAFETY: fdopendir did not take ownership when it returned null. + libc::close(descriptor) + }; + let open_message = io_error(Path::new("."), open_error); + if close_status != 0 { + return Err(format!( + "{open_message}; descriptor cleanup also failed: {}", + io_error(Path::new("."), std::io::Error::last_os_error()) + )); + } + return Err(open_message); } let mut entries = Vec::new(); + let mut read_error = None; loop { - let entry = unsafe { - // SAFETY: stream remains valid until the matching closedir below. - libc::readdir(stream) - }; + set_errno(0); + let entry = readdir_for_listing(stream); if entry.is_null() { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(0) { + read_error = Some(error); + } break; } let name = unsafe { @@ -738,17 +844,106 @@ fn list_directory_entries(directory: &File) -> Result, String> { } entries.push(OsString::from_vec(bytes.to_vec())); } - let close_status = unsafe { - // SAFETY: closes the stream and its owned duplicated descriptor exactly once. - libc::closedir(stream) + let close_status = closedir_for_listing(stream); + let close_error = if close_status == 0 { + None + } else { + Some(std::io::Error::last_os_error()) }; - if close_status != 0 { - return Err(io_error(Path::new("."), std::io::Error::last_os_error())); + match (read_error, close_error) { + (Some(read_error), Some(close_error)) => { + return Err(format!( + "{}; directory close also failed: {}", + io_error(Path::new("."), read_error), + io_error(Path::new("."), close_error) + )); + } + (Some(error), None) | (None, Some(error)) => { + return Err(io_error(Path::new("."), error)); + } + (None, None) => {} } entries.sort(); Ok(entries) } +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "tvos", + target_os = "watchos", + target_os = "visionos", + target_os = "freebsd" +))] +fn set_errno(value: libc::c_int) { + unsafe { + // SAFETY: libc returns the calling thread's errno storage. + *libc::__error() = value; + } +} + +#[cfg(any(target_os = "openbsd", target_os = "netbsd", target_os = "android"))] +fn set_errno(value: libc::c_int) { + unsafe { + // SAFETY: libc returns the calling thread's errno storage. + *libc::__errno() = value; + } +} + +#[cfg(any( + target_os = "linux", + target_os = "hurd", + target_os = "emscripten", + target_os = "redox", + target_os = "dragonfly" +))] +fn set_errno(value: libc::c_int) { + unsafe { + // SAFETY: libc returns the calling thread's errno storage. + *libc::__errno_location() = value; + } +} + +#[cfg(any(target_os = "solaris", target_os = "illumos"))] +fn set_errno(value: libc::c_int) { + unsafe { + // SAFETY: libc returns the calling thread's errno storage. + *libc::___errno() = value; + } +} + +#[cfg(target_os = "aix")] +fn set_errno(value: libc::c_int) { + unsafe { + // SAFETY: libc returns the calling thread's errno storage. + *libc::_Errno() = value; + } +} + +#[cfg(target_os = "haiku")] +fn set_errno(value: libc::c_int) { + unsafe { + // SAFETY: libc returns the calling thread's errno storage. + *libc::_errnop() = value; + } +} + +#[cfg(target_os = "nto")] +fn set_errno(value: libc::c_int) { + unsafe { + // SAFETY: libc returns the calling thread's errno storage. + *libc::__get_errno_ptr() = value; + } +} + +#[cfg(any(target_os = "cygwin", target_os = "nuttx"))] +fn set_errno(value: libc::c_int) { + unsafe { + // SAFETY: libc returns the calling thread's errno storage. + *libc::__errno() = value; + } +} + #[cfg(unix)] fn open_project_root_no_follow(path: &Path) -> Result { if path.as_os_str().is_empty() { @@ -1044,23 +1239,7 @@ pub fn apply_bridge_plan( )); } let mut next_manifest = current_manifest.clone(); - let mut activation = next_manifest - .harnesses - .get(&plan.harness_id) - .cloned() - .unwrap_or(HarnessActivation { - state: plan.state, - adapter_capability: capability_for(&plan.harness_id)?, - adapter_version: adapter_version_for(&plan.harness_id)?.to_string(), - bridge_fingerprint: String::new(), - bridge_path: None, - owned_files: Vec::new(), - managed_blocks: Vec::new(), - }); - activation.state = plan.state; - activation.adapter_capability = capability_for(&plan.harness_id)?; - activation.adapter_version = adapter_version_for(&plan.harness_id)?.to_string(); - activation.bridge_fingerprint = bridge_fingerprint(&plan.harness_id, &activation); + let activation = blocked_activation(&plan, next_manifest.harnesses.get(&plan.harness_id))?; next_manifest .harnesses .insert(plan.harness_id.clone(), activation); @@ -1177,23 +1356,8 @@ pub fn apply_bridge_plans_create_only( plan.harness_id, plan.state )); } - let mut activation = next_manifest - .harnesses - .get(&plan.harness_id) - .cloned() - .unwrap_or(HarnessActivation { - state: plan.state, - adapter_capability: capability_for(&plan.harness_id)?, - adapter_version: adapter_version_for(&plan.harness_id)?.to_string(), - bridge_fingerprint: String::new(), - bridge_path: None, - owned_files: Vec::new(), - managed_blocks: Vec::new(), - }); - activation.state = plan.state; - activation.adapter_capability = capability_for(&plan.harness_id)?; - activation.adapter_version = adapter_version_for(&plan.harness_id)?.to_string(); - activation.bridge_fingerprint = bridge_fingerprint(&plan.harness_id, &activation); + let activation = + blocked_activation(&plan, next_manifest.harnesses.get(&plan.harness_id))?; next_manifest .harnesses .insert(plan.harness_id.clone(), activation); @@ -1372,23 +1536,7 @@ pub fn preview_bridge_plan( )); } let mut next_manifest = current_manifest.clone(); - let mut activation = next_manifest - .harnesses - .get(&plan.harness_id) - .cloned() - .unwrap_or(HarnessActivation { - state: plan.state, - adapter_capability: capability_for(&plan.harness_id)?, - adapter_version: adapter_version_for(&plan.harness_id)?.to_string(), - bridge_fingerprint: String::new(), - bridge_path: None, - owned_files: Vec::new(), - managed_blocks: Vec::new(), - }); - activation.state = plan.state; - activation.adapter_capability = capability_for(&plan.harness_id)?; - activation.adapter_version = adapter_version_for(&plan.harness_id)?.to_string(); - activation.bridge_fingerprint = bridge_fingerprint(&plan.harness_id, &activation); + let activation = blocked_activation(&plan, next_manifest.harnesses.get(&plan.harness_id))?; next_manifest .harnesses .insert(plan.harness_id.clone(), activation); @@ -1528,7 +1676,9 @@ pub fn deactivate_bridge_plan( { next.bridge_path = Some(first); } - next.adapter_version = adapter_version_for(harness_id)?.to_string(); + let (adapter_capability, adapter_version) = adapter_contract(harness_id)?; + next.adapter_capability = adapter_capability; + next.adapter_version = adapter_version.to_string(); next.bridge_fingerprint = bridge_fingerprint(harness_id, next); if files_require_existing_entry_mutation(&files) @@ -1637,10 +1787,11 @@ fn prepare_apply( .first() .map(|owned| owned.path.clone()) .or_else(|| managed_blocks.first().map(|owned| owned.path.clone())); + let (adapter_capability, adapter_version) = adapter_contract(&plan.harness_id)?; let mut activation = HarnessActivation { state: applied_state(&plan.harness_id, plan.state), - adapter_capability: capability_for(&plan.harness_id)?, - adapter_version: adapter_version_for(&plan.harness_id)?.to_string(), + adapter_capability, + adapter_version: adapter_version.to_string(), bridge_fingerprint: String::new(), bridge_path, owned_files, @@ -2045,10 +2196,18 @@ fn remove_markdown_block( let Some((start, end)) = locate_markdown_block(content, block_id)? else { return Ok(None); }; + if !content.is_char_boundary(start) || !content.is_char_boundary(end) { + return Ok(None); + } if sha256(&content.as_bytes()[start..end]) != expected_hash { return Ok(None); } - let removal_start = start.saturating_sub(leading_separator.len()); + let Some(removal_start) = start.checked_sub(leading_separator.len()) else { + return Ok(None); + }; + if !content.is_char_boundary(removal_start) { + return Ok(None); + } if &content[removal_start..start] != leading_separator { return Ok(None); } @@ -2577,18 +2736,33 @@ fn sha256(bytes: &[u8]) -> String { format!("{:x}", hasher.finalize()) } -fn capability_for(harness_id: &str) -> Result { - match harness_id { - "codex" => Ok(AdapterCapability::GuidanceOnly), - "claude-code" => Ok(AdapterCapability::WrapperPreflight), - "pi" | "agent-zero" => Ok(AdapterCapability::NativePreflight), - "hermes" | "opencode" | "goose" => Ok(AdapterCapability::GuidanceOnly), - other => Err(format!("unknown harness adapter: {other}")), - } +fn blocked_activation( + plan: &AdapterPlan, + current: Option<&HarnessActivation>, +) -> Result { + let (adapter_capability, adapter_version) = adapter_contract(&plan.harness_id)?; + let mut activation = current.cloned().unwrap_or(HarnessActivation { + state: plan.state, + adapter_capability, + adapter_version: adapter_version.to_string(), + bridge_fingerprint: String::new(), + bridge_path: None, + owned_files: Vec::new(), + managed_blocks: Vec::new(), + }); + activation.state = plan.state; + activation.adapter_capability = adapter_capability; + activation.adapter_version = adapter_version.to_string(); + activation.bridge_fingerprint = bridge_fingerprint(&plan.harness_id, &activation); + Ok(activation) } -fn adapter_version_for(harness_id: &str) -> Result<&'static str, String> { - adapter_version(harness_id).ok_or_else(|| format!("unknown harness adapter: {harness_id}")) +fn adapter_contract(harness_id: &str) -> Result<(AdapterCapability, &'static str), String> { + let capability = adapter_capability(harness_id) + .ok_or_else(|| format!("unknown harness adapter: {harness_id}"))?; + let version = adapter_version(harness_id) + .ok_or_else(|| format!("unknown harness adapter: {harness_id}"))?; + Ok((capability, version)) } fn applied_state(harness_id: &str, planned: ActivationState) -> ActivationState { @@ -2658,7 +2832,10 @@ mod tests { use super::*; use crate::activation::{ adapters::{plan_activation, ActivationProject, AdapterPlan, BridgeWrite, PlannedWrite}, - manifest::{ActivationManifest, HarnessActivation, OwnedBridgeFile, OwnedManagedBlock}, + manifest::{ + save_manifest, ActivationManifest, HarnessActivation, OwnedBridgeFile, + OwnedManagedBlock, + }, ActivationState, AdapterCapability, ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, }; use serde_json::{json, Value}; @@ -2729,6 +2906,32 @@ mod tests { }); } + #[cfg(unix)] + fn fail_fdopendir_for_test() { + TEST_MUTATION_HOOKS.with(|hooks| hooks.borrow_mut().fail_fdopendir = true); + } + + #[cfg(unix)] + fn failed_fdopendir_fd_for_test() -> libc::c_int { + TEST_MUTATION_HOOKS.with(|hooks| { + hooks + .borrow_mut() + .failed_fdopendir_fd + .take() + .expect("fdopendir failure recorded its descriptor") + }) + } + + #[cfg(unix)] + fn fail_readdir_for_test() { + TEST_MUTATION_HOOKS.with(|hooks| hooks.borrow_mut().fail_readdir = true); + } + + #[cfg(unix)] + fn fail_closedir_for_test() { + TEST_MUTATION_HOOKS.with(|hooks| hooks.borrow_mut().fail_closedir = true); + } + fn plan(harness: &str, project: &ActivationProject) -> AdapterPlan { plan_activation(harness, project).unwrap() } @@ -2977,10 +3180,8 @@ mod tests { }; let canonical_manifest = make_manifest(&canonical_project, "canonical-store"); let selected_manifest = make_manifest(&selected_project, "selected-store"); - crate::activation::save_manifest(&canonical_project.memory_root, &canonical_manifest) - .unwrap(); - crate::activation::save_manifest(&selected_project.memory_root, &selected_manifest) - .unwrap(); + save_manifest(&canonical_project.memory_root, &canonical_manifest).unwrap(); + save_manifest(&selected_project.memory_root, &selected_manifest).unwrap(); assert_eq!( validate_isolated_preflight_roots( @@ -3150,6 +3351,15 @@ mod tests { let blocked_result = apply_bridge_plan(&project, &mut manifest, blocked, false).unwrap(); assert_eq!(blocked_result.state, ActivationState::NeedsPlugin); assert!(blocked_result.changed_paths.is_empty()); + let blocked_activation = manifest.harnesses.get("agent-zero").unwrap(); + assert_eq!( + blocked_activation.adapter_capability, + adapter_capability("agent-zero").unwrap() + ); + assert_eq!( + blocked_activation.adapter_version, + adapter_version("agent-zero").unwrap() + ); assert!(!project .project_root .join(".tree-ring/activation/agent-zero.json") @@ -3568,6 +3778,66 @@ mod tests { } } + #[test] + fn markdown_removal_rejects_separator_offset_inside_utf8_character() { + let block = markdown_block("codex"); + let content = format!("é{block}"); + + let removed = + remove_markdown_block(content.as_bytes(), "codex", &sha256(block.as_bytes()), "x") + .unwrap(); + + assert!(removed.is_none()); + } + + #[cfg(unix)] + #[test] + fn directory_listing_closes_fdopendir_failure_descriptor() { + let directory = tempfile::tempdir().unwrap(); + let file = File::open(directory.path()).unwrap(); + fail_fdopendir_for_test(); + + let error = list_directory_entries(&file).unwrap_err(); + let descriptor = failed_fdopendir_fd_for_test(); + let status = unsafe { + // SAFETY: fcntl only observes whether the recorded descriptor remains open. + libc::fcntl(descriptor, libc::F_GETFD) + }; + + assert!(error.contains("Input/output error")); + assert_eq!(status, -1); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EBADF) + ); + } + + #[cfg(unix)] + #[test] + fn directory_listing_resets_errno_and_propagates_read_and_close_errors() { + let directory = tempfile::tempdir().unwrap(); + let file = File::open(directory.path()).unwrap(); + + set_errno(libc::EIO); + assert!(list_directory_entries(&file).unwrap().is_empty()); + + fail_readdir_for_test(); + assert!(list_directory_entries(&file) + .unwrap_err() + .contains("Input/output error")); + + fail_closedir_for_test(); + assert!(list_directory_entries(&file) + .unwrap_err() + .contains("Input/output error")); + + fail_readdir_for_test(); + fail_closedir_for_test(); + assert!(list_directory_entries(&file) + .unwrap_err() + .contains("directory close also failed")); + } + #[cfg(unix)] #[test] fn final_entry_replacement_at_create_publication_is_never_followed_or_overwritten() { diff --git a/crates/tree-ring-memory-cli/src/activation/manifest.rs b/crates/tree-ring-memory-cli/src/activation/manifest.rs index 3d21109..4ed9d36 100644 --- a/crates/tree-ring-memory-cli/src/activation/manifest.rs +++ b/crates/tree-ring-memory-cli/src/activation/manifest.rs @@ -134,8 +134,13 @@ pub fn load_or_create_manifest( } } -/// Atomically persists a validated activation manifest. -pub fn save_manifest(memory_root: &Path, manifest: &ActivationManifest) -> Result<(), String> { +/// Test-only replacement writer for fixtures that exercise changed persisted +/// contracts. Production activation persistence remains creation-only. +#[cfg(test)] +pub(crate) fn save_manifest( + memory_root: &Path, + manifest: &ActivationManifest, +) -> Result<(), String> { validate_memory_root(memory_root)?; validate_manifest(manifest)?; atomic_write_json( @@ -492,6 +497,7 @@ fn read_json(path: &Path) -> Result { #[derive(Clone, Copy)] enum AtomicWriteMode { + #[cfg(test)] Replace, Create, } @@ -528,6 +534,7 @@ fn atomic_write_json( file.sync_all().map_err(|err| io_error(&temp_path, err))?; drop(file); match mode { + #[cfg(test)] AtomicWriteMode::Replace => { fs::rename(&temp_path, path).map_err(|err| io_error(path, err)) } diff --git a/crates/tree-ring-memory-cli/src/activation/mod.rs b/crates/tree-ring-memory-cli/src/activation/mod.rs index 60d4182..3808de8 100644 --- a/crates/tree-ring-memory-cli/src/activation/mod.rs +++ b/crates/tree-ring-memory-cli/src/activation/mod.rs @@ -42,8 +42,8 @@ pub mod preflight; #[allow(unused_imports)] pub use manifest::{ - load_manifest, load_or_create_manifest, prune_receipts, save_manifest, write_receipt, - ActivationManifest, ActivationReceipt, HarnessActivation, OwnedBridgeFile, OwnedManagedBlock, + load_manifest, load_or_create_manifest, prune_receipts, write_receipt, ActivationManifest, + ActivationReceipt, HarnessActivation, OwnedBridgeFile, OwnedManagedBlock, }; #[allow(unused_imports)] pub use preflight::{ From 3a3b92b7796635349a20420860031b841ea0c303 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 02:35:08 -0400 Subject: [PATCH 22/31] Harden activation preflight security --- .../src/actions/integrations.rs | 143 +++++++++++---- .../src/activation/preflight.rs | 142 ++++++++++++--- crates/tree-ring-memory-sqlite/src/lib.rs | 165 +++++++++++++++--- 3 files changed, 365 insertions(+), 85 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/actions/integrations.rs b/crates/tree-ring-memory-cli/src/actions/integrations.rs index 46c8b17..5628553 100644 --- a/crates/tree-ring-memory-cli/src/actions/integrations.rs +++ b/crates/tree-ring-memory-cli/src/actions/integrations.rs @@ -1,7 +1,6 @@ use chrono::{Duration, Utc}; use serde::Serialize; use serde_json::{Map, Value}; -use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; @@ -10,6 +9,7 @@ use tree_ring_memory_cli::activation::{ adapters::{scan_integrations, ActivationProject, IntegrationScanReport}, bridge::{apply_bridge_plan, deactivate_bridge_plan}, manifest::{bridge_fingerprint, ActivationManifest, ActivationReceipt}, + preflight::{project_fingerprint, read_receipt_candidates}, ActivationState, AdapterCapability, PreflightContextFormat, PreflightRequest, SessionIdentity, ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, RECEIPT_RETENTION_DAYS, }; @@ -107,7 +107,7 @@ pub fn scan(request: IntegrationScanRequest) -> IntegrationScanActionReport { pub fn status(request: IntegrationStatusRequest) -> Result { let scan = scan_integrations(&request.source_root); let manifest = optional_manifest(&request.memory_root)?.filter(|manifest| { - manifest.project_root_fingerprint == path_fingerprint(&request.source_root) + manifest.project_root_fingerprint == project_fingerprint(&request.source_root) }); let now = Utc::now(); let integrations = scan @@ -263,7 +263,7 @@ pub fn new_manifest(project_root: &Path) -> ActivationManifest { schema_version: ACTIVATION_SCHEMA_VERSION, protocol_version: ACTIVATION_PROTOCOL_VERSION, store_id: Uuid::new_v4().hyphenated().to_string(), - project_root_fingerprint: path_fingerprint(project_root), + project_root_fingerprint: project_fingerprint(project_root), cli_version: env!("CARGO_PKG_VERSION").to_string(), harnesses: Default::default(), } @@ -494,7 +494,7 @@ fn ensure_manifest_project( manifest: &ActivationManifest, source_root: &Path, ) -> Result<(), String> { - if manifest.project_root_fingerprint != path_fingerprint(source_root) { + if manifest.project_root_fingerprint != project_fingerprint(source_root) { return Err("activation manifest does not belong to the requested project".to_string()); } Ok(()) @@ -519,30 +519,15 @@ fn verify_activation_receipts_at( if harness.bridge_fingerprint != bridge_fingerprint(harness_id, harness) { return invalid_receipt(None, "bridge fingerprint does not match adapter contract"); } - let mut pending = vec![memory_root.join("activation/receipts").join(harness_id)]; let mut receipts = Vec::new(); - let mut found_json = false; - while let Some(path) = pending.pop() { - let entries = match fs::read_dir(path) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(_) => return invalid_receipt(None, "activation receipt directory is unreadable"), - }; - for entry in entries { - let Ok(entry) = entry else { - return invalid_receipt(None, "activation receipt directory is unreadable"); - }; - let path = entry.path(); - if path.is_dir() { - pending.push(path); - } else if path.extension().and_then(|value| value.to_str()) == Some("json") { - found_json = true; - if let Ok(bytes) = fs::read(&path) { - if let Ok(receipt) = serde_json::from_slice::(&bytes) { - receipts.push(receipt); - } - } - } + let candidates = match read_receipt_candidates(memory_root, harness_id) { + Ok(candidates) => candidates, + Err(_) => return invalid_receipt(None, "activation receipt directory is unreadable"), + }; + let found_json = !candidates.is_empty(); + for bytes in candidates { + if let Ok(receipt) = serde_json::from_slice::(&bytes) { + receipts.push(receipt); } } @@ -697,22 +682,68 @@ fn relative_path(path: &Path) -> Result { .ok_or_else(|| "activation report path must be UTF-8".to_string()) } -fn path_fingerprint(path: &Path) -> String { - let path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); - let mut hasher = Sha256::new(); - hasher.update(path.to_string_lossy().as_bytes()); - format!("{:x}", hasher.finalize()) -} - pub(crate) fn project_root_fingerprint(path: &Path) -> String { - path_fingerprint(path) + project_fingerprint(path) } #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] + use std::os::unix::fs::symlink; use tempfile::tempdir; + fn receipt_fixture() -> ( + tempfile::TempDir, + ActivationProject, + ActivationManifest, + activation::HarnessActivation, + ActivationReceipt, + ) { + let temp = tempdir().unwrap(); + let project_root = temp.path().join("project"); + fs::create_dir_all(&project_root).unwrap(); + let project = ActivationProject::from_project_root(&project_root); + fs::create_dir_all(&project.memory_root).unwrap(); + let mut harness = activation::HarnessActivation { + state: ActivationState::ConfiguredAwaitingProof, + adapter_capability: AdapterCapability::NativePreflight, + adapter_version: "1".to_string(), + bridge_fingerprint: String::new(), + bridge_path: Some(".agents/skills/tree-ring-memory/SKILL.md".to_string()), + owned_files: Vec::new(), + managed_blocks: Vec::new(), + }; + harness.bridge_fingerprint = bridge_fingerprint("pi", &harness); + let mut manifest = new_manifest(&project_root); + manifest.store_id = "store-test".to_string(); + manifest.harnesses.insert("pi".to_string(), harness.clone()); + let receipt = ActivationReceipt { + schema_version: ACTIVATION_SCHEMA_VERSION, + protocol_version: ACTIVATION_PROTOCOL_VERSION, + receipt_id: "receipt-test".to_string(), + harness_id: "pi".to_string(), + adapter_version: harness.adapter_version.clone(), + bridge_fingerprint: harness.bridge_fingerprint.clone(), + store_id: manifest.store_id.clone(), + project_root_fingerprint: manifest.project_root_fingerprint.clone(), + worker_key_fingerprint: "a".repeat(64), + session: SessionIdentity { + agent_profile: "pi".to_string(), + workflow_id: "workflow".to_string(), + session_id: "session".to_string(), + }, + state: ActivationState::Active, + query_class: "startup_fallback".to_string(), + result_count: 0, + selected_memory_ids_sha256: "b".repeat(64), + duration_ms: 1, + status: "success".to_string(), + recorded_at: Utc::now(), + }; + (temp, project, manifest, harness, receipt) + } + #[test] fn integration_action_scans_project_markers() { let dir = tempdir().unwrap(); @@ -771,4 +802,46 @@ mod tests { ) .is_err()); } + + #[cfg(unix)] + #[test] + fn receipt_discovery_rejects_a_symlinked_json_file_without_following_it() { + let (temp, project, manifest, harness, receipt) = receipt_fixture(); + let worker = project + .memory_root + .join("activation/receipts/pi") + .join(&receipt.worker_key_fingerprint); + fs::create_dir_all(&worker).unwrap(); + let outside = temp.path().join("outside-receipt.json"); + fs::write(&outside, serde_json::to_vec(&receipt).unwrap()).unwrap(); + symlink(&outside, worker.join("receipt-test.json")).unwrap(); + + let verification = + verify_activation_receipts(&project.memory_root, "pi", &manifest, &harness); + + assert_eq!(verification.status, ReceiptVerificationStatus::Invalid); + assert_eq!( + verification.diagnostic, + "activation receipt directory is unreadable" + ); + assert!(verification.receipt.is_none()); + } + + #[cfg(unix)] + #[test] + fn receipt_discovery_rejects_a_symlink_directory_cycle() { + let (_temp, project, manifest, harness, _receipt) = receipt_fixture(); + let harness_directory = project.memory_root.join("activation/receipts/pi"); + fs::create_dir_all(&harness_directory).unwrap(); + symlink(".", harness_directory.join("cycle")).unwrap(); + + let verification = + verify_activation_receipts(&project.memory_root, "pi", &manifest, &harness); + + assert_eq!(verification.status, ReceiptVerificationStatus::Invalid); + assert_eq!( + verification.diagnostic, + "activation receipt directory is unreadable" + ); + } } diff --git a/crates/tree-ring-memory-cli/src/activation/preflight.rs b/crates/tree-ring-memory-cli/src/activation/preflight.rs index fb8e9b6..711b744 100644 --- a/crates/tree-ring-memory-cli/src/activation/preflight.rs +++ b/crates/tree-ring-memory-cli/src/activation/preflight.rs @@ -12,7 +12,12 @@ use super::{ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::{ffi::OsStr, fmt, path::Path, time::Instant}; +use std::{ + ffi::OsStr, + fmt, + path::Path, + time::{Duration as StdDuration, Instant}, +}; use tree_ring_memory_core::SensitivityGuard; use tree_ring_memory_sqlite::{MemoryRetriever, RecallOptions, RecallResult, SQLiteMemoryStore}; use uuid::Uuid; @@ -179,6 +184,22 @@ pub(crate) fn prepare_preflight( project: &ActivationProject, manifest: &ActivationManifest, request: PreflightRequest, +) -> Result { + prepare_preflight_with_timeout( + store, + project, + manifest, + request, + StdDuration::from_millis(PREFLIGHT_TIMEOUT_MS), + ) +} + +fn prepare_preflight_with_timeout( + store: &SQLiteMemoryStore, + project: &ActivationProject, + manifest: &ActivationManifest, + request: PreflightRequest, + timeout: StdDuration, ) -> Result { validate_request_contract(&request)?; validate_identity(&request.identity)?; @@ -193,29 +214,34 @@ pub(crate) fn prepare_preflight( let started = Instant::now(); let (query, query_class) = safe_query(request.task_hint.as_deref()); - let results = MemoryRetriever::new(store) - .recall_with_options( - query, - &RecallOptions { - project: Some(&snapshot.project_name), - agent_profile: Some(&request.identity.agent_profile), - workflow_id: Some(&request.identity.workflow_id), - session_id: Some(&request.identity.session_id), - scope: None, - rings: None, - event_types: None, - include_sensitive: false, - include_superseded: false, - limit: MAX_RESULTS, - explain_ranking: false, - }, - ) - .map_err(|_| ActivationError::new("scoped recall failed"))?; + let results = match MemoryRetriever::new(store).recall_with_options_timeout( + query, + &RecallOptions { + project: Some(&snapshot.project_name), + agent_profile: Some(&request.identity.agent_profile), + workflow_id: Some(&request.identity.workflow_id), + session_id: Some(&request.identity.session_id), + scope: None, + rings: None, + event_types: None, + include_sensitive: false, + include_superseded: false, + limit: MAX_RESULTS, + explain_ranking: false, + }, + timeout, + ) { + Ok(results) => results, + Err(_) if started.elapsed() >= timeout => { + return Err(ActivationError::new("preflight timeout")); + } + Err(_) => return Err(ActivationError::new("scoped recall failed")), + }; let safe_results = safe_results(results); let context = render_safe_recall_context(&safe_results)?; let selected_memory_ids_sha256 = selected_memory_ids_digest(&safe_results); let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); - if duration_ms > PREFLIGHT_TIMEOUT_MS { + if started.elapsed() >= timeout { return Err(ActivationError::new("preflight timeout")); } @@ -637,6 +663,44 @@ fn ensure_store_path_matches( Ok(()) } +/// Enumerates receipt candidates through the pinned, descriptor-relative +/// activation filesystem. The fixed two-level layout prevents recursive +/// traversal and every directory/file open rejects symlinks. +pub fn read_receipt_candidates( + memory_root: &Path, + harness_id: &str, +) -> Result>, String> { + let receipt_project = ActivationProject::from_memory_root(memory_root.to_path_buf())?; + let project_fs = ProjectFs::open(&receipt_project)?; + let harness_directory = + std::path::PathBuf::from(".tree-ring/activation/receipts").join(harness_id); + let Some(workers) = project_fs.directory_entries(&harness_directory)? else { + return Ok(Vec::new()); + }; + let mut candidates = Vec::new(); + for worker in workers { + let worker_directory = harness_directory.join(worker); + let Some(entries) = project_fs.directory_entries(&worker_directory)? else { + continue; + }; + for name in entries { + if Path::new(&name) + .extension() + .and_then(|extension| extension.to_str()) + != Some("json") + { + continue; + } + candidates.push( + project_fs + .read_optional(&worker_directory.join(name))? + .unwrap_or_default(), + ); + } + } + Ok(candidates) +} + fn preflight_state( project: &ActivationProject, manifest: &ActivationManifest, @@ -811,8 +875,7 @@ fn install_pre_commit_hook(session_id: impl Into, hook: impl FnOnce() + .expect("pre-commit hook mutex poisoned") = Some((session_id.into(), Box::new(hook))); } -#[cfg(test)] -pub(crate) fn project_fingerprint(project_root: &Path) -> String { +pub fn project_fingerprint(project_root: &Path) -> String { fingerprint_path(project_root) } @@ -1098,6 +1161,41 @@ mod tests { assert!(receipt_files(&project.memory_root).is_empty()); } + #[test] + fn delayed_sqlite_recall_is_interrupted_at_the_preflight_deadline() { + let (_temp, project, store, manifest) = fixture(); + let database = project.memory_root.join("memory.sqlite"); + drop(store); + + let journal = rusqlite::Connection::open(&database).unwrap(); + let mode: String = journal + .query_row("PRAGMA journal_mode=DELETE", [], |row| row.get(0)) + .unwrap(); + assert_eq!(mode, "delete"); + drop(journal); + + let store = SQLiteMemoryStore::open_read_only(&database).unwrap(); + let locker = rusqlite::Connection::open(&database).unwrap(); + locker.execute_batch("BEGIN EXCLUSIVE").unwrap(); + let started = Instant::now(); + + let error = prepare_preflight_with_timeout( + &store, + &project, + &manifest, + fixture_request(), + StdDuration::from_millis(50), + ) + .err() + .expect("locked recall must time out"); + let elapsed = started.elapsed(); + locker.execute_batch("ROLLBACK").unwrap(); + + assert_eq!(error.to_string(), "preflight timeout"); + assert!(elapsed < StdDuration::from_secs(2), "elapsed: {elapsed:?}"); + assert!(receipt_files(&project.memory_root).is_empty()); + } + #[test] fn registry_version_mismatch_invalidates_prior_receipts_and_fails_closed() { let (_temp, project, store, mut manifest) = fixture(); diff --git a/crates/tree-ring-memory-sqlite/src/lib.rs b/crates/tree-ring-memory-sqlite/src/lib.rs index 614cc4e..28a3a9a 100644 --- a/crates/tree-ring-memory-sqlite/src/lib.rs +++ b/crates/tree-ring-memory-sqlite/src/lib.rs @@ -4,6 +4,7 @@ use rusqlite::{ }; use std::collections::HashSet; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use tree_ring_memory_core::models::{sqlite_error, MemoryEvent, TreeRingError, TreeRingResult}; use tree_ring_memory_core::recall::{search_queries, RecallScorer}; @@ -1162,6 +1163,65 @@ impl<'a> MemoryRetriever<'a> { &self, query: &str, options: &RecallOptions<'_>, + ) -> TreeRingResult> { + self.recall_with_options_until(query, options, None) + } + + /// Runs recall under a wall-clock deadline and interrupts an in-flight + /// SQLite statement when the deadline expires. + pub fn recall_with_options_timeout( + &self, + query: &str, + options: &RecallOptions<'_>, + timeout: Duration, + ) -> TreeRingResult> { + let started = Instant::now(); + let deadline = started.checked_add(timeout).unwrap_or(started); + let previous_busy_timeout_ms: u64 = self + .store + .connection + .query_row("PRAGMA busy_timeout", [], |row| row.get(0)) + .map_err(sqlite_error_from_rusqlite)?; + self.store + .connection + .busy_timeout(timeout) + .map_err(sqlite_error_from_rusqlite)?; + let interrupt = self.store.connection.get_interrupt_handle(); + let (cancel, cancelled) = std::sync::mpsc::channel(); + let watchdog = std::thread::spawn(move || { + if matches!( + cancelled.recv_timeout(timeout), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + ) { + interrupt.interrupt(); + true + } else { + false + } + }); + + let result = self.recall_with_options_until(query, options, Some(deadline)); + let _ = cancel.send(()); + let timed_out = watchdog.join().unwrap_or(true) || started.elapsed() >= timeout; + let restore_result = self + .store + .connection + .busy_timeout(Duration::from_millis(previous_busy_timeout_ms)) + .map_err(sqlite_error_from_rusqlite); + if timed_out { + return Err(tree_ring_memory_core::TreeRingError::Validation( + "recall deadline exceeded".to_string(), + )); + } + restore_result?; + result + } + + fn recall_with_options_until( + &self, + query: &str, + options: &RecallOptions<'_>, + deadline: Option, ) -> TreeRingResult> { if query.trim().is_empty() { return Ok(Vec::new()); @@ -1171,6 +1231,7 @@ impl<'a> MemoryRetriever<'a> { let mut seen_queries = HashSet::new(); let candidate_limit = Some(options.limit.saturating_mul(128).clamp(256, 2048)); for search_query in search_queries(query) { + ensure_recall_deadline(deadline)?; if !seen_queries.insert(search_query.clone()) { continue; } @@ -1192,6 +1253,7 @@ impl<'a> MemoryRetriever<'a> { } } if candidates.is_empty() { + ensure_recall_deadline(deadline)?; if let Some(fts_query) = format_plain_text_fts_or_query(query) { candidates = self.store.search_fts_filtered_limited( &fts_query, @@ -1209,40 +1271,49 @@ impl<'a> MemoryRetriever<'a> { } } - let mut results: Vec = candidates - .into_iter() - .filter(|event| { - matches_filters( - event, - options.project, - options.agent_profile, - options.workflow_id, - options.session_id, - options.scope, - options.rings, - options.event_types, - options.include_sensitive, - ) - }) - .map(|memory| { - let scored = RecallScorer::score(&memory, query); - RecallResult { - memory, - score: scored.score, - ranking: if options.explain_ranking { - scored.ranking.factors - } else { - Default::default() - }, - } - }) - .collect(); + let mut results = Vec::new(); + for memory in candidates { + ensure_recall_deadline(deadline)?; + if !matches_filters( + &memory, + options.project, + options.agent_profile, + options.workflow_id, + options.session_id, + options.scope, + options.rings, + options.event_types, + options.include_sensitive, + ) { + continue; + } + let scored = RecallScorer::score(&memory, query); + results.push(RecallResult { + memory, + score: scored.score, + ranking: if options.explain_ranking { + scored.ranking.factors + } else { + Default::default() + }, + }); + } + ensure_recall_deadline(deadline)?; results.sort_by(|left, right| right.score.total_cmp(&left.score)); results.truncate(options.limit); Ok(results) } } +fn ensure_recall_deadline(deadline: Option) -> TreeRingResult<()> { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + return Err(tree_ring_memory_core::TreeRingError::Validation( + "recall deadline exceeded".to_string(), + )); + } + Ok(()) +} + #[allow(clippy::too_many_arguments)] fn matches_filters( event: &MemoryEvent, @@ -2798,6 +2869,44 @@ mod tests { .any(|result| result.memory.sensitivity == "financial")); } + #[test] + fn recall_deadline_restores_the_configured_busy_timeout_on_all_paths() { + let dir = tempdir().unwrap(); + let store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + let configured: u64 = store + .connection_for_testing() + .query_row("PRAGMA busy_timeout", [], |row| row.get(0)) + .unwrap(); + let retriever = MemoryRetriever::new(&store); + + let error = retriever + .recall_with_options_timeout( + "startup constraints", + &RecallOptions::default(), + Duration::ZERO, + ) + .unwrap_err(); + assert_eq!(error.to_string(), "recall deadline exceeded"); + let after_timeout: u64 = store + .connection_for_testing() + .query_row("PRAGMA busy_timeout", [], |row| row.get(0)) + .unwrap(); + assert_eq!(after_timeout, configured); + + retriever + .recall_with_options_timeout( + "startup constraints", + &RecallOptions::default(), + Duration::from_secs(1), + ) + .unwrap(); + let after_success: u64 = store + .connection_for_testing() + .query_row("PRAGMA busy_timeout", [], |row| row.get(0)) + .unwrap(); + assert_eq!(after_success, configured); + } + #[test] fn recall_filters_project_before_candidate_limit() { let dir = tempdir().unwrap(); From 1f26db1190c375232f0d7518a4c31b533268fdd3 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 02:33:05 -0400 Subject: [PATCH 23/31] fix activation launcher evidence reporting --- .../src/activation/launcher.rs | 140 ++++++++++++++++-- .../src/harness_evidence.rs | 19 ++- crates/tree-ring-memory-cli/src/main.rs | 36 ++++- docs/protocol/harness-activation.md | 117 +++++++-------- 4 files changed, 225 insertions(+), 87 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/activation/launcher.rs b/crates/tree-ring-memory-cli/src/activation/launcher.rs index 48476bb..d2206c3 100644 --- a/crates/tree-ring-memory-cli/src/activation/launcher.rs +++ b/crates/tree-ring-memory-cli/src/activation/launcher.rs @@ -113,6 +113,30 @@ fn launch_with_spawner( arguments: &[OsString], spawner: &mut S, ) -> Result { + launch_with_spawner_and_cleanup( + store, + project, + manifest, + request, + arguments, + spawner, + cleanup_runtime, + ) +} + +fn launch_with_spawner_and_cleanup( + store: &SQLiteMemoryStore, + project: &ActivationProject, + manifest: &ActivationManifest, + request: LaunchRequest, + arguments: &[OsString], + spawner: &mut S, + cleanup: C, +) -> Result +where + S: ClaudeSpawner, + C: FnOnce(&ProjectFs, &str) -> Result<(), LaunchError>, +{ if adapter_capability(&request.harness_id) != Some(AdapterCapability::WrapperPreflight) { return Err(LaunchError::new(format!( "harness {} does not provide a wrapper preflight", @@ -153,10 +177,11 @@ fn launch_with_spawner( let mut child = match spawner.spawn(&mut command) { Ok(child) => child, Err(error) => { - cleanup_runtime(&project_fs, &receipt_id)?; - return Err(LaunchError::new(format!( - "failed to spawn Claude Code: {error}" - ))); + let spawn_error = LaunchError::new(format!("failed to spawn Claude Code: {error}")); + return match cleanup(&project_fs, &receipt_id) { + Ok(()) => Err(spawn_error), + Err(cleanup_error) => Err(combine_launch_errors(spawn_error, cleanup_error)), + }; } }; @@ -168,15 +193,34 @@ fn launch_with_spawner( }, ); let wait_result = child.wait(); - let cleanup_result = cleanup_runtime(&project_fs, &receipt_id); - - cleanup_result?; - commit_result?; - let outcome = wait_result - .map_err(|error| LaunchError::new(format!("failed to wait for Claude Code: {error}")))?; - outcome - .exit_code - .ok_or_else(|| LaunchError::new("Claude Code terminated without an exit code")) + let cleanup_result = cleanup(&project_fs, &receipt_id); + let outcome_result = wait_result + .map_err(|error| LaunchError::new(format!("failed to wait for Claude Code: {error}"))) + .and_then(|outcome| { + outcome + .exit_code + .ok_or_else(|| LaunchError::new("Claude Code terminated without an exit code")) + }); + + let mut failures = Vec::new(); + if let Err(error) = commit_result { + failures.push(error); + } + if let Err(error) = cleanup_result { + failures.push(error); + } + let outcome = match outcome_result { + Ok(outcome) => Some(outcome), + Err(error) => { + failures.push(error); + None + } + }; + let mut errors = failures.into_iter(); + if let Some(primary) = errors.next() { + return Err(errors.fold(primary, combine_launch_errors)); + } + Ok(outcome.expect("successful outcome was checked above")) } fn claude_command(project_root: &Path, context_path: &Path, arguments: &[OsString]) -> Command { @@ -218,6 +262,10 @@ fn cleanup_review_error(primary: String, cleanup: String) -> LaunchError { )) } +fn combine_launch_errors(primary: LaunchError, additional: LaunchError) -> LaunchError { + LaunchError::new(format!("{primary}; additionally, {additional}")) +} + #[cfg(test)] mod tests { use super::*; @@ -487,6 +535,34 @@ mod tests { assert!(receipt_files(&fixture.project.memory_root).is_empty()); } + #[test] + fn spawn_failure_preserves_spawn_and_cleanup_errors() { + let fixture = fixture(); + let (mut spawner, _captured) = + spawner(&fixture.project, Ok(ChildOutcome { exit_code: Some(0) })); + spawner.spawn_error = Some(io::ErrorKind::NotFound); + + let error = launch_with_spawner_and_cleanup( + &fixture.store, + &fixture.project, + &fixture.manifest, + request_for("claude-code"), + &[], + &mut spawner, + |_project_fs, _receipt_id| { + Err(LaunchError::new( + "private Claude context cleanup failed; injected cleanup failure", + )) + }, + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("failed to spawn Claude Code: injected spawn failure")); + assert!(error.contains("injected cleanup failure")); + assert!(receipt_files(&fixture.project.memory_root).is_empty()); + } + #[test] fn abnormal_child_outcome_removes_context_and_surfaces_failure() { let fixture = fixture(); @@ -568,4 +644,42 @@ mod tests { assert!(runtime_context_files(&fixture.project).is_empty()); assert!(receipt_files(&fixture.project.memory_root).is_empty()); } + + #[test] + fn post_wait_failure_preserves_commit_indeterminacy_and_cleanup_error() { + let fixture = fixture(); + let (mut spawner, _captured) = + spawner(&fixture.project, Ok(ChildOutcome { exit_code: Some(0) })); + let memory_root = fixture.project.memory_root.clone(); + let mut changed = fixture.manifest.clone(); + changed + .harnesses + .get_mut("claude-code") + .unwrap() + .adapter_capability = AdapterCapability::NativePreflight; + spawner.on_spawn = Some(Box::new(move || { + save_manifest(&memory_root, &changed).unwrap(); + })); + + let error = launch_with_spawner_and_cleanup( + &fixture.store, + &fixture.project, + &fixture.manifest, + request_for("claude-code"), + &[], + &mut spawner, + |_project_fs, _receipt_id| { + Err(LaunchError::new( + "private Claude context cleanup failed; injected cleanup failure", + )) + }, + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("failed to commit launch receipt")); + assert!(error.contains("receipt state may be indeterminate")); + assert!(error.contains("injected cleanup failure")); + assert!(receipt_files(&fixture.project.memory_root).is_empty()); + } } diff --git a/crates/tree-ring-memory-cli/src/harness_evidence.rs b/crates/tree-ring-memory-cli/src/harness_evidence.rs index 71733d1..02cbc51 100644 --- a/crates/tree-ring-memory-cli/src/harness_evidence.rs +++ b/crates/tree-ring-memory-cli/src/harness_evidence.rs @@ -1,6 +1,7 @@ use crate::actions::integrations::{ project_root_fingerprint, verify_activation_receipts, ReceiptVerificationStatus, }; +use crate::activation_state_name; use crate::evidence::{ atomic_write, publish_indexed_evidence, rollup_index_status, EvidenceRecordRef, EvidenceStatus, HARNESS_ACTIVATION_SUMMARY_FILE, @@ -504,10 +505,10 @@ fn render_harness_summary(generated_at: &str, records: &[HarnessProbeRecord]) -> ]; for record in records { lines.push(format!( - "| {} | {} | {:?} | {} |", + "| {} | {} | {} | {} |", record.name, record.status.as_str(), - record.activation.state, + activation_state_name(record.activation.state), record.activation.diagnostic.replace('|', "-") )); } @@ -529,10 +530,18 @@ mod tests { use tempfile::tempdir; use tree_ring_memory_cli::activation::manifest::bridge_fingerprint; use tree_ring_memory_cli::activation::{ - save_manifest, write_receipt, ActivationReceipt, HarnessActivation, SessionIdentity, + write_receipt, ActivationReceipt, HarnessActivation, SessionIdentity, ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, }; + fn write_manifest_fixture(memory_root: &Path, manifest: &ActivationManifest) { + fs::write( + memory_root.join("activation.json"), + serde_json::to_vec_pretty(manifest).unwrap(), + ) + .unwrap(); + } + fn request(project: &Path) -> HarnessCertificationRequest { HarnessCertificationRequest { source_root: project.to_path_buf(), @@ -571,7 +580,7 @@ mod tests { cli_version: env!("CARGO_PKG_VERSION").to_string(), harnesses: BTreeMap::from([(harness_id.to_string(), harness)]), }; - save_manifest(&memory_root, &manifest).unwrap(); + write_manifest_fixture(&memory_root, &manifest); manifest } @@ -804,6 +813,8 @@ mod tests { assert!(summary.contains("# Harness activation evidence")); assert!(summary.contains("Markers are detection context only")); + assert!(summary.contains("configured-awaiting-proof")); + assert!(!summary.contains("ConfiguredAwaitingProof")); assert!(!summary.contains(dir.path().to_str().unwrap())); let index: crate::evidence::EvidenceIndex = serde_json::from_str(&fs::read_to_string(report.index_path).unwrap()).unwrap(); diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs index acf3b2f..0ec1dd2 100644 --- a/crates/tree-ring-memory-cli/src/main.rs +++ b/crates/tree-ring-memory-cli/src/main.rs @@ -1912,8 +1912,10 @@ fn print_integration_report( ); for integration in &report.integrations { println!( - "{} [{:?}] activation={:?}", - integration.name, integration.status, integration.state + "{} [{:?}] activation={}", + integration.name, + integration.status, + activation_state_name(integration.state) ); if !integration.markers.is_empty() { println!( @@ -1986,10 +1988,10 @@ fn format_harness_certification_report( )]; for record in &report.records { lines.push(format!( - "{} [{}] activation={:?} {}", + "{} [{}] activation={} {}", record.name, record.status.as_str(), - record.activation.state, + activation_state_name(record.activation.state), record.summary )); lines.push(format!(" next: {}", record.next_step)); @@ -2074,6 +2076,26 @@ mod tests { } } + #[test] + fn activation_state_names_match_the_json_wire_contract() { + for state in [ + activation::ActivationState::Active, + activation::ActivationState::ConfiguredAwaitingProof, + activation::ActivationState::ActiveIsolated, + activation::ActivationState::NeedsTrust, + activation::ActivationState::NeedsProjectMount, + activation::ActivationState::NeedsPlugin, + activation::ActivationState::NeedsUserReview, + activation::ActivationState::Unsupported, + activation::ActivationState::Failed, + ] { + assert_eq!( + serde_json::to_value(state).unwrap().as_str().unwrap(), + activation_state_name(state) + ); + } + } + #[test] fn integrations_preflight_parses_direct_codex_identity() { let cli = Cli::try_parse_from([ @@ -2826,19 +2848,19 @@ mod tests { "Tree Ring Memory harness certification: pass=1 fail=1 skip=1 evidence=/tmp/project/target/tree-ring-certification" )); assert!(output.contains( - "Codex [pass] activation=Active Codex has a project marker and generated Tree Ring recall/remember guidance." + "Codex [pass] activation=active Codex has a project marker and generated Tree Ring recall/remember guidance." )); assert!(output.contains( " next: Merge the generated Tree Ring guidance into the active Codex instructions." )); assert!(output.contains( - "Goose [fail] activation=Failed Goose has a project marker but is missing generated Tree Ring guidance." + "Goose [fail] activation=failed Goose has a project marker but is missing generated Tree Ring guidance." )); assert!(output.contains( " next: Run `tree-ring init`, then reference `.tree-ring/SKILL.md` and `.tree-ring/CLI.md` from the harness project instructions." )); assert!(output.contains( - "PI [skip] activation=NeedsTrust PI was not detected for this project, so no compatibility claim is made." + "PI [skip] activation=needs-trust PI was not detected for this project, so no compatibility claim is made." )); assert!(output.contains( " next: Add a project-level harness marker or project instruction file, then rerun `tree-ring integrations certify`." diff --git a/docs/protocol/harness-activation.md b/docs/protocol/harness-activation.md index c9a4cbb..7e0177a 100644 --- a/docs/protocol/harness-activation.md +++ b/docs/protocol/harness-activation.md @@ -26,7 +26,7 @@ required for initialization. | State | Meaning | | --- | --- | -| `active` | A maintained bridge has a fresh matching receipt for a new session's scoped recall and safe context injection. | +| `active` | A maintained bridge has a fresh matching receipt for a new session's scoped recall and rendered safe context. | | `configured-awaiting-proof` | A safe bridge is installed, but no qualifying fresh receipt exists. | | `active-isolated` | Preflight succeeded against a store that does not match this project's canonical store. | | `needs-trust` | The runtime needs its own user approval before project resources load. | @@ -49,11 +49,11 @@ and protocol versions, stable `store_id`, project-root fingerprint, CLI version, and adapter records with state, capability, bridge path, owned files, and managed blocks. Receipts live under `.tree-ring/activation/receipts/`; they contain version and harness IDs, fingerprinted worker identity, harness-derived -agent/workflow/session IDs, state, timestamp, and optional query class, result -count, memory-ID digest, duration, and matching-store evidence. They never retain -raw prompts, recalled content, secrets, sensitive values, absolute paths, or -coordinator capabilities. Keep at most 100 receipts per harness/worker and none -older than 30 days. +agent/workflow/session IDs, state, timestamp, query class, result count, +selected-memory-ID digest, duration, success status, and matching-store +evidence. They never retain raw prompts, recalled content, secrets, sensitive +values, absolute paths, or coordinator capabilities. Keep at most 100 receipts +per harness/worker and none older than 30 days. A receipt proves a privacy-safe preflight check, not durable memory creation or an adversarial security boundary. Durable writes remain explicit. @@ -92,7 +92,6 @@ use synthetic IDs and no prompt, recalled context, capability, or absolute path. "claude-code": { "state": "configured-awaiting-proof", "adapter_version": "1", - "harness_version": "1.0.0", "adapter_capability": "native-preflight", "bridge_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "bridge_path": ".claude/settings.json", @@ -114,9 +113,9 @@ use synthetic IDs and no prompt, recalled context, capability, or absolute path. } ``` -The adapter record is keyed by its canonical `harness_id`. `adapter_version`, -`harness_version`, and `bridge_fingerprint` are required for receipt-backed -activation. For a bridge with more than one owned file or managed block, +The adapter record is keyed by its canonical `harness_id`. `adapter_version` +and `bridge_fingerprint` are required for receipt-backed activation. For a +bridge with more than one owned file or managed block, `bridge_fingerprint` is the SHA-256 of the UTF-8 canonical JSON array of its components. Each component has exactly `path`, `kind`, and `sha256`; sort the array by `path`, then `kind`, and serialize object keys in lexical order @@ -132,30 +131,32 @@ fingerprint without recording the project root. "protocol_version": 1, "receipt_id": "receipt-01", "harness_id": "claude-code", - "harness_version": "1.0.0", "adapter_version": "1", + "bridge_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "store_id": "01234567-89ab-4def-8123-456789abcdef", "project_root_fingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "bridge_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "worker_key_fingerprint": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "session": { "agent_profile": "claude-code", "workflow_id": "workflow-01", "session_id": "session-01" }, - "query_class": "project-startup-constraints", + "state": "active", + "query_class": "startup_fallback", "result_count": 0, - "selected_memory_ids_digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "selected_memory_ids_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "duration_ms": 18, - "state": "active", - "recorded_at": "2026-08-13T12:00:00Z", - "expires_at": "2026-09-12T12:00:00Z" + "status": "success", + "recorded_at": "2026-08-13T12:00:00Z" } ``` `query_class` is an approved stable category, never a raw task hint. A zero-result receipt has `result_count: 0` and a digest of the empty selected-ID -set; it is still valid only after the adapter successfully injects safe context. +set; it is still valid only after preflight successfully renders safe context +and persists the receipt. +Receipt freshness is derived from `recorded_at` and the 30-day retention +window; no expiry timestamp is serialized. ### Claude Code SessionStart input @@ -185,7 +186,7 @@ writes exactly this JSON object to stdout on successful preflight: { "hookSpecificOutput": { "hookEventName": "SessionStart", - "additionalContext": "Tree Ring preflight completed; safe project context was injected." + "additionalContext": "Tree Ring Memory scoped preflight recall:\n- No safe memories matched this scoped query.\nProject source and instructions remain authoritative; verify recalled guidance against them." } } ``` @@ -253,30 +254,24 @@ JSON stdin and consume this response shape. Their harness ID and ```json { - "protocol_version": 1, - "status": "ok", - "context": "Tree Ring preflight completed; safe project context was injected.", + "context": "Tree Ring Memory scoped preflight recall:\n- No safe memories matched this scoped query.\nProject source and instructions remain authoritative; verify recalled guidance against them.", + "state": "active", "receipt": { + "schema_version": 1, + "protocol_version": 1, "receipt_id": "receipt-01", "harness_id": "pi", - "harness_version": "1.0.0", "adapter_version": "1", + "bridge_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "store_id": "01234567-89ab-4def-8123-456789abcdef", "project_root_fingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "bridge_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "worker_key_fingerprint": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "session": { - "agent_profile": "pi", - "workflow_id": "workflow-01", - "session_id": "session-01" - }, - "query_class": "project-startup-constraints", + "query_class": "startup_fallback", "result_count": 0, - "selected_memory_ids_digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "selected_memory_ids_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "duration_ms": 18, - "state": "active", - "recorded_at": "2026-08-13T12:00:00Z", - "expires_at": "2026-09-12T12:00:00Z" + "status": "success", + "recorded_at": "2026-08-13T12:00:00Z" } } ``` @@ -285,39 +280,36 @@ Agent Zero returns this complete shape through its separate plugin: ```json { - "protocol_version": 1, - "status": "ok", - "context": "Tree Ring preflight completed; safe project context was injected.", + "context": "Tree Ring Memory scoped preflight recall:\n- No safe memories matched this scoped query.\nProject source and instructions remain authoritative; verify recalled guidance against them.", + "state": "active", "receipt": { + "schema_version": 1, + "protocol_version": 1, "receipt_id": "receipt-02", "harness_id": "agent-zero", - "harness_version": "1.0.0", "adapter_version": "1", + "bridge_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "store_id": "01234567-89ab-4def-8123-456789abcdef", "project_root_fingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "bridge_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "worker_key_fingerprint": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "session": { - "agent_profile": "agent-zero-worker", - "workflow_id": "workflow-01", - "session_id": "session-01" - }, - "query_class": "project-startup-constraints", + "query_class": "startup_fallback", "result_count": 0, - "selected_memory_ids_digest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "selected_memory_ids_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "duration_ms": 18, - "state": "active", - "recorded_at": "2026-08-13T12:00:00Z", - "expires_at": "2026-09-12T12:00:00Z" + "status": "success", + "recorded_at": "2026-08-13T12:00:00Z" } } ``` -The plugin derives the Agent Zero identity server-side; it must not accept a -model-supplied identity. Its binding carries the same protocol version, -`store_id`, project-root fingerprint, relative `memory_root`, and JSON -stdin/stdout command contract. An error response has `status: "error"`, a -bounded `error_code`, and no `context` or `receipt`. +The structured response exposes the serialized `ActivationReceiptSummary`, +which deliberately omits the persisted receipt's session identity and receipt +state. The top-level `state` reports the activation result. The plugin derives +the Agent Zero identity server-side; it must not accept a model-supplied +identity. Its binding carries the same protocol version, `store_id`, +project-root fingerprint, relative `memory_root`, and JSON stdin/stdout command +contract. A failed preflight emits no successful context or receipt and the +command exits with an error. ## Receipt verification @@ -325,20 +317,19 @@ An adapter may classify a harness as `active` only after it verifies the manifest, bridge, and receipt as one tuple. It must reject the receipt and report the exact non-active state if any of these equality requirements fail: -1. `receipt.protocol_version == manifest.protocol_version == 1`. -2. `receipt.harness_id` is exactly the adapter's canonical harness ID, and - `receipt.harness_version == manifest.harnesses[harness_id].harness_version`. +1. `receipt.schema_version == 1` and + `receipt.protocol_version == manifest.protocol_version == 1`. +2. `receipt.harness_id` is exactly the adapter's canonical harness ID. 3. `receipt.adapter_version == manifest.harnesses[harness_id].adapter_version`. 4. `receipt.store_id == manifest.store_id`. 5. `receipt.project_root_fingerprint == manifest.project_root_fingerprint`. 6. `receipt.bridge_fingerprint == manifest.harnesses[harness_id].bridge_fingerprint`, after recomputing that fingerprint from the installed owned material. -7. `receipt.session.session_id` is the currently running harness session and - its recorded worker/workflow identity matches the adapter-derived identity. -8. `receipt.state == "active"`, `recorded_at` is not in the future, and - `expires_at - recorded_at == 2,592,000 seconds` (30 days). The receipt is - fresh only while `recorded_at <= now < expires_at`; it is never reused for - a later session even if its TTL has not elapsed. +7. `receipt.status == "success"` and `receipt.state` is `active` or + `active-isolated`. +8. `recorded_at` is not in the future and is newer than the 30-day retention + cutoff. Freshness is calculated from `recorded_at`; the receipt has no + serialized expiry field. Any adapter, harness, root, or bridge change invalidates prior receipts. A matching receipt for another accessible store is `active-isolated`, never From 1eaad942fefeb50d14cd75068817b5ed3f8ba4f5 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 02:46:29 -0400 Subject: [PATCH 24/31] test: avoid racy fd reuse assertion --- .../src/activation/bridge.rs | 26 ++----------------- 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/activation/bridge.rs b/crates/tree-ring-memory-cli/src/activation/bridge.rs index 98092c1..dffb7d9 100644 --- a/crates/tree-ring-memory-cli/src/activation/bridge.rs +++ b/crates/tree-ring-memory-cli/src/activation/bridge.rs @@ -143,7 +143,6 @@ struct TestMutationHooks { fail_directory_sync_at: Vec, directory_syncs: usize, fail_fdopendir: bool, - failed_fdopendir_fd: Option, fail_readdir: bool, fail_closedir: bool, } @@ -206,7 +205,6 @@ fn fdopendir_for_listing(descriptor: libc::c_int) -> *mut libc::DIR { let mut hooks = hooks.borrow_mut(); if hooks.fail_fdopendir { hooks.fail_fdopendir = false; - hooks.failed_fdopendir_fd = Some(descriptor); true } else { false @@ -2911,17 +2909,6 @@ mod tests { TEST_MUTATION_HOOKS.with(|hooks| hooks.borrow_mut().fail_fdopendir = true); } - #[cfg(unix)] - fn failed_fdopendir_fd_for_test() -> libc::c_int { - TEST_MUTATION_HOOKS.with(|hooks| { - hooks - .borrow_mut() - .failed_fdopendir_fd - .take() - .expect("fdopendir failure recorded its descriptor") - }) - } - #[cfg(unix)] fn fail_readdir_for_test() { TEST_MUTATION_HOOKS.with(|hooks| hooks.borrow_mut().fail_readdir = true); @@ -3792,24 +3779,15 @@ mod tests { #[cfg(unix)] #[test] - fn directory_listing_closes_fdopendir_failure_descriptor() { + fn directory_listing_preserves_fdopendir_error_when_cleanup_succeeds() { let directory = tempfile::tempdir().unwrap(); let file = File::open(directory.path()).unwrap(); fail_fdopendir_for_test(); let error = list_directory_entries(&file).unwrap_err(); - let descriptor = failed_fdopendir_fd_for_test(); - let status = unsafe { - // SAFETY: fcntl only observes whether the recorded descriptor remains open. - libc::fcntl(descriptor, libc::F_GETFD) - }; assert!(error.contains("Input/output error")); - assert_eq!(status, -1); - assert_eq!( - std::io::Error::last_os_error().raw_os_error(), - Some(libc::EBADF) - ); + assert!(!error.contains("descriptor cleanup also failed")); } #[cfg(unix)] From 058dc045ec1644565e428bd69caee66f0a37c858 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 02:47:28 -0400 Subject: [PATCH 25/31] fix activation receipt candidate handling --- .../src/activation/preflight.rs | 35 ++++++++++++++++--- docs/protocol/harness-activation.md | 20 ++++++----- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/activation/preflight.rs b/crates/tree-ring-memory-cli/src/activation/preflight.rs index 711b744..c9de5ff 100644 --- a/crates/tree-ring-memory-cli/src/activation/preflight.rs +++ b/crates/tree-ring-memory-cli/src/activation/preflight.rs @@ -691,16 +691,25 @@ pub fn read_receipt_candidates( { continue; } - candidates.push( - project_fs - .read_optional(&worker_directory.join(name))? - .unwrap_or_default(), - ); + append_receipt_candidate( + &mut candidates, + project_fs.read_optional(&worker_directory.join(name)), + )?; } } Ok(candidates) } +fn append_receipt_candidate( + candidates: &mut Vec>, + candidate: Result>, String>, +) -> Result<(), String> { + if let Some(candidate) = candidate? { + candidates.push(candidate); + } + Ok(()) +} + fn preflight_state( project: &ActivationProject, manifest: &ActivationManifest, @@ -1435,4 +1444,20 @@ mod tests { assert_eq!(receipts.len(), 1); assert_ne!(receipts[0], path); } + + #[test] + fn receipt_candidate_collection_skips_safe_missing_files_and_propagates_errors() { + let mut candidates = Vec::new(); + + append_receipt_candidate(&mut candidates, Ok(None)).unwrap(); + append_receipt_candidate(&mut candidates, Ok(Some(b"valid receipt".to_vec()))).unwrap(); + + assert_eq!(candidates, vec![b"valid receipt".to_vec()]); + assert_eq!( + append_receipt_candidate(&mut candidates, Err("receipt read failed".to_string())) + .unwrap_err(), + "receipt read failed" + ); + assert_eq!(candidates, vec![b"valid receipt".to_vec()]); + } } diff --git a/docs/protocol/harness-activation.md b/docs/protocol/harness-activation.md index 7e0177a..7b99fa1 100644 --- a/docs/protocol/harness-activation.md +++ b/docs/protocol/harness-activation.md @@ -114,14 +114,18 @@ use synthetic IDs and no prompt, recalled context, capability, or absolute path. ``` The adapter record is keyed by its canonical `harness_id`. `adapter_version` -and `bridge_fingerprint` are required for receipt-backed activation. For a -bridge with more than one owned file or managed block, -`bridge_fingerprint` is the SHA-256 of the UTF-8 canonical JSON array of its -components. Each component has exactly `path`, `kind`, and `sha256`; sort -the array by `path`, then `kind`, and serialize object keys in lexical order -with no insignificant whitespace. `kind` is either `file` or -`managed-block:`. This lets every adapter calculate the same -fingerprint without recording the project root. +and `bridge_fingerprint` are required for receipt-backed activation. +`bridge_fingerprint` is the lowercase SHA-256 hex digest of a length-delimited +UTF-8 field stream. Each field is encoded as its UTF-8 byte length followed by +its bytes; the length is an unsigned, big-endian, platform-width integer +(`usize::to_be_bytes`, eight bytes on the supported 64-bit CLI targets). + +The stream begins with `harness_id`, then `adapter_version`. Owned files are +sorted by `(path, sha256)` and each appends the fields `"file"`, `path`, and +`sha256`. Managed blocks follow the files, sorted by +`(path, block_id, sha256, leading_separator)`, and each appends the fields +`"block"`, `path`, `block_id`, `sha256`, and `leading_separator`. No project +root is included. ### Redacted receipt From b4fa9b1ff8709234069a073dd52bad7ff45577be Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 02:55:06 -0400 Subject: [PATCH 26/31] docs: clarify activation fingerprint compatibility --- docs/protocol/harness-activation.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/protocol/harness-activation.md b/docs/protocol/harness-activation.md index 7b99fa1..3f37c6b 100644 --- a/docs/protocol/harness-activation.md +++ b/docs/protocol/harness-activation.md @@ -105,7 +105,8 @@ use synthetic IDs and no prompt, recalled context, capability, or absolute path. { "path": ".claude/settings.json", "block_id": "tree-ring-session-start-v1", - "sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + "sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "leading_separator": "\n" } ] } @@ -119,13 +120,22 @@ and `bridge_fingerprint` are required for receipt-backed activation. UTF-8 field stream. Each field is encoded as its UTF-8 byte length followed by its bytes; the length is an unsigned, big-endian, platform-width integer (`usize::to_be_bytes`, eight bytes on the supported 64-bit CLI targets). +This is the only executable bridge-fingerprint encoding for protocol version 1. +Earlier draft prose described a canonical-JSON encoding before executable +fingerprints existed; it was not a released manifest encoding and does not +define a legacy protocol-1 variant. The stream begins with `harness_id`, then `adapter_version`. Owned files are sorted by `(path, sha256)` and each appends the fields `"file"`, `path`, and `sha256`. Managed blocks follow the files, sorted by `(path, block_id, sha256, leading_separator)`, and each appends the fields `"block"`, `path`, `block_id`, `sha256`, and `leading_separator`. No project -root is included. +root is included. `leading_separator` records only adapter-owned whitespace +immediately before a managed block and is restricted to `""`, `"\n"`, or +`"\n\n"`. It may be omitted from manifest JSON only when empty; omission +deserializes as `""`, and the empty value still contributes a zero-length field +to the fingerprint. For example, the manifest above contributes one LF byte +for `leading_separator`, while an omitted field contributes no value bytes. ### Redacted receipt From 6994b2a7c86efe14cf07ffa037877d72343f2751 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 03:12:56 -0400 Subject: [PATCH 27/31] fix: support default project-local activation root --- .../src/activation/adapters.rs | 12 +++ .../src/activation/preflight.rs | 2 +- .../tests/harness_activation_acceptance.rs | 82 +++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/crates/tree-ring-memory-cli/src/activation/adapters.rs b/crates/tree-ring-memory-cli/src/activation/adapters.rs index c27e0e2..f1678b7 100644 --- a/crates/tree-ring-memory-cli/src/activation/adapters.rs +++ b/crates/tree-ring-memory-cli/src/activation/adapters.rs @@ -26,6 +26,18 @@ impl ActivationProject { } else { project_root.to_path_buf() }; + // Keep the root and its derived project parent in the same lexical + // form. The CLI default is `.tree-ring`; without this, it derives `.` + // as the project root but compares `.tree-ring` with `./.tree-ring` + // during the project-local safety validation. + let memory_root = if memory_root + .parent() + .is_some_and(|parent| parent.as_os_str().is_empty()) + { + project_root.join(&memory_root) + } else { + memory_root + }; Ok(Self { project_root, memory_root, diff --git a/crates/tree-ring-memory-cli/src/activation/preflight.rs b/crates/tree-ring-memory-cli/src/activation/preflight.rs index c9de5ff..6c3246f 100644 --- a/crates/tree-ring-memory-cli/src/activation/preflight.rs +++ b/crates/tree-ring-memory-cli/src/activation/preflight.rs @@ -741,7 +741,7 @@ fn configured_identity_root(project: &ActivationProject) -> Result<&Path, Activa } fn project_scope_name(project: &ActivationProject) -> Result { - configured_identity_root(project)? + canonical_or_original(configured_identity_root(project)?) .file_name() .and_then(OsStr::to_str) .filter(|name| !name.is_empty()) diff --git a/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs b/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs index fcda62b..fa3d8eb 100644 --- a/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs +++ b/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs @@ -132,6 +132,88 @@ fn shipped_fixtures_declare_only_project_local_versioned_activation_contracts() assert_eq!(codex["isolated_root"]["copy_sqlite"], false); } +#[test] +fn default_relative_root_initializes_from_the_project_root() { + let temp = tempdir().unwrap(); + let project = temp.path().join("relative-default-root"); + let empty_path = temp.path().join("empty-path"); + fs::create_dir_all(project.join(".codex")).unwrap(); + fs::create_dir_all(&empty_path).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_tree-ring")) + .current_dir(&project) + .env("PATH", OsString::from(empty_path.as_os_str())) + .env("HOME", project.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_COORDINATOR_TOKEN") + .arg("--json") + .arg("init") + .output() + .unwrap(); + + assert_success("default relative root init", &output); + let report = output_json("default relative root init", &output); + assert_eq!(report["ok"], true); + assert_eq!( + record_by_id(&report["integrations"], "codex")["state"], + "configured-awaiting-proof" + ); + assert!(project.join(".tree-ring/memory.sqlite").exists()); + assert!(project + .join(".agents/skills/tree-ring-memory/SKILL.md") + .exists()); + + let preflight = Command::new(env!("CARGO_BIN_EXE_tree-ring")) + .current_dir(&project) + .env("PATH", OsString::from(empty_path.as_os_str())) + .env("HOME", project.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_COORDINATOR_TOKEN") + .arg("--json") + .args([ + "integrations", + "preflight", + "--harness", + "codex", + "--agent-profile", + "smoke-worker", + "--workflow-id", + "smoke-flow", + "--session-id", + "smoke-session", + ]) + .output() + .unwrap(); + assert_success("default relative root preflight", &preflight); + assert_eq!( + output_json("default relative root preflight", &preflight)["state"], + "active" + ); + + let status = Command::new(env!("CARGO_BIN_EXE_tree-ring")) + .current_dir(&project) + .env("PATH", OsString::from(empty_path.as_os_str())) + .env("HOME", project.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_COORDINATOR_TOKEN") + .arg("--json") + .args(["integrations", "status"]) + .output() + .unwrap(); + assert_success("default relative root status", &status); + let status_report = output_json("default relative root status", &status); + assert_eq!( + record_by_id(&status_report["integrations"], "codex")["state"], + "active" + ); +} + #[test] fn init_preflight_status_and_certify_prove_same_store_workers_without_private_receipt_data() { let temp = tempdir().unwrap(); From 8c7f29bf7273ccd34f047ec10c902617dbd8bf88 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 03:40:37 -0400 Subject: [PATCH 28/31] feat: bootstrap Agent Zero activation safely --- .../src/actions/integrations.rs | 45 ++- .../src/activation/adapters.rs | 297 +++++++++++++++++- .../src/activation/bridge.rs | 84 +++-- .../src/activation/preflight.rs | 81 +++-- crates/tree-ring-memory-cli/src/main.rs | 41 ++- .../tests/harness_activation_acceptance.rs | 175 ++++++++++- 6 files changed, 627 insertions(+), 96 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/actions/integrations.rs b/crates/tree-ring-memory-cli/src/actions/integrations.rs index 5628553..d00867e 100644 --- a/crates/tree-ring-memory-cli/src/actions/integrations.rs +++ b/crates/tree-ring-memory-cli/src/actions/integrations.rs @@ -8,7 +8,7 @@ use tree_ring_memory_cli::activation::{ self, adapters::{scan_integrations, ActivationProject, IntegrationScanReport}, bridge::{apply_bridge_plan, deactivate_bridge_plan}, - manifest::{bridge_fingerprint, ActivationManifest, ActivationReceipt}, + manifest::{bridge_fingerprint, ActivationManifest, ActivationReceipt, HarnessActivation}, preflight::{project_fingerprint, read_receipt_candidates}, ActivationState, AdapterCapability, PreflightContextFormat, PreflightRequest, SessionIdentity, ACTIVATION_PROTOCOL_VERSION, ACTIVATION_SCHEMA_VERSION, RECEIPT_RETENTION_DAYS, @@ -135,11 +135,34 @@ pub fn status(request: IntegrationStatusRequest) -> Result Result bool { + activation.state == ActivationState::NeedsPlugin + && activation.bridge_path.as_deref() == Some(".tree-ring/activation/agent-zero.json") + && activation.managed_blocks.is_empty() + && matches!( + activation.owned_files.as_slice(), + [owned] if owned.path == ".tree-ring/activation/agent-zero.json" + ) +} + /// Applies a single adapter plan, or reports its validated read-only dry-run. pub fn activate( request: IntegrationActivationRequest, diff --git a/crates/tree-ring-memory-cli/src/activation/adapters.rs b/crates/tree-ring-memory-cli/src/activation/adapters.rs index f1678b7..5b7422d 100644 --- a/crates/tree-ring-memory-cli/src/activation/adapters.rs +++ b/crates/tree-ring-memory-cli/src/activation/adapters.rs @@ -1,9 +1,24 @@ use super::{ActivationState, AdapterCapability, ACTIVATION_PROTOCOL_VERSION}; -use serde::Serialize; -use std::path::{Component, Path, PathBuf}; +use serde::{Deserialize, Serialize}; +#[cfg(unix)] +use std::io::Read; +#[cfg(unix)] +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; use std::process::Command; +use std::{ + fs, + path::{Component, Path, PathBuf}, +}; pub const AGENT_ZERO_PLUGIN_ID: &str = "tree_ring_memory"; +pub const AGENT_ZERO_PLUGIN_MANIFEST_ENV: &str = "TREE_RING_AGENT_ZERO_PLUGIN_MANIFEST"; + +const AGENT_ZERO_CAPABILITY_FILE: &str = "activation-capability.json"; +const AGENT_ZERO_CAPABILITY_KIND: &str = "tree-ring-agent-zero-plugin-capability"; +const AGENT_ZERO_PLUGIN_VERSION: &str = "3.1.0"; +const AGENT_ZERO_TREE_RING_MIN_VERSION: &str = "0.14.0"; +const AGENT_ZERO_TREE_RING_MINOR_VERSION: &str = "0.14"; +const MAX_AGENT_ZERO_CAPABILITY_BYTES: u64 = 16 * 1024; /// Project paths used by activation. Adapter plans always target this root. #[derive(Debug, Clone, PartialEq, Eq)] @@ -85,6 +100,143 @@ impl AgentZeroPluginManifest { } } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AgentZeroCapabilityDocument { + schema_version: u16, + kind: String, + plugin_id: String, + plugin_version: String, + activation_protocol_version: u16, + tree_ring_version: AgentZeroTreeRingVersion, + enabled: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AgentZeroTreeRingVersion { + min: String, + minor: String, +} + +/// Reads the capability that the separately installed Agent Zero plugin +/// explicitly presents to a Tree Ring child process. It never scans project +/// markers or generic Agent Zero paths: only the plugin-owned, absolute, +/// no-follow descriptor named by the environment is considered. +pub fn agent_zero_plugin_manifest_for_project( + project_root: &Path, +) -> Option { + let descriptor = std::env::var_os(AGENT_ZERO_PLUGIN_MANIFEST_ENV).map(PathBuf::from)?; + read_agent_zero_plugin_manifest(project_root, &descriptor) +} + +fn read_agent_zero_plugin_manifest( + project_root: &Path, + descriptor: &Path, +) -> Option { + if !descriptor.is_absolute() + || descriptor.file_name().and_then(|name| name.to_str()) != Some(AGENT_ZERO_CAPABILITY_FILE) + { + return None; + } + + // Reject a descriptor symlink before canonicalizing. Canonicalization is + // then used only to compare the resolved external plugin location with + // the project root and to find its sibling plugin.yaml. + let metadata = fs::symlink_metadata(descriptor).ok()?; + if !metadata.file_type().is_file() || metadata.len() > MAX_AGENT_ZERO_CAPABILITY_BYTES { + return None; + } + let descriptor = fs::canonicalize(descriptor).ok()?; + if descriptor.file_name().and_then(|name| name.to_str()) != Some(AGENT_ZERO_CAPABILITY_FILE) { + return None; + } + let project_root = fs::canonicalize(project_root).ok()?; + if descriptor.starts_with(&project_root) { + return None; + } + + let capability = serde_json::from_slice::( + &read_regular_file_no_follow(&descriptor)?, + ) + .ok()?; + if capability.schema_version != 1 + || capability.kind != AGENT_ZERO_CAPABILITY_KIND + || capability.plugin_id != AGENT_ZERO_PLUGIN_ID + || capability.plugin_version != AGENT_ZERO_PLUGIN_VERSION + || capability.activation_protocol_version != ACTIVATION_PROTOCOL_VERSION + || capability.tree_ring_version.min != AGENT_ZERO_TREE_RING_MIN_VERSION + || capability.tree_ring_version.minor != AGENT_ZERO_TREE_RING_MINOR_VERSION + || !capability.enabled + { + return None; + } + + let plugin_yaml = descriptor.parent()?.join("plugin.yaml"); + let plugin_yaml = read_regular_file_no_follow(&plugin_yaml)?; + plugin_yaml_matches_capability(std::str::from_utf8(&plugin_yaml).ok()?) + .then_some(AgentZeroPluginManifest::compatible()) +} + +/// Opens one regular capability file without following its final path entry. +/// Non-Unix platforms fail closed because the rest of activation persistence +/// already requires descriptor-relative no-follow support there as well. +#[cfg(unix)] +fn read_regular_file_no_follow(path: &Path) -> Option> { + let before = fs::symlink_metadata(path).ok()?; + if !before.file_type().is_file() || before.len() > MAX_AGENT_ZERO_CAPABILITY_BYTES { + return None; + } + let file = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .ok()?; + let after = file.metadata().ok()?; + if !after.file_type().is_file() + || after.len() > MAX_AGENT_ZERO_CAPABILITY_BYTES + || before.dev() != after.dev() + || before.ino() != after.ino() + { + return None; + } + let mut contents = Vec::with_capacity(usize::try_from(after.len()).ok()?); + file.take(MAX_AGENT_ZERO_CAPABILITY_BYTES + 1) + .read_to_end(&mut contents) + .ok()?; + (contents.len() as u64 <= MAX_AGENT_ZERO_CAPABILITY_BYTES).then_some(contents) +} + +#[cfg(not(unix))] +fn read_regular_file_no_follow(_path: &Path) -> Option> { + None +} + +fn plugin_yaml_matches_capability(plugin_yaml: &str) -> bool { + let mut name = None; + let mut version = None; + for line in plugin_yaml.lines() { + if line.starts_with([' ', '\t']) { + continue; + } + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim().trim_matches(['\'', '"']); + match key.trim() { + "name" if name.replace(value).is_none() => {} + "version" if version.replace(value).is_none() => {} + "name" | "version" => return false, + _ => {} + } + } + name == Some(AGENT_ZERO_PLUGIN_ID) && version == Some(AGENT_ZERO_PLUGIN_VERSION) +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] pub struct IntegrationMarker { pub path: String, @@ -256,7 +408,10 @@ const ADAPTERS: [DeclarativeAdapter; 7] = [ display_name: "Agent Zero / A0", command: "agent-zero", capability: AdapterCapability::NativePreflight, - markers: &[".a0", "agent-zero", "a0"], + // Project `.a0`-style paths are not evidence that the separate + // tree_ring_memory plugin is installed or enabled. The plugin may + // prove its capability only through the explicit descriptor below. + markers: &[], support: AdapterSupport::AgentZero, }, DeclarativeAdapter { @@ -472,13 +627,27 @@ impl HarnessAdapter for DeclarativeAdapter { } fn plan(&self, _project: &ActivationProject, detection: &AdapterDetection) -> AdapterPlan { - let writes = match detection.state { - ActivationState::Unsupported | ActivationState::NeedsPlugin => Vec::new(), + // Agent Zero receives a passive, core-owned project binding even + // before its separate plugin proves availability. Keeping the + // persisted record at NeedsPlugin makes this creation-only bootstrap + // inert; a verified plugin descriptor is required later for + // configured status and receipt-producing preflight. + let state = if self.support == AdapterSupport::AgentZero { + ActivationState::NeedsPlugin + } else { + detection.state + }; + let writes = match state { + ActivationState::Unsupported | ActivationState::NeedsPlugin + if self.support != AdapterSupport::AgentZero => + { + Vec::new() + } _ => adapter_writes(self), }; AdapterPlan { harness_id: self.id.to_string(), - state: detection.state, + state, writes, next_step: next_step(self.support, detection.state).to_string(), } @@ -610,17 +779,21 @@ impl HarnessEnvironment for LocalHarnessEnvironment { } fn agent_zero_plugin_manifest(&self) -> Option { - // Core does not infer a plugin manifest from a generic project marker. - // The separate plugin supplies this capability through a future runtime - // environment; until then scan must remain conservatively needs-plugin. - None + agent_zero_plugin_manifest_for_project(&self.project_root) } } #[cfg(test)] mod tests { use super::*; - use std::collections::{BTreeMap, BTreeSet}; + use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + }; + use tempfile::tempdir; + + #[cfg(unix)] + use std::os::unix::fs::symlink; #[derive(Default)] struct FakeEnvironment { @@ -687,11 +860,18 @@ mod tests { } #[test] - fn missing_agent_zero_plugin_requires_the_separate_plugin_without_core_mutation() { - let detection = detect_adapters(&project(), &FakeEnvironment::default()); + fn missing_agent_zero_plugin_requires_the_separate_plugin_but_plans_a_passive_binding() { + let mut env = FakeEnvironment::default(); + env.paths.insert(PathBuf::from(".a0")); + let detection = detect_adapters(&project(), &env); let agent_zero = detection.by_id("agent-zero").unwrap(); assert_eq!(agent_zero.state, ActivationState::NeedsPlugin); - assert!(agent_zero.plan.writes.is_empty()); + assert!(agent_zero.markers.is_empty()); + assert_eq!(agent_zero.status, IntegrationStatus::Available); + assert_eq!( + agent_zero.plan.writes, + vec![bridge_write(".tree-ring/activation/agent-zero.json")] + ); } #[test] @@ -705,6 +885,7 @@ mod tests { .unwrap() .clone(); assert_eq!(agent_zero.state, ActivationState::ConfiguredAwaitingProof); + assert_eq!(agent_zero.plan.state, ActivationState::NeedsPlugin); assert_eq!( agent_zero.plan.writes, vec![bridge_write(".tree-ring/activation/agent-zero.json")] @@ -821,11 +1002,16 @@ mod tests { } #[test] - fn missing_agent_zero_plugin_blocks_deactivation_without_operations() { + fn missing_agent_zero_plugin_retains_ownership_for_passive_binding_deactivation() { let plan = plan_deactivation("agent-zero", &project()).unwrap(); assert_eq!(plan.state, ActivationState::NeedsPlugin); - assert!(plan.operations.is_empty()); + assert_eq!( + plan.operations, + vec![DeactivationOperation::BridgeWrite(BridgeWrite { + path: PathBuf::from(".tree-ring/activation/agent-zero.json"), + })] + ); } #[test] @@ -844,4 +1030,83 @@ mod tests { })] ); } + + #[test] + fn agent_zero_capability_descriptor_is_external_exact_and_no_follow() { + let temp = tempdir().unwrap(); + let project = temp.path().join("project"); + let plugin = temp.path().join("plugin"); + fs::create_dir_all(&project).unwrap(); + fs::create_dir_all(&plugin).unwrap(); + + let descriptor = write_capability_descriptor(&plugin, true); + assert_eq!( + read_agent_zero_plugin_manifest(&project, &descriptor), + Some(AgentZeroPluginManifest::compatible()) + ); + + fs::write(&descriptor, capability_document(false)).unwrap(); + assert!(read_agent_zero_plugin_manifest(&project, &descriptor).is_none()); + + fs::write( + &descriptor, + capability_document(true).replace("\"schema_version\":1", "\"schema_version\":2"), + ) + .unwrap(); + assert!(read_agent_zero_plugin_manifest(&project, &descriptor).is_none()); + + fs::write(&descriptor, capability_document(true)).unwrap(); + fs::write( + plugin.join("plugin.yaml"), + "name: tree_ring_memory\nversion: 3.0.1\n", + ) + .unwrap(); + assert!(read_agent_zero_plugin_manifest(&project, &descriptor).is_none()); + fs::write( + plugin.join("plugin.yaml"), + "name: tree_ring_memory\nversion: 3.1.0\n", + ) + .unwrap(); + assert!( + read_agent_zero_plugin_manifest(&project, Path::new(AGENT_ZERO_CAPABILITY_FILE)) + .is_none() + ); + + let inside_project = project.join("plugin"); + fs::create_dir_all(&inside_project).unwrap(); + let inside_descriptor = write_capability_descriptor(&inside_project, true); + assert!(read_agent_zero_plugin_manifest(&project, &inside_descriptor).is_none()); + + #[cfg(unix)] + { + fs::write(&descriptor, capability_document(true)).unwrap(); + let symlink_parent = temp.path().join("symlink-plugin"); + fs::create_dir_all(&symlink_parent).unwrap(); + fs::write( + symlink_parent.join("plugin.yaml"), + "name: tree_ring_memory\nversion: 3.1.0\n", + ) + .unwrap(); + let symlink_descriptor = symlink_parent.join(AGENT_ZERO_CAPABILITY_FILE); + symlink(&descriptor, &symlink_descriptor).unwrap(); + assert!(read_agent_zero_plugin_manifest(&project, &symlink_descriptor).is_none()); + } + } + + fn write_capability_descriptor(plugin: &Path, enabled: bool) -> PathBuf { + fs::write( + plugin.join("plugin.yaml"), + "name: tree_ring_memory\nversion: 3.1.0\n", + ) + .unwrap(); + let descriptor = plugin.join(AGENT_ZERO_CAPABILITY_FILE); + fs::write(&descriptor, capability_document(enabled)).unwrap(); + descriptor + } + + fn capability_document(enabled: bool) -> String { + format!( + r#"{{"schema_version":1,"kind":"tree-ring-agent-zero-plugin-capability","plugin_id":"tree_ring_memory","plugin_version":"3.1.0","activation_protocol_version":1,"tree_ring_version":{{"min":"0.14.0","minor":"0.14"}},"enabled":{enabled}}}"# + ) + } } diff --git a/crates/tree-ring-memory-cli/src/activation/bridge.rs b/crates/tree-ring-memory-cli/src/activation/bridge.rs index dffb7d9..7715a28 100644 --- a/crates/tree-ring-memory-cli/src/activation/bridge.rs +++ b/crates/tree-ring-memory-cli/src/activation/bridge.rs @@ -1226,10 +1226,7 @@ pub fn apply_bridge_plan( let _manifest_lock = project_fs.lock_manifest()?; let (current_manifest, expected_persisted) = reconcile_manifest(&project_fs, manifest)?; - if matches!( - plan.state, - ActivationState::NeedsPlugin | ActivationState::Unsupported - ) { + if is_non_writing_blocked_plan(&plan) { if !plan.writes.is_empty() { return Err(format!( "{} plan cannot write while in state {:?}", @@ -1344,10 +1341,7 @@ pub fn apply_bridge_plans_create_only( let mut outcomes = Vec::with_capacity(plans.len()); for plan in plans { - if matches!( - plan.state, - ActivationState::NeedsPlugin | ActivationState::Unsupported - ) { + if is_non_writing_blocked_plan(&plan) { if !plan.writes.is_empty() { return Err(format!( "{} plan cannot write while in state {:?}", @@ -1523,10 +1517,7 @@ pub fn preview_bridge_plan( validate_plan(&plan)?; let project_fs = ProjectFs::open(project)?; let (current_manifest, expected_persisted) = reconcile_manifest(&project_fs, manifest)?; - if matches!( - plan.state, - ActivationState::NeedsPlugin | ActivationState::Unsupported - ) { + if is_non_writing_blocked_plan(&plan) { if !plan.writes.is_empty() { return Err(format!( "{} plan cannot write while in state {:?}", @@ -1786,8 +1777,19 @@ fn prepare_apply( .map(|owned| owned.path.clone()) .or_else(|| managed_blocks.first().map(|owned| owned.path.clone())); let (adapter_capability, adapter_version) = adapter_contract(&plan.harness_id)?; + let state = if is_passive_agent_zero_plan(plan) { + // A previous compatible version may already have recorded the same + // binding as configured. Creation-only init must neither replace that + // manifest entry nor widen its state; live status overlays the + // currently verified plugin capability instead. + existing + .map(|activation| activation.state) + .unwrap_or(ActivationState::NeedsPlugin) + } else { + applied_state(&plan.harness_id, plan.state) + }; let mut activation = HarnessActivation { - state: applied_state(&plan.harness_id, plan.state), + state, adapter_capability, adapter_version: adapter_version.to_string(), bridge_fingerprint: String::new(), @@ -2062,7 +2064,9 @@ fn render_complete_file( } fn validate_plan(plan: &AdapterPlan) -> Result<(), String> { - let expected = if matches!( + let expected = if is_passive_agent_zero_plan(plan) { + vec![("file", ".tree-ring/activation/agent-zero.json", "")] + } else if matches!( plan.state, ActivationState::NeedsPlugin | ActivationState::Unsupported ) { @@ -2106,6 +2110,21 @@ fn validate_plan(plan: &AdapterPlan) -> Result<(), String> { Ok(()) } +/// Agent Zero's first-run binding is deliberately passive: it records only +/// Tree Ring's owned bridge and a NeedsPlugin state. The separate plugin must +/// later present its capability descriptor before this record is eligible for +/// preflight. No other blocked plan may write files. +fn is_passive_agent_zero_plan(plan: &AdapterPlan) -> bool { + plan.harness_id == "agent-zero" && plan.state == ActivationState::NeedsPlugin +} + +fn is_non_writing_blocked_plan(plan: &AdapterPlan) -> bool { + matches!( + plan.state, + ActivationState::NeedsPlugin | ActivationState::Unsupported + ) && !is_passive_agent_zero_plan(plan) +} + fn validate_project_shape(project: &ActivationProject) -> Result<(), String> { if project.memory_root != project.project_root.join(".tree-ring") { return Err( @@ -3331,30 +3350,26 @@ mod tests { } #[test] - fn agent_zero_requires_verified_plugin_plan_and_writes_only_protocol_binding() { + fn agent_zero_passive_binding_writes_only_the_protocol_binding() { let (_temp, project, mut manifest) = fixture(); - let blocked = plan("agent-zero", &project); + let passive = plan("agent-zero", &project); - let blocked_result = apply_bridge_plan(&project, &mut manifest, blocked, false).unwrap(); - assert_eq!(blocked_result.state, ActivationState::NeedsPlugin); - assert!(blocked_result.changed_paths.is_empty()); - let blocked_activation = manifest.harnesses.get("agent-zero").unwrap(); + let result = apply_bridge_plan(&project, &mut manifest, passive, false).unwrap(); + assert_eq!(result.state, ActivationState::NeedsPlugin); assert_eq!( - blocked_activation.adapter_capability, + result.changed_paths, + vec![PathBuf::from(".tree-ring/activation/agent-zero.json")] + ); + let activation = manifest.harnesses.get("agent-zero").unwrap(); + assert_eq!(activation.state, ActivationState::NeedsPlugin); + assert_eq!( + activation.adapter_capability, adapter_capability("agent-zero").unwrap() ); assert_eq!( - blocked_activation.adapter_version, + activation.adapter_version, adapter_version("agent-zero").unwrap() ); - assert!(!project - .project_root - .join(".tree-ring/activation/agent-zero.json") - .exists()); - - let (_temp, project, mut manifest) = fixture(); - apply_bridge_plan(&project, &mut manifest, verified_agent_zero_plan(), false).unwrap(); - let activation = manifest.harnesses.get("agent-zero").unwrap(); assert_eq!(activation.adapter_version, "1"); assert_eq!( activation.bridge_fingerprint, @@ -3585,8 +3600,7 @@ mod tests { } #[test] - fn agent_zero_missing_plugin_preserves_the_owned_binding_when_manifest_replacement_is_refused() - { + fn agent_zero_passive_plan_preserves_a_preexisting_configured_binding_without_replacement() { let (_temp, project, mut manifest) = fixture(); apply_bridge_plan(&project, &mut manifest, verified_agent_zero_plan(), false).unwrap(); let binding = project @@ -3598,9 +3612,13 @@ mod tests { apply_bridge_plan(&project, &mut manifest, plan("agent-zero", &project), false) .unwrap(); - assert_eq!(result.state, ActivationState::NeedsUserReview); + assert_eq!(result.state, ActivationState::NeedsPlugin); assert!(binding.exists()); assert_eq!(manifest.harnesses["agent-zero"].owned_files, ownership); + assert_eq!( + manifest.harnesses["agent-zero"].state, + ActivationState::ConfiguredAwaitingProof + ); assert_eq!( manifest.harnesses["agent-zero"].bridge_path.as_deref(), Some(".tree-ring/activation/agent-zero.json") diff --git a/crates/tree-ring-memory-cli/src/activation/preflight.rs b/crates/tree-ring-memory-cli/src/activation/preflight.rs index 6c3246f..4fdb6db 100644 --- a/crates/tree-ring-memory-cli/src/activation/preflight.rs +++ b/crates/tree-ring-memory-cli/src/activation/preflight.rs @@ -1,5 +1,8 @@ use super::{ - adapters::{adapter_capability, adapter_version, ActivationProject}, + adapters::{ + adapter_capability, adapter_version, agent_zero_plugin_manifest_for_project, + ActivationProject, + }, bridge::ProjectFs, manifest::{ bridge_fingerprint, fingerprint, fingerprint_path, @@ -329,7 +332,11 @@ fn prepare_preflight_contract( ) -> Result { let _manifest_lock = project_fs.lock_manifest().map_err(storage_error)?; let persisted = load_manifest_locked(project_fs).map_err(storage_error)?; - let contract = current_activation_contract(&persisted, &request.harness_id)?; + let contract = current_activation_contract( + &persisted, + &request.harness_id, + verified_agent_zero_plugin(project, request), + )?; invalidate_current_receipts(project_fs, &persisted, &request.harness_id, &contract)?; ensure_current_and_eligible(project_fs, &request.harness_id, &contract)?; let state = match preflight_state(project, &persisted) { @@ -369,7 +376,11 @@ fn commit_receipt( let _manifest_lock = project_fs.lock_manifest().map_err(storage_error)?; let persisted = load_manifest_locked(project_fs).map_err(storage_error)?; ensure_store_path_matches(store, project)?; - let contract = current_activation_contract(&persisted, &request.harness_id)?; + let contract = current_activation_contract( + &persisted, + &request.harness_id, + verified_agent_zero_plugin(project, request), + )?; invalidate_current_receipts(project_fs, &persisted, &request.harness_id, &contract)?; ensure_current_and_eligible(project_fs, &request.harness_id, &contract)?; let state = match preflight_state(project, &persisted) { @@ -407,6 +418,7 @@ fn commit_receipt( fn current_activation_contract( manifest: &ActivationManifest, harness_id: &str, + verified_agent_zero_plugin: bool, ) -> Result { let activation = manifest .harnesses @@ -425,19 +437,46 @@ fn current_activation_contract( manifest_matches_registry: activation.adapter_capability == adapter_capability && activation.adapter_version == adapter_version && activation.bridge_fingerprint == bridge_fingerprint, - eligible_for_preflight: matches!( - activation.state, - ActivationState::ConfiguredAwaitingProof - | ActivationState::NeedsTrust - | ActivationState::Active - | ActivationState::ActiveIsolated - ), + eligible_for_preflight: if harness_id == "agent-zero" { + // Agent Zero cannot inherit eligibility from a historical + // configured record. Its separately installed plugin must be + // presently verifiable, and only the passive core-owned binding + // may cross this bootstrap boundary. + verified_agent_zero_plugin && passive_agent_zero_activation(harness_id, activation) + } else { + matches!( + activation.state, + ActivationState::ConfiguredAwaitingProof + | ActivationState::NeedsTrust + | ActivationState::Active + | ActivationState::ActiveIsolated + ) + }, adapter_capability, adapter_version, bridge_fingerprint, }) } +fn verified_agent_zero_plugin(project: &ActivationProject, request: &PreflightRequest) -> bool { + request.harness_id == "agent-zero" + && agent_zero_plugin_manifest_for_project(&project.project_root).is_some() +} + +fn passive_agent_zero_activation( + harness_id: &str, + activation: &crate::activation::manifest::HarnessActivation, +) -> bool { + harness_id == "agent-zero" + && activation.state == ActivationState::NeedsPlugin + && activation.bridge_path.as_deref() == Some(".tree-ring/activation/agent-zero.json") + && activation.managed_blocks.is_empty() + && matches!( + activation.owned_files.as_slice(), + [owned] if owned.path == ".tree-ring/activation/agent-zero.json" + ) +} + fn invalidate_current_receipts( project_fs: &ProjectFs, manifest: &ActivationManifest, @@ -1369,7 +1408,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let mounted_root = temp.path().join("mounted-project"); fs::create_dir_all(&mounted_root).unwrap(); - let isolated_project_root = temp.path().join("agent-zero-store"); + let isolated_project_root = temp.path().join("pi-store"); let memory_root = isolated_project_root.join(".tree-ring"); fs::create_dir_all(&memory_root).unwrap(); let project = ActivationProject { @@ -1377,44 +1416,44 @@ mod tests { memory_root: memory_root.clone(), }; let mut activation = HarnessActivation { - state: ActivationState::ConfiguredAwaitingProof, + state: ActivationState::NeedsTrust, adapter_capability: AdapterCapability::NativePreflight, adapter_version: "1".to_string(), bridge_fingerprint: String::new(), - bridge_path: Some(".tree-ring/activation/agent-zero.json".to_string()), + bridge_path: Some(".pi/extensions/tree-ring-memory.ts".to_string()), owned_files: vec![OwnedBridgeFile { - path: ".tree-ring/activation/agent-zero.json".to_string(), + path: ".pi/extensions/tree-ring-memory.ts".to_string(), sha256: "e".repeat(64), }], managed_blocks: Vec::new(), }; - activation.bridge_fingerprint = bridge_fingerprint("agent-zero", &activation); + activation.bridge_fingerprint = bridge_fingerprint("pi", &activation); let manifest = ActivationManifest { schema_version: ACTIVATION_SCHEMA_VERSION, protocol_version: ACTIVATION_PROTOCOL_VERSION, store_id: "isolated-store".to_string(), project_root_fingerprint: project_fingerprint(&isolated_project_root), cli_version: env!("CARGO_PKG_VERSION").to_string(), - harnesses: BTreeMap::from([("agent-zero".to_string(), activation)]), + harnesses: BTreeMap::from([("pi".to_string(), activation)]), }; save_manifest(&memory_root, &manifest).unwrap(); let mut store = SQLiteMemoryStore::open(memory_root.join("memory.sqlite")).unwrap(); let mut local = MemoryEvent::new("isolated local startup constraint", "lesson").unwrap(); - local.project = Some("agent-zero-store".to_string()); - local.agent_profile = Some("agent-zero".to_string()); + local.project = Some("pi-store".to_string()); + local.agent_profile = Some("pi".to_string()); local.workflow_id = Some("workflow-1".to_string()); local.session_id = Some("session-1".to_string()); local.scope = "agent".to_string(); store.put(&local).unwrap(); let request = PreflightRequest::adapter_stdin( - "agent-zero", + "pi", SessionIdentity { - agent_profile: "agent-zero".to_string(), + agent_profile: "pi".to_string(), workflow_id: "workflow-1".to_string(), session_id: "session-1".to_string(), }, None, - PreflightContextFormat::Json, + PreflightContextFormat::PiBeforeAgentStart, ); let response = run_preflight(&store, &project, &manifest, request).unwrap(); diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs index 0ec1dd2..914e159 100644 --- a/crates/tree-ring-memory-cli/src/main.rs +++ b/crates/tree-ring-memory-cli/src/main.rs @@ -531,7 +531,7 @@ fn run_launch_command(cli: &Cli) -> Option> { } let project = activation::adapters::ActivationProject::from_memory_root(cli.root.clone())?; let manifest = activation::load_manifest(&cli.root)?; - ensure_manifest_preflight_ready(&manifest, harness)?; + ensure_manifest_preflight_ready(&manifest, harness, false)?; let store = SQLiteMemoryStore::open_read_only(cli.root.join("memory.sqlite")) .map_err(|error| error.to_string())?; activation::launcher::launch_with_preflight( @@ -761,7 +761,10 @@ fn run(cli: Cli) -> Result<(), String> { } else { activation::load_manifest(&cli.root)? }; - ensure_manifest_preflight_ready(&manifest, harness)?; + let verified_agent_zero_plugin = harness == "agent-zero" + && activation::adapters::agent_zero_plugin_manifest_for_project(&project.project_root) + .is_some(); + ensure_manifest_preflight_ready(&manifest, harness, verified_agent_zero_plugin)?; let store = SQLiteMemoryStore::open_read_only(&db_path).map_err(|error| { if canonical_project_root.is_some() { "isolated preflight storage unavailable".to_string() @@ -1235,13 +1238,24 @@ fn run_init(root: &Path, dry_run: bool, json_output: bool) -> Result<(), String> let scan = integration_scan_action(IntegrationScanRequest { source_root: project.project_root.clone(), }); - let candidates = scan + let mut candidates = scan .report .integrations .iter() .filter(|integration| integration.is_candidate()) .cloned() .collect::>(); + // Agent Zero never trusts a generic project marker, but init must still + // publish the passive core-owned binding so its separately installed + // plugin can later prove capability and complete preflight. + if !candidates + .iter() + .any(|candidate| candidate.id == "agent-zero") + { + if let Some(agent_zero) = scan.report.by_id("agent-zero").cloned() { + candidates.push(agent_zero); + } + } if dry_run { let reports = candidates @@ -1249,7 +1263,7 @@ fn run_init(root: &Path, dry_run: bool, json_output: bool) -> Result<(), String> .map( |detection| actions::integrations::IntegrationLifecycleActionReport { harness_id: detection.id, - state: detection.plan.state, + state: detection.state, changed_paths: detection .plan .writes @@ -1322,9 +1336,11 @@ fn run_init(root: &Path, dry_run: bool, json_output: bool) -> Result<(), String> } } status.integrations.retain(|entry| { - scan.report - .by_id(&entry.id) - .is_some_and(|item| item.is_candidate()) + entry.id == "agent-zero" + || scan + .report + .by_id(&entry.id) + .is_some_and(|item| item.is_candidate()) }); if json_output { @@ -1373,17 +1389,24 @@ fn activation_context_format( fn ensure_manifest_preflight_ready( manifest: &activation::ActivationManifest, harness_id: &str, + verified_agent_zero_plugin: bool, ) -> Result<(), String> { let activation = manifest.harnesses.get(harness_id).ok_or_else(|| { "harness has no activation record; run `tree-ring init` first".to_string() })?; - if !matches!( + let normally_eligible = matches!( activation.state, activation::ActivationState::ConfiguredAwaitingProof | activation::ActivationState::NeedsTrust | activation::ActivationState::Active | activation::ActivationState::ActiveIsolated - ) { + ); + let eligible = if harness_id == "agent-zero" { + verified_agent_zero_plugin && activation.state == activation::ActivationState::NeedsPlugin + } else { + normally_eligible + }; + if !eligible { return Err("harness activation state is not eligible for preflight".to_string()); } Ok(()) diff --git a/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs b/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs index fa3d8eb..c1e572f 100644 --- a/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs +++ b/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs @@ -235,6 +235,23 @@ fn init_preflight_status_and_certify_prove_same_store_workers_without_private_re assert_manifest_contracts(&root); let before_proof_status = integration_status(&root, &project, &empty_path); assert_status_state(&before_proof_status, "agent-zero", "needs-plugin"); + let no_plugin_agent_zero = json!({ + "agent_profile": "agent-zero-worker", + "workflow_id": WORKFLOW_ID, + "session_id": "agent-zero-session" + }); + let no_plugin_preflight = adapter_preflight_output( + &root, + &project, + &empty_path, + "agent-zero", + "json", + &no_plugin_agent_zero, + ); + assert!(!no_plugin_preflight.status.success()); + assert!(String::from_utf8_lossy(&no_plugin_preflight.stderr) + .contains("harness activation state is not eligible")); + assert!(receipt_documents(&root).is_empty()); seed_for_identity( &root, @@ -415,6 +432,82 @@ fn init_preflight_status_and_certify_prove_same_store_workers_without_private_re assert_eq!(agent_zero["activation"]["state"], "needs-plugin"); } +#[test] +fn agent_zero_plugin_descriptor_bootstraps_passive_binding_then_receipt_backed_active_status() { + let temp = tempdir().unwrap(); + let project = temp.path().join("agent-zero-project"); + let empty_path = temp.path().join("empty-path"); + fs::create_dir_all(&project).unwrap(); + fs::create_dir_all(&empty_path).unwrap(); + let descriptor = install_agent_zero_plugin(temp.path()); + let root = project.join(".tree-ring"); + + let init = tree_ring(&root, &project, &empty_path) + .env("TREE_RING_AGENT_ZERO_PLUGIN_MANIFEST", &descriptor) + .arg("init") + .output() + .unwrap(); + assert_success("Agent Zero descriptor init", &init); + let init = output_json("Agent Zero descriptor init", &init); + assert_eq!( + record_by_id(&init["integrations"], "agent-zero")["state"], + "configured-awaiting-proof" + ); + + let activation = &manifest(&root)["harnesses"]["agent-zero"]; + assert_eq!(activation["state"], "needs-plugin"); + assert_eq!( + activation["bridge_path"], + ".tree-ring/activation/agent-zero.json" + ); + assert!(root.join("activation/agent-zero.json").is_file()); + + // Re-running init is a no-replacement operation over the owned passive + // binding, so an already initialized project remains usable. + let repeat_init = tree_ring(&root, &project, &empty_path) + .env("TREE_RING_AGENT_ZERO_PLUGIN_MANIFEST", &descriptor) + .arg("init") + .output() + .unwrap(); + assert_success("repeat Agent Zero descriptor init", &repeat_init); + assert_eq!( + record_by_id( + &output_json("repeat Agent Zero descriptor init", &repeat_init)["integrations"], + "agent-zero" + )["state"], + "configured-awaiting-proof" + ); + + let configured = + integration_status_with_agent_zero_plugin(&root, &project, &empty_path, &descriptor); + assert_status_state(&configured, "agent-zero", "configured-awaiting-proof"); + + let input = json!({ + "agent_profile": "agent-zero-worker", + "workflow_id": "agent-zero-flow", + "session_id": "agent-zero-session" + }); + let preflight = + agent_zero_preflight_with_plugin(&root, &project, &empty_path, &descriptor, &input); + assert_eq!(preflight["state"], "active"); + + let active = + integration_status_with_agent_zero_plugin(&root, &project, &empty_path, &descriptor); + assert_status_state(&active, "agent-zero", "active"); + + // A receipt cannot keep Agent Zero active after the separately installed + // plugin is disabled: status must return to the passive needs-plugin + // state until a live compatible descriptor is available again. + fs::write( + &descriptor, + r#"{"schema_version":1,"kind":"tree-ring-agent-zero-plugin-capability","plugin_id":"tree_ring_memory","plugin_version":"3.1.0","activation_protocol_version":1,"tree_ring_version":{"min":"0.14.0","minor":"0.14"},"enabled":false}"#, + ) + .unwrap(); + let disabled = + integration_status_with_agent_zero_plugin(&root, &project, &empty_path, &descriptor); + assert_status_state(&disabled, "agent-zero", "needs-plugin"); +} + #[test] fn alternate_memory_root_is_active_isolated_without_copying_or_mutating_canonical_store() { let temp = tempdir().unwrap(); @@ -588,9 +681,6 @@ fn install_fixture_markers(project: &Path) { fn assert_initial_states(integrations: &Value) { for fixture in fixtures().into_values() { let id = fixture["harness_id"].as_str().unwrap(); - if fixture["marker_paths"].as_array().unwrap().is_empty() { - continue; - } let integration = record_by_id(integrations, id); assert_eq!( integration["state"], fixture["expected_activation_state_before_proof"], @@ -603,14 +693,9 @@ fn assert_manifest_contracts(root: &Path) { let manifest = manifest(root); for fixture in fixtures().into_values() { let id = fixture["harness_id"].as_str().unwrap(); - let Some(activation) = manifest["harnesses"].get(id) else { - assert_eq!(id, "agent-zero", "missing manifest contract for {id}"); - assert_eq!( - fixture["expected_activation_state_before_proof"], - "needs-plugin" - ); - continue; - }; + let activation = manifest["harnesses"] + .get(id) + .unwrap_or_else(|| panic!("missing manifest contract for {id}")); assert_eq!( activation["adapter_version"], fixture["adapter_version"], "{id}" @@ -777,6 +862,74 @@ fn integration_status(root: &Path, project: &Path, path: &Path) -> Value { output_json("integration status", &output) } +fn integration_status_with_agent_zero_plugin( + root: &Path, + project: &Path, + path: &Path, + descriptor: &Path, +) -> Value { + let output = tree_ring(root, project, path) + .env("TREE_RING_AGENT_ZERO_PLUGIN_MANIFEST", descriptor) + .arg("integrations") + .arg("status") + .arg("--source-root") + .arg(project) + .arg("--verbose") + .output() + .unwrap(); + assert_success("Agent Zero descriptor status", &output); + output_json("Agent Zero descriptor status", &output) +} + +fn agent_zero_preflight_with_plugin( + root: &Path, + project: &Path, + path: &Path, + descriptor: &Path, + input: &Value, +) -> Value { + let mut command = tree_ring(root, project, path); + command + .env("TREE_RING_AGENT_ZERO_PLUGIN_MANIFEST", descriptor) + .arg("integrations") + .arg("preflight") + .arg("--harness") + .arg("agent-zero") + .arg("--input-json-stdin") + .arg("--context-format") + .arg("json") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(serde_json::to_string(input).unwrap().as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert_success("Agent Zero descriptor preflight", &output); + output_json("Agent Zero descriptor preflight", &output) +} + +fn install_agent_zero_plugin(base: &Path) -> PathBuf { + let plugin = base.join("installed-agent-zero-plugin"); + fs::create_dir_all(&plugin).unwrap(); + fs::write( + plugin.join("plugin.yaml"), + "name: tree_ring_memory\nversion: 3.1.0\n", + ) + .unwrap(); + let descriptor = plugin.join("activation-capability.json"); + fs::write( + &descriptor, + r#"{"schema_version":1,"kind":"tree-ring-agent-zero-plugin-capability","plugin_id":"tree_ring_memory","plugin_version":"3.1.0","activation_protocol_version":1,"tree_ring_version":{"min":"0.14.0","minor":"0.14"},"enabled":true}"#, + ) + .unwrap(); + descriptor +} + fn assert_status_state(status: &Value, harness: &str, expected: &str) { let entry = record_by_id(&status["integrations"], harness); assert_eq!(entry["state"], expected, "{harness}: {entry}"); From 4d5cdc04bdd3ae3b2537f0e28befb60d482e8e38 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 03:33:56 -0400 Subject: [PATCH 29/31] docs: explain Agent Zero passive activation --- README.md | 32 ++++++++++ docs/integrations/agent-skill.md | 18 ++++-- docs/protocol/harness-activation.md | 99 +++++++++++++++++++++++++---- skills/tree-ring-memory/SKILL.md | 19 ++++-- 4 files changed, 146 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 0d3dc76..e704ae2 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,29 @@ same-host local filesystem with matching project-store identity. It does not claim safe SQLite sharing across hosts, NFS/network filesystems, or containers on different hosts. +### Agent Zero uses its separate plugin + +Agent Zero is intentionally a two-part setup. `tree-ring init` creates the +project-local, passive Agent Zero binding and records `needs-plugin`; that +proves only that Tree Ring owns the project-side configuration. It does not +claim that Agent Zero is installed, that the project is mounted there, or that +an agent has used memory. + +For a project that will use Agent Zero, install or update the separate +`tree_ring_memory` plugin, then configure it for the same mounted project root. +That is the only extra user setup. The plugin owns its fixed, +absolute `activation-capability.json` descriptor outside the project and passes +it to Tree Ring internally. Do not copy that file into the repository, create a +generic `.a0` marker, or try to invoke a descriptor command yourself. + +The plugin's descriptor-scoped status check can report +`configured-awaiting-proof`; a new Agent Zero session's descriptor-scoped +preflight writes the receipt required for `active`. A normal host-shell status +without the installed plugin descriptor intentionally remains `needs-plugin`. +The matching Tree Ring core and Agent Zero plugin release must both be +installed; a source checkout, a passive binding, or an older bundled CLI is not +an installed-capability claim. + Source install (requires Rust and Cargo): ```bash @@ -707,6 +730,15 @@ already published on disk is left intact. A bridge is not proof of runtime use. action, such as Pi trust, an Agent Zero plugin, a missing mount, or unmanaged-file review. Do not treat Hermes or another unverified runtime as active. +For Agent Zero, `init` also creates only Tree Ring's passive +`needs-plugin` binding. The separate `tree_ring_memory` plugin owns its +absolute, non-project `activation-capability.json` descriptor and uses it +internally after the user selects the mounted project. Only its +descriptor-scoped status and a new-session preflight can advance the runtime +view to `configured-awaiting-proof` and then receipt-backed `active`. There is +no manual descriptor workflow, and the core/plugin release pair must be +compatible before this path is available. + Memory updates are agent-mediated. Bridge files tell the active agent when to call `tree-ring recall`, `tree-ring remember`, `tree-ring evidence`, `tree-ring forget`, `tree-ring consolidate --dry-run`, or `tree-ring maintain`. diff --git a/docs/integrations/agent-skill.md b/docs/integrations/agent-skill.md index 4d01416..c25df83 100644 --- a/docs/integrations/agent-skill.md +++ b/docs/integrations/agent-skill.md @@ -393,10 +393,20 @@ a marker-only result cannot pass. Pi trust is the user's platform decision: report `needs-trust` rather than changing global trust. Agent Zero uses only its separate `tree_ring_memory` -plugin: a missing plugin is `needs-plugin`, an inaccessible canonical root is -`needs-project-mount`, and a different store is `active-isolated`. Tree Ring -does not modify Agent Zero core or create another store to imply sharing. Hermes -and any unverified runtime remain non-active. +plugin. `tree-ring init` creation-publishes Tree Ring's passive Agent Zero +binding with `needs-plugin`; that is configuration, not a claim that the plugin +or project mount is available. After the user installs/enables the matching +plugin release and selects the mounted project, the plugin owns an absolute, +non-project `activation-capability.json` descriptor and passes it internally. +Only its descriptor-scoped status and new-session preflight can report +`configured-awaiting-proof` and then receipt-backed `active`. + +Do not create a generic `.a0` marker, copy the descriptor into the project, or +try to invoke a descriptor command. Tree Ring does not modify Agent Zero core or +create another store to imply sharing. A missing, invalid, disabled, or +release-incompatible plugin descriptor remains `needs-plugin`; an inaccessible +canonical root is `needs-project-mount`, and a different store is +`active-isolated`. Hermes and any unverified runtime remain non-active. Receipts are privacy-redacted operational evidence, not raw prompts, recalled content, secrets, sensitive values, paths, or coordinator capabilities. They diff --git a/docs/protocol/harness-activation.md b/docs/protocol/harness-activation.md index 3f37c6b..6db26ca 100644 --- a/docs/protocol/harness-activation.md +++ b/docs/protocol/harness-activation.md @@ -10,11 +10,14 @@ tree-ring integrations status `init` creates the canonical `.tree-ring/` root, discovers maintained adapters, and attempts create-only publication of project-local owned bridges or bounded -managed blocks plus the activation manifest. It does not write global settings, -replace or remove an existing final entry, or require users to copy a bridge or -run `integrations link`. An existing bridge or manifest that would need mutation -is preserved and reported as `needs-user-review`. `integrations link` is an -advanced alias for controlled bridge work, not the default journey. +managed blocks plus the activation manifest. For Agent Zero it also +creation-publishes Tree Ring's passive +`.tree-ring/activation/agent-zero.json` binding and a `needs-plugin` manifest +record. It does not write Agent Zero core configuration, replace or remove an +existing final entry, or require users to copy a bridge or run `integrations +link`. An existing bridge or manifest that would need mutation is preserved and +reported as `needs-user-review`. `integrations link` is an advanced alias for +controlled bridge work, not the default journey. Advanced commands are `tree-ring integrations status --verbose`, `tree-ring integrations activate --harness --dry-run`, @@ -31,7 +34,7 @@ required for initialization. | `active-isolated` | Preflight succeeded against a store that does not match this project's canonical store. | | `needs-trust` | The runtime needs its own user approval before project resources load. | | `needs-project-mount` | The runtime cannot reach the canonical project root. | -| `needs-plugin` | Agent Zero needs its separate `tree_ring_memory` plugin installed or enabled. | +| `needs-plugin` | The passive Agent Zero binding exists, but its separate compatible `tree_ring_memory` plugin is absent, disabled, invalid, or not available to this command. | | `needs-user-review` | An existing or changed bridge/manifest cannot be safely replaced or removed, or publication durability is indeterminate. | | `unsupported` | No maintained adapter can prove the integration. | | `failed` | Detection, installation, preflight, or receipt verification has a concrete diagnostic. | @@ -72,6 +75,40 @@ changed harness `needs-user-review` in the returned in-memory manifest, and leaves any manifest that already reached disk intact. Disk and memory can therefore differ until the user reviews and reconciles the indeterminate state. +## Agent Zero passive binding and internal capability descriptor + +The Agent Zero adapter is deliberately passive at project initialization. The +project only receives Tree Ring-owned binding material and its `needs-plugin` +state; it does not receive plugin code, an Agent Zero core change, a generic +`.a0` marker, or a capability descriptor. This keeps a repository portable and +prevents a copied marker or configuration file from impersonating an installed +adapter. + +The separately installed `tree_ring_memory` plugin owns one fixed +`activation-capability.json` descriptor at its own plugin root. Its path is +absolute, outside the project, and must be a regular non-symlink file. The +plugin enforces that fixed safe location and passes the descriptor only through +its private child-process transport +(`TREE_RING_AGENT_ZERO_PLUGIN_MANIFEST`) for its internal core `init`, status, +and preflight calls from the configured mounted project. It strips an inherited +descriptor value from every other child process. It is not a user setting, +project artifact, API/UI payload, receipt field, or model input; users must not +copy, create, edit, or manually pass a descriptor. + +Core validates the descriptor's exact schema and the sibling plugin manifest's +name and version before it uses it. A descriptor-scoped `init` may establish the +passive binding but never turns it into proof or changes its persisted manifest +record from `needs-plugin`. Only descriptor-scoped runtime status can derive +`configured-awaiting-proof`, and only a descriptor-scoped new-session preflight +with a matching receipt can report `active`. Ordinary host-shell status without +that internal descriptor remains `needs-plugin`. There is no generic manual +descriptor command. + +This is also a release boundary: the descriptor's declared Tree Ring series, +the installed core CLI, and the installed plugin release must be compatible. +An unreleased source checkout, a stale bundled CLI, or a passive binding does +not make Agent Zero capable. + ## Canonical wire shapes The following JSON shapes are the version-1 interoperability contract. All @@ -137,6 +174,34 @@ deserializes as `""`, and the empty value still contributes a zero-length field to the fingerprint. For example, the manifest above contributes one LF byte for `leading_separator`, while an omitted field contributes no value bytes. +### Agent Zero plugin capability descriptor + +The following is the first compatible plugin descriptor. It is shown only to +define the cross-repository protocol; it remains plugin-owned and internal, not +a project file or a user-authored command input. + +```json +{ + "schema_version": 1, + "kind": "tree-ring-agent-zero-plugin-capability", + "plugin_id": "tree_ring_memory", + "plugin_version": "3.1.0", + "activation_protocol_version": 1, + "tree_ring_version": { + "min": "0.14.0", + "minor": "0.14" + }, + "enabled": true +} +``` + +`plugin_version` must exactly agree with the installed descriptor's sibling +`plugin.yaml`; the example's `3.1.0` is the first compatible plugin release. +The plugin enforces the descriptor's fixed safe external location; core +verifies every listed field and that sibling manifest identity before accepting +it. The descriptor never appears in a project manifest, Agent Zero tool input, +Web UI/API response, or receipt. + ### Redacted receipt ```json @@ -244,8 +309,10 @@ server and sends only this request to the project-local command: ``` Agent Zero does not send a task hint, prompt, coordinator capability, token, or -model-supplied identity. The plugin reads the relative project binding and -derives the three identity fields before invoking the command. +model-supplied identity. After descriptor-scoped status validates its internal +capability descriptor, the plugin reads the passive relative project binding and +derives the three identity fields before invoking preflight. The descriptor is +not JSON stdin and cannot be supplied by the model or a user request. ### Input validation and handling @@ -256,8 +323,11 @@ capability-bearing fields (including `capability`, `token`, `TREE_RING_COORDINATOR_TOKEN`) are rejected rather than ignored. Inputs that claim a store, root, bridge fingerprint, harness identity, or receipt state are also rejected; those values come only from the local manifest and adapter. -Raw stdin and SessionStart input are transient: Tree Ring does not persist or -log them, regardless of whether validation succeeds. +The Agent Zero descriptor is separate from stdin: it is accepted only through +the installed plugin's internal absolute-path transport, never through a +generic CLI option or an input field. Raw stdin and SessionStart input are +transient: Tree Ring does not persist or log them, regardless of whether +validation succeeds. ### Pi and Agent Zero JSON preflight responses @@ -352,9 +422,12 @@ matching receipt for another accessible store is `active-isolated`, never ## Runtime and shared-store boundaries Pi project trust is a user decision: report `needs-trust` and leave global -trust unchanged. Agent Zero uses only the separate `tree_ring_memory` plugin; -Tree Ring does not modify Agent Zero core, and a generic `.a0` marker is not an -adapter. An absent plugin is `needs-plugin`; an inaccessible project root is +trust unchanged. Agent Zero uses only the separate `tree_ring_memory` plugin. +`init` leaves a passive project binding at `needs-plugin`; the plugin's fixed +absolute, non-project descriptor is the only way its runtime status/preflight +can use that binding. Tree Ring does not modify Agent Zero core, and a generic +`.a0` marker is not an adapter. A missing, invalid, disabled, or release- +incompatible descriptor remains `needs-plugin`; an inaccessible project root is `needs-project-mount`; a different accessible store is `active-isolated`. Shared status requires every receipt to match the canonical project `store_id`. diff --git a/skills/tree-ring-memory/SKILL.md b/skills/tree-ring-memory/SKILL.md index 9865ed0..f21eaaa 100644 --- a/skills/tree-ring-memory/SKILL.md +++ b/skills/tree-ring-memory/SKILL.md @@ -123,11 +123,20 @@ published path. Preserve disk material, keep changed harnesses marked manifest already published on disk intact for explicit reconciliation. Pi trust is the user's decision: report `needs-trust` rather than changing -global trust. Agent Zero is separate: the existing `tree_ring_memory` plugin -must be installed/enabled and able to mount the canonical `.tree-ring` root. -Never treat a generic marker as its adapter, modify Agent Zero core, or call a -different plugin store shared. A different reachable store is -`active-isolated`; an unavailable root is `needs-project-mount`. +global trust. Agent Zero is separate: `tree-ring init` writes only Tree Ring's +passive Agent Zero binding with `needs-plugin`. The user installs/enables the +compatible `tree_ring_memory` plugin and selects the mounted project; the plugin +then owns its absolute, non-project `activation-capability.json` descriptor and +passes it internally. Only descriptor-scoped plugin status can derive +`configured-awaiting-proof`, and only its new-session preflight receipt can +make the runtime `active`. + +Never create a generic marker, copy or hand-author that descriptor, set its +internal transport, modify Agent Zero core, or call a different plugin store +shared. A missing, invalid, disabled, or release-incompatible descriptor stays +`needs-plugin`; a different reachable store is `active-isolated`; an +unavailable root is `needs-project-mount`. A passive binding, source checkout, +or stale bundled CLI is not installed capability. Receipts prove a privacy-safe preflight check, not durable memory creation or a security boundary. They exclude raw prompts, recalled content, secrets, From 05e80ff87866d388f9508dbe23276a778808ab13 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 03:43:40 -0400 Subject: [PATCH 30/31] docs: clarify Agent Zero preflight eligibility --- docs/protocol/harness-activation.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/protocol/harness-activation.md b/docs/protocol/harness-activation.md index 6db26ca..e5c8ab3 100644 --- a/docs/protocol/harness-activation.md +++ b/docs/protocol/harness-activation.md @@ -100,8 +100,11 @@ name and version before it uses it. A descriptor-scoped `init` may establish the passive binding but never turns it into proof or changes its persisted manifest record from `needs-plugin`. Only descriptor-scoped runtime status can derive `configured-awaiting-proof`, and only a descriptor-scoped new-session preflight -with a matching receipt can report `active`. Ordinary host-shell status without -that internal descriptor remains `needs-plugin`. There is no generic manual +with a matching receipt can report `active`. That preflight requires both the +live validated descriptor and the exact passive core-owned `needs-plugin` +binding record. Legacy or non-passive Agent Zero records are deliberately not +auto-migrated and do not qualify. Ordinary host-shell status without that +internal descriptor remains `needs-plugin`. There is no generic manual descriptor command. This is also a release boundary: the descriptor's declared Tree Ring series, From 1520d4b46215333c328d34124b569f5ebca6b597 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 14 Aug 2026 04:04:43 -0400 Subject: [PATCH 31/31] ci: publish verified release archives --- .github/workflows/release.yml | 74 +++++++++++++++++++++++++++++++++++ README.md | 12 +++--- scripts/package-release.sh | 27 +++++++++++++ 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31aaa42..cbb06da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,9 @@ on: required: false type: string +permissions: + contents: read + jobs: package-linux: name: Package ubuntu-latest @@ -28,6 +31,7 @@ jobs: - name: Build package on Debian Bookworm env: RUST_BOOKWORM_IMAGE: docker.io/library/rust:1.89-bookworm@sha256:c9ac3fa8945b61dede1e4500d25028aa8fd8a8fe46365fcf9c0422f8d999b9b0 + TREE_RING_RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} run: | docker run --rm \ --platform linux/amd64 \ @@ -35,6 +39,7 @@ jobs: -e HOME=/tmp \ -e CARGO_HOME=/tmp/cargo \ -e RUSTUP_HOME=/usr/local/rustup \ + -e TREE_RING_RELEASE_TAG \ -v "$GITHUB_WORKSPACE:/workspace" \ -w /workspace/source \ "$RUST_BOOKWORM_IMAGE" \ @@ -84,8 +89,20 @@ jobs: - name: Build package working-directory: source + env: + TREE_RING_RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} run: sh ../scripts/package-release.sh + - name: Smoke-test package + working-directory: source + run: | + macos_binary=$(find dist -path '*-darwin-arm64/tree-ring' -type f -print) + test -n "$macos_binary" + test "$(printf '%s\n' "$macos_binary" | wc -l)" -eq 1 + test "$(uname -m)" = "arm64" + version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1) + test "$("$macos_binary" --version)" = "tree-ring $version" + - name: Upload artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -93,3 +110,60 @@ jobs: path: | source/dist/*.tar.gz source/dist/*.sha256 + + publish-release: + name: Publish GitHub release assets + needs: [package-linux, package-macos] + if: github.event_name == 'push' && github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download release assets + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + pattern: tree-ring-memory-* + merge-multiple: true + path: release-assets + + - name: Verify release assets + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + version=${RELEASE_TAG#v} + test -n "$version" + test "$RELEASE_TAG" = "v$version" + for platform in darwin-arm64 linux-x86_64; do + archive="release-assets/tree-ring-memory-$version-$platform.tar.gz" + checksum="$archive.sha256" + test -f "$archive" + test -f "$checksum" + expected=$(awk '{print $1}' "$checksum") + actual=$(shasum -a 256 "$archive" | awk '{print $1}') + test "$actual" = "$expected" + for packaged_path in \ + "tree-ring-memory-$version-$platform/" \ + "tree-ring-memory-$version-$platform/tree-ring" \ + "tree-ring-memory-$version-$platform/README.md" \ + "tree-ring-memory-$version-$platform/LICENSE" \ + "tree-ring-memory-$version-$platform/install.sh"; do + tar -tzf "$archive" | grep -F -x "$packaged_path" > /dev/null + done + entry_count=$(tar -tzf "$archive" | wc -l | tr -d '[:space:]') + test "$entry_count" = "5" + done + + - name: Publish release assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then + gh release upload "$RELEASE_TAG" release-assets/*.tar.gz release-assets/*.sha256 \ + --clobber \ + --repo "$GITHUB_REPOSITORY" + else + gh release create "$RELEASE_TAG" release-assets/*.tar.gz release-assets/*.sha256 \ + --generate-notes \ + --repo "$GITHUB_REPOSITORY" + fi diff --git a/README.md b/README.md index e704ae2..7979d27 100644 --- a/README.md +++ b/README.md @@ -688,11 +688,13 @@ See [agent workflow proof](docs/integrations/agent-workflow-proof.md) for the controlled command, retained artifacts, exact structured-outcome checks, and interpretation limits. -`scripts/package-release.sh` builds the Rust CLI in release mode, creates a -platform tarball under `dist/`, and writes a SHA-256 checksum file. Tag pushes -run the release artifact workflow for Linux and macOS. Linux x86_64 artifacts -are built and executed on Debian Bookworm (glibc 2.36); older glibc systems -must build from source. +`scripts/package-release.sh` builds the Rust CLI in release mode, verifies its +version and archive layout, then creates a platform tarball under `dist/` with +a SHA-256 checksum file. A matching `v` tag builds the Linux x86_64 +and macOS ARM64 archives, verifies both, and attaches them to that GitHub +Release; a manually dispatched run remains artifact-only. Linux x86_64 +artifacts are built and executed on Debian Bookworm (glibc 2.36); older glibc +systems must build from source. ## Design Docs diff --git a/scripts/package-release.sh b/scripts/package-release.sh index bfd3f91..c6f9e64 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -7,6 +7,12 @@ VERSION=${TREE_RING_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | exit 1 } +RELEASE_TAG=${TREE_RING_RELEASE_TAG:-""} +if [ "$RELEASE_TAG" != "" ] && [ "$RELEASE_TAG" != "v$VERSION" ]; then + printf '%s\n' "Release tag $RELEASE_TAG does not match Cargo version $VERSION" >&2 + exit 1 +fi + OS=$(uname -s | tr '[:upper:]' '[:lower:]') ARCH=$(uname -m | tr '[:upper:]' '[:lower:]') NAME="tree-ring-memory-$VERSION-$OS-$ARCH" @@ -22,8 +28,29 @@ cargo build --release -p tree-ring-memory-cli --locked cp target/release/tree-ring "$WORK_DIR/tree-ring" cp README.md LICENSE install.sh "$WORK_DIR/" +binary_version=$("$WORK_DIR/tree-ring" --version) +[ "$binary_version" = "tree-ring $VERSION" ] || { + printf '%s\n' "Packaged binary version $binary_version does not match $VERSION" >&2 + exit 1 +} + tar -C "$DIST_DIR" -czf "$DIST_DIR/$ARCHIVE" "$NAME" +for packaged_path in \ + "$NAME/" \ + "$NAME/tree-ring" \ + "$NAME/README.md" \ + "$NAME/LICENSE" \ + "$NAME/install.sh"; do + tar -tzf "$DIST_DIR/$ARCHIVE" | grep -F -x "$packaged_path" > /dev/null +done + +archive_entry_count=$(tar -tzf "$DIST_DIR/$ARCHIVE" | wc -l | tr -d '[:space:]') +[ "$archive_entry_count" = "5" ] || { + printf '%s\n' "Release archive has unexpected contents" >&2 + exit 1 +} + if command -v shasum >/dev/null 2>&1; then ( cd "$DIST_DIR"