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/Cargo.lock b/Cargo.lock index 73ecb249d4..5866001ea5 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..251fb00dc6 --- /dev/null +++ b/crates/buzz-acp/src/delivery.rs @@ -0,0 +1,1861 @@ +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, 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; +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, + 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. + 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, + top_level_mention_policy, + ) + .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, + 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(); + 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(); + let job_mention_policy = top_level_mention_policy.clone(); + jobs.spawn(async move { + handle_claimed_request( + &job_root, + request_id, + path, + &job_capability, + &job_response_keys, + &job_rest, + job_mention_policy.as_deref(), + ) + .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, + top_level_mention_policy: Option<&str>, +) { + let response = match tokio::time::timeout( + REQUEST_PROCESSING_TIMEOUT, + process_request_file( + request_id, + &path, + capability, + rest, + top_level_mention_policy, + ), + ) + .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, + top_level_mention_policy: Option<&str>, +) -> 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_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), + } + } + } +} + +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}"), + ), + } +} + +#[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) { + 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}"), + ) + })?; + 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 { + 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_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; + 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, + 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")) + } + + 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 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(); + 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 65c9dd6203..b49b09825f 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; @@ -1264,6 +1265,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). @@ -1576,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 @@ -1596,6 +1655,47 @@ 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, + 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"); + 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 { @@ -1862,6 +1962,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); @@ -2003,14 +2104,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()) { @@ -2118,6 +2220,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(); @@ -2149,10 +2262,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 => { @@ -2599,6 +2712,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, @@ -2606,19 +2729,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 } @@ -2665,10 +2796,6 @@ async fn tokio_main() -> Result<()> { } None } - _ = shutdown_rx.changed() => { - tracing::info!("shutting down"); - break; - } } }; @@ -4063,6 +4190,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() { @@ -4140,6 +4296,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; @@ -4230,6 +4395,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( @@ -4560,6 +4731,24 @@ 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, + 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 { + name: broker_name.into(), + value: value.clone(), + }); + } + } env }, }] @@ -4597,6 +4786,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::*; @@ -6233,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(); @@ -6452,6 +6733,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 2cbb82411f..f96ae71c56 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -262,6 +262,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 @@ -402,10 +421,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. @@ -415,12 +444,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. @@ -428,15 +480,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. @@ -4011,6 +4086,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..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 { @@ -212,6 +272,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 +573,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 +600,14 @@ 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, + /// 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. + 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 +630,8 @@ impl BuzzClient { auth_tag: Option, 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)) @@ -553,6 +641,9 @@ impl BuzzClient { http, relay_url, keys, + delivery_broker, + top_level_mention_policy, + direct_transport_degraded_until: std::sync::Mutex::new(None), auth_tag, auth_tag_json, }) @@ -771,6 +862,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 +916,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 +1159,61 @@ 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() { + 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 +1257,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 +1670,445 @@ 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::{body::Bytes, 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 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)); + 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..664c50ea2b --- /dev/null +++ b/crates/buzz-core/src/delivery_broker.rs @@ -0,0 +1,547 @@ +//! 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 std::collections::BTreeSet; + +use nostr::{Event, PublicKey}; +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"; +/// 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. +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 + ) +} + +/// 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 +/// 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, Tag}; + + #[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 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(); + 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;