diff --git a/Cargo.lock b/Cargo.lock index ea5b02aaab..71274b29f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -795,6 +795,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806..2a85e68cef 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -79,3 +79,4 @@ nix = { version = "0.31", default-features = false, features = ["signal"] } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } httparse = "1" +tempfile = "3" diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 403512a322..63061ef5cc 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -9,6 +9,7 @@ mod pool; mod pool_lifecycle; mod queue; mod relay; +mod session_identity; mod setup_mode; mod usage; @@ -1558,6 +1559,8 @@ async fn tokio_main() -> Result<()> { .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), memory_enabled: config.memory_enabled, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), + session_identity_log_path: std::env::var_os("BUZZ_ACP_SESSION_IDENTITY_LOG") + .map(std::path::PathBuf::from), relay_url: config.relay_url.clone(), }); diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 158477c0af..7a42c38ec9 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -21,6 +21,7 @@ use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -547,6 +548,9 @@ pub struct PromptContext { /// Harness identity string for NIP-AM `harness` field. Derived from the /// configured `agent_command` at startup (e.g. `"goose"`, `"buzz-agent"`). pub harness_name: String, + /// Append-only local receipt mapping exact ACP session IDs to the signed + /// managed-agent identity. Desktop supplies a pair-scoped app-data path. + pub session_identity_log_path: Option, /// Relay URL this harness is connected to. Rides in observer payloads that /// the desktop keys per (agent, relay) pair, e.g. `session_config_captured`, /// mirroring the `managed_agent_runtime_lifecycle` frames. @@ -913,6 +917,23 @@ async fn create_session_and_apply_model( ) .await?; + if let Some(path) = ctx.session_identity_log_path.as_deref() { + if let Err(error) = crate::session_identity::append_receipt( + path, + &resp.session_id, + &ctx.agent_keys.public_key().to_hex(), + &ctx.harness_name, + ) { + // Identity telemetry must never make an otherwise valid ACP + // session unavailable. Missing receipts remain explicit unknowns. + tracing::warn!( + target: "buzz_acp::session_identity", + session_id = %resp.session_id, + "managed session identity receipt unavailable: {error}" + ); + } + } + if is_goose && agent.goose_system_prompt_supported != Some(false) { if let Some(prompt) = combined_system_prompt.as_deref() { match agent @@ -6300,10 +6321,86 @@ mod tests { agent_owner_pubkey: owner_pubkey, memory_enabled: false, harness_name: "goose".to_string(), + session_identity_log_path: None, relay_url: "ws://127.0.0.1:3000".to_string(), } } + async fn fake_acp_agent(session_id: &str) -> OwnedAgent { + let script = format!( + r#" + read -r _init + echo '{{"jsonrpc":"2.0","id":0,"result":{{"protocolVersion":2,"agentCapabilities":{{}}}}}}' + read -r _session + echo '{{"jsonrpc":"2.0","id":1,"result":{{"sessionId":"{session_id}"}}}}' + sleep 1 + "# + ); + let mut acp = AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) + .await + .expect("spawn fake ACP agent"); + acp.initialize().await.expect("initialize fake ACP agent"); + OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "codex-acp".to_string(), + goose_system_prompt_supported: None, + protocol_version: 2, + } + } + + #[tokio::test] + async fn new_acp_session_persists_the_exact_managed_identity() { + let session_id = "019fcac1-a301-7780-b42c-aebc569b4928"; + let mut agent = fake_acp_agent(session_id).await; + let keys = nostr::Keys::generate(); + let temp = tempfile::tempdir().expect("tempdir"); + let receipt_path = temp.path().join("session-identities.jsonl"); + let mut ctx = make_prompt_context_impl(&keys, None); + ctx.harness_name = "codex-acp".to_string(); + ctx.session_identity_log_path = Some(receipt_path.clone()); + + let created = create_session_and_apply_model(&mut agent, &ctx, None, None, None) + .await + .expect("create ACP session"); + let receipts = crate::session_identity::read_receipts(&receipt_path) + .expect("read persisted identity receipt"); + + assert_eq!(created, session_id); + assert_eq!(receipts[session_id].session_id, session_id); + assert_eq!( + receipts[session_id].agent_pubkey, + keys.public_key().to_hex() + ); + assert_eq!(receipts[session_id].harness, "codex-acp"); + } + + #[tokio::test] + async fn unavailable_receipt_path_does_not_fail_session_creation() { + let session_id = "3e404b95-9c13-4dfa-ac65-6f47da5b2bc6"; + let mut agent = fake_acp_agent(session_id).await; + let keys = nostr::Keys::generate(); + let temp = tempfile::tempdir().expect("tempdir"); + let missing_path = temp + .path() + .join("missing-parent") + .join("session-identities.jsonl"); + let mut ctx = make_prompt_context_impl(&keys, None); + ctx.harness_name = "claude-agent-acp".to_string(); + ctx.session_identity_log_path = Some(missing_path.clone()); + + let created = create_session_and_apply_model(&mut agent, &ctx, None, None, None) + .await + .expect("receipt failure must not fail ACP session creation"); + + assert_eq!(created, session_id); + assert!(!missing_path.exists()); + } + // ── render_canvas_section ──────────────────────────────────────────────── #[test] diff --git a/crates/buzz-acp/src/session_identity.rs b/crates/buzz-acp/src/session_identity.rs new file mode 100644 index 0000000000..3003cabd10 --- /dev/null +++ b/crates/buzz-acp/src/session_identity.rs @@ -0,0 +1,323 @@ +//! Durable local join between an ACP session and the managed Buzz identity. +//! +//! The receipt deliberately stores no prompt, message, channel, credential, +//! model, or cost content. Codex consumers join `session_id` to the UUID in the +//! rollout filename; `session_meta.id` is only a consistency check because an +//! aborted rollout may not contain a `session_meta` record. + +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::Path; + +use chrono::{SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[cfg(test)] +use chrono::DateTime; +#[cfg(test)] +use std::collections::HashMap; +#[cfg(test)] +use std::io::{BufRead, BufReader}; + +const SCHEMA_VERSION: u8 = 1; +const MAX_SESSION_ID_BYTES: usize = 256; +const MAX_HARNESS_BYTES: usize = 128; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionIdentityReceipt { + pub(crate) schema_version: u8, + pub(crate) session_id: String, + pub(crate) agent_pubkey: String, + pub(crate) harness: String, + pub(crate) recorded_at: String, +} + +#[derive(Debug, Error)] +pub(crate) enum SessionIdentityError { + #[error("invalid session id")] + InvalidSessionId, + #[error("invalid agent pubkey")] + InvalidAgentPubkey, + #[error("invalid harness identity")] + InvalidHarness, + #[cfg(test)] + #[error("invalid receipt at line {line}")] + InvalidReceipt { line: usize }, + #[cfg(test)] + #[error("conflicting identity for session at line {line}")] + ConflictingIdentity { line: usize }, + #[error("session identity receipt I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("session identity receipt serialization failed: {0}")] + Serialize(#[from] serde_json::Error), +} + +fn safe_identifier(value: &str, max_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= max_bytes + && value.chars().all(|character| !character.is_control()) +} + +fn valid_pubkey(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +#[cfg(test)] +fn validate_receipt(receipt: &SessionIdentityReceipt) -> bool { + receipt.schema_version == SCHEMA_VERSION + && safe_identifier(&receipt.session_id, MAX_SESSION_ID_BYTES) + && valid_pubkey(&receipt.agent_pubkey) + && safe_identifier(&receipt.harness, MAX_HARNESS_BYTES) + && DateTime::parse_from_rfc3339(&receipt.recorded_at).is_ok() +} + +pub(crate) fn append_receipt( + path: &Path, + session_id: &str, + agent_pubkey: &str, + harness: &str, +) -> Result<(), SessionIdentityError> { + if !safe_identifier(session_id, MAX_SESSION_ID_BYTES) { + return Err(SessionIdentityError::InvalidSessionId); + } + if !valid_pubkey(agent_pubkey) { + return Err(SessionIdentityError::InvalidAgentPubkey); + } + if !safe_identifier(harness, MAX_HARNESS_BYTES) { + return Err(SessionIdentityError::InvalidHarness); + } + + let receipt = SessionIdentityReceipt { + schema_version: SCHEMA_VERSION, + session_id: session_id.to_string(), + agent_pubkey: agent_pubkey.to_string(), + harness: harness.to_string(), + recorded_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + }; + let mut encoded = serde_json::to_vec(&receipt)?; + encoded.push(b'\n'); + + let mut options = OpenOptions::new(); + options.create(true).append(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + restrict_to_owner(&file)?; + file.write_all(&encoded)?; + file.sync_data()?; + Ok(()) +} + +#[cfg(unix)] +fn restrict_to_owner(file: &File) -> Result<(), std::io::Error> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let metadata = file.metadata()?; + if metadata.mode() & 0o077 != 0 { + let mut permissions = metadata.permissions(); + permissions.set_mode(0o600); + file.set_permissions(permissions)?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn restrict_to_owner(_file: &File) -> Result<(), std::io::Error> { + Ok(()) +} + +#[cfg(test)] +pub(crate) fn read_receipts( + path: &Path, +) -> Result, SessionIdentityError> { + let file = File::open(path)?; + let mut receipts: HashMap = HashMap::new(); + + for (index, line) in BufReader::new(file).lines().enumerate() { + let line_number = index + 1; + let line = line?; + let receipt: SessionIdentityReceipt = serde_json::from_str(&line) + .map_err(|_| SessionIdentityError::InvalidReceipt { line: line_number })?; + if !validate_receipt(&receipt) { + return Err(SessionIdentityError::InvalidReceipt { line: line_number }); + } + if let Some(existing) = receipts.get(&receipt.session_id) { + if existing.agent_pubkey != receipt.agent_pubkey || existing.harness != receipt.harness + { + return Err(SessionIdentityError::ConflictingIdentity { line: line_number }); + } + continue; + } + receipts.insert(receipt.session_id.clone(), receipt); + } + Ok(receipts) +} + +#[cfg(test)] +mod tests { + use super::{append_receipt, read_receipts, SessionIdentityReceipt}; + + const PUBKEY: &str = "cee956f33a68bd1ace03bb889790b06647f5264a4751604fd2196f574783392e"; + const CODEX_SESSION: &str = "019fcac1-a301-7780-b42c-aebc569b4928"; + + #[test] + fn receipt_round_trips_the_exact_codex_session_and_pubkey() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("sessions.jsonl"); + + append_receipt(&path, CODEX_SESSION, PUBKEY, "codex-acp") + .expect("append exact identity receipt"); + let raw: serde_json::Value = serde_json::from_str( + std::fs::read_to_string(&path) + .expect("read raw receipt") + .trim(), + ) + .expect("parse raw receipt"); + let fields = raw.as_object().expect("receipt object"); + assert_eq!(fields.len(), 5); + for expected in [ + "schema_version", + "session_id", + "agent_pubkey", + "harness", + "recorded_at", + ] { + assert!(fields.contains_key(expected), "missing {expected}"); + } + let receipts = read_receipts(&path).expect("read identity receipts"); + + assert_eq!( + receipts.get(CODEX_SESSION), + Some(&SessionIdentityReceipt { + schema_version: 1, + session_id: CODEX_SESSION.to_string(), + agent_pubkey: PUBKEY.to_string(), + harness: "codex-acp".to_string(), + recorded_at: receipts[CODEX_SESSION].recorded_at.clone(), + }) + ); + } + + #[test] + fn reopen_preserves_existing_receipts_and_adds_a_new_session() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("sessions.jsonl"); + let claude_session = "3e404b95-9c13-4dfa-ac65-6f47da5b2bc6"; + + append_receipt(&path, CODEX_SESSION, PUBKEY, "codex-acp").expect("first append"); + append_receipt(&path, claude_session, PUBKEY, "claude-agent-acp") + .expect("append after reopen"); + let receipts = read_receipts(&path).expect("read both receipts"); + + assert_eq!(receipts.len(), 2); + assert_eq!(receipts[CODEX_SESSION].harness, "codex-acp"); + assert_eq!(receipts[claude_session].harness, "claude-agent-acp"); + } + + #[test] + fn malformed_jsonl_is_rejected_instead_of_partially_attributed() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("sessions.jsonl"); + std::fs::write(&path, b"{not-json}\n").expect("write malformed fixture"); + + let error = read_receipts(&path).expect_err("malformed receipt must fail closed"); + + assert!(error.to_string().contains("line 1")); + } + + #[test] + fn unknown_schema_fields_are_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("sessions.jsonl"); + let receipt = serde_json::json!({ + "schema_version": 1, + "session_id": CODEX_SESSION, + "agent_pubkey": PUBKEY, + "harness": "codex-acp", + "recorded_at": "2026-08-04T03:00:00Z", + "prompt": "must never be accepted", + }); + std::fs::write(&path, format!("{receipt}\n")).expect("write unknown-field fixture"); + + let error = read_receipts(&path).expect_err("unknown fields must fail closed"); + + assert!(error.to_string().contains("line 1")); + } + + #[test] + fn conflicting_identity_for_one_session_is_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("sessions.jsonl"); + let other_pubkey = "a".repeat(64); + append_receipt(&path, CODEX_SESSION, PUBKEY, "codex-acp").expect("first mapping"); + append_receipt(&path, CODEX_SESSION, &other_pubkey, "codex-acp") + .expect("conflicting mapping is persisted for reader validation"); + + let error = read_receipts(&path).expect_err("conflict must fail closed"); + + assert!(error.to_string().contains("conflicting identity")); + } + + #[test] + fn repeated_identical_session_receipts_resolve_once() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("sessions.jsonl"); + append_receipt(&path, CODEX_SESSION, PUBKEY, "codex-acp").expect("first mapping"); + append_receipt(&path, CODEX_SESSION, PUBKEY, "codex-acp").expect("repeated mapping"); + + let receipts = read_receipts(&path).expect("identical mapping is idempotent"); + + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[CODEX_SESSION].agent_pubkey, PUBKEY); + } + + #[test] + fn invalid_identifiers_are_rejected_before_writing() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("sessions.jsonl"); + + let error = append_receipt(&path, "contains\nnewline", PUBKEY, "codex-acp") + .expect_err("unsafe session id must reject"); + + assert!(error.to_string().contains("session id")); + assert!(!path.exists()); + } + + #[test] + fn unavailable_parent_path_returns_an_explicit_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("missing-parent").join("sessions.jsonl"); + + let error = append_receipt(&path, CODEX_SESSION, PUBKEY, "codex-acp") + .expect_err("unavailable path must not be reported as persisted"); + + assert!(error.to_string().contains("I/O failed")); + assert!(!path.exists()); + } + + #[cfg(unix)] + #[test] + fn receipt_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("sessions.jsonl"); + + append_receipt(&path, CODEX_SESSION, PUBKEY, "codex-acp").expect("append receipt"); + + let mode = std::fs::metadata(path) + .expect("receipt metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } +} diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 592a5cbbd9..0e0f1fe240 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -81,6 +81,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // ambient env var must not be able to forge setup mode (NotReady) on a // Ready agent or suppress it (empty/stale payload) on a NotReady one. "BUZZ_ACP_SETUP_PAYLOAD", + // Exact session-to-agent identity receipt. Desktop derives a pair-scoped + // app-data path; allowing overrides could mix or redirect identities. + "BUZZ_ACP_SESSION_IDENTITY_LOG", // Desktop ownership markers: these brand every spawned harness with the // launching Desktop instance. A user-supplied override would let a // definition masquerade as a different instance or fake the nonce used diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index cf57b12546..5a48c8c345 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -145,6 +145,14 @@ fn reserved_keys_include_agent_owner_for_legacy_records() { assert!(merged.is_empty()); } +#[test] +fn reserved_keys_include_session_identity_receipt_path() { + assert!(is_reserved_env_key("BUZZ_ACP_SESSION_IDENTITY_LOG")); + let agent = map(&[("BUZZ_ACP_SESSION_IDENTITY_LOG", "/tmp/forged.jsonl")]); + let merged = merged_user_env(&BTreeMap::new(), &agent); + assert!(merged.is_empty()); +} + #[test] fn reserved_keys_include_respond_to_gate() { // Respond-to mode + allowlist control who the agent answers. diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 37927961ed..21e7e3a153 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -14,6 +14,22 @@ use crate::{ util::now_iso, }; +const SESSION_IDENTITY_LOG_ENV: &str = "BUZZ_ACP_SESSION_IDENTITY_LOG"; + +fn session_identity_log_path(runtime_log_path: &std::path::Path) -> std::path::PathBuf { + runtime_log_path.with_extension("session-identities.jsonl") +} + +fn configure_session_identity_receipt( + command: &mut std::process::Command, + runtime_log_path: &std::path::Path, +) { + command.env( + SESSION_IDENTITY_LOG_ENV, + session_identity_log_path(runtime_log_path), + ); +} + mod path; pub(in crate::managed_agents) use path::build_augmented_path; pub(crate) use path::compose_path_entries; @@ -860,6 +876,7 @@ pub fn spawn_agent_child( for (key, value) in &descriptor.env { command.env(key, value); } + configure_session_identity_receipt(&mut command, &log_path); configure_runtime_cli(&mut command, runtime_meta); // Buzz shared compute is stored as a native provider; derive the OpenAI-compatible diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 3f6ee996f6..d7eb8fbb9b 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -115,6 +115,30 @@ fn unknown_command_returns_none() { assert!(known_acp_runtime("custom-agent").is_none()); } +#[test] +fn session_identity_receipt_is_pair_scoped_beside_the_runtime_log() { + let log_path = std::path::Path::new( + "/app-data/agents/logs/agentpubkey__relayhash.log", + ); + + assert_eq!( + super::session_identity_log_path(log_path), + std::path::PathBuf::from( + "/app-data/agents/logs/agentpubkey__relayhash.session-identities.jsonl", + ) + ); + + let mut command = std::process::Command::new("buzz-acp"); + super::configure_session_identity_receipt(&mut command, log_path); + assert!(command.get_envs().any(|(key, value)| { + key == "BUZZ_ACP_SESSION_IDENTITY_LOG" + && value + == Some(std::ffi::OsStr::new( + "/app-data/agents/logs/agentpubkey__relayhash.session-identities.jsonl", + )) + })); +} + // ── build_respond_to_env tests ─────────────────────────────────────── use super::build_respond_to_env;