diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806..ce9ceac8d6 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -74,7 +74,7 @@ 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 = ["fs", "signal", "user"] } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..1442587f23 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -106,14 +106,18 @@ All configuration is via environment variables (or CLI flags — every env var h | Variable | Required | Default | Description | |----------|----------|---------|-------------| -| `BUZZ_PRIVATE_KEY` | **yes** | — | Agent's Nostr private key (`nsec1...`). Used for relay auth and agent identity. | +| `BUZZ_PRIVATE_KEY` | one key source required | — | Agent's Nostr private key (`nsec1...`). Conflicts with `BUZZ_PRIVATE_KEY_FILE`. | +| `BUZZ_PRIVATE_KEY_FILE` | one key source required | — | Absolute path to a current-user-owned, regular, non-symlink key file with mode `0600`. | +| `BUZZ_EXPECTED_PUBLIC_KEY` | with key file in supervised deployments | — | Expected hex pubkey derived from `BUZZ_PRIVATE_KEY_FILE`; startup fails on mismatch. | | `BUZZ_RELAY_URL` | no | `ws://localhost:3000` | Relay WebSocket URL. | | `BUZZ_ACP_AGENT_COMMAND` | no | `goose` | Agent binary to spawn. | | `BUZZ_ACP_AGENT_ARGS` | no | `acp` | Agent arguments (comma-separated). | | `BUZZ_ACP_MCP_COMMAND` | no | `""` (empty) | Path to an optional MCP server binary to provide to the agent subprocess. | -| `BUZZ_ACP_IDLE_TIMEOUT` | no | `620` | Idle timeout: max seconds of silence before cancelling a turn. Resets on any agent stdout activity. | +| `BUZZ_ACP_IDLE_TIMEOUT` | no | `900` | Idle timeout: max seconds of silence before cancelling a turn. Resets on any agent stdout activity. | | `BUZZ_ACP_MAX_TURN_DURATION` | no | `7200` | Absolute wall-clock cap per turn (safety valve). | | `BUZZ_API_TOKEN` | no | — | API token (required if relay enforces token auth). | +| `BUZZ_ACP_TURN_RECEIPTS` | no | `false` | OpenClaw-only opt-in that closes successful channel turns against signed Buzz reply and Gateway lineage evidence. Requires relay observer. | +| `BUZZ_ACP_EXPECTED_GATEWAY_SESSION_KEY` | with turn receipts | — | Fixed Gateway session key that observed ACP lineage must match. | **Note:** `BUZZ_ACP_AGENT_ARGS` splits on commas. For args with values, use: `-c,key="value"`. diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8a698954a0..5207c9c7e0 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -16,6 +16,80 @@ use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError}; use crate::observer::{ObserverContext, ObserverHandle}; use crate::usage::{TurnUsage, UsageTracker}; +/// ACP `_meta` key carrying a Buzz-verified triggering event. +/// +/// The value is transport metadata, not a prompt content block. Adapters that +/// do not explicitly consume the extension ignore it per ACP extensibility. +pub(crate) const TRUSTED_INBOUND_EVENT_META_NAMESPACE: &str = "buzz"; +pub(crate) const TRUSTED_INBOUND_EVENT_META_FIELD: &str = "inboundEvent"; + +/// Immutable evidence copied from one signature-verified triggering event. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TrustedInboundEventEnvelope { + schema_version: u8, + event_id: String, + author_pubkey: String, + kind: u16, + channel_id: String, + tags: Vec>, +} + +impl TrustedInboundEventEnvelope { + /// Build an envelope only for an unambiguous single-event batch whose + /// signed `h` tag exactly matches the subscribed channel. Any ambiguity or + /// failed signature verification produces no trusted metadata. + #[cfg(test)] + pub(crate) fn from_prompt_batch(batch: Option<&crate::queue::FlushBatch>) -> Option { + Self::try_from_prompt_batch(batch).ok() + } + + /// Return a stable, secret-free refusal code when trusted metadata cannot + /// be derived. The worker logs this at the admission boundary so a missing + /// envelope cannot masquerade as an agent/tool-policy failure. + pub(crate) fn try_from_prompt_batch( + batch: Option<&crate::queue::FlushBatch>, + ) -> Result { + let batch = batch.ok_or("missing_batch")?; + if batch.cancel_reason.is_some() { + return Err("cancelled_batch"); + } + if !batch.cancelled_events.is_empty() { + return Err("cancelled_events_present"); + } + if batch.events.len() != 1 { + return Err("ambiguous_event_count"); + } + let event = &batch.events[0].event; + if event.verify().is_err() { + return Err("invalid_signature"); + } + let tags: Vec> = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + let expected_channel = batch.channel_id.to_string(); + let h_tags: Vec<&Vec> = tags + .iter() + .filter(|tag| tag.first().map(String::as_str) == Some("h")) + .collect(); + if h_tags.len() != 1 + || h_tags[0].get(1).map(String::as_str) != Some(expected_channel.as_str()) + { + return Err("invalid_channel_binding"); + } + Ok(Self { + schema_version: 1, + event_id: event.id.to_hex(), + author_pubkey: event.pubkey.to_hex(), + kind: event.kind.as_u16(), + channel_id: expected_channel, + tags, + }) + } +} + /// Maximum allowed size of a single NDJSON line from the agent's stdout. /// Lines exceeding this limit are rejected to prevent OOM from rogue agents. const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB @@ -106,6 +180,9 @@ pub enum AcpError { #[error("Agent reported error (code {code}): {message}")] AgentError { code: i64, message: String }, + + #[error("Trusted inbound event refused: {0}")] + TrustedInboundEventRefused(&'static str), } /// Build an [`AcpError::AgentError`] from a JSON-RPC error object, @@ -187,6 +264,11 @@ pub struct AcpClient { /// Other agents may leave this unset — readers must treat `None` as /// "no active run to steer into" and fall back to cancel+merge. active_run_id: Option, + /// Most recent run ID observed during the current prompt, retained after + /// adapters clear their active-run marker at turn completion. + last_turn_run_id: Option, + /// Stable backend session key reported by ACP session lineage metadata. + active_session_key: Option, /// Whether the agent advertised `_meta.steering.supported: true` in its /// `initialize` response, meaning it implements the cross-adapter /// [`ACP_STEER_METHOD`] extension. @@ -213,6 +295,10 @@ pub struct AcpClient { goose_usage: UsageTracker, } +fn harness_bound_agent_env(key: &str) -> bool { + matches!(key, "BUZZ_RELAY_URL" | "BUZZ_PRIVATE_KEY") +} + /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape /// collisions. When both sides have an object for the same key, the merge recurses so /// unrelated nested keys from `base` are preserved. @@ -453,6 +539,7 @@ impl AcpClient { args: &[String], extra_env: &[(String, String)], has_generated_codex_config: bool, + forward_buzz_publisher_credentials: bool, ) -> Result { use std::process::Stdio; @@ -468,7 +555,9 @@ impl AcpClient { // Per-persona env vars (e.g., GOOSE_PROVIDER, BUZZ_AGENT_PROVIDER). // For most keys, operator precedence wins: skip injection if already set - // in the parent environment. + // in the parent environment. Harness-bound Buzz credentials are the + // exception: they must match the validated relay identity even if the + // parent happens to carry credentials for another workspace. // // CODEX_CONFIG is handled specially via build_codex_config_env: // • has_generated_codex_config=true: merge all CODEX_CONFIG entries + parent @@ -490,6 +579,14 @@ impl AcpClient { // entry falls through to the standard operator-wins treatment below. let codex_merge_active = codex_config_value.is_some(); + // The harness can retain its relay/signer authority while the managed + // agent remains unable to publish directly. env_remove also closes the + // parent-process inheritance path when credentials configured the harness. + if !forward_buzz_publisher_credentials { + cmd.env_remove("BUZZ_RELAY_URL"); + cmd.env_remove("BUZZ_PRIVATE_KEY"); + } + // Per-runtime environment defaults (e.g. Hermes MCP-startup isolation). // Applied first so both persona `extra_env` (below, via `Command::env` // key replacement) and inherited parent env (via the parent-presence @@ -505,7 +602,9 @@ impl AcpClient { // Handled by build_codex_config_env; skip here to avoid double-setting. continue; } - if std::env::var_os(key).is_none() { + if (forward_buzz_publisher_credentials && harness_bound_agent_env(key)) + || (!harness_bound_agent_env(key) && std::env::var_os(key).is_none()) + { cmd.env(key, value); } } @@ -547,6 +646,8 @@ impl AcpClient { observer_agent_index: None, observer_context: ObserverContext::default(), active_run_id: None, + last_turn_run_id: None, + active_session_key: None, steering_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), @@ -751,7 +852,27 @@ impl AcpClient { idle_timeout: std::time::Duration, max_duration: std::time::Duration, ) -> Result { - let params = build_prompt_params(session_id, prompt_blocks); + self.session_prompt_blocks_with_idle_timeout_and_meta( + session_id, + prompt_blocks, + None, + idle_timeout, + max_duration, + ) + .await + } + + /// Send `session/prompt`, optionally attaching a verified Buzz event as + /// ACP extension metadata outside the model-visible content blocks. + pub(crate) async fn session_prompt_blocks_with_idle_timeout_and_meta( + &mut self, + session_id: &str, + prompt_blocks: &[&str], + trusted_inbound_event: Option<&TrustedInboundEventEnvelope>, + idle_timeout: std::time::Duration, + max_duration: std::time::Duration, + ) -> Result { + let params = build_prompt_params(session_id, prompt_blocks, trusted_inbound_event); let hard_deadline = tokio::time::Instant::now() + max_duration; self.current_hard_deadline = Some(hard_deadline); @@ -841,6 +962,24 @@ impl AcpClient { self.active_run_id.as_deref() } + /// Stable backend session key observed from ACP session lineage evidence. + pub fn active_session_key(&self) -> Option<&str> { + self.active_session_key.as_deref() + } + + /// Run ID observed during the current turn, including after active state clears. + pub fn turn_run_id(&self) -> Option<&str> { + self.active_run_id + .as_deref() + .or(self.last_turn_run_id.as_deref()) + } + + /// Reset turn-scoped evidence before issuing a new prompt. + pub fn begin_turn_evidence(&mut self) { + self.active_run_id = None; + self.last_turn_run_id = None; + } + /// Whether the agent advertised the [`ACP_STEER_METHOD`] extension at /// `initialize` time (`_meta.steering.supported`). /// @@ -1775,19 +1914,30 @@ impl AcpClient { // on the update object itself — nested inside `update`, not // alongside it at the params level. Goose and buzz-agent both // emit it at `params.update._meta.goose.activeRunId`. - let meta = msg["params"]["update"] - .get("_meta") - .and_then(|m| m.get("goose")); - if let Some(goose_meta) = meta { - match goose_meta.get("activeRunId") { - Some(serde_json::Value::String(run_id)) => { + let update_meta = msg["params"]["update"].get("_meta"); + if let Some(session_key) = update_meta + .and_then(|meta| meta.get("sessionKey")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + { + self.active_session_key = Some(session_key.to_string()); + } + let run_value = update_meta.and_then(|meta| { + meta.get("activeRunId") + .or_else(|| meta.get("runId")) + .or_else(|| meta.get("goose").and_then(|goose| goose.get("activeRunId"))) + }); + if let Some(run_value) = run_value { + match run_value { + serde_json::Value::String(run_id) => { tracing::debug!( target: "acp::update", "session_info_update: activeRunId={run_id}" ); self.active_run_id = Some(run_id.clone()); + self.last_turn_run_id = Some(run_id.clone()); } - Some(serde_json::Value::Null) => { + serde_json::Value::Null => { tracing::debug!( target: "acp::update", "session_info_update: activeRunId cleared" @@ -1950,15 +2100,27 @@ impl AcpClient { } /// Build `session/prompt` params from one or more text content blocks. -fn build_prompt_params(session_id: &str, prompt_blocks: &[&str]) -> serde_json::Value { +fn build_prompt_params( + session_id: &str, + prompt_blocks: &[&str], + trusted_inbound_event: Option<&TrustedInboundEventEnvelope>, +) -> serde_json::Value { let blocks: Vec = prompt_blocks .iter() .map(|text| serde_json::json!({ "type": "text", "text": text })) .collect(); - serde_json::json!({ + let mut params = serde_json::json!({ "sessionId": session_id, "prompt": blocks, - }) + }); + if let Some(envelope) = trusted_inbound_event { + params["_meta"] = serde_json::json!({ + TRUSTED_INBOUND_EVENT_META_NAMESPACE: { + TRUSTED_INBOUND_EVENT_META_FIELD: envelope, + }, + }); + } + params } /// Build `_goose/unstable/session/steer` params from one or more text @@ -2223,6 +2385,70 @@ fn configure_no_window(cmd: &mut tokio::process::Command) { mod tests { use super::*; + #[test] + fn harness_buzz_credentials_override_parent_environment() { + assert!(harness_bound_agent_env("BUZZ_RELAY_URL")); + assert!(harness_bound_agent_env("BUZZ_PRIVATE_KEY")); + assert!(!harness_bound_agent_env("GOOSE_PROVIDER")); + assert!(!harness_bound_agent_env("CODEX_CONFIG")); + } + + #[tokio::test] + async fn isolated_child_cannot_read_explicit_publisher_credentials() { + let script = r#" + if test -n "$BUZZ_RELAY_URL" || test -n "$BUZZ_PRIVATE_KEY"; then + exit 23 + fi + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' + "#; + let publisher_env = vec![ + ( + "BUZZ_RELAY_URL".to_string(), + "ws://relay.invalid".to_string(), + ), + ("BUZZ_PRIVATE_KEY".to_string(), "secret".to_string()), + ]; + let mut client = AcpClient::spawn( + "bash", + &["-c".to_string(), script.to_string()], + &publisher_env, + false, + false, + ) + .await + .expect("spawn isolated child"); + + client + .initialize() + .await + .expect("child initializes only when publisher credentials are absent"); + } + + fn signed_batch( + channel_id: uuid::Uuid, + extra_tags: Vec, + ) -> crate::queue::FlushBatch { + let keys = nostr::Keys::generate(); + let mut tags = + vec![nostr::Tag::parse(["h", channel_id.to_string().as_str()]).expect("h tag")]; + tags.extend(extra_tags); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "hello") + .tags(tags) + .sign_with_keys(&keys) + .expect("signed event"); + crate::queue::FlushBatch { + channel_id, + events: vec![crate::queue::BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } + } + #[test] fn stop_reason_parses_all_known_values() { assert_eq!(StopReason::from_str("end_turn"), Some(StopReason::EndTurn)); @@ -2456,6 +2682,7 @@ mod tests { "/goal ship it", "[Buzz event: @mention]\nContent: @Eva /goal ship it", ], + None, ); let prompt = params["prompt"].as_array().unwrap(); assert_eq!(prompt.len(), 2); @@ -2465,6 +2692,109 @@ mod tests { assert_eq!(prompt[1]["type"].as_str(), Some("text")); } + #[test] + fn trusted_inbound_event_is_verified_and_stays_out_of_prompt_blocks() { + let channel_id = uuid::Uuid::new_v4(); + let p_tag = nostr::Tag::parse(["p", "ab".repeat(32).as_str()]).expect("p tag"); + let batch = signed_batch(channel_id, vec![p_tag]); + let envelope = TrustedInboundEventEnvelope::from_prompt_batch(Some(&batch)) + .expect("valid signed single event"); + let params = build_prompt_params("session", &["model-visible body"], Some(&envelope)); + + assert_eq!(params["prompt"][0]["text"], "model-visible body"); + assert_eq!(params["prompt"].as_array().map(Vec::len), Some(1)); + assert_eq!( + params["_meta"].as_object().map(|value| value.len()), + Some(1) + ); + assert_eq!( + params["_meta"][TRUSTED_INBOUND_EVENT_META_NAMESPACE] + .as_object() + .map(|value| value.len()), + Some(1) + ); + let metadata = ¶ms["_meta"][TRUSTED_INBOUND_EVENT_META_NAMESPACE] + [TRUSTED_INBOUND_EVENT_META_FIELD]; + assert_eq!(metadata["schemaVersion"], 1); + assert_eq!(metadata["eventId"], batch.events[0].event.id.to_hex()); + assert_eq!( + metadata["authorPubkey"], + batch.events[0].event.pubkey.to_hex() + ); + assert_eq!(metadata["kind"], 9); + assert_eq!(metadata["channelId"], channel_id.to_string()); + assert_eq!( + metadata["tags"], + serde_json::json!([["h", channel_id.to_string()], ["p", "ab".repeat(32)],]) + ); + } + + #[test] + fn trusted_inbound_event_fails_closed_for_tampering_or_ambiguous_room() { + let channel_id = uuid::Uuid::new_v4(); + let mut tampered = signed_batch(channel_id, vec![]); + let mut raw = serde_json::to_value(&tampered.events[0].event).expect("serialize"); + raw["content"] = serde_json::Value::String("tampered".into()); + tampered.events[0].event = serde_json::from_value(raw).expect("parse tampered event"); + assert!(TrustedInboundEventEnvelope::from_prompt_batch(Some(&tampered)).is_none()); + assert_eq!( + TrustedInboundEventEnvelope::try_from_prompt_batch(Some(&tampered)).err(), + Some("invalid_signature") + ); + + let second_h = nostr::Tag::parse(["h", channel_id.to_string().as_str()]).expect("h tag"); + let duplicate_room = signed_batch(channel_id, vec![second_h]); + assert!(TrustedInboundEventEnvelope::from_prompt_batch(Some(&duplicate_room)).is_none()); + + let other_channel = uuid::Uuid::new_v4(); + let wrong_h = + nostr::Tag::parse(["h", other_channel.to_string().as_str()]).expect("wrong h tag"); + let mut wrong_room = signed_batch(channel_id, vec![]); + wrong_room.events[0].event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "hello") + .tags([wrong_h]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("signed wrong-room event"); + assert!(TrustedInboundEventEnvelope::from_prompt_batch(Some(&wrong_room)).is_none()); + + let mut multi = signed_batch(channel_id, vec![]); + multi.events.push(multi.events[0].clone()); + assert!(TrustedInboundEventEnvelope::from_prompt_batch(Some(&multi)).is_none()); + assert_eq!( + TrustedInboundEventEnvelope::try_from_prompt_batch(Some(&multi)).err(), + Some("ambiguous_event_count") + ); + + let mut cancelled = signed_batch(channel_id, vec![]); + cancelled.cancelled_events.push(cancelled.events[0].clone()); + assert!(TrustedInboundEventEnvelope::from_prompt_batch(Some(&cancelled)).is_none()); + assert_eq!( + TrustedInboundEventEnvelope::try_from_prompt_batch(Some(&cancelled)).err(), + Some("cancelled_events_present") + ); + + // Queue fallback re-dispatches cancelled events in the regular event + // slot while preserving the cancellation reason. That shape must not + // regain trusted inbound authority merely because cancelled_events is + // empty after the move. + let mut cancelled_fallback = signed_batch(channel_id, vec![]); + cancelled_fallback.cancel_reason = Some(crate::queue::CancelReason::Steer); + assert!( + TrustedInboundEventEnvelope::from_prompt_batch(Some(&cancelled_fallback)).is_none() + ); + assert_eq!( + TrustedInboundEventEnvelope::try_from_prompt_batch(Some(&cancelled_fallback)).err(), + Some("cancelled_batch") + ); + + assert!(TrustedInboundEventEnvelope::from_prompt_batch(None).is_none()); + } + + #[test] + fn ordinary_prompt_params_have_no_trusted_metadata() { + let params = build_prompt_params("session", &["hello"], None); + assert!(params.get("_meta").is_none()); + } + #[test] fn permission_response_selected_format() { let id: u64 = 5; @@ -2847,7 +3177,7 @@ mod tests { } async fn spawn_script(script: &str) -> AcpClient { - AcpClient::spawn("bash", &["-c".into(), script.into()], &[], false) + AcpClient::spawn("bash", &["-c".into(), script.into()], &[], false, true) .await .expect("failed to spawn test script") } @@ -2880,6 +3210,7 @@ mod tests { &[], extra_env, false, + true, ) .await .expect("spawn env probe script"); @@ -3430,7 +3761,7 @@ mod tests { /// which is fine — these tests don't read from the agent, they just /// feed JSON into the parser. async fn spawn_inert_client() -> AcpClient { - AcpClient::spawn("cat", &[], &[], false) + AcpClient::spawn("cat", &[], &[], false, true) .await .expect("spawn cat as inert client") } @@ -3488,6 +3819,37 @@ mod tests { client.active_run_id().is_none(), "explicit null must clear active_run_id" ); + assert_eq!( + client.turn_run_id(), + Some("run-xyz"), + "turn evidence must retain the completed run id" + ); + } + + #[tokio::test] + async fn openclaw_session_lineage_and_flat_run_id_are_captured() { + let mut client = spawn_inert_client().await; + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "acp-session", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "sessionKey": "agent:main:buzz-private", + "runId": "gateway-run-42" + } + } + } + }); + let _ = client.handle_session_update(&msg); + assert_eq!(client.active_session_key(), Some("agent:main:buzz-private")); + assert_eq!(client.turn_run_id(), Some("gateway-run-42")); + + client.begin_turn_evidence(); + assert_eq!(client.active_session_key(), Some("agent:main:buzz-private")); + assert!(client.turn_run_id().is_none()); } #[tokio::test] diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index dab61be30a..e7ce0a7966 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -4,11 +4,12 @@ //! Config file (TOML) for complex subscription rules. use std::collections::{HashMap, HashSet}; +use std::io::Read; use std::path::PathBuf; use clap::Parser; use clap::ValueEnum; -use nostr::Keys; +use nostr::{Keys, PublicKey}; use thiserror::Error; use url::Url; use uuid::Uuid; @@ -47,6 +48,51 @@ pub enum ConfigError { ConfigFile(String), } +fn read_owned_secret_file(path: &std::path::Path) -> Result { + if !path.is_absolute() { + return Err(ConfigError::ConfigFile( + "--private-key-file must be an absolute path".into(), + )); + } + #[cfg(unix)] + let mut file = { + use nix::fcntl::{open, OFlag}; + use nix::sys::stat::Mode; + + let fd = open(path, OFlag::O_RDONLY | OFlag::O_NOFOLLOW, Mode::empty()) + .map_err(std::io::Error::from)?; + std::fs::File::from(fd) + }; + #[cfg(not(unix))] + let mut file = std::fs::OpenOptions::new().read(true).open(path)?; + + // Validate metadata from the same no-follow handle that supplies the key. + // This avoids a validate-then-reopen race where the path could be swapped. + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(ConfigError::ConfigFile( + "--private-key-file must be a regular, non-symlink file".into(), + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + if metadata.permissions().mode() & 0o777 != 0o600 { + return Err(ConfigError::ConfigFile( + "--private-key-file permissions must be 0600".into(), + )); + } + if metadata.uid() != nix::unistd::getuid().as_raw() { + return Err(ConfigError::ConfigFile( + "--private-key-file must be owned by the current user".into(), + )); + } + } + let mut secret = String::new(); + file.read_to_string(&mut secret)?; + Ok(secret.trim().to_string()) +} + #[derive(Debug, Clone, PartialEq, clap::ValueEnum)] pub enum SubscribeMode { Mentions, @@ -240,8 +286,33 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_RELAY_URL", default_value = "ws://localhost:3000")] pub relay_url: String, - #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] - pub private_key: String, + #[arg( + long, + env = "BUZZ_PRIVATE_KEY", + hide_env_values = true, + conflicts_with = "private_key_file", + required_unless_present = "private_key_file" + )] + pub private_key: Option, + + /// Read the Nostr private key from an operator-owned regular file. + #[arg( + long, + env = "BUZZ_PRIVATE_KEY_FILE", + hide_env_values = true, + conflicts_with = "private_key", + required_unless_present = "private_key" + )] + pub private_key_file: Option, + + /// Expected public key derived from `--private-key-file`. + #[arg( + long, + env = "BUZZ_EXPECTED_PUBLIC_KEY", + hide_env_values = true, + requires = "private_key_file" + )] + pub expected_public_key: Option, /// Agent owner pubkey (64-char hex). Used for --respond-to=owner-only gate. #[arg(long, env = "BUZZ_ACP_AGENT_OWNER")] @@ -474,6 +545,44 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)] pub relay_observer: bool, + /// Close successful OpenClaw turns with request/reply/session/run evidence. + /// Default-off because most ACP adapters do not expose Gateway lineage. + #[arg( + long, + env = "BUZZ_ACP_TURN_RECEIPTS", + default_value_t = false, + requires = "relay_observer" + )] + pub turn_receipts: bool, + + /// Fixed Gateway session key that observed ACP lineage must match. + #[arg( + long, + env = "BUZZ_ACP_EXPECTED_GATEWAY_SESSION_KEY", + hide_env_values = true, + requires = "turn_receipts" + )] + pub expected_gateway_session_key: Option, + + /// Attach one signature-verified triggering Buzz event to ACP request + /// metadata. This is never rendered into model-visible prompt text. + #[arg( + long, + env = "BUZZ_ACP_TRUSTED_INBOUND_ENVELOPE", + default_value_t = false + )] + pub trusted_inbound_envelope: bool, + + /// Keep Buzz publisher credentials inside the harness instead of forwarding + /// them to the managed ACP subprocess. + #[arg( + long, + env = "BUZZ_ACP_NO_AGENT_PUBLISHER_CREDENTIALS", + hide_env_values = true, + default_value_t = false + )] + pub no_agent_publisher_credentials: bool, + /// Connect and subscribe before starting the ACP/LLM subprocess pool. #[arg(long, env = "BUZZ_ACP_LAZY_POOL", default_value_t = false)] pub lazy_pool: bool, @@ -550,6 +659,15 @@ pub struct Config { pub has_generated_codex_config: bool, /// Whether to publish encrypted observer frames through the relay. pub relay_observer: bool, + /// Whether successful channel turns require closed observer receipt evidence. + pub turn_receipts: bool, + /// Expected stable Gateway session key for receipt verification. + pub expected_gateway_session_key: Option, + /// Whether to attach a verified, non-model inbound event envelope to ACP prompts. + pub trusted_inbound_envelope: bool, + /// Whether the managed ACP subprocess may receive Buzz publisher credentials. + pub forward_agent_publisher_credentials: bool, + /// Whether ACP/LLM subprocess initialization is deferred until accepted work arrives. pub lazy_pool: bool, /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. @@ -676,12 +794,14 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String { .next() .expect("rsplit always yields at least one element"); let lower = basename.to_ascii_lowercase(); - // Windows resolves commands through `.exe` binaries and npm's `.cmd`/`.bat` - // shims; all three name the same runtime identity. - let stem = [".exe", ".cmd", ".bat"] - .iter() - .find_map(|extension| lower.strip_suffix(extension)) - .unwrap_or(&lower); + let stem = if lower == "openclaw.mjs" { + "openclaw" + } else { + [".exe", ".cmd", ".bat"] + .iter() + .find_map(|extension| lower.strip_suffix(extension)) + .unwrap_or(&lower) + }; stem.chars() .map(|character| match character { ' ' | '_' => '-', @@ -833,13 +953,28 @@ impl Config { /// tests can construct `CliArgs` via `CliArgs::try_parse_from` and exercise the full /// validation path without going through process args. pub fn from_args(mut args: CliArgs) -> Result { - let keys = Keys::parse(&args.private_key)?; + let mut private_key = if let Some(value) = args.private_key.take() { + value + } else if let Some(path) = args.private_key_file.as_ref() { + read_owned_secret_file(path)? + } else { + return Err(ConfigError::ConfigFile( + "one of --private-key or --private-key-file is required".into(), + )); + }; + let keys = Keys::parse(&private_key)?; + if let Some(expected) = args.expected_public_key.as_deref() { + if keys.public_key().to_hex() != expected.trim().to_ascii_lowercase() { + return Err(ConfigError::ConfigFile( + "private-key file does not derive the expected public key".into(), + )); + } + } // Best-effort zeroize: overwrite the raw private key string to reduce // exposure via core dumps or heap inspection (#41). Without the `zeroize` // crate we can only clear the String — the allocator may retain copies. - args.private_key - .replace_range(.., &"0".repeat(args.private_key.len())); - args.private_key.clear(); + private_key.replace_range(.., &"0".repeat(private_key.len())); + private_key.clear(); let system_prompt = if let Some(text) = args.system_prompt { Some(text) @@ -1052,6 +1187,46 @@ impl Config { }; validate_multiple_event_handling(args.multiple_event_handling, args.dedup)?; + if args.turn_receipts + && normalize_agent_command_identity(&agent_command).as_str() != "openclaw" + { + return Err(ConfigError::ConfigFile( + "--turn-receipts currently requires --agent-command=openclaw".into(), + )); + } + if args.turn_receipts + && args + .expected_gateway_session_key + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return Err(ConfigError::ConfigFile( + "--turn-receipts requires a non-empty --expected-gateway-session-key".into(), + )); + } + if args.turn_receipts + && !receipt_owner_resolves( + &keys, + args.agent_owner.as_deref(), + std::env::var("BUZZ_AUTH_TAG").ok().as_deref(), + ) + { + return Err(ConfigError::ConfigFile( + "--turn-receipts requires a valid --agent-owner or verified BUZZ_AUTH_TAG".into(), + )); + } + if args.no_agent_publisher_credentials + && (!args.trusted_inbound_envelope + || !args.turn_receipts + || args.no_base_prompt + || args.base_prompt_file.is_none()) + { + return Err(ConfigError::ConfigFile( + "--no-agent-publisher-credentials requires --trusted-inbound-envelope, \ + --turn-receipts, and --base-prompt-file as one fail-closed publisher contract" + .into(), + )); + } let config = Config { keys, @@ -1098,6 +1273,10 @@ impl Config { persona_env_vars, has_generated_codex_config, relay_observer: args.relay_observer, + turn_receipts: args.turn_receipts, + expected_gateway_session_key: args.expected_gateway_session_key, + trusted_inbound_envelope: args.trusted_inbound_envelope, + forward_agent_publisher_credentials: !args.no_agent_publisher_credentials, lazy_pool: args.lazy_pool, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, @@ -1123,7 +1302,7 @@ impl Config { format!(" allowed_respond_to=[{}]", modes.join(",")) }; format!( - "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", + "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} agent_publisher_credentials={} {}{}", self.relay_url, self.keys.public_key().to_hex(), self.agent_command, @@ -1144,10 +1323,51 @@ impl Config { self.memory_enabled, self.model.as_deref().unwrap_or("(agent default)"), self.permission_mode, + if self.forward_agent_publisher_credentials { + "forwarded" + } else { + "harness-only" + }, respond_to_detail, allowed_respond_to_detail, ) } + + /// Build the managed agent's runtime environment. + /// + /// Buzz transport credentials are derived from the already-validated + /// harness identity at spawn time. They are never stored in worker + /// manifests or supervisor artifacts, and they replace any persona-level + /// values for these reserved keys. + pub fn agent_spawn_env(&self) -> Vec<(String, String)> { + let mut env = self.persona_env_vars.clone(); + env.retain(|(key, _)| !matches!(key.as_str(), "BUZZ_RELAY_URL" | "BUZZ_PRIVATE_KEY")); + if !self.forward_agent_publisher_credentials { + return env; + } + env.push(("BUZZ_RELAY_URL".into(), self.relay_url.clone())); + env.push(( + "BUZZ_PRIVATE_KEY".into(), + self.keys.secret_key().to_secret_hex(), + )); + env + } +} + +fn receipt_owner_resolves( + keys: &Keys, + explicit_owner: Option<&str>, + auth_tag: Option<&str>, +) -> bool { + let verified_auth_owner = auth_tag + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some_and(|value| buzz_sdk::nip_oa::verify_auth_tag(value, &keys.public_key()).is_ok()); + let valid_explicit_owner = explicit_owner + .map(str::trim) + .filter(|value| value.len() == 64) + .is_some_and(|value| PublicKey::from_hex(value).is_ok()); + verified_auth_owner || valid_explicit_owner } #[derive(Debug, serde::Deserialize)] @@ -1222,6 +1442,26 @@ pub fn load_rules(path: &std::path::Path) -> Result, Confi ))); } } + if rule.admit_invited_ephemeral + && !matches!(rule.channels, crate::filter::ChannelScope::List(_)) + { + return Err(ConfigError::ConfigFile(format!( + "rule '{}': admit_invited_ephemeral requires channels to be a UUID list", + rule.name + ))); + } + if rule.admit_invited_ephemeral && !rule.require_mention { + return Err(ConfigError::ConfigFile(format!( + "rule '{}': admit_invited_ephemeral requires require_mention=true", + rule.name + ))); + } + if rule.admit_invited_ephemeral && !rule.require_exact_channel_tag { + return Err(ConfigError::ConfigFile(format!( + "rule '{}': admit_invited_ephemeral requires require_exact_channel_tag=true", + rule.name + ))); + } // Deserialization leaves consecutive_timeouts at its zero default; reset explicitly. rule.consecutive_timeouts = Arc::new(AtomicU32::new(0)); } @@ -1229,6 +1469,31 @@ pub fn load_rules(path: &std::path::Path) -> Result, Confi Ok(config.rules) } +/// Add a metadata-verified ephemeral channel to every opt-in rule. +pub fn admit_invited_ephemeral_channel(rules: &mut [SubscriptionRule], channel_id: Uuid) -> bool { + let channel = channel_id.to_string(); + let mut admitted = false; + for rule in rules.iter_mut().filter(|rule| rule.admit_invited_ephemeral) { + if let crate::filter::ChannelScope::List(ids) = &mut rule.channels { + if !ids.contains(&channel) { + ids.push(channel.clone()); + } + admitted = true; + } + } + admitted +} + +/// Remove a departed ephemeral channel from opt-in rules. +pub fn remove_invited_ephemeral_channel(rules: &mut [SubscriptionRule], channel_id: Uuid) { + let channel = channel_id.to_string(); + for rule in rules.iter_mut().filter(|rule| rule.admit_invited_ephemeral) { + if let crate::filter::ChannelScope::List(ids) = &mut rule.channels { + ids.retain(|id| id != &channel); + } + } +} + /// Resolve per-channel NIP-01 filters from config + discovered channels. pub fn resolve_channel_filters( config: &Config, @@ -1468,6 +1733,10 @@ mod tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + turn_receipts: false, + expected_gateway_session_key: None, + trusted_inbound_envelope: false, + forward_agent_publisher_credentials: true, lazy_pool: false, agent_owner: None, no_base_prompt: false, @@ -1475,6 +1744,86 @@ mod tests { } } + #[test] + fn receipt_owner_requires_verified_auth_or_valid_explicit_pubkey() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let owner = owner_keys.public_key().to_hex(); + let auth_tag = + buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "") + .expect("test auth tag"); + + assert!(receipt_owner_resolves(&agent_keys, Some(&owner), None)); + assert!(receipt_owner_resolves(&agent_keys, None, Some(&auth_tag))); + assert!(!receipt_owner_resolves(&agent_keys, None, None)); + assert!(!receipt_owner_resolves( + &agent_keys, + Some("not-a-pubkey"), + Some("not-an-auth-tag") + )); + } + + #[test] + fn managed_agent_env_uses_validated_harness_buzz_identity() { + let mut config = test_config(SubscribeMode::All); + config.persona_env_vars = vec![ + ("BUZZ_RELAY_URL".into(), "ws://wrong.invalid".into()), + ("BUZZ_PRIVATE_KEY".into(), "wrong-secret".into()), + ("SAFE_PERSONA_SETTING".into(), "kept".into()), + ]; + + let env = config.agent_spawn_env(); + assert_eq!( + env.iter() + .filter(|(key, _)| key == "BUZZ_RELAY_URL") + .count(), + 1 + ); + assert_eq!( + env.iter() + .find(|(key, _)| key == "BUZZ_RELAY_URL") + .map(|(_, value)| value.as_str()), + Some(config.relay_url.as_str()) + ); + assert_eq!( + env.iter() + .filter(|(key, _)| key == "BUZZ_PRIVATE_KEY") + .count(), + 1 + ); + let expected_secret = config.keys.secret_key().to_secret_hex(); + assert_eq!( + env.iter() + .find(|(key, _)| key == "BUZZ_PRIVATE_KEY") + .map(|(_, value)| value.as_str()), + Some(expected_secret.as_str()) + ); + assert!(env.contains(&("SAFE_PERSONA_SETTING".into(), "kept".into()))); + } + + #[test] + fn isolated_agent_env_retains_harness_identity_but_omits_publisher_credentials() { + let mut config = test_config(SubscribeMode::All); + let harness_relay = config.relay_url.clone(); + let harness_pubkey = config.keys.public_key().to_hex(); + config.forward_agent_publisher_credentials = false; + config.persona_env_vars = vec![ + ("BUZZ_RELAY_URL".into(), "ws://wrong.invalid".into()), + ("BUZZ_PRIVATE_KEY".into(), "wrong-secret".into()), + ("SAFE_PERSONA_SETTING".into(), "kept".into()), + ]; + + let env = config.agent_spawn_env(); + assert!(!env.iter().any(|(key, _)| key == "BUZZ_RELAY_URL")); + assert!(!env.iter().any(|(key, _)| key == "BUZZ_PRIVATE_KEY")); + assert!(env.contains(&("SAFE_PERSONA_SETTING".into(), "kept".into()))); + assert_eq!(config.relay_url, harness_relay); + assert_eq!(config.keys.public_key().to_hex(), harness_pubkey); + assert!(config + .summary() + .contains("agent_publisher_credentials=harness-only")); + } + fn make_rule( name: &str, channels: ChannelScope, @@ -1483,9 +1832,12 @@ mod tests { ) -> SubscriptionRule { use std::sync::atomic::AtomicU32; use std::sync::Arc; + SubscriptionRule { name: name.into(), channels, + admit_invited_ephemeral: false, + require_exact_channel_tag: false, kinds, require_mention: mention, filter: None, @@ -1495,6 +1847,47 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn private_key_file_requires_regular_owned_mode_0600_and_expected_pubkey() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let dir = std::env::temp_dir().join(format!("buzz-acp-key-test-{}", Uuid::new_v4())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("aspect.sk"); + let keys = Keys::generate(); + std::fs::write(&path, keys.secret_key().to_secret_hex()).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert_eq!( + read_owned_secret_file(&path).expect("owned 0600 regular file"), + keys.secret_key().to_secret_hex() + ); + + let args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key-file", + path.to_str().unwrap(), + "--expected-public-key", + &"f".repeat(64), + ]) + .unwrap(); + assert!(matches!( + Config::from_args(args), + Err(ConfigError::ConfigFile(message)) + if message == "private-key file does not derive the expected public key" + )); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(read_owned_secret_file(&path).is_err()); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let link = dir.join("aspect-link.sk"); + symlink(&path, &link).unwrap(); + assert!(read_owned_secret_file(&link).is_err()); + std::fs::remove_file(link).unwrap(); + std::fs::remove_file(path).unwrap(); + std::fs::remove_dir(dir).unwrap(); + } + #[test] fn test_mentions_mode_default_kinds() { let config = test_config(SubscribeMode::Mentions); @@ -1613,6 +2006,11 @@ mod tests { "claude-code" ); assert_eq!(normalize_agent_command_identity("Goose.EXE"), "goose"); + assert_eq!( + normalize_agent_command_identity("/immutable/generation/openclaw.mjs"), + "openclaw" + ); + assert_eq!(normalize_agent_command_identity("codex.mjs"), "codex.mjs"); // Windows npm shims resolve to `.cmd`/`.bat` wrappers. assert_eq!( normalize_agent_command_identity(r"C:\Users\test\AppData\Roaming\npm\hermes-acp.cmd"), @@ -1895,6 +2293,195 @@ mod tests { assert!(result.is_empty()); } + #[test] + fn invited_ephemeral_rule_admits_and_removes_verified_channel() { + let private = Uuid::new_v4(); + let huddle = Uuid::new_v4(); + let mut private_rule = make_rule( + "private-office", + ChannelScope::List(vec![private.to_string()]), + vec![9], + false, + ); + private_rule.admit_invited_ephemeral = false; + let mut huddle_rule = + make_rule("invited-huddle", ChannelScope::List(vec![]), vec![9], true); + huddle_rule.admit_invited_ephemeral = true; + let mut rules = vec![private_rule, huddle_rule]; + + assert!(admit_invited_ephemeral_channel(&mut rules, huddle)); + let config = test_config(SubscribeMode::Config); + let filters = resolve_channel_filters(&config, &[private, huddle], &rules); + assert!(!filters[&private].require_mention); + assert!(filters[&huddle].require_mention); + + remove_invited_ephemeral_channel(&mut rules, huddle); + let filters = resolve_channel_filters(&config, &[private, huddle], &rules); + assert!(filters.contains_key(&private)); + assert!(!filters.contains_key(&huddle)); + } + + #[test] + fn ordinary_unlisted_channel_is_not_admitted() { + let private = Uuid::new_v4(); + let concilium = Uuid::new_v4(); + let private_rule = make_rule( + "private-office", + ChannelScope::List(vec![private.to_string()]), + vec![9], + false, + ); + let mut huddle_rule = + make_rule("invited-huddle", ChannelScope::List(vec![]), vec![9], true); + huddle_rule.admit_invited_ephemeral = true; + let config = test_config(SubscribeMode::Config); + + let filters = + resolve_channel_filters(&config, &[private, concilium], &[private_rule, huddle_rule]); + assert!(filters.contains_key(&private)); + assert!(!filters.contains_key(&concilium)); + } + + #[test] + fn aeon_six_worker_configs_load_through_real_rules_and_clap() { + use clap::Parser as _; + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("repository root"); + let deploy = root.join("deploy/local/aeon-aspects"); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(deploy.join("workers.json")).expect("workers manifest"), + ) + .expect("valid workers manifest"); + let architect = manifest["buzz"]["architectPubkey"] + .as_str() + .expect("architect pubkey"); + let concilium = manifest["buzz"]["conciliumChannelId"] + .as_str() + .expect("concilium id"); + let workers = manifest["workers"].as_array().expect("worker array"); + assert_eq!(workers.len(), 6); + let private_office_template = + std::fs::read_to_string(deploy.join("prompts/private-office.template.md")) + .expect("private-office prompt template"); + + for worker in workers { + let aspect = worker["aspect"].as_str().expect("aspect"); + let session = worker["sessionKey"].as_str().expect("session key"); + let private = worker["privateChannelId"].as_str().expect("private room"); + let config_path = deploy.join("config").join(format!("{aspect}.toml")); + let rules = load_rules(&config_path).expect("load production rule file"); + assert_eq!(rules.len(), 2); + assert!(!rules[0].admit_invited_ephemeral); + assert!(rules[0].require_exact_channel_tag); + assert!(!rules[0].require_mention); + assert!(rules[1].admit_invited_ephemeral); + assert!(rules[1].require_exact_channel_tag); + assert!(rules[1].require_mention); + assert_eq!(rules[0].kinds, vec![9, 40002]); + assert_eq!(rules[1].kinds, vec![9, 40002]); + assert!(rules.iter().all(|rule| { + rule.filter + .as_deref() + .is_some_and(|filter| filter.contains(architect)) + })); + let source = std::fs::read_to_string(&config_path).expect("config source"); + assert!(source.contains(private)); + assert!(!source.contains(concilium)); + + let agent_args = format!( + "acp,--session,{session},--require-existing,--token-file,/owned/token,--url,ws://127.0.0.1:18806,--provenance,meta+receipt,--no-prefix-cwd" + ); + let private_key = "1".repeat(64); + let mut argv = vec![ + "buzz-acp".to_owned(), + "--private-key".to_owned(), + private_key, + "--agent-owner".to_owned(), + architect.to_owned(), + "--agent-command".to_owned(), + "openclaw".to_owned(), + "--agent-args".to_owned(), + agent_args, + "--subscribe".to_owned(), + "config".to_owned(), + "--config".to_owned(), + config_path.to_string_lossy().into_owned(), + "--respond-to".to_owned(), + "owner-only".to_owned(), + "--allowed-respond-to".to_owned(), + "owner-only".to_owned(), + "--no-memory".to_owned(), + ]; + let base_prompt_file = worker["basePromptFile"] + .as_str() + .expect("every Aspect has a private-office base prompt"); + argv.push("--base-prompt-file".to_owned()); + argv.push(root.join(base_prompt_file).to_string_lossy().into_owned()); + argv.extend([ + "--dedup".to_owned(), + "queue".to_owned(), + "--multiple-event-handling".to_owned(), + "queue".to_owned(), + "--relay-observer".to_owned(), + "--trusted-inbound-envelope".to_owned(), + "--no-agent-publisher-credentials".to_owned(), + "--permission-mode".to_owned(), + "bypass-permissions".to_owned(), + "--heartbeat-interval".to_owned(), + "0".to_owned(), + "--turn-liveness-secs".to_owned(), + "10".to_owned(), + "--idle-timeout".to_owned(), + "900".to_owned(), + "--max-turn-duration".to_owned(), + "7200".to_owned(), + "--context-message-limit".to_owned(), + "12".to_owned(), + "--max-turns-per-session".to_owned(), + "0".to_owned(), + "--turn-receipts".to_owned(), + "--expected-gateway-session-key".to_owned(), + session.to_owned(), + ]); + let cli = CliArgs::try_parse_from(argv) + .expect("real Clap parser accepts rendered worker argv"); + assert!(cli.no_memory); + let expected_prompt_path = root.join(base_prompt_file); + assert!(!cli.no_base_prompt); + assert_eq!( + cli.base_prompt_file.as_deref(), + Some(expected_prompt_path.as_path()) + ); + let prompt = std::fs::read_to_string(expected_prompt_path).expect("base prompt"); + let expected_prompt = private_office_template + .replace("{{ROOM}}", &format!("#aspect-{aspect}")) + .replace("{{REPLY_TOOL}}", &format!("buzz_{aspect}_reply")); + assert_eq!(prompt, expected_prompt); + assert!(!prompt.contains("buzz messages send")); + assert!(cli.turn_receipts); + assert!(cli.trusted_inbound_envelope); + assert!(cli.no_agent_publisher_credentials); + assert_eq!(cli.permission_mode, PermissionMode::BypassPermissions); + assert_eq!(cli.heartbeat_interval, 0); + assert_eq!(cli.turn_liveness_secs, 10); + assert_eq!(cli.idle_timeout, Some(900)); + assert_eq!(cli.max_turn_duration, 7200); + assert_eq!(cli.context_message_limit, 12); + assert_eq!(cli.max_turns_per_session, 0); + assert!(cli.mcp_command.is_empty()); + assert!(cli.model.is_none()); + assert!(!cli.no_ignore_self); + assert!(!cli.no_presence); + assert!(!cli.no_typing); + assert_eq!(cli.expected_gateway_session_key.as_deref(), Some(session)); + assert!(cli.agent_args.iter().any(|arg| arg == "--require-existing")); + assert!(cli.agent_args.iter().any(|arg| arg == "--no-prefix-cwd")); + } + } + #[test] fn test_config_mode_require_mention_most_permissive() { let config = test_config(SubscribeMode::Config); @@ -2054,6 +2641,31 @@ channels = "ALL" std::fs::remove_dir_all(&dir).ok(); } + #[test] + fn test_load_rules_invited_ephemeral_requires_exact_channel_tag() { + let dir = std::env::temp_dir().join("buzz-acp-test-invited-exact-h"); + let path = dir.join("rules.toml"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + &path, + r#" +[[rules]] +name = "invited-huddle" +channels = [] +require_mention = true +admit_invited_ephemeral = true +require_exact_channel_tag = false +"#, + ) + .unwrap(); + + let err = load_rules(&path).unwrap_err(); + assert!(err + .to_string() + .contains("requires require_exact_channel_tag=true")); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn test_load_rules_too_many_rules_rejected() { let dir = std::env::temp_dir().join("buzz-acp-test-too-many"); @@ -2536,6 +3148,8 @@ channels = "ALL" assert_eq!(args.multiple_event_handling, MultipleEventHandling::Steer); // Dedup default must remain `queue` so steering's requirement is met. assert!(matches!(args.dedup, DedupMode::Queue)); + assert!(!args.trusted_inbound_envelope); + assert!(!args.no_agent_publisher_credentials); } #[test] @@ -2716,6 +3330,68 @@ channels = "ALL" // A minimal valid private key for test use (secp256k1 scalar = 1). const TEST_PRIVATE_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + const TEST_PUBLIC_KEY: &str = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + #[test] + fn isolated_publisher_contract_rejects_missing_trusted_envelope() { + let prompt_path = + std::env::temp_dir().join(format!("buzz-acp-publisher-contract-{}.md", Uuid::new_v4())); + std::fs::write(&prompt_path, "trusted publisher prompt").expect("write prompt"); + let args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--agent-owner", + TEST_PUBLIC_KEY, + "--agent-command", + "openclaw", + "--relay-observer", + "--turn-receipts", + "--expected-gateway-session-key", + "agent:main:buzz-private", + "--base-prompt-file", + prompt_path.to_str().expect("prompt path"), + "--no-agent-publisher-credentials", + ]) + .expect("clap should parse args"); + let error = Config::from_args(args).expect_err("contract must fail closed"); + std::fs::remove_file(prompt_path).expect("remove prompt"); + assert!(error + .to_string() + .contains("one fail-closed publisher contract")); + } + + #[test] + fn isolated_publisher_contract_accepts_complete_shape() { + let prompt_path = + std::env::temp_dir().join(format!("buzz-acp-publisher-contract-{}.md", Uuid::new_v4())); + std::fs::write(&prompt_path, "trusted publisher prompt").expect("write prompt"); + let args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--agent-owner", + TEST_PUBLIC_KEY, + "--agent-command", + "openclaw", + "--relay-observer", + "--turn-receipts", + "--expected-gateway-session-key", + "agent:main:buzz-private", + "--trusted-inbound-envelope", + "--base-prompt-file", + prompt_path.to_str().expect("prompt path"), + "--no-agent-publisher-credentials", + ]) + .expect("clap should parse args"); + let result = Config::from_args(args); + std::fs::remove_file(prompt_path).expect("remove prompt"); + assert!( + result.is_ok(), + "complete publisher contract should pass: {result:?}" + ); + } #[test] fn allowed_respond_to_full_path_rejects_disallowed_mode() { diff --git a/crates/buzz-acp/src/filter.rs b/crates/buzz-acp/src/filter.rs index 43edd969dd..5ac863cbcf 100644 --- a/crates/buzz-acp/src/filter.rs +++ b/crates/buzz-acp/src/filter.rs @@ -7,7 +7,7 @@ use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use tracing::{error, warn}; @@ -16,12 +16,34 @@ use tracing::{error, warn}; pub enum FilterError { #[error("expression too long ({len} bytes, max {max})")] ExpressionTooLong { len: usize, max: usize }, - #[error("evaluation timed out")] - Timeout, + #[error("evaluation timed out during {stage}")] + Timeout { stage: FilterTimeoutStage }, #[error("evaluation error: {0}")] EvalError(String), } +/// Stage at which a filter evaluation exhausted its deadline. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilterTimeoutStage { + /// Waiting for capacity in the bounded filter evaluator. + Admission, + /// Waiting for the blocking executor to start the admitted evaluation. + Start, + /// Evaluating the expression after the blocking job started. + Execution, +} + +impl std::fmt::Display for FilterTimeoutStage { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value = match self { + Self::Admission => "admission", + Self::Start => "start", + Self::Execution => "execution", + }; + formatter.write_str(value) + } +} + /// Variables extracted from a Nostr event for use in filter expressions. #[derive(Debug, Clone)] pub struct FilterContext { @@ -85,6 +107,13 @@ pub struct SubscriptionRule { pub name: String, /// Which channels this rule applies to. pub channels: ChannelScope, + /// Admit channels that the agent is invited to when their metadata carries + /// a positive TTL. Buzz huddles use TTL-scoped private channels. + #[serde(default)] + pub admit_invited_ephemeral: bool, + /// Require exactly one `h` tag equal to the resolved channel UUID. + #[serde(default)] + pub require_exact_channel_tag: bool, /// Nostr event kinds to match. Empty = wildcard (all kinds). #[serde(default)] pub kinds: Vec, @@ -118,6 +147,8 @@ impl Default for SubscriptionRule { Self { name: String::new(), channels: ChannelScope::All("all".into()), + admit_invited_ephemeral: false, + require_exact_channel_tag: false, kinds: Vec::new(), require_mention: false, filter: None, @@ -133,6 +164,8 @@ impl Clone for SubscriptionRule { Self { name: self.name.clone(), channels: self.channels.clone(), + admit_invited_ephemeral: self.admit_invited_ephemeral, + require_exact_channel_tag: self.require_exact_channel_tag, kinds: self.kinds.clone(), require_mention: self.require_mention, filter: self.filter.clone(), @@ -164,6 +197,13 @@ const MAX_EXPR_LEN: usize = 4096; /// Maximum wall-clock time allowed for a single evalexpr evaluation. const EVAL_TIMEOUT: Duration = Duration::from_millis(100); +/// Maximum time an admitted evaluation may wait for a blocking worker. +/// +/// This is deliberately separate from [`EVAL_TIMEOUT`]. Blocking-pool queue +/// latency is executor pressure, not expression execution time, and charging +/// it to the expression deadline caused trivial filters to fail spuriously. +const EVAL_START_TIMEOUT: Duration = Duration::from_secs(5); + /// Maximum concurrent blocking filter evaluations. /// /// The semaphore permit is moved into each `spawn_blocking` closure so it is @@ -209,6 +249,22 @@ pub async fn evaluate_filter( let eval_ctx = build_eval_context(ctx).map_err(FilterError::EvalError)?; let expr_owned = expr.to_owned(); + run_filter_eval(move || { + // Use the pre-compiled AST when available; fall back to string parsing. + if let Some(node) = node { + node.eval_boolean_with_context(&eval_ctx) + } else { + evalexpr::eval_boolean_with_context(&expr_owned, &eval_ctx) + } + .map_err(|error| error.to_string()) + }) + .await +} + +async fn run_filter_eval(evaluate: F) -> Result +where + F: FnOnce() -> Result + Send + 'static, +{ // Acquire an *owned* permit so it can be moved into the spawn_blocking closure. // The permit is held until the blocking task actually completes — not just until // the caller's timeout fires — so the semaphore truly bounds the number of live @@ -221,29 +277,53 @@ pub async fn evaluate_filter( Arc::clone(&*FILTER_EVAL_SEMAPHORE).acquire_owned(), ) .await - .map_err(|_| FilterError::Timeout)? + .map_err(|_| FilterError::Timeout { + stage: FilterTimeoutStage::Admission, + })? .map_err(|e| FilterError::EvalError(format!("semaphore closed: {e}")))?; - let result = tokio::time::timeout( - EVAL_TIMEOUT, - tokio::task::spawn_blocking(move || { - // Hold the permit for the lifetime of this closure: released only - // when the blocking thread returns, not when the caller times out. - let _permit = permit; - // Use the pre-compiled AST when available; fall back to string parsing. - if let Some(node) = node { - node.eval_boolean_with_context(&eval_ctx) - } else { - evalexpr::eval_boolean_with_context(&expr_owned, &eval_ctx) - } - }), - ) - .await - .map_err(|_| FilterError::Timeout)? - .map_err(|e| FilterError::EvalError(format!("eval task panicked: {e}")))? - .map_err(|e| FilterError::EvalError(e.to_string()))?; + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let mut task = tokio::task::spawn_blocking(move || { + // Hold the permit for the lifetime of this closure: released only + // when the blocking thread returns, not when the caller times out. + let _permit = permit; + let started_at = Instant::now(); + let _ = started_tx.send(()); + let result = evaluate(); + (result, started_at.elapsed()) + }); + + match tokio::time::timeout(EVAL_START_TIMEOUT, started_rx).await { + Ok(Ok(())) => {} + Ok(Err(_)) => { + return task + .await + .map_err(|error| FilterError::EvalError(format!("eval task panicked: {error}")))? + .0 + .map_err(FilterError::EvalError); + } + Err(_) => { + task.abort(); + return Err(FilterError::Timeout { + stage: FilterTimeoutStage::Start, + }); + } + } + + let (result, execution_time) = tokio::time::timeout(EVAL_TIMEOUT, &mut task) + .await + .map_err(|_| FilterError::Timeout { + stage: FilterTimeoutStage::Execution, + })? + .map_err(|error| FilterError::EvalError(format!("eval task panicked: {error}")))?; + + if execution_time > EVAL_TIMEOUT { + return Err(FilterError::Timeout { + stage: FilterTimeoutStage::Execution, + }); + } - Ok(result) + result.map_err(FilterError::EvalError) } /// Build an `evalexpr::HashMapContext` from a `FilterContext`. @@ -379,6 +459,23 @@ pub async fn match_event( continue; } + if rule.require_exact_channel_tag { + let channel = channel_id.to_string(); + let mut h_tag_count = 0; + let mut exact_channel = false; + for tag in event.tags.iter() { + let values = tag.as_slice(); + if values.first().map(|value| value.as_str()) == Some("h") { + h_tag_count += 1; + exact_channel = + values.get(1).map(|value| value.as_str()) == Some(channel.as_str()); + } + } + if h_tag_count != 1 || !exact_channel { + continue; + } + } + // 2. Kind filter (empty = wildcard). if !rule.kinds.is_empty() && !rule.kinds.contains(&(event.kind.as_u16() as u32)) { continue; @@ -423,12 +520,13 @@ pub async fn match_event( rule.consecutive_timeouts.store(0, Ordering::Relaxed); continue; } - Err(FilterError::Timeout) => { + Err(FilterError::Timeout { stage }) => { let n = rule.consecutive_timeouts.fetch_add(1, Ordering::Relaxed) + 1; warn!( rule = %rule.name, rule_index = index, consecutive_timeouts = n, + timeout_stage = %stage, "filter expression timed out; failing closed (no match for any rule)" ); // Fail-closed: timeout → no match, not next rule. @@ -484,6 +582,28 @@ mod tests { .unwrap() } + fn make_event_with_h_tags(kind: u32, channels: &[Uuid]) -> nostr::Event { + let keys = Keys::generate(); + let tags = channels + .iter() + .map(|channel| Tag::parse(["h".to_string(), channel.to_string()]).expect("h tag")); + EventBuilder::new(Kind::Custom(kind as u16), "hello") + .tags(tags) + .sign_with_keys(&keys) + .unwrap() + } + + fn make_event_with_valid_and_malformed_h_tag(kind: u32, channel: Uuid) -> nostr::Event { + let keys = Keys::generate(); + EventBuilder::new(Kind::Custom(kind as u16), "hello") + .tags([ + Tag::parse(["h".to_string(), channel.to_string()]).expect("valid h tag"), + Tag::parse(["h".to_string()]).expect("malformed h tag"), + ]) + .sign_with_keys(&keys) + .unwrap() + } + fn any_channel() -> Uuid { Uuid::new_v4() } @@ -499,6 +619,8 @@ mod tests { SubscriptionRule { name: name.into(), channels, + admit_invited_ephemeral: false, + require_exact_channel_tag: false, kinds, require_mention: mention, filter: filter.map(|s| s.into()), @@ -577,6 +699,75 @@ mod tests { assert!(result); } + #[test] + fn test_blocking_queue_delay_does_not_consume_expression_deadline() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .max_blocking_threads(1) + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let (blocker_started_tx, blocker_started_rx) = std::sync::mpsc::sync_channel(0); + let (release_blocker_tx, release_blocker_rx) = std::sync::mpsc::sync_channel(0); + let blocker = tokio::task::spawn_blocking(move || { + blocker_started_tx.send(()).unwrap(); + release_blocker_rx.recv().unwrap(); + }); + blocker_started_rx + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + + let release_thread = std::thread::spawn(move || { + std::thread::sleep(EVAL_TIMEOUT + Duration::from_millis(150)); + release_blocker_tx.send(()).unwrap(); + }); + + let event = make_event(9, "production request"); + let author = event.pubkey.to_hex(); + let expression = format!( + r#"author == "{a}" || author == "{author}" || author == "{c}""#, + a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + c = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + ); + let node = Arc::new(evalexpr::build_operator_tree(&expression).unwrap()); + let channel_id = any_channel(); + let mut rule = make_rule( + "private-office", + ChannelScope::List(vec![channel_id.to_string()]), + vec![9, 40002], + false, + Some(&expression), + None, + ); + rule.compiled_filter = Some(node); + + let matched = match_event(&event, channel_id, &[rule], "").await.unwrap(); + assert_eq!(matched.prompt_tag, "private-office"); + + release_thread.join().unwrap(); + blocker.await.unwrap(); + }); + } + + #[tokio::test] + async fn test_execution_deadline_remains_fail_closed() { + let error = run_filter_eval(|| { + std::thread::sleep(EVAL_TIMEOUT + Duration::from_millis(150)); + Ok(true) + }) + .await + .unwrap_err(); + + assert!(matches!( + error, + FilterError::Timeout { + stage: FilterTimeoutStage::Execution + } + )); + } + #[tokio::test] async fn test_match_event_first_match_wins() { let event = make_event(9, "hello"); @@ -635,6 +826,55 @@ mod tests { assert_eq!(matched.prompt_tag, "matched"); } + #[tokio::test] + async fn test_match_event_exact_channel_tag_rejects_missing_wrong_or_multiple() { + let channel = any_channel(); + let other = any_channel(); + let mut rule = make_rule( + "exact-room", + ChannelScope::List(vec![channel.to_string()]), + vec![9], + false, + None, + None, + ); + rule.require_exact_channel_tag = true; + assert!( + match_event(&make_event(9, "missing"), channel, &[rule.clone()], "") + .await + .is_none() + ); + assert!(match_event( + &make_event_with_h_tags(9, &[other]), + channel, + &[rule.clone()], + "" + ) + .await + .is_none()); + assert!(match_event( + &make_event_with_h_tags(9, &[channel, other]), + channel, + &[rule.clone()], + "" + ) + .await + .is_none()); + assert!(match_event( + &make_event_with_valid_and_malformed_h_tag(9, channel), + channel, + &[rule.clone()], + "" + ) + .await + .is_none()); + assert!( + match_event(&make_event_with_h_tags(9, &[channel]), channel, &[rule], "") + .await + .is_some() + ); + } + #[tokio::test] async fn test_match_event_require_mention() { let agent_pubkey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 403512a322..b9336d232f 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -361,6 +361,63 @@ async fn check_sibling_via_profile( false } +fn eligible_ephemeral_deadline( + current: &Result, relay::RelayError>, + now: chrono::DateTime, +) -> Option> { + match current { + Ok(Some(info)) if info.is_ephemeral_at(now) => info.ttl_deadline, + Ok(_) | Err(_) => None, + } +} + +#[cfg(test)] +mod ephemeral_admission_tests { + use super::*; + + fn channel_info( + channel_type: &str, + ttl_seconds: Option, + deadline: Option>, + ) -> relay::ChannelInfo { + relay::ChannelInfo { + name: "huddle".into(), + channel_type: channel_type.into(), + ttl_seconds, + ttl_deadline: deadline, + metadata_created_at: Some(1), + metadata_event_id: Some("a".repeat(64)), + } + } + + #[test] + fn canonical_revalidation_fails_closed_and_accepts_only_future_private_ttl() { + let now = chrono::Utc::now(); + let renewed = now + chrono::Duration::minutes(5); + assert_eq!( + eligible_ephemeral_deadline( + &Ok(Some(channel_info("private", Some(300), Some(renewed)))), + now + ), + Some(renewed) + ); + for current in [ + Ok(None), + Ok(Some(channel_info( + "private", + Some(300), + Some(now - chrono::Duration::seconds(1)), + ))), + Ok(Some(channel_info("private", Some(300), None))), + Ok(Some(channel_info("private", Some(0), Some(renewed)))), + Ok(Some(channel_info("stream", Some(300), Some(renewed)))), + Err(relay::RelayError::Timeout), + ] { + assert_eq!(eligible_ephemeral_deadline(¤t, now), None); + } + } +} + const OBSERVER_PUBLISH_INTERVAL: Duration = Duration::from_millis(167); const OBSERVER_PUBLISH_LIMIT_PER_MINUTE: usize = 90; @@ -1434,11 +1491,13 @@ async fn tokio_main() -> Result<()> { tracing::info!("discovered {} channel(s)", channel_info_map.len()); let channel_ids: Vec = channel_info_map.keys().copied().collect(); - let rules: Vec = match config.subscribe_mode { + let mut rules: Vec = match config.subscribe_mode { SubscribeMode::Mentions => { vec![SubscriptionRule { name: "mentions".into(), channels: filter::ChannelScope::All("all".into()), + admit_invited_ephemeral: false, + require_exact_channel_tag: false, kinds: config.kinds_override.clone().unwrap_or_else(|| { vec![ KIND_STREAM_MESSAGE, @@ -1457,6 +1516,8 @@ async fn tokio_main() -> Result<()> { vec![SubscriptionRule { name: "all".into(), channels: filter::ChannelScope::All("all".into()), + admit_invited_ephemeral: false, + require_exact_channel_tag: false, kinds: config.kinds_override.clone().unwrap_or_default(), require_mention: false, filter: None, @@ -1471,6 +1532,22 @@ async fn tokio_main() -> Result<()> { } }; + let mut admitted_ephemeral_deadlines: HashMap> = + HashMap::new(); + for (channel_id, info) in &channel_info_map { + if info.is_ephemeral() && config::admit_invited_ephemeral_channel(&mut rules, *channel_id) { + if let Some(deadline) = info.ttl_deadline { + admitted_ephemeral_deadlines.insert(*channel_id, deadline); + } + tracing::debug!( + %channel_id, + metadata_created_at = ?info.metadata_created_at, + metadata_event_id = ?info.metadata_event_id, + "admitted canonical private TTL channel" + ); + } + } + let channel_filters = config::resolve_channel_filters(&config, &channel_ids, &rules); if channel_filters.is_empty() { tracing::warn!("no channel subscriptions resolved — agent will sit idle"); @@ -1558,6 +1635,9 @@ async fn tokio_main() -> Result<()> { .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), memory_enabled: config.memory_enabled, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), + turn_receipts: config.turn_receipts, + expected_gateway_session_key: config.expected_gateway_session_key.clone(), + trusted_inbound_envelope: config.trusted_inbound_envelope, relay_url: config.relay_url.clone(), }); @@ -1598,6 +1678,10 @@ async fn tokio_main() -> Result<()> { } else { None }; + let mut ephemeral_revalidation = tokio::time::interval_at( + tokio::time::Instant::now() + Duration::from_secs(15), + Duration::from_secs(15), + ); let mut typing_channels: HashMap = HashMap::new(); let mut presence_task: Option> = None; @@ -1758,12 +1842,22 @@ async fn tokio_main() -> Result<()> { tracing::info!(agent = idx, "slot refill: spawning background respawn"); let cmd = config.agent_command.clone(); let args = config.agent_args.clone(); - let env = config.persona_env_vars.clone(); + let env = config.agent_spawn_env(); let has_codex = config.has_generated_codex_config; + let forward_publisher_credentials = config.forward_agent_publisher_credentials; let observer = observer.clone(); let guard = RespawnGuard::new(idx, respawn_tx.clone()); respawn_tasks.spawn(async move { - let result = spawn_and_init(&cmd, &args, &env, has_codex, idx, observer).await; + let result = spawn_and_init( + &cmd, + &args, + &env, + has_codex, + forward_publisher_credentials, + idx, + observer, + ) + .await; guard.send(result); }); } @@ -1966,26 +2060,61 @@ async fn tokio_main() -> Result<()> { if subscribed_channel_ids.contains(&ch) { tracing::debug!(channel_id = %ch, "membership notification: channel already subscribed"); - } else if let Some(filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) { - tracing::info!(channel_id = %ch, "membership notification: subscribing to new channel"); - if let Err(e) = relay.subscribe_channel_from(ch, filter, Some(ts)).await { - tracing::warn!("failed to subscribe to new channel {ch}: {e}"); + } else { + let mut filter = config::resolve_dynamic_channel_filter( + &config, ch, &rules, + ); + if filter.is_none() { + match relay.fetch_channel_info(ch).await { + Ok(Some(info)) if info.is_ephemeral() => { + if config::admit_invited_ephemeral_channel( + &mut rules, ch, + ) { + if let Some(deadline) = info.ttl_deadline { + admitted_ephemeral_deadlines + .insert(ch, deadline); + } + filter = config::resolve_dynamic_channel_filter( + &config, ch, &rules, + ); + } + } + Ok(_) => {} + Err(error) => { + tracing::warn!( + channel_id = %ch, + %error, + "membership metadata lookup failed — denying channel" + ); + } + } + } + if let Some(filter) = filter { + tracing::info!(channel_id = %ch, "membership notification: subscribing to new channel"); + if let Err(e) = relay.subscribe_channel_from(ch, filter, Some(ts)).await { + tracing::warn!("failed to subscribe to new channel {ch}: {e}"); + } else { + subscribed_channel_ids.insert(ch); + } } else { - subscribed_channel_ids.insert(ch); + tracing::debug!(channel_id = %ch, "membership notification: no matching rules — skipping"); } - } else { - tracing::debug!(channel_id = %ch, "membership notification: no matching rules — skipping"); } } else { + config::remove_invited_ephemeral_channel(&mut rules, ch); + admitted_ephemeral_deadlines.remove(&ch); subscribed_channel_ids.remove(&ch); tracing::info!(channel_id = %ch, "membership notification: unsubscribing from channel"); if let Err(e) = relay.unsubscribe_channel(ch).await { tracing::warn!("failed to unsubscribe from channel {ch}: {e}"); } - // Drain queued events and invalidate sessions for the - // removed channel. Events already in-flight will - // complete normally (the relay may reject actions if - // the agent lost access). + // Membership is an authorization boundary: cancel any + // in-flight turn before draining and invalidating state. + let _ = signal_in_flight_task( + &mut pool, + ch, + ControlSignal::Cancel, + ); let drained_ids = queue.drain_channel(ch); let invalidated = if pool_ready { pool.invalidate_channel_sessions(ch) @@ -2028,6 +2157,50 @@ async fn tokio_main() -> Result<()> { continue; } + // Dynamic-room admission gates every event behavior, + // including owner control commands. The periodic + // timer is cleanup; it is never an authorization + // grace window. + if admitted_ephemeral_deadlines.contains_key(&buzz_event.channel_id) { + let now = chrono::Utc::now(); + let current = relay.fetch_channel_info(buzz_event.channel_id).await; + if let Some(deadline) = eligible_ephemeral_deadline(¤t, now) { + if let Some(current_deadline) = admitted_ephemeral_deadlines.get_mut(&buzz_event.channel_id) { + *current_deadline = deadline; + } + } else { + if let Err(error) = ¤t { + tracing::warn!( + channel_id = %buzz_event.channel_id, + %error, + "ephemeral metadata dispatch check failed — revoking channel" + ); + } + admitted_ephemeral_deadlines.remove(&buzz_event.channel_id); + config::remove_invited_ephemeral_channel(&mut rules, buzz_event.channel_id); + subscribed_channel_ids.remove(&buzz_event.channel_id); + let _ = signal_in_flight_task( + &mut pool, + buzz_event.channel_id, + ControlSignal::Cancel, + ); + if let Err(error) = relay.unsubscribe_channel(buzz_event.channel_id).await { + tracing::warn!(channel_id = %buzz_event.channel_id, %error, "failed to unsubscribe revoked ephemeral channel"); + } + let drained_ids = queue.drain_channel(buzz_event.channel_id); + pool.invalidate_channel_sessions(buzz_event.channel_id); + removed_channels.insert(buzz_event.channel_id); + typing_channels.remove(&buzz_event.channel_id); + if !drained_ids.is_empty() { + let rest = ctx.rest_client.clone(); + tokio::spawn(async move { + pool::clear_reactions(rest, drained_ids).await; + }); + } + continue; + } + } + // Check: kind:9, content "!shutdown", from owner, mentions THIS agent. let is_shutdown = is_owner_control_command( &buzz_event.event, @@ -2133,8 +2306,8 @@ async fn tokio_main() -> Result<()> { // Coarse security policy: drop events from disallowed // authors before they reach subscription rules or the - // agent. Must be AFTER !shutdown (owner can always - // shut down regardless of gate mode). + // agent. Owner control commands have already been + // handled, after dynamic-room admission above. // // Both OwnerOnly and Allowlist accept events from // "siblings" — pubkeys whose agent_owner_pubkey @@ -2202,10 +2375,10 @@ async fn tokio_main() -> Result<()> { }); // 👀 — immediate "seen" reaction, only if the event // was actually queued (not dropped by DedupMode::Drop). - // Fire-and-forget: on rare fast-failure paths the - // guard's cleanup may race with this add, leaving a - // cosmetic stale 👀. Acceptable — see ReactionGuard docs. - if accepted { + // Trusted-envelope turns add 👀 only after the + // signed event passes admission in run_prompt_task. + // That keeps terminal refusals free of stale state. + if accepted && !ctx.trusted_inbound_envelope { let rc = ctx.rest_client.clone(); let eid = event_id_hex.clone(); tokio::spawn(async move { @@ -2341,6 +2514,68 @@ async fn tokio_main() -> Result<()> { } None } + _ = ephemeral_revalidation.tick() => { + let _ = result_rx; + let now = chrono::Utc::now(); + let channel_ids: Vec = admitted_ephemeral_deadlines + .keys() + .copied() + .collect(); + let mut revoke = Vec::new(); + for channel_id in channel_ids { + let eligible = match admitted_ephemeral_deadlines.get(&channel_id) { + Some(_) => { + let current = relay.fetch_channel_info(channel_id).await; + if let Err(error) = ¤t { + tracing::warn!( + %channel_id, + %error, + "ephemeral metadata revalidation failed — revoking channel" + ); + } + if let Some(deadline) = eligible_ephemeral_deadline(¤t, now) { + admitted_ephemeral_deadlines.insert(channel_id, deadline); + true + } else { + false + } + } + None => false, + }; + if !eligible { + revoke.push(channel_id); + } + } + for channel_id in revoke { + admitted_ephemeral_deadlines.remove(&channel_id); + config::remove_invited_ephemeral_channel(&mut rules, channel_id); + subscribed_channel_ids.remove(&channel_id); + if let Err(error) = relay.unsubscribe_channel(channel_id).await { + tracing::warn!(%channel_id, %error, "failed to unsubscribe revoked ephemeral channel"); + } + let drained_ids = queue.drain_channel(channel_id); + let _ = signal_in_flight_task( + &mut pool, + channel_id, + ControlSignal::Cancel, + ); + let invalidated = pool.invalidate_channel_sessions(channel_id); + removed_channels.insert(channel_id); + typing_channels.remove(&channel_id); + if !drained_ids.is_empty() { + let rest = ctx.rest_client.clone(); + tokio::spawn(async move { + pool::clear_reactions(rest, drained_ids).await; + }); + } + tracing::info!( + %channel_id, + invalidated, + "ephemeral channel expired or became ineligible — revoked" + ); + } + None + } _ = shutdown_rx.changed() => { tracing::info!("shutting down"); break; @@ -3506,14 +3741,24 @@ fn recover_panicked_agent( slot.respawn_in_flight = true; let cmd = config.agent_command.clone(); let args = config.agent_args.clone(); - let env = config.persona_env_vars.clone(); + let env = config.agent_spawn_env(); let has_codex = config.has_generated_codex_config; + let forward_publisher_credentials = config.forward_agent_publisher_credentials; let guard = RespawnGuard::new(i, respawn_tx.clone()); respawn_tasks.spawn(async move { if !delay.is_zero() { tokio::time::sleep(delay).await; } - let result = spawn_and_init(&cmd, &args, &env, has_codex, i, observer).await; + let result = spawn_and_init( + &cmd, + &args, + &env, + has_codex, + forward_publisher_credentials, + i, + observer, + ) + .await; guard.send(result); }); } @@ -3700,8 +3945,9 @@ fn spawn_respawn_task( // Spawn the actual work (shutdown + sleep + spawn + init) off the main loop. let cmd = config.agent_command.clone(); let args = config.agent_args.clone(); - let env = config.persona_env_vars.clone(); + let env = config.agent_spawn_env(); let has_codex = config.has_generated_codex_config; + let forward_publisher_credentials = config.forward_agent_publisher_credentials; let guard = RespawnGuard::new(index, respawn_tx.clone()); respawn_tasks.spawn(async move { // Shutdown old agent (reap child, prevent zombie). @@ -3713,7 +3959,16 @@ fn spawn_respawn_task( tokio::time::sleep(delay).await; } - let result = spawn_and_init(&cmd, &args, &env, has_codex, index, observer).await; + let result = spawn_and_init( + &cmd, + &args, + &env, + has_codex, + forward_publisher_credentials, + index, + observer, + ) + .await; guard.send(result); }); @@ -3757,6 +4012,7 @@ struct PoolStartup { args: Vec, extra_env: Vec<(String, String)>, has_generated_codex_config: bool, + forward_buzz_publisher_credentials: bool, model: Option, observer: Option, } @@ -3767,8 +4023,9 @@ impl PoolStartup { agents: config.agents, command: config.agent_command.clone(), args: config.agent_args.clone(), - extra_env: config.persona_env_vars.clone(), + extra_env: config.agent_spawn_env(), has_generated_codex_config: config.has_generated_codex_config, + forward_buzz_publisher_credentials: config.forward_agent_publisher_credentials, model: config.model.clone(), observer, } @@ -3788,6 +4045,7 @@ async fn initialize_agent_pool( &startup.args, &startup.extra_env, startup.has_generated_codex_config, + startup.forward_buzz_publisher_credentials, ) .await; match spawn_result { @@ -3888,12 +4146,19 @@ async fn spawn_and_init( args: &[String], extra_env: &[(String, String)], has_generated_codex_config: bool, + forward_buzz_publisher_credentials: bool, agent_index: usize, observer: Option, ) -> Result<(AcpClient, u32, String)> { - let mut acp = AcpClient::spawn(command, args, extra_env, has_generated_codex_config) - .await - .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?; + let mut acp = AcpClient::spawn( + command, + args, + extra_env, + has_generated_codex_config, + forward_buzz_publisher_credentials, + ) + .await + .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?; acp.set_observer(observer, agent_index); match acp.initialize().await { @@ -3922,7 +4187,7 @@ async fn spawn_and_init( async fn spawn_auth_client(agent: &AuthAgentArgs) -> Result { let agent_args = config::normalize_agent_args(&agent.agent_command, agent.agent_args.clone()); - AcpClient::spawn(&agent.agent_command, &agent_args, &[], false).await + AcpClient::spawn(&agent.agent_command, &agent_args, &[], false, true).await } fn extract_auth_methods(init_result: &serde_json::Value) -> Vec { @@ -4052,7 +4317,7 @@ async fn run_models(args: ModelsArgs) -> Result<()> { // Spawn outside the timeout so we always own the child for cleanup. // `models` subcommand doesn't use persona packs — no extra env, no codex config. let mut client = - match AcpClient::spawn(&args.agent.agent_command, &agent_args, &[], false).await { + match AcpClient::spawn(&args.agent.agent_command, &agent_args, &[], false, true).await { Ok(c) => c, Err(e) => { eprintln!("error: failed to spawn agent: {e}"); @@ -4658,6 +4923,10 @@ mod author_gate_tests { relay::ChannelInfo { name: "dm".into(), channel_type: "dm".into(), + ttl_seconds: None, + ttl_deadline: None, + metadata_created_at: None, + metadata_event_id: None, }, ), ( @@ -4665,6 +4934,10 @@ mod author_gate_tests { relay::ChannelInfo { name: "stream".into(), channel_type: "stream".into(), + ttl_seconds: None, + ttl_deadline: None, + metadata_created_at: None, + metadata_event_id: None, }, ), ]); @@ -4681,6 +4954,10 @@ mod author_gate_tests { relay::ChannelInfo { name: "unknown".into(), channel_type: "unknown".into(), + ttl_seconds: None, + ttl_deadline: None, + metadata_created_at: None, + metadata_event_id: None, }, )]); assert!( @@ -5031,6 +5308,10 @@ mod build_mcp_servers_tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + turn_receipts: false, + expected_gateway_session_key: None, + trusted_inbound_envelope: false, + forward_agent_publisher_credentials: true, lazy_pool: false, agent_owner: None, no_base_prompt: false, @@ -5252,6 +5533,10 @@ mod error_outcome_emission_tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + turn_receipts: false, + expected_gateway_session_key: None, + trusted_inbound_envelope: false, + forward_agent_publisher_credentials: true, lazy_pool: false, agent_owner: None, no_base_prompt: false, @@ -5281,7 +5566,7 @@ mod error_outcome_emission_tests { async fn dummy_agent(index: usize) -> OwnedAgent { OwnedAgent { index, - acp: AcpClient::spawn("cat", &[], &[], false) + acp: AcpClient::spawn("cat", &[], &[], false, true) .await .expect("spawn cat as inert agent"), state: Default::default(), diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index d1e005cbcc..b7c17b1584 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -31,6 +31,7 @@ use uuid::Uuid; use crate::acp::{ extract_model_config_options, extract_model_state, model_in_catalog, resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason, + TrustedInboundEventEnvelope, }; use crate::config::{compose_session_title, DedupMode, PermissionMode}; use crate::observer; @@ -546,6 +547,13 @@ pub struct PromptContext { /// Harness identity string for NIP-AM `harness` field. Derived from the /// configured `agent_command` at startup (e.g. `"goose"`, `"buzz-agent"`). pub harness_name: String, + /// Opt-in durable-evidence readback for OpenClaw-authored Buzz replies. + pub turn_receipts: bool, + /// Fixed Gateway session key that ACP lineage evidence must match. + pub expected_gateway_session_key: Option, + /// Attach one verified triggering event as non-model ACP metadata. + pub trusted_inbound_envelope: bool, + /// Relay URL this harness is connected to. Rides in observer payloads that /// the desktop keys per (agent, relay) pair, e.g. `session_config_captured`, /// mirroring the `managed_agent_runtime_lifecycle` frames. @@ -816,6 +824,221 @@ const CONTROL_CANCEL_GRACE: Duration = Duration::from_secs(5); /// Timeout for permission-mode requests (`session/set_config_option` with `configId: "mode"`). const PERMISSION_MODE_TIMEOUT: Duration = Duration::from_secs(5); +/// Number of relay readbacks used to resolve an out-of-band Buzz CLI reply. +const RECEIPT_REPLY_READBACK_ATTEMPTS: usize = 5; +/// Delay between successful relay queries that have not observed the reply yet. +const RECEIPT_REPLY_READBACK_DELAY: Duration = Duration::from_millis(200); + +fn merge_anchored_replies( + accumulated: &mut Vec, + observed: Vec, +) { + for reply in observed { + if !accumulated + .iter() + .any(|existing| existing.event_id == reply.event_id) + { + accumulated.push(reply); + } + } + accumulated.sort_by(|left, right| left.event_id.cmp(&right.event_id)); +} + +fn closed_receipt_payload( + request_event_ids: &[String], + reply_events: &[crate::relay::AnchoredReply], + gateway_session_key: Option<&str>, + expected_gateway_session_key: Option<&str>, + run_id: Option<&str>, + acp_session_id: &str, + turn_id: &str, +) -> serde_json::Value { + let failure = |reason: &str| { + serde_json::json!({ + "status": "failed", + "reason": reason, + "requestEventIds": request_event_ids, + "schemaVersion": 1, + "acpSessionId": acp_session_id, + "turnId": turn_id, + }) + }; + if request_event_ids.len() != 1 { + return failure("receipt_requires_exactly_one_request"); + } + if reply_events.len() != 1 { + return failure(if reply_events.is_empty() { + "receipt_requires_one_anchored_reply_found_zero" + } else { + "receipt_requires_one_anchored_reply_found_multiple" + }); + } + let Some(gateway_session_key) = gateway_session_key.filter(|value| !value.is_empty()) else { + return failure("gateway_session_key_evidence_missing"); + }; + let Some(expected_gateway_session_key) = + expected_gateway_session_key.filter(|value| !value.is_empty()) + else { + return failure("expected_gateway_session_key_missing"); + }; + if gateway_session_key != expected_gateway_session_key { + return failure("gateway_session_key_mismatch"); + } + let Some(run_id) = run_id.filter(|value| !value.is_empty()) else { + return failure("gateway_run_id_evidence_missing"); + }; + serde_json::json!({ + "status": "closed", + "schemaVersion": 1, + "requestEventId": request_event_ids[0], + "replyEventId": reply_events[0].event_id, + "replyAnchor": reply_events[0].reply_to, + "gatewaySessionKey": gateway_session_key, + "expectedGatewaySessionKey": expected_gateway_session_key, + "runId": run_id, + "acpSessionId": acp_session_id, + "turnId": turn_id, + }) +} + +struct ReplyReadbackScope<'a> { + expected_reply_anchor: Option<&'a str>, + prior_reply_event_ids: Result<&'a HashSet, &'a str>, +} + +async fn observe_closed_turn_receipt( + agent: &AcpClient, + ctx: &PromptContext, + channel_id: Uuid, + request_event_ids: &[String], + readback_scope: ReplyReadbackScope<'_>, + acp_session_id: &str, + turn_id: &str, +) { + let mut replies = Vec::new(); + if request_event_ids.len() == 1 { + let Some(expected_reply_anchor) = + receipt_query_anchor(request_event_ids, readback_scope.expected_reply_anchor) + else { + agent.observe( + "turn_receipt", + closed_receipt_payload( + request_event_ids, + &replies, + agent.active_session_key(), + ctx.expected_gateway_session_key.as_deref(), + agent.active_run_id(), + acp_session_id, + turn_id, + ), + ); + return; + }; + let prior_reply_event_ids = match readback_scope.prior_reply_event_ids { + Ok(ids) => ids, + Err(error) => { + agent.observe( + "turn_receipt", + serde_json::json!({ + "status": "failed", + "reason": "reply_evidence_baseline_query_failed", + "requestEventIds": request_event_ids, + "error": error, + }), + ); + return; + } + }; + for attempt in 0..RECEIPT_REPLY_READBACK_ATTEMPTS { + match ctx + .rest_client + .query_anchored_replies( + channel_id, + ctx.agent_keys.public_key(), + expected_reply_anchor, + ) + .await + { + Ok(found) => { + let found = replies_for_current_turn(found, prior_reply_event_ids); + merge_anchored_replies(&mut replies, found); + // Multiple distinct replies can never recover to a valid + // receipt, so fail early. A single reply must remain under + // observation for the complete bounded quiescence window + // so a delayed duplicate cannot escape cardinality checks. + if replies.len() > 1 { + break; + } + } + Err(error) => { + agent.observe( + "turn_receipt", + serde_json::json!({ + "status": "failed", + "reason": "reply_evidence_query_failed", + "requestEventIds": request_event_ids, + "error": error.to_string(), + }), + ); + return; + } + } + if attempt + 1 < RECEIPT_REPLY_READBACK_ATTEMPTS { + tokio::time::sleep(RECEIPT_REPLY_READBACK_DELAY).await; + } + } + } + agent.observe( + "turn_receipt", + closed_receipt_payload( + request_event_ids, + &replies, + agent.active_session_key(), + ctx.expected_gateway_session_key.as_deref(), + agent.turn_run_id(), + acp_session_id, + turn_id, + ), + ); +} + +fn replies_for_current_turn( + mut replies: Vec, + prior_reply_event_ids: &HashSet, +) -> Vec { + replies.retain(|reply| !prior_reply_event_ids.contains(&reply.event_id)); + replies +} + +async fn capture_prior_reply_event_ids( + ctx: &PromptContext, + source: &PromptSource, + request_event_ids: &[String], + expected_reply_anchor: Option<&str>, +) -> Result, String> { + let PromptSource::Channel(channel_id) = source else { + return Ok(HashSet::new()); + }; + let Some(anchor) = receipt_query_anchor(request_event_ids, expected_reply_anchor) else { + return Ok(HashSet::new()); + }; + ctx.rest_client + .query_anchored_replies(*channel_id, ctx.agent_keys.public_key(), anchor) + .await + .map(|replies| replies.into_iter().map(|reply| reply.event_id).collect()) + .map_err(|error| error.to_string()) +} + +fn receipt_query_anchor<'a>( + request_event_ids: &[String], + expected_reply_anchor: Option<&'a str>, +) -> Option<&'a str> { + (request_event_ids.len() == 1) + .then_some(expected_reply_anchor) + .flatten() + .filter(|anchor| !anchor.is_empty()) +} + /// Placeholder [`fetch_channel_info`] substitutes when a channel's metadata /// event carries no `name` tag. Not a real channel name — consumers that need /// an identifying name must treat it as absent. @@ -1384,6 +1607,54 @@ pub async fn run_prompt_task( turn_id.clone(), ); + // Install reaction cleanup before trusted admission can return. Refused + // events are terminal and must not retain a stale seen/typing marker. + let reaction_ids: Vec = batch + .as_ref() + .map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect()) + .unwrap_or_default(); + let _reaction_guard = ReactionGuard::new(ctx.rest_client.clone(), reaction_ids.clone()); + + let trusted_inbound_envelope = if ctx.trusted_inbound_envelope { + match &source { + PromptSource::Heartbeat => None, + PromptSource::Channel(_) => { + match TrustedInboundEventEnvelope::try_from_prompt_batch(batch.as_ref()) { + Ok(envelope) => Some(envelope), + Err(reason) => { + tracing::warn!( + refusal_code = reason, + "trusted inbound event envelope refused" + ); + agent.acp.observe( + "trusted_inbound_event_refused", + serde_json::json!({ "refusalCode": reason }), + ); + send_prompt_result( + &result_tx, + &turn_id, + agent, + source, + PromptOutcome::Error(AcpError::TrustedInboundEventRefused(reason)), + None, + ); + return; + } + } + } + } + } else { + None + }; + + // Trusted turns defer 👀 until signed-event admission succeeds. Awaiting + // it here orders the add before ReactionGuard can remove it. + if trusted_inbound_envelope.is_some() { + for event_id in &reaction_ids { + reaction_add(&ctx.rest_client, event_id, REACTION_SEEN).await; + } + } + // Start liveness with `turn_started`, not the final session/prompt call: // session creation, context fetches, and an initial message can themselves // take longer than the desktop's bounded prune pause. This future is pinned @@ -1412,15 +1683,6 @@ pub async fn run_prompt_task( let liveness_handle = tokio::spawn(liveness); let liveness_guard = LivenessGuard::new(liveness_handle, liveness_state); - // Collects event IDs up front. On drop (any exit path — normal, early - // return, or panic), spawns best-effort cleanup of both 👀 and 💬. - // See `ReactionGuard` docs for ordering guarantees and known edge cases. - let reaction_ids: Vec = batch - .as_ref() - .map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect()) - .unwrap_or_default(); - let _reaction_guard = ReactionGuard::new(ctx.rest_client.clone(), reaction_ids.clone()); - // // Core memory is delivered inside the system prompt the harness already // builds (system role for protocol >= 2, the `[System]` user-message @@ -1802,6 +2064,7 @@ pub async fn run_prompt_task( // (`prompt[0].text.startsWith("/")`) fires; the wrapped Buzz context // follows as a second block. let mut slash_command: Option = None; + let mut expected_reply_anchor: Option = None; let prompt_sections: Vec = if let Some(text) = prompt_text { // Heartbeats create their session before this point, so a Goose method-not-found // probe has already selected the correct framing for this process. @@ -1845,20 +2108,20 @@ pub async fn run_prompt_task( ); } - crate::queue::format_prompt( - b, - &crate::queue::FormatPromptArgs { - agent_core: agent_core.as_deref(), - channel_info: channel_info.as_ref(), - conversation_context: conversation_context.as_ref(), - profile_lookup: profile_lookup.as_ref(), - has_system_prompt_support: agent.has_system_prompt_support(), - base_prompt: ctx.base_prompt, - system_prompt: ctx.system_prompt.as_deref(), - team_instructions: ctx.team_instructions.as_deref(), - agent_canvas: agent_canvas.as_deref(), - }, - ) + let format_args = crate::queue::FormatPromptArgs { + agent_core: agent_core.as_deref(), + channel_info: channel_info.as_ref(), + conversation_context: conversation_context.as_ref(), + profile_lookup: profile_lookup.as_ref(), + turn_receipts: ctx.turn_receipts, + has_system_prompt_support: agent.has_system_prompt_support(), + base_prompt: ctx.base_prompt, + system_prompt: ctx.system_prompt.as_deref(), + team_instructions: ctx.team_instructions.as_deref(), + agent_canvas: agent_canvas.as_deref(), + }; + expected_reply_anchor = crate::queue::resolve_turn_reply_anchor(b, &format_args); + crate::queue::format_prompt(b, &format_args) } else { // Should not happen — batch is None only for heartbeats which have prompt_text. // Return the agent to the pool to prevent a permanent slot leak. @@ -1897,6 +2160,23 @@ pub async fn run_prompt_task( None => prompt_sections.iter().map(String::as_str).collect(), }; + // Snapshot replies that already exist at this turn's effective anchor. + // Thread-root follow-ups intentionally reuse a stable NIP-10 anchor, so + // anchor-only readback would otherwise count replies from earlier turns. + // The post-turn receipt subtracts this exact baseline and closes only over + // reply events first observed for the current turn. + let prior_reply_event_ids = if ctx.turn_receipts { + capture_prior_reply_event_ids( + &ctx, + &source, + &triggering_event_ids, + expected_reply_anchor.as_deref(), + ) + .await + } else { + Ok(HashSet::new()) + }; + // Turn start, labelled exactly as `log_stop_reason` labels the end, so a // log reads as start/stop pairs. Purely observational: an unpaired start is // the only durable evidence that a turn was entered and never returned, and @@ -1913,14 +2193,16 @@ pub async fn run_prompt_task( // the main loop can cancel, interrupt, or rotate it. Heartbeats // (control_rx=None) take the simple await path — they are not controllable. // + agent.acp.begin_turn_evidence(); let prompt_result = match control_rx { None => { // Heartbeat / non-cancellable path. tokio::select! { biased; - result = agent.acp.session_prompt_blocks_with_idle_timeout( + result = agent.acp.session_prompt_blocks_with_idle_timeout_and_meta( &session_id, &prompt_blocks, + trusted_inbound_envelope.as_ref(), ctx.idle_timeout, ctx.max_turn_duration, ) => result, @@ -1929,9 +2211,10 @@ pub async fn run_prompt_task( Some(rx) => { tokio::select! { biased; - result = agent.acp.session_prompt_blocks_with_idle_timeout( + result = agent.acp.session_prompt_blocks_with_idle_timeout_and_meta( &session_id, &prompt_blocks, + trusted_inbound_envelope.as_ref(), ctx.idle_timeout, ctx.max_turn_duration, ) => result, @@ -2051,6 +2334,25 @@ pub async fn run_prompt_task( &source, &control_signal, ); + if ctx.turn_receipts { + if let PromptSource::Channel(channel_id) = &source { + observe_closed_turn_receipt( + &agent.acp, + &ctx, + *channel_id, + &triggering_event_ids, + ReplyReadbackScope { + expected_reply_anchor: expected_reply_anchor.as_deref(), + prior_reply_event_ids: prior_reply_event_ids + .as_ref() + .map_err(String::as_str), + }, + &session_id, + &turn_id, + ) + .await; + } + } let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( &ctx, @@ -2080,6 +2382,30 @@ pub async fn run_prompt_task( Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); + // OpenClaw publishes its Buzz reply out of band through buzz-cli. + // Close the observer receipt from durable relay evidence before the + // agent is returned to the pool. Other ACP adapters retain their + // existing behavior and do not pay for this readback. + if ctx.turn_receipts { + if let PromptSource::Channel(channel_id) = &source { + observe_closed_turn_receipt( + &agent.acp, + &ctx, + *channel_id, + &triggering_event_ids, + ReplyReadbackScope { + expected_reply_anchor: expected_reply_anchor.as_deref(), + prior_reply_event_ids: prior_reply_event_ids + .as_ref() + .map_err(String::as_str), + }, + &session_id, + &turn_id, + ) + .await; + } + } + let should_rotate = matches!( stop_reason, StopReason::MaxTokens | StopReason::MaxTurnRequests @@ -3199,11 +3525,9 @@ fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) { /// 💬 (`react_working`) is fire-and-forget (spawned before the prompt fires). /// A brief race where 💬 appears slightly after the agent starts is acceptable. /// -/// 👀 (`react_seen`) is fire-and-forget from `main.rs` at queue-push time. -/// On rare fast-failure paths (e.g., `session_new` error on an idle agent), -/// the cleanup spawn may race with the 👀 add, leaving a stale 👀. This is -/// accepted as a cosmetic edge case — the message will be retried and the -/// stale 👀 is harmless. +/// 👀 (`react_seen`) is normally fire-and-forget from `main.rs` at queue-push +/// time. Trusted-envelope turns defer and await the add after admission so a +/// terminal authority refusal cannot leave a stale reaction. struct ReactionGuard { rest: Option, ids: Vec, @@ -3760,7 +4084,7 @@ async fn react_working(rest: &crate::relay::RestClient, event_ids: &[String]) { /// Fire-and-forget: remove both 👀 and 💬 from all events. Spawned on turn complete. /// Capped at `REACTION_CONCURRENCY` concurrent requests per chunk to avoid /// unbounded HTTP fan-out on large batches. -async fn clear_reactions(rest: crate::relay::RestClient, event_ids: Vec) { +pub(crate) async fn clear_reactions(rest: crate::relay::RestClient, event_ids: Vec) { // Each event needs two removals (👀 and 💬); pair them and chunk by // REACTION_CONCURRENCY pairs so the total concurrent requests stay bounded. for chunk in event_ids.chunks(REACTION_CONCURRENCY) { @@ -3780,6 +4104,213 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + #[test] + fn receipt_closes_only_with_one_request_reply_and_gateway_evidence() { + let payload = closed_receipt_payload( + &["a".repeat(64)], + &[crate::relay::AnchoredReply { + event_id: "b".repeat(64), + reply_to: "a".repeat(64), + }], + Some("agent:main:buzz-private"), + Some("agent:main:buzz-private"), + Some("run-1"), + "acp-1", + "turn-1", + ); + assert_eq!(payload["status"], "closed"); + assert_eq!(payload["requestEventId"], "a".repeat(64)); + assert_eq!(payload["replyEventId"], "b".repeat(64)); + assert_eq!(payload["replyAnchor"], "a".repeat(64)); + assert_eq!(payload["gatewaySessionKey"], "agent:main:buzz-private"); + assert_eq!(payload["runId"], "run-1"); + } + + #[test] + fn threaded_second_turn_receipt_queries_the_same_root_anchor_as_the_prompt() { + let root_id = "a".repeat(64); + let parent_id = "b".repeat(64); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "second turn") + .tags([ + Tag::parse(["e", root_id.as_str(), "", "root"]).expect("root tag"), + Tag::parse(["e", parent_id.as_str(), "", "reply"]).expect("reply tag"), + ]) + .sign_with_keys(&keys) + .expect("signed request"); + let request_id = event.id.to_hex(); + let batch = FlushBatch { + channel_id: Uuid::new_v4(), + events: vec![crate::queue::BatchEvent { + event, + prompt_tag: "private-office".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let args = crate::queue::FormatPromptArgs::default(); + let expected_anchor = crate::queue::resolve_turn_reply_anchor(&batch, &args); + let prompt = crate::queue::format_prompt(&batch, &args).join("\n\n"); + + assert_eq!(expected_anchor.as_deref(), Some(root_id.as_str())); + assert!(prompt.contains(&format!("--reply-to {root_id}"))); + assert_eq!( + receipt_query_anchor(&[request_id], expected_anchor.as_deref()), + Some(root_id.as_str()) + ); + } + + #[test] + fn top_level_dm_receipt_anchors_to_the_current_request() { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "first DM turn") + .sign_with_keys(&keys) + .expect("signed request"); + let request_id = event.id.to_hex(); + let batch = FlushBatch { + channel_id: Uuid::new_v4(), + events: vec![crate::queue::BatchEvent { + event, + prompt_tag: "private-office".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let channel_info = PromptChannelInfo { + name: "Nexus private".into(), + channel_type: "dm".into(), + }; + let args = crate::queue::FormatPromptArgs { + channel_info: Some(&channel_info), + turn_receipts: true, + ..Default::default() + }; + let expected_anchor = crate::queue::resolve_turn_reply_anchor(&batch, &args); + let prompt = crate::queue::format_prompt(&batch, &args).join("\n\n"); + + assert_eq!(expected_anchor.as_deref(), Some(request_id.as_str())); + assert!(prompt.contains(&format!("--reply-to {request_id}"))); + assert_eq!( + receipt_query_anchor( + std::slice::from_ref(&request_id), + expected_anchor.as_deref() + ), + Some(request_id.as_str()) + ); + } + + #[test] + fn receipt_fails_closed_on_cardinality_or_missing_evidence() { + let request = vec!["a".repeat(64)]; + assert_eq!( + closed_receipt_payload( + &request, + &[], + Some("session"), + Some("session"), + Some("run"), + "acp", + "turn" + )["reason"], + "receipt_requires_one_anchored_reply_found_zero" + ); + let duplicate = vec![ + crate::relay::AnchoredReply { + event_id: "b".repeat(64), + reply_to: request[0].clone(), + }, + crate::relay::AnchoredReply { + event_id: "c".repeat(64), + reply_to: request[0].clone(), + }, + ]; + assert_eq!( + closed_receipt_payload( + &request, + &duplicate, + Some("session"), + Some("session"), + Some("run"), + "acp", + "turn" + )["reason"], + "receipt_requires_one_anchored_reply_found_multiple" + ); + assert_eq!( + closed_receipt_payload( + &request, + &duplicate[..1], + None, + Some("session"), + Some("run"), + "acp", + "turn" + )["reason"], + "gateway_session_key_evidence_missing" + ); + assert_eq!( + closed_receipt_payload( + &request, + &duplicate[..1], + Some("session"), + Some("session"), + None, + "acp", + "turn" + )["reason"], + "gateway_run_id_evidence_missing" + ); + } + + #[test] + fn delayed_duplicate_reply_is_retained_for_fail_closed_receipt() { + let request = vec!["a".repeat(64)]; + let first = crate::relay::AnchoredReply { + event_id: "b".repeat(64), + reply_to: request[0].clone(), + }; + let delayed = crate::relay::AnchoredReply { + event_id: "c".repeat(64), + reply_to: request[0].clone(), + }; + let mut replies = Vec::new(); + merge_anchored_replies(&mut replies, vec![first.clone()]); + merge_anchored_replies(&mut replies, vec![first, delayed]); + assert_eq!(replies.len(), 2); + assert_eq!( + closed_receipt_payload( + &request, + &replies, + Some("session"), + Some("session"), + Some("run"), + "acp", + "turn" + )["reason"], + "receipt_requires_one_anchored_reply_found_multiple" + ); + } + + #[test] + fn stable_thread_anchor_excludes_replies_from_prior_turns() { + let stable_root = "a".repeat(64); + let prior = crate::relay::AnchoredReply { + event_id: "b".repeat(64), + reply_to: stable_root.clone(), + }; + let current = crate::relay::AnchoredReply { + event_id: "c".repeat(64), + reply_to: stable_root, + }; + let prior_ids = HashSet::from([prior.event_id.clone()]); + + let current_turn = replies_for_current_turn(vec![prior, current.clone()], &prior_ids); + + assert_eq!(current_turn, vec![current]); + } + // These pin the initial_message dispatch path (run_prompt_task, ~line 855): // a legacy agent WITH a base_prompt must get [Base] prepended to the user // message. This is the exact regression that shipped in the round-2 bug. @@ -4585,6 +5116,61 @@ mod tests { } } + #[tokio::test] + async fn trusted_channel_refusal_stops_before_session_prompt() { + let marker = std::env::temp_dir().join(format!( + "buzz-acp-unexpected-prompt-{}", + uuid::Uuid::new_v4() + )); + let script = format!( + "IFS= read -r _line && : > '{}' ; sleep 10", + marker.display() + ); + let acp = AcpClient::spawn("bash", &["-c".to_string(), script], &[], false, true) + .await + .expect("spawn test agent"); + let agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + }; + let channel_id = Uuid::new_v4(); + let batch = one_event_batch(channel_id); + let mut context = make_prompt_context_no_owner(); + context.trusted_inbound_envelope = true; + let (result_tx, mut result_rx) = mpsc::unbounded_channel(); + + run_prompt_task( + agent, + Some(batch), + Some("must not reach the agent".into()), + Arc::new(context), + result_tx, + None, + "trusted-refusal-turn".into(), + ) + .await; + + let result = result_rx.recv().await.expect("terminal prompt result"); + assert!(matches!( + result.outcome, + PromptOutcome::Error(AcpError::TrustedInboundEventRefused( + "invalid_channel_binding" + )) + )); + assert!(result.batch.is_none(), "refused authority must not requeue"); + assert!( + !marker.exists(), + "session/prompt must not be written after trusted admission refusal" + ); + } + #[test] fn test_requeue_cancelled_batch_maps_control_signal_to_cancel_reason() { let cases = [ @@ -5115,6 +5701,7 @@ mod tests { &["-c".to_string(), "sleep 10".to_string()], &[], false, + true, ) .await .expect("failed to spawn test agent"); @@ -5173,6 +5760,7 @@ mod tests { &["-c".to_string(), "sleep 10".to_string()], &[], false, + true, ) .await .expect("failed to spawn test agent"); @@ -5549,6 +6137,9 @@ mod tests { agent_owner_pubkey: owner_pubkey, memory_enabled: false, harness_name: "goose".to_string(), + turn_receipts: false, + expected_gateway_session_key: None, + trusted_inbound_envelope: false, relay_url: "ws://127.0.0.1:3000".to_string(), } } diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf..a9fff8bac1 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1272,9 +1272,9 @@ fn format_context_hints( s.push_str(&format!("\nParent: {parent}")); } } - if let Some(event_id) = reply_anchor { - append_reply_instruction(&mut s, event_id); - } + } + if let Some(event_id) = reply_anchor { + append_reply_instruction(&mut s, event_id); } s } else if let Some(ref root) = thread_tags.root_event_id { @@ -1355,6 +1355,9 @@ pub struct FormatPromptArgs<'a> { pub channel_info: Option<&'a PromptChannelInfo>, pub conversation_context: Option<&'a ConversationContext>, pub profile_lookup: Option<&'a PromptProfileLookup>, + /// Require a durable reply anchor even for the first message in a DM so + /// turn-receipt readback can correlate the published response. + pub turn_receipts: bool, /// When true, base_prompt and system_prompt are delivered via the system /// role (session/new) and omitted from the user message. When false /// (legacy agents), they are injected as `[Base]` and `[System]` sections. @@ -1383,26 +1386,37 @@ pub(crate) fn base_section(base_prompt: &str) -> String { format!("[Base]\n{}", base_prompt.trim_end()) } -/// Format a [`FlushBatch`] into the per-section prompt blocks for the agent. -/// -/// Produces a stable prompt with these sections (in order): -/// 0. `[Base]` — base prompt (only for legacy agents without systemPrompt support) -/// 1. `[System]` — system prompt (only for legacy agents without systemPrompt support) -/// 2. `[Agent Memory — core]` — if agent core memory is set -/// 3. `[Context]` — scope, channel name, and contextual hints for the agent -/// 4. `[Thread Context]` or `[Conversation Context]` — if fetched -/// 5. `[Event]` / `[Buzz events]` — the triggering event(s) +/// Resolve the exact reply anchor that will be instructed in this turn's prompt. /// -/// Each section is returned as its own block rather than one joined string so -/// the observer frame's size trimmer (`fit_observer_event_to_budget`) elides -/// the body of an oversized section in place, leaving every `[Header]` line at -/// the head of its own leaf — so the desktop "Prompt context" panel always -/// counts every section. The receiving agent reconstructs the full prompt by -/// joining the blocks (legacy agents see a single `\n` between sections rather -/// than a blank line; sections self-delimit with their `[Header]` line). +/// Receipt readback must reuse this value so a human thread follow-up flattened +/// to the root is not mistakenly queried against the triggering child event. +pub(crate) fn resolve_turn_reply_anchor( + batch: &FlushBatch, + args: &FormatPromptArgs<'_>, +) -> Option { + let last_event = batch.events.last()?; + let thread_tags = parse_thread_tags(&last_event.event); + let is_dm = args + .channel_info + .map(|ci| ci.channel_type == "dm") + .unwrap_or(false); + if is_dm { + return (thread_tags.root_event_id.is_some() || args.turn_receipts) + .then(|| last_event.event.id.to_hex()); + } + resolve_reply_anchor( + &last_event.event.pubkey.to_hex(), + &thread_tags, + &last_event.event.id.to_hex(), + args.profile_lookup, + ) +} + +/// Format a [`FlushBatch`] into stable per-section prompt blocks for the agent. /// -/// For agents with `protocol_version >= 2`, base_prompt and system_prompt are -/// delivered via the system role in `session/new` and omitted from this message. +/// Legacy agents receive base/system/memory/canvas sections here. Modern agents +/// receive those via the system role in `session/new`; both receive context, +/// conversation history, and triggering event sections. pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec { // Scope is always derived from the LAST event in the batch — that's the // one the agent is responding to. Thread/DM context is supplementary info @@ -1464,20 +1478,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec, + /// Canonical absolute expiry from the `ttl_deadline` metadata tag. + pub ttl_deadline: Option>, + /// Created-at timestamp of the canonical addressable metadata event. + pub metadata_created_at: Option, + /// Event ID of the canonical addressable metadata event. + pub metadata_event_id: Option, +} + +impl ChannelInfo { + /// Whether metadata proves that this is a currently-live TTL channel. + pub fn is_ephemeral(&self) -> bool { + self.is_ephemeral_at(chrono::Utc::now()) + } + + /// Evaluate ephemeral eligibility against an explicit clock. + pub fn is_ephemeral_at(&self, now: chrono::DateTime) -> bool { + self.channel_type == "private" + && self.ttl_seconds.is_some_and(|ttl| ttl > 0) + && self.ttl_deadline.is_some_and(|deadline| deadline > now) + } +} + +#[derive(Clone, Debug)] +struct MetadataCandidate { + created_at: u64, + event_id: String, + name: String, + channel_type: String, + archived: bool, + ttl_seconds: Option, + ttl_deadline: Option>, + malformed: bool, +} + +impl MetadataCandidate { + fn is_newer_than(&self, other: &Self) -> bool { + self.created_at > other.created_at + || (self.created_at == other.created_at && self.event_id < other.event_id) + } } pub(crate) fn channel_type_from_tags(tags: &[serde_json::Value]) -> String { @@ -172,38 +213,120 @@ pub(crate) fn merge_discovered_channels( channel_uuids: Vec, meta_events: &serde_json::Value, ) -> HashMap { - let mut meta_map: HashMap = HashMap::new(); - let mut archived: std::collections::HashSet = std::collections::HashSet::new(); + let mut meta_map: HashMap = HashMap::new(); + let mut unorderable_channels = HashSet::new(); if let Some(arr) = meta_events.as_array() { for ev in arr { let tags = match ev.get("tags").and_then(|t| t.as_array()) { Some(t) => t, None => continue, }; - let mut d_val = None; let mut name = None; let mut is_archived = false; + let mut ttl_seconds = None; + let mut ttl_deadline = None; + let mut seen_singletons = HashSet::new(); + let mut referenced_channels = HashSet::new(); + let mut malformed = false; for tag in tags { - if let Some(arr) = tag.as_array() { - match arr.first().and_then(|v| v.as_str()) { - Some("d") => d_val = arr.get(1).and_then(|v| v.as_str()), - Some("name") => name = arr.get(1).and_then(|v| v.as_str()), - Some("archived") => { - is_archived = arr.get(1).and_then(|v| v.as_str()) == Some("true") + let Some(arr) = tag.as_array() else { + continue; + }; + let Some(kind) = arr.first().and_then(|v| v.as_str()) else { + continue; + }; + if matches!( + kind, + "d" | "name" | "hidden" | "private" | "archived" | "ttl" | "ttl_deadline" + ) && !seen_singletons.insert(kind) + { + malformed = true; + } + match kind { + "d" => { + let value = arr.get(1).and_then(|v| v.as_str()); + malformed |= arr.len() != 2 || value.is_none_or(str::is_empty); + if let Some(value) = value { + if let Ok(uuid) = value.parse::() { + referenced_channels.insert(uuid); + } else { + malformed = true; + } } - _ => {} } + "name" => { + name = arr.get(1).and_then(|v| v.as_str()); + malformed |= arr.len() != 2 || name.is_none(); + } + "hidden" => { + malformed |= + arr.len() > 2 || arr.get(1).is_some_and(|v| v.as_str() != Some("")); + } + "private" => { + malformed |= + arr.len() > 2 || arr.get(1).is_some_and(|v| v.as_str() != Some("")); + } + "archived" => { + let value = arr.get(1).and_then(|v| v.as_str()); + malformed |= arr.len() != 2 || !matches!(value, Some("true" | "false")); + is_archived = value == Some("true"); + } + "ttl" => { + ttl_seconds = arr + .get(1) + .and_then(|v| v.as_str()) + .and_then(|value| value.parse::().ok()) + .filter(|ttl| *ttl > 0); + malformed |= arr.len() != 2 || ttl_seconds.is_none(); + } + "ttl_deadline" => { + ttl_deadline = arr + .get(1) + .and_then(|v| v.as_str()) + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) + .map(|deadline| deadline.with_timezone(&chrono::Utc)); + malformed |= arr.len() != 2 || ttl_deadline.is_none(); + } + _ => {} } } - if let Some(d) = d_val { - if let Ok(uuid) = d.parse::() { - if is_archived { - archived.insert(uuid); - continue; - } - let ch_name = name.unwrap_or("unknown").to_string(); - let ch_type = channel_type_from_tags(tags); - meta_map.insert(uuid, (ch_name, ch_type)); + if referenced_channels.is_empty() { + continue; + } + let Some(created_at) = ev.get("created_at").and_then(|value| value.as_u64()) else { + for uuid in referenced_channels { + warn!(channel_id = %uuid, "channel metadata missing created_at — failing channel metadata closed"); + unorderable_channels.insert(uuid); + } + continue; + }; + let Some(event_id) = ev.get("id").and_then(|value| value.as_str()).filter(|id| { + id.len() == 64 && id.chars().all(|character| character.is_ascii_hexdigit()) + }) else { + for uuid in referenced_channels { + warn!(channel_id = %uuid, "channel metadata missing valid event id — failing channel metadata closed"); + unorderable_channels.insert(uuid); + } + continue; + }; + let ch_name = name.unwrap_or("unknown").to_string(); + let ch_type = channel_type_from_tags(tags); + let candidate = MetadataCandidate { + created_at, + event_id: event_id.to_ascii_lowercase(), + name: ch_name, + channel_type: ch_type, + archived: is_archived, + ttl_seconds, + ttl_deadline, + malformed, + }; + for uuid in referenced_channels { + let replace = meta_map + .get(&uuid) + .is_none_or(|current| candidate.is_newer_than(current)); + if replace { + meta_map.insert(uuid, candidate.clone()); } } } @@ -211,13 +334,42 @@ pub(crate) fn merge_discovered_channels( let mut map = HashMap::with_capacity(channel_uuids.len()); for uuid in channel_uuids { - if archived.contains(&uuid) { + if unorderable_channels.contains(&uuid) { continue; } - let (name, channel_type) = meta_map - .remove(&uuid) - .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string())); - map.insert(uuid, ChannelInfo { name, channel_type }); + match meta_map.remove(&uuid) { + Some(candidate) if candidate.malformed => { + warn!(channel_id = %uuid, "canonical channel metadata has ambiguous or malformed singleton tags — failing channel metadata closed"); + continue; + } + Some(candidate) if candidate.archived => continue, + Some(candidate) => { + map.insert( + uuid, + ChannelInfo { + name: candidate.name, + channel_type: candidate.channel_type, + ttl_seconds: candidate.ttl_seconds, + ttl_deadline: candidate.ttl_deadline, + metadata_created_at: Some(candidate.created_at), + metadata_event_id: Some(candidate.event_id), + }, + ); + } + None => { + map.insert( + uuid, + ChannelInfo { + name: "unknown".to_string(), + channel_type: "unknown".to_string(), + ttl_seconds: None, + ttl_deadline: None, + metadata_created_at: None, + metadata_event_id: None, + }, + ); + } + } } map } @@ -238,6 +390,17 @@ pub struct RestClient { pub auth_tag_json: Option, } +/// Durable reply evidence read back from the relay event log. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnchoredReply { + /// Hex event ID of the verified agent-authored reply event. + pub event_id: String, + /// Effective NIP-10 `reply` anchor used for relay readback. This can be the + /// thread root rather than the triggering request when a follow-up turn is + /// deliberately flattened onto its existing thread. + pub reply_to: String, +} + /// Whether an HTTP status code is retriable (transient server/rate-limit errors). fn is_retriable_status(status: reqwest::StatusCode) -> bool { matches!(status.as_u16(), 429 | 502 | 503 | 504) @@ -405,6 +568,28 @@ impl RestClient { .map_err(|e| RelayError::Http(e.to_string())) } + /// Query agent-authored kind-9 replies with an exact NIP-10 `reply` anchor. + pub async fn query_anchored_replies( + &self, + channel_id: Uuid, + author: nostr::PublicKey, + request_event_id: &str, + ) -> Result, RelayError> { + use nostr::{Alphabet, SingleLetterTag}; + + let h_tag = SingleLetterTag::lowercase(Alphabet::H); + let e_tag = SingleLetterTag::lowercase(Alphabet::E); + let channel = channel_id.to_string(); + let filter = nostr::Filter::new() + .kind(Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16)) + .author(author) + .custom_tags(h_tag, [channel.as_str()]) + .custom_tags(e_tag, [request_event_id]) + .limit(3); + let response = self.query(&[filter]).await?; + parse_anchored_replies(&response, channel_id, author, request_event_id) + } + /// 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. @@ -423,6 +608,62 @@ impl RestClient { } } +fn parse_anchored_replies( + response: &Value, + channel_id: Uuid, + author: nostr::PublicKey, + request_event_id: &str, +) -> Result, RelayError> { + let events = response + .as_array() + .ok_or_else(|| RelayError::Http("expected JSON array from /query (replies)".into()))?; + let mut replies = Vec::new(); + let expected_channel = channel_id.to_string(); + for value in events { + let Ok(event) = serde_json::from_value::(value.clone()) else { + continue; + }; + if event.verify().is_err() + || event.kind.as_u16() as u32 != buzz_core::kind::KIND_STREAM_MESSAGE + || event.pubkey != author + { + continue; + } + let mut h_tag_count = 0; + let mut exact_channel = false; + for tag in event.tags.iter() { + let values = tag.as_slice(); + if values.first().map(|value| value.as_str()) == Some("h") { + h_tag_count += 1; + exact_channel = + values.get(1).map(|value| value.as_str()) == Some(expected_channel.as_str()); + } + } + let mut reply_anchor_count = 0; + let mut exact_reply = false; + for tag in event.tags.iter() { + let values = tag.as_slice(); + if values.first().map(|value| value.as_str()) == Some("e") + && values.get(3).map(|value| value.as_str()) == Some("reply") + { + reply_anchor_count += 1; + exact_reply = values.len() == 4 + && values.get(1).map(|value| value.as_str()) == Some(request_event_id) + && values.get(2).map(|value| value.as_str()) == Some(""); + } + } + if h_tag_count == 1 && exact_channel && reply_anchor_count == 1 && exact_reply { + replies.push(AnchoredReply { + event_id: event.id.to_hex(), + reply_to: request_event_id.to_ascii_lowercase(), + }); + } + } + replies.sort_by(|left, right| left.event_id.cmp(&right.event_id)); + replies.dedup_by(|left, right| left.event_id == right.event_id); + Ok(replies) +} + /// Events the harness cares about. #[derive(Debug, Clone)] pub struct BuzzEvent { @@ -712,6 +953,25 @@ impl HarnessRelay { Ok(map) } + /// Fetch metadata for a newly invited channel. Missing, malformed, or + /// archived metadata returns `None`, allowing admission to fail closed. + pub async fn fetch_channel_info( + &self, + channel_id: Uuid, + ) -> Result, RelayError> { + use nostr::{Alphabet, SingleLetterTag}; + + let d_tag = SingleLetterTag::lowercase(Alphabet::D); + let channel = channel_id.to_string(); + let filter = nostr::Filter::new() + .kind(Kind::Custom( + buzz_core::kind::KIND_NIP29_GROUP_METADATA as u16, + )) + .custom_tags(d_tag, [channel.as_str()]); + let events = self.rest_client().query(&[filter]).await?; + Ok(merge_discovered_channels(vec![channel_id], &events).remove(&channel_id)) + } + /// Build a [`RestClient`] that shares this relay's HTTP credentials. /// /// The returned client is cheap to clone (wraps `reqwest::Client` which is @@ -4068,6 +4328,16 @@ mod tests { } fn meta_event(uuid: Uuid, name: &str, extra: &[&str]) -> serde_json::Value { + meta_event_at(uuid, name, extra, 100, 'a') + } + + fn meta_event_at( + uuid: Uuid, + name: &str, + extra: &[&str], + created_at: u64, + id_character: char, + ) -> serde_json::Value { let mut tags = vec![ serde_json::json!(["d", uuid.to_string()]), serde_json::json!(["name", name]), @@ -4080,7 +4350,11 @@ mod tests { _ => {} } } - serde_json::json!({ "tags": tags }) + serde_json::json!({ + "id": id_character.to_string().repeat(64), + "created_at": created_at, + "tags": tags + }) } #[test] @@ -4136,6 +4410,364 @@ mod tests { ); } + #[test] + fn merge_discovered_channels_marks_positive_ttl_ephemeral() { + let huddle = Uuid::new_v4(); + let ordinary = Uuid::new_v4(); + let meta = serde_json::json!([ + meta_event( + huddle, + "huddle", + &[ + "private", + "", + "ttl", + "3600", + "ttl_deadline", + "2999-01-01T00:00:00Z" + ] + ), + meta_event(ordinary, "concilium", &["private", ""]), + ]); + + let map = merge_discovered_channels(vec![huddle, ordinary], &meta); + + assert!(map[&huddle].is_ephemeral()); + assert_eq!(map[&huddle].ttl_seconds, Some(3600)); + assert!(map[&huddle].ttl_deadline.is_some()); + assert!(!map[&ordinary].is_ephemeral()); + } + + #[test] + fn merge_discovered_channels_requires_future_deadline() { + let missing = Uuid::new_v4(); + let expired = Uuid::new_v4(); + let malformed = Uuid::new_v4(); + let meta = serde_json::json!([ + meta_event(missing, "missing", &["ttl", "3600"]), + meta_event( + expired, + "expired", + &["ttl", "3600", "ttl_deadline", "2000-01-01T00:00:00Z"] + ), + meta_event( + malformed, + "malformed", + &["ttl", "3600", "ttl_deadline", "tomorrow"] + ), + ]); + + let map = merge_discovered_channels(vec![missing, expired, malformed], &meta); + assert!(!map[&missing].is_ephemeral()); + assert!(!map[&expired].is_ephemeral()); + assert!( + !map.contains_key(&malformed), + "a malformed deadline fails the channel metadata closed" + ); + } + + #[test] + fn merge_discovered_channels_selects_canonical_latest_metadata() { + let channel = Uuid::new_v4(); + let latest = meta_event_at( + channel, + "latest", + &[ + "private", + "", + "ttl", + "3600", + "ttl_deadline", + "2999-01-01T00:00:00Z", + ], + 200, + 'b', + ); + let stale = meta_event_at( + channel, + "stale", + &[ + "private", + "", + "ttl", + "3600", + "ttl_deadline", + "2000-01-01T00:00:00Z", + ], + 100, + 'a', + ); + + let forward = merge_discovered_channels( + vec![channel], + &serde_json::json!([stale.clone(), latest.clone()]), + ); + let reverse = merge_discovered_channels(vec![channel], &serde_json::json!([latest, stale])); + assert_eq!(forward[&channel].name, "latest"); + assert_eq!(reverse[&channel].name, "latest"); + assert_eq!( + forward[&channel].metadata_event_id, + reverse[&channel].metadata_event_id + ); + assert!(forward[&channel].is_ephemeral()); + } + + #[test] + fn merge_discovered_channels_latest_archived_metadata_revokes_channel() { + let channel = Uuid::new_v4(); + let live = meta_event_at( + channel, + "live", + &["ttl", "3600", "ttl_deadline", "2999-01-01T00:00:00Z"], + 100, + 'a', + ); + let archived = meta_event_at(channel, "archived", &["archived", "true"], 200, 'b'); + + let map = merge_discovered_channels(vec![channel], &serde_json::json!([archived, live])); + assert!(!map.contains_key(&channel)); + } + + #[test] + fn merge_discovered_channels_same_second_uses_lower_event_id() { + let channel = Uuid::new_v4(); + let lower = meta_event_at(channel, "canonical", &["private", ""], 200, 'a'); + let higher = meta_event_at(channel, "loser", &["archived", "true"], 200, 'b'); + let forward = merge_discovered_channels( + vec![channel], + &serde_json::json!([higher.clone(), lower.clone()]), + ); + let reverse = merge_discovered_channels(vec![channel], &serde_json::json!([lower, higher])); + assert_eq!(forward[&channel].name, "canonical"); + assert_eq!(reverse[&channel].name, "canonical"); + } + + #[test] + fn merge_discovered_channels_requires_private_ttl_metadata() { + let public = Uuid::new_v4(); + let private = Uuid::new_v4(); + let deadline = ["ttl", "3600", "ttl_deadline", "2999-01-01T00:00:00Z"]; + let meta = serde_json::json!([ + meta_event(public, "public", &deadline), + meta_event( + private, + "private", + &[ + "private", + "", + "ttl", + "3600", + "ttl_deadline", + "2999-01-01T00:00:00Z" + ] + ) + ]); + let map = merge_discovered_channels(vec![public, private], &meta); + assert!(!map[&public].is_ephemeral()); + assert!(map[&private].is_ephemeral()); + } + + #[test] + fn merge_discovered_channels_malformed_candidate_fails_channel_closed() { + let channel = Uuid::new_v4(); + let valid = meta_event_at(channel, "valid", &["private", ""], 100, 'a'); + let malformed = serde_json::json!({ + "created_at": 200, + "tags": [["d", channel.to_string()], ["private", ""]] + }); + let map = merge_discovered_channels(vec![channel], &serde_json::json!([valid, malformed])); + assert!(!map.contains_key(&channel)); + } + + #[test] + fn merge_discovered_channels_rejects_zero_or_invalid_ttl() { + let zero = Uuid::new_v4(); + let invalid = Uuid::new_v4(); + let meta = serde_json::json!([ + meta_event(zero, "zero", &["ttl", "0"]), + meta_event(invalid, "invalid", &["ttl", "later"]), + ]); + + let map = merge_discovered_channels(vec![zero, invalid], &meta); + assert!(!map.contains_key(&zero)); + assert!(!map.contains_key(&invalid)); + } + + #[test] + fn merge_discovered_channels_rejects_duplicate_or_malformed_singletons() { + let duplicate_ttl = Uuid::new_v4(); + let contradictory_archive = Uuid::new_v4(); + let malformed_private = Uuid::new_v4(); + let duplicate_d = Uuid::new_v4(); + let other_d = Uuid::new_v4(); + + let mut duplicate_ttl_event = meta_event( + duplicate_ttl, + "duplicate ttl", + &[ + "private", + "", + "ttl", + "3600", + "ttl_deadline", + "2999-01-01T00:00:00Z", + ], + ); + duplicate_ttl_event["tags"] + .as_array_mut() + .unwrap() + .push(serde_json::json!(["ttl", "7200"])); + + let mut contradictory_archive_event = meta_event( + contradictory_archive, + "archive conflict", + &["archived", "false"], + ); + contradictory_archive_event["tags"] + .as_array_mut() + .unwrap() + .push(serde_json::json!(["archived", "true"])); + + let mut malformed_private_event = meta_event(malformed_private, "bad private", &[]); + malformed_private_event["tags"] + .as_array_mut() + .unwrap() + .push(serde_json::json!(["private", "true"])); + + let mut duplicate_d_event = meta_event(duplicate_d, "duplicate d", &[]); + duplicate_d_event["tags"] + .as_array_mut() + .unwrap() + .push(serde_json::json!(["d", other_d.to_string()])); + + let map = merge_discovered_channels( + vec![ + duplicate_ttl, + contradictory_archive, + malformed_private, + duplicate_d, + other_d, + ], + &serde_json::json!([ + duplicate_ttl_event, + contradictory_archive_event, + malformed_private_event, + duplicate_d_event, + ]), + ); + for channel in [ + duplicate_ttl, + contradictory_archive, + malformed_private, + duplicate_d, + other_d, + ] { + assert!( + !map.contains_key(&channel), + "ambiguous singleton metadata must fail {channel} closed" + ); + } + } + + #[test] + fn merge_discovered_channels_validates_only_the_canonical_latest_candidate() { + let recovered = Uuid::new_v4(); + let poisoned = Uuid::new_v4(); + let valid_latest = meta_event_at(recovered, "recovered", &["private", ""], 200, 'b'); + let valid_stale = meta_event_at(poisoned, "stale", &["private", ""], 100, 'a'); + let mut malformed_stale = meta_event_at(recovered, "bad stale", &[], 100, 'a'); + malformed_stale["tags"].as_array_mut().unwrap().extend([ + serde_json::json!(["ttl", "1"]), + serde_json::json!(["ttl", "2"]), + ]); + let mut malformed_latest = meta_event_at(poisoned, "bad latest", &[], 200, 'b'); + malformed_latest["tags"].as_array_mut().unwrap().extend([ + serde_json::json!(["archived", "false"]), + serde_json::json!(["archived", "true"]), + ]); + + for events in [ + serde_json::json!([ + malformed_stale.clone(), + valid_latest.clone(), + valid_stale.clone(), + malformed_latest.clone(), + ]), + serde_json::json!([malformed_latest, valid_stale, valid_latest, malformed_stale]), + ] { + let map = merge_discovered_channels(vec![recovered, poisoned], &events); + assert_eq!(map[&recovered].name, "recovered"); + assert!(!map.contains_key(&poisoned)); + } + } + + #[test] + fn merge_discovered_channels_covers_all_singleton_shapes() { + let duplicate_name = Uuid::new_v4(); + let malformed_hidden = Uuid::new_v4(); + let duplicate_deadline = Uuid::new_v4(); + let invalid_archived = Uuid::new_v4(); + let canonical_private = Uuid::new_v4(); + let canonical_hidden = Uuid::new_v4(); + + let mut events = vec![]; + for (channel, extra_tag) in [ + (duplicate_name, serde_json::json!(["name", "again"])), + (malformed_hidden, serde_json::json!(["hidden", "true"])), + ( + duplicate_deadline, + serde_json::json!(["ttl_deadline", "2998-01-01T00:00:00Z"]), + ), + (invalid_archived, serde_json::json!(["archived", "yes"])), + ] { + let mut event = meta_event(channel, "base", &["ttl_deadline", "2999-01-01T00:00:00Z"]); + event["tags"].as_array_mut().unwrap().push(extra_tag); + events.push(event); + } + events.push(serde_json::json!({ + "id": "e".repeat(64), + "created_at": 100, + "tags": [ + ["d", canonical_private.to_string()], + ["name", "private marker"], + ["private"], + ["ttl", "3600"], + ["ttl_deadline", "2999-01-01T00:00:00Z"] + ] + })); + events.push(serde_json::json!({ + "id": "f".repeat(64), + "created_at": 100, + "tags": [ + ["d", canonical_hidden.to_string()], + ["name", "hidden marker"], + ["hidden"] + ] + })); + + let map = merge_discovered_channels( + vec![ + duplicate_name, + malformed_hidden, + duplicate_deadline, + invalid_archived, + canonical_private, + canonical_hidden, + ], + &serde_json::Value::Array(events), + ); + for channel in [ + duplicate_name, + malformed_hidden, + duplicate_deadline, + invalid_archived, + ] { + assert!(!map.contains_key(&channel)); + } + assert!(map[&canonical_private].is_ephemeral()); + assert_eq!(map[&canonical_hidden].channel_type, "dm"); + } + #[test] fn merge_discovered_channels_archived_false_is_kept() { // An explicit archived=false (e.g. after unarchive) must NOT be skipped. @@ -4147,6 +4779,154 @@ mod tests { assert!(map.contains_key(&ch), "archived=false is treated as live"); } + fn signed_reply_event(keys: &Keys, channel: Uuid, request_id: &str, marker: &str) -> Event { + EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16), + "reply", + ) + .tags([ + Tag::parse(["h".to_string(), channel.to_string()]).unwrap(), + Tag::parse([ + "e".to_string(), + request_id.to_string(), + String::new(), + marker.to_string(), + ]) + .unwrap(), + ]) + .sign_with_keys(keys) + .unwrap() + } + + #[test] + fn anchored_reply_parser_verifies_signature_author_channel_and_marker() { + let keys = Keys::generate(); + let channel = Uuid::new_v4(); + let request = "a".repeat(64); + let valid = signed_reply_event(&keys, channel, &request, "reply"); + let parsed = parse_anchored_replies( + &serde_json::json!([valid]), + channel, + keys.public_key(), + &request, + ) + .unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].reply_to, request); + + let wrong_marker = signed_reply_event(&keys, channel, &request, "root"); + let wrong_channel = signed_reply_event(&keys, Uuid::new_v4(), &request, "reply"); + let wrong_author = signed_reply_event(&Keys::generate(), channel, &request, "reply"); + let mut tampered = + serde_json::to_value(signed_reply_event(&keys, channel, &request, "reply")).unwrap(); + tampered["content"] = serde_json::json!("tampered"); + let rejected = parse_anchored_replies( + &serde_json::json!([wrong_marker, wrong_channel, wrong_author, tampered]), + channel, + keys.public_key(), + &request, + ) + .unwrap(); + assert!(rejected.is_empty()); + + let malformed_extra_h = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16), + "reply", + ) + .tags([ + Tag::parse(["h".to_string(), channel.to_string()]).unwrap(), + Tag::parse(["h".to_string()]).unwrap(), + Tag::parse([ + "e".to_string(), + request.clone(), + String::new(), + "reply".to_string(), + ]) + .unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + assert!(parse_anchored_replies( + &serde_json::json!([malformed_extra_h]), + channel, + keys.public_key(), + &request, + ) + .unwrap() + .is_empty()); + } + + #[test] + fn anchored_reply_parser_preserves_multiple_distinct_replies_for_fail_closed_receipt() { + let keys = Keys::generate(); + let channel = Uuid::new_v4(); + let request = "a".repeat(64); + let first = signed_reply_event(&keys, channel, &request, "reply"); + let second = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16), + "second", + ) + .tags([ + Tag::parse(["h".to_string(), channel.to_string()]).unwrap(), + Tag::parse([ + "e".to_string(), + request.clone(), + String::new(), + "reply".to_string(), + ]) + .unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let replies = parse_anchored_replies( + &serde_json::json!([first, second]), + channel, + keys.public_key(), + &request, + ) + .unwrap(); + assert_eq!(replies.len(), 2); + } + + #[test] + fn anchored_reply_parser_rejects_multiple_reply_markers_in_one_event() { + let keys = Keys::generate(); + let channel = Uuid::new_v4(); + let request = "a".repeat(64); + let ambiguous = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16), + "ambiguous", + ) + .tags([ + Tag::parse(["h".to_string(), channel.to_string()]).unwrap(), + Tag::parse([ + "e".to_string(), + request.clone(), + String::new(), + "reply".to_string(), + ]) + .unwrap(), + Tag::parse([ + "e".to_string(), + "b".repeat(64), + String::new(), + "reply".to_string(), + ]) + .unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + + assert!(parse_anchored_replies( + &serde_json::json!([ambiguous]), + channel, + keys.public_key(), + &request, + ) + .unwrap() + .is_empty()); + } + #[test] fn parse_ok_accepted() { let text = r#"["OK","abc123",true,""]"#; diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea4..8a25131917 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -547,6 +547,8 @@ fn mentions_rule(kinds: Vec) -> filter::SubscriptionRule { filter::SubscriptionRule { name: "setup-mentions".into(), channels: filter::ChannelScope::All("all".into()), + admit_invited_ephemeral: false, + require_exact_channel_tag: false, kinds, require_mention: true, filter: None, diff --git a/deploy/local/aeon-aspects/README.md b/deploy/local/aeon-aspects/README.md new file mode 100644 index 0000000000..c385879d2e --- /dev/null +++ b/deploy/local/aeon-aspects/README.md @@ -0,0 +1,169 @@ +# AEON Aspect workers + +This package defines six activation-gated Buzz ACP workers with supervision +settings aligned to the live deployment. It renders and validates configuration +only; it does not install, load, start, restart, or switch any live service. + +Each worker uses Buzz only as the conversation transport. `--no-memory` keeps +Gateway as the sole memory and compaction owner. All six workers use the +existing `--base-prompt-file` facility with one shared contract rendered using +only the private-office room and trusted `buzz__reply` tool names. The +contract explicitly preserves each Aspect's full existing OpenClaw tools, +skills, memory, identity, and fixed session. These custom prompts replace +Buzz's generic `buzz messages send` doctrine, which is intentionally +incompatible with `--no-agent-publisher-credentials`. Fleet may supply an +absolute installed `basePromptPath`; no prompt content is embedded in a plist. +The adapter binds to a pre-created session with +`openclaw acp --require-existing --session ...`. Existing Buzz ACP controls +are pinned rather than inherited from changing defaults: heartbeat prompting +and proactive count-based session rotation are off, thread/DM context is +bounded to 12 messages, presence and typing remain on, and turns use a +900-second idle timeout with a two-hour absolute cap. `bypassPermissions` is +an explicit canary posture; it does not prove that a future interactive +approval workflow is preserved. + +`--trusted-inbound-envelope` copies one signature-verified triggering event +into the ACP request as `_meta.buzz.inboundEvent` (`schemaVersion: 1`). The +envelope carries the signed event ID, author, kind, exact channel ID, and exact +tag arrays outside the model-visible prompt. It is omitted for invalid, +multi-event, cancelled/merged, or room-ambiguous batches; OpenClaw must preserve +this trusted per-turn context before the Nexus reply tool can be enabled. +`--no-agent-publisher-credentials` keeps the validated Buzz relay and signer +inside `buzz-acp`; the spawned ACP agent receives neither `BUZZ_RELAY_URL` nor +`BUZZ_PRIVATE_KEY`. This makes the trusted reply tool the only outbound +publisher authority for AEON managed workers. + +Room admission uses two config rules per worker: + +- the fixed private-office UUID accepts Architect-authored kind-9 and + kind-40002 stream posts without + requiring a mention; +- a newly invited channel is added to the huddle rule only after canonical + kind-39000 metadata proves it is private, has a positive `ttl`, and has a + valid future `ttl_deadline`; huddle messages must mention the Aspect. + +Therefore Concilium and other ordinary rooms remain excluded. A metadata query +failure also denies admission. The canonical addressable metadata winner is +selected by newest `created_at`, then lowest event ID on a same-second tie. +Eligibility is rechecked before every dynamic-room dispatch and periodically; +expiry, archive, membership removal, or lookup failure unsubscribes the room, +drains queued work, invalidates its session, and cancels an in-flight turn. + +Run the source validation without changing live state: + +```sh +node deploy/local/aeon-aspects/validate.mjs +node deploy/local/aeon-aspects/render-private-office-prompts.mjs +node deploy/local/aeon-aspects/render-launchagents.mjs +node deploy/local/aeon-aspects/semantic-health.mjs /absolute/path/to/evidence.json +``` + +The no-argument validator uses the checked-in synthetic identity fixture so it +is runnable in any OSS checkout. To validate the private deployment contract, +pass its identity map explicitly (for example, +`node deploy/local/aeon-aspects/validate.mjs /absolute/path/identity-map.json`). + +`semantic-health.mjs` deliberately rejects `running` plus `agent_pool_ready` as +insufficient. A worker is healthy only when startup evidence also proves relay +connection and the exact private-office subscription, and one fresh functional +turn proves request ID, exactly one trusted `buzz__reply` call, anchored +reply ID, fixed Gateway session key, and fresh run ID. The command emits +`aeon_buzz_semantic_health_v1` JSON and exits nonzero for any missing or +mismatched field. + +The checked-in `launchagents/` previews are real, deterministic launchd +definitions with `RunAtLoad=true` and `KeepAlive=true`, matching the supervised +live posture so regeneration cannot silently disable DQ-39. They are not +installed by this package and contain only owned private-key file paths and +expected public keys, never key or token values. `/REQUIRES_FLEET/...` paths +intentionally make them non-runnable until Fleet supplies an immutable +OpenClaw binary and owned token file. `buzz-acp` opens its private-key file once +with no-follow semantics, validates metadata on that same handle (absolute +path, regular file, current-user owner, mode `0600`), and verifies that it +derives the expected Aspect pubkey. The token-file contract requires the same +path, type, ownership, and permission posture, with Fleet responsible for the +runtime permission readback. + +## Nexus resume and rollback packet + +Activation remains Fleet-gated. After the runtime lock is released, the +operator must pre-create `agent:main:buzz-private`, provide an owned token file, +provide the immutable Gateway generation and current OpenClaw ACP flag proof, +prove that the fixed Nexus session has a caller-bound outbound Buzz publisher +which signs as Nexus without accepting an arbitrary Aspect selector, verify the +live private room contains exactly Architect and Nexus, confirm the legacy +`aeon-buzz` private bridge is off, confirm no Gateway switch/restart is active, +render the Nexus worker, and request a separate Nexus-only canary authorization. +Rollback targets only +`org.aeon.buzz-acp.nexus`; it does not change Gateway configuration or enable +another reply path. + +## Evidence boundary + +The Rust runtime now carries the triggering request event ID through the turn, +cryptographically verifies relay reply evidence, requires exactly one kind-9 +reply with the exact NIP-10 `reply` anchor instructed in the prompt, and checks +the observed Gateway session key against the worker's fixed expected key. The session key is stable +session-level evidence and is intentionally retained across turns; `runId` is +cleared before each prompt and must be observed anew. Zero or multiple replies, +missing evidence, or a session mismatch produce one failed `turn_receipt`; they +never become a successful receipt or retry an otherwise completed user turn. +The request event ID remains the receipt correlation key even when a follow-up +turn is deliberately flattened onto the thread root; a two-turn contract test +proves that prompt instruction and relay evidence query use that same root. + +Current OpenClaw ACP exposes `_meta.sessionKey` but does **not** yet expose its +Gateway `runId` on the ACP wire. Therefore complete receipts remain blocked +until Fleet supplies a version whose per-prompt session evidence includes both +`sessionKey` and `runId`, with a fixture proving the exact wire contract. +Observer frames are encrypted transient evidence transport, not the durable +receipt store; the canary must materialize the verified tuple into the existing +vault receipt. + +The package also does not by itself grant OpenClaw the Aspect private key or a +Buzz CLI environment. Before activation, Fleet must prove the caller-bound +Gateway outbound publisher described above on the exact fixed session. Without +that authority there is no demonstrated signed reply path, so this checkpoint +must remain held even if inbound ACP prompting succeeds. + +Socket reconnect deduplication is covered by the existing in-memory event-ID +set and replay watermark. Exact-once behavior across a complete worker process +restart is not proven because processed event IDs are not durable. Supervised +restart is enabled to match live operations, but it does not itself prove +exactly-once delivery; durable inbox/outbox authority remains separate evidence. + +Relay observer receipts also admit existing Architect-signed cancel and model +control packets. `!cancel` maps to ACP cancellation, but `!rotate` only recreates +the Buzz ACP layer against the same fixed `--require-existing` Gateway session; +it is not Gateway memory compaction or a canonical session reset. + +Huddle audio remains text-mediated through Desktop STT and TTS. Kind-48106 +voice guidelines are posted before incremental agent membership and are not +replayed by the stream-message worker subscription, so guideline query/injection +is a named blocker before a spoken huddle canary; no raw-audio capability is +claimed here. + +Avatar metadata is not present in the current AEON identity map. Source +validation checks names, pubkeys, owners, private-room IDs, and the identity-map +membership declaration, but that declaration is not live relay evidence. Exact +live membership must be read back before the Nexus canary, and avatar validation +remains an explicit blocker before broad UI rollout. + +## Existing-feature parity + +| Buzz surface | Worker integration | Remaining proof | +| --- | --- | --- | +| private chat, mentions, threads, deep links | kind-9 and kind-40002 intake; bounded thread/DM context; exact reply anchoring | one live Nexus request/reply and deep-link readback | +| presence, typing, seen reaction | native `buzz-acp` behavior, enabled by the pinned posture | online/typing/clear/offline lifecycle in only the Nexus room | +| agent activity and cancel | encrypted relay observer and turn receipts | Desktop observer readback plus one terminal receipt | +| profiles and avatars | native Desktop kind-0 rendering | six approved profile events; avatars are missing from the identity contract | +| canvas and scoped search | native Buzz context/CLI surfaces | caller-bound Gateway Buzz read tool on the fixed Aspect session | +| media and authored reactions | native relay/Desktop surfaces | caller-bound Gateway publisher and bounded mutation authority | +| huddle STT/TTS | private text path is admitted by the invited-huddle rule | canonical kind-48106 guideline replay before spoken use | +| workflows | native Buzz workflow surface, intentionally not subscribed here | upstream approval/multi-room behavior and explicit AEON authority | + +The worker intentionally sets no Buzz MCP, model, system prompt, team +instructions, or initial message. The narrow base prompt governs only the +required final Buzz publication; Gateway continues to own tools, skills, +memory, model, and Aspect identity. Concilium, workflows, raw audio, media +mutations, and broad A2A speech remain independently gated. diff --git a/deploy/local/aeon-aspects/config/fontis.toml b/deploy/local/aeon-aspects/config/fontis.toml new file mode 100644 index 0000000000..c6662d1109 --- /dev/null +++ b/deploy/local/aeon-aspects/config/fontis.toml @@ -0,0 +1,16 @@ +[[rules]] +name = "fontis-private-office" +channels = ["600cb602-ff2a-422b-bdb2-6353bb81100d"] +kinds = [9, 40002] +require_exact_channel_tag = true +require_mention = false +filter = 'author == "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f"' + +[[rules]] +name = "fontis-invited-huddle" +channels = [] +admit_invited_ephemeral = true +kinds = [9, 40002] +require_exact_channel_tag = true +require_mention = true +filter = 'author == "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f"' diff --git a/deploy/local/aeon-aspects/config/mechanon.toml b/deploy/local/aeon-aspects/config/mechanon.toml new file mode 100644 index 0000000000..dd700f6156 --- /dev/null +++ b/deploy/local/aeon-aspects/config/mechanon.toml @@ -0,0 +1,16 @@ +[[rules]] +name = "mechanon-private-office" +channels = ["7d022bea-5e81-4d18-bd2e-176e9eda1971"] +kinds = [9, 40002] +require_exact_channel_tag = true +require_mention = false +filter = 'author == "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f"' + +[[rules]] +name = "mechanon-invited-huddle" +channels = [] +admit_invited_ephemeral = true +kinds = [9, 40002] +require_exact_channel_tag = true +require_mention = true +filter = 'author == "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f"' diff --git a/deploy/local/aeon-aspects/config/nexus.toml b/deploy/local/aeon-aspects/config/nexus.toml new file mode 100644 index 0000000000..97dece4815 --- /dev/null +++ b/deploy/local/aeon-aspects/config/nexus.toml @@ -0,0 +1,16 @@ +[[rules]] +name = "nexus-private-office" +channels = ["7e8c4840-d401-4701-afc9-7ae7174cfc4e"] +kinds = [9, 40002] +require_exact_channel_tag = true +require_mention = false +filter = 'author == "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f"' + +[[rules]] +name = "nexus-invited-huddle" +channels = [] +admit_invited_ephemeral = true +kinds = [9, 40002] +require_exact_channel_tag = true +require_mention = true +filter = 'author == "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f"' diff --git a/deploy/local/aeon-aspects/config/sapientis.toml b/deploy/local/aeon-aspects/config/sapientis.toml new file mode 100644 index 0000000000..c015dc17f0 --- /dev/null +++ b/deploy/local/aeon-aspects/config/sapientis.toml @@ -0,0 +1,16 @@ +[[rules]] +name = "sapientis-private-office" +channels = ["4e9f115c-cf8d-4abe-a085-c2b2c64ef2c2"] +kinds = [9, 40002] +require_exact_channel_tag = true +require_mention = false +filter = 'author == "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f"' + +[[rules]] +name = "sapientis-invited-huddle" +channels = [] +admit_invited_ephemeral = true +kinds = [9, 40002] +require_exact_channel_tag = true +require_mention = true +filter = 'author == "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f"' diff --git a/deploy/local/aeon-aspects/config/viatica.toml b/deploy/local/aeon-aspects/config/viatica.toml new file mode 100644 index 0000000000..d530bf9e94 --- /dev/null +++ b/deploy/local/aeon-aspects/config/viatica.toml @@ -0,0 +1,16 @@ +[[rules]] +name = "viatica-private-office" +channels = ["39560f3c-f638-41a1-945b-ed5400ec41fc"] +kinds = [9, 40002] +require_exact_channel_tag = true +require_mention = false +filter = 'author == "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f"' + +[[rules]] +name = "viatica-invited-huddle" +channels = [] +admit_invited_ephemeral = true +kinds = [9, 40002] +require_exact_channel_tag = true +require_mention = true +filter = 'author == "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f"' diff --git a/deploy/local/aeon-aspects/config/voxis.toml b/deploy/local/aeon-aspects/config/voxis.toml new file mode 100644 index 0000000000..1f8f114d71 --- /dev/null +++ b/deploy/local/aeon-aspects/config/voxis.toml @@ -0,0 +1,16 @@ +[[rules]] +name = "voxis-private-office" +channels = ["d00b15e9-0dbe-4f35-895e-b6d36faa299e"] +kinds = [9, 40002] +require_exact_channel_tag = true +require_mention = false +filter = 'author == "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f"' + +[[rules]] +name = "voxis-invited-huddle" +channels = [] +admit_invited_ephemeral = true +kinds = [9, 40002] +require_exact_channel_tag = true +require_mention = true +filter = 'author == "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f"' diff --git a/deploy/local/aeon-aspects/fixtures/identity-map.json b/deploy/local/aeon-aspects/fixtures/identity-map.json new file mode 100644 index 0000000000..ae99ee888e --- /dev/null +++ b/deploy/local/aeon-aspects/fixtures/identity-map.json @@ -0,0 +1,72 @@ +{ + "members": { + "architect": { + "pubkey_hex": "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f" + }, + "nexus": { + "display_name": "Nexus", + "pubkey_hex": "ca2bce90e858e12f399f7b4cd510b5fce74e4238352229ada47b8bdd1a7f3b97", + "gateway_agent_id": "main", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/nexus.sk" + }, + "mechanon": { + "display_name": "Mechanon", + "pubkey_hex": "44437bd6007641845f154bdb7698b1627745b0a1b1d9bdfcf4826fe82a37ca9d", + "gateway_agent_id": "mechanon", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/mechanon.sk" + }, + "fontis": { + "display_name": "Fontis", + "pubkey_hex": "3672ca65abf6b12effaf57475ce31260a4bc96e6d3acf01b909f9c2a44542147", + "gateway_agent_id": "fontis", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/fontis.sk" + }, + "sapientis": { + "display_name": "Sapientis", + "pubkey_hex": "1b7ddc51e6e7d688cd1816047b3ec35e7460014bf146813a505fee851e8b0d89", + "gateway_agent_id": "sapientis", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/sapientis.sk" + }, + "viatica": { + "display_name": "Viatica", + "pubkey_hex": "89a2f2679f83cd9033582f023a3b92e01ed2b5950587efae565045ae65bd03cc", + "gateway_agent_id": "viatica", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/viatica.sk" + }, + "voxis": { + "display_name": "Voxis", + "pubkey_hex": "e2b124c4728989a09d0f7df96cd2b1823635cc220ed9cdd4f97eaedc12a17fe4", + "gateway_agent_id": "voxis", + "secret_ref": "/Volumes/AEON/Projects/buzz-data/keys/voxis.sk" + } + }, + "channels": { + "concilium": { + "channel_id": "4ac1cee0-2238-483f-b25b-f99ec17eec46" + }, + "aspect_nexus": { + "channel_id": "7e8c4840-d401-4701-afc9-7ae7174cfc4e", + "members": ["architect", "nexus"] + }, + "aspect_mechanon": { + "channel_id": "7d022bea-5e81-4d18-bd2e-176e9eda1971", + "members": ["architect", "mechanon"] + }, + "aspect_fontis": { + "channel_id": "600cb602-ff2a-422b-bdb2-6353bb81100d", + "members": ["architect", "fontis"] + }, + "aspect_sapientis": { + "channel_id": "4e9f115c-cf8d-4abe-a085-c2b2c64ef2c2", + "members": ["architect", "sapientis"] + }, + "aspect_viatica": { + "channel_id": "39560f3c-f638-41a1-945b-ed5400ec41fc", + "members": ["architect", "viatica"] + }, + "aspect_voxis": { + "channel_id": "d00b15e9-0dbe-4f35-895e-b6d36faa299e", + "members": ["architect", "voxis"] + } + } +} diff --git a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.fontis.plist b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.fontis.plist new file mode 100644 index 0000000000..33991a10dd --- /dev/null +++ b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.fontis.plist @@ -0,0 +1,66 @@ + + + + + Labelorg.aeon.buzz-acp.fontis + ProgramArguments + + /Volumes/AEON/Projects/buzz/target/release/buzz-acp + --relay-url + ws://localhost:3000 + --private-key-file + /Volumes/AEON/Projects/buzz-data/keys/fontis.sk + --expected-public-key + 3672ca65abf6b12effaf57475ce31260a4bc96e6d3acf01b909f9c2a44542147 + --agent-owner + 73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f + --agent-command + /REQUIRES_FLEET/immutable-openclaw/bin/openclaw + --agent-args + acp,--session,agent:fontis:buzz-private,--require-existing,--token-file,/REQUIRES_FLEET/owned-token-file,--url,ws://127.0.0.1:18806,--provenance,meta+receipt,--no-prefix-cwd + --agents + 1 + --subscribe + config + --config + /Volumes/AEON/Projects/buzz/deploy/local/aeon-aspects/config/fontis.toml + --respond-to + owner-only + --allowed-respond-to + owner-only + --no-memory + --base-prompt-file + /Volumes/AEON/Projects/buzz/deploy/local/aeon-aspects/prompts/fontis-private-office.md + --dedup + queue + --multiple-event-handling + queue + --relay-observer + --trusted-inbound-envelope + --no-agent-publisher-credentials + --permission-mode + bypass-permissions + --heartbeat-interval + 0 + --turn-liveness-secs + 10 + --idle-timeout + 900 + --max-turn-duration + 7200 + --context-message-limit + 12 + --max-turns-per-session + 0 + --turn-receipts + --expected-gateway-session-key + agent:fontis:buzz-private + + WorkingDirectory/Volumes/AEON/Projects/buzz + RunAtLoad + KeepAlive + ProcessTypeBackground + StandardOutPath/Volumes/AEON/Projects/buzz-data/logs/fontis.buzz-acp.log + StandardErrorPath/Volumes/AEON/Projects/buzz-data/logs/fontis.buzz-acp.err.log + + diff --git a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.mechanon.plist b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.mechanon.plist new file mode 100644 index 0000000000..ad77105f0e --- /dev/null +++ b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.mechanon.plist @@ -0,0 +1,66 @@ + + + + + Labelorg.aeon.buzz-acp.mechanon + ProgramArguments + + /Volumes/AEON/Projects/buzz/target/release/buzz-acp + --relay-url + ws://localhost:3000 + --private-key-file + /Volumes/AEON/Projects/buzz-data/keys/mechanon.sk + --expected-public-key + 44437bd6007641845f154bdb7698b1627745b0a1b1d9bdfcf4826fe82a37ca9d + --agent-owner + 73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f + --agent-command + /REQUIRES_FLEET/immutable-openclaw/bin/openclaw + --agent-args + acp,--session,agent:mechanon:buzz-private,--require-existing,--token-file,/REQUIRES_FLEET/owned-token-file,--url,ws://127.0.0.1:18806,--provenance,meta+receipt,--no-prefix-cwd + --agents + 1 + --subscribe + config + --config + /Volumes/AEON/Projects/buzz/deploy/local/aeon-aspects/config/mechanon.toml + --respond-to + owner-only + --allowed-respond-to + owner-only + --no-memory + --base-prompt-file + /Volumes/AEON/Projects/buzz/deploy/local/aeon-aspects/prompts/mechanon-private-office.md + --dedup + queue + --multiple-event-handling + queue + --relay-observer + --trusted-inbound-envelope + --no-agent-publisher-credentials + --permission-mode + bypass-permissions + --heartbeat-interval + 0 + --turn-liveness-secs + 10 + --idle-timeout + 900 + --max-turn-duration + 7200 + --context-message-limit + 12 + --max-turns-per-session + 0 + --turn-receipts + --expected-gateway-session-key + agent:mechanon:buzz-private + + WorkingDirectory/Volumes/AEON/Projects/buzz + RunAtLoad + KeepAlive + ProcessTypeBackground + StandardOutPath/Volumes/AEON/Projects/buzz-data/logs/mechanon.buzz-acp.log + StandardErrorPath/Volumes/AEON/Projects/buzz-data/logs/mechanon.buzz-acp.err.log + + diff --git a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.nexus.plist b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.nexus.plist new file mode 100644 index 0000000000..ffeec10e43 --- /dev/null +++ b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.nexus.plist @@ -0,0 +1,66 @@ + + + + + Labelorg.aeon.buzz-acp.nexus + ProgramArguments + + /Volumes/AEON/Projects/buzz/target/release/buzz-acp + --relay-url + ws://localhost:3000 + --private-key-file + /Volumes/AEON/Projects/buzz-data/keys/nexus.sk + --expected-public-key + ca2bce90e858e12f399f7b4cd510b5fce74e4238352229ada47b8bdd1a7f3b97 + --agent-owner + 73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f + --agent-command + /REQUIRES_FLEET/immutable-openclaw/bin/openclaw + --agent-args + acp,--session,agent:main:buzz-private,--require-existing,--token-file,/REQUIRES_FLEET/owned-token-file,--url,ws://127.0.0.1:18806,--provenance,meta+receipt,--no-prefix-cwd + --agents + 1 + --subscribe + config + --config + /Volumes/AEON/Projects/buzz/deploy/local/aeon-aspects/config/nexus.toml + --respond-to + owner-only + --allowed-respond-to + owner-only + --no-memory + --base-prompt-file + /Volumes/AEON/Projects/buzz/deploy/local/aeon-aspects/prompts/nexus-private-office.md + --dedup + queue + --multiple-event-handling + queue + --relay-observer + --trusted-inbound-envelope + --no-agent-publisher-credentials + --permission-mode + bypass-permissions + --heartbeat-interval + 0 + --turn-liveness-secs + 10 + --idle-timeout + 900 + --max-turn-duration + 7200 + --context-message-limit + 12 + --max-turns-per-session + 0 + --turn-receipts + --expected-gateway-session-key + agent:main:buzz-private + + WorkingDirectory/Volumes/AEON/Projects/buzz + RunAtLoad + KeepAlive + ProcessTypeBackground + StandardOutPath/Volumes/AEON/Projects/buzz-data/logs/nexus.buzz-acp.log + StandardErrorPath/Volumes/AEON/Projects/buzz-data/logs/nexus.buzz-acp.err.log + + diff --git a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.sapientis.plist b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.sapientis.plist new file mode 100644 index 0000000000..651d57312e --- /dev/null +++ b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.sapientis.plist @@ -0,0 +1,66 @@ + + + + + Labelorg.aeon.buzz-acp.sapientis + ProgramArguments + + /Volumes/AEON/Projects/buzz/target/release/buzz-acp + --relay-url + ws://localhost:3000 + --private-key-file + /Volumes/AEON/Projects/buzz-data/keys/sapientis.sk + --expected-public-key + 1b7ddc51e6e7d688cd1816047b3ec35e7460014bf146813a505fee851e8b0d89 + --agent-owner + 73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f + --agent-command + /REQUIRES_FLEET/immutable-openclaw/bin/openclaw + --agent-args + acp,--session,agent:sapientis:buzz-private,--require-existing,--token-file,/REQUIRES_FLEET/owned-token-file,--url,ws://127.0.0.1:18806,--provenance,meta+receipt,--no-prefix-cwd + --agents + 1 + --subscribe + config + --config + /Volumes/AEON/Projects/buzz/deploy/local/aeon-aspects/config/sapientis.toml + --respond-to + owner-only + --allowed-respond-to + owner-only + --no-memory + --base-prompt-file + /Volumes/AEON/Projects/buzz/deploy/local/aeon-aspects/prompts/sapientis-private-office.md + --dedup + queue + --multiple-event-handling + queue + --relay-observer + --trusted-inbound-envelope + --no-agent-publisher-credentials + --permission-mode + bypass-permissions + --heartbeat-interval + 0 + --turn-liveness-secs + 10 + --idle-timeout + 900 + --max-turn-duration + 7200 + --context-message-limit + 12 + --max-turns-per-session + 0 + --turn-receipts + --expected-gateway-session-key + agent:sapientis:buzz-private + + WorkingDirectory/Volumes/AEON/Projects/buzz + RunAtLoad + KeepAlive + ProcessTypeBackground + StandardOutPath/Volumes/AEON/Projects/buzz-data/logs/sapientis.buzz-acp.log + StandardErrorPath/Volumes/AEON/Projects/buzz-data/logs/sapientis.buzz-acp.err.log + + diff --git a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.viatica.plist b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.viatica.plist new file mode 100644 index 0000000000..fdcbdc69d3 --- /dev/null +++ b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.viatica.plist @@ -0,0 +1,66 @@ + + + + + Labelorg.aeon.buzz-acp.viatica + ProgramArguments + + /Volumes/AEON/Projects/buzz/target/release/buzz-acp + --relay-url + ws://localhost:3000 + --private-key-file + /Volumes/AEON/Projects/buzz-data/keys/viatica.sk + --expected-public-key + 89a2f2679f83cd9033582f023a3b92e01ed2b5950587efae565045ae65bd03cc + --agent-owner + 73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f + --agent-command + /REQUIRES_FLEET/immutable-openclaw/bin/openclaw + --agent-args + acp,--session,agent:viatica:buzz-private,--require-existing,--token-file,/REQUIRES_FLEET/owned-token-file,--url,ws://127.0.0.1:18806,--provenance,meta+receipt,--no-prefix-cwd + --agents + 1 + --subscribe + config + --config + /Volumes/AEON/Projects/buzz/deploy/local/aeon-aspects/config/viatica.toml + --respond-to + owner-only + --allowed-respond-to + owner-only + --no-memory + --base-prompt-file + /Volumes/AEON/Projects/buzz/deploy/local/aeon-aspects/prompts/viatica-private-office.md + --dedup + queue + --multiple-event-handling + queue + --relay-observer + --trusted-inbound-envelope + --no-agent-publisher-credentials + --permission-mode + bypass-permissions + --heartbeat-interval + 0 + --turn-liveness-secs + 10 + --idle-timeout + 900 + --max-turn-duration + 7200 + --context-message-limit + 12 + --max-turns-per-session + 0 + --turn-receipts + --expected-gateway-session-key + agent:viatica:buzz-private + + WorkingDirectory/Volumes/AEON/Projects/buzz + RunAtLoad + KeepAlive + ProcessTypeBackground + StandardOutPath/Volumes/AEON/Projects/buzz-data/logs/viatica.buzz-acp.log + StandardErrorPath/Volumes/AEON/Projects/buzz-data/logs/viatica.buzz-acp.err.log + + diff --git a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.voxis.plist b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.voxis.plist new file mode 100644 index 0000000000..8c414167eb --- /dev/null +++ b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.voxis.plist @@ -0,0 +1,66 @@ + + + + + Labelorg.aeon.buzz-acp.voxis + ProgramArguments + + /Volumes/AEON/Projects/buzz/target/release/buzz-acp + --relay-url + ws://localhost:3000 + --private-key-file + /Volumes/AEON/Projects/buzz-data/keys/voxis.sk + --expected-public-key + e2b124c4728989a09d0f7df96cd2b1823635cc220ed9cdd4f97eaedc12a17fe4 + --agent-owner + 73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f + --agent-command + /REQUIRES_FLEET/immutable-openclaw/bin/openclaw + --agent-args + acp,--session,agent:voxis:buzz-private,--require-existing,--token-file,/REQUIRES_FLEET/owned-token-file,--url,ws://127.0.0.1:18806,--provenance,meta+receipt,--no-prefix-cwd + --agents + 1 + --subscribe + config + --config + /Volumes/AEON/Projects/buzz/deploy/local/aeon-aspects/config/voxis.toml + --respond-to + owner-only + --allowed-respond-to + owner-only + --no-memory + --base-prompt-file + /Volumes/AEON/Projects/buzz/deploy/local/aeon-aspects/prompts/voxis-private-office.md + --dedup + queue + --multiple-event-handling + queue + --relay-observer + --trusted-inbound-envelope + --no-agent-publisher-credentials + --permission-mode + bypass-permissions + --heartbeat-interval + 0 + --turn-liveness-secs + 10 + --idle-timeout + 900 + --max-turn-duration + 7200 + --context-message-limit + 12 + --max-turns-per-session + 0 + --turn-receipts + --expected-gateway-session-key + agent:voxis:buzz-private + + WorkingDirectory/Volumes/AEON/Projects/buzz + RunAtLoad + KeepAlive + ProcessTypeBackground + StandardOutPath/Volumes/AEON/Projects/buzz-data/logs/voxis.buzz-acp.log + StandardErrorPath/Volumes/AEON/Projects/buzz-data/logs/voxis.buzz-acp.err.log + + diff --git a/deploy/local/aeon-aspects/prompts/fontis-private-office.md b/deploy/local/aeon-aspects/prompts/fontis-private-office.md new file mode 100644 index 0000000000..d5a2cd5bbb --- /dev/null +++ b/deploy/local/aeon-aspects/prompts/fontis-private-office.md @@ -0,0 +1,3 @@ +# AEON Private-Office Publishing + +For every accepted Architect-authored turn in `#aspect-fontis`, use the Aspect's full existing OpenClaw tool, skill, memory, identity, and session capabilities as the work requires. After the work is complete, call exactly one `buzz_fontis_reply` with the final human-visible response. Plain assistant text is not published to Buzz. Publisher credentials are intentionally withheld. The contextual reply tool is the only outbound publisher for this room. This publishing contract does not restrict any other tool use or capability. diff --git a/deploy/local/aeon-aspects/prompts/mechanon-private-office.md b/deploy/local/aeon-aspects/prompts/mechanon-private-office.md new file mode 100644 index 0000000000..ee5206fd5e --- /dev/null +++ b/deploy/local/aeon-aspects/prompts/mechanon-private-office.md @@ -0,0 +1,3 @@ +# AEON Private-Office Publishing + +For every accepted Architect-authored turn in `#aspect-mechanon`, use the Aspect's full existing OpenClaw tool, skill, memory, identity, and session capabilities as the work requires. After the work is complete, call exactly one `buzz_mechanon_reply` with the final human-visible response. Plain assistant text is not published to Buzz. Publisher credentials are intentionally withheld. The contextual reply tool is the only outbound publisher for this room. This publishing contract does not restrict any other tool use or capability. diff --git a/deploy/local/aeon-aspects/prompts/nexus-private-office.md b/deploy/local/aeon-aspects/prompts/nexus-private-office.md new file mode 100644 index 0000000000..27c90b3140 --- /dev/null +++ b/deploy/local/aeon-aspects/prompts/nexus-private-office.md @@ -0,0 +1,3 @@ +# AEON Private-Office Publishing + +For every accepted Architect-authored turn in `#aspect-nexus`, use the Aspect's full existing OpenClaw tool, skill, memory, identity, and session capabilities as the work requires. After the work is complete, call exactly one `buzz_nexus_reply` with the final human-visible response. Plain assistant text is not published to Buzz. Publisher credentials are intentionally withheld. The contextual reply tool is the only outbound publisher for this room. This publishing contract does not restrict any other tool use or capability. diff --git a/deploy/local/aeon-aspects/prompts/private-office.template.md b/deploy/local/aeon-aspects/prompts/private-office.template.md new file mode 100644 index 0000000000..973079c5a4 --- /dev/null +++ b/deploy/local/aeon-aspects/prompts/private-office.template.md @@ -0,0 +1,3 @@ +# AEON Private-Office Publishing + +For every accepted Architect-authored turn in `{{ROOM}}`, use the Aspect's full existing OpenClaw tool, skill, memory, identity, and session capabilities as the work requires. After the work is complete, call exactly one `{{REPLY_TOOL}}` with the final human-visible response. Plain assistant text is not published to Buzz. Publisher credentials are intentionally withheld. The contextual reply tool is the only outbound publisher for this room. This publishing contract does not restrict any other tool use or capability. diff --git a/deploy/local/aeon-aspects/prompts/sapientis-private-office.md b/deploy/local/aeon-aspects/prompts/sapientis-private-office.md new file mode 100644 index 0000000000..bd867e405c --- /dev/null +++ b/deploy/local/aeon-aspects/prompts/sapientis-private-office.md @@ -0,0 +1,3 @@ +# AEON Private-Office Publishing + +For every accepted Architect-authored turn in `#aspect-sapientis`, use the Aspect's full existing OpenClaw tool, skill, memory, identity, and session capabilities as the work requires. After the work is complete, call exactly one `buzz_sapientis_reply` with the final human-visible response. Plain assistant text is not published to Buzz. Publisher credentials are intentionally withheld. The contextual reply tool is the only outbound publisher for this room. This publishing contract does not restrict any other tool use or capability. diff --git a/deploy/local/aeon-aspects/prompts/viatica-private-office.md b/deploy/local/aeon-aspects/prompts/viatica-private-office.md new file mode 100644 index 0000000000..fac4eb4477 --- /dev/null +++ b/deploy/local/aeon-aspects/prompts/viatica-private-office.md @@ -0,0 +1,3 @@ +# AEON Private-Office Publishing + +For every accepted Architect-authored turn in `#aspect-viatica`, use the Aspect's full existing OpenClaw tool, skill, memory, identity, and session capabilities as the work requires. After the work is complete, call exactly one `buzz_viatica_reply` with the final human-visible response. Plain assistant text is not published to Buzz. Publisher credentials are intentionally withheld. The contextual reply tool is the only outbound publisher for this room. This publishing contract does not restrict any other tool use or capability. diff --git a/deploy/local/aeon-aspects/prompts/voxis-private-office.md b/deploy/local/aeon-aspects/prompts/voxis-private-office.md new file mode 100644 index 0000000000..74ecdcb984 --- /dev/null +++ b/deploy/local/aeon-aspects/prompts/voxis-private-office.md @@ -0,0 +1,3 @@ +# AEON Private-Office Publishing + +For every accepted Architect-authored turn in `#aspect-voxis`, use the Aspect's full existing OpenClaw tool, skill, memory, identity, and session capabilities as the work requires. After the work is complete, call exactly one `buzz_voxis_reply` with the final human-visible response. Plain assistant text is not published to Buzz. Publisher credentials are intentionally withheld. The contextual reply tool is the only outbound publisher for this room. This publishing contract does not restrict any other tool use or capability. diff --git a/deploy/local/aeon-aspects/render-launchagents.mjs b/deploy/local/aeon-aspects/render-launchagents.mjs new file mode 100644 index 0000000000..e9418c830a --- /dev/null +++ b/deploy/local/aeon-aspects/render-launchagents.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadJson, renderDisabledLaunchAgent, validateManifest } from "./worker.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const manifest = loadJson(join(here, "workers.json")); +const identityMap = loadJson(process.argv[2] ?? join(here, "fixtures", "identity-map.json")); +const validation = validateManifest(manifest, identityMap); +if (!validation.ok) { + console.error(validation.errors.join("\n")); + process.exit(1); +} +const artifacts = Object.fromEntries( + manifest.workers.map((worker) => { + const artifact = renderDisabledLaunchAgent(manifest, identityMap, worker.aspect); + return [`${artifact.label}.plist`, artifact.plist]; + }), +); +process.stdout.write(`${JSON.stringify({ schema: "aeon_disabled_launchagents_v1", artifacts }, null, 2)}\n`); diff --git a/deploy/local/aeon-aspects/render-private-office-prompts.mjs b/deploy/local/aeon-aspects/render-private-office-prompts.mjs new file mode 100644 index 0000000000..e616a2c9a9 --- /dev/null +++ b/deploy/local/aeon-aspects/render-private-office-prompts.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadJson, renderPrivateOfficePrompt, validateManifest } from "./worker.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const manifest = loadJson(join(here, "workers.json")); +const identityMap = loadJson(process.argv[2] ?? join(here, "fixtures", "identity-map.json")); +const validation = validateManifest(manifest, identityMap); +if (!validation.ok) { + console.error(validation.errors.join("\n")); + process.exit(1); +} +const template = fs.readFileSync(join(here, "prompts", "private-office.template.md"), "utf8"); +const artifacts = Object.fromEntries( + manifest.workers.map((worker) => [ + `${worker.aspect}-private-office.md`, + renderPrivateOfficePrompt(template, worker.aspect), + ]), +); +process.stdout.write( + `${JSON.stringify({ schema: "aeon_private_office_prompts_v1", artifacts }, null, 2)}\n`, +); diff --git a/deploy/local/aeon-aspects/semantic-health.mjs b/deploy/local/aeon-aspects/semantic-health.mjs new file mode 100644 index 0000000000..4fa1bfadee --- /dev/null +++ b/deploy/local/aeon-aspects/semantic-health.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import { evaluateSemanticHealth } from "./worker.mjs"; + +const source = process.argv[2] ? fs.readFileSync(process.argv[2], "utf8") : fs.readFileSync(0, "utf8"); +const evidence = JSON.parse(source); +const checks = (Array.isArray(evidence) ? evidence : [evidence]).map((entry) => ({ + aspect: entry.aspect, + ...evaluateSemanticHealth(entry), +})); +const result = { + schema: "aeon_buzz_semantic_health_v1", + ok: checks.length > 0 && checks.every((check) => check.healthy), + checks, +}; + +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +if (!result.ok) process.exitCode = 1; diff --git a/deploy/local/aeon-aspects/validate.mjs b/deploy/local/aeon-aspects/validate.mjs new file mode 100644 index 0000000000..f8175bd18d --- /dev/null +++ b/deploy/local/aeon-aspects/validate.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + loadJson, + renderDisabledLaunchAgent, + renderPrivateOfficePrompt, + renderWorker, + validateManifest, +} from "./worker.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const manifestPath = join(here, "workers.json"); +const manifest = loadJson(manifestPath); +const identityMapPath = process.argv[2] ?? join(here, "fixtures", "identity-map.json"); +const identityMap = loadJson(identityMapPath); +const validation = validateManifest(manifest, identityMap); +const rendered = validation.ok ? manifest.workers.map((worker) => renderWorker(manifest, identityMap, worker.aspect)) : []; +const launchAgents = []; +if (validation.ok) { + const promptTemplate = fs.readFileSync(join(here, "prompts", "private-office.template.md"), "utf8"); + for (const worker of manifest.workers) { + const promptPath = join(here, "prompts", `${worker.aspect}-private-office.md`); + const expectedPrompt = renderPrivateOfficePrompt(promptTemplate, worker.aspect); + if (!fs.existsSync(promptPath) || fs.readFileSync(promptPath, "utf8") !== expectedPrompt) { + validation.errors.push(`${worker.aspect}: checked-in private-office prompt drift`); + } + const artifact = renderDisabledLaunchAgent(manifest, identityMap, worker.aspect); + const path = join(here, "launchagents", `${artifact.label}.plist`); + if (!fs.existsSync(path) || fs.readFileSync(path, "utf8") !== artifact.plist) { + validation.errors.push(`${worker.aspect}: checked-in LaunchAgent preview drift`); + } + launchAgents.push({ label: artifact.label, path, runAtLoad: artifact.runAtLoad, keepAlive: artifact.keepAlive }); + } + validation.ok = validation.errors.length === 0; +} +console.log(JSON.stringify({ ...validation, workers: rendered, launchAgents }, null, 2)); +process.exitCode = validation.ok ? 0 : 1; diff --git a/deploy/local/aeon-aspects/worker.mjs b/deploy/local/aeon-aspects/worker.mjs new file mode 100644 index 0000000000..4443308a0c --- /dev/null +++ b/deploy/local/aeon-aspects/worker.mjs @@ -0,0 +1,371 @@ +import fs from "node:fs"; + +const PRIVATE_OFFICE_PROMPT_PREFIX = "deploy/local/aeon-aspects/prompts"; + +export function renderPrivateOfficePrompt(template, aspect) { + if (!/^[a-z][a-z0-9-]*$/.test(aspect)) { + throw new Error(`invalid Aspect prompt slug: ${aspect}`); + } + const rendered = template + .replaceAll("{{ROOM}}", `#aspect-${aspect}`) + .replaceAll("{{REPLY_TOOL}}", `buzz_${aspect}_reply`); + if (rendered.includes("{{")) { + throw new Error(`${aspect}: unresolved private-office prompt token`); + } + return rendered; +} + +export function loadJson(path) { + return JSON.parse(fs.readFileSync(path, "utf8")); +} + +export function validateManifest(manifest, identityMap) { + const errors = []; + const warnings = []; + if (manifest.enabled !== false) errors.push("package must be disabled by default"); + if (manifest.workers?.length !== 6) errors.push("exactly six Aspect workers are required"); + if (manifest.buzz?.relayUrl !== "ws://localhost:3000") errors.push("Buzz relay must use localhost"); + if (manifest.posture?.memory !== false) errors.push("Buzz memory injection must be disabled"); + if (manifest.posture?.basePrompt !== false) errors.push("compiled generic Buzz base prompt must be disabled"); + if (manifest.posture?.respondTo !== "owner-only") errors.push("respondTo must be owner-only"); + if (manifest.posture?.agents !== 1) errors.push("each worker must have one ACP subprocess"); + if (manifest.posture?.dedup !== "queue") errors.push("dedup must be queue"); + if (manifest.posture?.multipleEventHandling !== "queue") errors.push("multipleEventHandling must be queue"); + if (manifest.posture?.presence !== true) errors.push("presence must remain enabled"); + if (manifest.posture?.typing !== true) errors.push("typing must remain enabled"); + if (manifest.posture?.relayObserver !== true) errors.push("relay observer must remain enabled for receipts"); + if (manifest.posture?.trustedInboundEnvelope !== true) errors.push("trusted inbound envelope must remain enabled"); + if (manifest.posture?.permissionMode !== "bypass-permissions") errors.push("permission mode must be explicitly bypass-permissions"); + if (manifest.posture?.heartbeatIntervalSecs !== 0) errors.push("ACP heartbeat prompting must be disabled"); + if (manifest.posture?.turnLivenessSecs !== 10) errors.push("turn liveness must be 10 seconds"); + if (manifest.posture?.idleTimeoutSecs !== 900) errors.push("idle timeout must be 900 seconds"); + if (manifest.posture?.maxTurnDurationSecs !== 7200) errors.push("max turn duration must be 7200 seconds"); + if (manifest.posture?.contextMessageLimit !== 12) errors.push("context message limit must be 12"); + if (manifest.posture?.maxTurnsPerSession !== 0) errors.push("Buzz session rotation must be disabled"); + const tokenContract = manifest.gateway?.tokenFileContract; + if ( + tokenContract?.absolute !== true || tokenContract?.regular !== true || + tokenContract?.symlink !== false || tokenContract?.owner !== "current-user" || + tokenContract?.mode !== "0600" + ) { + errors.push("Gateway token file contract must require absolute regular non-symlink current-user 0600"); + } + if (manifest.supervisor?.runAtLoad !== true || manifest.supervisor?.startOnAppLaunch !== true) { + errors.push("workers must retain live supervised startup"); + } + if (manifest.supervisor?.restartOnFailure !== true) { + errors.push("workers must retain live restart supervision"); + } + const concilium = identityMap.channels?.concilium; + if (concilium?.channel_id !== manifest.buzz?.conciliumChannelId) errors.push("Concilium UUID drift"); + const architect = identityMap.members?.architect; + if (architect?.pubkey_hex !== manifest.buzz?.architectPubkey) errors.push("Architect pubkey drift"); + + for (const worker of manifest.workers ?? []) { + const member = identityMap.members?.[worker.aspect]; + const channel = identityMap.channels?.[`aspect_${worker.aspect}`]; + if (!member) { errors.push(`${worker.aspect}: missing identity-map member`); continue; } + if (worker.displayName !== member.display_name) errors.push(`${worker.aspect}: display name drift`); + if (worker.pubkey !== member.pubkey_hex) errors.push(`${worker.aspect}: pubkey drift`); + if (worker.gatewayAgentId !== member.gateway_agent_id) errors.push(`${worker.aspect}: Gateway agent drift`); + if (worker.privateChannelId !== channel?.channel_id) errors.push(`${worker.aspect}: private room drift`); + const expectedMembers = JSON.stringify(["architect", worker.aspect]); + if (JSON.stringify(channel?.members) !== expectedMembers) errors.push(`${worker.aspect}: private room membership is not exact`); + if (concilium?.channel_id === worker.privateChannelId) errors.push(`${worker.aspect}: private room is Concilium`); + if (worker.sessionKey !== `agent:${worker.gatewayAgentId}:buzz-private`) errors.push(`${worker.aspect}: unstable session key`); + if (!member.secret_ref) errors.push(`${worker.aspect}: missing private-key reference`); + const expectedPromptFile = `${PRIVATE_OFFICE_PROMPT_PREFIX}/${worker.aspect}-private-office.md`; + if (worker.basePromptFile !== expectedPromptFile) { + errors.push(`${worker.aspect}: trusted private-office base prompt drift`); + } + } + warnings.push("avatar metadata is absent from identity-map.json; live profile avatar validation remains open"); + return { ok: errors.length === 0, errors, warnings }; +} + +export function renderWorker(manifest, identityMap, aspect, tokenFile = "${AEON_GATEWAY_TOKEN_FILE}") { + const worker = manifest.workers.find((item) => item.aspect === aspect); + if (!worker) throw new Error(`unknown Aspect: ${aspect}`); + const member = identityMap.members[aspect]; + const configPath = `deploy/local/aeon-aspects/config/${aspect}.toml`; + const basePromptArgs = worker.basePromptFile + ? ["--base-prompt-file", worker.basePromptFile] + : ["--no-base-prompt"]; + const rendered = { + enabled: false, + label: `org.aeon.buzz-acp.${aspect}`, + command: "buzz-acp", + args: [ + "--relay-url", manifest.buzz.relayUrl, + "--private-key-file", member.secret_ref, + "--expected-public-key", worker.pubkey, + "--agent-owner", manifest.buzz.architectPubkey, + "--agent-command", "openclaw", + "--agent-args", ["acp", "--session", worker.sessionKey, "--require-existing", "--token-file", tokenFile, "--url", manifest.gateway.url, "--provenance", manifest.gateway.provenance, "--no-prefix-cwd"].join(","), + "--agents", "1", "--subscribe", "config", "--config", configPath, + "--respond-to", "owner-only", "--allowed-respond-to", "owner-only", + "--no-memory", ...basePromptArgs, "--dedup", "queue", "--multiple-event-handling", "queue", "--relay-observer", "--trusted-inbound-envelope", "--no-agent-publisher-credentials", + "--permission-mode", manifest.posture.permissionMode, + "--heartbeat-interval", String(manifest.posture.heartbeatIntervalSecs), + "--turn-liveness-secs", String(manifest.posture.turnLivenessSecs), + "--idle-timeout", String(manifest.posture.idleTimeoutSecs), + "--max-turn-duration", String(manifest.posture.maxTurnDurationSecs), + "--context-message-limit", String(manifest.posture.contextMessageLimit), + "--max-turns-per-session", String(manifest.posture.maxTurnsPerSession), + "--turn-receipts", "--expected-gateway-session-key", worker.sessionKey + ], + privateKeyRef: member.secret_ref, + sessionKey: worker.sessionKey, + supervisor: manifest.supervisor + }; + assertTrustedPublisherContract(rendered.args, aspect, worker.basePromptFile); + return rendered; +} + +function xml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function assertArgSafe(value, label) { + if (/[\0\r\n,]/.test(value)) throw new Error(`${label} contains a forbidden delimiter`); +} + +function countArg(argv, expected) { + return argv.filter((value) => value === expected).length; +} + +export function assertTrustedPublisherContract(argv, aspect, expectedBasePromptFile) { + if (!Array.isArray(argv)) throw new Error(`${aspect}: worker argv must be an array`); + for (const flag of [ + "--no-agent-publisher-credentials", + "--trusted-inbound-envelope", + "--base-prompt-file", + "--turn-receipts", + ]) { + const count = countArg(argv, flag); + if (count !== 1) { + throw new Error(`${aspect}: trusted publisher contract requires exactly one ${flag}; found ${count}`); + } + } + if (argv.includes("--no-base-prompt")) { + throw new Error(`${aspect}: trusted publisher contract forbids --no-base-prompt`); + } + const basePromptIndex = argv.indexOf("--base-prompt-file"); + if (argv[basePromptIndex + 1] !== expectedBasePromptFile) { + throw new Error(`${aspect}: trusted publisher contract base prompt drift`); + } +} + +export function evaluateSemanticHealth({ aspect, sessionKey, state, startup, receipt }) { + const failures = []; + if (state !== "running") failures.push("worker_not_running"); + if (startup?.agentPoolReady !== true) failures.push("agent_pool_not_ready"); + if (startup?.relayConnected !== true) failures.push("relay_not_connected"); + if (startup?.privateOfficeSubscribed !== true) failures.push("private_office_not_subscribed"); + if (!receipt?.requestEventId) failures.push("request_event_missing"); + if (!receipt?.replyEventId) failures.push("reply_event_missing"); + if (receipt?.replyTo !== receipt?.requestEventId) failures.push("reply_anchor_mismatch"); + if (receipt?.sessionKey !== sessionKey) failures.push("gateway_session_mismatch"); + if (!receipt?.runId) failures.push("fresh_run_missing"); + if (receipt?.toolName !== `buzz_${aspect}_reply`) failures.push("trusted_reply_tool_mismatch"); + if (receipt?.toolCallCount !== 1) failures.push("trusted_reply_tool_count_mismatch"); + return { healthy: failures.length === 0, failures }; +} + +export function renderDisabledLaunchAgent(manifest, identityMap, aspect, options = {}) { + const buzzAcpPath = options.buzzAcpPath ?? "/Volumes/AEON/Projects/buzz/target/release/buzz-acp"; + const openclawPath = options.openclawPath ?? "/REQUIRES_FLEET/immutable-openclaw/bin/openclaw"; + const tokenFile = options.tokenFile ?? "/REQUIRES_FLEET/owned-token-file"; + const privateKeyFile = options.privateKeyFile ?? identityMap.members[aspect].secret_ref; + const workingDirectory = options.workingDirectory ?? "/Volumes/AEON/Projects/buzz"; + const configPath = options.configPath ?? null; + const basePromptPath = options.basePromptPath ?? null; + const stdoutPath = options.stdoutPath ?? null; + const stderrPath = options.stderrPath ?? null; + const launcherPath = options.launcherPath ?? null; + const executablePath = options.executablePath ?? null; + const openclawConfigPath = options.openclawConfigPath ?? null; + const openclawStateDir = options.openclawStateDir ?? null; + const relayUrl = options.relayUrl ?? manifest.buzz.relayUrl; + const respondTo = options.respondTo ?? null; + const allowedRespondTo = options.allowedRespondTo ?? null; + const respondToAllowlist = options.respondToAllowlist ?? null; + const additionalEnvironment = options.additionalEnvironment ?? {}; + const agentCommandPrefixArgs = options.agentCommandPrefixArgs ?? []; + if (!/^wss?:\/\/[^,\s]+$/.test(relayUrl)) { + throw new Error("relayUrl must be an absolute ws:// or wss:// URL"); + } + assertArgSafe(relayUrl, "relayUrl"); + if ((respondTo === null) !== (allowedRespondTo === null)) { + throw new Error("respondTo and allowedRespondTo must be supplied together"); + } + for (const [label, value] of Object.entries({ + ...(respondTo !== null ? { respondTo } : {}), + ...(allowedRespondTo !== null ? { allowedRespondTo } : {}), + })) { + if (!/^[a-z-]+(?:,[a-z-]+)*$/.test(value)) { + throw new Error(`${label} contains an invalid response mode`); + } + } + if ( + respondToAllowlist !== null && + !/^[0-9a-f]{64}(?:,[0-9a-f]{64})*$/.test(respondToAllowlist) + ) { + throw new Error("respondToAllowlist must be a comma-separated lowercase pubkey list"); + } + if ( + additionalEnvironment === null || + Array.isArray(additionalEnvironment) || + typeof additionalEnvironment !== "object" + ) { + throw new Error("additionalEnvironment must be an object"); + } + for (const [key, value] of Object.entries(additionalEnvironment)) { + if (!/^[A-Z][A-Z0-9_]*$/.test(key) || typeof value !== "string" || /[\0\r\n]/.test(value)) { + throw new Error(`additionalEnvironment.${key} is invalid`); + } + } + if (!Array.isArray(agentCommandPrefixArgs)) { + throw new Error("agentCommandPrefixArgs must be an array"); + } + if (agentCommandPrefixArgs.length > 1) { + throw new Error("agentCommandPrefixArgs accepts exactly one OpenClaw entrypoint"); + } + for (const [index, value] of agentCommandPrefixArgs.entries()) { + if (typeof value !== "string" || !value.startsWith("/") || !value.endsWith("/openclaw.mjs")) { + throw new Error(`agentCommandPrefixArgs[${index}] must be absolute`); + } + assertArgSafe(value, `agentCommandPrefixArgs[${index}]`); + } + for (const [label, value] of Object.entries({ + buzzAcpPath, + openclawPath, + tokenFile, + privateKeyFile, + workingDirectory, + ...(configPath !== null ? { configPath } : {}), + ...(basePromptPath !== null ? { basePromptPath } : {}), + ...(stdoutPath !== null ? { stdoutPath } : {}), + ...(stderrPath !== null ? { stderrPath } : {}), + ...(launcherPath !== null ? { launcherPath } : {}), + ...(openclawConfigPath !== null ? { openclawConfigPath } : {}), + ...(openclawStateDir !== null ? { openclawStateDir } : {}), + })) { + if (!value.startsWith("/")) throw new Error(`${label} must be absolute`); + assertArgSafe(value, label); + } + if ((openclawConfigPath === null) !== (openclawStateDir === null)) { + throw new Error("openclawConfigPath and openclawStateDir must be supplied together"); + } + if (executablePath !== null) { + if (!executablePath.split(":").every((entry) => entry.startsWith("/"))) { + throw new Error("executablePath entries must be absolute"); + } + assertArgSafe(executablePath, "executablePath"); + } + const launchIdentityMap = { + ...identityMap, + members: { + ...identityMap.members, + [aspect]: { ...identityMap.members[aspect], secret_ref: privateKeyFile }, + }, + }; + const rendered = renderWorker(manifest, launchIdentityMap, aspect, tokenFile); + const relayUrlIndex = rendered.args.indexOf("--relay-url") + 1; + rendered.args[relayUrlIndex] = relayUrl; + if (respondTo !== null) { + rendered.args[rendered.args.indexOf("--respond-to") + 1] = respondTo; + rendered.args[rendered.args.indexOf("--allowed-respond-to") + 1] = allowedRespondTo; + const existingAllowlist = rendered.args.indexOf("--respond-to-allowlist"); + if (existingAllowlist >= 0) rendered.args.splice(existingAllowlist, 2); + if (respondToAllowlist !== null) { + const insertAt = rendered.args.indexOf("--allowed-respond-to") + 2; + rendered.args.splice(insertAt, 0, "--respond-to-allowlist", respondToAllowlist); + } + } else if (respondToAllowlist !== null) { + throw new Error("respondToAllowlist requires respondTo and allowedRespondTo"); + } + const agentCommandIndex = rendered.args.indexOf("--agent-command") + 1; + rendered.args[agentCommandIndex] = openclawPath; + if (agentCommandPrefixArgs.length > 0) { + const agentArgsIndex = rendered.args.indexOf("--agent-args") + 1; + rendered.args[agentArgsIndex] = [agentCommandPrefixArgs.join(","), rendered.args[agentArgsIndex]].join(","); + } + const configIndex = rendered.args.indexOf("--config") + 1; + rendered.args[configIndex] = configPath ?? `${workingDirectory}/${rendered.args[configIndex]}`; + const basePromptIndex = rendered.args.indexOf("--base-prompt-file") + 1; + if (basePromptIndex > 0) { + rendered.args[basePromptIndex] = + basePromptPath ?? `${workingDirectory}/${rendered.args[basePromptIndex]}`; + } else if (basePromptPath !== null) { + throw new Error(`${aspect}: basePromptPath supplied for worker without a custom base prompt`); + } + assertTrustedPublisherContract( + rendered.args, + aspect, + rendered.args[rendered.args.indexOf("--base-prompt-file") + 1], + ); + // launchd may reject direct execution of provenance-marked development binaries. + // A Fleet-owned system launcher keeps the binary and its digest explicit in argv. + const argv = [...(launcherPath ? [launcherPath] : []), buzzAcpPath, ...rendered.args]; + const worker = manifest.workers.find((item) => item.aspect === aspect); + const stdout = stdoutPath ?? `/Volumes/AEON/Projects/buzz-data/logs/${aspect}.buzz-acp.log`; + const stderr = stderrPath ?? `/Volumes/AEON/Projects/buzz-data/logs/${aspect}.buzz-acp.err.log`; + const argsXml = argv.map((arg) => ` ${xml(arg)}`).join("\n"); + const environment = { + ...additionalEnvironment, + ...(executablePath ? { PATH: executablePath } : {}), + ...(openclawConfigPath ? { OPENCLAW_CONFIG_PATH: openclawConfigPath } : {}), + ...(openclawStateDir ? { OPENCLAW_STATE_DIR: openclawStateDir } : {}), + }; + const environmentEntries = Object.entries(environment) + .map(([key, value]) => `${key}${xml(value)}`) + .join(""); + const environmentXml = environmentEntries + ? `\n EnvironmentVariables\n ${environmentEntries}` + : ""; + return { + aspect, + label: rendered.label, + enabled: false, + runAtLoad: manifest.supervisor.runAtLoad, + keepAlive: manifest.supervisor.restartOnFailure, + argv, + privateKeyFile, + tokenFile, + tokenFileContract: manifest.gateway.tokenFileContract, + expectedPublicKey: worker.pubkey, + rollback: ["launchctl", "bootout", `gui//${rendered.label}`], + plist: ` + + + + Label${xml(rendered.label)} + ProgramArguments + +${argsXml} + + WorkingDirectory${xml(workingDirectory)}${environmentXml} + RunAtLoad<${manifest.supervisor.runAtLoad}/> + KeepAlive<${manifest.supervisor.restartOnFailure}/> + ProcessTypeBackground + StandardOutPath${xml(stdout)} + StandardErrorPath${xml(stderr)} + + +`, + }; +} + +export function correlateReceipt({ triggeringEventIds, replyEvents, sessionKey, runId }) { + if (!Array.isArray(triggeringEventIds) || triggeringEventIds.length !== 1) throw new Error("receipt requires exactly one request event"); + const requestEventId = triggeringEventIds[0]; + const matches = replyEvents.filter((event) => event.replyTo === requestEventId); + if (matches.length !== 1) throw new Error(`receipt requires exactly one anchored reply; found ${matches.length}`); + if (!sessionKey || !runId) throw new Error("receipt requires Gateway session key and run id"); + return { requestEventId, replyEventId: matches[0].eventId, gatewaySessionKey: sessionKey, runId }; +} diff --git a/deploy/local/aeon-aspects/worker.test.mjs b/deploy/local/aeon-aspects/worker.test.mjs new file mode 100644 index 0000000000..441b3e227e --- /dev/null +++ b/deploy/local/aeon-aspects/worker.test.mjs @@ -0,0 +1,479 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import fs from "node:fs"; +import { + assertTrustedPublisherContract, + correlateReceipt, + evaluateSemanticHealth, + loadJson, + renderDisabledLaunchAgent, + renderPrivateOfficePrompt, + renderWorker, + validateManifest, +} from "./worker.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const manifest = loadJson(join(here, "workers.json")); +// Source checks must be runnable by upstream contributors without the private +// AEON vault mount. Operators can still supply an explicit identity-map path. +const identityMap = loadJson(join(here, "fixtures", "identity-map.json")); + +test("six-worker manifest matches the synthetic identity-map contract", () => { + const result = validateManifest(manifest, identityMap); + assert.equal(result.ok, true, result.errors.join("\n")); + assert.equal(manifest.workers.length, 6); + assert.match(result.warnings[0], /avatar metadata is absent/); +}); + +test("every rendered worker is disabled and binds an existing fixed session", () => { + for (const worker of manifest.workers) { + const rendered = renderWorker(manifest, identityMap, worker.aspect, "/owned/gateway.token"); + const argv = rendered.args.join(" "); + assert.equal(rendered.enabled, false); + assert.equal(rendered.supervisor.startOnAppLaunch, true); + assert.equal(rendered.supervisor.runAtLoad, true); + assert.equal(rendered.supervisor.restartOnFailure, true); + assert.match(argv, /--no-memory/); + assert.match(argv, new RegExp(`--base-prompt-file ${worker.basePromptFile}`)); + assert.doesNotMatch(argv, /--no-base-prompt/); + assert.match(argv, /--respond-to owner-only/); + assert.match(argv, /--allowed-respond-to owner-only/); + assert.match(argv, /--agents 1/); + assert.match(argv, /--dedup queue/); + assert.match(argv, /--multiple-event-handling queue/); + assert.match(argv, /--relay-observer/); + assert.match(argv, /--trusted-inbound-envelope/); + assert.match(argv, /--no-agent-publisher-credentials/); + assert.match(argv, /--permission-mode bypass-permissions/); + assert.match(argv, /--heartbeat-interval 0/); + assert.match(argv, /--turn-liveness-secs 10/); + assert.match(argv, /--idle-timeout 900/); + assert.match(argv, /--max-turn-duration 7200/); + assert.match(argv, /--context-message-limit 12/); + assert.match(argv, /--max-turns-per-session 0/); + assert.match(argv, /--turn-receipts/); + assert.doesNotMatch(argv, /--no-presence|--no-typing|--no-ignore-self/); + assert.doesNotMatch(argv, /--mcp-command|--model|--system-prompt|--team-instructions|--initial-message/); + assert.match(argv, new RegExp(`--expected-gateway-session-key ${worker.sessionKey}`)); + assert.match(argv, new RegExp(`--private-key-file ${identityMap.members[worker.aspect].secret_ref}`)); + assert.match(argv, new RegExp(`--expected-public-key ${worker.pubkey}`)); + assert.match(argv, /--agent-args acp,--session,agent:[a-z]+:buzz-private,--require-existing,--token-file,\/owned\/gateway.token,--url,ws:\/\/127.0.0.1:18806,--provenance,meta\+receipt,--no-prefix-cwd/); + } +}); + +test("one shared contract renders exact trusted publisher prompts for all six offices", () => { + const template = fs.readFileSync(join(here, "prompts", "private-office.template.md"), "utf8"); + assert.equal((template.match(/\{\{ROOM\}\}/g) ?? []).length, 1); + assert.equal((template.match(/\{\{REPLY_TOOL\}\}/g) ?? []).length, 1); + assert.equal((template.match(/\{\{/g) ?? []).length, 2); + for (const worker of manifest.workers) { + const rendered = renderWorker(manifest, identityMap, worker.aspect); + const promptPath = join(here, "..", "..", "..", worker.basePromptFile); + const prompt = fs.readFileSync(promptPath, "utf8"); + const tool = `buzz_${worker.aspect}_reply`; + assert.equal(prompt, renderPrivateOfficePrompt(template, worker.aspect)); + assert.match(prompt, new RegExp(`#aspect-${worker.aspect}`)); + assert.match(prompt, new RegExp(`exactly one \`${tool}\``)); + assert.match(prompt, /Plain assistant text is not published to Buzz/); + assert.doesNotMatch(prompt, /buzz messages send/); + assert.match(prompt, /Publisher credentials are intentionally withheld/); + assert.match(prompt, /full existing OpenClaw tool, skill, memory, identity, and session capabilities/); + assert.match(prompt, /does not restrict any other tool use or capability/); + assert.equal((prompt.match(new RegExp(tool, "g")) ?? []).length, 1); + for (const other of manifest.workers) { + if (other.aspect !== worker.aspect) { + assert.doesNotMatch(prompt, new RegExp(`buzz_${other.aspect}_reply`)); + } + } + assert.doesNotMatch(rendered.args.join(" "), /--no-base-prompt/); + } +}); + +test("credential isolation, trusted envelope, receipt, and exact prompt are indivisible", () => { + for (const worker of manifest.workers) { + const rendered = renderWorker(manifest, identityMap, worker.aspect); + assert.doesNotThrow(() => + assertTrustedPublisherContract(rendered.args, worker.aspect, worker.basePromptFile), + ); + for (const required of [ + "--no-agent-publisher-credentials", + "--trusted-inbound-envelope", + "--base-prompt-file", + "--turn-receipts", + ]) { + const broken = rendered.args.filter((value) => value !== required); + assert.throws( + () => assertTrustedPublisherContract(broken, worker.aspect, worker.basePromptFile), + new RegExp(required.replaceAll("-", "\\-")), + ); + } + const wrongPrompt = [...rendered.args]; + wrongPrompt[wrongPrompt.indexOf("--base-prompt-file") + 1] = "/wrong/prompt.md"; + assert.throws( + () => assertTrustedPublisherContract(wrongPrompt, worker.aspect, worker.basePromptFile), + /base prompt drift/, + ); + } +}); + +test("no-argument prompt renderer emits the six checked-in generated contracts", () => { + const output = execFileSync(process.execPath, [join(here, "render-private-office-prompts.mjs")], { + cwd: here, + encoding: "utf8", + }); + const rendered = JSON.parse(output); + assert.equal(rendered.schema, "aeon_private_office_prompts_v1"); + assert.equal(Object.keys(rendered.artifacts).length, 6); + for (const worker of manifest.workers) { + assert.equal( + rendered.artifacts[`${worker.aspect}-private-office.md`], + fs.readFileSync(join(here, "prompts", `${worker.aspect}-private-office.md`), "utf8"), + ); + } +}); + +test("Nexus uses the uniform publisher contract without changing its fixed session", () => { + const rendered = renderWorker(manifest, identityMap, "nexus", "/owned/gateway.token"); + assert.match( + rendered.args.join(" "), + /--base-prompt-file deploy\/local\/aeon-aspects\/prompts\/nexus-private-office\.md/, + ); + assert.match(rendered.args.join(" "), /--session,agent:main:buzz-private,--require-existing/); +}); + +test("six deterministic LaunchAgent previews are disabled and secret-free", () => { + const labels = new Set(); + for (const worker of manifest.workers) { + const first = renderDisabledLaunchAgent(manifest, identityMap, worker.aspect); + const second = renderDisabledLaunchAgent(manifest, identityMap, worker.aspect); + assert.deepEqual(second, first); + assert.equal(first.runAtLoad, true); + assert.equal(first.keepAlive, true); + assert.deepEqual(first.tokenFileContract, { + absolute: true, + regular: true, + symlink: false, + owner: "current-user", + mode: "0600", + }); + assert.match(first.plist, /RunAtLoad<\/key>/); + assert.match(first.plist, /KeepAlive<\/key>/); + assert.match(first.plist, /\/REQUIRES_FLEET\/immutable-openclaw\/bin\/openclaw/); + assert.match(first.plist, /\/REQUIRES_FLEET\/owned-token-file/); + assert.doesNotMatch(first.plist, /nsec1|BUZZ_PRIVATE_KEY=/); + assert.match(first.plist, /--no-agent-publisher-credentials/); + assert.match(first.plist, /--base-prompt-file/); + assert.match(first.plist, new RegExp(`/Volumes/AEON/Projects/buzz/${worker.basePromptFile}`)); + assert.doesNotMatch(first.plist, /--no-base-prompt/); + assert.deepEqual(first.rollback, ["launchctl", "bootout", `gui//${first.label}`]); + labels.add(first.label); + } + assert.equal(labels.size, 6); +}); + +test("no-argument LaunchAgent renderer uses the checked-in identity fixture", () => { + const output = execFileSync(process.execPath, [join(here, "render-launchagents.mjs")], { + cwd: here, + encoding: "utf8", + }); + const rendered = JSON.parse(output); + assert.equal(rendered.schema, "aeon_disabled_launchagents_v1"); + assert.equal(Object.keys(rendered.artifacts).length, 6); + for (const worker of manifest.workers) { + const expected = renderDisabledLaunchAgent(manifest, identityMap, worker.aspect); + assert.equal(rendered.artifacts[`${expected.label}.plist`], expected.plist); + } +}); + +test("LaunchAgent rendering rejects unsafe or relative runtime paths", () => { + assert.throws( + () => renderDisabledLaunchAgent(manifest, identityMap, "nexus", { tokenFile: "relative.token" }), + /must be absolute/, + ); + assert.throws( + () => renderDisabledLaunchAgent(manifest, identityMap, "nexus", { openclawPath: "/bad,command" }), + /forbidden delimiter/, + ); + assert.throws( + () => renderDisabledLaunchAgent(manifest, identityMap, "nexus", { executablePath: "bin:/usr/bin" }), + /entries must be absolute/, + ); + assert.throws( + () => renderDisabledLaunchAgent(manifest, identityMap, "nexus", { launcherPath: "usr/bin/env" }), + /must be absolute/, + ); + assert.throws( + () => renderDisabledLaunchAgent(manifest, identityMap, "nexus", { stdoutPath: "relative.log" }), + /must be absolute/, + ); + assert.throws( + () => renderDisabledLaunchAgent(manifest, identityMap, "viatica", { basePromptPath: "relative.md" }), + /must be absolute/, + ); + assert.throws( + () => renderDisabledLaunchAgent(manifest, identityMap, "nexus", { basePromptPath: "relative.md" }), + /must be absolute/, + ); + assert.throws( + () => renderDisabledLaunchAgent(manifest, identityMap, "nexus", { agentCommandPrefixArgs: ["relative.mjs"] }), + /must be absolute/, + ); + assert.throws( + () => + renderDisabledLaunchAgent(manifest, identityMap, "nexus", { + agentCommandPrefixArgs: ["/immutable/a/openclaw.mjs", "/immutable/b/openclaw.mjs"], + }), + /exactly one/, + ); + assert.throws( + () => renderDisabledLaunchAgent(manifest, identityMap, "nexus", { openclawStateDir: "/state" }), + /must be supplied together/, + ); + assert.throws( + () => renderDisabledLaunchAgent(manifest, identityMap, "nexus", { + openclawConfigPath: "", + openclawStateDir: "/state", + }), + /must be absolute/, + ); +}); + +test("Fleet can bind the immutable Node runtime without changing disabled previews", () => { + const defaultRendered = renderDisabledLaunchAgent(manifest, identityMap, "nexus"); + assert.equal( + defaultRendered.plist, + fs.readFileSync(join(here, "launchagents", "org.aeon.buzz-acp.nexus.plist"), "utf8"), + ); + const rendered = renderDisabledLaunchAgent(manifest, identityMap, "nexus", { + executablePath: "/owned/service-runtime/bin:/usr/bin:/bin", + openclawConfigPath: "/owned/state/openclaw.json", + openclawStateDir: "/owned/state", + }); + assert.match( + rendered.plist, + /PATH<\/key>\/owned\/service-runtime\/bin:\/usr\/bin:\/bin<\/string>/, + ); + assert.match(rendered.plist, /OPENCLAW_CONFIG_PATH<\/key>\/owned\/state\/openclaw.json<\/string>/); + assert.match(rendered.plist, /OPENCLAW_STATE_DIR<\/key>\/owned\/state<\/string>/); +}); + +test("Fleet can parameterize the canonical WSS relay without hardcoding a private host", () => { + const rendered = renderDisabledLaunchAgent(manifest, identityMap, "nexus", { + relayUrl: "wss://buzz.example.test", + }); + assert.equal( + rendered.argv[rendered.argv.indexOf("--relay-url") + 1], + "wss://buzz.example.test", + ); + assert.doesNotMatch(JSON.stringify(manifest), /buzz\.example\.test/); + assert.throws( + () => renderDisabledLaunchAgent(manifest, identityMap, "nexus", { relayUrl: "https://buzz.example.test" }), + /absolute ws/, + ); +}); + +test("Fleet can preserve the live response policy and benign launch environment", () => { + const first = "c5f9e0a85da537e107fdcc60ea7ee7e1c5e2b5ac0691a30b6566b3f043a50455"; + const second = "7924cc1dd5389c567ea4ad2b3013b71df28c7b856247365efcccbc7763bfdb7f"; + const rendered = renderDisabledLaunchAgent(manifest, identityMap, "nexus", { + respondTo: "allowlist", + allowedRespondTo: "owner-only,allowlist", + respondToAllowlist: `${first},${second}`, + additionalEnvironment: { CI: "1", NO_COLOR: "1" }, + }); + assert.equal(rendered.argv[rendered.argv.indexOf("--respond-to") + 1], "allowlist"); + assert.equal( + rendered.argv[rendered.argv.indexOf("--allowed-respond-to") + 1], + "owner-only,allowlist", + ); + assert.equal( + rendered.argv[rendered.argv.indexOf("--respond-to-allowlist") + 1], + `${first},${second}`, + ); + assert.match(rendered.plist, /CI<\/key>1<\/string>/); + assert.match(rendered.plist, /NO_COLOR<\/key>1<\/string>/); +}); + +test("Fleet can use a system launcher while retaining the exact Buzz binary", () => { + const rendered = renderDisabledLaunchAgent(manifest, identityMap, "nexus", { + buzzAcpPath: "/owned/bin/buzz-acp", + launcherPath: "/usr/bin/env", + }); + assert.deepEqual(rendered.argv.slice(0, 2), ["/usr/bin/env", "/owned/bin/buzz-acp"]); + assert.match( + rendered.plist, + /\n \/usr\/bin\/env<\/string>\n \/owned\/bin\/buzz-acp<\/string>/, + ); +}); + +test("Fleet can keep launchd-owned paths local while Buzz reads its canonical config", () => { + const rendered = renderDisabledLaunchAgent(manifest, identityMap, "nexus", { + workingDirectory: "/Users/operator", + privateKeyFile: "/Users/operator/Library/Application Support/AEON/secrets/nexus.sk", + configPath: "/Volumes/AEON/Projects/buzz/deploy/local/aeon-aspects/config/nexus.toml", + stdoutPath: "/Users/operator/Library/Logs/AEON/nexus.buzz-acp.log", + stderrPath: "/Users/operator/Library/Logs/AEON/nexus.buzz-acp.err.log", + }); + assert.equal( + rendered.argv[rendered.argv.indexOf("--config") + 1], + "/Volumes/AEON/Projects/buzz/deploy/local/aeon-aspects/config/nexus.toml", + ); + assert.equal( + rendered.argv[rendered.argv.indexOf("--private-key-file") + 1], + "/Users/operator/Library/Application Support/AEON/secrets/nexus.sk", + ); + assert.match(rendered.plist, /WorkingDirectory<\/key>\/Users\/operator<\/string>/); + assert.match(rendered.plist, /StandardOutPath<\/key>\/Users\/operator\/Library\/Logs\/AEON\/nexus\.buzz-acp\.log<\/string>/); +}); + +test("Fleet can install a trusted private-office prompt outside the working directory", () => { + const rendered = renderDisabledLaunchAgent(manifest, identityMap, "viatica", { + workingDirectory: "/Users/operator", + basePromptPath: "/owned/prompts/viatica-private-office.md", + }); + assert.equal( + rendered.argv[rendered.argv.indexOf("--base-prompt-file") + 1], + "/owned/prompts/viatica-private-office.md", + ); + assert.doesNotMatch(rendered.plist, /--no-base-prompt/); +}); + +test("Fleet can launch immutable OpenClaw through a local Node identity", () => { + const rendered = renderDisabledLaunchAgent(manifest, identityMap, "nexus", { + openclawPath: "/owned/bin/openclaw", + agentCommandPrefixArgs: ["/immutable/generation/openclaw.mjs"], + }); + assert.equal(rendered.argv[rendered.argv.indexOf("--agent-command") + 1], "/owned/bin/openclaw"); + assert.equal( + rendered.argv[rendered.argv.indexOf("--agent-args") + 1], + "/immutable/generation/openclaw.mjs,acp,--session,agent:main:buzz-private,--require-existing,--token-file,/REQUIRES_FLEET/owned-token-file,--url,ws://127.0.0.1:18806,--provenance,meta+receipt,--no-prefix-cwd", + ); +}); + +test("worker restart renders the identical require-existing Gateway binding", () => { + const first = renderWorker(manifest, identityMap, "nexus", "/owned/gateway.token"); + const restarted = renderWorker(manifest, identityMap, "nexus", "/owned/gateway.token"); + assert.deepEqual(restarted, first); + assert.match(first.args.join(" "), /--session,agent:main:buzz-private,--require-existing/); +}); + +test("Nexus activation is not coupled to the legacy aeon-buzz bridge", () => { + const rendered = renderWorker(manifest, identityMap, "nexus"); + const serialized = JSON.stringify(rendered); + assert.doesNotMatch(serialized, /aeon-buzz/); + assert.equal(rendered.sessionKey, "agent:main:buzz-private"); +}); + +test("each room config enforces Architect-only private and huddle rules", () => { + for (const worker of manifest.workers) { + const source = fs.readFileSync(join(here, "config", `${worker.aspect}.toml`), "utf8"); + assert.match(source, new RegExp(worker.privateChannelId)); + assert.equal((source.match(/kinds = \[9, 40002\]/g) ?? []).length, 2); + assert.equal((source.match(/require_exact_channel_tag = true/g) ?? []).length, 2); + assert.match(source, /require_mention = false/); + assert.match(source, /admit_invited_ephemeral = true/); + assert.match(source, /require_mention = true/); + assert.equal((source.match(new RegExp(manifest.buzz.architectPubkey, "g")) ?? []).length, 2); + assert.doesNotMatch(source, new RegExp(manifest.buzz.conciliumChannelId)); + } +}); + +test("receipt correlation joins one request, one anchored reply, session, and run", () => { + assert.deepEqual( + correlateReceipt({ + triggeringEventIds: ["request-1"], + replyEvents: [{ eventId: "reply-1", replyTo: "request-1" }], + sessionKey: "agent:main:buzz-private", + runId: "run-1" + }), + { + requestEventId: "request-1", + replyEventId: "reply-1", + gatewaySessionKey: "agent:main:buzz-private", + runId: "run-1" + } + ); +}); + +test("receipt correlation fails closed on zero or duplicate replies", () => { + const base = { triggeringEventIds: ["request-1"], sessionKey: "session", runId: "run" }; + assert.throws(() => correlateReceipt({ ...base, replyEvents: [] }), /found 0/); + assert.throws( + () => correlateReceipt({ ...base, replyEvents: [ + { eventId: "reply-1", replyTo: "request-1" }, + { eventId: "reply-2", replyTo: "request-1" } + ] }), + /found 2/ + ); +}); + +test("semantic health rejects green-but-mute workers", () => { + const base = { + aspect: "nexus", + sessionKey: "agent:main:buzz-private", + state: "running", + startup: { + agentPoolReady: true, + relayConnected: true, + privateOfficeSubscribed: true, + }, + }; + const mute = evaluateSemanticHealth(base); + assert.equal(mute.healthy, false); + assert.deepEqual(mute.failures, [ + "request_event_missing", + "reply_event_missing", + "gateway_session_mismatch", + "fresh_run_missing", + "trusted_reply_tool_mismatch", + "trusted_reply_tool_count_mismatch", + ]); + + const healthy = evaluateSemanticHealth({ + ...base, + receipt: { + requestEventId: "request-1", + replyEventId: "reply-1", + replyTo: "request-1", + sessionKey: "agent:main:buzz-private", + runId: "run-1", + toolName: "buzz_nexus_reply", + toolCallCount: 1, + }, + }); + assert.deepEqual(healthy, { healthy: true, failures: [] }); +}); + +test("semantic health command fails closed until a functional reply path is proven", () => { + const evidence = { + aspect: "nexus", + sessionKey: "agent:main:buzz-private", + state: "running", + startup: { + agentPoolReady: true, + relayConnected: true, + privateOfficeSubscribed: true, + }, + }; + const inputPath = join( + process.env.TMPDIR ?? "/tmp", + `buzz-semantic-health-${process.pid}-${Date.now()}.json`, + ); + fs.writeFileSync(inputPath, JSON.stringify(evidence)); + try { + assert.throws( + () => execFileSync(process.execPath, [join(here, "semantic-health.mjs"), inputPath]), + (error) => { + const result = JSON.parse(error.stdout.toString()); + assert.equal(result.ok, false); + assert.ok(result.checks[0].failures.includes("request_event_missing")); + return true; + }, + ); + } finally { + fs.rmSync(inputPath, { force: true }); + } +}); diff --git a/deploy/local/aeon-aspects/workers.json b/deploy/local/aeon-aspects/workers.json new file mode 100644 index 0000000000..7bd4f520f4 --- /dev/null +++ b/deploy/local/aeon-aspects/workers.json @@ -0,0 +1,50 @@ +{ + "schema": "aeon_buzz_acp_workers_v1", + "enabled": false, + "identityMap": "/Volumes/AEON/aeon-vault/aeon-v6-workspace/contracts/buzz/identity-map.json", + "gateway": { + "url": "ws://127.0.0.1:18806", + "tokenFileEnv": "AEON_GATEWAY_TOKEN_FILE", + "tokenFileContract": {"absolute":true,"regular":true,"symlink":false,"owner":"current-user","mode":"0600"}, + "provenance": "meta+receipt" + }, + "buzz": { + "relayUrl": "ws://localhost:3000", + "architectPubkey": "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f", + "conciliumChannelId": "4ac1cee0-2238-483f-b25b-f99ec17eec46" + }, + "posture": { + "agents": 1, + "respondTo": "owner-only", + "allowedRespondTo": ["owner-only"], + "memory": false, + "basePrompt": false, + "dedup": "queue", + "multipleEventHandling": "queue", + "presence": true, + "typing": true, + "relayObserver": true, + "trustedInboundEnvelope": true, + "permissionMode": "bypass-permissions", + "heartbeatIntervalSecs": 0, + "turnLivenessSecs": 10, + "idleTimeoutSecs": 900, + "maxTurnDurationSecs": 7200, + "contextMessageLimit": 12, + "maxTurnsPerSession": 0 + }, + "supervisor": { + "startOnAppLaunch": true, + "runAtLoad": true, + "restartOnFailure": true, + "throttleSeconds": 10 + }, + "workers": [ + {"aspect":"nexus","displayName":"Nexus","gatewayAgentId":"main","sessionKey":"agent:main:buzz-private","privateChannelId":"7e8c4840-d401-4701-afc9-7ae7174cfc4e","pubkey":"ca2bce90e858e12f399f7b4cd510b5fce74e4238352229ada47b8bdd1a7f3b97","basePromptFile":"deploy/local/aeon-aspects/prompts/nexus-private-office.md"}, + {"aspect":"mechanon","displayName":"Mechanon","gatewayAgentId":"mechanon","sessionKey":"agent:mechanon:buzz-private","privateChannelId":"7d022bea-5e81-4d18-bd2e-176e9eda1971","pubkey":"44437bd6007641845f154bdb7698b1627745b0a1b1d9bdfcf4826fe82a37ca9d","basePromptFile":"deploy/local/aeon-aspects/prompts/mechanon-private-office.md"}, + {"aspect":"fontis","displayName":"Fontis","gatewayAgentId":"fontis","sessionKey":"agent:fontis:buzz-private","privateChannelId":"600cb602-ff2a-422b-bdb2-6353bb81100d","pubkey":"3672ca65abf6b12effaf57475ce31260a4bc96e6d3acf01b909f9c2a44542147","basePromptFile":"deploy/local/aeon-aspects/prompts/fontis-private-office.md"}, + {"aspect":"sapientis","displayName":"Sapientis","gatewayAgentId":"sapientis","sessionKey":"agent:sapientis:buzz-private","privateChannelId":"4e9f115c-cf8d-4abe-a085-c2b2c64ef2c2","pubkey":"1b7ddc51e6e7d688cd1816047b3ec35e7460014bf146813a505fee851e8b0d89","basePromptFile":"deploy/local/aeon-aspects/prompts/sapientis-private-office.md"}, + {"aspect":"viatica","displayName":"Viatica","gatewayAgentId":"viatica","sessionKey":"agent:viatica:buzz-private","privateChannelId":"39560f3c-f638-41a1-945b-ed5400ec41fc","pubkey":"89a2f2679f83cd9033582f023a3b92e01ed2b5950587efae565045ae65bd03cc","basePromptFile":"deploy/local/aeon-aspects/prompts/viatica-private-office.md"}, + {"aspect":"voxis","displayName":"Voxis","gatewayAgentId":"voxis","sessionKey":"agent:voxis:buzz-private","privateChannelId":"d00b15e9-0dbe-4f35-895e-b6d36faa299e","pubkey":"e2b124c4728989a09d0f7df96cd2b1823635cc220ed9cdd4f97eaedc12a17fe4","basePromptFile":"deploy/local/aeon-aspects/prompts/voxis-private-office.md"} + ] +} diff --git a/desktop/src-tauri/src/archive/mod_tests.rs b/desktop/src-tauri/src/archive/mod_tests.rs index 288d2ab34c..3cd81771dd 100644 --- a/desktop/src-tauri/src/archive/mod_tests.rs +++ b/desktop/src-tauri/src/archive/mod_tests.rs @@ -2,13 +2,13 @@ //! //! Kept in a sibling file so `mod.rs` stays under the 1000-line gate; //! `#[path]`-included from there. - use super::pipeline::BucketWithResult; use super::*; use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; use rusqlite::Connection; use uuid::Uuid; +mod receipt_tests; // ── Helpers ────────────────────────────────────────────────────────────── fn in_memory() -> Connection { diff --git a/desktop/src-tauri/src/archive/receipt_tests.rs b/desktop/src-tauri/src/archive/receipt_tests.rs new file mode 100644 index 0000000000..efd736ea91 --- /dev/null +++ b/desktop/src-tauri/src/archive/receipt_tests.rs @@ -0,0 +1,105 @@ +use super::*; + +/// The archive persistence path retains a closed ACP receipt as the exact +/// signed observer frame after the database is reopened. +#[test] +fn test_closed_turn_receipt_archives_exact_signed_frame() { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let agent_pk = agent_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let request_event_id = "11".repeat(32); + let reply_event_id = "22".repeat(32); + + let conn = store::open_archive_db(&db_path).expect("open archive db"); + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + + let receipt = serde_json::json!({ + "seq": 42, + "timestamp": "2026-07-22T20:25:04Z", + "kind": "turn_receipt", + "agentIndex": 0, + "channelId": "00000000-0000-4000-8000-000000000001", + "sessionId": "00000000-0000-4000-8000-000000000002", + "turnId": "5dc7e9cf-f30f-49bf-b7fc-e424afad2d3f", + "startedAt": "2026-07-22T20:24:00Z", + "payload": { + "status": "closed", + "schemaVersion": 1, + "requestEventId": request_event_id.clone(), + "replyEventId": reply_event_id, + "replyAnchor": request_event_id, + "gatewaySessionKey": "agent:main:buzz-private", + "expectedGatewaySessionKey": "agent:main:buzz-private", + "runId": "00000000-0000-4000-8000-000000000003", + "acpSessionId": "00000000-0000-4000-8000-000000000002", + "turnId": "5dc7e9cf-f30f-49bf-b7fc-e424afad2d3f" + } + }); + let encrypted = buzz_core_pkg::observer::encrypt_observer_payload( + &agent_keys, + &owner_keys.public_key(), + &receipt, + ) + .expect("encrypt receipt"); + let event = buzz_sdk_pkg::build_agent_observer_frame( + &owner_pk, + &agent_pk, + OBSERVER_FRAME_TELEMETRY, + &encrypted, + ) + .expect("build receipt frame") + .sign_with_keys(&agent_keys) + .expect("sign receipt frame"); + let signed_json = event.as_json(); + + let result = run_batch_sync_with_keys( + vec![candidate(&event, ScopeType::OwnerP, &owner_pk)], + &owner_pk, + relay_url, + &conn, + Vec::new(), + &owner_keys, + ); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); + drop(conn); + + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + let (kind, author, raw_json): (i64, String, String) = read_conn + .query_row( + "SELECT kind, pubkey, raw_json FROM archived_events + WHERE identity_pubkey = ?1 AND relay_url = ?2 AND id = ?3", + rusqlite::params![owner_pk, relay_url, event.id.to_hex()], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("read archived receipt"); + assert_eq!(kind, 24200); + assert_eq!(author, agent_pk); + assert_eq!( + raw_json, signed_json, + "archive must retain the signed frame" + ); + let scope_count: i64 = read_conn + .query_row( + "SELECT COUNT(*) FROM archived_event_scopes + WHERE identity_pubkey = ?1 AND relay_url = ?2 AND id = ?3 + AND scope_type = 'owner_p' AND scope_value = ?1", + rusqlite::params![owner_pk, relay_url, event.id.to_hex()], + |row| row.get(0), + ) + .expect("read archived receipt scope"); + assert_eq!(scope_count, 1, "receipt must retain its owner_p scope"); + + let stored_event = Event::from_json(&raw_json).expect("parse archived frame"); + buzz_core_pkg::verify_event(&stored_event).expect("verify archived signature"); + assert_eq!(stored_event.id, event.id); + let decoded: serde_json::Value = + buzz_core_pkg::observer::decrypt_observer_payload(&owner_keys, &stored_event) + .expect("decrypt archived receipt"); + assert_eq!(decoded, receipt); + assert_eq!(decoded["payload"]["status"], "closed"); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 92fa172ff5..8ea55d5a3d 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -59,7 +59,7 @@ import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types" import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel"; import { Button } from "@/shared/ui/button"; -import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; +import { buildChannelMainTimelineEntries } from "@/features/messages/lib/channelMainTimeline"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome"; @@ -67,6 +67,7 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; + export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentPubkeys, @@ -456,15 +457,15 @@ export const ChannelPane = React.memo(function ChannelPane({ return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); }, [activeChannel, messages]); - const mainTimelineEntries = React.useMemo( + const { entries: mainTimelineEntries, flattenReplies } = React.useMemo( () => - buildMainTimelineEntries( + buildChannelMainTimelineEntries( + activeChannel, visibleMessages, - new Set(), threadSummaries, profiles, ), - [profiles, threadSummaries, visibleMessages], + [activeChannel, profiles, threadSummaries, visibleMessages], ); useRenderScopedReactionHydration({ activeChannel, @@ -667,6 +668,7 @@ export const ChannelPane = React.memo(function ChannelPane({ entranceMessageId={entranceMessageId} onEntranceMessageComplete={onEntranceMessageComplete} mainEntries={mainTimelineEntries} + flattenReplies={flattenReplies} threadSummaries={threadSummaries} messages={visibleMessages} firstUnreadMessageId={firstUnreadMessageId} diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 062b0ee40b..a66dca0be1 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -13,12 +13,18 @@ import { isBroadcastReply, normalizeMentionPubkeys, resolveReplyRootId, + shouldFlattenChannelTimeline, } from "@/features/messages/lib/threading"; import { projectChannelWindowMessages, refreshChannelWindowMessages, } from "@/features/messages/lib/projectChannelWindow"; import { reconcileChannelWindowMessages } from "@/features/messages/lib/channelWindowReconciliation"; +import { + fetchFlattenTimelineReplies, + flattenTimelineRootIds, + mergeFlattenTimelineReplies, +} from "@/features/messages/lib/flattenChannelTimeline"; import { mergeMessages, mergeTimelineCacheMessages, @@ -226,6 +232,31 @@ export function useChannelWindowQuery(channel: Channel | null) { }); } +async function withFlattenedTimelineReplies( + channel: Channel, + window: ChannelWindowStore, + messages: RelayEvent[], +): Promise { + if (!shouldFlattenChannelTimeline(channel)) { + return messages; + } + const rootIds = flattenTimelineRootIds(window); + if (rootIds.length === 0) { + return messages; + } + try { + const replies = await fetchFlattenTimelineReplies(channel.id, rootIds); + return mergeFlattenTimelineReplies(messages, replies); + } catch (error) { + console.error( + "Failed to hydrate flattened timeline replies for channel", + channel.id, + error, + ); + return messages; + } +} + export function useChannelMessagesQuery(channel: Channel | null) { const queryClient = useQueryClient(); const queryKey = channelMessagesKey(channel?.id ?? "none"); @@ -245,7 +276,8 @@ export function useChannelMessagesQuery(channel: Channel | null) { emptyChannelWindowStore(); const next = replaceNewestChannelWindow(current, page); queryClient.setQueryData(windowKey, next); - return reconcileChannelWindowMessages(next, previousMessages); + const reconciled = reconcileChannelWindowMessages(next, previousMessages); + return withFlattenedTimelineReplies(channel, next, reconciled); }, staleTime: 5 * 60 * 1_000, gcTime: 60 * 60 * 1_000, @@ -256,6 +288,7 @@ export function useChannelSubscription(channel: Channel | null) { const queryClient = useQueryClient(); const channelId = channel?.id ?? null; const channelType = channel?.channelType ?? null; + const flattenTimeline = shouldFlattenChannelTimeline(channel); const refreshNewestWindow = useEffectEvent(async () => { if (!channelId) return; await refreshChannelWindowMessages(queryClient, channelId); @@ -288,7 +321,9 @@ export function useChannelSubscription(channel: Channel | null) { (current = []) => mergeMessages(current, event), ); } - if (!isBroadcastReply(event.tags)) return; + // Private/DM flatten: keep NIP-10 tags, but still admit the event into + // the live window overlay so it renders as a normal timeline row. + if (!isBroadcastReply(event.tags) && !flattenTimeline) return; } if (!isTimelineRow && !CHANNEL_AUX_KINDS.has(event.kind)) return; if (!isTimelineRow) { diff --git a/desktop/src/features/messages/lib/channelMainTimeline.ts b/desktop/src/features/messages/lib/channelMainTimeline.ts new file mode 100644 index 0000000000..9483c99018 --- /dev/null +++ b/desktop/src/features/messages/lib/channelMainTimeline.ts @@ -0,0 +1,31 @@ +import type { Channel } from "@/shared/api/types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; +import { + buildMainTimelineEntries, + type MainTimelineEntry, +} from "@/features/messages/lib/threadPanel"; +import { shouldFlattenChannelTimeline } from "@/features/messages/lib/threading"; +import type { TimelineMessage } from "@/features/messages/types"; + +/** Main-timeline entries with private/DM reply flatten applied when appropriate. */ +export function buildChannelMainTimelineEntries( + channel: Pick | null | undefined, + messages: TimelineMessage[], + threadSummaries: ReadonlyMap, + profiles?: UserProfileLookup, +): { entries: MainTimelineEntry[]; flattenReplies: boolean } { + const flattenReplies = shouldFlattenChannelTimeline(channel); + return { + flattenReplies, + entries: buildMainTimelineEntries( + messages, + new Set(), + threadSummaries, + profiles, + { + flattenReplies, + }, + ), + }; +} diff --git a/desktop/src/features/messages/lib/flattenChannelTimeline.test.mjs b/desktop/src/features/messages/lib/flattenChannelTimeline.test.mjs new file mode 100644 index 0000000000..4f7f37051e --- /dev/null +++ b/desktop/src/features/messages/lib/flattenChannelTimeline.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + mergeFlattenTimelineReplies, + flattenTimelineRootIds, +} from "./flattenChannelTimeline.ts"; +import { shouldFlattenChannelTimeline } from "./threading.ts"; + +test("shouldFlattenChannelTimeline is true for private rooms and DMs only", () => { + assert.equal( + shouldFlattenChannelTimeline({ + channelType: "stream", + visibility: "private", + }), + true, + ); + assert.equal( + shouldFlattenChannelTimeline({ channelType: "dm", visibility: "private" }), + true, + ); + assert.equal( + shouldFlattenChannelTimeline({ channelType: "stream", visibility: "open" }), + false, + ); + assert.equal(shouldFlattenChannelTimeline(null), false); +}); + +test("mergeFlattenTimelineReplies preserves reply tags and dedupes by id", () => { + const root = { + id: "root", + pubkey: "a", + created_at: 1, + kind: 9, + tags: [["h", "channel"]], + content: "root", + sig: "", + }; + const reply = { + id: "reply", + pubkey: "b", + created_at: 2, + kind: 9, + tags: [ + ["h", "channel"], + ["e", "root", "", "reply"], + ], + content: "reply", + sig: "", + }; + + const merged = mergeFlattenTimelineReplies([root], [reply, reply]); + assert.deepEqual( + merged.map((event) => event.id), + ["root", "reply"], + ); + assert.deepEqual(merged[1].tags, reply.tags); +}); + +test("flattenTimelineRootIds reads page and live summary roots", () => { + const store = { + pages: [ + { + startCursor: null, + rows: [ + { + event: { + id: "root-a", + pubkey: "a", + created_at: 1, + kind: 9, + tags: [], + content: "", + sig: "", + }, + thread: { + replyCount: 1, + descendantCount: 1, + lastReplyAt: 2, + participantPubkeys: ["b"], + }, + }, + ], + aux: [], + hasMore: false, + nextCursor: null, + }, + ], + liveOverlay: [], + liveAux: [], + liveSummaries: { + "root-b": { + summary: { + replyCount: 2, + descendantCount: 2, + lastReplyAt: 3, + participantPubkeys: ["c"], + }, + createdAt: 3, + }, + }, + }; + + assert.deepEqual(flattenTimelineRootIds(store).sort(), ["root-a", "root-b"]); +}); diff --git a/desktop/src/features/messages/lib/flattenChannelTimeline.ts b/desktop/src/features/messages/lib/flattenChannelTimeline.ts new file mode 100644 index 0000000000..45d5bfa0dd --- /dev/null +++ b/desktop/src/features/messages/lib/flattenChannelTimeline.ts @@ -0,0 +1,62 @@ +import type { RelayEvent, ThreadCursor } from "@/shared/api/types"; +import { getThreadReplies } from "@/shared/api/tauri"; +import { + channelWindowThreadSummaries, + type ChannelWindowStore, +} from "@/features/messages/lib/channelWindowStore"; +import { isTimelineContentEvent } from "@/features/messages/lib/formatTimelineMessages"; +import { + dedupeMessagesById, + sortMessages, +} from "@/features/messages/lib/messageQueryKeys"; + +const THREAD_PAGE_LIMIT = 200; +const MAX_THREAD_PAGES = 50; +const MAX_FLATTEN_ROOTS = 40; + +/** + * Fetch reply bodies for roots that the channel window only surfaces as + * kind:39005 summaries. Used so private/DM timelines can render those replies + * inline without requiring `["broadcast","1"]` on the wire. + */ +export async function fetchFlattenTimelineReplies( + channelId: string, + rootIds: readonly string[], +): Promise { + const replies: RelayEvent[] = []; + const cappedRoots = rootIds.slice(0, MAX_FLATTEN_ROOTS); + for (const rootId of cappedRoots) { + let cursor: ThreadCursor | null = null; + for (let page = 0; page < MAX_THREAD_PAGES; page += 1) { + const response = await getThreadReplies(rootId, channelId, { + limit: THREAD_PAGE_LIMIT, + cursor, + }); + for (const event of response.events) { + if (isTimelineContentEvent(event)) { + replies.push(event); + } + } + if (!response.nextCursor) break; + cursor = response.nextCursor; + } + } + return replies; +} + +/** Roots that have a relay thread summary and therefore hidden reply bodies. */ +export function flattenTimelineRootIds(store: ChannelWindowStore): string[] { + return [...channelWindowThreadSummaries(store).keys()]; +} + +/** + * Merge hydrated reply events into the reconciled message list. Reply tags are + * preserved; callers decide whether to render them via flattenReplies. + */ +export function mergeFlattenTimelineReplies( + messages: RelayEvent[], + replies: RelayEvent[], +): RelayEvent[] { + if (replies.length === 0) return messages; + return sortMessages(dedupeMessagesById([...messages, ...replies])); +} diff --git a/desktop/src/features/messages/lib/pageOlderMessages.ts b/desktop/src/features/messages/lib/pageOlderMessages.ts index af51a19348..3318465390 100644 --- a/desktop/src/features/messages/lib/pageOlderMessages.ts +++ b/desktop/src/features/messages/lib/pageOlderMessages.ts @@ -6,8 +6,18 @@ import { } from "@/features/messages/lib/channelWindowStore"; import { projectChannelWindowMessages } from "@/features/messages/lib/projectChannelWindow"; import { parseChannelWindowResponse } from "@/features/messages/lib/channelWindowResponse"; -import { channelWindowKey } from "@/features/messages/lib/messageQueryKeys"; +import { + channelMessagesKey, + channelWindowKey, +} from "@/features/messages/lib/messageQueryKeys"; +import { + fetchFlattenTimelineReplies, + flattenTimelineRootIds, + mergeFlattenTimelineReplies, +} from "@/features/messages/lib/flattenChannelTimeline"; +import { shouldFlattenChannelTimeline } from "@/features/messages/lib/threading"; import { getChannelWindowEvents } from "@/shared/api/channelWindow"; +import type { Channel, RelayEvent } from "@/shared/api/types"; const CHANNEL_WINDOW_PAGE_SIZE = 50; export type PageOlderResult = { hasOlderMessages: boolean }; @@ -18,20 +28,50 @@ export function pageOlderMessagesUntilRowFloor( queryClient: QueryClient, channelId: string, shouldContinue: () => boolean, + channel?: Channel | null, ): Promise { const running = inFlightPasses.get(channelId); if (running) return running; - const pass = runPage(queryClient, channelId, shouldContinue).finally(() => { - inFlightPasses.delete(channelId); - }); + const pass = runPage(queryClient, channelId, shouldContinue, channel).finally( + () => { + inFlightPasses.delete(channelId); + }, + ); inFlightPasses.set(channelId, pass); return pass; } +async function hydrateFlattenedOlderReplies( + queryClient: QueryClient, + channel: Channel, + store: ChannelWindowStore, + knownRootIds: ReadonlySet, +) { + const rootIds = flattenTimelineRootIds(store).filter( + (rootId) => !knownRootIds.has(rootId), + ); + if (rootIds.length === 0) return; + try { + const replies = await fetchFlattenTimelineReplies(channel.id, rootIds); + if (replies.length === 0) return; + queryClient.setQueryData( + channelMessagesKey(channel.id), + (messages = []) => mergeFlattenTimelineReplies(messages, replies), + ); + } catch (error) { + console.error( + "Failed to hydrate flattened older timeline replies for channel", + channel.id, + error, + ); + } +} + async function runPage( queryClient: QueryClient, channelId: string, shouldContinue: () => boolean, + channel?: Channel | null, ): Promise { const store = queryClient.getQueryData( channelWindowKey(channelId), @@ -41,6 +81,7 @@ async function runPage( return { hasOlderMessages: false }; } + const knownRootIds = new Set(flattenTimelineRootIds(store)); const requestCursor = tail.nextCursor; const events = await getChannelWindowEvents( channelId, @@ -56,5 +97,13 @@ async function runPage( const next = appendOlderChannelWindow(retained, page); queryClient.setQueryData(channelWindowKey(channelId), next); projectChannelWindowMessages(queryClient, channelId); + if (shouldFlattenChannelTimeline(channel ?? null) && channel) { + await hydrateFlattenedOlderReplies( + queryClient, + channel, + next, + knownRootIds, + ); + } return { hasOlderMessages: page.hasMore }; } diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs index 8981d36380..04434d7387 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -67,6 +67,49 @@ test("buildMainTimelineEntries includes broadcast replies", () => { ); }); +test("buildMainTimelineEntries flattenReplies renders reply-tagged events inline without summaries", () => { + const root = message({ id: "root", createdAt: 1 }); + const reply = message({ + id: "reply", + createdAt: 2, + parentId: "root", + rootId: "root", + depth: 1, + tags: [["e", "root", "", "reply"]], + }); + const summaries = new Map([ + [ + "root", + { + replyCount: 1, + descendantCount: 1, + lastReplyAt: 2, + participantPubkeys: ["author"], + }, + ], + ]); + + const entries = buildMainTimelineEntries( + [root, reply], + new Set(), + summaries, + undefined, + { flattenReplies: true }, + ); + + assert.deepEqual( + entries.map((entry) => [ + entry.message.id, + entry.message.depth, + entry.summary, + ]), + [ + ["root", 0, null], + ["reply", 0, null], + ], + ); +}); + test("buildMainTimelineEntries keeps huddle thread replies out of the parent timeline summary", () => { const huddleRoot = message({ id: "huddle-root", diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index ebc1b35ae7..5e1f286a39 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -428,12 +428,23 @@ function mergeThreadSummaries( }; } +export type BuildMainTimelineEntriesOptions = { + /** + * When true, reply-tagged events render as ordinary timeline rows and + * thread-summary badges are suppressed. Presentation only — does not alter + * event tags. See `shouldFlattenChannelTimeline`. + */ + flattenReplies?: boolean; +}; + export function buildMainTimelineEntries( messages: TimelineMessage[], unreadReplyIds: ReadonlySet = new Set(), relaySummaries: ReadonlyMap = new Map(), profiles?: UserProfileLookup, + options?: BuildMainTimelineEntriesOptions, ): MainTimelineEntry[] { + const flattenReplies = options?.flattenReplies === true; const { descendantStatsByMessageId } = buildThreadPanelIndex( messages, unreadReplyIds, @@ -442,24 +453,32 @@ export function buildMainTimelineEntries( return messages .filter( (message) => - message.parentId == null || isBroadcastReply(message.tags ?? []), + flattenReplies || + message.parentId == null || + isBroadcastReply(message.tags ?? []), ) .map((message) => { - const relaySummary = relaySummaries.get(message.id); + // Flattened private/DM replies keep NIP-10 tags but render as depth-0 + // timeline posts (no thread indent / "1 reply" badge). + const displayMessage = + flattenReplies && message.parentId != null + ? { ...message, depth: 0 } + : message; + if (flattenReplies || displayMessage.kind === KIND_HUDDLE_STARTED) { + return { message: displayMessage, summary: null }; + } + const relaySummary = relaySummaries.get(displayMessage.id); return { - message, - summary: - message.kind === KIND_HUDDLE_STARTED - ? null - : mergeThreadSummaries( - buildSummaryForDirectReplies( - message.id, - descendantStatsByMessageId, - ), - relaySummary - ? buildRelayThreadSummary(message.id, relaySummary, profiles) - : null, - ), + message: displayMessage, + summary: mergeThreadSummaries( + buildSummaryForDirectReplies( + displayMessage.id, + descendantStatsByMessageId, + ), + relaySummary + ? buildRelayThreadSummary(displayMessage.id, relaySummary, profiles) + : null, + ), }; }); } diff --git a/desktop/src/features/messages/lib/threading.ts b/desktop/src/features/messages/lib/threading.ts index 95694f4989..96fb06af06 100644 --- a/desktop/src/features/messages/lib/threading.ts +++ b/desktop/src/features/messages/lib/threading.ts @@ -1,4 +1,4 @@ -import type { RelayEvent } from "@/shared/api/types"; +import type { Channel, RelayEvent } from "@/shared/api/types"; export type ThreadReference = { parentId: string | null; @@ -22,6 +22,22 @@ export function isThreadReply(tags: string[][]): boolean { return ref.parentId !== null && !isBroadcastReply(tags); } +/** + * Private rooms and DMs render reply-tagged events inline on the channel + * timeline (no "N replies" summary collapse). Wire tags are unchanged — + * NIP-10 reply/root markers stay on the event for ACP turn receipts. + * + * Covers AEON Aspect private offices (e.g. Nexus `#aspect-nexus` / + * `7e8c4840-d401-4701-afc9-7ae7174cfc4e`) where agent replies should read as + * ordinary chat posts. + */ +export function shouldFlattenChannelTimeline( + channel: Pick | null | undefined, +): boolean { + if (!channel) return false; + return channel.visibility === "private" || channel.channelType === "dm"; +} + export function getThreadReference(tags: string[][]): ThreadReference { const eventTags = getEventTags(tags); diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index cc5fb1e3dc..063c4c2dbf 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -44,6 +44,11 @@ type MessageTimelineProps = { huddleMemberPubkeysPending?: boolean; messages: TimelineMessage[]; mainEntries?: MainTimelineEntry[]; + /** + * When true, deferred entry rebuilds keep reply-tagged events inline (private + * rooms / DMs). Ignored when `mainEntries` is provided. + */ + flattenReplies?: boolean; /** Relay thread summaries (root id → summary) for the deferred-pass entry * fallback, so badge rows survive while a scrollback page commits. */ threadSummaries?: ReadonlyMap; @@ -152,6 +157,7 @@ const MessageTimelineBase = React.forwardRef< directMessageIntro = null, messages, mainEntries, + flattenReplies = false, threadSummaries, isLoading = false, entranceMessageId = null, @@ -626,6 +632,7 @@ const MessageTimelineBase = React.forwardRef< onEntranceMessageComplete={onEntranceMessageComplete} messageFooters={messageFooters} mainEntries={renderedMessages === messages ? mainEntries : undefined} + flattenReplies={flattenReplies} leadingContent={virtualizedLeadingContent} historyExhausted={renderedHistoryExhausted} threadSummaries={threadSummaries} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 3c35cb6d43..64f1263f02 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -70,6 +70,11 @@ type TimelineMessageListProps = { /** Hoisted main-timeline entries (computed once in ChannelPane). Falls back * to deriving them here when omitted (e.g. the deferred-render pass). */ mainEntries?: MainTimelineEntry[]; + /** + * Private/DM presentation: render reply-tagged events inline when rebuilding + * entries without `mainEntries`. Wire tags stay untouched. + */ + flattenReplies?: boolean; /** Relay thread summaries keyed by thread root id. Keeps badge rows alive on * the deferred-render fallback — replies usually are not local timeline * rows, so without the relay map every summary row unmounts mid-scrollback. */ @@ -138,6 +143,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onEntranceMessageComplete, messageFooters, mainEntries, + flattenReplies = false, threadSummaries, messages, onDelete, @@ -167,8 +173,10 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ const entries = React.useMemo( () => mainEntries ?? - buildMainTimelineEntries(messages, undefined, threadSummaries, profiles), - [mainEntries, messages, profiles, threadSummaries], + buildMainTimelineEntries(messages, undefined, threadSummaries, profiles, { + flattenReplies, + }), + [flattenReplies, mainEntries, messages, profiles, threadSummaries], ); const reviewCommentsByRootId = React.useMemo( () => diff --git a/desktop/src/features/messages/useFetchOlderMessages.ts b/desktop/src/features/messages/useFetchOlderMessages.ts index 2c602b88dc..0a4888a06b 100644 --- a/desktop/src/features/messages/useFetchOlderMessages.ts +++ b/desktop/src/features/messages/useFetchOlderMessages.ts @@ -68,6 +68,7 @@ export function useFetchOlderMessages(channel: Channel | null) { queryClient, channelId, () => channelId === channel?.id, + channel, ); } catch (error) { console.error("Failed to fetch older messages", channelId, error); @@ -75,7 +76,7 @@ export function useFetchOlderMessages(channel: Channel | null) { isFetchingOlderRef.current = false; setIsFetchingOlder(false); } - }, [channel?.id, channelId, queryClient]); + }, [channel, channelId, queryClient]); return { fetchOlder, isFetchingOlder, hasOlderMessages, historyExhausted }; }