diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 0d87bca028c..b9a8f2d2494 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -13,15 +13,66 @@ use tokio::io::AsyncWriteExt; use tokio::process::{Child, ChildStdin, ChildStdout}; use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError}; -use crate::observer::{ObserverContext, ObserverHandle}; +use nostr::{EventBuilder, Keys, Kind, PublicKey, Tag}; +use uuid::Uuid; + +use crate::config::{PermissionMode, PermissionPolicy, ResolvedPermissionConfig}; +use crate::observer::{AuthorizationEnvelope, ObserverContext, ObserverEvent, ObserverHandle}; +use crate::relay::RelayEventPublisher; use crate::usage::{ PromptResponseUsage, StandardAdapterKind, StandardUsageTracker, TurnUsage, UsageTracker, }; +use buzz_core::observer::OBSERVER_MAX_PLAINTEXT_LEN; /// 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 +/// Maximum number of `session/request_permission` requests that may be +/// simultaneously pending under the `ask` policy. New requests beyond this +/// cap are denied immediately (fail closed) so the map remains bounded. +pub const PERMISSION_MAP_CAP: usize = 8; + +/// Maximum number of options in a single `session/request_permission` request. +/// Requests with more options are denied immediately (admission preflight). +const PERMISSION_OPTIONS_MAX: usize = 16; + +/// Per-request timeout under the `ask` policy. The desktop has at most this +/// long to deliver a `permission_decision` control frame before the harness +/// fails closed with the denial response. +const PERMISSION_ASK_TIMEOUT_SECS: u64 = 300; + +/// Maximum time to wait for a relay `OK` after publishing the kind-9 sentinel +/// card. If the relay does not acknowledge within this window the request is +/// denied immediately (fail closed). The publish deadline is +/// `min(now + SENTINEL_PUBLISH_TIMEOUT_SECS, expiresAt)`. +pub(crate) const SENTINEL_PUBLISH_TIMEOUT_SECS: u64 = 10; + +/// Delay between resolved kind-40003 edit retransmission attempts. +/// +/// The permission decision is already irreversible by the time the resolved +/// edit publishes, so the edit must reach the relay to retire the UI card. +/// While the relay is disconnected an acked publish resolves as `Uncertain` +/// immediately; this backoff paces retransmission of the same signed event +/// across a reconnect instead of busy-looping. Bounded overall by the card's +/// expiry. +const RESOLVED_RETRANSMIT_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2); + +/// Delivery window for the resolved-edit retransmit loop, measured from the +/// instant the decision is resolved (ACP response written). Independent of the +/// original click/card deadline: an ordinary timeout resolves at expiry (when +/// the card deadline is already past), so using the click deadline as the retry +/// bound means the loop exits before the first attempt. +/// +/// Aligned with the relay's 300 s card-maximum and the per-request +/// `PERMISSION_ASK_TIMEOUT_SECS` admission window. Using 300 s here means +/// the retransmit task can span the full TLS reconnect ladder (typically +/// ≤60 s) plus any additional relay backpressure, ensuring the resolved edit +/// always reaches the relay before the card naturally expires. The first +/// publish attempt is unconditional (deadline is future at spawn time); only +/// retries consult this bound. +const RESOLVED_DELIVERY_WINDOW_SECS: u64 = 300; + /// An MCP server configuration passed to `session/new`. /// /// Corresponds to the `McpServerStdio` variant in the ACP schema. @@ -108,6 +159,16 @@ pub enum AcpError { #[error("Agent reported error (code {code}): {message}")] AgentError { code: i64, message: String }, + + /// A permission response write was interrupted mid-flight by a cancel. + /// + /// The process may have received the response bytes but may not have acted + /// on them — state is irrecoverably uncertain. The agent process MUST be + /// replaced (not returned to the pool) after this error. The cancel path + /// surfaces this through `cancel_with_cleanup_grace` so + /// `classify_control_cancel_failure` in `pool.rs` triggers respawn. + #[error("Permission response write was interrupted — process state uncertain")] + PermissionPoisoned, } /// Build an [`AcpError::AgentError`] from a JSON-RPC error object, @@ -134,6 +195,75 @@ fn build_initialize_params() -> serde_json::Value { }) } +/// A decision delivered by the desktop via a `permission_decision` control frame. +#[derive(Debug, Clone)] +pub struct PermissionDecision { + /// The nonce that was advertised in the `authorization` envelope of the + /// `acp_read` frame for this request. + pub request_nonce: String, + /// The `optionId` the owner chose. Must exactly match one of the options in + /// the original request. + pub option_id: String, +} + +/// Lifecycle state of a single `session/request_permission` request under +/// the `ask` policy. +#[derive(Debug, Clone)] +enum PermissionEntryState { + /// Kind-9 sentinel published; waiting for relay `OK accepted=true`. + /// An authorized early decision arriving in this state is buffered in + /// `PermissionEntry::early_decision` and applied on admission. + Publishing, + /// Relay confirmed the sentinel (`OK accepted=true`). Waiting for an + /// owner decision via the `permission_decision` control channel. + Pending, + /// A decision arrived; we are in the process of writing the response. + /// Cancel during this state → `PermissionPoisoned`. + Writing, +} + +/// Per-request state tracked in `AcpClient::pending_permissions` under `ask`. +/// +/// Entries are **removed** from the map on every terminal transition +/// (applied/timed_out/cancelled). The absence of a nonce from the map is the +/// replay guard — no `Resolved` tombstone is kept, so capacity measures only +/// live (Publishing, Pending, or Writing) requests. +#[derive(Debug)] +struct PermissionEntry { + /// Nonce bound to this request — must match the desktop's decision. + nonce: String, + /// The exact options snapshot from the original request. + options_snapshot: Vec, + /// The two validated card actions (allow_once / reject_once) surfaced for + /// this request. The read loop accepts an owner decision only when its + /// `optionId` matches one of these two — never a forbidden option (e.g. + /// `allow_always`) that the adapter offered but the card never showed. + card_actions: CardActions, + /// Current lifecycle state. + state: PermissionEntryState, + /// Per-request hard deadline: `min(registered_at + 300s, turn hard deadline)`. + /// Expiry → fail closed (denial + `timed_out` outcome). + deadline: tokio::time::Instant, + /// Unix timestamp of `expiresAt` included in both the pending and resolved + /// sentinel payloads. Stored once at build time so the resolved edit reuses + /// the exact same value (no recompute drift). + expiry_unix_secs: u64, + /// Event ID of the kind-9 sentinel card published into the thread. + /// `None` while still in `Publishing` state (set on `Accepted`). + /// The kind-40003 edit is skipped when this is `None`. + sentinel_event_id: Option, + /// An authorized decision that arrived while the entry was still in + /// `Publishing` state. Applied immediately on `Accepted`; discarded on + /// any non-accepted outcome (entry is denied instead). + early_decision: Option, + /// Human-readable description of the requested operation, extracted from + /// the ACP `session/request_permission` message via `description_from_request_permission`. + /// Tries `params.title`, `params.subject.toolCall.title`, `params.toolCall.title`, + /// `params.toolCall.rawInput.command`, and `params._meta.codex.params.reason` in order. + /// Truncated to `SENTINEL_STRING_MAX_BYTES`. `None` when no path yields a non-empty string. + description: Option, +} + /// ACP client that owns an agent subprocess and communicates over its stdio. /// /// One `AcpClient` per agent process. Multiple sessions can be created on the @@ -155,11 +285,75 @@ pub struct AcpClient { /// permits both numeric and string IDs from the agent. /// Used by [`cancel_with_cleanup`](AcpClient::cancel_with_cleanup) to send /// a `cancelled` outcome before the agent returns from `session/prompt`. + /// + /// Under `reject` and `allow` policies only one request can be in-flight + /// (synchronous handling), so a single Option suffices. + /// Under `ask` the full map is `pending_permissions` below. pending_permission_id: Option, /// Whether we have already sent a response to the pending permission request. - /// Guards against double-response if a timeout fires after the allow_once + /// Guards against double-response if a timeout fires after the rejection /// response was written but before `pending_permission_id` was cleared. permission_responded: bool, + /// Pending `session/request_permission` entries under the `ask` policy. + /// + /// Keyed by request id (as JSON Value). Bounded at `PERMISSION_MAP_CAP`. + /// Entries transition: `Pending → Writing`. On any terminal outcome + /// (applied/timed_out/cancelled) the entry is **removed** — the absence of + /// a nonce is the replay guard. Capacity is live count only (no tombstones). + /// Cleared at turn end as a safety net. + pending_permissions: std::collections::HashMap, + /// Whether this process is poisoned due to a cancel-during-write. + /// + /// When `true` the process MUST NOT be returned to the pool — it must be + /// respawned. The cancel path surfaces this via `PermissionPoisoned`. + permission_poisoned: bool, + /// Resolved permission configuration. Determines how `handle_permission_request` + /// answers ACP `session/request_permission` frames. + permission_config: ResolvedPermissionConfig, + /// Whether an agent owner pubkey was resolved at startup. + /// + /// Used by the `ask` availability gate: `ask` without a known owner downgrades + /// to `reject` (the desktop needs an owner to route the permission card to). + owner_pubkey_known: bool, + /// Channel for delivering `permission_decision` control frames from the + /// observer dispatch loop into the read loop's decision arm. + /// Installed by `install_permission_decision_rx`; consumed by the read loop. + permission_decision_rx: Option>, + /// Publisher for kind-9 sentinel cards and kind-40003 edits. + /// Set via `set_relay_publisher`. When `None`, sentinel publishing is skipped + /// (permission flow continues without a UI card). + relay_publisher: Option, + /// Agent signing keys for building sentinel Nostr events. + /// Set via `set_agent_relay_keys`. Must be set alongside `relay_publisher`. + agent_relay_keys: Option, + /// Agent owner pubkey (hex). p-tagged on the kind-9 sentinel so the + /// desktop routes the card to the correct viewer. Set via `set_agent_owner_pubkey_hex`. + agent_owner_pubkey_hex: Option, + /// Pubkey of the first event in the current turn's batch. + /// Used by the D7-final admission check: `ask` only proceeds for turns + /// initiated by the agent owner. Set per-turn by `set_turn_initiator_pubkey`. + turn_initiator_pubkey: Option, + /// Channel UUID for the `h` tag on the kind-9 sentinel. + /// Set per-turn by `set_turn_channel_context`. + sentinel_channel_id: Option, + /// Event ID of the triggering turn event for the kind-9 sentinel reply tag. + /// Set per-turn by `set_turn_channel_context`. + sentinel_thread_reply_id: Option, + /// In-flight ACK receiver for the currently-publishing sentinel. + /// + /// Set by `handle_permission_request` when a kind-9 is sent via + /// `register_publish_ack`. The read loop's select! arm polls this until + /// the relay responds or the publish deadline fires. At most one entry can + /// be in `Publishing` state at a time — the admission preflight denies a + /// new Ask request while a publish is in flight, so this single slot is + /// never overwritten with an unacknowledged receiver still live. + /// + /// A background task awaits the `oneshot::Receiver` and forwards + /// the `(entry_id, outcome)` pair here via mpsc, decoupling the borrow from + /// the read loop's `self` reference. The relay background task owns deadline + /// enforcement — it sweeps expired waiters with `Uncertain`, so `ack_rx` + /// always resolves before the deadline without any caller-side timeout. + sentinel_ack_result_rx: Option>, /// The JSON-RPC id of the most recently sent `session/prompt` request. /// Used by [`cancel_with_cleanup`] to drain the correct response. /// Set in [`session_prompt_with_idle_timeout`]; consumed in [`cancel_with_cleanup`]. @@ -210,6 +404,11 @@ pub struct AcpClient { steer_rx: Option>, /// Usage tracker for goose/buzz-agent's cumulative notification format. goose_usage: UsageTracker, + /// Test-only: count every write attempt (before the actual I/O). Incremented + /// at the top of `write_ndjson_inner` so callers can assert "exactly N attempts" + /// independently of whether the writes succeeded. + #[cfg(test)] + write_attempt_count: Option>, /// Per-turn prompt-response usage and Claude's optional cumulative cost. standard_usage: StandardUsageTracker, /// Known adapter identity for prompt-response usage mapping. @@ -552,6 +751,23 @@ impl AcpClient { next_id: 0, pending_permission_id: None, permission_responded: false, + pending_permissions: std::collections::HashMap::new(), + permission_poisoned: false, + permission_config: ResolvedPermissionConfig { + policy: crate::config::PermissionPolicy::Reject, + effective_mode: PermissionMode::DontAsk, + mode_source: crate::config::ModeSource::Derived, + transmit_mode: true, + }, + owner_pubkey_known: false, + permission_decision_rx: None, + relay_publisher: None, + agent_relay_keys: None, + agent_owner_pubkey_hex: None, + turn_initiator_pubkey: None, + sentinel_channel_id: None, + sentinel_thread_reply_id: None, + sentinel_ack_result_rx: None, last_prompt_id: None, current_hard_deadline: None, observer: None, @@ -561,6 +777,8 @@ impl AcpClient { steering_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), + #[cfg(test)] + write_attempt_count: None, standard_usage: StandardUsageTracker::default(), standard_adapter, }) @@ -572,11 +790,86 @@ impl AcpClient { self.observer_agent_index = Some(agent_index); } + /// Set the resolved permission configuration for this agent process. + /// + /// Called once after spawn (like `set_observer`) by `pool_lifecycle`. + pub fn set_permission_config(&mut self, config: ResolvedPermissionConfig) { + self.permission_config = config; + } + + /// Record whether the agent owner pubkey is known at startup. + /// + /// The `ask` availability gate downgrades to `reject` when the owner is + /// unknown — the desktop needs an owner to route the permission card. + pub fn set_owner_pubkey_known(&mut self, known: bool) { + self.owner_pubkey_known = known; + } + + /// Install the per-session `permission_decision` receiver. + /// + /// The matching `Sender` is held by `handle_observer_control` in `lib.rs` + /// and delivers `permission_decision` control frames into the read loop's + /// decision arm. Idempotent — replaces any previously installed receiver. + pub fn install_permission_decision_rx( + &mut self, + rx: tokio::sync::mpsc::Receiver, + ) { + self.permission_decision_rx = Some(rx); + } + + /// Install the relay publisher and agent signing keys for sentinel card publishing. + /// + /// Both must be set together. When either is absent, sentinel publishing is + /// skipped; the permission flow continues without a UI card. + pub fn set_relay_publisher(&mut self, publisher: RelayEventPublisher, keys: Keys) { + self.relay_publisher = Some(publisher); + self.agent_relay_keys = Some(keys); + } + + /// Set the agent owner pubkey hex for the sentinel p-tag. + pub fn set_agent_owner_pubkey_hex(&mut self, hex: Option) { + self.agent_owner_pubkey_hex = hex; + } + + /// Set the turn initiator pubkey for the D7-final admission check. + /// + /// Must be called at the start of each turn (before `session_prompt_with_idle_timeout`). + /// The `ask` policy rejects requests for turns NOT initiated by the agent owner. + pub fn set_turn_initiator_pubkey(&mut self, pubkey: Option) { + self.turn_initiator_pubkey = pubkey; + } + + /// Set the per-turn channel context for sentinel card routing. + /// + /// `channel_id` — the `h` tag on the kind-9. + /// `thread_reply_event_id` — the `e` reply tag (triggering turn event). + pub fn set_turn_channel_context( + &mut self, + channel_id: Option, + thread_reply_event_id: Option, + ) { + self.sentinel_channel_id = channel_id; + self.sentinel_thread_reply_id = thread_reply_event_id; + } + /// Update metadata that will be attached to subsequent raw wire events. pub fn set_observer_context(&mut self, context: ObserverContext) { self.observer_context = context; } + /// Install a write-attempt counter for tests. + /// + /// When set, every call to `write_ndjson_inner` (regardless of success or failure) + /// atomically increments the counter before attempting the I/O. Tests can use this + /// to assert "exactly one attempt was made" even when the write fails. + #[cfg(test)] + pub fn set_write_attempt_count( + &mut self, + counter: std::sync::Arc, + ) { + self.write_attempt_count = Some(counter); + } + /// Return a clone of the observer handle, if attached. pub(crate) fn observer_handle(&self) -> Option { self.observer.clone() @@ -599,6 +892,24 @@ impl AcpClient { } } + /// Emit a semantic event with an authorization envelope, if observer enabled. + fn observe_authorized( + &self, + kind: impl Into, + authorization: AuthorizationEnvelope, + payload: serde_json::Value, + ) { + if let Some(observer) = &self.observer { + observer.emit_authorized( + kind, + self.observer_agent_index, + &self.observer_context, + authorization, + payload, + ); + } + } + /// Send the `initialize` request and return the agent's response result value. /// /// Must be called exactly once, before any other ACP method. @@ -825,6 +1136,10 @@ impl AcpClient { Ok(_) => { self.last_prompt_id = None; self.current_hard_deadline = None; + // Turn completed normally — drain resolved/expired permission entries. + // Pending entries are unexpected here (should be Resolved or expired), + // but drain unconditionally to guarantee the map never leaks across turns. + self.pending_permissions.clear(); } Err(AcpError::IdleTimeout(_) | AcpError::HardTimeout { .. }) => { // Leave last_prompt_id and current_hard_deadline set — @@ -833,6 +1148,10 @@ impl AcpClient { Err(_) => { self.last_prompt_id = None; self.current_hard_deadline = None; + // Non-recoverable error — drain the map to prevent capacity leak + // if the pool reuses this process (poisoned processes are respawned, + // but clean error exits may be returned to the pool). + self.pending_permissions.clear(); } } self.parse_prompt_response(session_id, &result?) @@ -1023,8 +1342,109 @@ impl AcpClient { AcpError::Protocol("cancel_with_cleanup called with no in-flight prompt".into()) })?; - // Step 1: respond to any pending permission request with "cancelled", - // but only if we haven't already responded (guards against double-response race). + // Check for poisoning first: if a permission write is in progress we + // must not send any more bytes to this process — return the dedicated + // error so `classify_control_cancel_failure` triggers respawn. + if self.permission_poisoned { + tracing::error!( + target: "acp::cancel", + "cancel on poisoned process — triggering respawn" + ); + return Err(AcpError::PermissionPoisoned); + } + + // Step 1: respond to any pending permission request with "cancelled". + // + // Under `ask` policy: collect entry ids, peek without pre-removal, and + // route each through `finish_permission()`. The first write failure poisons + // the process and stops immediately; Writing-state entries poison immediately. + // + // Under `reject`/`allow` policy: use the old single-id path below. + let ids_to_cancel: Vec = self.pending_permissions.keys().cloned().collect(); + for req_id_str in ids_to_cancel { + // Peek at state without removing — finish_permission removes on success. + let state = self + .pending_permissions + .get(&req_id_str) + .map(|e| e.state.clone()); + match state { + Some(PermissionEntryState::Publishing) => { + // Cancel during Publishing: drop the ACK receiver and deny with + // cancelled outcome. finish_permission will attempt a kind-40003 + // edit if sentinel_event_id is set (it is — stored at build time). + self.sentinel_ack_result_rx = None; // drop background task receiver + let perm_id: serde_json::Value = serde_json::from_str(&req_id_str) + .unwrap_or_else(|_| serde_json::Value::String(req_id_str.clone())); + let nonce = self + .pending_permissions + .get(&req_id_str) + .map(|e| e.nonce.clone()) + .unwrap_or_default(); + let response = permission_response_cancelled(&perm_id); + let ok = self + .finish_permission( + (&req_id_str, &perm_id), + (&nonce, "cancelled", response), + None, + None, + ) + .await; + if !ok { + return Err(AcpError::PermissionPoisoned); + } + } + Some(PermissionEntryState::Writing) => { + let entry = self.pending_permissions.remove(&req_id_str).unwrap(); + tracing::error!( + target: "acp::cancel", + "cancel during permission write for req_id={req_id_str} — poisoning process" + ); + // Emit uncertain terminal so Desktop retires the card. + self.observe_authorized( + "permission_terminal", + AuthorizationEnvelope { + request_nonce: entry.nonce.clone(), + actionable: false, + reason: Some("uncertain".to_string()), + expires_at: None, + }, + serde_json::json!({ "id": req_id_str }), + ); + self.permission_poisoned = true; + return Err(AcpError::PermissionPoisoned); + } + Some(PermissionEntryState::Pending) => { + // Parse id back to JSON value for the wire response. + let perm_id: serde_json::Value = serde_json::from_str(&req_id_str) + .unwrap_or_else(|_| serde_json::Value::String(req_id_str.clone())); + let nonce = self + .pending_permissions + .get(&req_id_str) + .map(|e| e.nonce.clone()) + .unwrap_or_default(); + let response = permission_response_cancelled(&perm_id); + // finish_permission removes the entry and poisons on write failure. + // The cancel path has no loop-owned idle state to re-arm. + let ok = self + .finish_permission( + (&req_id_str, &perm_id), + (&nonce, "cancelled", response), + None, + None, // no idle re-arm in cancel path + ) + .await; + if !ok { + // Write failed → process is already poisoned; stop immediately. + return Err(AcpError::PermissionPoisoned); + } + } + None => { + // Entry was concurrently removed (shouldn't happen, but be safe). + } + } + } + + // Old single-id path (reject/allow policy). if let Some(perm_id) = self.pending_permission_id.clone() { if !self.permission_responded { let response = permission_response_cancelled(&perm_id); @@ -1058,6 +1478,8 @@ impl AcpClient { remaining, ) .await?; + // Cancel completed — drain any remaining entries (safety net). + self.pending_permissions.clear(); self.parse_prompt_response(session_id, &result) } @@ -1065,7 +1487,30 @@ impl AcpClient { /// /// Bounded by a 30-second write timeout. If the agent stops reading stdin /// (e.g., it's stuck or dead), the write would otherwise block forever. + /// + /// Emits a generic `acp_write` observer event. For permission response paths + /// that emit their own authorized event, use `write_ndjson_no_observe`. async fn write_ndjson(&mut self, value: &serde_json::Value) -> Result<(), AcpError> { + self.write_ndjson_inner(value, true).await + } + + /// Write NDJSON without emitting a generic `acp_write` observer event. + /// + /// Used for permission response paths that emit a single authorized event + /// themselves — prevents duplicate generic+authorized telemetry. + async fn write_ndjson_no_observe(&mut self, value: &serde_json::Value) -> Result<(), AcpError> { + self.write_ndjson_inner(value, false).await + } + + async fn write_ndjson_inner( + &mut self, + value: &serde_json::Value, + emit_observe: bool, + ) -> Result<(), AcpError> { + #[cfg(test)] + if let Some(counter) = &self.write_attempt_count { + counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); let line = serde_json::to_string(value)?; tokio::time::timeout(WRITE_TIMEOUT, async { @@ -1077,13 +1522,245 @@ impl AcpClient { .await .map_err(|_| AcpError::WriteTimeout(WRITE_TIMEOUT))? .map_err(AcpError::Io)?; - self.observe("acp_write", value.clone()); + if emit_observe { + self.observe("acp_write", value.clone()); + } Ok(()) } /// Default timeout for non-prompt RPCs (initialize, session/new, etc.). const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + /// Terminal helper: write `response` for a permission request, emit one + /// authorized `acp_write` with `reason`, remove the entry from the map, + /// and re-arm the idle deadline if no live (Pending|Writing) entries remain. + /// + /// On any write failure the process is poisoned — no further bytes are + /// sent; an observer-only `permission_terminal` event is emitted so Desktop + /// can retire the card. + /// + /// Returns `true` if the write succeeded (terminal outcome delivered), + /// `false` if the write failed and the process is now poisoned. + /// + /// `entry`: `(id_str, id_val)` — map key + JSON-RPC id value for logging. + /// `outcome`: `(nonce, reason, response)` — what to write and observe. + /// `write_deadline`: optional absolute deadline bounding the write. + /// `idle_deadline_and_timeout`: optional `(&mut Instant, Duration)` for + /// re-arming the idle window. Pass `None` for synchronous policy paths + /// (reject/allow/preflight-denial) that have no loop-owned idle state. + async fn finish_permission( + &mut self, + entry: (&str, &serde_json::Value), + outcome: (&str, &str, serde_json::Value), + write_deadline: Option, + idle_deadline_and_timeout: Option<(&mut tokio::time::Instant, std::time::Duration)>, + ) -> bool { + let (id_str, id_val) = entry; + let (nonce, reason, response) = outcome; + // Write the response. Use a bounded timeout when one is provided. + let write_result = if let Some(deadline) = write_deadline { + tokio::time::timeout_at(deadline, self.write_ndjson_no_observe(&response)) + .await + .unwrap_or(Err(AcpError::WriteTimeout(std::time::Duration::from_secs( + 30, + )))) + } else { + self.write_ndjson_no_observe(&response).await + }; + + match write_result { + Ok(()) => { + // Emit single authorized acp_write correlated by nonce. + self.observe_authorized( + "acp_write", + AuthorizationEnvelope { + request_nonce: nonce.to_string(), + actionable: false, + reason: Some(reason.to_string()), + expires_at: None, + }, + response.clone(), + ); + // Extract sentinel data before removing the entry — used to + // publish the kind-40003 edit that resolves the UI card. + let sentinel_context = self.pending_permissions.get(id_str).map(|e| { + ( + e.sentinel_event_id.clone(), + e.card_actions.clone(), + e.nonce.clone(), + e.expiry_unix_secs, + e.description.clone(), + ) + }); + // Remove entry — absence of the nonce is the replay guard. + self.pending_permissions.remove(id_str); + // Re-arm idle if no live (Publishing|Pending|Writing) entries remain. + if let Some((idle_deadline, idle_timeout)) = idle_deadline_and_timeout { + let live = self.pending_permissions.values().any(|e| { + matches!( + e.state, + PermissionEntryState::Publishing + | PermissionEntryState::Pending + | PermissionEntryState::Writing + ) + }); + if !live { + *idle_deadline = tokio::time::Instant::now() + idle_timeout; + } + } + // Publish the kind-40003 resolved edit if a sentinel was published. + // Best-effort: a failure here is logged but does not fail the permission + // resolution — the agent has already received the ACP response. + if let Some(( + Some(original_event_id), + card_actions, + entry_nonce, + expiry_unix_secs, + entry_description, + )) = sentinel_context + { + // Clone all relay context upfront to avoid holding &mut self borrows + // across the async publish call. + let keys_opt = self.agent_relay_keys.clone(); + let channel_id_opt = self.sentinel_channel_id; + let publisher_opt = self.relay_publisher.clone(); + let session_id_owned = self.observer_context.session_id.clone(); + let turn_id = self.observer_context.turn_id.clone().unwrap_or_default(); + + if let (Some(keys), Some(channel_id), Some(publisher)) = + (keys_opt, channel_id_opt, publisher_opt) + { + // `reason` maps directly to the schema's `outcome` field. + let chosen_option_id: Option = if reason == "applied" { + response + .pointer("/result/outcome/optionId") + .and_then(|v| v.as_str()) + .map(str::to_string) + } else { + None + }; + // Use the stored wire expiry_unix_secs — no recompute. + if let Some(content) = build_sentinel_resolved_payload( + &entry_nonce, + &original_event_id, + &card_actions, + expiry_unix_secs, + session_id_owned.as_deref(), + &turn_id, + reason, + chosen_option_id.as_deref(), + entry_description.as_deref(), + ) { + if let Some(event) = build_kind40003_sentinel( + &keys, + channel_id, + &original_event_id, + &content, + ) { + // The decision is already irreversible (ACP + // response written, entry removed above). Publish + // via the acked lane with bounded retransmission + // so a socket failure at this instant doesn't + // permanently strand the card as "Timed out" — + // the same signed event is idempotently resent on + // Uncertain until the relay accepts it or the + // delivery window closes. The delivery window + // starts now (resolution time), independent of + // the original card/click deadline, so ordinary + // timeouts (where entry_deadline is already past) + // still get at least one publish attempt. + // Detached so the read loop is never blocked. + let delivery_deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(RESOLVED_DELIVERY_WINDOW_SECS); + tokio::spawn(retransmit_resolved_edit( + publisher, + event, + delivery_deadline, + )); + } + } + } + } + tracing::debug!( + target: "acp::permission", + "permission id={id_val} finished: reason={reason}" + ); + true + } + Err(e) => { + tracing::error!( + target: "acp::permission", + "permission write failed for id={id_val} reason={reason}: {e} — poisoning process" + ); + self.permission_poisoned = true; + // Remove entry so cancel doesn't attempt a second write. + self.pending_permissions.remove(id_str); + // Emit an observer-only `permission_terminal` so Desktop can retire the card + // even though no ACP response was confirmed. + self.observe_authorized( + "permission_terminal", + AuthorizationEnvelope { + request_nonce: nonce.to_string(), + actionable: false, + reason: Some("uncertain".to_string()), + expires_at: None, + }, + serde_json::json!({ "id": id_val }), + ); + false + } + } + } + + /// Terminal helper for synchronous policy paths (`reject`, `allow`, + /// preflight denial). Unlike `finish_permission`, this does not manage + /// `pending_permissions` — these paths are resolved inline before the + /// entry is inserted. + /// + /// Writes `response`, then emits an authorized `acp_write` observer event + /// correlated by `nonce` with the given `reason`. On write failure the + /// process is poisoned and `Err(AcpError::PermissionPoisoned)` is returned. + /// + /// Standardized `reason` values for policy terminals: + /// - `"rejected"` — `reject` policy or preflight denial. + /// - `"allowed"` — `allow` policy auto-approval. + /// - `"allow_failed_closed"` — `allow` policy with no unique allow_once option. + async fn finish_permission_sync( + &mut self, + id_val: &serde_json::Value, + nonce: &str, + reason: &str, + response: serde_json::Value, + ) -> Result<(), AcpError> { + match self.write_ndjson_no_observe(&response).await { + Ok(()) => { + self.observe_authorized( + "acp_write", + AuthorizationEnvelope { + request_nonce: nonce.to_string(), + actionable: false, + reason: Some(reason.to_string()), + expires_at: None, + }, + response, + ); + tracing::debug!( + target: "acp::permission", + "synchronous permission id={id_val} finished: reason={reason}" + ); + Ok(()) + } + Err(e) => { + tracing::error!( + target: "acp::permission", + "synchronous permission write failed for id={id_val} reason={reason}: {e} — poisoning process" + ); + self.permission_poisoned = true; + Err(AcpError::PermissionPoisoned) + } + } + } + /// Send a JSON-RPC request and wait for the matching response. /// /// Assigns the next available id, writes the NDJSON line to stdin, @@ -1182,7 +1859,8 @@ impl AcpClient { /// /// While waiting, handles: /// - `session/update` notifications → logged via tracing - /// - `session/request_permission` requests → auto-approved with `allow_once` + /// - `session/request_permission` requests → rejected unless an owner has + /// already selected a non-interactive permission mode at session setup /// - Any other messages → debug-logged and ignored; if they carry an `id` /// (i.e. they are requests, not notifications), a JSON-RPC -32601 error is sent. /// @@ -1258,7 +1936,26 @@ impl AcpClient { self.handle_goose_usage_update(&msg); } "session/request_permission" => { - self.handle_permission_request(&msg).await?; + // Pre-turn (session/new) path: no decision arm installed. + // Force reject regardless of policy — ask requests would + // register map entries that can never be resolved without + // the turn reader's decision arm. + let saved_policy = self.permission_config.policy; + if matches!(saved_policy, PermissionPolicy::Ask) { + // Temporarily downgrade to reject for this request only. + let saved = std::mem::replace( + &mut self.permission_config.policy, + PermissionPolicy::Reject, + ); + let deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(PERMISSION_ASK_TIMEOUT_SECS); + let _ = self.handle_permission_request(&msg, deadline).await; + self.permission_config.policy = saved; + } else { + let deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(PERMISSION_ASK_TIMEOUT_SECS); + self.handle_permission_request(&msg, deadline).await?; + } } other => { // If the unknown message has an id, it's a request expecting a reply. @@ -1328,6 +2025,19 @@ impl AcpClient { // so the ack_tx oneshot is never leaked silently). let mut steer_rx = self.steer_rx.take(); + // Take the per-session permission decision receiver into a local for + // the same reason: `self.reader` and `decision_rx` cannot both be + // borrowed inside `select!` via `self`. + let mut decision_rx = self.permission_decision_rx.take(); + + // Receiver for sentinel publish ACK results. Set after + // `handle_permission_request` installs a sentinel; moved here from + // `self.sentinel_ack_result_rx` at the top of each loop iteration so + // it can be polled inside `select!` independently of `self`. + let mut ack_result_rx: Option< + tokio::sync::mpsc::Receiver<(String, crate::relay::AckOutcome)>, + > = None; + // Tracks the in-flight steer write: `(request_id, transport, ack_tx)`. // While `Some`, the steer arm is gated off so we don't stack writes, // and a response matching `id` is routed to the ack_tx instead @@ -1347,14 +2057,68 @@ impl AcpClient { let mut last_activity_at = now; loop { + // Move any newly-set sentinel ACK receiver from self to the local, + // so it can be polled inside select! without conflicting with self. + if ack_result_rx.is_none() { + if let Some(rx) = self.sentinel_ack_result_rx.take() { + ack_result_rx = Some(rx); + } + } + + // If the process was poisoned by a cancel-during-write, surface the + // error immediately so the caller can respawn. + if self.permission_poisoned { + if let Some((_, _, ack_tx)) = pending_steer.take() { + let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); + } + return Err(AcpError::PermissionPoisoned); + } + // Determine which deadline fires first BEFORE sleeping — this is // the classification we'll use on timeout, immune to scheduler jitter. - let idle_fires_first = idle_deadline < hard_deadline; - let next_deadline = if idle_fires_first { - idle_deadline + // + // Deadline logic: + // - When any Pending permission entries exist, suspend the idle + // deadline (owner is deciding; agent silence is expected) and + // wake on the earliest permission deadline instead. + // - Otherwise wake on min(idle, hard) as normal. + let has_pending_permissions = self.pending_permissions.values().any(|e| { + matches!( + e.state, + PermissionEntryState::Publishing | PermissionEntryState::Pending + ) + }); + let next_deadline; + let idle_fires_first; + if has_pending_permissions { + // Suspend idle; find earliest permission deadline (capped by hard). + // Publishing entries use their publish_deadline (in sentinel_ack_rx) + // or their entry deadline — we use entry.deadline for both states. + let earliest_perm = self + .pending_permissions + .values() + .filter(|e| { + matches!( + e.state, + PermissionEntryState::Publishing | PermissionEntryState::Pending + ) + }) + .map(|e| e.deadline) + .min() + .unwrap_or(hard_deadline); + // Also factor in the publish deadline for the in-flight ACK. + // The background task enforces publish_deadline itself; for the + // select! wakeup we rely on earliest_perm (the entry.deadline). + next_deadline = earliest_perm.min(hard_deadline); + idle_fires_first = false; // hard deadline governs if we wake } else { - hard_deadline - }; + idle_fires_first = idle_deadline < hard_deadline; + next_deadline = if idle_fires_first { + idle_deadline + } else { + hard_deadline + }; + } // Pre-select deadline check — required by Max's review. Under // `biased`, a continuously-ready reader arm wins every poll and @@ -1364,27 +2128,407 @@ impl AcpClient { // exists). Check the classified deadline here so a steady- // stream agent is still bounded. if Instant::now() >= next_deadline { + // When pending permission entries exist (including when + // entry.deadline == hard_deadline), fall through to let the + // expiry block process timed-out entries first. + // We return HardTimeout after the expiry block in that case. + if !has_pending_permissions { + if let Some((_, _, ack_tx)) = pending_steer.take() { + // Prompt is timing out — release the withheld event via + // PromptCompletedNeutral (no fallback signal: there is + // no in-flight turn to signal once we return, and + // normal dispatch handles redelivery). + let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); + } + if idle_fires_first { + tracing::warn!("idle timeout ({idle_timeout:?}) — no agent activity"); + return Err(AcpError::IdleTimeout(idle_timeout)); + } else { + let silence = Instant::now().saturating_duration_since(last_activity_at); + tracing::warn!("hard turn timeout exceeded (silence {silence:?})"); + return Err(AcpError::HardTimeout { silence }); + } + } + } + + // Expire any pending `ask` permission entries whose per-request + // deadline has passed. Fail closed: write denial response for each + // expired entry. `finish_permission` removes the entry on success + // and emits `permission_terminal` + poisons on write failure. + { + let now = Instant::now(); + + // Publishing entries whose publish deadline has passed: the + // background task handles the publish timeout and sends an + // Uncertain outcome via sentinel_ack_result_rx. No action + // needed here — the select! arm will process it on next iteration. + // However, if the entry deadline (300s) has also passed while + // still in Publishing (very unusual), deny it directly. + { + let publishing_expired: Vec<_> = self + .pending_permissions + .iter() + .filter(|(_, e)| { + matches!(e.state, PermissionEntryState::Publishing) && now >= e.deadline + }) + .map(|(k, e)| { + ( + k.clone(), + serde_json::from_str(k) + .unwrap_or_else(|_| serde_json::Value::String(k.clone())), + e.options_snapshot.clone(), + e.nonce.clone(), + ) + }) + .collect(); + for (id_str, id_val, opts, nonce) in publishing_expired { + tracing::warn!( + target: "acp::permission", + "Publishing entry hard deadline for id={id_val} — failing closed" + ); + // Drop the ACK result channel if it matches. + if self + .sentinel_ack_result_rx + .as_ref() + .map(|_| true) + .unwrap_or(false) + { + self.sentinel_ack_result_rx = None; + } + if let Ok(response) = permission_denial_response(&id_val, &opts) { + let ok = self + .finish_permission( + (&id_str, &id_val), + (&nonce, "timed_out", response), + None, + Some((&mut idle_deadline, idle_timeout)), + ) + .await; + if !ok { + return Err(AcpError::PermissionPoisoned); + } + } + } + } + + let expired: Vec<(String, serde_json::Value, Vec, String)> = + self.pending_permissions + .iter() + .filter(|(_, e)| { + matches!(e.state, PermissionEntryState::Pending) && now >= e.deadline + }) + .map(|(id_str, e)| { + ( + id_str.clone(), + serde_json::from_str(id_str) + .unwrap_or_else(|_| serde_json::Value::String(id_str.clone())), + e.options_snapshot.clone(), + e.nonce.clone(), + ) + }) + .collect(); + for (id_str, id_val, opts, nonce) in expired { + tracing::warn!( + target: "acp::permission", + "ask timeout for permission id={id_val} — failing closed" + ); + if let Ok(response) = permission_denial_response(&id_val, &opts) { + let ok = self + .finish_permission( + (&id_str, &id_val), + (&nonce, "timed_out", response), + None, + Some((&mut idle_deadline, idle_timeout)), + ) + .await; + if !ok { + // Write failed → process is poisoned; stop immediately. + return Err(AcpError::PermissionPoisoned); + } + } + } + } + + // After processing expired permission entries, check if the hard + // deadline has now been reached — this handles the deadline-equality + // case where entry.deadline == hard_deadline: we wrote the fail-closed + // response above, now exit with HardTimeout. + if Instant::now() >= hard_deadline + && !self.pending_permissions.values().any(|e| { + matches!( + e.state, + PermissionEntryState::Publishing | PermissionEntryState::Pending + ) + }) + { if let Some((_, _, ack_tx)) = pending_steer.take() { - // Prompt is timing out — release the withheld event via - // PromptCompletedNeutral (no fallback signal: there is - // no in-flight turn to signal once we return, and - // normal dispatch handles redelivery). let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } - if idle_fires_first { - tracing::warn!("idle timeout ({idle_timeout:?}) — no agent activity"); - return Err(AcpError::IdleTimeout(idle_timeout)); - } else { - let silence = Instant::now().saturating_duration_since(last_activity_at); - tracing::warn!("hard turn timeout exceeded (silence {silence:?})"); - return Err(AcpError::HardTimeout { silence }); - } + let silence = Instant::now().saturating_duration_since(last_activity_at); + tracing::warn!("hard turn timeout exceeded (silence {silence:?})"); + return Err(AcpError::HardTimeout { silence }); } // LinesCodec::new_with_max_length enforces MAX_LINE_SIZE at the // read level — the buffer never grows beyond the limit. let read_result = tokio::select! { biased; + // Decision arm — must be FIRST in the biased select! (spec §9) so + // owner decisions are not starved by a continuously-ready stdout. + // Cancel-safe: `mpsc::Receiver::recv` does not lose messages on drop. + Some(decision) = async { + match decision_rx.as_mut() { + Some(rx) => rx.recv().await, + None => None, + } + } => { + // Find the pending entry by nonce match. + // A decision arriving during Publishing is buffered; it will + // be applied immediately when the relay ACK is received. + let entry_id = self.pending_permissions + .iter() + .find(|(_, e)| { + matches!( + e.state, + PermissionEntryState::Publishing | PermissionEntryState::Pending + ) && e.nonce == decision.request_nonce + }) + .map(|(k, _)| k.clone()); + + if let Some(id_str) = entry_id { + // Accept the decision only when its optionId is exactly + // one of the two ruled card actions (allow_once / + // reject_once) — never a forbidden option (e.g. + // `allow_always`) that the adapter offered but the card + // never surfaced. Membership in the raw snapshot is not + // sufficient. + let opt_valid = self.pending_permissions + .get(&id_str) + .map(|e| e.card_actions.accepts(decision.option_id.as_str())) + .unwrap_or(false); + + if !opt_valid { + tracing::warn!( + target: "acp::permission", + "permission_decision optionId {:?} is not a ruled card action for id={id_str} — ignoring", + decision.option_id + ); + } else { + let entry_state = self + .pending_permissions + .get(&id_str) + .map(|e| e.state.clone()); + + match entry_state { + Some(PermissionEntryState::Publishing) => { + // Buffer the first valid decision; apply on ACK. + // Subsequent valid decisions for the same nonce + // are ignored — first-wins prevents a later + // opposing decision from overwriting the first + // while the card publish is awaiting ACK. + if let Some(entry) = + self.pending_permissions.get_mut(&id_str) + { + if entry.early_decision.is_none() { + entry.early_decision = Some(decision); + tracing::debug!( + target: "acp::permission", + "permission_decision buffered during Publishing for id={id_str}" + ); + } else { + tracing::debug!( + target: "acp::permission", + "permission_decision ignored — first decision already buffered for id={id_str}" + ); + } + } + } + Some(PermissionEntryState::Pending) => { + // Transition Pending → Writing. + let (nonce, id_val) = { + let entry = + self.pending_permissions.get_mut(&id_str).unwrap(); + entry.state = PermissionEntryState::Writing; + ( + entry.nonce.clone(), + serde_json::from_str::(&id_str) + .unwrap_or_else(|_| { + serde_json::Value::String(id_str.clone()) + }), + ) + }; + + let response = + permission_response_selected(&id_val, &decision.option_id); + let write_deadline = (Instant::now() + + std::time::Duration::from_secs(30)) + .min(hard_deadline); + let ok = self + .finish_permission( + (&id_str, &id_val), + (&nonce, "applied", response), + Some(write_deadline), + Some((&mut idle_deadline, idle_timeout)), + ) + .await; + if ok { + tracing::info!( + target: "acp::permission", + "permission id={id_val} answered: optionId={:?}", + decision.option_id + ); + } else { + // Write failed → process poisoned; break out immediately. + if let Some((_, _, ack_tx)) = pending_steer.take() { + let _ = ack_tx + .send(crate::pool::SteerAck::PromptCompletedNeutral); + } + return Err(AcpError::PermissionPoisoned); + } + } + _ => {} + } + } + } else { + tracing::warn!( + target: "acp::permission", + "permission_decision nonce {:?} has no matching pending entry — ignoring", + decision.request_nonce + ); + } + None // loop back; don't set read_result + } + // Sentinel ACK arm: fires when the relay responds to the kind-9 publish. + // Publishing → Pending on Accepted (apply any buffered early decision). + // Any other outcome → deny synchronously and remove the entry. + // Cancel-safe: mpsc::Receiver::recv does not lose messages on drop. + Some((pub_id, ack_result)) = async { + match ack_result_rx.as_mut() { + Some(rx) => rx.recv().await, + None => None, + } + } => { + // Received one ACK result; the channel is now drained (capacity=1). + ack_result_rx = None; + match ack_result { + crate::relay::AckOutcome::Accepted => { + // Transition Publishing → Pending and take any buffered + // early decision in one mutable access. + // sentinel_event_id is already stored at build time. + let early_decision = + if let Some(entry) = self + .pending_permissions + .get_mut(&pub_id) + .filter(|e| matches!(e.state, PermissionEntryState::Publishing)) + { + entry.state = PermissionEntryState::Pending; + tracing::debug!( + target: "acp::permission", + "sentinel ACK accepted for id={pub_id} — transitioning to Pending" + ); + entry.early_decision.take() + } else { + None + }; + // Apply buffered early decision if present. + if let Some(decision) = early_decision { + let id_str = pub_id.clone(); + let opt_valid = self + .pending_permissions + .get(&id_str) + .map(|e| e.card_actions.accepts(decision.option_id.as_str())) + .unwrap_or(false); + if opt_valid { + let (nonce, id_val) = { + let entry = self + .pending_permissions + .get_mut(&id_str) + .unwrap(); + entry.state = PermissionEntryState::Writing; + ( + entry.nonce.clone(), + serde_json::from_str::(&id_str) + .unwrap_or_else(|_| { + serde_json::Value::String(id_str.clone()) + }), + ) + }; + let response = permission_response_selected( + &id_val, + &decision.option_id, + ); + let write_deadline = (Instant::now() + + std::time::Duration::from_secs(30)) + .min(hard_deadline); + let ok = self + .finish_permission( + (&id_str, &id_val), + (&nonce, "applied", response), + Some(write_deadline), + Some((&mut idle_deadline, idle_timeout)), + ) + .await; + if ok { + tracing::info!( + target: "acp::permission", + "permission id={id_val} answered (early decision applied): optionId={:?}", + decision.option_id + ); + } else { + if let Some((_, _, ack_tx)) = pending_steer.take() { + let _ = ack_tx.send( + crate::pool::SteerAck::PromptCompletedNeutral, + ); + } + return Err(AcpError::PermissionPoisoned); + } + } + } + } + outcome => { + // Rejected or Uncertain: deny and remove the entry. + let reason_str = match &outcome { + crate::relay::AckOutcome::Rejected { message } => { + format!("rejected by relay: {message}") + } + _ => "relay delivery uncertain".to_string(), + }; + tracing::warn!( + target: "acp::permission", + "sentinel publish not accepted for id={pub_id}: {reason_str} — failing closed" + ); + if let Some(entry) = self + .pending_permissions + .get(&pub_id) + .filter(|e| matches!(e.state, PermissionEntryState::Publishing)) + { + let id_val: serde_json::Value = serde_json::from_str(&pub_id) + .unwrap_or_else(|_| serde_json::Value::String(pub_id.clone())); + let opts = entry.options_snapshot.clone(); + let nonce = entry.nonce.clone(); + if let Ok(response) = permission_denial_response(&id_val, &opts) { + let ok = self + .finish_permission( + (&pub_id, &id_val), + (&nonce, "timed_out", response), + None, + Some((&mut idle_deadline, idle_timeout)), + ) + .await; + if !ok { + if let Some((_, _, ack_tx)) = pending_steer.take() { + let _ = ack_tx.send( + crate::pool::SteerAck::PromptCompletedNeutral, + ); + } + return Err(AcpError::PermissionPoisoned); + } + } + } + } + } + None // loop back + } read_result = self.reader.next() => Some(read_result), // Steer arm: gated off whenever a steer write is already in // flight so we don't stack two writes against the same @@ -1489,16 +2633,23 @@ impl AcpClient { // would catch this anyway, but firing the deadline arm // here makes the wakeup immediate (no extra reader poll // round-trip when stdout is idle). - if let Some((_, _, ack_tx)) = pending_steer.take() { - let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); - } - if idle_fires_first { - tracing::warn!("idle timeout ({idle_timeout:?}) — no agent activity"); - return Err(AcpError::IdleTimeout(idle_timeout)); + // When pending permissions exist (including equality with + // hard_deadline), loop back to let the expiry block process + // timed-out entries first. + if has_pending_permissions { + None // loop back; expiry block will fire (then we return HardTimeout if still past) } else { - let silence = Instant::now().saturating_duration_since(last_activity_at); - tracing::warn!("hard turn timeout exceeded (silence {silence:?})"); - return Err(AcpError::HardTimeout { silence }); + if let Some((_, _, ack_tx)) = pending_steer.take() { + let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); + } + if idle_fires_first { + tracing::warn!("idle timeout ({idle_timeout:?}) — no agent activity"); + return Err(AcpError::IdleTimeout(idle_timeout)); + } else { + let silence = Instant::now().saturating_duration_since(last_activity_at); + tracing::warn!("hard turn timeout exceeded (silence {silence:?})"); + return Err(AcpError::HardTimeout { silence }); + } } } }; @@ -1557,7 +2708,16 @@ impl AcpClient { continue; } }; - self.observe("acp_read", msg.clone()); + // Suppress the generic `acp_read` for `session/request_permission` + // under the `ask` policy — `handle_permission_request` emits the + // single enveloped frame instead (spec §6 "one frame per request"). + let is_ask_permission_request = + matches!(self.permission_config.policy, PermissionPolicy::Ask) + && msg.get("method").and_then(|v| v.as_str()) + == Some("session/request_permission"); + if !is_ask_permission_request { + self.observe("acp_read", msg.clone()); + } let activity_now = Instant::now(); idle_deadline = activity_now + idle_timeout; @@ -1706,7 +2866,7 @@ impl AcpClient { self.handle_goose_usage_update(&msg); } "session/request_permission" => { - self.handle_permission_request(&msg).await?; + self.handle_permission_request(&msg, hard_deadline).await?; } other => { // If the unknown message has an id, it's a request expecting a reply. @@ -1922,90 +3082,526 @@ impl AcpClient { } } - /// Auto-approve a `session/request_permission` request from the agent. + /// Handle a `session/request_permission` request from the agent. /// - /// Finds the option with `kind == "allow_once"` and responds with its `optionId`. - /// If no `allow_once` option exists, falls back to `reject_once`. + /// Dispatches based on the resolved permission policy: + /// - `reject` — deny via `reject_once`/`cancelled` (byte-for-byte old behaviour). + /// - `allow` — auto-select the unique validated `allow_once` option; fail closed. + /// - `ask` — register in the pending map, emit an actionable frame, and return. + /// The read loop's decision arm (added to `select!`) delivers the owner + /// decision. This call is intentionally **non-blocking** for `ask`; + /// the actual response is written asynchronously via the decision arm. /// - /// **Critical:** Never hardcode `optionId` — always find it dynamically by `kind`. + /// **Admission preflight (always runs before any policy dispatch):** + /// options nonempty, count ≤ PERMISSION_OPTIONS_MAX, every optionId unique + + /// nonempty, required kind/name fields present, no duplicate live requestId, + /// plaintext size ≤ OBSERVER_MAX_PLAINTEXT_LEN. Fail → immediate denial + emit + /// with `actionable: false`. /// - /// The request `id` is stored as `serde_json::Value` to support both numeric - /// and string IDs per JSON-RPC 2.0. - async fn handle_permission_request(&mut self, msg: &serde_json::Value) -> Result<(), AcpError> { + /// Under `ask`, the generic pre-dispatch `acp_read` (acp.rs:1697 seam) is + /// **suppressed** for permission requests; this method emits the single + /// post-preflight enveloped frame instead. + /// + /// Returns `Ok(true)` when the caller should suppress the normal `acp_read` emit + /// (i.e. this method already emitted the enveloped frame), `Ok(false)` otherwise. + pub(crate) async fn handle_permission_request( + &mut self, + msg: &serde_json::Value, + // Hard deadline for the current turn. Used to bound per-request ask timeouts. + hard_deadline: tokio::time::Instant, + ) -> Result { // Extract id as a Value — JSON-RPC 2.0 allows both numeric and string IDs. let id = msg .get("id") .cloned() .ok_or_else(|| AcpError::Protocol("permission request missing id".into()))?; - // Store pending permission id so cancel_with_cleanup can respond to it. - self.pending_permission_id = Some(id.clone()); - // Mark as not yet responded — guards against double-response race. - self.permission_responded = false; + let options = match msg["params"]["options"].as_array() { + Some(o) => o.clone(), + None => { + // Missing options — emit non-actionable frame and deny. + let reason = "missing or non-array options field"; + tracing::warn!(target: "acp::permission", "{reason}, id={id}"); + let nonce = new_permission_nonce(); + self.emit_permission_read_non_actionable(&id, msg, &nonce, reason); + let response = permission_denial_response(&id, &[])?; + self.finish_permission_sync(&id, &nonce, "rejected", response) + .await?; + return Ok(true); + } + }; - let options = msg["params"]["options"] - .as_array() - .ok_or_else(|| AcpError::Protocol("permission request missing options".into()))?; + // ── Admission preflight ──────────────────────────────────────────────── + let preflight_result = run_admission_preflight( + &id, + &options, + msg, + self.permission_config.policy, + AskGates { + // Check for duplicate live requestId under ask. + is_duplicate_id: if matches!(self.permission_config.policy, PermissionPolicy::Ask) { + let id_str = id.to_string(); + self.pending_permissions.contains_key(&id_str) + } else { + false + }, + is_map_at_cap: if matches!(self.permission_config.policy, PermissionPolicy::Ask) { + // Count every live entry — including `Publishing` — against the + // cap. A broken/malicious adapter that never triggers the ACK + // could otherwise accumulate unbounded Publishing entries/cards + // below the cap; counting them here bounds the total live set. + self.pending_permissions + .values() + .filter(|e| { + matches!( + e.state, + PermissionEntryState::Publishing + | PermissionEntryState::Pending + | PermissionEntryState::Writing + ) + }) + .count() + >= PERMISSION_MAP_CAP + } else { + false + }, + // A single sentinel ACK receiver slot is shared across publishes, + // so at most one entry may be in `Publishing` at a time. A new Ask + // request while a publish is still in flight is denied (fail closed). + is_publish_in_flight: if matches!( + self.permission_config.policy, + PermissionPolicy::Ask + ) { + self.pending_permissions + .values() + .any(|e| matches!(e.state, PermissionEntryState::Publishing)) + } else { + false + }, + }, + (&self.observer_context, self.observer_agent_index), + ); - tracing::debug!( + if let Err(reason) = preflight_result { + tracing::warn!(target: "acp::permission", "preflight failed: {reason}, id={id}"); + let nonce = new_permission_nonce(); + self.emit_permission_read_non_actionable(&id, msg, &nonce, &reason); + let response = permission_denial_response(&id, &options)?; + self.finish_permission_sync(&id, &nonce, "rejected", response) + .await?; + return Ok(true); + } + // ── Preflight passed ─────────────────────────────────────────────────── + + tracing::debug!( target: "acp::permission", - "session/request_permission id={id}, {} options", - options.len() + "session/request_permission id={id}, {} options, policy={}", + options.len(), + self.permission_config.policy ); - // Find allow_once by kind — NEVER hardcode optionId. - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); + match self.permission_config.policy { + PermissionPolicy::Reject => { + // Byte-for-byte old behaviour: deny, track pending id for cancel. + self.pending_permission_id = Some(id.clone()); + self.permission_responded = false; + + // For reject, the caller already emitted acp_read unconditionally; + // emit a non-actionable authorization envelope alongside. + let nonce = new_permission_nonce(); + self.emit_permission_read_with_nonce( + &id, + msg, + &nonce, + false, + Some("policy=reject"), + ); - let response = if let Some(opt) = allow_once { - let option_id = opt["optionId"] - .as_str() - .ok_or_else(|| AcpError::Protocol("allow_once option missing optionId".into()))?; - tracing::info!( - target: "acp::permission", - "auto-approving permission id={id} with allow_once optionId={option_id:?}" - ); - permission_response_selected(&id, option_id) - } else { - // No allow_once — fall back to reject_once. - tracing::warn!( - target: "acp::permission", - "no allow_once option found in permission request id={id}, falling back to reject_once" - ); - let reject = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); + let response = permission_denial_response(&id, &options)?; + self.finish_permission_sync(&id, &nonce, "rejected", response) + .await?; + self.permission_responded = true; + self.pending_permission_id = None; + Ok(true) + } + PermissionPolicy::Allow => { + // Auto-select the unique allow_once option; fail closed otherwise. + self.pending_permission_id = Some(id.clone()); + self.permission_responded = false; + + match select_allow_once(&options) { + Ok(option_id) => { + tracing::info!( + target: "acp::permission", + "allow: selecting allow_once optionId={option_id:?} for id={id}" + ); + let nonce = new_permission_nonce(); + // Emit enveloped acp_read (non-actionable: auto-approved). + self.emit_permission_read_with_nonce( + &id, + msg, + &nonce, + false, + Some("policy=allow; auto-approved"), + ); + let response = permission_response_selected(&id, &option_id); + self.finish_permission_sync(&id, &nonce, "allowed", response) + .await?; + self.permission_responded = true; + self.pending_permission_id = None; + } + Err(reason) => { + // Fail closed. + tracing::warn!( + target: "acp::permission", + "allow: fail closed — {reason}, id={id}" + ); + let nonce = new_permission_nonce(); + self.emit_permission_read_with_nonce( + &id, + msg, + &nonce, + false, + Some(&format!("policy=allow; fail closed: {reason}")), + ); + let response = permission_denial_response(&id, &options)?; + self.finish_permission_sync(&id, &nonce, "allow_failed_closed", response) + .await?; + self.permission_responded = true; + self.pending_permission_id = None; + } + } + Ok(true) + } + PermissionPolicy::Ask => { + // Availability gate (spec §10): `ask` requires both an active observer + // and a known owner. Without either, downgrade to `reject` with a loud + // warning — never sideways to `allow`. + let observer_active = self.observer.is_some(); + if !observer_active || !self.owner_pubkey_known { + tracing::warn!( + target: "acp::permission", + "ask policy unavailable (observer={}, owner_known={}) — downgrading to reject for id={id}", + observer_active, + self.owner_pubkey_known + ); + // Fall through to the Reject arm's logic. + self.pending_permission_id = Some(id.clone()); + self.permission_responded = false; + let nonce = new_permission_nonce(); + self.emit_permission_read_with_nonce( + &id, + msg, + &nonce, + false, + Some("policy=ask unavailable (no observer/owner); downgraded to reject"), + ); + let response = permission_denial_response(&id, &options)?; + self.finish_permission_sync(&id, &nonce, "rejected", response) + .await?; + self.permission_responded = true; + self.pending_permission_id = None; + return Ok(true); + } - if let Some(opt) = reject { - let option_id = opt["optionId"].as_str().unwrap_or("reject"); - permission_response_selected(&id, option_id) - } else { - return Err(AcpError::Protocol( - "no suitable permission option found (neither allow_once nor reject_once)" - .into(), - )); + // Register in the pending map and emit the actionable frame. + // The read loop's decision arm delivers the response asynchronously. + let id_str = id.to_string(); + let nonce = new_permission_nonce(); + + // D7-final admission check: `ask` only proceeds when a relay + // publisher is available AND the turn was initiated by the agent + // owner. Without either, deny synchronously with zero card events. + // There is no bypass for sessions without relay context — a request + // that cannot present a card to the owner is always denied. + let owner_initiated = match ( + &self.relay_publisher, + &self.turn_initiator_pubkey, + &self.agent_owner_pubkey_hex, + ) { + (Some(_), Some(initiator), Some(owner_hex)) => initiator.to_hex() == *owner_hex, + // No publisher, or owner/initiator not set: deny. + _ => false, + }; + if !owner_initiated { + tracing::warn!( + target: "acp::permission", + "ask D7-final: turn not owner-initiated (or no relay context) — downgrading to reject for id={id}" + ); + self.pending_permission_id = Some(id.clone()); + self.permission_responded = false; + let nonce = new_permission_nonce(); + self.emit_permission_read_with_nonce( + &id, + msg, + &nonce, + false, + Some("policy=ask; D7-final: non-owner turn or no relay context; downgraded to reject"), + ); + let response = permission_denial_response(&id, &options)?; + self.finish_permission_sync(&id, &nonce, "rejected", response) + .await?; + self.permission_responded = true; + self.pending_permission_id = None; + return Ok(true); + } + + // Select exactly the two ruled card actions (allow_once / + // reject_once). Fail closed — deny with zero card events — if the + // adapter does not offer exactly one of each. This is the single + // enforcement point that keeps a forbidden option (e.g. + // `allow_always`) from ever reaching the owner as a button. + let card_actions = match select_card_actions(&options) { + Ok(actions) => actions, + Err(reason) => { + tracing::warn!( + target: "acp::permission", + "ask: cannot build two-action card ({reason}) — downgrading to reject for id={id}" + ); + self.pending_permission_id = Some(id.clone()); + self.permission_responded = false; + let deny_nonce = new_permission_nonce(); + self.emit_permission_read_with_nonce( + &id, + msg, + &deny_nonce, + false, + Some(&format!("policy=ask; fail closed: {reason}")), + ); + let response = permission_denial_response(&id, &options)?; + self.finish_permission_sync(&id, &deny_nonce, "rejected", response) + .await?; + self.permission_responded = true; + self.pending_permission_id = None; + return Ok(true); + } + }; + + // Per-request deadline: min(now + 300s, turn hard deadline). + let ask_deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(PERMISSION_ASK_TIMEOUT_SECS); + let entry_deadline = ask_deadline.min(hard_deadline); + // Compute and store expiry_unix_secs once — the envelope, the + // pending payload, and the resolved payload all reuse this value + // (no recompute drift). The desktop bounds its + // retransmit-until-acked loop by the envelope's copy. + let expiry_unix_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + + entry_deadline + .checked_duration_since(tokio::time::Instant::now()) + .unwrap_or_default() + .as_secs(); + + // Emit the single enveloped acp_read — suppresses the caller's + // generic emit via the Ok(true) return. + self.observe_authorized( + "acp_read", + AuthorizationEnvelope { + request_nonce: nonce.clone(), + actionable: true, + reason: None, + expires_at: Some(expiry_unix_secs), + }, + msg.clone(), + ); + + // Build and sign the kind-9 sentinel event ONCE before inserting + // the entry — the resolved edit retransmits the same signed event + // on retry, matching the spec requirement. + // Extract a human-readable description from the real producer + // shapes — see `description_from_request_permission` for the + // full precedence rationale. + let description_owned: Option = description_from_request_permission(msg); + let sentinel_event = { + let keys_opt = self.agent_relay_keys.clone(); + let channel_id_opt = self.sentinel_channel_id; + let owner_hex_opt = self.agent_owner_pubkey_hex.clone(); + let turn_id = self.observer_context.turn_id.clone().unwrap_or_default(); + let session_id_owned = self.observer_context.session_id.clone(); + let reply_id = self.sentinel_thread_reply_id.clone(); + + keys_opt.zip(channel_id_opt).zip(owner_hex_opt).and_then( + |((keys, channel_id), owner_hex)| { + let content = build_sentinel_pending_payload( + &nonce, + &card_actions, + expiry_unix_secs, + session_id_owned.as_deref(), + &turn_id, + description_owned.as_deref(), + )?; + build_kind9_sentinel( + &keys, + channel_id, + &owner_hex, + reply_id.as_deref(), + &content, + ) + }, + ) + }; + + // Insert entry as Publishing. The relay ACK transitions it to Pending. + // If the sentinel event could not be built (keys/channel absent even + // after the D7 check passes — shouldn't happen in production), skip + // the ACK path and fall through to deny. + let publisher_opt = self.relay_publisher.clone(); + match (sentinel_event, publisher_opt) { + (Some(event), Some(publisher)) => { + let sentinel_id = event.id.to_hex(); + self.pending_permissions.insert( + id_str.clone(), + PermissionEntry { + nonce: nonce.clone(), + options_snapshot: options.clone(), + card_actions, + state: PermissionEntryState::Publishing, + deadline: entry_deadline, + expiry_unix_secs, + // Store the event ID at build time so the resolved edit + // can reference it even if the ACK arm hasn't fired yet. + sentinel_event_id: Some(sentinel_id), + early_decision: None, + description: description_owned.clone(), + }, + ); + // Publish deadline: min(fixed publish timeout, entry deadline). + let publish_deadline = (tokio::time::Instant::now() + + std::time::Duration::from_secs(SENTINEL_PUBLISH_TIMEOUT_SECS)) + .min(entry_deadline); + match publisher + .register_publish_ack(event, publish_deadline) + .await + { + Ok(ack_rx) => { + // Spawn a task that awaits the relay ACK and forwards + // the result via mpsc to the read loop's select! arm. + // + // The background relay task owns the `publish_deadline` + // — it sweeps expired waiters with `Uncertain` so + // `ack_rx` always resolves before the deadline. No + // caller-side timeout is needed here. + let (ack_result_tx, ack_result_rx) = tokio::sync::mpsc::channel(1); + let entry_id_for_task = id_str.clone(); + tokio::spawn(async move { + let outcome = + ack_rx.await.unwrap_or(crate::relay::AckOutcome::Uncertain); + // Best-effort send: if the read loop already + // cleaned up, the send fails harmlessly. + let _ = ack_result_tx.send((entry_id_for_task, outcome)).await; + }); + self.sentinel_ack_result_rx = Some(ack_result_rx); + } + Err(_) => { + // Command channel closed — relay unavailable. + // Remove the Publishing entry and deny synchronously. + self.pending_permissions.remove(&id_str); + tracing::warn!( + target: "acp::permission", + "sentinel publish channel closed for id={id} — downgrading to reject" + ); + self.pending_permission_id = Some(id.clone()); + self.permission_responded = false; + let deny_nonce = new_permission_nonce(); + self.emit_permission_read_with_nonce( + &id, + msg, + &deny_nonce, + false, + Some("policy=ask; relay channel closed; downgraded to reject"), + ); + let response = permission_denial_response(&id, &options)?; + self.finish_permission_sync(&id, &deny_nonce, "rejected", response) + .await?; + self.permission_responded = true; + self.pending_permission_id = None; + } + } + } + _ => { + // Keys or channel absent despite D7 passing — deny. + tracing::warn!( + target: "acp::permission", + "sentinel event could not be built for id={id} — downgrading to reject" + ); + self.pending_permission_id = Some(id.clone()); + self.permission_responded = false; + let deny_nonce = new_permission_nonce(); + self.emit_permission_read_with_nonce( + &id, + msg, + &deny_nonce, + false, + Some("policy=ask; sentinel build failed; downgraded to reject"), + ); + let response = permission_denial_response(&id, &options)?; + self.finish_permission_sync(&id, &deny_nonce, "rejected", response) + .await?; + self.permission_responded = true; + self.pending_permission_id = None; + } + } + + // Do NOT set pending_permission_id for ask — the map is the + // sole source of truth. The legacy single-id slot is only used + // by reject/allow (synchronous paths). + Ok(true) } - }; + } + } - // Write the response first, then mark as responded. - // - // Previous ordering (flag-before-write) was intended to guard against a - // double-response if a timeout fires between write and flag-set. However, - // the deadlock risk is worse: if write_ndjson fails (e.g. WriteTimeout), - // the flag would be true but no response was actually sent. Then - // cancel_with_cleanup would see permission_responded=true, skip sending - // the cancelled outcome, and the agent would hang waiting for a reply - // that never arrives — a guaranteed deadlock. - // - // The correct fix: set the flag AFTER a successful write. The double- - // response window (between write completion and flag-set) is negligibly - // small and bounded by a single memory store; the deadlock window was - // unbounded. - self.write_ndjson(&response).await?; - self.permission_responded = true; - self.pending_permission_id = None; - Ok(()) + /// Emit a non-actionable `acp_read` authorization frame for a permission request. + /// + /// The caller is responsible for generating the nonce and passing the same + /// value to the corresponding `finish_permission_sync` call so that both the + /// `acp_read` and `acp_write` telemetry frames share one nonce — required for + /// Desktop's nonce-only correlation to retire the card. + fn emit_permission_read_non_actionable( + &self, + id: &serde_json::Value, + msg: &serde_json::Value, + nonce: &str, + reason: &str, + ) { + self.observe_authorized( + "acp_read", + AuthorizationEnvelope { + request_nonce: nonce.to_string(), + actionable: false, + reason: Some(reason.to_string()), + expires_at: None, + }, + msg.clone(), + ); + tracing::debug!(target: "acp::permission", "non-actionable permission read id={id}"); + } + + /// Emit an `acp_read` with an authorization envelope. + /// + /// Only ever called with `actionable: false` (fail-closed / auto-deny + /// paths); the single actionable emit builds its envelope inline with the + /// card expiry. `expires_at` is therefore always `None` here — no owner + /// decision is awaited on these frames. + fn emit_permission_read_with_nonce( + &self, + _id: &serde_json::Value, + msg: &serde_json::Value, + nonce: &str, + actionable: bool, + reason: Option<&str>, + ) { + self.observe_authorized( + "acp_read", + AuthorizationEnvelope { + request_nonce: nonce.to_string(), + actionable, + reason: reason.map(str::to_string), + expires_at: None, + }, + msg.clone(), + ); } /// Parse a completed prompt response and retain its optional per-turn usage. @@ -2120,6 +3716,896 @@ fn permission_response_cancelled(id: &serde_json::Value) -> serde_json::Value { }) } +/// Choose the fail-closed response to a `session/request_permission` request. +/// +/// Buzz has no human permission prompt in this harness, so selecting +/// `allow_once` would turn any admitted prompt into an implicit approval. +/// Prefer the adapter's `reject_once` option — matched by `kind`, never by a +/// hardcoded `optionId` — and fall back to the protocol's cancelled outcome for +/// adapters that do not offer one. Both answers deny. +/// +/// Kept free of the client so the decision is testable without an agent +/// subprocess: `AcpClient` owns a real `Child` and its stdio pipes. +fn permission_denial_response( + id: &serde_json::Value, + options: &[serde_json::Value], +) -> Result { + let reject_once = options + .iter() + .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); + + let Some(opt) = reject_once else { + tracing::warn!( + target: "acp::permission", + "no reject_once option found in permission request id={id}, cancelling" + ); + return Ok(permission_response_cancelled(id)); + }; + + let Some(option_id) = opt["optionId"].as_str().filter(|s| !s.is_empty()) else { + // reject_once found but optionId is missing or empty — malformed request; + // fall back to `cancelled` rather than returning a Protocol error so the + // adapter still receives a valid JSON-RPC response. + tracing::warn!( + target: "acp::permission", + "reject_once option has missing or empty optionId for id={id}, cancelling" + ); + return Ok(permission_response_cancelled(id)); + }; + tracing::info!( + target: "acp::permission", + "rejecting permission id={id} with reject_once optionId={option_id:?}" + ); + Ok(permission_response_selected(id, option_id)) +} + +/// Generate a cryptographically random, URL-safe nonce string. +/// +/// Used as the `requestNonce` in [`crate::observer::AuthorizationEnvelope`]. +/// The nonce is single-use and bound to a specific permission request. +fn new_permission_nonce() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// Frozen sentinel byte bounds, shared verbatim with the Desktop parser +/// (`MAX_STRING_BYTES` / `MAX_CONTENT_BYTES` in `permissionRequest.ts`). +/// +/// The producer and parser MUST agree on both the values AND the unit — UTF-8 +/// bytes — so a card the harness emits always parses and a card the parser +/// accepts is always one the harness could emit. Measuring in Rust `char` +/// scalars vs JavaScript UTF-16 code units (the prior split) let a producer- +/// valid multibyte label be rejected by the parser, publishing a card the +/// desktop renders as raw JSON until timeout. +/// +/// `SENTINEL_STRING_MAX_BYTES` bounds every untrusted string leaf: labels, +/// each `optionId`, `requestNonce`, `sessionId`, `turnId`, and `chosenOptionId`. +/// `SENTINEL_CONTENT_MAX_BYTES` bounds the total serialized sentinel content. +const SENTINEL_STRING_MAX_BYTES: usize = 200; +const SENTINEL_CONTENT_MAX_BYTES: usize = 4096; + +/// Truncate `s` to at most `max_bytes` UTF-8 bytes on a char boundary. +/// +/// Labels are lossy display strings, so an over-long one is truncated (not +/// rejected). Truncating on a char boundary guarantees valid UTF-8 and a byte +/// length the Desktop parser — which bounds the same field in bytes — accepts. +fn truncate_to_bytes(s: &str, max_bytes: usize) -> String { + if s.len() <= max_bytes { + return s.to_string(); + } + let mut end = max_bytes; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + s[..end].to_string() +} + +/// Truncate `s` to at most `max_bytes` UTF-8 bytes using a head + "…" + tail +/// layout that preserves both the start and the end of the string. +/// +/// This keeps distinguishing suffixes visible even when many strings share a +/// long common prefix, which head-only truncation collapses into identical +/// output. The ellipsis is the UTF-8 character U+2026 (3 bytes); head and tail +/// together fill the remaining budget. If `max_bytes < 5` (3-byte ellipsis + +/// at least one byte each side) the function falls back to head-only +/// truncation so it always fits within the budget. +fn truncate_to_bytes_head_tail(s: &str, max_bytes: usize) -> String { + if s.len() <= max_bytes { + return s.to_string(); + } + const ELLIPSIS: &str = "\u{2026}"; // 3 UTF-8 bytes + const ELLIPSIS_BYTES: usize = 3; + // Need room for at least 1 head byte + ellipsis + 1 tail byte. + if max_bytes < 1 + ELLIPSIS_BYTES + 1 { + return truncate_to_bytes(s, max_bytes); + } + let available = max_bytes - ELLIPSIS_BYTES; + // Split evenly; tail gets the extra byte when available is odd. + let head_budget = available / 2; + let tail_budget = available - head_budget; + + // Snap head to a char boundary (walk backward from head_budget). + let mut head_end = head_budget; + while head_end > 0 && !s.is_char_boundary(head_end) { + head_end -= 1; + } + // Snap tail to a char boundary (walk forward from s.len() - tail_budget). + let tail_start_raw = s.len().saturating_sub(tail_budget); + let mut tail_start = tail_start_raw; + while tail_start < s.len() && !s.is_char_boundary(tail_start) { + tail_start += 1; + } + // Guard: if the boundaries crossed (very short string with multi-byte + // chars), fall back to head-only. + if head_end >= tail_start { + return truncate_to_bytes(s, max_bytes); + } + format!("{}{}{}", &s[..head_end], ELLIPSIS, &s[tail_start..]) +} + +/// Enforce the frozen sentinel string bound on one field. +/// +/// Returns `None` (fail closed) when `value` exceeds `SENTINEL_STRING_MAX_BYTES` +/// UTF-8 bytes. `sessionId` is the load-bearing case: it comes straight from the +/// adapter's unbounded `session/new` response, so an oversized adapter session +/// ID must abort sentinel construction rather than publish a card the Desktop +/// parser rejects (which would render as raw JSON until timeout). Labels are the +/// exception — they are truncated at the source, never passed here. +fn check_sentinel_field(field: &str, value: &str) -> Option<()> { + if value.len() > SENTINEL_STRING_MAX_BYTES { + tracing::warn!( + target: "acp::permission", + "sentinel field {field} exceeds {SENTINEL_STRING_MAX_BYTES} bytes ({}) — failing closed", + value.len() + ); + return None; + } + Some(()) +} + +/// Serialize a sentinel payload and enforce the total-content byte bound. +/// +/// Returns `None` (fail closed) when serialization fails or the serialized +/// content exceeds `SENTINEL_CONTENT_MAX_BYTES`. This is the single total-size +/// gate the Desktop parser mirrors (`MAX_CONTENT_BYTES`), so producer and parser +/// can never disagree on whether a given card is admissible. +fn serialize_bounded_sentinel(payload: &serde_json::Value) -> Option { + let content = serde_json::to_string(payload).ok()?; + if content.len() > SENTINEL_CONTENT_MAX_BYTES { + tracing::warn!( + target: "acp::permission", + "sentinel content exceeds {SENTINEL_CONTENT_MAX_BYTES} bytes ({}) — failing closed", + content.len() + ); + return None; + } + Some(content) +} + +/// The two card actions surfaced to the owner: the validated `allow_once` and +/// `reject_once` options, in that fixed order. Built by [`select_card_actions`] +/// so the sentinel can never advertise a third (e.g. `allow_always`) action. +#[derive(Debug, Clone)] +struct CardActions { + allow: serde_json::Value, + reject: serde_json::Value, +} + +impl CardActions { + /// The `optionId` of the validated `allow_once` action. + fn allow_id(&self) -> &str { + self.allow + .get("optionId") + .and_then(|v| v.as_str()) + .unwrap_or_default() + } + + /// The `optionId` of the validated `reject_once` action. + fn reject_id(&self) -> &str { + self.reject + .get("optionId") + .and_then(|v| v.as_str()) + .unwrap_or_default() + } + + /// True iff `option_id` is exactly one of the two ruled card actions. + /// The read loop gates owner decisions on this so a decision can never + /// select a forbidden option (e.g. `allow_always`) that an adapter offered + /// but the card never surfaced. + fn accepts(&self, option_id: &str) -> bool { + option_id == self.allow_id() || option_id == self.reject_id() + } +} + +/// Build the ordered `[optionIds, labels]` pair for a sentinel from the two +/// validated card actions. Order is fixed: allow first, reject second. +fn sentinel_option_fields(actions: &CardActions) -> (Vec, serde_json::Value) { + let mut option_ids = Vec::with_capacity(2); + let mut labels = serde_json::Map::with_capacity(2); + for opt in [&actions.allow, &actions.reject] { + // optionId presence/non-emptiness was validated by select_card_actions. + let id = opt + .get("optionId") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let name = opt.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let capped = truncate_to_bytes(name, SENTINEL_STRING_MAX_BYTES); + option_ids.push(serde_json::Value::String(id.to_string())); + labels.insert(id.to_string(), serde_json::Value::String(capped)); + } + (option_ids, labels.into()) +} + +/// Combined byte budget for the entire description string (title + argument +/// context). Matches `SENTINEL_STRING_MAX_BYTES` so the value is already +/// within the per-field sentinel cap, and the final +/// `build_sentinel_pending_payload` cap is a no-op identity for well-formed +/// descriptions. Keeping it at 200 leaves the context fragment at most +/// `200 - len(title) - 3` bytes (for the `(…)` wrapper) when title is short. +const DESCRIPTION_COMBINED_MAX_BYTES: usize = 200; + +/// Prefix characters that suggest a value is a secret or credential. +/// Checked against JSON object keys (lowercased) to decide whether a value +/// must be redacted before appending it to a card description. +/// +/// Design: narrow allowlist of truly suspicious prefixes rather than a wide +/// blocklist so legitimate fields (e.g. `token_count`, `pathname`) are not +/// inadvertently suppressed. The check is recursive — any nested object whose +/// key matches is also redacted. Secret-shaped values are replaced with +/// `""` regardless of their actual type. +const SECRET_KEY_PREFIXES: &[&str] = &[ + "secret", + "password", + "passwd", + "token", + "apikey", + "api_key", + "auth", + "credential", + "private", +]; + +/// Return `true` when `key` (lowercased) suggests a secret/credential value +/// that should be redacted from card descriptions. +fn is_secret_key(key: &str) -> bool { + let lower = key.to_lowercase(); + SECRET_KEY_PREFIXES + .iter() + .any(|prefix| lower.starts_with(prefix)) +} + +/// Produce a compact, human-readable argument context string from a +/// `rawInput` JSON object. +/// +/// Precedence within the object: +/// +/// 1. `command` — verbatim shell command string (already legible). +/// 2. File/path keys (`file`, `path`, `filename`, `filepath`, `target`, +/// `source`, `destination`, `url`) — most relevant for file-access tools. +/// 3. `cwd` — working-directory context. +/// 4. `reason` — rationale text provided by the caller. +/// 5. Compact JSON fallback: all non-secret scalar fields serialised as a +/// JSON object, e.g. `{"n":3,"mode":"fast"}`. +/// +/// Secret-bearing keys (see `is_secret_key`) are replaced with `""` +/// at every level before the fallback serialisation; they are also excluded +/// from the named-key paths (a field named `password` is never surfaced). +/// +/// Returns `None` when `raw_input` is not a JSON object, is null, or contains +/// no extractable non-secret fields. +fn summarize_raw_input(raw_input: &serde_json::Value) -> Option { + let obj = raw_input.as_object()?; + + // --- Priority 1: shell command --- + if let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) { + if !cmd.is_empty() && !is_secret_key("command") { + return Some(cmd.to_string()); + } + } + + // --- Priority 2: file / path keys --- + const FILE_KEYS: &[&str] = &[ + "file", + "path", + "filename", + "filepath", + "target", + "source", + "destination", + "url", + ]; + for key in FILE_KEYS { + if is_secret_key(key) { + continue; + } + if let Some(val) = obj.get(*key).and_then(|v| v.as_str()) { + if !val.is_empty() { + return Some(val.to_string()); + } + } + } + + // --- Priority 3: cwd --- + if let Some(cwd) = obj.get("cwd").and_then(|v| v.as_str()) { + if !cwd.is_empty() && !is_secret_key("cwd") { + return Some(cwd.to_string()); + } + } + + // --- Priority 4: reason --- + if let Some(reason) = obj.get("reason").and_then(|v| v.as_str()) { + if !reason.is_empty() && !is_secret_key("reason") { + return Some(reason.to_string()); + } + } + + // --- Priority 5: compact JSON fallback (scalars only, secrets redacted) --- + let mut sanitised = serde_json::Map::new(); + for (k, v) in obj { + if is_secret_key(k) { + sanitised.insert( + k.clone(), + serde_json::Value::String("".to_string()), + ); + } else if v.is_string() || v.is_number() || v.is_boolean() { + sanitised.insert(k.clone(), v.clone()); + } + // Skip null, arrays, nested objects in the fallback. + } + if sanitised.is_empty() { + return None; + } + serde_json::to_string(&sanitised).ok() +} + +/// Extract a truthful, bounded operation description from a +/// `session/request_permission` JSON-RPC message, trying real producer shapes +/// in priority order: +/// +/// 1. `params.title` — buzz-agent v2 top-level string (= `call.name`). +/// 2. `params.subject.toolCall.title` — v2 nested fallback (same value). +/// 3. `params.toolCall.title` — buzz-agent v1 / codex-acp permissions-request. +/// 4. `params.toolCall.rawInput.command` — codex-acp v1.1.7 command execution. +/// 5. `params._meta.codex.params.reason` — codex-acp v1.1.7 file-change. +/// +/// For paths 1–3 (buzz-agent v1/v2), the function also inspects the `rawInput` +/// object and appends a bounded argument-context summary so that two calls of +/// the same tool with different arguments produce distinguishable descriptions. +/// The combined form is `"(<context>)"` or `"<title>(<context>…)"` when +/// truncated. The combined output is capped at `DESCRIPTION_COMBINED_MAX_BYTES` +/// (200 UTF-8 bytes); if the context portion would be empty after extracting +/// all known fields, no parenthetical is appended. +/// +/// Context extraction order within `rawInput` (see `summarize_raw_input`): +/// `command` → file/path keys → `cwd` → `reason` → compact JSON fallback. +/// Secret-bearing keys (`token*`, `password*`, etc.) are redacted at all +/// levels before any fallback serialisation and are never surfaced verbatim. +/// +/// For paths 4–5 (codex-specific), the extracted string is concrete command or +/// reason text that already carries the distinguishing argument; no additional +/// summarisation is needed. +/// +/// Returns `None` when no non-empty string is found in any path, or when `msg` +/// does not have a `params` object. +/// +/// Extracted as a pure function so tests can exercise it with verbatim wire +/// shapes without going through the full permission-request lifecycle. +pub(crate) fn description_from_request_permission(msg: &serde_json::Value) -> Option<String> { + // Paths 1-3: tool-name title (buzz-agent v1/v2). + let title = [ + msg.pointer("/params/title").and_then(|v| v.as_str()), + msg.pointer("/params/subject/toolCall/title") + .and_then(|v| v.as_str()), + msg.pointer("/params/toolCall/title") + .and_then(|v| v.as_str()), + ] + .into_iter() + .flatten() + .find(|s| !s.is_empty()); + + if let Some(t) = title { + // v2 rawInput lives under `params.subject.toolCall.rawInput`; + // v1 lives under `params.toolCall.rawInput`. Read v2 first. + let raw_input = msg + .pointer("/params/subject/toolCall/rawInput") + .or_else(|| msg.pointer("/params/toolCall/rawInput")); + + return Some(match raw_input { + Some(ri) if ri.is_object() => { + // Budget allocation so the combined form always fits in + // DESCRIPTION_COMBINED_MAX_BYTES: + // + // wrapper overhead: 5 bytes — "(" + "…" (U+2026, 3 bytes) + ")" + // context reserve: 10 bytes — minimum useful argument context + // + // The title is capped to `DESCRIPTION_COMBINED_MAX_BYTES - + // wrapper_overhead - context_reserve` so that even a maximum-length + // title always leaves room for at least `context_reserve` bytes of + // argument context. Context is truncated with head+tail layout so + // that strings sharing a long common prefix remain distinguishable + // (suffix differences survive even when the budget is small). + const WRAPPER_OVERHEAD: usize = 5; // "(" + "…" (3 bytes) + ")" + const CONTEXT_RESERVE: usize = 10; // minimum visible context + let title_cap_limit = DESCRIPTION_COMBINED_MAX_BYTES + .saturating_sub(WRAPPER_OVERHEAD) + .saturating_sub(CONTEXT_RESERVE); + let title_cap = truncate_to_bytes(t, title_cap_limit); + let context_budget = DESCRIPTION_COMBINED_MAX_BYTES + .saturating_sub(title_cap.len()) + .saturating_sub(WRAPPER_OVERHEAD); + + match summarize_raw_input(ri) { + Some(ctx) if !ctx.is_empty() => { + let ctx_cap = truncate_to_bytes_head_tail(&ctx, context_budget); + let truncated = ctx.len() > ctx_cap.len(); + if truncated { + format!("{title_cap}({ctx_cap}…)") + } else { + format!("{title_cap}({ctx_cap})") + } + } + _ => title_cap, + } + } + _ => truncate_to_bytes(t, DESCRIPTION_COMBINED_MAX_BYTES), + }); + } + + // Paths 4-5: codex-specific fallbacks — concrete argument text. + [ + // codex-acp v1.1.7 command execution: toolCall.rawInput.command. + // Verbatim wire shape from codex-acp tag v1.1.7, + // `buildCommandPermissionRequest` in `CodexApprovalHandler.ts`. + msg.pointer("/params/toolCall/rawInput/command") + .and_then(|v| v.as_str()), + // codex-acp v1.1.7 file-change: reason in _meta.codex.params. + // Verbatim wire shape from `buildFileChangePermissionRequest`. + msg.pointer("/params/_meta/codex/params/reason") + .and_then(|v| v.as_str()), + ] + .into_iter() + .flatten() + .find(|s| !s.is_empty()) + .map(|s| truncate_to_bytes(s, DESCRIPTION_COMBINED_MAX_BYTES)) +} + +/// Build the JSON payload for a kind-9 PENDING sentinel card. +/// +/// Fails closed (`None`) when any bounded string field (`requestNonce`, +/// `turnId`, `sessionId`) exceeds `SENTINEL_STRING_MAX_BYTES`, when the total +/// serialized content exceeds `SENTINEL_CONTENT_MAX_BYTES`, or when +/// `serde_json::to_string` fails (the last is unreachable in practice). Labels +/// are truncated to the byte limit rather than rejected. A `None` return routes +/// to synchronous denial — no card is ever published. The `expiry_unix_secs` is +/// `min(registered_at + 300, hard_deadline)`. +/// +/// The card advertises EXACTLY the two ruled actions (allow_once, reject_once); +/// no other adapter option (e.g. `allow_always`) is ever forwarded. +fn build_sentinel_pending_payload( + nonce: &str, + actions: &CardActions, + expiry_unix_secs: u64, + session_id: Option<&str>, + turn_id: &str, + description: Option<&str>, +) -> Option<String> { + check_sentinel_field("requestNonce", nonce)?; + check_sentinel_field("turnId", turn_id)?; + if let Some(sid) = session_id { + check_sentinel_field("sessionId", sid)?; + } + let (option_ids, labels) = sentinel_option_fields(actions); + // Description is display-only — truncate rather than reject, matching the + // labels precedent. A None or empty subject is omitted from the payload. + let description_capped = description.map(|d| truncate_to_bytes(d, SENTINEL_STRING_MAX_BYTES)); + let payload = serde_json::json!({ + "v": 1, + "state": "pending", + "requestNonce": nonce, + "sessionId": session_id, + "turnId": turn_id, + "expiresAt": expiry_unix_secs, + "optionIds": option_ids, + "labels": labels, + "description": description_capped, + }); + serialize_bounded_sentinel(&payload) +} + +/// Build the JSON payload for a kind-40003 RESOLVED sentinel card edit. +#[allow(clippy::too_many_arguments)] +fn build_sentinel_resolved_payload( + nonce: &str, + original_event_id: &str, + actions: &CardActions, + expiry_unix_secs: u64, + session_id: Option<&str>, + turn_id: &str, + outcome: &str, + chosen_option_id: Option<&str>, + description: Option<&str>, +) -> Option<String> { + check_sentinel_field("requestNonce", nonce)?; + check_sentinel_field("turnId", turn_id)?; + if let Some(sid) = session_id { + check_sentinel_field("sessionId", sid)?; + } + if let Some(chosen) = chosen_option_id { + check_sentinel_field("chosenOptionId", chosen)?; + } + let (option_ids, labels) = sentinel_option_fields(actions); + let description_capped = description.map(|d| truncate_to_bytes(d, SENTINEL_STRING_MAX_BYTES)); + let payload = serde_json::json!({ + "v": 1, + "state": "resolved", + "requestNonce": nonce, + "originalEventId": original_event_id, + "sessionId": session_id, + "turnId": turn_id, + "expiresAt": expiry_unix_secs, + "optionIds": option_ids, + "labels": labels, + "outcome": outcome, + "chosenOptionId": chosen_option_id, + "description": description_capped, + }); + serialize_bounded_sentinel(&payload) +} + +/// Build and sign a kind-9 sentinel card event. +/// +/// Returns `None` when required context is absent (relay keys, channel ID, or +/// payload serialization fails). The event is signed by the agent's relay keys. +fn build_kind9_sentinel( + keys: &Keys, + channel_id: Uuid, + owner_pubkey_hex: &str, + thread_reply_event_id: Option<&str>, + content: &str, +) -> Option<nostr::Event> { + let mut tags = vec![ + Tag::parse(["h", &channel_id.to_string()]).ok()?, + Tag::parse(["p", owner_pubkey_hex]).ok()?, + ]; + if let Some(reply_id) = thread_reply_event_id { + // NIP-10 reply tag: ["e", <id>, "", "reply"] + tags.push(Tag::parse(["e", reply_id, "", "reply"]).ok()?); + } + EventBuilder::new(Kind::Custom(9), content) + .tags(tags) + .sign_with_keys(keys) + .ok() +} + +/// Build and sign a kind-40003 edit event targeting a kind-9 sentinel. +fn build_kind40003_sentinel( + keys: &Keys, + channel_id: Uuid, + target_event_id: &str, + content: &str, +) -> Option<nostr::Event> { + let tags = vec![ + Tag::parse(["h", &channel_id.to_string()]).ok()?, + Tag::parse(["e", target_event_id]).ok()?, + ]; + EventBuilder::new(Kind::Custom(40003), content) + .tags(tags) + .sign_with_keys(keys) + .ok() +} + +/// Retransmit an already-signed resolved kind-40003 edit until the relay +/// accepts it, bounded by `delivery_deadline`. +/// +/// The permission decision is irreversible before this runs (`finish_permission` +/// has already written the ACP response and removed the entry). A plain +/// fire-and-forget publish loses the edit whenever the socket is down at that +/// instant — the relay background task drops non-observer publishes while +/// disconnected — leaving the authoritative thread card stuck as "Timed out" +/// even though execution continued. Reusing the pending path's acked lane, this +/// retransmits the *same signed event* (idempotent by event id) on every +/// `Uncertain` outcome, pausing [`RESOLVED_RETRANSMIT_BACKOFF`] between tries so +/// a reconnect can carry it through. `Accepted`/`Rejected` are terminal (the +/// relay saw it). +/// +/// `delivery_deadline` is computed at resolution time as +/// `Instant::now() + RESOLVED_DELIVERY_WINDOW_SECS`, independent of the original +/// card/click deadline. The first publish attempt is **unconditional** — the +/// deadline is only consulted before each *retry* so that the relay always sees +/// at least one publication even when the caller supplies an already-expired +/// deadline (e.g. during ordinary timeouts where `entry_deadline` was already +/// past when `finish_permission` fired). +/// +/// Spawned detached so it never blocks the read loop. `event` is consumed and +/// resent by clone each attempt so the signature and id are stable across retries. +async fn retransmit_resolved_edit( + publisher: RelayEventPublisher, + event: nostr::Event, + delivery_deadline: tokio::time::Instant, +) { + let mut first_attempt = true; + loop { + // The first attempt is unconditional — an already-expired deadline must + // not prevent the single relay write that resolves the card. Subsequent + // retries (Uncertain outcome) are gated by the deadline so the loop + // terminates once the delivery window closes. + if !first_attempt && tokio::time::Instant::now() >= delivery_deadline { + tracing::warn!( + target: "acp::permission", + "resolved edit {} not accepted before delivery window — giving up", + event.id.to_hex() + ); + return; + } + first_attempt = false; + // Per-attempt ACK deadline: min(fixed publish timeout, delivery_deadline). + // Capping each attempt at SENTINEL_PUBLISH_TIMEOUT sweeps a stuck waiter + // promptly so the same signed event is resent, while the deadline check + // above keeps the overall delivery window as the outer bound. + let attempt_deadline = (tokio::time::Instant::now() + + std::time::Duration::from_secs(SENTINEL_PUBLISH_TIMEOUT_SECS)) + .min(delivery_deadline); + match publisher + .register_publish_ack(event.clone(), attempt_deadline) + .await + { + Ok(ack_rx) => match ack_rx.await.unwrap_or(crate::relay::AckOutcome::Uncertain) { + crate::relay::AckOutcome::Accepted => { + tracing::debug!( + target: "acp::permission", + "resolved edit {} accepted by relay", + event.id.to_hex() + ); + return; + } + crate::relay::AckOutcome::Rejected { message } => { + tracing::warn!( + target: "acp::permission", + "resolved edit {} rejected by relay: {message} — not retrying", + event.id.to_hex() + ); + return; + } + crate::relay::AckOutcome::Uncertain => { + // Socket down or ACK deadline swept: back off, then resend + // the identical signed event once a reconnect is possible. + } + }, + Err(_) => { + // Command channel closed — the relay task is gone for good; + // no reconnect will happen, so stop. + tracing::warn!( + target: "acp::permission", + "resolved edit {} publish channel closed — giving up", + event.id.to_hex() + ); + return; + } + } + tokio::time::sleep(RESOLVED_RETRANSMIT_BACKOFF).await; + } +} + +/// Select the unique `allow_once` option from a permission request's option list. +/// +/// Returns `Ok(option_id)` when there is exactly one option with `kind = +/// "allow_once"` and a non-empty `optionId`. Returns `Err(reason)` (fail +/// closed) when: +/// - zero `allow_once` options are present, +/// - multiple `allow_once` options are present (ambiguous), +/// - the matching option has a missing or empty `optionId`. +/// +/// `allow_always` options are deliberately not selected — they would grant +/// indefinite access without a per-request human decision. +fn select_allow_once(options: &[serde_json::Value]) -> Result<String, String> { + select_unique_option_id(options, "allow_once") +} + +/// Select the unique `reject_once` option's `optionId` from a request's option +/// list. Same fail-closed semantics as [`select_allow_once`]. +fn select_reject_once(options: &[serde_json::Value]) -> Result<String, String> { + select_unique_option_id(options, "reject_once") +} + +/// Return the `optionId` of the single option whose `kind` matches `kind`. +/// +/// Returns `Err(reason)` (fail closed) when zero or multiple options match, or +/// when the matching option's `optionId` is missing/empty. +fn select_unique_option_id(options: &[serde_json::Value], kind: &str) -> Result<String, String> { + let candidates: Vec<&serde_json::Value> = options + .iter() + .filter(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some(kind)) + .collect(); + + match candidates.len() { + 0 => Err(format!("no {kind} option found")), + 2.. => Err(format!( + "multiple {kind} options found ({}); ambiguous", + candidates.len() + )), + 1 => candidates[0] + .get("optionId") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("{kind} option has missing or empty optionId")), + } +} + +/// Select the exactly-two card actions from a permission request's options: +/// the unique `allow_once` and the unique `reject_once`. Returns their option +/// objects (with `kind`/`name`/`optionId`) so the caller can build a card that +/// offers ONLY those two, fail closed otherwise. +/// +/// This is the single enforcement point for the ruled product contract: +/// Allow-once / Reject only. `allow_always` and any other adapter option are +/// never surfaced as an actionable button. +fn select_card_actions(options: &[serde_json::Value]) -> Result<CardActions, String> { + let allow_id = select_allow_once(options)?; + let reject_id = select_reject_once(options)?; + for id in [&allow_id, &reject_id] { + if id.len() > SENTINEL_STRING_MAX_BYTES { + return Err(format!( + "optionId exceeds {SENTINEL_STRING_MAX_BYTES} bytes: {} > {}", + id.len(), + SENTINEL_STRING_MAX_BYTES + )); + } + } + let find = |target: &str| -> serde_json::Value { + options + .iter() + .find(|o| o.get("optionId").and_then(|v| v.as_str()) == Some(target)) + .cloned() + .unwrap_or(serde_json::Value::Null) + }; + Ok(CardActions { + allow: find(&allow_id), + reject: find(&reject_id), + }) +} + +/// Ask-only admission gates the caller precomputes from live map state. Grouped +/// so the preflight signature stays small; each field short-circuits a distinct +/// fail-closed reason (see checks 6, 7, 7b below). +struct AskGates { + is_duplicate_id: bool, + is_map_at_cap: bool, + is_publish_in_flight: bool, +} + +/// Validate a `session/request_permission` request before it touches the +/// pending map or policy dispatch. +/// +/// Returns `Ok(())` on a clean request; `Err(reason)` on the first violation. +/// +/// Checks (in order): +/// 1. `options` nonempty. +/// 2. `options` count ≤ `PERMISSION_OPTIONS_MAX`. +/// 3. Every `optionId` is present and non-empty. +/// 4. Every `optionId` is unique across the request. +/// 5. Every option has a non-empty `kind` and `name`. +/// 6. Duplicate live `requestId` (only relevant under `ask`, caller passes flag). +/// 7. Permission map at capacity (only relevant under `ask`, caller passes flag). +/// 8. Full serialised `ObserverEvent` (raw payload + all envelope fields + real +/// context) fits within `OBSERVER_MAX_PLAINTEXT_LEN` — no leaf surgery on frames. +fn run_admission_preflight( + _id: &serde_json::Value, + options: &[serde_json::Value], + msg: &serde_json::Value, + _policy: PermissionPolicy, + ask_gates: AskGates, + size_ctx: (&ObserverContext, Option<usize>), +) -> Result<(), String> { + let AskGates { + is_duplicate_id, + is_map_at_cap, + is_publish_in_flight, + } = ask_gates; + let (observer_context, agent_index) = size_ctx; + // 1. options nonempty + if options.is_empty() { + return Err("options array is empty".to_string()); + } + + // 2. count ≤ PERMISSION_OPTIONS_MAX + if options.len() > PERMISSION_OPTIONS_MAX { + return Err(format!( + "too many options: {} > {}", + options.len(), + PERMISSION_OPTIONS_MAX + )); + } + + // 3 & 4. optionId present, non-empty, unique + let mut seen_ids = std::collections::HashSet::new(); + for opt in options { + let option_id = opt + .get("optionId") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| "option has missing or empty optionId".to_string())?; + if !seen_ids.insert(option_id) { + return Err(format!("duplicate optionId: {option_id:?}")); + } + } + + // 5. required kind and name fields + for opt in options { + if opt + .get("kind") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .is_none() + { + return Err("option has missing or empty kind".to_string()); + } + if opt + .get("name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .is_none() + { + return Err("option has missing or empty name".to_string()); + } + } + + // 6. duplicate live requestId (ask only — caller computes flag) + if is_duplicate_id { + return Err("duplicate live requestId".to_string()); + } + + // 7. map at capacity (ask only — caller computes flag) + if is_map_at_cap { + return Err(format!( + "pending permission map at capacity ({})", + PERMISSION_MAP_CAP + )); + } + + // 7b. a sentinel publish is already in flight (ask only — one at a time) + if is_publish_in_flight { + return Err("a sentinel publish is already in flight".to_string()); + } + + // 8. Full annotated `ObserverEvent` fits within `OBSERVER_MAX_PLAINTEXT_LEN`. + // + // Construct the exact production `ObserverEvent` with the real observer context + // and a representative nonce. Serialise it and reject if over cap. This is the + // same construction path the observer uses at emit time, so any payload that + // passes here is guaranteed to fit in the final frame — no leaf surgery needed. + // + // A UUID nonce is used for sizing; the actual nonce is generated after the + // preflight passes, but all nonces are the same UUID length. + let candidate_event = ObserverEvent { + seq: u64::MAX, // worst-case seq (19 digits) + timestamp: "2026-01-01T00:00:00.000000000+00:00".to_string(), // max RFC3339 len + kind: "acp_read".to_string(), + agent_index, + channel_id: observer_context.channel_id.clone(), + session_id: observer_context.session_id.clone(), + turn_id: observer_context.turn_id.clone(), + started_at: observer_context.started_at.clone(), + authorization: Some(AuthorizationEnvelope { + // UUID nonce — all production nonces are this length. + request_nonce: "00000000-0000-0000-0000-000000000000".to_string(), + actionable: true, + reason: None, + expires_at: None, + }), + payload: msg.clone(), + }; + let annotated_len = serde_json::to_string(&candidate_event) + .map(|s| s.len()) + .unwrap_or(usize::MAX); + if annotated_len > OBSERVER_MAX_PLAINTEXT_LEN { + return Err(format!( + "permission request payload too large: annotated size {annotated_len} > {OBSERVER_MAX_PLAINTEXT_LEN}" + )); + } + + Ok(()) +} + /// Full `session/new` response — session ID plus the raw JSON result. /// /// Callers use the extractor helpers to pull model info from `raw`. @@ -2351,6 +4837,7 @@ fn configure_no_window(cmd: &mut tokio::process::Command) { #[cfg(test)] mod tests { use super::*; + use crate::config::ModeSource; #[test] fn stop_reason_parses_all_known_values() { @@ -2396,63 +4883,100 @@ mod tests { assert_eq!(StopReason::from_str("Refusal"), Some(StopReason::Refusal)); } + fn options(json: &str) -> Vec<serde_json::Value> { + serde_json::from_str(json).expect("option list") + } + + fn outcome(response: &serde_json::Value) -> Option<&str> { + response["result"]["outcome"]["outcome"].as_str() + } + + /// The offered `allow_once` and `allow_always` options must be ignored: + /// there is no human to click them, so choosing either would make every + /// admitted prompt an implicit approval. `optionId`s are deliberately + /// non-obvious to prove they are matched by `kind`, never hardcoded. #[test] - fn find_allow_once_by_kind_not_by_option_id() { - // optionId values are intentionally non-obvious to prove we don't hardcode them. - let options: Vec<serde_json::Value> = serde_json::from_str( + fn permission_requests_select_reject_once_not_allow_once() { + let options = options( r#"[ {"optionId": "opt-reject-42", "name": "Reject", "kind": "reject_once"}, {"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"}, {"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"} ]"#, - ) - .unwrap(); + ); - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); + let response = + permission_denial_response(&serde_json::json!(7), &options).expect("denial response"); - assert!(allow_once.is_some(), "should find allow_once option"); - let opt = allow_once.unwrap(); - // Found by kind, not by hardcoded optionId - assert_eq!(opt["kind"].as_str(), Some("allow_once")); - assert_eq!(opt["optionId"].as_str(), Some("opt-allow-99")); + assert_eq!(outcome(&response), Some("selected")); + assert_eq!( + response["result"]["outcome"]["optionId"].as_str(), + Some("opt-reject-42"), + "must select reject_once even when allow options are offered" + ); } + /// Fail-closed backstop: an adapter that offers no `reject_once` must still + /// be denied, via the protocol's cancelled outcome rather than an error or + /// an approval. #[test] - fn find_allow_once_returns_none_when_absent() { - let options: Vec<serde_json::Value> = serde_json::from_str( + fn permission_request_without_reject_once_is_cancelled() { + let options = options( r#"[ - {"optionId": "reject-1", "name": "Reject", "kind": "reject_once"}, - {"optionId": "reject-always", "name": "Always reject", "kind": "reject_always"} + {"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"}, + {"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"} ]"#, - ) - .unwrap(); + ); - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); + let response = permission_denial_response(&serde_json::json!("req-1"), &options) + .expect("cancelled response"); - assert!(allow_once.is_none()); + assert_eq!(outcome(&response), Some("cancelled")); + assert_eq!( + response["id"].as_str(), + Some("req-1"), + "string ids must round-trip per JSON-RPC 2.0" + ); } + /// An empty option list is the degenerate form of the same backstop. #[test] - fn find_reject_once_fallback_when_no_allow_once() { - let options: Vec<serde_json::Value> = serde_json::from_str( - r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#, - ) - .unwrap(); + fn permission_request_with_no_options_is_cancelled() { + let response = + permission_denial_response(&serde_json::json!(1), &[]).expect("cancelled response"); - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); - assert!(allow_once.is_none()); + assert_eq!(outcome(&response), Some("cancelled")); + } - let reject_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); - assert!(reject_once.is_some()); - assert_eq!(reject_once.unwrap()["optionId"].as_str(), Some("rej-x")); + /// A `reject_once` option missing its `optionId` falls back to a `cancelled` + /// response rather than propagating a Protocol error. This ensures the adapter + /// always receives a valid JSON-RPC response, even for malformed requests. + #[test] + fn reject_once_without_option_id_falls_back_to_cancelled() { + let options = options(r#"[{"name": "Reject", "kind": "reject_once"}]"#); + + let response = permission_denial_response(&serde_json::json!(1), &options) + .expect("malformed reject_once must not error"); + + assert_eq!( + response["result"]["outcome"]["outcome"].as_str(), + Some("cancelled"), + "malformed reject_once must produce cancelled, got: {response}" + ); + } + + #[test] + fn find_reject_once_by_kind() { + let options = + options(r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#); + + let response = + permission_denial_response(&serde_json::json!(1), &options).expect("denial response"); + + assert_eq!( + response["result"]["outcome"]["optionId"].as_str(), + Some("rej-x") + ); } #[test] @@ -5027,4 +7551,5633 @@ mod tests { "error must mention sandbox_workspace_write" ); } + + // ══════════════════════════════════════════════════════════════════════════ + // ── Permission policy: pinned tests (#4938) ─────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════ + // + // Tests are grouped by the pinned requirement they cover, labelled as + // "Pinned §N" matching the spec's numbered list. + // + // These tests use: + // • `spawn_inert_client()` (cat) for pure unit coverage of `handle_permission_request`. + // • `spawn_script(s)` for end-to-end coverage of `read_until_response_with_idle_timeout`. + // • `AcpClient::set_permission_config` / `set_owner_pubkey_known` helpers. + // + // "observer" is left None for tests that only care about deny/allow path; + // an in-process observer is installed for tests that verify acp_write events. + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// Build a minimal `session/request_permission` JSON-RPC message. + fn perm_request(id: u64, options: &[(&str, &str, &str)]) -> serde_json::Value { + let opts: Vec<serde_json::Value> = options + .iter() + .map(|(opt_id, kind, name)| { + serde_json::json!({"optionId": opt_id, "kind": kind, "name": name}) + }) + .collect(); + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "session/request_permission", + "params": { + "sessionId": "sess-test", + "options": opts, + } + }) + } + + /// Canonical 3-option set used in most tests. + fn default_opts() -> &'static [(&'static str, &'static str, &'static str)] { + &[ + ("opt-allow", "allow_once", "Allow once"), + ("opt-reject", "reject_once", "Reject once"), + ("opt-always", "allow_always", "Always allow"), + ] + } + + /// A canonical `CardActions` pair for tests that construct a + /// `PermissionEntry` directly (allow-once `opt-allow`, reject-once + /// `opt-reject`). + fn test_card_actions() -> CardActions { + CardActions { + allow: serde_json::json!({"optionId":"opt-allow","kind":"allow_once","name":"Allow once"}), + reject: serde_json::json!({"optionId":"opt-reject","kind":"reject_once","name":"Reject once"}), + } + } + + /// Set policy=allow on a client and mark owner known. + fn set_policy(client: &mut AcpClient, policy: PermissionPolicy) { + let config = ResolvedPermissionConfig::resolve(policy, None).expect("valid policy"); + client.set_permission_config(config); + client.set_owner_pubkey_known(true); + } + + /// Install a matching owner/initiator relay context on `client` so that the + /// D7-final admission check passes and `handle_permission_request` inserts an + /// entry as `Publishing` instead of denying synchronously. + /// + /// The test_pair publisher auto-ACKs every `PublishEventAcked` command with + /// `AckOutcome::Accepted`. A background task drains the event receiver so the + /// channel never fills and blocks the background task inside the publisher. + /// + /// Returns the matching owner `Keys` so callers that need a non-owner pubkey + /// can derive a different key for negative tests. + fn install_test_relay_context(client: &mut AcpClient) -> Keys { + let keys = Keys::generate(); + let owner_hex = keys.public_key().to_hex(); + let (publisher, event_rx) = crate::relay::RelayEventPublisher::test_pair(); + // Drain published events so the channel never fills. + tokio::spawn(async move { + let mut rx = event_rx; + while rx.recv().await.is_some() {} + }); + client.set_relay_publisher(publisher, keys.clone()); + client.set_agent_owner_pubkey_hex(Some(owner_hex)); + client.set_turn_initiator_pubkey(Some(keys.public_key())); + client.set_turn_channel_context( + Some(uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()), + None, + ); + keys + } + + // ── Pinned §2: allow selector — unique/zero/multiple/malformed ──────────── + + #[test] + fn allow_selector_picks_unique_allow_once() { + // Unique allow_once → Ok with that optionId. + let opts = serde_json::from_str::<Vec<serde_json::Value>>( + r#"[{"optionId":"opt-a","kind":"allow_once","name":"Allow"}, + {"optionId":"opt-r","kind":"reject_once","name":"Reject"}]"#, + ) + .unwrap(); + assert_eq!(select_allow_once(&opts), Ok("opt-a".to_string())); + } + + #[test] + fn allow_selector_fails_closed_on_zero_allow_once() { + // No allow_once options → fail closed. + let opts = serde_json::from_str::<Vec<serde_json::Value>>( + r#"[{"optionId":"opt-r","kind":"reject_once","name":"Reject"}]"#, + ) + .unwrap(); + assert!(select_allow_once(&opts).is_err()); + } + + #[test] + fn allow_selector_fails_closed_on_multiple_allow_once() { + // Two allow_once candidates → ambiguous, fail closed. + let opts = serde_json::from_str::<Vec<serde_json::Value>>( + r#"[{"optionId":"opt-a1","kind":"allow_once","name":"A1"}, + {"optionId":"opt-a2","kind":"allow_once","name":"A2"}]"#, + ) + .unwrap(); + assert!(select_allow_once(&opts).is_err()); + } + + #[test] + fn allow_selector_fails_closed_on_missing_option_id() { + // allow_once present but optionId absent → malformed, fail closed. + let opts = serde_json::from_str::<Vec<serde_json::Value>>( + r#"[{"kind":"allow_once","name":"Allow"}]"#, + ) + .unwrap(); + assert!(select_allow_once(&opts).is_err()); + } + + #[test] + fn allow_selector_never_selects_allow_always() { + // allow_always must NOT be selected even when it is the only option + // with an "allow" kind — indefinite access without per-request approval. + let opts = serde_json::from_str::<Vec<serde_json::Value>>( + r#"[{"optionId":"opt-aa","kind":"allow_always","name":"Always"}]"#, + ) + .unwrap(); + assert!( + select_allow_once(&opts).is_err(), + "allow_always must never be auto-selected" + ); + } + + // ── F1: two-action card contract — select_card_actions + accepts ───────── + + #[test] + fn select_card_actions_picks_exactly_allow_and_reject_dropping_allow_always() { + // A request offering allow_once, reject_once, AND allow_always must + // yield a card carrying only the two ruled actions. + let opts = serde_json::from_str::<Vec<serde_json::Value>>( + r#"[{"optionId":"a","kind":"allow_once","name":"Allow"}, + {"optionId":"r","kind":"reject_once","name":"Reject"}, + {"optionId":"aa","kind":"allow_always","name":"Always"}]"#, + ) + .unwrap(); + let actions = select_card_actions(&opts).expect("must select the two ruled actions"); + assert_eq!(actions.allow_id(), "a"); + assert_eq!(actions.reject_id(), "r"); + // The forbidden allow_always option is neither surfaced nor acceptable. + assert!(actions.accepts("a"), "allow_once must be accepted"); + assert!(actions.accepts("r"), "reject_once must be accepted"); + assert!( + !actions.accepts("aa"), + "allow_always must never be an acceptable decision" + ); + } + + #[test] + fn select_card_actions_fails_closed_without_both_actions() { + // Missing reject_once → fail closed (no card). + let allow_only = serde_json::from_str::<Vec<serde_json::Value>>( + r#"[{"optionId":"a","kind":"allow_once","name":"Allow"}]"#, + ) + .unwrap(); + assert!(select_card_actions(&allow_only).is_err()); + + // Missing allow_once → fail closed. + let reject_only = serde_json::from_str::<Vec<serde_json::Value>>( + r#"[{"optionId":"r","kind":"reject_once","name":"Reject"}]"#, + ) + .unwrap(); + assert!(select_card_actions(&reject_only).is_err()); + + // Ambiguous (two allow_once) → fail closed. + let ambiguous = serde_json::from_str::<Vec<serde_json::Value>>( + r#"[{"optionId":"a1","kind":"allow_once","name":"A1"}, + {"optionId":"a2","kind":"allow_once","name":"A2"}, + {"optionId":"r","kind":"reject_once","name":"R"}]"#, + ) + .unwrap(); + assert!(select_card_actions(&ambiguous).is_err()); + } + + #[test] + fn select_card_actions_fails_closed_on_oversized_option_id() { + // An adversarial adapter embedding an oversized optionId must be + // rejected before it can inflate the sentinel/DOM. + let big = "x".repeat(SENTINEL_STRING_MAX_BYTES + 1); + let opts = serde_json::json!([ + {"optionId": big, "kind": "allow_once", "name": "Allow"}, + {"optionId": "r", "kind": "reject_once", "name": "Reject"}, + ]); + let opts = opts.as_array().unwrap().clone(); + assert!( + select_card_actions(&opts).is_err(), + "an optionId over SENTINEL_STRING_MAX_BYTES must fail closed" + ); + } + + // ── F3: frozen sentinel byte bounds (producer side) ────────────────────── + + #[test] + fn build_sentinel_pending_fails_closed_on_oversized_session_id() { + // The adapter-supplied sessionId is unbounded upstream. An oversized one + // must abort sentinel construction — never publish a card the Desktop + // parser rejects (which renders as raw JSON until timeout). + let actions = test_card_actions(); + let big_session = "s".repeat(SENTINEL_STRING_MAX_BYTES + 1); + let out = build_sentinel_pending_payload( + "nonce-abc", + &actions, + 1_700_000_300, + Some(&big_session), + "turn-xyz", + None, + ); + assert!( + out.is_none(), + "an over-limit sessionId must fail closed (no sentinel)" + ); + } + + #[test] + fn build_sentinel_pending_fails_closed_on_oversized_nonce() { + let actions = test_card_actions(); + let big_nonce = "n".repeat(SENTINEL_STRING_MAX_BYTES + 1); + let out = build_sentinel_pending_payload( + &big_nonce, + &actions, + 1_700_000_300, + Some("sess"), + "turn-xyz", + None, + ); + assert!(out.is_none(), "an over-limit nonce must fail closed"); + } + + #[test] + fn build_sentinel_pending_at_session_id_limit_succeeds() { + // Exactly at the limit must succeed — the gate is not over-tight. + let actions = test_card_actions(); + let session = "s".repeat(SENTINEL_STRING_MAX_BYTES); + let out = build_sentinel_pending_payload( + "nonce-abc", + &actions, + 1_700_000_300, + Some(&session), + "turn-xyz", + None, + ); + assert!( + out.is_some(), + "a sessionId exactly at SENTINEL_STRING_MAX_BYTES must be accepted" + ); + } + + #[test] + fn sentinel_label_truncated_to_byte_limit_on_char_boundary() { + // A multibyte label over the byte limit is truncated on a char boundary, + // yielding valid UTF-8 within SENTINEL_STRING_MAX_BYTES that the Desktop + // byte-bounded parser accepts. + let big_label = "😀".repeat(60); // 240 UTF-8 bytes + let opts = serde_json::json!([ + {"optionId":"a","kind":"allow_once","name": big_label}, + {"optionId":"r","kind":"reject_once","name":"Reject"}, + ]); + let opts = opts.as_array().unwrap().clone(); + let actions = select_card_actions(&opts).expect("two actions"); + let (_, labels) = sentinel_option_fields(&actions); + let label = labels["a"].as_str().unwrap(); + assert!( + label.len() <= SENTINEL_STRING_MAX_BYTES, + "label must be truncated to <= {SENTINEL_STRING_MAX_BYTES} bytes, got {}", + label.len() + ); + // Every 😀 is 4 bytes, so a byte-boundary truncation at 200 keeps 50 of + // them (200 bytes) — never a split scalar. + assert!( + label.chars().all(|c| c == '😀'), + "truncation must land on a char boundary (no mojibake)" + ); + } + + #[test] + fn build_sentinel_fails_closed_on_oversized_total_content() { + // Drive serialize_bounded_sentinel directly to prove the total-content + // gate rejects an oversized payload. (Per-field-valid input can reach + // this gate through select_card_actions too — JSON escaping expands + // control characters, so distinct 200-byte option IDs built from + // U+0000/U+0001, repeated as optionIds and label keys, inflate the + // serialized total past SENTINEL_CONTENT_MAX_BYTES — but a synthetic + // oversized payload exercises the gate in isolation.) + let mut labels = serde_json::Map::new(); + labels.insert("a".into(), serde_json::json!("x".repeat(200))); + let payload = serde_json::json!({ + "v": 1, + "state": "pending", + "pad": "y".repeat(SENTINEL_CONTENT_MAX_BYTES), + "labels": labels, + }); + assert!( + serialize_bounded_sentinel(&payload).is_none(), + "total content over SENTINEL_CONTENT_MAX_BYTES must fail closed" + ); + } + + #[test] + fn build_sentinel_resolved_fails_closed_on_oversized_chosen_option_id() { + let actions = test_card_actions(); + let big_chosen = "c".repeat(SENTINEL_STRING_MAX_BYTES + 1); + let out = build_sentinel_resolved_payload( + "nonce-abc", + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead", + &actions, + 1_700_000_300, + Some("sess"), + "turn-xyz", + "applied", + Some(&big_chosen), + None, + ); + assert!( + out.is_none(), + "an over-limit chosenOptionId must fail closed" + ); + } + + // ── F2: description field — truncation and omission ─────────────────────── + + #[test] + fn build_sentinel_pending_description_present_and_within_limit() { + let actions = test_card_actions(); + let out = build_sentinel_pending_payload( + "nonce-abc", + &actions, + 1_700_000_300, + Some("sess"), + "turn-xyz", + Some("read a file"), + ) + .expect("must succeed with a short description"); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!( + v["description"], "read a file", + "description must round-trip" + ); + } + + #[test] + fn build_sentinel_pending_description_none_omits_field() { + let actions = test_card_actions(); + let out = build_sentinel_pending_payload( + "nonce-abc", + &actions, + 1_700_000_300, + Some("sess"), + "turn-xyz", + None, + ) + .expect("must succeed without description"); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + // `None` produces `"description": null` in the JSON, not a missing key. + assert!( + v["description"].is_null(), + "description must be null when not provided" + ); + } + + #[test] + fn build_sentinel_pending_description_truncated_on_producer_side() { + // An over-limit description is truncated at a char boundary and accepted + // (display-only — truncate, not reject, matching the labels precedent). + let actions = test_card_actions(); + let over_limit = "a".repeat(SENTINEL_STRING_MAX_BYTES + 50); + let out = build_sentinel_pending_payload( + "nonce-abc", + &actions, + 1_700_000_300, + Some("sess"), + "turn-xyz", + Some(&over_limit), + ) + .expect("over-limit description must be truncated and accepted"); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + let description = v["description"] + .as_str() + .expect("description must be a string"); + assert!( + description.len() <= SENTINEL_STRING_MAX_BYTES, + "truncated description must be within limit: got {} bytes", + description.len() + ); + } + + #[test] + fn build_sentinel_pending_description_multibyte_truncated_on_char_boundary() { + // A multibyte description over the byte limit is truncated on a char + // boundary, yielding valid UTF-8 within SENTINEL_STRING_MAX_BYTES. + let actions = test_card_actions(); + let big_desc = "😀".repeat(60); // 240 UTF-8 bytes > 200 + let out = build_sentinel_pending_payload( + "nonce-abc", + &actions, + 1_700_000_300, + Some("sess"), + "turn-xyz", + Some(&big_desc), + ) + .expect("multibyte over-limit description must be truncated and accepted"); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + let desc = v["description"] + .as_str() + .expect("description must be a string"); + assert!( + desc.len() <= SENTINEL_STRING_MAX_BYTES, + "truncated multibyte description must be within limit" + ); + // Must be valid UTF-8 — verify by checking no decoding errors. + assert!( + std::str::from_utf8(desc.as_bytes()).is_ok(), + "truncated description must be valid UTF-8" + ); + } + + // ── F2: description extraction from real producer wire shapes ───────────── + // + // These tests call `description_from_request_permission` with verbatim wire + // shapes produced by real adapters. If the extraction pointer changes, at + // least one test goes red — preventing the "green tests, dead feature" trap. + + #[test] + fn description_from_v2_params_title() { + // buzz-agent v2: `request_permission_params(2, ...)` from wire.rs. + // `params.title` = call.name; `params.subject.toolCall.rawInput` = call.arguments. + // The description must include BOTH the tool name AND a summary of rawInput + // so two calls of the same tool with different commands are distinguishable. + // Verbatim shape from `request_permission_params` in + // `crates/buzz-agent/src/wire.rs` (version >= 2 branch). + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "session/request_permission", + "params": { + "sessionId": "ses-1", + "title": "fake__shell", + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-abc", + "title": "fake__shell", + "rawInput": {"command": "ls -la /tmp"}, + }, + }, + "options": [], + } + }); + let desc = description_from_request_permission(&msg); + // Must include the tool name AND the rawInput summary. + let desc_str = desc.as_deref().expect("v2 must yield a description"); + assert!( + desc_str.starts_with("fake__shell("), + "v2 description must include tool name; got {desc_str:?}" + ); + assert!( + desc_str.contains("ls -la /tmp"), + "v2 description must include rawInput command; got {desc_str:?}" + ); + } + + #[test] + fn description_from_v2_two_distinct_commands_are_distinguishable() { + // Two v2 calls of the SAME tool (fake__shell) with different commands must + // produce different descriptions — the informed-consent requirement. + let make_msg = |cmd: &str| { + serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "method": "session/request_permission", + "params": { + "sessionId": "ses-x", + "title": "fake__shell", + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-x", + "title": "fake__shell", + "rawInput": {"command": cmd}, + }, + }, + "options": [], + } + }) + }; + let desc_ls = description_from_request_permission(&make_msg("ls /tmp")) + .expect("must yield a description"); + let desc_rm = description_from_request_permission(&make_msg("rm -rf /home")) + .expect("must yield a description"); + assert_ne!( + desc_ls, desc_rm, + "different shell commands must produce distinguishable descriptions" + ); + assert!( + desc_ls.contains("ls /tmp"), + "description must carry the actual command: {desc_ls:?}" + ); + assert!( + desc_rm.contains("rm -rf /home"), + "description must carry the actual command: {desc_rm:?}" + ); + } + + #[test] + fn description_from_v2_file_path_argument_is_visible() { + // A file-read tool call with a path argument must surface the path in the + // description so the owner can identify which file is being accessed. + let msg = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "method": "session/request_permission", + "params": { + "sessionId": "ses-y", + "title": "read_file", + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-y", + "title": "read_file", + "rawInput": {"path": "/etc/secrets/api.key"}, + }, + }, + "options": [], + } + }); + let desc = description_from_request_permission(&msg).expect("must yield a description"); + assert!( + desc.contains("/etc/secrets/api.key"), + "file path must appear in description: {desc:?}" + ); + } + + #[test] + fn description_from_v2_hostile_markup_in_rawinput_is_safe() { + // rawInput containing HTML/script tags is stored as a plain JSON string — + // not interpreted as markup by the extractor. The description is used in + // a React text node, not dangerouslySetInnerHTML, so the content is safe + // at render time. This test confirms the extractor does not strip or + // reject hostile content (stripping would defeat truthfulness). + let msg = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "method": "session/request_permission", + "params": { + "sessionId": "ses-z", + "title": "eval_code", + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-z", + "title": "eval_code", + "rawInput": {"code": "<script>alert(1)</script>"}, + }, + }, + "options": [], + } + }); + let desc = description_from_request_permission(&msg).expect("must yield a description"); + // The raw tag text is preserved verbatim (safe in a text node), not stripped. + assert!( + desc.contains("script"), + "hostile markup must be preserved as plain text: {desc:?}" + ); + // The description is a regular Rust String — valid UTF-8, no panic. + assert!(!desc.is_empty()); + } + + #[test] + fn description_from_v2_control_characters_in_rawinput_are_preserved() { + // Control characters in rawInput are encoded as \uXXXX in JSON, so the + // compact JSON form is safe ASCII. The extractor does not reject them. + let msg = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "method": "session/request_permission", + "params": { + "sessionId": "ses-ctrl", + "title": "exec", + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-ctrl", + "title": "exec", + "rawInput": {"cmd": "echo\x00\x01\x1b"}, + }, + }, + "options": [], + } + }); + let desc = description_from_request_permission(&msg) + .expect("must yield a description for control-char input"); + assert!( + desc.starts_with("exec("), + "description must start with tool name: {desc:?}" + ); + } + + #[test] + fn description_from_v2_rawinput_truncated_to_byte_limit() { + // A very large rawInput command is truncated so the total description + // fits within DESCRIPTION_COMBINED_MAX_BYTES. The truncated form includes + // the "…" marker. + let big_cmd = "x".repeat(500); + let msg = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "method": "session/request_permission", + "params": { + "sessionId": "ses-big", + "title": "fake__shell", + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-big", + "title": "fake__shell", + "rawInput": {"command": big_cmd}, + }, + }, + "options": [], + } + }); + let desc = description_from_request_permission(&msg) + .expect("must yield a description even for oversized rawInput"); + assert!( + desc.len() <= DESCRIPTION_COMBINED_MAX_BYTES, + "combined description must be within DESCRIPTION_COMBINED_MAX_BYTES ({DESCRIPTION_COMBINED_MAX_BYTES}): {} bytes, got {desc:?}", + desc.len() + ); + // Must include the truncation marker. + assert!( + desc.contains('…'), + "truncated description must contain the ellipsis marker: {desc:?}" + ); + } + + #[test] + fn description_from_v2_utf8_truncation_on_char_boundary() { + // rawInput with multibyte characters (e.g. emoji) is truncated on a + // character boundary so the result is always valid UTF-8. + let emoji_cmd = "🚀".repeat(40); // 4 bytes each → 160 bytes > 120 limit + let msg = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "method": "session/request_permission", + "params": { + "sessionId": "ses-utf8", + "title": "run", + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-utf8", + "title": "run", + "rawInput": {"cmd": emoji_cmd}, + }, + }, + "options": [], + } + }); + let desc = description_from_request_permission(&msg) + .expect("must yield a description for multibyte rawInput"); + assert!( + std::str::from_utf8(desc.as_bytes()).is_ok(), + "description must be valid UTF-8 after truncation: {desc:?}" + ); + } + + #[test] + fn description_from_v1_toolcall_title() { + // buzz-agent v1: `request_permission_params(1, ...)` from wire.rs. + // No top-level `title`; `params.toolCall.title` carries the name and + // `params.toolCall.rawInput` carries the arguments. + // Also matches codex-acp's permissions-request variant (kind: "other"). + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "session/request_permission", + "params": { + "sessionId": "ses-2", + "toolCall": { + "toolCallId": "tc-def", + "title": "read_file", + "kind": "other", + "rawInput": {"path": "/etc/hosts"}, + }, + "options": [], + } + }); + let desc = description_from_request_permission(&msg); + let desc_str = desc.as_deref().expect("v1 must yield a description"); + assert!( + desc_str.starts_with("read_file("), + "v1 description must include tool name; got {desc_str:?}" + ); + assert!( + desc_str.contains("/etc/hosts"), + "v1 description must include path argument; got {desc_str:?}" + ); + } + + #[test] + fn description_from_v1_two_distinct_commands_are_distinguishable() { + // Two v1 calls of the SAME tool with different rawInputs must be + // distinguishable — matching the requirement for v2 above. + let make_msg = |path: &str| { + serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "method": "session/request_permission", + "params": { + "sessionId": "ses-v1", + "toolCall": { + "toolCallId": "tc-v1", + "title": "read_file", + "kind": "other", + "rawInput": {"path": path}, + }, + "options": [], + } + }) + }; + let desc_a = description_from_request_permission(&make_msg("/etc/hosts")) + .expect("must yield description"); + let desc_b = description_from_request_permission(&make_msg("/etc/shadow")) + .expect("must yield description"); + assert_ne!( + desc_a, desc_b, + "different paths must yield different descriptions" + ); + } + + #[test] + fn description_title_only_when_rawinput_is_null() { + // rawInput = null means no argument context is available; description + // is the tool name alone. + let msg = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "method": "session/request_permission", + "params": { + "sessionId": "ses-null", + "title": "noop_tool", + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-null", + "title": "noop_tool", + "rawInput": null, + }, + }, + "options": [], + } + }); + let desc = description_from_request_permission(&msg) + .expect("must yield a description even with null rawInput"); + assert_eq!(desc, "noop_tool", "null rawInput must yield title only"); + } + + // ── Pinned §3: duplicate option IDs ────────────────────────────────────── + + // ── F2 codex-acp v1.1.7 wire shapes ───────────────────────────────────── + // + // Verbatim wire shapes from `buildCommandPermissionRequest` and + // `buildFileChangePermissionRequest` in codex-acp tag v1.1.7 + // (`src/CodexApprovalHandler.ts`). These tests go red if the extraction + // pointers are changed back to anything that misses the v1.1.7 shapes. + + #[test] + fn description_from_codex_v1_1_7_command_request() { + // codex-acp v1.1.7 `buildCommandPermissionRequest`: + // { sessionId, toolCall: { toolCallId, kind:"execute", status:"pending", + // rawInput: { command, cwd } | null }, options, _meta: { codex: { params } } } + // No `title` at params or toolCall level — command is in rawInput.command. + // Verbatim from codex-acp tag v1.1.7 `CodexApprovalHandler.ts:119-135`. + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "session/request_permission", + "params": { + "sessionId": "ses-codex-1", + "toolCall": { + "toolCallId": "tc-cmd-001", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "ls -la /tmp", + "cwd": "/home/user" + } + }, + "options": [], + "_meta": { "codex": { "params": { "command": "ls -la /tmp", "itemId": "tc-cmd-001" } } } + } + }); + let desc = description_from_request_permission(&msg); + assert_eq!( + desc.as_deref(), + Some("ls -la /tmp"), + "codex v1.1.7 command request must extract rawInput.command as description" + ); + } + + #[test] + fn description_from_codex_v1_1_7_file_change_request() { + // codex-acp v1.1.7 `buildFileChangePermissionRequest`: + // { sessionId, toolCall: { toolCallId, kind:"edit", status:"pending" }, + // options, _meta: { codex: { params: { ..., reason? } } } } + // No title or rawInput — useful context is in _meta.codex.params.reason. + // Verbatim from codex-acp tag v1.1.7 `CodexApprovalHandler.ts:137-151`. + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "session/request_permission", + "params": { + "sessionId": "ses-codex-2", + "toolCall": { + "toolCallId": "tc-edit-002", + "kind": "edit", + "status": "pending" + }, + "options": [], + "_meta": { + "codex": { + "params": { + "itemId": "tc-edit-002", + "reason": "Write updated config to /etc/app.conf" + } + } + } + } + }); + let desc = description_from_request_permission(&msg); + assert_eq!( + desc.as_deref(), + Some("Write updated config to /etc/app.conf"), + "codex v1.1.7 file-change request must extract _meta.codex.params.reason as description" + ); + } + + #[test] + fn description_returns_none_for_file_change_without_reason() { + // codex-acp v1.1.7 file-change with no reason field — no fallback available. + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "session/request_permission", + "params": { + "sessionId": "ses-codex-3", + "toolCall": { + "toolCallId": "tc-edit-003", + "kind": "edit", + "status": "pending" + }, + "options": [], + "_meta": { "codex": { "params": { "itemId": "tc-edit-003" } } } + } + }); + let desc = description_from_request_permission(&msg); + assert_eq!( + desc, None, + "file-change request with no reason must yield None" + ); + } + + // ── F1 v2: malformed / null / scalar rawInput / redaction ──────────────── + // + // These tests cover the new `summarize_raw_input` extraction logic: + // malformed structures, null values, scalar rawInput (not an object), + // secret-bearing key redaction, and the combined byte-bound invariant. + + #[test] + fn description_from_v2_rawinput_scalar_string_yields_title_only() { + // rawInput is a scalar string (not an object) — summarize_raw_input + // returns None for non-objects, so the description is the title only. + let msg = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "method": "session/request_permission", + "params": { + "title": "some_tool", + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-scalar", + "title": "some_tool", + "rawInput": "not-an-object", + }, + }, + "options": [], + } + }); + let desc = description_from_request_permission(&msg) + .expect("scalar rawInput must yield title-only description"); + assert_eq!( + desc, "some_tool", + "scalar rawInput must yield title only, not produce a panic: {desc:?}" + ); + } + + #[test] + fn description_from_v2_rawinput_empty_object_yields_title_only() { + // rawInput is an empty object {} — summarize_raw_input yields None + // (no known keys, no fallback scalars), so description is title only. + let msg = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "method": "session/request_permission", + "params": { + "title": "empty_tool", + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-empty", + "title": "empty_tool", + "rawInput": {}, + }, + }, + "options": [], + } + }); + let desc = description_from_request_permission(&msg) + .expect("empty rawInput must yield title-only description"); + assert_eq!( + desc, "empty_tool", + "empty rawInput must yield title only: {desc:?}" + ); + } + + #[test] + fn description_from_v2_rawinput_secret_key_redacted() { + // rawInput contains a `token` key — a secret-bearing key that must be + // redacted. The description must NOT expose the token value verbatim. + // The fallback serialisation includes the key but with "<redacted>" value. + // + // Mutation proof: removing `is_secret_key` check from the fallback loop + // makes the raw token value appear in the description — this assertion + // would then go red. + let msg = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "method": "session/request_permission", + "params": { + "title": "api_call", + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-secret", + "title": "api_call", + "rawInput": { + "token": "super-secret-bearer-12345", + "mode": "fast", + }, + }, + }, + "options": [], + } + }); + let desc = description_from_request_permission(&msg) + .expect("must yield a description with redacted token"); + assert!( + !desc.contains("super-secret-bearer-12345"), + "secret token value must not appear verbatim in description: {desc:?}" + ); + // The non-secret key `mode` may appear. + assert!( + desc.contains("fast") || desc.contains("api_call"), + "description must contain either the non-secret field or the tool name: {desc:?}" + ); + } + + #[test] + fn description_from_v2_password_key_redacted() { + // rawInput contains a `password` key — must be redacted in the fallback. + let msg = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "method": "session/request_permission", + "params": { + "title": "login", + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-pwd", + "title": "login", + "rawInput": { + "username": "alice", + "password": "hunter2", + }, + }, + }, + "options": [], + } + }); + let desc = description_from_request_permission(&msg) + .expect("must yield a description with redacted password"); + assert!( + !desc.contains("hunter2"), + "password value must not appear verbatim in description: {desc:?}" + ); + } + + #[test] + fn description_from_v2_command_key_takes_priority_over_path() { + // When rawInput has both `command` and `path`, `command` wins (priority 1 > 2). + let msg = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "method": "session/request_permission", + "params": { + "title": "do_thing", + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-priority", + "title": "do_thing", + "rawInput": { + "command": "rm -rf /tmp", + "path": "/home/user", + }, + }, + }, + "options": [], + } + }); + let desc = description_from_request_permission(&msg) + .expect("must yield description with command priority"); + // command wins over path. + assert!( + desc.contains("rm -rf /tmp"), + "command key must take priority over path key: {desc:?}" + ); + } + + #[test] + fn description_combined_byte_bound_invariant() { + // Regardless of rawInput content, the combined description must never + // exceed DESCRIPTION_COMBINED_MAX_BYTES (200). + // + // Regression proof: the old implementation could produce a combined + // string up to SENTINEL_STRING_MAX_BYTES + DESCRIPTION_RAW_INPUT_BYTES + 2 + // (322 bytes) because title and summary were budgeted independently. + // The new implementation computes the total budget from a single cap. + let long_title = "t".repeat(50); + let long_cmd = "c".repeat(300); + let msg = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "method": "session/request_permission", + "params": { + "sessionId": "ses-bound", + "title": long_title, + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-bound", + "title": long_title, + "rawInput": {"command": long_cmd}, + }, + }, + "options": [], + } + }); + // Count the bytes of the entire title field (simulating a long title + // + long command edge case). + let title_200 = "a".repeat(DESCRIPTION_COMBINED_MAX_BYTES); + let msg2 = serde_json::json!({ + "jsonrpc": "2.0", "id": 2, + "method": "session/request_permission", + "params": { + "title": title_200, + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-bound2", + "title": title_200, + "rawInput": {"command": long_cmd}, + }, + }, + "options": [], + } + }); + for (label, m) in [("long_title+long_cmd", &msg), ("max_title+long_cmd", &msg2)] { + let desc = description_from_request_permission(m).expect("must yield a description"); + assert!( + desc.len() <= DESCRIPTION_COMBINED_MAX_BYTES, + "{label}: combined description must fit in {DESCRIPTION_COMBINED_MAX_BYTES} bytes; got {} bytes: {desc:?}", + desc.len() + ); + } + } + + /// Production-seam test: the combined description in the sentinel carries + /// both the title and a bounded argument context — two different commands + /// under the same long title produce distinguishable descriptions, and + /// the distinguishing content survives the `build_sentinel_pending_payload` + /// serialization path. + /// + /// This test asserts: + /// 1. Both `description_from_request_permission` outputs fit within + /// `DESCRIPTION_COMBINED_MAX_BYTES`. + /// 2. Each extractor output contains the (truncated) title AND part of + /// the command — proving neither erases the other. + /// 3. The two extractor descriptions differ (`assert_ne!`) — proving + /// distinguishability even for same-prefix contexts. + /// 4. The descriptions survive `build_sentinel_pending_payload` unchanged + /// (the sentinel serialises the extractor output verbatim; confirming + /// that the downstream cap is a no-op for well-formed descriptions). + /// 5. The two sentinel `description` fields also differ — proving that + /// the distinguishability is not lost in the sentinel seam. + /// + /// Mutation proofs: + /// - Restoring `title_cap_limit = DESCRIPTION_COMBINED_MAX_BYTES` saturates + /// `context_budget` to zero for a 200-byte title → no context appended → + /// assertions 2/3 fire. + /// - Replacing `truncate_to_bytes_head_tail` with `truncate_to_bytes` + /// (prefix-only) with same-prefix contexts `sameprefix-a` / `sameprefix-b` + /// produces identical truncated prefixes → assertion 3/5 fire. + #[tokio::test] + async fn production_seam_description_combined_bound_in_sentinel() { + // 200-byte title triggers the old saturation: old code → context_budget = 0. + // New code: title_cap = truncate(200, 185) = 185 bytes, + // context_budget = 200 - 185 - 5 = 10 → context preserved. + let long_title = "t".repeat(DESCRIPTION_COMBINED_MAX_BYTES); // 200 bytes + + // Same-prefix commands: prefix-only truncation to 10 bytes collapses both + // to "sameprefix" — identical. Head/tail truncation preserves the suffix + // ("-a" vs "-b") making them distinct. + let cmd_a = "sameprefix-a".repeat(20); // 240 bytes — requires truncation + let cmd_b = "sameprefix-b".repeat(20); // same length, different suffix + + let make_msg = |title: &str, cmd: &str| { + serde_json::json!({ + "jsonrpc": "2.0", + "id": 77, + "method": "session/request_permission", + "params": { + "sessionId": "sess-bound-seam", + "title": title, + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-seam", + "title": title, + "rawInput": {"command": cmd}, + }, + }, + "options": [ + {"optionId": "opt-allow", "kind": "allow_once", "name": "Allow"}, + {"optionId": "opt-deny", "kind": "reject_once", "name": "Deny"}, + ], + } + }) + }; + + // ── Step 1: extractor outputs ────────────────────────────────────────── + let desc_a = description_from_request_permission(&make_msg(&long_title, &cmd_a)) + .expect("must yield description for cmd_a"); + let desc_b = description_from_request_permission(&make_msg(&long_title, &cmd_b)) + .expect("must yield description for cmd_b"); + + // 1. Both fit the combined budget. + assert!( + desc_a.len() <= DESCRIPTION_COMBINED_MAX_BYTES, + "desc_a must fit in {DESCRIPTION_COMBINED_MAX_BYTES} bytes; got {} bytes: {desc_a:?}", + desc_a.len() + ); + assert!( + desc_b.len() <= DESCRIPTION_COMBINED_MAX_BYTES, + "desc_b must fit in {DESCRIPTION_COMBINED_MAX_BYTES} bytes; got {} bytes: {desc_b:?}", + desc_b.len() + ); + + // 2. Each description contains both title context AND argument context. + // The title is truncated to 185 bytes (200 - 5 overhead - 10 reserve). + let title_prefix = &long_title[..185]; + assert!( + desc_a.starts_with(title_prefix), + "desc_a must start with the capped title; got: {desc_a:?}" + ); + // Contains '…' from head/tail context — proves truncation included both ends. + assert!( + desc_a.contains('\u{2026}'), + "desc_a must contain the ellipsis from truncation; got: {desc_a:?}" + ); + + // 3. Same-prefix distinguishability: same title, same prefix in command, + // different suffix → descriptions must differ. + // Mutation: prefix-only truncation → both collapse to "sameprefix" → assert_ne! fails. + assert_ne!( + desc_a, desc_b, + "descriptions for same-prefix commands must be distinguishable via head/tail truncation; \ + mutation: prefix-only truncation → both collapse to the same prefix → assert_ne! fails" + ); + + // ── Step 2: sentinel seam — descriptions survive build_sentinel_pending_payload ── + let card_actions = CardActions { + allow: serde_json::json!({"optionId": "opt-allow", "kind": "allow_once", "name": "Allow"}), + reject: serde_json::json!({"optionId": "opt-deny", "kind": "reject_once", "name": "Deny"}), + }; + let nonce_a = "nonce-seam-a"; + let nonce_b = "nonce-seam-b"; + let expiry = 9_999_999_999u64; + let turn_id = "turn-seam"; + + let payload_a = build_sentinel_pending_payload( + nonce_a, + &card_actions, + expiry, + Some("sess-seam"), + turn_id, + Some(&desc_a), + ) + .expect("sentinel payload must build for cmd_a"); + let payload_b = build_sentinel_pending_payload( + nonce_b, + &card_actions, + expiry, + Some("sess-seam"), + turn_id, + Some(&desc_b), + ) + .expect("sentinel payload must build for cmd_b"); + + let sentinel_a: serde_json::Value = + serde_json::from_str(&payload_a).expect("sentinel_a must be valid JSON"); + let sentinel_b: serde_json::Value = + serde_json::from_str(&payload_b).expect("sentinel_b must be valid JSON"); + + let sdesc_a = sentinel_a["description"] + .as_str() + .expect("sentinel_a must have a description field"); + let sdesc_b = sentinel_b["description"] + .as_str() + .expect("sentinel_b must have a description field"); + + // 4. Sentinel descriptions match extractor output (downstream cap is a no-op). + assert_eq!( + sdesc_a, desc_a, + "sentinel description_a must equal the extractor output verbatim" + ); + assert_eq!( + sdesc_b, desc_b, + "sentinel description_b must equal the extractor output verbatim" + ); + + // 5. Sentinel descriptions also differ. + assert_ne!( + sdesc_a, sdesc_b, + "sentinel descriptions for different commands must be distinguishable; \ + mutation: prefix-only context truncation → both sentinel descriptions collapse \ + to the same prefix → assert_ne! fails" + ); + } + + // ── Pinned §3: duplicate option IDs ────────────────────────────────────── + + #[test] + fn admission_preflight_rejects_duplicate_option_ids() { + let id = serde_json::json!(1); + let msg = perm_request( + 1, + &[("dup", "allow_once", "A"), ("dup", "reject_once", "R")], + ); + let opts = msg["params"]["options"].as_array().unwrap().clone(); + let result = run_admission_preflight( + &id, + &opts, + &msg, + PermissionPolicy::Ask, + AskGates { + is_duplicate_id: false, + is_map_at_cap: false, + is_publish_in_flight: false, + }, + (&ObserverContext::default(), None), + ); + assert!(result.is_err(), "duplicate optionId must fail preflight"); + let reason = result.unwrap_err(); + assert!( + reason.contains("duplicate optionId"), + "reason must name the check, got: {reason}" + ); + } + + // ── Publish-in-flight admission guard ──────────────────────────────────── + + #[test] + fn admission_preflight_rejects_publish_in_flight() { + // A single sentinel ACK slot is shared across publishes, so at most one + // entry may be in `Publishing` at a time. When a publish is already in + // flight the preflight must fail closed. Mutation proof: flipping the + // flag to `false` makes the same input pass. + let id = serde_json::json!(2); + let msg = perm_request(2, default_opts()); + let opts = msg["params"]["options"].as_array().unwrap().clone(); + let result = run_admission_preflight( + &id, + &opts, + &msg, + PermissionPolicy::Ask, + AskGates { + is_duplicate_id: false, + is_map_at_cap: false, + is_publish_in_flight: true, // publish already in flight + }, + (&ObserverContext::default(), None), + ); + assert!( + result.is_err(), + "a request while a publish is in flight must fail preflight" + ); + assert!( + result.unwrap_err().contains("publish is already in flight"), + "reason must name the check" + ); + // Same input with no publish in flight passes the guard. + assert!( + run_admission_preflight( + &id, + &opts, + &msg, + PermissionPolicy::Ask, + AskGates { + is_duplicate_id: false, + is_map_at_cap: false, + is_publish_in_flight: false, + }, + (&ObserverContext::default(), None), + ) + .is_ok(), + "identical input must pass when no publish is in flight" + ); + } + + // ── Publish-in-flight: second Ask denied without disturbing the slot ───── + + #[tokio::test] + async fn handle_permission_request_denies_second_while_publishing() { + // A distinct-id Ask request arriving while an earlier one is still in + // `Publishing` must be denied synchronously by the publish-in-flight + // guard — never inserting a second Publishing entry that would overwrite + // the single ACK receiver slot or create an unroutable card. + // + // Production-shaped: a full owner/initiator/channel/publisher context is + // installed and the FIRST entry is created through + // `handle_permission_request` with a SILENT publisher (never ACKs), so + // it genuinely reaches and stays in `Publishing`. This exercises the real + // insertion path after preflight — the acceptance bar is that mutating + // ONLY the production `is_publish_in_flight` argument (~acp.rs:3102) to + // `false` turns THIS test red (the second request would then be admitted + // and overwrite the live ACK slot). + let mut client = spawn_script("sleep 600").await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + // Silent publisher: never sends an ACK, so the first entry stays Publishing. + let keys = Keys::generate(); + let owner_hex = keys.public_key().to_hex(); + let (publisher, event_rx) = crate::relay::RelayEventPublisher::test_pair_silent(); + tokio::spawn(async move { + let mut rx = event_rx; + while rx.recv().await.is_some() {} + }); + client.set_relay_publisher(publisher, keys.clone()); + client.set_agent_owner_pubkey_hex(Some(owner_hex)); + client.set_turn_initiator_pubkey(Some(keys.public_key())); + client.set_turn_channel_context( + Some(uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000007").unwrap()), + None, + ); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // First request: reaches Publishing (silent publisher never ACKs). + let first = perm_request(1, default_opts()); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + client + .handle_permission_request(&first, hard) + .await + .expect("first request must register as Publishing"); + assert!( + matches!( + client.pending_permissions.get("1").map(|e| e.state.clone()), + Some(PermissionEntryState::Publishing) + ), + "first entry must be in Publishing state" + ); + assert!( + client.sentinel_ack_result_rx.is_some(), + "first request must install its ACK receiver" + ); + + // Second request with a DIFFERENT id while the first is Publishing. + let second = perm_request(2, default_opts()); + let result = client.handle_permission_request(&second, hard).await; + assert!( + result.is_ok(), + "publish-in-flight denial must not propagate as Err, got {result:?}" + ); + // No second entry added — only the original Publishing entry remains. + assert_eq!( + client.pending_permissions.len(), + 1, + "second request must be denied, not registered" + ); + assert!( + client.pending_permissions.contains_key("1"), + "the in-flight Publishing entry must survive untouched" + ); + // The single ACK slot must still hold the ORIGINAL receiver (not overwritten). + assert!( + client.sentinel_ack_result_rx.is_some(), + "the in-flight ACK receiver must not be overwritten or dropped" + ); + // The denial reason must explicitly name the publish-in-flight guard — + // this is what distinguishes it from any unrelated fail-closed gate and + // makes the production mutation (is_publish_in_flight → false) turn the + // test red rather than passing via a different denial path. + let events = obs.snapshot(); + let publish_in_flight_denials: Vec<_> = events + .iter() + .filter(|e| { + e.kind == "acp_read" + && e.authorization + .as_ref() + .and_then(|a| a.reason.as_deref()) + .map(|r| r.contains("publish is already in flight")) + .unwrap_or(false) + }) + .collect(); + assert_eq!( + publish_in_flight_denials.len(), + 1, + "second request must be denied by the publish-in-flight guard; events: {events:?}" + ); + } + + // ── Pinned §2: duplicate request ID ────────────────────────────────────── + + #[tokio::test] + async fn handle_permission_request_denies_duplicate_live_request_id() { + // Under ask policy, a second request with the same id while the first + // is still pending must be denied immediately without disturbing the original. + let mut client = spawn_inert_client().await; + set_policy(&mut client, PermissionPolicy::Ask); + // Simulate an already-registered pending entry with the same id. + client.pending_permissions.insert( + "1".to_string(), + PermissionEntry { + nonce: "nonce-abc".to_string(), + options_snapshot: vec![], + card_actions: test_card_actions(), + state: PermissionEntryState::Pending, + deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(300), + expiry_unix_secs: 0, + sentinel_event_id: None, + early_decision: None, + description: None, + }, + ); + let msg = perm_request(1, default_opts()); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let result = client.handle_permission_request(&msg, hard_deadline).await; + // Must succeed (Ok) — denial was written and the call itself doesn't error. + assert!( + result.is_ok(), + "duplicate-id must not propagate as Err, got {result:?}" + ); + // The original entry must still be in the map, untouched. + assert!( + client.pending_permissions.contains_key("1"), + "original pending entry must survive the duplicate-id rejection" + ); + // Only one entry should exist (the duplicate was denied, not registered). + assert_eq!( + client.pending_permissions.len(), + 1, + "no new entry should be added for the duplicate id" + ); + } + + // ── Pinned §4: oversize subject → plaintext cap exceeded ───────────────── + + #[test] + fn admission_preflight_rejects_oversize_msg_exceeding_plaintext_cap() { + // Construct a message large enough to exceed OBSERVER_MAX_PLAINTEXT_LEN. + // We embed the large payload directly in the msg so that + // `serde_json::to_string(msg).len() > OBSERVER_MAX_PLAINTEXT_LEN`. + let id = serde_json::json!(42); + let oversize_subject = "x".repeat(OBSERVER_MAX_PLAINTEXT_LEN + 1); + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": 42, + "method": "session/request_permission", + "params": { + "sessionId": "sess", + "subject": oversize_subject, + "options": [{"optionId":"opt","kind":"allow_once","name":"A"}] + } + }); + let opts = vec![serde_json::json!({"optionId":"opt","kind":"allow_once","name":"A"})]; + let result = run_admission_preflight( + &id, + &opts, + &msg, + PermissionPolicy::Ask, + AskGates { + is_duplicate_id: false, + is_map_at_cap: false, + is_publish_in_flight: false, + }, + (&ObserverContext::default(), None), + ); + assert!(result.is_err(), "oversize msg must fail preflight"); + let reason = result.unwrap_err(); + assert!( + reason.contains("too large") || reason.contains("payload"), + "reason should mention payload size, got: {reason}" + ); + } + + #[test] + fn admission_preflight_rejects_payload_overflowing_after_full_event_construction() { + // Construct a context matching production (UUID-sized IDs) and compute the + // maximum msg payload that fits within OBSERVER_MAX_PLAINTEXT_LEN when + // serialised as the actual ObserverEvent. Then submit a payload one byte + // larger and verify the preflight rejects it. + // + // This exercises the production code path: the check constructs the + // exact ObserverEvent with real context fields, not an estimate. + use crate::observer::ObserverContext; + + let ctx = ObserverContext { + channel_id: Some("00000000-0000-0000-0000-000000000000".to_string()), + session_id: Some("sess-00000000-0000-0000-0000-000000000000".to_string()), + turn_id: Some("00000000-0000-0000-0000-000000000000".to_string()), + started_at: Some("2026-01-01T00:00:00.000000000+00:00".to_string()), + }; + + // Binary-search for the exact max subject length that still fits. + // We wrap it in a minimal msg structure to simulate a real request. + let template = |subject: &str| { + serde_json::json!({ + "jsonrpc": "2.0", + "id": 42, + "method": "session/request_permission", + "params": { + "sessionId": "sess", + "subject": subject, + "options": [{"optionId":"opt","kind":"allow_once","name":"A"}] + } + }) + }; + let opts = vec![serde_json::json!({"optionId":"opt","kind":"allow_once","name":"A"})]; + let id = serde_json::json!(42); + + // Build the ObserverEvent exactly as the preflight does to find where the + // boundary is — then make a msg one byte over that boundary. + let make_candidate = |msg: &serde_json::Value| ObserverEvent { + seq: u64::MAX, + timestamp: "2026-01-01T00:00:00.000000000+00:00".to_string(), + kind: "acp_read".to_string(), + agent_index: None, + channel_id: ctx.channel_id.clone(), + session_id: ctx.session_id.clone(), + turn_id: ctx.turn_id.clone(), + started_at: ctx.started_at.clone(), + authorization: Some(AuthorizationEnvelope { + request_nonce: "00000000-0000-0000-0000-000000000000".to_string(), + actionable: true, + reason: None, + expires_at: None, + }), + payload: msg.clone(), + }; + + // Find a subject length that overflows after event wrapping. + // Start with a large subject known to overflow (cap worth of padding). + let overflow_subject = "z".repeat(OBSERVER_MAX_PLAINTEXT_LEN); + let overflow_msg = template(&overflow_subject); + let overflow_event_len = serde_json::to_string(&make_candidate(&overflow_msg)) + .unwrap() + .len(); + assert!( + overflow_event_len > OBSERVER_MAX_PLAINTEXT_LEN, + "test setup: overflow_event_len ({overflow_event_len}) must exceed cap" + ); + + // The preflight must reject this payload. + let result = run_admission_preflight( + &id, + &opts, + &overflow_msg, + PermissionPolicy::Ask, + AskGates { + is_duplicate_id: false, + is_map_at_cap: false, + is_publish_in_flight: false, + }, + (&ctx, None), + ); + assert!( + result.is_err(), + "payload overflowing after event construction must fail preflight (event_len={overflow_event_len})" + ); + let reason = result.unwrap_err(); + assert!( + reason.contains("too large") || reason.contains("payload"), + "reason should mention payload size, got: {reason}" + ); + + // Sanity-check: an empty subject (tiny msg) must pass the preflight. + let tiny_msg = template(""); + let tiny_event_len = serde_json::to_string(&make_candidate(&tiny_msg)) + .unwrap() + .len(); + assert!( + tiny_event_len <= OBSERVER_MAX_PLAINTEXT_LEN, + "test setup: tiny_event_len ({tiny_event_len}) must be within cap" + ); + let ok_result = run_admission_preflight( + &id, + &opts, + &tiny_msg, + PermissionPolicy::Ask, + AskGates { + is_duplicate_id: false, + is_map_at_cap: false, + is_publish_in_flight: false, + }, + (&ctx, None), + ); + assert!( + ok_result.is_ok(), + "small payload must pass preflight, got: {ok_result:?}" + ); + } + + #[test] + fn denial_response_with_malformed_reject_once_falls_back_to_cancelled() { + // A reject_once option with a missing optionId must produce a `cancelled` + // response, not a Protocol error — the adapter must always receive a valid + // JSON-RPC response. + let id = serde_json::json!(7); + let opts = vec![ + serde_json::json!({"kind": "reject_once", "name": "Reject"}), // no optionId + ]; + let response = permission_denial_response(&id, &opts) + .expect("malformed reject_once must not return Err"); + // The response must be a cancelled frame (no optionId in result.outcome). + let outcome = &response["result"]["outcome"]; + assert_eq!( + outcome["outcome"].as_str(), + Some("cancelled"), + "malformed reject_once must produce cancelled response, got: {response}" + ); + } + + // ── Pinned §5: map overflow ─────────────────────────────────────────────── + + #[tokio::test] + async fn handle_permission_request_denies_when_map_at_capacity() { + let mut client = spawn_inert_client().await; + set_policy(&mut client, PermissionPolicy::Ask); + + // Fill the map to PERMISSION_MAP_CAP. + for i in 0..PERMISSION_MAP_CAP { + client.pending_permissions.insert( + format!("{i}"), + PermissionEntry { + nonce: format!("nonce-{i}"), + options_snapshot: vec![], + card_actions: test_card_actions(), + state: PermissionEntryState::Pending, + deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(300), + expiry_unix_secs: 0, + sentinel_event_id: None, + early_decision: None, + description: None, + }, + ); + } + assert_eq!(client.pending_permissions.len(), PERMISSION_MAP_CAP); + + // One more request with a new id → must be denied. + let msg = perm_request(99, default_opts()); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let result = client.handle_permission_request(&msg, hard_deadline).await; + assert!( + result.is_ok(), + "map-at-cap must not propagate Err, got {result:?}" + ); + // Map must not have grown. + assert_eq!( + client.pending_permissions.len(), + PERMISSION_MAP_CAP, + "map must not grow beyond capacity after denial" + ); + } + + #[tokio::test] + async fn handle_permission_request_counts_publishing_toward_capacity() { + // The cap must count `Publishing` entries too: 7 Pending + 1 Publishing + // == PERMISSION_MAP_CAP, so a 9th request is denied at the CAPACITY + // check (which precedes the publish-in-flight check). Mutation proof: + // excluding `Publishing` from the count drops the total to 7 < cap, so + // the request instead reaches — and is denied by — the publish-in-flight + // guard, changing the reason string. Asserting the "at capacity" reason + // pins that Publishing is counted. + let mut client = spawn_inert_client().await; + set_policy(&mut client, PermissionPolicy::Ask); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + + for i in 0..(PERMISSION_MAP_CAP - 1) { + client.pending_permissions.insert( + format!("{i}"), + PermissionEntry { + nonce: format!("nonce-{i}"), + options_snapshot: vec![], + card_actions: test_card_actions(), + state: PermissionEntryState::Pending, + deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(300), + expiry_unix_secs: 0, + sentinel_event_id: None, + early_decision: None, + description: None, + }, + ); + } + // The 8th entry is Publishing (with a live ACK slot). + client.pending_permissions.insert( + "pub".to_string(), + PermissionEntry { + nonce: "nonce-pub".to_string(), + options_snapshot: vec![], + card_actions: test_card_actions(), + state: PermissionEntryState::Publishing, + deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(300), + expiry_unix_secs: 0, + sentinel_event_id: Some("sentinel-pub".to_string()), + early_decision: None, + description: None, + }, + ); + let (_ack_tx, ack_rx) = tokio::sync::mpsc::channel(1); + client.sentinel_ack_result_rx = Some(ack_rx); + assert_eq!(client.pending_permissions.len(), PERMISSION_MAP_CAP); + + let msg = perm_request(99, default_opts()); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let result = client.handle_permission_request(&msg, hard_deadline).await; + assert!(result.is_ok(), "cap denial must not propagate Err"); + assert_eq!( + client.pending_permissions.len(), + PERMISSION_MAP_CAP, + "map must not grow past the cap" + ); + // The denial reason must name the capacity check (proving Publishing counts). + let events = obs.snapshot(); + let cap_reads: Vec<_> = events + .iter() + .filter(|e| { + e.kind == "acp_read" + && e.authorization + .as_ref() + .and_then(|a| a.reason.as_deref()) + .map(|r| r.contains("at capacity")) + .unwrap_or(false) + }) + .collect(); + assert_eq!( + cap_reads.len(), + 1, + "denial reason must name the capacity check; events: {events:?}" + ); + } + + // ── Pinned §7: mode matrix — unset + every explicit mode × 3 policies ──── + + #[test] + fn resolved_permission_config_reject_unset_derives_dont_ask() { + let cfg = ResolvedPermissionConfig::resolve(PermissionPolicy::Reject, None).unwrap(); + assert_eq!(cfg.effective_mode, PermissionMode::DontAsk); + assert_eq!(cfg.mode_source, ModeSource::Derived); + assert!(cfg.transmit_mode, "transmit_mode must always be true"); + } + + #[test] + fn resolved_permission_config_ask_unset_derives_default() { + let cfg = ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(); + assert_eq!(cfg.effective_mode, PermissionMode::Default); + assert_eq!(cfg.mode_source, ModeSource::Derived); + } + + #[test] + fn resolved_permission_config_allow_unset_derives_default_not_dont_ask() { + // allow + unset → default (NOT dontAsk — dontAsk self-denies before Buzz can answer) + let cfg = ResolvedPermissionConfig::resolve(PermissionPolicy::Allow, None).unwrap(); + assert_eq!(cfg.effective_mode, PermissionMode::Default); + assert!( + cfg.effective_mode != PermissionMode::DontAsk, + "allow policy must NOT derive dontAsk" + ); + } + + #[test] + fn resolved_permission_config_reject_plus_explicit_dont_ask_is_ok() { + // reject + dontAsk explicit is valid: both say "deny". + let cfg = ResolvedPermissionConfig::resolve( + PermissionPolicy::Reject, + Some(PermissionMode::DontAsk), + ) + .unwrap(); + assert_eq!(cfg.effective_mode, PermissionMode::DontAsk); + assert_eq!(cfg.mode_source, ModeSource::Explicit); + } + + #[test] + fn resolved_permission_config_ask_plus_explicit_dont_ask_is_startup_error() { + let result = + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, Some(PermissionMode::DontAsk)); + assert!(result.is_err(), "ask + dontAsk must be a startup error"); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("dontAsk"), + "error must mention dontAsk, got: {msg}" + ); + } + + #[test] + fn resolved_permission_config_allow_plus_explicit_dont_ask_is_startup_error() { + let result = ResolvedPermissionConfig::resolve( + PermissionPolicy::Allow, + Some(PermissionMode::DontAsk), + ); + assert!(result.is_err(), "allow + dontAsk must be a startup error"); + } + + #[test] + fn resolved_permission_config_ask_plus_explicit_accept_edits_is_ok() { + let cfg = ResolvedPermissionConfig::resolve( + PermissionPolicy::Ask, + Some(PermissionMode::AcceptEdits), + ) + .unwrap(); + assert_eq!(cfg.effective_mode, PermissionMode::AcceptEdits); + assert_eq!(cfg.mode_source, ModeSource::Explicit); + } + + #[test] + fn resolved_permission_config_allow_plus_explicit_plan_is_ok() { + let cfg = + ResolvedPermissionConfig::resolve(PermissionPolicy::Allow, Some(PermissionMode::Plan)) + .unwrap(); + assert_eq!(cfg.effective_mode, PermissionMode::Plan); + assert_eq!(cfg.mode_source, ModeSource::Explicit); + } + + #[test] + fn resolved_permission_config_transmit_mode_always_true() { + // transmit_mode is always true regardless of policy/mode combination. + for policy in [ + PermissionPolicy::Reject, + PermissionPolicy::Ask, + PermissionPolicy::Allow, + ] { + let cfg = ResolvedPermissionConfig::resolve(policy, None).unwrap(); + assert!(cfg.transmit_mode, "transmit_mode must be true for {policy}"); + } + } + + // ── Pinned §10: ask availability gate — no observer → downgrade to reject ─ + + #[tokio::test] + async fn ask_without_observer_downgrades_to_reject() { + // ask policy but no observer installed → must downgrade to reject, + // never sideways to allow. + let mut client = spawn_inert_client().await; + let config = ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(); + client.set_permission_config(config); + client.set_owner_pubkey_known(true); + // No observer installed (default). + + let msg = perm_request(1, default_opts()); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let result = client.handle_permission_request(&msg, hard_deadline).await; + // Denial was written — Ok(true) means caller should suppress generic emit. + assert!( + result.is_ok(), + "ask downgrade to reject must not propagate Err" + ); + // Confirm nothing was left pending in the map — it was denied synchronously. + assert!( + client.pending_permissions.is_empty(), + "downgraded-to-reject must not leave a pending entry" + ); + } + + #[tokio::test] + async fn ask_without_owner_known_downgrades_to_reject() { + // ask policy with observer but unknown owner → downgrade to reject. + let mut client = spawn_inert_client().await; + let config = ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(); + client.set_permission_config(config); + client.set_owner_pubkey_known(false); // explicitly unknown + + let msg = perm_request(2, default_opts()); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let result = client.handle_permission_request(&msg, hard_deadline).await; + assert!(result.is_ok()); + assert!(client.pending_permissions.is_empty()); + } + + // ── Production-path tests: real loop emits request, captures nonce ────── + + /// Full end-to-end production path test for the `ask` decision flow: + /// + /// 1. Script emits a real `session/request_permission` on stdout. + /// 2. The read loop processes it via `handle_permission_request()` — + /// no state is pre-planted. + /// 3. The nonce is captured from the observer. + /// 4. A valid decision is sent through the decision channel. + /// 5. The loop writes the permission response to the script's stdin. + /// 6. The script captures the response line into a temp file — the test + /// reads the file and asserts the exact JSON-RPC id and option_id at + /// the wire level. + /// 7. The script emits the terminal id=999 reply; the loop returns `Ok`. + #[tokio::test] + async fn ask_production_path_emits_request_captures_nonce_and_delivers_decision() { + // Script: emit permission request, read the harness response into a file + // so the test can verify what was actually written on the wire, then emit + // the terminal response. + let capture_file = + std::env::temp_dir().join(format!("buzz-acp-wire-{}.json", uuid::Uuid::new_v4())); + let perm_req = r#"{"jsonrpc":"2.0","id":42,"method":"session/request_permission","params":{"sessionId":"sess","requestId":"req-prod","subject":"read a file","options":[{"optionId":"opt-allow","kind":"allow_once","name":"Allow"},{"optionId":"opt-deny","kind":"reject_once","name":"Deny"}]}}"#; + let terminal = r#"{"jsonrpc":"2.0","id":999,"result":{"stopReason":"end_turn"}}"#; + // Read the permission response from harness stdin, save to capture_file, + // then emit the terminal session/prompt response. + let script = format!( + r#"printf '{perm_req}\n'; read -r resp; printf '%s' "$resp" > {capture}; printf '{terminal}\n'"#, + perm_req = perm_req, + capture = capture_file.display(), + terminal = terminal, + ); + + let mut client = spawn_script(&script).await; + let config = ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(); + client.set_permission_config(config); + client.set_owner_pubkey_known(true); + install_test_relay_context(&mut client); + + // Subscribe to the observer BEFORE starting the loop so we capture all events. + let obs = crate::observer::ObserverHandle::in_process(); + let mut obs_rx = obs.subscribe(); + client.set_observer(Some(obs.clone()), 0); + + let (perm_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // Spawn a task that waits for the observer to emit the actionable acp_read + // (the permission request), then delivers a matching decision. + let decision_task = tokio::spawn(async move { + // Wait for the actionable acp_read from the observer. + let mut found_nonce: Option<String> = None; + while let Ok(Ok(event)) = + tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()).await + { + if event.kind == "acp_read" { + if let Some(auth) = &event.authorization { + if auth.actionable { + found_nonce = Some(auth.request_nonce.clone()); + break; + } + } + } + } + let nonce = found_nonce.expect("actionable acp_read must be emitted"); + // Deliver a valid decision by the captured nonce. + perm_tx + .send(PermissionDecision { + request_nonce: nonce, + option_id: "opt-allow".to_string(), + }) + .await + .expect("decision channel must accept"); + }); + + let idle = std::time::Duration::from_secs(5); + let max_dur = std::time::Duration::from_secs(15); + let hard_deadline = tokio::time::Instant::now() + max_dur; + let result = client + .read_until_response_with_idle_timeout("sess", 999, idle, hard_deadline, max_dur) + .await; + + assert!( + result.is_ok(), + "production-path ask loop must succeed after decision is delivered, got: {result:?}" + ); + assert_eq!( + result.unwrap().get("stopReason").and_then(|v| v.as_str()), + Some("end_turn"), + ); + + // Verify the observer emitted an authorized acp_write (the decision response). + let _ = decision_task.await; + let events = obs.snapshot(); + let write_events: Vec<_> = events + .iter() + .filter(|e| e.kind == "acp_write" && e.authorization.is_some()) + .collect(); + assert!( + !write_events.is_empty(), + "observer must emit at least one authorized acp_write after decision applied" + ); + + // Wire-level assertion: read what the harness actually wrote on the pipe. + // The capture file contains the raw NDJSON line the agent's stdin received. + let wire_line = tokio::time::timeout( + std::time::Duration::from_secs(2), + tokio::task::spawn_blocking({ + let capture_file = capture_file.clone(); + move || { + // Poll briefly for the file to be populated. + for _ in 0..20 { + if let Ok(s) = std::fs::read_to_string(&capture_file) { + if !s.is_empty() { + return s; + } + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + String::new() + } + }), + ) + .await + .expect("timeout reading wire capture") + .expect("spawn_blocking failed"); + + let _ = std::fs::remove_file(&capture_file); + + assert!( + !wire_line.is_empty(), + "harness must write a permission response on the wire (capture file was empty)" + ); + let wire_json: serde_json::Value = + serde_json::from_str(&wire_line).expect("wire response must be valid JSON"); + assert_eq!( + wire_json["id"], + serde_json::json!(42), + "wire response id must match the permission request id=42" + ); + let outcome = &wire_json["result"]["outcome"]; + assert_eq!( + outcome["outcome"].as_str(), + Some("selected"), + "wire response must carry selected outcome for an approved decision" + ); + assert_eq!( + outcome["optionId"].as_str(), + Some("opt-allow"), + "wire response optionId must match the delivered decision" + ); + } + + /// Cancel test: asserts exactly one JSON-RPC response per pending id, no + /// replay on subsequent cancel. Proves behavior at the wire level by + /// capturing the raw NDJSON lines written to the agent's stdin. + #[tokio::test] + async fn cancel_writes_exactly_one_response_per_pending_id_no_replay() { + // Script: read all stdin lines (cancel responses) into a capture file, + // then stay alive briefly. + let capture_file = + std::env::temp_dir().join(format!("buzz-acp-cancel-{}.ndjson", uuid::Uuid::new_v4())); + // Loop reading stdin, appending each line to capture file, exit on EOF. + let script = format!( + r#"while IFS= read -r line; do printf '%s\n' "$line" >> {capture}; done; sleep 2"#, + capture = capture_file.display(), + ); + let mut client = spawn_script(&script).await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + install_test_relay_context(&mut client); + + // Subscribe to observer to capture writes. + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // Register two distinct Pending entries via the production path. + let mut expected_ids: Vec<u64> = Vec::new(); + let mut expected_nonces: Vec<String> = Vec::new(); + for i in 0..2u64 { + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + let msg = perm_request(i, default_opts()); + client + .handle_permission_request(&msg, hard_deadline) + .await + .expect("ask registration must succeed"); + // In production the relay ACK transitions the entry Publishing → + // Pending in the read loop before the next request arrives, so two + // Pending entries legitimately coexist. This test does not drive the + // loop between registrations, so apply that transition explicitly — + // otherwise the publish-in-flight guard denies the second request. + if let Some(entry) = client.pending_permissions.get_mut(&i.to_string()) { + entry.state = PermissionEntryState::Pending; + } + // Capture the nonce that was bound to this entry. + let nonce = client + .pending_permissions + .get(&i.to_string()) + .expect("entry must be registered") + .nonce + .clone(); + expected_ids.push(i); + expected_nonces.push(nonce); + } + assert_eq!( + client.pending_permissions.len(), + 2, + "two pending entries must be registered before cancel" + ); + client.last_prompt_id = Some(999); + + // First cancel: must drain both entries and write exactly two responses. + let _ = client + .cancel_with_cleanup_grace("sess-exact-once", std::time::Duration::from_millis(200)) + .await; + assert!( + client.pending_permissions.is_empty(), + "all pending entries must be drained after cancel" + ); + + // Give the script a moment to flush appended lines. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Wire-level assertion: read capture file and parse each line. + let wire_lines = tokio::task::spawn_blocking({ + let capture_file = capture_file.clone(); + move || { + for _ in 0..20 { + if let Ok(s) = std::fs::read_to_string(&capture_file) { + let lines: Vec<String> = s + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(); + if lines.len() >= 2 { + return lines; + } + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + vec![] + } + }) + .await + .expect("spawn_blocking failed"); + let _ = std::fs::remove_file(&capture_file); + + // Two wire responses must have been written (one per pending entry). + // Note: session/cancel also writes to stdin; filter to permission responses only. + let perm_responses: Vec<serde_json::Value> = wire_lines + .iter() + .filter_map(|l| serde_json::from_str(l).ok()) + .filter(|v: &serde_json::Value| { + // Permission responses have {"id": <num>, "result": {"outcome": {...}}} + // (no "method" key). + v.get("result").and_then(|r| r.get("outcome")).is_some() + }) + .collect(); + + assert_eq!( + perm_responses.len(), + 2, + "cancel must write exactly two permission responses on the wire (one per pending id), got: {perm_responses:?}" + ); + + // Each response must carry one of the registered ids and have a rejection outcome. + let written_ids: Vec<u64> = perm_responses + .iter() + .filter_map(|v| v["id"].as_u64()) + .collect(); + for expected_id in &expected_ids { + assert!( + written_ids.contains(expected_id), + "wire responses must cover id={expected_id}, got: {written_ids:?}" + ); + } + + // Observer-level: nonces must match registered entries. + let events_after_first = obs.snapshot(); + let cancel_nonces: Vec<String> = events_after_first + .iter() + .filter(|e| { + e.kind == "acp_write" + && e.authorization + .as_ref() + .map(|a| a.reason.as_deref() == Some("cancelled")) + .unwrap_or(false) + }) + .filter_map(|e| e.authorization.as_ref().map(|a| a.request_nonce.clone())) + .collect(); + assert_eq!( + cancel_nonces.len(), + 2, + "cancel must emit exactly one authorized acp_write per pending id, got: {cancel_nonces:?}" + ); + for nonce in &cancel_nonces { + assert!( + expected_nonces.contains(nonce), + "emitted cancel nonce {nonce:?} does not match any registered entry nonce" + ); + } + + // Second cancel on the same client: no pending entries remain, must not + // re-emit any additional acp_write (no replay). + let _ = client + .cancel_with_cleanup_grace("sess-exact-once", std::time::Duration::from_millis(200)) + .await; + let events_after_second = obs.snapshot(); + let write_count_after_second = events_after_second + .iter() + .filter(|e| e.kind == "acp_write" && e.authorization.is_some()) + .count(); + assert_eq!( + write_count_after_second, 2, + "second cancel must not emit additional acp_writes (no replay)" + ); + } + + /// Paused-time test — Part 1: at exactly 299s, the pending entry still exists + /// and the loop has NOT timed out. + /// + /// Uses a single continuously running loop advanced to 299s then hard-stopped. + /// Asserts the loop returned an external (outer) timeout, not an internal deadline, + /// AND the entry is still Pending in the map — proving idle suspension works. + #[tokio::test(start_paused = true)] + async fn ask_permission_pending_at_299_seconds() { + let mut client = spawn_script("sleep 600").await; + let config = ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(); + client.set_permission_config(config); + client.set_owner_pubkey_known(true); + install_test_relay_context(&mut client); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // Register one pending entry — deadline is now + 300s. + let msg = perm_request(1, default_opts()); + let hard_deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(PERMISSION_ASK_TIMEOUT_SECS + 10); + client + .handle_permission_request(&msg, hard_deadline) + .await + .expect("ask registration must succeed"); + assert_eq!(client.pending_permissions.len(), 1, "entry registered"); + + // Idle is 5s — would fire immediately if not suspended. + let idle = std::time::Duration::from_secs(5); + let max_dur = std::time::Duration::from_secs(PERMISSION_ASK_TIMEOUT_SECS + 10); + let hard_deadline2 = tokio::time::Instant::now() + max_dur; + + // Advance virtual time to 299s concurrently with the running loop. + // The loop must be running to process the advance; the outer real-time + // timeout (50ms wall clock) is the expected exit path. + let loop_fut = client.read_until_response_with_idle_timeout( + "sess-299s", + 999, + idle, + hard_deadline2, + max_dur, + ); + let result = tokio::select! { + r = loop_fut => Some(r), + _ = async { + tokio::time::advance(std::time::Duration::from_secs(PERMISSION_ASK_TIMEOUT_SECS - 1)).await; + } => None, + }; + + // Loop must still be pending (returned None from the select advance branch). + // If result is Some, the loop exited — which means it timed out internally. + assert!( + result.is_none(), + "loop must still be running at 299s (idle suspended); \ + it exited with: {result:?}" + ); + // Entry must still be Pending in the map at 299s. + assert!( + client.pending_permissions.contains_key("1"), + "entry must still be Pending at 299s" + ); + // No timed_out acp_write must have been emitted yet. + let events = obs.snapshot(); + let timeout_writes: Vec<_> = events + .iter() + .filter(|e| { + e.kind == "acp_write" + && e.authorization + .as_ref() + .map(|a| a.reason.as_deref() == Some("timed_out")) + .unwrap_or(false) + }) + .collect(); + assert!( + timeout_writes.is_empty(), + "no timed_out write must be emitted at 299s; got: {timeout_writes:?}" + ); + } + + /// Paused-time test — Part 2: the permission deadline fires at exactly 300s. + /// + /// Runs the loop continuously and advances virtual time to 300s. Asserts: + /// - The entry is removed from the map (deadline processed). + /// - Exactly one `timed_out` authorized `acp_write` is emitted in the observer. + /// - The loop exits via `HardTimeout` (not `PermissionPoisoned`). + #[tokio::test(start_paused = true)] + async fn ask_permission_deadline_fires_at_exactly_300_seconds() { + let mut client = spawn_script("sleep 600").await; + let config = ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(); + client.set_permission_config(config); + client.set_owner_pubkey_known(true); + install_test_relay_context(&mut client); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // hard_deadline is equal to the permission deadline — exercises the + // equality case fixed in this round. + let now = tokio::time::Instant::now(); + let perm_deadline = now + std::time::Duration::from_secs(PERMISSION_ASK_TIMEOUT_SECS); + // Use the same deadline for both the entry and the hard deadline. + let msg = perm_request(1, default_opts()); + client + .handle_permission_request(&msg, perm_deadline) + .await + .expect("ask registration must succeed"); + assert_eq!(client.pending_permissions.len(), 1, "entry registered"); + + let idle = std::time::Duration::from_secs(5); + let max_dur = std::time::Duration::from_secs(PERMISSION_ASK_TIMEOUT_SECS + 10); + // Loop hard deadline is generous — permission deadline (== hard_deadline passed + // to handle_permission_request) is the one that must fire. + let loop_hard = tokio::time::Instant::now() + max_dur; + + // Run the loop and advance virtual time to 300s concurrently. + let loop_result = tokio::select! { + r = client.read_until_response_with_idle_timeout("sess-300s", 999, idle, loop_hard, max_dur) => Some(r), + _ = async { + // Advance 1ms past the 300s permission deadline. + tokio::time::advance(std::time::Duration::from_secs(PERMISSION_ASK_TIMEOUT_SECS) + std::time::Duration::from_millis(1)).await; + } => None, + }; + + // The loop MUST complete (not be cancelled by the select branch): + // the advance fires and triggers the expiry block, which should + // process the entry and return HardTimeout (since entry.deadline == hard_deadline). + // If it comes back None, advance happened before the loop could react — tolerate + // this only if the entry is removed. + let entry_removed = !client.pending_permissions.contains_key("1"); + + // Verify the observer emitted exactly one timed_out write. + let events = obs.snapshot(); + let timeout_writes: Vec<_> = events + .iter() + .filter(|e| { + e.kind == "acp_write" + && e.authorization + .as_ref() + .map(|a| a.reason.as_deref() == Some("timed_out")) + .unwrap_or(false) + }) + .collect(); + + // Either the loop completed with HardTimeout after writing timed_out, + // or the advance preempted it — in the latter case we at minimum need + // to confirm the entry WAS processed (removed) on the next loop iteration. + // Allow for either pattern since tokio::select non-determinism can fire + // the advance arm first; what must hold is: once we drive the loop once more, + // the entry is gone and one timed_out was written. + if loop_result.is_none() { + // Advance won the select — drive the loop one more iteration to process expiry. + let drive_result = tokio::select! { + r = client.read_until_response_with_idle_timeout("sess-300s", 999, idle, loop_hard, max_dur) => Some(r), + _ = async { + tokio::time::advance(std::time::Duration::from_millis(100)).await; + } => None, + }; + let _ = drive_result; + } + + // Now assert invariants. + assert!( + !client.pending_permissions.contains_key("1"), + "entry must be removed after 300s permission deadline" + ); + let events2 = obs.snapshot(); + let timeout_writes2: Vec<_> = events2 + .iter() + .filter(|e| { + e.kind == "acp_write" + && e.authorization + .as_ref() + .map(|a| a.reason.as_deref() == Some("timed_out")) + .unwrap_or(false) + }) + .collect(); + assert_eq!( + timeout_writes2.len(), + 1, + "exactly one timed_out acp_write must be emitted at 300s; got: {timeout_writes2:?}" + ); + let _ = entry_removed; + let _ = timeout_writes; + } + + /// Deadline-equality test: `entry.deadline == loop_hard_deadline`. + /// + /// When a request is registered within 300s of the turn hard cap, + /// `entry.deadline = min(now + 300s, hard_deadline) = hard_deadline`. + /// + /// The pre-select check must NOT return `HardTimeout` before processing the + /// expired entry — it must write the fail-closed denial first, THEN return + /// `HardTimeout`. This test proves the fix: equal deadlines → denial written. + /// + /// Wire-level proof: the denial line is captured from child stdin NDJSON and + /// parsed to confirm it contains exactly one `timed_out` response for id=1 + /// before `HardTimeout` is returned. + #[tokio::test(start_paused = true)] + async fn ask_permission_entry_deadline_equal_to_loop_hard_deadline_writes_denial_before_exit() { + // Proves: when entry.deadline == loop_hard_deadline, the fail-closed denial + // is written to the pipe exactly once BEFORE HardTimeout is returned. + // + // Proof strategy: + // 1. tokio::spawn keeps the loop future alive continuously (no drops/restarts). + // 2. Virtual time advances past the shared deadline; loop returns HardTimeout. + // 3. Attempt counter (incremented before I/O in write_ndjson_inner) asserts + // exactly one write attempt — distinguishes "stopped after first" from + // "tried all and all failed". + // 4. Observer payload asserts the exact fail-closed JSON written to the pipe: + // the observer records the same serde_json::Value that is serialised and + // written; with emit_observe=true in write_ndjson_inner this is identical + // to what the adapter receives. + // + // File-capture is not used because start_paused = true makes real-time I/O + // between the harness and the shell subprocess unreliable for test assertions + // (virtual-time advance does not advance wall-clock for OS file flushing). + let mut client = spawn_script("sleep 600").await; + let config = ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(); + client.set_permission_config(config); + client.set_owner_pubkey_known(true); + install_test_relay_context(&mut client); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // Install the attempt counter — proves exactly one write attempt. + let attempt_counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + client.set_write_attempt_count(attempt_counter.clone()); + + // Set entry.deadline == loop_hard_deadline. + // With PERMISSION_ASK_TIMEOUT_SECS = 300: + // entry.deadline = min(now + 300s, hard_deadline) = now + 300s = hard_deadline. + let now = tokio::time::Instant::now(); + let shared_deadline = now + std::time::Duration::from_secs(PERMISSION_ASK_TIMEOUT_SECS); + + let msg = perm_request(1, default_opts()); + client + .handle_permission_request(&msg, shared_deadline) + .await + .expect("ask registration must succeed"); + assert_eq!(client.pending_permissions.len(), 1, "entry registered"); + + // Move the client into a spawned task so it stays alive across the + // virtual-time advance — mirrors the idle-rearm test pattern. The task + // owns the loop future continuously from start to finish (no drops, no + // restarts) while the test body drives time from the outside. + let idle = std::time::Duration::from_secs(5); + let max_dur = std::time::Duration::from_secs(PERMISSION_ASK_TIMEOUT_SECS); + let loop_task = tokio::spawn(async move { + client + .read_until_response_with_idle_timeout( + "sess-eq", + 999, + idle, + shared_deadline, + max_dur, + ) + .await + }); + + // Advance virtual time past the shared deadline. The loop task wakes, + // processes the expired entry (writes the fail-closed denial), and then + // returns HardTimeout because entry.deadline == hard_deadline. + tokio::time::advance( + std::time::Duration::from_secs(PERMISSION_ASK_TIMEOUT_SECS) + + std::time::Duration::from_millis(1), + ) + .await; + + // Await the continuously running loop and assert HardTimeout — not any + // other error and not Ok (Ok would mean a terminal session/prompt response + // was read instead of the hard deadline firing). + let loop_result = loop_task.await.expect("loop task must not panic"); + assert!( + matches!(loop_result, Err(AcpError::HardTimeout { .. })), + "loop must exit with HardTimeout after equality deadline fires; got: {loop_result:?}" + ); + + // Assert exactly ONE write attempt — the fail-closed denial for id=1. + // Counter increments at the top of write_ndjson_inner before I/O; + // a value > 1 would mean a duplicate write escaped the expiry block. + let attempts = attempt_counter.load(std::sync::atomic::Ordering::Relaxed); + assert_eq!( + attempts, 1, + "exactly one write attempt must be made (the timed-out denial for id=1); \ + got {attempts} attempts" + ); + + // Exact payload proof via observer telemetry. + // write_ndjson_inner calls observe("acp_write", value) with emit_observe=true + // using the same serde_json::Value that was serialised to the pipe — the + // observer record IS the wire content for virtual-time tests. + // Assert: exactly one timed_out acp_write, id=1, outcome=selected, optionId=opt-reject. + // (permission_denial_response selects the reject_once option from default_opts.) + let events = obs.snapshot(); + let timed_out_writes: Vec<_> = events + .iter() + .filter(|e| { + e.kind == "acp_write" + && e.authorization + .as_ref() + .map(|a| a.reason.as_deref() == Some("timed_out")) + .unwrap_or(false) + }) + .collect(); + assert_eq!( + timed_out_writes.len(), + 1, + "exactly one timed_out acp_write must be observed; got: {timed_out_writes:?}" + ); + let payload = &timed_out_writes[0].payload; + assert_eq!( + payload["id"], + serde_json::json!(1), + "denial payload id must be 1; got {payload}" + ); + assert_eq!( + payload["result"]["outcome"]["outcome"].as_str(), + Some("selected"), + "denial payload must carry outcome=selected; got {payload}" + ); + assert_eq!( + payload["result"]["outcome"]["optionId"].as_str(), + Some("opt-reject"), + "denial optionId must be opt-reject (reject_once from default_opts); got {payload}" + ); + } + + /// Real-time test — Part 3: idle is re-armed after the last pending entry resolves. + /// + /// A single continuously running loop: + /// 1. Processes a permission request (idle suspended while pending). + /// 2. Receives a decision (applied) — entry removed, idle re-armed. + /// 3. After one full idle interval of silence, the loop exits with IdleTimeout. + /// + /// This proves that a slow human decision grants the agent a fresh idle window, + /// not an insta-cancel. Uses real time with short (100ms) idle window. + #[tokio::test] + async fn ask_permission_idle_rearmed_after_last_entry_resolves() { + // Script: emit a permission request, read one line (the response), then sleep forever. + // After the permission is answered, the agent stays silent — idle must fire. + let perm_req = r#"{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"sess","requestId":"req-rearm","subject":"test","options":[{"optionId":"opt-allow","kind":"allow_once","name":"Allow"},{"optionId":"opt-deny","kind":"reject_once","name":"Deny"}]}}"#; + let script = format!( + r#"printf '{perm_req}\n'; read -r _resp; sleep 600"#, + perm_req = perm_req + ); + + let mut client = spawn_script(&script).await; + let config = ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(); + client.set_permission_config(config); + client.set_owner_pubkey_known(true); + install_test_relay_context(&mut client); + let obs = crate::observer::ObserverHandle::in_process(); + let mut obs_rx = obs.subscribe(); + client.set_observer(Some(obs.clone()), 0); + let (perm_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // Use real-time with short (100ms) idle window so the test completes fast. + // Hard deadline is generous (10s) — only idle fires in this scenario. + let idle = std::time::Duration::from_millis(100); + let max_dur = std::time::Duration::from_secs(10); + let hard_deadline = tokio::time::Instant::now() + max_dur; + + // Run the full loop in a spawned task (continuously, no restarts). + let loop_task = tokio::spawn(async move { + client + .read_until_response_with_idle_timeout( + "sess-rearm", + 999, + idle, + hard_deadline, + max_dur, + ) + .await + }); + + // Wait for the actionable acp_read from the observer (real-time wait, 5s budget). + let mut found_nonce: Option<String> = None; + let wait_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + while tokio::time::Instant::now() < wait_deadline { + match tokio::time::timeout(std::time::Duration::from_millis(200), obs_rx.recv()).await { + Ok(Ok(event)) => { + if event.kind == "acp_read" { + if let Some(auth) = &event.authorization { + if auth.actionable { + found_nonce = Some(auth.request_nonce.clone()); + break; + } + } + } + } + // Timeout or channel closed — give up. + _ => break, + } + } + let nonce = found_nonce.expect("actionable acp_read must be emitted within 5s"); + + // Send the decision — causes finish_permission to write the response and + // re-arm the idle deadline to now + 100ms. + perm_tx + .send(PermissionDecision { + request_nonce: nonce, + option_id: "opt-allow".to_string(), + }) + .await + .expect("decision channel must accept"); + + // The loop now has a fresh 100ms idle window. It must exit via IdleTimeout + // (agent stays silent after the response). Wait up to 5s (generous real-time + // budget), then assert the loop exited with IdleTimeout — not PermissionPoisoned + // or any other error — proving idle was re-armed after the decision was applied. + let result = loop_task.await.expect("loop task must not panic"); + + assert!( + matches!(result, Err(AcpError::IdleTimeout(_))), + "after permission resolved, idle must fire and exit the loop; got: {result:?}" + ); + + // Confirm the applied decision emitted an authorized acp_write in the observer. + let events = obs.snapshot(); + let applied_writes: Vec<_> = events + .iter() + .filter(|e| { + e.kind == "acp_write" + && e.authorization + .as_ref() + .map(|a| a.reason.as_deref() == Some("applied")) + .unwrap_or(false) + }) + .collect(); + assert_eq!( + applied_writes.len(), + 1, + "exactly one applied acp_write must be emitted after decision; got: {applied_writes:?}" + ); + } + + /// Capacity recovery: 9 sequential requests all succeed when each prior + /// request is decided before the next is queued. Entries are removed on + /// terminal transition so the 9th slot is available. + /// + /// Proves behavior at the wire level: a capture script collects all stdin + /// NDJSON lines so we can assert 9 distinct permission responses were written. + #[tokio::test] + async fn ask_nine_sequential_requests_all_succeed_after_capacity_recovery() { + // Script: read all stdin lines into a capture file, then stay alive. + // This captures every wire write the harness makes to the agent. + let capture_file = + std::env::temp_dir().join(format!("buzz-acp-cap9-{}.ndjson", uuid::Uuid::new_v4())); + let script = format!( + r#"while IFS= read -r line; do printf '%s\n' "$line" >> {capture}; done; sleep 2"#, + capture = capture_file.display(), + ); + let mut client = spawn_script(&script).await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + install_test_relay_context(&mut client); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + + // Register each request and immediately deliver a decision, one at a time. + // After each decision is applied, the entry is removed from the map, + // freeing a slot for the next request. This proves capacity recovery. + // + // A fresh permission decision channel is installed for each iteration so + // the receiver is live when the loop runs. `read_until_response_with_idle_timeout` + // takes the rx for its duration; creating a new one per iteration avoids + // the "rx dropped between calls" problem that would occur with a single receiver. + let mut response_nonces: Vec<String> = Vec::new(); + for i in 0..9u64 { + // Fresh channel per iteration — the rx is live for exactly one loop call. + let (iter_tx, iter_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(4); + client.install_permission_decision_rx(iter_rx); + + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + let msg = perm_request(i + 100, default_opts()); + let result = client.handle_permission_request(&msg, hard).await; + assert!( + result.as_ref().is_ok_and(|v| *v), + "request {i} must register successfully (capacity not exhausted), got: {result:?}" + ); + + // Capture the nonce and deliver a decision immediately. + let id_str = (i + 100).to_string(); + let nonce = client + .pending_permissions + .get(&id_str) + .expect("entry must be Pending after registration") + .nonce + .clone(); + response_nonces.push(nonce.clone()); + iter_tx + .send(PermissionDecision { + request_nonce: nonce, + option_id: "opt-allow".to_string(), + }) + .await + .ok(); + + // Drive the loop briefly to process the queued decision. + let hard_loop = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + let _ = tokio::time::timeout( + std::time::Duration::from_millis(300), + client.read_until_response_with_idle_timeout( + "sess-cap9", + 9999, + std::time::Duration::from_millis(150), + hard_loop, + std::time::Duration::from_secs(5), + ), + ) + .await; + + // After the decision is applied the entry must be removed (no tombstone). + assert!( + !client.pending_permissions.contains_key(&id_str), + "entry {i} must be removed after decision applied" + ); + } + + // All 9 requests succeeded. Map must be empty. + assert!( + client.pending_permissions.is_empty(), + "map must be empty after 9 sequential requests all resolved" + ); + + // Give the script a moment to flush all lines. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Wire-level assertion: 9 distinct permission responses were written on the pipe. + let wire_lines = tokio::task::spawn_blocking({ + let capture_file = capture_file.clone(); + move || { + for _ in 0..30 { + if let Ok(s) = std::fs::read_to_string(&capture_file) { + let lines: Vec<String> = s + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(); + if lines.len() >= 9 { + return lines; + } + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + // Return whatever we have. + std::fs::read_to_string(&capture_file) + .unwrap_or_default() + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect() + } + }) + .await + .expect("spawn_blocking failed"); + let _ = std::fs::remove_file(&capture_file); + + // Filter to permission responses: {"id": <num>, "result": {"outcome": {...}}} + let perm_responses: Vec<serde_json::Value> = wire_lines + .iter() + .filter_map(|l| serde_json::from_str(l).ok()) + .filter(|v: &serde_json::Value| { + v.get("result").and_then(|r| r.get("outcome")).is_some() + }) + .collect(); + + // The 9 distinct IDs (100..108) each got one wire response. + let written_ids: std::collections::HashSet<u64> = perm_responses + .iter() + .filter_map(|v| v["id"].as_u64()) + .collect(); + assert_eq!( + written_ids.len(), + 9, + "must have 9 distinct permission wire responses (one per request id), \ + got ids: {written_ids:?}, total responses: {perm_responses:?}" + ); + // Verify ids span 100..108 inclusive. + for expected_id in 100..109u64 { + assert!( + written_ids.contains(&expected_id), + "missing wire response for id={expected_id}" + ); + } + + // Observer-level: 9 distinct authorized acp_write nonces. + let events = obs.snapshot(); + let write_nonces: std::collections::HashSet<String> = events + .iter() + .filter(|e| { + e.kind == "acp_write" + && e.authorization + .as_ref() + .map(|a| a.reason.as_deref() == Some("applied")) + .unwrap_or(false) + }) + .filter_map(|e| e.authorization.as_ref().map(|a| a.request_nonce.clone())) + .collect(); + assert_eq!( + write_nonces.len(), + 9, + "must have 9 distinct authorized acp_write events (one per request), got: {write_nonces:?}" + ); + } + + // ── Pinned §1 (simpler): ask entry registered synchronously ────────────── + + #[tokio::test] + async fn ask_registers_entry_in_pending_map() { + // Verify that handle_permission_request under ask policy inserts + // a Pending entry into the map (without needing a live decision loop). + let mut client = spawn_inert_client().await; + let config = ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(); + client.set_permission_config(config); + client.set_owner_pubkey_known(true); + // Install an observer so the ask arm doesn't downgrade. + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs), 0); + // Install a permission decision channel (must be installed or take() panics). + let (_perm_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + // Install relay context so D7 passes and the entry is inserted. + install_test_relay_context(&mut client); + + let msg = perm_request(42, default_opts()); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let result = client.handle_permission_request(&msg, hard_deadline).await; + assert!( + result.is_ok(), + "ask must return Ok to suppress generic emit" + ); + assert!( + result.unwrap(), + "ask must return Ok(true) to suppress generic emit" + ); + assert_eq!( + client.pending_permissions.len(), + 1, + "exactly one entry must be registered after ask" + ); + let entry = client + .pending_permissions + .get("42") + .expect("entry under id=42"); + assert!( + matches!( + entry.state, + PermissionEntryState::Publishing | PermissionEntryState::Pending + ), + "entry must start in Publishing or Pending state (relay ACK may arrive before assertion)" + ); + } + + // ── Pinned §1 (cancel during write path): poison process test ──────────── + + #[test] + fn cancel_during_writing_poisons_process() { + // Simulate a process that has an entry in Writing state at cancel time. + // cancel_with_cleanup_until must return PermissionPoisoned and set the flag. + // + // We test this synchronously because cancel_with_cleanup_until is async + // and we need to manipulate state directly. We use a tokio runtime. + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + // Use a "sleep" script so the process is alive but won't emit responses. + let mut client = spawn_script("sleep 10").await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + + // Manually plant an entry in Writing state — this simulates cancel + // arriving while the harness was in the middle of writing. + client.pending_permissions.insert( + "99".to_string(), + PermissionEntry { + nonce: "n99".to_string(), + options_snapshot: vec![], + card_actions: test_card_actions(), + state: PermissionEntryState::Writing, + deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(300), + expiry_unix_secs: 0, + sentinel_event_id: None, + early_decision: None, + description: None, + }, + ); + // cancel_with_cleanup needs last_prompt_id to be Some. + client.last_prompt_id = Some(999); + + let err = client + .cancel_with_cleanup_grace("sess-poison", std::time::Duration::from_millis(500)) + .await + .expect_err("cancel during write must return Err"); + + assert!( + matches!(err, AcpError::PermissionPoisoned), + "expected PermissionPoisoned, got {err:?}" + ); + assert!( + client.permission_poisoned, + "poisoned flag must be set after cancel-during-write" + ); + }); + } + + #[test] + fn poisoned_process_surfaces_immediately_on_next_cancel() { + // Once poisoned, every subsequent cancel must immediately return PermissionPoisoned + // without writing anything — the process is unsafe to use. + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let mut client = spawn_script("sleep 10").await; + client.permission_poisoned = true; + client.last_prompt_id = Some(1); + + let err = client + .cancel_with_cleanup_grace("sess", std::time::Duration::from_millis(200)) + .await + .expect_err("poisoned process must error immediately"); + assert!(matches!(err, AcpError::PermissionPoisoned)); + }); + } + + /// Two-entry cancel: first write fails → stop immediately, no second write. + /// + /// Registers two Pending entries, then cancels against a process whose stdin + /// pipe is already closed (script exits immediately). The first + /// `finish_permission()` call returns `false` (write failed, process poisoned), + /// and the cancel loop must return `Err(PermissionPoisoned)` immediately — zero + /// bytes are written for the second entry. + /// + /// Uses an instrumented write-attempt counter to assert exactly ONE attempt was + /// made (the first, which failed), not just that no successful writes occurred. + /// The counter distinguishes "stopped after first attempt" from "tried all and + /// all failed" — the latter would allow the loop to continue past the poison. + #[tokio::test] + async fn cancel_first_write_fails_stops_immediately_no_second_write() { + // Script: exit immediately without reading stdin. + // After exit, the read-end of stdin is closed; writes fail with BrokenPipe. + let mut client = spawn_script("exit 0").await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + install_test_relay_context(&mut client); + + // Install the write-attempt counter BEFORE registration so all writes + // (including the registration acks and the cancel responses) are counted. + let attempt_counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + client.set_write_attempt_count(attempt_counter.clone()); + + // Register two Pending entries. + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + for i in 0..2u64 { + let msg = perm_request(i, default_opts()); + client + .handle_permission_request(&msg, hard) + .await + .expect("ask registration must succeed"); + // In production the relay ACK transitions the entry Publishing → + // Pending in the read loop before the next request arrives, so two + // Pending entries legitimately coexist. This test does not drive the + // loop between registrations, so apply that transition explicitly — + // otherwise the publish-in-flight guard denies the second request. + if let Some(entry) = client.pending_permissions.get_mut(&i.to_string()) { + entry.state = PermissionEntryState::Pending; + } + } + assert_eq!( + client.pending_permissions.len(), + 2, + "two entries must be registered" + ); + client.last_prompt_id = Some(999); + + // Wait briefly for the script to exit and close its stdin read-end. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Snapshot the attempt count before cancel so we can count only cancel writes. + let attempts_before_cancel = attempt_counter.load(std::sync::atomic::Ordering::Relaxed); + + // Cancel: the first finish_permission() write must fail (BrokenPipe), + // poison the process, and return Err(PermissionPoisoned) immediately. + let err = client + .cancel_with_cleanup_grace("sess-fail2", std::time::Duration::from_millis(500)) + .await + .expect_err("cancel on closed-stdin process must return Err"); + assert!( + matches!(err, AcpError::PermissionPoisoned), + "expected PermissionPoisoned, got {err:?}" + ); + assert!( + client.permission_poisoned, + "poisoned flag must be set after cancel write failure" + ); + + // Exactly ONE write attempt during the cancel phase. + // If the loop stopped after the first failed attempt, count = 1. + // If it continued and tried the second entry, count = 2. + let attempts_during_cancel = + attempt_counter.load(std::sync::atomic::Ordering::Relaxed) - attempts_before_cancel; + assert_eq!( + attempts_during_cancel, 1, + "cancel must attempt exactly one write (for the first entry) then stop; \ + attempted {attempts_during_cancel} times" + ); + + // No successful cancel writes. + let events = obs.snapshot(); + let cancel_writes = events + .iter() + .filter(|e| { + e.kind == "acp_write" + && e.authorization + .as_ref() + .map(|a| a.reason.as_deref() == Some("cancelled")) + .unwrap_or(false) + }) + .count(); + assert_eq!( + cancel_writes, 0, + "no successful cancel writes must be emitted when first write fails; got {cancel_writes}" + ); + + // At least one `permission_terminal` uncertain event must be emitted. + let uncertain_events = events + .iter() + .filter(|e| { + e.kind == "permission_terminal" + && e.authorization + .as_ref() + .map(|a| a.reason.as_deref() == Some("uncertain")) + .unwrap_or(false) + }) + .count(); + assert!( + uncertain_events >= 1, + "at least one permission_terminal(uncertain) must be emitted on write failure; got {uncertain_events}" + ); + } + + #[test] + fn poisoned_process_check_in_read_loop_returns_poison_error() { + // Once permission_poisoned is set, read_until_response_with_idle_timeout + // must return PermissionPoisoned on the next loop iteration. + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let mut client = spawn_script("sleep 10").await; + client.permission_poisoned = true; + client.last_prompt_id = Some(42); + + let idle = std::time::Duration::from_secs(5); + let max_dur = std::time::Duration::from_secs(10); + let hard_deadline = tokio::time::Instant::now() + max_dur; + let result = client + .read_until_response_with_idle_timeout("sess", 42, idle, hard_deadline, max_dur) + .await; + assert!( + matches!(result, Err(AcpError::PermissionPoisoned)), + "expected PermissionPoisoned from poisoned-flag check, got {result:?}" + ); + }); + } + + // ── Pinned §5: cancel drains pending entries with cancelled ─────────────── + + #[test] + fn cancel_drains_pending_entries_with_cancelled_response() { + // Under ask policy: cancel must drain all Pending entries and write + // "cancelled" responses for each, then proceed to session/cancel. + // Verifies: + // - Map is empty after cancel (entries were drained). + // - Cancel result is NOT PermissionPoisoned (no Writing entries present). + // - Cancel exits normally (Ok or CancelDrainTimeout — sleep script never + // emits a response, so this exits via timeout, which is expected). + // + // We can verify that Pending entries are removed by checking the map post-cancel. + // We don't verify the wire bytes here (that requires a live script) — we verify + // the state machine: Pending entries disappear after cancel. + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + // Use a "sleep" script — stays alive but ignores stdin. + let mut client = spawn_script("sleep 5").await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + + // Plant two Pending entries. + for i in 0..2u64 { + client.pending_permissions.insert( + format!("{i}"), + PermissionEntry { + nonce: format!("n{i}"), + options_snapshot: vec![ + serde_json::json!({"optionId":"opt","kind":"reject_once","name":"R"}), + ], + card_actions: test_card_actions(), + state: PermissionEntryState::Pending, + deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(300), + expiry_unix_secs: 0, + sentinel_event_id: None, + early_decision: None, + description: None, + }, + ); + } + client.last_prompt_id = Some(999); + + // cancel_with_cleanup_grace with short grace — the sleep script will + // never emit a response, so this exits via CancelDrainTimeout. + let result = client + .cancel_with_cleanup_grace("sess-drain", std::time::Duration::from_millis(200)) + .await; + + // Should NOT be PermissionPoisoned (no Writing entries). + assert!( + !matches!(result, Err(AcpError::PermissionPoisoned)), + "no Writing entries — must not be PermissionPoisoned" + ); + // Map must be empty — Pending entries were drained. + assert!( + client.pending_permissions.is_empty(), + "all Pending entries must be removed from the map after cancel" + ); + }); + } + + // ── D7-final admission: named tests (owner / non-owner / no-publisher / unresolved) ─ + + /// D7: owner-initiated turn + matching owner hex → entry inserted as Publishing. + #[tokio::test] + async fn d7_owner_initiated_turn_inserts_publishing_entry() { + let mut client = spawn_inert_client().await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs), 0); + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + // Owner-initiated: initiator == owner. + install_test_relay_context(&mut client); + + let msg = perm_request(1, default_opts()); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let result = client.handle_permission_request(&msg, hard).await; + assert!(result.is_ok_and(|v| v), "owner-initiated ask must succeed"); + assert_eq!( + client.pending_permissions.len(), + 1, + "entry must be inserted for owner-initiated turn" + ); + } + + /// D7: non-owner-initiated turn → request denied synchronously, no entry inserted. + #[tokio::test] + async fn d7_non_owner_initiated_turn_denied_no_entry() { + let mut client = spawn_inert_client().await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs), 0); + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // Install relay context but set a DIFFERENT initiator (non-owner). + let owner_keys = install_test_relay_context(&mut client); + let non_owner_keys = Keys::generate(); + assert_ne!( + owner_keys.public_key(), + non_owner_keys.public_key(), + "keys must be different" + ); + // Override the initiator with a different pubkey. + client.set_turn_initiator_pubkey(Some(non_owner_keys.public_key())); + + let msg = perm_request(1, default_opts()); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let result = client.handle_permission_request(&msg, hard).await; + assert!( + result.is_ok(), + "non-owner ask must return Ok (not propagate error)" + ); + assert!( + client.pending_permissions.is_empty(), + "non-owner ask must not insert a pending entry" + ); + } + + /// D7: no relay publisher → request denied synchronously, no entry inserted. + #[tokio::test] + async fn d7_no_relay_publisher_denied_no_entry() { + let mut client = spawn_inert_client().await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs), 0); + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + // Intentionally set owner/initiator WITHOUT installing a relay publisher. + let keys = Keys::generate(); + let owner_hex = keys.public_key().to_hex(); + client.set_agent_owner_pubkey_hex(Some(owner_hex)); + client.set_turn_initiator_pubkey(Some(keys.public_key())); + // No relay publisher → D7 denies. + + let msg = perm_request(1, default_opts()); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let result = client.handle_permission_request(&msg, hard).await; + assert!( + result.is_ok(), + "no-publisher ask must return Ok (not propagate error)" + ); + assert!( + client.pending_permissions.is_empty(), + "no-publisher ask must not insert a pending entry" + ); + } + + /// D7: unresolved owner (relay present but owner hex absent) → denied, no entry. + #[tokio::test] + async fn d7_unresolved_owner_denied_no_entry() { + let mut client = spawn_inert_client().await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs), 0); + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // Install publisher and initiator but NO owner hex. + let keys = Keys::generate(); + let (publisher, event_rx) = crate::relay::RelayEventPublisher::test_pair(); + tokio::spawn(async move { + let mut rx = event_rx; + while rx.recv().await.is_some() {} + }); + client.set_relay_publisher(publisher, keys.clone()); + client.set_turn_initiator_pubkey(Some(keys.public_key())); + client.set_turn_channel_context( + Some(uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap()), + None, + ); + // owner_hex deliberately NOT set → D7 denies. + + let msg = perm_request(1, default_opts()); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let result = client.handle_permission_request(&msg, hard).await; + assert!(result.is_ok(), "unresolved-owner ask must return Ok"); + assert!( + client.pending_permissions.is_empty(), + "unresolved-owner ask must not insert a pending entry" + ); + } + + // ── ACK lifecycle tests (frozen named list) ─────────────────────────────── + + /// Positive OK: relay accepts → entry transitions Publishing → Pending, + /// then a decision drives it to Writing/terminal. Map empty after resolution. + #[tokio::test] + async fn sentinel_ack_accepted_transitions_to_pending_and_decision_applies() { + // Script: read one line (the permission response), then exit. + let capture_file = + std::env::temp_dir().join(format!("buzz-acp-ack-ok-{}.json", uuid::Uuid::new_v4())); + let script = format!( + r#"read -r resp; printf '%s' "$resp" > {capture}"#, + capture = capture_file.display(), + ); + let mut client = spawn_script(&script).await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + install_test_relay_context(&mut client); // auto-accepts + let obs = crate::observer::ObserverHandle::in_process(); + let mut obs_rx = obs.subscribe(); + client.set_observer(Some(obs.clone()), 0); + let (perm_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // Background task: wait for actionable acp_read, then deliver decision. + let decision_task = tokio::spawn(async move { + let mut found_nonce: Option<String> = None; + while let Ok(Ok(event)) = + tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()).await + { + if event.kind == "acp_read" { + if let Some(auth) = &event.authorization { + if auth.actionable { + found_nonce = Some(auth.request_nonce.clone()); + break; + } + } + } + } + let nonce = found_nonce.expect("actionable acp_read must be emitted after ACK"); + perm_tx + .send(PermissionDecision { + request_nonce: nonce, + option_id: "opt-allow".to_string(), + }) + .await + .expect("decision send must succeed"); + }); + + let _ = decision_task.await; + + // Run the loop briefly — it should process the ACK (Accepted), transition to Pending, + // then apply the decision via the observer-based task above. + // We use a short-lived inert script since we only care about the permission write. + let idle = std::time::Duration::from_secs(5); + let max_dur = std::time::Duration::from_secs(10); + let hard = tokio::time::Instant::now() + max_dur; + + // Drive the loop; it will exit via IdleTimeout after the decision is applied. + let result = tokio::time::timeout( + max_dur, + client.read_until_response_with_idle_timeout("sess-ack-ok", 999, idle, hard, max_dur), + ) + .await; + + // Map must be empty after the decision is applied. + assert!( + client.pending_permissions.is_empty(), + "map must be empty after ACK+decision cycle; result: {result:?}" + ); + let _ = std::fs::remove_file(&capture_file); + } + + /// Rejected OK: relay rejects sentinel → entry denied immediately, map empty, no card shown. + #[tokio::test] + async fn sentinel_ack_rejected_denies_immediately_map_empty() { + let mut client = spawn_script("sleep 600").await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + // Install a rejecting publisher. + let keys = Keys::generate(); + let owner_hex = keys.public_key().to_hex(); + let (publisher, event_rx) = crate::relay::RelayEventPublisher::test_pair_rejecting(); + tokio::spawn(async move { + let mut rx = event_rx; + while rx.recv().await.is_some() {} + }); + client.set_relay_publisher(publisher, keys.clone()); + client.set_agent_owner_pubkey_hex(Some(owner_hex)); + client.set_turn_initiator_pubkey(Some(keys.public_key())); + client.set_turn_channel_context( + Some(uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap()), + None, + ); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + let msg = perm_request(1, default_opts()); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + client + .handle_permission_request(&msg, hard) + .await + .expect("registration must succeed"); + assert_eq!( + client.pending_permissions.len(), + 1, + "entry must be inserted as Publishing before ACK" + ); + + // Drive the loop: relay task runs, sends Rejected, ACK arm fires, entry denied. + // Use a real-time timeout — the rejecting publisher fires immediately. + let max_dur = std::time::Duration::from_secs(5); + let hard2 = tokio::time::Instant::now() + max_dur; + let loop_result = tokio::time::timeout( + max_dur, + client.read_until_response_with_idle_timeout( + "sess-ack-reject", + 999, + std::time::Duration::from_secs(5), + hard2, + max_dur, + ), + ) + .await; + + assert!( + client.pending_permissions.is_empty(), + "map must be empty after relay rejection; loop_result={loop_result:?}" + ); + + // A timed_out write must have been emitted by the reject path. + let events = obs.snapshot(); + let timeout_or_denied_writes: Vec<_> = events + .iter() + .filter(|e| { + e.kind == "acp_write" + && e.authorization + .as_ref() + .map(|a| { + a.reason.as_deref() == Some("timed_out") + || a.reason.as_deref() == Some("rejected") + }) + .unwrap_or(false) + }) + .collect(); + assert!( + !timeout_or_denied_writes.is_empty(), + "a denial write must be emitted after relay rejection; events: {events:?}" + ); + } + + /// Timeout with map empty: relay never ACKs within SENTINEL_PUBLISH_TIMEOUT_SECS → + /// entry denied, map provably empty before the 300s turn deadline. + #[tokio::test(start_paused = true)] + async fn sentinel_ack_timeout_denies_and_map_empty() { + let mut client = spawn_script("sleep 600").await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + // Install a silent publisher — never sends an ACK. + let keys = Keys::generate(); + let owner_hex = keys.public_key().to_hex(); + let (publisher, event_rx) = crate::relay::RelayEventPublisher::test_pair_silent(); + tokio::spawn(async move { + let mut rx = event_rx; + while rx.recv().await.is_some() {} + }); + client.set_relay_publisher(publisher, keys.clone()); + client.set_agent_owner_pubkey_hex(Some(owner_hex)); + client.set_turn_initiator_pubkey(Some(keys.public_key())); + client.set_turn_channel_context( + Some(uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000004").unwrap()), + None, + ); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + let msg = perm_request(1, default_opts()); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + client + .handle_permission_request(&msg, hard) + .await + .expect("registration must succeed"); + assert_eq!( + client.pending_permissions.len(), + 1, + "entry must be inserted as Publishing" + ); + + // Advance past SENTINEL_PUBLISH_TIMEOUT_SECS (10s) so the background task's + // timeout fires and sends Uncertain to ack_result_rx. + tokio::time::advance(std::time::Duration::from_secs( + SENTINEL_PUBLISH_TIMEOUT_SECS + 1, + )) + .await; + + // Drive the loop to process the timeout outcome. + let hard2 = tokio::time::Instant::now() + std::time::Duration::from_secs(290); + let _ = tokio::select! { + r = client.read_until_response_with_idle_timeout( + "sess-ack-timeout", 999, + std::time::Duration::from_secs(5), + hard2, + std::time::Duration::from_secs(290), + ) => r, + _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => Err(AcpError::IdleTimeout(std::time::Duration::from_millis(100))), + }; + + assert!( + client.pending_permissions.is_empty(), + "map must be empty after publish timeout" + ); + + // A denial write must have been emitted. + let events = obs.snapshot(); + let denial_writes: Vec<_> = events + .iter() + .filter(|e| e.kind == "acp_write" && e.authorization.is_some()) + .collect(); + assert!( + !denial_writes.is_empty(), + "a denial write must be emitted after publish timeout; events: {events:?}" + ); + } + + /// Socket failure (channel closed): relay command channel closes → `register_publish_ack` + /// returns Err → entry denied synchronously, map empty immediately. + #[tokio::test] + async fn sentinel_ack_socket_failure_denies_synchronously_map_empty() { + let mut client = spawn_inert_client().await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs), 0); + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // Build a publisher whose cmd_tx is immediately dropped so any send returns Err. + let keys = Keys::generate(); + let owner_hex = keys.public_key().to_hex(); + // Create a publisher with a dead (closed) command channel. + let publisher = crate::relay::RelayEventPublisher::test_pair_dead(); + client.set_relay_publisher(publisher, keys.clone()); + client.set_agent_owner_pubkey_hex(Some(owner_hex)); + client.set_turn_initiator_pubkey(Some(keys.public_key())); + client.set_turn_channel_context( + Some(uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000005").unwrap()), + None, + ); + + let msg = perm_request(1, default_opts()); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + // register_publish_ack will fail → deny path runs synchronously. + let result = client.handle_permission_request(&msg, hard).await; + assert!( + result.is_ok(), + "socket-failure ask must return Ok (deny path)" + ); + assert!( + client.pending_permissions.is_empty(), + "map must be empty after socket failure — entry was removed before returning" + ); + } + + /// Early decision buffered then applied: a decision arrives while the entry is + /// still in Publishing state; it is buffered and applied immediately on ACK. + #[tokio::test] + async fn sentinel_ack_early_decision_buffered_then_applied_on_accepted() { + // Script: read one permission response line (from the early-decision path), exit. + let capture_file = + std::env::temp_dir().join(format!("buzz-acp-early-{}.json", uuid::Uuid::new_v4())); + let script = format!( + r#"read -r resp; printf '%s' "$resp" > {capture}; sleep 2"#, + capture = capture_file.display(), + ); + let mut client = spawn_script(&script).await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + // Use the auto-accepting test_pair. + install_test_relay_context(&mut client); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + let (perm_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + let msg = perm_request(77, default_opts()); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + client + .handle_permission_request(&msg, hard) + .await + .expect("registration must succeed"); + + // Read the nonce from the Publishing entry (before ACK arrives). + let nonce = client + .pending_permissions + .get("77") + .expect("entry must be in map") + .nonce + .clone(); + + // Send a decision NOW — the entry is still in Publishing state. + // This decision should be buffered in early_decision and applied on ACK. + perm_tx + .send(PermissionDecision { + request_nonce: nonce, + option_id: "opt-allow".to_string(), + }) + .await + .expect("decision send must succeed"); + + // Drive the loop — ACK fires (Accepted), buffered decision applied, map empties. + let idle = std::time::Duration::from_millis(200); + let max_dur = std::time::Duration::from_secs(5); + let hard2 = tokio::time::Instant::now() + max_dur; + let _ = tokio::time::timeout( + max_dur, + client.read_until_response_with_idle_timeout( + "sess-early-decision", + 999, + idle, + hard2, + max_dur, + ), + ) + .await; + + assert!( + client.pending_permissions.is_empty(), + "map must be empty after early-decision + ACK cycle" + ); + + // Observer must show an applied write. + let events = obs.snapshot(); + let applied_writes: Vec<_> = events + .iter() + .filter(|e| { + e.kind == "acp_write" + && e.authorization + .as_ref() + .map(|a| a.reason.as_deref() == Some("applied")) + .unwrap_or(false) + }) + .collect(); + assert_eq!( + applied_writes.len(), + 1, + "exactly one applied write after early decision + ACK; got: {applied_writes:?}" + ); + let _ = std::fs::remove_file(&capture_file); + } + + /// Deadline-during-publish: an entry whose publish deadline has passed while + /// still in `Publishing` state is denied and never transitions to `Pending`. + /// + /// Uses `test_pair_silent` (drops ack_tx immediately) to simulate a relay + /// that never sends OK. With `start_paused = true` we advance time past + /// `SENTINEL_PUBLISH_TIMEOUT_SECS` so the relay background task's deadline + /// arm fires, sweeping the waiter as `Uncertain`, which the ACP loop processes + /// as a denial — the entry must not enter `Pending` and the map must be empty. + #[tokio::test(start_paused = true)] + async fn sentinel_ack_deadline_during_publishing_never_admitted() { + let mut client = spawn_script("sleep 600").await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + let keys = Keys::generate(); + let owner_hex = keys.public_key().to_hex(); + let (publisher, event_rx) = crate::relay::RelayEventPublisher::test_pair_silent(); + tokio::spawn(async move { + let mut rx = event_rx; + while rx.recv().await.is_some() {} + }); + client.set_relay_publisher(publisher, keys.clone()); + client.set_agent_owner_pubkey_hex(Some(owner_hex)); + client.set_turn_initiator_pubkey(Some(keys.public_key())); + client.set_turn_channel_context( + Some(uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000006").unwrap()), + None, + ); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + let msg = perm_request(99, default_opts()); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + client + .handle_permission_request(&msg, hard) + .await + .expect("registration must succeed"); + + // Entry is in Publishing state. Advance past the publish deadline. + tokio::time::advance(std::time::Duration::from_secs( + SENTINEL_PUBLISH_TIMEOUT_SECS + 1, + )) + .await; + + // Drive the loop — ack_result_rx receives Uncertain (from the dropped + // sender), the ACK arm fires, the entry is denied, and the map empties. + let hard2 = tokio::time::Instant::now() + std::time::Duration::from_secs(290); + let _ = tokio::select! { + r = client.read_until_response_with_idle_timeout( + "sess-deadline-during-publishing", 999, + std::time::Duration::from_secs(5), + hard2, + std::time::Duration::from_secs(290), + ) => r, + _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => { + Err(AcpError::IdleTimeout(std::time::Duration::from_millis(100))) + } + }; + + assert!( + client.pending_permissions.is_empty(), + "map must be empty — deadline-during-publish must deny, never admit to Pending" + ); + + // A denial write must have been emitted (publish timeout → fail closed). + // No Pending transition occurred — the entry went Publishing → denied. + let events = obs.snapshot(); + let denial_writes: Vec<_> = events + .iter() + .filter(|e| { + e.kind == "acp_write" + && e.authorization + .as_ref() + .map(|a| { + a.reason.as_deref() == Some("timed_out") + || a.reason.as_deref() == Some("rejected") + }) + .unwrap_or(false) + }) + .collect(); + assert!( + !denial_writes.is_empty(), + "a denial write must be emitted after deadline fires during Publishing; events: {events:?}" + ); + } + + /// Resolved-edit delivery survives a relay disconnect at decision time. + /// + /// The permission decision is irreversible once `finish_permission` writes + /// the ACP response and removes the entry, so the resolved kind-40003 edit + /// that retires the UI card MUST reach the relay even if the socket is down + /// at that instant. This drives the full production lifecycle (Publishing → + /// Pending → Writing via an early-buffered decision → `finish_permission`), + /// with a publisher that reports the FIRST resolved-edit publish as + /// `Uncertain` (disconnected) and every later one as `Accepted` + /// (reconnected). The fix retransmits the *same signed event* on Uncertain, + /// so the card is repaired on reconnect. + /// + /// Acceptance bar (mutation proof): reverting the production path to a + /// fire-and-forget `publisher.publish_event(event)` publishes the resolved + /// edit exactly once with no ACK awaited, so only ONE kind-40003 event is + /// ever emitted and this test goes red on the retransmission assertion. + #[tokio::test] + async fn resolved_edit_retransmitted_until_accepted_across_disconnect() { + // Script reads the one permission response line (early-decision path), then idles. + let capture_file = std::env::temp_dir().join(format!( + "buzz-acp-resolved-retx-{}.json", + uuid::Uuid::new_v4() + )); + let script = format!( + r#"read -r resp; printf '%s' "$resp" > {capture}; sleep 5"#, + capture = capture_file.display(), + ); + let mut client = spawn_script(&script).await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + + // Publisher: kind-9 sentinel Accepted (lifecycle proceeds); the first + // resolved kind-40003 publish is Uncertain (socket down), then Accepted. + let keys = Keys::generate(); + let owner_hex = keys.public_key().to_hex(); + let (publisher, event_rx) = + crate::relay::RelayEventPublisher::test_pair_resolved_reconnect(1); + // Collect every published event (including each retransmission attempt). + let published: std::sync::Arc<std::sync::Mutex<Vec<(u16, String)>>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let published_drain = published.clone(); + tokio::spawn(async move { + let mut rx = event_rx; + while let Some(ev) = rx.recv().await { + published_drain + .lock() + .unwrap() + .push((ev.kind.as_u16(), ev.id.to_hex())); + } + }); + client.set_relay_publisher(publisher, keys.clone()); + client.set_agent_owner_pubkey_hex(Some(owner_hex)); + client.set_turn_initiator_pubkey(Some(keys.public_key())); + client.set_turn_channel_context( + Some(uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000008").unwrap()), + None, + ); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + let (perm_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + let msg = perm_request(88, default_opts()); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + client + .handle_permission_request(&msg, hard) + .await + .expect("registration must succeed"); + + // Buffer an allow decision while still Publishing; applied on ACK. + let nonce = client + .pending_permissions + .get("88") + .expect("entry must be in map") + .nonce + .clone(); + perm_tx + .send(PermissionDecision { + request_nonce: nonce, + option_id: "opt-allow".to_string(), + }) + .await + .expect("decision send must succeed"); + + // Drive the loop: ACK Accepted → Pending → buffered decision applied → + // finish_permission writes the ACP response and spawns the resolved-edit + // retransmit task. Loop exits on the short idle timeout. + let idle = std::time::Duration::from_millis(200); + let max_dur = std::time::Duration::from_secs(5); + let hard2 = tokio::time::Instant::now() + max_dur; + let _ = tokio::time::timeout( + max_dur, + client.read_until_response_with_idle_timeout( + "sess-resolved-retx", + 999, + idle, + hard2, + max_dur, + ), + ) + .await; + + assert!( + client.pending_permissions.is_empty(), + "map must be empty after the decision is applied" + ); + + // Wait for the detached retransmit task: first attempt (Uncertain), + // RESOLVED_RETRANSMIT_BACKOFF, second attempt (Accepted). Poll until two + // resolved-edit publishes are observed or a generous bound elapses. + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(6); + loop { + let resolved_count = published + .lock() + .unwrap() + .iter() + .filter(|(kind, _)| *kind == 40003) + .count(); + if resolved_count >= 2 || tokio::time::Instant::now() >= deadline { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + let resolved_ids: Vec<String> = published + .lock() + .unwrap() + .iter() + .filter(|(kind, _)| *kind == 40003) + .map(|(_, id)| id.clone()) + .collect(); + + // The resolved edit was retransmitted across the disconnect — this is + // the assertion the fire-and-forget mutation turns red (it publishes + // the edit exactly once with no ACK, so resolved_ids.len() == 1). + assert!( + resolved_ids.len() >= 2, + "resolved kind-40003 edit must be retransmitted after an Uncertain outcome; \ + saw {} publish(es): {resolved_ids:?}", + resolved_ids.len() + ); + // Every retransmission is the SAME signed event (idempotent by id) — + // the spec requirement that a retry resends the identical event. + assert!( + resolved_ids.windows(2).all(|w| w[0] == w[1]), + "every retransmission must be the same signed event id; saw {resolved_ids:?}" + ); + + let _ = std::fs::remove_file(&capture_file); + } + + /// Resolved-edit delivery survives a *lost `OK` on a connected socket*. + /// + /// Distinct from the disconnect case: here the relay receives the EVENT but + /// its `OK` never comes back, so the acked waiter is resolved only when its + /// per-waiter deadline sweeps. With the raw card expiry (≤300s) as that + /// deadline the single attempt would park the whole window and exit with + /// zero retransmissions; the fix caps each attempt at + /// `SENTINEL_PUBLISH_TIMEOUT_SECS` so the stuck waiter sweeps promptly and + /// the identical signed event is resent and accepted. + /// + /// Acceptance bar (mutation proof): reverting the per-attempt deadline back + /// to the raw `expiry_deadline` (`register_publish_ack(event.clone(), + /// expiry_deadline)`) makes the first attempt park until card expiry, so + /// only ONE kind-40003 publish is ever emitted and this test goes red. + /// + /// Runs under paused tokio time so the 10s per-attempt deadline and the 2s + /// backoff advance deterministically without real waiting. + #[tokio::test(start_paused = true)] + async fn resolved_edit_retransmitted_after_lost_ok_on_connected_socket() { + let keys = Keys::generate(); + let (publisher, mut event_rx) = + crate::relay::RelayEventPublisher::test_pair_resolved_lost_ok(1); + + // Collect every resolved-edit publish (each retransmission attempt). + let published: std::sync::Arc<std::sync::Mutex<Vec<String>>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let published_drain = published.clone(); + tokio::spawn(async move { + while let Some(ev) = event_rx.recv().await { + if ev.kind.as_u16() == 40003 { + published_drain.lock().unwrap().push(ev.id.to_hex()); + } + } + }); + + // Sign the resolved edit once; the retransmit loop resends this exact event. + let event = build_kind40003_sentinel( + &keys, + uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000009").unwrap(), + "target-event-id", + "resolved-edit-content", + ) + .expect("sentinel must build"); + + // Card expiry generously beyond one per-attempt deadline (10s) so the + // first attempt sweeps and a second attempt is still within the window. + let expiry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(60); + + let task = tokio::spawn(retransmit_resolved_edit(publisher, event, expiry_deadline)); + + // Under paused time the runtime auto-advances the clock while every task + // is parked on a timer, fast-forwarding the 10s per-attempt deadline sweep + // and the 2s backoff. Joining the task drives it to the Accepted second + // attempt; a wall-clock guard keeps a regression from hanging. + let joined = tokio::time::timeout(std::time::Duration::from_secs(120), task).await; + assert!(joined.is_ok(), "retransmit task must terminate, not hang"); + // Let the collector task drain the forwarded publishes. + tokio::task::yield_now().await; + + let resolved_ids = published.lock().unwrap().clone(); + // Retransmitted despite the connected-socket lost OK — the assertion the + // raw-expiry-deadline mutation turns red (it parks 60s, publishes once). + assert!( + resolved_ids.len() >= 2, + "resolved kind-40003 edit must be retransmitted after a lost OK on a \ + connected socket; saw {} publish(es): {resolved_ids:?}", + resolved_ids.len() + ); + assert!( + resolved_ids.windows(2).all(|w| w[0] == w[1]), + "every retransmission must be the same signed event id; saw {resolved_ids:?}" + ); + } + + // ── Item 5: exact kind-9 content string from build_sentinel_pending_payload ─ + + /// Emit the exact JSON string that `build_sentinel_pending_payload` produces + /// for a canonical request with a fixed nonce, session, turn, and expiry. + /// This string is the cross-boundary fixture the Desktop parser is verified + /// against (no fence wrapper). + /// + /// The card advertises EXACTLY the two ruled actions (allow_once, + /// reject_once). A third adapter option (`allow_always`) is present in the + /// request but must never appear in the sentinel — this test is the + /// mutation proof for that contract (F1): if `select_card_actions` stopped + /// filtering, `optionIds` would carry the forbidden ID and the length + /// assertion below would fail. + #[test] + fn kind9_content_fixture_structural_invariants() { + let nonce = "test-nonce-fixture-abc123"; + let options: Vec<serde_json::Value> = vec![ + serde_json::json!({"optionId":"opt-allow","kind":"allow_once","name":"Allow once"}), + serde_json::json!({"optionId":"opt-reject","kind":"reject_once","name":"Reject"}), + serde_json::json!({"optionId":"opt-always","kind":"allow_always","name":"Always allow"}), + ]; + let expiry_unix_secs: u64 = 1_700_000_300; // fixed for reproducibility + let session_id = Some("sess-fixture-001"); + let turn_id = "turn-fixture-xyz"; + + let actions = select_card_actions(&options) + .expect("select_card_actions must succeed with one allow_once + one reject_once"); + let content = build_sentinel_pending_payload( + nonce, + &actions, + expiry_unix_secs, + session_id, + turn_id, + Some("read a file"), + ) + .expect("build_sentinel_pending_payload must succeed"); + + // Fixture coupling: the producer output MUST be byte-identical to the + // checked-in fixture the Desktop boundary test parses. A producer-side + // change to the wire shape breaks THIS assertion, forcing the fixture + // (and the desktop test that consumes it) to be updated in lockstep. + const FIXTURE: &str = include_str!("../tests/fixtures/sentinel_pending.json"); + assert_eq!( + content, FIXTURE, + "producer output must be byte-equal to the shared cross-language fixture \ + (crates/buzz-acp/tests/fixtures/sentinel_pending.json); if this diff is \ + intentional, regenerate the fixture and the desktop boundary test" + ); + + // Print the canonical fixture string for the Desktop fixture. + println!("kind-9 content fixture:\n{content}"); + + let v: serde_json::Value = + serde_json::from_str(&content).expect("content must be valid JSON"); + + // Structural invariants required by the Desktop parser. + assert_eq!(v["v"], serde_json::json!(1), "v must be 1"); + assert_eq!(v["state"], "pending", "state must be 'pending'"); + assert_eq!(v["requestNonce"], nonce, "requestNonce must match"); + assert_eq!( + v["expiresAt"], expiry_unix_secs, + "expiresAt must be the supplied unix seconds" + ); + assert_eq!( + v["sessionId"], + serde_json::json!("sess-fixture-001"), + "sessionId must match" + ); + assert_eq!(v["turnId"], turn_id, "turnId must match"); + + // optionIds must contain EXACTLY the two ruled actions, allow first, + // reject second — never the forbidden allow_always option. + let option_ids = v["optionIds"] + .as_array() + .expect("optionIds must be an array"); + assert_eq!(option_ids.len(), 2, "optionIds must have exactly 2 entries"); + assert_eq!(option_ids[0], "opt-allow"); + assert_eq!(option_ids[1], "opt-reject"); + assert!( + !option_ids.iter().any(|id| id == "opt-always"), + "the forbidden allow_always option must never reach the sentinel" + ); + + // labels must be an object with one key per surfaced optionId. + let labels = v["labels"].as_object().expect("labels must be an object"); + assert_eq!(labels.len(), 2, "labels must have exactly 2 entries"); + assert_eq!(labels["opt-allow"], "Allow once"); + assert_eq!(labels["opt-reject"], "Reject"); + assert!( + !labels.contains_key("opt-always"), + "the forbidden allow_always label must never reach the sentinel" + ); + + // The durable-rule disclosure fields are gone: the card can no longer + // carry an allow_always action, so there is nothing to disclose. + assert!( + v.get("hasDurableRule").is_none(), + "hasDurableRule must not be present — the card is two-action only" + ); + assert!( + v.get("durableRuleNote").is_none(), + "durableRuleNote must not be present — the card is two-action only" + ); + + // originalEventId must NOT be present in a pending payload. + assert!( + v.get("originalEventId").is_none() || v["originalEventId"].is_null(), + "pending payload must not contain a non-null originalEventId" + ); + } + + // ── Pinned §2: reject policy is byte-for-byte unchanged ─────────────────── + + #[tokio::test] + async fn reject_policy_denies_synchronously_and_returns_ok_true() { + let mut client = spawn_inert_client().await; + set_policy(&mut client, PermissionPolicy::Reject); + + let msg = perm_request(7, default_opts()); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let result = client.handle_permission_request(&msg, hard_deadline).await; + // Reject is synchronous — no pending entry, Ok(true) to suppress generic emit. + assert!(result.is_ok(), "reject must return Ok"); + assert!(result.unwrap(), "reject must return Ok(true)"); + assert!( + client.pending_permissions.is_empty(), + "reject must not leave pending entries" + ); + // Legacy single-id slot must also be cleared after the synchronous response. + assert!( + client.pending_permission_id.is_none(), + "pending_permission_id must be None after reject completes" + ); + assert!( + client.permission_responded, + "permission_responded must be true after reject completes" + ); + } + + // ── Pinned §2: allow policy auto-selects allow_once ─────────────────────── + + #[tokio::test] + async fn allow_policy_auto_selects_allow_once_and_returns_ok_true() { + let mut client = spawn_inert_client().await; + set_policy(&mut client, PermissionPolicy::Allow); + + let msg = perm_request(8, default_opts()); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let result = client.handle_permission_request(&msg, hard_deadline).await; + assert!(result.is_ok(), "allow auto-select must return Ok"); + assert!(result.unwrap(), "allow auto-select must return Ok(true)"); + // No pending entries — handled synchronously. + assert!(client.pending_permissions.is_empty()); + } + + #[tokio::test] + async fn allow_policy_fails_closed_with_no_allow_once_option() { + let mut client = spawn_inert_client().await; + set_policy(&mut client, PermissionPolicy::Allow); + + // Only reject_once offered — allow policy must fail closed. + let msg = perm_request(9, &[("opt-r", "reject_once", "Reject")]); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let result = client.handle_permission_request(&msg, hard_deadline).await; + // Fail closed: denial written, Ok(true) returned. + assert!(result.is_ok(), "fail-closed allow must return Ok"); + assert!(result.unwrap(), "fail-closed allow must return Ok(true)"); + assert!(client.pending_permissions.is_empty()); + } + + // ── Pinned §6: decision arm — validated option_id must be in snapshot ───── + + #[tokio::test] + async fn decision_with_unknown_option_id_is_ignored() { + // A decision carrying an optionId not in the snapshot must be ignored + // (no response written, entry stays Pending) — the loop continues. + // After the bad decision is processed, the loop times out on idle (since the + // script produces no output after the initial response) and the entry is + // still Pending at that point. + // + // The script produces the terminal id=999 response only AFTER a short delay, + // giving the loop time to process the bad decision and leave the entry Pending. + // We verify the entry is still Pending by running the loop until idle timeout. + let script = "sleep 2; echo '{\"jsonrpc\":\"2.0\",\"id\":999,\"result\":{\"done\":true}}'"; + let mut client = spawn_script(script).await; + client.set_owner_pubkey_known(true); + set_policy(&mut client, PermissionPolicy::Ask); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs), 0); + + let nonce = "test-nonce-bad-opt".to_string(); + let req_id_str = "5".to_string(); + client.pending_permissions.insert( + req_id_str.clone(), + PermissionEntry { + nonce: nonce.clone(), + options_snapshot: vec![ + serde_json::json!({"optionId":"valid-opt","kind":"allow_once","name":"A"}), + ], + card_actions: CardActions { + allow: serde_json::json!({"optionId":"valid-opt","kind":"allow_once","name":"A"}), + reject: serde_json::json!({"optionId":"valid-reject","kind":"reject_once","name":"R"}), + }, + state: PermissionEntryState::Pending, + deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(300), + expiry_unix_secs: 0, + sentinel_event_id: None, + early_decision: None, + description: None, + }, + ); + + // Deliver a decision with a nonce that matches but an invalid optionId. + let bad_decision = PermissionDecision { + request_nonce: nonce, + option_id: "nonexistent-option".to_string(), + }; + + let (tx, rx) = tokio::sync::mpsc::channel::<PermissionDecision>(1); + client.install_permission_decision_rx(rx); + // Send the bad decision; then close the sender so the channel is exhausted. + tx.send(bad_decision).await.unwrap(); + drop(tx); + + // Drive the loop with a short idle timeout — the bad decision is processed + // on the first iteration (entry stays Pending), then the loop idles. + let idle = std::time::Duration::from_millis(300); + let max_dur = std::time::Duration::from_secs(5); + let hard_deadline = tokio::time::Instant::now() + max_dur; + let result = client + .read_until_response_with_idle_timeout("sess-bad-opt", 5, idle, hard_deadline, max_dur) + .await; + + // The loop exits via idle timeout (script sleeps; bad decision was ignored, + // so no terminal response for id=5 was written, and idle fires). + // We accept either idle timeout OR id=999 match (if the script's sleep was short). + // The critical assertion is on the entry state. + let _ = result; // exit reason is not the focus + + // Entry must still be Pending — the bad decision did not mutate it. + let entry = client.pending_permissions.get(&req_id_str); + // The loop drains on non-recoverable errors; on idle timeout (recoverable) it + // does NOT drain — entry must still be there and Pending. + match entry { + Some(e) => assert!( + matches!(e.state, PermissionEntryState::Pending), + "entry must still be Pending after bad decision, got: {:?}", + e.state + ), + None => panic!("entry was removed — idle timeout should not drain the map"), + } + } + + // ── Pinned §7 (wire transmission): transmit_mode drives set_config_option ─ + + #[test] + fn resolved_permission_config_effective_mode_wire_string_is_correct() { + // Verify that effective_mode.as_wire_str() returns the correct ACP wire value. + let cfg = ResolvedPermissionConfig::resolve(PermissionPolicy::Reject, None).unwrap(); + assert_eq!(cfg.effective_mode.as_wire_str(), "dontAsk"); + + let cfg = ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(); + assert_eq!(cfg.effective_mode.as_wire_str(), "default"); + + let cfg = ResolvedPermissionConfig::resolve(PermissionPolicy::Allow, None).unwrap(); + assert_eq!(cfg.effective_mode.as_wire_str(), "default"); + } + + // ── Pinned amendment: PermissionMode::Auto matrix row ──────────────────── + // + // `auto` = model-gated classifier — the adapter may self-approve most tool + // calls internally but can still forward residual permission requests to ACP. + // - allow + auto → compatible (transmit as-is; both want unattended approval) + // - ask + auto → compatible with warning (residual escalations surface cards; + // internally-approved calls bypass ask silently) + // - reject + auto → startup error (inverted security: policy says deny, adapter + // auto-approves everything) + + #[test] + fn resolved_permission_config_allow_plus_explicit_auto_is_ok() { + // allow + auto is compatible: both want unattended approval. + let cfg = + ResolvedPermissionConfig::resolve(PermissionPolicy::Allow, Some(PermissionMode::Auto)) + .unwrap(); + assert_eq!(cfg.effective_mode, PermissionMode::Auto); + assert_eq!(cfg.effective_mode.as_wire_str(), "auto"); + assert_eq!(cfg.mode_source, ModeSource::Explicit); + } + + #[test] + fn resolved_permission_config_ask_plus_explicit_auto_is_ok_with_warning() { + // ask + auto is compatible-with-warning: residual escalations still surface + // cards; internally-approved calls bypass the ask flow silently. + // `auto` is a model classifier, not a bypass — some requests still escalate. + let result = + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, Some(PermissionMode::Auto)); + assert!( + result.is_ok(), + "ask + auto must succeed (warn only), got: {result:?}" + ); + let cfg = result.unwrap(); + assert_eq!(cfg.effective_mode, PermissionMode::Auto); + assert_eq!(cfg.mode_source, ModeSource::Explicit); + } + + #[test] + fn resolved_permission_config_reject_plus_explicit_auto_is_startup_error() { + // reject + auto: inverted-security worst case — policy says deny but + // adapter auto-approves everything internally. + let result = + ResolvedPermissionConfig::resolve(PermissionPolicy::Reject, Some(PermissionMode::Auto)); + assert!(result.is_err(), "reject + auto must be a startup error"); + let msg = format!("{}", result.unwrap_err()); + assert!(msg.contains("auto"), "error must mention auto, got: {msg}"); + } + + #[test] + fn permission_mode_auto_wire_string_is_correct() { + assert_eq!(PermissionMode::Auto.as_wire_str(), "auto"); + assert!(!PermissionMode::Auto.is_default()); + } + + /// Synchronous denial (missing options): `acp_read` and `acp_write` must share one nonce. + /// + /// Before the nonce-threading fix, `emit_permission_read_non_actionable` generated + /// its own nonce independently of the nonce passed to `finish_permission_sync`, so + /// the two telemetry frames carried different nonces. Desktop's nonce-only rule then + /// left the read card live because the write could never find it. + #[tokio::test] + async fn sync_denial_malformed_options_read_and_write_carry_same_nonce() { + let mut client = spawn_inert_client().await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + + // Request with no options field — triggers the malformed path. + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": 77, + "method": "session/request_permission", + "params": { + "sessionId": "sess", + "subject": "read a file" + // "options" deliberately omitted + } + }); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + client + .handle_permission_request(&msg, hard) + .await + .expect("malformed denial must not error"); + + let events = obs.snapshot(); + + let read_nonce = events + .iter() + .find(|e| e.kind == "acp_read" && e.authorization.is_some()) + .and_then(|e| e.authorization.as_ref()) + .map(|a| a.request_nonce.clone()) + .expect("acp_read with authorization must be emitted"); + + let write_nonce = events + .iter() + .find(|e| e.kind == "acp_write" && e.authorization.is_some()) + .and_then(|e| e.authorization.as_ref()) + .map(|a| a.request_nonce.clone()) + .expect("acp_write with authorization must be emitted"); + + assert_eq!( + read_nonce, write_nonce, + "acp_read and acp_write must carry the same nonce so Desktop can retire the card; \ + read={read_nonce}, write={write_nonce}" + ); + } + + /// Synchronous denial (preflight failure): `acp_read` and `acp_write` must share one nonce. + #[tokio::test] + async fn sync_denial_preflight_failure_read_and_write_carry_same_nonce() { + let mut client = spawn_inert_client().await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + + // Oversize subject triggers admission preflight failure. + let oversize_subject = "x".repeat(OBSERVER_MAX_PLAINTEXT_LEN + 1); + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": 88, + "method": "session/request_permission", + "params": { + "sessionId": "sess", + "subject": oversize_subject, + "options": [ + {"optionId": "opt-allow", "kind": "allow_once", "name": "Allow"}, + {"optionId": "opt-deny", "kind": "reject_once", "name": "Deny"} + ] + } + }); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + client + .handle_permission_request(&msg, hard) + .await + .expect("preflight denial must not error"); + + let events = obs.snapshot(); + + let read_nonce = events + .iter() + .find(|e| e.kind == "acp_read" && e.authorization.is_some()) + .and_then(|e| e.authorization.as_ref()) + .map(|a| a.request_nonce.clone()) + .expect("acp_read with authorization must be emitted"); + + let write_nonce = events + .iter() + .find(|e| e.kind == "acp_write" && e.authorization.is_some()) + .and_then(|e| e.authorization.as_ref()) + .map(|a| a.request_nonce.clone()) + .expect("acp_write with authorization must be emitted"); + + assert_eq!( + read_nonce, write_nonce, + "acp_read and acp_write must carry the same nonce so Desktop can retire the card; \ + read={read_nonce}, write={write_nonce}" + ); + } + + // ── F1: production-seam — description reaches the pending card ──────────── + // + // Carl's bar: a regression that goes through `handle_permission_request` and + // asserts the extracted description makes it into the published sentinel + // content — not just the pure extractor function. + // + // Shape: buzz-agent v2 (`params.title` + `params.subject.toolCall.rawInput`). + // The published kind-9 content is captured from the relay test publisher's + // event channel and parsed to confirm the `description` field carries the + // expected `"<tool_name>(<rawInput_json>)"` form. + + #[tokio::test] + async fn production_seam_description_reaches_pending_card_sentinel() { + // Script: read one line (the permission response when decided), then idle. + let capture_file = + std::env::temp_dir().join(format!("buzz-acp-desc-seam-{}.json", uuid::Uuid::new_v4())); + let script = format!( + r#"read -r resp; printf '%s' "$resp" > {capture}; sleep 5"#, + capture = capture_file.display(), + ); + let mut client = spawn_script(&script).await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + + // Capture every published event, including the kind-9 pending sentinel. + let keys = Keys::generate(); + let owner_hex = keys.public_key().to_hex(); + let (publisher, event_rx) = crate::relay::RelayEventPublisher::test_pair(); + let published: std::sync::Arc<std::sync::Mutex<Vec<nostr::Event>>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let published_drain = published.clone(); + tokio::spawn(async move { + let mut rx = event_rx; + while let Some(ev) = rx.recv().await { + published_drain.lock().unwrap().push(ev); + } + }); + client.set_relay_publisher(publisher, keys.clone()); + client.set_agent_owner_pubkey_hex(Some(owner_hex)); + client.set_turn_initiator_pubkey(Some(keys.public_key())); + client.set_turn_channel_context( + Some(uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000010").unwrap()), + None, + ); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + let (_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // v2 buzz-agent wire shape: params.title = tool name, + // params.subject.toolCall.rawInput = call.arguments object. + // Two distinct commands (ls vs rm) must produce distinguishable descriptions. + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "id": 55, + "method": "session/request_permission", + "params": { + "sessionId": "sess-seam", + "title": "fake__shell", + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": "tc-seam", + "title": "fake__shell", + "rawInput": {"command": "ls -la /tmp"}, + }, + }, + "options": [ + {"optionId": "opt-allow", "kind": "allow_once", "name": "Allow"}, + {"optionId": "opt-deny", "kind": "reject_once", "name": "Deny"}, + ], + } + }); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + client + .handle_permission_request(&msg, hard) + .await + .expect("registration must succeed"); + + // Wait for the kind-9 sentinel to be published (auto-ACKed by test_pair). + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let found = published + .lock() + .unwrap() + .iter() + .any(|ev| ev.kind.as_u16() == 9); + if found || tokio::time::Instant::now() >= deadline { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + // Extract the kind-9 event content and parse the sentinel payload. + let kind9_content = { + let guard = published.lock().unwrap(); + guard + .iter() + .find(|ev| ev.kind.as_u16() == 9) + .map(|ev| ev.content.clone()) + .expect("kind-9 sentinel must have been published") + }; + let payload: serde_json::Value = + serde_json::from_str(&kind9_content).expect("kind-9 content must be valid JSON"); + + // The description field must carry the tool name and the rawInput summary — + // confirming that `description_from_request_permission` is wired to the real + // sentinel-building path, not just tested as a pure function. + let description = payload["description"] + .as_str() + .expect("description must be a string in the published kind-9 content"); + assert!( + description.starts_with("fake__shell("), + "sentinel description must include the tool name; got: {description:?}" + ); + assert!( + description.contains("ls -la /tmp"), + "sentinel description must carry the rawInput command; got: {description:?}" + ); + + // Regression binding: removing `description_from_request_permission` from the + // `handle_permission_request` path (passing None always) turns this test red + // because `payload["description"]` becomes null and `as_str()` fails. + + let _ = std::fs::remove_file(&capture_file); + } + + // ── F2: ordinary-timeout path publishes the resolved edit ───────────────── + // + // Bug reproduced: `retransmit_resolved_edit` was spawned with `entry_deadline`, + // which is ALREADY PAST when an ordinary timeout fires (the entry expired → + // deadline = then, now > then → loop exits immediately with zero publish + // attempts). Fix: compute `delivery_deadline = Instant::now() + + // RESOLVED_DELIVERY_WINDOW_SECS` at resolution time. + // + // Mutation proof: reverting the production path back to `entry_deadline` + // (already past at resolution time) makes the retransmit task exit without + // publishing any kind-40003 event — this test goes red. + + #[tokio::test(start_paused = true)] + async fn ordinary_timeout_publishes_resolved_edit() { + // Script: capture the permission response (timed_out denial), then idle. + let capture_file = std::env::temp_dir().join(format!( + "buzz-acp-timeout-retransmit-{}.json", + uuid::Uuid::new_v4() + )); + let script = format!( + r#"read -r resp; printf '%s' "$resp" > {capture}; sleep 600"#, + capture = capture_file.display(), + ); + let mut client = spawn_script(&script).await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + + // Collect every published event so we can count kind-40003 resolved edits. + let keys = Keys::generate(); + let owner_hex = keys.public_key().to_hex(); + // test_pair auto-ACKs every sentinel → entry transitions Publishing→Pending. + let (publisher, event_rx) = crate::relay::RelayEventPublisher::test_pair(); + let published_40003: std::sync::Arc<std::sync::Mutex<Vec<String>>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let drain_40003 = published_40003.clone(); + tokio::spawn(async move { + let mut rx = event_rx; + while let Some(ev) = rx.recv().await { + if ev.kind.as_u16() == 40003 { + drain_40003.lock().unwrap().push(ev.id.to_hex()); + } + } + }); + client.set_relay_publisher(publisher, keys.clone()); + client.set_agent_owner_pubkey_hex(Some(owner_hex)); + client.set_turn_initiator_pubkey(Some(keys.public_key())); + client.set_turn_channel_context( + Some(uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000011").unwrap()), + None, + ); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + let (_perm_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // Register the permission request with a short deadline (10s from now, + // under paused time so it won't actually elapse without explicit advance). + let perm_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(60); + let msg = perm_request(1, default_opts()); + client + .handle_permission_request(&msg, perm_deadline) + .await + .expect("registration must succeed"); + + let idle = std::time::Duration::from_millis(200); + let max_dur = std::time::Duration::from_secs(60); + + // Pass 1: let the loop run briefly to pick up the auto-ACK from test_pair + // (the background ACK task runs immediately since test_pair resolves Accepted). + // This transitions the entry from Publishing → Pending. + let _ = tokio::time::timeout( + std::time::Duration::from_millis(50), + client.read_until_response_with_idle_timeout( + "sess-timeout-retransmit", + 999, + idle, + hard_deadline, + max_dur, + ), + ) + .await; + + // Advance virtual time past the permission deadline (10s) so the expired + // Pending entry is visible to the next loop iteration. + tokio::time::advance(std::time::Duration::from_secs( + SENTINEL_PUBLISH_TIMEOUT_SECS + 11, + )) + .await; + + // Pass 2: drive the loop to detect the expired entry → finish_permission + // writes the timed_out denial and spawns the resolved-edit retransmit task. + let _ = tokio::time::timeout( + std::time::Duration::from_millis(200), + client.read_until_response_with_idle_timeout( + "sess-timeout-retransmit", + 999, + std::time::Duration::from_millis(50), + hard_deadline, + max_dur, + ), + ) + .await; + + // Entry must be removed (timed_out). + assert!( + client.pending_permissions.is_empty(), + "entry must be gone after ordinary timeout" + ); + + // Wait for the detached retransmit task to publish the resolved kind-40003 edit. + // Under paused time, advance a generous window for the first attempt. + let retransmit_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(65); + loop { + let count = published_40003.lock().unwrap().len(); + if count >= 1 || tokio::time::Instant::now() >= retransmit_deadline { + break; + } + // Advance time in small steps to let the spawned task run its first attempt. + tokio::time::advance(std::time::Duration::from_millis(100)).await; + } + + let resolved_count = published_40003.lock().unwrap().len(); + assert!( + resolved_count >= 1, + "ordinary timeout must publish at least one kind-40003 resolved edit \ + (would be 0 with the old entry_deadline which is already past at timeout); \ + got {resolved_count} publish(es)" + ); + + let _ = std::fs::remove_file(&capture_file); + } + + // ── F4: first-wins — early_decision guards subsequent valid decisions ────── + // + // Regression: before F4, `entry.early_decision = Some(decision)` was + // unconditional, so a later conflicting decision could overwrite the first. + // After F4, the guard `if entry.early_decision.is_none()` ensures only the + // FIRST valid decision is buffered; subsequent ones are ignored. + // + // Mutation proof: removing the `is_none()` guard (changing it back to an + // unconditional assignment) lets the second Allow overwrite the first Reject, + // so the applied write carries "opt-allow" and the assertion on "opt-reject" + // goes red. + + #[tokio::test] + async fn early_decision_first_wins_reject_then_allow_reject_applied() { + // Script: capture the decision response (one JSON-RPC result line). + let capture_file = + std::env::temp_dir().join(format!("buzz-acp-first-wins-{}.json", uuid::Uuid::new_v4())); + let script = format!( + r#"read -r resp; printf '%s' "$resp" > {capture}; sleep 5"#, + capture = capture_file.display(), + ); + let mut client = spawn_script(&script).await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + let (perm_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // Manually insert a Publishing entry — the sentinel is already "published" + // from the agent's perspective (we own the ack_tx). The test controls when + // the ACK fires so both decisions arrive before the transition to Pending. + let nonce = "nonce-first-wins".to_string(); + let entry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + client.pending_permissions.insert( + "99".to_string(), + PermissionEntry { + nonce: nonce.clone(), + options_snapshot: default_opts() + .iter() + .map(|(id, kind, name)| { + serde_json::json!({"optionId": id, "kind": kind, "name": name}) + }) + .collect(), + card_actions: test_card_actions(), + state: PermissionEntryState::Publishing, + deadline: entry_deadline, + expiry_unix_secs: 0, + sentinel_event_id: Some("sentinel-fw".to_string()), + early_decision: None, + description: None, + }, + ); + // Install a manual ACK channel so we control when the ACK fires. + let (ack_tx, ack_rx) = tokio::sync::mpsc::channel::<(String, crate::relay::AckOutcome)>(1); + client.sentinel_ack_result_rx = Some(ack_rx); + + // Pre-send the full decision sequence before the loop runs: + // Reject (first) → buffered as early_decision + // Reject (dup) → ignored because early_decision is already set + // Allow → ignored because early_decision is already set + // Allow (dup) → ignored because early_decision is already set + // Channel capacity (8) holds all four without blocking. + for option_id in &["opt-reject", "opt-reject", "opt-allow", "opt-allow"] { + perm_tx + .send(PermissionDecision { + request_nonce: nonce.clone(), + option_id: (*option_id).to_string(), + }) + .await + .expect("send must succeed"); + } + + // Fire the ACK from a background task with a slight delay so all four + // decisions are processed first (buffered) before the ACK transitions + // Publishing → Pending → applies the early decision. + let ack_tx_clone = ack_tx; + tokio::spawn(async move { + // Let all four decision messages be processed by the decision arm first. + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + let _ = ack_tx_clone + .send(("99".to_string(), crate::relay::AckOutcome::Accepted)) + .await; + }); + + // Drive the loop until the entry is resolved (map empties). + // The loop processes: (1) Reject → buffered as early_decision, + // (2) Reject dup → ignored (early_decision already set), + // (3) Allow → ignored (early_decision already set), + // (4) Allow dup → ignored (early_decision already set), + // (5) ACK Accepted → Publishing→Pending → apply buffered Reject → map empties. + let idle = std::time::Duration::from_millis(200); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + let _ = tokio::time::timeout( + std::time::Duration::from_secs(5), + client.read_until_response_with_idle_timeout( + "sess-first-wins", + 999, + idle, + hard, + std::time::Duration::from_secs(10), + ), + ) + .await; + + // Entry must be gone — decision was applied. + assert!( + client.pending_permissions.is_empty(), + "entry must be removed after ACK + early decision applied" + ); + + // Observer must show exactly one applied write with the REJECT option id. + let events = obs.snapshot(); + let applied_writes: Vec<_> = events + .iter() + .filter(|e| { + e.kind == "acp_write" + && e.authorization + .as_ref() + .map(|a| a.reason.as_deref() == Some("applied")) + .unwrap_or(false) + }) + .collect(); + assert_eq!( + applied_writes.len(), + 1, + "exactly one applied write must be emitted; got: {applied_writes:?}" + ); + // The applied write's payload carries the decision optionId in the ACP + // result. Use the same path as the existing denial-optionId tests. + let payload = &applied_writes[0].payload; + assert_eq!( + payload["result"]["outcome"]["optionId"].as_str(), + Some("opt-reject"), + "applied decision must be the first (Reject) — not the second (Allow); \ + mutation: remove is_none() guard → Allow overwrites → this assertion goes red; \ + got: {payload}" + ); + + let _ = std::fs::remove_file(&capture_file); + } + + // ── Thread routing: nonce-mismatch drop in two concurrent read loops ────── + // + // When two read loops own distinct nonce snapshots in the same channel, + // a decision fan-outed to both must be applied by the owner (Thread A) + // and silently dropped by the non-owner (Thread B, nonce mismatch). + // Thread B must remain pending and still be resolvable by its own decision. + // + // This is the read-loop counterpart to the lib.rs fan-out routing tests: + // those prove delivery to both mpsc receivers; this proves the read loop + // correctly handles a mismatched nonce without resolving the wrong entry. + // + // Mutation proof: removing the `card_actions.accepts(decision.option_id)` + // check in the read loop (accepting any nonce unconditionally) causes Thread B + // to consume Thread A's decision — its entry resolves on the wrong nonce — + // and the assertion `!client_b.pending_permissions.is_empty()` goes red. + // (Actually the nonce check is in the entry lookup, not card_actions; the + // test proves the correct entry-by-nonce lookup path.) + // + // Mutation: removing the `nonce == entry.nonce` guard (accepting any nonce) + // makes client_b apply Thread A's decision → map empties → assert fires. + + #[tokio::test] + async fn two_read_loops_same_channel_nonce_mismatch_dropped_by_sibling() { + let capture_a = + std::env::temp_dir().join(format!("buzz-acp-routing-a-{}.json", uuid::Uuid::new_v4())); + let capture_b = + std::env::temp_dir().join(format!("buzz-acp-routing-b-{}.json", uuid::Uuid::new_v4())); + let script_a = format!( + r#"read -r resp; printf '%s' "$resp" > {capture}; sleep 600"#, + capture = capture_a.display() + ); + let script_b = format!( + r#"read -r resp; printf '%s' "$resp" > {capture}; sleep 600"#, + capture = capture_b.display() + ); + + // Client A and Client B share the same channel_id so their decisions + // would be fan-outed to each other's read loop. + let channel_id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000041").unwrap(); + + let make_client = |script: &str| { + let s = script.to_string(); + async move { + let mut c = spawn_script(&s).await; + c.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + c.set_owner_pubkey_known(true); + let keys = Keys::generate(); + let owner_hex = keys.public_key().to_hex(); + let (publisher, event_rx) = crate::relay::RelayEventPublisher::test_pair(); + tokio::spawn(async move { + let mut rx = event_rx; + while rx.recv().await.is_some() {} + }); + c.set_relay_publisher(publisher, keys.clone()); + c.set_agent_owner_pubkey_hex(Some(owner_hex)); + c.set_turn_initiator_pubkey(Some(keys.public_key())); + c.set_turn_channel_context(Some(channel_id), None); + let obs = crate::observer::ObserverHandle::in_process(); + c.set_observer(Some(obs), 0); + c + } + }; + let mut client_a = make_client(&script_a).await; + let mut client_b = make_client(&script_b).await; + + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + + // Register permission requests for both clients. + let msg_a = perm_request(61, default_opts()); + let msg_b = perm_request(62, default_opts()); + client_a + .handle_permission_request(&msg_a, hard) + .await + .expect("A registration must succeed"); + client_b + .handle_permission_request(&msg_b, hard) + .await + .expect("B registration must succeed"); + + // Extract the auto-generated nonces. + let nonce_a = client_a + .pending_permissions + .values() + .next() + .map(|e| e.nonce.clone()) + .expect("client_a must have one entry"); + let nonce_b = client_b + .pending_permissions + .values() + .next() + .map(|e| e.nonce.clone()) + .expect("client_b must have one entry"); + assert_ne!(nonce_a, nonce_b, "two distinct nonces must be generated"); + + let idle = std::time::Duration::from_millis(100); + let ten_s = std::time::Duration::from_secs(10); + + // ── Phase 1: fan-out Thread A's decision to BOTH read loops ─────────── + // Send nonce_a's decision to client_a's loop (it should apply) and + // ALSO to client_b's loop (it should drop — nonce mismatch). + { + let (tx_a, rx_a) = tokio::sync::mpsc::channel::<PermissionDecision>(4); + tx_a.send(PermissionDecision { + request_nonce: nonce_a.clone(), + option_id: "opt-allow".to_string(), + }) + .await + .expect("send must succeed"); + client_a.install_permission_decision_rx(rx_a); + } + // Same decision to client_b (simulates fan-out; nonce_a ≠ nonce_b → dropped). + { + let (tx_b_cross, rx_b_cross) = tokio::sync::mpsc::channel::<PermissionDecision>(4); + tx_b_cross + .send(PermissionDecision { + request_nonce: nonce_a.clone(), // Thread A's nonce, wrong for B + option_id: "opt-allow".to_string(), + }) + .await + .expect("send must succeed"); + client_b.install_permission_decision_rx(rx_b_cross); + } + + // Drive both loops simultaneously; Thread A resolves, Thread B idles out. + let (res_a, res_b) = tokio::join!( + tokio::time::timeout( + std::time::Duration::from_secs(3), + client_a.read_until_response_with_idle_timeout( + "sess-routing-a", + 61, + idle, + hard, + ten_s, + ) + ), + tokio::time::timeout( + std::time::Duration::from_secs(3), + client_b.read_until_response_with_idle_timeout( + "sess-routing-b", + 62, + idle, + hard, + ten_s, + ) + ), + ); + // client_a resolves (response written → loop returns Ok). + assert!( + res_a.is_ok(), + "Thread A's loop must complete (decision applied) within the timeout" + ); + // client_b may timeout (no response for B yet) — that's expected. + drop(res_b); + + assert!( + client_a.pending_permissions.is_empty(), + "Thread A's entry must be resolved after its own decision; \ + mutation: nonce mismatch not checked → Thread B consumes A's decision \ + → client_a's entry is never resolved → this fires instead" + ); + assert!( + !client_b.pending_permissions.is_empty(), + "Thread B's entry must remain pending after Thread A's decision fan-out; \ + mutation: nonce not checked → B wrongly applies A's decision → map empty → this fires" + ); + + // ── Phase 2: Thread B's own decision arrives and is applied ─────────── + { + let (tx_b_own, rx_b_own) = tokio::sync::mpsc::channel::<PermissionDecision>(4); + tx_b_own + .send(PermissionDecision { + request_nonce: nonce_b.clone(), + option_id: "opt-allow".to_string(), + }) + .await + .expect("send must succeed"); + client_b.install_permission_decision_rx(rx_b_own); + } + let _ = tokio::time::timeout( + std::time::Duration::from_secs(3), + client_b.read_until_response_with_idle_timeout( + "sess-routing-b2", + 62, + idle, + hard, + ten_s, + ), + ) + .await; + assert!( + client_b.pending_permissions.is_empty(), + "Thread B's entry must be resolved after its own decision arrives" + ); + + let _ = std::fs::remove_file(&capture_a); + let _ = std::fs::remove_file(&capture_b); + } + + // ── F2: unconditional first attempt with an already-expired deadline ────── + // + // The retransmit loop must publish the resolved edit at least once even when + // the delivery_deadline has already elapsed at call time. This covers the + // ordinary-timeout path where the card's entry_deadline expired before + // `finish_permission` called `retransmit_resolved_edit`. + // + // Mutation proof: reverting the `!first_attempt &&` guard (i.e. making the + // deadline check unconditional at loop-top) causes the loop to return + // immediately on an already-expired deadline without publishing — the + // assertion that a kind-40003 event was emitted goes red. + + #[tokio::test(start_paused = true)] + async fn retransmit_resolved_edit_unconditional_first_attempt_on_expired_deadline() { + let keys = Keys::generate(); + + let event = nostr::EventBuilder::new(nostr::Kind::from(40003), "resolved-expired") + .sign(&keys) + .await + .unwrap(); + let event_id = event.id.to_hex(); + + // Use an accepting publisher — we only need to confirm the event is + // attempted once despite the expired deadline. + let (publisher, mut event_rx) = crate::relay::RelayEventPublisher::test_pair(); + + let collected: std::sync::Arc<std::sync::Mutex<Vec<String>>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let collected_drain = collected.clone(); + tokio::spawn(async move { + while let Some(ev) = event_rx.recv().await { + if ev.kind.as_u16() == 40003 { + collected_drain.lock().unwrap().push(ev.id.to_hex()); + } + } + }); + + // Supply an already-expired deadline. + // Under start_paused, Instant::now() is fixed at epoch; subtract 1ns. + let already_expired = tokio::time::Instant::now() - std::time::Duration::from_nanos(1); + let handle = tokio::spawn(retransmit_resolved_edit(publisher, event, already_expired)); + + // Advance time past the per-attempt timeout so the spawned task drains. + tokio::time::advance(std::time::Duration::from_secs( + SENTINEL_PUBLISH_TIMEOUT_SECS + 1, + )) + .await; + tokio::task::yield_now().await; + let _ = tokio::time::timeout(std::time::Duration::from_millis(200), handle).await; + tokio::task::yield_now().await; + + let seen = collected.lock().unwrap().clone(); + assert!( + !seen.is_empty(), + "retransmit must publish even when delivery_deadline is already expired at call time; \ + mutation: unconditional loop-top deadline check → zero publishes → this goes red" + ); + assert_eq!( + seen[0], event_id, + "published event must carry the same stable signed id" + ); + } + + // ── F2: always-Uncertain bounded-exit ───────────────────────────────────── + // + // A retransmit loop that perpetually receives `Uncertain` (via test_pair_silent + // which drops ack_tx so every await resolves as RecvError → Uncertain) must + // still terminate once the delivery window elapses. Under paused tokio time + // we advance past the window and confirm the loop exits. + // + // Mutation proof: removing the `delivery_deadline` gate from the retry loop + // (i.e. looping forever on Uncertain) makes `handle.is_finished()` never true + // within the test budget — the polling loop exhausts its window and the + // subsequent `timeout(1s, handle)` fires → assertion fails. + + #[tokio::test(start_paused = true)] + async fn retransmit_resolved_edit_always_uncertain_bounded_exit() { + let keys = Keys::generate(); + + let event = nostr::EventBuilder::new(nostr::Kind::from(40003), "resolved-uncertain") + .sign(&keys) + .await + .unwrap(); + + // test_pair_silent drops ack_tx on each PublishEventAcked → every + // ack_rx.await yields Err(RecvError) → unwrapped as Uncertain. + let (publisher, _event_rx) = crate::relay::RelayEventPublisher::test_pair_silent(); + + // Short delivery window to advance past quickly. + let delivery_window = std::time::Duration::from_secs(4); + let delivery_deadline = tokio::time::Instant::now() + delivery_window; + let handle = tokio::spawn(retransmit_resolved_edit( + publisher, + event, + delivery_deadline, + )); + + // Each Uncertain attempt is followed by RESOLVED_RETRANSMIT_BACKOFF sleep, + // and resolving `ack_rx` requires the test_pair_silent task to run (to drop + // ack_tx). Under paused time, advance in small steps so channel-driven + // interleaving between the retransmit task and the silent publisher task + // can proceed; tokio auto-advances through parked timers on each step. + let poll_deadline = + tokio::time::Instant::now() + delivery_window + std::time::Duration::from_secs(30); + loop { + if handle.is_finished() || tokio::time::Instant::now() >= poll_deadline { + break; + } + tokio::time::advance(std::time::Duration::from_millis(500)).await; + tokio::task::yield_now().await; + } + + let join_result = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await; + assert!( + join_result.is_ok(), + "retransmit loop must exit once the delivery window elapses even when every \ + attempt returns Uncertain; mutation: increase `delivery_window` (the injected \ + 4s deadline passed directly to this test) → loop never completes within test \ + budget → timeout fires" + ); + } + + // ── F2: old-expiry-crossing retransmit — decision applied after card expires ─ + // + // Both tests below verify that the delivery window for the kind-40003 + // resolved edit is anchored at **resolution time** (`now() + 300s`), NOT + // at the original card deadline. The card deadline has already elapsed + // when `finish_permission` runs; if the old `entry.deadline` were used as + // `delivery_deadline`, the retransmit loop would have an already-expired + // window after its first attempt — only one publish, never a retry. + // + // Mutation proof (both tests): restoring `entry.deadline` as the spawn + // argument to `retransmit_resolved_edit` sets `delivery_deadline` to a + // value that is already < `now()` at retry time. The second assertion + // (`resolved_ids.len() >= 2`) goes red — only one publish ever occurs. + + // Case A: disconnect/Uncertain on the first resolved-edit attempt, resolved + // after the original card deadline — second attempt must still land. + #[tokio::test(start_paused = true)] + async fn resolved_edit_retransmitted_across_old_card_expiry_disconnect() { + let capture_file = std::env::temp_dir().join(format!( + "buzz-acp-expiry-disconnect-{}.json", + uuid::Uuid::new_v4() + )); + let script = format!( + r#"read -r resp; printf '%s' "$resp" > {capture}; sleep 600"#, + capture = capture_file.display(), + ); + let mut client = spawn_script(&script).await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + + let keys = Keys::generate(); + let owner_hex = keys.public_key().to_hex(); + // One Uncertain on the first kind-40003, then Accepted — simulates + // a disconnect that clears between the first and second attempt. + let (publisher, event_rx) = + crate::relay::RelayEventPublisher::test_pair_resolved_reconnect(1); + let published_40003: std::sync::Arc<std::sync::Mutex<Vec<String>>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let drain = published_40003.clone(); + tokio::spawn(async move { + let mut rx = event_rx; + while let Some(ev) = rx.recv().await { + if ev.kind.as_u16() == 40003 { + drain.lock().unwrap().push(ev.id.to_hex()); + } + } + }); + client.set_relay_publisher(publisher, keys.clone()); + client.set_agent_owner_pubkey_hex(Some(owner_hex)); + client.set_turn_initiator_pubkey(Some(keys.public_key())); + client.set_turn_channel_context( + Some(uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000031").unwrap()), + None, + ); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + let (perm_tx, perm_rx) = tokio::sync::mpsc::channel::<PermissionDecision>(8); + client.install_permission_decision_rx(perm_rx); + + // Short card deadline — 1 s. The entry will expire before the decision + // is applied, proving the delivery window is not tied to entry.deadline. + let short_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + let msg = perm_request(51, default_opts()); + client + .handle_permission_request(&msg, short_deadline) + .await + .expect("registration must succeed"); + + let nonce = client + .pending_permissions + .get("51") + .expect("entry must be in map") + .nonce + .clone(); + + // Pre-send decision before the loop; the entry is still in Publishing + // state at this point — decision is buffered as early_decision. + perm_tx + .send(PermissionDecision { + request_nonce: nonce, + option_id: "opt-allow".to_string(), + }) + .await + .expect("send must succeed"); + + // Advance past the short card deadline BEFORE running the loop. + // This ensures the original entry.deadline has already elapsed when + // finish_permission fires — simulating a long-delayed decision. + tokio::time::advance(std::time::Duration::from_secs(2)).await; + tokio::task::yield_now().await; + + // Drive the loop; finish_permission will compute delivery_deadline = + // now() + 300s, well beyond the expired entry.deadline. + let idle = std::time::Duration::from_millis(200); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + let _ = tokio::time::timeout( + std::time::Duration::from_secs(5), + client.read_until_response_with_idle_timeout( + "sess-expiry-dc", + 51, + idle, + hard, + std::time::Duration::from_secs(10), + ), + ) + .await; + + // Wait for both retransmit attempts to complete. + let poll_end = tokio::time::Instant::now() + std::time::Duration::from_secs(20); + loop { + if published_40003.lock().unwrap().len() >= 2 || tokio::time::Instant::now() >= poll_end + { + break; + } + tokio::time::advance(std::time::Duration::from_millis(500)).await; + tokio::task::yield_now().await; + } + + let ids = published_40003.lock().unwrap().clone(); + assert!( + ids.len() >= 2, + "resolved edit must be retransmitted after an Uncertain outcome even when \ + the original card deadline has already elapsed; \ + mutation: restore entry.deadline as delivery_deadline → second attempt \ + never fires (already-expired window) → len()==1 → this assertion goes red; \ + saw {ids:?}" + ); + assert!( + ids.windows(2).all(|w| w[0] == w[1]), + "every retransmission must carry the same signed event id; got {ids:?}" + ); + let _ = std::fs::remove_file(&capture_file); + } + + // Case B: lost-OK (connected socket) resolved after the original card + // deadline — the per-attempt timeout sweeps and a retry still lands. + #[tokio::test(start_paused = true)] + async fn resolved_edit_retransmitted_across_old_card_expiry_lost_ok() { + let keys = Keys::generate(); + // One lost-OK on the first kind-40003 attempt, then Accepted. + let (publisher, mut event_rx) = + crate::relay::RelayEventPublisher::test_pair_resolved_lost_ok(1); + + let published_40003: std::sync::Arc<std::sync::Mutex<Vec<String>>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let drain = published_40003.clone(); + tokio::spawn(async move { + while let Some(ev) = event_rx.recv().await { + if ev.kind.as_u16() == 40003 { + drain.lock().unwrap().push(ev.id.to_hex()); + } + } + }); + + // Sign the resolved edit once; the retransmit loop resends this exact event. + let event = build_kind40003_sentinel( + &keys, + uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000032").unwrap(), + "original-event-id-lost-ok", + "resolved-lost-ok-expiry", + ) + .expect("sentinel must build"); + + // delivery_deadline = 60s from now (well within the 300s production window). + // The old entry.deadline would have been, say, 1s — already expired. + let delivery_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(60); + + // Advance past the "old card expiry" (1s) before starting, so any code + // using entry.deadline as the window would already be expired. + tokio::time::advance(std::time::Duration::from_secs(2)).await; + tokio::task::yield_now().await; + + let task = tokio::spawn(retransmit_resolved_edit( + publisher, + event, + delivery_deadline, + )); + + // Under paused time the per-attempt deadline (SENTINEL_PUBLISH_TIMEOUT_SECS) + // auto-advances and the stuck waiter sweeps. Then the backoff elapses and + // the second attempt (Accepted) lands. + let joined = tokio::time::timeout(std::time::Duration::from_secs(120), task).await; + assert!(joined.is_ok(), "retransmit task must terminate"); + tokio::task::yield_now().await; + + let ids = published_40003.lock().unwrap().clone(); + assert!( + ids.len() >= 2, + "resolved edit must be retransmitted via a lost-OK sweep even when started \ + after the old card deadline elapsed; \ + mutation: restore entry.deadline as delivery_deadline → after the lost-OK \ + sweep, delivery_deadline is already past → retry loop exits → len()==1 → \ + this assertion goes red; saw {ids:?}" + ); + assert!( + ids.windows(2).all(|w| w[0] == w[1]), + "every retransmission must carry the same signed event id; got {ids:?}" + ); + } + + // ── F3: Rust read-loop coverage — allow_always / reject_always rejected ─── + // + // The read loop (via the `ask` policy path in `handle_permission_request`) + // snapshots only the `allow_once` and `reject_once` option IDs into + // `card_actions`. A decision carrying `allow_always` or `reject_always` + // as its `option_id` does NOT match either snapshotted ID and is silently + // ignored (logged as "not a ruled card action"). This test drives the loop + // with all four option kinds offered by the adapter and confirms: + // - `allow_always` and `reject_always` decisions are dropped. + // - `allow_once` and `reject_once` decisions ARE accepted. + // + // The test delivers each persistent kind in isolation and inspects the + // observer AFTER each partial loop run: + // step 1: allow_always alone → entry still Pending, zero applied writes. + // step 2: reject_always alone → entry still Pending, zero applied writes. + // step 3: allow_once → entry resolved, exactly one applied write. + // + // Mutation proof: changing `CardActions::accepts()` to always return `true` + // allows `allow_always` through on step 1. The entry is resolved early + // (map empty) and the observer shows an applied write before allow_once + // ever arrives — the intermediate step-1 pending assertion goes red. + + #[tokio::test] + async fn allow_always_and_reject_always_decisions_are_ignored_by_read_loop() { + let mut client = spawn_script("sleep 600").await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + install_test_relay_context(&mut client); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + + // Offer all four option kinds. The read loop must only snapshot + // allow_once + reject_once into card_actions. + let four_opts: &[(&str, &str, &str)] = &[ + ("opt-allow-once", "allow_once", "Allow once"), + ("opt-reject-once", "reject_once", "Deny once"), + ("opt-allow-always", "allow_always", "Always allow"), + ("opt-reject-always", "reject_always", "Always deny"), + ]; + let msg = perm_request(42, four_opts); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + + client + .handle_permission_request(&msg, hard) + .await + .expect("registration must succeed"); + + // Extract the nonce from the pending map (auto-generated by handle_permission_request). + let nonce = client + .pending_permissions + .values() + .next() + .map(|e| e.nonce.clone()) + .expect("one entry must be in the pending map after registration"); + + let short_idle = std::time::Duration::from_millis(100); + let ten_s = std::time::Duration::from_secs(10); + + // Helper: count applied acp_write events in the observer snapshot. + let applied_count = |o: &crate::observer::ObserverHandle| { + o.snapshot() + .into_iter() + .filter(|e| { + e.kind == "acp_write" + && e.authorization + .as_ref() + .map(|a| a.reason.as_deref() == Some("applied")) + .unwrap_or(false) + }) + .count() + }; + + // `read_until_response_with_idle_timeout` takes `permission_decision_rx` + // via `.take()` and drops it on return. A fresh channel pair is installed + // before each step so the next call sees a live receiver with its message + // already buffered. No outer channel is needed — each step is self-contained. + + // ── Step 1: allow_always alone ──────────────────────────────────────── + // The loop ACKs the sentinel on its first iteration (Publishing → Pending); + // allow_always is delivered but must be dropped by `card_actions.accepts()`. + { + let (tx1, rx1) = tokio::sync::mpsc::channel::<PermissionDecision>(4); + tx1.send(PermissionDecision { + request_nonce: nonce.clone(), + option_id: "opt-allow-always".to_string(), + }) + .await + .expect("send must succeed"); + client.install_permission_decision_rx(rx1); + } + let _ = tokio::time::timeout( + std::time::Duration::from_secs(2), + client.read_until_response_with_idle_timeout("sess-f3-aa", 42, short_idle, hard, ten_s), + ) + .await; + + // Entry must still be pending — allow_always was ignored. + // Mutation: `accepts()` → true → allow_always resolves the entry → + // map is empty here → this assert fires. + assert!( + !client.pending_permissions.is_empty(), + "entry must still be pending after allow_always — it is not a ruled card action; \ + mutation: CardActions::accepts() → true → entry resolved early → this fires" + ); + assert_eq!( + applied_count(&obs), + 0, + "no applied ACP write must occur after allow_always; \ + mutation: accepts()→true → applied write emitted → count>0 → this fires" + ); + + // ── Step 2: reject_always alone ─────────────────────────────────────── + { + let (tx2, rx2) = tokio::sync::mpsc::channel::<PermissionDecision>(4); + tx2.send(PermissionDecision { + request_nonce: nonce.clone(), + option_id: "opt-reject-always".to_string(), + }) + .await + .expect("send must succeed"); + client.install_permission_decision_rx(rx2); + } + let _ = tokio::time::timeout( + std::time::Duration::from_secs(2), + client.read_until_response_with_idle_timeout("sess-f3-ra", 42, short_idle, hard, ten_s), + ) + .await; + + assert!( + !client.pending_permissions.is_empty(), + "entry must still be pending after reject_always — it is not a ruled card action" + ); + assert_eq!( + applied_count(&obs), + 0, + "no applied ACP write must occur after reject_always" + ); + + // ── Step 3: allow_once resolves the entry ───────────────────────────── + { + let (tx3, rx3) = tokio::sync::mpsc::channel::<PermissionDecision>(4); + tx3.send(PermissionDecision { + request_nonce: nonce.clone(), + option_id: "opt-allow-once".to_string(), + }) + .await + .expect("send must succeed"); + client.install_permission_decision_rx(rx3); + } + let _ = tokio::time::timeout( + std::time::Duration::from_secs(5), + client.read_until_response_with_idle_timeout("sess-f3-ao", 42, short_idle, hard, ten_s), + ) + .await; + + assert!( + client.pending_permissions.is_empty(), + "entry must be resolved after allow_once decision" + ); + assert_eq!( + applied_count(&obs), + 1, + "exactly one applied ACP write must be emitted for allow_once" + ); + } + + /// F3 reject_once proof: the counterpart to the allow_once step above. + /// + /// A fresh client with the same four options registers one permission request. + /// `reject_once` must resolve the entry and emit exactly one applied ACP write + /// carrying the reject option ID. + /// + /// Mutation proof: changing `CardActions::accepts()` to return only + /// `option_id == self.allow_id()` (dropping reject acceptance) leaves the + /// entry pending and emits zero writes — this assertion fires. + #[tokio::test] + async fn reject_once_resolves_entry_with_applied_write() { + tokio::time::pause(); + let mut client = spawn_script("sleep 600").await; + client.set_permission_config( + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).unwrap(), + ); + client.set_owner_pubkey_known(true); + install_test_relay_context(&mut client); + let obs = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(obs.clone()), 0); + + let four_opts: &[(&str, &str, &str)] = &[ + ("opt-allow-once", "allow_once", "Allow once"), + ("opt-reject-once", "reject_once", "Deny once"), + ("opt-allow-always", "allow_always", "Always allow"), + ("opt-reject-always", "reject_always", "Always deny"), + ]; + let msg = perm_request(99, four_opts); + let hard = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + + client + .handle_permission_request(&msg, hard) + .await + .expect("registration must succeed"); + + let nonce = client + .pending_permissions + .values() + .next() + .map(|e| e.nonce.clone()) + .expect("one entry must be in the pending map after registration"); + + let short_idle = std::time::Duration::from_millis(100); + let ten_s = std::time::Duration::from_secs(10); + + // ── reject_once resolves the entry ──────────────────────────────────── + { + let (tx, rx) = tokio::sync::mpsc::channel::<PermissionDecision>(4); + tx.send(PermissionDecision { + request_nonce: nonce.clone(), + option_id: "opt-reject-once".to_string(), + }) + .await + .expect("send must succeed"); + client.install_permission_decision_rx(rx); + } + let _ = tokio::time::timeout( + std::time::Duration::from_secs(5), + client.read_until_response_with_idle_timeout("sess-f3-ro", 99, short_idle, hard, ten_s), + ) + .await; + + assert!( + client.pending_permissions.is_empty(), + "entry must be resolved after reject_once decision; \ + mutation: accepts() drops reject → entry stays pending → this fires" + ); + + // Collect the applied writes and assert exactly one, carrying the reject option ID. + let events = obs.snapshot(); + let applied_writes: Vec<_> = events + .iter() + .filter(|e| { + e.kind == "acp_write" + && e.authorization + .as_ref() + .map(|a| a.reason.as_deref() == Some("applied")) + .unwrap_or(false) + }) + .collect(); + assert_eq!( + applied_writes.len(), + 1, + "exactly one applied ACP write must be emitted for reject_once; \ + mutation: accepts() drops reject → zero writes → this fires; got: {applied_writes:?}" + ); + let payload = &applied_writes[0].payload; + assert_eq!( + payload["result"]["outcome"]["optionId"].as_str(), + Some("opt-reject-once"), + "applied write must carry the reject option ID; \ + mutation: response helper emits wrong optionId → this fires; got: {payload}" + ); + } } diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index e0e424f0185..6da32dfc1b2 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -115,8 +115,12 @@ impl std::fmt::Display for RespondTo { /// `configId: "mode"` (e.g. `claude-agent-acp`). /// /// - `default` — agent's built-in behaviour (permission requests per tool call). +/// - `auto` — fully autonomous execution; model-gated classifier (requires `supportsAutoMode`); +/// the adapter degrades gracefully to `default` when the active model does not +/// support it. The adapter auto-approves most tool calls internally, but residual +/// `session/request_permission` escalations may still cross ACP when the model +/// chooses manual approval for a specific call. /// - `acceptEdits` — auto-approve file edits, still ask for other tools. -/// - `bypassPermissions` — skip the permission flow entirely. /// - `dontAsk` — never prompt; reject anything that would require permission. /// - `plan` — planning-only mode (no tool execution). #[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)] @@ -124,17 +128,25 @@ pub enum PermissionMode { /// Agent default — permission requests per tool call. #[value(alias = "default")] Default, - /// Auto mode — fully autonomous execution; model-gated (requires a model - /// that supports `supportsAutoMode`). Degrades gracefully to `default` - /// when the session's active model does not support it. + /// Fully autonomous execution; model-gated (requires `supportsAutoMode`). + /// + /// `auto` is a model-gated classifier — the adapter self-approves most tool + /// calls internally, but can fall back to forwarding residual + /// `session/request_permission` requests to ACP when the model chooses manual + /// approval for a specific call. It is therefore **not** a hard bypass. + /// + /// Policy compatibility: + /// - `allow + auto` — compatible; both want unattended approval. + /// - `ask + auto` — compatible with a startup warning; residual escalations + /// still surface permission cards, but internally approved calls bypass the + /// ask flow silently. + /// - `reject + auto` — startup contradiction; adapter auto-approves + /// internally while the policy intends to deny — inverted-security worst case. #[value(alias = "auto")] Auto, /// Auto-approve file edits, still ask for other tools. #[value(alias = "acceptEdits")] AcceptEdits, - /// Skip the permission flow entirely. - #[value(alias = "bypassPermissions")] - BypassPermissions, /// Never prompt; reject anything that would require permission. #[value(alias = "dontAsk")] DontAsk, @@ -151,7 +163,6 @@ impl PermissionMode { Self::Default => "default", Self::Auto => "auto", Self::AcceptEdits => "acceptEdits", - Self::BypassPermissions => "bypassPermissions", Self::DontAsk => "dontAsk", Self::Plan => "plan", } @@ -159,6 +170,7 @@ impl PermissionMode { /// Returns `true` when the mode is the agent's built-in default and /// therefore doesn't need to be explicitly set. + #[cfg(test)] pub fn is_default(&self) -> bool { matches!(self, Self::Default) } @@ -170,6 +182,173 @@ impl std::fmt::Display for PermissionMode { } } +/// How Buzz responds to an ACP `session/request_permission` request. +/// +/// Injected as `BUZZ_ACP_PERMISSION_POLICY`. Desktop injects the resolved +/// per-agent or fleet-wide value; headless defaults to `reject`. +/// +/// - `allow` — auto-select the unique `allow_once` option; fail closed if +/// zero or multiple `allow_once` candidates, malformed options, +/// or any validation error. +/// - `ask` — surface the request as an actionable card for the owner; +/// fail closed on timeout (300 s) or if the observer / owner is +/// unavailable. +/// - `reject` — deny every request (today's behaviour, headless default). +#[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)] +pub enum PermissionPolicy { + /// Auto-approve via the unique `allow_once` option; fail closed otherwise. + #[value(alias = "allow")] + Allow, + /// Surface as an actionable card; fail closed on timeout or unavailability. + #[value(alias = "ask")] + Ask, + /// Deny all requests — headless default, byte-for-byte today's behaviour. + #[value(alias = "reject")] + Reject, +} + +impl std::fmt::Display for PermissionPolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Allow => "allow", + Self::Ask => "ask", + Self::Reject => "reject", + }) + } +} + +/// Whether an effective `PermissionMode` was derived by the harness or +/// supplied explicitly by the operator. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ModeSource { + /// No `--permission-mode` was supplied; the harness derived the mode from + /// the active `PermissionPolicy`. + Derived, + /// An explicit `--permission-mode` / `BUZZ_ACP_PERMISSION_MODE` value was + /// supplied by the operator. + Explicit, +} + +impl std::fmt::Display for ModeSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Derived => "derived", + Self::Explicit => "explicit", + }) + } +} + +/// Resolved, immutable per-startup permission configuration. +/// +/// Computed once in `Config::from_args` from `policy` + optional `mode` and +/// carried through `PromptContext` (via `Arc`) so every task reads the same +/// value without re-deriving it. +/// +/// `transmit_mode` — set `session/set_config_option` for this mode whenever +/// the agent advertises it. **Always set** (including for `PermissionMode::Default`); +/// the caller decides whether to skip based on advertisement, not derivation. +#[derive(Debug, Clone)] +pub struct ResolvedPermissionConfig { + /// The high-level policy governing how permission requests are answered. + pub policy: PermissionPolicy, + /// The ACP mode that will be sent to the agent after session creation. + pub effective_mode: PermissionMode, + /// Whether `effective_mode` was derived or supplied explicitly. + pub mode_source: ModeSource, + /// `true` when the effective mode should be transmitted to the agent via + /// `session/set_config_option`, i.e. whenever the agent advertises it. + pub transmit_mode: bool, +} + +impl ResolvedPermissionConfig { + /// Derive the config from a `policy` and an optional explicit `mode`. + /// + /// Returns `Err` for contradictory combinations: + /// - `ask` + explicit `dontAsk` — harness would want the agent to + /// escalate, but `dontAsk` makes the agent self-deny internally. + /// - `allow` + explicit `dontAsk` — same contradiction. + /// - `reject` + explicit `auto` — inverted-security worst case: policy says + /// "deny" but the adapter auto-approves everything internally. + /// + /// Emits a warning (not an error) for `ask + auto`: internally-approved tool + /// calls bypass the ask flow silently, but residual escalations still surface + /// cards — the combination works, with the caveat that not all requests are seen. + pub fn resolve( + policy: PermissionPolicy, + explicit_mode: Option<PermissionMode>, + ) -> Result<Self, ConfigError> { + // Fail on contradictory ask/allow + dontAsk combinations. + if matches!(policy, PermissionPolicy::Ask | PermissionPolicy::Allow) + && explicit_mode == Some(PermissionMode::DontAsk) + { + return Err(ConfigError::ConfigFile(format!( + "permission_policy={policy} conflicts with permission_mode=dontAsk: \ + dontAsk makes the agent self-deny internally before Buzz can answer" + ))); + } + // Fail on reject + auto: inverted-security worst case — policy says "deny" + // but the adapter auto-approves everything internally. + // `ask` + auto is a warning-only case: the adapter MAY still forward residual + // permission requests to ACP (auto is a model classifier, not bypass mode); + // warn and transmit rather than fail startup. + // `allow` + auto is compatible: both policies want unattended approval. + if policy == PermissionPolicy::Reject && explicit_mode == Some(PermissionMode::Auto) { + return Err(ConfigError::ConfigFile(format!( + "permission_policy={policy} conflicts with permission_mode=auto: \ + auto makes the adapter self-approve internally, which bypasses the \ + reject policy — inverted-security worst case" + ))); + } + // Warn on ask + auto: residual permission requests may still reach ACP + // (auto is a model classifier, not bypass mode) so ask can still surface + // cards — but internally-approved calls will bypass the ask flow silently. + if policy == PermissionPolicy::Ask && explicit_mode == Some(PermissionMode::Auto) { + tracing::warn!( + "permission_policy=ask with permission_mode=auto: internally-approved \ + tool calls bypass Buzz ask flow; residual escalations will still \ + surface cards. Consider policy=allow if unattended approval is intended." + ); + } + + let (effective_mode, mode_source) = match explicit_mode { + Some(m) => (m, ModeSource::Explicit), + None => { + // Mode matrix — derived from policy when no explicit mode given: + // reject → dontAsk (harness rejects; adapter also self-denies for + // consistency — byte-for-byte today's behaviour) + // ask → default (keep the adapter escalating to Buzz) + // allow → default (keep the adapter escalating to Buzz; + // dontAsk would silently self-deny before we + // could auto-select allow_once) + let derived = match policy { + PermissionPolicy::Reject => PermissionMode::DontAsk, + PermissionPolicy::Ask | PermissionPolicy::Allow => PermissionMode::Default, + }; + (derived, ModeSource::Derived) + } + }; + + Ok(Self { + policy, + effective_mode, + mode_source, + // Always transmit — the caller skips based on agent advertisement, + // not on whether the mode is the default. + transmit_mode: true, + }) + } +} + +impl std::fmt::Display for ResolvedPermissionConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "policy={} mode={}({})", + self.policy, self.effective_mode, self.mode_source + ) + } +} + /// CLI args for `buzz-acp models` — query available models from an agent. /// /// This is a standalone `Parser` (not a subcommand variant) because the @@ -456,19 +635,32 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_SESSION_TITLE")] pub session_title: Option<String>, - /// Permission mode for agents that support `session/set_config_option` - /// with `configId: "mode"` (e.g. `claude-agent-acp`). + /// How Buzz responds to ACP `session/request_permission` requests. /// - /// Defaults to `bypassPermissions` which skips the per-tool-call - /// permission flow. Set to `default` to restore the agent's built-in - /// behaviour. + /// - `reject` (headless default) — deny all permission requests. + /// - `ask` — surface as an actionable card; auto-deny on timeout (300 s) + /// or when the observer / owner is unavailable. + /// - `allow` — auto-approve via the unique `allow_once` option; + /// fail closed if zero or multiple `allow_once` candidates. + /// + /// Desktop injects the resolved per-agent or fleet-wide value. + /// Headless installations should leave this unset (defaults to `reject`). #[arg( long, - env = "BUZZ_ACP_PERMISSION_MODE", - default_value = "bypass-permissions", + env = "BUZZ_ACP_PERMISSION_POLICY", + default_value = "reject", value_enum )] - pub permission_mode: PermissionMode, + pub permission_policy: PermissionPolicy, + + /// ACP permission mode sent to the agent via `session/set_config_option`. + /// + /// When unset the harness derives a sensible default from `permission_policy`: + /// `reject` → `dontAsk`, `ask` / `allow` → `default`. + /// Explicit values are validated: `ask` or `allow` + `dontAsk` is a startup + /// error because `dontAsk` makes the agent self-deny before Buzz can answer. + #[arg(long, env = "BUZZ_ACP_PERMISSION_MODE", value_enum)] + pub permission_mode: Option<PermissionMode>, /// Inbound author gate: which authors' events the harness forwards. /// Modes: owner-only (default), allowlist, anyone, nobody. @@ -578,8 +770,10 @@ pub struct Config { /// Sanitized session title, sent as `_meta.sessionTitle` on `session/new`. /// `None` when unset or when the configured value sanitized to empty. pub session_title: Option<String>, - /// Permission mode to apply after session creation. `Default` = skip. - pub permission_mode: PermissionMode, + /// Resolved permission configuration — policy, effective ACP mode, and + /// how to transmit it. Computed once from `PermissionPolicy` + optional + /// explicit `PermissionMode` in `from_args`. + pub permission_config: ResolvedPermissionConfig, /// Inbound author gate mode. pub respond_to: RespondTo, /// Validated allowlist of pubkey hex strings (used when respond_to == Allowlist). @@ -1135,6 +1329,9 @@ impl Config { validate_multiple_event_handling(args.multiple_event_handling, args.dedup)?; + let permission_config = + ResolvedPermissionConfig::resolve(args.permission_policy, args.permission_mode)?; + let config = Config { keys, relay_url: args.relay_url, @@ -1175,7 +1372,7 @@ impl Config { .session_title .as_deref() .and_then(sanitize_session_title), - permission_mode: args.permission_mode, + permission_config, respond_to: args.respond_to, respond_to_allowlist, allowed_respond_to, @@ -1209,7 +1406,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={:?} session_policy={} 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={:?} session_policy={} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={}({}) {}{}", self.relay_url, self.keys.public_key().to_hex(), self.agent_command, @@ -1230,7 +1427,8 @@ impl Config { self.typing_enabled, self.memory_enabled, self.model.as_deref().unwrap_or("(agent default)"), - self.permission_mode, + self.permission_config.effective_mode, + self.permission_config.mode_source, respond_to_detail, allowed_respond_to_detail, ) @@ -1550,7 +1748,11 @@ mod tests { model: None, effort_level: None, session_title: None, - permission_mode: PermissionMode::BypassPermissions, + permission_config: ResolvedPermissionConfig::resolve( + PermissionPolicy::Reject, + Some(PermissionMode::DontAsk), + ) + .expect("test config"), respond_to: RespondTo::Anyone, respond_to_allowlist: HashSet::new(), allowed_respond_to: Vec::new(), @@ -2369,10 +2571,6 @@ channels = "ALL" assert_eq!(PermissionMode::Default.as_wire_str(), "default"); assert_eq!(PermissionMode::Auto.as_wire_str(), "auto"); assert_eq!(PermissionMode::AcceptEdits.as_wire_str(), "acceptEdits"); - assert_eq!( - PermissionMode::BypassPermissions.as_wire_str(), - "bypassPermissions" - ); assert_eq!(PermissionMode::DontAsk.as_wire_str(), "dontAsk"); assert_eq!(PermissionMode::Plan.as_wire_str(), "plan"); } @@ -2381,7 +2579,6 @@ channels = "ALL" fn test_permission_mode_is_default() { assert!(PermissionMode::Default.is_default()); assert!(!PermissionMode::Auto.is_default()); - assert!(!PermissionMode::BypassPermissions.is_default()); assert!(!PermissionMode::AcceptEdits.is_default()); assert!(!PermissionMode::DontAsk.is_default()); assert!(!PermissionMode::Plan.is_default()); @@ -2400,10 +2597,7 @@ channels = "ALL" #[test] fn test_permission_mode_display() { - assert_eq!( - format!("{}", PermissionMode::BypassPermissions), - "bypassPermissions" - ); + assert_eq!(format!("{}", PermissionMode::DontAsk), "dontAsk"); assert_eq!(format!("{}", PermissionMode::Default), "default"); assert_eq!(format!("{}", PermissionMode::Auto), "auto"); } @@ -2411,10 +2605,14 @@ channels = "ALL" #[test] fn test_summary_includes_permission_mode() { let mut config = test_config(SubscribeMode::Mentions); - config.permission_mode = PermissionMode::BypassPermissions; + config.permission_config = ResolvedPermissionConfig::resolve( + PermissionPolicy::Reject, + Some(PermissionMode::DontAsk), + ) + .expect("test config"); let s = config.summary(); assert!( - s.contains("permission_mode=bypassPermissions"), + s.contains("permission_mode=dontAsk"), "summary should include permission_mode, got: {s}" ); } @@ -2422,7 +2620,8 @@ channels = "ALL" #[test] fn test_summary_permission_mode_default() { let mut config = test_config(SubscribeMode::Mentions); - config.permission_mode = PermissionMode::Default; + config.permission_config = + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, None).expect("test config"); let s = config.summary(); assert!( s.contains("permission_mode=default"), @@ -2431,9 +2630,12 @@ channels = "ALL" } #[test] - fn test_default_config_uses_bypass_permissions() { + fn test_default_config_rejects_interactive_permissions() { let config = test_config(SubscribeMode::Mentions); - assert_eq!(config.permission_mode, PermissionMode::BypassPermissions); + assert_eq!( + config.permission_config.effective_mode, + PermissionMode::DontAsk + ); } #[test] @@ -2445,7 +2647,6 @@ channels = "ALL" ("default", PermissionMode::Default), ("auto", PermissionMode::Auto), ("accept-edits", PermissionMode::AcceptEdits), - ("bypass-permissions", PermissionMode::BypassPermissions), ("dont-ask", PermissionMode::DontAsk), ("plan", PermissionMode::Plan), ]; @@ -2460,15 +2661,13 @@ channels = "ALL" #[test] fn test_permission_mode_value_enum_camel_case_aliases() { - // Operators may set env vars using the camelCase wire-format strings - // (e.g. BUZZ_ACP_PERMISSION_MODE=bypassPermissions). The #[value(alias)] - // attributes ensure these parse correctly. + // Operators may set env vars using the camelCase wire-format strings. + // The #[value(alias)] attributes ensure these parse correctly. use clap::ValueEnum; let cases = [ ("default", PermissionMode::Default), ("auto", PermissionMode::Auto), ("acceptEdits", PermissionMode::AcceptEdits), - ("bypassPermissions", PermissionMode::BypassPermissions), ("dontAsk", PermissionMode::DontAsk), ("plan", PermissionMode::Plan), ]; @@ -2481,6 +2680,18 @@ channels = "ALL" } } + #[test] + fn test_permission_mode_rejects_unattended_bypass() { + use clap::ValueEnum; + + for input in ["bypass-permissions", "bypassPermissions"] { + assert!( + PermissionMode::from_str(input, true).is_err(), + "{input:?} must not disable the ACP permission boundary" + ); + } + } + /// Helper: resolve idle_timeout_secs using the same precedence logic as Config::from_args. /// Precedence: explicit --idle-timeout > --turn-timeout (deprecated) > `DEFAULT_IDLE_TIMEOUT_SECS`. fn resolve_idle_timeout(idle: Option<u64>, turn: Option<u64>) -> u64 { diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index af504a11768..86c014db2c5 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -950,7 +950,19 @@ impl ObserverPublishQueue { // Pre-trim at enqueue so (a) byte accounting reflects what will ship // and (b) one oversized leaf cannot force every frame it touches into // whole-envelope elision downstream. + // + // Authorization frames must not be leaf-trimmed (NIP-AO §3 requires + // byte-for-byte reproduction). `fit_observer_event_to_budget` returns + // without mutating them; if they are still over-cap after that guard, + // suppress entirely rather than enqueue an over-budget frame. fit_observer_event_to_budget(&mut event); + if event.authorization.is_some() && serialized_len(&event) > OBSERVER_MAX_PLAINTEXT_LEN { + tracing::warn!( + kind = %event.kind, + "suppressing authorized observer frame at enqueue: over-cap after fit" + ); + return; + } let bytes = serialized_len(&event); self.pending_bytes += bytes; self.events.push_back((bytes, source_events, event)); @@ -1091,6 +1103,7 @@ fn batch_envelope(events: &[observer::ObserverEvent]) -> observer::ObserverEvent session_id: last.session_id.clone(), turn_id: last.turn_id.clone(), started_at: last.started_at.clone(), + authorization: None, payload: serde_json::json!({ "events": serde_json::to_value(events).unwrap_or_default(), }), @@ -1391,6 +1404,19 @@ fn fit_observer_event_to_budget(event: &mut observer::ObserverEvent) { return; } + // Authorization frames carry byte-for-byte raw ACP that must not be + // rewritten — NIP-AO §3 requires the payload to be reproduced exactly as + // received. If the annotated event is still over-cap after the early-return + // above, suppress it entirely rather than mutate the ACP bytes. + if event.authorization.is_some() { + tracing::warn!( + kind = %event.kind, + "dropping authorized observer frame: annotated size exceeds cap \ + and payload must not be trimmed" + ); + return; + } + // Raw size of the payload we are about to trim, captured before mutation so // the stub's `originalBytes` reports source bytes discarded, not serialized // overflow — consistent with the per-leaf marker's raw byte count. @@ -1560,7 +1586,13 @@ async fn publish_relay_observer_event( } /// Maximum age (seconds) for an observer control frame to be considered fresh. -const OBSERVER_CONTROL_FRESHNESS_SECS: i64 = 300; +/// +/// Doubles as the observer-control subscription lookback (see +/// [`crate::relay::build_observer_control_req`]): one constant drives both the +/// admission window and the resubscribe `since` so they cannot drift. A frame +/// signed just before a reconnect resubscribe stays inside both windows, and a +/// retransmitted copy sent while the socket was down lands after reconnect. +pub(crate) const OBSERVER_CONTROL_FRESHNESS_SECS: i64 = 300; fn handle_relay_observer_control_event( keys: &nostr::Keys, @@ -1614,6 +1646,9 @@ fn handle_relay_observer_control_event( Some("switch_model") => { handle_switch_model_control(&payload, pool, observer); } + Some("permission_decision") => { + handle_permission_decision_control(&payload, pool, observer); + } Some("publish_project_owner_announcements") => { handle_publish_project_owner_announcements_control( &payload, @@ -1907,6 +1942,208 @@ fn handle_switch_model_control( } } +/// Handle a `permission_decision` control frame. +/// +/// Extracts `channelId`, `requestNonce`, and `optionId` from the payload and +/// delivers a [`crate::acp::PermissionDecision`] to the in-flight read loop +/// via the per-task `permission_decision_tx` mpsc channel. +/// +/// **Fan-out routing (same-channel multi-thread safety):** The relay supports +/// concurrent thread-scoped tasks in one channel. Finding only the first task +/// by `channel_id` would let the wrong sibling consume-and-ignore a frame whose +/// nonce belongs to a different thread, stranding that thread's decision until +/// timeout. Instead this function fans out to **all** tasks whose `channel_id` +/// matches — the nonce is unguessable, so every read loop that receives the +/// decision drops it immediately when it has no matching pending entry (the +/// `"no matching pending entry"` trace path in `acp.rs`), while the owning loop +/// accepts it. Ownership signature validation was already performed upstream +/// before this function is called. +/// +/// If there is no in-flight task for the channel, or all senders are gone, the +/// frame is dropped silently (the per-request 300s timeout will fail the entry +/// closed on its own). +fn handle_permission_decision_control( + payload: &serde_json::Value, + pool: &mut AgentPool, + observer: Option<&observer::ObserverHandle>, +) { + let Some(channel_id) = payload + .get("channelId") + .and_then(|v| v.as_str()) + .and_then(|v| v.parse::<Uuid>().ok()) + else { + tracing::warn!("observer permission_decision control frame missing valid channelId"); + return; + }; + + let Some(request_nonce) = payload + .get("requestNonce") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + else { + tracing::warn!("observer permission_decision control frame missing requestNonce"); + return; + }; + + let Some(option_id) = payload + .get("optionId") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + else { + tracing::warn!("observer permission_decision control frame missing optionId"); + return; + }; + + let decision = crate::acp::PermissionDecision { + request_nonce: request_nonce.to_string(), + option_id: option_id.to_string(), + }; + + // Collect all same-channel tasks that have a permission_decision_tx + // installed. Fan out: every eligible read loop in the channel receives the + // decision and lets the nonce select the owning entry. + enum Delivery { + Sent, + Full, + Closed, + NoChannel, + NoTask, + } + + // Gather delivery results for all matching tasks. + let deliveries: Vec<Delivery> = { + let matching: Vec<_> = pool + .task_map_mut() + .values_mut() + .filter(|m| m.channel_id == Some(channel_id)) + .collect(); + + if matching.is_empty() { + vec![Delivery::NoTask] + } else { + matching + .into_iter() + .map(|meta| match &meta.permission_decision_tx { + Some(tx) => match tx.try_send(decision.clone()) { + Ok(()) => Delivery::Sent, + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => Delivery::Full, + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => Delivery::Closed, + }, + None => Delivery::NoChannel, + }) + .collect() + } + }; + + // Record the nonce and summarise into a single observer status. + // + // Correctness requirement (fan-out false-ack): a `permission_decision` is + // fanned out to all same-channel loops; each loop uses the nonce to select + // its own entry. The dispatcher cannot identify which task owns the nonce, + // so it must not report `sent` — stopping Desktop's retransmit loop — unless + // every loop that has a permission tx accepted the message. If any tx-equipped + // loop's queue returned `Full` or `Closed` (owner-queue saturated while a + // sibling accepted), the nonce-owning loop may not have received the decision, + // so we return a retryable status and leave Desktop's retry loop running. + let any_sent = deliveries.iter().any(|d| matches!(d, Delivery::Sent)); + let any_full = deliveries.iter().any(|d| matches!(d, Delivery::Full)); + // All tx-equipped loops accepted: no Full and no Closed among the deliveries. + let all_tx_accepted = + any_sent && !any_full && !deliveries.iter().any(|d| matches!(d, Delivery::Closed)); + + // Only suppress retransmits (record nonce) when every tx-equipped loop + // received the decision — a partial fan-out is not a confirmed delivery. + if all_tx_accepted { + pool.record_permission_decision(request_nonce); + } + + // Summarise into a single status for the observer frame. + // all_tx_accepted → "sent" (Desktop retransmit stops, decision confirmed). + // any_sent + any_full → "channel_full" (transient queue saturation: Desktop + // keeps the retransmit loop active and resends on the next tick; the owning + // loop's first-wins dedup tolerates duplicate deliveries once the queue drains). + // No Sent → fall through to the existing priority ladder. + let status = if all_tx_accepted { + tracing::info!( + channel = %channel_id, + nonce = %request_nonce, + option_id = %option_id, + tasks_fanned = deliveries.len(), + "permission_decision delivered to all read loop(s)" + ); + "sent" + } else if any_sent && any_full { + // Mixed: at least one sibling accepted but the owner queue was full. + // Reporting "sent" here would stop Desktop's retransmit loop while the + // nonce-owning read loop never received the decision. Return "channel_full" + // so Desktop keeps the retransmit loop active until the queue drains. + tracing::warn!( + channel = %channel_id, + nonce = %request_nonce, + "permission_decision: some loops sent, owner loop full — \ + reporting channel_full to keep Desktop retransmitting" + ); + "channel_full" + } else if deliveries.iter().any(|d| matches!(d, Delivery::Full)) { + tracing::warn!( + channel = %channel_id, + "permission_decision channel full — dropping (will timeout)" + ); + "channel_full" + } else if deliveries.iter().any(|d| matches!(d, Delivery::Closed)) { + tracing::warn!( + channel = %channel_id, + "permission_decision channel closed — read loop already exited" + ); + if pool.was_recently_decided(request_nonce) { + "already_decided" + } else { + "channel_closed" + } + } else if deliveries.iter().any(|d| matches!(d, Delivery::NoChannel)) { + tracing::warn!( + channel = %channel_id, + "permission_decision_tx not installed for in-flight task" + ); + "no_channel" + } else { + // NoTask — no in-flight task at all. + if pool.was_recently_decided(request_nonce) { + tracing::debug!( + channel = %channel_id, + nonce = %request_nonce, + "permission_decision retransmit for an already-decided nonce — acking success-shaped" + ); + "already_decided" + } else { + tracing::warn!( + channel = %channel_id, + "permission_decision control frame for channel with no in-flight task" + ); + "no_active_turn" + } + }; + + if let Some(observer) = observer { + observer.emit( + "control_result", + None, + &observer::ObserverContext { + channel_id: Some(channel_id.to_string()), + session_id: None, + turn_id: None, + started_at: None, + }, + serde_json::json!({ + "type": "permission_decision", + "status": status, + "requestNonce": request_nonce, + "optionId": option_id, + }), + ); + } +} + /// Maximum crashes in a 60-second window before a slot's circuit opens. const CIRCUIT_BREAKER_THRESHOLD: usize = 3; /// Window for circuit-breaker crash counting. @@ -2719,7 +2956,7 @@ async fn tokio_main() -> Result<()> { channel_info: pool::ChannelInfoResolver::new(channel_info_map, relay.rest_client()), context_message_limit: config.context_message_limit, max_turns_per_session: config.max_turns_per_session, - permission_mode: config.permission_mode, + permission_config: config.permission_config.clone(), agent_keys: config.keys.clone(), agent_owner_pubkey: startup_owner .as_deref() @@ -2727,6 +2964,7 @@ async fn tokio_main() -> Result<()> { memory_enabled: config.memory_enabled, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), relay_url: config.relay_url.clone(), + relay_event_publisher: Some(relay.event_publisher()), }); if !config.memory_enabled { @@ -4342,6 +4580,17 @@ fn dispatch_pending( agent.acp.install_steer_rx(rx); let steer_tx = Some(tx); + // Permission decision channel: delivers `permission_decision` control + // frames into the read loop's decision arm (spec §4). Installed + // per-session (the receiver is taken by the read loop and dropped + // when the turn ends; the next turn installs a fresh pair). Capacity + // matches PERMISSION_MAP_CAP so each pending entry gets a slot. + let (perm_tx, perm_rx) = tokio::sync::mpsc::channel::<crate::acp::PermissionDecision>( + crate::acp::PERMISSION_MAP_CAP, + ); + agent.acp.install_permission_decision_rx(perm_rx); + let permission_decision_tx = Some(perm_tx); + // Prompt text is now built inside run_prompt_task (needs async for // context fetching). Pass None for prompt_text; batch carries the data. let (control_tx, control_rx) = tokio::sync::oneshot::channel::<ControlSignal>(); @@ -4371,6 +4620,7 @@ fn dispatch_pending( recoverable_batch, control_tx: Some(control_tx), steer_tx, + permission_decision_tx, successful_steer_deliveries: HashSet::new(), }, ); @@ -4800,6 +5050,10 @@ fn handle_prompt_result( | acp::AcpError::WriteTimeout(_) | acp::AcpError::Timeout(_) | acp::AcpError::Protocol(_) + // A poisoned process wrote a partial permission response + // and must NOT be returned to the pool — the pipe state is + // uncertain and re-use would corrupt the next turn's writes. + | acp::AcpError::PermissionPoisoned ); let error_code = match &e { acp::AcpError::AgentError { code, .. } => Some(*code), @@ -5045,6 +5299,7 @@ fn dispatch_heartbeat( recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -5858,6 +6113,7 @@ mod owner_control_command_tests { recoverable_batch: None, control_tx: Some(control_tx), steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -5904,6 +6160,7 @@ mod owner_control_command_tests { recoverable_batch: None, control_tx: Some(control_tx), steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -7859,6 +8116,7 @@ mod observer_publish_queue_tests { session_id: Some("session-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, + authorization: None, payload: serde_json::json!({ "seq": seq }), } } @@ -8727,6 +8985,7 @@ mod observer_chunk_coalescer_tests { session_id: Some("session-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, + authorization: None, payload: serde_json::json!({ "jsonrpc": "2.0", "method": "session/update", @@ -8755,6 +9014,7 @@ mod observer_chunk_coalescer_tests { session_id: Some("session-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, + authorization: None, payload: serde_json::json!({ "type": "turn_started" }), } } @@ -8852,7 +9112,11 @@ mod build_mcp_servers_tests { model: None, effort_level: None, session_title: None, - permission_mode: config::PermissionMode::BypassPermissions, + permission_config: config::ResolvedPermissionConfig::resolve( + config::PermissionPolicy::Reject, + None, + ) + .expect("test config"), respond_to: config::RespondTo::Anyone, respond_to_allowlist: std::collections::HashSet::new(), allowed_respond_to: vec![], @@ -9077,7 +9341,11 @@ mod error_outcome_emission_tests { model: None, effort_level: None, session_title: None, - permission_mode: config::PermissionMode::BypassPermissions, + permission_config: config::ResolvedPermissionConfig::resolve( + config::PermissionPolicy::Reject, + None, + ) + .expect("test config"), respond_to: config::RespondTo::Anyone, respond_to_allowlist: HashSet::new(), allowed_respond_to: vec![], @@ -9159,6 +9427,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::from([ crate::pool::SuccessfulSteerDelivery { event_id: steer_event_id.into(), @@ -9234,6 +9503,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::from([ crate::pool::SuccessfulSteerDelivery { event_id: "stale-event".into(), @@ -9356,6 +9626,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::from([ crate::pool::SuccessfulSteerDelivery { event_id: "stale-event".into(), @@ -9425,6 +9696,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -9505,6 +9777,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -9597,6 +9870,7 @@ mod error_outcome_emission_tests { recoverable_batch: Some(batch), control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -9696,6 +9970,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -9793,6 +10068,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -9901,6 +10177,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -9979,6 +10256,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -10076,6 +10354,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -10196,6 +10475,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -10338,6 +10618,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -10472,6 +10753,7 @@ mod error_outcome_emission_tests { control_tx: None, steer_tx: None, successful_steer_deliveries: HashSet::new(), + permission_decision_tx: None, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -10626,6 +10908,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -10714,6 +10997,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -10763,6 +11047,463 @@ mod error_outcome_emission_tests { } } +#[cfg(test)] +mod permission_decision_control_tests { + //! Pins the A2 inbound-delivery dedup: a `permission_decision` control + //! frame is forwarded to the in-flight task's mpsc exactly once; a later + //! retransmit of the same nonce that lands after the deciding task has + //! ended is acked success-shaped (`already_decided`) rather than failing + //! the already-resolved card with `no_active_turn` / `channel_closed`. + + use super::*; + use crate::observer::ObserverHandle; + use crate::pool::{AgentPool, TaskMeta}; + use std::collections::HashSet; + + fn decision_payload(channel_id: Uuid, nonce: &str) -> serde_json::Value { + serde_json::json!({ + "channelId": channel_id.to_string(), + "requestNonce": nonce, + "optionId": "opt-allow", + }) + } + + /// Install an in-flight task for `channel_id` carrying a permission mpsc, + /// returning the receiver so the test can observe delivery. + fn install_task( + pool: &mut AgentPool, + channel_id: Uuid, + ) -> tokio::sync::mpsc::Receiver<crate::acp::PermissionDecision> { + let (tx, rx) = tokio::sync::mpsc::channel(4); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + scope: None, + turn_id: "test-turn".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + permission_decision_tx: Some(tx), + successful_steer_deliveries: HashSet::new(), + }, + ); + rx + } + + /// Drain the observer for the single `control_result` status string. + fn control_result_status( + rx: &mut tokio::sync::broadcast::Receiver<observer::ObserverEvent>, + ) -> String { + loop { + let event = rx.try_recv().expect("a control_result event was emitted"); + if event.kind == "control_result" { + return event.payload["status"].as_str().unwrap().to_string(); + } + } + } + + #[tokio::test] + async fn decision_is_delivered_once_then_retransmit_is_already_decided() { + let observer = ObserverHandle::in_process(); + let mut rx_obs = observer.subscribe(); + let channel_id = Uuid::new_v4(); + let nonce = "nonce-live"; + + let mut pool = AgentPool::from_slots(vec![None]); + let mut rx_task = install_task(&mut pool, channel_id); + + // (a) First delivery reaches the read loop's mpsc and records the nonce. + handle_permission_decision_control( + &decision_payload(channel_id, nonce), + &mut pool, + Some(&observer), + ); + assert_eq!(control_result_status(&mut rx_obs), "sent"); + let delivered = rx_task.try_recv().expect("decision delivered to read loop"); + assert_eq!(delivered.request_nonce, nonce); + assert_eq!(delivered.option_id, "opt-allow"); + + // (b) The deciding task ends: drop its mpsc and remove it from the map, + // exactly as `handle_prompt_result` would on turn completion. + drop(rx_task); + pool.task_map_mut().clear(); + + // (c) A retransmit of the same nonce lands with no in-flight task. It + // must be recognized as an already-applied duplicate. + handle_permission_decision_control( + &decision_payload(channel_id, nonce), + &mut pool, + Some(&observer), + ); + assert_eq!( + control_result_status(&mut rx_obs), + "already_decided", + "retransmit after the task ended must ack success-shaped, not fail the resolved card" + ); + } + + #[tokio::test] + async fn unknown_nonce_with_no_task_is_no_active_turn() { + // Mutation guard: without the recently-decided record, a decision for a + // channel with no in-flight task falls through to `no_active_turn`. + // This is what a retransmit of a *never-delivered* nonce must still get, + // and what the `already_decided` path above would collapse into if + // `was_recently_decided` were stubbed to always-false. + let observer = ObserverHandle::in_process(); + let mut rx_obs = observer.subscribe(); + let channel_id = Uuid::new_v4(); + + let mut pool = AgentPool::from_slots(vec![None]); + handle_permission_decision_control( + &decision_payload(channel_id, "never-seen"), + &mut pool, + Some(&observer), + ); + assert_eq!(control_result_status(&mut rx_obs), "no_active_turn"); + } + + #[tokio::test] + async fn closed_channel_for_decided_nonce_is_already_decided() { + // The read loop is still in the task map but its receiver was dropped + // (loop exited mid-turn). A retransmit of an already-delivered nonce on + // that closed channel must ack `already_decided`, not `channel_closed`. + let observer = ObserverHandle::in_process(); + let mut rx_obs = observer.subscribe(); + let channel_id = Uuid::new_v4(); + let nonce = "nonce-closed"; + + let mut pool = AgentPool::from_slots(vec![None]); + let rx_task = install_task(&mut pool, channel_id); + + handle_permission_decision_control( + &decision_payload(channel_id, nonce), + &mut pool, + Some(&observer), + ); + assert_eq!(control_result_status(&mut rx_obs), "sent"); + + // Read loop exits: its receiver drops, but the task_map entry (with the + // now-closed sender) is still present. + drop(rx_task); + handle_permission_decision_control( + &decision_payload(channel_id, nonce), + &mut pool, + Some(&observer), + ); + assert_eq!( + control_result_status(&mut rx_obs), + "already_decided", + "closed channel for an already-delivered nonce acks success-shaped" + ); + } + + /// Routing hazard regression: two concurrent thread-scoped tasks in the + /// same channel. A `permission_decision` frame must be fanned out to BOTH + /// tasks so the correct owning read loop can accept the decision by nonce. + /// + /// **Scenario:** + /// - Thread A and Thread B both have `permission_decision_tx` installed. + /// - A decision arrives whose nonce belongs to Thread A. + /// - The fix fans out to BOTH channels; Thread A receives it. + /// - Thread B also receives it (fan-out), but its read loop drops it on + /// nonce mismatch — that is correct and expected. + /// + /// **Mutation proof:** reverting the fan-out to a `.find()` (first-match + /// only) and running with Thread B installed first makes Thread A's channel + /// empty (`try_recv` returns an error), and the assertion on Thread A fails. + #[tokio::test] + async fn two_threads_same_channel_fan_out_routes_to_owning_thread() { + let observer = ObserverHandle::in_process(); + let mut rx_obs = observer.subscribe(); + let channel_id = Uuid::new_v4(); + let nonce_a = "nonce-thread-a"; + + let mut pool = AgentPool::from_slots(vec![None]); + + // Install Thread B first — a `.find()`-only implementation would pick + // Thread B and deliver there, stranding Thread A's decision. + let mut rx_b = install_task(&mut pool, channel_id); + let mut rx_a = install_task(&mut pool, channel_id); + + // Deliver a decision with Thread A's nonce. + handle_permission_decision_control( + &decision_payload(channel_id, nonce_a), + &mut pool, + Some(&observer), + ); + assert_eq!( + control_result_status(&mut rx_obs), + "sent", + "decision must be delivered (status sent)" + ); + + // Thread A's channel MUST have received the decision. + let received_a = rx_a.try_recv(); + assert!( + received_a.is_ok(), + "Thread A must receive the decision — fan-out failure would leave this empty; got: {received_a:?}" + ); + assert_eq!(received_a.unwrap().request_nonce, nonce_a); + + // Thread B also received it (fan-out). Its read loop would drop it on + // nonce mismatch; here we just confirm fan-out delivered to both. + let received_b = rx_b.try_recv(); + assert!( + received_b.is_ok(), + "Thread B should also receive via fan-out (nonce mismatch handled by the read loop)" + ); + } + + /// Cross-thread isolation: a decision for Thread A must NOT strand Thread B. + /// + /// Both threads are running concurrently. After Thread A's decision is + /// applied (its entry resolved), Thread B can still receive its own + /// decision independently. + #[tokio::test] + async fn two_threads_same_channel_thread_b_not_stranded() { + let observer = ObserverHandle::in_process(); + let mut rx_obs = observer.subscribe(); + let channel_id = Uuid::new_v4(); + let nonce_a = "nonce-strand-a"; + let nonce_b = "nonce-strand-b"; + + let mut pool = AgentPool::from_slots(vec![None]); + let mut rx_b = install_task(&mut pool, channel_id); + let mut rx_a = install_task(&mut pool, channel_id); + + // Deliver Thread A's decision. + handle_permission_decision_control( + &decision_payload(channel_id, nonce_a), + &mut pool, + Some(&observer), + ); + assert_eq!(control_result_status(&mut rx_obs), "sent"); + // Drain both channels. + let _ = rx_a.try_recv(); + let _ = rx_b.try_recv(); + + // Now deliver Thread B's decision. + let payload_b = serde_json::json!({ + "channelId": channel_id.to_string(), + "requestNonce": nonce_b, + "optionId": "opt-deny", + }); + handle_permission_decision_control(&payload_b, &mut pool, Some(&observer)); + assert_eq!(control_result_status(&mut rx_obs), "sent"); + + // Thread B must receive its own decision. + let received_b_2 = rx_b.try_recv(); + assert!( + received_b_2.is_ok(), + "Thread B must receive its own decision after Thread A's was handled: {received_b_2:?}" + ); + assert_eq!(received_b_2.unwrap().request_nonce, nonce_b); + } + + /// Fan-out false-ack: mixed-result (owner-Full + sibling-Sent) must NOT + /// report `sent` or record the nonce. + /// + /// Scenario (Carl's verbatim requirement): + /// 1. Two tasks share a channel — owner (rx_owner, capacity 4) and + /// sibling (rx_sibling, capacity 4). Owner's queue is saturated with + /// 4 unread messages; sibling's queue is kept clear. + /// 2. A permission decision for nonce N is fanned out: sibling receives + /// it (`Sent`), owner queue returns `Full`. + /// 3. Expected: status == `channel_full` (Desktop keeps retransmitting), + /// nonce N is NOT in `was_recently_decided` (no suppression yet). + /// 4. Owner queue is drained; decision is retransmitted. + /// Expected: status == `sent`, nonce N recorded, owner receives it. + /// + /// **Mutation proof:** reverting the `all_tx_accepted` gate to the prior + /// `any_sent` semantics makes step 3 return `"sent"` and records the nonce, + /// causing the assertion `assert_ne!(status_mixed, "sent")` to fail. + #[tokio::test] + async fn mixed_result_owner_full_sibling_sent_reports_channel_full_not_sent() { + let observer = ObserverHandle::in_process(); + let mut rx_obs = observer.subscribe(); + let channel_id = Uuid::new_v4(); + let nonce = "nonce-mixed-full"; + + let mut pool = AgentPool::from_slots(vec![None]); + // Install sibling first — fan-out visits it before the owner. + // Keep the sibling receiver so we can drain it between fill rounds. + let mut rx_sibling = install_task(&mut pool, channel_id); + let mut rx_owner = install_task(&mut pool, channel_id); + + // Saturate the owner's queue (capacity 4) with four fill decisions. + // After each send we drain the sibling's queue so it never fills. + let fill_nonce = "nonce-fill"; + for _ in 0..4 { + handle_permission_decision_control( + &decision_payload(channel_id, fill_nonce), + &mut pool, + Some(&observer), + ); + // Observer drain — keeps it responsive. + let _ = control_result_status(&mut rx_obs); + // Drain sibling so its queue stays open for the critical decision. + while rx_sibling.try_recv().is_ok() {} + } + // Precondition: owner queue is full (4/4 unread), sibling queue is empty. + assert!( + rx_owner.try_recv().is_ok(), + "precondition: owner queue must have unread messages (fill worked)" + ); + // We just popped one — push it back conceptually: re-fill the slot we + // accidentally drained by sending one more fill decision. + handle_permission_decision_control( + &decision_payload(channel_id, fill_nonce), + &mut pool, + Some(&observer), + ); + let _ = control_result_status(&mut rx_obs); + while rx_sibling.try_recv().is_ok() {} + // Owner queue: 4/4 full again (we drained 1 then immediately refilled). + + // Deliver the critical decision. + // Owner queue: Full. Sibling queue: Sent. → any_sent=true, any_full=true. + handle_permission_decision_control( + &decision_payload(channel_id, nonce), + &mut pool, + Some(&observer), + ); + let status_mixed = control_result_status(&mut rx_obs); + + assert_ne!( + status_mixed, "sent", + "mixed-result (owner Full, sibling Sent) must NOT report 'sent'" + ); + assert_eq!( + status_mixed, "channel_full", + "mixed-result must report 'channel_full' to keep Desktop retransmitting" + ); + assert!( + !pool.was_recently_decided(nonce), + "nonce must NOT be recorded when owner queue was Full — \ + retransmit suppression must not activate" + ); + + // Drain the owner queue fully — makes room for the retransmit. + while rx_owner.try_recv().is_ok() {} + // Also drain the sibling's copy of the critical nonce so it won't be + // counted as full on the retransmit path either. + while rx_sibling.try_recv().is_ok() {} + + // Retransmit. Now both queues have capacity: all tx-equipped loops + // accept → must report `sent` and record the nonce. + handle_permission_decision_control( + &decision_payload(channel_id, nonce), + &mut pool, + Some(&observer), + ); + let status_retry = control_result_status(&mut rx_obs); + + assert_eq!( + status_retry, "sent", + "after drain, retransmitted decision must reach owner and report 'sent'" + ); + assert!( + pool.was_recently_decided(nonce), + "nonce must be recorded after all-loops-accepted retransmit" + ); + // Confirm the owner's queue actually received the retransmit. + let received = rx_owner.try_recv(); + assert!( + received.is_ok(), + "owner loop must receive the retransmitted decision after drain: {received:?}" + ); + assert_eq!(received.unwrap().request_nonce, nonce); + } + + /// F4 outer signed-control path: `handle_relay_observer_control_event` + /// admits a valid owner-signed, NIP-44-encrypted kind-24200 frame and + /// delivers the enclosed `permission_decision` payload to the in-flight + /// task's mpsc — exactly the path the Desktop takes when submitting a + /// decision over the relay. + /// + /// This test proves the outer admission path (signature check, owner-pubkey + /// check, freshness window, NIP-44 decrypt, type dispatch) without a live + /// relay: we build and sign the event locally then call the handler directly. + /// + /// Mutation proof: if the `is_none()` → `true` first-wins guard is removed + /// the early_decision would be overwritten by a later allow → the wrong + /// option is applied. Pairing this signed-path admission test with the + /// existing `early_decision_first_wins_reject_then_allow_reject_applied` + /// inner-loop test (which uses the same first-wins mutation) closes the + /// gap between the outer signed path and the inner read loop. + #[tokio::test] + async fn signed_observer_control_event_delivers_permission_decision() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + + let observer = ObserverHandle::in_process(); + let mut rx_obs = observer.subscribe(); + let channel_id = Uuid::new_v4(); + let nonce = "nonce-signed-outer"; + + // Generate agent keys (the recipient of the encrypted payload) and owner + // keys (the sender — the owner who clicked the card in Desktop). + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + + let mut pool = AgentPool::from_slots(vec![None]); + let mut rx_task = install_task(&mut pool, channel_id); + + // Build the permission_decision payload and NIP-44 encrypt it from the + // owner to the agent, exactly as Desktop does. + let decision_payload_value = serde_json::json!({ + "type": "permission_decision", + "channelId": channel_id.to_string(), + "requestNonce": nonce, + "optionId": "opt-reject", + }); + let encrypted = buzz_core::observer::encrypt_observer_payload( + &owner_keys, + &agent_keys.public_key(), + &decision_payload_value, + ) + .expect("encrypt permission_decision payload"); + + // Build a kind-24200 observer control frame signed by the owner. + let event = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_AGENT_OBSERVER_FRAME as u16), + &encrypted, + ) + .tags([ + Tag::parse(["p", &agent_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["frame", buzz_core::observer::OBSERVER_FRAME_CONTROL]).unwrap(), + ]) + .sign_with_keys(&owner_keys) + .expect("sign observer control event"); + + // Call the outer admission handler: signature check, owner-pubkey guard, + // freshness check, NIP-44 decrypt, type dispatch → mpsc delivery. + handle_relay_observer_control_event( + &agent_keys, + event, + &mut pool, + Some(&observer), + &owner_keys.public_key().to_hex(), + RelayEventPublisher::test_pair_dead(), + ); + + // The handler is synchronous and delivers immediately. + assert_eq!( + control_result_status(&mut rx_obs), + "sent", + "signed outer-control path must deliver the decision and emit status: sent" + ); + let delivered = rx_task + .try_recv() + .expect("decision must be delivered to the read loop"); + assert_eq!(delivered.request_nonce, nonce); + assert_eq!(delivered.option_id, "opt-reject"); + } +} + #[cfg(test)] mod observer_payload_trim_tests { use super::*; @@ -10777,6 +11518,7 @@ mod observer_payload_trim_tests { session_id: Some("sess-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, + authorization: None, payload, } } @@ -11010,4 +11752,42 @@ mod observer_payload_trim_tests { assert!(leaf.ends_with('…')); assert!(leaf.contains("[elided")); } + + /// Authorized observer frames must never be leaf-trimmed or stubbed. + /// `fit_observer_event_to_budget` must leave the payload untouched when + /// `authorization` is present, even if the serialized frame is over-cap. + #[test] + fn test_authorized_frame_payload_is_never_trimmed() { + // Build an over-cap authorized frame (big payload, authorization present). + let big = "x".repeat(OBSERVER_MAX_PLAINTEXT_LEN + 1000); + let mut event = event_with_payload( + "acp_read", + serde_json::json!({ "method": "session/request_permission", "body": big }), + ); + event.authorization = Some(crate::observer::AuthorizationEnvelope { + request_nonce: "test-nonce".to_string(), + actionable: true, + reason: None, + expires_at: None, + }); + + let payload_before = event.payload.clone(); + assert!( + serialized(&event).len() > OBSERVER_MAX_PLAINTEXT_LEN, + "precondition: authorized frame is over-cap" + ); + + fit_observer_event_to_budget(&mut event); + + // Payload must be byte-for-byte identical — no leaf trim, no stub. + assert_eq!( + event.payload, payload_before, + "authorized frame payload must not be mutated by fit_observer_event_to_budget" + ); + // Authorization envelope must still be present and intact. + assert!( + event.authorization.is_some(), + "authorization envelope must survive fit_observer_event_to_budget" + ); + } } diff --git a/crates/buzz-acp/src/observer.rs b/crates/buzz-acp/src/observer.rs index 7029e5af6d5..3dce1974713 100644 --- a/crates/buzz-acp/src/observer.rs +++ b/crates/buzz-acp/src/observer.rs @@ -30,6 +30,33 @@ pub struct ObserverContext { pub started_at: Option<String>, } +/// Authorization envelope attached to permission-related observer events. +/// +/// Present on the single `acp_read` emitted after a permission request passes +/// the admission preflight, and on the corresponding `acp_write` after the +/// response is confirmed written. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthorizationEnvelope { + /// Single-use nonce bound to this request — delivered to the desktop and + /// consumed exactly once when the owner makes a decision. + pub request_nonce: String, + /// `true` when the owner can take action (policy=ask, preflight passed, + /// owner/observer available). `false` for auto-deny / fail-closed paths. + pub actionable: bool, + /// Human-readable reason when `actionable` is `false`. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + /// Wire card-expiry (unix seconds) for an actionable card — the same value + /// stored in the kind-9 sentinel. The desktop bounds its + /// retransmit-until-acked loop by this deadline, so a decision published + /// while the harness socket is down keeps being resent until the card + /// expires (never past it). `None` on non-actionable / already-resolved + /// frames, where no owner decision is awaited. + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option<u64>, +} + /// Handle used by the harness to publish local observer events. #[derive(Clone)] pub struct ObserverHandle { @@ -54,7 +81,7 @@ fn new_observer_handle() -> ObserverHandle { } /// Event delivered through the in-process observer bus. -#[derive(Clone, Serialize)] +#[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ObserverEvent { /// Monotonic process-local sequence number. @@ -74,6 +101,12 @@ pub struct ObserverEvent { /// RFC3339 timestamp at which the current turn began, when known. #[serde(skip_serializing_if = "Option::is_none")] pub started_at: Option<String>, + /// Authorization envelope — present only on permission `acp_read` / + /// `acp_write` frames, and on the observer-only `permission_terminal` frame + /// (which carries `reason = "uncertain"` and is never sent on the ACP wire). + /// `None` on all other event kinds. + #[serde(skip_serializing_if = "Option::is_none")] + pub authorization: Option<AuthorizationEnvelope>, /// Raw or semantic event payload. pub payload: serde_json::Value, } @@ -107,6 +140,31 @@ impl ObserverHandle { agent_index: Option<usize>, context: &ObserverContext, payload: serde_json::Value, + ) { + self.emit_inner(kind, agent_index, context, None, payload); + } + + /// Emit a local observer event with an authorization envelope. + /// + /// Used for permission `acp_read` and `acp_write` frames. + pub fn emit_authorized( + &self, + kind: impl Into<String>, + agent_index: Option<usize>, + context: &ObserverContext, + authorization: AuthorizationEnvelope, + payload: serde_json::Value, + ) { + self.emit_inner(kind, agent_index, context, Some(authorization), payload); + } + + fn emit_inner( + &self, + kind: impl Into<String>, + agent_index: Option<usize>, + context: &ObserverContext, + authorization: Option<AuthorizationEnvelope>, + payload: serde_json::Value, ) { let event = ObserverEvent { seq: self.inner.seq.fetch_add(1, Ordering::Relaxed), @@ -117,6 +175,7 @@ impl ObserverHandle { session_id: context.session_id.clone(), turn_id: context.turn_id.clone(), started_at: context.started_at.clone(), + authorization, payload, }; diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 4d20e30ee23..f34b5a70cb0 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -34,7 +34,9 @@ use crate::acp::{ model_in_catalog, resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod, StopReason, SystemPromptTransport, }; -use crate::config::{compose_scoped_session_title, DedupMode, PermissionMode}; +use crate::config::{ + compose_scoped_session_title, DedupMode, PermissionMode, ResolvedPermissionConfig, +}; use crate::observer; use crate::prompt_project::{pick_authoritative_project_home, PromptProjectInfo}; use crate::queue::{ @@ -79,6 +81,13 @@ pub struct TaskMeta { /// tasks only — all prompt tasks install a steer channel regardless /// of the agent's name. pub steer_tx: Option<tokio::sync::mpsc::Sender<SteerRequest>>, + /// Permission decision channel — delivers `permission_decision` control + /// frames from the observer dispatch loop into the read loop's decision + /// arm. `None` until the first `ask`-policy permission request arrives + /// (installed per-session by the pool dispatch path). Cloned from the + /// sender end of the channel installed on `AcpClient` via + /// `install_permission_decision_rx`. + pub permission_decision_tx: Option<tokio::sync::mpsc::Sender<crate::acp::PermissionDecision>>, /// Successful non-cancelling steers acknowledged while this task owned the /// live session. The session ID prevents a late ack from contaminating a /// replacement session after task return. @@ -330,6 +339,15 @@ pub struct AgentPool { result_rx: mpsc::UnboundedReceiver<PromptResult>, pub join_set: JoinSet<()>, task_map: HashMap<tokio::task::Id, TaskMeta>, + /// Nonces of permission decisions already forwarded to a read loop, with the + /// instant each was recorded. The desktop retransmits a decision until it + /// sees a `control_result`; a copy that arrives after the read loop applied + /// the decision and its task ended would otherwise get `no_active_turn` / + /// `channel_closed` and flip the resolved card to failed. Recording the + /// nonce on first delivery lets [`Self::was_recently_decided`] recognize + /// such a late duplicate and ack it success-shaped instead. Pruned to + /// [`DECIDED_NONCE_RETENTION`] (≥ the card's max expiry) on every write. + recently_decided: HashMap<String, tokio::time::Instant>, /// Authoritative directory of which worker most recently owned each session /// scope's provider session. Survives while a worker is checked out (its /// `SessionState` is invisible to the pool then), so a busy owner does not @@ -339,6 +357,11 @@ pub struct AgentPool { session_owners: HashMap<SessionScope, usize>, } +/// Retention for [`AgentPool::recently_decided`]. Matches the maximum card +/// expiry (`PERMISSION_ASK_TIMEOUT_SECS`) so a nonce stays recognized for as +/// long as the desktop could still be retransmitting it, then is reclaimed. +const DECIDED_NONCE_RETENTION: Duration = Duration::from_secs(300); + /// Result returned by a completed prompt task. pub struct PromptResult { pub agent: OwnedAgent, @@ -785,8 +808,8 @@ pub struct PromptContext { pub context_message_limit: u32, /// Max turns per session before proactive rotation. 0 = disabled. pub max_turns_per_session: u32, - /// Permission mode to apply after session creation. `Default` = skip. - pub permission_mode: PermissionMode, + /// Resolved permission configuration — policy, effective ACP mode, and how to transmit. + pub permission_config: ResolvedPermissionConfig, /// Agent identity — used to derive the NIP-AE conversation key at /// session creation for core injection. pub agent_keys: nostr::Keys, @@ -806,6 +829,11 @@ pub struct PromptContext { /// the desktop keys per (agent, relay) pair, e.g. `session_config_captured`, /// mirroring the `managed_agent_runtime_lifecycle` frames. pub relay_url: String, + /// Publisher for kind-9 sentinel cards and kind-40003 edits. + /// When set, `run_prompt_task` wires it into `AcpClient` so permission + /// cards appear in the channel thread. `None` disables sentinel publishing + /// (observer feed path remains). + pub relay_event_publisher: Option<crate::relay::RelayEventPublisher>, } impl AgentPool { @@ -823,10 +851,31 @@ impl AgentPool { result_rx, join_set: JoinSet::new(), task_map: HashMap::new(), + recently_decided: HashMap::new(), session_owners: HashMap::new(), } } + /// Record a permission-decision nonce as delivered to a read loop and prune + /// entries older than [`DECIDED_NONCE_RETENTION`]. Called when a decision is + /// first forwarded so a later retransmit of the same nonce is recognized. + pub fn record_permission_decision(&mut self, nonce: &str) { + let now = tokio::time::Instant::now(); + self.recently_decided + .retain(|_, at| now.duration_since(*at) < DECIDED_NONCE_RETENTION); + self.recently_decided.insert(nonce.to_string(), now); + } + + /// Whether `nonce` was recently forwarded to a read loop and is still within + /// the retention window. A late retransmit that matches is a duplicate the + /// harness has already forwarded — the caller acks it success-shaped rather + /// than failing the resolved card. + pub fn was_recently_decided(&self, nonce: &str) -> bool { + self.recently_decided.get(nonce).is_some_and(|at| { + tokio::time::Instant::now().duration_since(*at) < DECIDED_NONCE_RETENTION + }) + } + /// Record which worker is handling `scope` so a later dispatch can detect a /// busy owner and avoid opening a duplicate session on another worker. pub fn record_scope_owner(&mut self, scope: SessionScope, agent_index: usize) { @@ -1515,14 +1564,20 @@ async fn create_session_and_apply_model( }), ); - // Apply permission mode if not the agent's built-in default AND the agent - // advertises the requested mode in session/new. Agents that don't support - // the mode (e.g., goose crashes on unrecognized set_config_option values) - // are safely skipped — the harness auto-approves via handle_permission_request. - if !ctx.permission_mode.is_default() - && agent_supports_mode(&resp.raw, ctx.permission_mode.as_wire_str()) + // Apply permission mode whenever the agent advertises it (including `default`). + // The `transmit_mode` flag handles any future cases where transmission should be skipped. + if ctx.permission_config.transmit_mode + && agent_supports_mode( + &resp.raw, + ctx.permission_config.effective_mode.as_wire_str(), + ) { - apply_permission_mode(&mut agent.acp, &resp.session_id, &ctx.permission_mode).await?; + apply_permission_mode( + &mut agent.acp, + &resp.session_id, + &ctx.permission_config.effective_mode, + ) + .await?; } Ok(resp.session_id) @@ -1769,11 +1824,7 @@ fn patch_config_option_current_value( } } -/// Set the session permission mode via `session/set_config_option`. -/// -/// Non-fatal for most errors: logs and proceeds. The agent falls back -/// to its default permission mode (`"default"`), which still works via -/// Check if the agent's `session/new` response advertises a given mode ID +/// Check whether the agent's `session/new` response advertises a given mode ID /// in `result.modes.availableModes[].id`. Returns `false` if the modes /// field is absent or the mode isn't listed. fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str) -> bool { @@ -1789,7 +1840,11 @@ fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str) .unwrap_or(false) } -/// per-tool auto-approval in `handle_permission_request`. +/// Set the session permission mode via `session/set_config_option`. +/// +/// Non-fatal for most errors: logs and proceeds. The agent falls back to its +/// default mode, and any interactive permission request is rejected by +/// `handle_permission_request`. /// /// **Fatal exception:** if the agent process exits (e.g., goose crashes on /// unrecognized methods), returns `Err(AgentExited)` so the caller can respawn. @@ -1829,7 +1884,7 @@ async fn apply_permission_mode( Ok(Err(e)) => { tracing::warn!( target: "pool::permission", - "failed to set permission mode {wire:?}: {e} — falling back to per-tool auto-approval" + "failed to set permission mode {wire:?}: {e} — falling back to per-tool rejection" ); } Err(_) => { @@ -2052,6 +2107,44 @@ pub async fn run_prompt_task( turn_id.clone(), turn_started_at.clone(), )); + + // Wire permission configuration and owner-knowledge into the ACP client so + // `handle_permission_request` can evaluate the ask availability gate. These + // values come from `PromptContext` (resolved once at startup from CLI args and + // desktop-injected env vars) and are idempotent to re-apply across turns. + agent + .acp + .set_permission_config(ctx.permission_config.clone()); + agent + .acp + .set_owner_pubkey_known(ctx.agent_owner_pubkey.is_some()); + + // Wire sentinel card publisher, agent signing keys, owner pubkey, and + // per-turn context for D7-final admission and kind-9/40003 publishing. + if let Some(publisher) = ctx.relay_event_publisher.clone() { + agent + .acp + .set_relay_publisher(publisher, ctx.agent_keys.clone()); + } + agent + .acp + .set_agent_owner_pubkey_hex(ctx.agent_owner_pubkey.as_ref().map(|pk| pk.to_hex())); + // D7-final: record the turn initiator from the first event in the batch. + let turn_initiator = batch + .as_ref() + .and_then(|b| b.events.first()) + .map(|be| be.event.pubkey); + agent.acp.set_turn_initiator_pubkey(turn_initiator); + // Sentinel routing: channel UUID and reply anchor from batch. + let batch_channel_id = batch.as_ref().map(|b| b.channel_id); + let thread_reply_event_id = batch + .as_ref() + .and_then(|b| b.events.first()) + .map(|be| be.event.id.to_hex()); + agent + .acp + .set_turn_channel_context(batch_channel_id, thread_reply_event_id); + let triggering_event_ids: Vec<String> = batch .as_ref() .map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect()) @@ -5147,6 +5240,60 @@ mod tests { } } + #[tokio::test(start_paused = true)] + async fn recently_decided_recognizes_a_nonce_within_retention() { + let mut pool = AgentPool::from_slots(vec![None]); + assert!( + !pool.was_recently_decided("n1"), + "unknown nonce is not recently decided" + ); + pool.record_permission_decision("n1"); + assert!( + pool.was_recently_decided("n1"), + "just-recorded nonce is recognized" + ); + assert!( + !pool.was_recently_decided("n2"), + "a different nonce is not recognized" + ); + } + + #[tokio::test(start_paused = true)] + async fn recently_decided_expires_after_retention_window() { + let mut pool = AgentPool::from_slots(vec![None]); + pool.record_permission_decision("n1"); + // Just inside the window: still recognized. + tokio::time::advance(DECIDED_NONCE_RETENTION - Duration::from_secs(1)).await; + assert!( + pool.was_recently_decided("n1"), + "nonce inside retention is still recognized" + ); + // Past the window: no longer recognized (bounds the set's growth and + // stops acking retransmits for cards that have long since expired). + tokio::time::advance(Duration::from_secs(2)).await; + assert!( + !pool.was_recently_decided("n1"), + "nonce past retention is forgotten" + ); + } + + #[tokio::test(start_paused = true)] + async fn recording_prunes_entries_past_retention() { + let mut pool = AgentPool::from_slots(vec![None]); + pool.record_permission_decision("old"); + tokio::time::advance(DECIDED_NONCE_RETENTION + Duration::from_secs(1)).await; + // Recording a new nonce prunes the stale one so the map cannot grow + // without bound over a long-lived process. + pool.record_permission_decision("new"); + assert!(!pool.was_recently_decided("old"), "stale entry was pruned"); + assert!(pool.was_recently_decided("new"), "fresh entry retained"); + assert_eq!( + pool.recently_decided.len(), + 1, + "only the fresh entry remains" + ); + } + #[test] fn delivery_receipt_line_sorts_event_ids() { let channel_id = Uuid::nil(); @@ -8681,12 +8828,17 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" ), context_message_limit: 0, max_turns_per_session: 0, - permission_mode: PermissionMode::Default, + permission_config: ResolvedPermissionConfig::resolve( + crate::config::PermissionPolicy::Reject, + None, + ) + .expect("test config"), agent_keys: agent_keys.clone(), agent_owner_pubkey: owner_pubkey, memory_enabled: false, harness_name: "goose".to_string(), relay_url: "ws://127.0.0.1:3000".to_string(), + relay_event_publisher: None, } } diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index e4e41b4660d..470bd63d107 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -123,7 +123,7 @@ use buzz_core::kind::{ use futures_util::{SinkExt, StreamExt}; use nostr::{Event, EventBuilder, Keys, Kind, RelayUrl, Tag}; use serde_json::{json, Value}; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; use tokio::time::timeout; use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; use tracing::{debug, info, warn}; @@ -660,6 +660,22 @@ const MEMBERSHIP_NOTIF_SUB_ID: &str = "membership-notif"; /// Subscription ID for encrypted owner-to-agent observer control frames. const OBSERVER_CONTROL_SUB_ID: &str = "agent-observer-control"; +/// Outcome of a relay-acknowledged event publish. +/// +/// Delivered to the caller through the oneshot sender registered by +/// `PublishEventAcked`. The background task resolves the waiter exactly once +/// per event ID — either on `OK`, on socket failure, or on disconnect. +#[derive(Debug)] +#[allow(dead_code)] +pub enum AckOutcome { + /// Relay accepted the event (`OK accepted=true`). + Accepted, + /// Relay rejected the event (`OK accepted=false`). + Rejected { message: String }, + /// Connection was lost before an `OK` arrived — delivery is uncertain. + Uncertain, +} + /// Commands sent from `HarnessRelay` to the background WebSocket task. enum RelayCommand { /// Subscribe to a channel (sends a NIP-01 REQ) with the given filter. @@ -680,6 +696,27 @@ enum RelayCommand { SubscribeObserverControls, /// Publish a signed event to the relay (for typing indicators, etc.). PublishEvent { event: Box<Event> }, + /// Publish a signed event to the relay and wait for relay `OK`. + /// + /// The ack sender is resolved exactly once: + /// - `AckOutcome::Accepted` on `OK accepted=true` + /// - `AckOutcome::Rejected` on `OK accepted=false` + /// - `AckOutcome::Uncertain` on socket failure or disconnect + /// + /// The waiter is registered in `BgState::ack_waiters` keyed by event ID + /// **before** the EVENT frame is sent — this is required by the spec. + /// + /// `deadline` is the per-waiter expiry instant (`min(fixed_publish_timeout, + /// expiresAt)`). The background task enforces this deadline itself — sweeping + /// the waiter entry and sending `Uncertain` when it fires — so the map is + /// provably empty on every path without requiring the caller to participate. + #[allow(dead_code)] + PublishEventAcked { + event: Box<Event>, + ack_tx: oneshot::Sender<AckOutcome>, + /// Per-waiter expiry enforced by the background task. + deadline: tokio::time::Instant, + }, /// Floor `since` for membership notification replay; events before startup are never re-delivered. SetStartupWatermark { ts: u64 }, } @@ -714,7 +751,9 @@ pub struct HarnessRelay { bg_handle: Option<tokio::task::JoinHandle<()>>, } -/// Cloneable publisher handle for signed events on the relay background socket. +/// Thin handle for publishing signed events from outside the relay background task. +/// +/// Cheaply cloneable — the underlying `mpsc::Sender` is reference-counted. #[derive(Clone)] pub struct RelayEventPublisher { cmd_tx: mpsc::Sender<RelayCommand>, @@ -731,24 +770,242 @@ impl RelayEventPublisher { .map_err(|_| RelayError::ConnectionClosed) } + /// Register an ACK waiter for a signed event and return the receiver + /// **without** awaiting the outcome. + /// + /// The background task sends the EVENT frame and resolves the waiter + /// exactly once (accepted, rejected, or uncertain). The caller owns the + /// returned [`oneshot::Receiver`] and must poll or await it — typically + /// in a `tokio::select!` arm alongside other loop futures. + /// + /// Registration-before-send is guaranteed: the background task inserts the + /// waiter into `ack_waiters` before writing the EVENT frame. + /// + /// `deadline` is the per-waiter expiry instant (`min(fixed_publish_timeout, + /// expiresAt)`). The background task enforces this deadline itself so the + /// `ack_waiters` map is provably empty on every path. + /// + /// # Errors + /// Returns `RelayError::ConnectionClosed` if the command channel is closed. + pub async fn register_publish_ack( + &self, + event: Event, + deadline: tokio::time::Instant, + ) -> Result<oneshot::Receiver<AckOutcome>, RelayError> { + let (ack_tx, ack_rx) = oneshot::channel(); + self.cmd_tx + .send(RelayCommand::PublishEventAcked { + event: Box::new(event), + ack_tx, + deadline, + }) + .await + .map_err(|_| RelayError::ConnectionClosed)?; + Ok(ack_rx) + } + /// Test-only publisher pair: published events are forwarded to the /// returned receiver instead of a live relay socket. #[cfg(test)] + #[allow(clippy::collapsible_match)] pub(crate) fn test_pair() -> (Self, mpsc::Receiver<Event>) { let (cmd_tx, mut cmd_rx) = mpsc::channel::<RelayCommand>(64); let (event_tx, event_rx) = mpsc::channel(64); tokio::spawn(async move { while let Some(cmd) = cmd_rx.recv().await { - if let RelayCommand::PublishEvent { event } = cmd { - if event_tx.send(*event).await.is_err() { - break; + match cmd { + RelayCommand::PublishEvent { event } => { + if event_tx.send(*event).await.is_err() { + break; + } + } + RelayCommand::PublishEventAcked { event, ack_tx, .. } => { + let _ = event_tx.send(*event).await; + let _ = ack_tx.send(AckOutcome::Accepted); } + _ => {} } } }); (Self { cmd_tx }, event_rx) } -} + + /// Test publisher that rejects every `PublishEventAcked` command with + /// `AckOutcome::Rejected`. Used to test the rejected-ACK deny path. + #[cfg(test)] + #[allow(clippy::collapsible_match)] + pub(crate) fn test_pair_rejecting() -> (Self, mpsc::Receiver<Event>) { + let (cmd_tx, mut cmd_rx) = mpsc::channel::<RelayCommand>(64); + let (event_tx, event_rx) = mpsc::channel(64); + tokio::spawn(async move { + while let Some(cmd) = cmd_rx.recv().await { + match cmd { + RelayCommand::PublishEvent { event } => { + if event_tx.send(*event).await.is_err() { + break; + } + } + RelayCommand::PublishEventAcked { event, ack_tx, .. } => { + let _ = event_tx.send(*event).await; + let _ = ack_tx.send(AckOutcome::Rejected { + message: "rate-limited".to_string(), + }); + } + _ => {} + } + } + }); + (Self { cmd_tx }, event_rx) + } + + /// Test publisher that never sends an ACK for `PublishEventAcked` commands + /// (simulates a relay that accepts the command but never responds with OK). + /// Used to test the timeout path. + #[cfg(test)] + #[allow(clippy::collapsible_match)] + pub(crate) fn test_pair_silent() -> (Self, mpsc::Receiver<Event>) { + let (cmd_tx, mut cmd_rx) = mpsc::channel::<RelayCommand>(64); + let (event_tx, event_rx) = mpsc::channel(64); + tokio::spawn(async move { + while let Some(cmd) = cmd_rx.recv().await { + match cmd { + RelayCommand::PublishEvent { event } => { + if event_tx.send(*event).await.is_err() { + break; + } + } + RelayCommand::PublishEventAcked { + event, ack_tx: _, .. + } => { + // Intentionally drop ack_tx without sending — simulates + // a relay that never confirms the event. + let _ = event_tx.send(*event).await; + // ack_tx is dropped here → ack_rx.await returns Err(RecvError) → Uncertain + } + _ => {} + } + } + }); + (Self { cmd_tx }, event_rx) + } + + /// Test publisher that simulates a relay which is disconnected when the + /// resolved kind-40003 edit is first published, then reconnects. + /// + /// - kind-9 sentinel publishes (`PublishEventAcked`) are always `Accepted` + /// so the permission lifecycle proceeds normally to `finish_permission`. + /// - the first `resolved_uncertain_before_accept` kind-40003 publishes + /// resolve as `Uncertain` (socket down), then every later one is + /// `Accepted` (reconnected). + /// + /// Every published event — including each retransmission attempt — is + /// forwarded to the returned receiver so a test can count attempts and + /// assert the *same* signed event id is retransmitted. + #[cfg(test)] + #[allow(clippy::collapsible_match)] + pub(crate) fn test_pair_resolved_reconnect( + resolved_uncertain_before_accept: usize, + ) -> (Self, mpsc::Receiver<Event>) { + let (cmd_tx, mut cmd_rx) = mpsc::channel::<RelayCommand>(64); + let (event_tx, event_rx) = mpsc::channel(64); + tokio::spawn(async move { + let mut resolved_uncertain_remaining = resolved_uncertain_before_accept; + while let Some(cmd) = cmd_rx.recv().await { + match cmd { + RelayCommand::PublishEvent { event } => { + if event_tx.send(*event).await.is_err() { + break; + } + } + RelayCommand::PublishEventAcked { event, ack_tx, .. } => { + let is_resolved = event.kind.as_u16() == 40003; + let _ = event_tx.send(*event).await; + if is_resolved && resolved_uncertain_remaining > 0 { + resolved_uncertain_remaining -= 1; + let _ = ack_tx.send(AckOutcome::Uncertain); + } else { + let _ = ack_tx.send(AckOutcome::Accepted); + } + } + _ => {} + } + } + }); + (Self { cmd_tx }, event_rx) + } + + /// Test publisher that simulates a relay whose `OK` is lost on a connected + /// socket: the EVENT frame is written, but the acknowledgement never arrives + /// and the waiter is only resolved when its own per-waiter deadline sweeps. + /// + /// - kind-9 sentinel publishes are always `Accepted` immediately so the + /// permission lifecycle proceeds to `finish_permission`. + /// - the first `resolved_lost_before_accept` kind-40003 publishes forward the + /// event but withhold the ACK until the supplied `deadline`, then resolve + /// `Uncertain` — exactly what the relay background task does when it sweeps + /// a waiter whose `OK` never came back. Every later one is `Accepted`. + /// + /// This distinguishes the per-attempt-deadline seam from + /// [`Self::test_pair_resolved_reconnect`]: here the socket is *connected* and + /// the event reaches the relay, so only a bounded per-attempt ACK deadline — + /// not the card expiry — sweeps the stuck waiter in time to retransmit. + #[cfg(test)] + #[allow(clippy::collapsible_match)] + pub(crate) fn test_pair_resolved_lost_ok( + resolved_lost_before_accept: usize, + ) -> (Self, mpsc::Receiver<Event>) { + let (cmd_tx, mut cmd_rx) = mpsc::channel::<RelayCommand>(64); + let (event_tx, event_rx) = mpsc::channel(64); + tokio::spawn(async move { + let mut lost_remaining = resolved_lost_before_accept; + while let Some(cmd) = cmd_rx.recv().await { + match cmd { + RelayCommand::PublishEvent { event } => { + if event_tx.send(*event).await.is_err() { + break; + } + } + RelayCommand::PublishEventAcked { + event, + ack_tx, + deadline, + .. + } => { + let is_resolved = event.kind.as_u16() == 40003; + let _ = event_tx.send(*event).await; + if is_resolved && lost_remaining > 0 { + lost_remaining -= 1; + // Withhold the ACK until the caller's per-waiter + // deadline, then sweep it Uncertain — the connected + // socket wrote the EVENT but the OK was lost. + tokio::spawn(async move { + tokio::time::sleep_until(deadline).await; + let _ = ack_tx.send(AckOutcome::Uncertain); + }); + } else { + let _ = ack_tx.send(AckOutcome::Accepted); + } + } + _ => {} + } + } + }); + (Self { cmd_tx }, event_rx) + } + + /// Test publisher whose command channel is dead on arrival (receiver dropped + /// before the first send). Any [`RelayCommand`] sent through this publisher + /// returns `Err(SendError)`, which the production code maps to + /// [`RelayError::ConnectionClosed`] — the same error path as a real socket failure. + /// + /// Used by `sentinel_ack_socket_failure_denies_synchronously_map_empty`. + #[cfg(test)] + pub(crate) fn test_pair_dead() -> Self { + let (cmd_tx, cmd_rx) = mpsc::channel::<RelayCommand>(1); + drop(cmd_rx); // close the channel immediately + Self { cmd_tx } + } +} // end impl RelayEventPublisher impl HarnessRelay { /// Connect to relay and authenticate via NIP-42. @@ -1208,6 +1465,14 @@ struct BgState { /// Frames evicted from the bounded pending/in-flight observer buffers since /// summary log. Makes overflow loss visible instead of silent. gated_observer_dropped: u64, + /// Pending `OK` acknowledgement waiters for `PublishEventAcked` commands. + /// + /// Keyed by event ID (hex). Registered before the EVENT frame is sent; + /// resolved exactly once on `OK`, socket failure, disconnect, or per-waiter + /// deadline expiry. The deadline (`min(fixed_publish_timeout, expiresAt)`) + /// is stored alongside the sender so the background task can sweep expired + /// waiters without relying on the caller side for cleanup. + ack_waiters: HashMap<String, (oneshot::Sender<AckOutcome>, tokio::time::Instant)>, /// Channels whose REQ failed during `resubscribe_after_reconnect`. /// /// A single failed channel REQ is parked here instead of aborting the whole @@ -1247,6 +1512,7 @@ impl BgState { gated_observer_pending: VecDeque::new(), observer_in_flight: VecDeque::new(), gated_observer_dropped: 0, + ack_waiters: HashMap::new(), resubscribe_retry: HashSet::new(), connection_generation: 0, backoff_step: 0, @@ -1410,6 +1676,51 @@ impl BgState { } } + /// Drain all pending `OK` acknowledgement waiters with `Uncertain`. + /// + /// Called on disconnect/reconnect so callers are not left waiting + /// indefinitely. A dropped sender (receiver already gone) is silently + /// discarded. + fn drain_ack_waiters_uncertain(&mut self) { + for (event_id, (ack_tx, _deadline)) in self.ack_waiters.drain() { + debug!("ack waiter for event {event_id} drained as uncertain (disconnect)"); + let _ = ack_tx.send(AckOutcome::Uncertain); + } + } + + /// Return the earliest per-waiter deadline, or `None` if there are no waiters. + /// + /// Used by the main event loop to arm a select arm that fires when the + /// soonest waiter deadline expires, ensuring the background task — not the + /// caller — owns expiry. + fn next_ack_deadline(&self) -> Option<tokio::time::Instant> { + self.ack_waiters + .values() + .map(|(_, deadline)| *deadline) + .min() + } + + /// Sweep all waiters whose deadline has passed, resolving each with `Uncertain`. + /// + /// Called from the main event loop's deadline select arm. After this call + /// every expired entry is removed from the map and its sender has been + /// consumed, so the map shrinks monotonically toward empty. + fn sweep_expired_ack_waiters(&mut self) { + let now = tokio::time::Instant::now(); + let expired: Vec<String> = self + .ack_waiters + .iter() + .filter(|(_, (_, deadline))| now >= *deadline) + .map(|(event_id, _)| event_id.clone()) + .collect(); + for event_id in expired { + if let Some((ack_tx, _)) = self.ack_waiters.remove(&event_id) { + debug!("ack waiter for event {event_id} expired — resolved as uncertain"); + let _ = ack_tx.send(AckOutcome::Uncertain); + } + } + } + fn track_observer_in_flight(&mut self, event: Box<Event>) { if self.observer_in_flight.len() >= GATED_OBSERVER_QUEUE_CAP { self.observer_in_flight.pop_front(); @@ -1489,6 +1800,11 @@ fn apply_command_to_state(state: &mut BgState, cmd: RelayCommand) { } // Already reconnecting — redundant. RelayCommand::Reconnect => {} + // Acked publish while disconnected: the socket is gone so the event + // cannot be sent; resolve the waiter as uncertain immediately. + RelayCommand::PublishEventAcked { ack_tx, .. } => { + let _ = ack_tx.send(AckOutcome::Uncertain); + } // Callers MUST handle Shutdown before calling this function. RelayCommand::Shutdown => { debug_assert!( @@ -1513,6 +1829,11 @@ fn retain_failed_command_intent(state: &mut BgState, cmd: RelayCommand) { state.park_gated_observer_frame(event); } RelayCommand::PublishEvent { .. } => {} + // Acked publish arrived while disconnected — resolve the waiter as + // uncertain immediately so the caller is not left waiting. + RelayCommand::PublishEventAcked { ack_tx, .. } => { + let _ = ack_tx.send(AckOutcome::Uncertain); + } cmd => apply_command_to_state(state, cmd), } } @@ -1716,6 +2037,29 @@ async fn execute_connected_command( debug!("startup watermark set to {ts}"); true } + RelayCommand::PublishEventAcked { + event, + ack_tx, + deadline, + } => { + // Register the waiter BEFORE sending the EVENT frame — if the relay + // sends OK before our next select! tick, the waiter must already be + // present or the resolution is lost. + let event_id = event.id.to_hex(); + state + .ack_waiters + .insert(event_id.clone(), (ack_tx, deadline)); + if send_publish_event_frame(ws, &event).await { + true + } else { + // Send failed — drain the waiter we just registered so the + // caller is not left waiting indefinitely. + if let Some((ack_tx, _)) = state.ack_waiters.remove(&event_id) { + let _ = ack_tx.send(AckOutcome::Uncertain); + } + false + } + } // Control-flow commands — callers handle these before dispatching. RelayCommand::Shutdown | RelayCommand::Reconnect => { debug_assert!( @@ -2220,6 +2564,25 @@ async fn run_background_task( } => { drain_pacing_next = None; } + + // ACK-waiter deadline arm — the background task owns expiry. + // + // Fires at the earliest per-waiter deadline stored in + // `ack_waiters`. When it fires, `sweep_expired_ack_waiters` + // removes every expired entry and sends `Uncertain`, so the + // map is provably empty after every deadline regardless of + // whether the relay ever sends an OK. + // + // `pending()` when there are no waiters so this arm is + // always dormant in the common case and never blocks. + _ = async { + match state.next_ack_deadline() { + Some(t) => tokio::time::sleep_until(t).await, + None => std::future::pending::<()>().await, + } + } => { + state.sweep_expired_ack_waiters(); + } } // Reset backoff_step on a long healthy run so a subsequent brief drop @@ -2567,6 +2930,17 @@ async fn handle_ws_message( warn!("mid-session AUTH rejected (event {event_id}): {message} — triggering reconnect"); return false; } + // Resolve any ack waiter registered by PublishEventAcked. + if let Some((ack_tx, _)) = state.ack_waiters.remove(&event_id) { + let outcome = if accepted { + AckOutcome::Accepted + } else { + AckOutcome::Rejected { + message: message.clone(), + } + }; + let _ = ack_tx.send(outcome); + } // A refused EVENT is acknowledged on its own channel, so the // backoff must arm here — not only in the NOTICE arm. Without // this the harness would publish straight back into the same @@ -3127,6 +3501,9 @@ async fn try_autonomous_reconnect( auth_tag: Option<&nostr::Tag>, ) -> ReconnectOutcome { state.requeue_observer_in_flight(); + // Any pending ack waiters cannot be resolved on this socket — drain them + // as uncertain so callers are not left blocked across the reconnect. + state.drain_ack_waiters_uncertain(); // 5 attempts, up to 16s base backoff. Shares delay values with the // initial-connect retry in `HarnessRelay::connect()` (STARTUP_CONNECT_BACKOFFS) — // see its doc comment for how the two loops consume the array differently. @@ -3258,6 +3635,9 @@ async fn wait_for_reconnect( auth_tag: Option<&nostr::Tag>, ) -> ReconnectOutcome { state.requeue_observer_in_flight(); + // Any pending ack waiters cannot be resolved on this socket — drain them + // as uncertain so callers are not left blocked across the reconnect. + state.drain_ack_waiters_uncertain(); if !skip_drain { // Drain commands until we get Reconnect (or Shutdown). // Other commands update state so reconnect reflects latest intent. @@ -3496,20 +3876,38 @@ async fn send_membership_subscribe( } } -/// Send a NIP-01 REQ for owner-to-agent observer control frames. -async fn send_observer_control_subscribe(ws: &mut WsStream, agent_pubkey_hex: &str) -> bool { - let req = json!([ +/// Build the NIP-01 REQ for owner-to-agent observer control frames. +/// +/// The subscription looks back `OBSERVER_CONTROL_FRESHNESS_SECS` (the same +/// constant the admission window uses) rather than starting at `now`. Kind-24200 +/// control frames are ephemeral — the relay never stores them, so there is no +/// server-side replay — but the relay's live fan-out filters by +/// `created_at >= since`. A `since = now` sub therefore drops a decision that +/// reaches the relay moments after resubscribe but was signed moments before, +/// and drops a retransmitted copy whose `created_at` predates the reconnect. +/// The freshness-width lookback closes that race and lets the desktop's +/// retransmit land after a reconnect, while the admission window still rejects +/// anything genuinely stale. +fn build_observer_control_req(agent_pubkey_hex: &str, now_secs: u64) -> Value { + let since = now_secs.saturating_sub(crate::OBSERVER_CONTROL_FRESHNESS_SECS as u64); + json!([ "REQ", OBSERVER_CONTROL_SUB_ID, { "kinds": [KIND_AGENT_OBSERVER_FRAME], "#p": [agent_pubkey_hex], - "since": std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + "since": since, } - ]); + ]) +} + +/// Send a NIP-01 REQ for owner-to-agent observer control frames. +async fn send_observer_control_subscribe(ws: &mut WsStream, agent_pubkey_hex: &str) -> bool { + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let req = build_observer_control_req(agent_pubkey_hex, now_secs); match serde_json::to_string(&req) { Ok(text) => { @@ -4363,6 +4761,41 @@ mod tests { server.abort(); } + #[test] + fn observer_control_req_looks_back_one_freshness_window() { + // The subscription `since` must be `now - OBSERVER_CONTROL_FRESHNESS_SECS`, + // not `now`. A `since = now` sub drops a decision that reaches the relay + // moments after resubscribe but was signed just before, and drops a + // retransmitted copy whose `created_at` predates the reconnect. + let now: u64 = 1_700_000_000; + let req = build_observer_control_req("agentpk", now); + let since = req[2]["since"].as_u64().expect("since is a u64"); + assert_eq!( + since, + now - crate::OBSERVER_CONTROL_FRESHNESS_SECS as u64, + "since must look back exactly one freshness window" + ); + // Mutation guard: a `since = now` builder would fail this. + assert_ne!(since, now, "since must not start at now"); + // Filter shape is otherwise unchanged. + assert_eq!(req[0], "REQ"); + assert_eq!(req[1], OBSERVER_CONTROL_SUB_ID); + assert_eq!( + req[2]["kinds"], + serde_json::json!([KIND_AGENT_OBSERVER_FRAME]) + ); + assert_eq!(req[2]["#p"], serde_json::json!(["agentpk"])); + } + + #[test] + fn observer_control_req_saturates_at_epoch() { + // A clock reading below the freshness window must not underflow; the + // lookback saturates to 0 rather than wrapping to a huge `since` that + // would filter out every live frame. + let req = build_observer_control_req("agentpk", 10); + assert_eq!(req[2]["since"].as_u64(), Some(0)); + } + #[test] fn relay_ws_to_http_plain() { assert_eq!( @@ -6783,4 +7216,119 @@ mod tests { "channel_dropped_since must be cleared on successful drain" ); } + + // ── ACK-waiter cleanup contract (frozen named tests) ───────────────────── + + /// Disconnect drain: all registered ack waiters are resolved `Uncertain` + /// and the map is empty after `drain_ack_waiters_uncertain`. + #[test] + fn ack_waiter_disconnect_drain_all_uncertain_map_empty() { + let keys = nostr::Keys::generate(); + let mut state = BgState::new(); + + // Register three waiters with distinct event IDs. + let mut outcomes: Vec<tokio::sync::oneshot::Receiver<AckOutcome>> = Vec::new(); + for i in 1u64..=3 { + let event = make_test_event(&keys, i); + let event_id = event.id.to_hex(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + let (tx, rx) = tokio::sync::oneshot::channel(); + state.ack_waiters.insert(event_id, (tx, deadline)); + outcomes.push(rx); + } + assert_eq!(state.ack_waiters.len(), 3, "three waiters registered"); + + // Simulate disconnect: drain all waiters. + state.drain_ack_waiters_uncertain(); + + assert!( + state.ack_waiters.is_empty(), + "map must be empty after disconnect drain" + ); + + // Every receiver must have been resolved with Uncertain. + for mut rx in outcomes { + match rx.try_recv() { + Ok(AckOutcome::Uncertain) => {} + other => panic!("expected Uncertain, got {other:?}"), + } + } + } + + /// Late OK after cleanup: an OK arrives for an event ID that has already + /// been removed from ack_waiters (e.g., swept by deadline or disconnect). + /// The map lookup finds nothing — no panic, no insertion, map stays empty, + /// the late OK is silently discarded. + #[test] + fn ack_waiter_late_ok_after_cleanup_is_noop_map_stays_empty() { + let mut state = BgState::new(); + + // Simulate a waiter that was already removed (timeout/disconnect/sweep). + // The map is empty — no prior state. + assert!(state.ack_waiters.is_empty(), "map starts empty"); + + // Apply an OK for an event ID that has no registered waiter. + let phantom_event_id = "a".repeat(64); + let removed = state.ack_waiters.remove(&phantom_event_id); + assert!( + removed.is_none(), + "remove on absent key must return None — no panic, no side effect" + ); + assert!( + state.ack_waiters.is_empty(), + "map must remain empty after late OK for unknown event ID" + ); + } + + /// Sweep expired waiters: `sweep_expired_ack_waiters` removes only entries + /// whose deadline has passed, resolves them `Uncertain`, and leaves + /// non-expired entries intact. + #[tokio::test(start_paused = true)] + async fn ack_waiter_sweep_removes_expired_leaves_live() { + let keys = nostr::Keys::generate(); + let mut state = BgState::new(); + + // One waiter with a deadline 1s out. + let event_soon = make_test_event(&keys, 1); + let id_soon = event_soon.id.to_hex(); + let deadline_soon = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + let (tx_soon, mut rx_soon) = tokio::sync::oneshot::channel::<AckOutcome>(); + state + .ack_waiters + .insert(id_soon.clone(), (tx_soon, deadline_soon)); + + // One waiter with a deadline 10s out. + let event_later = make_test_event(&keys, 2); + let id_later = event_later.id.to_hex(); + let deadline_later = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + let (tx_later, mut rx_later) = tokio::sync::oneshot::channel::<AckOutcome>(); + state + .ack_waiters + .insert(id_later.clone(), (tx_later, deadline_later)); + + // Advance time past the first deadline but not the second. + tokio::time::advance(std::time::Duration::from_secs(2)).await; + + state.sweep_expired_ack_waiters(); + + // The soon-deadline waiter must be gone and resolved Uncertain. + assert!( + !state.ack_waiters.contains_key(&id_soon), + "expired waiter must be removed" + ); + match rx_soon.try_recv() { + Ok(AckOutcome::Uncertain) => {} + other => panic!("expired waiter must be resolved Uncertain, got {other:?}"), + } + + // The later-deadline waiter must still be present and unresolved. + assert!( + state.ack_waiters.contains_key(&id_later), + "live waiter must remain in map" + ); + assert!( + rx_later.try_recv().is_err(), + "live waiter must not be resolved yet" + ); + } } diff --git a/crates/buzz-acp/tests/fixtures/sentinel_pending.json b/crates/buzz-acp/tests/fixtures/sentinel_pending.json new file mode 100644 index 00000000000..af8b07bde39 --- /dev/null +++ b/crates/buzz-acp/tests/fixtures/sentinel_pending.json @@ -0,0 +1 @@ +{"description":"read a file","expiresAt":1700000300,"labels":{"opt-allow":"Allow once","opt-reject":"Reject"},"optionIds":["opt-allow","opt-reject"],"requestNonce":"test-nonce-fixture-abc123","sessionId":"sess-fixture-001","state":"pending","turnId":"turn-fixture-xyz","v":1} \ No newline at end of file diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json index fb291a7db54..86f98eae354 100644 --- a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json @@ -25,6 +25,7 @@ "BUZZ_ACP_DISPLAY_NAME": "worker", "BUZZ_ACP_LAZY_POOL": "true", "BUZZ_ACP_MODEL": "gpt-5", + "BUZZ_ACP_PERMISSION_POLICY": "ask", "BUZZ_ACP_RELAY_OBSERVER": "true", "BUZZ_ACP_SESSION_POLICY": "channel", "BUZZ_ACP_SESSION_TITLE": "worker", diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 093e925f18a..2c90ee90f07 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -69,6 +69,7 @@ fn goose_runtime() -> &'static KnownAcpRuntime { fn agent_record() -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), @@ -122,6 +123,8 @@ fn agent_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, agent_command_override: None, persona_source_version: None, @@ -131,6 +134,7 @@ fn agent_record() -> ManagedAgentRecord { fn persona_with_model(model: &str) -> AgentDefinition { AgentDefinition { + permission_policy: None, description: None, id: "persona-1".to_string(), display_name: "Persona".to_string(), diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 7c382a663b2..009ae289fbf 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -453,9 +453,8 @@ fn model_discovery_ignores_stale_record_for_linked_agent() { assert_eq!(discovery.model.as_deref(), Some("persona-model")); assert_eq!(discovery.provider.as_deref(), Some("anthropic")); - // And the discovery env comes from the descriptor, whose layering also - // resolves through the definition — the derived model env var must carry - // the persona's model, not the stale record snapshot. + // And the discovery env comes from the descriptor, whose layering also resolves + // through the definition — the derived model env var must carry the persona's model. assert_eq!( discovery.env.get("GOOSE_MODEL").map(String::as_str), Some("persona-model") diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs index 2ef014d7956..d4f2d0e5d3f 100644 --- a/desktop/src-tauri/src/commands/agent_models_update.rs +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -302,6 +302,11 @@ pub async fn update_managed_agent( record.respond_to_allowlist = prospective_allowlist; } + crate::managed_agents::permission_policy::apply_permission_policy_update( + record, + input.permission_policy, + )?; + // Effort + env_vars: applied together inside `apply_record_field_updates` to // enforce the ordering invariant (env_vars before effort column write) and // provide a directly-testable production seam. Effort persists inside the diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index eacef621fb3..c8eab80898e 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -273,13 +273,8 @@ pub(super) async fn start_local_agent_with_preflight( if record.backend != BackendKind::Local { return Err(format!("agent {pubkey} is no longer a local agent")); } - // Re-snapshot the persona onto the record at every spawn so the agent always - // starts with the current persona config (system_prompt, model, provider, - // runtime). This clears the "out of date" drift badge without requiring a - // delete+recreate. See `apply_persona_snapshot` for the precedence and - // env-override self-heal rules. - // Load personas once: used for snapshot application below and summary build - // at the end — avoids a second disk read for the same file in the same call. + // Re-snapshot the persona at every spawn (current persona config wins; clears + // drift badge). Load once — also used for summary build at the end. let personas = load_personas(app).unwrap_or_default(); if let Some(persona_id) = record.persona_id.clone() { match personas.iter().find(|p| p.id == persona_id) { @@ -677,7 +672,6 @@ pub async fn create_managed_agent( model: effective_model.clone(), provider: effective_provider.clone(), persona_source_version: snapshot_source_version, - // Provider agents are managed externally — force false. start_on_app_launch: if input.backend != BackendKind::Local { false } else { @@ -715,6 +709,9 @@ pub async fn create_managed_agent( definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, + // Instances carry no definition-scoped policy; the tier-2 resolver + // reads it live from the linked definition, not this snapshot. + definition_permission_policy: None, relay_mesh: if effective_provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) { @@ -724,6 +721,8 @@ pub async fn create_managed_agent( } else { relay_mesh.clone() }, + permission_policy: None, // inherits global default or built-in `ask` + applied_permission_policy: None, // populated on first successful remote deploy effort_level: None, }; @@ -1170,6 +1169,7 @@ mod deploy; pub(super) mod provider_access; mod provider_deploy; pub(super) use deploy::build_deploy_payload; +use deploy::extract_applied_permission_policy; #[cfg(test)] use deploy::{deploy_payload_json, DeployProjections}; #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/agents/provider_deploy.rs b/desktop/src-tauri/src/commands/agents/provider_deploy.rs index bb56a67eaa4..d462fa54247 100644 --- a/desktop/src-tauri/src/commands/agents/provider_deploy.rs +++ b/desktop/src-tauri/src/commands/agents/provider_deploy.rs @@ -99,6 +99,12 @@ pub(crate) async fn deploy_to_provider( .map_or_else(|| resolve_provider_binary(&provider_id), Ok)?; let deployed_agent_json = agent_json.clone(); + // Enforce the deploy-receipt invariant BEFORE invoking the provider: the + // applied policy is the byte-identical value build_deploy_payload wrote into + // the REBUILT payload, and a missing/unparseable one is a broken JSON-boundary + // invariant that must fail the deploy rather than silently stamp None and + // suppress the drift row. + let applied_policy = super::extract_applied_permission_policy(&agent_json)?; let config_clone = config.clone(); let deploy_result = tokio::task::spawn_blocking(move || provider_deploy(&bin_path, &agent_json, &config_clone)) @@ -116,7 +122,7 @@ pub(crate) async fn deploy_to_provider( .find(|r| r.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; - let result = apply_deploy_result(rec, deploy_result, &deployed_agent_json); + let result = apply_deploy_result(rec, deploy_result, &deployed_agent_json, applied_policy); save_managed_agents(app, &records)?; result } @@ -175,6 +181,7 @@ fn apply_deploy_result( record: &mut crate::managed_agents::ManagedAgentRecord, deploy_result: Result<String, String>, deployed_agent_json: &serde_json::Value, + applied_policy: crate::managed_agents::permission_policy::PermissionPolicy, ) -> Result<(), String> { match deploy_result { Ok(backend_agent_id) => { @@ -185,9 +192,16 @@ fn apply_deploy_result( record.last_started_at = Some(now_iso()); record.updated_at = now_iso(); record.last_error = None; + // Confirmed receipt: the exact policy sent in this deploy, so a + // later global-default flip is detectable as drift against the + // live worker. + record.applied_permission_policy = Some(applied_policy); Ok(()) } Err(error) => { + // Retain the last confirmed `applied_permission_policy`: the old + // worker may still be running it, and `last_error` records the + // failed new attempt. Clearing it would destroy known truth. record.last_error = Some(error.clone()); record.updated_at = now_iso(); Err(error) @@ -291,12 +305,44 @@ mod tests { &mut record, Ok("provider-agent".into()), &policy_payload("owner-only"), + crate::managed_agents::permission_policy::PermissionPolicy::Allow, ) .unwrap(); assert!(!record.provider_policy_pending); assert_eq!(record.backend_agent_id.as_deref(), Some("provider-agent")); assert_eq!(record.last_error, None); + assert_eq!( + record.applied_permission_policy, + Some(crate::managed_agents::permission_policy::PermissionPolicy::Allow), + "successful deploy stamps the exact policy sent as the confirmed receipt" + ); + } + + #[test] + fn successful_redeploy_updates_the_applied_receipt() { + let mut record = record(); + + apply_deploy_result( + &mut record, + Ok("provider-agent".into()), + &policy_payload("owner-only"), + crate::managed_agents::permission_policy::PermissionPolicy::Allow, + ) + .unwrap(); + apply_deploy_result( + &mut record, + Ok("provider-agent".into()), + &policy_payload("owner-only"), + crate::managed_agents::permission_policy::PermissionPolicy::Reject, + ) + .unwrap(); + + assert_eq!( + record.applied_permission_policy, + Some(crate::managed_agents::permission_policy::PermissionPolicy::Reject), + "redeploy must update the applied receipt to the new sent value" + ); } #[test] @@ -308,6 +354,7 @@ mod tests { &mut record, Ok("provider-agent".into()), &policy_payload("owner-only"), + crate::managed_agents::permission_policy::PermissionPolicy::Allow, ) .unwrap(); @@ -317,16 +364,34 @@ mod tests { #[test] fn failed_deploy_preserves_pending_policy() { let mut record = record(); + // Stamp a confirmed `applied_permission_policy` via a stale (mismatched) + // deploy so `provider_policy_pending` stays true going into the failure: + // the payload's `owner-only` audience differs from this record's `anyone`. + record.respond_to = crate::managed_agents::RespondTo::Anyone; + + apply_deploy_result( + &mut record, + Ok("provider-agent".into()), + &policy_payload("owner-only"), + crate::managed_agents::permission_policy::PermissionPolicy::Allow, + ) + .unwrap(); let error = apply_deploy_result( &mut record, Err("provider unavailable".into()), &policy_payload("owner-only"), + crate::managed_agents::permission_policy::PermissionPolicy::Reject, ) .expect_err("deployment should fail"); assert_eq!(error, "provider unavailable"); assert!(record.provider_policy_pending); assert_eq!(record.last_error.as_deref(), Some("provider unavailable")); + assert_eq!( + record.applied_permission_policy, + Some(crate::managed_agents::permission_policy::PermissionPolicy::Allow), + "failed redeploy must retain the last confirmed applied policy" + ); } } diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index de8ca8cc789..deb4ce1a052 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -46,6 +46,7 @@ pub(crate) fn resolve_deploy_model_provider( /// `descriptor.env` is the authoritative six-layer environment for ordinary /// values. Desktop-owned settings are reserved, stripped from that layer, and /// emitted through `policy_env` so local and provider launches agree. +#[allow(clippy::too_many_arguments)] fn build_launch_block_for_policy( record: &ManagedAgentRecord, descriptor: &crate::managed_agents::readiness::EffectiveHarnessDescriptor, @@ -54,6 +55,7 @@ fn build_launch_block_for_policy( effective_model: Option<&str>, owner_pubkey: &str, session_policy: crate::managed_agents::AcpSessionPolicy, + effective_permission_policy: Option<crate::managed_agents::permission_policy::PermissionPolicy>, ) -> serde_json::Value { use crate::managed_agents::{ known_acp_runtime, resolve_session_title, DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, @@ -121,6 +123,20 @@ fn build_launch_block_for_policy( policy_env.insert("BUZZ_ACP_TEAM_INSTRUCTIONS".into(), value); } + // Permission policy: use the caller-resolved value (per-agent → global → + // built-in), falling back to the built-in default if the caller did not + // provide one. Tests pass `None`; production callers pass the result of + // `resolve_effective_permission_policy(record, personas, global_config)`. + { + let policy = effective_permission_policy.unwrap_or_else( + crate::managed_agents::permission_policy::PermissionPolicy::desktop_default, + ); + policy_env.insert( + "BUZZ_ACP_PERMISSION_POLICY".into(), + policy.as_str().to_string(), + ); + } + // B2 remote parity: mirror the local A1 model authority. For a Claude // launch, ALWAYS strip BOTH BUZZ_ACP_MODEL and ANTHROPIC_MODEL from // launch.env — the resolved canonical model rides policy_env.ANTHROPIC_MODEL @@ -137,6 +153,7 @@ fn build_launch_block_for_policy( let is_claude = runtime.map(|r| r.id == "claude").unwrap_or(false); let strip_key = |k: &str| { k.eq_ignore_ascii_case(crate::managed_agents::ACP_SESSION_POLICY_ENV_VAR) + || k.eq_ignore_ascii_case("BUZZ_ACP_PERMISSION_POLICY") || (is_claude && (k.eq_ignore_ascii_case("BUZZ_ACP_MODEL") || k.eq_ignore_ascii_case("ANTHROPIC_MODEL"))) @@ -165,6 +182,7 @@ pub(super) fn build_launch_block( effective_prompt: Option<&str>, effective_model: Option<&str>, owner_pubkey: &str, + effective_permission_policy: Option<crate::managed_agents::permission_policy::PermissionPolicy>, ) -> serde_json::Value { build_launch_block_for_policy( record, @@ -174,6 +192,7 @@ pub(super) fn build_launch_block( effective_model, owner_pubkey, crate::managed_agents::AcpSessionPolicy::Channel, + effective_permission_policy, ) } @@ -216,6 +235,10 @@ pub(crate) fn build_deploy_payload<R: tauri::Runtime>( crate::managed_agents::resolve_effective_harness_descriptor(record, &personas, &global) .map_err(|error| crate::managed_agents::user_facing_harness_error(&error))?; let owner_pubkey = super::workspace_owner_hex(state)?; + let (effective_policy, _) = + crate::managed_agents::permission_policy::resolve_effective_permission_policy( + record, &personas, &global, + ); let launch = build_launch_block_for_policy( record, &descriptor, @@ -224,6 +247,7 @@ pub(crate) fn build_deploy_payload<R: tauri::Runtime>( effective.model.value.as_deref(), &owner_pubkey, crate::managed_agents::acp_session_policy(state), + Some(effective_policy), ); let effective_parallelism = @@ -284,6 +308,21 @@ pub(super) fn deploy_payload_json( }) } +/// Extract the effective permission policy from a deploy payload produced by +/// `build_deploy_payload`. The value is the byte-identical policy the deploy +/// path will stamp as the applied receipt; a missing or unparseable value is a +/// broken invariant on the JSON boundary. Callers must fail the deploy rather +/// than stamping a silent `None` that would suppress the drift row. +pub(super) fn extract_applied_permission_policy( + agent_json: &serde_json::Value, +) -> Result<crate::managed_agents::permission_policy::PermissionPolicy, String> { + let raw = agent_json["launch"]["policy_env"]["BUZZ_ACP_PERMISSION_POLICY"] + .as_str() + .ok_or("deploy payload is missing launch.policy_env.BUZZ_ACP_PERMISSION_POLICY")?; + serde_json::from_value(serde_json::Value::String(raw.to_string())) + .map_err(|_| format!("deploy payload has unrecognized permission policy {raw:?}")) +} + #[cfg(test)] mod tests { use super::*; @@ -335,6 +374,7 @@ mod tests { Some("prompt"), Some("model"), "owner-hex", + None, ); assert_eq!(launch["command"], "goose"); @@ -385,6 +425,7 @@ mod tests { None, "owner-hex", crate::managed_agents::AcpSessionPolicy::Thread, + None, ); assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_POLICY"], "thread"); @@ -413,6 +454,7 @@ mod tests { None, Some("claude-opus-4"), "owner-hex", + None, ); assert_eq!( launch["policy_env"]["ANTHROPIC_MODEL"], "claude-opus-4", @@ -449,6 +491,7 @@ mod tests { None, Some("claude-opus-4"), "owner-hex", + None, ); // Canonical model rides policy_env alone. @@ -482,7 +525,7 @@ mod tests { ("ANTHROPIC_MODEL".to_string(), "user-opus".to_string()), ]), }; - let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex", None); assert!(launch["policy_env"]["ANTHROPIC_MODEL"].is_null()); assert!(launch["policy_env"]["BUZZ_ACP_MODEL"].is_null()); @@ -507,8 +550,15 @@ mod tests { args: vec![], env: BTreeMap::from([("BUZZ_ACP_MODEL".to_string(), "user-model".to_string())]), }; - let launch = - build_launch_block(&record, &descriptor, &[], None, Some("model"), "owner-hex"); + let launch = build_launch_block( + &record, + &descriptor, + &[], + None, + Some("model"), + "owner-hex", + None, + ); // goose puts canonical in policy_env, and the user launch.env value is // preserved (later-wins is the intended goose behavior). @@ -530,7 +580,7 @@ mod tests { // The single projected effort key the descriptor resolver emits. env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "high".to_string())]), }; - let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex", None); assert_eq!( launch["env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", "the projected effort key must survive into launch.env" @@ -550,7 +600,7 @@ mod tests { args: vec![], env: BTreeMap::new(), }; - let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex", None); assert!( launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), "policy_env must NOT contain BUZZ_ACP_EFFORT_LEVEL when effort_level is None" @@ -582,7 +632,7 @@ mod tests { &Default::default(), ) .expect("claude descriptor resolves"); - let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex", None); // The projected canonical authority is the single effort value carried. assert_eq!( @@ -607,7 +657,7 @@ mod tests { args: vec![], env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]), }; - let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex", None); // No canonical — key must NOT appear in policy_env. assert!( @@ -635,7 +685,7 @@ mod tests { env: BTreeMap::new(), }; - let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex", None); assert_eq!( launch["policy_env"]["BUZZ_ACP_AGENTS"], @@ -657,7 +707,7 @@ mod tests { env: BTreeMap::new(), }; - let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex", None); assert_eq!( launch["policy_env"]["BUZZ_ACP_AGENTS"], "8", @@ -687,7 +737,7 @@ mod tests { }; let cap = crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM; - let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex", None); let effective_parallelism = crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); let payload = deploy_payload_json( @@ -732,7 +782,7 @@ mod tests { env: BTreeMap::new(), }; - let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex", None); let effective_parallelism = crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); let payload = deploy_payload_json( @@ -778,7 +828,7 @@ mod tests { }; let cap = crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM; - let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex", None); let effective_parallelism = crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); let payload = deploy_payload_json( diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index ef71321bedf..1aab77e0cad 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -9,6 +9,7 @@ fn bare_agent_record( use crate::managed_agents::{BackendKind, RespondTo}; use std::collections::BTreeMap; ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), @@ -61,6 +62,8 @@ fn bare_agent_record( catalog_source: None, team_catalog_source: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, @@ -71,6 +74,7 @@ fn bare_agent_record( fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition { use std::collections::BTreeMap; AgentDefinition { + permission_policy: None, description: None, id: id.to_string(), display_name: "Test Persona".to_string(), @@ -572,6 +576,7 @@ fn deploy_payload_matches_the_shared_full_launch_fixture() { None, Some("gpt-5"), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + None, ); let agent = deploy_payload_json( &record, diff --git a/desktop/src-tauri/src/commands/personas/create.rs b/desktop/src-tauri/src/commands/personas/create.rs index 2f19d1256e1..a7e3111ffc4 100644 --- a/desktop/src-tauri/src/commands/personas/create.rs +++ b/desktop/src-tauri/src/commands/personas/create.rs @@ -78,6 +78,7 @@ pub async fn create_persona( respond_to: None, respond_to_allowlist: Vec::new(), parallelism: None, + permission_policy: None, created_at: now.clone(), updated_at: now, }; diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 6a10a1f9ee2..a209daf42a4 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -17,6 +17,7 @@ fn make_agent( runtime_pid: Option<u32>, ) -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: pubkey.to_string(), name: "Test Agent".to_string(), @@ -69,6 +70,8 @@ fn make_agent( catalog_source: None, team_catalog_source: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs index 390e4850773..393815bc12a 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs @@ -26,6 +26,7 @@ const TEAM_ID: &str = "team-seam"; fn member(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { + permission_policy: None, id: id.to_string(), display_name: display_name.to_string(), description: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index e90df637314..fe62d4ee569 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -10,6 +10,7 @@ const UUID: &str = "11111111-2222-3333-4444-555555555555"; // sadscan:disable sq /// IS its UUID id. Carries env_vars + source_team that must survive a patch. fn local_in_app() -> AgentDefinition { AgentDefinition { + permission_policy: None, description: None, id: UUID.to_string(), display_name: "Local".to_string(), @@ -39,6 +40,7 @@ fn local_in_app() -> AgentDefinition { /// slug = Some(d-tag), empty env_vars, source_team None. fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition { AgentDefinition { + permission_policy: None, description: None, id: d_tag.to_string(), display_name: display_name.to_string(), @@ -163,6 +165,7 @@ const AGENT_PUBKEY: &str = "agentpubkeyhex00000000000000000000000000000000000000 /// event must NEVER be able to overwrite. fn local_agent() -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: AGENT_PUBKEY.to_string(), name: "Local Agent".to_string(), @@ -222,6 +225,8 @@ fn local_agent() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, } } diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index 3e4fabbcf5b..49c5336d0cc 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -310,6 +310,7 @@ mod tests { fn persona() -> AgentDefinition { AgentDefinition { + permission_policy: None, description: None, id: "catalog-reviewer".to_string(), display_name: "Catalog Reviewer".to_string(), diff --git a/desktop/src-tauri/src/commands/personas/sharing.rs b/desktop/src-tauri/src/commands/personas/sharing.rs index 331ec9d0d70..5b8fc84185c 100644 --- a/desktop/src-tauri/src/commands/personas/sharing.rs +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -146,6 +146,7 @@ mod tests { fn persona() -> AgentDefinition { AgentDefinition { + permission_policy: None, description: None, id: "catalog-reviewer".to_string(), display_name: "Catalog Reviewer".to_string(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index 55a64db59bc..2aebaff7e7f 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; fn make_definition(slug: &str) -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: String::new(), slug: Some(slug.to_string()), @@ -67,6 +68,8 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 041a0b91dc9..5453b22f39a 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -126,8 +126,7 @@ pub struct AgentSnapshotImportResult { /// /// This is the single authoritative selection path for all import-time /// allowlist and behavioral decisions. It is extracted as a pure, testable -/// function so that unit tests exercise the exact production logic rather -/// than a reconstruction of it. +/// function so unit tests exercise the exact production logic. /// /// # UI contract /// @@ -148,9 +147,8 @@ pub struct AgentSnapshotImportResult { /// and there is no coherent value to write. /// /// Non-allowlist + non-empty + Clear: preserve the source mode but empty the -/// list. Only allowlist-mode requires a mode downgrade on Clear, because -/// `allowlist` without entries is an invalid state. Non-allowlist modes -/// remain valid with an empty list. +/// list. Only allowlist-mode requires a mode downgrade on Clear, because +/// `allowlist` without entries is an invalid state. pub(crate) fn resolve_snapshot_import_behavior( raw_respond_to: Option<&str>, raw_allowlist: &[String], @@ -313,8 +311,7 @@ pub(crate) fn decode_snapshot_from_bytes( /// (the owner identity or the named local agent record). /// /// Returns the decoded manifest and whether it came from a locked envelope. -/// When neither endpoint exists, fails closed with the locked-card refusal — -/// never partial plaintext, never crypto details. +/// When neither endpoint exists, fails closed with the locked-card refusal. pub(crate) fn decode_snapshot_for_import( file_bytes: &[u8], owner_keys: Option<&nostr::Keys>, @@ -363,11 +360,10 @@ where /// envelope's two exact key endpoints; a card that cannot be unlocked fails /// with the locked-card refusal (shown directly to the user), never a /// partial preview. Identity-recovery mode is tolerated: owner keys are -/// simply unavailable, so only the agent-record endpoint can unlock. +/// unavailable, so only the agent-record endpoint can unlock. /// -/// Returns an `AgentSnapshotImportPreview` or a descriptive error. Errors -/// represent irrecoverable failures (corrupt / unsupported / locked-to- -/// someone-else file) and are shown directly to the user. +/// Returns an `AgentSnapshotImportPreview` or a descriptive error shown +/// directly to the user (corrupt / unsupported / locked-to-someone-else). #[tauri::command] pub async fn preview_agent_snapshot_import( file_bytes: Vec<u8>, @@ -446,8 +442,8 @@ pub(crate) fn build_agent_snapshot_import_preview( /// via `sync_managed_agent_profile`. /// 4. Memory — for each opted-in entry, build a fresh `kind:30174` event /// with `engram::build_event` under the new agent↔owner conversation -/// key and POST it to the relay. Failures are collected and returned as -/// `memory_errors`; the agent itself is already created. +/// key and POST it. Failures are collected as `memory_errors`; the +/// agent itself is already created. /// /// Importing the same file twice yields two distinct agents with different /// keypairs. No source identity material (pubkey, nsec, auth_tag, relay_url, @@ -459,8 +455,7 @@ pub async fn confirm_agent_snapshot_import( state: State<'_, AppState>, ) -> Result<AgentSnapshotImportResult, String> { // ── Phase 1: validate (no writes) ──────────────────────────────────────── - // Locked cards unlock only via this machine's exact key endpoints; - // anything else fails closed here, before key generation. + // Locked cards unlock only via this machine's exact key endpoints. let snapshot = { let owner_keys = state.signing_keys().ok(); let records = { @@ -585,6 +580,8 @@ pub async fn confirm_agent_snapshot_import( respond_to: respond_to_wire.clone(), respond_to_allowlist: minted.respond_to_allowlist.clone(), parallelism: minted_parallelism, + // Definition policy is a local grant, never in a shared snapshot. + permission_policy: None, created_at: now.clone(), updated_at: now.clone(), }; @@ -659,10 +656,13 @@ pub async fn confirm_agent_snapshot_import( definition_respond_to: respond_to_wire.clone(), definition_respond_to_allowlist: minted.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, + definition_permission_policy: None, relay_mesh: None, effort_level: None, runtime: snapshot.definition.runtime.clone(), name_pool: snapshot.definition.name_pool.clone(), + permission_policy: None, + applied_permission_policy: None, }; records.push(record.clone()); diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index abf4bef443d..b4d01506464 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -20,6 +20,7 @@ use std::collections::BTreeMap; /// persona_id. fn make_definition(slug: &str) -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: String::new(), slug: Some(slug.to_string()), @@ -76,6 +77,8 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, } } diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index 7aedcb25ef5..e0b100ecbfc 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -5,6 +5,7 @@ use super::*; fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: format!("pubkey-{name}"), name: name.to_string(), @@ -61,6 +62,8 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, } } diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 9c57ce12b53..2baa32181ec 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -141,6 +141,8 @@ fn definition_from_snapshot( respond_to, respond_to_allowlist: behavior.respond_to_allowlist, parallelism: behavior.parallelism, + // Local authority grant, never present in a shared team snapshot. + permission_policy: None, created_at: now.to_string(), updated_at: now.to_string(), }) @@ -623,7 +625,10 @@ pub async fn confirm_team_snapshot_import( definition_respond_to: respond_to_wire.clone(), definition_respond_to_allowlist: definition.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, + definition_permission_policy: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, runtime: member.definition.runtime.clone(), name_pool: member.definition.name_pool.clone(), diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index 13c7f6ae810..7eccfd687f1 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -55,6 +55,7 @@ fn snapshot(members: Vec<AgentSnapshot>) -> TeamSnapshot { fn team_export_round_trip_preserves_team_and_excludes_member_memory() { let definitions = vec![ AgentDefinition { + permission_policy: None, description: Some("A careful reviewer.".to_string()), id: "alice".to_string(), display_name: "Alice".to_string(), @@ -79,6 +80,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { updated_at: "now".to_string(), }, AgentDefinition { + permission_policy: None, description: None, id: "bob".to_string(), display_name: "Bob".to_string(), @@ -151,6 +153,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { #[test] fn team_export_with_instance_and_memory_level_uses_supplied_entries() { let definitions = vec![AgentDefinition { + permission_policy: None, description: None, id: "alice".to_string(), display_name: "Alice".to_string(), @@ -193,6 +196,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { // Build a fake instance record tied to this team+persona. let instance = ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: "a".repeat(64), name: "Alice".to_string(), @@ -247,6 +251,8 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, runtime: None, name_pool: vec![], diff --git a/desktop/src-tauri/src/commands/teams/adopt/apply.rs b/desktop/src-tauri/src/commands/teams/adopt/apply.rs index d52e71aeee1..e7986a01e4c 100644 --- a/desktop/src-tauri/src/commands/teams/adopt/apply.rs +++ b/desktop/src-tauri/src/commands/teams/adopt/apply.rs @@ -435,6 +435,7 @@ fn member_copy( now: &str, ) -> Result<AgentDefinition, String> { Ok(AgentDefinition { + permission_policy: None, id: Uuid::new_v4().to_string(), display_name: member.display_name.clone(), // Team catalog members carry no public description; an adopted copy diff --git a/desktop/src-tauri/src/commands/teams/adopt/tests.rs b/desktop/src-tauri/src/commands/teams/adopt/tests.rs index 2235bd0b2b9..ed26b569a40 100644 --- a/desktop/src-tauri/src/commands/teams/adopt/tests.rs +++ b/desktop/src-tauri/src/commands/teams/adopt/tests.rs @@ -21,6 +21,7 @@ const TEAM_D_TAG: &str = "team-alpha"; fn persona(id: &str, prompt: &str) -> AgentDefinition { AgentDefinition { + permission_policy: None, id: id.to_string(), display_name: id.to_string(), description: None, diff --git a/desktop/src-tauri/src/commands/teams/pending/tests.rs b/desktop/src-tauri/src/commands/teams/pending/tests.rs index 7f4d31a6535..cf89d7159db 100644 --- a/desktop/src-tauri/src/commands/teams/pending/tests.rs +++ b/desktop/src-tauri/src/commands/teams/pending/tests.rs @@ -12,6 +12,7 @@ const KIND_DELETE: u32 = 5; fn member(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { + permission_policy: None, id: id.to_string(), display_name: display_name.to_string(), description: None, diff --git a/desktop/src-tauri/src/commands/teams/sharing/tests.rs b/desktop/src-tauri/src/commands/teams/sharing/tests.rs index a6e5a7d2d77..0c1d8a3381f 100644 --- a/desktop/src-tauri/src/commands/teams/sharing/tests.rs +++ b/desktop/src-tauri/src/commands/teams/sharing/tests.rs @@ -14,6 +14,7 @@ use std::sync::{Arc, Mutex}; fn member(id: &str) -> AgentDefinition { AgentDefinition { + permission_policy: None, id: id.to_string(), display_name: "One".to_string(), description: None, diff --git a/desktop/src-tauri/src/event_sync_team_catalog_tests.rs b/desktop/src-tauri/src/event_sync_team_catalog_tests.rs index 5fcf66a4588..d21cceab380 100644 --- a/desktop/src-tauri/src/event_sync_team_catalog_tests.rs +++ b/desktop/src-tauri/src/event_sync_team_catalog_tests.rs @@ -12,6 +12,7 @@ const TEAM_ID: &str = "team-alpha"; fn member(id: &str, prompt: &str) -> AgentDefinition { AgentDefinition { + permission_policy: None, id: id.to_string(), display_name: id.to_string(), description: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_env.rs b/desktop/src-tauri/src/managed_agents/agent_env.rs index 59b300d9d17..59f96fc2579 100644 --- a/desktop/src-tauri/src/managed_agents/agent_env.rs +++ b/desktop/src-tauri/src/managed_agents/agent_env.rs @@ -116,6 +116,42 @@ pub(crate) fn build_buzz_agent_provider_defaults(cmd: &mut std::process::Command } } +/// Wire the git-credential-nostr helper onto `cmd` for Buzz relay git (NIP-98). +/// +/// Sets ephemeral `GIT_CONFIG_*` vars scoped to the relay HTTP URL and mirrors +/// the agent key into `NOSTR_PRIVATE_KEY`. When the helper binary is absent, +/// the agent simply lacks automatic Buzz git auth — a warning, not a failure. +pub(super) fn configure_git_credential_helper( + cmd: &mut std::process::Command, + relay_url: &str, + private_key_nsec: &str, + agent_name: &str, +) { + let Some(cred_helper) = super::resolve_command("git-credential-nostr") else { + eprintln!( + "buzz-desktop: git-credential-nostr not found — agent {agent_name} will not have automatic Buzz git auth", + ); + return; + }; + let relay_http_url = crate::relay::relay_http_base_url(relay_url); + cmd.env("NOSTR_PRIVATE_KEY", private_key_nsec); + cmd.env("GIT_TERMINAL_PROMPT", "0"); + cmd.env("GIT_CONFIG_COUNT", "2"); + cmd.env( + "GIT_CONFIG_KEY_0", + format!("credential.{relay_http_url}/git.helper"), + ); + cmd.env( + "GIT_CONFIG_VALUE_0", + cred_helper.to_string_lossy().replace('\\', "/"), + ); + cmd.env( + "GIT_CONFIG_KEY_1", + format!("credential.{relay_http_url}/git.useHttpPath"), + ); + cmd.env("GIT_CONFIG_VALUE_1", "true"); +} + /// Parse newline-delimited `KEY=VALUE` lines from a baked env blob. /// Blank lines are skipped. Each non-blank line must contain `=`; the key /// is everything before the first `=`, the value is everything after (values diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 85f34260ce7..1fa87263dd5 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -164,6 +164,7 @@ mod tests { fn sample_agent() -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: "agentpubkeyhex".to_string(), name: "Test Agent".to_string(), @@ -225,6 +226,8 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 131966409b0..0777b04bca0 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -366,6 +366,7 @@ mod tests { /// pubkey/nsec pair matters here. fn record_with_keys(pubkey: String, private_key_nsec: String) -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey, name: "Locked Test".to_string(), @@ -418,6 +419,8 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, agent_command_override: None, persona_source_version: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index da881f64f5a..defa5c61078 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; /// relevant to snapshot export are filled; the rest use defaults. fn minimal_record() -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: "deadbeef".to_string(), name: "Test Agent".to_string(), @@ -75,6 +76,8 @@ fn minimal_record() -> ManagedAgentRecord { definition_respond_to_allowlist: vec!["abc123def".to_string()], definition_parallelism: Some(4), relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs index 9c4568fceb4..95d7054ad3a 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs @@ -85,6 +85,9 @@ pub(super) fn record() -> ManagedAgentRecord { agent_command_override: None, persona_source_version: None, provider: None, + permission_policy: None, + definition_permission_policy: None, + applied_permission_policy: None, } } @@ -119,6 +122,7 @@ fn persona(id: &str, env_vars: BTreeMap<String, String>) -> AgentDefinition { parallelism: None, created_at: String::new(), updated_at: String::new(), + permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 34b4f1496f5..2370e220952 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -68,6 +68,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { fn test_record() -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: "test".to_string(), name: "Test Agent".to_string(), @@ -121,6 +122,8 @@ fn test_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, agent_command_override: None, persona_source_version: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index dc155d82f5b..1701a00a433 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -170,6 +170,7 @@ fn classifies_cli_missing_when_adapter_found_but_cli_absent() { } fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agents::AgentDefinition { crate::managed_agents::AgentDefinition { + permission_policy: None, description: None, id: id.to_string(), display_name: id.to_string(), @@ -205,13 +206,13 @@ fn effective_agent_command_explicit_override_wins() { ); } -/// Minimal record for `record_agent_command` tests; only resolution inputs vary. fn record_with( runtime: Option<&str>, persona_id: Option<&str>, override_cmd: Option<&str>, ) -> crate::managed_agents::types::ManagedAgentRecord { crate::managed_agents::types::ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: String::new(), name: "r".to_string(), @@ -268,6 +269,8 @@ fn record_with( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, } } @@ -1366,8 +1369,7 @@ fn registry_warm_then_try_record_resolves_custom_id() { /// NOT silently fall back to buzz-agent. /// /// This test would fail if save/delete commands do not call -/// warm_harness_registry_from_dir transactionally, or if try_record_agent_command -/// silently falls back to default_agent_command() for dangling ids. +/// warm_harness_registry_from_dir transactionally. #[test] fn registry_delete_then_try_record_returns_dangling_error() { use crate::managed_agents::custom_harnesses::{ @@ -1676,9 +1678,7 @@ fn builtin_catalog_entry_has_empty_definition_env() { // These drive `discover_acp_runtimes_from` itself and land a save/delete in // the window between its directory scan and its registry publish (via the // `pre_publish_test_hook` seam). They red if discovery's final line reverts -// to publishing its pre-probe `loaded_defs` snapshot — the original bug — -// unlike the `custom_harnesses` seam tests, which pin only the fresh-read -// contract of `warm_harness_registry_locked`. +// to publishing its pre-probe `loaded_defs` snapshot — the original bug. /// RAII guard: installs the pre-publish hook, clears it on drop (even on /// panic) so a failing test cannot poison later ones. diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index 1ed44ace946..9c6390bac22 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -8,6 +8,7 @@ fn definition( prompt: &str, ) -> AgentDefinition { AgentDefinition { + permission_policy: None, description: None, id: id.to_string(), display_name: "Test Definition".to_string(), @@ -41,6 +42,7 @@ fn record( ) -> ManagedAgentRecord { use crate::managed_agents::{BackendKind, RespondTo}; ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: "agent-pk".to_string(), name: "Agent".to_string(), @@ -93,6 +95,8 @@ fn record( catalog_source: None, team_catalog_source: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, diff --git a/desktop/src-tauri/src/managed_agents/global_config/mod.rs b/desktop/src-tauri/src/managed_agents/global_config/mod.rs index c38529e7837..8cf79e45d0c 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -70,6 +70,14 @@ pub struct GlobalAgentConfig { /// Preferred ACP runtime for definitions without an explicit runtime. #[serde(default)] pub preferred_runtime: Option<String>, + /// Fleet-wide permission policy default. `None` = use the built-in + /// desktop default (`ask`). Per-agent `permission_policy` takes precedence. + /// + /// Semantics match the per-agent field: `ask` shows the Allow/Deny card, + /// `allow` auto-approves the unique `allow_once` option (explicit opt-in + /// only), `reject` auto-denies. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_policy: Option<crate::managed_agents::permission_policy::PermissionPolicy>, } /// Validate a `GlobalAgentConfig` before persisting it. diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 5f39b7b75f2..f56fe43176e 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -267,6 +267,7 @@ fn roundtrip_serialization() { provider: Some("anthropic".to_string()), model: Some("claude-opus-4".to_string()), preferred_runtime: Some("claude".to_string()), + permission_policy: None, }; let json = serde_json::to_string(&config).expect("serialize"); let back: GlobalAgentConfig = serde_json::from_str(&json).expect("deserialize"); @@ -299,6 +300,7 @@ fn default_global_config_serializes_all_fields() { fn bare_record() -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), @@ -351,6 +353,8 @@ fn bare_record() -> ManagedAgentRecord { catalog_source: None, team_catalog_source: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, @@ -361,6 +365,7 @@ fn bare_record() -> ManagedAgentRecord { fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition { AgentDefinition { + permission_policy: None, description: None, id: id.to_string(), display_name: "Test Persona".to_string(), @@ -598,6 +603,7 @@ fn populated_global_config_round_trips() { provider: Some("anthropic".to_string()), model: Some("claude-opus-4-5".to_string()), preferred_runtime: None, + permission_policy: None, }; let json = serde_json::to_string(&original).expect("serialization must not fail"); let decoded: GlobalAgentConfig = @@ -624,6 +630,7 @@ fn record_runtime_wins_over_persona_runtime_for_command_resolution() { record.persona_id = Some("p1".to_string()); let persona = AgentDefinition { + permission_policy: None, description: None, id: "p1".to_string(), display_name: "Goose persona".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 392059dfb6c..228a5aaff64 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -3,6 +3,7 @@ mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; pub(crate) mod agent_snapshot_envelope; +pub(crate) mod permission_policy; pub(crate) mod team_snapshot; pub(crate) use access_policy::{owner_only, owner_only_access_build, projected_access_with_policy}; pub(crate) use agent_env::{ diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs index c712b2525d4..0b86365e146 100644 --- a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs @@ -31,6 +31,7 @@ fn make_persona(id: &str, display_name: &str) -> AgentDefinition { respond_to: None, respond_to_allowlist: Vec::new(), parallelism: None, + permission_policy: None, created_at: String::new(), updated_at: String::new(), } @@ -93,6 +94,9 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, + definition_permission_policy: None, + permission_policy: None, + applied_permission_policy: None, relay_mesh: None, effort_level: None, } diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index f0806c8bc04..7700315a852 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -64,6 +64,7 @@ mod tests { fn record_with(runtime: Option<&str>, parallelism: u32) -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: String::new(), name: "r".to_string(), @@ -120,6 +121,8 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, } } @@ -130,6 +133,7 @@ mod tests { ) -> crate::managed_agents::types::AgentDefinition { use crate::managed_agents::types::AgentDefinition; AgentDefinition { + permission_policy: None, description: None, id: id.to_string(), display_name: String::new(), diff --git a/desktop/src-tauri/src/managed_agents/permission_policy.rs b/desktop/src-tauri/src/managed_agents/permission_policy.rs new file mode 100644 index 00000000000..f2be1a9dfe1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/permission_policy.rs @@ -0,0 +1,372 @@ +//! Permission policy enum, source attribution, and the precedence resolver. +//! +//! `BUZZ_ACP_PERMISSION_POLICY` is in `RESERVED_ENV_KEYS` so users cannot +//! override it via the env-vars UI — a manual override would make the running +//! harness use a different policy than the saved/UI-visible setting. + +use serde::{Deserialize, Serialize}; + +use super::types::{AgentDefinition, ManagedAgentRecord}; + +/// How the agent answers `session/request_permission` requests. +/// +/// - `Ask` — show an Allow/Deny card; auto-deny after 300 s (desktop default). +/// - `Allow` — auto-select the unique `allow_once` option; explicit opt-in. +/// - `Reject` — deny immediately; headless/CLI default. +/// +/// Wire format is lowercase to match the harness CLI vocabulary and the +/// `BUZZ_ACP_PERMISSION_POLICY` env var the harness reads. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum PermissionPolicy { + Ask, + Allow, + Reject, +} + +impl PermissionPolicy { + /// The env-var wire string consumed by the harness + /// (`BUZZ_ACP_PERMISSION_POLICY`). + pub fn as_str(self) -> &'static str { + match self { + Self::Ask => "ask", + Self::Allow => "allow", + Self::Reject => "reject", + } + } + + /// The built-in desktop default: show the Allow/Deny card. + /// + /// Headless / bare-CLI callers use `Reject` — they never have a UI to + /// answer a card. The desktop injects the resolved effective policy so + /// headless sessions spawned by the desktop still pick up the user's + /// choice. + pub fn desktop_default() -> Self { + Self::Ask + } +} + +/// Where the effective [`PermissionPolicy`] came from. Serialized as a +/// `snake_case` string for TypeScript's exhaustive-switch pattern. +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PermissionPolicySource { + /// Set explicitly on this agent record. + Agent, + /// Inherited from the linked definition's default policy. + Definition, + /// Inherited from the global agent config. + GlobalDefault, + /// Neither per-agent nor global is set; using the built-in desktop default. + BuiltIn, +} + +/// Resolve the effective permission policy for an agent. +/// +/// Precedence (highest first): +/// 1. `record.permission_policy` — per-agent override. +/// 2. linked definition's `permission_policy` — definition default. +/// 3. `global.permission_policy` — fleet-wide default. +/// 4. [`PermissionPolicy::desktop_default`] — built-in. +/// +/// The `definitions` slice is the same one every spawn/summary/deploy path +/// already loads. Tier 2 is a lookup by `record.persona_id`, so a linked +/// instance and the spawn-env it launches with resolve identically — a +/// definition-less or orphaned record simply skips the tier. +pub fn resolve_effective_permission_policy( + record: &ManagedAgentRecord, + definitions: &[AgentDefinition], + global: &super::global_config::GlobalAgentConfig, +) -> (PermissionPolicy, PermissionPolicySource) { + if let Some(policy) = record.permission_policy { + return (policy, PermissionPolicySource::Agent); + } + if let Some(policy) = record + .persona_id + .as_ref() + .and_then(|pid| definitions.iter().find(|d| d.id == *pid)) + .and_then(|def| def.permission_policy) + { + return (policy, PermissionPolicySource::Definition); + } + if let Some(policy) = global.permission_policy { + return (policy, PermissionPolicySource::GlobalDefault); + } + ( + PermissionPolicy::desktop_default(), + PermissionPolicySource::BuiltIn, + ) +} + +/// Apply a permission-policy update from an agent-update request. +/// +/// Returns `Ok(())` when the field was updated (or there was nothing to do). +/// Returns `Err(message)` when the update is rejected because the agent is +/// deployed remotely and its policy is therefore read-only. +/// +/// `update` is the two-layer optional: `None` = don't touch, `Some(None)` = +/// clear the per-agent override, `Some(Some(policy))` = set the override. +pub fn apply_permission_policy_update( + record: &mut ManagedAgentRecord, + update: Option<Option<PermissionPolicy>>, +) -> Result<(), String> { + let Some(policy) = update else { return Ok(()) }; + if matches!(record.backend, super::BackendKind::Provider { .. }) + && record.backend_agent_id.is_some() + { + return Err("permission_policy is read-only while the agent is deployed remotely; shut down and redeploy to change it".to_string()); + } + record.permission_policy = policy; + Ok(()) +} + +/// Resolve the effective policy and inject `BUZZ_ACP_PERMISSION_POLICY` so the +/// running process and the UI-visible setting stay in sync. Returns the policy +/// so the caller can stamp it onto the spawn-config snapshot. +pub fn inject_spawn_permission_policy( + command: &mut std::process::Command, + record: &ManagedAgentRecord, + definitions: &[AgentDefinition], + global: &super::global_config::GlobalAgentConfig, +) -> PermissionPolicy { + let (policy, _) = resolve_effective_permission_policy(record, definitions, global); + command.env("BUZZ_ACP_PERMISSION_POLICY", policy.as_str()); + policy +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::global_config::GlobalAgentConfig; + + fn empty_record() -> ManagedAgentRecord { + serde_json::from_value(serde_json::json!({ + "pubkey": "abcd1234", + "name": "test", + "display_name": "Test", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://relay.example", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 300, + "idle_timeout_seconds": 900, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + })) + .expect("minimal ManagedAgentRecord") + } + + fn definition(id: &str, policy: Option<PermissionPolicy>) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: "Def".to_string(), + avatar_url: None, + description: None, + system_prompt: String::new(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: None, + env_vars: Default::default(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + permission_policy: policy, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } + } + + #[test] + fn test_per_agent_policy_beats_global_and_built_in() { + let mut record = empty_record(); + record.permission_policy = Some(PermissionPolicy::Allow); + let global = GlobalAgentConfig { + permission_policy: Some(PermissionPolicy::Reject), + ..Default::default() + }; + + let (policy, source) = resolve_effective_permission_policy(&record, &[], &global); + assert_eq!(policy, PermissionPolicy::Allow); + assert_eq!(source, PermissionPolicySource::Agent); + } + + #[test] + fn test_global_policy_beats_built_in_when_no_per_agent() { + let mut record = empty_record(); + record.permission_policy = None; + let global = GlobalAgentConfig { + permission_policy: Some(PermissionPolicy::Allow), + ..Default::default() + }; + + let (policy, source) = resolve_effective_permission_policy(&record, &[], &global); + assert_eq!(policy, PermissionPolicy::Allow); + assert_eq!(source, PermissionPolicySource::GlobalDefault); + } + + #[test] + fn test_built_in_used_when_neither_per_agent_nor_global_is_set() { + let mut record = empty_record(); + record.permission_policy = None; + let global = GlobalAgentConfig::default(); // permission_policy = None + + let (policy, source) = resolve_effective_permission_policy(&record, &[], &global); + assert_eq!(policy, PermissionPolicy::Ask); // desktop_default + assert_eq!(source, PermissionPolicySource::BuiltIn); + } + + #[test] + fn test_per_agent_reject_beats_global_allow() { + let mut record = empty_record(); + record.permission_policy = Some(PermissionPolicy::Reject); + let global = GlobalAgentConfig { + permission_policy: Some(PermissionPolicy::Allow), + ..Default::default() + }; + + let (policy, source) = resolve_effective_permission_policy(&record, &[], &global); + assert_eq!(policy, PermissionPolicy::Reject); + assert_eq!(source, PermissionPolicySource::Agent); + } + + #[test] + fn test_definition_policy_beats_global_when_no_per_agent_override() { + let mut record = empty_record(); + record.permission_policy = None; + record.persona_id = Some("def-1".to_string()); + let defs = [definition("def-1", Some(PermissionPolicy::Reject))]; + let global = GlobalAgentConfig { + permission_policy: Some(PermissionPolicy::Allow), + ..Default::default() + }; + + let (policy, source) = resolve_effective_permission_policy(&record, &defs, &global); + assert_eq!(policy, PermissionPolicy::Reject); + assert_eq!(source, PermissionPolicySource::Definition); + } + + #[test] + fn test_per_agent_override_beats_definition_default() { + let mut record = empty_record(); + record.permission_policy = Some(PermissionPolicy::Allow); + record.persona_id = Some("def-1".to_string()); + let defs = [definition("def-1", Some(PermissionPolicy::Reject))]; + let global = GlobalAgentConfig::default(); + + let (policy, source) = resolve_effective_permission_policy(&record, &defs, &global); + assert_eq!(policy, PermissionPolicy::Allow); + assert_eq!(source, PermissionPolicySource::Agent); + } + + #[test] + fn test_definition_without_policy_falls_through_to_global() { + let mut record = empty_record(); + record.permission_policy = None; + record.persona_id = Some("def-1".to_string()); + // Linked definition carries no default — tier 2 is skipped. + let defs = [definition("def-1", None)]; + let global = GlobalAgentConfig { + permission_policy: Some(PermissionPolicy::Allow), + ..Default::default() + }; + + let (policy, source) = resolve_effective_permission_policy(&record, &defs, &global); + assert_eq!(policy, PermissionPolicy::Allow); + assert_eq!(source, PermissionPolicySource::GlobalDefault); + } + + #[test] + fn test_orphaned_persona_id_skips_definition_tier() { + let mut record = empty_record(); + record.permission_policy = None; + record.persona_id = Some("missing".to_string()); + // The linked definition is gone; a stale slice with a different id + // must not resolve tier 2 (no `find` match) — fall to built-in. + let defs = [definition("def-1", Some(PermissionPolicy::Reject))]; + let global = GlobalAgentConfig::default(); + + let (policy, source) = resolve_effective_permission_policy(&record, &defs, &global); + assert_eq!(policy, PermissionPolicy::Ask); + assert_eq!(source, PermissionPolicySource::BuiltIn); + } + + /// Desired-vs-applied drift at the resolver level (Wes's regression, resolver + /// half): after a post-deploy global flip to Reject, the recomputed *desired* + /// policy is Reject while the persisted *applied* receipt stays Allow, so the + /// two diverge and the UI can flag drift. The production stamp/receipt half — + /// that `applied` is written from the byte-identical sent value and survives a + /// failed redeploy — is pinned by the discriminating transition tests in + /// `commands/agents_deploy.rs`. + #[test] + fn test_applied_policy_survives_global_flip_deploy_allow_global_flips_to_reject() { + let mut record = empty_record(); + record.permission_policy = None; + record.applied_permission_policy = Some(PermissionPolicy::Allow); + + let global_after_flip = GlobalAgentConfig { + permission_policy: Some(PermissionPolicy::Reject), + ..Default::default() + }; + + let (desired, source) = + resolve_effective_permission_policy(&record, &[], &global_after_flip); + assert_eq!(desired, PermissionPolicy::Reject); + assert_eq!(source, PermissionPolicySource::GlobalDefault); + assert_ne!(record.applied_permission_policy, Some(desired)); + } + + /// Paul's acceptance row for the definition tier: editing a definition's + /// default policy MUST succeed while a linked instance is deployed — unlike + /// a *per-instance* override, which `apply_permission_policy_update` rejects + /// while deployed, a definition has no deploy receipt and its write path + /// (`apply_persona_behavior`) carries no such guard. The edit then lights + /// the deployed instance's drift row: with no per-instance override the + /// recomputed *desired* policy resolves from the definition tier (now + /// Reject) while the byte-stamped *applied* receipt stays Allow, so the two + /// diverge exactly as the UI drift row keys on. + #[test] + fn test_definition_default_edit_while_deployed_succeeds_and_lights_drift() { + use super::super::types::{apply_persona_behavior, PersonaBehaviorRequest}; + + // A linked instance deployed remotely, launched under Allow (receipt), + // with no per-instance override so it resolves through the definition. + let mut record = empty_record(); + record.persona_id = Some("def-1".to_string()); + record.permission_policy = None; + record.applied_permission_policy = Some(PermissionPolicy::Allow); + + // Edit the linked definition's default to Reject through the real write + // path. There is no deployed-read-only guard here — a definition is + // never itself deployed — so the edit succeeds unconditionally. + let mut def = definition("def-1", Some(PermissionPolicy::Allow)); + apply_persona_behavior( + &mut def, + Some(PersonaBehaviorRequest { + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + permission_policy: Some(PermissionPolicy::Reject), + }), + ) + .expect("editing a definition default must succeed while deployed"); + assert_eq!(def.permission_policy, Some(PermissionPolicy::Reject)); + + // The deployed instance now resolves desired=Reject from the definition + // tier while applied stays Allow → drift. + let global = GlobalAgentConfig::default(); + let (desired, source) = resolve_effective_permission_policy(&record, &[def], &global); + assert_eq!(desired, PermissionPolicy::Reject); + assert_eq!(source, PermissionPolicySource::Definition); + assert_ne!(record.applied_permission_policy, Some(desired)); + } +} diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index fa80b456a07..59ca4b9256b 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -254,6 +254,9 @@ pub fn persona_from_event(event: &nostr::Event) -> Result<AgentDefinition, Strin respond_to: content.respond_to, respond_to_allowlist: content.respond_to_allowlist, parallelism: content.parallelism, + // Not published in persona events (authority grant stays local), so a + // persona parsed from the relay carries no definition-scoped policy. + permission_policy: None, created_at: created_at.clone(), updated_at: created_at, }) diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 9367ad463e2..0107f5c63b2 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -5,6 +5,7 @@ use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; /// state right after creation, before any snapshot apply. pub(super) fn sample_record() -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: "p".repeat(64), name: "agent".into(), @@ -61,6 +62,8 @@ pub(super) fn sample_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, } } @@ -145,6 +148,7 @@ fn preview_passes_through_unchanged_when_persona_missing() { pub(super) fn sample_persona() -> AgentDefinition { AgentDefinition { + permission_policy: None, description: None, id: "test-persona".to_string(), display_name: "Test Persona".to_string(), @@ -374,29 +378,11 @@ fn content_matches_nip_ap_vector() { // An event built from this content carries the byte-exact vector as its // signed content, so a second implementer following the spec computes // the same NIP-01 id. - let record = AgentDefinition { - description: None, - id: "test-agent".to_string(), - display_name: "Test Agent".to_string(), - avatar_url: Some("https://example.com/avatar.png".to_string()), - system_prompt: "You are a test assistant.".to_string(), - runtime: Some("goose".to_string()), - model: Some("claude-opus-4".to_string()), - provider: Some("anthropic".to_string()), - name_pool: vec!["Alpha".to_string(), "Beta".to_string()], - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - team_catalog_source: None, - env_vars: BTreeMap::new(), - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: "2025-01-01T00:00:00Z".to_string(), - updated_at: "2025-01-01T00:00:00Z".to_string(), + let record = { + let mut p = sample_persona(); + p.id = "test-agent".to_string(); + p.display_name = "Test Agent".to_string(); + p }; let event = build_persona_event(&record) .unwrap() @@ -408,6 +394,7 @@ fn content_matches_nip_ap_vector() { #[test] fn round_trip_minimal_persona() { let record = AgentDefinition { + permission_policy: None, description: None, id: "minimal".to_string(), display_name: "Minimal".to_string(), @@ -507,6 +494,7 @@ fn behavioral_defaults_survive_record_round_trip() { #[test] fn quad_absent_definition_hash_stable_across_activation() { let record = AgentDefinition { + permission_policy: None, description: None, id: "quad-absent".to_string(), display_name: "Test".to_string(), @@ -549,10 +537,29 @@ fn quad_absent_definition_hash_stable_across_activation() { ); } +/// The definition permission policy is a local authority grant, never +/// published. `persona_content_hash` is computed over `PersonaEventContent` +/// which has no policy field — flipping the definition's policy must not +/// move the hash (no spurious drift badge or republish wave). +#[test] +fn definition_permission_policy_does_not_affect_content_hash() { + let base = sample_persona(); + let mut with_policy = base.clone(); + with_policy.permission_policy = + Some(crate::managed_agents::permission_policy::PermissionPolicy::Allow); + + assert_eq!( + persona_content_hash(&persona_event_content(&base)), + persona_content_hash(&persona_event_content(&with_policy)), + "setting a definition permission policy must not change the published content hash" + ); +} + /// Test-only bridge: build an AgentDefinition from parsed content the same /// way `persona_from_event` maps fields, without needing a signed event. fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDefinition { AgentDefinition { + permission_policy: None, description: content.description, id: "staged".to_string(), display_name: content.display_name, diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 094d0a1a478..4e41535e3f4 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -141,6 +141,7 @@ fn built_in_persona_records(now: &str) -> Vec<AgentDefinition> { respond_to: None, respond_to_allowlist: Vec::new(), parallelism: None, + permission_policy: None, created_at: now.to_string(), updated_at: now.to_string(), }) diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index a52f6aa3b19..dd31da38f99 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -8,6 +8,7 @@ use crate::managed_agents::AgentDefinition; fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { + permission_policy: None, description: None, id: id.to_string(), display_name: display_name.to_string(), diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 88cc7884c41..6e561ea25ed 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -111,10 +111,9 @@ pub(crate) struct EffectiveHarnessDescriptor { /// /// Returns `Err("DANGLING_HARNESS_ID:<id>")` when the record (or its linked /// persona) references a runtime id that no longer exists in the registry — -/// the same typed error produced by `try_record_agent_command`. Callers that -/// cannot meaningfully continue with a dangling id (e.g. `spawn_agent_child`) -/// propagate the error; callers that degrade gracefully may use -/// `.unwrap_or_else(|_| …)`. +/// the same typed error produced by `try_record_agent_command`. Callers that +/// cannot continue with a dangling id propagate the error; callers that degrade +/// gracefully may use `.unwrap_or_else(|_| …)`. /// /// Does NOT require an `AppHandle` so it is fully unit-testable. /// @@ -409,10 +408,8 @@ impl AgentReadiness { /// credential store — NOT `OPENAI_API_KEY`). /// * **unknown / custom command**: always `Ready` (no requirements known). /// -/// Databricks note: `DATABRICKS_TOKEN` is `.unwrap_or_default()` in -/// `buzz-agent/src/config.rs:143` — it is an escape hatch for static tokens -/// but the normal path is OAuth PKCE. We intentionally do NOT mark the -/// token as required to avoid a false NotReady for users on OAuth. +/// Databricks note: `DATABRICKS_TOKEN` is `.unwrap_or_default()` (escape hatch +/// for static tokens); normal path is OAuth PKCE, so token is not required. pub(crate) fn agent_readiness(effective: &EffectiveAgentEnv) -> AgentReadiness { let runtime = known_acp_runtime(&effective.effective_command); let missing = collect_missing_requirements(effective, runtime); @@ -1493,6 +1490,7 @@ mod tests { ); // Minimal record: only the fields resolve_effective_agent_env reads. let record = crate::managed_agents::types::ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: "test-pubkey".to_string(), name: "test-agent".to_string(), @@ -1549,6 +1547,8 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, }; diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index c01d29f3c2a..7d411c795eb 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -79,6 +79,11 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // for same-session sweep decisions. "BUZZ_MANAGED_AGENT", "BUZZ_MANAGED_AGENT_START_NONCE", + // Permission policy gate: Desktop resolves the effective policy + // (per-agent > global > built-in) and injects it here. A user-supplied + // override would make the running harness use a different policy than the + // saved/UI-visible setting — exactly the truthfulness failure #4938 fixes. + "BUZZ_ACP_PERMISSION_POLICY", ]; pub(crate) fn is_reserved_env_key(key: &str) -> bool { diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b10271ea1c7..3e452c610c1 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -7,9 +7,10 @@ use super::agent_env::idle_pool_sleep_env; use crate::{ managed_agents::{ append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, - missing_command_message, normalize_agent_args, open_log_file, resolve_command, - spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, ManagedAgentSummary, + missing_command_message, normalize_agent_args, open_log_file, + permission_policy::resolve_effective_permission_policy, resolve_command, spawn_key_refusal, + KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, + ManagedAgentSummary, }, util::now_iso, }; @@ -289,6 +290,9 @@ pub fn build_managed_agent_summary( .unwrap_or("") .to_string(); + let (effective_permission_policy_summary, effective_permission_policy_source) = + resolve_effective_permission_policy(record, personas, global_config); + Ok(ManagedAgentSummary { pubkey: record.pubkey.clone(), name: record.name.clone(), @@ -331,6 +335,9 @@ pub fn build_managed_agent_summary( log_path, respond_to: record.respond_to, respond_to_allowlist: record.respond_to_allowlist.clone(), + permission_policy: effective_permission_policy_summary, + permission_policy_source: effective_permission_policy_source, + applied_permission_policy: record.applied_permission_policy, }) } @@ -811,33 +818,24 @@ pub fn spawn_agent_child( command.env_remove(key); } + // Inject BUZZ_ACP_PERMISSION_POLICY, keeping the running process and the + // UI-visible setting in sync; the returned policy is stamped below. + let effective_permission_policy = super::permission_policy::inject_spawn_permission_policy( + &mut command, + record, + &personas, + &global, + ); + command.env("BUZZ_ACP_RELAY_OBSERVER", "true"); // Git credential helper: NIP-98 auth for Buzz relay git via git-credential-nostr. - // Ephemeral GIT_CONFIG_COUNT env vars scoped to relay HTTP URL; NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY. - if let Some(cred_helper) = resolve_command("git-credential-nostr") { - let relay_http_url = crate::relay::relay_http_base_url(&effective_relay_url); - - command.env("NOSTR_PRIVATE_KEY", &record.private_key_nsec); - command.env("GIT_TERMINAL_PROMPT", "0"); - command.env("GIT_CONFIG_COUNT", "2"); - command.env( - "GIT_CONFIG_KEY_0", - format!("credential.{relay_http_url}/git.helper"), - ); - let helper = cred_helper.to_string_lossy().replace('\\', "/"); - command.env("GIT_CONFIG_VALUE_0", helper); - command.env( - "GIT_CONFIG_KEY_1", - format!("credential.{relay_http_url}/git.useHttpPath"), - ); - command.env("GIT_CONFIG_VALUE_1", "true"); - } else { - eprintln!( - "buzz-desktop: git-credential-nostr not found — agent {} will not have automatic Buzz git auth", - record.name, - ); - } + super::agent_env::configure_git_credential_helper( + &mut command, + &effective_relay_url, + &record.private_key_nsec, + &record.name, + ); // User env (descriptor.env): fully-layered floor→runtime→definition→global→persona→agent, // reserved-key filtered. Written last so user-explicit values win over Buzz-set env. @@ -888,6 +886,7 @@ pub fn spawn_agent_child( system_prompt: effective_prompt.as_deref(), model: effective_model.as_deref(), provider: effective_provider.as_deref(), + permission_policy: effective_permission_policy, enforced_owner_only: super::owner_only_access_build(), session_policy: acp_session_policy, }, diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index 05e11fc4cdf..68784e59223 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -36,6 +36,7 @@ pub(super) fn fixture( auth_tag: Option<String>, ) -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: "p".into(), name: "n".into(), @@ -92,6 +93,8 @@ pub(super) fn fixture( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 57521c04fff..487636afb14 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -272,6 +272,7 @@ fn persona_with_provider( provider: Option<&str>, ) -> crate::managed_agents::AgentDefinition { crate::managed_agents::AgentDefinition { + permission_policy: None, description: None, id: id.to_string(), display_name: id.to_string(), @@ -1012,8 +1013,7 @@ fn invalid_pubkey_resolves_no_pair_key() { // Linux used an AND-gate (name + marker) — wrong for custom harnesses. // Fix: all platforms gate on `process_has_buzz_marker` alone; the receipt path // is verified below via `valid_agent_runtime_receipt_with` (injectable), -// which no longer takes a name-check predicate at all — reinstating an -// AND-gate would be a signature change these tests would catch. +// which no longer takes a name-check predicate at all. // ── Collector-discriminating sweep tests (C-9 / Thufir F6) ────────────────── // diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index 810ad439f29..f9dcd8a66bc 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -72,6 +72,8 @@ pub(crate) struct SpawnConfigInputs<'a> { pub system_prompt: Option<&'a str>, pub model: Option<&'a str>, pub provider: Option<&'a str>, + /// Resolved effective permission policy (per-agent > global > built-in). + pub permission_policy: super::permission_policy::PermissionPolicy, /// Compile-time distribution capability projected at this runtime boundary. /// The stored record remains portable; only effective spawned access is stamped. pub enforced_owner_only: bool, @@ -132,6 +134,10 @@ pub(crate) struct SpawnConfigSnapshot { pub idle_timeout_seconds: Option<u64>, pub max_turn_duration_seconds: Option<u64>, pub parallelism: u32, + /// Effective permission policy at spawn time. Reaches the harness via + /// `BUZZ_ACP_PERMISSION_POLICY`. Tracked in the snapshot so an edit shows + /// in the `needsRestart` diff. + pub permission_policy: String, /// The startup effort the harness will actually apply, resolved by /// [`effective_effort`]: the single effort key the harness-agnostic /// projection left in `descriptor.env` under the runtime's destination key. @@ -189,6 +195,7 @@ impl SpawnConfigSnapshot { system_prompt, model, provider, + permission_policy, enforced_owner_only, session_policy, } = inputs; @@ -252,6 +259,7 @@ impl SpawnConfigSnapshot { // pool and must badge. The diff surface consequently displays the // effective value — that is correct, it is what actually runs. parallelism: super::effective_parallelism(&descriptor.command, record.parallelism), + permission_policy: permission_policy.as_str().to_string(), // Sole effort representation — see the field doc and the `env` // strip above. Reads the single projected effort key the descriptor // resolver left in `descriptor.env`, so the badge compares exactly @@ -348,6 +356,10 @@ pub(crate) fn prospective_spawn_config_snapshot( system_prompt: prompt.as_deref(), model: model.as_deref(), provider: provider.as_deref(), + permission_policy: super::permission_policy::resolve_effective_permission_policy( + record, personas, global, + ) + .0, enforced_owner_only, session_policy, }) diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs index 43ce7718595..5fb9761d703 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs @@ -28,6 +28,7 @@ fn base() -> SpawnConfigSnapshot { idle_timeout_seconds: Some(600), max_turn_duration_seconds: Some(7200), parallelism: 1, + permission_policy: "ask".into(), effort_level: Some("high".into()), session_policy: "channel".into(), } @@ -72,6 +73,9 @@ fn mutations() -> Vec<Mutation> { s.max_turn_duration_seconds = None }), ("parallelism", |s| s.parallelism = 8), + ("permission_policy", |s| { + s.permission_policy = "allow".into() + }), ("effort_level", |s| s.effort_level = None), ("session_policy", |s| s.session_policy = "thread".into()), ] diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 388256e01c6..ca01708a76b 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -43,6 +43,7 @@ fn snap(record: &ManagedAgentRecord) -> serde_json::Value { fn record() -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: "p".repeat(64), name: "agent".into(), @@ -99,12 +100,15 @@ fn record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, } } fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { AgentDefinition { + permission_policy: None, description: None, id: id.into(), display_name: id.into(), diff --git a/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs b/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs index 8f9d68245de..ac15053e543 100644 --- a/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs +++ b/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs @@ -5,6 +5,7 @@ mod reuse_hint; // built-in reuse-hint projection-hash boundary gate (Carl r9 P1 fn member(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { + permission_policy: None, id: id.to_string(), display_name: display_name.to_string(), description: None, diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index fdeb54c4f27..16a6e94dee0 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -254,6 +254,7 @@ mod tests { /// Build a minimal `ManagedAgentRecord` for use as a team member. fn agent_record(name: &str) -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: format!("{name}-pubkey"), name: name.to_string(), @@ -314,6 +315,8 @@ mod tests { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index fc6f0f1a97b..8789c29a0a2 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -167,6 +167,7 @@ fn validate_team_deletion_rejects_built_ins() { fn managed_agent(name: &str) -> ManagedAgentRecord { ManagedAgentRecord { + definition_permission_policy: None, description: None, pubkey: name.to_string(), name: name.to_string(), @@ -220,6 +221,8 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { catalog_source: None, team_catalog_source: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], @@ -454,6 +457,7 @@ const D_TAG: &str = "my-team"; fn catalog_copy(id: &str, owner: &str, d_tag: &str) -> AgentDefinition { AgentDefinition { + permission_policy: None, id: id.to_string(), display_name: id.to_string(), description: None, @@ -694,6 +698,7 @@ fn test_ref_check_preserves_copy_used_by_a_standalone_managed_agent() { fn catalog_persona(id: &str, owner: &str, d_tag: &str) -> AgentDefinition { AgentDefinition { + permission_policy: None, id: id.to_string(), display_name: id.to_string(), description: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 2620f0337fc..9a9fc36a389 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -1,6 +1,5 @@ use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, path::PathBuf, process::Child}; - #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum BackendKind { @@ -99,15 +98,17 @@ pub struct AgentDefinition { pub respond_to_allowlist: Vec<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub parallelism: Option<u32>, + /// Definition-level default permission policy — tier 2 of the resolver + /// (`resolve_effective_permission_policy`). Local-only; not published. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_policy: Option<super::permission_policy::PermissionPolicy>, pub created_at: String, pub updated_at: String, } impl AgentDefinition { - /// Project this persona onto a key-less unified [`ManagedAgentRecord`] - /// (Phase 1A store fold). Identity fields stay empty — keys are minted on - /// first start. `AgentDefinition.id` becomes `slug`, preserving the 30175 - /// event coordinate (`d_tag = slug`) across the fold. + /// Project this persona onto a key-less unified [`ManagedAgentRecord`] (Phase 1A store fold). + /// Identity fields are empty; keys are minted on first start. pub fn into_agent_record(self) -> ManagedAgentRecord { ManagedAgentRecord { pubkey: String::new(), @@ -166,7 +167,10 @@ impl AgentDefinition { definition_respond_to: self.respond_to, definition_respond_to_allowlist: self.respond_to_allowlist, definition_parallelism: self.parallelism, + definition_permission_policy: self.permission_policy, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, effort_level: None, } } @@ -204,6 +208,7 @@ impl ManagedAgentRecord { respond_to: self.definition_respond_to.clone(), respond_to_allowlist: self.definition_respond_to_allowlist.clone(), parallelism: self.definition_parallelism, + permission_policy: self.definition_permission_policy, created_at: self.created_at.clone(), updated_at: self.updated_at.clone(), }) @@ -369,6 +374,10 @@ pub struct ManagedAgentRecord { /// Preserved across mode toggles so users don't lose state. #[serde(default)] pub respond_to_allowlist: Vec<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_policy: Option<super::permission_policy::PermissionPolicy>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub applied_permission_policy: Option<super::permission_policy::PermissionPolicy>, /// Optional display name distinct from the unique `name` handle. Absorbed /// from `AgentDefinition.display_name` (unified agent model, Phase 1A). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -451,6 +460,10 @@ pub struct ManagedAgentRecord { pub definition_respond_to_allowlist: Vec<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub definition_parallelism: Option<u32>, + /// Definition-level default permission policy — a *definition*'s advertised + /// default (tier 2), distinct from the instance override above. Local-only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub definition_permission_policy: Option<super::permission_policy::PermissionPolicy>, /// Typed marker for relay-mesh agents. `Some(_)` means this agent runs its /// inference through Buzz's relay-mesh local endpoint; the `model_ref` is /// the served model id to route to. `None` is a normal agent. @@ -554,16 +567,11 @@ pub struct ManagedAgentSummary { /// persona is gone, so there is nothing newer to drift toward). pub persona_out_of_date: bool, /// `true` when the agent was created from a persona that no longer exists. - /// Distinct from out-of-date: there is no current persona to respawn into. - /// An orphaned agent also cannot be (re)started — `spawn_agent_child` - /// refuses it (see `effective_config::resolve_effective_config`'s - /// `OrphanedInstance` arm via `require_resolved`) — so the UI - /// should surface that it's stuck, not merely stale. + /// `true` when the agent's linked persona no longer exists; no current + /// persona to respawn into and the agent cannot be (re)started. pub persona_orphaned: bool, - /// `true` when the running process's spawn config no longer matches - /// what a spawn would use today. Derived from `restart_diff` — lit - /// exactly when there is something to show. Always `false` for stopped, - /// orphaned, or `runtime_pid`-adopted agents. + /// `true` when the running process's spawn config no longer matches what + /// a spawn would use today. Always `false` for stopped/orphaned agents. pub needs_restart: bool, /// Fields that drifted since launch, redacted for display. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -586,6 +594,10 @@ pub struct ManagedAgentSummary { pub log_path: String, pub respond_to: RespondTo, pub respond_to_allowlist: Vec<String>, + pub permission_policy: super::permission_policy::PermissionPolicy, + pub permission_policy_source: super::permission_policy::PermissionPolicySource, + #[serde(skip_serializing_if = "Option::is_none")] + pub applied_permission_policy: Option<super::permission_policy::PermissionPolicy>, } #[derive(Debug, Serialize)] diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index 824ca4ccf3a..4706fc1661d 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -26,6 +26,13 @@ pub struct PersonaBehaviorRequest { pub respond_to_allowlist: Vec<String>, #[serde(default)] pub parallelism: Option<u32>, + /// Definition-level default permission policy (resolver tier 2). Within a + /// present behavior group this replaces the stored value as a unit like + /// the fields above: `None` = no definition default (defer to the global / + /// built-in tier), `Some(policy)` = advertise that default. Unlike the + /// others it is never published or mint-copied — a local authority grant. + #[serde(default)] + pub permission_policy: Option<super::super::permission_policy::PermissionPolicy>, } /// Validate a behavior group and apply it onto a persona record. @@ -68,6 +75,9 @@ pub fn apply_persona_behavior( Vec::new() }; record.parallelism = behavior.parallelism; + // Definition-scoped default: replace as part of the behavior group. `None` + // clears any stored default so the resolver falls through to global/built-in. + record.permission_policy = behavior.permission_policy; Ok(()) } @@ -260,6 +270,12 @@ pub struct UpdateManagedAgentRequest { /// normalized server-side). #[serde(default)] pub respond_to_allowlist: Option<Vec<String>>, + /// Absent = don't touch. `null` = clear per-agent override (revert to + /// global/built-in). Present string = set per-agent override. + /// Remote deployed agents: rejected server-side (displayed read-only in UI). + #[serde(default, deserialize_with = "crate::util::double_option")] + pub permission_policy: + Option<Option<crate::managed_agents::permission_policy::PermissionPolicy>>, /// Absent = don't touch. `null` = clear the canonical effort column /// (revert to inherited default). `"value"` = set the column. /// @@ -281,11 +297,14 @@ mod tests { record.respond_to = Some("allowlist".to_string()); record.respond_to_allowlist = vec!["a".repeat(64)]; record.parallelism = Some(4); + record.permission_policy = + Some(crate::managed_agents::permission_policy::PermissionPolicy::Reject); record } fn record_without_quad() -> AgentDefinition { AgentDefinition { + permission_policy: None, description: None, id: "p-1".to_string(), display_name: "Test".to_string(), @@ -321,6 +340,10 @@ mod tests { assert_eq!(record.respond_to.as_deref(), Some("allowlist")); assert_eq!(record.respond_to_allowlist, vec!["a".repeat(64)]); assert_eq!(record.parallelism, Some(4)); + assert_eq!( + record.permission_policy, + Some(crate::managed_agents::permission_policy::PermissionPolicy::Reject) + ); } #[test] @@ -332,12 +355,17 @@ mod tests { respond_to: Some(RespondTo::Anyone), respond_to_allowlist: Vec::new(), parallelism: None, + // Omitting the policy from a present group clears the stored + // definition default — same replace-as-a-unit contract as the + // other fields, so an edit can revert to global/built-in. + permission_policy: None, }), ) .unwrap(); assert_eq!(record.respond_to.as_deref(), Some("anyone")); assert!(record.respond_to_allowlist.is_empty()); assert_eq!(record.parallelism, None); + assert_eq!(record.permission_policy, None); } #[test] @@ -410,6 +438,8 @@ mod tests { /// Pinky's loop row: an applied behavior group must flow through /// `persona_event_content` so the republished 30175 carries the edited /// behavior group — the write path and the publish path cannot drift apart. + /// The permission policy is the deliberate exception: it is set on the + /// record but MUST NOT reach the published content (local authority grant). #[test] fn applied_behavior_flows_into_persona_event_content() { let mut record = record_without_quad(); @@ -419,13 +449,28 @@ mod tests { respond_to: Some(RespondTo::Allowlist), respond_to_allowlist: vec!["c".repeat(64)], parallelism: Some(3), + permission_policy: Some( + crate::managed_agents::permission_policy::PermissionPolicy::Allow, + ), }), ) .unwrap(); + // Stored on the record for the resolver's tier 2. + assert_eq!( + record.permission_policy, + Some(crate::managed_agents::permission_policy::PermissionPolicy::Allow) + ); let content = crate::managed_agents::persona_events::persona_event_content(&record); assert_eq!(content.respond_to.as_deref(), Some("allowlist")); assert_eq!(content.respond_to_allowlist, vec!["c".repeat(64)]); assert_eq!(content.parallelism, Some(3)); + // Local-only: PersonaEventContent has no policy field, so the edited + // default never leaves the desktop even as respond_to/parallelism do. + let json = serde_json::to_value(&content).unwrap(); + assert!( + json.get("permissionPolicy").is_none() && json.get("permission_policy").is_none(), + "definition permission policy must never be published: {json}" + ); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 0918ab2c65c..f6501aeeb19 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -487,6 +487,7 @@ fn sample_agent_record() -> ManagedAgentRecord { fn sample_persona() -> AgentDefinition { AgentDefinition { + permission_policy: None, description: None, id: "custom:helper".to_string(), display_name: "Helper".to_string(), @@ -761,6 +762,10 @@ fn summary_fixture( log_path: String::new(), respond_to: RespondTo::OwnerOnly, respond_to_allowlist: Vec::new(), + permission_policy: crate::managed_agents::permission_policy::PermissionPolicy::Ask, + permission_policy_source: + crate::managed_agents::permission_policy::PermissionPolicySource::BuiltIn, + applied_permission_policy: None, } } @@ -801,3 +806,31 @@ fn summary_with_drift_serializes_restart_diff_entries() { }])) ); } + +#[test] +fn applied_permission_policy_drift_serializes_correctly() { + // When applied_permission_policy differs from permission_policy, both values + // must reach the wire so the frontend can detect drift and prompt a redeploy. + let mut summary = summary_fixture(Vec::new()); + summary.permission_policy = crate::managed_agents::permission_policy::PermissionPolicy::Reject; + summary.applied_permission_policy = + Some(crate::managed_agents::permission_policy::PermissionPolicy::Allow); + + let wire = serde_json::to_value(&summary).expect("summary serializes"); + assert_eq!(wire["permission_policy"], serde_json::json!("reject")); + assert_eq!( + wire["applied_permission_policy"], + serde_json::json!("allow") + ); +} + +#[test] +fn applied_permission_policy_none_omitted_from_wire() { + // For local agents and never-deployed remote agents, applied_permission_policy + // is None — it must be omitted from the wire (skip_serializing_if = "Option::is_none"). + let wire = serde_json::to_value(summary_fixture(Vec::new())).expect("summary serializes"); + assert!( + wire.get("applied_permission_policy").is_none(), + "absent applied_permission_policy must be omitted, got: {wire}" + ); +} diff --git a/desktop/src-tauri/src/migration_avatar_tests.rs b/desktop/src-tauri/src/migration_avatar_tests.rs index 2573ce2d566..af18822921b 100644 --- a/desktop/src-tauri/src/migration_avatar_tests.rs +++ b/desktop/src-tauri/src/migration_avatar_tests.rs @@ -25,6 +25,7 @@ fn refresh_builtin_agent_avatars_updates_seeded_values_and_preserves_customizati }, ]; let definition = crate::managed_agents::AgentDefinition { + permission_policy: None, description: None, id: "builtin:fizz".to_string(), display_name: "Fizz".to_string(), diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 39f3a3bdef7..6399cea895b 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -237,6 +237,7 @@ with a TypeScript lookup table or an id comparison in a component. mid-conversation effort control without a plan ruling. The archived live-effort machinery lives on `archive/claude-config-gaps-live-effort` for reference only. +15. **Owner-only builds constrain managed runtimes, not relay-agent mentions.** 15. **The persona `description` is public display metadata.** It is optional, capped at 280 characters, and validated through the shared visible-text policy (`validate_agent_description_text` in `definition_validation.rs`) @@ -267,7 +268,6 @@ with a TypeScript lookup table or an id comparison in a component. The dialog field lives in `ui/AgentDescriptionField.tsx` (`AgentIdentityFields`), not inline in the over-1000-line dialogs. - 16. **Owner-only builds constrain managed runtimes, not relay-agent mentions.** The compiled owner-only capability applies when Desktop starts or deploys a managed agent. Independently operated relay agents with NIP-OA ownership @@ -281,6 +281,49 @@ with a TypeScript lookup table or an id comparison in a component. clamp to either mention path. Local `agents-data-changed` events refresh only local persona/team/managed-agent caches; they must never invalidate the remote relay directory. +16. **A remote deploy's permission policy has two truths: desired and applied.** + **The desired policy is live-resolved through a four-tier chain** + (`resolve_effective_permission_policy`), highest precedence first: the + per-instance record override (`source: agent`) → the linked definition's + default (`source: definition`) → the global config default + (`source: global_default`) → the built-in `ask` (`source: built_in`). The + definition tier is a lookup by `record.persona_id` against the same + `definitions` slice every spawn/summary/deploy path already loads, so a + linked instance and the spawn-env it launches with resolve identically — + **the resolver signature is total: every call site passes the definitions, + none shortcuts to `None`.** The definition default is a *live* tier, not + mint-copied: changing it moves every linked instance's desired policy on the + next summary (and lights the drift row on a deployed one), exactly like the + global default. **The editable control lives on the agent definition + (Create / Edit agent) via `PersonaAdvancedFields` → `personaBehaviorDraft` → + `apply_persona_behavior`**, replace-as-a-unit with the rest of the behavior + group; `null` clears the default so the resolver falls through. It is + **local-only — a definition default is an authority grant, never published to + the catalog (`persona_event_content` omits it) and never mint-copied onto + instances**, unlike `respond_to`/`parallelism`. The instance surface + (`AgentPermissionPolicyField`) is **display-only**: it never edits policy, it + shows the resolved value + source and the per-instance drift row. The + *desired* policy is recomputed on every summary from the mutable agent + record + linked definition + global config + (`resolve_effective_permission_policy`). The *applied* + policy is the byte-identical value that was sent to the provider at deploy + time, persisted on the record as `applied_permission_policy` and re-exposed on + the summary — never recomputed. Flipping the global default after a deploy + changes desired but not applied, so the remote worker keeps running the policy + it was launched with until a redeploy. **Stamp the applied value at the single + deploy choke point** (`deploy_to_provider`): `extract_applied_permission_policy` + reads it from `launch.policy_env.BUZZ_ACP_PERMISSION_POLICY` and **must run + before the provider is invoked** — a missing or unparseable value is a broken + payload invariant that fails the deploy, never a silent `None` (a silent `None` + would suppress the drift row and defeat the field). **A failed redeploy retains + the last confirmed applied value** (`record_deploy_failure` leaves it untouched) + — the old worker may still be running it, and `last_error` records the new + attempt; clearing it would destroy known truth. **Legacy fail-quiet:** a record + with `applied_permission_policy` absent (pre-feature, or provider-selected but + never deployed) shows no drift row. `AgentPermissionPolicyField` renders the + amber "applied X · desired Y — redeploy required" row only when the agent is + remotely deployed (`backend.type === "provider"` **and** `backendAgentId !== + null`) **and** applied is non-null **and** applied differs from desired. 17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label. @@ -354,6 +397,26 @@ buzz messages send --channel <channel-id> --reply-to <thread-root-id> \ - Rust: `runtime_metadata_env_vars` tests pin spawn-time key application. - Rust: persona sharing/retention tests pin relay+owner scoping, durable enqueue errors, relay rejection/unavailability, and accepted publication. +- Rust: `commands/agents_deploy.rs` tests pin the applied-policy deploy receipt — + `extract_applied_permission_policy` reads the exact sent value and errors on a + missing/unparseable one; `record_deploy_success` stamps it and a redeploy + updates it; `record_deploy_failure` retains the last confirmed value. +- Rust: `managed_agents/permission_policy.rs` pins the four-tier resolver — the + instance override beats the definition default beats the global default beats + built-in `ask`, each stamping its own `PermissionPolicySource`; and the + resolver-level drift where a post-deploy global flip diverges desired from the + persisted applied receipt. +- Rust: `managed_agents/types/requests.rs` pins `apply_persona_behavior` storing + the definition default as part of the behavior group, and + `persona_events` pins that the default never reaches the published + `PersonaEventContent` (local authority grant). +- `ui/personaDialogState.test.mjs` — the definition default seeds into the + create/edit/duplicate behavior draft, including a policy-only persona. +- `ui/AgentPermissionPolicyField.render.test.mjs` — the display-only instance + surface: the source label (including `definition` → "agent definition"), no + editable control, and the drift row shown for remote+drift with both values + and "redeploy required"; hidden when applied equals desired, when applied is + absent, for local agents, and for a provider-selected-but-undeployed agent. - Rust: `definition_validation` and inbound persona tests pin the shared Unicode/control-character policy at local, import, publish, and sync gates. diff --git a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs index 343ce241335..de04a634a22 100644 --- a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs +++ b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs @@ -1015,8 +1015,8 @@ describe("raw-event-level merge: stateful aggregates across live/archive boundar // The row must carry the fully-resolved production label. assert.equal( permRows[0].outcome, - "Approved (allow_once)", - "permission row outcome must be the production-shaped label when request+response are in the combined window", + "Approved", + "permission row outcome must use verb-only fallback when no harness label flows through the legacy key path", ); }); diff --git a/desktop/src/features/agents/lib/permissionDecisionDelivery.test.mjs b/desktop/src/features/agents/lib/permissionDecisionDelivery.test.mjs new file mode 100644 index 00000000000..9db8eb20fcf --- /dev/null +++ b/desktop/src/features/agents/lib/permissionDecisionDelivery.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveDecisionDeadlineSecs } from "./permissionDecisionDelivery.ts"; + +const NOW = 1_700_000_000; + +test("resolveDecisionDeadlineSecs prefers the card's own expiresAt", () => { + assert.equal( + resolveDecisionDeadlineSecs(1_700_000_300, "2026-08-28T00:00:00Z", NOW), + 1_700_000_300, + "an explicit expiresAt wins over any fallback", + ); +}); + +test("resolveDecisionDeadlineSecs falls back to frame timestamp + 300s when expiresAt is absent", () => { + // A pre-upgrade/archived frame without expiresAt anchors on the frame's own + // clock, not click time — a long-archived card is already past its deadline. + const ts = "2023-11-14T22:13:20.000Z"; // == 1_700_000_000 unix seconds + assert.equal( + resolveDecisionDeadlineSecs(undefined, ts, NOW + 999_999), + NOW + 300, + "deadline = frame-timestamp seconds + 300, independent of now", + ); +}); + +test("resolveDecisionDeadlineSecs falls back to now + 300s when the timestamp is unparseable", () => { + assert.equal( + resolveDecisionDeadlineSecs(undefined, "not-a-date", NOW), + NOW + 300, + "an unparseable timestamp anchors on now so the loop still terminates", + ); + assert.equal( + resolveDecisionDeadlineSecs(undefined, undefined, NOW), + NOW + 300, + "an absent timestamp anchors on now", + ); +}); diff --git a/desktop/src/features/agents/lib/permissionDecisionDelivery.ts b/desktop/src/features/agents/lib/permissionDecisionDelivery.ts new file mode 100644 index 00000000000..1fe6517b8b3 --- /dev/null +++ b/desktop/src/features/agents/lib/permissionDecisionDelivery.ts @@ -0,0 +1,75 @@ +import { sendPermissionDecision } from "@/shared/api/agentControl"; +import { subscribeControlResults } from "@/features/agents/observerRelayStore"; +import { retransmitPermissionDecision } from "./retransmitPermissionDecision"; + +/** Retransmit cadence: resend the decision every 2 s until acked or expired. */ +const RETRANSMIT_INTERVAL_MS = 2_000; + +/** + * Fallback card lifetime (seconds) when a permission frame carries no + * `expiresAt` — matches the harness admission window (`PERMISSION_ASK_TIMEOUT` + * / `OBSERVER_CONTROL_FRESHNESS_SECS`). Only archived / pre-upgrade frames lack + * the field; live frames always carry it since harness and desktop ship + * together. + */ +const FALLBACK_CARD_LIFETIME_SECS = 300; + +/** + * Resolve the effective expiry deadline (unix seconds) for a decision. + * + * Prefers the card's own `expiresAt`. When absent (an archived or pre-upgrade + * frame signed before the field existed), fall back to the frame's timestamp + * plus the fallback lifetime, so the card's real clock — not click time — + * anchors the deadline; a decision on a long-archived card is already past it + * and never retransmits. When the timestamp is also unparseable, anchor to + * `nowSecs` so the loop still terminates within one fallback window. + */ +export function resolveDecisionDeadlineSecs( + expiresAt: number | undefined, + frameTimestamp: string | undefined, + nowSecs: number, +): number { + if (typeof expiresAt === "number") return expiresAt; + const framedAt = frameTimestamp ? Date.parse(frameTimestamp) : NaN; + const anchorSecs = Number.isFinite(framedAt) ? framedAt / 1000 : nowSecs; + return anchorSecs + FALLBACK_CARD_LIFETIME_SECS; +} + +/** + * Deliver a permission decision with a retransmit-until-acked loop, wiring the + * real relay send, `control_result` subscription, and interval scheduler into + * the pure {@link retransmitPermissionDecision} orchestrator. + * + * Fire-and-forget from the caller's view: the returned promise resolves when + * the harness acknowledges the nonce, the harness returns an authoritative + * failure (the card re-enables for retry), or the card's deadline passes. + * The card's UI reaction (resolve / retry) is driven separately by the + * `control_result` reducer path, so callers need not await this. + */ +export function startPermissionDecisionDelivery({ + agentPubkey, + channelId, + requestNonce, + optionId, + deadlineSecs, +}: { + agentPubkey: string; + channelId: string; + requestNonce: string; + optionId: string; + deadlineSecs: number; +}): Promise<"acked" | "expired" | "failed"> { + return retransmitPermissionDecision({ + requestNonce, + send: () => + sendPermissionDecision(agentPubkey, channelId, requestNonce, optionId), + subscribe: (listener) => subscribeControlResults(agentPubkey, listener), + scheduleRetransmit: (onTick) => { + const id = setInterval(onTick, RETRANSMIT_INTERVAL_MS); + // Node/test environments: don't let the interval keep the process alive. + (id as unknown as { unref?: () => void }).unref?.(); + return () => clearInterval(id); + }, + deadlineReached: () => Date.now() / 1000 >= deadlineSecs, + }); +} diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index 928920f9a32..7fa450c4efc 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -85,6 +85,9 @@ function publicationToPersona( respondTo: publication.agent.respondTo, respondToAllowlist: [], parallelism: publication.agent.parallelism, + // Local authority grant — never published, so a catalog entry never + // carries one. A copy added locally can gain one via its own edit. + permissionPolicy: null, createdAt: timestamp, updatedAt: timestamp, }; diff --git a/desktop/src/features/agents/lib/retransmitPermissionDecision.test.mjs b/desktop/src/features/agents/lib/retransmitPermissionDecision.test.mjs new file mode 100644 index 00000000000..053a4111f7e --- /dev/null +++ b/desktop/src/features/agents/lib/retransmitPermissionDecision.test.mjs @@ -0,0 +1,333 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { retransmitPermissionDecision } from "./retransmitPermissionDecision.ts"; + +const NONCE = "nonce-1"; + +function frame(overrides = {}) { + return { + type: "permission_decision", + status: "sent", + requestNonce: NONCE, + ...overrides, + }; +} + +/** + * Controllable harness mirroring the real wiring: a single-listener pub/sub + * whose unsubscribe genuinely detaches, a manual retransmit tick, a manual + * deadline flag, and a send counter. `failSends` rejects the first N send + * attempts, mirroring a socket that is briefly down. + */ +function harness({ nonce = NONCE, failSends = 0 } = {}) { + let listener = null; + let tickCb = null; + let unsubscribeCalls = 0; + let cancelRetransmitCalls = 0; + let sendCalls = 0; + let expired = false; + let remainingFailures = failSends; + + const outcome = retransmitPermissionDecision({ + requestNonce: nonce, + send: () => { + sendCalls += 1; + if (remainingFailures > 0) { + remainingFailures -= 1; + return Promise.reject(new Error("send failed: socket down")); + } + return Promise.resolve(); + }, + subscribe: (fn) => { + listener = fn; + return () => { + unsubscribeCalls += 1; + listener = null; + }; + }, + scheduleRetransmit: (cb) => { + tickCb = cb; + return () => { + cancelRetransmitCalls += 1; + // Mirror clearInterval: a cancelled scheduler fires no more ticks. + tickCb = null; + }; + }, + deadlineReached: () => expired, + }); + + return { + outcome, + push: (f) => listener?.(f), + tick: () => tickCb?.(), + expire: () => { + expired = true; + }, + get sendCalls() { + return sendCalls; + }, + get unsubscribeCalls() { + return unsubscribeCalls; + }, + get cancelRetransmitCalls() { + return cancelRetransmitCalls; + }, + }; +} + +const drainMicrotasks = async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); +}; + +test("retransmitPermissionDecision sends immediately and resolves acked on a matching control_result", async () => { + const h = harness(); + await drainMicrotasks(); + assert.equal(h.sendCalls, 1, "first send fires immediately"); + + h.push(frame()); + assert.equal(await h.outcome, "acked"); + assert.equal(h.unsubscribeCalls, 1, "settles unsubscribe the listener"); + assert.equal( + h.cancelRetransmitCalls, + 1, + "settles cancel the retransmit loop", + ); +}); + +test("retransmitPermissionDecision resends on each tick until acked", async () => { + const h = harness(); + await drainMicrotasks(); + assert.equal(h.sendCalls, 1); + + h.tick(); + h.tick(); + await drainMicrotasks(); + assert.equal(h.sendCalls, 3, "two ticks resend twice more"); + + h.push(frame()); + assert.equal(await h.outcome, "acked"); + // A tick after settle must not resend. + h.tick(); + await drainMicrotasks(); + assert.equal(h.sendCalls, 3, "no resend after the loop has settled"); +}); + +test("retransmitPermissionDecision stops at the deadline and resolves expired without resending", async () => { + const h = harness(); + await drainMicrotasks(); + assert.equal(h.sendCalls, 1); + + h.expire(); + h.tick(); + assert.equal(await h.outcome, "expired"); + assert.equal(h.sendCalls, 1, "a tick past the deadline must not resend"); + assert.equal(h.unsubscribeCalls, 1); + assert.equal(h.cancelRetransmitCalls, 1); +}); + +test("retransmitPermissionDecision resolves acked on an already_decided status", async () => { + // A late retransmit the harness recognizes as an already-applied duplicate + // acks `already_decided`; it settles the loop exactly like `sent`. + const h = harness(); + h.push(frame({ status: "already_decided" })); + assert.equal(await h.outcome, "acked"); +}); + +test("retransmitPermissionDecision ignores a control_result for a different nonce", async () => { + const h = harness(); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + // Foreign nonce and a non-permission frame must both be inert. + h.push(frame({ requestNonce: "other-nonce" })); + h.push({ type: "switch_model", status: "switched", requestNonce: NONCE }); + await drainMicrotasks(); + assert.equal(settled, false, "no foreign or off-type frame settles the loop"); + + h.push(frame()); + assert.equal(await h.outcome, "acked"); +}); + +test("retransmitPermissionDecision survives a rejected first send and acks when a later tick's send resolves", async () => { + // The causal case: the owner clicks while the relay socket is down. The first + // send rejects, but the loop must stay live so a later retransmit — once the + // socket recovers — delivers and acks. + const unhandled = []; + const onUnhandled = (reason) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + const h = harness({ failSends: 1 }); + await drainMicrotasks(); + assert.equal(h.sendCalls, 1, "first send fired and rejected"); + + // A later tick, after the socket recovers, resends successfully. + h.tick(); + await drainMicrotasks(); + assert.equal(h.sendCalls, 2, "the loop retries after a rejected send"); + + h.push(frame()); + assert.equal(await h.outcome, "acked"); + assert.equal(h.unsubscribeCalls, 1); + assert.equal(h.cancelRetransmitCalls, 1); + } finally { + process.off("unhandledRejection", onUnhandled); + } + assert.deepEqual(unhandled, [], "a rejected send must not surface unhandled"); +}); + +test("retransmitPermissionDecision expires cleanly when every send rejects", async () => { + const unhandled = []; + const onUnhandled = (reason) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + // Every send rejects (permanent transport failure). The loop must not throw + // — it keeps retrying until the deadline, then resolves "expired". + const h = harness({ failSends: Infinity }); + await drainMicrotasks(); + assert.equal(h.sendCalls, 1, "first send fired and rejected"); + + h.tick(); + await drainMicrotasks(); + assert.equal(h.sendCalls, 2, "keeps retrying through rejection"); + + h.expire(); + h.tick(); + assert.equal(await h.outcome, "expired"); + assert.equal(h.sendCalls, 2, "no resend past the deadline"); + assert.equal(h.unsubscribeCalls, 1, "listener torn down at expiry"); + assert.equal(h.cancelRetransmitCalls, 1, "scheduler torn down at expiry"); + } finally { + process.off("unhandledRejection", onUnhandled); + } + assert.deepEqual(unhandled, [], "rejected sends must not surface unhandled"); +}); + +test("retransmitPermissionDecision: channel_full keeps the loop active and resends until acked", async () => { + // `channel_full` is a transient queue-saturation signal. The loop must NOT + // settle on it — it stays subscribed and the scheduler keeps firing. A later + // `sent` frame (once the queue drains) must settle `acked`. + const h = harness(); + await drainMicrotasks(); + assert.equal(h.sendCalls, 1, "first send fired"); + + // Harness replies with channel_full — loop must stay active. + h.push(frame({ status: "channel_full" })); + await drainMicrotasks(); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + await drainMicrotasks(); + assert.equal(settled, false, "channel_full must not settle the loop"); + assert.equal( + h.cancelRetransmitCalls, + 0, + "scheduler must still be running after channel_full", + ); + assert.equal( + h.unsubscribeCalls, + 0, + "listener must still be subscribed after channel_full", + ); + + // Scheduler fires on the next tick — loop resends. + h.tick(); + await drainMicrotasks(); + assert.equal(h.sendCalls, 2, "loop resends on next tick after channel_full"); + + // A later `sent` frame settles the loop. + h.push(frame({ status: "sent" })); + assert.equal(await h.outcome, "acked"); + assert.equal(h.unsubscribeCalls, 1, "listener torn down on acked"); + assert.equal(h.cancelRetransmitCalls, 1, "scheduler torn down on acked"); +}); + +test("retransmitPermissionDecision: channel_full then deadline expires without acking resolves expired", async () => { + // If the deadline fires while waiting for the queue to drain, the loop + // resolves "expired" (fail-closed) — not "failed" and not stuck open. + const h = harness(); + await drainMicrotasks(); + + h.push(frame({ status: "channel_full" })); + await drainMicrotasks(); + + h.expire(); + h.tick(); + assert.equal(await h.outcome, "expired"); + assert.equal( + h.sendCalls, + 1, + "no resend after deadline while waiting on channel_full", + ); +}); + +test("retransmitPermissionDecision resolves failed on authoritative negative control_result statuses", async () => { + // The three authoritative failure statuses (no_active_turn / channel_closed / + // no_channel) mean the harness answered with a routing refusal. The loop must + // stop retransmitting (re-sending cannot change the refusal) and resolve + // "failed" so the card can re-enable for owner retry. + for (const status of ["no_active_turn", "channel_closed", "no_channel"]) { + const h = harness(); + await drainMicrotasks(); + assert.equal(h.sendCalls, 1, `first send fired (${status})`); + + h.push(frame({ status })); + assert.equal( + await h.outcome, + "failed", + `authoritative status "${status}" must resolve "failed"`, + ); + assert.equal(h.unsubscribeCalls, 1, `listener torn down on "${status}"`); + assert.equal( + h.cancelRetransmitCalls, + 1, + `scheduler torn down on "${status}"`, + ); + + // A tick after failure must NOT resend — the loop has settled. + h.tick(); + await drainMicrotasks(); + assert.equal( + h.sendCalls, + 1, + `no resend after failure settle on "${status}"`, + ); + } +}); + +test("retransmitPermissionDecision: negative result then retry delivers acked", async () => { + // Carl's exact regression: a failure reply leaves the card actionable, the + // owner retries (new harness() = fresh loop), and the second attempt succeeds. + const first = harness(); + await drainMicrotasks(); + first.push(frame({ status: "no_active_turn" })); + assert.equal(await first.outcome, "failed"); + + // Owner retries — fresh orchestrator instance. + const second = harness(); + await drainMicrotasks(); + second.push(frame({ status: "sent" })); + assert.equal(await second.outcome, "acked"); + assert.equal(second.unsubscribeCalls, 1); + assert.equal(second.cancelRetransmitCalls, 1); +}); + +test("retransmitPermissionDecision: frame after failure settlement is inert", async () => { + // A late duplicate `control_result` for the same nonce arriving after the + // loop has already settled (via a failure status) must not re-resolve. + const h = harness(); + await drainMicrotasks(); + h.push(frame({ status: "no_active_turn" })); + assert.equal(await h.outcome, "failed"); + + // The unsubscribe detaches the listener, so a late push is dropped. + // Verify the loop doesn't try to double-resolve or re-enable a second loop. + h.push(frame({ status: "sent" })); // arrives after settle — inert + h.tick(); // tick after settle must not resend + await drainMicrotasks(); + assert.equal(h.sendCalls, 1, "no resend after settled failure"); + assert.equal(h.unsubscribeCalls, 1, "listener detached exactly once"); +}); diff --git a/desktop/src/features/agents/lib/retransmitPermissionDecision.ts b/desktop/src/features/agents/lib/retransmitPermissionDecision.ts new file mode 100644 index 00000000000..1d1de6fc59c --- /dev/null +++ b/desktop/src/features/agents/lib/retransmitPermissionDecision.ts @@ -0,0 +1,132 @@ +import type { ControlResultFrame } from "@/shared/api/types"; + +/** + * Drive a permission decision to the agent harness with a retransmit-until-acked + * loop, so an owner's click survives a harness socket that is briefly down. + * + * The send side is otherwise fire-and-forget: `sendPermissionDecision` publishes + * one `permission_decision` observer control frame and the harness replies out + * of band with a `control_result`. Kind-24200 control frames are ephemeral — the + * relay never stores them — so a frame that reaches the relay while the agent's + * subscription is down is dropped and never delivered. A single fire-and-forget + * send therefore has no delivery guarantee: the card would hang until the 300 s + * fail-closed timeout even though the owner decided promptly. + * + * This orchestrator resends the decision on a fixed cadence until it observes a + * `control_result` for THIS nonce, then stops. The outcome depends on the frame's + * status: + * + * - `sent` / `already_decided` → the harness routed or previously + * delivery-suppressed the decision. Resolves `"acked"` (loop stops, card stays + * disabled while the terminal edit arrives). + * + * - `channel_full` → a transient queue-saturation condition (the owning read + * loop's 8-slot queue was full at the moment of delivery). The loop stays + * active and resends on the next scheduler tick; the owning loop's first-wins + * dedup tolerates duplicates once the queue drains. The card stays disabled + * during the automatic retry. Worst case: the deadline bound in + * `deadlineReached()` ends the loop with `"expired"` and the card fails closed + * exactly as it would without this retry. + * + * - `no_active_turn` / `channel_closed` / `no_channel` → authoritative routing + * refusals (no in-flight turn, channel gone). The loop resolves `"failed"`, + * stopping retransmission (re-sending the same nonce cannot change the refusal), + * and the card returns to the actionable state for owner retry. + * + * If no reply arrives before the card's own `expiresAt` deadline the loop + * resolves `"expired"`: the card times out on its own and a decision applied past + * expiry would be rejected anyway, so retransmitting past it is pointless. + * + * A nonce guard scopes the settling frame to this exact decision: a replayed or + * concurrent `control_result` for a different card carries a different nonce and + * is inert, mirroring the `requestId` guard in `awaitLiveSwitchOutcome`. + * + * A send rejection is the transport failure this loop exists to survive, so the + * orchestrator never throws: the first send and every retransmit run through + * the same guarded path, and a rejected attempt is swallowed — the next tick + * retries. This guarantees exactly one loop per click (the UI's disabled state + * guards double-click) and no unhandled rejection on any path. Permanent send + * failure therefore ends the same way as silence: the loop retries until + * `expiresAt`, then resolves `"expired"` and the card fails closed. + * + * The loop is isolated from React and the relay so it can be unit tested with a + * fake clock and synthetic frames. The caller injects the send, the + * `control_result` subscription, and the retransmit scheduler. + */ +export function retransmitPermissionDecision({ + requestNonce, + send, + subscribe, + scheduleRetransmit, + deadlineReached, +}: { + /** Nonce of the decision being delivered; frames without it are ignored. */ + requestNonce: string; + /** Publish one `permission_decision` frame. Resolves when the send settles. */ + send: () => Promise<void>; + /** Register a `control_result` listener; returns an unsubscribe function. */ + subscribe: (listener: (frame: ControlResultFrame) => void) => () => void; + /** + * Schedule the retransmit cadence; `onTick` fires once per interval. Returns + * a cancel function. The caller drives the real interval (e.g. every 2 s). + */ + scheduleRetransmit: (onTick: () => void) => () => void; + /** + * Whether the card's `expiresAt` deadline has passed. Checked before each + * retransmit so the loop never resends past expiry. + */ + deadlineReached: () => boolean; +}): Promise<"acked" | "expired" | "failed"> { + return new Promise<"acked" | "expired" | "failed">((resolve) => { + let unsubscribe = () => {}; + let cancelRetransmit = () => {}; + const finish = (outcome: "acked" | "expired" | "failed") => { + cancelRetransmit(); + unsubscribe(); + resolve(outcome); + }; + // Attempt one transmit, respecting the deadline and swallowing a rejected + // send. A rejection is a failed transmit, not a fatal error: the next tick + // retries, so the loop survives a socket that is briefly down. + const transmit = () => { + if (deadlineReached()) { + finish("expired"); + return; + } + void send().catch(() => {}); + }; + unsubscribe = subscribe((frame) => { + // A `control_result` for THIS nonce means the harness received the + // decision. Frames for other cards (or non-permission frames) carry a + // different nonce (or none) and are inert. + if ( + frame.type !== "permission_decision" || + frame.requestNonce !== requestNonce + ) { + return; + } + // `sent` / `already_decided` → harness routed or delivery-suppressed the + // decision; settle `acked`. + if (frame.status === "sent" || frame.status === "already_decided") { + finish("acked"); + return; + } + // `channel_full` is a transient queue-saturation signal: the owning read + // loop's queue was momentarily full. Do NOT settle — stay subscribed and + // let the scheduler keep resending. The card remains disabled until the + // loop either acks or the deadline expires. The owning loop's first-wins + // dedup tolerates the duplicate delivery once the queue drains. + if (frame.status === "channel_full") { + return; + } + // `no_active_turn` / `channel_closed` / `no_channel` are authoritative + // routing refusals — retransmitting the same nonce cannot change them. + // Settle `failed` so the card re-enables for owner retry. + finish("failed"); + }); + cancelRetransmit = scheduleRetransmit(transmit); + + // Fire the first send immediately, then let the scheduler drive resends. + transmit(); + }); +} diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 5e0a4ab9613..adfb1aa4714 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -69,6 +69,7 @@ export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = { provider: null, model: null, preferred_runtime: null, + permission_policy: null, }; const BAKED_STRUCTURED_KEYS = new Set([ diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx index 3564f31cb50..f6300b0da11 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx @@ -17,7 +17,7 @@ import { getGlobalAgentConfig, setGlobalAgentConfig, } from "@/shared/api/tauriGlobalAgentConfig"; -import type { GlobalAgentConfig } from "@/shared/api/types"; +import type { GlobalAgentConfig, PermissionPolicy } from "@/shared/api/types"; import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri"; import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfig"; import { @@ -303,6 +303,36 @@ export function AgentDefaultsEditor({ value={selectedRuntime?.id ?? ""} /> </div> + {/* Fleet-wide permission policy default */} + <div className="space-y-1.5"> + <label + className="text-sm font-medium text-foreground" + htmlFor="global-agent-default-permission-policy" + > + Default permission policy + </label> + <select + className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring" + data-testid="global-agent-default-permission-policy" + id="global-agent-default-permission-policy" + value={config.permission_policy ?? ""} + onChange={(e) => { + const val = e.target.value; + handleConfigChange({ + ...config, + permission_policy: + val === "" ? null : (val as PermissionPolicy), + }); + }} + > + <option value="">Inherit built-in (ask)</option> + <option value="ask">Ask — show Allow/Deny card</option> + <option value="allow"> + Allow — auto-approve (explicit opt-in) + </option> + <option value="reject">Reject — auto-deny</option> + </select> + </div> {flatLayout ? ( <AnimatePresence initial={false}> {configFields ? ( diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 205cf13a449..7674b3a8604 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -15,11 +15,9 @@ import { } from "@/features/agents/hooks"; import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOwnerOnly"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; -import type { - ManagedAgent, - RespondToMode, - UpdateManagedAgentInput, -} from "@/shared/api/types"; +import { AgentPermissionPolicyField } from "./AgentPermissionPolicyField"; +import { useRespondToField } from "./OwnerOnlyAccessField"; +import type { ManagedAgent, UpdateManagedAgentInput } from "@/shared/api/types"; import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -38,6 +36,7 @@ import { getDefaultLlmModelLabel, getDefaultPersonaRuntime, getPersonaProviderOptions, + getProviderApiKeyEnvVar, isMissingRequiredDropdownField, NO_RUNTIME_DROPDOWN_VALUE, PERSONA_FIELD_CONTROL_CLASS, @@ -81,7 +80,6 @@ import { getBakedModelInheritLabel, getBakedProviderInheritLabel, } from "./bakedEnvHelpers"; -import { getProviderApiKeyEnvVar } from "./agentConfigOptions"; import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; @@ -168,12 +166,7 @@ export function AgentInstanceEditDialog({ [agent.personaId, personasQuery.data], ); const inheritedEnvVars = linkedPersona?.envVars ?? {}; - const [respondTo, setRespondTo] = React.useState<RespondToMode>( - agent.respondTo, - ); - const [respondToAllowlist, setRespondToAllowlist] = React.useState<string[]>( - agent.respondToAllowlist, - ); + const rto = useRespondToField(agent); const [showAdvancedFields, setShowAdvancedFields] = React.useState(false); const [avatarUrl, setAvatarUrl] = React.useState(agent.avatarUrl ?? ""); const [isAvatarUploadPending, setIsAvatarUploadPending] = @@ -181,11 +174,9 @@ export function AgentInstanceEditDialog({ const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false); const shouldReduceMotion = useReducedMotion(); - // Runtime selector: defaults to "custom" until the dialog opens and the - // catalog loads. The open-effect re-derives the correct id from the catalog. + // Runtime selector: defaults to "custom"; open-effect re-derives from catalog. const [selectedRuntimeId, setSelectedRuntimeId] = React.useState("custom"); - // Tracks whether the user has made an in-dialog runtime selection. const runtimeTouched = React.useRef(false); // Reset form state only when the dialog opens or when switching to a different agent. @@ -208,11 +199,10 @@ export function AgentInstanceEditDialog({ setIsCustomProviderEditing(false); setEnvVars(agent.envVars); setAutoRestartOnConfigChange(agent.autoRestartOnConfigChange); + rto.reset(); setEffortLevel(null); effortTouched.current = false; setSetterError(null); - setRespondTo(agent.respondTo); - setRespondToAllowlist(agent.respondToAllowlist); setAvatarUrl(agent.avatarUrl ?? ""); setShowAdvancedFields(false); setIsAvatarUploadPending(false); @@ -625,8 +615,8 @@ export function AgentInstanceEditDialog({ parallelism, agentAcpCommand: agent.acpCommand, acpCommand, - respondTo, - respondToAllowlistLength: respondToAllowlist.length, + respondTo: rto.respondTo, + respondToAllowlistLength: rto.respondToAllowlist.length, selectedRuntimeId, inheritHarness, agentCommand, @@ -733,7 +723,8 @@ export function AgentInstanceEditDialog({ envVars: envVarsEqual(submitEnvVars, agent.envVars) ? undefined : submitEnvVars, - respondTo: respondTo !== agent.respondTo ? respondTo : undefined, + respondTo: + rto.respondTo !== agent.respondTo ? rto.respondTo : undefined, // The allowlist is preserved across mode toggles in local UI state // (so a user can flip away from allowlist and back without losing // their entries), but we only send it on the wire when (a) it @@ -741,10 +732,14 @@ export function AgentInstanceEditDialog({ // an allowlist while switching to a non-allowlist mode would be // harmless server-side, but it's noise in the persisted record. respondToAllowlist: - respondTo === "allowlist" && - respondToAllowlist.join(",") !== agent.respondToAllowlist.join(",") - ? respondToAllowlist + rto.respondTo === "allowlist" && + rto.respondToAllowlist.join(",") !== + agent.respondToAllowlist.join(",") + ? rto.respondToAllowlist : undefined, + // Permission policy is set on the agent definition, not here — the + // instance surface is display-only (effective value + drift row), so + // this update never touches the stored per-instance override. }; // Resolve effort before the update so access-change restarts can @@ -1002,12 +997,13 @@ export function AgentInstanceEditDialog({ </div> <OwnerOnlyAccessField accessLocked={agentAccessOwnerOnly === true} - allowlist={respondToAllowlist} + allowlist={rto.respondToAllowlist} disabled={isSaving} - mode={respondTo} - onAllowlistChange={setRespondToAllowlist} - onModeChange={setRespondTo} + mode={rto.respondTo} + onAllowlistChange={rto.setRespondToAllowlist} + onModeChange={rto.setRespondTo} /> + <AgentPermissionPolicyField agent={agent} /> <RunOnSummarySection backend={agent.backend} /> {/* Provider (runtime) */} diff --git a/desktop/src/features/agents/ui/AgentPermissionPolicyField.render.test.mjs b/desktop/src/features/agents/ui/AgentPermissionPolicyField.render.test.mjs new file mode 100644 index 00000000000..5ff7f0921ce --- /dev/null +++ b/desktop/src/features/agents/ui/AgentPermissionPolicyField.render.test.mjs @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { AgentPermissionPolicyField } from "./AgentPermissionPolicyField.tsx"; + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +// A remotely deployed agent: backend is a provider and a backendAgentId exists. +// The drift row only appears for this shape, so most cases build on it. +function deployedAgent(overrides) { + return { + backend: { type: "provider", id: "openclaw", config: {} }, + backendAgentId: "backend-1", + permissionPolicy: "reject", + permissionPolicySource: "global_default", + appliedPermissionPolicy: "allow", + ...overrides, + }; +} + +function render(agent) { + return renderToStaticMarkup( + React.createElement(AgentPermissionPolicyField, { agent }), + ); +} + +// --------------------------------------------------------------------------- +// Remote + drift: applied ≠ desired ⇒ amber drift row with both values +// --------------------------------------------------------------------------- + +test("test_remote_drift_shows_applied_desired_and_redeploy_required", () => { + const html = render(deployedAgent()); + + assert.ok( + html.includes("Applied policy:"), + "drift row must label the applied policy", + ); + assert.ok(html.includes("allow"), "drift row must show the applied value"); + assert.ok(html.includes("reject"), "drift row must show the desired value"); + assert.ok( + html.includes("redeploy required"), + "drift row must prompt a redeploy", + ); +}); + +// --------------------------------------------------------------------------- +// Remote, applied == desired: no drift row +// --------------------------------------------------------------------------- + +test("test_remote_no_drift_when_applied_equals_desired_renders_no_drift_row", () => { + const html = render( + deployedAgent({ + permissionPolicy: "allow", + appliedPermissionPolicy: "allow", + }), + ); + + assert.ok( + !html.includes("Applied policy:"), + "no drift row when applied equals desired", + ); +}); + +// --------------------------------------------------------------------------- +// Remote, applied absent (pre-feature / never-redeployed): no drift row +// --------------------------------------------------------------------------- + +test("test_remote_absent_applied_renders_no_drift_row", () => { + const html = render(deployedAgent({ appliedPermissionPolicy: null })); + + assert.ok( + !html.includes("Applied policy:"), + "absent applied policy must fail quiet — no drift row", + ); +}); + +// --------------------------------------------------------------------------- +// Local agent: display-only, never editable, never a drift row +// --------------------------------------------------------------------------- + +test("test_local_agent_renders_display_only_and_no_drift_row", () => { + const html = render({ + backend: { type: "local" }, + backendAgentId: null, + permissionPolicy: "reject", + permissionPolicySource: "global_default", + appliedPermissionPolicy: "allow", + }); + + assert.ok( + !html.includes("<select"), + "policy is edited on the definition — no instance-side select", + ); + assert.ok( + html.includes("agent definition"), + "local agent must point the owner to the definition surface", + ); + assert.ok( + !html.includes("Applied policy:"), + "local agent must never show a drift row", + ); +}); + +// --------------------------------------------------------------------------- +// Definition source resolves to a human label, not the raw enum +// --------------------------------------------------------------------------- + +test("test_definition_source_renders_agent_definition_label", () => { + const html = render( + deployedAgent({ + permissionPolicy: "allow", + permissionPolicySource: "definition", + appliedPermissionPolicy: "allow", + }), + ); + + assert.ok( + html.includes("from agent definition"), + "definition source must render its human label", + ); + assert.ok( + !html.includes("from definition"), + "raw enum value must not leak into the label", + ); +}); + +// --------------------------------------------------------------------------- +// Provider selected but not yet deployed (backendAgentId null): no drift row +// --------------------------------------------------------------------------- + +test("test_provider_not_yet_deployed_renders_no_drift_row", () => { + const html = render(deployedAgent({ backendAgentId: null })); + + assert.ok( + !html.includes("Applied policy:"), + "an undeployed provider agent has no confirmed receipt — no drift row", + ); +}); diff --git a/desktop/src/features/agents/ui/AgentPermissionPolicyField.tsx b/desktop/src/features/agents/ui/AgentPermissionPolicyField.tsx new file mode 100644 index 00000000000..7b7fbda4b91 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentPermissionPolicyField.tsx @@ -0,0 +1,66 @@ +import type { ManagedAgent } from "@/shared/api/types"; + +const SOURCE_LABEL: Record<string, string> = { + agent: "agent override", + definition: "agent definition", + global_default: "global default", + built_in: "built-in", +}; + +type Props = { + agent: Pick< + ManagedAgent, + | "backend" + | "backendAgentId" + | "permissionPolicy" + | "permissionPolicySource" + | "appliedPermissionPolicy" + >; +}; + +/** + * Instance-side, display-only view of an agent's effective permission policy. + * + * The editable default lives on the agent definition (create / edit-agent); + * this surface never sets policy. It shows the resolved effective value and, + * for a remotely deployed agent, the applied-vs-desired drift row that a + * per-instance receipt makes possible. A definition has no deploy receipt, so + * drift is inherently instance-scoped and stays here. + */ +export function AgentPermissionPolicyField({ agent }: Props) { + const isRemoteDeployed = + agent.backend.type === "provider" && agent.backendAgentId !== null; + const sourceLabel = + SOURCE_LABEL[agent.permissionPolicySource] ?? agent.permissionPolicySource; + + const hasDrift = + isRemoteDeployed && + agent.appliedPermissionPolicy !== null && + agent.appliedPermissionPolicy !== agent.permissionPolicy; + + return ( + <div className="space-y-1.5"> + <div className="flex items-center gap-1.5"> + <span className="text-sm font-medium text-foreground"> + Permission policy + </span> + <span className="text-xs text-muted-foreground"> + ({agent.permissionPolicy} · from {sourceLabel}) + </span> + </div> + <p className="text-xs text-muted-foreground"> + {isRemoteDeployed + ? "Read-only while deployed. To change, edit the agent definition and redeploy." + : "Set the default in the agent definition (Create / Edit agent)."} + </p> + {hasDrift && ( + <p className="text-xs text-amber-600 dark:text-amber-400"> + Applied policy:{" "} + <span className="font-medium">{agent.appliedPermissionPolicy}</span> · + Desired: <span className="font-medium">{agent.permissionPolicy}</span>{" "} + — redeploy required to apply. + </p> + )} + </div> + ); +} diff --git a/desktop/src/features/agents/ui/OwnerOnlyAccessField.tsx b/desktop/src/features/agents/ui/OwnerOnlyAccessField.tsx index 4ff7f351a06..bbe93eabcb7 100644 --- a/desktop/src/features/agents/ui/OwnerOnlyAccessField.tsx +++ b/desktop/src/features/agents/ui/OwnerOnlyAccessField.tsx @@ -1,9 +1,37 @@ -import type { RespondToMode } from "@/shared/api/types"; +import React from "react"; +import type { ManagedAgent, RespondToMode } from "@/shared/api/types"; import { CreateAgentRespondToField, OWNER_ONLY_ACCESS_DISABLED_REASON, } from "./RespondToField"; +/** + * Manages respondTo/respondToAllowlist state for the edit dialog. + * Returns the values, setters, and a `reset` function for discard/re-open. + */ +export function useRespondToField( + agent: Pick<ManagedAgent, "respondTo" | "respondToAllowlist">, +) { + const [respondTo, setRespondTo] = React.useState<RespondToMode>( + agent.respondTo, + ); + const [respondToAllowlist, setRespondToAllowlist] = React.useState<string[]>( + agent.respondToAllowlist, + ); + const reset = React.useCallback(() => { + setRespondTo(agent.respondTo); + setRespondToAllowlist(agent.respondToAllowlist); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [agent.respondTo, agent.respondToAllowlist]); + return { + respondTo, + setRespondTo, + respondToAllowlist, + setRespondToAllowlist, + reset, + }; +} + export function OwnerOnlyAccessField({ accessLocked, allowlist, diff --git a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx index c81d4405fb4..b472240ad85 100644 --- a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx @@ -8,6 +8,7 @@ import { OWNER_ONLY_ACCESS_DISABLED_REASON, } from "./RespondToField"; import type { PersonaBehaviorDraft } from "./personaBehaviorDraft"; +import { PersonaDropdownField } from "./PersonaDropdownField"; import { isBuzzAgentRuntime, BUZZ_AGENT_THINKING_EFFORT, @@ -27,13 +28,25 @@ import { PERSONA_FIELD_SHELL_CLASS, PERSONA_LABEL_OPTIONAL_CLASS, } from "./agentConfigOptions"; -import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import type { + AcpRuntimeCatalogEntry, + PermissionPolicy, +} from "@/shared/api/types"; import { deriveNumericDescriptors, structuredEnvKeys, type RuntimeCatalogStatus, } from "../lib/agentConfigCore"; +/** The definition-default policy dropdown. `""` is the inherit sentinel — + * it maps to a `null` draft value (defer to global/built-in `ask`). */ +const PERMISSION_POLICY_OPTIONS: readonly { label: string; value: string }[] = [ + { label: "Inherit (global default, else ask)", value: "" }, + { label: "Ask — show Allow/Deny card", value: "ask" }, + { label: "Allow — auto-approve (explicit opt-in)", value: "allow" }, + { label: "Reject — auto-deny", value: "reject" }, +]; + export function PersonaAdvancedFields({ behaviorDraft, disabled, @@ -205,6 +218,35 @@ export function PersonaAdvancedFields({ </div> </div> + <div className="space-y-1.5"> + <label + className="text-sm font-medium text-foreground" + htmlFor="persona-permission-policy" + > + Permission policy + <span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span> + </label> + <PersonaDropdownField + disabled={disabled} + id="persona-permission-policy" + onValueChange={(value) => + onBehaviorDraftChange({ + ...behaviorDraft, + permissionPolicy: + value === "" ? null : (value as PermissionPolicy), + }) + } + options={PERMISSION_POLICY_OPTIONS} + placeholder="Inherit (global default, else ask)" + value={behaviorDraft.permissionPolicy ?? ""} + /> + <p className="text-xs text-muted-foreground"> + How instances answer permission requests by default. A per-agent + override still wins; leaving this on Inherit defers to the global + default. + </p> + </div> + <div className="space-y-1.5"> <label className="text-sm font-medium text-foreground" diff --git a/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.render.test.mjs b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.render.test.mjs new file mode 100644 index 00000000000..587a38703b2 --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.render.test.mjs @@ -0,0 +1,933 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, mock, test } from "node:test"; + +import { JSDOM } from "jsdom"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { LifecycleActivity } from "./LifecycleActivity.tsx"; +import { buildTranscript } from "../agentSessionTranscript.ts"; + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const BASE_PROPS = { + agentAvatarUrl: null, + agentName: "Test Agent", + agentPubkey: "pubkey123", +}; + +const BASE_IDENTITY = { + turnId: "turn-1", + sessionId: "session-1", + channelId: "channel-1", +}; + +/** + * Build a pending permission lifecycle item with the given options array. + * The card is actionable (awaiting a user decision) and has a request nonce. + */ +function pendingPermissionItem(options) { + return { + id: "perm-1", + type: "lifecycle", + renderClass: "permission", + title: "Tool requires approval", + text: "Run shell command", + timestamp: "2026-08-10T00:00:00.000Z", + requestNonce: "nonce-abc", + actionable: true, + options, + ...BASE_IDENTITY, + }; +} + +// --------------------------------------------------------------------------- +// jsdom + fake-timer setup (required for the interactive delivery-seam tests) +// The static renderToStaticMarkup tests do not use document/window but the +// setup is harmless for them: it only assigns globals they never read. +// --------------------------------------------------------------------------- + +const dom = new JSDOM("<!doctype html><html><body></body></html>", { + url: "http://localhost", +}); + +// Deterministic wall-clock epoch — far from real time to avoid expiry surprises. +// expiresAt is set to FAKE_NOW_SECS + 9_999_999 in the interactive tests so +// the card never expires during the test. +const FAKE_NOW_MS = 1_000_000_000_000; + +before(() => { + mock.timers.enable({ apis: ["setInterval", "Date"], now: FAKE_NOW_MS }); + + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + MutationObserver: class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + dom.window.MutationObserver = globalThis.MutationObserver; + dom.window.ResizeObserver = globalThis.ResizeObserver; +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); + mock.timers.reset(); + mock.timers.enable({ apis: ["setInterval", "Date"], now: FAKE_NOW_MS }); +}); + +after(() => { + mock.timers.reset(); + dom.window.close(); +}); + +test("test_allow_once_renders_actionable_allow_button", () => { + const html = renderToStaticMarkup( + React.createElement(LifecycleActivity, { + ...BASE_PROPS, + item: pendingPermissionItem([ + { optionId: "opt-allow", kind: "allow_once", label: "Allow once" }, + ]), + }), + ); + + // The button must be present and labelled correctly. + assert.ok( + html.includes("permission-decision-opt-allow"), + "allow_once option should render a button with its optionId testid", + ); + assert.ok( + html.includes("Allow once"), + "allow_once option should show its label", + ); + + // The persistent-grant badge must NOT appear for a pure allow_once card. + assert.ok( + !html.includes("permission-decision-persistent-grant"), + "allow_once card should not render the persistent-grant badge", + ); +}); + +// --------------------------------------------------------------------------- +// reject_once — renders a red actionable Deny button +// --------------------------------------------------------------------------- + +test("test_reject_once_renders_actionable_deny_button", () => { + const html = renderToStaticMarkup( + React.createElement(LifecycleActivity, { + ...BASE_PROPS, + item: pendingPermissionItem([ + { optionId: "opt-deny", kind: "reject_once" }, + ]), + }), + ); + + assert.ok( + html.includes("permission-decision-opt-deny"), + "reject_once option should render a button with its optionId testid", + ); + // Deny button uses destructive styling; verify at least the testid is there. + assert.ok( + !html.includes("permission-decision-persistent-grant"), + "reject_once card should not render the persistent-grant badge", + ); +}); + +// --------------------------------------------------------------------------- +// allow_always — not actionable, no badge (F3: persistent-grant badge removed) +// --------------------------------------------------------------------------- + +test("test_allow_always_renders_no_button_and_no_badge", () => { + // After F3: allow_always is NOT in ACTIONABLE_KINDS and the persistent-grant + // badge has been removed. A card with only allow_always renders nothing + // actionable — no button and no badge — because the two-option contract + // (allow_once / reject_once only) is enforced at both the Rust sentinel and + // the observer surface. + const html = renderToStaticMarkup( + React.createElement(LifecycleActivity, { + ...BASE_PROPS, + item: pendingPermissionItem([ + { optionId: "opt-always", kind: "allow_always", label: "Always allow" }, + ]), + }), + ); + + // No button for allow_always. + assert.ok( + !html.includes("permission-decision-opt-always"), + "allow_always option must not render an actionable button", + ); + // No <button> element at all — no actionable options. + assert.ok( + !html.includes("<button"), + "allow_always-only card must not render any button element", + ); + // The persistent-grant badge is gone — it was the only surface that showed + // allow_always and it has been removed in F3. + assert.ok( + !html.includes("permission-decision-persistent-grant"), + "persistent-grant badge must not render after F3 removal", + ); + assert.ok( + !html.includes("Permanent grant"), + "persistent-grant copy must not render after F3 removal", + ); +}); + +// --------------------------------------------------------------------------- +// Unknown kind — fail closed: renders nothing actionable, no badge +// --------------------------------------------------------------------------- + +test("test_unknown_kind_fails_closed_renders_nothing", () => { + const html = renderToStaticMarkup( + React.createElement(LifecycleActivity, { + ...BASE_PROPS, + item: pendingPermissionItem([ + { optionId: "opt-mystery", kind: "future_unknown_verb" }, + ]), + }), + ); + + // No button for the unknown kind. + assert.ok( + !html.includes("permission-decision-opt-mystery"), + "unknown kind must not render an actionable button", + ); + // No persistent-grant badge either. + assert.ok( + !html.includes("permission-decision-persistent-grant"), + "unknown kind must not render the persistent-grant badge", + ); + // No button element at all. + assert.ok( + !html.includes("<button"), + "unknown-kind-only card must not render any button element", + ); + // The outer permission card shell is still rendered (title row etc.). + assert.ok( + html.includes("transcript-permission-item"), + "unknown kind still renders the permission card shell", + ); +}); + +// --------------------------------------------------------------------------- +// Unknown reject_*-prefixed kind — fail closed: exact allowlist, not prefix +// --------------------------------------------------------------------------- + +test("test_unknown_reject_prefixed_kind_fails_closed_renders_nothing", () => { + const html = renderToStaticMarkup( + React.createElement(LifecycleActivity, { + ...BASE_PROPS, + item: pendingPermissionItem([ + { optionId: "opt-reject-future", kind: "reject_later_v2" }, + ]), + }), + ); + + // A reject-prefixed but unrecognized kind must NOT render a trusted button: + // recognition is an exact allowlist, not a prefix match. + assert.ok( + !html.includes("permission-decision-opt-reject-future"), + "unknown reject_*-prefixed kind must not render an actionable button", + ); + assert.ok( + !html.includes("<button"), + "unknown reject_*-prefixed-only card must not render any button element", + ); + assert.ok( + !html.includes("permission-decision-persistent-grant"), + "unknown reject_*-prefixed kind must not render the persistent-grant badge", + ); + // The outer permission card shell is still rendered. + assert.ok( + html.includes("transcript-permission-item"), + "unknown reject_*-prefixed kind still renders the permission card shell", + ); +}); + +// --------------------------------------------------------------------------- +// reject_always — not actionable (F3: removed from ACTIONABLE_KINDS) +// --------------------------------------------------------------------------- + +test("test_reject_always_renders_no_button", () => { + // After F3: reject_always is removed from ACTIONABLE_KINDS. The thread card + // cannot grant permanent denial; the ACP read loop accepts only allow_once and + // reject_once. A reject_always option must render as inert — no clickable + // button. The outer card shell is still rendered (the activity still appears + // in the transcript), but no action can be taken on it. + const html = renderToStaticMarkup( + React.createElement(LifecycleActivity, { + ...BASE_PROPS, + item: pendingPermissionItem([ + { optionId: "opt-reject-always", kind: "reject_always" }, + ]), + }), + ); + + // Must NOT render a clickable button for reject_always. + assert.ok( + !html.includes("permission-decision-opt-reject-always"), + "reject_always must not render an actionable button after F3", + ); + // No button element at all — no actionable options present. + assert.ok( + !html.includes("<button"), + "reject_always-only card must not render any button element after F3", + ); + // No persistent-grant badge either. + assert.ok( + !html.includes("permission-decision-persistent-grant"), + "reject_always card must not render the persistent-grant badge", + ); + // The outer card shell IS rendered. + assert.ok( + html.includes("transcript-permission-item"), + "reject_always still renders the outer permission card shell", + ); +}); + +// --------------------------------------------------------------------------- +// Mixed options — allow_once + allow_always: only allow_once actionable +// No persistent-grant badge after F3 removal +// --------------------------------------------------------------------------- + +test("test_mixed_allow_once_and_allow_always_only_allow_once_actionable", () => { + // After F3: allow_always is not in ACTIONABLE_KINDS and the persistent-grant + // badge is removed. A mixed card renders only the allow_once button; allow_always + // is inert context with no UI surface. + const html = renderToStaticMarkup( + React.createElement(LifecycleActivity, { + ...BASE_PROPS, + item: pendingPermissionItem([ + { optionId: "opt-once", kind: "allow_once" }, + { optionId: "opt-always", kind: "allow_always" }, + ]), + }), + ); + + // allow_once produces a button. + assert.ok( + html.includes("permission-decision-opt-once"), + "allow_once in mixed card should render a button", + ); + // allow_always does NOT produce a button. + assert.ok( + !html.includes("permission-decision-opt-always"), + "allow_always in mixed card must not render a button", + ); + // No persistent-grant badge — it has been removed. + assert.ok( + !html.includes("permission-decision-persistent-grant"), + "mixed card must not render the persistent-grant badge after F3 removal", + ); + assert.ok( + !html.includes("Permanent grant"), + "mixed card must not render persistent-grant copy after F3 removal", + ); +}); + +// --------------------------------------------------------------------------- +// F3 contract: all four adapter option kinds — only allow_once + reject_once +// are actionable; allow_always and reject_always are inert. +// --------------------------------------------------------------------------- + +test("test_four_option_contract_only_allow_once_and_reject_once_actionable", () => { + // The two-option contract: the thread card may only action allow_once and + // reject_once. This test covers all four recognized adapter option kinds in + // a single card and verifies the exact set of rendered buttons. + // + // Mutation proof: removing "reject_once" from ACTIONABLE_KINDS in + // LifecycleActivity.tsx makes "permission-decision-opt-deny" absent — the + // assertion on opt-deny goes red. Removing "allow_once" makes opt-allow + // absent similarly. The ACP read loop on the Rust side accepts only the + // two option IDs snapshotted into CardActions (allow_once / reject_once); + // sending an allow_always or reject_always option ID is silently ignored. + const html = renderToStaticMarkup( + React.createElement(LifecycleActivity, { + ...BASE_PROPS, + item: pendingPermissionItem([ + { optionId: "opt-allow", kind: "allow_once", label: "Allow once" }, + { optionId: "opt-deny", kind: "reject_once", label: "Deny" }, + { optionId: "opt-always", kind: "allow_always", label: "Always allow" }, + { + optionId: "opt-reject-always", + kind: "reject_always", + label: "Always deny", + }, + ]), + }), + ); + + // Only allow_once and reject_once render buttons. + assert.ok( + html.includes("permission-decision-opt-allow"), + "allow_once must render an actionable button", + ); + assert.ok( + html.includes("permission-decision-opt-deny"), + "reject_once must render an actionable button", + ); + + // allow_always and reject_always must NOT render buttons. + assert.ok( + !html.includes("permission-decision-opt-always"), + "allow_always must not render a button in a mixed four-option card", + ); + assert.ok( + !html.includes("permission-decision-opt-reject-always"), + "reject_always must not render a button in a mixed four-option card", + ); + + // No persistent-grant badge — removed in F3. + assert.ok( + !html.includes("permission-decision-persistent-grant"), + "four-option card must not render the persistent-grant badge", + ); + // Exactly two <button> elements (allow_once + reject_once). + const buttonCount = (html.match(/<button/g) ?? []).length; + assert.equal( + buttonCount, + 2, + `four-option card must render exactly 2 buttons (allow_once + reject_once); got ${buttonCount}`, + ); +}); + +// --------------------------------------------------------------------------- +// F3 cross-layer: acp_read → buildTranscript → LifecycleActivity +// +// Starts with all four adapter option kinds in the request payload. +// Drives the event through the full transcript reducer so the card is built +// from the real processing path, not a hand-rolled fixture. +// Then renders via LifecycleActivity and confirms the two-button contract. +// --------------------------------------------------------------------------- + +test("test_f3_cross_layer_four_options_acp_read_to_lifecycle_activity_two_buttons", () => { + // Build an acp_read event carrying all four adapter option kinds. + // This is the real wire shape the observer feed emits when the agent + // requests permission with a full four-option set. + const acpReadEvent = { + seq: 1, + timestamp: "2026-09-01T10:00:00.000Z", + kind: "acp_read", + agentIndex: 0, + channelId: "ch-f3-cross", + sessionId: "sess-f3-cross", + turnId: "turn-f3-cross", + payload: { + jsonrpc: "2.0", + id: "req-f3", + method: "session/request_permission", + params: { + title: "Tool requires approval", + toolCallId: "tc-f3", + // Four option kinds offered by the adapter. + options: [ + { + optionId: "opt-allow-once", + kind: "allow_once", + name: "Allow once", + }, + { optionId: "opt-reject-once", kind: "reject_once", name: "Deny" }, + { + optionId: "opt-allow-always", + kind: "allow_always", + name: "Always allow", + }, + { + optionId: "opt-reject-always", + kind: "reject_always", + name: "Always deny", + }, + ], + }, + }, + // Authorization envelope: marks the card as actionable with a nonce. + authorization: { + requestNonce: "nonce-f3-cross", + actionable: true, + }, + }; + + // 1. Drive through the transcript reducer. + const transcript = buildTranscript([acpReadEvent]); + const card = transcript.find((item) => item.renderClass === "permission"); + assert.ok(card, "transcript must contain a permission card"); + assert.equal( + card.requestNonce, + "nonce-f3-cross", + "card must carry the request nonce", + ); + assert.ok(card.actionable, "card must be actionable"); + assert.ok(Array.isArray(card.options), "card must have options"); + assert.equal(card.options.length, 4, "all four options must be on the card"); + + // 2. Render via LifecycleActivity and assert the two-button contract. + const html = renderToStaticMarkup( + React.createElement(LifecycleActivity, { + ...BASE_PROPS, + item: card, + }), + ); + + // Only allow_once and reject_once render buttons (ACTIONABLE_KINDS contract). + assert.ok( + html.includes("permission-decision-opt-allow-once"), + "allow_once must render a button via cross-layer path", + ); + assert.ok( + html.includes("permission-decision-opt-reject-once"), + "reject_once must render a button via cross-layer path", + ); + + // allow_always and reject_always must NOT render buttons. + assert.ok( + !html.includes("permission-decision-opt-allow-always"), + "allow_always must not render a button via cross-layer path", + ); + assert.ok( + !html.includes("permission-decision-opt-reject-always"), + "reject_always must not render a button via cross-layer path", + ); + + // Exactly two <button> elements. + const buttonCount = (html.match(/<button/g) ?? []).length; + assert.equal( + buttonCount, + 2, + `cross-layer four-option card must render exactly 2 buttons; got ${buttonCount}`, + ); +}); + +// --------------------------------------------------------------------------- +// Cross-layer reducer+mounted regression: channel_full leaves buttons disabled +// +// Proves that the full pipeline — acp_read → buildTranscript reducer → +// LifecycleActivity component — correctly leaves both buttons DISABLED after +// a `channel_full` control_result, matching the "transient, retransmit +// orchestrator keeps going" contract. +// +// The companion case proves authoritative failures (`no_active_turn`) DO +// re-enable buttons — so the effect path is also covered. +// +// Mutation proof: restoring `channel_full` to increment `deliveryFailed` in +// `handlePermissionDecisionResult` → the card acquires `deliveryFailed: 1` → +// the component re-renders with `deliveryFailed={1}` → the useEffect fires → +// `setPending(null)` re-enables both buttons → the disabled assertion fails. +// --------------------------------------------------------------------------- + +test("test_channel_full_reducer_to_component_buttons_stay_disabled", async () => { + const { createElement, act } = await import("react"); + const { render, fireEvent } = await import("@testing-library/react"); + + const FAKE_NOW_SECS = Math.floor(FAKE_NOW_MS / 1000); + const FUTURE_EXPIRY = FAKE_NOW_SECS + 9_999_999; + const nonce = "nonce-cross-layer-cf"; + + // Base acp_read event. + const acpReadEvent = { + seq: 1, + timestamp: "2026-09-01T10:00:00.000Z", + kind: "acp_read", + agentIndex: 0, + channelId: "ch-cross-cf", + sessionId: "sess-cross-cf", + turnId: "turn-cross-cf", + payload: { + jsonrpc: "2.0", + id: "req-cross-cf", + method: "session/request_permission", + params: { + title: "Tool requires approval", + toolCallId: "tc-cross-cf", + options: [ + { + optionId: "opt-allow-once", + kind: "allow_once", + name: "Allow once", + }, + { optionId: "opt-reject-once", kind: "reject_once", name: "Deny" }, + ], + }, + }, + authorization: { + requestNonce: nonce, + actionable: true, + expiresAt: FUTURE_EXPIRY, + }, + }; + + // `channel_full` control_result — transient; must NOT set deliveryFailed. + const channelFullResult = { + seq: 2, + timestamp: "2026-09-01T10:00:01.000Z", + kind: "control_result", + agentIndex: 0, + channelId: "ch-cross-cf", + sessionId: "sess-cross-cf", + turnId: "turn-cross-cf", + payload: { + type: "permission_decision", + status: "channel_full", + requestNonce: nonce, + optionId: "opt-allow-once", + }, + }; + + // `no_active_turn` control_result — authoritative failure; MUST set deliveryFailed. + const authoritativeFailure = { + seq: 2, + timestamp: "2026-09-01T10:00:01.000Z", + kind: "control_result", + agentIndex: 0, + channelId: "ch-cross-cf", + sessionId: "sess-cross-cf", + turnId: "turn-cross-cf", + payload: { + type: "permission_decision", + status: "no_active_turn", + requestNonce: nonce, + optionId: "opt-allow-once", + }, + }; + + // Build both card states through the real transcript reducer. + const cardAfterChannelFull = buildTranscript([ + acpReadEvent, + channelFullResult, + ]).find((i) => i.renderClass === "permission"); + const cardAfterAuthoritativeFailure = buildTranscript([ + acpReadEvent, + authoritativeFailure, + ]).find((i) => i.renderClass === "permission"); + assert.ok(cardAfterChannelFull, "card must exist after channel_full"); + assert.ok( + cardAfterAuthoritativeFailure, + "card must exist after authoritative failure", + ); + + // Reducer-level gate: channel_full must NOT set deliveryFailed. + assert.equal( + cardAfterChannelFull.deliveryFailed, + undefined, + "channel_full must not set deliveryFailed in the reducer (mutation: restoring increment → 1 here → test fails)", + ); + // Reducer-level gate: no_active_turn MUST set deliveryFailed. + assert.equal( + cardAfterAuthoritativeFailure.deliveryFailed, + 1, + "no_active_turn must set deliveryFailed in the reducer", + ); + + // ── Component: channel_full → buttons stay disabled ─────────────────────── + // Start with the initial card (no deliveryFailed), click Allow to set pending. + const initialCard = buildTranscript([acpReadEvent]).find( + (i) => i.renderClass === "permission", + ); + + // Track delivery calls to ensure no second delivery is started. + const deliveryCalls = []; + // The first delivery is intentionally stalled — never resolves. + function stalledDelivery({ optionId }) { + deliveryCalls.push(optionId); + return new Promise(() => {}); + } + + let container, rerender; + await act(async () => { + ({ container, rerender } = render( + createElement(LifecycleActivity, { + ...BASE_PROPS, + item: initialCard, + _deliveryFn: stalledDelivery, + }), + )); + }); + + // Click Allow — sets pending, disables both buttons. + const allowBtn = container.querySelector( + '[data-testid="permission-decision-opt-allow-once"]', + ); + assert.ok(allowBtn, "allow_once button must be present before click"); + await act(async () => { + fireEvent.click(allowBtn); + await Promise.resolve(); + }); + assert.equal(deliveryCalls.length, 1, "first delivery must fire on click"); + + // Now rerender with the post-channel_full card (deliveryFailed undefined). + // The useEffect must NOT fire (deliveryFailed didn't change), so pending stays + // set and both buttons remain disabled. + await act(async () => { + rerender( + createElement(LifecycleActivity, { + ...BASE_PROPS, + item: cardAfterChannelFull, + _deliveryFn: stalledDelivery, + }), + ); + await Promise.resolve(); + }); + + const allowBtnAfterCF = container.querySelector( + '[data-testid="permission-decision-opt-allow-once"]', + ); + const denyBtnAfterCF = container.querySelector( + '[data-testid="permission-decision-opt-reject-once"]', + ); + assert.ok(allowBtnAfterCF, "allow button must still be in DOM"); + assert.ok(denyBtnAfterCF, "deny button must still be in DOM"); + assert.ok( + allowBtnAfterCF.disabled, + "allow button must remain DISABLED after channel_full (mutation: increment deliveryFailed → setPending(null) fires → button enabled → this fails)", + ); + assert.ok( + denyBtnAfterCF.disabled, + "deny button must remain DISABLED after channel_full — both buttons stay disabled during automatic retry", + ); + assert.equal( + deliveryCalls.length, + 1, + "no second delivery must start after channel_full — retransmit orchestrator handles resend, not a second click", + ); + + // ── Companion: authoritative failure re-enables buttons ─────────────────── + // Render a fresh card, click, then rerender with deliveryFailed: 1. + const deliveryCalls2 = []; + function stalledDelivery2({ optionId }) { + deliveryCalls2.push(optionId); + return new Promise(() => {}); + } + + let container2, rerender2; + await act(async () => { + ({ container: container2, rerender: rerender2 } = render( + createElement(LifecycleActivity, { + ...BASE_PROPS, + item: initialCard, + _deliveryFn: stalledDelivery2, + }), + )); + }); + + const allowBtn2 = container2.querySelector( + '[data-testid="permission-decision-opt-allow-once"]', + ); + assert.ok(allowBtn2, "allow button must be present for companion case"); + await act(async () => { + fireEvent.click(allowBtn2); + await Promise.resolve(); + }); + + // Rerender with authoritative failure card (deliveryFailed: 1). + // useEffect sees deliveryFailed change 0→1 → setPending(null) → buttons enabled. + await act(async () => { + rerender2( + createElement(LifecycleActivity, { + ...BASE_PROPS, + item: cardAfterAuthoritativeFailure, + _deliveryFn: stalledDelivery2, + }), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + const allowBtnAfterFail = container2.querySelector( + '[data-testid="permission-decision-opt-allow-once"]', + ); + assert.ok( + !allowBtnAfterFail.disabled, + "allow button must be RE-ENABLED after no_active_turn — user can retry", + ); +}); + +// --------------------------------------------------------------------------- +// F3 interactive delivery-seam: acp_read → buildTranscript → LifecycleActivity +// click buttons → assert _deliveryFn called with ruled allow_once/reject_once IDs +// +// Mutation proof: removing `allow_once` from ACTIONABLE_KINDS → the allow_once +// button is not rendered → fireEvent.click finds no element → first delivery +// assertion fails. Removing `reject_once` → same for reject_once. +// Removing both → zero delivery calls → both assertions fail. +// --------------------------------------------------------------------------- + +test("test_f3_interactive_delivery_seam_allow_once_and_reject_once_fire_delivery", async () => { + const { createElement, act } = await import("react"); + const { render, fireEvent } = await import("@testing-library/react"); + + const FAKE_NOW_SECS = Math.floor(FAKE_NOW_MS / 1000); + const FUTURE_EXPIRY = FAKE_NOW_SECS + 9_999_999; + + // Record delivery calls: { optionId, requestNonce }[] + const deliveryCalls = []; + function mockDeliveryFn({ optionId, requestNonce }) { + deliveryCalls.push({ optionId, requestNonce }); + // Resolve as "acked" so the component doesn't re-enable the button. + return Promise.resolve("acked"); + } + + // Build the transcript card from a real acp_read event carrying all four + // option kinds — same wire shape as the static cross-layer test above. + const acpReadEvent = { + seq: 1, + timestamp: "2026-09-01T10:00:00.000Z", + kind: "acp_read", + agentIndex: 0, + channelId: "ch-interactive", + sessionId: "sess-interactive", + turnId: "turn-interactive", + payload: { + jsonrpc: "2.0", + id: "req-interactive", + method: "session/request_permission", + params: { + title: "Tool requires approval", + toolCallId: "tc-interactive", + options: [ + { + optionId: "opt-allow-once", + kind: "allow_once", + name: "Allow once", + }, + { optionId: "opt-reject-once", kind: "reject_once", name: "Deny" }, + { + optionId: "opt-allow-always", + kind: "allow_always", + name: "Always allow", + }, + { + optionId: "opt-reject-always", + kind: "reject_always", + name: "Always deny", + }, + ], + }, + }, + authorization: { + requestNonce: "nonce-interactive", + actionable: true, + expiresAt: FUTURE_EXPIRY, + }, + }; + + const transcript = buildTranscript([acpReadEvent]); + const card = transcript.find((item) => item.renderClass === "permission"); + assert.ok(card, "transcript must contain a permission card"); + assert.ok(card.actionable, "card must be actionable"); + + let container; + await act(async () => { + ({ container } = render( + createElement(LifecycleActivity, { + ...BASE_PROPS, + item: card, + _deliveryFn: mockDeliveryFn, + }), + )); + }); + + // ── Click allow_once — must call delivery with opt-allow-once ───────────── + const allowBtn = container.querySelector( + '[data-testid="permission-decision-opt-allow-once"]', + ); + assert.ok( + allowBtn !== null, + "allow_once button must be present (ACTIONABLE_KINDS must include allow_once)", + ); + await act(async () => { + fireEvent.click(allowBtn); + // Drain microtasks so the async delivery fn resolves. + await Promise.resolve(); + await Promise.resolve(); + }); + assert.equal( + deliveryCalls.length, + 1, + "exactly one delivery call after clicking allow_once; mutation: remove allow_once from ACTIONABLE_KINDS → zero calls", + ); + assert.equal( + deliveryCalls[0].optionId, + "opt-allow-once", + "delivery must be called with the allow_once optionId; mutation: wrong id → fails", + ); + assert.equal( + deliveryCalls[0].requestNonce, + "nonce-interactive", + "delivery must carry the card's requestNonce", + ); + + // ── allow_always must NOT have a button (not in ACTIONABLE_KINDS) ───────── + assert.equal( + container.querySelector( + '[data-testid="permission-decision-opt-allow-always"]', + ), + null, + "allow_always must not render a clickable button", + ); + + // ── reject_always must NOT have a button either ─────────────────────────── + assert.equal( + container.querySelector( + '[data-testid="permission-decision-opt-reject-always"]', + ), + null, + "reject_always must not render a clickable button", + ); + + // ── Render a fresh card and click reject_once ────────────────────────────── + // Use a separate render to avoid the pending-state from the allow_once click + // disabling the reject_once button. + deliveryCalls.length = 0; + let container2; + await act(async () => { + ({ container: container2 } = render( + createElement(LifecycleActivity, { + ...BASE_PROPS, + item: card, + _deliveryFn: mockDeliveryFn, + }), + )); + }); + + const rejectBtn = container2.querySelector( + '[data-testid="permission-decision-opt-reject-once"]', + ); + assert.ok( + rejectBtn !== null, + "reject_once button must be present (ACTIONABLE_KINDS must include reject_once)", + ); + await act(async () => { + fireEvent.click(rejectBtn); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.equal( + deliveryCalls.length, + 1, + "exactly one delivery call after clicking reject_once; mutation: remove reject_once from ACTIONABLE_KINDS → zero calls", + ); + assert.equal( + deliveryCalls[0].optionId, + "opt-reject-once", + "delivery must be called with the reject_once optionId; mutation: wrong id → fails", + ); +}); diff --git a/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx index 53a00e64638..629ad51a01b 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx @@ -1,5 +1,10 @@ import { AlertCircle, CheckCircle2, ShieldCheck, XCircle } from "lucide-react"; +import * as React from "react"; +import { + resolveDecisionDeadlineSecs, + startPermissionDecisionDelivery, +} from "@/features/agents/lib/permissionDecisionDelivery"; import { formatTranscriptTimestampTitle } from "../agentSessionUtils"; import { ActivityRow, ActivityRowLabel } from "./ActivityRow"; import { ToolActivity } from "./ToolActivity"; @@ -29,7 +34,7 @@ function splitPermissionText(text: string): { /** * Derive the visual tone and icon for a resolved permission outcome string. * Outcome strings come from describePermissionOutcome: - * "Approved (...)" | "Denied (...)" | "Cancelled" + * "Approved (...)" | "Denied (...)" | "Cancelled" | "uncertain" pinned copy */ function permissionOutcomeTone(outcome: string): "approve" | "deny" | "cancel" { if (outcome.startsWith("Approved")) return "approve"; @@ -37,7 +42,161 @@ function permissionOutcomeTone(outcome: string): "approve" | "deny" | "cancel" { return "cancel"; } -export function LifecycleActivity(props: ActivityRenderClassItemProps) { +/** + * Exact recognized permission-option kinds the observer feed can act on. + * Only `allow_once` and `reject_once` are actionable — matching the thread + * card's two-option contract. `reject_always` is deliberately excluded: the + * read loop accepts only the two snapshotted ruled IDs (allow_once/reject_once), + * so a `reject_always` click would be sent, acknowledged as "sent", but silently + * ignored by the loop — the request would stay pending until timeout with no + * persistent denial installed. Any kind not in this set is treated as unknown + * and rendered non-actionable. + */ +const ACTIONABLE_KINDS = new Set(["allow_once", "reject_once"]); + +function isActionableKind(kind: string): boolean { + return ACTIONABLE_KINDS.has(kind); +} + +/** + * Default button label when the harness omits one. + */ +function defaultOptionLabel(kind: string): string { + if (kind === "reject_once") return "Deny"; + return "Allow"; +} + +/** + * Allow/Deny buttons for an actionable permission card. + * Renders the agent's exact options as labeled buttons; a click sends the + * `permission_decision` control event (fire-and-forget). + * + * On authoritative delivery failure (`no_active_turn`, `channel_closed`, + * `no_channel`), buttons are re-enabled so the user can retry. The transient + * `channel_full` status does NOT re-enable buttons — the retransmit + * orchestrator handles that status automatically and keeps resending. The + * harness's 300 s fail-closed timeout is the backstop for permanently lost + * frames. + */ +function PermissionDecisionButtons({ + agentPubkey, + channelId, + options, + requestNonce, + deliveryFailed, + deadlineSecs, + _deliveryFn, +}: { + agentPubkey: string; + channelId: string; + options: Array<{ optionId: string; kind: string; label?: string }>; + requestNonce: string; + /** + * Monotonically increasing failure token from the reducer — incremented on + * every authoritative delivery failure (`no_active_turn`, `channel_closed`, + * `no_channel`). The transient `channel_full` status does NOT increment this + * token; the retransmit orchestrator handles that status automatically. + * Keying the effect on this number (not a boolean) ensures a second failure + * after a retry also re-enables buttons. + */ + deliveryFailed?: number; + /** + * Effective expiry deadline (unix seconds) bounding the retransmit loop. + */ + deadlineSecs: number; + /** + * Seam for testing — injects a mock delivery function without needing + * `mock.module`. Production callers omit this; the real + * `startPermissionDecisionDelivery` is used by default. + */ + _deliveryFn?: typeof startPermissionDecisionDelivery; +}) { + const deliveryFn = _deliveryFn ?? startPermissionDecisionDelivery; + const [pending, setPending] = React.useState<string | null>(null); + + // Re-enable buttons when the reducer signals an authoritative delivery + // failure (`no_active_turn`, `channel_closed`, `no_channel`). The transient + // `channel_full` status does NOT increment this token — the retransmit + // orchestrator stays subscribed and keeps resending automatically, so buttons + // must remain disabled until the retry settles or the deadline expires. + React.useEffect(() => { + if (deliveryFailed) { + setPending(null); + } + }, [deliveryFailed]); + + // Classify each option into an actionable bucket or a non-actionable + // display-only slot. Recognition is an EXACT allowlist, never a prefix: + // an unknown kind (including an unrecognized `reject_*` such as + // `reject_later_v2`) fails closed and is not rendered, so the user cannot + // click a trusted-looking button whose semantics this UI doesn't understand. + const actionableOptions = options.filter(({ kind }) => + isActionableKind(kind), + ); + + if (actionableOptions.length === 0) { + return null; + } + + return ( + <div className="mt-1.5 flex flex-wrap gap-1.5"> + {actionableOptions.map(({ optionId, kind, label }) => { + const isDeny = kind === "reject_once"; + const displayLabel = label ?? defaultOptionLabel(kind); + return ( + <button + key={optionId} + type="button" + className={ + isDeny + ? "rounded px-2 py-0.5 text-xs font-medium border border-destructive/40 text-destructive hover:bg-destructive/10 disabled:opacity-50" + : "rounded px-2 py-0.5 text-xs font-medium border border-green-600/40 text-green-700 dark:text-green-400 hover:bg-green-600/10 disabled:opacity-50" + } + data-testid={`permission-decision-${optionId}`} + disabled={pending !== null} + onClick={() => { + setPending(optionId); + void deliveryFn({ + agentPubkey, + channelId, + requestNonce, + optionId, + deadlineSecs, + }) + .then((outcome) => { + // `"failed"` means the harness received the frame but could + // not route it — re-enable so the user can retry. The reducer + // `deliveryFailed` path also re-enables via the `control_result` + // frame; this fast path handles the case before the reducer + // fires. `"acked"` / `"expired"` are terminal; the transcript + // item updates via the observer relay and no retry is needed. + if (outcome === "failed") setPending(null); + }) + .catch(() => { + // The delivery loop never rejects — it resolves one of + // "acked" | "expired" | "failed". This branch guards against + // any unexpected error and re-enables for safety. + setPending(null); + }); + }} + > + {pending === optionId ? "…" : displayLabel} + </button> + ); + })} + </div> + ); +} + +export function LifecycleActivity( + props: ActivityRenderClassItemProps & { + /** + * Seam for testing — injected mock delivery function threaded through to + * `PermissionDecisionButtons`. Production callers omit this prop. + */ + _deliveryFn?: typeof startPermissionDecisionDelivery; + }, +) { if (props.item.type === "tool") { return <ToolActivity {...props} />; } @@ -55,6 +214,11 @@ export function LifecycleActivity(props: ActivityRenderClassItemProps) { const { requestLines, optionsLine } = splitPermissionText(props.item.text); const outcome = props.item.outcome; const tone = outcome ? permissionOutcomeTone(outcome) : null; + const actionable = props.item.actionable ?? false; + const requestNonce = props.item.requestNonce; + const options = props.item.options ?? []; + const authorizationReason = props.item.authorizationReason; + const deliveryFailed = props.item.deliveryFailed; return ( <div className="rounded-md border border-amber-500/20 bg-amber-500/5 px-2 py-1.5 text-left text-xs text-amber-700 dark:text-amber-400" @@ -69,11 +233,31 @@ export function LifecycleActivity(props: ActivityRenderClassItemProps) { <span className="opacity-80"> · {requestLines}</span> ) : null} </div> - {/* Row 2: options (muted sub-line) */} - {optionsLine ? ( + {/* Row 2: authorization reason (from envelope), if present */} + {authorizationReason ? ( + <div className="mt-0.5 pl-5 opacity-70">{authorizationReason}</div> + ) : null} + {/* Row 3: options sub-line (legacy fallback) */} + {optionsLine && !authorizationReason ? ( <div className="mt-0.5 pl-5 opacity-60">{optionsLine}</div> ) : null} - {/* Row 3: decision — only when outcome is resolved */} + {/* Row 4: Allow/Deny buttons (actionable card awaiting decision) */} + {actionable && requestNonce && !outcome ? ( + <PermissionDecisionButtons + agentPubkey={props.agentPubkey} + channelId={props.item.channelId ?? ""} + options={options} + requestNonce={requestNonce} + deliveryFailed={deliveryFailed} + deadlineSecs={resolveDecisionDeadlineSecs( + props.item.expiresAt, + props.item.timestamp, + Date.now() / 1000, + )} + _deliveryFn={props._deliveryFn} + /> + ) : null} + {/* Row 5: decision — only when outcome is resolved */} {outcome && tone ? ( <> <div className="my-1 border-t border-amber-500/20" /> diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index c8cfd30088e..c44ab71a524 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -824,7 +824,7 @@ test("buildTranscript appends Approved outcome when allow_once is selected", () const item = transcript[0]; assert.equal(item.type, "lifecycle"); assert.equal(item.renderClass, "permission"); - assert.equal(item.outcome, "Approved (allow_once)"); + assert.equal(item.outcome, "Approved"); assert.doesNotMatch(item.text ?? "", /Approved/); }); @@ -836,7 +836,7 @@ test("buildTranscript appends Denied outcome when reject_once is selected", () = const item = transcript[0]; assert.equal(item.type, "lifecycle"); - assert.equal(item.outcome, "Denied (reject_once)"); + assert.equal(item.outcome, "Denied"); assert.doesNotMatch(item.text ?? "", /Denied/); }); @@ -882,7 +882,7 @@ test("buildTranscript appends Approved outcome for a numeric JSON-RPC id (select const item = transcript[0]; assert.equal(item.type, "lifecycle"); assert.equal(item.renderClass, "permission"); - assert.equal(item.outcome, "Approved (allow_once)"); + assert.equal(item.outcome, "Approved"); assert.doesNotMatch(item.text ?? "", /Approved/); }); @@ -910,8 +910,8 @@ test('buildTranscript does not collide between numeric id 1 and string id "1"', makePermissionResponse(2, "1", "selected", "reject_once"), ]); - assert.equal(transcriptNumeric[0].outcome, "Approved (allow_once)"); - assert.equal(transcriptString[0].outcome, "Denied (reject_once)"); + assert.equal(transcriptNumeric[0].outcome, "Approved"); + assert.equal(transcriptString[0].outcome, "Denied"); }); // ─── observer parity: new session/update classifier cases ──────────────────── @@ -2125,3 +2125,1076 @@ test("buildTranscript session/new bare systemPrompt field takes precedence over "_meta.systemPrompt.append must not appear when bare field is present", ); }); + +// ── authorization envelope + nonce-keyed cards ──────────────────────────────── + +/** Build an acp_read permission event with a full authorization envelope. */ +function makePermissionRequestWithAuth( + seq, + requestId, + nonce, + { + actionable = true, + reason, + expiresAt, + turnId = "turn-1", + channelId = "ch-1", + } = {}, +) { + return { + seq, + timestamp: "2026-07-01T10:00:00.000Z", + kind: "acp_read", + agentIndex: 0, + channelId, + sessionId: "session-1", + turnId, + payload: { + jsonrpc: "2.0", + id: requestId, + method: "session/request_permission", + params: { + title: "Confirm push", + toolCallId: "tool-1", + options: [ + { optionId: "allow_once", kind: "allow_once", name: "Allow" }, + { optionId: "reject_once", kind: "reject_once", name: "Reject" }, + ], + }, + }, + authorization: { requestNonce: nonce, actionable, reason, expiresAt }, + }; +} + +test("buildTranscript_carries_authorization_expiresAt_onto_the_card", () => { + // The envelope's expiresAt must reach the transcript item so the observer- + // feed card can bound its retransmit loop by the real card deadline. + const transcript = buildTranscript([ + makePermissionRequestWithAuth(1, "req-exp", "nonce-exp", { + expiresAt: 1_700_000_300, + }), + ]); + const card = transcript.find( + (i) => i.renderClass === "permission" && i.requestNonce === "nonce-exp", + ); + assert.ok(card, "permission card must exist"); + assert.equal(card.expiresAt, 1_700_000_300); +}); + +test("buildTranscript_nonce_keyed_card_is_actionable_with_options", () => { + // An acp_read with an authorization envelope should produce one card + // keyed by nonce, with actionable=true and the parsed options attached. + const transcript = buildTranscript([ + makePermissionRequestWithAuth(1, "req-n1", "nonce-abc"), + ]); + + assert.equal(transcript.length, 1); + const item = transcript[0]; + assert.equal(item.type, "lifecycle"); + assert.equal(item.renderClass, "permission"); + assert.equal(item.requestNonce, "nonce-abc"); + assert.equal(item.actionable, true); + assert.equal(item.channelId, "ch-1"); + assert.ok(Array.isArray(item.options)); + assert.equal(item.options.length, 2); + assert.equal(item.options[0].optionId, "allow_once"); + // Card is keyed by nonce, not by turn. + assert.ok( + item.id.includes("nonce-abc"), + `expected nonce in id, got ${item.id}`, + ); +}); + +test("buildTranscript_actionable_false_envelope_produces_read_only_card", () => { + const transcript = buildTranscript([ + makePermissionRequestWithAuth(1, "req-n2", "nonce-readonly", { + actionable: false, + reason: "auto-rejected: reject policy", + }), + ]); + + assert.equal(transcript.length, 1); + const item = transcript[0]; + assert.equal(item.actionable, false); + assert.equal(item.authorizationReason, "auto-rejected: reject policy"); +}); + +test("buildTranscript_concurrent_requests_same_turn_produce_separate_cards", () => { + // Two permission requests in the same turn with different nonces must each + // get their own card — nonce is the unique key. + const transcript = buildTranscript([ + makePermissionRequestWithAuth(1, "req-c1", "nonce-c1", { + turnId: "turn-1", + }), + makePermissionRequestWithAuth(2, "req-c2", "nonce-c2", { + turnId: "turn-1", + }), + ]); + + // Two distinct cards. + const cards = transcript.filter((i) => i.renderClass === "permission"); + assert.equal(cards.length, 2, "expected two separate permission cards"); + const nonces = cards.map((c) => c.requestNonce).sort(); + assert.deepEqual(nonces, ["nonce-c1", "nonce-c2"]); + // Each card id is unique. + assert.notEqual(cards[0].id, cards[1].id); +}); + +test("buildTranscript_without_auth_envelope_falls_back_to_turn_keyed_card", () => { + // A permission request without an authorization envelope (legacy / reject + // policy path) still produces a card using the turn-based key. + const transcript = buildTranscript([ + { + seq: 1, + timestamp: "2026-07-01T10:00:00.000Z", + kind: "acp_read", + agentIndex: 0, + channelId: "ch-1", + sessionId: "session-1", + turnId: "turn-legacy", + payload: { + jsonrpc: "2.0", + id: "req-leg", + method: "session/request_permission", + params: { + title: "Confirm push", + toolCallId: "tool-1", + options: [ + { optionId: "allow_once", kind: "allow_once", name: "Allow" }, + ], + }, + }, + // No authorization field. + }, + ]); + + assert.equal(transcript.length, 1); + const item = transcript[0]; + assert.equal(item.renderClass, "permission"); + assert.equal(item.requestNonce, undefined); + assert.equal(item.actionable, undefined); + // Fall-back key uses turn id. + assert.ok( + item.id.includes("turn-legacy"), + `expected turn id in fallback key, got ${item.id}`, + ); +}); + +test("buildTranscript_uncertain_outcome_uses_pinned_copy", () => { + // The 'uncertain' terminal state must use the verbatim pinned copy, never + // "denied" or "failed closed". + const transcript = buildTranscript([ + makePermissionRequest(1, "req-unc"), + makePermissionResponse(2, "req-unc", "uncertain"), + ]); + + assert.equal(transcript.length, 1); + const item = transcript[0]; + assert.equal(item.renderClass, "permission"); + assert.match( + item.outcome ?? "", + /Approval outcome unknown.*agent process stopped/i, + "uncertain must use the pinned copy", + ); + // Must not use 'denied' or 'failed closed'. + assert.doesNotMatch(item.outcome ?? "", /denied/i); + assert.doesNotMatch(item.outcome ?? "", /failed closed/i); +}); + +test("buildTranscript_timed_out_outcome_renders_correctly", () => { + const transcript = buildTranscript([ + makePermissionRequest(1, "req-to"), + makePermissionResponse(2, "req-to", "timed_out"), + ]); + + const item = transcript[0]; + assert.equal(item.renderClass, "permission"); + assert.ok(item.outcome, "timed_out should produce an outcome string"); + assert.doesNotMatch(item.outcome ?? "", /Approved/i); +}); + +test("buildTranscript_nonce_card_channelId_is_threaded_from_event", () => { + // The channelId on the card must come from the event, not a hard-coded value, + // so PermissionDecisionButtons can pass it to sendPermissionDecision. + const transcript = buildTranscript([ + makePermissionRequestWithAuth(1, "req-ch", "nonce-ch", { + channelId: "specific-channel-id", + }), + ]); + + const item = transcript[0]; + assert.equal(item.channelId, "specific-channel-id"); +}); + +test("buildTranscript_control_result_non_sent_marks_card_delivery_failed", () => { + // A `control_result` with non-`sent` status must set deliveryFailed on the + // matching card so PermissionDecisionButtons can re-enable buttons for retry. + const nonce = "nonce-delivery-fail"; + const events = [ + // First: the permission request that creates the card. + makePermissionRequestWithAuth(1, "req-df", nonce), + // Second: a control_result with non-sent status. + { + seq: 2, + timestamp: "2026-07-01T10:00:01.000Z", + kind: "control_result", + agentIndex: 0, + channelId: "ch-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { + type: "permission_decision", + status: "no_active_turn", + requestNonce: nonce, + optionId: "allow_once", + }, + }, + ]; + const transcript = buildTranscript(events); + + const card = transcript.find( + (i) => i.renderClass === "permission" && i.requestNonce === nonce, + ); + assert.ok(card, "permission card must exist"); + assert.equal( + card.deliveryFailed, + 1, + "deliveryFailed must be 1 after first non-sent control_result", + ); + // Card must still be actionable so the user can retry. + assert.equal( + card.actionable, + true, + "card must remain actionable after delivery failure", + ); +}); + +test("buildTranscript_control_result_second_failure_increments_delivery_failed", () => { + // A second non-`sent` control_result must increment deliveryFailed so the + // useEffect([deliveryFailed]) dependency in PermissionDecisionButtons + // re-fires and re-enables the buttons for a second retry attempt. + const nonce = "nonce-delivery-fail-2"; + const events = [ + makePermissionRequestWithAuth(1, "req-df2", nonce), + // First failure. + { + seq: 2, + timestamp: "2026-07-01T10:00:01.000Z", + kind: "control_result", + agentIndex: 0, + channelId: "ch-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { + type: "permission_decision", + status: "no_active_turn", + requestNonce: nonce, + optionId: "allow_once", + }, + }, + // Second failure (user retried; harness still unavailable). + { + seq: 3, + timestamp: "2026-07-01T10:00:02.000Z", + kind: "control_result", + agentIndex: 0, + channelId: "ch-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { + type: "permission_decision", + status: "no_channel", + requestNonce: nonce, + optionId: "allow_once", + }, + }, + ]; + const transcript = buildTranscript(events); + + const card = transcript.find( + (i) => i.renderClass === "permission" && i.requestNonce === nonce, + ); + assert.ok(card, "permission card must exist"); + assert.equal( + card.deliveryFailed, + 2, + "deliveryFailed must be 2 after two non-sent control_results — each failure must increment the token", + ); + assert.equal( + card.actionable, + true, + "card must remain actionable after second delivery failure", + ); +}); + +test("buildTranscript_control_result_sent_does_not_mark_delivery_failed", () => { + // A `control_result` with `sent` status must NOT set deliveryFailed — the + // click reached the harness successfully. + const nonce = "nonce-delivery-ok"; + const events = [ + makePermissionRequestWithAuth(1, "req-ok", nonce), + { + seq: 2, + timestamp: "2026-07-01T10:00:01.000Z", + kind: "control_result", + agentIndex: 0, + channelId: "ch-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { + type: "permission_decision", + status: "sent", + requestNonce: nonce, + optionId: "allow_once", + }, + }, + ]; + const transcript = buildTranscript(events); + + const card = transcript.find( + (i) => i.renderClass === "permission" && i.requestNonce === nonce, + ); + assert.ok(card, "permission card must exist"); + assert.equal( + card.deliveryFailed, + undefined, + "deliveryFailed must not be set on sent control_result", + ); +}); + +test("buildTranscript_control_result_already_decided_does_not_mark_delivery_failed", () => { + // `already_decided` is success: a retransmit matched a nonce the harness had + // already applied (the deciding task ended). It must NOT set deliveryFailed — + // failing a correctly-resolved card is the exact P1 the retransmit loop and + // this dedup exist to prevent. + const nonce = "nonce-already-decided"; + const events = [ + makePermissionRequestWithAuth(1, "req-ad", nonce), + { + seq: 2, + timestamp: "2026-07-01T10:00:01.000Z", + kind: "control_result", + agentIndex: 0, + channelId: "ch-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { + type: "permission_decision", + status: "already_decided", + requestNonce: nonce, + optionId: "allow_once", + }, + }, + ]; + const transcript = buildTranscript(events); + + const card = transcript.find( + (i) => i.renderClass === "permission" && i.requestNonce === nonce, + ); + assert.ok(card, "permission card must exist"); + assert.equal( + card.deliveryFailed, + undefined, + "deliveryFailed must not be set on already_decided control_result", + ); +}); + +test("buildTranscript_control_result_channel_full_does_not_mark_delivery_failed", () => { + // `channel_full` is a transient queue-saturation status — the retransmit + // orchestrator stays subscribed and keeps resending automatically. The card + // must remain DISABLED (deliveryFailed must NOT be incremented) so a second + // click cannot start a racing delivery loop while the first is still alive. + // + // Mutation proof: restoring `channel_full` to increment `deliveryFailed` in + // `handlePermissionDecisionResult` → `deliveryFailed` becomes 1 → this + // assertion fails at `expected undefined, got 1`. + const nonce = "nonce-channel-full"; + const events = [ + makePermissionRequestWithAuth(1, "req-cf", nonce), + { + seq: 2, + timestamp: "2026-07-01T10:00:01.000Z", + kind: "control_result", + agentIndex: 0, + channelId: "ch-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { + type: "permission_decision", + status: "channel_full", + requestNonce: nonce, + optionId: "allow_once", + }, + }, + ]; + const transcript = buildTranscript(events); + + const card = transcript.find( + (i) => i.renderClass === "permission" && i.requestNonce === nonce, + ); + assert.ok(card, "permission card must exist"); + assert.equal( + card.deliveryFailed, + undefined, + "deliveryFailed must not be set on channel_full — it is transient; buttons must stay disabled while the retransmit loop is active", + ); + // Card must remain actionable (the request is still live). + assert.equal( + card.actionable, + true, + "card must remain actionable after channel_full — the retransmit loop is still in progress", + ); +}); + +// ─── permission index cleanup + FOREIGN-nonce tests (Pass 4) ───────────────── + +import { buildTranscriptState } from "./agentSessionTranscript.ts"; + +function makePermissionWriteWithNonce( + seq, + requestId, + nonce, + outcome = "selected", + optionId = "allow_once", + { channelId = "ch-1", sessionId = "session-1", turnId = "turn-1" } = {}, +) { + const resultOutcome = + outcome === "selected" ? { outcome: "selected", optionId } : { outcome }; + return { + seq, + timestamp: "2026-07-01T10:00:01.000Z", + kind: "acp_write", + agentIndex: 0, + channelId, + sessionId, + turnId, + payload: { + jsonrpc: "2.0", + id: requestId, + result: { outcome: resultOutcome }, + }, + authorization: { + requestNonce: nonce, + actionable: false, + reason: "applied", + }, + }; +} + +function makePermissionTerminalEvent( + seq, + requestId, + nonce, + { channelId = "ch-1", sessionId = "session-1", turnId = "turn-1" } = {}, +) { + return { + seq, + timestamp: "2026-07-01T10:00:02.000Z", + kind: "permission_terminal", + agentIndex: 0, + channelId, + sessionId, + turnId, + payload: { id: requestId }, + authorization: { + requestNonce: nonce, + actionable: false, + reason: "uncertain", + }, + }; +} + +function makeTurnCompleted( + seq, + { channelId = "ch-1", sessionId = "session-1", turnId = "turn-1" } = {}, +) { + return { + seq, + timestamp: "2026-07-01T10:00:05.000Z", + kind: "turn_completed", + agentIndex: 0, + channelId, + sessionId, + turnId, + payload: {}, + }; +} + +function makeTurnError( + seq, + { channelId = "ch-1", sessionId = "session-1", turnId = "turn-1" } = {}, +) { + return { + seq, + timestamp: "2026-07-01T10:00:05.000Z", + kind: "turn_error", + agentIndex: 0, + channelId, + sessionId, + turnId, + payload: { message: "process died" }, + }; +} + +// ─── FOREIGN-nonce: unknown nonce is dropped, wrong card not mutated ───────── + +test("buildTranscript_foreign_nonce_acp_write_does_not_mutate_any_card", () => { + // Register card A with nonce-A. Send an acp_write with nonce-FOREIGN + // (not in the index). The response must be silently dropped — card A + // must remain actionable and have no outcome appended. + const events = [ + makePermissionRequestWithAuth(1, "req-a", "nonce-A"), + makePermissionWriteWithNonce( + 2, + "req-a", + "nonce-FOREIGN", + "selected", + "allow_once", + ), + ]; + const state = buildTranscriptState(events); + const transcript = state.items; + + assert.equal(transcript.length, 1, "only one card must exist"); + const card = transcript[0]; + assert.equal(card.renderClass, "permission"); + assert.equal(card.requestNonce, "nonce-A"); + assert.equal( + card.actionable, + true, + "card A must remain actionable — FOREIGN nonce must not retire it", + ); + assert.equal( + card.outcome, + undefined, + "no outcome must be appended — FOREIGN nonce write must be dropped", + ); + + // The nonce index must still contain nonce-A (FOREIGN was silently dropped). + assert.ok( + state.pendingPermissionsByNonce.has("nonce-A"), + "nonce-A must remain in the index after FOREIGN write is dropped", + ); + assert.ok( + !state.pendingPermissionsByNonce.has("nonce-FOREIGN"), + "nonce-FOREIGN must never appear in the index", + ); +}); + +test("buildTranscript_foreign_nonce_does_not_resolve_other_card_by_id", () => { + // card-1 (nonce-X) and card-2 (nonce-Y) are registered. + // An acp_write arrives with the id of card-1 but carries nonce-FOREIGN. + // Neither card must be mutated (nonce-FOREIGN lookup fails → drop). + const events = [ + makePermissionRequestWithAuth(1, "req-x", "nonce-X"), + makePermissionRequestWithAuth(2, "req-x", "nonce-Y", { turnId: "turn-2" }), + // Same wire id as req-x but an unknown nonce → must be dropped entirely. + makePermissionWriteWithNonce( + 3, + "req-x", + "nonce-FOREIGN", + "selected", + "allow_once", + ), + ]; + const state = buildTranscriptState(events); + const cards = state.items.filter((i) => i.renderClass === "permission"); + + assert.equal(cards.length, 2, "both permission cards must exist"); + for (const card of cards) { + assert.equal( + card.actionable, + true, + `card ${card.requestNonce} must remain actionable — FOREIGN nonce write must not touch it`, + ); + assert.equal( + card.outcome, + undefined, + "no outcome must be set by a FOREIGN nonce write", + ); + } +}); + +// ─── Index cleanup: both indexes cleared on acp_write terminal ──────────────── + +test("buildTranscript_acp_write_terminal_clears_both_indexes", () => { + // After a known-nonce acp_write outcome, both pendingPermissions (legacy key) + // and pendingPermissionsByNonce must be cleared for that entry. + const events = [ + makePermissionRequestWithAuth(1, "req-b", "nonce-B"), + makePermissionWriteWithNonce( + 2, + "req-b", + "nonce-B", + "selected", + "allow_once", + ), + ]; + const state = buildTranscriptState(events); + + assert.ok( + !state.pendingPermissionsByNonce.has("nonce-B"), + "pendingPermissionsByNonce must be cleared after nonce-B acp_write terminal", + ); + // Legacy key: JSON-encoded requestId scoped by channel:session:turn:id. + const legacyKey = `ch-1:session-1:turn-1:${JSON.stringify("req-b")}`; + assert.ok( + !state.pendingPermissions.has(legacyKey), + "pendingPermissions legacy key must be cleared after acp_write terminal", + ); + // Card outcome must be set. + const card = state.items[0]; + assert.ok(card.outcome, "card must have an outcome after acp_write terminal"); + assert.equal(card.actionable, false); +}); + +// ─── Index cleanup: permission_terminal clears both indexes ─────────────────── + +test("buildTranscript_permission_terminal_clears_both_indexes", () => { + // After a permission_terminal event, both indexes must be cleared for that nonce. + const events = [ + makePermissionRequestWithAuth(1, "req-pt", "nonce-PT"), + makePermissionTerminalEvent(2, "req-pt", "nonce-PT"), + ]; + const state = buildTranscriptState(events); + + assert.ok( + !state.pendingPermissionsByNonce.has("nonce-PT"), + "pendingPermissionsByNonce must be cleared by permission_terminal", + ); + const legacyKey = `ch-1:session-1:turn-1:${JSON.stringify("req-pt")}`; + assert.ok( + !state.pendingPermissions.has(legacyKey), + "pendingPermissions legacy key must be cleared by permission_terminal", + ); +}); + +// ─── Index cleanup: turn_completed backstop clears both indexes ─────────────── + +test("buildTranscript_turn_completed_backstop_clears_both_indexes", () => { + // A turn_completed event must clear any remaining live permission entries + // in both indexes (the backstop for cards not yet retired by their terminal). + const events = [ + makePermissionRequestWithAuth(1, "req-tc", "nonce-TC"), + makeTurnCompleted(2), + ]; + const state = buildTranscriptState(events); + + assert.ok( + !state.pendingPermissionsByNonce.has("nonce-TC"), + "pendingPermissionsByNonce must be cleared by turn_completed backstop", + ); + const legacyKey = `ch-1:session-1:turn-1:${JSON.stringify("req-tc")}`; + assert.ok( + !state.pendingPermissions.has(legacyKey), + "pendingPermissions legacy key must be cleared by turn_completed backstop", + ); + // Card must be retired (not actionable). + const card = state.items.find( + (i) => i.renderClass === "permission" && i.requestNonce === "nonce-TC", + ); + assert.ok(card, "permission card must still exist after turn_completed"); + assert.equal( + card.actionable, + false, + "card must be non-actionable after turn_completed backstop", + ); +}); + +test("buildTranscript_turn_error_backstop_clears_both_indexes", () => { + // Same as turn_completed: a turn_error must also clear both indexes. + const events = [ + makePermissionRequestWithAuth(1, "req-te", "nonce-TE"), + makeTurnError(2), + ]; + const state = buildTranscriptState(events); + + assert.ok( + !state.pendingPermissionsByNonce.has("nonce-TE"), + "pendingPermissionsByNonce must be cleared by turn_error backstop", + ); + const legacyKey = `ch-1:session-1:turn-1:${JSON.stringify("req-te")}`; + assert.ok( + !state.pendingPermissions.has(legacyKey), + "pendingPermissions legacy key must be cleared by turn_error backstop", + ); +}); + +// ─── permission_terminal live replay + archive replay ──────────────────────── + +test("buildTranscript_permission_terminal_retires_card_with_pinned_uncertain_copy", () => { + // permission_terminal must retire the card with the verbatim pinned + // uncertain copy, NOT "denied" or "failed closed". + const events = [ + makePermissionRequestWithAuth(1, "req-live", "nonce-LIVE"), + makePermissionTerminalEvent(2, "req-live", "nonce-LIVE"), + ]; + const transcript = buildTranscript(events); + + assert.equal(transcript.length, 1); + const card = transcript[0]; + assert.equal(card.renderClass, "permission"); + assert.equal( + card.actionable, + false, + "card must be non-actionable after permission_terminal", + ); + assert.match( + card.outcome ?? "", + /Approval outcome unknown.*agent process stopped/i, + "permission_terminal must use the pinned uncertain copy", + ); + assert.doesNotMatch(card.outcome ?? "", /denied/i); + assert.doesNotMatch(card.outcome ?? "", /failed closed/i); +}); + +test("buildTranscript_permission_terminal_in_archive_replay_retires_card", () => { + // In an archive (lifecycle-only) replay the card must be retired by + // permission_terminal. The sequence of events is the same as live replay; + // what changes is the assertion that the card is retired even with no + // subsequent acp_write. + const events = [ + makePermissionRequestWithAuth(1, "req-arc", "nonce-ARC"), + makePermissionTerminalEvent(2, "req-arc", "nonce-ARC"), + ]; + const state = buildTranscriptState(events); + const card = state.items.find( + (i) => i.renderClass === "permission" && i.requestNonce === "nonce-ARC", + ); + + assert.ok(card, "permission card must exist in archive replay"); + assert.equal( + card.actionable, + false, + "card must be non-actionable after permission_terminal in archive replay", + ); + assert.match( + card.outcome ?? "", + /Approval outcome unknown/i, + "archive replay permission_terminal must set the uncertain outcome copy", + ); + // Both indexes must be clean. + assert.ok( + !state.pendingPermissionsByNonce.has("nonce-ARC"), + "nonce index must be clean after archive replay", + ); +}); + +// ─── Sync denial: acp_write with matching nonce retires the acp_read card ───── +// These tests verify the nonce-threading fix: before the fix, sync denial paths +// generated two different nonces (one for acp_read, a second for acp_write), +// so Desktop's nonce-only correlation could never find the read card. + +test("buildTranscript_sync_denial_write_with_matching_nonce_retires_card", () => { + // Non-actionable acp_read (sync denial — reject/preflight path) followed by + // acp_write carrying the SAME nonce. The write must retire the card and clear + // both indexes. + const nonce = "nonce-sync-deny"; + const events = [ + // Non-actionable read: card is created but not user-interactive. + makePermissionRequestWithAuth(1, "req-sd", nonce, { + actionable: false, + reason: "rejected", + }), + // Write with the same nonce — this is the fix under test. + makePermissionWriteWithNonce(2, "req-sd", nonce, "rejected", "reject_once"), + ]; + const state = buildTranscriptState(events); + + // Card must be retired (not actionable, outcome set). + const card = state.items.find( + (i) => i.renderClass === "permission" && i.requestNonce === nonce, + ); + assert.ok(card, "permission card must exist after sync denial"); + assert.equal( + card.actionable, + false, + "card must be non-actionable after matching-nonce acp_write", + ); + + // Both indexes must be cleared. + assert.ok( + !state.pendingPermissionsByNonce.has(nonce), + "nonce index must be cleared after matching-nonce acp_write", + ); + const legacyKey = `ch-1:session-1:turn-1:${JSON.stringify("req-sd")}`; + assert.ok( + !state.pendingPermissions.has(legacyKey), + "legacy index must be cleared after matching-nonce acp_write", + ); +}); + +test("buildTranscript_sync_denial_write_with_mismatched_nonce_leaves_card_live", () => { + // Regression guard: if the nonce on the acp_write does NOT match the acp_read, + // Desktop's nonce-only rule must drop the write — the read card stays live. + // (This is the broken-before-fix scenario the nonce-threading corrects.) + const readNonce = "nonce-read-mismatch"; + const writeNonce = "nonce-write-different"; // intentionally different + const events = [ + makePermissionRequestWithAuth(1, "req-mm", readNonce, { + actionable: false, + reason: "rejected", + }), + makePermissionWriteWithNonce( + 2, + "req-mm", + writeNonce, + "rejected", + "reject_once", + ), + ]; + const state = buildTranscriptState(events); + + // The write carried an unknown nonce → dropped per nonce-only rule. + // The read card remains in the nonce index. + assert.ok( + state.pendingPermissionsByNonce.has(readNonce), + "nonce index must still contain the read card when write nonce does not match", + ); +}); + +// ─── P2: turn-scoped retirement — sibling thread cards survive their turn ───── + +test("buildTranscript_turn_scoped_retirement_does_not_retire_sibling_thread_cards", () => { + // Regression guard for P2: with concurrent thread-scoped turns, a + // turn_completed event for Thread B must NOT retire Thread A's still-pending + // permission cards. Only cards whose turnId matches the terminating turn are + // retired. + // + // Sequence: + // 1. Thread A (turnId="turn-A") raises a permission request — card is live. + // 2. Thread B (turnId="turn-B") raises a permission request — card is live. + // 3. Thread B completes (turn_completed, turnId="turn-B"). + // 4. Thread A's card must still be actionable. + // 5. Thread A's decision arrives — card is resolved correctly. + // 6. Archive replay of the same sequence produces the same final state + // (turn-scoped retirement holds for both live and replay paths). + const CH = "ch-2"; + const events = [ + // Thread A permission request. + makePermissionRequestWithAuth(1, "req-A", "nonce-A", { + turnId: "turn-A", + channelId: CH, + }), + // Thread B permission request. + makePermissionRequestWithAuth(2, "req-B", "nonce-B", { + turnId: "turn-B", + channelId: CH, + }), + // Thread B completes — must NOT retire Thread A's card. + makeTurnCompleted(3, { channelId: CH, turnId: "turn-B" }), + // Thread A's decision is applied. + makePermissionWriteWithNonce( + 4, + "req-A", + "nonce-A", + "selected", + "allow_once", + { + channelId: CH, + turnId: "turn-A", + }, + ), + ]; + + // ── Live path ────────────────────────────────────────────────────────────── + + // Step 3: after turn-B completes, A must still be actionable. + const stateAfterBComplete = buildTranscriptState(events.slice(0, 3)); + const cardA_live = stateAfterBComplete.items.find( + (i) => i.renderClass === "permission" && i.requestNonce === "nonce-A", + ); + const cardB_live = stateAfterBComplete.items.find( + (i) => i.renderClass === "permission" && i.requestNonce === "nonce-B", + ); + assert.ok(cardA_live, "Thread A card must exist after Thread B completes"); + assert.equal( + cardA_live.actionable, + true, + "Thread A card must still be actionable after Thread B completes", + ); + assert.ok(cardB_live, "Thread B card must exist"); + assert.equal( + cardB_live.actionable, + false, + "Thread B card must be retired by its own turn_completed", + ); + + // Step 4: A's decision resolves it correctly. + const stateAfterADecision = buildTranscriptState(events); + const cardA_resolved = stateAfterADecision.items.find( + (i) => i.renderClass === "permission" && i.requestNonce === "nonce-A", + ); + assert.ok(cardA_resolved, "Thread A card must still exist after A decision"); + assert.equal( + cardA_resolved.actionable, + false, + "Thread A card must be retired after its own decision", + ); + assert.ok( + cardA_resolved.outcome, + "Thread A card must have an outcome after its own decision", + ); + assert.ok( + !stateAfterADecision.pendingPermissionsByNonce.has("nonce-A"), + "nonce-A must be cleared from the index after Thread A's decision", + ); + assert.ok( + !stateAfterADecision.pendingPermissionsByNonce.has("nonce-B"), + "nonce-B must be cleared from the index after Thread B's turn_completed", + ); + + // ── Archive replay: same sequence produces the same final state ──────────── + + const replay = buildTranscriptState(events); + const cardA_replay = replay.items.find( + (i) => i.renderClass === "permission" && i.requestNonce === "nonce-A", + ); + assert.ok(cardA_replay, "Thread A card must survive replay"); + assert.equal( + cardA_replay.actionable, + false, + "Thread A card must be resolved in replay", + ); + assert.ok( + cardA_replay.outcome, + "Thread A card must have an outcome in replay", + ); +}); + +test("buildTranscript_turn_error_scoped_retirement_does_not_retire_sibling_thread_cards", () => { + // Same as the turn_completed variant but using turn_error so the same + // scoping invariant is verified for the error-terminal path. + // + // Mutation guard: replacing retireLivePermissionCardsForTurn with + // retireAllLivePermissionCards in the turn_error handler makes this test fail + // because Thread A's card is retired by Thread B's error event. + const CH = "ch-3"; + const events = [ + makePermissionRequestWithAuth(1, "req-EA", "nonce-EA", { + turnId: "turn-EA", + channelId: CH, + }), + makePermissionRequestWithAuth(2, "req-EB", "nonce-EB", { + turnId: "turn-EB", + channelId: CH, + }), + // Thread B errors — must NOT retire Thread A's card. + makeTurnError(3, { channelId: CH, turnId: "turn-EB" }), + // Thread A's decision applies. + makePermissionWriteWithNonce( + 4, + "req-EA", + "nonce-EA", + "selected", + "allow_once", + { + channelId: CH, + turnId: "turn-EA", + }, + ), + ]; + + const stateAfterBError = buildTranscriptState(events.slice(0, 3)); + const cardA = stateAfterBError.items.find( + (i) => i.renderClass === "permission" && i.requestNonce === "nonce-EA", + ); + assert.ok(cardA, "Thread A card must exist after Thread B errors"); + assert.equal( + cardA.actionable, + true, + "Thread A card must still be actionable after Thread B errors", + ); + + const stateAfterADecision = buildTranscriptState(events); + const cardA_resolved = stateAfterADecision.items.find( + (i) => i.renderClass === "permission" && i.requestNonce === "nonce-EA", + ); + assert.ok( + cardA_resolved?.outcome, + "Thread A card must have an outcome after its own decision", + ); + assert.equal( + cardA_resolved?.actionable, + false, + "Thread A card must be retired after its own decision", + ); +}); + +test("buildTranscript_turn_scoped_retirement_cleans_legacy_key_with_colon_in_sessionId", () => { + // Regression guard for the P2 MINOR: the legacy `pendingPermissions` key + // format is `ch:sessionId:turnId:requestId` — if `sessionId` contains `:`, + // positional `split(":")` at index 2 returns part of `sessionId` instead of + // `turnId`, leaving the legacy key behind after turn-scoped retirement. + // + // The fix: match by the item's `turnId` field instead of parsing the key. + // This test confirms a card whose sessionId is "session:with:colon" is fully + // cleaned up (card retired + nonce cleared + legacy key deleted) by + // `retireLivePermissionCardsForTurn`, with no stale entry remaining. + const CH = "ch-colon"; + // Craft the event directly — makePermissionRequestWithAuth hardcodes sessionId. + const requestEvent = { + seq: 1, + timestamp: "2026-07-01T10:00:00.000Z", + kind: "acp_read", + agentIndex: 0, + channelId: CH, + sessionId: "session:with:colon", + turnId: "turn-colon", + payload: { + jsonrpc: "2.0", + id: "req-col", + method: "session/request_permission", + params: { + title: "Colon test", + toolCallId: "tool-col", + options: [ + { optionId: "allow_once", kind: "allow_once", name: "Allow" }, + { optionId: "reject_once", kind: "reject_once", name: "Reject" }, + ], + }, + }, + authorization: { requestNonce: "nonce-col", actionable: true }, + }; + // turn_completed for the same turn triggers retireLivePermissionCardsForTurn. + const completedEvent = makeTurnCompleted(2, { + channelId: CH, + sessionId: "session:with:colon", + turnId: "turn-colon", + }); + + const state = buildTranscriptState([requestEvent, completedEvent]); + + // Card must be retired. + const card = state.items.find( + (i) => i.renderClass === "permission" && i.requestNonce === "nonce-col", + ); + assert.ok(card, "permission card must exist"); + assert.equal( + card.actionable, + false, + "card must be retired by turn_completed even when sessionId contains ':'", + ); + + // Nonce index must be cleared. + assert.ok( + !state.pendingPermissionsByNonce.has("nonce-col"), + "nonce index must be cleared after turn-scoped retirement", + ); + + // Legacy pendingPermissions key must be cleaned up (not left behind by + // the old positional-split implementation). + const legacyKey = `${CH}:session:with:colon:turn-colon:"req-col"`; + assert.ok( + !state.pendingPermissions.has(legacyKey), + "legacy pendingPermissions key must be deleted after turn-scoped retirement", + ); + + // Verify no stale pendingPermissions entries remain for this channel at all. + for (const key of state.pendingPermissions.keys()) { + assert.ok( + !key.startsWith(`${CH}:`), + `stale pendingPermissions entry found after retirement: ${key}`, + ); + } +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 63bf1597cb8..3cb7480507c 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -28,6 +28,13 @@ import { parseSystemPromptSections, } from "./agentSessionTranscriptHelpers"; import { friendlyTurnErrorCopy } from "../lib/friendlyAgentLastError"; +import { + describePermissionRequest, + retireLivePermissionCardsForTurn, + handlePermissionTerminal, + handlePermissionWrite, + handlePermissionDecisionResult, +} from "./agentSessionTranscriptPermissions"; export { describeRawEvent } from "./agentSessionTranscriptHelpers"; @@ -47,6 +54,14 @@ export type TranscriptState = { string, { itemId: string; optionNames: Map<string, string> } >; + /** + * Maps `requestNonce` → `itemId` for actionable permission cards. + * Populated alongside `pendingPermissions` when the `authorization` envelope + * is present on the `acp_read` frame. Used by the nonce-correlated `acp_write` + * terminal handler and the `permission_terminal` event handler to retire the + * card on any terminal outcome (applied, timed_out, cancelled, uncertain). + */ + pendingPermissionsByNonce: Map<string, string>; continuationSeq: number; latestSessionId: string | null; }; @@ -59,6 +74,7 @@ export function createEmptyTranscriptState(): TranscriptState { sealedKeys: new Set(), triggeringEventIdsByTurn: new Map(), pendingPermissions: new Map(), + pendingPermissionsByNonce: new Map(), continuationSeq: 0, latestSessionId: null, }; @@ -79,6 +95,7 @@ type TranscriptDraft = { string, { itemId: string; optionNames: Map<string, string> } >; + pendingPermissionsByNonce: Map<string, string>; continuationSeq: number; latestSessionId: string | null; changed: boolean; @@ -92,6 +109,7 @@ function draftFrom(state: TranscriptState): TranscriptDraft { sealedKeys: state.sealedKeys, triggeringEventIdsByTurn: state.triggeringEventIdsByTurn, pendingPermissions: state.pendingPermissions, + pendingPermissionsByNonce: state.pendingPermissionsByNonce, continuationSeq: state.continuationSeq, latestSessionId: state.latestSessionId, changed: false, @@ -171,88 +189,8 @@ function stringifyPayload(value: unknown) { } } -function describePermissionRequest(payload: Record<string, unknown>) { - const params = asRecord(payload.params); - const title = - asString(params.title) ?? - asString(params.message) ?? - asString(params.reason) ?? - "Permission requested"; - const toolCallId = - asString(params.toolCallId) ?? asString(params.tool_call_id); - const options = Array.isArray(params.options) - ? params.options - .map((option) => { - const record = asRecord(option); - return ( - asString(record.name) ?? - asString(record.kind) ?? - asString(record.optionId) - ); - }) - .filter((option): option is string => Boolean(option)) - : []; - const detail: string[] = []; - if (title !== "Permission requested") detail.push(title); - if (toolCallId) detail.push(`Tool call: ${toolCallId}`); - if (options.length > 0) detail.push(`Options: ${options.join(", ")}`); - - // Build optionId → kind map for outcome labeling on the response. - const optionNames = new Map<string, string>(); - if (Array.isArray(params.options)) { - for (const option of params.options) { - const record = asRecord(option); - const optionId = asString(record.optionId); - const kind = asString(record.kind); - if (optionId && kind) { - optionNames.set(optionId, kind); - } - } - } - - return { - title, - text: detail.join("\n"), - optionNames, - descriptor: { - renderClass: "permission" as const, - label: "Permission requested", - preview: title, - action: { verb: "Requested", object: title }, - tone: "admin" as const, - operation: "session/request_permission", - object: title, - source: "acp" as const, - groupKey: "permission:request", - }, - }; -} - -/** - * Format a human-readable outcome label from a permission response. - * kind values from ACP: allow_once, allow_always, reject_once, reject_always. - * "reject_*" kinds are denials; anything else that is selected is an approval. - */ -function describePermissionOutcome( - outcome: string, - optionId: string | null, - optionNames: Map<string, string>, -): string { - if (outcome === "cancelled") { - return "Cancelled"; - } - if (outcome === "selected" && optionId) { - const kind = optionNames.get(optionId) ?? optionId; - const isDenial = kind.startsWith("reject"); - const verb = isDenial ? "Denied" : "Approved"; - return `${verb} (${kind})`; - } - return outcome; -} - /** * Stable map key for a JSON-RPC id, which may be a string or a finite number - * per the spec. Using JSON.stringify avoids collisions between the number 1 and * the string "1". Returns null for null, undefined, or non-id values (objects, * booleans) so callers can gate on presence without a separate type check. */ @@ -786,13 +724,36 @@ export function processTranscriptEvent( ctx, event.kind, ); + // Backstop: retire permission cards belonging to this terminating turn so + // missing telemetry and archive replay never reconstruct live controls + // after a terminal turn/process state. Scoped to `event.turnId` so that + // sibling threads' pending cards are not retired — each concurrent turn owns + // its own cards. Falls back to channel-wide retirement only when no turn + // identity is present (legacy archive frames). + retireLivePermissionCardsForTurn(d, ch, event.turnId); + } else if (event.kind === "turn_completed") { + // Backstop: retire permission cards belonging to this completing turn. + // Applied/timed-out/cancelled cards should already be retired via their + // nonce-correlated acp_write frames, but uncertain (process-poison) cards + // may only receive a turn_completed — this ensures they are not left + // actionable in live state or archive replay. Scoped to `event.turnId`; + // channel-wide backstop kept only when no turn identity exists on the event. + retireLivePermissionCardsForTurn(d, ch, event.turnId); + } else if (event.kind === "permission_terminal") { + handlePermissionTerminal(d, event.authorization, event.payload, ch, ctx); } else if (event.kind === "acp_read" || event.kind === "acp_write") { const payload = asRecord(event.payload); const method = asString(payload.method); if (method === "session/request_permission") { const request = describePermissionRequest(payload); - const itemId = `permission:${ch}:${event.turnId ?? event.seq}`; + // Key by nonce when the authorization envelope is present — this gives + // each concurrent ACP request its own card. Fall back to the turn-based + // key for legacy/non-ask paths where no nonce is emitted. + const auth = event.authorization; + const itemId = auth?.requestNonce + ? `permission:${ch}:nonce:${auth.requestNonce}` + : `permission:${ch}:${event.turnId ?? event.seq}`; upsertLifecycleItem( d, itemId, @@ -804,40 +765,41 @@ export function processTranscriptEvent( "permission_request", request.descriptor, ); - // Index by JSON-RPC id so the response (acp_write with result.outcome, - // no method) can correlate by id rather than by turn/seq. + + // Attach authorization-envelope fields to the item. The `authorization` + // object is on the ObserverEvent itself (not the payload — payloads are + // raw ACP with no `_buzz` wrapper). + if (auth) { + const existing = d.itemsById.get(itemId); + if (existing?.type === "lifecycle") { + replaceItem(d, itemId, { + ...existing, + requestNonce: auth.requestNonce, + actionable: auth.actionable, + authorizationReason: auth.reason, + expiresAt: auth.expiresAt, + options: request.options, + }); + } + // Index by nonce so acp_write terminal frames can retire the card. + d.pendingPermissionsByNonce = new Map(d.pendingPermissionsByNonce); + d.pendingPermissionsByNonce.set(auth.requestNonce, itemId); + } + + // Legacy id index: keyed by compound (channel, session, turn, id) to + // prevent cross-channel / cross-session JSON-RPC id collisions. + // Only used by authorized frames that carry NO nonce (non-ask paths). const requestId = jsonRpcId(payload.id); if (requestId) { + const legacyKey = `${ch}:${ctx.sessionId ?? ""}:${ctx.turnId ?? ""}:${requestId}`; d.pendingPermissions = new Map(d.pendingPermissions); - d.pendingPermissions.set(requestId, { + d.pendingPermissions.set(legacyKey, { itemId, optionNames: request.optionNames, }); } } else if (event.kind === "acp_write" && !method) { - // Permission response: {"id": <same as request>, "result": {"outcome": {...}}} - const responseId = jsonRpcId(payload.id); - const result = asRecord(asRecord(payload.result).outcome); - const outcomeKind = asString(result.outcome); - const pending = responseId ? d.pendingPermissions.get(responseId) : null; - if (pending && outcomeKind && responseId) { - const optionId = asString(result.optionId) ?? null; - const outcomeText = describePermissionOutcome( - outcomeKind, - optionId, - pending.optionNames, - ); - const existing = d.itemsById.get(pending.itemId); - if (existing?.type === "lifecycle") { - replaceItem(d, pending.itemId, { - ...existing, - outcome: outcomeText, - }); - // Remove from pending map — the outcome is now recorded. - d.pendingPermissions = new Map(d.pendingPermissions); - d.pendingPermissions.delete(responseId); - } - } + handlePermissionWrite(d, event.authorization, payload, ch, ctx); } else if (event.kind === "acp_write" && method === "session/prompt") { const promptBlocks = extractPromptBlocks(payload); if (promptBlocks.length > 0) { @@ -1138,6 +1100,8 @@ export function processTranscriptEvent( ); } } + } else if (event.kind === "control_result") { + handlePermissionDecisionResult(d, asRecord(event.payload)); } if (!d.changed && d.latestSessionId === state.latestSessionId) { @@ -1151,6 +1115,7 @@ export function processTranscriptEvent( sealedKeys: d.sealedKeys, triggeringEventIdsByTurn: d.triggeringEventIdsByTurn, pendingPermissions: d.pendingPermissions, + pendingPermissionsByNonce: d.pendingPermissionsByNonce, continuationSeq: d.continuationSeq, latestSessionId: d.latestSessionId, }; diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPermissions.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPermissions.test.mjs new file mode 100644 index 00000000000..4909c90a8f1 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPermissions.test.mjs @@ -0,0 +1,229 @@ +/** + * Named test matrix for the label-fix: describePermissionOutcome and + * describePermissionTerminalReason must render the harness-provided label, + * never the raw ACP kind string. + * + * Covers dispatch item 6 (2a label fix) from the Phase-2 brief. + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + describePermissionOutcome, + describePermissionTerminalReason, +} from "./agentSessionTranscriptPermissions.ts"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/** ACP kind → harness label mapping as would arrive from describePermissionRequest */ +const ALLOW_ONCE_LABELS = new Map([["opt-allow-once", "Allow once"]]); +const ALLOW_ONCE_KINDS = new Map([["opt-allow-once", "allow_once"]]); + +const ALLOW_ALWAYS_LABELS = new Map([["opt-allow-always", "Always allow"]]); +const ALLOW_ALWAYS_KINDS = new Map([["opt-allow-always", "allow_always"]]); + +const DENY_LABELS = new Map([["opt-deny", "Deny"]]); +const DENY_KINDS = new Map([["opt-deny", "reject_once"]]); + +const EMPTY = new Map(); + +// ── describePermissionOutcome ───────────────────────────────────────────────── + +describe("describePermissionOutcome — label rendering", () => { + it("test_label_fix_renders_harness_label_not_raw_kind", () => { + // The core regression: must return "Allow once", not "Approved (allow_once)" + const result = describePermissionOutcome( + "selected", + "opt-allow-once", + ALLOW_ONCE_LABELS, + ALLOW_ONCE_KINDS, + ); + assert.equal(result, "Allow once"); + assert.ok(!result.includes("allow_once"), "must not contain raw ACP kind"); + }); + + it("test_label_fix_deny_renders_harness_label_not_raw_kind", () => { + const result = describePermissionOutcome( + "selected", + "opt-deny", + DENY_LABELS, + DENY_KINDS, + ); + assert.equal(result, "Deny"); + assert.ok(!result.includes("reject_once"), "must not contain raw ACP kind"); + }); + + it("test_label_fix_always_allow_renders_harness_label", () => { + const result = describePermissionOutcome( + "selected", + "opt-allow-always", + ALLOW_ALWAYS_LABELS, + ALLOW_ALWAYS_KINDS, + ); + assert.equal(result, "Always allow"); + assert.ok( + !result.includes("allow_always"), + "must not contain raw ACP kind", + ); + }); + + it("test_no_label_falls_back_to_verb_only_not_kind", () => { + // When no harness label is available, render verb only ("Approved" / "Denied"), + // never the raw kind string. + const result = describePermissionOutcome( + "selected", + "opt-allow-once", + EMPTY, // no labels + ALLOW_ONCE_KINDS, + ); + assert.equal(result, "Approved"); + assert.ok(!result.includes("allow_once"), "must not contain raw ACP kind"); + }); + + it("test_no_label_deny_verb_fallback", () => { + const result = describePermissionOutcome( + "selected", + "opt-deny", + EMPTY, + DENY_KINDS, + ); + assert.equal(result, "Denied"); + assert.ok(!result.includes("reject_once"), "must not contain raw ACP kind"); + }); + + it("test_cancelled_outcome", () => { + assert.equal( + describePermissionOutcome("cancelled", null, EMPTY), + "Cancelled", + ); + }); + + it("test_timed_out_outcome", () => { + assert.equal( + describePermissionOutcome("timed_out", null, EMPTY), + "Timed out", + ); + }); + + it("test_uncertain_outcome_verbatim", () => { + const result = describePermissionOutcome("uncertain", null, EMPTY); + assert.equal( + result, + "Approval outcome unknown; agent process stopped before it could continue.", + ); + }); + + it("test_unknown_outcome_passthrough", () => { + // Unknown outcomes pass through unchanged. + assert.equal( + describePermissionOutcome("some_new_outcome", null, EMPTY), + "some_new_outcome", + ); + }); +}); + +// ── describePermissionTerminalReason ───────────────────────────────────────── + +describe("describePermissionTerminalReason — label rendering", () => { + const OPTIONS_WITH_LABEL = [ + { optionId: "opt-allow-once", kind: "allow_once", label: "Allow once" }, + { + optionId: "opt-allow-always", + kind: "allow_always", + label: "Always allow", + }, + { optionId: "opt-deny", kind: "reject_once", label: "Deny" }, + ]; + + const OPTIONS_WITHOUT_LABEL = [ + { optionId: "opt-allow-once", kind: "allow_once" }, + { optionId: "opt-deny", kind: "reject_once" }, + ]; + + it("test_terminal_reason_applied_renders_harness_label", () => { + const result = describePermissionTerminalReason( + "applied", + "selected", + "opt-allow-once", + OPTIONS_WITH_LABEL, + ); + assert.equal(result, "Allow once"); + assert.ok(!result.includes("allow_once"), "must not contain raw ACP kind"); + }); + + it("test_terminal_reason_applied_always_allow_label", () => { + const result = describePermissionTerminalReason( + "applied", + "selected", + "opt-allow-always", + OPTIONS_WITH_LABEL, + ); + assert.equal(result, "Always allow"); + }); + + it("test_terminal_reason_applied_deny_renders_harness_label", () => { + const result = describePermissionTerminalReason( + "applied", + "selected", + "opt-deny", + OPTIONS_WITH_LABEL, + ); + assert.equal(result, "Deny"); + assert.ok(!result.includes("reject_once"), "must not contain raw ACP kind"); + }); + + it("test_terminal_reason_applied_no_label_falls_back_to_verb", () => { + // Options without a label field: verb-only fallback, never raw kind. + const result = describePermissionTerminalReason( + "applied", + "selected", + "opt-allow-once", + OPTIONS_WITHOUT_LABEL, + ); + assert.equal(result, "Approved"); + assert.ok(!result.includes("allow_once"), "must not contain raw ACP kind"); + }); + + it("test_terminal_reason_timed_out", () => { + assert.equal( + describePermissionTerminalReason("timed_out", null, null, []), + "Timed out", + ); + }); + + it("test_terminal_reason_cancelled", () => { + assert.equal( + describePermissionTerminalReason("cancelled", null, null, []), + "Cancelled", + ); + }); + + it("test_terminal_reason_uncertain_verbatim", () => { + assert.equal( + describePermissionTerminalReason("uncertain", null, null, []), + "Approval outcome unknown; agent process stopped before it could continue.", + ); + }); + + it("test_terminal_no_reason_falls_back_to_outcome", () => { + // Reason absent → outcome-level fallback, still renders label not kind. + const result = describePermissionTerminalReason( + undefined, + "selected", + "opt-allow-once", + OPTIONS_WITH_LABEL, + ); + assert.equal(result, "Allow once"); + }); + + it("test_terminal_no_reason_no_options_passes_through", () => { + // No reason, no options, unknown outcome → passthrough. + const result = describePermissionTerminalReason( + undefined, + "some_outcome", + null, + [], + ); + assert.equal(result, "some_outcome"); + }); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPermissions.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPermissions.ts new file mode 100644 index 00000000000..df79868fac8 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPermissions.ts @@ -0,0 +1,537 @@ +/** + * Pure helper functions and draft-mutating permission handlers extracted from + * agentSessionTranscript.ts to keep that file under the line-count ratchet. + * + * Consumers: agentSessionTranscript.ts only. Do not import from elsewhere. + */ +import { asRecord, asString } from "./agentSessionUtils"; +import type { TranscriptItem } from "./agentSessionTypes"; + +// --------------------------------------------------------------------------- +// Minimal draft slice — structural subset of TranscriptDraft that permission +// helpers operate on. TranscriptDraft satisfies this interface via TypeScript +// structural typing; no import from the main transcript file is required. +// --------------------------------------------------------------------------- +export type PermissionDraftSlice = { + items: TranscriptItem[]; + itemsById: Map<string, TranscriptItem>; + pendingPermissions: Map< + string, + { itemId: string; optionNames: Map<string, string> } + >; + pendingPermissionsByNonce: Map<string, string>; + changed: boolean; +}; + +/** Replica of TranscriptItemContext — duplicated to avoid a circular import. */ +type PermCtx = { + sessionId: string | null; + turnId: string | null; +}; + +/** + * Inline replica of jsonRpcId — duplicated to avoid a circular import. + * Converts a JSON-RPC id value to a stable string key, or null for + * non-id types (null, undefined, object, boolean). + */ +function jsonRpcIdLocal(value: unknown): string | null { + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" && Number.isFinite(value)) + return JSON.stringify(value); + return null; +} + +/** + * Mutate a draft in place, replacing the item at `id`. Copies items/itemsById + * on the first mutation (copy-on-write semantics mirror the main draft helpers). + */ +function setPermissionItem( + d: PermissionDraftSlice, + id: string, + updated: TranscriptItem, +) { + if (!d.changed) { + d.items = [...d.items]; + d.itemsById = new Map(d.itemsById); + d.changed = true; + } + const idx = d.items.findIndex((it) => it.id === id); + if (idx !== -1) d.items[idx] = updated; + d.itemsById.set(id, updated); +} + +// --------------------------------------------------------------------------- +// Pure description helpers +// --------------------------------------------------------------------------- + +/** + * Extract a human-readable title, body text, option name map, structured + * options list, and activity descriptor from an ACP `session/request_permission` + * payload. + */ +export function describePermissionRequest(payload: Record<string, unknown>) { + const params = asRecord(payload.params); + const title = + asString(params.title) ?? + asString(params.message) ?? + asString(params.reason) ?? + "Permission requested"; + const toolCallId = + asString(params.toolCallId) ?? asString(params.tool_call_id); + + // Build both the display-string list and the structured options list in + // a single pass over params.options. + const optionNames = new Map<string, string>(); + const structuredOptions: Array<{ + optionId: string; + kind: string; + label?: string; + }> = []; + const optionDisplayNames: string[] = []; + if (Array.isArray(params.options)) { + for (const option of params.options) { + const rec = asRecord(option); + const optionId = asString(rec.optionId); + const kind = asString(rec.kind); + const label = asString(rec.label) ?? asString(rec.name); + const displayName = + asString(rec.name) ?? asString(rec.kind) ?? asString(rec.optionId); + if (displayName) optionDisplayNames.push(displayName); + if (optionId && kind) { + optionNames.set(optionId, kind); + structuredOptions.push({ + optionId, + kind, + ...(label ? { label } : {}), + }); + } + } + } + + const detail: string[] = []; + if (title !== "Permission requested") detail.push(title); + if (toolCallId) detail.push(`Tool call: ${toolCallId}`); + if (optionDisplayNames.length > 0) + detail.push(`Options: ${optionDisplayNames.join(", ")}`); + + return { + title, + text: detail.join("\n"), + optionNames, + options: structuredOptions, + descriptor: { + renderClass: "permission" as const, + label: "Permission requested", + preview: title, + action: { verb: "Requested", object: title }, + tone: "admin" as const, + operation: "session/request_permission", + object: title, + source: "acp" as const, + groupKey: "permission:request", + }, + }; +} + +/** + * Format a human-readable outcome label from a permission response. + * kind values from ACP: allow_once, allow_always, reject_once, reject_always. + * "reject_*" kinds are denials; anything else that is selected is an approval. + * + * `optionLabels` maps optionId → harness-provided display label (e.g. "Allow once"). + * `optionKinds` maps optionId → ACP kind (e.g. "allow_once"), used only to + * determine the deny/approve verb when no label is available. The raw kind + * string is NEVER rendered to the user. + */ +export function describePermissionOutcome( + outcome: string, + optionId: string | null, + optionLabels: Map<string, string>, + optionKinds?: Map<string, string>, +): string { + if (outcome === "cancelled") { + return "Cancelled"; + } + if (outcome === "timed_out") { + return "Timed out"; + } + if (outcome === "uncertain") { + // Pinned verbatim copy — must never say "denied" or "failed closed". + return "Approval outcome unknown; agent process stopped before it could continue."; + } + if (outcome === "selected" && optionId) { + const label = optionLabels.get(optionId); + const kind = optionKinds?.get(optionId) ?? optionId; + const isDenial = kind.startsWith("reject"); + const verb = isDenial ? "Denied" : "Approved"; + // Render the harness-provided label, never the raw ACP kind string. + return label ?? `${verb}`; + } + return outcome; +} + +/** + * Derive human-readable outcome copy from the `authorization.reason` field + * that accompanies terminal `acp_write` events. This is preferred over + * deriving copy from the ACP `result.outcome` field directly because the + * `reason` values are harness-level semantics (applied / timed_out / + * cancelled) whereas `result.outcome` is adapter-level (selected / reject_once + * etc.) and does not distinguish timeout from explicit denial. + * + * Falls back to `describePermissionOutcome` when `reason` is absent (legacy + * paths that predate the authorization envelope). + */ +export function describePermissionTerminalReason( + reason: string | undefined, + outcomeKind: string | null | undefined, + optionId: string | null, + options: + | Array<{ optionId: string; kind: string; label?: string; name?: string }> + | undefined, +): string { + if (reason === "applied") { + // Build label map (harness-provided display strings) and kind map (for + // deny/approve verb fallback only). Labels are preferred; raw kind strings + // are never rendered to the user. + // `label` is used by sentinel-format options; `name` is used by ACP + // JSON-RPC options. Fall back to undefined (verb-only) if neither is set. + const optionLabels = new Map<string, string>( + (options ?? []) + .map( + (o) => + [o.optionId, o.label ?? o.name] as [string, string | undefined], + ) + .filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + const optionKinds = new Map( + (options ?? []).map((o) => [o.optionId, o.kind]), + ); + return describePermissionOutcome( + outcomeKind ?? "selected", + optionId, + optionLabels, + optionKinds, + ); + } + if (reason === "timed_out") return "Timed out"; + if (reason === "cancelled") return "Cancelled"; + if (reason === "uncertain") { + return "Approval outcome unknown; agent process stopped before it could continue."; + } + // No reason: fall back to ACP outcome-level copy. + const optionLabels = new Map<string, string>( + (options ?? []) + .map( + (o) => [o.optionId, o.label ?? o.name] as [string, string | undefined], + ) + .filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + const optionKinds = new Map((options ?? []).map((o) => [o.optionId, o.kind])); + return describePermissionOutcome( + outcomeKind ?? "", + optionId, + optionLabels, + optionKinds, + ); +} + +// --------------------------------------------------------------------------- +// Draft-mutating permission helpers +// --------------------------------------------------------------------------- + +/** + * Retire all live (actionable) permission cards for a given channel. + * Called on terminal turn/process events (`turn_error`, `agent_panic`, + * `turn_completed`) as a backstop so cards do not remain clickable after + * the turn that owned them has ended. + */ +export function retireAllLivePermissionCards( + d: PermissionDraftSlice, + channelId: string, +) { + const prefix = `permission:${channelId}:`; + let retired = false; + for (const [id, item] of d.itemsById) { + if ( + id.startsWith(prefix) && + item.type === "lifecycle" && + item.renderClass === "permission" && + item.actionable + ) { + if (!retired) { + // Copy on first mutation. + d.items = [...d.items]; + d.itemsById = new Map(d.itemsById); + retired = true; + d.changed = true; + } + const updated = { ...item, actionable: false }; + d.itemsById.set(id, updated); + const idx = d.items.findIndex((i) => i.id === id); + if (idx !== -1) d.items[idx] = updated; + // Clean up nonce index if present. + if (item.requestNonce) { + d.pendingPermissionsByNonce = new Map(d.pendingPermissionsByNonce); + d.pendingPermissionsByNonce.delete(item.requestNonce); + } + } + } + // Clean up all pendingPermissions entries scoped to this channel. + // Keys use the compound format `ch:session:turn:id` — drop any that start + // with the channel prefix. + const chPrefix = `${channelId}:`; + let permsMutated = false; + for (const key of d.pendingPermissions.keys()) { + if (key.startsWith(chPrefix)) { + if (!permsMutated) { + d.pendingPermissions = new Map(d.pendingPermissions); + permsMutated = true; + } + d.pendingPermissions.delete(key); + } + } +} + +/** + * Retire live permission cards scoped to a specific turn, identified by + * `turnId`. Cards whose `turnId` matches are retired (actionable → false) + * and their nonce indexes removed. + * + * With concurrent thread-scoped turns, a channel-wide retirement on + * `turn_completed` / `turn_error` would retire pending cards belonging to + * still-running sibling threads. This function scopes the backstop to the + * single terminating turn so siblings remain actionable. + * + * Falls back to `retireAllLivePermissionCards` when `turnId` is absent — + * kept as a backstop for events (e.g. legacy archive frames) that carry no + * turn identity. + */ +export function retireLivePermissionCardsForTurn( + d: PermissionDraftSlice, + channelId: string, + turnId: string | null | undefined, +): void { + if (!turnId) { + // No turn identity available — fall back to channel-wide backstop. + retireAllLivePermissionCards(d, channelId); + return; + } + const prefix = `permission:${channelId}:`; + let retired = false; + for (const [id, item] of d.itemsById) { + if ( + id.startsWith(prefix) && + item.type === "lifecycle" && + item.renderClass === "permission" && + item.actionable && + item.turnId === turnId + ) { + if (!retired) { + d.items = [...d.items]; + d.itemsById = new Map(d.itemsById); + retired = true; + d.changed = true; + } + const updated = { ...item, actionable: false }; + d.itemsById.set(id, updated); + const idx = d.items.findIndex((i) => i.id === id); + if (idx !== -1) d.items[idx] = updated; + // Clean up nonce index if present. + if (item.requestNonce) { + d.pendingPermissionsByNonce = new Map(d.pendingPermissionsByNonce); + d.pendingPermissionsByNonce.delete(item.requestNonce); + } + } + } + // `pendingPermissions` keys use the compound format `ch:session:turn:id` + // where `session` and `turn` are unrestricted strings that may themselves + // contain `:`. Positional splitting is unsafe. Instead, look up the item + // for each entry and match by its `turnId` — the item already carries the + // authoritative identity, so no key parsing is needed. + let permsMutated = false; + for (const [key, { itemId }] of d.pendingPermissions) { + const item = d.itemsById.get(itemId); + if (item && item.turnId === turnId) { + if (!permsMutated) { + d.pendingPermissions = new Map(d.pendingPermissions); + permsMutated = true; + } + d.pendingPermissions.delete(key); + } + } +} + +/** + * Handle an observer-only `permission_terminal` event. + * Emitted for uncertain outcomes (process poison, cancel-during-write) where + * no confirmed ACP wire response is available. + */ +export function handlePermissionTerminal( + d: PermissionDraftSlice, + authorization: { requestNonce: string; reason?: string } | undefined | null, + payload: unknown, + ch: string, + ctx: PermCtx, +) { + const nonce = authorization?.requestNonce; + if (!nonce) return; + const itemId = d.pendingPermissionsByNonce.get(nonce); + if (!itemId) return; + const existing = d.itemsById.get(itemId); + if (existing?.type === "lifecycle") { + setPermissionItem(d, itemId, { + ...existing, + outcome: + "Approval outcome unknown; agent process stopped before it could continue.", + actionable: false, + }); + } + d.pendingPermissionsByNonce = new Map(d.pendingPermissionsByNonce); + d.pendingPermissionsByNonce.delete(nonce); + // Clean up any matching compound legacy entry. + const responseId = jsonRpcIdLocal(asRecord(payload).id); + if (responseId) { + const legacyKey = `${ch}:${ctx.sessionId ?? ""}:${ctx.turnId ?? ""}:${responseId}`; + if (d.pendingPermissions.has(legacyKey)) { + d.pendingPermissions = new Map(d.pendingPermissions); + d.pendingPermissions.delete(legacyKey); + } + } +} + +/** + * Handle an `acp_write` frame with no `method` — a permission response carrying + * `result.outcome`. Correlates by nonce (primary) or legacy compound key (fallback). + */ +export function handlePermissionWrite( + d: PermissionDraftSlice, + authorization: + | { requestNonce?: string | null; reason?: string } + | undefined + | null, + payload: Record<string, unknown>, + ch: string, + ctx: PermCtx, +) { + const nonce = authorization?.requestNonce; + const terminalReason = authorization?.reason; + const responseId = jsonRpcIdLocal(payload.id); + const result = asRecord(asRecord(payload.result).outcome); + const outcomeKind = asString(result.outcome); + + if (nonce !== undefined && nonce !== null) { + // Nonce present: nonce-only path. Do NOT fall back on unknown nonce. + const itemIdByNonce = d.pendingPermissionsByNonce.get(nonce); + if (itemIdByNonce) { + const existing = d.itemsById.get(itemIdByNonce); + if (existing?.type === "lifecycle") { + const outcomeText = describePermissionTerminalReason( + terminalReason, + outcomeKind, + asString(result.optionId) ?? null, + existing.options, + ); + setPermissionItem(d, itemIdByNonce, { + ...existing, + outcome: outcomeText, + actionable: false, + }); + } + // Clean up nonce index. + d.pendingPermissionsByNonce = new Map(d.pendingPermissionsByNonce); + d.pendingPermissionsByNonce.delete(nonce); + // Clean up compound legacy key if it matches. + if (responseId) { + const legacyKey = `${ch}:${ctx.sessionId ?? ""}:${ctx.turnId ?? ""}:${responseId}`; + if (d.pendingPermissions.has(legacyKey)) { + d.pendingPermissions = new Map(d.pendingPermissions); + d.pendingPermissions.delete(legacyKey); + } + } + } + // Unknown nonce: drop frame — do not mutate any card. + } else if (outcomeKind && responseId) { + // No nonce: legacy compound-key fallback for non-ask paths. + const legacyKey = `${ch}:${ctx.sessionId ?? ""}:${ctx.turnId ?? ""}:${responseId}`; + const pendingById = d.pendingPermissions.get(legacyKey); + if (pendingById) { + const optionId = asString(result.optionId) ?? null; + const outcomeText = describePermissionOutcome( + outcomeKind, + optionId, + // Legacy path: no harness labels available (non-ask path). + // Pass an empty labels map so the verb-only fallback ("Approved" / + // "Denied") renders rather than a raw kind string. + new Map(), + pendingById.optionNames, + ); + const existing = d.itemsById.get(pendingById.itemId); + if (existing?.type === "lifecycle") { + setPermissionItem(d, pendingById.itemId, { + ...existing, + outcome: outcomeText, + actionable: false, + }); + } + d.pendingPermissions = new Map(d.pendingPermissions); + d.pendingPermissions.delete(legacyKey); + } + } +} + +/** + * Handle a `control_result` frame for a `permission_decision` delivery. + * + * Three statuses are treated as authoritative failures — the harness cannot + * route the decision at all, so the card's `deliveryFailed` token is + * incremented and the buttons re-enable for a manual retry: + * `no_active_turn`, `channel_closed`, `no_channel` + * + * Two statuses are success and must not fail the card: + * `sent` — the harness forwarded the decision to the live read loop. + * `already_decided` — a retransmit matched a nonce the harness had already + * applied (suppressed); incrementing `deliveryFailed` here would flip a + * correctly-resolved card back to clickable, the exact P1 this exists to + * prevent. + * + * `channel_full` is a transient queue-saturation condition (one or more + * eligible queues could not accept the frame). The retransmit orchestrator + * (`retransmitPermissionDecision.ts`) stays subscribed and keeps resending + * until the harness accepts or the deadline expires. The card must remain + * DISABLED while the automatic retry is in progress — do NOT increment + * `deliveryFailed` here. + */ +export function handlePermissionDecisionResult( + d: PermissionDraftSlice, + payload: Record<string, unknown>, +) { + const frameType = asString(payload.type); + if (frameType !== "permission_decision") return; + const deliveryStatus = asString(payload.status); + // Success statuses — no card update needed. + if (deliveryStatus === "sent" || deliveryStatus === "already_decided") return; + // Transient queue-saturation — retransmit orchestrator keeps retrying; + // do not re-enable the buttons mid-retry. + if (deliveryStatus === "channel_full") return; + // Authoritative failure — find the card by nonce and mark it retryable. + const nonce = asString(payload.requestNonce); + if (!nonce) return; + const itemId = d.pendingPermissionsByNonce.get(nonce); + if (!itemId) return; + const existing = d.itemsById.get(itemId); + if ( + existing?.type === "lifecycle" && + existing.renderClass === "permission" && + existing.actionable + ) { + setPermissionItem(d, itemId, { + ...existing, + // Increment the failure token so the effect in + // PermissionDecisionButtons re-fires even when a prior + // failure already set deliveryFailed (a sticky boolean + // value would not change on the second failure and the + // useEffect dependency would not trigger). + deliveryFailed: (existing.deliveryFailed ?? 0) + 1, + }); + } +} diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 578f98076cd..e8118b03a0d 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -10,6 +10,25 @@ export type ObserverEvent = { turnId: string | null; startedAt?: string | null; payload: unknown; + /** + * Present on `acp_read` permission frames (kind === "acp_read" + method === + * "session/request_permission"). Carries the harness-level permission gate + * metadata — `requestNonce`, `actionable`, and an optional human-readable + * `reason`. Payloads are raw ACP; there is no `_buzz` wrapper field. + */ + authorization?: { + requestNonce: string; + actionable: boolean; + reason?: string; + /** + * Wire card-expiry (unix seconds) for an actionable card. Bounds the + * desktop's retransmit-until-acked loop so a decision published while the + * harness socket is down is resent until the card expires, never past it. + * Absent on non-actionable frames and on archived/pre-upgrade frames signed + * before this field existed. + */ + expiresAt?: number; + }; }; export type ConnectionState = @@ -112,6 +131,47 @@ export type TranscriptItem = timestamp: string; descriptor?: AgentActivityDescriptor; acpSource?: TranscriptAcpSource; + /** + * Nonce from the `authorization` envelope on an `acp_read` permission + * frame. Present only on `renderClass === "permission"` items; used to + * correlate the `permission_decision` control response and to match + * incoming `control_result` frames back to this card. + */ + requestNonce?: string; + /** + * Wire card-expiry (unix seconds) from the `authorization` envelope on an + * actionable `acp_read` permission frame. Bounds the observer-feed card's + * retransmit-until-acked loop. Absent on read-only cards and on + * archived/pre-upgrade frames. + */ + expiresAt?: number; + /** + * When `true`, this card is waiting for a user Allow/Deny decision. + * `false` (or absent) means the card is read-only (auto-handled, or the + * policy is not `ask`). + */ + actionable?: boolean; + /** + * Human-readable reason string from the `authorization` envelope. + * Displayed as context below the request description. + */ + authorizationReason?: string; + /** + * Parsed options from the request params, passed back for Allow/Deny + * button rendering. + */ + options?: Array<{ optionId: string; kind: string; label?: string }>; + /** + * Monotonically increasing token incremented on every authoritative + * `control_result` delivery failure (`no_active_turn`, `channel_closed`, + * `no_channel`). The transient `channel_full` status does NOT increment + * this token — the retransmit orchestrator handles that status + * automatically. The `PermissionDecisionButtons` component keys its + * re-enable effect on this value, so a second failure after a retry + * (same boolean value would not re-trigger the effect) still re-enables + * the buttons. `undefined` when no failure has occurred. + */ + deliveryFailed?: number; } & TranscriptItemIdentity) | ({ id: string; diff --git a/desktop/src/features/agents/ui/personaBehaviorDraft.test.mjs b/desktop/src/features/agents/ui/personaBehaviorDraft.test.mjs index 9cf1d32e75e..936d8c067e4 100644 --- a/desktop/src/features/agents/ui/personaBehaviorDraft.test.mjs +++ b/desktop/src/features/agents/ui/personaBehaviorDraft.test.mjs @@ -92,6 +92,7 @@ test("edit with a changed quad submits the full group", () => { respondTo: "allowlist", respondToAllowlist: [HEX, "b".repeat(64)], parallelism: undefined, + permissionPolicy: undefined, }); }); @@ -115,12 +116,54 @@ test("draftFromBehavior round-trips a full quad and copies the list", () => { respondTo: "allowlist", respondToAllowlist: [HEX], parallelism: "3", + permissionPolicy: null, }); draft.respondToAllowlist.push("mutated"); assert.deepEqual(behavior.respondToAllowlist, [HEX], "list must be copied"); assert.deepEqual(draftFromBehavior(undefined), emptyPersonaBehaviorDraft); }); +// ── Permission policy (definition default, resolver tier 2) ────────────────── + +test("a policy-only draft submits just the policy on create", () => { + const group = behaviorForSubmit( + { ...emptyPersonaBehaviorDraft, permissionPolicy: "reject" }, + emptyPersonaBehaviorDraft, + false, + ); + assert.deepEqual(group, { + respondTo: undefined, + respondToAllowlist: undefined, + parallelism: undefined, + permissionPolicy: "reject", + }); +}); + +test("changing only the policy on edit submits the full group", () => { + const seed = { ...emptyPersonaBehaviorDraft, permissionPolicy: "ask" }; + const group = behaviorForSubmit( + { ...seed, permissionPolicy: "allow" }, + seed, + true, + ); + assert.equal(group.permissionPolicy, "allow"); +}); + +test("clearing the policy on edit submits an explicit clear, not nothing", () => { + // A definition whose only behavioral field is a policy, cleared to inherit, + // must submit `{}` — "submit nothing" would silently no-op the clear and the + // stored default would resurrect on reopen (same contract as respondTo). + const seed = { ...emptyPersonaBehaviorDraft, permissionPolicy: "allow" }; + const group = behaviorForSubmit(emptyPersonaBehaviorDraft, seed, true); + assert.deepEqual(group, {}, "full clear must submit a replace-with-empty"); +}); + +test("draftFromBehavior round-trips a policy-only behavior group", () => { + const draft = draftFromBehavior({ permissionPolicy: "reject" }); + assert.equal(draft.permissionPolicy, "reject"); + assert.equal(draft.respondTo, null); +}); + test("edit full-clear submits an explicit empty group, not nothing", () => { // Pinky's 48f260a11 finding: a mode-less quad (toolsets/parallelism only) // cleared to completely empty must still submit, or the stored quad diff --git a/desktop/src/features/agents/ui/personaBehaviorDraft.ts b/desktop/src/features/agents/ui/personaBehaviorDraft.ts index 3122f8a676a..13e5a400a19 100644 --- a/desktop/src/features/agents/ui/personaBehaviorDraft.ts +++ b/desktop/src/features/agents/ui/personaBehaviorDraft.ts @@ -1,4 +1,8 @@ -import type { PersonaBehaviorInput, RespondToMode } from "@/shared/api/types"; +import type { + PermissionPolicy, + PersonaBehaviorInput, + RespondToMode, +} from "@/shared/api/types"; /** * Dialog-side draft of a definition's NIP-AP behavioral group. @@ -15,12 +19,19 @@ export type PersonaBehaviorDraft = { respondToAllowlist: string[]; /** Raw text; only `parseInt > 0` submits (legacy dialog parity). */ parallelism: string; + /** + * Definition-level default permission policy — resolver tier 2. `null` + * means "no default" (defer to global/built-in). Local-only: it is never + * published, so it does not affect the definition's content hash. + */ + permissionPolicy: PermissionPolicy | null; }; export const emptyPersonaBehaviorDraft: PersonaBehaviorDraft = { respondTo: null, respondToAllowlist: [], parallelism: "", + permissionPolicy: null, }; /** Seed the draft from a dialog-state behavior group (edit/duplicate). */ @@ -32,6 +43,7 @@ export function draftFromBehavior( respondToAllowlist: [...(behavior?.respondToAllowlist ?? [])], parallelism: behavior?.parallelism != null ? String(behavior.parallelism) : "", + permissionPolicy: behavior?.permissionPolicy ?? null, }; } @@ -56,9 +68,12 @@ function behaviorFromDraft( respondToAllowlist: draft.respondTo === "allowlist" ? draft.respondToAllowlist : undefined, parallelism: parallelism > 0 ? parallelism : undefined, + permissionPolicy: draft.permissionPolicy ?? undefined, }; const isEmpty = - group.respondTo === undefined && group.parallelism === undefined; + group.respondTo === undefined && + group.parallelism === undefined && + group.permissionPolicy === undefined; return isEmpty ? undefined : group; } diff --git a/desktop/src/features/agents/ui/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs index aab59803ddb..47a56d3d2da 100644 --- a/desktop/src/features/agents/ui/personaDialogState.test.mjs +++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs @@ -262,6 +262,10 @@ test("edit and duplicate seed the behavior group from a quad-bearing persona", ( respondTo: "allowlist", respondToAllowlist: ["a".repeat(64)], parallelism: 4, + // No definition-default policy on this persona, so the seed carries it as + // undefined alongside the other unset fields (matches the `?? undefined` + // pattern the whole group uses; JSON.stringify drops it on submit). + permissionPolicy: undefined, }; assert.deepEqual( editPersonaDialogState(persona).initialValues.behavior, @@ -273,6 +277,65 @@ test("edit and duplicate seed the behavior group from a quad-bearing persona", ( ); }); +test("edit and duplicate seed the definition permission policy into the behavior group", () => { + // Regression companion to the behaviorEntry fix: a stored definition-default + // policy must reach the dialog's behavior draft, or editing an unrelated + // field would submit a group without it and silently clear the default. + const persona = { + id: "persona-policy", + displayName: "Gated", + avatarUrl: null, + systemPrompt: "Guarded.", + runtime: null, + model: null, + provider: null, + isBuiltIn: false, + isActive: true, + permissionPolicy: "reject", + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-02T00:00:00Z", + }; + + assert.equal( + editPersonaDialogState(persona).initialValues.behavior?.permissionPolicy, + "reject", + ); + assert.equal( + duplicatePersonaDialogState(persona).initialValues.behavior + ?.permissionPolicy, + "reject", + ); +}); + +test("a policy-only persona still seeds a behavior group", () => { + // The behaviorEntry guard must treat permissionPolicy as behavior-bearing: + // a persona whose only behavioral field is the policy default must NOT be + // seeded as behavior-less, or the default would never reach the dialog. + const persona = { + id: "persona-policy-only", + displayName: "PolicyOnly", + avatarUrl: null, + systemPrompt: "Just a policy.", + runtime: null, + model: null, + provider: null, + isBuiltIn: false, + isActive: true, + respondTo: null, + parallelism: null, + permissionPolicy: "allow", + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-02T00:00:00Z", + }; + + assert.deepEqual(editPersonaDialogState(persona).initialValues.behavior, { + respondTo: undefined, + respondToAllowlist: undefined, + parallelism: undefined, + permissionPolicy: "allow", + }); +}); + test("a linked instance overrides stale definition access in the edit dialog", () => { const persona = { id: "persona-instance-access", @@ -300,6 +363,10 @@ test("a linked instance overrides stale definition access in the edit dialog", ( respondTo: "allowlist", respondToAllowlist: ["c".repeat(64)], parallelism: 2, + // No definition-default policy on this persona, so the seed carries it as + // undefined alongside the other unset fields (JSON.stringify drops it on + // submit); the access override only rewrites respondTo/respondToAllowlist. + permissionPolicy: undefined, }); }); diff --git a/desktop/src/features/agents/ui/personaDialogState.ts b/desktop/src/features/agents/ui/personaDialogState.ts index a686dbd6827..b8b33fe97e4 100644 --- a/desktop/src/features/agents/ui/personaDialogState.ts +++ b/desktop/src/features/agents/ui/personaDialogState.ts @@ -81,14 +81,18 @@ export function duplicatePersonaDialogState( } /** - * Seed a dialog behavior group from a stored persona. A quad-less persona + * Seed a dialog behavior group from a stored persona. A behavior-less persona * yields no `behavior` key at all, keeping initialValues byte-identical to - * the pre-quad shape (spread-in entry, matching the namePool import pattern). + * the pre-behavior shape (spread-in entry, matching the namePool import pattern). */ function behaviorEntry( persona: AgentPersona, ): { behavior: PersonaBehaviorInput } | Record<string, never> { - if (persona.respondTo == null && persona.parallelism == null) { + if ( + persona.respondTo == null && + persona.parallelism == null && + persona.permissionPolicy == null + ) { return {}; } return { @@ -99,6 +103,7 @@ function behaviorEntry( ? persona.respondToAllowlist : undefined, parallelism: persona.parallelism ?? undefined, + permissionPolicy: persona.permissionPolicy ?? undefined, }, }; } diff --git a/desktop/src/features/agents/useGlobalAgentConfig.ts b/desktop/src/features/agents/useGlobalAgentConfig.ts index 4b90beb43d8..294427742ed 100644 --- a/desktop/src/features/agents/useGlobalAgentConfig.ts +++ b/desktop/src/features/agents/useGlobalAgentConfig.ts @@ -19,6 +19,7 @@ const EMPTY_CONFIG: GlobalAgentConfig = { provider: null, model: null, preferred_runtime: null, + permission_policy: null, }; export const globalAgentConfigQueryKey = ["globalAgentConfig"] as const; diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index ee4cc628f26..6002a09d157 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -773,3 +773,204 @@ test("verified agent owner may publish a suppression edit", () => { true, ); }); + +// --------------------------------------------------------------------------- +// Sentinel edit-gate regression: only agent-signed edits may overlay a +// permission-request sentinel. Owner/attacker edits must leave the original +// pending body intact. Drives formatTimelineMessages → computePermissionRequest. +// +// PUBKEY_A = agent signer, PUBKEY_B = owner, ATTACKER = third party. +// --------------------------------------------------------------------------- + +import { computePermissionRequest } from "@/shared/lib/computePermissionRequest.ts"; + +const ATTACKER_PUBKEY = + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + +// Minimal valid pending sentinel — bare JSON as the harness emits. +const PENDING_SENTINEL = JSON.stringify({ + v: 1, + state: "pending", + requestNonce: "sentinel-gate-test-nonce", + sessionId: null, + turnId: null, + expiresAt: 9_999_999_999, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow", "opt-deny": "Deny" }, +}); + +const RESOLVED_SENTINEL = JSON.stringify({ + v: 1, + state: "resolved", + requestNonce: "sentinel-gate-test-nonce", + originalEventId: HEX64_A, + sessionId: null, + turnId: null, + expiresAt: 9_999_999_999, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow", "opt-deny": "Deny" }, + outcome: "applied", + chosenOptionId: "opt-allow", +}); + +// Agent-signed pending sentinel message. +function sentinelMessage(overrides = {}) { + return { + id: HEX64_A, + pubkey: PUBKEY_A, + kind: 9, + created_at: 1_700_000_000, + content: PENDING_SENTINEL, + tags: [["h", CHANNEL_ID]], + sig: "sig", + ...overrides, + }; +} + +// Edit event targeting the sentinel. +function sentinelEdit(content, signerPubkey, overrides = {}) { + return { + id: HEX64_B, + pubkey: signerPubkey, + kind: 40003, + created_at: 1_700_000_001, + content, + tags: [ + ["h", CHANNEL_ID], + ["e", HEX64_A], + ], + sig: "sig", + ...overrides, + }; +} + +// Profiles: PUBKEY_A agent whose owner is PUBKEY_B. +const SENTINEL_PROFILES = { + [PUBKEY_A]: { ownerPubkey: PUBKEY_B, isAgent: true }, +}; + +test("sentinel_owner_edit_rejected_pending_body_preserved_and_card_actionable", () => { + // Case 1: agent-signed pending kind-9 + owner-signed resolved edit. + // The owner edit is authorized for normal messages but must be dropped for + // sentinels — pending body must survive, card must remain actionable. + const ownerEdit = sentinelEdit(RESOLVED_SENTINEL, PUBKEY_B); + const [row] = formatTimelineMessages( + [sentinelMessage(), ownerEdit], + null, + undefined, + null, + SENTINEL_PROFILES, + ); + + // Body must be the original pending sentinel, not the resolved override. + assert.equal(row.body, PENDING_SENTINEL, "pending body preserved"); + assert.equal( + row.editSignerPubkey, + undefined, + "no editSignerPubkey when edit is rejected", + ); + + // computePermissionRequest with no edit: must yield a pending payload. + const payload = computePermissionRequest( + row.body, + true, + PUBKEY_A, + row.signerPubkey, + row.editSignerPubkey, + ); + assert.ok(payload !== null, "card must be active"); + assert.equal(payload.state, "pending", "card remains pending"); +}); + +test("sentinel_edit_before_original_owner_edit_still_rejected", () => { + // Case 2: same as case 1 but edit arrives before the original in the array. + const ownerEdit = sentinelEdit(RESOLVED_SENTINEL, PUBKEY_B, { + created_at: 1_699_999_999, + }); + const [row] = formatTimelineMessages( + [ownerEdit, sentinelMessage()], + null, + undefined, + null, + SENTINEL_PROFILES, + ); + + assert.equal( + row.body, + PENDING_SENTINEL, + "pending body preserved regardless of arrival order", + ); + assert.equal(row.editSignerPubkey, undefined, "no editSignerPubkey"); + + const payload = computePermissionRequest( + row.body, + true, + PUBKEY_A, + row.signerPubkey, + row.editSignerPubkey, + ); + assert.ok(payload !== null, "card must be active"); + assert.equal(payload.state, "pending", "card remains pending"); +}); + +test("sentinel_attacker_edit_rejected_pending_body_preserved", () => { + // Case 3: attacker-signed edit targeting a sentinel — neither authorized + // by isAuthorizedMessageEdit nor by the sentinel gate. + const attackerEdit = sentinelEdit(RESOLVED_SENTINEL, ATTACKER_PUBKEY); + const [row] = formatTimelineMessages( + [sentinelMessage(), attackerEdit], + null, + undefined, + null, + SENTINEL_PROFILES, + ); + + assert.equal( + row.body, + PENDING_SENTINEL, + "pending body preserved against attacker edit", + ); + assert.equal(row.editSignerPubkey, undefined); + + const payload = computePermissionRequest( + row.body, + true, + PUBKEY_A, + row.signerPubkey, + row.editSignerPubkey, + ); + assert.ok(payload !== null, "card active"); + assert.equal(payload.state, "pending"); +}); + +test("sentinel_agent_edit_accepted_card_retires_to_resolved", () => { + // Case 4: agent-signed resolved edit — the one valid resolution path. + // pending card must retire to non-actionable resolved state. + const agentEdit = sentinelEdit(RESOLVED_SENTINEL, PUBKEY_A); + const [row] = formatTimelineMessages( + [sentinelMessage(), agentEdit], + null, + undefined, + null, + SENTINEL_PROFILES, + ); + + assert.equal(row.body, RESOLVED_SENTINEL, "resolved sentinel body applied"); + assert.equal( + row.editSignerPubkey, + PUBKEY_A.toLowerCase(), + "editSignerPubkey is the agent", + ); + + const payload = computePermissionRequest( + row.body, + true, + PUBKEY_A, + row.signerPubkey, + row.editSignerPubkey, + row.id, + row.preEditBody, + ); + assert.ok(payload !== null, "card present"); + assert.equal(payload.state, "resolved", "card retired to resolved"); +}); diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index a24da67f700..bfdb86bc1b7 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -46,6 +46,7 @@ import { formatTime } from "@/features/messages/lib/dateFormatters"; // can exercise the exact same source the renderer uses. import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; import { truncatePubkey } from "@/shared/lib/pubkey"; +import { isPermissionRequestSentinel } from "@/shared/lib/permissionRequest"; const HEX_RE = /^[0-9a-f]+$/i; @@ -262,7 +263,12 @@ export function formatTimelineMessages( // the original (`h`, `p` mentions, etc.) stay untouched. const editsByTargetId = new Map< string, - { content: string; tags: string[][]; createdAt: number } + { + content: string; + tags: string[][]; + createdAt: number; + signerPubkey: string; + } >(); for (const event of events) { if ( @@ -283,6 +289,19 @@ export function formatTimelineMessages( ) { continue; } + + // Sentinel-specific edit gate: permission-request sentinels may only be + // overlaid by an edit signed by the ORIGINAL AGENT (byte-equal to the + // target's signer). Owner-signed or attacker-signed edits of sentinels + // are silently dropped here so the authenticated pending card is preserved + // intact. Generic owner-edit behavior for non-sentinel messages is + // unchanged. + if ( + isPermissionRequestSentinel(target.content) && + normalizePubkey(event.pubkey) !== normalizePubkey(target.pubkey) + ) { + continue; + } if (hasLinkPreviewSuppression(event.tags)) { previewSuppressedTargetIds.add(targetId); } @@ -293,6 +312,7 @@ export function formatTimelineMessages( content: event.content, tags: event.tags, createdAt: event.created_at, + signerPubkey: normalizePubkey(event.pubkey), }); } } @@ -504,6 +524,8 @@ export function formatTimelineMessages( : undefined, time: formatTime(event.created_at), body: edit ? edit.content : event.content, + editSignerPubkey: edit?.signerPubkey, + preEditBody: edit ? event.content : undefined, parentId: thread.parentId, rootId: thread.rootId, depth: getDepth(event), diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index ec656f22366..7e29d24e1e9 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -24,6 +24,21 @@ export type TimelineMessage = { * user that cryptographically signed the event. */ signerPubkey?: string; + /** + * Signer pubkey of the most recent authorized kind-40003 edit, normalized to + * lowercase hex. Present only when an edit exists. Used by the + * `PermissionRequestCard` to enforce edit authenticity: only edits signed by + * the original agent may resolve the card. + */ + editSignerPubkey?: string; + /** + * The message body BEFORE the most recent edit was overlaid, when an edit + * exists. For a permission-request sentinel this is the original pending + * payload; `computePermissionRequest` correlates the resolved edit's + * `requestNonce`/`sessionId`/`turnId` against it so a same-signer agent + * cannot cross-apply a resolution meant for a different card. + */ + preEditBody?: string; author: string; /** True when the displayed author is known to be an agent. */ isAgent?: boolean; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 02858dac6a0..30395a1ddcf 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -32,8 +32,10 @@ import { KIND_STREAM_MESSAGE_DIFF, } from "@/shared/constants/kinds"; import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey"; +import { PermissionRequestCardBlock } from "@/features/messages/ui/PermissionRequestCardBlock"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { selectPermissionRequest as selectPermReq } from "@/features/messages/ui/permissionRequestAuthPubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; @@ -162,8 +164,7 @@ export const MessageRow = React.memo( videoReviewCommentRootId?: string; videoReviewContext?: VideoReviewContext; }) { - // Keep the transient send state with its timestamp rather than collapsing - // it into a grouped message row with no header. + // Keep transient send state on its own row, never a headerless group row. const isDisplayedAsContinuation = isContinuation && !message.pending; const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>( null, @@ -242,10 +243,9 @@ export const MessageRow = React.memo( () => resolveMentionProps(message.tags, profiles), [profiles, message.tags], ); - // "Is this pubkey an agent" = the community-scoped baseline every surface - // shares (managed ∪ relay) plus the pubkey's own profile `isAgent` flag from this surface's lookup. Both are per-pubkey - // O(1) checks — no per-row rescan of `profiles` (that duplicated parent - // work in every mounted row and re-ran on each profile-lookup change). + // "Is this pubkey an agent" = the community-scoped baseline (managed ∪ + // relay) plus the pubkey's own profile `isAgent` flag — both O(1) per + // pubkey, no per-row rescan of `profiles`. const knownAgentPubkeys = useKnownAgentPubkeys(); const isKnownAgentPubkey = React.useCallback( (pubkey: string) => { @@ -374,6 +374,9 @@ export const MessageRow = React.memo( const getTag = (name: string) => message.tags?.find((tag) => tag[0] === name)?.[1]; + // Computed once — prose suppressed iff card renders, by construction. + const permReq = selectPermReq(message, isKnownAgentPubkey, channelId); + const renderBody = () => { switch (message.kind) { case KIND_STREAM_MESSAGE_DIFF: @@ -421,6 +424,8 @@ export const MessageRow = React.memo( ); } + if (permReq !== null) return null; + return ( <VideoReviewCommentMarkdown channelNames={channelNames} @@ -429,10 +434,9 @@ export const MessageRow = React.memo( emojiOnly && "text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle", )} - // Only pass the author pubkey for agent-authored messages so - // config-nudge cards can authenticate the sender. Uses the - // raw event signer (signerPubkey), not a relay-delegated display - // author, because the agent itself must have signed the card. + // Author pubkey only for agent messages, so config-nudge cards + // authenticate the sender. Uses the raw event signer + // (signerPubkey), not a relay-delegated author. configNudgeAuthorPubkey={getConfigNudgeAuthorPubkey( message, isKnownAgentPubkey, @@ -466,6 +470,10 @@ export const MessageRow = React.memo( const showRespondToIndicator = message.respondTo === "anyone" || message.respondTo === "allowlist"; + const respondToIndicatorLabel = + message.respondTo === "anyone" + ? "Anyone can send instructions to this agent" + : "Selected people can send instructions to this agent"; const avatarNode = ( <div className="relative shrink-0"> @@ -481,20 +489,10 @@ export const MessageRow = React.memo( !hideAgentAccessBadge && !isThreadReplyLayout ? ( <span - className={cn( - "absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center rounded-full bg-background", - )} + className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center rounded-full bg-background" role="img" - aria-label={ - message.respondTo === "anyone" - ? "Anyone can send instructions to this agent" - : "Selected people can send instructions to this agent" - } - title={ - message.respondTo === "anyone" - ? "Anyone can send instructions to this agent" - : "Selected people can send instructions to this agent" - } + aria-label={respondToIndicatorLabel} + title={respondToIndicatorLabel} > {message.respondTo === "anyone" ? ( <AlertTriangle @@ -680,6 +678,11 @@ export const MessageRow = React.memo( <> <SentFromThreadLine channelId={channelId} tags={message.tags} /> {renderBody()} + <PermissionRequestCardBlock + message={message} + permReq={permReq} + channelId={channelId} + /> {continuationMetadataNode} <MessageReactions messageId={message.id} @@ -928,8 +931,7 @@ export const MessageRow = React.memo( </article> </div> ); - // Callbacks (onReply, onToggleReaction) intentionally excluded: inline arrows - // from parent create new refs every render — including them defeats memo. + // Callbacks (onReply, onToggleReaction) excluded: parent arrows defeat memo. }, (prev, next) => prev.message.id === next.message.id && @@ -941,17 +943,17 @@ export const MessageRow = React.memo( prev.message.ownerLabel === next.message.ownerLabel && prev.message.avatarUrl === next.message.avatarUrl && prev.message.accent === next.message.accent && - // The header timestamp and hover gutter both derive from createdAt (the - // old `time` prop was the same value pre-formatted; this row reads neither). + // Header timestamp and hover gutter both derive from createdAt. prev.message.createdAt === next.message.createdAt && prev.message.depth === next.message.depth && prev.message.kind === next.message.kind && prev.message.pending === next.message.pending && prev.message.edited === next.message.edited && - // Value comparisons, not identity: these arrays are rebuilt with fresh - // identities on every ingest/refetch even when unchanged — identity - // checks made every row re-render on every streamed event in an open - // thread (see messageRowEquality.ts). + prev.message.editSignerPubkey === next.message.editSignerPubkey && + prev.message.signerPubkey === next.message.signerPubkey && + prev.message.preEditBody === next.message.preEditBody && + prev.channelId === next.channelId && + // Value comparisons, not identity: fresh identities on ingest (messageRowEquality.ts). reactionsEqual(prev.message.reactions, next.message.reactions) && tagsEqual(prev.message.tags, next.message.tags) && prev.message.role === next.message.role && @@ -993,5 +995,4 @@ export const MessageRow = React.memo( prev.videoReviewCommentRootId === next.videoReviewCommentRootId && prev.videoReviewContext === next.videoReviewContext, ); - MessageRow.displayName = "MessageRow"; diff --git a/desktop/src/features/messages/ui/MessageRowProseGate.test.mjs b/desktop/src/features/messages/ui/MessageRowProseGate.test.mjs new file mode 100644 index 00000000000..ed363aa6375 --- /dev/null +++ b/desktop/src/features/messages/ui/MessageRowProseGate.test.mjs @@ -0,0 +1,517 @@ +/** + * MessageRow prose-suppression gate: forged-signer sentinel renders prose + * (not a blank row). + * + * This test exercises the REAL MessageRow call site for the prose gate: + * + * line 431: `if (permReq !== null) return null;` + * + * Thufir's B2 mutation replaces that line with + * `if (isPermissionRequestSentinel(message.body)) return null;` + * — which causes this test to fail: the sentinel shape matches → prose + * suppressed → the distinctive nonce string disappears from textContent → + * assertion fires. + * + * Sub-components that use `useAppNavigation` (which requires a live + * RouterProvider) are stubbed via inline `registerHooks` so the test runs + * in the standard Node.js + JSDOM harness without router overhead. + * The stub pattern follows inboxReopenNavigation.test.mjs. + * + * What is stubbed (and why): + * - `useAppNavigation` — called by `markdown.tsx` MarkdownInner and + * `SentFromThreadLine` for navigation callbacks; requires a live + * TanStack RouterProvider (async init, hangs the test in JSDOM). All + * stubbed callbacks are noops — navigation is not exercised here. + * - `MessageActionBar` — brings in `useRemindLater` and other Tauri-backed + * contexts; only needed for the action bar overlay which is irrelevant + * to the prose-gate contract. + * + * What is NOT stubbed: + * - `VideoReviewCommentMarkdown` / `MarkdownInner` — the actual prose + * renderer that displays the body text. Its output is exactly what + * the test observes. + */ +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { test, before, afterEach, after, mock } from "node:test"; +import { JSDOM } from "jsdom"; + +// ── Stubs ────────────────────────────────────────────────────────────────── + +const NAV_STUB_SOURCE = + "export function useAppNavigation() {\n" + + " return {\n" + + " goChannel: async () => {},\n" + + " goAgents: async () => {},\n" + + " goHome: async () => {},\n" + + " goWorkflows: async () => {},\n" + + " goBack: async () => {},\n" + + " commitNavigation: async () => {},\n" + + " navigate: async () => {},\n" + + " handleSearchHit: async () => {},\n" + + " };\n" + + "}\n"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + // useAppNavigation: called by markdown.tsx + SentFromThreadLine. + // Requires a live RouterProvider — stub so tests run without one. + if ( + specifier === "@/app/navigation/useAppNavigation" || + specifier.endsWith("/useAppNavigation.ts") || + specifier.endsWith("/useAppNavigation") + ) { + return { + shortCircuit: true, + url: "buzz-prose-gate-stub:useAppNavigation", + }; + } + // MessageActionBar: uses useRemindLater + other contexts irrelevant to + // the prose-gate contract. + if ( + specifier === "./MessageActionBar" || + specifier === "@/features/messages/ui/MessageActionBar" + ) { + return { + shortCircuit: true, + url: "buzz-prose-gate-stub:MessageActionBar", + }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-prose-gate-stub:useAppNavigation") { + return { format: "module", shortCircuit: true, source: NAV_STUB_SOURCE }; + } + if (url === "buzz-prose-gate-stub:MessageActionBar") { + return { + format: "module", + shortCircuit: true, + source: "export const MessageActionBar = () => null;\n", + }; + } + return nextLoad(url, context); + }, +}); + +// ── jsdom setup ──────────────────────────────────────────────────────────── + +const dom = new JSDOM("<!doctype html><html><body></body></html>", { + url: "http://localhost", +}); + +const FAKE_NOW_MS = 1_000_000_000_000; + +before(() => { + mock.timers.enable({ apis: ["setInterval", "Date"], now: FAKE_NOW_MS }); + globalThis.self = globalThis; + + // Tauri IPC stub: resolve to null so React Query queries settle immediately + // and don't hold the event loop open after cleanup. + dom.window.__TAURI_INTERNALS__ = { + invoke: () => Promise.resolve(null), + transformCallback: () => 0, + unregisterCallback: () => {}, + }; + + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + location: dom.window.location, + MutationObserver: class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + dom.window.MutationObserver = globalThis.MutationObserver; + dom.window.ResizeObserver = globalThis.ResizeObserver; +}); + +let sharedQc; + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); + if (sharedQc) { + sharedQc.clear(); + sharedQc = undefined; + } + mock.timers.reset(); + mock.timers.enable({ apis: ["setInterval", "Date"], now: FAKE_NOW_MS }); +}); + +after(() => { + mock.timers.reset(); + dom.window.close(); +}); + +// ── Fixtures ─────────────────────────────────────────────────────────────── + +const AGENT_PUBKEY = + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; +const ATTACKER_PUBKEY = + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + +// Distinctive nonce — appears in the rendered prose iff body is NOT suppressed. +const FORGED_NONCE = "xforged-prose-gate-nonce-9c3a1b7f"; + +const FORGED_SENTINEL_BODY = JSON.stringify({ + v: 1, + state: "pending", + requestNonce: FORGED_NONCE, + sessionId: "sess-probe", + turnId: "turn-probe", + expiresAt: 9_999_999_999, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, +}); + +// QueryClient configured to settle quickly and not hold the event loop. +async function makeQc() { + const { QueryClient } = await import("@tanstack/react-query"); + sharedQc = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + staleTime: Infinity, + gcTime: 0, // GC immediately after clear() — no lingering subscriptions + }, + }, + }); + return sharedQc; +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +test("test_forged_signer_sentinel_renders_prose_not_blank", async () => { + // Forged signer: the message is signed by ATTACKER_PUBKEY, which is NOT + // a registered agent pubkey. selectPermissionRequest returns null → + // permReq is null → prose gate does NOT suppress → body renders as text. + // + // MUTATION PROOF: if line 431 is changed from + // `if (permReq !== null) return null;` + // to + // `if (isPermissionRequestSentinel(message.body)) return null;` + // then the sentinel-shaped body matches the shape check → prose is + // suppressed → FORGED_NONCE disappears from textContent → this test FAILS. + const React = (await import("react")).default; + const { render } = await import("@testing-library/react"); + const { QueryClientProvider } = await import("@tanstack/react-query"); + const { ChannelNavigationProvider } = await import( + "@/shared/context/ChannelNavigationContext.tsx" + ); + + const qc = await makeQc(); + const MessageRowMod = await import("./MessageRow.tsx"); + const MessageRow = MessageRowMod.MessageRow ?? MessageRowMod.default; + + const message = { + id: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + pubkey: AGENT_PUBKEY, + /** Forged: signer is ATTACKER, not the registered agent. */ + signerPubkey: ATTACKER_PUBKEY, + ownerPubkey: AGENT_PUBKEY, + kind: 9, + createdAt: 1_700_000_000, + isAgent: true, + author: "TestAgent", + avatarUrl: null, + time: "12:00", + depth: 0, + body: FORGED_SENTINEL_BODY, + tags: [], + reactions: [], + edited: false, + pending: false, + rootId: null, + parentId: null, + }; + + const { container } = render( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(MessageRow, { + message, + channelId: "test-channel", + // profiles[AGENT_PUBKEY].isAgent = true → isKnownAgentPubkey returns + // true for AGENT_PUBKEY. ATTACKER_PUBKEY is absent → returns false. + // selectPermissionRequest: signerPubkey = ATTACKER → not trusted → + // returns null → permReq is null → gate passes → prose renders. + profiles: { + [AGENT_PUBKEY]: { + displayName: "TestAgent", + avatarUrl: null, + isAgent: true, + }, + }, + }), + ), + ), + ); + + const textContent = container.textContent ?? ""; + const card = container.querySelector("[data-permission-request]"); + + // The body nonce must appear in prose (gate did not suppress). + assert.ok( + textContent.includes(FORGED_NONCE), + `Forged-signer sentinel must render body as prose — nonce "${FORGED_NONCE}" not ` + + `found in textContent. This test fails when MessageRow reverts to ` + + `shape-only prose suppression (isPermissionRequestSentinel gate) ` + + `instead of trust-aware suppression (permReq !== null gate).`, + ); + + // No card — the signer is not a trusted agent. + assert.equal( + card, + null, + "forged-signer sentinel must not render a permission card", + ); +}); + +// ── Fixtures for rerender tests ─────────────────────────────────────────── + +// Nonce embedded in requestNonce — will appear in textContent if body +// renders as prose (not suppressed), will be absent if card renders. +const RERENDER_NONCE_SIGNER = "xrerender-signer-nonce-4f8d2e1a"; +const RERENDER_NONCE_CHANNEL = "xrerender-channel-nonce-7b3c9f05"; + +function makePendingBody(nonce) { + return JSON.stringify({ + v: 1, + state: "pending", + requestNonce: nonce, + sessionId: "sess-rerender", + turnId: "turn-rerender", + expiresAt: 9_999_999_999, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + }); +} + +test("test_trusted_to_forged_signer_rerender_restores_prose", async () => { + // Start: AGENT_PUBKEY is a registered agent → selectPermissionRequest + // returns non-null → permReq !== null → prose suppressed, card renders. + // Rerender: signerPubkey changed to ATTACKER_PUBKEY (unregistered) → + // comparator detects the change (signerPubkey comparison added) → + // component rerenders → selectPermissionRequest returns null → + // prose suppressed gate does NOT fire → body renders as prose → nonce visible. + // + // MUTATION PROOF: dropping `prev.message.signerPubkey === next.message.signerPubkey` + // from the comparator causes this test to FAIL: the memo skips the rerender, + // the stale permReq !== null result is kept, prose stays suppressed, nonce + // remains absent from textContent. + const React = (await import("react")).default; + const { render, act } = await import("@testing-library/react"); + const { QueryClientProvider } = await import("@tanstack/react-query"); + const { ChannelNavigationProvider } = await import( + "@/shared/context/ChannelNavigationContext.tsx" + ); + + const qc = await makeQc(); + const MessageRowMod = await import("./MessageRow.tsx"); + const MessageRow = MessageRowMod.MessageRow ?? MessageRowMod.default; + + const BODY = makePendingBody(RERENDER_NONCE_SIGNER); + const baseMessage = { + id: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + pubkey: AGENT_PUBKEY, + signerPubkey: AGENT_PUBKEY, + ownerPubkey: AGENT_PUBKEY, + kind: 9, + createdAt: 1_700_000_001, + isAgent: true, + author: "TrustedAgent", + avatarUrl: null, + time: "12:01", + depth: 0, + body: BODY, + tags: [], + reactions: [], + edited: false, + pending: false, + rootId: null, + parentId: null, + }; + const profiles = { + [AGENT_PUBKEY]: { + displayName: "TrustedAgent", + avatarUrl: null, + isAgent: true, + }, + }; + + const { container, rerender } = render( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(MessageRow, { + message: baseMessage, + channelId: "test-channel", + profiles, + }), + ), + ), + ); + + // Initial render: trusted signer → permReq non-null → prose suppressed. + const initialText = container.textContent ?? ""; + assert.ok( + !initialText.includes(RERENDER_NONCE_SIGNER), + "Initial render with trusted signer must suppress prose (nonce absent).", + ); + + // Rerender with forged (untrusted) signer. + const forgedMessage = { ...baseMessage, signerPubkey: ATTACKER_PUBKEY }; + await act(async () => { + rerender( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(MessageRow, { + message: forgedMessage, + channelId: "test-channel", + profiles, + }), + ), + ), + ); + }); + + const afterText = container.textContent ?? ""; + assert.ok( + afterText.includes(RERENDER_NONCE_SIGNER), + `After rerender with forged signer, prose must be restored — ` + + `nonce "${RERENDER_NONCE_SIGNER}" not found. ` + + `FAILS when signerPubkey is omitted from the memo comparator.`, + ); +}); + +test("test_null_channelid_rerender_removes_card_restores_prose", async () => { + // Start: channelId "ch-a" (truthy) → selectPermissionRequest returns + // non-null → card renders, prose suppressed. + // Rerender: channelId set to null → comparator detects the change + // (channelId comparison added) → component rerenders → selectPermissionRequest + // returns null (channelId falsy) → prose gate does NOT fire → prose visible. + // + // MUTATION PROOF: dropping `prev.channelId === next.channelId` from the + // comparator causes this test to FAIL: the memo skips the rerender, stale + // permReq !== null kept, prose stays suppressed, nonce absent. + const React = (await import("react")).default; + const { render, act } = await import("@testing-library/react"); + const { QueryClientProvider } = await import("@tanstack/react-query"); + const { ChannelNavigationProvider } = await import( + "@/shared/context/ChannelNavigationContext.tsx" + ); + + const qc = await makeQc(); + const MessageRowMod = await import("./MessageRow.tsx"); + const MessageRow = MessageRowMod.MessageRow ?? MessageRowMod.default; + + const BODY = makePendingBody(RERENDER_NONCE_CHANNEL); + const message = { + id: "cafe5678cafe5678cafe5678cafe5678cafe5678cafe5678cafe5678cafe5678", + pubkey: AGENT_PUBKEY, + signerPubkey: AGENT_PUBKEY, + ownerPubkey: AGENT_PUBKEY, + kind: 9, + createdAt: 1_700_000_002, + isAgent: true, + author: "TrustedAgent", + avatarUrl: null, + time: "12:02", + depth: 0, + body: BODY, + tags: [], + reactions: [], + edited: false, + pending: false, + rootId: null, + parentId: null, + }; + const profiles = { + [AGENT_PUBKEY]: { + displayName: "TrustedAgent", + avatarUrl: null, + isAgent: true, + }, + }; + + const { container, rerender } = render( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(MessageRow, { + message, + channelId: "ch-a", + profiles, + }), + ), + ), + ); + + // Initial render: real channelId → prose suppressed. + const initialText = container.textContent ?? ""; + assert.ok( + !initialText.includes(RERENDER_NONCE_CHANNEL), + "Initial render with real channelId must suppress prose (nonce absent).", + ); + + // Rerender with null channelId. + await act(async () => { + rerender( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(MessageRow, { + message, + channelId: null, + profiles, + }), + ), + ), + ); + }); + + const afterText = container.textContent ?? ""; + assert.ok( + afterText.includes(RERENDER_NONCE_CHANNEL), + `After rerender with null channelId, prose must be restored — ` + + `nonce "${RERENDER_NONCE_CHANNEL}" not found. ` + + `FAILS when channelId is omitted from the memo comparator.`, + ); +}); diff --git a/desktop/src/features/messages/ui/PermissionRequestCardBlock.test.mjs b/desktop/src/features/messages/ui/PermissionRequestCardBlock.test.mjs new file mode 100644 index 00000000000..35d03e22bad --- /dev/null +++ b/desktop/src/features/messages/ui/PermissionRequestCardBlock.test.mjs @@ -0,0 +1,841 @@ +/** + * Component-level render tests for `PermissionRequestCardBlock`. + * + * These tests verify the render-time security and behaviour gates: + * - non-owner viewer sees read-only card (no buttons) + * - forged signer (signerPubkey ≠ agentPubkey) renders nothing + * - agent-signed edit resolves the card to non-actionable state + * - owner/attacker-signed edits do NOT resolve the card + * - expiry: buttons disabled after the ticking clock crosses expiresAt + * + * Wire contract: harness signs bare JSON as the kind:9 event content. + */ +import assert from "node:assert/strict"; +import { after, afterEach, before, mock, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// ── jsdom setup ─────────────────────────────────────────────────────────────── + +const dom = new JSDOM("<!doctype html><html><body></body></html>", { + url: "http://localhost", +}); + +// Use fake timers for all tests: prevents real setIntervals in +// PendingPermissionRequestCard from keeping the event loop alive after unmount. +// All tests use a fixed epoch so `Date.now()` returns a deterministic value. +const FAKE_NOW_MS = 1_000_000_000_000; // far from real time — avoids expiry surprises + +before(() => { + // Enable fake timers before any components load so Date.now() is stable. + mock.timers.enable({ apis: ["setInterval", "Date"], now: FAKE_NOW_MS }); + + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + // smoothCorners.ts requires MutationObserver; ResizeObserver used by + // various attachment components. Provide no-op stubs. + MutationObserver: class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + // smoothCorners.ts attaches a MutationObserver to the document; stub on window too + dom.window.MutationObserver = globalThis.MutationObserver; + dom.window.ResizeObserver = globalThis.ResizeObserver; +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); + if (sharedQc) { + sharedQc.clear(); + sharedQc = undefined; + } + // Drain any pending fake timers from this test before the next one starts. + mock.timers.reset(); + mock.timers.enable({ apis: ["setInterval", "Date"], now: FAKE_NOW_MS }); +}); + +after(async () => { + mock.timers.reset(); + dom.window.close(); +}); + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const AGENT_PUBKEY = + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; +const OWNER_PUBKEY = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const ATTACKER_PUBKEY = + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; +const CHANNEL_ID = "test-channel-id"; + +// The kind-9 sentinel event ID. A resolved edit must name this in +// `originalEventId` (F5 correlation). +const MESSAGE_ID = + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead"; + +// Unix epoch far in the future — buttons are live under the fake clock +const FUTURE_EXPIRY = Math.floor(FAKE_NOW_MS / 1000) + 9_999_999; +// Unix epoch in the past — buttons expired immediately (prefixed _ = intentionally unused) +const _PAST_EXPIRY = 1; + +function makePendingContent(expiresAt = FUTURE_EXPIRY) { + return JSON.stringify({ + v: 1, + state: "pending", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + }); +} + +function makeResolvedContent() { + return JSON.stringify({ + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: MESSAGE_ID, + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: FUTURE_EXPIRY, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + outcome: "applied", + chosenOptionId: "opt-allow", + }); +} + +// Shared QueryClient — created once, cleared between tests. +// `gcTime: 0` prevents React Query's garbage-collection timer from keeping +// the event loop alive after the test completes. +let sharedQc; + +async function getQueryClient(viewerPubkey) { + const { QueryClient } = await import("@tanstack/react-query"); + if (sharedQc) sharedQc.clear(); + sharedQc = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); + sharedQc.setQueryData(["identity"], { pubkey: viewerPubkey }); + return sharedQc; +} + +async function makeQueryClient(viewerPubkey) { + return getQueryClient(viewerPubkey); +} + +// ── Render helper ───────────────────────────────────────────────────────────── + +// Build a minimal TimelineMessage carrying the sentinel. +function makeMessage({ + content, + signerPubkey, + editSignerPubkey, + ownerPubkey, + id, + preEditBody, +}) { + return { + id, + kind: 9, + isAgent: true, + body: content, + signerPubkey, + editSignerPubkey, + ownerPubkey, + preEditBody, + }; +} + +// The block now accepts a pre-computed `permReq` from `selectPermissionRequest`. +// We compute it here in the test helper — using the same function MessageRow uses — +// so forged signer (signer ≠ agent) fails the gate exactly as production does. +function makeIsKnownAgentPubkey(agentPubkey) { + return (pubkey) => pubkey === agentPubkey; +} + +async function renderBlock({ + content, + signerPubkey = AGENT_PUBKEY, + agentPubkey = AGENT_PUBKEY, + editSignerPubkey = undefined, + ownerPubkey = OWNER_PUBKEY, + viewerPubkey = OWNER_PUBKEY, + id = MESSAGE_ID, + preEditBody = undefined, +}) { + const { createElement, act } = await import("react"); + const { render } = await import("@testing-library/react"); + const { QueryClientProvider } = await import("@tanstack/react-query"); + const { PermissionRequestCardBlock } = await import( + "./PermissionRequestCardBlock.tsx" + ); + const { selectPermissionRequest } = await import( + "./permissionRequestAuthPubkey.ts" + ); + + const qc = await makeQueryClient(viewerPubkey); + const message = makeMessage({ + content, + signerPubkey, + editSignerPubkey, + ownerPubkey, + id, + preEditBody, + }); + const isKnownAgentPubkey = makeIsKnownAgentPubkey(agentPubkey); + // Compute permReq using the same selector MessageRow uses in production. + const permReq = selectPermissionRequest( + message, + isKnownAgentPubkey, + CHANNEL_ID, + ); + + let container; + await act(async () => { + ({ container } = render( + createElement( + QueryClientProvider, + { client: qc }, + createElement(PermissionRequestCardBlock, { + message, + permReq, + channelId: CHANNEL_ID, + }), + ), + )); + }); + + return container; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +test("test_owner_viewer_sees_action_buttons_on_pending_card", async () => { + const container = await renderBlock({ + content: makePendingContent(), + viewerPubkey: OWNER_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + + const allowBtn = container.querySelector( + '[data-testid="permission-decision-opt-allow"]', + ); + const denyBtn = container.querySelector( + '[data-testid="permission-decision-opt-deny"]', + ); + assert.ok(allowBtn !== null, "owner should see allow button"); + assert.ok(denyBtn !== null, "owner should see deny button"); +}); + +test("test_non_owner_viewer_sees_read_only_card_no_buttons", async () => { + const container = await renderBlock({ + content: makePendingContent(), + viewerPubkey: ATTACKER_PUBKEY, // not the owner + ownerPubkey: OWNER_PUBKEY, + }); + + // Card should render (sentinel parsed and agent matches signer) + const card = container.querySelector("[data-permission-request]"); + assert.ok(card !== null, "card renders for non-owner"); + + // But no action buttons + const btn = container.querySelector('[data-testid^="permission-decision-"]'); + assert.equal(btn, null, "non-owner must not see action buttons"); + + // Read-only indicator text present + assert.ok( + container.textContent?.includes("Waiting for owner approval"), + "non-owner sees waiting message", + ); +}); + +test("test_forged_signer_renders_nothing", async () => { + const container = await renderBlock({ + content: makePendingContent(), + agentPubkey: AGENT_PUBKEY, + signerPubkey: ATTACKER_PUBKEY, // signer ≠ agent → rejected + viewerPubkey: OWNER_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + + const card = container.querySelector("[data-permission-request]"); + assert.equal(card, null, "forged signer must not render any card"); +}); + +test("test_agent_signed_edit_resolves_card_to_non_actionable", async () => { + // kind-40003 edit signed by the original agent → resolved card, no buttons + const container = await renderBlock({ + content: makeResolvedContent(), + agentPubkey: AGENT_PUBKEY, + signerPubkey: AGENT_PUBKEY, + editSignerPubkey: AGENT_PUBKEY, // edit signed by agent ✓ + preEditBody: makePendingContent(), // correlates nonce/session/turn ✓ + viewerPubkey: OWNER_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + + const card = container.querySelector("[data-permission-request]"); + assert.ok(card !== null, "resolved card renders"); + + const btn = container.querySelector('[data-testid^="permission-decision-"]'); + assert.equal(btn, null, "resolved card has no action buttons"); + + assert.ok( + container.textContent?.includes("Permission request resolved"), + "resolved label present", + ); +}); + +test("test_owner_signed_edit_does_not_resolve_card", async () => { + // kind-40003 signed by owner, not agent → edit-authenticity gate rejects + const container = await renderBlock({ + content: makeResolvedContent(), + agentPubkey: AGENT_PUBKEY, + signerPubkey: AGENT_PUBKEY, + editSignerPubkey: OWNER_PUBKEY, // edit signed by owner ✗ + viewerPubkey: OWNER_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + + // computePermissionRequest returns null → block renders nothing + const card = container.querySelector("[data-permission-request]"); + assert.equal(card, null, "owner-signed edit must not resolve card"); +}); + +test("test_attacker_signed_edit_does_not_resolve_card", async () => { + const container = await renderBlock({ + content: makeResolvedContent(), + agentPubkey: AGENT_PUBKEY, + signerPubkey: AGENT_PUBKEY, + editSignerPubkey: ATTACKER_PUBKEY, // attacker edit ✗ + viewerPubkey: OWNER_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + + const card = container.querySelector("[data-permission-request]"); + assert.equal(card, null, "attacker-signed edit must not resolve card"); +}); + +test("test_expiry_disables_buttons_after_clock_tick", async () => { + // FAKE_NOW_MS is the current epoch. Set expiry to 1s in the future. + const EXPIRY_SECS = Math.floor(FAKE_NOW_MS / 1000) + 1; + + const { createElement, act } = await import("react"); + const { render } = await import("@testing-library/react"); + const { QueryClientProvider } = await import("@tanstack/react-query"); + const { PermissionRequestCardBlock } = await import( + "./PermissionRequestCardBlock.tsx" + ); + const { selectPermissionRequest } = await import( + "./permissionRequestAuthPubkey.ts" + ); + + const qc = await makeQueryClient(OWNER_PUBKEY); + const message = makeMessage({ + content: makePendingContent(EXPIRY_SECS), + signerPubkey: AGENT_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + const permReq = selectPermissionRequest( + message, + makeIsKnownAgentPubkey(AGENT_PUBKEY), + CHANNEL_ID, + ); + + let container; + await act(async () => { + ({ container } = render( + createElement( + QueryClientProvider, + { client: qc }, + createElement(PermissionRequestCardBlock, { + message, + permReq, + channelId: CHANNEL_ID, + }), + ), + )); + }); + + // Before expiry: buttons must be present + const btnBefore = container.querySelector( + '[data-testid="permission-decision-opt-allow"]', + ); + assert.ok(btnBefore !== null, "buttons present before expiry"); + + // Advance clock by 2 seconds — past the 1s expiry + await act(async () => { + mock.timers.tick(2_000); + }); + + // After expiry: buttons must be gone, timed-out message shown + const btnAfter = container.querySelector( + '[data-testid="permission-decision-opt-allow"]', + ); + assert.equal(btnAfter, null, "buttons absent after expiry tick"); + assert.ok( + container.textContent?.includes("Timed out"), + "timed-out message shown after expiry", + ); +}); + +// ── Delivery-outcome recovery test (Carl's P1 regression) ───────────────────── +// +// Mutation target: `permission-request-card.tsx` line +// `if (outcome === "failed") setSubmitted(null);` +// Removing that line leaves the card permanently on "Decision sent" after a +// harness routing failure, and this test catches it while the orchestrator +// suite stays green. The test uses the `_deliveryFn` seam to control the +// outcome without a real relay. + +test("test_failed_delivery_re_enables_buttons_and_successful_retry_reaches_sent", async () => { + // Build a controllable delivery function whose outcome we resolve manually. + // Each call returns a fresh promise; `resolveDelivery` settles the most + // recently created one. + let resolveDelivery; + function makeDeliveryFn() { + return (..._args) => + new Promise((resolve) => { + resolveDelivery = resolve; + }); + } + + const { createElement, act } = await import("react"); + const { render, fireEvent } = await import("@testing-library/react"); + const { QueryClientProvider } = await import("@tanstack/react-query"); + const { PermissionRequestCardBlock } = await import( + "./PermissionRequestCardBlock.tsx" + ); + const { selectPermissionRequest } = await import( + "./permissionRequestAuthPubkey.ts" + ); + + const qc = await makeQueryClient(OWNER_PUBKEY); + const message = makeMessage({ + content: makePendingContent(), + signerPubkey: AGENT_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + const permReq = selectPermissionRequest( + message, + makeIsKnownAgentPubkey(AGENT_PUBKEY), + CHANNEL_ID, + ); + + let container; + await act(async () => { + ({ container } = render( + createElement( + QueryClientProvider, + { client: qc }, + createElement(PermissionRequestCardBlock, { + message, + permReq, + channelId: CHANNEL_ID, + _deliveryFn: makeDeliveryFn(), + }), + ), + )); + }); + + // ── Step 1: initial render shows action buttons ────────────────────────── + const allowBtnInitial = container.querySelector( + '[data-testid="permission-decision-opt-allow"]', + ); + assert.ok(allowBtnInitial !== null, "buttons present before any click"); + + // ── Step 2: click — card shows "Decision sent" ──────────────────────────── + await act(async () => { + fireEvent.click(allowBtnInitial); + }); + assert.ok( + container.textContent?.includes("Decision sent"), + "card shows Decision sent after click", + ); + assert.equal( + container.querySelector('[data-testid="permission-decision-opt-allow"]'), + null, + "buttons hidden while decision is in flight", + ); + + // ── Step 3: delivery resolves "failed" → buttons must return ───────────── + // This is Carl's regression: without `if (outcome === "failed") setSubmitted(null)` + // the card stays stuck on "Decision sent" and the owner cannot retry. + await act(async () => { + resolveDelivery("failed"); + // Drain microtasks so React processes the state update. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const allowBtnAfterFail = container.querySelector( + '[data-testid="permission-decision-opt-allow"]', + ); + assert.ok( + allowBtnAfterFail !== null, + "buttons re-enabled after failed delivery — owner can retry", + ); + assert.ok( + !container.textContent?.includes("Decision sent"), + "Decision sent text cleared after failed delivery", + ); + + // ── Step 4: retry click → delivery resolves "acked" → terminal sent ─────── + // No re-render needed. `_deliveryFn` is the closure returned by `makeDeliveryFn()`. + // Each invocation of that closure creates a fresh promise and updates `resolveDelivery`, + // so clicking the re-enabled button starts a new delivery loop via the same seam. + await act(async () => { + fireEvent.click(allowBtnAfterFail); + }); + // Card is back to "Decision sent" for the second attempt + assert.ok( + container.textContent?.includes("Decision sent"), + "Decision sent shown during second delivery attempt", + ); + + // Resolve the second delivery as "acked" → terminal state + await act(async () => { + resolveDelivery("acked"); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Buttons stay hidden — "acked" is terminal, card awaits harness kind-40003 edit + assert.equal( + container.querySelector('[data-testid="permission-decision-opt-allow"]'), + null, + "buttons stay hidden after acked — waiting for harness resolution", + ); + assert.ok( + container.textContent?.includes("Decision sent"), + "Decision sent persists after acked — terminal state", + ); +}); + +// ── F1: reject choice renders "Denied", not "Approved" ──────────────────────── +// +// Mutation target: the `chosenOptionId === allowOptionId` branch in +// `outcomeLabel` (permission-request-card.tsx). Collapsing it back to +// label-blind `Approved:` for all `applied` outcomes → the test below fails. + +test("test_allow_choice_renders_approved_label", async () => { + // optionIds[0] = "opt-allow" (allow contract). Choosing it → "Approved: Allow once". + const resolvedContent = JSON.stringify({ + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: MESSAGE_ID, + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: FUTURE_EXPIRY, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + outcome: "applied", + chosenOptionId: "opt-allow", // allow option chosen + }); + + const container = await renderBlock({ + content: resolvedContent, + agentPubkey: AGENT_PUBKEY, + signerPubkey: AGENT_PUBKEY, + editSignerPubkey: AGENT_PUBKEY, + preEditBody: makePendingContent(), + viewerPubkey: OWNER_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + + assert.ok( + container.textContent?.includes("Approved"), + "allow choice must render 'Approved'", + ); + assert.ok( + !container.textContent?.includes("Denied"), + "allow choice must NOT render 'Denied'", + ); +}); + +test("test_reject_choice_renders_denied_not_approved", async () => { + // optionIds[1] = "opt-deny" (reject contract). Choosing it → "Denied: Deny". + // This is the F1 bug: before the fix, this rendered "Approved: Deny". + const resolvedContent = JSON.stringify({ + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: MESSAGE_ID, + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: FUTURE_EXPIRY, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + outcome: "applied", + chosenOptionId: "opt-deny", // reject option chosen + }); + + const container = await renderBlock({ + content: resolvedContent, + agentPubkey: AGENT_PUBKEY, + signerPubkey: AGENT_PUBKEY, + editSignerPubkey: AGENT_PUBKEY, + preEditBody: makePendingContent(), + viewerPubkey: OWNER_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + + assert.ok( + container.textContent?.includes("Denied"), + "reject choice must render 'Denied'", + ); + assert.ok( + !container.textContent?.includes("Approved"), + "reject choice must NOT render 'Approved' — F1 regression proof", + ); +}); + +// ── F2: description renders on pending card ──────────────────────────────────── + +test("test_description_renders_on_pending_card", async () => { + const contentWithDesc = JSON.stringify({ + v: 1, + state: "pending", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: FUTURE_EXPIRY, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + description: "read /etc/hosts", + }); + + const container = await renderBlock({ + content: contentWithDesc, + viewerPubkey: OWNER_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + + assert.ok( + container.textContent?.includes("read /etc/hosts"), + "description must appear on the pending card", + ); +}); + +test("test_no_description_does_not_break_pending_card", async () => { + // No description field — card should still render with buttons + const container = await renderBlock({ + content: makePendingContent(), + viewerPubkey: OWNER_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + + const card = container.querySelector("[data-permission-request]"); + assert.ok(card !== null, "card must render without description"); + + const allowBtn = container.querySelector( + '[data-testid="permission-decision-opt-allow"]', + ); + assert.ok(allowBtn !== null, "buttons must render without description"); +}); + +test("test_hostile_markup_description_renders_as_inert_text", async () => { + // Carl's F2 requirement: hostile markup must render as inert text — no + // element or execution, reachable by AT, not aria-hidden. + // React renders values as text children; this test verifies by querying the + // accessibility tree (textContent) and confirming no <script>/<img> element. + const hostileDesc = "<script>alert('xss')</script><b>bold</b>"; + const contentWithHostile = JSON.stringify({ + v: 1, + state: "pending", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: FUTURE_EXPIRY, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + description: hostileDesc, + }); + + const container = await renderBlock({ + content: contentWithHostile, + viewerPubkey: OWNER_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + + // No <script> or <img> element created — markup treated as text + assert.equal( + container.querySelector("script"), + null, + "hostile <script> must not create a script element", + ); + assert.equal( + container.querySelector("b"), + null, + "hostile <b> must not create a bold element — markup is escaped", + ); + + // The raw string must appear as text content (HTML-escaped, reachable by AT) + assert.ok( + container.textContent?.includes("<script>"), + "hostile markup must appear as literal text (accessible, not executed)", + ); +}); + +test("test_control_character_description_renders_safely", async () => { + // Control characters in description must not crash the renderer or produce + // invisible/inaccessible content. + const controlDesc = "Run\x00command\x08with\x1fcontrol\x7fchars"; + const contentWithControl = JSON.stringify({ + v: 1, + state: "pending", + requestNonce: "b9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c5", + sessionId: "sess-ctrl", + turnId: "turn-ctrl", + expiresAt: FUTURE_EXPIRY, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + description: controlDesc, + }); + + const container = await renderBlock({ + content: contentWithControl, + viewerPubkey: OWNER_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + + // Card renders (control chars do not prevent render) + const card = container.querySelector("[data-permission-request]"); + assert.ok(card !== null, "card must render with control-char description"); + + // Visible text portions are present in the accessibility tree + assert.ok( + container.textContent?.includes("Run"), + "printable parts of control-char description must be in textContent", + ); +}); + +test("test_description_accessible_not_aria_hidden", async () => { + // Description must be reachable by assistive technology — not wrapped in + // aria-hidden or hidden from the accessibility tree. + const desc = "Allow read access to /etc/hosts"; + const contentWithDesc = JSON.stringify({ + v: 1, + state: "pending", + requestNonce: "c9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c6", + sessionId: "sess-at", + turnId: "turn-at", + expiresAt: FUTURE_EXPIRY, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + description: desc, + }); + + const container = await renderBlock({ + content: contentWithDesc, + viewerPubkey: OWNER_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + + // Find the element containing the description text + const allText = container.textContent ?? ""; + assert.ok( + allText.includes(desc), + "description must appear in textContent (reachable by AT)", + ); + + // The description must NOT be inside an aria-hidden element + const ariaHiddenWithDesc = [ + ...container.querySelectorAll("[aria-hidden]"), + ].some((el) => el.textContent?.includes(desc)); + assert.equal( + ariaHiddenWithDesc, + false, + "description must not be inside an aria-hidden element", + ); +}); + +test("test_born_resolved_no_provenance_renders_nothing", async () => { + // A kind-9 whose body is already "resolved" but has no edit provenance + // (no editSignerPubkey, no preEditBody). computePermissionRequest rejects + // it — born-resolved cards bypass the agent-signed-edit requirement and + // would render a completed card with zero proof of owner action. + // The block returns null; selectPermissionRequest also returns null so + // MessageRow falls back to prose (no blank row). + const container = await renderBlock({ + content: makeResolvedContent(), + signerPubkey: AGENT_PUBKEY, + agentPubkey: AGENT_PUBKEY, + editSignerPubkey: undefined, // no edit provenance + id: MESSAGE_ID, + preEditBody: undefined, + viewerPubkey: OWNER_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + const card = container.querySelector("[data-permission-request]"); + assert.equal( + card, + null, + "born-resolved sentinel without edit provenance must not render any card", + ); +}); + +test("test_correlation_mismatch_resolved_renders_nothing", async () => { + // Resolved body where originalEventId ≠ message.id — the edit claims to + // resolve a DIFFERENT card. computePermissionRequest rejects it. + // The block returns null; hasPermissionRequestCard also returns false so + // MessageRow falls back to prose (no blank row). + const OTHER_EVENT_ID = + "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"; + const mismatchedResolved = JSON.stringify({ + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: OTHER_EVENT_ID, // ← names a different event + sessionId: "sess-fixture-001", + turnId: "turn-fixture-xyz", + expiresAt: FUTURE_EXPIRY, + optionIds: ["opt-allow", "opt-reject"], + labels: { "opt-allow": "Allow once", "opt-reject": "Reject" }, + outcome: "applied", + chosenOptionId: "opt-allow", + }); + const container = await renderBlock({ + content: mismatchedResolved, + signerPubkey: AGENT_PUBKEY, + agentPubkey: AGENT_PUBKEY, + editSignerPubkey: AGENT_PUBKEY, + id: MESSAGE_ID, // ← MESSAGE_ID ≠ OTHER_EVENT_ID + preEditBody: makePendingContent(), + viewerPubkey: OWNER_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + }); + const card = container.querySelector("[data-permission-request]"); + assert.equal( + card, + null, + "correlation-mismatch resolved body must not render any card", + ); +}); diff --git a/desktop/src/features/messages/ui/PermissionRequestCardBlock.tsx b/desktop/src/features/messages/ui/PermissionRequestCardBlock.tsx new file mode 100644 index 00000000000..2a2f943dc53 --- /dev/null +++ b/desktop/src/features/messages/ui/PermissionRequestCardBlock.tsx @@ -0,0 +1,82 @@ +/** + * Wrapper that handles the current-viewer identity check for the + * `PermissionRequestCard`, keeping React hooks out of the memo-heavy + * `MessageRow` component. + * + * Accepts a pre-computed `permReq` from `selectPermissionRequest` — the trust + * computation happens once in `MessageRow` and the result is passed down here, + * so prose is suppressed iff the card renders, by construction. + * + * Renders nothing when `permReq` is null (no trusted sentinel, wrong signer, + * falsy channelId, or non-interactive surface). + */ +import * as React from "react"; + +import type { startPermissionDecisionDelivery } from "@/features/agents/lib/permissionDecisionDelivery"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { PermissionRequestSelection } from "@/features/messages/ui/permissionRequestAuthPubkey"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { AttachmentGroup } from "@/shared/ui/attachment"; +import { PermissionRequestCard } from "@/shared/ui/permission-request-card"; + +export type PermissionRequestCardBlockProps = { + /** The message that may carry a permission-request sentinel. */ + message: Pick<TimelineMessage, "ownerPubkey">; + /** + * Pre-computed selection from `selectPermissionRequest`. When null, no card + * renders. MessageRow computes this once and uses the same result for prose + * suppression and this block — ensuring they stay in sync. + */ + permReq: PermissionRequestSelection | null; + /** + * Delivery function injected by tests to control the outcome without a real + * relay. Production callers omit this. + * + * @internal — test seam only; not part of the public API. + */ + _deliveryFn?: typeof startPermissionDecisionDelivery; + /** Channel ID for routing the decision click. */ + channelId: string | null | undefined; +}; + +export const PermissionRequestCardBlock = React.memo( + function PermissionRequestCardBlock({ + message, + permReq, + channelId, + _deliveryFn, + }: PermissionRequestCardBlockProps) { + const identityQuery = useIdentityQuery(); + const viewerPubkey = identityQuery.data?.pubkey; + + if (permReq === null || !channelId) return null; + + const { agentPubkey, request } = permReq; + const ownerPubkey = message.ownerPubkey; + const isOwner = + !!viewerPubkey && + !!ownerPubkey && + normalizePubkey(viewerPubkey) === normalizePubkey(ownerPubkey); + + return ( + <AttachmentGroup + className="max-w-full flex-wrap overflow-visible pb-0" + data-permission-request="" + > + <PermissionRequestCard + agentPubkey={agentPubkey} + channelId={channelId} + isOwner={isOwner} + request={request} + _deliveryFn={_deliveryFn} + /> + </AttachmentGroup> + ); + }, + (prev, next) => + prev.message === next.message && + prev.permReq === next.permReq && + prev.channelId === next.channelId && + prev._deliveryFn === next._deliveryFn, +); diff --git a/desktop/src/features/messages/ui/permissionRequestAuthPubkey.test.mjs b/desktop/src/features/messages/ui/permissionRequestAuthPubkey.test.mjs new file mode 100644 index 00000000000..b36cf2e0979 --- /dev/null +++ b/desktop/src/features/messages/ui/permissionRequestAuthPubkey.test.mjs @@ -0,0 +1,348 @@ +/** + * Tests for `selectPermissionRequest` (primary) and `hasPermissionRequestCard` + * (legacy delegate). + * + * `selectPermissionRequest` is the single source of truth for prose suppression + * in `MessageRow` — it incorporates channelId + isAgent eligibility AND calls + * `computePermissionRequest` to produce the trusted payload the card block + * renders. Non-null iff a card will render. + * + * This closes every blank-row case: + * - falsy channelId → no card → no prose suppression + * - !message.isAgent → no card → no prose suppression + * - forged signer (signerPubkey ≠ agentPubkey) → computePermissionRequest null + * - born-resolved-no-provenance → computePermissionRequest null + * - correlation-mismatch resolved body → computePermissionRequest null + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +const mod = await import("./permissionRequestAuthPubkey.js").catch( + () => import("./permissionRequestAuthPubkey.ts"), +); +const { + getPermissionRequestAgentPubkey, + hasPermissionRequestCard, + selectPermissionRequest, +} = mod; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const AGENT_PUBKEY = + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; +const ATTACKER_PUBKEY = + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + +// A valid 64-char hex event ID used as the sentinel's own ID. +const MESSAGE_ID = + "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; +const OTHER_ID = + "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"; + +const CHANNEL_ID = "chan-test-001"; + +const PENDING_BODY = JSON.stringify({ + v: 1, + state: "pending", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 9_999_999_999, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, +}); + +// A valid resolved body that correlates to MESSAGE_ID + PENDING_BODY nonce. +const RESOLVED_BODY = JSON.stringify({ + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: MESSAGE_ID, + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 9_999_999_999, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + outcome: "applied", + chosenOptionId: "opt-allow", +}); + +const PROSE_BODY = "Hello from the agent"; + +function makePendingMessage(overrides = {}) { + return { + kind: 9, + isAgent: true, + signerPubkey: AGENT_PUBKEY, + body: PENDING_BODY, + id: MESSAGE_ID, + editSignerPubkey: undefined, + preEditBody: undefined, + ...overrides, + }; +} + +function isKnownAgent(pubkey) { + return pubkey === AGENT_PUBKEY; +} + +// ── getPermissionRequestAgentPubkey ─────────────────────────────────────────── + +describe("getPermissionRequestAgentPubkey", () => { + it("test_returns_signer_pubkey_for_known_agent_on_kind9", () => { + const msg = makePendingMessage(); + assert.equal( + getPermissionRequestAgentPubkey(msg, isKnownAgent), + AGENT_PUBKEY, + ); + }); + + it("test_returns_undefined_for_unknown_signer", () => { + const msg = makePendingMessage({ signerPubkey: ATTACKER_PUBKEY }); + assert.equal(getPermissionRequestAgentPubkey(msg, isKnownAgent), undefined); + }); + + it("test_returns_undefined_for_non_kind9", () => { + const msg = makePendingMessage({ kind: 1 }); + assert.equal(getPermissionRequestAgentPubkey(msg, isKnownAgent), undefined); + }); +}); + +// ── selectPermissionRequest ─────────────────────────────────────────────────── +// +// These are the authoritative tests for the prose-suppression gate. +// selectPermissionRequest folds in channelId + isAgent eligibility so the +// result can be used directly in MessageRow without any secondary check. + +describe("selectPermissionRequest", () => { + it("test_returns_selection_for_trusted_pending_sentinel", () => { + const msg = makePendingMessage(); + const sel = selectPermissionRequest(msg, isKnownAgent, CHANNEL_ID); + assert.ok(sel !== null, "trusted pending sentinel must return a selection"); + assert.equal(sel.agentPubkey, AGENT_PUBKEY); + assert.equal(sel.request.state, "pending"); + }); + + it("test_returns_null_for_null_channel_id", () => { + // channelId=null means no card → no prose suppression. + const msg = makePendingMessage(); + assert.equal( + selectPermissionRequest(msg, isKnownAgent, null), + null, + "null channelId must return null — no card will render", + ); + }); + + it("test_returns_null_for_undefined_channel_id", () => { + const msg = makePendingMessage(); + assert.equal( + selectPermissionRequest(msg, isKnownAgent, undefined), + null, + "undefined channelId must return null", + ); + }); + + it("test_returns_null_for_empty_string_channel_id", () => { + const msg = makePendingMessage(); + assert.equal( + selectPermissionRequest(msg, isKnownAgent, ""), + null, + "empty-string channelId must return null (falsy)", + ); + }); + + it("test_returns_null_when_isAgent_false", () => { + // !isAgent → no card → no prose suppression. + const msg = makePendingMessage({ isAgent: false }); + assert.equal( + selectPermissionRequest(msg, isKnownAgent, CHANNEL_ID), + null, + "non-agent message must return null", + ); + }); + + it("test_returns_null_for_forged_signer_prose_not_suppressed", () => { + // F3: forged signer — valid sentinel JSON but wrong signer. + // computePermissionRequest rejects on the D1 signer gate. + // Prose must NOT be suppressed — fallback to markdown. + const msg = makePendingMessage({ signerPubkey: ATTACKER_PUBKEY }); + assert.equal( + selectPermissionRequest(msg, isKnownAgent, CHANNEL_ID), + null, + "forged signer must return null — prose must not be suppressed", + ); + }); + + it("test_returns_null_for_prose_body_even_with_known_agent", () => { + const msg = makePendingMessage({ body: PROSE_BODY }); + assert.equal( + selectPermissionRequest(msg, isKnownAgent, CHANNEL_ID), + null, + "non-sentinel body must not suppress prose", + ); + }); + + it("test_returns_null_for_born_resolved_no_provenance", () => { + // Born-resolved-no-provenance: body is already "resolved" but has no edit + // provenance. computePermissionRequest rejects — no edit signer present. + const msg = makePendingMessage({ + body: RESOLVED_BODY, + editSignerPubkey: undefined, + id: MESSAGE_ID, + preEditBody: undefined, + }); + assert.equal( + selectPermissionRequest(msg, isKnownAgent, CHANNEL_ID), + null, + "born-resolved without edit provenance must return null", + ); + }); + + it("test_returns_selection_for_resolved_with_valid_provenance", () => { + const msg = makePendingMessage({ + body: RESOLVED_BODY, + editSignerPubkey: AGENT_PUBKEY, + id: MESSAGE_ID, + preEditBody: PENDING_BODY, + }); + const sel = selectPermissionRequest(msg, isKnownAgent, CHANNEL_ID); + assert.ok( + sel !== null, + "resolved with valid provenance must return selection", + ); + assert.equal(sel.request.state, "resolved"); + }); + + it("test_returns_null_for_correlation_mismatch_resolved", () => { + const mismatchedBody = JSON.stringify({ + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: OTHER_ID, // ← different from MESSAGE_ID + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 9_999_999_999, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + outcome: "applied", + chosenOptionId: "opt-allow", + }); + const msg = makePendingMessage({ + body: mismatchedBody, + editSignerPubkey: AGENT_PUBKEY, + id: MESSAGE_ID, + preEditBody: PENDING_BODY, + }); + assert.equal( + selectPermissionRequest(msg, isKnownAgent, CHANNEL_ID), + null, + "correlation-mismatch resolved must return null", + ); + }); +}); + +// ── hasPermissionRequestCard ────────────────────────────────────────────────── +// Legacy boolean delegate — kept for coverage. Tests use isAgent: true because +// the function now requires it (delegates to selectPermissionRequest path). + +describe("hasPermissionRequestCard", () => { + it("test_returns_true_for_trusted_agent_pending_sentinel", () => { + const msg = makePendingMessage(); + assert.equal( + hasPermissionRequestCard(msg, isKnownAgent), + true, + "trusted pending sentinel must return true", + ); + }); + + it("test_returns_false_for_forged_signer_prose_not_suppressed", () => { + const msg = makePendingMessage({ signerPubkey: ATTACKER_PUBKEY }); + assert.equal( + hasPermissionRequestCard(msg, isKnownAgent), + false, + "forged signer must NOT suppress prose — fallback to markdown", + ); + }); + + it("test_returns_false_for_prose_body_even_with_known_agent", () => { + const msg = makePendingMessage({ body: PROSE_BODY }); + assert.equal( + hasPermissionRequestCard(msg, isKnownAgent), + false, + "non-sentinel body must not suppress prose", + ); + }); + + it("test_returns_false_for_unknown_agent", () => { + const msg = makePendingMessage({ signerPubkey: ATTACKER_PUBKEY }); + assert.equal( + hasPermissionRequestCard(msg, isKnownAgent), + false, + "unknown agent must not suppress prose", + ); + }); + + it("test_returns_false_for_non_kind9", () => { + const msg = makePendingMessage({ kind: 1 }); + assert.equal( + hasPermissionRequestCard(msg, isKnownAgent), + false, + "wrong kind must not suppress prose", + ); + }); + + it("test_returns_false_for_born_resolved_no_provenance", () => { + const msg = makePendingMessage({ + body: RESOLVED_BODY, + editSignerPubkey: undefined, + id: MESSAGE_ID, + preEditBody: undefined, + }); + assert.equal( + hasPermissionRequestCard(msg, isKnownAgent), + false, + "born-resolved sentinel without edit provenance must NOT suppress prose", + ); + }); + + it("test_returns_true_for_resolved_with_valid_provenance", () => { + const msg = makePendingMessage({ + body: RESOLVED_BODY, + editSignerPubkey: AGENT_PUBKEY, + id: MESSAGE_ID, + preEditBody: PENDING_BODY, + }); + assert.equal( + hasPermissionRequestCard(msg, isKnownAgent), + true, + "resolved sentinel with valid edit provenance must suppress prose", + ); + }); + + it("test_returns_false_for_correlation_mismatch_resolved", () => { + const mismatchedBody = JSON.stringify({ + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: OTHER_ID, // ← different from MESSAGE_ID + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 9_999_999_999, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + outcome: "applied", + chosenOptionId: "opt-allow", + }); + const msg = makePendingMessage({ + body: mismatchedBody, + editSignerPubkey: AGENT_PUBKEY, + id: MESSAGE_ID, + preEditBody: PENDING_BODY, + }); + assert.equal( + hasPermissionRequestCard(msg, isKnownAgent), + false, + "correlation-mismatch resolved body must NOT suppress prose", + ); + }); +}); diff --git a/desktop/src/features/messages/ui/permissionRequestAuthPubkey.ts b/desktop/src/features/messages/ui/permissionRequestAuthPubkey.ts new file mode 100644 index 00000000000..d9b4868c1e6 --- /dev/null +++ b/desktop/src/features/messages/ui/permissionRequestAuthPubkey.ts @@ -0,0 +1,137 @@ +import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; +import type { TimelineMessage } from "@/features/messages/types"; +import { computePermissionRequest } from "@/shared/lib/computePermissionRequest"; +import type { PermissionRequestPayload } from "@/shared/lib/permissionRequest"; + +/** + * Returns the agent pubkey to use for the `PermissionRequestCard` for a given + * message, or `undefined` when the permission-card path should be disabled. + * + * The card is enabled ONLY when: + * 1. `message.kind === KIND_STREAM_MESSAGE` — restricts to the setup-listener + * wire format (kind:9). + * 2. `message.signerPubkey` is set and passes `isKnownAgentPubkey` — + * authenticates against the raw event signer (NOT `message.pubkey`, + * which may be a relay-delegated display author). + * + * Mirrors `getConfigNudgeAuthorPubkey` — same signer-vs-delegated-author + * distinction, same test-friendly pure-function shape. + */ +export function getPermissionRequestAgentPubkey( + message: Pick<TimelineMessage, "kind" | "signerPubkey">, + isKnownAgentPubkey: (pubkey: string) => boolean, +): string | undefined { + if ( + message.kind === KIND_STREAM_MESSAGE && + message.signerPubkey && + isKnownAgentPubkey(message.signerPubkey) + ) { + return message.signerPubkey; + } + return undefined; +} + +/** + * Pre-computed permission-request result — the single trusted payload and the + * authenticated agent pubkey. Returned by `selectPermissionRequest` when the + * card will render; `null` when it will not. + */ +export type PermissionRequestSelection = { + agentPubkey: string; + request: PermissionRequestPayload; +}; + +/** + * Computes the permission-request card payload ONCE, incorporating all render + * eligibility checks — including `channelId` and `message.isAgent` — so the + * result can be used for BOTH prose suppression in `MessageRow` AND as the + * pre-computed input to `PermissionRequestCardBlock`. + * + * Returns non-null iff a card will render, by construction: + * - `channelId` is truthy (falsy channelId → no card → no prose suppression) + * - `message.isAgent` is true + * - signer is a known agent (`getPermissionRequestAgentPubkey` succeeds) + * - `computePermissionRequest` returns a non-null payload + * + * This is the single source of truth for the prose-suppression decision in + * `MessageRow`. Passing this result to `PermissionRequestCardBlock` closes + * the double-computation gap and ensures prose is suppressed iff the card + * renders — by construction, not by approximation. + * + * Mirrors `selectProseOrPermission` — the card's prose-suppression contract. + */ +export function selectPermissionRequest( + message: Pick< + TimelineMessage, + | "kind" + | "isAgent" + | "signerPubkey" + | "body" + | "editSignerPubkey" + | "id" + | "preEditBody" + >, + isKnownAgentPubkey: (pubkey: string) => boolean, + channelId: string | null | undefined, +): PermissionRequestSelection | null { + if (!channelId || !message.isAgent) return null; + const agentPubkey = getPermissionRequestAgentPubkey( + message, + isKnownAgentPubkey, + ); + const request = computePermissionRequest( + message.body, + true, + agentPubkey, + message.signerPubkey, + message.editSignerPubkey, + message.id, + message.preEditBody, + ); + if (request === null || !agentPubkey) return null; + return { agentPubkey, request }; +} + +/** + * Returns `true` only when `selectPermissionRequest` returns a non-null + * selection — i.e., when a card will render. + * + * Kept as a thin delegate over `selectPermissionRequest` for callers that only + * need a boolean (e.g. MessageRow's prose-suppression guard). The prose guard + * and the card block both derive from the same `selectPermissionRequest` call, + * so prose is suppressed iff the card renders. + * + * @deprecated Use `selectPermissionRequest` directly when you also need the + * computed agentPubkey/request to pass to the block. + */ +export function hasPermissionRequestCard( + message: Pick< + TimelineMessage, + | "kind" + | "isAgent" + | "signerPubkey" + | "body" + | "editSignerPubkey" + | "id" + | "preEditBody" + >, + isKnownAgentPubkey: (pubkey: string) => boolean, +): boolean { + // Note: channelId is not available here; MessageRow should use + // selectPermissionRequest directly and pass channelId. + const agentPubkey = getPermissionRequestAgentPubkey( + message, + isKnownAgentPubkey, + ); + return ( + computePermissionRequest( + message.body, + true, + agentPubkey, + message.signerPubkey, + message.editSignerPubkey, + message.id, + message.preEditBody, + ) !== null + ); +} diff --git a/desktop/src/shared/api/agentControl.ts b/desktop/src/shared/api/agentControl.ts index 67a6d377d28..929a75301ee 100644 --- a/desktop/src/shared/api/agentControl.ts +++ b/desktop/src/shared/api/agentControl.ts @@ -36,3 +36,29 @@ export async function switchManagedAgentModel( requestId, }); } + +/** + * Send a permission decision to a running agent's ACP harness. The decision + * is fire-and-forget: the harness receives it via the observer control channel + * and updates the permission card asynchronously via a `control_result` frame. + * + * @param pubkey - Agent's public key (hex or npub). + * @param channelId - The channel from which the permission request was issued. + * The harness validates this before looking up the nonce. + * @param nonce - `requestNonce` from the `authorization` envelope on the + * corresponding `acp_read` permission frame. + * @param optionId - The chosen option's `optionId` (e.g. `"allow_once"`). + */ +export async function sendPermissionDecision( + pubkey: string, + channelId: string, + nonce: string, + optionId: string, +): Promise<void> { + await sendAgentObserverControl(pubkey, { + type: "permission_decision", + channelId, + requestNonce: nonce, + optionId, + }); +} diff --git a/desktop/src/shared/api/managedAgentMapping.ts b/desktop/src/shared/api/managedAgentMapping.ts new file mode 100644 index 00000000000..839094e2023 --- /dev/null +++ b/desktop/src/shared/api/managedAgentMapping.ts @@ -0,0 +1,107 @@ +import type { + ManagedAgent, + ManagedAgentBackend, + PermissionPolicy, + PermissionPolicySource, +} from "@/shared/api/types"; +import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff"; + +export type RawManagedAgent = { + pubkey: string; + name: string; + persona_id: string | null; + // Optional: pre-feature fixtures may omit it. The record's harness/runtime id. + runtime?: string | null; + team_id?: string | null; + relay_url: string; + acp_command: string; + agent_command: string; + agent_command_override?: string | null; + agent_args: string[]; + mcp_command: string; + turn_timeout_seconds: number; + idle_timeout_seconds: number | null; + max_turn_duration_seconds: number | null; + parallelism: number; + system_prompt: string | null; + avatar_url?: string | null; + model: string | null; + model_source?: ManagedAgent["modelSource"]; + provider: string | null; + persona_out_of_date: boolean; + persona_orphaned: boolean; + needs_restart: boolean; + restart_diff?: RawRestartDiffEntry[]; + env_vars?: Record<string, string>; + status: ManagedAgent["status"]; + pid: number | null; + created_at: string; + updated_at: string; + last_started_at: string | null; + last_stopped_at: string | null; + last_exit_code: number | null; + last_error: string | null; + last_error_code: number | null; + log_path: string; + start_on_app_launch: boolean; + auto_restart_on_config_change?: boolean; + backend: ManagedAgentBackend; + backend_agent_id: string | null; + // Pre-feature fixtures may omit these; mapped to "owner-only"/[] in fromRawManagedAgent. + respond_to?: ManagedAgent["respondTo"]; + respond_to_allowlist?: string[]; + // Pre-feature fixtures may omit these; defaults applied in fromRawManagedAgent. + permission_policy?: PermissionPolicy; + permission_policy_source?: PermissionPolicySource; + /** Policy actually applied at the last remote deploy. `null` / absent for local or never-deployed agents. */ + applied_permission_policy?: PermissionPolicy | null; +}; + +export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { + return { + pubkey: agent.pubkey, + name: agent.name, + personaId: agent.persona_id, + runtime: agent.runtime ?? null, + teamId: agent.team_id ?? null, + relayUrl: agent.relay_url, + acpCommand: agent.acp_command, + agentCommand: agent.agent_command, + agentCommandOverride: agent.agent_command_override ?? null, + agentArgs: agent.agent_args, + mcpCommand: agent.mcp_command, + turnTimeoutSeconds: agent.turn_timeout_seconds, + idleTimeoutSeconds: agent.idle_timeout_seconds, + maxTurnDurationSeconds: agent.max_turn_duration_seconds, + parallelism: agent.parallelism, + systemPrompt: agent.system_prompt, + avatarUrl: agent.avatar_url ?? null, + model: agent.model, + modelSource: agent.model_source ?? null, + provider: agent.provider ?? null, + personaOutOfDate: agent.persona_out_of_date ?? false, + personaOrphaned: agent.persona_orphaned ?? false, + needsRestart: agent.needs_restart ?? false, + restartDiff: agent.restart_diff ?? [], + envVars: agent.env_vars ?? {}, + status: agent.status, + pid: agent.pid, + createdAt: agent.created_at, + updatedAt: agent.updated_at, + lastStartedAt: agent.last_started_at, + lastStoppedAt: agent.last_stopped_at, + lastExitCode: agent.last_exit_code, + lastError: agent.last_error, + lastErrorCode: agent.last_error_code ?? null, + logPath: agent.log_path, + startOnAppLaunch: agent.start_on_app_launch, + autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true, + backend: agent.backend, + backendAgentId: agent.backend_agent_id, + respondTo: agent.respond_to ?? "owner-only", + respondToAllowlist: agent.respond_to_allowlist ?? [], + permissionPolicy: agent.permission_policy ?? "ask", + permissionPolicySource: agent.permission_policy_source ?? "built_in", + appliedPermissionPolicy: agent.applied_permission_policy ?? null, + }; +} diff --git a/desktop/src/shared/api/permissionPolicy.ts b/desktop/src/shared/api/permissionPolicy.ts new file mode 100644 index 00000000000..987b5e0fdf4 --- /dev/null +++ b/desktop/src/shared/api/permissionPolicy.ts @@ -0,0 +1,58 @@ +/** + * Permission policy controlling how the ACP harness answers + * `session/request_permission` calls. + * + * - `ask`: Show an actionable Allow/Deny card in the transcript (desktop default). + * - `allow`: Auto-approve the unique `allow_once` option (explicit opt-in). + * - `reject`: Auto-deny all requests without surfacing a card. + */ +export type PermissionPolicy = "ask" | "allow" | "reject"; + +/** + * Where the effective permission policy value came from. + * + * - `agent`: Per-agent override set on this specific agent record. + * - `definition`: Inherited from the linked persona definition's default policy. + * - `global_default`: Fleet-wide default from the global agent config. + * - `built_in`: Neither layer had a value; the desktop built-in default (`ask`) applies. + */ +export type PermissionPolicySource = + | "agent" + | "definition" + | "global_default" + | "built_in"; + +export type CancelManagedAgentTurnResult = { + status: "sent" | "no_active_turn"; +}; + +/** + * Outcome of a live `switch_model` control frame, surfaced asynchronously via + * the agent's `control_result` observer frame. Busy path: `sent` (cancel + + * requeue on the new model) or `turn_ending` (oneshot already consumed this + * turn). Idle path: `switched`, `unsupported_model`, or `no_active_turn`. + */ +export type SwitchManagedAgentModelStatus = + | "sent" + | "turn_ending" + | "switched" + | "unsupported_model" + | "no_active_turn" + | "failure"; + +export type ControlResultFrame = { + type: "cancel_turn" | "switch_model" | "permission_decision"; + status: string; + modelId?: string; + /** Present on `permission_decision` results — identifies the request card to retire. */ + requestNonce?: string; + /** Opaque per-pick id echoed from the request; correlates late frames. */ + requestId?: string; + /** Buzz channel UUID from the observer envelope; disambiguates channels. */ + channelId?: string | null; +}; + +export type BackendProviderCandidate = { + id: string; + binaryPath: string; +}; diff --git a/desktop/src/shared/api/personaTypes.ts b/desktop/src/shared/api/personaTypes.ts index f18e9fe96b9..1dc10584c7d 100644 --- a/desktop/src/shared/api/personaTypes.ts +++ b/desktop/src/shared/api/personaTypes.ts @@ -39,6 +39,8 @@ export type AgentPersona = { respondTo: RespondToMode | null; respondToAllowlist: string[]; parallelism: number | null; + /** Definition-level default permission policy — tier 2 of the resolver (instance → this → global → built-in `ask`). Null = defer. Local-only. */ + permissionPolicy: import("./permissionPolicy").PermissionPolicy | null; createdAt: string; updatedAt: string; }; @@ -61,6 +63,8 @@ export type PersonaBehaviorInput = { respondTo?: RespondToMode; respondToAllowlist?: string[]; parallelism?: number; + /** Definition-level default permission policy. Within a present behavior group it replaces the stored value as a unit: omitted clears the default. Never published. */ + permissionPolicy?: import("./permissionPolicy").PermissionPolicy; }; export type CreatePersonaInput = { diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 984b9d176df..bc823ee3c09 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -16,7 +16,6 @@ import type { GetHomeFeedInput, HomeFeedResponse, ManagedAgent, - ManagedAgentBackend, RelayAgent, RelayMember, RelayMemberRole, @@ -110,52 +109,13 @@ type RawRelayAgent = { respond_to?: RelayAgent["respondTo"]; respond_to_allowlist?: string[]; }; -import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff"; -export type RawManagedAgent = { - pubkey: string; - name: string; - persona_id: string | null; - // Optional: pre-feature fixtures may omit it. The record's harness/runtime id. - runtime?: string | null; - team_id?: string | null; - relay_url: string; - acp_command: string; - agent_command: string; - agent_command_override?: string | null; - agent_args: string[]; - mcp_command: string; - turn_timeout_seconds: number; - idle_timeout_seconds: number | null; - max_turn_duration_seconds: number | null; - parallelism: number; - system_prompt: string | null; - avatar_url?: string | null; - model: string | null; - model_source?: ManagedAgent["modelSource"]; - provider: string | null; - persona_out_of_date: boolean; - persona_orphaned: boolean; - needs_restart: boolean; - restart_diff?: RawRestartDiffEntry[]; - env_vars?: Record<string, string>; - status: ManagedAgent["status"]; - pid: number | null; - created_at: string; - updated_at: string; - last_started_at: string | null; - last_stopped_at: string | null; - last_exit_code: number | null; - last_error: string | null; - last_error_code: number | null; - log_path: string; - start_on_app_launch: boolean; - auto_restart_on_config_change?: boolean; - backend: ManagedAgentBackend; - backend_agent_id: string | null; - // Pre-feature fixtures may omit these; mapped to "owner-only"/[] in fromRawManagedAgent. - respond_to?: ManagedAgent["respondTo"]; - respond_to_allowlist?: string[]; -}; + +import { + fromRawManagedAgent, + type RawManagedAgent, +} from "@/shared/api/managedAgentMapping"; +export { fromRawManagedAgent }; +export type { RawManagedAgent }; type RawCreateManagedAgentResponse = { agent: RawManagedAgent; @@ -570,7 +530,6 @@ export async function uploadMediaBytes( } export { editMessage } from "@/shared/api/editMessage"; - export async function deleteMessage( channelId: string, eventId: string, @@ -625,52 +584,6 @@ function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent { }; } -export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { - return { - pubkey: agent.pubkey, - name: agent.name, - personaId: agent.persona_id, - runtime: agent.runtime ?? null, - teamId: agent.team_id ?? null, - relayUrl: agent.relay_url, - acpCommand: agent.acp_command, - agentCommand: agent.agent_command, - agentCommandOverride: agent.agent_command_override ?? null, - agentArgs: agent.agent_args, - mcpCommand: agent.mcp_command, - turnTimeoutSeconds: agent.turn_timeout_seconds, - idleTimeoutSeconds: agent.idle_timeout_seconds, - maxTurnDurationSeconds: agent.max_turn_duration_seconds, - parallelism: agent.parallelism, - systemPrompt: agent.system_prompt, - avatarUrl: agent.avatar_url ?? null, - model: agent.model, - modelSource: agent.model_source ?? null, - provider: agent.provider ?? null, - personaOutOfDate: agent.persona_out_of_date ?? false, - personaOrphaned: agent.persona_orphaned ?? false, - needsRestart: agent.needs_restart ?? false, - restartDiff: agent.restart_diff ?? [], - envVars: agent.env_vars ?? {}, - status: agent.status, - pid: agent.pid, - createdAt: agent.created_at, - updatedAt: agent.updated_at, - lastStartedAt: agent.last_started_at, - lastStoppedAt: agent.last_stopped_at, - lastExitCode: agent.last_exit_code, - lastError: agent.last_error, - lastErrorCode: agent.last_error_code ?? null, - logPath: agent.log_path, - startOnAppLaunch: agent.start_on_app_launch, - autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true, - backend: agent.backend, - backendAgentId: agent.backend_agent_id, - respondTo: agent.respond_to ?? "owner-only", - respondToAllowlist: agent.respond_to_allowlist ?? [], - }; -} - export function fromRawAcpRuntimeCatalogEntry( entry: RawAcpRuntimeCatalogEntry, ): AcpRuntimeCatalogEntry { diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index d1619daea4f..8c98c0dad6b 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -2,6 +2,7 @@ import { invokeTauri } from "@/shared/api/tauri"; import type { AgentPersona, CreatePersonaInput, + PermissionPolicy, RespondToMode, UpdatePersonaInput, } from "@/shared/api/types"; @@ -31,6 +32,12 @@ export type RawPersona = { respond_to?: string | null; respond_to_allowlist?: string[]; parallelism?: number | null; + /** + * Definition-level default permission policy (resolver tier 2). Local + * authority grant — never present in a published/imported persona event, + * so it is absent on catalog and team-imported personas. + */ + permission_policy?: PermissionPolicy | null; created_at: string; updated_at: string; /** Non-null when the pack `.persona.md` write-back failed (non-fatal). */ @@ -62,6 +69,7 @@ export function fromRawPersona(persona: RawPersona): AgentPersona { respondTo: (persona.respond_to as RespondToMode | undefined) ?? null, respondToAllowlist: persona.respond_to_allowlist ?? [], parallelism: persona.parallelism ?? null, + permissionPolicy: persona.permission_policy ?? null, createdAt: persona.created_at, updatedAt: persona.updated_at, }; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 9b5b8ce43be..527a60d5ee3 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -301,6 +301,10 @@ export type ManagedAgentBackend = | { type: "provider"; id: string; config: Record<string, unknown> }; import type { RestartDiffEntry } from "./restartDiff"; +import type { + PermissionPolicy, + PermissionPolicySource, +} from "./permissionPolicy"; export type { JsonValue, RestartChange, RestartDiffEntry } from "./restartDiff"; export type ManagedAgent = { pubkey: string; @@ -371,15 +375,22 @@ export type ManagedAgent = { * `"allowlist"`. Preserved across mode toggles. */ respondToAllowlist: string[]; + /** Effective permission policy at the last spawn. */ + permissionPolicy: PermissionPolicy; + /** Source of `permissionPolicy`: agent, definition, global_default, or built_in. */ + permissionPolicySource: PermissionPolicySource; + /** Policy active on the remote worker; non-null only after a successful deploy. */ + appliedPermissionPolicy: PermissionPolicy | null; }; /** Inbound author gate mode. Mirrors buzz-acp's --respond-to CLI flag. */ export type RespondToMode = "owner-only" | "allowlist" | "anyone"; -export type BackendProviderCandidate = { - id: string; - binaryPath: string; -}; +export type { + PermissionPolicy, + PermissionPolicySource, + BackendProviderCandidate, +} from "./permissionPolicy"; export type BackendProviderProbeResult = { ok: boolean; @@ -430,6 +441,8 @@ export type CreateManagedAgentInput = { */ respondToAllowlist?: string[]; relayMesh?: RelayMeshConfig; + /** Per-agent permission policy override. Omitted = inherit from global or built-in default. */ + permissionPolicy?: PermissionPolicy; }; export type CreateManagedAgentResponse = { @@ -444,25 +457,11 @@ export type ManagedAgentLog = { logPath: string; }; -/** Outcome of a live `switch_model` control frame; `failure` lands late. */ -export type SwitchManagedAgentModelStatus = - | "sent" - | "turn_ending" - | "ambiguous_target" - | "switched" - | "unsupported_model" - | "no_active_turn" - | "failure"; - -export type ControlResultFrame = { - type: "cancel_turn" | "switch_model"; - status: string; - modelId?: string; - /** Opaque per-pick id echoed from the request; correlates late frames. */ - requestId?: string; - /** Buzz channel UUID from the observer envelope; disambiguates channels. */ - channelId?: string | null; -}; +export type { + CancelManagedAgentTurnResult, + SwitchManagedAgentModelStatus, + ControlResultFrame, +} from "./permissionPolicy"; export type GitBashPrerequisite = { available: boolean; @@ -705,6 +704,8 @@ export type UpdateManagedAgentInput = { respondTo?: RespondToMode; /** Absent = keep. Present = replace the allowlist (server-validated). */ respondToAllowlist?: string[]; + /** Absent = don't touch. `null` = clear to inherit. Remote: read-only. */ + permissionPolicy?: PermissionPolicy | null; /** Tri-state: absent = don't touch; `null` = clear; `string` = set. Persisted in the locked update so access-change restarts snapshot the new effort. Send only when `effortTouched`. */ effortLevel?: string | null; }; @@ -858,8 +859,8 @@ export type ForumThreadResponse = { * * The event-id tiebreak is load-bearing: thread replies routinely share a * `createdAt` second (bursty threads), so a timestamp-only cursor would skip - * every tied reply past the page limit. The pair `(createdAt, eventId)` orders - * replies unambiguously and lets paging resume strictly after the last event. + * every tied reply past the page limit. `(createdAt, eventId)` orders replies + * unambiguously and lets paging resume strictly after the last event. */ export type ThreadCursor = { createdAt: number; @@ -880,8 +881,7 @@ export type ThreadRepliesResponse = { * The event-id tiebreak is load-bearing for the dense-second case: the relay * orders `created_at DESC, id ASC` and advances past a second denser than one * page with `id > eventId`. A bare `createdAt` (`until`) cursor cannot escape - * such a second — it re-returns the same slice forever, leaving older history - * unreachable. `(createdAt, eventId)` moves strictly older every page. + * such a second. `(createdAt, eventId)` moves strictly older every page. */ export type ChannelPageCursor = { createdAt: number; @@ -901,9 +901,7 @@ export type ChannelMessagesPageResponse = { * Global agent configuration defaults applied to ALL agents. * * Lowest user-settable layer — per-agent and persona values win on any key - * collision. Mirrors the Rust `GlobalAgentConfig` struct. - * - * Precedence: baked floor < global < persona < per-agent. + * collision. Precedence: baked floor < global < persona < per-agent. */ export type GlobalAgentConfig = { /** Global env vars injected into all agents unconditionally. */ @@ -914,6 +912,8 @@ export type GlobalAgentConfig = { model: string | null; /** Preferred ACP runtime for agents without a persona-specific runtime. */ preferred_runtime: string | null; + /** Fleet-wide policy fallback. `null` = no fleet default; `ask` applies. */ + permission_policy: PermissionPolicy | null; }; /** diff --git a/desktop/src/shared/lib/computePermissionRequest.test.mjs b/desktop/src/shared/lib/computePermissionRequest.test.mjs new file mode 100644 index 00000000000..85a0c927269 --- /dev/null +++ b/desktop/src/shared/lib/computePermissionRequest.test.mjs @@ -0,0 +1,334 @@ +/** + * Named test matrix for `computePermissionRequest` and `selectProseOrPermission`. + * + * Fixtures use the frozen schema (event b31c716e). + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + computePermissionRequest, + selectProseOrPermission, +} from "./computePermissionRequest.ts"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const AGENT_PUBKEY = + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; +const ATTACKER_PUBKEY = + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; +const OWNER_PUBKEY = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + +// The kind-9 sentinel event ID. A resolved edit must name this in +// `originalEventId` (F5 correlation). +const MESSAGE_ID = + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead"; + +const PENDING_PAYLOAD = { + v: 1, + state: "pending", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 9999999999, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, +}; + +const RESOLVED_PAYLOAD = { + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: MESSAGE_ID, + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 9999999999, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + outcome: "applied", + chosenOptionId: "opt-allow", +}; + +// Wire contract: the harness signs bare JSON as the kind:9 event content. +// computePermissionRequest receives the raw event content string — no fence. +function raw(payload) { + return JSON.stringify(payload); +} + +// ── computePermissionRequest ────────────────────────────────────────────────── + +test("test_not_interactive_returns_null", () => { + assert.equal( + computePermissionRequest( + raw(PENDING_PAYLOAD), + false, + AGENT_PUBKEY, + AGENT_PUBKEY, + ), + null, + ); +}); + +test("test_missing_agentPubkey_returns_null", () => { + assert.equal( + computePermissionRequest( + raw(PENDING_PAYLOAD), + true, + undefined, + AGENT_PUBKEY, + ), + null, + ); +}); + +test("test_missing_signerPubkey_returns_null", () => { + assert.equal( + computePermissionRequest( + raw(PENDING_PAYLOAD), + true, + AGENT_PUBKEY, + undefined, + ), + null, + ); +}); + +test("test_forged_card_wrong_signer_returns_null", () => { + // agentPubkey (channel's known agent) ≠ signerPubkey (event signer) + assert.equal( + computePermissionRequest( + raw(PENDING_PAYLOAD), + true, + AGENT_PUBKEY, + ATTACKER_PUBKEY, + ), + null, + ); +}); + +test("test_valid_signer_returns_payload", () => { + const result = computePermissionRequest( + raw(PENDING_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + ); + assert.deepEqual(result, PENDING_PAYLOAD); +}); + +test("test_signer_check_is_case_insensitive", () => { + const result = computePermissionRequest( + raw(PENDING_PAYLOAD), + true, + AGENT_PUBKEY.toUpperCase(), + AGENT_PUBKEY.toLowerCase(), + ); + assert.deepEqual(result, PENDING_PAYLOAD); +}); + +test("test_no_sentinel_returns_null", () => { + assert.equal( + computePermissionRequest( + "No sentinel here", + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + ), + null, + ); +}); + +test("test_agent_signed_edit_resolves_card", () => { + const result = computePermissionRequest( + raw(RESOLVED_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, // original event signer + AGENT_PUBKEY, // edit signer == agent ✓ + MESSAGE_ID, // originalEventId names this card ✓ + raw(PENDING_PAYLOAD), // nonce/session/turn correlate ✓ + ); + assert.deepEqual(result, RESOLVED_PAYLOAD); +}); + +test("test_resolved_edit_with_mismatched_originalEventId_returns_null", () => { + // F5: same-signer agent edit, but originalEventId names a DIFFERENT card. + assert.equal( + computePermissionRequest( + raw(RESOLVED_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + AGENT_PUBKEY, + "0000000000000000000000000000000000000000000000000000000000000000", + raw(PENDING_PAYLOAD), + ), + null, + ); +}); + +test("test_resolved_edit_with_mismatched_nonce_returns_null", () => { + // F5: originalEventId matches, but the resolved nonce does not correlate + // to the pending body the edit overlaid — a cross-applied resolution. + const pendingOther = { ...PENDING_PAYLOAD, requestNonce: "different-nonce" }; + assert.equal( + computePermissionRequest( + raw(RESOLVED_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + AGENT_PUBKEY, + MESSAGE_ID, + raw(pendingOther), + ), + null, + ); +}); + +test("test_resolved_edit_without_pending_body_returns_null", () => { + // F5: an arrived edit with no retained pending body cannot be correlated. + assert.equal( + computePermissionRequest( + raw(RESOLVED_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + AGENT_PUBKEY, + MESSAGE_ID, + undefined, + ), + null, + ); +}); + +test("test_owner_signed_edit_does_not_resolve", () => { + assert.equal( + computePermissionRequest( + raw(RESOLVED_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + OWNER_PUBKEY, // edit signer is owner, not agent ✗ + ), + null, + ); +}); + +test("test_attacker_signed_edit_does_not_resolve", () => { + assert.equal( + computePermissionRequest( + raw(RESOLVED_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + ATTACKER_PUBKEY, // attacker edit ✗ + ), + null, + ); +}); + +test("test_born_resolved_body_without_edit_provenance_returns_null", () => { + // A kind-9 whose content is *born* `resolved` (no kind-40003 edit overlaid) + // carries no edit provenance: editSignerPubkey is undefined. Such a payload + // must NOT render as a completed card — it would pass the D1 signer gate + // alone with zero evidence of an edit, matching original event, or matching + // nonce/session/turn. Mutation proof: relaxing the resolved-state guard to + // run only when editSignerPubkey is non-null turns this red. + const result = computePermissionRequest( + raw(RESOLVED_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + undefined, // no edit arrived → no provenance + ); + assert.equal(result, null); +}); + +// ── selectProseOrPermission ─────────────────────────────────────────────────── + +test("test_selectProseOrPermission_returns_markdown_when_no_request", () => { + const node = "markdown-node"; + assert.equal(selectProseOrPermission(null, node), node); +}); + +test("test_selectProseOrPermission_returns_null_when_request_present", () => { + // Pass a typed object directly (not parsed from content) + assert.equal(selectProseOrPermission(PENDING_PAYLOAD, "markdown-node"), null); +}); + +// ── Component behavior — pure-function coverage ─────────────────────────────── +// These test the underlying pure logic for behaviors that manifest in the +// React component. Component state (double-click guard, countdown UI) is +// not testable without a DOM renderer. + +test("test_non_owner_viewer_gets_payload_but_is_owner_false", () => { + // computePermissionRequest returns the payload for any authenticated viewer; + // isOwner is determined by the caller (PermissionRequestCardBlock) comparing + // viewerPubkey to ownerPubkey. Verify the payload is returned so the card + // renders, then the test documents that a non-owner sees it as read-only. + const result = computePermissionRequest( + raw(PENDING_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + ); + assert.ok(result !== null, "payload returned for authenticated render"); + // isOwner=false would be computed by PermissionRequestCardBlock when + // viewerPubkey !== ownerPubkey — card renders in read-only mode (no buttons). +}); + +test("test_replay_with_edit_provenance_returns_resolved_payload", () => { + // Archive/replay of a resolved card: `formatTimelineMessages` overlays the + // kind-40003 edit onto the pending kind-9 and supplies editSignerPubkey, + // messageId, and preEditContent together (formatTimelineMessages.ts:526-528). + // With full provenance the resolved payload renders in non-actionable state. + const result = computePermissionRequest( + raw(RESOLVED_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + AGENT_PUBKEY, // edit signer supplied on replay ✓ + MESSAGE_ID, // originalEventId names this card ✓ + raw(PENDING_PAYLOAD), // pre-edit pending body correlates ✓ + ); + assert.deepEqual(result, RESOLVED_PAYLOAD); + assert.equal(result?.state, "resolved"); +}); + +test("test_expiry_field_is_preserved_for_local_disable", () => { + // computePermissionRequest preserves the expiresAt field so the card's + // PermissionButtons component can compare it to Date.now() / 1000 and + // disable buttons locally when the harness deadline has passed. + const result = computePermissionRequest( + raw(PENDING_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + ); + assert.ok(result !== null); + assert.equal(result.expiresAt, 9999999999); + // Buttons disable when expiresAt <= Date.now()/1000. Since 9999999999 is + // far in the future, buttons would be enabled. A past value would disable them. + assert.ok( + result.expiresAt > Date.now() / 1000, + "far-future expiresAt stays enabled", + ); +}); + +test("test_past_expiresAt_parsed_without_rejection", () => { + // The parser accepts any finite expiresAt (past or future) — expiry is + // enforced by the component at render time, not at parse time. + const expired = { ...PENDING_PAYLOAD, expiresAt: 1 }; // Unix epoch + 1s (past) + const result = computePermissionRequest( + raw(expired), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + ); + assert.ok( + result !== null, + "past expiresAt is valid — expiry enforced at render", + ); + assert.equal(result.expiresAt, 1); +}); diff --git a/desktop/src/shared/lib/computePermissionRequest.ts b/desktop/src/shared/lib/computePermissionRequest.ts new file mode 100644 index 00000000000..2b9fe1f599f --- /dev/null +++ b/desktop/src/shared/lib/computePermissionRequest.ts @@ -0,0 +1,117 @@ +import type { ReactNode } from "react"; +import type { PermissionRequestPayload } from "@/shared/lib/permissionRequest"; +import { extractPermissionRequest } from "@/shared/lib/permissionRequest"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * Pure helper that computes the active `PermissionRequestPayload` for a + * message body. + * + * The card is active ONLY when: + * 1. `interactive` is true — non-interactive surfaces (search snippets, etc.) + * never render actionable cards. + * 2. `agentPubkey` is provided and matches `signerPubkey` — authenticates + * the sentinel against the raw event signer from the signed envelope, not + * a relay-delegated author. This enforces the D1 requirement that forged + * cards (wrong signer) never become actionable. + * 3. For resolved state: `editSignerPubkey` must be present AND equal + * `agentPubkey` — only edits signed by the original agent may flip the + * card to resolved. Owner-signed, attacker-signed, and born-resolved + * payloads (a kind-9 whose content is already `resolved`, carrying no edit + * provenance) are all rejected. + * 4. For resolved state: the resolved payload must correlate to THIS card — + * its `originalEventId` must equal `messageId`, and its + * `requestNonce`/`sessionId`/`turnId` must match the original pending + * payload (`preEditContent`). Same-signer authenticity alone is not enough: + * a buggy or compromised agent could otherwise cross-apply a resolution it + * signed for one card onto a different card it also signed. + * + * Extracted into its own module so it can be tested without pulling in + * markdown.tsx's heavy dependency chain. + */ +export function computePermissionRequest( + content: string, + interactive: boolean, + /** Normalized hex pubkey of the known agent for this channel (from signed envelope). */ + agentPubkey: string | undefined | null, + /** Raw signer pubkey of the message event (from the signed envelope's pubkey field). */ + signerPubkey: string | undefined | null, + /** + * Signer pubkey of the most recent kind-40003 edit for this message, if any. + * Undefined/null means no edit has arrived. Only edits where + * `editSignerPubkey === agentPubkey` may resolve the card. + */ + editSignerPubkey?: string | null, + /** Event ID of this message (the kind-9 sentinel). A resolved edit must name it. */ + messageId?: string | null, + /** + * The pending body before the edit was overlaid. Used to correlate the + * resolved edit's nonce/session/turn against the card it claims to resolve. + */ + preEditContent?: string | null, +): PermissionRequestPayload | null { + if (!interactive || !agentPubkey || !signerPubkey) return null; + + // D1 signer gate: the kind-9 must be signed by the known agent. + if (normalizePubkey(signerPubkey) !== normalizePubkey(agentPubkey)) { + return null; + } + + const payload = extractPermissionRequest(content); + if (payload === null) return null; + + // For resolved state: a completed card must have arrived as a kind-40003 + // edit overlaid on its pending kind-9. `formatTimelineMessages` supplies + // `editSignerPubkey`, `messageId`, and `preEditContent` together only when + // an edit exists, so legitimate resolutions always carry all three. A + // kind-9 whose content is *born* `resolved` has no edit provenance — + // requiring it here rejects a forged completed card that would otherwise + // pass the D1 signer gate alone and render with zero evidence of an edit. + if (payload.state === "resolved") { + // Edit signer must be present and match the original agent. Owner-signed + // or attacker-signed edits, and born-resolved payloads (no signer), are + // all rejected. + if ( + editSignerPubkey === undefined || + editSignerPubkey === null || + normalizePubkey(editSignerPubkey) !== normalizePubkey(agentPubkey) + ) { + return null; + } + + // Correlate the resolution to THIS card. `originalEventId` must name this + // message, and the frozen correlation fields must match the pending + // payload the edit overlaid — otherwise a same-signer agent could + // cross-apply a resolution meant for a different card. + if (!messageId || payload.originalEventId !== messageId) { + return null; + } + const pending = preEditContent + ? extractPermissionRequest(preEditContent) + : null; + if ( + pending === null || + pending.requestNonce !== payload.requestNonce || + pending.sessionId !== payload.sessionId || + pending.turnId !== payload.turnId + ) { + return null; + } + } + + return payload; +} + +/** + * Returns `markdownNode` when no trusted permission-request payload is present, + * or `null` when the card should suppress the prose. + * + * Mirrors `selectProseOrNudge` from computeConfigNudge.ts — same prose- + * suppression contract. + */ +export function selectProseOrPermission( + request: PermissionRequestPayload | null, + markdownNode: ReactNode, +): ReactNode { + return request === null ? markdownNode : null; +} diff --git a/desktop/src/shared/lib/permissionRequest.test.mjs b/desktop/src/shared/lib/permissionRequest.test.mjs new file mode 100644 index 00000000000..e1e7e36c72c --- /dev/null +++ b/desktop/src/shared/lib/permissionRequest.test.mjs @@ -0,0 +1,609 @@ +/** + * Named test matrix for the `permissionRequest` sentinel parser. + * + * The card is a two-action contract: the sentinel carries EXACTLY the ruled + * `allow_once` and `reject_once` options and nothing else. The harness enforces + * this on the producer side (`select_card_actions`); these tests pin the parser + * side — exact cardinality, bounded ids/labels, unique ids, and + * `chosenOptionId` membership. + * + * Wire contract: the harness signs BARE JSON as the kind:9 event content — + * no fence wrapper. Tests feed raw JSON strings matching that shape exactly. + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const mod = await import("./permissionRequest.js").catch( + () => import("./permissionRequest.ts"), +); +const { extractPermissionRequest, isPermissionRequestSentinel } = mod; + +// ── Fixtures (bare JSON as the harness emits) ──────────────────────────────── + +const PENDING_NORMAL = { + v: 1, + state: "pending", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 1786206732, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, +}; + +const RESOLVED_APPLIED = { + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 1786206732, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + outcome: "applied", + chosenOptionId: "opt-allow", +}; + +const RESOLVED_TIMED_OUT = { + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 1786206732, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + outcome: "timed_out", + chosenOptionId: null, +}; + +const RESOLVED_CANCELLED = { + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 1786206732, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + outcome: "cancelled", + chosenOptionId: null, +}; + +const RESOLVED_REJECTED = { + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 1786206732, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + outcome: "rejected", + chosenOptionId: null, +}; + +// ── Helper: bare JSON string as the harness emits ───────────────────────────── +// No fence, no prose — this is the exact kind:9 event content string. +function raw(payload) { + return JSON.stringify(payload); +} + +// ── Parse: happy-path fixtures — raw JSON strings ───────────────────────────── + +describe("extractPermissionRequest — pending fixtures", () => { + it("test_pending_normal_parses_correctly", () => { + const result = extractPermissionRequest(raw(PENDING_NORMAL)); + assert.ok(result !== null, "should parse"); + assert.equal(result.state, "pending"); + assert.equal(result.requestNonce, "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4"); + assert.equal(result.sessionId, "sess-abc"); + assert.equal(result.turnId, "turn-xyz"); + assert.equal(result.expiresAt, 1786206732); + assert.deepEqual(result.optionIds, ["opt-allow", "opt-deny"]); + assert.deepEqual(result.labels, { + "opt-allow": "Allow once", + "opt-deny": "Deny", + }); + // pending has no originalEventId, outcome, chosenOptionId + assert.ok(!("originalEventId" in result)); + assert.ok(!("outcome" in result)); + assert.ok(!("chosenOptionId" in result)); + }); + + it("test_pending_with_leading_whitespace_parses_correctly", () => { + // trim() before parse — consistent with how relay may deliver content + const result = extractPermissionRequest(` ${raw(PENDING_NORMAL)}\n`); + assert.ok(result !== null, "should parse with surrounding whitespace"); + assert.equal(result.state, "pending"); + }); +}); + +describe("extractPermissionRequest — resolved fixtures", () => { + it("test_resolved_applied_parses_correctly", () => { + const result = extractPermissionRequest(raw(RESOLVED_APPLIED)); + assert.ok(result !== null, "should parse"); + assert.equal(result.state, "resolved"); + assert.equal(result.outcome, "applied"); + assert.equal(result.chosenOptionId, "opt-allow"); + assert.equal( + result.originalEventId, + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead", + ); + }); + + it("test_resolved_timed_out_parses_correctly", () => { + const result = extractPermissionRequest(raw(RESOLVED_TIMED_OUT)); + assert.ok(result !== null, "should parse"); + assert.equal(result.state, "resolved"); + assert.equal(result.outcome, "timed_out"); + assert.equal(result.chosenOptionId, null); + }); + + it("test_resolved_cancelled_parses_correctly", () => { + const result = extractPermissionRequest(raw(RESOLVED_CANCELLED)); + assert.ok(result !== null, "should parse"); + assert.equal(result.state, "resolved"); + assert.equal(result.outcome, "cancelled"); + assert.equal(result.chosenOptionId, null); + }); + + it("test_resolved_rejected_parses_correctly", () => { + const result = extractPermissionRequest(raw(RESOLVED_REJECTED)); + assert.ok(result !== null, "should parse"); + assert.equal(result.state, "resolved"); + assert.equal(result.outcome, "rejected"); + assert.equal(result.chosenOptionId, null); + }); +}); + +// ── Parse: rejection cases ──────────────────────────────────────────────────── + +describe("extractPermissionRequest — rejection cases", () => { + it("test_prose_only_returns_null", () => { + // Ordinary kind:9 message (no sentinel) — must not parse + assert.equal(extractPermissionRequest("just prose, no JSON"), null); + }); + + it("test_wrong_version_returns_null", () => { + const bad = { ...PENDING_NORMAL, v: 2 }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_unknown_state_returns_null", () => { + const bad = { ...PENDING_NORMAL, state: "unknown" }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_empty_optionIds_returns_null", () => { + const bad = { ...PENDING_NORMAL, optionIds: [], labels: {} }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_single_optionId_returns_null", () => { + // Exactly two are required — one is not a valid two-action card. + const bad = { + ...PENDING_NORMAL, + optionIds: ["opt-allow"], + labels: { "opt-allow": "Allow once" }, + }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_three_optionIds_returns_null", () => { + // A third option (e.g. allow_always) must never produce an actionable card. + const bad = { + ...PENDING_NORMAL, + optionIds: ["opt-allow", "opt-deny", "opt-always"], + labels: { + "opt-allow": "Allow once", + "opt-deny": "Deny", + "opt-always": "Always allow", + }, + }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_duplicate_optionIds_returns_null", () => { + const bad = { + ...PENDING_NORMAL, + optionIds: ["opt-allow", "opt-allow"], + labels: { "opt-allow": "Allow once" }, + }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_optionId_exceeding_200_chars_returns_null", () => { + const big = "x".repeat(201); + const bad = { + ...PENDING_NORMAL, + optionIds: [big, "opt-deny"], + labels: { [big]: "Allow once", "opt-deny": "Deny" }, + }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_requestNonce_exceeding_200_chars_returns_null", () => { + const bad = { ...PENDING_NORMAL, requestNonce: "n".repeat(201) }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_extra_label_key_returns_null", () => { + // labels must have exactly one entry per optionId — no unbounded extra keys. + const bad = { + ...PENDING_NORMAL, + labels: { + "opt-allow": "Allow once", + "opt-deny": "Deny", + "opt-extra": "Extra", + }, + }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_label_exceeding_200_chars_returns_null", () => { + const bad = { + ...PENDING_NORMAL, + labels: { "opt-allow": "x".repeat(201), "opt-deny": "Deny" }, + }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_missing_requestNonce_returns_null", () => { + const { requestNonce: _, ...bad } = PENDING_NORMAL; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_fractional_expiresAt_returns_null", () => { + // Frozen schema requires integer seconds + const bad = { ...PENDING_NORMAL, expiresAt: 1786206732.5 }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_negative_expiresAt_returns_null", () => { + const bad = { ...PENDING_NORMAL, expiresAt: -1 }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_non_finite_expiresAt_returns_null", () => { + // JSON.stringify converts Infinity to null, so this tests null expiresAt + const bad = { ...PENDING_NORMAL, expiresAt: null }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_optionId_without_label_returns_null", () => { + // Every advertised optionId must have a label entry + const bad = { + ...PENDING_NORMAL, + optionIds: ["opt-allow", "opt-extra"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + // "opt-extra" has no label; "opt-deny" is an extra key + }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_resolved_missing_originalEventId_returns_null", () => { + const { originalEventId: _, ...bad } = RESOLVED_APPLIED; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_resolved_originalEventId_wrong_length_returns_null", () => { + const bad = { ...RESOLVED_APPLIED, originalEventId: "tooshort" }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_resolved_originalEventId_uppercase_returns_null", () => { + // Must be lowercase hex per HEX64_RE + const bad = { + ...RESOLVED_APPLIED, + originalEventId: + "DEADBEEF0001DEADBEEF0002DEADBEEF0003DEADBEEF0004DEADBEEF0005DEAD", + }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_resolved_unknown_outcome_returns_null", () => { + const bad = { ...RESOLVED_TIMED_OUT, outcome: "expired" }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_resolved_applied_with_null_chosenOptionId_returns_null", () => { + // outcome === "applied" requires a non-null chosenOptionId + const bad = { ...RESOLVED_APPLIED, chosenOptionId: null }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_resolved_applied_chosenOptionId_not_in_optionIds_returns_null", () => { + // chosenOptionId must be one of the advertised optionIds + const bad = { ...RESOLVED_APPLIED, chosenOptionId: "opt-nonexistent" }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_resolved_timed_out_with_nonnull_chosenOptionId_returns_null", () => { + // outcome !== "applied" requires null chosenOptionId + const bad = { ...RESOLVED_TIMED_OUT, chosenOptionId: "opt-allow" }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_invalid_json_returns_null", () => { + assert.equal(extractPermissionRequest("{not valid json}"), null); + }); + + it("test_empty_string_returns_null", () => { + assert.equal(extractPermissionRequest(""), null); + }); + + it("test_json_array_returns_null", () => { + // Arrays are not sentinel objects + assert.equal(extractPermissionRequest("[1,2,3]"), null); + }); + + it("test_json_null_returns_null", () => { + assert.equal(extractPermissionRequest("null"), null); + }); + + it("test_json_number_returns_null", () => { + assert.equal(extractPermissionRequest("42"), null); + }); +}); + +// ── Byte-unit boundary (producer/parser agreement) ────────────────────────── +// These pin the shared unit: UTF-8 bytes, identical to the harness +// (`SENTINEL_STRING_MAX_BYTES` / `SENTINEL_CONTENT_MAX_BYTES`). A prior split +// (Rust char scalars vs JS UTF-16 code units) let a producer-valid multibyte +// label be rejected here, publishing a card the desktop renders as raw JSON. + +describe("extractPermissionRequest — byte-unit boundaries", () => { + // "😀" is 1 JS char pair (length 2 as UTF-16 units it counts as 2), 4 UTF-8 bytes. + // 50 of them = 200 UTF-8 bytes: exactly at the limit, must be ACCEPTED. + it("test_multibyte_label_at_200_bytes_parses", () => { + const label = "😀".repeat(50); // 50 * 4 = 200 UTF-8 bytes + assert.equal(new TextEncoder().encode(label).length, 200); + const ok = { + ...PENDING_NORMAL, + labels: { "opt-allow": label, "opt-deny": "Deny" }, + }; + const result = extractPermissionRequest(raw(ok)); + assert.ok(result !== null, "a 200-UTF-8-byte label must be accepted"); + assert.equal(result.labels["opt-allow"], label); + }); + + it("test_multibyte_label_over_200_bytes_returns_null", () => { + const label = "😀".repeat(51); // 204 UTF-8 bytes + const bad = { + ...PENDING_NORMAL, + labels: { "opt-allow": label, "opt-deny": "Deny" }, + }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_sessionId_over_200_bytes_returns_null", () => { + // The adapter-supplied sessionId is the load-bearing hole: an oversized one + // must be rejected, not published as an unrenderable card. + const bad = { ...PENDING_NORMAL, sessionId: "s".repeat(201) }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_turnId_over_200_bytes_returns_null", () => { + const bad = { ...PENDING_NORMAL, turnId: "t".repeat(201) }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_total_content_over_max_bytes_returns_null", () => { + // A structurally-valid sentinel padded past MAX_CONTENT_BYTES via an extra + // (ignored) field must be rejected before parsing — the total-content gate. + const bad = { ...PENDING_NORMAL, pad: "x".repeat(5000) }; + const serialized = raw(bad); + assert.ok( + new TextEncoder().encode(serialized).length > 4096, + "fixture must exceed MAX_CONTENT_BYTES to exercise the gate", + ); + assert.equal(extractPermissionRequest(serialized), null); + }); + + it("test_total_content_with_whitespace_padding_over_max_bytes_returns_null", () => { + // The gate measures RAW content, not the trimmed value. A structurally-valid + // sentinel prefixed with whitespace whose RAW size exceeds MAX_CONTENT_BYTES + // must be rejected — otherwise whitespace padding smuggles signed content + // past the frozen total boundary. Mutation proof: gating `content.trim()` + // instead of `content` makes this test pass the oversized payload and parse. + const body = raw(PENDING_NORMAL); + const padded = " ".repeat(5000) + body; + assert.ok( + new TextEncoder().encode(body).length <= 4096, + "the trimmed body alone must be within MAX_CONTENT_BYTES", + ); + assert.ok( + new TextEncoder().encode(padded).length > 4096, + "raw padded content must exceed MAX_CONTENT_BYTES to exercise the gate", + ); + assert.equal(extractPermissionRequest(padded), null); + }); + + it("test_normal_bounded_content_well_under_max_bytes_parses", () => { + // A normal bounded sentinel — every leaf within MAX_STRING_BYTES and the + // fixed field set — carries ample headroom below MAX_CONTENT_BYTES, so it + // must parse. Assert the fixture's byte size to make that headroom explicit + // and prove the total-content gate is not over-tight for typical content. + // (Adversarial per-field-valid input can still reach the gate: JSON escaping + // expands control characters, so distinct 200-byte option IDs built from + // e.g. U+0000/U+0001, repeated as optionIds and label keys, can push the + // serialized shape over 4096 and correctly fail closed at the producer.) + const serialized = raw(PENDING_NORMAL); + const size = new TextEncoder().encode(serialized).length; + assert.ok( + size < 4096, + `a normal bounded sentinel must be under MAX_CONTENT_BYTES (got ${size})`, + ); + assert.ok( + extractPermissionRequest(serialized) !== null, + "normal bounded content must parse", + ); + }); +}); + +// ── isPermissionRequestSentinel ─────────────────────────────────────────────── + +describe("isPermissionRequestSentinel", () => { + it("test_sentinel_pending_returns_true", () => { + assert.equal(isPermissionRequestSentinel(raw(PENDING_NORMAL)), true); + }); + + it("test_sentinel_resolved_returns_true", () => { + assert.equal(isPermissionRequestSentinel(raw(RESOLVED_APPLIED)), true); + }); + + it("test_prose_message_returns_false", () => { + assert.equal(isPermissionRequestSentinel("Hello world"), false); + }); + + it("test_invalid_json_returns_false", () => { + assert.equal(isPermissionRequestSentinel("{bad json"), false); + }); + + it("test_json_without_v1_returns_false", () => { + // A valid JSON object that is not a sentinel + assert.equal( + isPermissionRequestSentinel('{"type":"normal_message"}'), + false, + ); + }); +}); + +// ── Harness integration fixture ─────────────────────────────────────────────── +// The exact bytes below live in the shared, checked-in fixture +// `crates/buzz-acp/tests/fixtures/sentinel_pending.json`. The Rust test +// `kind9_content_fixture_structural_invariants` asserts `build_sentinel_pending_payload` +// output is byte-equal to that file; this test asserts the Desktop parser accepts +// the same file. Because both sides consume ONE file, a producer-side wire change +// breaks the Rust byte-equality assertion — the literal can no longer silently drift. +// (serde_json serializes object keys in sorted BTreeMap order.) +describe("harness integration fixture", () => { + const HARNESS_KIND9_CONTENT = readFileSync( + fileURLToPath( + new URL( + "../../../../crates/buzz-acp/tests/fixtures/sentinel_pending.json", + import.meta.url, + ), + ), + "utf8", + ); + + it("test_harness_kind9_content_parses_to_pending_payload", () => { + const result = extractPermissionRequest(HARNESS_KIND9_CONTENT); + assert.ok( + result !== null, + "harness fixture must parse to a non-null payload", + ); + assert.equal(result.state, "pending"); + assert.equal(result.v, 1); + assert.equal(result.requestNonce, "test-nonce-fixture-abc123"); + assert.equal(result.expiresAt, 1700000300); + assert.deepEqual(result.optionIds, ["opt-allow", "opt-reject"]); + assert.equal(result.sessionId, "sess-fixture-001"); + assert.equal(result.turnId, "turn-fixture-xyz"); + // F2: fixture now carries description from params.subject + assert.equal( + result.description, + "read a file", + "fixture description must round-trip from Rust producer", + ); + }); + + it("test_harness_kind9_content_identified_as_sentinel", () => { + assert.equal(isPermissionRequestSentinel(HARNESS_KIND9_CONTENT), true); + }); +}); + +// ── F2: description field — parser validation ───────────────────────────────── + +describe("extractPermissionRequest — description field", () => { + it("test_pending_with_description_string_parses_correctly", () => { + const payload = { ...PENDING_NORMAL, description: "read /etc/hosts" }; + const result = extractPermissionRequest(raw(payload)); + assert.ok(result !== null, "pending with description must parse"); + assert.equal(result.description, "read /etc/hosts"); + }); + + it("test_pending_with_null_description_parses_correctly", () => { + const payload = { ...PENDING_NORMAL, description: null }; + const result = extractPermissionRequest(raw(payload)); + assert.ok(result !== null, "pending with null description must parse"); + assert.equal(result.description, null); + }); + + it("test_pending_without_description_field_parses_correctly", () => { + // No description key at all — field is optional + const result = extractPermissionRequest(raw(PENDING_NORMAL)); + assert.ok(result !== null, "pending without description field must parse"); + assert.ok( + result.description === undefined || result.description === null, + "absent description must be undefined or null", + ); + }); + + it("test_pending_with_overlong_description_is_rejected", () => { + // Parser-side: an over-limit description in the wire payload is rejected + // (producer truncates producer-side; a non-compliant producer is untrusted). + const overlong = "a".repeat(201); // 201 bytes > MAX_STRING_BYTES=200 + const payload = { ...PENDING_NORMAL, description: overlong }; + const result = extractPermissionRequest(raw(payload)); + assert.equal( + result, + null, + "pending with over-limit description must be rejected by parser", + ); + }); + + it("test_pending_with_non_string_description_is_rejected", () => { + // Parser-side: a non-null non-string description is invalid + const payload = { ...PENDING_NORMAL, description: 42 }; + const result = extractPermissionRequest(raw(payload)); + assert.equal( + result, + null, + "pending with numeric description must be rejected", + ); + }); + + it("test_pending_with_markup_description_parses_as_text", () => { + // Hostile markup in description — parser accepts it as-is; React renders as text + const hostile = "<script>alert(1)</script>"; + const payload = { ...PENDING_NORMAL, description: hostile }; + const result = extractPermissionRequest(raw(payload)); + assert.ok(result !== null, "hostile markup description must parse"); + // The string round-trips unchanged — sanitization is React's job at render time + assert.equal(result.description, hostile); + }); + + it("test_resolved_with_description_parses_correctly", () => { + const payload = { + ...RESOLVED_APPLIED, + description: "read /etc/hosts", + }; + const result = extractPermissionRequest(raw(payload)); + assert.ok(result !== null, "resolved with description must parse"); + assert.equal(result.description, "read /etc/hosts"); + }); + + it("test_resolved_with_overlong_description_is_rejected", () => { + const overlong = "b".repeat(201); + const payload = { ...RESOLVED_APPLIED, description: overlong }; + const result = extractPermissionRequest(raw(payload)); + assert.equal( + result, + null, + "resolved with over-limit description must be rejected", + ); + }); +}); diff --git a/desktop/src/shared/lib/permissionRequest.ts b/desktop/src/shared/lib/permissionRequest.ts new file mode 100644 index 00000000000..5912a016a6f --- /dev/null +++ b/desktop/src/shared/lib/permissionRequest.ts @@ -0,0 +1,314 @@ +/** + * Utilities for extracting and parsing the permission-request sentinel that + * `buzz-acp` publishes as a kind:9 reply into the triggering thread when an + * `ask`-policy permission request is admitted. + * + * Wire format (versioned discriminated union, schema v1 — frozen at event + * b31c716e): + * + * The harness serialises a bare JSON object as the kind:9 event content: + * + * {"v":1,"state":"pending","requestNonce":"…", …} + * + * Desktop identifies a sentinel by `"v":1` in the top-level JSON object. + * Non-JSON content and JSON objects without `"v":1` are left untouched. + * There is no fenced wire format — non-sentinel kind:9s must NOT be modified. + * + * Security invariants: + * - `agentPubkey` and `channelId` are derived from the SIGNED EVENT ENVELOPE, + * never from sentinel JSON. + * - `optionId` values are opaque — treated as arbitrary strings; never + * interpreted as ACP kinds by the renderer. + * - Labels come from `labels[optionId]` — harness-provided display strings, + * not raw ACP kind names. + * - All untrusted display strings are size-bounded (≤ 200 UTF-8 bytes) and + * HTML-escaped by React at render time. + */ + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** + * Pending sentinel — the card is actionable. + * + * `requestNonce` and `expiresAt` are trusted as unsigned ints from the harness. + * `labels` values are untrusted display strings (capped at 200 UTF-8 bytes). + */ +export type PermissionRequestPending = { + v: 1; + state: "pending"; + requestNonce: string; + sessionId: string | null; + turnId: string | null; + expiresAt: number; + /** + * Opaque option IDs. Exactly two — the ruled `allow_once` and `reject_once` + * actions, in that order. The harness never forwards a third option (e.g. + * `allow_always`), so any other cardinality is a malformed sentinel. + */ + optionIds: string[]; + /** Harness-provided display labels keyed by optionId. Each ≤ 200 UTF-8 bytes. */ + labels: Record<string, string>; + /** + * Human-readable description of the requested operation, sourced from the + * ACP `session/request_permission` message via `description_from_request_permission` + * in `crates/buzz-acp/src/acp.rs`. Tries `params.title`, + * `params.subject.toolCall.title`, `params.toolCall.title`, + * `params.toolCall.rawInput.command`, and `params._meta.codex.params.reason` in order. + * Truncated producer-side to ≤ 200 UTF-8 bytes. `null` when no path yields a + * non-empty string. + * + * Display-only — treated as an untrusted string and rendered as text (HTML- + * escaped by React). The sentinel is valid when this field is absent or null. + */ + description?: string | null; +}; + +/** + * Resolved sentinel — the card is non-actionable (archived state). + * + * Published by the harness as a kind-40003 edit signed by the original agent. + * `originalEventId` is the kind-9 event ID — correlates the edit to the card. + */ +export type PermissionRequestResolved = { + v: 1; + state: "resolved"; + requestNonce: string; + originalEventId: string; + sessionId: string | null; + turnId: string | null; + expiresAt: number; + optionIds: string[]; + labels: Record<string, string>; + /** Outcome of the permission request. */ + outcome: "applied" | "timed_out" | "cancelled" | "rejected"; + /** Non-null only when outcome === "applied". */ + chosenOptionId: string | null; + /** + * Human-readable description of the requested operation — same value as in + * the corresponding pending sentinel. `null` or absent when no subject was + * provided. Rendered on the resolved card for context. + */ + description?: string | null; +}; + +export type PermissionRequestPayload = + | PermissionRequestPending + | PermissionRequestResolved; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** + * Frozen sentinel byte bounds — shared verbatim with the harness producer + * (`SENTINEL_STRING_MAX_BYTES` / `SENTINEL_CONTENT_MAX_BYTES` in + * `crates/buzz-acp/src/acp.rs`). + * + * Both the values AND the unit — UTF-8 bytes — must match the producer. The + * prior split (harness truncated labels by Rust `char` scalars while this parser + * bounded by JavaScript `.length` UTF-16 code units) let a producer-valid + * multibyte label be rejected here, publishing a card the desktop renders as raw + * JSON until timeout. Measuring in bytes on both sides closes that gap. + * + * `MAX_STRING_BYTES` bounds every untrusted string leaf: labels, each + * `optionId`, `requestNonce`, `sessionId`, `turnId`, and `chosenOptionId`. + * `MAX_CONTENT_BYTES` bounds the total serialized sentinel content. + */ +const MAX_STRING_BYTES = 200; +const MAX_CONTENT_BYTES = 4096; + +/** UTF-8 byte length of a string — the shared measurement unit. */ +const UTF8 = new TextEncoder(); +function byteLength(s: string): number { + return UTF8.encode(s).length; +} + +/** + * Exact number of option IDs in a sentinel. The card is a two-action contract: + * the ruled `allow_once` and `reject_once` options and nothing else. The + * harness enforces this on the producer side (`select_card_actions`); the + * parser rejects any other cardinality so the two sides can never diverge. + */ +const OPTION_IDS_COUNT = 2; + +/** Regex for a valid 64-character lowercase hex Nostr event ID. */ +const HEX64_RE = /^[0-9a-f]{64}$/; + +/** The four valid outcome strings. */ +const VALID_OUTCOMES = new Set([ + "applied", + "timed_out", + "cancelled", + "rejected", +]); + +// ── Extractor ───────────────────────────────────────────────────────────────── + +/** + * Extract the `PermissionRequestPayload` from a kind:9 event content string, + * if present. + * + * The harness signs bare JSON as the event content — no fence wrapper. Desktop + * identifies sentinels by `"v":1` at the top level. Non-JSON content and JSON + * objects that do not carry `"v":1` are returned as `null`; `MessageRow` renders + * them as ordinary markdown. + * + * Returns `null` when: + * - the content is not valid JSON + * - the parsed value is not a sentinel object (missing `v:1`) + * - the parsed value does not match the expected shape or invariants + * + * Never throws — all errors are swallowed so this is safe in the render path. + */ +export function extractPermissionRequest( + content: string, +): PermissionRequestPayload | null { + // Total-content byte bound — the single size gate mirrored by the harness + // (`SENTINEL_CONTENT_MAX_BYTES`). Measured against the RAW content, not the + // trimmed value, so the bound matches the producer's complete serialized + // output byte-for-byte; gating trimmed content would let whitespace padding + // smuggle signed content past the frozen boundary. Reject before parsing so + // an oversized signed payload can never allocate an outsized DOM/control value. + if (byteLength(content) > MAX_CONTENT_BYTES) return null; + let parsed: unknown; + try { + parsed = JSON.parse(content.trim()); + } catch { + return null; + } + return isPermissionRequestPayload(parsed) ? parsed : null; +} + +/** + * Returns `true` when the kind:9 content is a permission-request sentinel. + * Used by `MessageRow` to decide whether to suppress markdown rendering. + * + * When `extractPermissionRequest` returns a non-null value the content IS the + * sentinel; the entire string is consumed by the card. Non-sentinel kind:9s are + * rendered as ordinary markdown, unchanged. + */ +export function isPermissionRequestSentinel(content: string): boolean { + return extractPermissionRequest(content) !== null; +} + +// ── Type guards ──────────────────────────────────────────────────────────────── + +function isSafeString(v: unknown): v is string { + return typeof v === "string" && byteLength(v) <= MAX_STRING_BYTES; +} + +function isNullableString(v: unknown): v is string | null { + return v === null || isSafeString(v); +} + +function isLabelsRecord(v: unknown): v is Record<string, string> { + if (typeof v !== "object" || v === null || Array.isArray(v)) return false; + return Object.values(v as Record<string, unknown>).every(isSafeString); +} + +function isPermissionRequestPayload(v: unknown): v is PermissionRequestPayload { + if (typeof v !== "object" || v === null || Array.isArray(v)) return false; + const p = v as Record<string, unknown>; + if (p.v !== 1) return false; + + // Shared fields present in both states + if ( + typeof p.requestNonce !== "string" || + p.requestNonce.length === 0 || + byteLength(p.requestNonce) > MAX_STRING_BYTES + ) { + return false; + } + if (!isNullableString(p.sessionId)) return false; + if (!isNullableString(p.turnId)) return false; + // expiresAt must be an integer (no fractional seconds, no negative values) + if ( + typeof p.expiresAt !== "number" || + !Number.isFinite(p.expiresAt) || + !Number.isInteger(p.expiresAt) || + p.expiresAt < 0 + ) { + return false; + } + // optionIds: EXACTLY two bounded, non-empty, unique opaque strings. + if ( + !Array.isArray(p.optionIds) || + p.optionIds.length !== OPTION_IDS_COUNT || + !p.optionIds.every( + (id) => + typeof id === "string" && + id.length > 0 && + byteLength(id) <= MAX_STRING_BYTES, + ) + ) { + return false; + } + if ( + new Set(p.optionIds as string[]).size !== (p.optionIds as string[]).length + ) { + return false; + } + if (!isLabelsRecord(p.labels)) return false; + // labels must have exactly one entry per advertised optionId — no extra keys. + const optionIds = p.optionIds as string[]; + const labelKeys = Object.keys(p.labels as Record<string, unknown>); + if (labelKeys.length !== optionIds.length) return false; + if ( + !optionIds.every( + (id) => typeof (p.labels as Record<string, unknown>)[id] === "string", + ) + ) { + return false; + } + + if (p.state === "pending") { + // description: optional field — absent/null or a bounded string are all valid. + if ( + "description" in p && + p.description !== null && + p.description !== undefined && + (typeof p.description !== "string" || + byteLength(p.description) > MAX_STRING_BYTES) + ) { + return false; + } + return true; + } + + if (p.state === "resolved") { + // originalEventId: 64-char lowercase hex string + if ( + typeof p.originalEventId !== "string" || + !HEX64_RE.test(p.originalEventId) + ) { + return false; + } + // outcome: exactly one of the four literals + if (!VALID_OUTCOMES.has(p.outcome as string)) return false; + // chosenOptionId: non-null ⟺ outcome === "applied", bounded, and must be + // one of the advertised optionIds. + if (p.outcome === "applied") { + if ( + typeof p.chosenOptionId !== "string" || + p.chosenOptionId.length === 0 || + byteLength(p.chosenOptionId) > MAX_STRING_BYTES || + !optionIds.includes(p.chosenOptionId) + ) { + return false; + } + } else { + if (p.chosenOptionId !== null) return false; + } + // description: optional field — same rules as pending. + if ( + "description" in p && + p.description !== null && + p.description !== undefined && + (typeof p.description !== "string" || + byteLength(p.description) > MAX_STRING_BYTES) + ) { + return false; + } + return true; + } + + return false; +} diff --git a/desktop/src/shared/ui/permission-request-card.tsx b/desktop/src/shared/ui/permission-request-card.tsx new file mode 100644 index 00000000000..5d039c35ae2 --- /dev/null +++ b/desktop/src/shared/ui/permission-request-card.tsx @@ -0,0 +1,370 @@ +/** + * Inline card rendered when the desktop detects a version-1 bare-JSON + * permission-request sentinel in a kind:9 message body. Mirrors the + * `ConfigNudgeCard` pattern. + * + * Wire format: the harness signs a bare JSON object `{"v":1,"state":"pending",…}` + * as the kind:9 event content. No code-fence wrapper — non-JSON content and + * JSON without `"v":1` render as ordinary markdown. + * + * Security invariants enforced by the caller (`MessageRow`): + * - `request` is only non-null when the kind-9 signer equals the known agent + * pubkey for this channel (D1 signer gate in `computePermissionRequest`). + * - Resolved state (`state === "resolved"`) requires the edit to have been + * signed by the original agent (edit authenticity gate). + * - Only an agent-signed kind-40003 edit may overlay the sentinel body — + * enforced in `formatTimelineMessages` before `computePermissionRequest` runs. + * + * Actionable buttons render ONLY when: + * (a) `request.state === "pending"` AND + * (b) `isOwner` is true (the current viewer is the verified agent owner). + * All other viewers see a read-only card. + */ +import * as React from "react"; +import { ShieldCheck } from "lucide-react"; + +import { startPermissionDecisionDelivery } from "@/features/agents/lib/permissionDecisionDelivery"; +import { cn } from "@/shared/lib/cn"; +import { + Attachment, + AttachmentContent, + AttachmentMedia, + AttachmentTitle, +} from "@/shared/ui/attachment"; +import type { + PermissionRequestPayload, + PermissionRequestPending, +} from "@/shared/lib/permissionRequest"; + +export type PermissionRequestCardProps = { + className?: string; + request: PermissionRequestPayload; + /** Hex pubkey of the agent that published the sentinel. */ + agentPubkey: string; + /** Channel ID for routing the permission decision. */ + channelId: string; + /** + * True when the current viewer is the verified agent owner. + * Absent or false → read-only card (buttons suppressed). + */ + isOwner?: boolean; + /** + * Delivery function injected by tests so component tests can control + * the outcome without a real relay. Production callers omit this and + * get `startPermissionDecisionDelivery` by default. + * + * @internal — test seam only; not part of the public API. + */ + _deliveryFn?: typeof startPermissionDecisionDelivery; +}; + +/** + * Heuristic: treat an option as "deny" when its harness label contains deny, + * reject, or block (case-insensitive). Opaque optionIds carry no inherent + * semantics — the label is the only display hint available. + */ +function isDenyLabel(label: string): boolean { + const lower = label.toLowerCase(); + return ( + lower.includes("deny") || + lower.includes("reject") || + lower.includes("block") + ); +} + +function buttonClass(deny: boolean): string { + return deny + ? "rounded px-2 py-0.5 text-xs font-medium border border-destructive/40 text-destructive hover:bg-destructive/10 disabled:opacity-50" + : "rounded px-2 py-0.5 text-xs font-medium border border-green-600/40 text-green-700 dark:text-green-400 hover:bg-green-600/10 disabled:opacity-50"; +} + +/** + * Outcome display label — maps the harness outcome string to human copy. + * + * Verb derivation uses the positional allow contract: `optionIds[0]` is always + * the `allow_once` action and `optionIds[1]` is always the `reject_once` action + * (fixed order guaranteed by `sentinel_option_fields` in `crates/buzz-acp/src/acp.rs` + * and pinned by the cross-language fixture `crates/buzz-acp/tests/fixtures/sentinel_pending.json`). + * Deriving the verb from this signed identity — not the mutable display label — ensures + * that a reject option named "Allow file access" still renders as "Denied", not "Approved". + */ +function outcomeLabel( + outcome: string, + chosenOptionId: string | null, + labels: Record<string, string>, + allowOptionId: string, +): string { + if (outcome === "applied" && chosenOptionId !== null) { + const chosen = labels[chosenOptionId]; + if (chosenOptionId === allowOptionId) { + return chosen ? `Approved: ${chosen}` : "Approved"; + } + // chosenOptionId is the reject option + return chosen ? `Denied: ${chosen}` : "Denied"; + } + if (outcome === "timed_out") return "Timed out"; + if (outcome === "cancelled") return "Cancelled"; + if (outcome === "rejected") return "Denied"; + return outcome; +} + +/** + * Allow/Deny buttons for a pending, owner-visible permission card. + * On click: disables locally and shows "Decision sent". Convergence to final + * state comes from the agent's kind-40003 edit or expiry — no promise of + * immediate resolution from the harness response. + */ +function PermissionButtons({ + agentPubkey, + channelId, + request, + nowSecs, + deliveryFn = startPermissionDecisionDelivery, +}: { + agentPubkey: string; + channelId: string; + request: PermissionRequestPending; + /** Current time in seconds (driven by a parent ticking state). */ + nowSecs: number; + /** + * Delivery function — defaults to `startPermissionDecisionDelivery`. + * Injected by tests to control the outcome without a real relay. + */ + deliveryFn?: typeof startPermissionDecisionDelivery; +}) { + const [submitted, setSubmitted] = React.useState<string | null>(null); + + const expired = request.expiresAt <= nowSecs; + + if (expired) { + return ( + <div + aria-live="polite" + className="mt-1.5 text-xs text-muted-foreground" + role="status" + > + Timed out + </div> + ); + } + + if (submitted !== null) { + return ( + <div + aria-live="polite" + className="mt-1.5 text-xs text-muted-foreground" + role="status" + > + Decision sent + </div> + ); + } + + return ( + <div className="mt-1.5 flex flex-col gap-2"> + <div className="flex flex-wrap gap-1.5"> + {request.optionIds.map((optionId) => { + const label = request.labels[optionId] ?? optionId; + return ( + <button + key={optionId} + type="button" + className={buttonClass(isDenyLabel(label))} + data-testid={`permission-decision-${optionId}`} + onClick={() => { + // Recheck expiry at click time — prevents submitting a + // decision on a card that expired between renders. + if (request.expiresAt <= Date.now() / 1000) return; + setSubmitted(optionId); + void deliveryFn({ + agentPubkey, + channelId, + requestNonce: request.requestNonce, + optionId, + deadlineSecs: request.expiresAt, + }) + .then((outcome) => { + // `"failed"` means the harness received the frame but could + // not route it (no_active_turn / channel_closed / no_channel) + // — re-enable so the owner can retry. `"acked"` and + // `"expired"` are terminal: the harness applied the decision + // or the card timed out; the card transitions away via the + // kind-40003 edit or expiry countdown and no retry is needed. + // `"channel_full"` is transient: the retransmit loop stays + // active and keeps resending — no re-enable needed here. + if (outcome === "failed") setSubmitted(null); + }) + .catch(() => { + // The delivery loop never rejects — it resolves one of + // "acked" | "expired" | "failed". This branch guards against + // any unexpected error and re-enables for safety. + setSubmitted(null); + }); + }} + > + {label} + </button> + ); + })} + </div> + </div> + ); +} + +/** + * Countdown display for a pending card. Updates the shared `now` state + * every second until expiry so that both the countdown and the button + * actionability are driven by the same tick. + */ +function ExpiryCountdown({ + expiresAt, + nowSecs, +}: { + expiresAt: number; + nowSecs: number; +}) { + const secsLeft = Math.max(0, Math.round(expiresAt - nowSecs)); + + if (secsLeft <= 0) return null; + const mins = Math.floor(secsLeft / 60); + const secs = secsLeft % 60; + const label = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; + return ( + <span className="text-2xs text-muted-foreground opacity-70"> + {" "} + · expires in {label} + </span> + ); +} + +export function PermissionRequestCard({ + className, + request, + agentPubkey, + channelId, + isOwner, + _deliveryFn, +}: PermissionRequestCardProps) { + if (request.state === "resolved") { + const resolvedLabel = outcomeLabel( + request.outcome, + request.chosenOptionId, + request.labels, + request.optionIds[0] ?? "", + ); + return ( + <Attachment + className={cn( + "max-w-[min(100%,32rem)] shrink-0 shadow-none", + className, + )} + orientation="horizontal" + state="done" + > + <AttachmentMedia className="text-muted-foreground"> + <ShieldCheck aria-hidden="true" className="h-4 w-4" /> + </AttachmentMedia> + <AttachmentContent> + <AttachmentTitle className="whitespace-normal text-muted-foreground line-clamp-2"> + Permission request resolved + </AttachmentTitle> + {request.description ? ( + <div className="mt-0.5 text-xs text-muted-foreground line-clamp-2"> + {request.description} + </div> + ) : null} + <div className="mt-0.5 text-xs text-muted-foreground"> + {resolvedLabel} + </div> + </AttachmentContent> + </Attachment> + ); + } + + // Pending state — one ticking `nowSecs` drives both the countdown display + // and button actionability so expiry is observed atomically. + return ( + <PendingPermissionRequestCard + agentPubkey={agentPubkey} + channelId={channelId} + className={className} + isOwner={isOwner} + request={request} + deliveryFn={_deliveryFn} + /> + ); +} + +/** + * Pending-state card. Owns the ticking `nowSecs` state so that + * `ExpiryCountdown` and `PermissionButtons` always see the same clock value. + */ +function PendingPermissionRequestCard({ + className, + request, + agentPubkey, + channelId, + isOwner, + deliveryFn, +}: { + className?: string; + request: PermissionRequestPending; + agentPubkey: string; + channelId: string; + isOwner?: boolean; + deliveryFn?: typeof startPermissionDecisionDelivery; +}) { + const [nowSecs, setNowSecs] = React.useState(() => Date.now() / 1000); + + React.useEffect(() => { + const id = setInterval(() => { + const now = Date.now() / 1000; + setNowSecs(now); + if (now >= request.expiresAt) clearInterval(id); + }, 1000); + // In Node test environments (not browsers), intervals can keep the process + // alive. Call unref() when available to allow clean test exits. + (id as unknown as { unref?: () => void }).unref?.(); + return () => clearInterval(id); + }, [request.expiresAt]); + + const expired = request.expiresAt <= nowSecs; + + return ( + <Attachment + className={cn("max-w-[min(100%,32rem)] shrink-0 shadow-none", className)} + orientation="horizontal" + state={expired ? "done" : "idle"} + > + <AttachmentMedia className="text-amber-600 dark:text-amber-400"> + <ShieldCheck aria-hidden="true" className="h-4 w-4" /> + </AttachmentMedia> + <AttachmentContent> + <AttachmentTitle className="whitespace-normal text-amber-700 dark:text-amber-400 line-clamp-2"> + Permission request + {!expired ? ( + <ExpiryCountdown expiresAt={request.expiresAt} nowSecs={nowSecs} /> + ) : null} + </AttachmentTitle> + {request.description ? ( + <div className="mt-0.5 text-xs text-muted-foreground line-clamp-3"> + {request.description} + </div> + ) : null} + {isOwner ? ( + <PermissionButtons + agentPubkey={agentPubkey} + channelId={channelId} + nowSecs={nowSecs} + request={request} + deliveryFn={deliveryFn} + /> + ) : ( + <div className="mt-1 text-xs text-muted-foreground"> + Waiting for owner approval + </div> + )} + </AttachmentContent> + </Attachment> + ); +} diff --git a/desktop/tests/e2e/observer-feed-screenshots.spec.ts b/desktop/tests/e2e/observer-feed-screenshots.spec.ts index 44ff609c9ee..3f50e60e410 100644 --- a/desktop/tests/e2e/observer-feed-screenshots.spec.ts +++ b/desktop/tests/e2e/observer-feed-screenshots.spec.ts @@ -275,8 +275,10 @@ test.describe("observer feed screenshots", () => { }, ]); - // The permission row should show the "Approved (allow_once)" outcome. - await expect(feedPanel.getByText(/Approved.*allow_once/)).toBeVisible({ + // The permission row shows the harness-provided option label ("Allow once"), + // not the raw ACP kind. The legacy non-ask path has no label map, so it + // falls back to the verb-only form: "Approved". + await expect(feedPanel.getByText("Approved")).toBeVisible({ timeout: 5_000, }); await settleAnimations(feedPanel); diff --git a/docs/nips/NIP-AO.md b/docs/nips/NIP-AO.md index 36adea04871..f396c2b5d11 100644 --- a/docs/nips/NIP-AO.md +++ b/docs/nips/NIP-AO.md @@ -24,6 +24,11 @@ It is strictly scoped to the agent↔owner relationship and carries no durable s - **Owner**: The human (or system) whose pubkey the agent was provisioned under. - **Observer Frame**: A single kind 24200 event carrying one unit of telemetry or control. - **Session**: A bounded agent execution correlated by a shared `sessionId`. +- **Request nonce**: A single-use random token bound to one `session/request_permission` + call. The harness generates it on arrival of the request, embeds it in the + `authorization` envelope of the emitted `acp_read` telemetry frame, and consumes it + exactly once when a matching `permission_decision` control frame is received. A nonce + that is never matched expires with the per-request fail-closed timeout. ## Event Kinds @@ -58,8 +63,8 @@ Events MUST have exactly one `p` tag, exactly one `agent` tag, and exactly one `frame` MUST be `"telemetry"` or `"control"`. Relays SHOULD silently drop events with unrecognized `frame` values (returning OK to the publisher for forward -compatibility). Clients MUST ignore events with unrecognized `frame` values. An `h` tag MAY be included when the session runs within a NIP-29 group -context. +compatibility). Clients MUST ignore events with unrecognized `frame` values. An `h` +tag MAY be included when the session runs within a NIP-29 group context. ## Encryption @@ -80,14 +85,15 @@ The `content` field decrypts to an `ObserverEvent` JSON object: ```json { - "seq": <monotonic_integer>, - "timestamp": "<rfc3339_string>", - "kind": "<frame_kind>", - "agentIndex": <integer> | null, - "channelId": "<channel_uuid>" | null, - "sessionId": "<session_id>" | null, - "turnId": "<turn_id>" | null, - "payload": { ... } + "seq": <monotonic_integer>, + "timestamp": "<rfc3339_string>", + "kind": "<frame_kind>", + "agentIndex": <integer> | null, + "channelId": "<channel_uuid>" | null, + "sessionId": "<session_id>" | null, + "turnId": "<turn_id>" | null, + "authorization": { ... } | omitted, + "payload": { ... } } ``` @@ -99,21 +105,108 @@ gracefully. `seq` is monotonically increasing per session (drop detection). `timestamp` is an RFC 3339 datetime string with sub-second precision (e.g., `"2026-04-29T12:00:41.500Z"`). `agentIndex` identifies the agent in multi-agent scenarios. `sessionId`/`turnId` -correlate frames across a session and turn. `payload` is kind-specific (MAY be `{}`). -Unknown `kind` values MUST be ignored. +correlate frames across a session and turn. `payload` carries the raw ACP JSON frame +byte-for-byte — it is NEVER mutated by the harness. Unknown `kind` values MUST be +ignored. + +`authorization` is present only on `acp_read` and `acp_write` frames that correspond +to `session/request_permission` calls (see [Authorization Envelope](#authorization-envelope) +below). It is omitted on all other frame kinds — with one exception: the observer-only +`permission_terminal` kind also carries `authorization` (with `reason = "uncertain"`) to +signal an unconfirmed outcome. `permission_terminal` is never an ACP wire frame; it is +emitted by the harness solely for Desktop card retirement when no confirmed `acp_write` +response was possible. ### Frame Kinds -| `kind` | Description | -|--------------------|----------------------------------------------------------| -| `acp_read` | Inbound ACP protocol frame (model → harness) | -| `acp_write` | Outbound ACP protocol frame (harness → model) | -| `turn_started` | A new agent turn has begun | -| `session_resolved` | Session completed or terminated | +| `kind` | Description | +|--------------------|--------------------------------------------------------------------| +| `acp_read` | Inbound ACP protocol frame (model → harness) | +| `acp_write` | Outbound ACP protocol frame (harness → model) | +| `turn_started` | A new agent turn has begun | +| `session_resolved` | Session ready — emitted once when the agent session is established (before the first prompt) | +| `turn_completed` | Terminal lifecycle — emitted when a turn ends (success, cancel, or timeout) | +| `turn_error` | Terminal lifecycle — emitted when a turn ends with an error or process death | +| `control_result` | Acknowledgement telemetry emitted after processing a control frame | +| `permission_terminal` | Observer-only terminal for uncertain permission outcomes (process poison or cancel-during-write). No ACP wire response was confirmed. Carries an `authorization` envelope with `reason = "uncertain"`. Desktop uses this to retire the card without a JSON-RPC response. | + +Permission `acp_read` frames (carrying `session/request_permission` calls) always +include an `authorization` envelope. The corresponding `acp_write` (the harness +response) also includes an `authorization` envelope correlated by the same nonce — +this pairs the challenge and answer in the observer log. + +Synchronous policy outcomes (`reject`, `allow`, preflight denial) also produce +`acp_write` frames with `authorization` envelopes. Their `reason` values are: + +| Policy path | `reason` | +|-------------|----------| +| `reject` policy, preflight denial, ask-unavailable downgrade | `"rejected"` | +| `allow` policy (auto-approval succeeded) | `"allowed"` | +| `allow` policy (fail-closed, no unique allow_once option) | `"allow_failed_closed"` | + +**One-write / one-observe contract.** Each pending permission entry produces at most +one ACP wire write and at most one authorized `acp_write` observer event. The write +and the observer event are always emitted together; if the write fails the observer +event is suppressed. The sole exception is the `uncertain` terminal (see below) in +which neither is emitted. + +### Authorization Envelope + +When an `acp_read` or `acp_write` frame relates to a `session/request_permission` +call, the `ObserverEvent` carries an `authorization` field: + +```json +{ + "requestNonce": "<single-use opaque string>", + "actionable": true | false, + "reason": "<terminal-reason>" | omitted +} +``` + +- `requestNonce`: a single-use random token generated by the harness for this request. + It is embedded in the `acp_read` emit and MUST be echoed verbatim in the + `permission_decision` control frame sent by the desktop. The harness consumes the + nonce exactly once — a second `permission_decision` carrying the same nonce is + silently ignored. If no matching decision arrives before the per-request timeout, + the harness fails the request closed. +- `actionable`: `true` when the owner can act (policy=`ask`, preflight passed, owner + and observer available). `false` for auto-deny, fail-closed, and terminal outcomes. +- `reason`: present on every `acp_write` authorization envelope. Identifies the + terminal outcome for this request. Defined values: + + | Value | Meaning | + |-------|---------| + | `"applied"` | Owner decision was received and written to the agent pipe. | + | `"timed_out"` | No decision arrived before the 300-second per-request deadline; request failed closed (denial). | + | `"cancelled"` | The turn was cancelled while the request was pending; request failed closed (denial). | + | `"rejected"` | `reject` policy, preflight denial, or ask-unavailable downgrade; request denied synchronously without an actionable card. | + | `"allowed"` | `allow` policy auto-approval succeeded; request granted synchronously. | + | `"allow_failed_closed"` | `allow` policy but no unique `allow_once` option available; request denied synchronously. | + + `"rejected"`, `"allowed"`, and `"allow_failed_closed"` are emitted on `acp_write` frames + for synchronous policy paths (see [Synchronous policy outcomes](#synchronous-policy-outcomes)). + They are NOT emitted for `ask`-policy pending-map entries. + + The `uncertain` outcome does NOT produce an `acp_write` observer event — instead the + harness emits a `permission_terminal` observer event with `authorization.reason = "uncertain"` so + Desktop clients can retire the card without an ACP wire response. The process is + irrecoverably poisoned and will be respawned by the pool. Desktop clients MUST NOT + expect an `acp_write` for every `acp_read` they receive; the corresponding + `turn_error` and `turn_completed` events are the reliable terminal lifecycle signals. + +**Nonce binding.** The nonce is bound to the agent, channel, session, turn, request +ID, and exact option snapshot at generation time. It MUST NOT be reused across +requests, turns, or sessions. The harness rejects a `permission_decision` whose nonce +does not match any live pending entry. ### Control (`frame=control`) -The `content` field decrypts to: +The `content` field decrypts to a JSON object with a required `type` field. +Implementations MUST ignore events with unrecognized `type` values. + +#### `cancel_turn` + +Cancel the in-flight agent turn for the given channel. ```json { @@ -122,8 +215,92 @@ The `content` field decrypts to: } ``` -The only defined control type is `cancel_turn`. Implementations MUST ignore -events with unrecognized `type` values. +#### `switch_model` + +Switch the active model for the agent session in the given channel. + +- **Busy turn:** delivers `ControlSignal::SwitchModel` over the per-turn oneshot, + which triggers the harness to cancel the current turn and requeue with the new model. + If the oneshot is already consumed (a prior cancel/interrupt is in flight), the + switch cannot land and the current turn is left to complete with the old model. +- **Idle session:** validates the model against the cached catalog and, if valid, + invalidates and reapplies the agent's model config immediately. + +```json +{ + "type": "switch_model", + "channelId": "<channel_uuid>", + "modelId": "<model_identifier>" +} +``` + +#### `permission_decision` + +Deliver the owner's decision for a pending `session/request_permission` call. +The harness matches `requestNonce` to a live pending entry and, if found, transitions +the entry from `pending` to `writing` and writes the ACP response. + +```json +{ + "type": "permission_decision", + "channelId": "<channel_uuid>", + "requestNonce": "<nonce from the acp_read authorization envelope>", + "optionId": "<chosen option id from the original request>" +} +``` + +The harness MUST: +1. Verify `requestNonce` matches a live pending entry (else ignore silently). +2. Verify `optionId` is present in the exact option snapshot recorded at nonce + generation time (else ignore silently — prevents replay with an altered option). +3. Transition the entry to `writing` atomically before performing the ACP write. +4. Emit an `acp_write` telemetry frame with a matching `authorization` envelope only + after the write is confirmed. + +**Best-effort delivery.** `permission_decision` frames ride the ordinary observer +control path — they are NOT guaranteed to arrive before the per-request timeout. +If no matching `permission_decision` is received within `min(300s, remaining hard +deadline)`, the harness fails the request closed (deny). The owner SHOULD respond +before this deadline; the desktop MAY surface the deadline to the owner in the +permission card UI. + +### `control_result` Telemetry + +After processing any control frame, the harness emits a `control_result` telemetry +event to confirm receipt. This is an `acp_read`-style telemetry frame (kind = +`control_result`) that carries a `payload` describing the outcome: + +**`cancel_turn`:** +```json +{ "type": "cancel_turn", "status": "sent" | "no_active_turn" } +``` + +**`switch_model`:** +```json +{ "type": "switch_model", "status": "sent" | "turn_ending" | "switched" | "unsupported_model" | "no_active_turn", "modelId": "..." } +``` + +**`permission_decision`:** +```json +{ + "type": "permission_decision", + "status": "sent" | "already_decided" | "no_active_turn" | "channel_full" | "channel_closed" | "no_channel", + "requestNonce": "<nonce>", + "optionId": "<optionId>" +} +``` + +`status: "sent"` means the decision was delivered to the in-flight read loop. +`status: "already_decided"` means the nonce was already applied by a prior delivery +(a retransmit reached the harness after the first copy was accepted); treat it as +success. `status: "channel_full"` is a transient queue-saturation signal — the +owning read loop's queue was momentarily full. The desktop SHOULD keep retransmitting +(the scheduler remains active); the card stays disabled during the automatic retry. +The owning loop's first-wins dedup tolerates duplicate deliveries once the queue drains. +The three remaining failure statuses (`no_active_turn`, `channel_closed`, `no_channel`) +indicate authoritative routing refusals — the harness received the frame but could not +route the decision and retransmitting the same nonce cannot change that; the desktop +should re-enable the card so the owner can retry. ## Ephemerality Contract @@ -132,7 +309,9 @@ events with unrecognized `type` values. - Relays MUST NOT include kind 24200 events in audit logs. - Relays SHOULD fan out kind 24200 events only via in-memory pub/sub, never via a database write path. -- Clients SHOULD subscribe with `since=<now>`; historical replay is not supported. +- Clients SHOULD subscribe with `since=<now - 300s>` to recover frames from the past + five minutes (e.g., after a brief reconnect); historical replay beyond this window + is not supported. - Clients SHOULD buffer received events in a bounded in-memory ring buffer. ## Authorization @@ -152,6 +331,123 @@ Both directions require relay confirmation of the agent-owner relationship via database lookup. `#p` tag matching alone is insufficient. Unauthorized publish or subscribe attempts MUST be rejected with `AUTH required`. +The harness additionally enforces a ±5-minute `created_at` freshness window on +incoming control frames as defense-in-depth against relay-captured replay. + +## Permission Sentinel Cards + +When the permission policy is `ask`, the harness publishes a **sentinel card** into +the channel thread so the owner can act on the permission request without reading the +observer feed. The sentinel lifecycle is: + +### Sentinel event structure + +**PENDING card (kind 9)** — published after the relay acknowledges the event with +`OK accepted=true`. The harness registers the request in the `Publishing` state and +sends the event to the relay; only on relay `OK accepted=true` does the entry +transition to `Pending` and the card become visible to the owner. + +If the relay rejects the publish (`OK accepted=false`), the relay does not respond +within `min(10 s, expiresAt)`, or the relay connection fails, the request is denied +immediately with no card shown (fail closed). + +An authorized owner decision that arrives while the entry is still in `Publishing` +state is buffered and applied as soon as the relay `OK` is received, with no +additional round trip. + +The event content is a compact JSON object that matches the D6 frozen schema +(`requestNonce`, `optionIds`, `labels`, `expiresAt`, `description`, …). `description` +is sourced from `params.title` (buzz-agent v2), `params.subject.toolCall.title` +(v2 fallback), `params.toolCall.title` (buzz-agent v1 / codex-acp permissions-request), +`params.toolCall.rawInput.command` (codex-acp v1.1.7 command requests), or +`params._meta.codex.params.reason` (codex-acp v1.1.7 file-change requests) of the +`session/request_permission` message — the first non-empty value wins. It is +`null` when no adapter-provided description is found. `optionIds` is a +**two-action contract**: exactly one `allow_once` and one `reject_once`, in that +order — the harness selects them via `select_card_actions` and fails closed if +either is absent or ambiguous, so no other option (e.g. `allow_always`) can ever +be forwarded. Desktop identifies the sentinel via `"v":1` + `"state":"pending"` +in the content. Key properties: + +- Signed by the **agent's relay keys** (not the agent's ACP identity). +- `h` tag: channel UUID. +- `e ["e", <turn_event_id>, "", "reply"]` tag: thread-reply to the triggering turn event. +- `p` tag: owner pubkey. Desktop renders actionable buttons only when the current viewer pubkey matches. + +**RESOLVED edit (kind 40003)** — published by the harness on every terminal outcome +(applied, timed_out, cancelled). The edit targets the kind-9 event and carries the +same JSON payload with `"state":"resolved"`, the `outcome` field, `chosenOptionId` +(non-null only for `applied`), and `originalEventId` (the kind-9 event ID). + +### D7-final admission + +The `ask` path includes a **D7-final admission check**: the harness compares the +`pubkey` of the first event in the turn batch against the resolved agent owner pubkey. + +- If `turn_initiator_pubkey == agent_owner_pubkey`: the card is posted and the request + is held pending a decision. +- If they differ (non-owner-initiated turn): the request is silently downgraded to + `reject` — no card is posted, no interactive prompt is shown. This closes the + gap where a peer agent could trigger a permission request the owner never sees. + +Heartbeat turns and turns without a resolved owner always downgrade to reject. + +### D5 two-action contract + +The sentinel forwards **exactly two** options: one `allow_once` and one +`reject_once`, selected by the harness (`select_card_actions`) from the adapter's +option list. An adapter offering a durable `allow_always` option never has it +forwarded — if the two ruled actions are not both present and unambiguous, the +harness fails closed and posts no card. The read loop accepts an owner decision +only when it matches one of those two snapshotted actions, not on mere membership +in the adapter's original option list. Because no durable rule can ever be +offered, there is no durable-rule disclosure. + +### D6 frozen sentinel size limits + +All untrusted string leaves and the total serialized content are bounded in +**UTF-8 bytes**, enforced identically on both the harness producer +(`SENTINEL_STRING_MAX_BYTES` / `SENTINEL_CONTENT_MAX_BYTES` in +`crates/buzz-acp/src/acp.rs`) and the Desktop parser (`MAX_STRING_BYTES` / +`MAX_CONTENT_BYTES` in `permissionRequest.ts`). The producer and parser MUST +agree on both the values AND the unit; a Rust-char-scalar vs JS-UTF-16-code-unit +split would let a producer-valid card be rejected by Desktop and rendered as raw +JSON until timeout. + +| Field | Limit | Over-limit behavior | +|-------|-------|---------------------| +| `requestNonce` | 200 UTF-8 bytes | fail closed (no card) | +| `sessionId` | 200 UTF-8 bytes | fail closed (no card) | +| `turnId` | 200 UTF-8 bytes | fail closed (no card) | +| each `optionId` | 200 UTF-8 bytes | fail closed (no card) | +| `chosenOptionId` | 200 UTF-8 bytes | fail closed (no edit) | +| each label value | 200 UTF-8 bytes | truncated on a char boundary at the producer | +| `description` | 200 UTF-8 bytes | truncated on a char boundary at the producer | +| total serialized content | 4096 UTF-8 bytes | fail closed (no card) | + +`sessionId` is the load-bearing case: it comes straight from the adapter's +unbounded `session/new` response, so an oversized adapter session ID aborts +sentinel construction (synchronous deny, zero card events) rather than publishing +an unrenderable card. Labels and `description` are lossy display strings and are the +only fields truncated rather than rejected; truncation lands on a UTF-8 char boundary +so the result is always valid UTF-8 within the byte limit the Desktop parser accepts. +Label values in the sentinel come directly from the ACP options' `name` fields; +render them verbatim. `description` is sourced from the adapter's operation field — +`params.title` (buzz-agent v2), `params.subject.toolCall.title` (v2 fallback), +`params.toolCall.title` (v1 / codex-acp permissions-request), +`params.toolCall.rawInput.command` (codex-acp v1.1.7 command), or +`params._meta.codex.params.reason` (codex-acp v1.1.7 file-change) — and describes the operation being +authorized. + +### Sentinel authenticity + +Desktop MUST verify: +1. `event.pubkey` (kind-9) matches the agent's known public key. +2. The kind-40003 edit is signed by the same pubkey as the kind-9. + +Cards signed by any other key MUST be treated as untrusted and not rendered as +actionable permission prompts. + ## Relay Behavior On receiving a kind 24200 event, a relay MUST: @@ -170,9 +466,12 @@ freshness window to prevent replay of captured events. Clients subscribe with: ```json -{"kinds": [24200], "#p": ["<own_pubkey>"], "since": <now>} +{"kinds": [24200], "#p": ["<own_pubkey>"], "since": <now - 300>} ``` +The `since` lookback of 300 seconds (5 minutes) allows recovery of recent frames +after brief reconnects without enabling unbounded historical replay. + On receiving an event, a client MUST: 1. Verify the event signature. @@ -184,8 +483,8 @@ Clients SHOULD verify that the `agent` tag matches a known/trusted agent pubkey before decrypting. Clients SHOULD buffer events in a bounded ring buffer (RECOMMENDED maximum: 800 events). -Clients MUST NOT request historical kind 24200 events (no `since` in the past, no -`until`, no `ids` queries). +Clients MUST NOT request historical kind 24200 events beyond the 5-minute lookback +window (no `since` further in the past, no `until`, no `ids` queries). ## Security Considerations @@ -197,19 +496,34 @@ rate. For maximum metadata privacy, implementors MAY wrap events in NIP-59 gift agent's private key allows decryption of any captured ciphertext. **Replay attacks.** A captured, signed event could be replayed without a freshness -check. Relays are RECOMMENDED to enforce a `created_at` freshness window. +check. Relays are RECOMMENDED to enforce a `created_at` freshness window. The harness +enforces this as defense-in-depth on incoming control frames. **Rogue relays.** The ephemerality contract is relay policy, not cryptography. NIP-44 encryption ensures stored events remain opaque to the relay operator absent key compromise. **Best-effort delivery.** Control frames can be dropped during reconnect or queue -overflow. Control commands SHOULD be treated as advisory with idempotent semantics. -Agents MUST NOT rely on guaranteed delivery of control frames. +overflow. `permission_decision` frames follow the same best-effort path; the +mandatory per-request fail-closed timeout (max 300 seconds) ensures the harness never +blocks indefinitely waiting for a decision that never arrives. + +**Permission nonce security.** Request nonces are single-use and generated fresh per +request. A `permission_decision` carrying a nonce that does not match an active +pending entry is silently ignored. The harness verifies that the chosen `optionId` is +present in the exact option snapshot captured at nonce generation — preventing a +replayed or modified decision from selecting an option not offered in the original +request. + +**Cancel during write (poison).** If a cancel arrives while the harness is writing +an ACP permission response mid-flight, the process state is irrecoverably uncertain. +The harness surfaces a dedicated `PermissionPoisoned` error through `cancel_with_cleanup_grace`, +which causes the pool to respawn the agent process rather than return it. All other +pending permission entries for that session are drained with `cancelled` responses. **Operational persistence vectors.** Telemetry may transiently exist in process memory, crash dumps, and application logs. Implementations SHOULD minimize logging -of decrypted payloads and MUST NOT log it at INFO level or above. +of decrypted payloads and MUST NOT log them at INFO level or above. ## Relationship to Other NIPs @@ -295,6 +609,82 @@ of decrypted payloads and MUST NOT log it at INFO level or above. } ``` +--- + +### 3. Permission request (ask policy) — challenge + decision round trip + +**Step 1 — agent emits `session/request_permission`; harness emits `acp_read` telemetry:** + +```json +{ + "seq": 101, + "timestamp": "2026-08-01T10:00:00.000Z", + "kind": "acp_read", + "agentIndex": 0, + "channelId": "52a85618-0f8f-4542-94ec-599e6e1c6f2e", + "sessionId": "sess-abc", + "turnId": "turn-xyz", + "authorization": { + "requestNonce": "a9f3b2c1d4e5...", + "actionable": true + }, + "payload": { + "jsonrpc": "2.0", + "id": "req-17", + "method": "session/request_permission", + "params": { + "sessionId": "sess-abc", + "options": [ + { "optionId": "opt-allow", "kind": "allow_once", "name": "Allow once" }, + { "optionId": "opt-deny", "kind": "reject_once", "name": "Deny" } + ] + } + } +} +``` + +**Step 2 — desktop sends `permission_decision` control frame:** + +```json +{ + "type": "permission_decision", + "channelId": "52a85618-0f8f-4542-94ec-599e6e1c6f2e", + "requestNonce": "a9f3b2c1d4e5...", + "optionId": "opt-allow" +} +``` + +**Step 3 — harness writes ACP response and emits `acp_write` telemetry:** + +```json +{ + "seq": 102, + "timestamp": "2026-08-01T10:00:04.120Z", + "kind": "acp_write", + "agentIndex": 0, + "channelId": "52a85618-0f8f-4542-94ec-599e6e1c6f2e", + "sessionId": "sess-abc", + "turnId": "turn-xyz", + "authorization": { + "requestNonce": "a9f3b2c1d4e5...", + "actionable": false, + "reason": "applied" + }, + "payload": { + "jsonrpc": "2.0", + "id": "req-17", + "result": { "outcome": { "outcome": "selected", "optionId": "opt-allow" } } + } +} +``` + +Note: `actionable` is `false` on the `acp_write` telemetry frame — the decision has +been applied and the card is no longer actionable. `reason: "applied"` is the +standard terminal annotation for a successfully delivered decision. When the request +expires without a decision, the harness emits `reason: "timed_out"`. When the turn +is cancelled while the request is pending, the harness emits `reason: "cancelled"`. +If the cancel arrives mid-write (`uncertain`), no `acp_write` frame is emitted at all. + ## Reference Implementation -[block/sprout PR #421](https://github.com/block/sprout/pull/421) +[block/buzz PR #4938](https://github.com/block/buzz/pull/4938)