From 7626a21f89c7e35bf3e5e78e5e706f5abeb68e28 Mon Sep 17 00:00:00 2001 From: Greg Moskalenko Date: Wed, 5 Aug 2026 16:14:14 -0700 Subject: [PATCH 1/3] fix: make Codex delivery resilient to sandbox DNS Honor the adapter network capability at startup and broker exact signed events through the harness when the sandboxed CLI cannot reach the relay. Preserve direct delivery as the primary path and verify relay acceptance plus exact readback for both paths. Co-authored-by: Greg Moskalenko Signed-off-by: Greg Moskalenko --- Cargo.lock | 9 + Cargo.toml | 2 +- crates/buzz-acp/Cargo.toml | 11 +- crates/buzz-acp/src/acp.rs | 175 ++- crates/buzz-acp/src/config.rs | 61 +- crates/buzz-acp/src/delivery.rs | 1530 +++++++++++++++++++++++ crates/buzz-acp/src/lib.rs | 127 ++ crates/buzz-acp/src/relay.rs | 128 +- crates/buzz-cli/Cargo.toml | 6 + crates/buzz-cli/src/client.rs | 447 ++++++- crates/buzz-cli/src/delivery_broker.rs | 663 ++++++++++ crates/buzz-cli/src/lib.rs | 1 + crates/buzz-core/src/delivery_broker.rs | 216 ++++ crates/buzz-core/src/lib.rs | 2 + 14 files changed, 3352 insertions(+), 26 deletions(-) create mode 100644 crates/buzz-acp/src/delivery.rs create mode 100644 crates/buzz-cli/src/delivery_broker.rs create mode 100644 crates/buzz-core/src/delivery_broker.rs diff --git a/Cargo.lock b/Cargo.lock index 937ead564a..eb6253f0ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,23 +801,29 @@ name = "buzz-acp" version = "0.1.0" dependencies = [ "anyhow", + "axum", "base64 0.22.1", "buzz-core", "buzz-persona", "buzz-sdk", "chrono", "clap", + "dirs", "evalexpr", "futures-util", "hex", "httparse", + "libc", "nix 0.31.3", "nostr", + "rand 0.10.1", "reqwest 0.13.4", "rustls", "serde", "serde_json", "sha2 0.11.0", + "subtle", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", @@ -827,6 +833,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "windows-sys 0.61.2", ] [[package]] @@ -952,6 +959,7 @@ dependencies = [ "dirs", "hex", "infer", + "libc", "nostr", "rand 0.10.1", "reqwest 0.13.4", @@ -964,6 +972,7 @@ dependencies = [ "tokio", "url", "uuid", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cc1dd0f9df..94adb253db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,7 +99,7 @@ uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } # HTTP client (webhook delivery) -reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false } +reqwest = { version = "0.13", features = ["json", "rustls", "stream"], default-features = false } # Cryptography sha2 = "0.11" diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806..cfc81e2dc2 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -53,6 +53,10 @@ url = { workspace = true } sha2 = { workspace = true } base64 = "0.22" hex = { workspace = true } +rand = { workspace = true } +subtle = { workspace = true } +tempfile = "3" +dirs = "6" # Logging tracing = { workspace = true } @@ -74,8 +78,13 @@ evalexpr = { workspace = true } # Process-group kill (safe wrapper around killpg) — Unix-only; kill_process_group # has a #[cfg(not(unix))] fallback in acp.rs. [target.'cfg(unix)'.dependencies] -nix = { version = "0.31", default-features = false, features = ["signal"] } +nix = { version = "0.31", default-features = false, features = ["process", "signal"] } +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } httparse = "1" +axum = { workspace = true } diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 93109fa94d..1fb8a56588 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -254,7 +254,9 @@ fn deep_merge( /// deep-merged into the result (parent wins on colliding keys at every nesting level; /// unrelated keys from either side survive). /// 4. **Forced overlay** — `sandbox_workspace_write.network_access = true` is applied -/// last so relay access is guaranteed regardless of operator / persona config. +/// last so relay access is guaranteed regardless of operator / persona config. When +/// a harness delivery broker is present, its isolated `requests` directory is appended +/// to `sandbox_workspace_write.writable_roots` without replacing existing roots. /// /// When `has_generated_codex_config` is false, the function returns `None` and the /// caller handles any persona-supplied `CODEX_CONFIG` with ordinary operator-wins @@ -264,7 +266,8 @@ fn deep_merge( /// /// Returns `Err(AcpError::Protocol)` when `has_generated_codex_config` is true and any /// `CODEX_CONFIG` value is not valid JSON or is not a JSON object, or when -/// `sandbox_workspace_write` is present but not an object after all merges. +/// `sandbox_workspace_write` is present but not an object after all merges, or when its +/// `writable_roots` value is not an array. pub(crate) fn build_codex_config_env( extra_env: &[(String, String)], parent_codex_config: Option<&str>, @@ -335,13 +338,82 @@ pub(crate) fn build_codex_config_env( } } - // Force sandbox_workspace_write.network_access = true (our invariant, always wins). + let broker_request_root = extra_env + .iter() + .rev() + .find(|(key, _)| key == buzz_core::delivery_broker::BROKER_DIR_ENV) + .map(|(_, root)| std::path::Path::new(root).join("requests")); + + // Force sandbox_workspace_write.network_access = true (our invariant, always wins) + // and add only the broker request inbox as writable. The broker parent, + // processing directory, and signed-response directory remain outside the + // sandbox's writable roots. let sws_entry = base .entry("sandbox_workspace_write") .or_insert_with(|| serde_json::json!({})); match sws_entry { serde_json::Value::Object(sws_obj) => { sws_obj.insert("network_access".to_string(), serde_json::Value::Bool(true)); + if let Some(request_root) = broker_request_root { + let request_root = request_root.to_str().ok_or_else(|| { + AcpError::Protocol("delivery broker request path is not valid UTF-8".into()) + })?; + let writable_roots = sws_obj + .entry("writable_roots") + .or_insert_with(|| serde_json::json!([])); + let serde_json::Value::Array(roots) = writable_roots else { + return Err(AcpError::Protocol( + "CODEX_CONFIG sandbox_workspace_write.writable_roots is not an array" + .into(), + )); + }; + let canonical_request_root = std::fs::canonicalize(request_root).map_err(|e| { + AcpError::Protocol(format!( + "canonicalize delivery broker request root {request_root}: {e}" + )) + })?; + let canonical_broker_root = canonical_request_root.parent().ok_or_else(|| { + AcpError::Protocol("delivery broker request root has no parent".into()) + })?; + for existing in roots.iter() { + let existing = existing.as_str().ok_or_else(|| { + AcpError::Protocol( + "CODEX_CONFIG writable_roots entries must be strings".into(), + ) + })?; + let existing_path = std::path::PathBuf::from(existing); + let existing_path = if existing_path.is_absolute() { + existing_path + } else { + std::env::current_dir() + .map_err(|e| AcpError::Protocol(e.to_string()))? + .join(existing_path) + }; + let canonical_existing = + std::fs::canonicalize(&existing_path).map_err(|e| { + AcpError::Protocol(format!( + "canonicalize CODEX_CONFIG writable root {}: {e}", + existing_path.display() + )) + })?; + if canonical_existing != canonical_request_root + && (canonical_broker_root.starts_with(&canonical_existing) + || canonical_existing.starts_with(canonical_broker_root)) + { + return Err(AcpError::Protocol(format!( + "CODEX_CONFIG writable root {} overlaps protected delivery broker root {}", + canonical_existing.display(), + canonical_broker_root.display() + ))); + } + } + if !roots + .iter() + .any(|value| value.as_str() == Some(request_root)) + { + roots.push(serde_json::Value::String(request_root.into())); + } + } } other => { return Err(AcpError::Protocol(format!( @@ -505,6 +577,17 @@ impl AcpClient { // Handled by build_codex_config_env; skip here to avoid double-setting. continue; } + if matches!( + key.as_str(), + buzz_core::delivery_broker::BROKER_DIR_ENV + | buzz_core::delivery_broker::BROKER_CAPABILITY_ENV + | buzz_core::delivery_broker::BROKER_RESPONSE_PUBKEY_ENV + ) { + // These values are generated per harness lifetime. A stale + // inherited value must never override the live broker. + cmd.env(key, value); + continue; + } if std::env::var_os(key).is_none() { cmd.env(key, value); } @@ -4466,6 +4549,92 @@ mod tests { ); } + #[test] + fn build_codex_config_env_appends_only_broker_request_writable_root() { + let temp = tempfile::tempdir().expect("tempdir"); + let broker_root = temp.path().join("broker"); + let existing_root = temp.path().join("existing"); + std::fs::create_dir_all(broker_root.join("requests")).expect("requests"); + std::fs::create_dir(&existing_root).expect("existing root"); + let broker_root_text = broker_root.to_string_lossy().into_owned(); + let persona = serde_json::json!({ + "sandbox_workspace_write": { + "writable_roots": [existing_root.to_string_lossy()] + } + }) + .to_string(); + let extra = vec![ + ("CODEX_CONFIG".into(), persona), + ("CODEX_CONFIG".into(), GENERATED.into()), + ( + buzz_core::delivery_broker::BROKER_DIR_ENV.into(), + broker_root_text, + ), + ]; + let merged = build_codex_config_env(&extra, None, true).unwrap().unwrap(); + let value: serde_json::Value = serde_json::from_str(&merged).unwrap(); + let roots = value["sandbox_workspace_write"]["writable_roots"] + .as_array() + .expect("writable roots"); + assert!(roots + .iter() + .any(|root| root.as_str() == Some(existing_root.to_string_lossy().as_ref()))); + let expected = broker_root.join("requests").to_string_lossy().into_owned(); + assert_eq!( + roots + .iter() + .filter(|root| root.as_str() == Some(expected.as_str())) + .count(), + 1 + ); + assert!(!roots + .iter() + .any(|root| root.as_str() == Some(broker_root.to_string_lossy().as_ref()))); + } + + #[test] + fn build_codex_config_env_rejects_non_array_writable_roots_for_broker() { + let temp = tempfile::tempdir().expect("tempdir"); + let broker_root = temp.path().join("broker"); + std::fs::create_dir_all(broker_root.join("requests")).expect("requests"); + let extra = vec![ + ( + "CODEX_CONFIG".into(), + r#"{"sandbox_workspace_write":{"writable_roots":"/too-broad"}}"#.into(), + ), + ("CODEX_CONFIG".into(), GENERATED.into()), + ( + buzz_core::delivery_broker::BROKER_DIR_ENV.into(), + broker_root.to_string_lossy().into_owned(), + ), + ]; + let error = build_codex_config_env(&extra, None, true).expect_err("invalid roots"); + assert!(error.to_string().contains("writable_roots")); + } + + #[test] + fn build_codex_config_env_rejects_writable_root_overlapping_broker_parent() { + let temp = tempfile::tempdir().expect("tempdir"); + let broker_root = temp.path().join("broker"); + std::fs::create_dir_all(broker_root.join("requests")).expect("requests"); + let persona = serde_json::json!({ + "sandbox_workspace_write": { + "writable_roots": [temp.path().to_string_lossy()] + } + }) + .to_string(); + let extra = vec![ + ("CODEX_CONFIG".into(), persona), + ("CODEX_CONFIG".into(), GENERATED.into()), + ( + buzz_core::delivery_broker::BROKER_DIR_ENV.into(), + broker_root.to_string_lossy().into_owned(), + ), + ]; + let error = build_codex_config_env(&extra, None, true).expect_err("overlap"); + assert!(error.to_string().contains("overlaps protected")); + } + #[test] fn build_codex_config_env_persona_only_signal_false_returns_none() { // Persona set CODEX_CONFIG; Buzz did not inject a generated overlay (signal=false). diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index d959685846..44e112d02f 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -683,12 +683,27 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String { .iter() .find_map(|extension| lower.strip_suffix(extension)) .unwrap_or(&lower); - stem.chars() + let identity: String = stem + .chars() .map(|character| match character { ' ' | '_' => '-', _ => character, }) - .collect() + .collect(); + // Standalone codex-acp releases use platform-qualified binary names. They + // are the same runtime identity and must receive the generated Codex + // network policy, broker environment, and capability gate even when Buzz + // launches the downloaded artifact directly rather than through an npm + // shim named `codex-acp`. + match identity.as_str() { + "codex-acp-x64-linux" + | "codex-acp-arm64-linux" + | "codex-acp-x64-darwin" + | "codex-acp-arm64-darwin" + | "codex-acp-x64-windows" + | "codex-acp-arm64-windows" => "codex-acp".into(), + _ => identity, + } } fn default_agent_args(command: &str) -> Option> { @@ -729,11 +744,12 @@ pub(crate) fn default_agent_env(command: &str) -> &'static [(&'static str, &'sta /// Returns `Some(("CODEX_CONFIG", "{\"sandbox_workspace_write\":{\"network_access\":true}}"))` for /// Codex agents, or `None` for non-Codex agents or when the relay URL cannot be parsed. /// -/// The env var is forwarded by the `@agentclientprotocol/codex-acp` adapter (1.x) as a -/// session-level config override (via `CODEX_CONFIG` → `thread/start config`), which is -/// equivalent to the TOML override `sandbox_workspace_write.network_access = true`. -/// That sets `NetworkSandboxPolicy::Enabled`, causing the Seatbelt policy to include -/// `(allow network-outbound)` — full outbound TCP/TLS at the OS level. +/// The env var is forwarded by a compatible `@agentclientprotocol/codex-acp` adapter as +/// both the session-level config override and the per-turn workspace-write policy. The +/// per-turn propagation matters because Codex treats that policy as authoritative over +/// the thread configuration. It is equivalent to the TOML override +/// `sandbox_workspace_write.network_access = true`, which enables outbound TCP/TLS while +/// retaining the workspace-write filesystem sandbox. /// /// URL validation is preserved as a guard: injection is skipped when the relay URL cannot /// be parsed, avoiding accidental sandbox widening for malformed configs. @@ -1634,6 +1650,37 @@ mod tests { assert_eq!(normalize_agent_command_identity("///"), ""); } + #[test] + fn packaged_codex_acp_commands_activate_the_codex_delivery_path() { + let packaged_commands = [ + "/opt/buzz/codex-acp-x64-linux", + "/opt/buzz/codex-acp-arm64-linux", + "/opt/buzz/codex-acp-x64-darwin", + "/opt/buzz/codex-acp-arm64-darwin", + r"C:\Buzz\codex-acp-x64-windows.exe", + r"C:\Buzz\codex-acp-arm64-windows.exe", + ]; + let private_key = Keys::generate().secret_key().to_secret_hex(); + + for command in packaged_commands { + assert_eq!(normalize_agent_command_identity(command), "codex-acp"); + assert!(codex_network_env(command, "wss://relay.example.com").is_some()); + + let args = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &private_key, + "--agent-command", + command, + ]); + let config = Config::from_args(args).expect("packaged Codex config"); + assert!( + config.has_generated_codex_config, + "packaged command did not activate Codex policy: {command}" + ); + } + } + #[test] fn default_agent_env_recognizes_hermes_identities() { for command in [ diff --git a/crates/buzz-acp/src/delivery.rs b/crates/buzz-acp/src/delivery.rs new file mode 100644 index 0000000000..733136f804 --- /dev/null +++ b/crates/buzz-acp/src/delivery.rs @@ -0,0 +1,1530 @@ +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::Context; +use buzz_core::delivery_broker::{ + broker_response_digest, is_brokered_message_kind, BrokerErrorCode, BrokerOperation, + BrokerRequest, BrokerResponse, BrokerResponseEnvelope, BROKER_CAPABILITY_ENV, BROKER_DIR_ENV, + BROKER_PROTOCOL_VERSION, BROKER_RESPONSE_ATTESTATION_KIND, BROKER_RESPONSE_PUBKEY_ENV, + MAX_BROKER_REQUEST_BYTES, MAX_BROKER_RESPONSE_BYTES, MAX_BROKER_RESULT_BYTES, +}; +use nostr::{Event, EventBuilder, Keys, Kind}; +use subtle::ConstantTimeEq; +use uuid::Uuid; + +use crate::relay::RestClient; + +const REQUEST_MAX_AGE: Duration = Duration::from_secs(30); +const REQUEST_MAX_FUTURE_SKEW: Duration = Duration::from_secs(5); +const BROKER_POLL_INTERVAL: Duration = Duration::from_millis(20); +const MAX_FILTERS: usize = 8; +const MAX_FILTER_LIMIT: u64 = 5_000; +const DEFAULT_QUERY_LIMIT: u64 = 500; +const MAX_THREAD_DEPTH: u64 = 100; +const MAX_FEED_TYPES: usize = 16; +const MAX_MESSAGE_CONTENT_BYTES: usize = 64 * 1024; +const MAX_EVENT_TAGS: usize = 256; +const MAX_QUEUE_REJECTIONS_PER_POLL: usize = 8; +const MAX_SCAN_ENTRIES: usize = 256; +// Atomic staging files do not consume the valid-candidate budget, but retain a +// separate traversal ceiling so a cluttered writable request directory cannot +// monopolize the broker loop indefinitely. Normal clients create at most one +// short-lived staging file per in-flight request, so this leaves wide margin. +const MAX_SCAN_DIRECTORY_ENTRIES: usize = MAX_SCAN_ENTRIES * 4; +const STALE_RESPONSE_AGE: Duration = Duration::from_secs(120); +const MAX_CONCURRENT_REQUESTS: usize = 8; +const REQUEST_PROCESSING_TIMEOUT: Duration = Duration::from_secs(110); +const BROKER_ROOT_PREFIX: &str = "buzz-delivery-broker-"; +const LEASE_REFRESH_INTERVAL: Duration = Duration::from_secs(30); +const LEASE_STALE_AGE: Duration = Duration::from_secs(10 * 60); +const LEGACY_ROOT_STALE_AGE: Duration = Duration::from_secs(24 * 60 * 60); + +#[derive(serde::Serialize, serde::Deserialize)] +struct BrokerLease { + pid: u32, + instance_id: Uuid, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ProcessLiveness { + Alive, + Dead, + Unknown, +} + +pub(crate) struct DeliveryBroker { + root: tempfile::TempDir, + task: tokio::task::JoinHandle<()>, + capability: String, + response_pubkey: String, + liveness_guard: Option, +} + +impl DeliveryBroker { + pub(crate) fn start( + relay_url: &str, + keys: Keys, + auth_tag_json: Option, + ) -> anyhow::Result { + // Keep the broker parent outside the agent workspace. The Codex policy + // receives only `/requests` as an additional writable root, so it + // cannot rename `processing`, `responses`, or the broker root itself. + let broker_parent = dirs::data_local_dir() + .ok_or_else(|| anyhow::anyhow!("local application data directory is unavailable"))? + .join("buzz-acp") + .join("delivery-brokers"); + std::fs::create_dir_all(&broker_parent).with_context(|| { + format!("create delivery broker parent {}", broker_parent.display()) + })?; + let broker_parent = std::fs::canonicalize(&broker_parent).with_context(|| { + format!( + "canonicalize delivery broker parent {}", + broker_parent.display() + ) + })?; + let cwd = std::fs::canonicalize(std::env::current_dir()?) + .context("canonicalize delivery broker working directory")?; + if broker_parent.starts_with(&cwd) || cwd.starts_with(&broker_parent) { + anyhow::bail!( + "delivery broker parent {} overlaps agent workspace {}", + broker_parent.display(), + cwd.display() + ); + } + cleanup_stale_broker_roots(&broker_parent); + let root = tempfile::Builder::new() + .prefix(BROKER_ROOT_PREFIX) + .tempdir_in(&broker_parent) + .with_context(|| format!("create delivery broker under {}", broker_parent.display()))?; + for child in ["requests", "processing", "responses"] { + std::fs::create_dir(root.path().join(child)) + .with_context(|| format!("create delivery broker {child} directory"))?; + } + let instance_id = Uuid::new_v4(); + create_lease(root.path(), instance_id).with_context(|| { + format!("create delivery broker lease in {}", root.path().display()) + })?; + refresh_heartbeat(root.path()).with_context(|| { + format!( + "create delivery broker heartbeat in {}", + root.path().display() + ) + })?; + let liveness_guard = create_liveness_guard(root.path()).with_context(|| { + format!( + "create delivery broker liveness guard in {}", + root.path().display() + ) + })?; + + let capability = random_capability(); + let response_keys = Keys::generate(); + let response_pubkey = response_keys.public_key().to_hex(); + let rest = RestClient::new(relay_url, keys, auth_tag_json) + .map_err(|e| anyhow::anyhow!("create delivery broker relay client: {e}"))?; + let task_root = root.path().to_path_buf(); + let task_capability = capability.clone(); + let task = tokio::spawn(async move { + run_broker(task_root, task_capability, response_keys, rest).await; + }); + + Ok(Self { + root, + task, + capability, + response_pubkey, + liveness_guard, + }) + } + + pub(crate) fn environment(&self) -> anyhow::Result<[(String, String); 3]> { + let root = self + .root + .path() + .to_str() + .ok_or_else(|| anyhow::anyhow!("delivery broker path is not valid UTF-8"))?; + Ok([ + (BROKER_DIR_ENV.into(), root.into()), + (BROKER_CAPABILITY_ENV.into(), self.capability.clone()), + ( + BROKER_RESPONSE_PUBKEY_ENV.into(), + self.response_pubkey.clone(), + ), + ]) + } +} + +impl Drop for DeliveryBroker { + fn drop(&mut self) { + self.task.abort(); + // On Windows the guard denies sharing so another process can prove the + // broker is alive. Release it before TempDir attempts recursive cleanup. + self.liveness_guard.take(); + } +} + +async fn run_broker(root: PathBuf, capability: String, response_keys: Keys, rest: RestClient) { + let mut last_cleanup = tokio::time::Instant::now(); + let mut last_lease_refresh = tokio::time::Instant::now(); + let mut jobs = tokio::task::JoinSet::new(); + loop { + if last_lease_refresh.elapsed() >= LEASE_REFRESH_INTERVAL { + if let Err(error) = refresh_heartbeat(&root) { + tracing::warn!("delivery broker heartbeat refresh failed: {error}"); + } + last_lease_refresh = tokio::time::Instant::now(); + } + if last_cleanup.elapsed() >= Duration::from_secs(5) { + prune_stale_files(&root.join("requests"), STALE_RESPONSE_AGE, MAX_SCAN_ENTRIES); + prune_stale_files( + &root.join("processing"), + STALE_RESPONSE_AGE, + MAX_SCAN_ENTRIES, + ); + prune_stale_files( + &root.join("responses"), + STALE_RESPONSE_AGE, + MAX_SCAN_ENTRIES, + ); + last_cleanup = tokio::time::Instant::now(); + } + while jobs.len() < MAX_CONCURRENT_REQUESTS { + match next_request_path(&root) { + Ok(Some((request_id, path))) => { + let job_root = root.clone(); + let job_capability = capability.clone(); + let job_response_keys = response_keys.clone(); + let job_rest = rest.clone(); + jobs.spawn(async move { + handle_claimed_request( + &job_root, + request_id, + path, + &job_capability, + &job_response_keys, + &job_rest, + ) + .await; + }); + } + Ok(None) => break, + Err(error) => { + tracing::warn!("delivery broker request scan failed: {error}"); + break; + } + } + } + if jobs.len() >= MAX_CONCURRENT_REQUESTS { + reject_queued_requests(&root, &response_keys); + } + + tokio::select! { + _ = tokio::time::sleep(BROKER_POLL_INTERVAL) => {} + completed = jobs.join_next(), if !jobs.is_empty() => { + if let Some(Err(error)) = completed { + tracing::warn!("delivery broker request task failed: {error}"); + } + } + } + } +} + +fn create_lease(root: &Path, instance_id: Uuid) -> std::io::Result<()> { + let lease = root.join("lease"); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options.open(lease)?; + if metadata_is_reparse_point(&file.metadata()?) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "delivery broker lease is a reparse point", + )); + } + let lease = BrokerLease { + pid: std::process::id(), + instance_id, + }; + serde_json::to_writer(&mut file, &lease).map_err(std::io::Error::other)?; + file.sync_all() +} + +fn refresh_heartbeat(root: &Path) -> std::io::Result<()> { + let heartbeat = root.join("heartbeat"); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options.open(heartbeat)?; + if metadata_is_reparse_point(&file.metadata()?) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "delivery broker heartbeat is a reparse point", + )); + } + file.write_all(b"1")?; + file.sync_all() +} + +fn create_liveness_guard(root: &Path) -> std::io::Result> { + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + let guard = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .share_mode(0) + .open(root.join("liveness.lock"))?; + return Ok(Some(guard)); + } + #[cfg(not(windows))] + { + let _ = root; + Ok(None) + } +} + +fn cleanup_stale_broker_roots(parent: &Path) { + let now = SystemTime::now(); + let Ok(entries) = std::fs::read_dir(parent) else { + return; + }; + for entry in entries.take(MAX_SCAN_ENTRIES).flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.starts_with(BROKER_ROOT_PREFIX) { + continue; + } + let Ok(root_metadata) = std::fs::symlink_metadata(&path) else { + continue; + }; + if root_metadata.file_type().is_symlink() + || metadata_is_reparse_point(&root_metadata) + || !root_metadata.is_dir() + { + continue; + } + let root_modified = root_metadata.modified().ok(); + let lease_path = path.join("lease"); + let reap = match std::fs::symlink_metadata(&lease_path) { + Ok(metadata) + if metadata.is_file() + && !metadata.file_type().is_symlink() + && !metadata_is_reparse_point(&metadata) => + { + let freshness_metadata = std::fs::symlink_metadata(path.join("heartbeat")) + .ok() + .filter(|heartbeat| { + heartbeat.is_file() + && !heartbeat.file_type().is_symlink() + && !metadata_is_reparse_point(heartbeat) + }); + let lease_stale = freshness_metadata + .as_ref() + .unwrap_or(&metadata) + .modified() + .ok() + .and_then(|modified| now.duration_since(modified).ok()) + .is_some_and(|age| age >= LEASE_STALE_AGE); + let parsed_lease = read_bounded_regular_file(&lease_path, 4 * 1024) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + match parsed_lease { + Some(lease) => lease_root_is_reapable( + lease_stale, + lease_process_liveness(lease.pid, &path.join("liveness.lock")), + ), + None => legacy_root_is_stale(now, root_modified), + } + } + Ok(_) => false, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + legacy_root_is_stale(now, root_modified) + } + Err(_) => false, + }; + if reap { + if let Err(error) = std::fs::remove_dir_all(&path) { + tracing::warn!(path = %path.display(), "stale delivery broker cleanup failed: {error}"); + } + } + } +} + +fn legacy_root_is_stale(now: SystemTime, root_modified: Option) -> bool { + root_modified + .and_then(|modified| now.duration_since(modified).ok()) + .is_some_and(|age| age >= LEGACY_ROOT_STALE_AGE) +} + +fn lease_root_is_reapable(stale: bool, liveness: ProcessLiveness) -> bool { + stale && liveness == ProcessLiveness::Dead +} + +#[cfg(unix)] +fn process_liveness(pid: u32) -> ProcessLiveness { + let Ok(pid) = i32::try_from(pid) else { + return ProcessLiveness::Unknown; + }; + match nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None) { + Ok(()) => ProcessLiveness::Alive, + Err(nix::errno::Errno::ESRCH) => ProcessLiveness::Dead, + Err(_) => ProcessLiveness::Unknown, + } +} + +#[cfg(windows)] +fn lease_process_liveness(_pid: u32, lock_path: &Path) -> ProcessLiveness { + use std::os::windows::fs::OpenOptionsExt; + match std::fs::OpenOptions::new() + .read(true) + .write(true) + .share_mode(0) + .open(lock_path) + { + Ok(_) => ProcessLiveness::Dead, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + ProcessLiveness::Alive + } + Err(_) => ProcessLiveness::Unknown, + } +} + +#[cfg(unix)] +fn lease_process_liveness(pid: u32, _lock_path: &Path) -> ProcessLiveness { + process_liveness(pid) +} + +#[cfg(not(any(unix, windows)))] +fn lease_process_liveness(_pid: u32, _lock_path: &Path) -> ProcessLiveness { + ProcessLiveness::Unknown +} + +async fn handle_claimed_request( + root: &Path, + request_id: Uuid, + path: PathBuf, + capability: &str, + response_keys: &Keys, + rest: &RestClient, +) { + let response = match tokio::time::timeout( + REQUEST_PROCESSING_TIMEOUT, + process_request_file(request_id, &path, capability, rest), + ) + .await + { + Ok(response) => response, + Err(_) => BrokerResponse::failure( + request_id, + BrokerErrorCode::DeliveryUnknown, + "delivery broker request processing timed out", + ), + }; + write_claimed_response(root, request_id, path, response, response_keys); +} + +fn write_claimed_response( + root: &Path, + request_id: Uuid, + path: PathBuf, + response: BrokerResponse, + response_keys: &Keys, +) { + let response_path = root.join("responses").join(format!("{request_id}.json")); + match encode_signed_response(response, response_keys) { + Ok(bytes) => { + if let Err(error) = write_atomic(&response_path, &bytes) { + tracing::warn!( + request_id = %request_id, + "delivery broker response write failed: {error}" + ); + } + } + Err(error) => tracing::warn!( + request_id = %request_id, + "delivery broker response encode failed: {error}" + ), + } + let _ = std::fs::remove_file(path); +} + +fn reject_queued_requests(root: &Path, response_keys: &Keys) { + for _ in 0..MAX_QUEUE_REJECTIONS_PER_POLL { + match next_request_path(root) { + Ok(Some((request_id, path))) => write_claimed_response( + root, + request_id, + path, + BrokerResponse::failure( + request_id, + BrokerErrorCode::Busy, + "delivery broker is at its in-flight limit; request was not executed", + ), + response_keys, + ), + Ok(None) => break, + Err(error) => { + tracing::warn!("delivery broker overflow scan failed: {error}"); + break; + } + } + } +} + +fn encode_signed_response( + response: BrokerResponse, + response_keys: &Keys, +) -> Result, String> { + let request_id = response.request_id; + let envelope = signed_response_envelope(response, response_keys)?; + let bytes = serde_json::to_vec(&envelope).map_err(|error| error.to_string())?; + if bytes.len() as u64 <= MAX_BROKER_RESPONSE_BYTES { + return Ok(bytes); + } + + let failure = BrokerResponse::failure( + request_id, + BrokerErrorCode::Internal, + "delivery broker response envelope exceeded its size limit", + ); + let envelope = signed_response_envelope(failure, response_keys)?; + let bytes = serde_json::to_vec(&envelope).map_err(|error| error.to_string())?; + if bytes.len() as u64 > MAX_BROKER_RESPONSE_BYTES { + return Err("delivery broker error envelope exceeded its size limit".into()); + } + Ok(bytes) +} + +fn signed_response_envelope( + response: BrokerResponse, + response_keys: &Keys, +) -> Result { + let content = broker_response_digest(&response).map_err(|error| error.to_string())?; + let attestation = EventBuilder::new(Kind::Custom(BROKER_RESPONSE_ATTESTATION_KIND), content) + .tags([]) + .sign_with_keys(response_keys) + .map_err(|error| error.to_string())?; + Ok(BrokerResponseEnvelope { + response, + attestation, + }) +} + +fn next_request_path(root: &Path) -> std::io::Result> { + let requests = root.join("requests"); + let processing = root.join("processing"); + let mut candidates = Vec::new(); + for entry in std::fs::read_dir(requests)?.take(MAX_SCAN_DIRECTORY_ENTRIES) { + let entry = entry?; + let source = entry.path(); + if source + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|name| name.starts_with('.')) + { + // The CLI creates dot-prefixed temporary files in this directory + // and atomically renames them to `.json` only after a + // complete fsync. Never unlink an in-progress atomic write. + continue; + } + let metadata = std::fs::symlink_metadata(&source)?; + if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) { + let _ = std::fs::remove_file(&source); + continue; + } + if !metadata.is_file() { + continue; + } + let Some(stem) = source.file_stem().and_then(|value| value.to_str()) else { + let _ = std::fs::remove_file(&source); + continue; + }; + if source.extension().and_then(|value| value.to_str()) != Some("json") { + let _ = std::fs::remove_file(&source); + continue; + } + let Ok(request_id) = Uuid::parse_str(stem) else { + let _ = std::fs::remove_file(&source); + continue; + }; + candidates.push((request_id, source)); + if candidates.len() >= MAX_SCAN_ENTRIES { + break; + } + } + + candidates.sort_by(|left, right| left.1.cmp(&right.1)); + for (request_id, source) in candidates { + let claimed = processing.join(format!("{request_id}.json")); + match std::fs::rename(&source, &claimed) { + Ok(()) => return Ok(Some((request_id, claimed))), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error), + } + } + Ok(None) +} + +fn prune_stale_files(directory: &Path, max_age: Duration, max_entries: usize) { + let now = SystemTime::now(); + let Ok(entries) = std::fs::read_dir(directory) else { + return; + }; + for entry in entries.take(max_entries).flatten() { + let path = entry.path(); + let Ok(metadata) = std::fs::symlink_metadata(&path) else { + continue; + }; + if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) { + let _ = std::fs::remove_file(path); + continue; + } + if !metadata.is_file() { + continue; + } + let stale = metadata + .modified() + .ok() + .and_then(|modified| now.duration_since(modified).ok()) + .is_some_and(|age| age >= max_age); + if stale { + let _ = std::fs::remove_file(path); + } + } +} + +async fn process_request_file( + filename_request_id: Uuid, + path: &Path, + expected_capability: &str, + rest: &RestClient, +) -> BrokerResponse { + let bytes = match read_bounded_regular_file(path, MAX_BROKER_REQUEST_BYTES) { + Ok(bytes) => bytes, + Err(error) => { + return BrokerResponse::failure( + filename_request_id, + BrokerErrorCode::InvalidRequest, + format!("request read failed: {error}"), + ) + } + }; + let request: BrokerRequest = match serde_json::from_slice(&bytes) { + Ok(request) => request, + Err(error) => { + return BrokerResponse::failure( + filename_request_id, + BrokerErrorCode::InvalidRequest, + format!("request decode failed: {error}"), + ) + } + }; + + if request.version != BROKER_PROTOCOL_VERSION || request.request_id != filename_request_id { + return BrokerResponse::failure( + filename_request_id, + BrokerErrorCode::InvalidRequest, + "request protocol version or identifier mismatch", + ); + } + if !capabilities_equal(&request.capability, expected_capability) { + return BrokerResponse::failure( + filename_request_id, + BrokerErrorCode::Unauthorized, + "invalid delivery broker capability", + ); + } + if !request_time_is_valid(request.created_at_ms) { + return BrokerResponse::failure( + filename_request_id, + BrokerErrorCode::InvalidRequest, + "delivery broker request is stale or from the future", + ); + } + + match request.operation { + BrokerOperation::Query { mut filters } => { + if let Err(error) = validate_filters(&mut filters, true) { + return BrokerResponse::failure( + filename_request_id, + BrokerErrorCode::InvalidRequest, + error, + ); + } + match rest.query_values(&filters).await { + Ok(value) => bounded_result_response(filename_request_id, value), + Err(error) => BrokerResponse::failure( + filename_request_id, + BrokerErrorCode::Internal, + sanitize_detail(&error.to_string()), + ), + } + } + BrokerOperation::Count { mut filters } => { + if let Err(error) = validate_filters(&mut filters, false) { + return BrokerResponse::failure( + filename_request_id, + BrokerErrorCode::InvalidRequest, + error, + ); + } + match rest.count_values(&filters).await { + Ok(value) => bounded_result_response(filename_request_id, value), + Err(error) => BrokerResponse::failure( + filename_request_id, + BrokerErrorCode::Internal, + sanitize_detail(&error.to_string()), + ), + } + } + BrokerOperation::SubmitStoredMessage { event } => { + match submit_verified_message(rest, &event).await { + Ok(value) => BrokerResponse::success(filename_request_id, value), + Err((code, message)) => BrokerResponse::failure(filename_request_id, code, message), + } + } + } +} + +fn bounded_result_response(request_id: Uuid, value: serde_json::Value) -> BrokerResponse { + match serde_json::to_vec(&value) { + Ok(bytes) if bytes.len() as u64 <= MAX_BROKER_RESULT_BYTES => { + BrokerResponse::success(request_id, value) + } + Ok(_) => BrokerResponse::failure( + request_id, + BrokerErrorCode::Internal, + "delivery broker relay result exceeded its size limit", + ), + Err(error) => BrokerResponse::failure( + request_id, + BrokerErrorCode::Internal, + format!("delivery broker response serialization failed: {error}"), + ), + } +} + +async fn submit_verified_message( + rest: &RestClient, + event: &Event, +) -> Result { + let kind = event.kind.as_u16(); + if !is_brokered_message_kind(kind) { + return Err(( + BrokerErrorCode::Unsupported, + format!("event kind {kind} is not allowed by the delivery broker"), + )); + } + if event.pubkey != rest.keys.public_key() { + return Err(( + BrokerErrorCode::Unauthorized, + "event signer does not match the harness identity".into(), + )); + } + if event.content.len() > MAX_MESSAGE_CONTENT_BYTES || event.tags.len() > MAX_EVENT_TAGS { + return Err(( + BrokerErrorCode::InvalidRequest, + "event content or tag count exceeds the broker limit".into(), + )); + } + event.verify().map_err(|error| { + ( + BrokerErrorCode::InvalidRequest, + format!("event signature verification failed: {error}"), + ) + })?; + + let expected_id = event.id.to_hex(); + match exact_readback_once(rest, event).await { + Ok(true) => { + return Ok(serde_json::json!({ + "event_id": expected_id, + "accepted": true, + "message": "event was already present on exact readback", + "delivery_path": "harness_broker", + "readback_verified": true, + "reconciled": true, + })); + } + Ok(false) | Err(ExactReadbackError::Transport(_)) => {} + Err(ExactReadbackError::Invalid(message)) => { + return Err((BrokerErrorCode::DeliveryUnknown, message)); + } + } + let submission = rest.submit_event(event).await; + let readback = verify_exact_readback(rest, event).await; + + match (submission, readback) { + (Ok(receipt), Ok(())) => { + let mut object = receipt.as_object().cloned().unwrap_or_default(); + let accepted = object + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let receipt_id = object + .get("event_id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let reconciled = !accepted || receipt_id != expected_id; + object.insert("event_id".into(), serde_json::json!(expected_id)); + object.insert("accepted".into(), serde_json::json!(true)); + object.insert("delivery_path".into(), serde_json::json!("harness_broker")); + object.insert("readback_verified".into(), serde_json::json!(true)); + object.insert("reconciled".into(), serde_json::json!(reconciled)); + Ok(serde_json::Value::Object(object)) + } + (Ok(receipt), Err(readback_error)) => { + let accepted = receipt + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let receipt_id_matches = receipt + .get("event_id") + .and_then(serde_json::Value::as_str) + .is_some_and(|event_id| event_id == expected_id); + let message = receipt + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("relay did not accept the event"); + if !accepted { + Err(( + BrokerErrorCode::RelayRejected, + sanitize_detail(&format!( + "{message}; exact readback failed: {readback_error}" + )), + )) + } else if !receipt_id_matches { + Err(( + BrokerErrorCode::DeliveryUnknown, + sanitize_detail(&format!( + "relay receipt event id did not match; exact readback failed: {readback_error}" + )), + )) + } else { + Err(( + BrokerErrorCode::DeliveryUnknown, + sanitize_detail(&format!( + "relay accepted the event but exact readback failed: {readback_error}" + )), + )) + } + } + (Err(_submit_error), Ok(())) => Ok(serde_json::json!({ + "event_id": expected_id, + "accepted": true, + "message": "submission outcome reconciled by exact event readback", + "delivery_path": "harness_broker", + "readback_verified": true, + "reconciled": true, + })), + (Err(submit_error), Err(readback_error)) => Err(( + BrokerErrorCode::DeliveryUnknown, + sanitize_detail(&format!( + "event submission failed: {submit_error}; exact readback failed: {readback_error}" + )), + )), + } +} + +async fn verify_exact_readback(rest: &RestClient, expected: &Event) -> Result<(), String> { + let delays = [50_u64, 100, 200, 400]; + let mut last_error = None; + for (index, delay_ms) in delays.iter().copied().enumerate() { + match exact_readback_once(rest, expected).await { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(ExactReadbackError::Transport(error)) => last_error = Some(error), + Err(ExactReadbackError::Invalid(error)) => return Err(error), + } + if index + 1 < delays.len() { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } + } + Err(last_error.unwrap_or_else(|| "accepted event was not visible on exact readback".into())) +} + +enum ExactReadbackError { + Transport(String), + Invalid(String), +} + +async fn exact_readback_once( + rest: &RestClient, + expected: &Event, +) -> Result { + let filter = serde_json::json!({"ids": [expected.id.to_hex()], "limit": 1}); + let value = rest + .query_values(std::slice::from_ref(&filter)) + .await + .map_err(|error| ExactReadbackError::Transport(sanitize_detail(&error.to_string())))?; + let events = value.as_array().ok_or_else(|| { + ExactReadbackError::Invalid("relay readback response was not an event array".into()) + })?; + let Some(found) = events.first() else { + return Ok(false); + }; + let event = serde_json::from_value::(found.clone()) + .map_err(|error| ExactReadbackError::Invalid(format!("invalid readback event: {error}")))?; + if event.verify().is_ok() && event == *expected { + Ok(true) + } else { + Err(ExactReadbackError::Invalid( + "relay readback did not exactly match the signed event".into(), + )) + } +} + +fn validate_filters(filters: &mut [serde_json::Value], is_query: bool) -> Result<(), String> { + if filters.is_empty() || filters.len() > MAX_FILTERS { + return Err(format!("filter count must be between 1 and {MAX_FILTERS}")); + } + for filter in filters { + let object = filter + .as_object_mut() + .ok_or_else(|| "each filter must be a JSON object".to_string())?; + for key in object.keys() { + let allowed = matches!( + key.as_str(), + "ids" + | "authors" + | "kinds" + | "since" + | "until" + | "limit" + | "search" + | "before_id" + | "depth_limit" + | "feed_types" + ) || key.starts_with('#'); + if !allowed { + return Err(format!("unsupported filter field: {key}")); + } + } + match object.get("limit").and_then(serde_json::Value::as_u64) { + Some(limit) if limit <= MAX_FILTER_LIMIT => {} + Some(_) => return Err(format!("filter limit exceeds {MAX_FILTER_LIMIT}")), + None if is_query => { + object.insert("limit".into(), serde_json::json!(DEFAULT_QUERY_LIMIT)); + } + None => {} + } + if object + .get("depth_limit") + .and_then(serde_json::Value::as_u64) + .is_some_and(|depth| depth > MAX_THREAD_DEPTH) + { + return Err(format!("depth_limit exceeds {MAX_THREAD_DEPTH}")); + } + if let Some(feed_types) = object.get("feed_types") { + let values = feed_types + .as_array() + .ok_or_else(|| "feed_types must be an array".to_string())?; + if values.len() > MAX_FEED_TYPES || values.iter().any(|value| !value.is_string()) { + return Err(format!( + "feed_types must contain at most {MAX_FEED_TYPES} strings" + )); + } + } + } + Ok(()) +} + +fn request_time_is_valid(created_at_ms: u64) -> bool { + let now = unix_now_ms(); + created_at_ms <= now.saturating_add(REQUEST_MAX_FUTURE_SKEW.as_millis() as u64) + && now.saturating_sub(created_at_ms) <= REQUEST_MAX_AGE.as_millis() as u64 +} + +fn capabilities_equal(provided: &str, expected: &str) -> bool { + provided.len() == expected.len() && bool::from(provided.as_bytes().ct_eq(expected.as_bytes())) +} + +fn random_capability() -> String { + use rand::Rng; + let mut bytes = [0_u8; 32]; + rand::rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} + +fn unix_now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u128::from(u64::MAX)) as u64 +} + +fn sanitize_detail(detail: &str) -> String { + detail + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(512) + .collect() +} + +fn read_bounded_regular_file(path: &Path, max_bytes: u64) -> std::io::Result> { + let mut file = open_read_nofollow(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata_is_reparse_point(&metadata) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "broker request is not a regular file", + )); + } + if metadata.len() > max_bytes { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "broker request exceeds size limit", + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + Read::by_ref(&mut file) + .take(max_bytes + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > max_bytes { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "broker request exceeds size limit", + )); + } + Ok(bytes) +} + +fn open_read_nofollow(path: &Path) -> std::io::Result { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + // Open the reparse point itself instead of traversing it. The opened + // handle's metadata is then checked for FILE_ATTRIBUTE_REPARSE_POINT. + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + options.open(path) +} + +fn metadata_is_reparse_point(metadata: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; + return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; + } + #[cfg(not(windows))] + { + let _ = metadata; + false + } +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let parent = path.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent") + })?; + let temp_path = parent.join(format!(".{}.tmp", Uuid::new_v4())); + + #[cfg(unix)] + let mut file = { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temp_path)? + }; + #[cfg(not(unix))] + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path)?; + + let result = (|| { + file.write_all(bytes)?; + file.sync_all()?; + std::fs::rename(&temp_path, path) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temp_path); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Bytes, extract::State, routing::post, Json, Router}; + use nostr::{EventBuilder, Kind}; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }; + + #[derive(Clone)] + struct TestRelayState { + stored: Arc>>, + store_on_submit: bool, + receipt: serde_json::Value, + submit_count: Arc, + } + + async fn test_query(State(state): State) -> Json { + let events = state + .stored + .lock() + .expect("stored lock") + .clone() + .into_iter() + .collect::>(); + Json(serde_json::to_value(events).expect("events json")) + } + + async fn test_submit( + State(state): State, + body: Bytes, + ) -> Json { + state.submit_count.fetch_add(1, Ordering::SeqCst); + let event: Event = serde_json::from_slice(&body).expect("submitted event"); + if state.store_on_submit { + *state.stored.lock().expect("stored lock") = Some(event); + } + Json(state.receipt) + } + + async fn spawn_test_relay( + initial: Option, + store_on_submit: bool, + receipt: serde_json::Value, + ) -> (RestClient, Arc) { + let submit_count = Arc::new(AtomicUsize::new(0)); + let state = TestRelayState { + stored: Arc::new(Mutex::new(initial)), + store_on_submit, + receipt, + submit_count: submit_count.clone(), + }; + let app = Router::new() + .route("/query", post(test_query)) + .route("/events", post(test_submit)) + .with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test relay"); + }); + let keys = Keys::generate(); + let rest = RestClient::new(&format!("http://{address}"), keys, None).expect("rest client"); + (rest, submit_count) + } + + #[test] + fn filter_validation_is_bounded_and_allowlisted() { + let mut bounded = vec![serde_json::json!({"kinds": [9], "limit": 1})]; + assert!(validate_filters(&mut bounded, true).is_ok()); + + let mut normalized = vec![serde_json::json!({ + "kinds": [9], + "depth_limit": 4, + "feed_types": ["mentions", "needs_action"] + })]; + assert!(validate_filters(&mut normalized, true).is_ok()); + assert_eq!(normalized[0]["limit"], DEFAULT_QUERY_LIMIT); + + let mut invalid = vec![serde_json::json!({ + "kinds": [9], + "limit": 1, + "path": "/admin" + })]; + assert!(validate_filters(&mut invalid, true).is_err()); + } + + #[tokio::test] + async fn submit_validation_rejects_wrong_kind_and_signer() { + let harness_keys = Keys::generate(); + let other_keys = Keys::generate(); + let rest = RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1".into(), + keys: harness_keys.clone(), + auth_tag_json: None, + }; + let wrong_kind = EventBuilder::new(Kind::Custom(1), "content") + .tags([]) + .sign_with_keys(&harness_keys) + .expect("sign"); + let wrong_signer = EventBuilder::new(Kind::Custom(9), "content") + .tags([]) + .sign_with_keys(&other_keys) + .expect("sign"); + let mut bad_signature = EventBuilder::new(Kind::Custom(9), "content") + .tags([]) + .sign_with_keys(&harness_keys) + .expect("sign"); + bad_signature.content.push_str(" tampered"); + + let kind_error = submit_verified_message(&rest, &wrong_kind) + .await + .expect_err("wrong kind"); + let signer_error = submit_verified_message(&rest, &wrong_signer) + .await + .expect_err("wrong signer"); + let signature_error = submit_verified_message(&rest, &bad_signature) + .await + .expect_err("bad signature"); + assert_eq!(kind_error.0, BrokerErrorCode::Unsupported); + assert_eq!(signer_error.0, BrokerErrorCode::Unauthorized); + assert_eq!(signature_error.0, BrokerErrorCode::InvalidRequest); + } + + #[tokio::test] + async fn exact_preflight_reconciles_without_resubmitting() { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "already stored") + .tags([]) + .sign_with_keys(&keys) + .expect("event"); + let (mut rest, submit_count) = spawn_test_relay( + Some(event.clone()), + false, + serde_json::json!({"accepted": false, "event_id": "wrong"}), + ) + .await; + rest.keys = keys; + + let result = submit_verified_message(&rest, &event) + .await + .expect("reconciled result"); + assert_eq!(result["event_id"], event.id.to_hex()); + assert_eq!(result["delivery_path"], "harness_broker"); + assert_eq!(result["readback_verified"], true); + assert_eq!(result["reconciled"], true); + assert_eq!(submit_count.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn rejected_receipt_is_reconciled_only_by_exact_readback() { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "stored despite receipt") + .tags([]) + .sign_with_keys(&keys) + .expect("event"); + let (mut rest, submit_count) = spawn_test_relay( + None, + true, + serde_json::json!({ + "accepted": false, + "event_id": "wrong", + "message": "ambiguous rejection" + }), + ) + .await; + rest.keys = keys; + + let result = submit_verified_message(&rest, &event) + .await + .expect("exact readback reconciles"); + assert_eq!(result["event_id"], event.id.to_hex()); + assert_eq!(result["delivery_path"], "harness_broker"); + assert_eq!(result["readback_verified"], true); + assert_eq!(result["reconciled"], true); + assert_eq!(submit_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn non_object_receipt_is_authoritative_after_exact_readback() { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "stored with malformed receipt") + .tags([]) + .sign_with_keys(&keys) + .expect("event"); + let (mut rest, submit_count) = + spawn_test_relay(None, true, serde_json::json!("stored")).await; + rest.keys = keys; + + let result = submit_verified_message(&rest, &event) + .await + .expect("exact readback is authoritative"); + assert_eq!(result["event_id"], event.id.to_hex()); + assert_eq!(result["accepted"], true); + assert_eq!(result["delivery_path"], "harness_broker"); + assert_eq!(result["readback_verified"], true); + assert_eq!(result["reconciled"], true); + assert_eq!(submit_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn rejected_or_wrong_receipt_without_readback_fails_closed() { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "not stored") + .tags([]) + .sign_with_keys(&keys) + .expect("event"); + let (mut rest, submit_count) = spawn_test_relay( + None, + false, + serde_json::json!({ + "accepted": false, + "event_id": "wrong", + "message": "rejected" + }), + ) + .await; + rest.keys = keys; + + let error = submit_verified_message(&rest, &event) + .await + .expect_err("missing readback must fail"); + assert_eq!(error.0, BrokerErrorCode::RelayRejected); + assert_eq!(submit_count.load(Ordering::SeqCst), 1); + } + + #[test] + fn capability_comparison_requires_exact_value() { + assert!(capabilities_equal("abc", "abc")); + assert!(!capabilities_equal("abc", "abd")); + assert!(!capabilities_equal("abc", "abcd")); + } + + #[cfg(unix)] + #[test] + fn request_reader_does_not_follow_symlinks() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let target = temp.path().join("target"); + let link = temp.path().join("request.json"); + std::fs::write(&target, b"secret").expect("target"); + symlink(&target, &link).expect("symlink"); + assert!(read_bounded_regular_file(&link, 1024).is_err()); + } + + #[cfg(windows)] + #[test] + fn request_reader_does_not_follow_reparse_point_symlinks() { + use std::os::windows::fs::symlink_file; + + let temp = tempfile::tempdir().expect("tempdir"); + let target = temp.path().join("target"); + let link = temp.path().join("request.json"); + std::fs::write(&target, b"secret").expect("target"); + if symlink_file(&target, &link).is_err() { + // Windows requires Developer Mode or SeCreateSymbolicLinkPrivilege. + return; + } + assert!(read_bounded_regular_file(&link, 1024).is_err()); + } + + #[test] + fn invalid_queue_entry_is_removed_instead_of_rescanned() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(temp.path().join("requests")).expect("requests"); + std::fs::create_dir(temp.path().join("processing")).expect("processing"); + let invalid = temp.path().join("requests/not-a-request.json"); + std::fs::write(&invalid, b"bad").expect("invalid request"); + + assert!(next_request_path(temp.path()).expect("scan").is_none()); + assert!(!invalid.exists()); + } + + #[test] + fn in_progress_atomic_request_file_is_not_removed() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(temp.path().join("requests")).expect("requests"); + std::fs::create_dir(temp.path().join("processing")).expect("processing"); + let partial = temp.path().join("requests/.request.json.random.tmp"); + std::fs::write(&partial, b"partial").expect("partial request"); + + assert!(next_request_path(temp.path()).expect("scan").is_none()); + assert!(partial.exists()); + } + + #[test] + fn atomic_staging_clutter_does_not_starve_a_complete_request() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(temp.path().join("requests")).expect("requests"); + std::fs::create_dir(temp.path().join("processing")).expect("processing"); + for index in 0..MAX_SCAN_ENTRIES { + std::fs::write( + temp.path() + .join("requests") + .join(format!(".request-{index}.tmp")), + b"partial", + ) + .expect("staging file"); + } + let request_id = Uuid::new_v4(); + std::fs::write( + temp.path() + .join("requests") + .join(format!("{request_id}.json")), + b"{}", + ) + .expect("complete request"); + + let claimed = next_request_path(temp.path()) + .expect("scan") + .expect("complete request remains claimable"); + assert_eq!(claimed.0, request_id); + assert_eq!( + std::fs::read_dir(temp.path().join("requests")) + .expect("requests") + .count(), + MAX_SCAN_ENTRIES + ); + } + + #[test] + fn saturated_broker_returns_a_correlated_signed_busy_response() { + use std::collections::HashSet; + + let temp = tempfile::tempdir().expect("tempdir"); + for child in ["requests", "processing", "responses"] { + std::fs::create_dir(temp.path().join(child)).expect("broker directory"); + } + let request_ids: HashSet = (0..=MAX_CONCURRENT_REQUESTS) + .map(|_| Uuid::new_v4()) + .collect(); + for request_id in &request_ids { + std::fs::write( + temp.path() + .join("requests") + .join(format!("{request_id}.json")), + b"{}", + ) + .expect("queued request"); + } + + let mut admitted = HashSet::new(); + for _ in 0..MAX_CONCURRENT_REQUESTS { + let (request_id, _) = next_request_path(temp.path()) + .expect("scan") + .expect("admitted request"); + admitted.insert(request_id); + } + let overflow_id = request_ids + .difference(&admitted) + .copied() + .next() + .expect("one overflow request"); + let response_keys = Keys::generate(); + reject_queued_requests(temp.path(), &response_keys); + + assert_eq!( + std::fs::read_dir(temp.path().join("processing")) + .expect("processing") + .count(), + MAX_CONCURRENT_REQUESTS, + "busy response must not disturb admitted work" + ); + assert_eq!( + std::fs::read_dir(temp.path().join("requests")) + .expect("requests") + .count(), + 0, + "overflow request must receive a response instead of timing out" + ); + let bytes = std::fs::read( + temp.path() + .join("responses") + .join(format!("{overflow_id}.json")), + ) + .expect("busy response"); + let envelope: BrokerResponseEnvelope = + serde_json::from_slice(&bytes).expect("signed response envelope"); + envelope.attestation.verify().expect("valid attestation"); + assert_eq!(envelope.attestation.pubkey, response_keys.public_key()); + assert_eq!( + envelope.attestation.content, + broker_response_digest(&envelope.response).expect("response digest") + ); + assert_eq!(envelope.response.request_id, overflow_id); + assert!(matches!( + envelope.response.error, + Some(buzz_core::delivery_broker::BrokerError { + code: BrokerErrorCode::Busy, + .. + }) + )); + } + + #[test] + fn oversized_response_is_replaced_by_a_bounded_signed_failure() { + let request_id = Uuid::new_v4(); + let response_keys = Keys::generate(); + let response = BrokerResponse::success( + request_id, + serde_json::json!({"payload": "x".repeat(MAX_BROKER_RESPONSE_BYTES as usize)}), + ); + + let bytes = encode_signed_response(response, &response_keys).expect("bounded envelope"); + assert!(bytes.len() as u64 <= MAX_BROKER_RESPONSE_BYTES); + let envelope: BrokerResponseEnvelope = + serde_json::from_slice(&bytes).expect("failure envelope"); + envelope.attestation.verify().expect("valid attestation"); + assert_eq!(envelope.attestation.pubkey, response_keys.public_key()); + assert_eq!( + envelope.attestation.content, + broker_response_digest(&envelope.response).expect("response digest") + ); + assert_eq!(envelope.response.request_id, request_id); + assert!(matches!( + envelope.response.error, + Some(buzz_core::delivery_broker::BrokerError { + code: BrokerErrorCode::Internal, + .. + }) + )); + } + + #[test] + fn stale_root_predicates_require_age_and_proven_dead_process() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100_000); + let old_root = now - LEGACY_ROOT_STALE_AGE - Duration::from_secs(1); + + assert!(legacy_root_is_stale(now, Some(old_root))); + assert!(!legacy_root_is_stale(now, Some(now))); + assert!(lease_root_is_reapable(true, ProcessLiveness::Dead)); + assert!(!lease_root_is_reapable(true, ProcessLiveness::Alive)); + assert!(!lease_root_is_reapable(true, ProcessLiveness::Unknown)); + assert!(!lease_root_is_reapable(false, ProcessLiveness::Dead)); + } + + #[cfg(unix)] + #[test] + fn current_process_is_not_classified_dead() { + assert_ne!(process_liveness(std::process::id()), ProcessLiveness::Dead); + } +} diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 0c4e5f158c..cf5e9ddb85 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2,6 +2,7 @@ mod acp; mod config; +mod delivery; mod engram_fetch; mod filter; mod observer; @@ -1365,6 +1366,42 @@ async fn tokio_main() -> Result<()> { ); } + // Codex keeps direct relay networking as the primary path. Give its child + // processes a narrow filesystem fallback for bounded queries/counts and + // exact signed message events when that direct transport fails. The + // capability and path are generated per harness lifetime and are forced + // into the child environment below; other runtimes retain direct HTTP. + let _delivery_broker = if config.has_generated_codex_config { + let auth_tag_json = std::env::var("BUZZ_AUTH_TAG") + .ok() + .filter(|value| !value.is_empty()) + .and_then(|value| buzz_sdk::nip_oa::parse_auth_tag(&value).ok()) + .and_then(|tag| serde_json::to_string(tag.as_slice()).ok()); + match delivery::DeliveryBroker::start(&config.relay_url, config.keys.clone(), auth_tag_json) + .and_then(|broker| { + let environment = broker.environment()?; + Ok((broker, environment)) + }) { + Ok((broker, environment)) => { + config.persona_env_vars.extend(environment); + tracing::info!("harness delivery broker enabled for Codex message transport"); + Some(broker) + } + Err(error) => { + // The broker must never widen an overlapping workspace merely + // to start. Keep Codex's direct network path available and make + // the missing fallback visible instead of taking the agent + // completely offline. + tracing::warn!( + "harness delivery broker disabled; Codex will use direct relay transport only: {error}" + ); + None + } + } + } else { + None + }; + let mut pool = if config.lazy_pool { AgentPool::from_slots((0..config.agents).map(|_| None).collect()) } else { @@ -3832,6 +3869,35 @@ fn normalized_agent_name(init_result: &serde_json::Value) -> String { .to_ascii_lowercase() } +fn validate_codex_workspace_write_capability( + init_result: &serde_json::Value, + required: bool, +) -> Result<()> { + if !required { + return Ok(()); + } + let capability = init_result.pointer("/_meta/codex/workspaceWriteConfig"); + let supported = capability + .and_then(|value| value.get("version")) + .and_then(serde_json::Value::as_u64) + .is_some_and(|version| version >= 1) + && capability + .and_then(|value| value.get("networkAccess")) + .and_then(serde_json::Value::as_bool) + == Some(true) + && capability + .and_then(|value| value.get("writableRoots")) + .and_then(serde_json::Value::as_bool) + == Some(true); + if supported { + Ok(()) + } else { + anyhow::bail!( + "Codex adapter is incompatible: this Buzz build requires per-turn workspace-write network and writable-root configuration; update @agentclientprotocol/codex-acp" + ) + } +} + async fn shutdown_agent_slots(slots: &mut [Option]) { for slot in slots { if let Some(mut agent) = slot.take() { @@ -3909,6 +3975,15 @@ async fn initialize_agent_pool( }; match initialize_result { Ok(Ok(init_result)) => { + if let Err(error) = validate_codex_workspace_write_capability( + &init_result, + startup.has_generated_codex_config, + ) { + tracing::error!(agent = i, "agent initialize rejected: {error}"); + acp.shutdown().await; + agent_slots.push(None); + continue; + } tracing::info!(agent = i, "agent initialized: {init_result}"); let protocol_version = init_result["protocolVersion"].as_u64().unwrap_or(1) as u32; @@ -3999,6 +4074,12 @@ async fn spawn_and_init( match acp.initialize().await { Ok(init_result) => { + if let Err(error) = + validate_codex_workspace_write_capability(&init_result, has_generated_codex_config) + { + acp.shutdown().await; + return Err(error); + } tracing::info!("agent initialized: {init_result}"); let protocol_version = init_result["protocolVersion"].as_u64().unwrap_or(1) as u32; acp.observe( @@ -4329,6 +4410,22 @@ fn build_mcp_servers(config: &Config) -> Vec { }); } } + for broker_name in [ + buzz_core::delivery_broker::BROKER_DIR_ENV, + buzz_core::delivery_broker::BROKER_CAPABILITY_ENV, + buzz_core::delivery_broker::BROKER_RESPONSE_PUBKEY_ENV, + ] { + if let Some((_, value)) = config + .persona_env_vars + .iter() + .find(|(name, _)| name == broker_name) + { + env.push(EnvVar { + name: broker_name.into(), + value: value.clone(), + }); + } + } env }, }] @@ -5378,6 +5475,36 @@ mod error_outcome_emission_tests { ); } + #[test] + fn codex_workspace_write_capability_is_required_only_for_codex_policy() { + let compatible = serde_json::json!({ + "_meta": { + "codex": { + "workspaceWriteConfig": { + "version": 1, + "networkAccess": true, + "writableRoots": true + } + } + } + }); + assert!(validate_codex_workspace_write_capability(&compatible, true).is_ok()); + assert!(validate_codex_workspace_write_capability( + &serde_json::json!({"agentInfo": {"name": "goose"}}), + false + ) + .is_ok()); + + for incompatible in [ + serde_json::json!({}), + serde_json::json!({"_meta":{"codex":{"workspaceWriteConfig":{"version":0,"networkAccess":true,"writableRoots":true}}}}), + serde_json::json!({"_meta":{"codex":{"workspaceWriteConfig":{"version":1,"networkAccess":false,"writableRoots":true}}}}), + serde_json::json!({"_meta":{"codex":{"workspaceWriteConfig":{"version":1,"networkAccess":true,"writableRoots":false}}}}), + ] { + assert!(validate_codex_workspace_write_capability(&incompatible, true).is_err()); + } + } + /// Spawn a real but inert agent subprocess (`cat`) so the error paths have /// an `OwnedAgent` to move into respawn or return to the pool. The error /// branches never talk to the subprocess. diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index aea5cee077..6e78e97f5f 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -259,6 +259,25 @@ fn unix_now_secs() -> u64 { } impl RestClient { + /// Build a standalone bridge client with the same timeout policy as the + /// harness relay. Used by the delivery broker before the WebSocket exists. + pub(crate) fn new( + relay_url: &str, + keys: Keys, + auth_tag_json: Option, + ) -> Result { + Ok(Self { + http: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + .map_err(|e| RelayError::Http(format!("failed to build HTTP client: {e}")))?, + base_url: relay_ws_to_http(relay_url), + keys, + auth_tag_json, + }) + } + /// Sign a NIP-98 HTTP Auth event (kind:27235) for the given method/URL/body. /// /// Returns the `Authorization: Nostr ` header value (without the @@ -399,10 +418,20 @@ impl RestClient { pub async fn query(&self, filters: &[nostr::Filter]) -> Result { let body_bytes = serde_json::to_vec(filters) .map_err(|e| RelayError::Http(format!("filter serialize error: {e}")))?; - let resp = self.bridge_post("/query", &body_bytes).await?; - resp.json() - .await - .map_err(|e| RelayError::Http(e.to_string())) + self.bridge_post_json("/query", &body_bytes).await + } + + /// Query with already-validated JSON filters, preserving relay extensions + /// such as composite pagination fields that `nostr::Filter` does not model. + pub(crate) async fn query_values(&self, filters: &[Value]) -> Result { + let body_bytes = serde_json::to_vec(filters) + .map_err(|e| RelayError::Http(format!("filter serialize error: {e}")))?; + self.bridge_post_json_bounded( + "/query", + &body_bytes, + buzz_core::delivery_broker::MAX_BROKER_RESULT_BYTES, + ) + .await } /// Count events via the HTTP bridge: `POST /count` with NIP-98 auth. @@ -412,12 +441,35 @@ impl RestClient { pub async fn count(&self, filters: &[nostr::Filter]) -> Result { let body_bytes = serde_json::to_vec(filters) .map_err(|e| RelayError::Http(format!("filter serialize error: {e}")))?; - let resp = self.bridge_post("/count", &body_bytes).await?; + self.bridge_post_json("/count", &body_bytes).await + } + + /// Count with already-validated JSON filters for the delivery broker. + pub(crate) async fn count_values(&self, filters: &[Value]) -> Result { + let body_bytes = serde_json::to_vec(filters) + .map_err(|e| RelayError::Http(format!("filter serialize error: {e}")))?; + self.bridge_post_json_bounded("/count", &body_bytes, 1024 * 1024) + .await + } + + async fn bridge_post_json(&self, path: &str, body_bytes: &[u8]) -> Result { + let resp = self.bridge_post(path, body_bytes).await?; resp.json() .await .map_err(|e| RelayError::Http(e.to_string())) } + async fn bridge_post_json_bounded( + &self, + path: &str, + body_bytes: &[u8], + max_bytes: u64, + ) -> Result { + let resp = self.bridge_post(path, body_bytes).await?; + let bytes = read_bounded_response(resp, max_bytes).await?; + serde_json::from_slice(&bytes).map_err(|e| RelayError::Http(e.to_string())) + } + /// Submit a signed event via the HTTP bridge: `POST /events` with NIP-98 auth. /// /// The event must already be signed. Returns the relay response JSON. @@ -425,15 +477,38 @@ impl RestClient { let body_bytes = serde_json::to_vec(event) .map_err(|e| RelayError::Http(format!("event serialize error: {e}")))?; let resp = self.bridge_post("/events", &body_bytes).await?; - let text = resp - .text() - .await - .map_err(|e| RelayError::Http(e.to_string()))?; - if text.is_empty() { + let bytes = read_bounded_response(resp, 1024 * 1024).await?; + if bytes.is_empty() { return Ok(Value::Null); } - serde_json::from_str(&text).map_err(|e| RelayError::Http(e.to_string())) + serde_json::from_slice(&bytes).map_err(|e| RelayError::Http(e.to_string())) + } +} + +async fn read_bounded_response( + response: reqwest::Response, + max_bytes: u64, +) -> Result, RelayError> { + if response + .content_length() + .is_some_and(|length| length > max_bytes) + { + return Err(RelayError::Http(format!( + "relay response exceeds {max_bytes} bytes" + ))); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| RelayError::Http(error.to_string()))?; + if (bytes.len() as u64).saturating_add(chunk.len() as u64) > max_bytes { + return Err(RelayError::Http(format!( + "relay response exceeds {max_bytes} bytes" + ))); + } + bytes.extend_from_slice(&chunk); } + Ok(bytes) } /// Events the harness cares about. @@ -4008,6 +4083,37 @@ async fn wait_for_any_ok( mod tests { use super::*; + #[tokio::test] + async fn bounded_response_rejects_chunked_body_before_unbounded_buffering() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind response server"); + let address = listener.local_addr().expect("response server address"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept response client"); + let mut request = vec![0_u8; 4096]; + let _ = stream.read(&mut request).await.expect("read request"); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n4\r\nabcd\r\n4\r\nefgh\r\n0\r\n\r\n", + ) + .await + .expect("write chunked response"); + }); + let response = reqwest::Client::new() + .get(format!("http://{address}")) + .send() + .await + .expect("get chunked response"); + let error = read_bounded_response(response, 6) + .await + .expect_err("body must exceed bound"); + assert!(error.to_string().contains("exceeds 6 bytes")); + server.await.expect("response server task"); + } + #[test] fn relay_ws_to_http_plain() { assert_eq!( diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 1476e60bfd..807be4e8f8 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -86,6 +86,12 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" # Random number generation — full jitter for exponential backoff in with_retry rand = { workspace = true } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } + [dev-dependencies] # Scratch files for channel-templates.json fixtures in tests tempfile = "3" diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index ee8868ad92..e8a17540c9 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -212,6 +212,23 @@ fn is_moderation_kind(kind: u16) -> bool { matches!(kind, 9040..=9044) } +fn is_broker_fallback_error(error: &CliError) -> bool { + match error { + CliError::Network(error) => { + error.is_connect() + || error.is_timeout() + || error.is_request() + || error.is_body() + || error.is_decode() + } + CliError::Relay { + status: 502..=504, .. + } => true, + CliError::DeliveryUnknown(_) => true, + _ => false, + } +} + /// Returns `true` for HTTP status codes that indicate a successful response /// (equivalent to `reqwest::StatusCode::is_success()` for u16). fn resp_was_success(status: u16) -> bool { @@ -496,6 +513,7 @@ mod media_download_tests { } const QUERY_PAGE_SIZE: u32 = 500; +const DIRECT_TRANSPORT_RETRY_COOLDOWN: Duration = Duration::from_secs(2); fn advance_query_cursor( filter: &mut serde_json::Value, @@ -522,6 +540,12 @@ pub struct BuzzClient { http: reqwest::Client, relay_url: String, // base URL, no trailing slash, e.g. "https://relay.buzz.place" keys: Keys, + /// Harness-owned transport used only when explicitly injected by buzz-acp. + delivery_broker: Option, + /// Short circuit-breaker window after a direct transport failure. This + /// avoids repeating a DNS/connect retry budget inside one command while + /// still probing the direct path again after a bounded cooldown. + direct_transport_degraded_until: std::sync::Mutex>, /// Optional NIP-OA auth tag injected into every signed event. auth_tag: Option, /// Raw JSON of the auth tag for the `x-auth-tag` HTTP header. @@ -544,6 +568,7 @@ impl BuzzClient { auth_tag: Option, auth_tag_json: Option, ) -> Result { + let delivery_broker = crate::delivery_broker::DeliveryBrokerClient::from_env()?; let http = reqwest::Client::builder() .timeout(env_duration_secs("BUZZ_TIMEOUT_SECS", 30)) .connect_timeout(env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15)) @@ -553,6 +578,8 @@ impl BuzzClient { http, relay_url, keys, + delivery_broker, + direct_transport_degraded_until: std::sync::Mutex::new(None), auth_tag, auth_tag_json, }) @@ -771,6 +798,30 @@ impl BuzzClient { /// Execute a one-shot query with multiple filters via the HTTP bridge. /// Each filter is ORed by the relay (standard Nostr REQ behavior). pub async fn query_multi(&self, filters: &[serde_json::Value]) -> Result { + if self.direct_transport_is_degraded() { + if let Some(broker) = &self.delivery_broker { + return broker.query(filters).await; + } + } + let direct_error = match self.query_multi_direct(filters).await { + Ok(response) => return Ok(response), + Err(error) => error, + }; + if let Some(broker) = &self.delivery_broker { + if !is_broker_fallback_error(&direct_error) { + return Err(direct_error); + } + self.note_direct_transport_failure(&direct_error); + eprintln!("direct relay query failed; trying harness delivery broker: {direct_error}"); + // Queries are read-only. Preserve the broker's signed error class + // so a Busy/503 remains safely retryable instead of being flattened + // into a non-retryable local error. + return broker.query(filters).await; + } + Err(direct_error) + } + + async fn query_multi_direct(&self, filters: &[serde_json::Value]) -> Result { let url = format!("{}/query", self.relay_url); let body = bytes::Bytes::from( serde_json::to_vec(filters) @@ -801,6 +852,28 @@ impl BuzzClient { /// Returns the count as a JSON string. #[allow(dead_code)] pub async fn count(&self, filter: &serde_json::Value) -> Result { + if self.direct_transport_is_degraded() { + if let Some(broker) = &self.delivery_broker { + return broker.count(std::slice::from_ref(filter)).await; + } + } + let direct_error = match self.count_direct(filter).await { + Ok(response) => return Ok(response), + Err(error) => error, + }; + if let Some(broker) = &self.delivery_broker { + if !is_broker_fallback_error(&direct_error) { + return Err(direct_error); + } + self.note_direct_transport_failure(&direct_error); + eprintln!("direct relay count failed; trying harness delivery broker: {direct_error}"); + // Counts are read-only. Preserve retryable broker overload errors. + return broker.count(std::slice::from_ref(filter)).await; + } + Err(direct_error) + } + + async fn count_direct(&self, filter: &serde_json::Value) -> Result { let url = format!("{}/count", self.relay_url); let body = bytes::Bytes::from( serde_json::to_vec(&[filter]) @@ -1022,6 +1095,53 @@ impl BuzzClient { /// Content-addressed uploads are exempt: same bytes ⇒ same hash, so outer /// re-run is safe regardless of the failure kind. async fn submit_stored_event(&self, event: nostr::Event) -> Result { + if buzz_core::delivery_broker::is_brokered_message_kind(event.kind.as_u16()) { + if let Some(broker) = &self.delivery_broker { + if self.direct_transport_is_degraded() { + return broker.submit_message(&event).await; + } + let direct_error = match self.submit_stored_event_direct(&event).await { + Ok(response) => { + let receipt = validate_message_receipt(&response, &event); + match self.verify_message_readback(&event).await { + Ok(()) => { + return Ok(mark_verified_delivery_path( + &response, + "direct", + &event, + receipt.is_err(), + )); + } + Err(readback_error) => receipt.err().unwrap_or(readback_error), + } + } + Err(error) => error, + }; + if !is_broker_fallback_error(&direct_error) { + return Err(direct_error); + } + self.note_direct_transport_failure(&direct_error); + eprintln!( + "direct message delivery failed; trying the same signed event through the harness broker: {direct_error}" + ); + return broker.submit_message(&event).await.map_err(|broker_error| { + if matches!(&direct_error, CliError::Network(error) if error.is_connect()) + && matches!(&broker_error, CliError::Relay { .. }) + { + broker_error + } else { + CliError::DeliveryUnknown(format!( + "direct message delivery failed ({direct_error}); delivery broker fallback failed ({broker_error})" + )) + } + }); + } + } + + self.submit_stored_event_direct(&event).await + } + + async fn submit_stored_event_direct(&self, event: &nostr::Event) -> Result { let url = format!("{}/events", self.relay_url); let body = bytes::Bytes::from( serde_json::to_vec(&event) @@ -1065,6 +1185,58 @@ impl BuzzClient { result } + fn direct_transport_is_degraded(&self) -> bool { + let Ok(mut degraded_until) = self.direct_transport_degraded_until.lock() else { + return false; + }; + match *degraded_until { + Some(until) if std::time::Instant::now() < until => true, + _ => { + *degraded_until = None; + false + } + } + } + + fn note_direct_transport_failure(&self, error: &CliError) { + if is_broker_fallback_error(error) { + if let Ok(mut degraded_until) = self.direct_transport_degraded_until.lock() { + *degraded_until = Some(std::time::Instant::now() + DIRECT_TRANSPORT_RETRY_COOLDOWN); + } + } + } + + async fn verify_message_readback(&self, expected: &nostr::Event) -> Result<(), CliError> { + let filter = serde_json::json!({"ids": [expected.id.to_hex()], "limit": 1}); + let delays = [50_u64, 100, 200, 400]; + let mut last_error = None; + for (index, delay_ms) in delays.iter().copied().enumerate() { + match self.query(&filter).await { + Ok(raw) => match serde_json::from_str::>(&raw) { + Ok(events) => { + if let Some(found) = events.first() { + if found.verify().is_ok() && found == expected { + return Ok(()); + } + return Err(CliError::DeliveryUnknown( + "direct message readback did not exactly match the signed event" + .into(), + )); + } + } + Err(error) => last_error = Some(error.to_string()), + }, + Err(error) => last_error = Some(error.to_string()), + } + if index + 1 < delays.len() { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } + } + Err(CliError::DeliveryUnknown(last_error.unwrap_or_else(|| { + "direct message was accepted but not visible on exact readback".into() + }))) + } + /// Publish an ephemeral event via WebSocket with NIP-42 authentication. /// /// The relay rejects ephemeral kinds (20000–29999) over HTTP. Delegates to @@ -1426,17 +1598,286 @@ pub fn extract_relay_response_field(resp: &str, field: &str) -> Option { pub fn normalize_write_response(raw: &str) -> String { if let Ok(v) = serde_json::from_str::(raw) { if v.get("event_id").is_some() || v.get("accepted").is_some() { - return serde_json::json!({ + let mut normalized = serde_json::json!({ "event_id": v.get("event_id").and_then(|v| v.as_str()).unwrap_or(""), "accepted": v.get("accepted").and_then(|v| v.as_bool()).unwrap_or(false), "message": v.get("message").and_then(|v| v.as_str()).unwrap_or(""), - }) - .to_string(); + }); + if let Some(object) = normalized.as_object_mut() { + for field in ["delivery_path", "readback_verified", "reconciled"] { + if let Some(value) = v.get(field) { + object.insert(field.into(), value.clone()); + } + } + } + return normalized.to_string(); } } raw.to_string() } +fn mark_verified_delivery_path( + raw: &str, + path: &str, + event: &nostr::Event, + reconciled: bool, +) -> String { + let mut object = serde_json::from_str::(raw) + .ok() + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + object.insert("event_id".into(), serde_json::json!(event.id.to_hex())); + object.insert("accepted".into(), serde_json::json!(true)); + object.insert("delivery_path".into(), serde_json::json!(path)); + object.insert("readback_verified".into(), serde_json::json!(true)); + object.insert("reconciled".into(), serde_json::json!(reconciled)); + serde_json::Value::Object(object).to_string() +} + +fn validate_message_receipt(raw: &str, event: &nostr::Event) -> Result<(), CliError> { + let value: serde_json::Value = serde_json::from_str(raw).map_err(|error| { + CliError::DeliveryUnknown(format!("message receipt was not valid JSON: {error}")) + })?; + let accepted = value + .get("accepted") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| { + CliError::DeliveryUnknown( + "message receipt did not contain a boolean accepted field".into(), + ) + })?; + if !accepted { + let message = value + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("relay rejected the signed message"); + Err(CliError::Relay { + status: 400, + body: message.into(), + }) + } else { + let event_id = value + .get("event_id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + CliError::DeliveryUnknown( + "accepted message receipt did not contain an event id".into(), + ) + })?; + if event_id == event.id.to_hex() { + Ok(()) + } else { + Err(CliError::DeliveryUnknown( + "message receipt did not match the signed event id".into(), + )) + } + } +} + +#[cfg(test)] +mod delivery_contract_tests { + use super::*; + use axum::{routing::post, Json, Router}; + use buzz_core::delivery_broker::{ + broker_response_digest, BrokerOperation, BrokerRequest, BrokerResponse, + BrokerResponseEnvelope, BROKER_RESPONSE_ATTESTATION_KIND, + }; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + + fn signed_message() -> nostr::Event { + EventBuilder::new(Kind::Custom(9), "delivery contract") + .tags([]) + .sign_with_keys(&Keys::generate()) + .expect("signed message") + } + + #[test] + fn matching_receipt_and_exact_readback_marker_report_direct_path() { + let event = signed_message(); + let receipt = serde_json::json!({ + "event_id": event.id.to_hex(), + "accepted": true, + "message": "stored" + }) + .to_string(); + validate_message_receipt(&receipt, &event).expect("valid receipt"); + let marked = mark_verified_delivery_path(&receipt, "direct", &event, false); + let value: serde_json::Value = serde_json::from_str(&marked).expect("marked json"); + assert_eq!(value["event_id"], event.id.to_hex()); + assert_eq!(value["accepted"], true); + assert_eq!(value["delivery_path"], "direct"); + assert_eq!(value["readback_verified"], true); + assert_eq!(value["reconciled"], false); + } + + #[test] + fn exact_readback_synthesizes_success_for_a_malformed_receipt() { + let event = signed_message(); + let marked = mark_verified_delivery_path("not-json", "direct", &event, true); + let value: serde_json::Value = serde_json::from_str(&marked).expect("canonical result"); + assert_eq!(value["event_id"], event.id.to_hex()); + assert_eq!(value["accepted"], true); + assert_eq!(value["delivery_path"], "direct"); + assert_eq!(value["readback_verified"], true); + assert_eq!(value["reconciled"], true); + } + + #[test] + fn rejected_and_wrong_id_receipts_fail_with_distinct_outcomes() { + let event = signed_message(); + let rejected = serde_json::json!({ + "event_id": event.id.to_hex(), + "accepted": false, + "message": "denied" + }) + .to_string(); + assert!(matches!( + validate_message_receipt(&rejected, &event), + Err(CliError::Relay { status: 400, .. }) + )); + + let wrong_id = serde_json::json!({ + "event_id": "wrong", + "accepted": true, + "message": "stored" + }) + .to_string(); + assert!(matches!( + validate_message_receipt(&wrong_id, &event), + Err(CliError::DeliveryUnknown(_)) + )); + } + + #[test] + fn fallback_decision_is_limited_to_transport_or_ambiguous_outcomes() { + assert!(is_broker_fallback_error(&CliError::DeliveryUnknown( + "ambiguous".into() + ))); + assert!(is_broker_fallback_error(&CliError::Relay { + status: 503, + body: "unavailable".into() + })); + assert!(!is_broker_fallback_error(&CliError::Relay { + status: 400, + body: "denied".into() + })); + assert!(!is_broker_fallback_error(&CliError::Usage( + "invalid".into() + ))); + } + + #[tokio::test] + async fn ambiguous_direct_message_result_falls_back_with_the_same_signed_event() { + let direct_event_posts = Arc::new(AtomicUsize::new(0)); + let event_posts = direct_event_posts.clone(); + let app = Router::new() + .route( + "/events", + post(move || { + let event_posts = event_posts.clone(); + async move { + event_posts.fetch_add(1, Ordering::SeqCst); + Json(serde_json::json!({"unexpected": "receipt"})) + } + }), + ) + .route("/query", post(|| async { Json(serde_json::json!([])) })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("direct relay listener"); + let direct_url = format!("http://{}", listener.local_addr().expect("listener addr")); + let direct_server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("direct relay server") + }); + + let temp = tempfile::tempdir().expect("broker root"); + std::fs::create_dir(temp.path().join("requests")).expect("requests"); + std::fs::create_dir(temp.path().join("processing")).expect("processing"); + std::fs::create_dir(temp.path().join("responses")).expect("responses"); + let response_keys = Keys::generate(); + let broker = crate::delivery_broker::DeliveryBrokerClient::new( + temp.path().to_path_buf(), + "secret".into(), + response_keys.public_key(), + ) + .expect("broker client"); + + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "exact structured fallback") + .tags([]) + .sign_with_keys(&keys) + .expect("signed message"); + let expected = event.clone(); + let request_dir = temp.path().join("requests"); + let response_dir = temp.path().join("responses"); + let broker_server = tokio::spawn(async move { + loop { + if let Some(entry) = std::fs::read_dir(&request_dir) + .expect("read requests") + .flatten() + .next() + { + let request: BrokerRequest = + serde_json::from_slice(&std::fs::read(entry.path()).expect("read request")) + .expect("decode request"); + let BrokerOperation::SubmitStoredMessage { event } = request.operation else { + panic!("expected stored-message request"); + }; + assert_eq!(*event, expected); + let response = BrokerResponse::success( + request.request_id, + serde_json::json!({ + "event_id": expected.id.to_hex(), + "accepted": true, + "message": "stored", + "delivery_path": "harness_broker", + "readback_verified": true, + "reconciled": false + }), + ); + let attestation = EventBuilder::new( + Kind::Custom(BROKER_RESPONSE_ATTESTATION_KIND), + broker_response_digest(&response).expect("response digest"), + ) + .tags([]) + .sign_with_keys(&response_keys) + .expect("response attestation"); + let envelope = BrokerResponseEnvelope { + response, + attestation, + }; + crate::delivery_broker::write_atomic( + &response_dir.join(format!("{}.json", request.request_id)), + &serde_json::to_vec(&envelope).expect("encode response"), + ) + .expect("write response"); + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }); + + let mut client = BuzzClient::new(direct_url, keys, None, None).expect("client"); + client.delivery_broker = Some(broker); + let raw = client + .submit_stored_event(event) + .await + .expect("broker fallback delivery"); + broker_server.await.expect("broker task"); + direct_server.abort(); + + let value: serde_json::Value = serde_json::from_str(&raw).expect("delivery result"); + assert_eq!(direct_event_posts.load(Ordering::SeqCst), 1); + assert_eq!(value["delivery_path"], "harness_broker"); + assert_eq!(value["readback_verified"], true); + } +} + #[cfg(test)] mod retry_tests { use std::time::Duration; diff --git a/crates/buzz-cli/src/delivery_broker.rs b/crates/buzz-cli/src/delivery_broker.rs new file mode 100644 index 0000000000..7598c08e0d --- /dev/null +++ b/crates/buzz-cli/src/delivery_broker.rs @@ -0,0 +1,663 @@ +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use buzz_core::delivery_broker::{ + broker_response_digest, BrokerErrorCode, BrokerOperation, BrokerRequest, BrokerResponse, + BrokerResponseEnvelope, BROKER_CAPABILITY_ENV, BROKER_DIR_ENV, BROKER_PROTOCOL_VERSION, + BROKER_RESPONSE_ATTESTATION_KIND, BROKER_RESPONSE_PUBKEY_ENV, MAX_BROKER_REQUEST_BYTES, + MAX_BROKER_RESPONSE_BYTES, +}; +use nostr::PublicKey; +use serde_json::Value; +use uuid::Uuid; + +use crate::error::CliError; + +// The broker admits requests up to 30 seconds old and bounds claimed work at +// 110 seconds. Leave margin for atomic response publication and polling. +const BROKER_RESPONSE_TIMEOUT: Duration = Duration::from_secs(150); +const BROKER_POLL_INTERVAL: Duration = Duration::from_millis(20); +const MAX_IN_FLIGHT_REQUESTS: usize = 8; + +#[derive(Clone)] +pub(crate) struct DeliveryBrokerClient { + root: PathBuf, + capability: String, + response_pubkey: PublicKey, +} + +impl DeliveryBrokerClient { + pub(crate) fn from_env() -> Result, CliError> { + let root = std::env::var_os(BROKER_DIR_ENV); + let capability = std::env::var(BROKER_CAPABILITY_ENV).ok(); + let response_pubkey = std::env::var(BROKER_RESPONSE_PUBKEY_ENV).ok(); + match (root, capability, response_pubkey) { + (None, None, None) => Ok(None), + (Some(root), Some(capability), Some(response_pubkey)) if !capability.is_empty() => { + let response_pubkey = PublicKey::parse(&response_pubkey).map_err(|_| { + CliError::Other("delivery broker response pubkey is invalid".into()) + })?; + Self::new(PathBuf::from(root), capability, response_pubkey).map(Some) + } + _ => Err(CliError::Other(format!( + "delivery broker is partially configured; {BROKER_DIR_ENV}, \ + {BROKER_CAPABILITY_ENV}, and {BROKER_RESPONSE_PUBKEY_ENV} are required" + ))), + } + } + + pub(crate) fn new( + root: PathBuf, + capability: String, + response_pubkey: PublicKey, + ) -> Result { + if !root.is_absolute() { + return Err(CliError::Other( + "delivery broker directory must be an absolute path".into(), + )); + } + for child in ["requests", "processing", "responses"] { + let path = root.join(child); + let metadata = std::fs::symlink_metadata(&path).map_err(|e| { + CliError::Other(format!( + "delivery broker {child} directory is unavailable: {e}" + )) + })?; + if metadata.file_type().is_symlink() + || metadata_is_reparse_point(&metadata) + || !metadata.is_dir() + { + return Err(CliError::Other(format!( + "delivery broker {child} path is not a real directory" + ))); + } + } + Ok(Self { + root, + capability, + response_pubkey, + }) + } + + pub(crate) async fn query(&self, filters: &[Value]) -> Result { + self.call(BrokerOperation::Query { + filters: filters.to_vec(), + }) + .await + .map(|value| value.to_string()) + } + + pub(crate) async fn count(&self, filters: &[Value]) -> Result { + self.call(BrokerOperation::Count { + filters: filters.to_vec(), + }) + .await + .map(|value| value.to_string()) + } + + pub(crate) async fn submit_message(&self, event: &nostr::Event) -> Result { + self.call(BrokerOperation::SubmitStoredMessage { + event: Box::new(event.clone()), + }) + .await + .map(|value| value.to_string()) + } + + async fn call(&self, operation: BrokerOperation) -> Result { + self.call_with_timeout(operation, BROKER_RESPONSE_TIMEOUT) + .await + } + + async fn call_with_timeout( + &self, + operation: BrokerOperation, + timeout: Duration, + ) -> Result { + let request_id = Uuid::new_v4(); + let request = BrokerRequest { + version: BROKER_PROTOCOL_VERSION, + request_id, + capability: self.capability.clone(), + created_at_ms: unix_now_ms(), + operation, + }; + let bytes = serde_json::to_vec(&request) + .map_err(|e| CliError::Other(format!("delivery broker request encode failed: {e}")))?; + if bytes.len() as u64 > MAX_BROKER_REQUEST_BYTES { + return Err(CliError::Usage(format!( + "delivery broker request exceeds {} bytes", + MAX_BROKER_REQUEST_BYTES + ))); + } + + let request_path = self + .root + .join("requests") + .join(format!("{request_id}.json")); + let in_flight = ["requests", "processing"] + .into_iter() + .map(|child| count_request_files(&self.root.join(child))) + .collect::, _>>()? + .into_iter() + .sum::(); + if in_flight >= MAX_IN_FLIGHT_REQUESTS { + return Err(CliError::Relay { + status: 503, + body: format!( + "delivery broker is at its in-flight limit (max {MAX_IN_FLIGHT_REQUESTS})" + ), + }); + } + write_atomic(&request_path, &bytes) + .map_err(|e| CliError::Other(format!("delivery broker request publish failed: {e}")))?; + + let response_path = self + .root + .join("responses") + .join(format!("{request_id}.json")); + let deadline = tokio::time::Instant::now() + timeout; + loop { + match read_bounded_regular_file(&response_path, MAX_BROKER_RESPONSE_BYTES) { + Ok(Some(bytes)) => { + let _ = std::fs::remove_file(&response_path); + let envelope: BrokerResponseEnvelope = + serde_json::from_slice(&bytes).map_err(|e| { + CliError::Other(format!("delivery broker response decode failed: {e}")) + })?; + verify_envelope(&envelope, &self.response_pubkey)?; + return parse_response(request_id, envelope.response); + } + Ok(None) => {} + Err(e) => { + return Err(CliError::Other(format!( + "delivery broker response read failed: {e}" + ))) + } + } + + if tokio::time::Instant::now() >= deadline { + let _ = std::fs::remove_file(&request_path); + let _ = std::fs::remove_file(&response_path); + return Err(CliError::DeliveryUnknown(format!( + "delivery broker timed out waiting for request {request_id}; the operation may have completed" + ))); + } + tokio::time::sleep(BROKER_POLL_INTERVAL).await; + } + } +} + +fn count_request_files(directory: &Path) -> Result { + Ok(std::fs::read_dir(directory) + .map_err(|e| CliError::Other(format!("delivery broker queue read failed: {e}")))? + .filter_map(Result::ok) + .filter(|entry| { + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + return false; + } + std::fs::symlink_metadata(path).is_ok_and(|metadata| { + metadata.is_file() + && !metadata.file_type().is_symlink() + && !metadata_is_reparse_point(&metadata) + }) + }) + .take(MAX_IN_FLIGHT_REQUESTS) + .count()) +} + +fn verify_envelope( + envelope: &BrokerResponseEnvelope, + expected_pubkey: &PublicKey, +) -> Result<(), CliError> { + if envelope.attestation.pubkey != *expected_pubkey + || envelope.attestation.kind.as_u16() != BROKER_RESPONSE_ATTESTATION_KIND + || !envelope.attestation.tags.is_empty() + { + return Err(CliError::Other( + "delivery broker response attestation identity or shape is invalid".into(), + )); + } + envelope.attestation.verify().map_err(|e| { + CliError::Other(format!( + "delivery broker response signature verification failed: {e}" + )) + })?; + let expected_content = broker_response_digest(&envelope.response).map_err(|e| { + CliError::Other(format!( + "delivery broker response canonicalization failed: {e}" + )) + })?; + if envelope.attestation.content != expected_content { + return Err(CliError::Other( + "delivery broker response attestation did not match its payload".into(), + )); + } + Ok(()) +} + +fn parse_response(request_id: Uuid, response: BrokerResponse) -> Result { + if response.version != BROKER_PROTOCOL_VERSION || response.request_id != request_id { + return Err(CliError::Other( + "delivery broker returned a mismatched protocol response".into(), + )); + } + match (response.result, response.error) { + (Some(result), None) => Ok(result), + (None, Some(error)) => { + let detail = format!("delivery broker {:?}: {}", error.code, error.message); + match error.code { + BrokerErrorCode::Busy => Err(CliError::Relay { + status: 503, + body: detail, + }), + BrokerErrorCode::RelayRejected => Err(CliError::Relay { + status: 400, + body: detail, + }), + BrokerErrorCode::DeliveryUnknown => Err(CliError::DeliveryUnknown(detail)), + _ => Err(CliError::Other(detail)), + } + } + _ => Err(CliError::Other( + "delivery broker response must contain exactly one of result or error".into(), + )), + } +} + +fn unix_now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u128::from(u64::MAX)) as u64 +} + +fn read_bounded_regular_file(path: &Path, max_bytes: u64) -> std::io::Result>> { + let mut file = match open_read_nofollow(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata_is_reparse_point(&metadata) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "broker response is not a regular file", + )); + } + if metadata.len() > max_bytes { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "broker response exceeds size limit", + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + Read::by_ref(&mut file) + .take(max_bytes + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > max_bytes { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "broker response exceeds size limit", + )); + } + Ok(Some(bytes)) +} + +fn open_read_nofollow(path: &Path) -> std::io::Result { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + // Open the reparse point itself instead of traversing it. The opened + // handle's metadata is then checked for FILE_ATTRIBUTE_REPARSE_POINT. + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + options.open(path) +} + +fn metadata_is_reparse_point(metadata: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; + return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; + } + #[cfg(not(windows))] + { + let _ = metadata; + false + } +} + +pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let parent = path.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent") + })?; + let temp_path = parent.join(format!( + ".{}.{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("broker"), + Uuid::new_v4() + )); + + #[cfg(unix)] + let mut file = { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temp_path)? + }; + #[cfg(not(unix))] + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path)?; + + let result = (|| { + file.write_all(bytes)?; + file.sync_all()?; + std::fs::rename(&temp_path, path) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temp_path); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind}; + + #[tokio::test] + async fn request_and_response_round_trip_is_correlated() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(temp.path().join("requests")).expect("requests"); + std::fs::create_dir(temp.path().join("processing")).expect("processing"); + std::fs::create_dir(temp.path().join("responses")).expect("responses"); + let response_keys = Keys::generate(); + let client = DeliveryBrokerClient::new( + temp.path().to_path_buf(), + "secret".into(), + response_keys.public_key(), + ) + .expect("client"); + let request_dir = temp.path().join("requests"); + let response_dir = temp.path().join("responses"); + + let server = tokio::spawn(async move { + loop { + let entry = std::fs::read_dir(&request_dir) + .expect("read requests") + .flatten() + .next(); + if let Some(entry) = entry { + let request: BrokerRequest = + serde_json::from_slice(&std::fs::read(entry.path()).expect("read request")) + .expect("decode request"); + let response = BrokerResponse::success( + request.request_id, + serde_json::json!({"count": 3}), + ); + let attestation = EventBuilder::new( + Kind::Custom(BROKER_RESPONSE_ATTESTATION_KIND), + broker_response_digest(&response).expect("response digest"), + ) + .tags([]) + .sign_with_keys(&response_keys) + .expect("sign response"); + let envelope = BrokerResponseEnvelope { + response, + attestation, + }; + let path = response_dir.join(format!("{}.json", request.request_id)); + write_atomic( + &path, + &serde_json::to_vec(&envelope).expect("encode response"), + ) + .expect("write response"); + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }); + + let value = client + .call_with_timeout( + BrokerOperation::Count { + filters: vec![serde_json::json!({"kinds": [9]})], + }, + Duration::from_secs(2), + ) + .await + .expect("broker call"); + server.await.expect("server task"); + assert_eq!(value, serde_json::json!({"count": 3})); + } + + #[tokio::test] + async fn submitted_message_preserves_the_exact_signed_event() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(temp.path().join("requests")).expect("requests"); + std::fs::create_dir(temp.path().join("processing")).expect("processing"); + std::fs::create_dir(temp.path().join("responses")).expect("responses"); + let response_keys = Keys::generate(); + let client = DeliveryBrokerClient::new( + temp.path().to_path_buf(), + "secret".into(), + response_keys.public_key(), + ) + .expect("client"); + let event = EventBuilder::new(Kind::Custom(9), "exact\nstructured reply") + .tags([]) + .sign_with_keys(&Keys::generate()) + .expect("event"); + let expected = event.clone(); + let request_dir = temp.path().join("requests"); + let response_dir = temp.path().join("responses"); + + let server = tokio::spawn(async move { + loop { + if let Some(entry) = std::fs::read_dir(&request_dir) + .expect("read requests") + .flatten() + .next() + { + let request: BrokerRequest = + serde_json::from_slice(&std::fs::read(entry.path()).expect("read request")) + .expect("decode request"); + let BrokerOperation::SubmitStoredMessage { event } = request.operation else { + panic!("expected stored message operation"); + }; + assert_eq!(*event, expected); + let response = BrokerResponse::success( + request.request_id, + serde_json::json!({ + "event_id": expected.id.to_hex(), + "accepted": true, + "delivery_path": "harness_broker", + "readback_verified": true, + "reconciled": false + }), + ); + let attestation = EventBuilder::new( + Kind::Custom(BROKER_RESPONSE_ATTESTATION_KIND), + broker_response_digest(&response).expect("response digest"), + ) + .tags([]) + .sign_with_keys(&response_keys) + .expect("sign response"); + let envelope = BrokerResponseEnvelope { + response, + attestation, + }; + write_atomic( + &response_dir.join(format!("{}.json", request.request_id)), + &serde_json::to_vec(&envelope).expect("encode response"), + ) + .expect("write response"); + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }); + + let raw = client + .submit_message(&event) + .await + .expect("broker delivery"); + server.await.expect("server task"); + let value: serde_json::Value = serde_json::from_str(&raw).expect("response json"); + assert_eq!(value["event_id"], event.id.to_hex()); + assert_eq!(value["delivery_path"], "harness_broker"); + assert_eq!(value["readback_verified"], true); + } + + #[test] + fn mismatched_response_id_fails_closed() { + let expected = Uuid::new_v4(); + let response = BrokerResponse::success(Uuid::new_v4(), serde_json::json!([])); + assert!(parse_response(expected, response).is_err()); + } + + #[test] + fn forged_response_attestation_is_rejected() { + let expected_keys = Keys::generate(); + let attacker_keys = Keys::generate(); + let response = BrokerResponse::success(Uuid::new_v4(), serde_json::json!([])); + let attestation = EventBuilder::new( + Kind::Custom(BROKER_RESPONSE_ATTESTATION_KIND), + broker_response_digest(&response).expect("response digest"), + ) + .tags([]) + .sign_with_keys(&attacker_keys) + .expect("sign response"); + let envelope = BrokerResponseEnvelope { + response, + attestation, + }; + + assert!(verify_envelope(&envelope, &expected_keys.public_key()).is_err()); + } + + #[test] + fn response_attestation_binds_result_request_and_protocol() { + let response_keys = Keys::generate(); + let response = BrokerResponse::success(Uuid::new_v4(), serde_json::json!({"count": 1})); + let attestation = EventBuilder::new( + Kind::Custom(BROKER_RESPONSE_ATTESTATION_KIND), + broker_response_digest(&response).expect("response digest"), + ) + .tags([]) + .sign_with_keys(&response_keys) + .expect("sign response"); + let envelope = BrokerResponseEnvelope { + response, + attestation, + }; + verify_envelope(&envelope, &response_keys.public_key()).expect("original envelope"); + + let mut mutated_result = envelope.clone(); + mutated_result.response.result = Some(serde_json::json!({"count": 2})); + assert!(verify_envelope(&mutated_result, &response_keys.public_key()).is_err()); + + let mut mutated_request = envelope.clone(); + mutated_request.response.request_id = Uuid::new_v4(); + assert!(verify_envelope(&mutated_request, &response_keys.public_key()).is_err()); + + let mut mutated_protocol = envelope; + mutated_protocol.response.version = BROKER_PROTOCOL_VERSION.saturating_add(1); + assert!(verify_envelope(&mutated_protocol, &response_keys.public_key()).is_err()); + } + + #[cfg(unix)] + #[test] + fn response_reader_does_not_follow_symlinks() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let target = temp.path().join("target"); + let link = temp.path().join("response.json"); + std::fs::write(&target, b"forged").expect("target"); + symlink(&target, &link).expect("symlink"); + assert!(read_bounded_regular_file(&link, 1024).is_err()); + } + + #[cfg(windows)] + #[test] + fn response_reader_does_not_follow_reparse_point_symlinks() { + use std::os::windows::fs::symlink_file; + + let temp = tempfile::tempdir().expect("tempdir"); + let target = temp.path().join("target"); + let link = temp.path().join("response.json"); + std::fs::write(&target, b"forged").expect("target"); + if symlink_file(&target, &link).is_err() { + // Windows requires Developer Mode or SeCreateSymbolicLinkPrivilege. + return; + } + assert!(read_bounded_regular_file(&link, 1024).is_err()); + } + + #[tokio::test] + async fn queue_cap_rejects_before_publishing_another_request() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(temp.path().join("requests")).expect("requests"); + std::fs::create_dir(temp.path().join("processing")).expect("processing"); + std::fs::create_dir(temp.path().join("responses")).expect("responses"); + for index in 0..MAX_IN_FLIGHT_REQUESTS { + let child = if index % 2 == 0 { + "requests" + } else { + "processing" + }; + std::fs::write(temp.path().join(child).join(format!("{index}.json")), b"x") + .expect("in-flight file"); + } + let client = DeliveryBrokerClient::new( + temp.path().to_path_buf(), + "secret".into(), + Keys::generate().public_key(), + ) + .expect("client"); + let error = client + .call_with_timeout( + BrokerOperation::Count { + filters: vec![serde_json::json!({"kinds": [9]})], + }, + Duration::from_millis(1), + ) + .await + .expect_err("queue must be full"); + assert!(matches!(error, CliError::Relay { status: 503, .. })); + assert!(crate::error::is_retryable_error(&error)); + assert!(error.to_string().contains("in-flight limit")); + } + + #[test] + fn signed_busy_response_is_a_retryable_503() { + let request_id = Uuid::new_v4(); + let response = BrokerResponse::failure( + request_id, + BrokerErrorCode::Busy, + "delivery broker is at its in-flight limit", + ); + + let error = parse_response(request_id, response).expect_err("busy must fail"); + assert!(matches!(error, CliError::Relay { status: 503, .. })); + assert!(crate::error::is_retryable_error(&error)); + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 8a8bb053b0..96f2083168 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1,6 +1,7 @@ pub mod agent_management; mod client; mod commands; +mod delivery_broker; mod error; mod links; mod validate; diff --git a/crates/buzz-core/src/delivery_broker.rs b/crates/buzz-core/src/delivery_broker.rs new file mode 100644 index 0000000000..dada8e53bf --- /dev/null +++ b/crates/buzz-core/src/delivery_broker.rs @@ -0,0 +1,216 @@ +//! Wire types for the narrow ACP delivery broker. +//! +//! The broker is intentionally transport-only: the CLI still resolves mentions +//! and threads, builds tags, and signs events. The harness only performs the +//! relay HTTP operations that a sandboxed CLI cannot perform itself. + +use nostr::Event; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +/// Environment variable containing the broker's private request directory. +pub const BROKER_DIR_ENV: &str = "BUZZ_DELIVERY_BROKER_DIR"; +/// Environment variable containing the per-harness broker capability. +pub const BROKER_CAPABILITY_ENV: &str = "BUZZ_DELIVERY_BROKER_CAPABILITY"; +/// Environment variable containing the broker's ephemeral response-signing pubkey. +pub const BROKER_RESPONSE_PUBKEY_ENV: &str = "BUZZ_DELIVERY_BROKER_RESPONSE_PUBKEY"; +/// Current on-disk protocol version. +pub const BROKER_PROTOCOL_VERSION: u8 = 1; +/// Local-only Nostr kind used to attest broker response bytes. +pub const BROKER_RESPONSE_ATTESTATION_KIND: u16 = 24_201; +/// Maximum serialized request size accepted by either endpoint. +pub const MAX_BROKER_REQUEST_BYTES: u64 = 512 * 1024; +/// Maximum serialized response size accepted by the CLI. +pub const MAX_BROKER_RESPONSE_BYTES: u64 = 8 * 1024 * 1024; +/// Maximum serialized relay result before response and attestation overhead. +pub const MAX_BROKER_RESULT_BYTES: u64 = 7 * 1024 * 1024; + +/// Return whether a stored event is a user-visible message payload eligible +/// for exact-event broker delivery. +pub fn is_brokered_message_kind(kind: u16) -> bool { + matches!( + u32::from(kind), + crate::kind::KIND_STREAM_MESSAGE + | crate::kind::KIND_STREAM_MESSAGE_V2 + | crate::kind::KIND_STREAM_MESSAGE_EDIT + | crate::kind::KIND_STREAM_MESSAGE_DIFF + | crate::kind::KIND_FORUM_POST + | crate::kind::KIND_FORUM_COMMENT + ) +} + +/// Canonical digest signed by the broker's response attestation. +/// +/// Signing a fixed-size digest avoids embedding the complete response twice in +/// the on-disk envelope while still binding every response byte. +pub fn broker_response_digest(response: &BrokerResponse) -> Result { + let bytes = serde_json::to_vec(response)?; + Ok(hex::encode(Sha256::digest(bytes))) +} + +/// A single authenticated request from `buzz-cli` to `buzz-acp`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerRequest { + /// Protocol version used to fail closed across incompatible releases. + pub version: u8, + /// Unique request identifier, also used as the response filename. + pub request_id: Uuid, + /// Per-harness bearer capability supplied out-of-band in the child environment. + pub capability: String, + /// Client wall-clock timestamp in Unix milliseconds. + pub created_at_ms: u64, + /// Narrow operation requested from the harness. + pub operation: BrokerOperation, +} + +/// Relay operations exposed by the delivery broker. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +pub enum BrokerOperation { + /// Execute a bounded Nostr filter query through `POST /query`. + Query { + /// One or more Nostr filter objects. + filters: Vec, + }, + /// Execute a bounded Nostr count through `POST /count`. + Count { + /// One or more Nostr filter objects. + filters: Vec, + }, + /// Submit an already-signed, stored message event through `POST /events`. + SubmitStoredMessage { + /// Exact event signed by the CLI. The broker never rebuilds or re-signs it. + event: Box, + }, +} + +/// A broker response written atomically for one request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerResponse { + /// Protocol version used to fail closed across incompatible releases. + pub version: u8, + /// Request identifier copied from the authenticated request. + pub request_id: Uuid, + /// Successful relay response, present only when `error` is absent. + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + /// Structured failure, present only when `result` is absent. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Authenticated response envelope written by the harness. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerResponseEnvelope { + /// Structured response whose exact JSON encoding is signed. + pub response: BrokerResponse, + /// Ephemeral Nostr signature over the serialized response. + pub attestation: Event, +} + +impl BrokerResponse { + /// Construct a successful response. + pub fn success(request_id: Uuid, result: Value) -> Self { + Self { + version: BROKER_PROTOCOL_VERSION, + request_id, + result: Some(result), + error: None, + } + } + + /// Construct a failed response. + pub fn failure(request_id: Uuid, code: BrokerErrorCode, message: impl Into) -> Self { + Self { + version: BROKER_PROTOCOL_VERSION, + request_id, + result: None, + error: Some(BrokerError { + code, + message: message.into(), + }), + } + } +} + +/// Structured broker failure returned to the CLI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerError { + /// Stable machine-readable error category. + pub code: BrokerErrorCode, + /// Sanitized human-readable detail. + pub message: String, +} + +/// Stable delivery-broker error categories. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrokerErrorCode { + /// The request failed schema, size, age, or filter validation. + InvalidRequest, + /// The bearer capability did not match this harness. + Unauthorized, + /// The operation or event kind is outside the broker allowlist. + Unsupported, + /// The broker is at its bounded concurrency limit and did not execute the request. + Busy, + /// The relay explicitly rejected the operation. + RelayRejected, + /// The event may have been accepted but could not be verified by exact readback. + DeliveryUnknown, + /// A local broker transport or serialization failure occurred. + Internal, +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind}; + + #[test] + fn signed_event_round_trips_without_reconstruction() { + let event = EventBuilder::new(Kind::Custom(9), "exact\ncontent") + .tags([]) + .sign_with_keys(&Keys::generate()) + .expect("sign event"); + let request = BrokerRequest { + version: BROKER_PROTOCOL_VERSION, + request_id: Uuid::new_v4(), + capability: "capability".into(), + created_at_ms: 1, + operation: BrokerOperation::SubmitStoredMessage { + event: Box::new(event.clone()), + }, + }; + + let encoded = serde_json::to_vec(&request).expect("serialize request"); + let decoded: BrokerRequest = serde_json::from_slice(&encoded).expect("parse request"); + let BrokerOperation::SubmitStoredMessage { event: decoded } = decoded.operation else { + panic!("wrong operation"); + }; + assert_eq!(*decoded, event); + } + + #[test] + fn brokered_message_kind_allowlist_covers_message_payloads_only() { + for kind in [9, 40002, 40003, 40008, 45001, 45003] { + assert!(is_brokered_message_kind(kind), "kind {kind}"); + } + for kind in [5, 7, 40004, 40005, 40006, 40007, 45002] { + assert!(!is_brokered_message_kind(kind), "kind {kind}"); + } + } + + #[test] + fn response_digest_changes_with_the_bound_payload() { + let request_id = Uuid::new_v4(); + let first = BrokerResponse::success(request_id, serde_json::json!({"count": 1})); + let second = BrokerResponse::success(request_id, serde_json::json!({"count": 2})); + assert_ne!( + broker_response_digest(&first).unwrap(), + broker_response_digest(&second).unwrap() + ); + } +} diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 7424915c83..76028b644c 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,8 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +/// Authenticated filesystem transport shared by the Buzz CLI and ACP harness. +pub mod delivery_broker; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, /// body parse/serialize, envelope build/validate, head selection. pub mod engram; From 3239e06a3b8a531f8385f2fb855d71ef7790dbb6 Mon Sep 17 00:00:00 2001 From: Greg Moskalenko Date: Thu, 6 Aug 2026 08:13:10 -0700 Subject: [PATCH 2/3] fix: wake lazy pool for heartbeats Co-authored-by: Greg Moskalenko Signed-off-by: Greg Moskalenko --- crates/buzz-acp/src/lib.rs | 194 ++++++++++++++++++++++++++++++++----- 1 file changed, 172 insertions(+), 22 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index cf5e9ddb85..d7c3efeb57 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1034,6 +1034,57 @@ struct SlotCircuit { respawn_in_flight: bool, } +/// A heartbeat tick that arrives before a lazy agent pool is ready. +/// +/// The timer itself must stay non-blocking while the pool initializes, so a +/// single bit records the deferred tick. Repeated ticks coalesce, queued human +/// events retain priority, and the deferred heartbeat is consumed only when an +/// idle agent can actually run it. +#[derive(Default)] +struct DeferredHeartbeat { + pending: bool, +} + +#[derive(Debug, PartialEq, Eq)] +enum HeartbeatTickAction { + Dispatch, + DeferForPool, + Coalesce, +} + +impl DeferredHeartbeat { + fn on_tick(&mut self, pool_ready: bool) -> HeartbeatTickAction { + if self.pending { + HeartbeatTickAction::Coalesce + } else if !pool_ready { + self.pending = true; + HeartbeatTickAction::DeferForPool + } else { + HeartbeatTickAction::Dispatch + } + } + + fn needs_pool_wake(&self) -> bool { + self.pending + } + + fn is_dispatchable( + &self, + pool_ready: bool, + events_pending: bool, + heartbeat_in_flight: bool, + agent_idle: bool, + ) -> bool { + self.pending && pool_ready && !events_pending && !heartbeat_in_flight && agent_idle + } + + /// Consume the deferred tick from the selected dispatch branch. + fn consume(&mut self) { + debug_assert!(self.pending); + self.pending = false; + } +} + /// Result of [`SlotCircuit::record_crash`]. enum CrashVerdict { /// Respawn is allowed after sleeping for this duration (jittered backoff). @@ -1668,6 +1719,7 @@ async fn tokio_main() -> Result<()> { None }; let mut heartbeat_in_flight = false; + let mut deferred_heartbeat = DeferredHeartbeat::default(); let mut presence_heartbeat = if config.presence_enabled { let interval = Duration::from_secs(60); @@ -1809,14 +1861,15 @@ async fn tokio_main() -> Result<()> { } loop { - // Whether buffered work is waiting on a lazy pool. Also gates the + // Whether buffered work or a heartbeat is waiting on a lazy pool. Also gates the // retry-deadline sleep arm below: a `Failed` lifecycle keeps its // (possibly past) `retry_at` until the next wake, so sleeping on it // unconditionally would complete instantly on every iteration — a - // busy spin — whenever the queued work drained after a failed wake. + // busy spin — whenever all wake demand drained after a failed wake. let mut lazy_wake_work_pending = false; if config.lazy_pool && !pool_ready { - lazy_wake_work_pending = queue.has_flushable_work(); + lazy_wake_work_pending = + queue.has_flushable_work() || deferred_heartbeat.needs_pool_wake(); if let Some(attempt) = pool_lifecycle .start_wake_if_due(lazy_wake_work_pending, tokio::time::Instant::now()) { @@ -1924,6 +1977,17 @@ async fn tokio_main() -> Result<()> { } } + // Keep this as a select guard rather than dispatching eagerly above: + // buffered relay events and shutdown must win the biased race. Computing + // it after respawn collection also lets a newly returned idle agent make + // the deferred heartbeat immediately runnable. + let deferred_heartbeat_dispatchable = deferred_heartbeat.is_dispatchable( + pool_ready, + queue.has_flushable_work(), + heartbeat_in_flight, + pool.any_idle(), + ); + // Borrow result_rx and join_set simultaneously via split-borrow helper. let pool_event: Option = { let (result_rx, join_set) = pool.rx_and_join_set(); @@ -1955,10 +2019,10 @@ async fn tokio_main() -> Result<()> { Some((attempt, result)) = wake_rx.recv(), if config.lazy_pool && !pool_ready => { Some(PoolEvent::Wake(attempt, result)) } - // Gated on pending work: with an empty queue there is nothing - // for the retry to dispatch, and a past `retry_at` would + // Gated on pending wake demand: without a queued event or + // deferred heartbeat there is nothing for the retry to dispatch, and a past `retry_at` would // otherwise complete instantly on every iteration (busy spin). - // The next accepted event re-enables the arm. + // The next accepted event or heartbeat tick re-enables the arm. _ = async { match pool_lifecycle.retry_at() { Some(retry_at) if lazy_wake_work_pending => { @@ -2405,6 +2469,16 @@ async fn tokio_main() -> Result<()> { } None } + _ = shutdown_rx.changed() => { + tracing::info!("shutting down"); + break; + } + _ = std::future::ready(()), if deferred_heartbeat_dispatchable => { + let _ = result_rx; + deferred_heartbeat.consume(); + dispatch_heartbeat(&mut pool, &ctx, &mut heartbeat_in_flight); + None + } _ = async { match heartbeat.as_mut() { Some(hb) => hb.tick().await, @@ -2412,19 +2486,27 @@ async fn tokio_main() -> Result<()> { } } => { let _ = result_rx; - if !pool_ready { - tracing::debug!("heartbeat_skipped_pool_not_ready"); - } else if queue.has_flushable_work() { - tracing::debug!("heartbeat_skipped_events"); - for (channel_id, thread_tags) in - dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) - { - typing_channels.insert(channel_id, thread_tags); + match deferred_heartbeat.on_tick(pool_ready) { + HeartbeatTickAction::DeferForPool => { + tracing::info!("heartbeat_deferred_pool_not_ready"); + } + HeartbeatTickAction::Coalesce => { + tracing::debug!("heartbeat_coalesced_deferred_tick"); + } + HeartbeatTickAction::Dispatch if queue.has_flushable_work() => { + tracing::debug!("heartbeat_skipped_events"); + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { + typing_channels.insert(channel_id, thread_tags); + } + } + HeartbeatTickAction::Dispatch if pool.any_idle() => { + dispatch_heartbeat(&mut pool, &ctx, &mut heartbeat_in_flight); + } + HeartbeatTickAction::Dispatch => { + tracing::debug!("heartbeat_skipped_busy"); } - } else if pool.any_idle() { - dispatch_heartbeat(&mut pool, &ctx, &mut heartbeat_in_flight); - } else { - tracing::debug!("heartbeat_skipped_busy"); } None } @@ -2471,10 +2553,6 @@ async fn tokio_main() -> Result<()> { } None } - _ = shutdown_rx.changed() => { - tracing::info!("shutting down"); - break; - } } }; @@ -4463,6 +4541,78 @@ mod heartbeat_base_prompt_tests { } } +#[cfg(test)] +mod deferred_heartbeat_tests { + use super::{DeferredHeartbeat, HeartbeatTickAction, PoolLifecycle}; + use std::time::Duration; + use tokio::time::Instant; + + #[test] + fn lazy_tick_wakes_pool_then_dispatches_exactly_once() { + let mut heartbeat = DeferredHeartbeat::default(); + + assert_eq!(heartbeat.on_tick(false), HeartbeatTickAction::DeferForPool); + assert!(heartbeat.needs_pool_wake()); + assert!(!heartbeat.is_dispatchable(false, false, false, true)); + assert!(!heartbeat.is_dispatchable(true, true, false, true)); + assert!( + heartbeat.needs_pool_wake(), + "human work must retain priority" + ); + assert!(heartbeat.is_dispatchable(true, false, false, true)); + heartbeat.consume(); + assert!(!heartbeat.needs_pool_wake()); + assert!(!heartbeat.is_dispatchable(true, false, false, true)); + } + + #[test] + fn lazy_ticks_coalesce_while_pool_is_waking_or_busy() { + let mut heartbeat = DeferredHeartbeat::default(); + + assert_eq!(heartbeat.on_tick(false), HeartbeatTickAction::DeferForPool); + assert_eq!(heartbeat.on_tick(false), HeartbeatTickAction::Coalesce); + assert_eq!(heartbeat.on_tick(true), HeartbeatTickAction::Coalesce); + assert!(!heartbeat.is_dispatchable(true, false, true, true)); + assert!(!heartbeat.is_dispatchable(true, false, false, false)); + assert!(heartbeat.is_dispatchable(true, false, false, true)); + heartbeat.consume(); + assert!(!heartbeat.needs_pool_wake()); + } + + #[test] + fn ready_pool_tick_keeps_existing_immediate_path() { + let mut heartbeat = DeferredHeartbeat::default(); + + assert_eq!(heartbeat.on_tick(true), HeartbeatTickAction::Dispatch); + assert!(!heartbeat.needs_pool_wake()); + } + + #[tokio::test(start_paused = true)] + async fn failed_pool_wake_retries_while_heartbeat_remains_pending() { + let mut heartbeat = DeferredHeartbeat::default(); + let mut lifecycle = PoolLifecycle::<()>::listening(); + let now = Instant::now(); + + assert_eq!(heartbeat.on_tick(false), HeartbeatTickAction::DeferForPool); + assert_eq!( + lifecycle.start_wake_if_due(heartbeat.needs_pool_wake(), now), + Some(1) + ); + lifecycle + .complete_wake(1, Err("provider unavailable".into()), now) + .unwrap(); + assert_eq!( + lifecycle.start_wake_if_due(heartbeat.needs_pool_wake(), now + Duration::from_secs(4)), + None + ); + assert_eq!( + lifecycle.start_wake_if_due(heartbeat.needs_pool_wake(), now + Duration::from_secs(5)), + Some(2) + ); + assert!(heartbeat.needs_pool_wake()); + } +} + #[cfg(test)] mod owner_control_command_tests { use super::*; From 708054d8630ca0536e1e03a70199a2f935fef3a3 Mon Sep 17 00:00:00 2001 From: Greg Moskalenko Date: Thu, 6 Aug 2026 11:29:14 -0700 Subject: [PATCH 3/3] fix(agent): enforce outbound mention routing Add an exact recipient policy for new conversational posts across direct CLI and delivery-broker paths. Preserve ordinary unmentioned replies only after signed same-channel parent verification, while leaving edits and diffs unaffected. Co-authored-by: Greg Moskalenko Signed-off-by: Greg Moskalenko --- .env.example | 8 + crates/buzz-acp/src/delivery.rs | 349 +++++++++++++++++++++++- crates/buzz-acp/src/lib.rs | 44 ++- crates/buzz-cli/src/client.rs | 233 +++++++++++++++- crates/buzz-core/src/delivery_broker.rs | 335 ++++++++++++++++++++++- 5 files changed, 952 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index b9bfcada0e..1a5dd0bfef 100644 --- a/.env.example +++ b/.env.example @@ -196,6 +196,14 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # Path to a file containing the heartbeat prompt. # BUZZ_ACP_HEARTBEAT_PROMPT_FILE= +# Optional exact mention set for new stream messages and forum posts/comments. +# When configured, every top-level message must carry exactly these +# comma-separated hex pubkeys or npubs as p tags. Replies may omit p tags only +# when their signed parent exists in the same channel; any p tags they do carry +# must exactly match this set. Edits and diff messages are unaffected. The CLI +# and harness broker both reject mismatches before relay submission. +# BUZZ_OUTBOUND_TOP_LEVEL_MENTION_PUBKEYS= + # ── Desktop development ────────────────────────────────────────────────────── # DEV-only: replay first-run onboarding and the Welcome Team kickoff on each # app launch while keeping the current identity and relay data. diff --git a/crates/buzz-acp/src/delivery.rs b/crates/buzz-acp/src/delivery.rs index 733136f804..251fb00dc6 100644 --- a/crates/buzz-acp/src/delivery.rs +++ b/crates/buzz-acp/src/delivery.rs @@ -4,10 +4,13 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::Context; use buzz_core::delivery_broker::{ - broker_response_digest, is_brokered_message_kind, BrokerErrorCode, BrokerOperation, - BrokerRequest, BrokerResponse, BrokerResponseEnvelope, BROKER_CAPABILITY_ENV, BROKER_DIR_ENV, - BROKER_PROTOCOL_VERSION, BROKER_RESPONSE_ATTESTATION_KIND, BROKER_RESPONSE_PUBKEY_ENV, - MAX_BROKER_REQUEST_BYTES, MAX_BROKER_RESPONSE_BYTES, MAX_BROKER_RESULT_BYTES, + broker_response_digest, evaluate_top_level_mention_policy, is_brokered_message_kind, + is_mention_policy_message_kind, validate_mention_policy_reply_parent, + validate_top_level_mention_policy_config, BrokerErrorCode, BrokerOperation, BrokerRequest, + BrokerResponse, BrokerResponseEnvelope, MentionPolicyDecision, MentionPolicyReplyContext, + BROKER_CAPABILITY_ENV, BROKER_DIR_ENV, BROKER_PROTOCOL_VERSION, + BROKER_RESPONSE_ATTESTATION_KIND, BROKER_RESPONSE_PUBKEY_ENV, MAX_BROKER_REQUEST_BYTES, + MAX_BROKER_RESPONSE_BYTES, MAX_BROKER_RESULT_BYTES, TOP_LEVEL_MENTION_PUBKEYS_ENV, }; use nostr::{Event, EventBuilder, Keys, Kind}; use subtle::ConstantTimeEq; @@ -66,7 +69,12 @@ impl DeliveryBroker { relay_url: &str, keys: Keys, auth_tag_json: Option, + top_level_mention_policy: Option, ) -> anyhow::Result { + if let Some(configured) = top_level_mention_policy.as_deref() { + validate_top_level_mention_policy_config(configured) + .map_err(|message| anyhow::anyhow!("{TOP_LEVEL_MENTION_PUBKEYS_ENV}: {message}"))?; + } // Keep the broker parent outside the agent workspace. The Codex policy // receives only `/requests` as an additional writable root, so it // cannot rename `processing`, `responses`, or the broker root itself. @@ -126,7 +134,14 @@ impl DeliveryBroker { let task_root = root.path().to_path_buf(); let task_capability = capability.clone(); let task = tokio::spawn(async move { - run_broker(task_root, task_capability, response_keys, rest).await; + run_broker( + task_root, + task_capability, + response_keys, + rest, + top_level_mention_policy, + ) + .await; }); Ok(Self { @@ -164,7 +179,13 @@ impl Drop for DeliveryBroker { } } -async fn run_broker(root: PathBuf, capability: String, response_keys: Keys, rest: RestClient) { +async fn run_broker( + root: PathBuf, + capability: String, + response_keys: Keys, + rest: RestClient, + top_level_mention_policy: Option, +) { let mut last_cleanup = tokio::time::Instant::now(); let mut last_lease_refresh = tokio::time::Instant::now(); let mut jobs = tokio::task::JoinSet::new(); @@ -196,6 +217,7 @@ async fn run_broker(root: PathBuf, capability: String, response_keys: Keys, rest let job_capability = capability.clone(); let job_response_keys = response_keys.clone(); let job_rest = rest.clone(); + let job_mention_policy = top_level_mention_policy.clone(); jobs.spawn(async move { handle_claimed_request( &job_root, @@ -204,6 +226,7 @@ async fn run_broker(root: PathBuf, capability: String, response_keys: Keys, rest &job_capability, &job_response_keys, &job_rest, + job_mention_policy.as_deref(), ) .await; }); @@ -430,10 +453,17 @@ async fn handle_claimed_request( capability: &str, response_keys: &Keys, rest: &RestClient, + top_level_mention_policy: Option<&str>, ) { let response = match tokio::time::timeout( REQUEST_PROCESSING_TIMEOUT, - process_request_file(request_id, &path, capability, rest), + process_request_file( + request_id, + &path, + capability, + rest, + top_level_mention_policy, + ), ) .await { @@ -622,6 +652,7 @@ async fn process_request_file( path: &Path, expected_capability: &str, rest: &RestClient, + top_level_mention_policy: Option<&str>, ) -> BrokerResponse { let bytes = match read_bounded_regular_file(path, MAX_BROKER_REQUEST_BYTES) { Ok(bytes) => bytes, @@ -702,7 +733,8 @@ async fn process_request_file( } } BrokerOperation::SubmitStoredMessage { event } => { - match submit_verified_message(rest, &event).await { + match submit_verified_message_with_policy(rest, &event, top_level_mention_policy).await + { Ok(value) => BrokerResponse::success(filename_request_id, value), Err((code, message)) => BrokerResponse::failure(filename_request_id, code, message), } @@ -728,9 +760,18 @@ fn bounded_result_response(request_id: Uuid, value: serde_json::Value) -> Broker } } +#[cfg(test)] async fn submit_verified_message( rest: &RestClient, event: &Event, +) -> Result { + submit_verified_message_with_policy(rest, event, None).await +} + +async fn submit_verified_message_with_policy( + rest: &RestClient, + event: &Event, + top_level_mention_policy: Option<&str>, ) -> Result { let kind = event.kind.as_u16(); if !is_brokered_message_kind(kind) { @@ -757,6 +798,18 @@ async fn submit_verified_message( format!("event signature verification failed: {error}"), ) })?; + if is_mention_policy_message_kind(kind) { + if let Some(configured) = top_level_mention_policy { + match evaluate_top_level_mention_policy(event, configured) + .map_err(|message| (BrokerErrorCode::InvalidRequest, message))? + { + MentionPolicyDecision::Allow => {} + MentionPolicyDecision::VerifyReply(context) => { + verify_mention_policy_reply_parent(rest, &context).await?; + } + } + } + } let expected_id = event.id.to_hex(); match exact_readback_once(rest, event).await { @@ -850,6 +903,87 @@ async fn submit_verified_message( } } +async fn verify_mention_policy_reply_parent( + rest: &RestClient, + context: &MentionPolicyReplyContext, +) -> Result<(), (BrokerErrorCode, String)> { + let filters = [serde_json::json!({ + "ids": [context.parent_event_id.clone()], + "limit": 1 + })]; + let value = rest.query_values(&filters).await.map_err(|error| { + ( + BrokerErrorCode::Internal, + sanitize_detail(&format!("reply parent query failed: {error}")), + ) + })?; + let parents: Vec = serde_json::from_value(value).map_err(|error| { + ( + BrokerErrorCode::Internal, + sanitize_detail(&format!("reply parent response was invalid: {error}")), + ) + })?; + let mut matching = parents + .into_iter() + .filter(|parent| parent.id.to_hex() == context.parent_event_id); + let parent = matching.next().ok_or_else(|| { + ( + BrokerErrorCode::InvalidRequest, + format!("reply parent {} was not found", context.parent_event_id), + ) + })?; + if matching.next().is_some() { + return Err(( + BrokerErrorCode::InvalidRequest, + "relay returned duplicate reply parents".into(), + )); + } + validate_mention_policy_reply_parent(&parent, context) + .map_err(|message| (BrokerErrorCode::InvalidRequest, message)) +} + +pub(crate) fn normalize_top_level_mention_policy( + persona_env_vars: &mut Vec<(String, String)>, +) -> anyhow::Result> { + let parent = match std::env::var(TOP_LEVEL_MENTION_PUBKEYS_ENV) { + Ok(value) => Some(value), + Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(_)) => { + anyhow::bail!("{TOP_LEVEL_MENTION_PUBKEYS_ENV} is not valid UTF-8") + } + }; + normalize_top_level_mention_policy_with_parent(parent, persona_env_vars) +} + +fn normalize_top_level_mention_policy_with_parent( + parent: Option, + persona_env_vars: &mut Vec<(String, String)>, +) -> anyhow::Result> { + let selected = select_top_level_mention_policy(parent, persona_env_vars); + if let Some(configured) = selected.as_deref() { + validate_top_level_mention_policy_config(configured) + .map_err(|message| anyhow::anyhow!("{TOP_LEVEL_MENTION_PUBKEYS_ENV}: {message}"))?; + } + persona_env_vars.retain(|(key, _)| key != TOP_LEVEL_MENTION_PUBKEYS_ENV); + if let Some(configured) = selected.as_ref() { + persona_env_vars.push((TOP_LEVEL_MENTION_PUBKEYS_ENV.into(), configured.clone())); + } + Ok(selected) +} + +fn select_top_level_mention_policy( + parent: Option, + persona_env_vars: &[(String, String)], +) -> Option { + parent.or_else(|| { + persona_env_vars + .iter() + .rev() + .find(|(key, _)| key == TOP_LEVEL_MENTION_PUBKEYS_ENV) + .map(|(_, value)| value.clone()) + }) +} + async fn verify_exact_readback(rest: &RestClient, expected: &Event) -> Result<(), String> { let delays = [50_u64, 100, 200, 400]; let mut last_error = None; @@ -1099,13 +1233,33 @@ mod tests { submit_count: Arc, } - async fn test_query(State(state): State) -> Json { + async fn test_query( + State(state): State, + body: Bytes, + ) -> Json { + let filters: serde_json::Value = + serde_json::from_slice(&body).expect("query filter payload"); + let requested_ids = filters + .as_array() + .and_then(|filters| filters.first()) + .and_then(|filter| filter.get("ids")) + .and_then(serde_json::Value::as_array) + .map(|ids| { + ids.iter() + .filter_map(serde_json::Value::as_str) + .collect::>() + }); let events = state .stored .lock() .expect("stored lock") .clone() .into_iter() + .filter(|event| { + requested_ids + .as_ref() + .is_none_or(|ids| ids.contains(&event.id.to_hex().as_str())) + }) .collect::>(); Json(serde_json::to_value(events).expect("events json")) } @@ -1209,6 +1363,183 @@ mod tests { assert_eq!(signature_error.0, BrokerErrorCode::InvalidRequest); } + #[tokio::test] + async fn configured_mention_policy_rejects_before_broker_relay_submission() { + let keys = Keys::generate(); + let required_recipient = Keys::generate().public_key(); + let wrong_recipient = Keys::generate().public_key(); + let event = EventBuilder::new(Kind::Custom(9), "wrong recipient") + .tags([nostr::Tag::public_key(wrong_recipient)]) + .sign_with_keys(&keys) + .expect("event"); + let (mut rest, submit_count) = spawn_test_relay( + None, + true, + serde_json::json!({ + "accepted": true, + "event_id": event.id.to_hex(), + "message": "stored" + }), + ) + .await; + rest.keys = keys; + + let error = + submit_verified_message_with_policy(&rest, &event, Some(&required_recipient.to_hex())) + .await + .expect_err("policy mismatch must fail closed"); + + assert_eq!(error.0, BrokerErrorCode::InvalidRequest); + assert_eq!(submit_count.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn configured_mention_policy_verifies_unmentioned_reply_parent() { + let keys = Keys::generate(); + let parent_keys = Keys::generate(); + let required_recipient = Keys::generate().public_key(); + let channel_id = Uuid::new_v4().to_string(); + let parent = EventBuilder::new(Kind::Custom(9), "parent") + .tags([nostr::Tag::parse(["h", &channel_id]).expect("parent channel")]) + .sign_with_keys(&parent_keys) + .expect("parent event"); + let reply = EventBuilder::new(Kind::Custom(9), "reply") + .tags([ + nostr::Tag::parse(["h", &channel_id]).expect("reply channel"), + nostr::Tag::parse(["e", &parent.id.to_hex(), "", "reply"]).expect("reply marker"), + ]) + .sign_with_keys(&keys) + .expect("reply event"); + let (mut rest, submit_count) = spawn_test_relay( + Some(parent), + true, + serde_json::json!({ + "accepted": true, + "event_id": reply.id.to_hex(), + "message": "stored" + }), + ) + .await; + rest.keys = keys; + + let result = + submit_verified_message_with_policy(&rest, &reply, Some(&required_recipient.to_hex())) + .await + .expect("signed same-channel reply"); + + assert_eq!(result["event_id"], reply.id.to_hex()); + assert_eq!(result["readback_verified"], true); + assert_eq!(submit_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn configured_mention_policy_rejects_forged_or_cross_channel_reply() { + let keys = Keys::generate(); + let required_recipient = Keys::generate().public_key(); + let parent_channel = Uuid::new_v4().to_string(); + let reply_channel = Uuid::new_v4().to_string(); + let parent = EventBuilder::new(Kind::Custom(9), "parent") + .tags([nostr::Tag::parse(["h", &parent_channel]).expect("parent channel")]) + .sign_with_keys(&Keys::generate()) + .expect("parent event"); + let reply = EventBuilder::new(Kind::Custom(9), "reply") + .tags([ + nostr::Tag::parse(["h", &reply_channel]).expect("reply channel"), + nostr::Tag::parse(["e", &parent.id.to_hex(), "", "reply"]).expect("reply marker"), + ]) + .sign_with_keys(&keys) + .expect("reply event"); + let (mut rest, submit_count) = spawn_test_relay( + Some(parent), + true, + serde_json::json!({"accepted": true, "event_id": reply.id.to_hex()}), + ) + .await; + rest.keys = keys; + + let error = + submit_verified_message_with_policy(&rest, &reply, Some(&required_recipient.to_hex())) + .await + .expect_err("cross-channel reply must fail closed"); + + assert_eq!(error.0, BrokerErrorCode::InvalidRequest); + assert_eq!(submit_count.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn configured_mention_policy_does_not_block_edit_payloads() { + let keys = Keys::generate(); + let required_recipient = Keys::generate().public_key(); + let event = EventBuilder::new(Kind::Custom(40003), "edited") + .tags([]) + .sign_with_keys(&keys) + .expect("edit event"); + let (mut rest, submit_count) = spawn_test_relay( + None, + true, + serde_json::json!({ + "accepted": true, + "event_id": event.id.to_hex(), + "message": "stored" + }), + ) + .await; + rest.keys = keys; + + submit_verified_message_with_policy(&rest, &event, Some(&required_recipient.to_hex())) + .await + .expect("edit remains deliverable"); + + assert_eq!(submit_count.load(Ordering::SeqCst), 1); + } + + #[test] + fn mention_policy_resolution_uses_parent_then_last_persona_value() { + let first = Keys::generate().public_key().to_hex(); + let last = Keys::generate().public_key().to_hex(); + let parent = Keys::generate().public_key().to_hex(); + let persona = vec![ + (TOP_LEVEL_MENTION_PUBKEYS_ENV.into(), first), + ("UNRELATED".into(), "value".into()), + (TOP_LEVEL_MENTION_PUBKEYS_ENV.into(), last.clone()), + ]; + + assert_eq!( + select_top_level_mention_policy(Some(parent.clone()), &persona), + Some(parent) + ); + assert_eq!(select_top_level_mention_policy(None, &persona), Some(last)); + } + + #[test] + fn mention_policy_normalization_collapses_duplicates_and_validates() { + let first = Keys::generate().public_key().to_hex(); + let selected = Keys::generate().public_key().to_hex(); + let mut persona = vec![ + (TOP_LEVEL_MENTION_PUBKEYS_ENV.into(), first), + ("UNRELATED".into(), "value".into()), + (TOP_LEVEL_MENTION_PUBKEYS_ENV.into(), selected.clone()), + ]; + + let result = normalize_top_level_mention_policy_with_parent(None, &mut persona) + .expect("valid policy"); + + assert_eq!(result, Some(selected.clone())); + let normalized = persona + .iter() + .filter(|(key, _)| key == TOP_LEVEL_MENTION_PUBKEYS_ENV) + .map(|(_, value)| value.as_str()) + .collect::>(); + assert_eq!( + normalized, + vec![selected.as_str()], + "normalization must leave one exact policy value" + ); + + let mut invalid = vec![(TOP_LEVEL_MENTION_PUBKEYS_ENV.into(), "invalid".into())]; + assert!(normalize_top_level_mention_policy_with_parent(None, &mut invalid).is_err()); + } + #[tokio::test] async fn exact_preflight_reconciles_without_resubmitting() { let keys = Keys::generate(); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 4c1bb93767..b49b09825f 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1628,6 +1628,13 @@ async fn tokio_main() -> Result<()> { return setup_mode::run_setup_listener(config, payload).await; } + // Resolve the outbound mention policy once, with harness environment taking + // precedence over persona configuration. Store one canonical pair so the + // agent child, MCP subprocesses, and harness broker all receive the same + // immutable policy value. + let top_level_mention_policy = + delivery::normalize_top_level_mention_policy(&mut config.persona_env_vars)?; + tracing::info!("buzz-acp starting: {}", config.summary()); let observer = config @@ -1659,11 +1666,16 @@ async fn tokio_main() -> Result<()> { .filter(|value| !value.is_empty()) .and_then(|value| buzz_sdk::nip_oa::parse_auth_tag(&value).ok()) .and_then(|tag| serde_json::to_string(tag.as_slice()).ok()); - match delivery::DeliveryBroker::start(&config.relay_url, config.keys.clone(), auth_tag_json) - .and_then(|broker| { - let environment = broker.environment()?; - Ok((broker, environment)) - }) { + match delivery::DeliveryBroker::start( + &config.relay_url, + config.keys.clone(), + auth_tag_json, + top_level_mention_policy.clone(), + ) + .and_then(|broker| { + let environment = broker.environment()?; + Ok((broker, environment)) + }) { Ok((broker, environment)) => { config.persona_env_vars.extend(environment); tracing::info!("harness delivery broker enabled for Codex message transport"); @@ -4723,10 +4735,12 @@ fn build_mcp_servers(config: &Config) -> Vec { buzz_core::delivery_broker::BROKER_DIR_ENV, buzz_core::delivery_broker::BROKER_CAPABILITY_ENV, buzz_core::delivery_broker::BROKER_RESPONSE_PUBKEY_ENV, + buzz_core::delivery_broker::TOP_LEVEL_MENTION_PUBKEYS_ENV, ] { if let Some((_, value)) = config .persona_env_vars .iter() + .rev() .find(|(name, _)| name == broker_name) { env.push(EnvVar { @@ -6480,6 +6494,26 @@ mod build_mcp_servers_tests { ); } + #[test] + fn session_new_mcp_server_forwards_top_level_mention_policy_once() { + let mut config = test_config(); + let policy = nostr::Keys::generate().public_key().to_hex(); + config.persona_env_vars.push(( + buzz_core::delivery_broker::TOP_LEVEL_MENTION_PUBKEYS_ENV.into(), + policy.clone(), + )); + + let servers = build_mcp_servers(&config); + let values = servers[0] + .env + .iter() + .filter(|entry| entry.name == buzz_core::delivery_broker::TOP_LEVEL_MENTION_PUBKEYS_ENV) + .map(|entry| entry.value.as_str()) + .collect::>(); + + assert_eq!(values, vec![policy.as_str()]); + } + #[test] fn session_new_mcp_server_forwards_buzz_auth_tag() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index e8a17540c9..b4c1177648 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -8,6 +8,66 @@ use sha2::{Digest, Sha256}; use crate::error::CliError; +fn configured_top_level_mention_policy() -> Result, CliError> { + use buzz_core::delivery_broker::TOP_LEVEL_MENTION_PUBKEYS_ENV; + + match std::env::var(TOP_LEVEL_MENTION_PUBKEYS_ENV) { + Ok(value) => Ok(Some(value)), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => Err(CliError::Usage(format!( + "{TOP_LEVEL_MENTION_PUBKEYS_ENV} is not valid UTF-8" + ))), + } +} + +async fn enforce_top_level_mention_policy( + client: &BuzzClient, + event: &nostr::Event, + configured: Option<&str>, +) -> Result<(), CliError> { + use buzz_core::delivery_broker::{ + evaluate_top_level_mention_policy, validate_mention_policy_reply_parent, + MentionPolicyDecision, TOP_LEVEL_MENTION_PUBKEYS_ENV, + }; + + let Some(configured) = configured else { + return Ok(()); + }; + let decision = evaluate_top_level_mention_policy(event, configured).map_err(|message| { + CliError::Usage(format!("{TOP_LEVEL_MENTION_PUBKEYS_ENV}: {message}")) + })?; + let MentionPolicyDecision::VerifyReply(context) = decision else { + return Ok(()); + }; + + let filter = serde_json::json!({ + "ids": [context.parent_event_id.clone()], + "limit": 1 + }); + let raw = client.query(&filter).await?; + let parents: Vec = serde_json::from_str(&raw).map_err(|error| { + CliError::Other(format!( + "failed to parse mention-policy reply parent: {error}" + )) + })?; + let mut matching = parents + .into_iter() + .filter(|parent| parent.id.to_hex() == context.parent_event_id); + let parent = matching.next().ok_or_else(|| { + CliError::Usage(format!( + "{TOP_LEVEL_MENTION_PUBKEYS_ENV}: reply parent {} was not found", + context.parent_event_id + )) + })?; + if matching.next().is_some() { + return Err(CliError::Usage(format!( + "{TOP_LEVEL_MENTION_PUBKEYS_ENV}: relay returned duplicate reply parents" + ))); + } + validate_mention_policy_reply_parent(&parent, &context) + .map_err(|message| CliError::Usage(format!("{TOP_LEVEL_MENTION_PUBKEYS_ENV}: {message}"))) +} + /// Descriptor returned by the relay after a successful upload. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct BlobDescriptor { @@ -542,6 +602,8 @@ pub struct BuzzClient { keys: Keys, /// Harness-owned transport used only when explicitly injected by buzz-acp. delivery_broker: Option, + /// Optional exact p-tag policy captured once at client construction. + top_level_mention_policy: Option, /// Short circuit-breaker window after a direct transport failure. This /// avoids repeating a DNS/connect retry budget inside one command while /// still probing the direct path again after a bounded cooldown. @@ -569,6 +631,7 @@ impl BuzzClient { auth_tag_json: Option, ) -> Result { let delivery_broker = crate::delivery_broker::DeliveryBrokerClient::from_env()?; + let top_level_mention_policy = configured_top_level_mention_policy()?; let http = reqwest::Client::builder() .timeout(env_duration_secs("BUZZ_TIMEOUT_SECS", 30)) .connect_timeout(env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15)) @@ -579,6 +642,7 @@ impl BuzzClient { relay_url, keys, delivery_broker, + top_level_mention_policy, direct_transport_degraded_until: std::sync::Mutex::new(None), auth_tag, auth_tag_json, @@ -1095,6 +1159,14 @@ impl BuzzClient { /// Content-addressed uploads are exempt: same bytes ⇒ same hash, so outer /// re-run is safe regardless of the failure kind. async fn submit_stored_event(&self, event: nostr::Event) -> Result { + if buzz_core::delivery_broker::is_mention_policy_message_kind(event.kind.as_u16()) { + enforce_top_level_mention_policy( + self, + &event, + self.top_level_mention_policy.as_deref(), + ) + .await?; + } if buzz_core::delivery_broker::is_brokered_message_kind(event.kind.as_u16()) { if let Some(broker) = &self.delivery_broker { if self.direct_transport_is_degraded() { @@ -1677,7 +1749,7 @@ fn validate_message_receipt(raw: &str, event: &nostr::Event) -> Result<(), CliEr #[cfg(test)] mod delivery_contract_tests { use super::*; - use axum::{routing::post, Json, Router}; + use axum::{body::Bytes, routing::post, Json, Router}; use buzz_core::delivery_broker::{ broker_response_digest, BrokerOperation, BrokerRequest, BrokerResponse, BrokerResponseEnvelope, BROKER_RESPONSE_ATTESTATION_KIND, @@ -1769,6 +1841,165 @@ mod delivery_contract_tests { ))); } + #[tokio::test] + async fn configured_mention_policy_rejects_before_direct_relay_submission() { + let event_posts = Arc::new(AtomicUsize::new(0)); + let observed_posts = event_posts.clone(); + let app = Router::new() + .route( + "/events", + post(move || { + let observed_posts = observed_posts.clone(); + async move { + observed_posts.fetch_add(1, Ordering::SeqCst); + Json(serde_json::json!({"accepted": true})) + } + }), + ) + .route("/query", post(|| async { Json(serde_json::json!([])) })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("relay listener"); + let relay_url = format!("http://{}", listener.local_addr().expect("listener addr")); + let relay_server = + tokio::spawn(async move { axum::serve(listener, app).await.expect("relay server") }); + + let keys = Keys::generate(); + let wrong_recipient = Keys::generate().public_key(); + let required_recipient = Keys::generate().public_key(); + let event = EventBuilder::new(Kind::Custom(9), "wrong recipient") + .tags([Tag::public_key(wrong_recipient)]) + .sign_with_keys(&keys) + .expect("signed message"); + let mut client = BuzzClient::new(relay_url, keys, None, None).expect("client"); + client.top_level_mention_policy = Some(required_recipient.to_hex()); + + let error = client + .submit_stored_event(event) + .await + .expect_err("policy mismatch must fail closed"); + relay_server.abort(); + + assert!(matches!(error, CliError::Usage(_))); + assert_eq!(event_posts.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn configured_mention_policy_allows_matching_direct_submission() { + let event_posts = Arc::new(AtomicUsize::new(0)); + let observed_posts = event_posts.clone(); + let app = Router::new().route( + "/events", + post(move |body: Bytes| { + let observed_posts = observed_posts.clone(); + async move { + observed_posts.fetch_add(1, Ordering::SeqCst); + let event: nostr::Event = + serde_json::from_slice(&body).expect("submitted event"); + Json(serde_json::json!({ + "accepted": true, + "event_id": event.id.to_hex(), + "message": "stored" + })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("relay listener"); + let relay_url = format!("http://{}", listener.local_addr().expect("listener addr")); + let relay_server = + tokio::spawn(async move { axum::serve(listener, app).await.expect("relay server") }); + + let keys = Keys::generate(); + let required_recipient = Keys::generate().public_key(); + let event = EventBuilder::new(Kind::Custom(9), "matching recipient") + .tags([Tag::public_key(required_recipient)]) + .sign_with_keys(&keys) + .expect("signed message"); + let mut client = BuzzClient::new(relay_url, keys, None, None).expect("client"); + client.delivery_broker = None; + client.top_level_mention_policy = Some(required_recipient.to_hex()); + + client + .submit_stored_event(event) + .await + .expect("matching message is submitted"); + relay_server.abort(); + + assert_eq!(event_posts.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn configured_mention_policy_verifies_direct_reply_parent() { + let event_posts = Arc::new(AtomicUsize::new(0)); + let query_posts = Arc::new(AtomicUsize::new(0)); + let observed_events = event_posts.clone(); + let observed_queries = query_posts.clone(); + let channel_id = uuid::Uuid::new_v4().to_string(); + let parent = EventBuilder::new(Kind::Custom(9), "parent") + .tags([Tag::parse(["h", &channel_id]).expect("parent channel")]) + .sign_with_keys(&Keys::generate()) + .expect("parent event"); + let returned_parent = parent.clone(); + let app = Router::new() + .route( + "/query", + post(move || { + let observed_queries = observed_queries.clone(); + let returned_parent = returned_parent.clone(); + async move { + observed_queries.fetch_add(1, Ordering::SeqCst); + Json(serde_json::json!([returned_parent])) + } + }), + ) + .route( + "/events", + post(move |body: Bytes| { + let observed_events = observed_events.clone(); + async move { + observed_events.fetch_add(1, Ordering::SeqCst); + let event: nostr::Event = + serde_json::from_slice(&body).expect("submitted event"); + Json(serde_json::json!({ + "accepted": true, + "event_id": event.id.to_hex(), + "message": "stored" + })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("relay listener"); + let relay_url = format!("http://{}", listener.local_addr().expect("listener addr")); + let relay_server = + tokio::spawn(async move { axum::serve(listener, app).await.expect("relay server") }); + + let keys = Keys::generate(); + let required_recipient = Keys::generate().public_key(); + let reply = EventBuilder::new(Kind::Custom(9), "reply") + .tags([ + Tag::parse(["h", &channel_id]).expect("reply channel"), + Tag::parse(["e", &parent.id.to_hex(), "", "reply"]).expect("reply marker"), + ]) + .sign_with_keys(&keys) + .expect("signed reply"); + let mut client = BuzzClient::new(relay_url, keys, None, None).expect("client"); + client.delivery_broker = None; + client.top_level_mention_policy = Some(required_recipient.to_hex()); + + client + .submit_stored_event(reply) + .await + .expect("signed same-channel reply is submitted"); + relay_server.abort(); + + assert_eq!(query_posts.load(Ordering::SeqCst), 1); + assert_eq!(event_posts.load(Ordering::SeqCst), 1); + } + #[tokio::test] async fn ambiguous_direct_message_result_falls_back_with_the_same_signed_event() { let direct_event_posts = Arc::new(AtomicUsize::new(0)); diff --git a/crates/buzz-core/src/delivery_broker.rs b/crates/buzz-core/src/delivery_broker.rs index dada8e53bf..664c50ea2b 100644 --- a/crates/buzz-core/src/delivery_broker.rs +++ b/crates/buzz-core/src/delivery_broker.rs @@ -4,7 +4,9 @@ //! and threads, builds tags, and signs events. The harness only performs the //! relay HTTP operations that a sandboxed CLI cannot perform itself. -use nostr::Event; +use std::collections::BTreeSet; + +use nostr::{Event, PublicKey}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -16,6 +18,12 @@ pub const BROKER_DIR_ENV: &str = "BUZZ_DELIVERY_BROKER_DIR"; pub const BROKER_CAPABILITY_ENV: &str = "BUZZ_DELIVERY_BROKER_CAPABILITY"; /// Environment variable containing the broker's ephemeral response-signing pubkey. pub const BROKER_RESPONSE_PUBKEY_ENV: &str = "BUZZ_DELIVERY_BROKER_RESPONSE_PUBKEY"; +/// Optional exact mention set for new stream messages and forum posts/comments. +/// +/// Replies may omit `p` tags only after their signed parent is verified in the +/// same channel. If they carry any, those tags must still match the configured +/// set. Edits and diff payloads are outside this policy. +pub const TOP_LEVEL_MENTION_PUBKEYS_ENV: &str = "BUZZ_OUTBOUND_TOP_LEVEL_MENTION_PUBKEYS"; /// Current on-disk protocol version. pub const BROKER_PROTOCOL_VERSION: u8 = 1; /// Local-only Nostr kind used to attest broker response bytes. @@ -41,6 +49,190 @@ pub fn is_brokered_message_kind(kind: u16) -> bool { ) } +/// Return whether an outbound message kind is subject to mention routing. +/// +/// Edits and diff payloads share the delivery broker but do not create a new +/// conversational post and their builders do not accept mention tags. +pub fn is_mention_policy_message_kind(kind: u16) -> bool { + matches!( + u32::from(kind), + crate::kind::KIND_STREAM_MESSAGE + | crate::kind::KIND_STREAM_MESSAGE_V2 + | crate::kind::KIND_FORUM_POST + | crate::kind::KIND_FORUM_COMMENT + ) +} + +/// Relay context that must be verified before an unmentioned reply can pass. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MentionPolicyReplyContext { + /// Signed event ID carried by the child's NIP-10 reply marker. + pub parent_event_id: String, + /// Canonical UUID from the child's `h` channel tag. + pub channel_id: String, +} + +/// Result of evaluating a signed event against the configured mention policy. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MentionPolicyDecision { + /// The signed event already carries the exact configured mention set. + Allow, + /// The event has no mentions and requires signed same-channel parent proof. + VerifyReply(MentionPolicyReplyContext), +} + +/// Evaluate the exact `p`-tag set on an outbound conversational message. +/// +/// `configured_pubkeys` is a comma-separated list of hex pubkeys or npubs. +/// Top-level events, and replies that carry `p` tags, must contain exactly the +/// configured mention set. An unmentioned reply returns a verification request; +/// callers must fetch and validate its signed same-channel parent before send. +pub fn evaluate_top_level_mention_policy( + event: &Event, + configured_pubkeys: &str, +) -> Result { + let required = parse_mention_policy_pubkeys(configured_pubkeys)?; + let mut actual = BTreeSet::new(); + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some("p") { + continue; + } + let value = parts + .get(1) + .ok_or_else(|| "outbound message contains a malformed p tag".to_string())?; + let pubkey = PublicKey::parse(value) + .map_err(|_| format!("outbound message contains an invalid p-tag pubkey: {value}"))?; + if !actual.insert(pubkey.to_hex()) { + return Err(format!( + "outbound message contains a duplicate p-tag pubkey: {}", + pubkey.to_hex() + )); + } + } + + if actual == required { + return Ok(MentionPolicyDecision::Allow); + } + + if !actual.is_empty() { + return Err(mention_policy_mismatch(&required, &actual)); + } + + let mut reply_parent = None; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some("e") + || parts.get(3).map(String::as_str) != Some("reply") + { + continue; + } + if reply_parent.is_some() { + return Err("outbound reply contains multiple NIP-10 reply markers".into()); + } + let value = parts + .get(1) + .ok_or_else(|| "outbound reply contains a malformed reply marker".to_string())?; + let event_id = nostr::EventId::parse(value) + .map_err(|_| format!("outbound reply contains an invalid parent event id: {value}"))?; + reply_parent = Some(event_id.to_hex()); + } + + let Some(parent_event_id) = reply_parent else { + return Err(mention_policy_mismatch(&required, &actual)); + }; + let channel_id = single_channel_id(event)?; + Ok(MentionPolicyDecision::VerifyReply( + MentionPolicyReplyContext { + parent_event_id, + channel_id, + }, + )) +} + +fn mention_policy_mismatch(required: &BTreeSet, actual: &BTreeSet) -> String { + format!( + "outbound message blocked: mention pubkeys must exactly match [{}], but the signed event contains [{}]; retry with the configured mention identity and no other mentions, or use --reply-to without an explicit mention for a genuine reply", + required.iter().cloned().collect::>().join(","), + actual.iter().cloned().collect::>().join(",") + ) +} + +fn single_channel_id(event: &Event) -> Result { + let mut channel_id = None; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some("h") { + continue; + } + if channel_id.is_some() { + return Err("outbound message contains multiple channel tags".into()); + } + let value = parts + .get(1) + .ok_or_else(|| "outbound message contains a malformed channel tag".to_string())?; + let parsed = Uuid::parse_str(value) + .map_err(|_| format!("outbound message contains an invalid channel id: {value}"))?; + channel_id = Some(parsed.to_string()); + } + channel_id.ok_or_else(|| "outbound reply is missing its channel tag".into()) +} + +/// Verify the signed parent required by an unmentioned reply decision. +pub fn validate_mention_policy_reply_parent( + parent: &Event, + context: &MentionPolicyReplyContext, +) -> Result<(), String> { + parent + .verify() + .map_err(|error| format!("reply parent signature verification failed: {error}"))?; + if parent.id.to_hex() != context.parent_event_id { + return Err("reply parent id does not match the signed reply marker".into()); + } + if !is_brokered_message_kind(parent.kind.as_u16()) { + return Err(format!( + "reply parent kind {} is not a supported message kind", + parent.kind.as_u16() + )); + } + let parent_channel = single_channel_id(parent)?; + if parent_channel != context.channel_id { + return Err(format!( + "reply parent belongs to channel {parent_channel}, not {}", + context.channel_id + )); + } + Ok(()) +} + +fn parse_mention_policy_pubkeys(configured_pubkeys: &str) -> Result, String> { + if configured_pubkeys.trim().is_empty() { + return Err("top-level mention policy is configured but empty".into()); + } + + let mut pubkeys = BTreeSet::new(); + for value in configured_pubkeys.split(',') { + let value = value.trim(); + if value.is_empty() { + return Err("top-level mention policy contains an empty pubkey".into()); + } + let pubkey = PublicKey::parse(value) + .map_err(|_| format!("top-level mention policy contains an invalid pubkey: {value}"))?; + if !pubkeys.insert(pubkey.to_hex()) { + return Err(format!( + "top-level mention policy contains a duplicate pubkey: {}", + pubkey.to_hex() + )); + } + } + Ok(pubkeys) +} + +/// Validate a configured mention policy without evaluating an event. +pub fn validate_top_level_mention_policy_config(configured_pubkeys: &str) -> Result<(), String> { + parse_mention_policy_pubkeys(configured_pubkeys).map(|_| ()) +} + /// Canonical digest signed by the broker's response attestation. /// /// Signing a fixed-size digest avoids embedding the complete response twice in @@ -167,7 +359,7 @@ pub enum BrokerErrorCode { #[cfg(test)] mod tests { use super::*; - use nostr::{EventBuilder, Keys, Kind}; + use nostr::{EventBuilder, Keys, Kind, Tag}; #[test] fn signed_event_round_trips_without_reconstruction() { @@ -203,6 +395,145 @@ mod tests { } } + #[test] + fn mention_policy_scope_excludes_edits_and_diffs() { + for kind in [9, 40002, 45001, 45003] { + assert!(is_mention_policy_message_kind(kind), "kind {kind}"); + } + for kind in [40003, 40008] { + assert!(!is_mention_policy_message_kind(kind), "kind {kind}"); + } + } + + #[test] + fn top_level_mention_policy_requires_the_exact_configured_set() { + let signer = Keys::generate(); + let required = Keys::generate().public_key(); + let other = Keys::generate().public_key(); + let matching = EventBuilder::new(Kind::Custom(9), "matching") + .tags([Tag::public_key(required)]) + .sign_with_keys(&signer) + .expect("sign matching event"); + let missing = EventBuilder::new(Kind::Custom(9), "missing") + .tags([]) + .sign_with_keys(&signer) + .expect("sign missing event"); + let additional = EventBuilder::new(Kind::Custom(9), "additional") + .tags([Tag::public_key(required), Tag::public_key(other)]) + .sign_with_keys(&signer) + .expect("sign additional event"); + + let configured = required.to_hex(); + assert_eq!( + evaluate_top_level_mention_policy(&matching, &configured), + Ok(MentionPolicyDecision::Allow) + ); + assert!(evaluate_top_level_mention_policy(&missing, &configured).is_err()); + assert!(evaluate_top_level_mention_policy(&additional, &configured).is_err()); + } + + #[test] + fn top_level_mention_policy_allows_unmentioned_replies_but_checks_reply_mentions() { + let signer = Keys::generate(); + let required = Keys::generate().public_key(); + let other = Keys::generate().public_key(); + let channel_id = Uuid::new_v4().to_string(); + let other_channel_id = Uuid::new_v4().to_string(); + let parent = EventBuilder::new(Kind::Custom(9), "parent") + .tags([Tag::parse(["h", &channel_id]).expect("parent channel")]) + .sign_with_keys(&signer) + .expect("sign parent"); + let reply_tag = + Tag::parse(["e", &parent.id.to_hex(), "", "reply"]).expect("build reply tag"); + let unmentioned_reply = EventBuilder::new(Kind::Custom(9), "reply") + .tags([ + Tag::parse(["h", &channel_id]).expect("reply channel"), + reply_tag.clone(), + ]) + .sign_with_keys(&signer) + .expect("sign reply"); + let matching_reply = EventBuilder::new(Kind::Custom(9), "matching reply") + .tags([reply_tag.clone(), Tag::public_key(required)]) + .sign_with_keys(&signer) + .expect("sign matching reply"); + let wrong_reply = EventBuilder::new(Kind::Custom(9), "wrong reply") + .tags([reply_tag, Tag::public_key(other)]) + .sign_with_keys(&signer) + .expect("sign wrong reply"); + let cross_channel_parent = EventBuilder::new(Kind::Custom(9), "cross-channel parent") + .tags([Tag::parse(["h", &other_channel_id]).expect("other channel")]) + .sign_with_keys(&signer) + .expect("sign cross-channel parent"); + let cross_channel_reply = EventBuilder::new(Kind::Custom(9), "cross-channel reply") + .tags([ + Tag::parse(["h", &channel_id]).expect("reply channel"), + Tag::parse(["e", &cross_channel_parent.id.to_hex(), "", "reply"]) + .expect("cross-channel reply tag"), + ]) + .sign_with_keys(&signer) + .expect("sign cross-channel reply"); + + let configured = required.to_hex(); + let MentionPolicyDecision::VerifyReply(reply_context) = + evaluate_top_level_mention_policy(&unmentioned_reply, &configured) + .expect("reply requires verification") + else { + panic!("unmentioned reply must require parent verification"); + }; + validate_mention_policy_reply_parent(&parent, &reply_context) + .expect("signed same-channel parent"); + assert_eq!( + evaluate_top_level_mention_policy(&matching_reply, &configured), + Ok(MentionPolicyDecision::Allow) + ); + assert!(evaluate_top_level_mention_policy(&wrong_reply, &configured).is_err()); + + let MentionPolicyDecision::VerifyReply(cross_channel_context) = + evaluate_top_level_mention_policy(&cross_channel_reply, &configured) + .expect("reply shape") + else { + panic!("unmentioned reply must require parent verification"); + }; + assert!(validate_mention_policy_reply_parent( + &cross_channel_parent, + &cross_channel_context + ) + .is_err()); + } + + #[test] + fn top_level_mention_policy_rejects_invalid_configuration() { + let event = EventBuilder::new(Kind::Custom(9), "message") + .tags([]) + .sign_with_keys(&Keys::generate()) + .expect("sign event"); + + assert!(evaluate_top_level_mention_policy(&event, "").is_err()); + assert!(evaluate_top_level_mention_policy(&event, "not-a-pubkey").is_err()); + } + + #[test] + fn top_level_mention_policy_rejects_duplicate_config_and_event_tags() { + let signer = Keys::generate(); + let required = Keys::generate().public_key(); + let duplicate = EventBuilder::new(Kind::Custom(9), "duplicate") + .tags([Tag::public_key(required), Tag::public_key(required)]) + .sign_with_keys(&signer) + .expect("sign duplicate event"); + let matching = EventBuilder::new(Kind::Custom(9), "matching") + .tags([Tag::public_key(required)]) + .sign_with_keys(&signer) + .expect("sign matching event"); + let configured = required.to_hex(); + + assert!(evaluate_top_level_mention_policy(&duplicate, &configured).is_err()); + assert!(evaluate_top_level_mention_policy( + &matching, + &format!("{configured},{configured}") + ) + .is_err()); + } + #[test] fn response_digest_changes_with_the_bound_payload() { let request_id = Uuid::new_v4();