diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8460372aba..92a67deed6 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -13,13 +13,39 @@ 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::{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; + /// An MCP server configuration passed to `session/new`. /// /// Corresponds to the `McpServerStdio` variant in the ACP schema. @@ -106,6 +132,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, @@ -132,6 +168,64 @@ 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, + /// 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, +} + /// ACP client that owns an agent subprocess and communicates over its stdio. /// /// One `AcpClient` per agent process. Multiple sessions can be created on the @@ -153,11 +247,73 @@ 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. Exactly one entry can + /// be in `Publishing` state at a time (capacity-guarded). + /// + /// 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`]. @@ -211,6 +367,11 @@ pub struct AcpClient { /// deltas. Both goose and buzz-agent emit this notification; goose gates /// on client capability advertisement, buzz-agent emits unconditionally. 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>, } /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape @@ -541,6 +702,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, @@ -550,6 +728,8 @@ impl AcpClient { steering_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), + #[cfg(test)] + write_attempt_count: None, }) } @@ -559,11 +739,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() @@ -586,6 +841,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. @@ -811,6 +1084,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 — @@ -819,6 +1096,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_stop_reason(&result?) @@ -1013,8 +1294,108 @@ 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()), + }, + 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); @@ -1048,6 +1429,8 @@ impl AcpClient { remaining, ) .await?; + // Cancel completed — drain any remaining entries (safety net). + self.pending_permissions.clear(); self.parse_stop_reason(&result) } @@ -1055,7 +1438,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 { @@ -1067,13 +1473,220 @@ 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()), + }, + 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.options_snapshot.clone(), + e.nonce.clone(), + e.expiry_unix_secs, + ) + }); + // 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), + options_snapshot, + entry_nonce, + expiry_unix_secs, + )) = 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, + &options_snapshot, + expiry_unix_secs, + session_id_owned.as_deref(), + &turn_id, + reason, + chosen_option_id.as_deref(), + ) { + if let Some(event) = build_kind40003_sentinel( + &keys, + channel_id, + &original_event_id, + &content, + ) { + let _ = publisher.publish_event(event).await; + } + } + } + } + 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()), + }, + 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()), + }, + 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, @@ -1172,7 +1785,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. /// @@ -1248,7 +1862,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. @@ -1318,6 +1951,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 @@ -1337,14 +1983,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 @@ -1354,27 +2054,403 @@ 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 { + // Validate the chosen option_id is in the snapshot. + let opt_valid = self.pending_permissions + .get(&id_str) + .map(|e| { + e.options_snapshot.iter().any(|opt| { + opt.get("optionId") + .and_then(|v| v.as_str()) + == Some(decision.option_id.as_str()) + }) + }) + .unwrap_or(false); + + if !opt_valid { + tracing::warn!( + target: "acp::permission", + "permission_decision optionId {:?} not in snapshot 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 decision; apply on ACK. + if let Some(entry) = + self.pending_permissions.get_mut(&id_str) + { + entry.early_decision = Some(decision); + tracing::debug!( + target: "acp::permission", + "permission_decision buffered during Publishing 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.options_snapshot.iter().any(|opt| { + opt.get("optionId") + .and_then(|v| v.as_str()) + == Some(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 @@ -1479,16 +2555,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 }); + } } } }; @@ -1547,7 +2630,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; @@ -1696,7 +2788,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. @@ -1884,90 +2976,457 @@ 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); + } + }; + + // ── Admission preflight ──────────────────────────────────────────────── + let preflight_result = run_admission_preflight( + &id, + &options, + msg, + self.permission_config.policy, + // Check for duplicate live requestId under ask. + if matches!(self.permission_config.policy, PermissionPolicy::Ask) { + let id_str = id.to_string(); + self.pending_permissions.contains_key(&id_str) + } else { + false + }, + if matches!(self.permission_config.policy, PermissionPolicy::Ask) { + self.pending_permissions + .values() + .filter(|e| { + matches!( + e.state, + PermissionEntryState::Pending | PermissionEntryState::Writing + ) + }) + .count() + >= PERMISSION_MAP_CAP + } else { + false + }, + (&self.observer_context, self.observer_agent_index), + ); - let options = msg["params"]["options"] - .as_array() - .ok_or_else(|| AcpError::Protocol("permission request missing options".into()))?; + 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); + } + + // 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, + }, + msg.clone(), + ); + + // 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 — both the pending and + // resolved payloads reuse this value (no recompute drift). + 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(); + + // 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. + 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, + &options, + expiry_unix_secs, + session_id_owned.as_deref(), + &turn_id, + )?; + 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(), + 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, + }, + ); + // 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()), + }, + msg.clone(), + ); + tracing::debug!(target: "acp::permission", "non-actionable permission read id={id}"); + } + + /// Emit an `acp_read` with an authorization envelope. + 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), + }, + msg.clone(), + ); } /// Parse `stopReason` from a `session/prompt` result value. @@ -2060,6 +3519,388 @@ 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() +} + +/// Maximum length of a label string in a sentinel card. +/// +/// Matches the D6 frozen schema: labels come from untrusted agent-supplied ACP +/// options and must be capped before embedding in the Nostr event content. +const SENTINEL_LABEL_MAX: usize = 200; + +/// Build the JSON payload for a kind-9 PENDING sentinel card. +/// +/// Returns `None` only when `serde_json::to_string` fails (unreachable in +/// practice). The `expiry_unix_secs` is `min(registered_at + 300, hard_deadline)`. +fn build_sentinel_pending_payload( + nonce: &str, + options: &[serde_json::Value], + expiry_unix_secs: u64, + session_id: Option<&str>, + turn_id: &str, +) -> Option { + // Extract opaque optionIds and capped labels from the ACP options. + let option_ids: Vec = options + .iter() + .filter_map(|o| o.get("optionId").and_then(|v| v.as_str())) + .map(|s| serde_json::Value::String(s.to_string())) + .collect(); + let labels: serde_json::Value = options + .iter() + .filter_map(|o| { + let id = o.get("optionId")?.as_str()?; + let name = o.get("name")?.as_str().unwrap_or(""); + let capped: String = name.chars().take(SENTINEL_LABEL_MAX).collect(); + Some((id.to_string(), serde_json::Value::String(capped))) + }) + .collect::>() + .into(); + + // Detect if any option has kind = "allow_always" (D5 durable-rule disclosure). + let has_durable_rule = options.iter().any(|o| { + o.get("kind") + .and_then(|k| k.as_str()) + .map(|k| k == "allow_always") + .unwrap_or(false) + }); + let durable_rule_note = if has_durable_rule { + serde_json::Value::String( + "Includes an 'Always allow' option — creates a machine-wide durable rule in Codex." + .to_string(), + ) + } else { + serde_json::Value::Null + }; + + 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, + "hasDurableRule": has_durable_rule, + "durableRuleNote": durable_rule_note, + }); + serde_json::to_string(&payload).ok() +} + +/// 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, + options: &[serde_json::Value], + expiry_unix_secs: u64, + session_id: Option<&str>, + turn_id: &str, + outcome: &str, + chosen_option_id: Option<&str>, +) -> Option { + let option_ids: Vec = options + .iter() + .filter_map(|o| o.get("optionId").and_then(|v| v.as_str())) + .map(|s| serde_json::Value::String(s.to_string())) + .collect(); + let labels: serde_json::Value = options + .iter() + .filter_map(|o| { + let id = o.get("optionId")?.as_str()?; + let name = o.get("name")?.as_str().unwrap_or(""); + let capped: String = name.chars().take(SENTINEL_LABEL_MAX).collect(); + Some((id.to_string(), serde_json::Value::String(capped))) + }) + .collect::>() + .into(); + + let has_durable_rule = options.iter().any(|o| { + o.get("kind") + .and_then(|k| k.as_str()) + .map(|k| k == "allow_always") + .unwrap_or(false) + }); + let durable_rule_note = if has_durable_rule { + serde_json::Value::String( + "Includes an 'Always allow' option — creates a machine-wide durable rule in Codex." + .to_string(), + ) + } else { + serde_json::Value::Null + }; + + 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, + "hasDurableRule": has_durable_rule, + "durableRuleNote": durable_rule_note, + "outcome": outcome, + "chosenOptionId": chosen_option_id, + }); + serde_json::to_string(&payload).ok() +} + +/// 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 { + 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", , "", "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 { + 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() +} + +/// 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 { + let candidates: Vec<&serde_json::Value> = options + .iter() + .filter(|opt| { + opt.get("kind") + .and_then(|k| k.as_str()) + .map(|k| k == "allow_once") + .unwrap_or(false) + }) + .collect(); + + match candidates.len() { + 0 => Err("no allow_once option found".to_string()), + 2.. => Err(format!( + "multiple allow_once options found ({}); ambiguous", + candidates.len() + )), + 1 => { + let opt = candidates[0]; + let option_id = opt + .get("optionId") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| "allow_once option has missing or empty optionId".to_string())?; + Ok(option_id.to_string()) + } + } +} + +/// 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, + is_duplicate_id: bool, + is_map_at_cap: bool, + size_ctx: (&ObserverContext, Option), +) -> Result<(), String> { + 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 + )); + } + + // 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, + }), + 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`. @@ -2269,6 +4110,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() { @@ -2314,63 +4156,100 @@ mod tests { assert_eq!(StopReason::from_str("Refusal"), Some(StopReason::Refusal)); } + fn options(json: &str) -> Vec { + 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::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::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_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 permission_request_with_no_options_is_cancelled() { + let response = + permission_denial_response(&serde_json::json!(1), &[]).expect("cancelled response"); - assert!(allow_once.is_none()); + assert_eq!(outcome(&response), Some("cancelled")); } + /// 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 find_reject_once_fallback_when_no_allow_once() { - let options: Vec = serde_json::from_str( - r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#, - ) - .unwrap(); + fn reject_once_without_option_id_falls_back_to_cancelled() { + let options = options(r#"[{"name": "Reject", "kind": "reject_once"}]"#); - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); - assert!(allow_once.is_none()); + let response = permission_denial_response(&serde_json::json!(1), &options) + .expect("malformed reject_once must not error"); - 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")); + 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] @@ -4625,4 +6504,2784 @@ 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 = 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"), + ] + } + + /// 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::>( + 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::>( + 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::>( + 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::>( + 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::>( + r#"[{"optionId":"opt-aa","kind":"allow_always","name":"Always"}]"#, + ) + .unwrap(); + assert!( + select_allow_once(&opts).is_err(), + "allow_always must never be auto-selected" + ); + } + + // ── 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, + false, + 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}" + ); + } + + // ── 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![], + 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, + }, + ); + 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, + false, + 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, + }), + 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, + false, + 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, + false, + 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![], + 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, + }, + ); + } + 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" + ); + } + + // ── 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::(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 = 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::(8); + client.install_permission_decision_rx(perm_rx); + + // Register two distinct Pending entries via the production path. + let mut expected_ids: Vec = Vec::new(); + let mut expected_nonces: Vec = 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"); + // 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 = 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 = wire_lines + .iter() + .filter_map(|l| serde_json::from_str(l).ok()) + .filter(|v: &serde_json::Value| { + // Permission responses have {"id": , "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 = 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 = 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::(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::(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::(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::(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 = 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 = 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::(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 = 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": , "result": {"outcome": {...}}} + let perm_responses: Vec = 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 = 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 = 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::(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![], + 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, + }, + ); + // 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::(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"); + } + 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"}), + ], + 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, + }, + ); + } + 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::(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::(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::(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::(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::(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 = 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::(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::(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::(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::(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::(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:?}" + ); + } + + // ── 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 3-option request with a fixed nonce, session, turn, and + /// expiry. This string is the cross-boundary fixture Hayt uses to verify the + /// Desktop parser against real harness output (no fence wrapper). + /// + /// The test asserts structural invariants rather than byte equality so it does + /// not break if field ordering changes, but the `println!` output is the + /// canonical fixture string. + #[test] + fn kind9_content_fixture_structural_invariants() { + let nonce = "test-nonce-fixture-abc123"; + let options: Vec = 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 content = + build_sentinel_pending_payload(nonce, &options, expiry_unix_secs, session_id, turn_id) + .expect("build_sentinel_pending_payload must succeed"); + + // Print the canonical fixture string for Hayt to embed as 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 (b31c716e schema). + 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 three option IDs in order. + let option_ids = v["optionIds"] + .as_array() + .expect("optionIds must be an array"); + assert_eq!(option_ids.len(), 3, "optionIds must have 3 entries"); + assert_eq!(option_ids[0], "opt-allow"); + assert_eq!(option_ids[1], "opt-reject"); + assert_eq!(option_ids[2], "opt-always"); + + // labels must be an object with one key per optionId. + let labels = v["labels"].as_object().expect("labels must be an object"); + assert_eq!(labels.len(), 3, "labels must have 3 entries"); + assert_eq!(labels["opt-allow"], "Allow once"); + assert_eq!(labels["opt-reject"], "Reject"); + assert_eq!(labels["opt-always"], "Always allow"); + + // D5: allow_always option → hasDurableRule true, durableRuleNote non-null. + assert_eq!( + v["hasDurableRule"], true, + "hasDurableRule must be true (allow_always present)" + ); + assert!( + v["durableRuleNote"] + .as_str() + .map(|s| !s.is_empty()) + .unwrap_or(false), + "durableRuleNote must be a non-empty string when hasDurableRule is true" + ); + + // 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"}), + ], + 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, + }, + ); + + // 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::(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}" + ); + } } diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 35aaec188d..7091c5dc3f 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,12 +128,25 @@ pub enum PermissionMode { /// Agent default — permission requests per tool call. #[value(alias = "default")] Default, + /// 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, @@ -144,8 +161,8 @@ impl PermissionMode { pub fn as_wire_str(&self) -> &'static str { match self { Self::Default => "default", + Self::Auto => "auto", Self::AcceptEdits => "acceptEdits", - Self::BypassPermissions => "bypassPermissions", Self::DontAsk => "dontAsk", Self::Plan => "plan", } @@ -153,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) } @@ -164,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, + ) -> Result { + // 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 @@ -429,19 +614,32 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_SESSION_TITLE")] pub session_title: Option, - /// 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, /// Inbound author gate: which authors' events the harness forwards. /// Modes: owner-only (default), allowlist, anyone, nobody. @@ -536,8 +734,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, - /// 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). @@ -1060,6 +1260,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, @@ -1098,7 +1301,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, @@ -1131,7 +1334,7 @@ impl Config { format!(" allowed_respond_to=[{}]", modes.join(",")) }; format!( - "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", + "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={}({}) {}{}", self.relay_url, self.keys.public_key().to_hex(), self.agent_command, @@ -1151,7 +1354,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, ) @@ -1469,7 +1673,11 @@ mod tests { memory_enabled: true, model: 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(), @@ -2270,10 +2478,6 @@ channels = "ALL" fn test_permission_mode_wire_strings() { assert_eq!(PermissionMode::Default.as_wire_str(), "default"); 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"); } @@ -2281,7 +2485,6 @@ channels = "ALL" #[test] fn test_permission_mode_is_default() { assert!(PermissionMode::Default.is_default()); - assert!(!PermissionMode::BypassPermissions.is_default()); assert!(!PermissionMode::AcceptEdits.is_default()); assert!(!PermissionMode::DontAsk.is_default()); assert!(!PermissionMode::Plan.is_default()); @@ -2289,20 +2492,21 @@ 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"); } #[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}" ); } @@ -2310,7 +2514,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"), @@ -2319,9 +2524,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] @@ -2332,7 +2540,6 @@ channels = "ALL" let cases = [ ("default", PermissionMode::Default), ("accept-edits", PermissionMode::AcceptEdits), - ("bypass-permissions", PermissionMode::BypassPermissions), ("dont-ask", PermissionMode::DontAsk), ("plan", PermissionMode::Plan), ]; @@ -2347,14 +2554,12 @@ 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), ("acceptEdits", PermissionMode::AcceptEdits), - ("bypassPermissions", PermissionMode::BypassPermissions), ("dontAsk", PermissionMode::DontAsk), ("plan", PermissionMode::Plan), ]; @@ -2367,6 +2572,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, turn: Option) -> u64 { diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index fa348eeb3c..632690fc53 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -453,7 +453,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)); @@ -594,6 +606,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(), }), @@ -894,6 +907,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. @@ -1116,6 +1142,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); + } _ => { tracing::debug!(payload = %payload, "ignoring unknown observer control frame"); } @@ -1235,6 +1264,120 @@ 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. +/// +/// If there is no in-flight task for the channel, or the sender is 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::().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(), + }; + + // Find the in-flight task for this channel and deliver via its mpsc. + let entry = pool + .task_map_mut() + .values_mut() + .find(|m| m.channel_id == Some(channel_id)); + + let status = if let Some(meta) = entry { + if let Some(tx) = &meta.permission_decision_tx { + match tx.try_send(decision) { + Ok(()) => { + tracing::info!( + channel = %channel_id, + nonce = %request_nonce, + option_id = %option_id, + "permission_decision delivered to read loop" + ); + "sent" + } + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + tracing::warn!( + channel = %channel_id, + "permission_decision channel full — dropping (will timeout)" + ); + "channel_full" + } + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { + tracing::warn!( + channel = %channel_id, + "permission_decision channel closed — read loop already exited" + ); + "channel_closed" + } + } + } else { + tracing::warn!( + channel = %channel_id, + "permission_decision_tx not installed for in-flight task" + ); + "no_channel" + } + } 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. @@ -1835,7 +1978,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() @@ -1843,6 +1986,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 { @@ -3301,6 +3445,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::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::(); @@ -3329,6 +3484,7 @@ fn dispatch_pending( recoverable_batch, control_tx: Some(control_tx), steer_tx, + permission_decision_tx, successful_steer_deliveries: HashSet::new(), }, ); @@ -3736,6 +3892,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), @@ -3965,6 +4125,7 @@ fn dispatch_heartbeat( recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -4743,6 +4904,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(), }, ); @@ -5249,6 +5411,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 }), } } @@ -6117,6 +6280,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", @@ -6145,6 +6309,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" }), } } @@ -6240,7 +6405,11 @@ mod build_mcp_servers_tests { memory_enabled: false, model: 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![], @@ -6462,7 +6631,11 @@ mod error_outcome_emission_tests { memory_enabled: false, model: 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![], @@ -6539,6 +6712,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(), @@ -6611,6 +6785,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(), @@ -6726,6 +6901,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(), @@ -6791,6 +6967,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -6868,6 +7045,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -6961,6 +7139,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -7053,6 +7232,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -7159,6 +7339,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -7236,6 +7417,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -7331,6 +7513,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -7448,6 +7631,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -7588,6 +7772,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -7777,6 +7962,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -7863,6 +8049,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_decision_tx: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -7926,6 +8113,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, } } @@ -8159,4 +8347,41 @@ 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, + }); + + 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 7029e5af6d..2f35e3e103 100644 --- a/crates/buzz-acp/src/observer.rs +++ b/crates/buzz-acp/src/observer.rs @@ -30,6 +30,25 @@ pub struct ObserverContext { pub started_at: Option, } +/// 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, +} + /// Handle used by the harness to publish local observer events. #[derive(Clone)] pub struct ObserverHandle { @@ -54,7 +73,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 +93,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, + /// 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, /// Raw or semantic event payload. pub payload: serde_json::Value, } @@ -107,6 +132,31 @@ impl ObserverHandle { agent_index: Option, 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, + agent_index: Option, + 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, + agent_index: Option, + context: &ObserverContext, + authorization: Option, + payload: serde_json::Value, ) { let event = ObserverEvent { seq: self.inner.seq.fetch_add(1, Ordering::Relaxed), @@ -117,6 +167,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 33bd5507fb..9ee1d4f7b9 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -34,7 +34,7 @@ use crate::acp::{ resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod, StopReason, SystemPromptTransport, }; -use crate::config::{compose_session_title, DedupMode, PermissionMode}; +use crate::config::{compose_session_title, DedupMode, PermissionMode, ResolvedPermissionConfig}; use crate::observer; use crate::queue::{ CancelReason, ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, @@ -73,6 +73,13 @@ pub struct TaskMeta { /// tasks only — all prompt tasks install a steer channel regardless /// of the agent's name. pub steer_tx: Option>, + /// 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>, /// 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. @@ -584,8 +591,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, @@ -605,6 +612,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, } impl AgentPool { @@ -1089,14 +1101,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) @@ -1205,11 +1223,7 @@ async fn apply_model_switch( Ok(()) } -/// 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 { @@ -1225,7 +1239,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. @@ -1265,7 +1283,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(_) => { @@ -1475,6 +1493,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 = batch .as_ref() .map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect()) @@ -7456,12 +7512,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 2cbb82411f..b8ff6f99cc 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}; @@ -514,6 +514,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. @@ -534,6 +550,27 @@ enum RelayCommand { SubscribeObserverControls, /// Publish a signed event to the relay (for typing indicators, etc.). PublishEvent { event: Box }, + /// 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, + ack_tx: oneshot::Sender, + /// 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 }, } @@ -568,7 +605,9 @@ pub struct HarnessRelay { bg_handle: Option>, } -/// 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, @@ -585,24 +624,138 @@ 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, 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) { let (cmd_tx, mut cmd_rx) = mpsc::channel::(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) { + let (cmd_tx, mut cmd_rx) = mpsc::channel::(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) { + let (cmd_tx, mut cmd_rx) = mpsc::channel::(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 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::(1); + drop(cmd_rx); // close the channel immediately + Self { cmd_tx } + } +} // end impl RelayEventPublisher impl HarnessRelay { /// Connect to relay and authenticate via NIP-42. @@ -1062,6 +1215,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, tokio::time::Instant)>, /// Channels whose REQ failed during `resubscribe_after_reconnect`. /// /// A single failed channel REQ is parked here instead of aborting the whole @@ -1097,6 +1258,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(), backoff_step: 0, } @@ -1225,6 +1387,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 { + 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 = 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) { if self.observer_in_flight.len() >= GATED_OBSERVER_QUEUE_CAP { self.observer_in_flight.pop_front(); @@ -1304,6 +1511,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!( @@ -1328,6 +1540,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), } } @@ -1531,6 +1748,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!( @@ -2035,6 +2275,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 @@ -2377,6 +2636,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); + } state.acknowledge_observer_frame(&event_id); debug!("OK for event {event_id}: accepted={accepted} message={message}"); } @@ -2918,6 +3188,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. @@ -3048,6 +3321,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. @@ -6246,4 +6522,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> = 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::(); + 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::(); + 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-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 beffc29440..8c243a0c34 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_TITLE": "worker", "GOOSE_MODE": "auto" diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index b63370b95f..a9256a60f4 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -116,6 +116,8 @@ fn agent_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, agent_command_override: None, persona_source_version: None, provider: None, diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 4704582372..cc24d1de44 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -20,9 +20,9 @@ use crate::{ build_managed_agent_summary, current_instance_id, discovery_env_with_baked_floor, find_managed_agent_mut, known_acp_runtime, load_global_agent_config, load_managed_agents, load_personas, managed_agent_avatar_url, missing_command_message, normalize_agent_args, - resolve_command, save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, - AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse, - DEFAULT_ACP_COMMAND, + permission_policy::apply_permission_policy_update, resolve_command, save_managed_agents, + sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo, AgentModelsResponse, + UpdateManagedAgentRequest, UpdateManagedAgentResponse, DEFAULT_ACP_COMMAND, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -846,12 +846,11 @@ pub async fn update_managed_agent( ); } record.respond_to = prospective_mode; - // Preserve the persisted allowlist across mode toggles — only replace - // when the caller explicitly supplied a new list. if input.respond_to_allowlist.is_some() { record.respond_to_allowlist = prospective_allowlist; } + apply_permission_policy_update(record, input.permission_policy)?; record.updated_at = now_iso(); save_managed_agents(&app, &records)?; diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index dd61fc9398..0dc6aa020d 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -408,13 +408,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) { @@ -480,12 +475,16 @@ async fn deploy_to_provider( .map_or_else(|| resolve_provider_binary(provider_id), Ok)?; let config_clone = config.clone(); + // Enforce the deploy-receipt invariant BEFORE invoking the provider: the + // applied policy is the byte-identical value build_deploy_payload wrote, 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 = extract_applied_permission_policy(&agent_json)?; let deploy_result = tokio::task::spawn_blocking(move || provider_deploy(&bin_path, &agent_json, &config_clone)) .await .map_err(|e| format!("spawn_blocking failed: {e}"))?; - // Persist result under lock. let _store_guard = state .managed_agents_store_lock .lock() @@ -498,14 +497,10 @@ async fn deploy_to_provider( match deploy_result { Ok(backend_agent_id) => { - rec.backend_agent_id = Some(backend_agent_id); - rec.last_started_at = Some(now_iso()); - rec.updated_at = now_iso(); - rec.last_error = None; + record_deploy_success(rec, backend_agent_id, applied_policy); } Err(ref e) => { - rec.last_error = Some(e.clone()); - rec.updated_at = now_iso(); + record_deploy_failure(rec, e); save_managed_agents(app, &records)?; return Err(e.clone()); } @@ -868,7 +863,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 { @@ -913,6 +907,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 }; records.push(record); @@ -1363,6 +1359,7 @@ use deploy::build_deploy_payload; use deploy::{deploy_payload_json, DeployProjections}; #[cfg(test)] use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider}; +use deploy::{extract_applied_permission_policy, record_deploy_failure, record_deploy_success}; #[path = "agents_profile.rs"] mod profile; diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 47ee5f92d4..39d14e7ca6 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -46,6 +46,10 @@ pub(crate) fn resolve_deploy_model_provider( /// `descriptor.env` is the authoritative six-layer environment. Policy values /// are deliberately separate because providers apply them below that layered /// environment, preserving the local spawn's power-user override semantics. +/// +/// `effective_permission_policy` is the already-resolved per-agent → global → +/// built-in policy. Pass it from the caller so that this function does not need +/// the global config; tests can pass `None` to get the built-in default. pub(super) fn build_launch_block( record: &ManagedAgentRecord, descriptor: &crate::managed_agents::readiness::EffectiveHarnessDescriptor, @@ -53,6 +57,7 @@ pub(super) fn build_launch_block( effective_prompt: Option<&str>, effective_model: Option<&str>, owner_pubkey: &str, + effective_permission_policy: Option, ) -> serde_json::Value { use crate::managed_agents::{ known_acp_runtime, resolve_session_title, DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, @@ -101,6 +106,20 @@ pub(super) fn build_launch_block( 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, 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(), + ); + } + serde_json::json!({ "command": descriptor.command, "args": descriptor.args, @@ -149,6 +168,10 @@ pub(super) fn build_deploy_payload( 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, &global, + ); let launch = build_launch_block( record, &descriptor, @@ -156,6 +179,7 @@ pub(super) fn build_deploy_payload( effective.system_prompt.value.as_deref(), effective.model.value.as_deref(), &owner_pubkey, + Some(effective_policy), ); let effective_parallelism = @@ -216,6 +240,50 @@ pub(super) fn deploy_payload_json( }) } +use crate::managed_agents::permission_policy::PermissionPolicy; + +/// Extract the applied permission policy from a deploy payload — the byte-identical +/// value `build_deploy_payload` wrote into `launch.policy_env`, not a recompute. +/// +/// The key is unconditionally present in every payload `build_deploy_payload` +/// produces (it falls back to `desktop_default`), so a missing or unparseable +/// value is a broken invariant on the JSON boundary, not a legacy shape. Callers +/// must fail the deploy rather than stamping a silent `None` that would suppress +/// the drift row and defeat the field's purpose. +pub(super) fn extract_applied_permission_policy( + agent_json: &serde_json::Value, +) -> Result { + 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:?}")) +} + +/// Record the outcome of a successful provider deploy. Stamps the confirmed +/// receipt: `applied_permission_policy` is the exact value that was sent, so a +/// later global-default flip is detectable as drift against the live worker. +pub(super) fn record_deploy_success( + record: &mut ManagedAgentRecord, + backend_agent_id: String, + applied_policy: PermissionPolicy, +) { + record.backend_agent_id = Some(backend_agent_id); + record.last_started_at = Some(crate::util::now_iso()); + record.updated_at = crate::util::now_iso(); + record.last_error = None; + record.applied_permission_policy = Some(applied_policy); +} + +/// Record a failed provider deploy. The previous `applied_permission_policy` is +/// intentionally retained: it is the last confirmed deployment receipt, the old +/// worker may still be running that policy, and `last_error` records the failed +/// new attempt. Clearing it would destroy known truth. +pub(super) fn record_deploy_failure(record: &mut ManagedAgentRecord, error: &str) { + record.last_error = Some(error.to_string()); + record.updated_at = crate::util::now_iso(); +} + #[cfg(test)] mod tests { use super::*; @@ -267,6 +335,7 @@ mod tests { Some("prompt"), Some("model"), "owner-hex", + None, ); assert_eq!(launch["command"], "goose"); @@ -305,7 +374,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"], @@ -327,7 +396,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", @@ -357,7 +426,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( @@ -402,7 +471,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( @@ -448,7 +517,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( @@ -475,4 +544,175 @@ mod tests { "legacy top-level parallelism must match launch.policy_env — both must be {cap}" ); } + + /// `build_launch_block` with an explicit `allow` policy injects `allow`. + #[test] + fn launch_block_explicit_allow_policy_injected() { + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::new(), + }; + let launch = build_launch_block( + &record, + &descriptor, + &[], + None, + None, + "owner-hex", + Some(crate::managed_agents::permission_policy::PermissionPolicy::Allow), + ); + assert_eq!( + launch["policy_env"]["BUZZ_ACP_PERMISSION_POLICY"], "allow", + "explicit allow policy must be injected into policy_env" + ); + } + + /// `build_launch_block` with `None` (test callers / no global) falls back to + /// the built-in desktop default (`ask`). + #[test] + fn launch_block_none_policy_falls_back_to_built_in_ask() { + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::new(), + }; + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex", None); + assert_eq!( + launch["policy_env"]["BUZZ_ACP_PERMISSION_POLICY"], "ask", + "None effective_permission_policy must fall back to built-in ask" + ); + } + + /// Production deploy path: global `allow` override is respected when the + /// record has no per-agent policy, matching the local-spawn resolver. + #[test] + fn launch_block_global_allow_policy_used_when_record_has_none() { + let mut record = record(); + record.permission_policy = None; + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::new(), + }; + let global = crate::managed_agents::global_config::GlobalAgentConfig { + permission_policy: Some( + crate::managed_agents::permission_policy::PermissionPolicy::Allow, + ), + ..Default::default() + }; + let (effective_policy, _) = + crate::managed_agents::permission_policy::resolve_effective_permission_policy( + &record, &global, + ); + let launch = build_launch_block( + &record, + &descriptor, + &[], + None, + None, + "owner-hex", + Some(effective_policy), + ); + assert_eq!( + launch["policy_env"]["BUZZ_ACP_PERMISSION_POLICY"], "allow", + "global allow policy must be injected when record has no per-agent policy" + ); + } + + // ── Deploy-receipt invariant + applied-policy state transitions ────────── + + /// A payload built by `build_launch_block` always carries the policy key, and + /// `extract_applied_permission_policy` reads back the byte-identical value — + /// not a recompute. This is the receipt the deploy path stamps. + #[test] + fn extract_applied_policy_reads_the_exact_sent_value() { + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::new(), + }; + let launch = build_launch_block( + &record, + &descriptor, + &[], + None, + None, + "owner-hex", + Some(PermissionPolicy::Allow), + ); + let payload = serde_json::json!({ "launch": launch }); + assert_eq!( + extract_applied_permission_policy(&payload), + Ok(PermissionPolicy::Allow), + "extract must return the exact policy the payload carries" + ); + } + + /// A payload missing the policy key is a broken invariant: extraction errors + /// so the deploy fails before the provider is invoked, never stamping None. + #[test] + fn extract_applied_policy_missing_key_errors() { + let payload = serde_json::json!({ "launch": { "policy_env": {} } }); + assert!( + extract_applied_permission_policy(&payload).is_err(), + "missing policy key must error, not silently stamp None" + ); + } + + /// A payload whose policy value is not a recognized enum variant errors — + /// the deploy fails before the provider is invoked. + #[test] + fn extract_applied_policy_unparseable_value_errors() { + let payload = serde_json::json!({ + "launch": { "policy_env": { "BUZZ_ACP_PERMISSION_POLICY": "bogus" } } + }); + assert!( + extract_applied_permission_policy(&payload).is_err(), + "unrecognized policy value must error, not silently stamp None" + ); + } + + /// Successful deploy stamps the exact sent value as the confirmed receipt and + /// clears any prior error. + #[test] + fn record_deploy_success_stamps_exact_sent_value() { + let mut rec = record(); + rec.last_error = Some("stale error".into()); + record_deploy_success(&mut rec, "backend-1".into(), PermissionPolicy::Allow); + assert_eq!(rec.backend_agent_id.as_deref(), Some("backend-1")); + assert_eq!(rec.applied_permission_policy, Some(PermissionPolicy::Allow)); + assert_eq!(rec.last_error, None); + } + + /// Successful redeploy overwrites the applied receipt with the new sent value. + #[test] + fn record_deploy_success_redeploy_updates_applied_value() { + let mut rec = record(); + record_deploy_success(&mut rec, "backend-1".into(), PermissionPolicy::Allow); + record_deploy_success(&mut rec, "backend-1".into(), PermissionPolicy::Reject); + assert_eq!( + rec.applied_permission_policy, + Some(PermissionPolicy::Reject), + "redeploy must update the applied receipt to the new sent value" + ); + } + + /// Failed redeploy retains the last confirmed applied policy — the old worker + /// may still be running it — while recording the new error. + #[test] + fn record_deploy_failure_retains_last_confirmed_applied_value() { + let mut rec = record(); + record_deploy_success(&mut rec, "backend-1".into(), PermissionPolicy::Allow); + record_deploy_failure(&mut rec, "provider unreachable"); + assert_eq!( + rec.applied_permission_policy, + Some(PermissionPolicy::Allow), + "failed redeploy must retain the last confirmed applied policy" + ); + assert_eq!(rec.last_error.as_deref(), Some("provider unreachable")); + } } diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 54a03e2bab..368459edba 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -58,6 +58,8 @@ fn bare_agent_record( source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], @@ -482,6 +484,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/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 8ff7cfbd9b..cc9ee04f1f 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -66,6 +66,8 @@ fn make_agent( source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], 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 1005a83432..c17e9012eb 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -215,6 +215,8 @@ fn local_agent() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, } } 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 b769d74d7b..f49dca18ec 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -64,6 +64,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, } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d7f0323304..b3b290a840 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -654,6 +654,8 @@ pub async fn confirm_agent_snapshot_import( relay_mesh: 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 c453b09a9d..27538b02f8 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -73,6 +73,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, } } 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 c60215ae4d..3e8b170d42 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 @@ -58,6 +58,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, } } diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d..9ab2e71905 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -609,6 +609,8 @@ pub async fn confirm_team_snapshot_import( definition_respond_to_allowlist: definition.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, relay_mesh: None, + permission_policy: None, + applied_permission_policy: 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 c9a6d8812a..f022cf9f42 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -229,6 +229,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, runtime: None, name_pool: vec![], }; diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 4a7b80079d..b41a131492 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -216,6 +216,8 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: 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 8508c27073..adb0e02da5 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -416,6 +416,8 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, agent_command_override: None, persona_source_version: None, provider: 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 b4492418e5..960601bad3 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -72,6 +72,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, } } 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 62caffeb2e..0f82b6a216 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 @@ -115,6 +115,8 @@ fn test_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, agent_command_override: None, persona_source_version: None, provider: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 6fe6a77521..ce7b904e6d 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -222,8 +222,7 @@ fn effective_agent_command_explicit_override_wins() { ); } -/// Minimal record for `record_agent_command` tests. Only the resolution -/// inputs (runtime / persona_id / agent_command_override) vary. +/// Minimal record for `record_agent_command` tests. fn record_with( runtime: Option<&str>, persona_id: Option<&str>, @@ -283,13 +282,14 @@ fn record_with( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, } } #[test] fn record_agent_command_own_runtime_wins_over_persona() { - // A record with its own materialized runtime never consults the - // persona list — the unified-model resolution. + // A record with its own materialized runtime wins over the persona list. let personas = vec![persona_with_runtime("p1", Some("goose"))]; let record = record_with(Some("claude"), Some("p1"), None); assert_eq!(record_agent_command(&record, &personas), "claude-agent-acp"); 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 c8e437809c..b26081ba5e 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -88,6 +88,8 @@ fn record( source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], 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 162f447981..50e3082394 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, + /// 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, } /// 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 553596e226..cbf1a295ab 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"); @@ -348,6 +349,8 @@ fn bare_record() -> ManagedAgentRecord { source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], @@ -592,6 +595,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 = diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index fe90ce430f..a0f9c6f9f5 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/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index cbef171f6f..7ab7d99c05 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -502,6 +502,8 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index e1691575b1..78819720c4 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -117,6 +117,8 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, } } 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 0000000000..ab1a29b07a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/permission_policy.rs @@ -0,0 +1,206 @@ +//! 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::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 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. `global.permission_policy` — fleet-wide default. +/// 3. [`PermissionPolicy::desktop_default`] — built-in. +pub fn resolve_effective_permission_policy( + record: &ManagedAgentRecord, + global: &super::global_config::GlobalAgentConfig, +) -> (PermissionPolicy, PermissionPolicySource) { + if let Some(policy) = record.permission_policy { + return (policy, PermissionPolicySource::Agent); + } + 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>, +) -> 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(()) +} + +#[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") + } + + #[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); + } + + /// 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)); + } +} 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 0580b12ce2..7260a35761 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -58,6 +58,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, } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c072448ff1..2b330739c8 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1465,9 +1465,8 @@ mod tests { #[test] fn resolve_effective_agent_env_user_env_wins_over_structured_fields() { - // A record whose env_vars explicitly set provider/model must win over - // any baked defaults. In OSS test builds the baked map is empty, so - // this test validates the user-env layer is present in the output. + // env_vars must win over baked defaults; in OSS builds the baked map is empty, + // so this verifies the user-env layer is present. let mut env_vars = BTreeMap::new(); env_vars.insert("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string()); env_vars.insert( @@ -1475,7 +1474,6 @@ mod tests { "claude-opus-4-5".to_string(), ); - // Minimal record: only the fields resolve_effective_agent_env reads. let record = crate::managed_agents::types::ManagedAgentRecord { pubkey: "test-pubkey".to_string(), name: "test-agent".to_string(), @@ -1530,6 +1528,8 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, }; let runtime = known_acp_runtime_exact("buzz-agent"); 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 8698d3a51d..3a972ed849 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -70,6 +70,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 ec804869c4..a4bf742428 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::build_buzz_agent_provider_defaults; 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, }; @@ -296,6 +297,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, global_config); + Ok(ManagedAgentSummary { pubkey: record.pubkey.clone(), name: record.name.clone(), @@ -338,6 +342,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, }) } @@ -761,6 +768,14 @@ pub fn spawn_agent_child( command.env_remove(key); } + // Inject BUZZ_ACP_PERMISSION_POLICY — resolved here so the running process + // and the UI-visible setting are always in sync. + let (effective_permission_policy, _) = resolve_effective_permission_policy(record, &global); + command.env( + "BUZZ_ACP_PERMISSION_POLICY", + effective_permission_policy.as_str(), + ); + command.env("BUZZ_ACP_RELAY_OBSERVER", "true"); // ── Git credential helper for Buzz relay ────────────────────────── @@ -844,6 +859,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, }, ); 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 9836d983ed..e5eeed2552 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -89,5 +89,7 @@ pub(super) fn fixture( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index ba2129c984..238b263f6f 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, } /// The effective spawn configuration of one managed-agent process. @@ -123,6 +125,10 @@ pub(crate) struct SpawnConfigSnapshot { pub idle_timeout_seconds: Option, pub max_turn_duration_seconds: Option, 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, } impl SpawnConfigSnapshot { @@ -136,6 +142,7 @@ impl SpawnConfigSnapshot { system_prompt, model, provider, + permission_policy, } = inputs; Self { acp_command: record.acp_command.clone(), @@ -174,6 +181,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(), } } @@ -262,6 +270,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, global, + ) + .0, }) } 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 a7a8cab93e..ca9999f17e 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(), } } @@ -70,6 +71,9 @@ fn mutations() -> Vec { s.max_turn_duration_seconds = None }), ("parallelism", |s| s.parallelism = 8), + ("permission_policy", |s| { + s.permission_policy = "allow".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 1ceeee372f..0c8d7b2450 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -70,6 +70,8 @@ fn record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 96082acc76..6ddd0b1d3d 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -309,6 +309,8 @@ mod tests { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 1ffa60eda9..67455c5981 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -213,6 +213,8 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e5be105fed..dd410b1b31 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 { @@ -93,10 +92,8 @@ pub struct AgentDefinition { } 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(), @@ -153,6 +150,8 @@ impl AgentDefinition { definition_respond_to_allowlist: self.respond_to_allowlist, definition_parallelism: self.parallelism, relay_mesh: None, + permission_policy: None, + applied_permission_policy: None, } } } @@ -352,6 +351,10 @@ pub struct ManagedAgentRecord { /// Preserved across mode toggles so users don't lose state. #[serde(default)] pub respond_to_allowlist: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub applied_permission_policy: Option, /// 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")] @@ -534,16 +537,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")] @@ -566,6 +564,10 @@ pub struct ManagedAgentSummary { pub log_path: String, pub respond_to: RespondTo, pub respond_to_allowlist: Vec, + 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, } #[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 e28b0bd461..ae90375297 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -253,6 +253,12 @@ pub struct UpdateManagedAgentRequest { /// normalized server-side). #[serde(default)] pub respond_to_allowlist: Option>, + /// 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>, } #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 1db7b9b524..36f67b13cc 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -744,6 +744,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, } } @@ -784,3 +788,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/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index b578326eba..2f2e3faf90 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -171,6 +171,27 @@ with a TypeScript lookup table or an id comparison in a component. `getAgentAccessOwnerOnly()` is true, every managed agent's access control is locked to owner-only, including provider-backed agents. A provider backend does not prove remote execution and must never create a policy carve-out. +12. **A remote deploy's permission policy has two truths: desired and applied.** + The *desired* policy is recomputed on every summary from the mutable agent + record + 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. ## The tests that enforce this @@ -198,6 +219,16 @@ with a TypeScript lookup table or an id comparison in a component. - 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 resolver-level drift — a + post-deploy global flip diverges desired from the persisted applied receipt. +- `ui/AgentPermissionPolicyField.render.test.mjs` — 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. ## Keep this file true diff --git a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs index 343ce24133..de04a634a2 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/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 11f68e8a56..67b5513bfd 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -68,6 +68,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 69e8b3a5b4..71b7b25446 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 { @@ -294,6 +294,36 @@ export function AgentDefaultsEditor({ value={selectedRuntime?.id ?? ""} /> + {/* Fleet-wide permission policy default */} +
+ + +
{flatLayout ? ( {configFields ? ( diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index f7ee098833..ef206f66a9 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -13,11 +13,10 @@ 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 type { AgentPermissionPolicyFieldHandle } 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"; @@ -35,6 +34,8 @@ import { getDefaultLlmModelLabel, getDefaultPersonaRuntime, getPersonaProviderOptions, + getProviderApiKeyEnvVar, + getProviderApiKeyLabel, isMissingRequiredDropdownField, NO_RUNTIME_DROPDOWN_VALUE, PERSONA_FIELD_CONTROL_CLASS, @@ -78,10 +79,6 @@ import { getBakedModelInheritLabel, getBakedProviderInheritLabel, } from "./bakedEnvHelpers"; -import { - getProviderApiKeyEnvVar, - getProviderApiKeyLabel, -} from "./agentConfigOptions"; import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; @@ -154,12 +151,9 @@ export function AgentInstanceEditDialog({ [agent.personaId, personasQuery.data], ); const inheritedEnvVars = linkedPersona?.envVars ?? {}; - const [respondTo, setRespondTo] = React.useState( - agent.respondTo, - ); - const [respondToAllowlist, setRespondToAllowlist] = React.useState( - agent.respondToAllowlist, - ); + const rto = useRespondToField(agent); + const permissionPolicyRef = + React.useRef(null); const [showAdvancedFields, setShowAdvancedFields] = React.useState(false); const [avatarUrl, setAvatarUrl] = React.useState(agent.avatarUrl ?? ""); const [isAvatarUploadPending, setIsAvatarUploadPending] = @@ -167,11 +161,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. @@ -194,8 +186,8 @@ export function AgentInstanceEditDialog({ setIsCustomProviderEditing(false); setEnvVars(agent.envVars); setAutoRestartOnConfigChange(agent.autoRestartOnConfigChange); - setRespondTo(agent.respondTo); - setRespondToAllowlist(agent.respondToAllowlist); + rto.reset(); + permissionPolicyRef.current?.reset(); setAvatarUrl(agent.avatarUrl ?? ""); setShowAdvancedFields(false); setIsAvatarUploadPending(false); @@ -605,8 +597,8 @@ export function AgentInstanceEditDialog({ parallelism, agentAcpCommand: agent.acpCommand, acpCommand, - respondTo, - respondToAllowlistLength: respondToAllowlist.length, + respondTo: rto.respondTo, + respondToAllowlistLength: rto.respondToAllowlist.length, selectedRuntimeId, inheritHarness, agentCommand, @@ -713,7 +705,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 @@ -721,10 +714,12 @@ 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, + permissionPolicy: permissionPolicyRef.current?.getUpdate(), }; const result = await updateMutation.mutateAsync(input); @@ -938,11 +933,16 @@ export function AgentInstanceEditDialog({ + 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 0000000000..acda28fefc --- /dev/null +++ b/desktop/src/features/agents/ui/AgentPermissionPolicyField.render.test.mjs @@ -0,0 +1,116 @@ +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, disabled: false }), + ); +} + +// --------------------------------------------------------------------------- +// 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: editable select, never a drift row even if applied differs +// --------------------------------------------------------------------------- + +test("test_local_agent_renders_editable_select_and_no_drift_row", () => { + const html = render({ + backend: { type: "local" }, + backendAgentId: null, + permissionPolicy: "reject", + permissionPolicySource: "global_default", + appliedPermissionPolicy: "allow", + }); + + assert.ok( + html.includes(" { + 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 0000000000..57c504b57a --- /dev/null +++ b/desktop/src/features/agents/ui/AgentPermissionPolicyField.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import type { ManagedAgent, PermissionPolicy } from "@/shared/api/types"; + +const SOURCE_LABEL: Record = { + agent: "agent override", + global_default: "global default", + built_in: "built-in", +}; + +function initialValue( + agent: Pick, +): PermissionPolicy | null { + return agent.permissionPolicySource === "agent" + ? agent.permissionPolicy + : null; +} + +export type AgentPermissionPolicyFieldHandle = { + reset(): void; + getUpdate(): PermissionPolicy | null | undefined; +}; + +type Props = { + agent: Pick< + ManagedAgent, + | "backend" + | "backendAgentId" + | "permissionPolicy" + | "permissionPolicySource" + | "appliedPermissionPolicy" + >; + disabled: boolean; +}; + +/** Self-managing permission policy selector. Expose reset/getUpdate via ref. */ +export const AgentPermissionPolicyField = React.forwardRef< + AgentPermissionPolicyFieldHandle, + Props +>(function AgentPermissionPolicyField({ agent, disabled }, ref) { + const [value, setValue] = React.useState(() => + initialValue(agent), + ); + + React.useImperativeHandle(ref, () => ({ + reset: () => setValue(initialValue(agent)), + getUpdate: () => (value !== initialValue(agent) ? value : undefined), + })); + + 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 ( +
+
+ + + ({agent.permissionPolicy} · from {sourceLabel}) + +
+ {isRemoteDeployed ? ( + <> +

+ Read-only while deployed. To change, shut down and redeploy the + agent. +

+ {hasDrift && ( +

+ Applied policy:{" "} + + {agent.appliedPermissionPolicy} + {" "} + · Desired:{" "} + {agent.permissionPolicy} — + redeploy required to apply. +

+ )} + + ) : ( + + )} +
+ ); +}); diff --git a/desktop/src/features/agents/ui/OwnerOnlyAccessField.tsx b/desktop/src/features/agents/ui/OwnerOnlyAccessField.tsx index 4ff7f351a0..bbe93eabcb 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, +) { + const [respondTo, setRespondTo] = React.useState( + agent.respondTo, + ); + const [respondToAllowlist, setRespondToAllowlist] = React.useState( + 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/activityRenderClasses/LifecycleActivity.render.test.mjs b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.render.test.mjs new file mode 100644 index 0000000000..eebde10d82 --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.render.test.mjs @@ -0,0 +1,263 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { LifecycleActivity } from "./LifecycleActivity.tsx"; + +// --------------------------------------------------------------------------- +// 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, + }; +} + +// --------------------------------------------------------------------------- +// allow_once — renders a green actionable Allow button +// --------------------------------------------------------------------------- + +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 — non-actionable badge, no clickable button +// --------------------------------------------------------------------------- + +test("test_allow_always_renders_non_actionable_persistent_grant_badge", () => { + const html = renderToStaticMarkup( + React.createElement(LifecycleActivity, { + ...BASE_PROPS, + item: pendingPermissionItem([ + { optionId: "opt-always", kind: "allow_always", label: "Always allow" }, + ]), + }), + ); + + // Must show the non-actionable badge. + assert.ok( + html.includes("permission-decision-persistent-grant"), + "allow_always option should render the persistent-grant badge", + ); + assert.ok( + html.includes("Permanent grant"), + "persistent-grant badge should contain differentiating copy", + ); + + // Must NOT render a clickable button for this optionId. + assert.ok( + !html.includes("permission-decision-opt-always"), + "allow_always option must not render an actionable button", + ); + // No + ); + })} + {/* allow_always: non-actionable badge — the thread card is the correct + surface for persistent grants (D5 disclosure). The observer feed + shows it as an informational note only. */} + {hasPersistentGrant ? ( + + Permanent grant — use request card + + ) : null} + + ); +} + export function LifecycleActivity(props: ActivityRenderClassItemProps) { if (props.item.type === "tool") { return ; @@ -55,6 +187,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 (
· {requestLines} ) : null}
- {/* Row 2: options (muted sub-line) */} - {optionsLine ? ( + {/* Row 2: authorization reason (from envelope), if present */} + {authorizationReason ? ( +
{authorizationReason}
+ ) : null} + {/* Row 3: options sub-line (legacy fallback) */} + {optionsLine && !authorizationReason ? (
{optionsLine}
) : null} - {/* Row 3: decision — only when outcome is resolved */} + {/* Row 4: Allow/Deny buttons (actionable card awaiting decision) */} + {actionable && requestNonce && !outcome ? ( + + ) : null} + {/* Row 5: decision — only when outcome is resolved */} {outcome && tone ? ( <>
diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index b4a139eb0e..31dcaf71d7 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -776,7 +776,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/); }); @@ -788,7 +788,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/); }); @@ -834,7 +834,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/); }); @@ -862,8 +862,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 ──────────────────── @@ -2077,3 +2077,725 @@ 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, 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 }, + }; +} + +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: "channel_closed", + 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", + ); +}); + +// ─── 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", + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index e371bf5fc3..90c2eb2e86 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, + retireAllLivePermissionCards, + handlePermissionTerminal, + handlePermissionWrite, + handlePermissionDecisionResult, +} from "./agentSessionTranscriptPermissions"; export { describeRawEvent } from "./agentSessionTranscriptHelpers"; @@ -47,6 +54,14 @@ export type TranscriptState = { string, { itemId: string; optionNames: Map } >; + /** + * 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; 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 } >; + pendingPermissionsByNonce: Map; 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) { - 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(); - 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 { - 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,32 @@ export function processTranscriptEvent( ctx, event.kind, ); + // Backstop: retire any still-live permission cards for this channel so + // missing telemetry and archive replay never reconstruct live controls + // after a terminal turn/process state. + retireAllLivePermissionCards(d, ch); + } else if (event.kind === "turn_completed") { + // Backstop: retire any still-live permission cards for this channel. + // 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. + retireAllLivePermissionCards(d, ch); + } 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 +761,40 @@ 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, + 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": , "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 promptText = extractPromptText(payload); if (promptText) { @@ -1138,6 +1095,8 @@ export function processTranscriptEvent( ); } } + } else if (event.kind === "control_result") { + handlePermissionDecisionResult(d, asRecord(event.payload)); } if (!d.changed && d.latestSessionId === state.latestSessionId) { @@ -1151,6 +1110,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 0000000000..4909c90a8f --- /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 0000000000..028f507049 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPermissions.ts @@ -0,0 +1,448 @@ +/** + * 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; + pendingPermissions: Map< + string, + { itemId: string; optionNames: Map } + >; + pendingPermissionsByNonce: Map; + 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) { + 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(); + 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, + optionKinds?: Map, +): 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( + (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( + (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); + } + } +} + +/** + * 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, + 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. + * A non-"sent" status means the click did not reach the harness — marks the + * card with an incremented `deliveryFailed` counter so buttons re-enable for + * retry. + */ +export function handlePermissionDecisionResult( + d: PermissionDraftSlice, + payload: Record, +) { + const frameType = asString(payload.type); + if (frameType !== "permission_decision") return; + const deliveryStatus = asString(payload.status); + if (deliveryStatus === "sent") return; + // Delivery failed — 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 578f98076c..39168e0cd4 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -10,6 +10,17 @@ 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; + }; }; export type ConnectionState = @@ -112,6 +123,37 @@ 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; + /** + * 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 `control_result` + * with a non-`sent` delivery status. 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/useGlobalAgentConfig.ts b/desktop/src/features/agents/useGlobalAgentConfig.ts index 4b90beb43d..294427742e 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 ee4cc628f2..31c9956991 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -773,3 +773,207 @@ 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" }, + hasDurableRule: false, + durableRuleNote: null, +}); + +const RESOLVED_SENTINEL = JSON.stringify({ + v: 1, + state: "resolved", + requestNonce: "sentinel-gate-test-nonce", + originalEventId: + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + sessionId: null, + turnId: null, + expiresAt: 9_999_999_999, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow", "opt-deny": "Deny" }, + hasDurableRule: false, + durableRuleNote: null, + 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, + ); + 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 ab35ecfcc4..0616a6eac2 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -43,6 +43,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,7 @@ export function formatTimelineMessages( : undefined, time: formatTime(event.created_at), body: edit ? edit.content : event.content, + editSignerPubkey: edit?.signerPubkey, 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 ec656f2236..1b42170131 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -24,6 +24,13 @@ 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; 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 51d9832c12..f43a3550c6 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -29,8 +29,11 @@ import { KIND_STREAM_MESSAGE_DIFF, } from "@/shared/constants/kinds"; import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey"; +import { getPermissionRequestAgentPubkey } from "@/features/messages/ui/permissionRequestAuthPubkey"; +import { PermissionRequestCardBlock } from "@/features/messages/ui/PermissionRequestCardBlock"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { isPermissionRequestSentinel } from "@/shared/lib/permissionRequest"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; @@ -385,6 +388,14 @@ export const MessageRow = React.memo( ); } + // Suppress prose for permission-request sentinels. The harness + // encodes the sentinel as bare JSON in the event content — the + // PermissionRequestCardBlock below renders the card; there is no + // separate prose to preserve. + if (message.isAgent && isPermissionRequestSentinel(message.body)) { + return null; + } + const reviewRootEventId = videoReviewCommentRootId; const reviewTimecode = reviewRootEventId ? parseVideoReviewTimecode(message.body) @@ -647,6 +658,20 @@ export const MessageRow = React.memo( const messageBodyNode = ( <> {renderBody()} + {channelId && message.isAgent ? ( + + ) : null} {continuationMetadataNode} ", { + 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"; + +// 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" }, + hasDurableRule: false, + durableRuleNote: null, + }); +} + +function makeResolvedContent() { + return JSON.stringify({ + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: FUTURE_EXPIRY, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + hasDurableRule: false, + durableRuleNote: null, + 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 ───────────────────────────────────────────────────────────── + +async function renderBlock({ + content, + signerPubkey = AGENT_PUBKEY, + agentPubkey = AGENT_PUBKEY, + editSignerPubkey = undefined, + ownerPubkey = OWNER_PUBKEY, + viewerPubkey = OWNER_PUBKEY, +}) { + 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 qc = await makeQueryClient(viewerPubkey); + + let container; + await act(async () => { + ({ container } = render( + createElement( + QueryClientProvider, + { client: qc }, + createElement(PermissionRequestCardBlock, { + content, + interactive: true, + agentPubkey, + signerPubkey, + editSignerPubkey, + ownerPubkey, + 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 ✓ + 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 qc = await makeQueryClient(OWNER_PUBKEY); + + let container; + await act(async () => { + ({ container } = render( + createElement( + QueryClientProvider, + { client: qc }, + createElement(PermissionRequestCardBlock, { + content: makePendingContent(EXPIRY_SECS), + interactive: true, + agentPubkey: AGENT_PUBKEY, + signerPubkey: AGENT_PUBKEY, + ownerPubkey: OWNER_PUBKEY, + 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", + ); +}); diff --git a/desktop/src/features/messages/ui/PermissionRequestCardBlock.tsx b/desktop/src/features/messages/ui/PermissionRequestCardBlock.tsx new file mode 100644 index 0000000000..d958fb778c --- /dev/null +++ b/desktop/src/features/messages/ui/PermissionRequestCardBlock.tsx @@ -0,0 +1,90 @@ +/** + * Wrapper that handles the current-viewer identity check for the + * `PermissionRequestCard`, keeping React hooks out of the memo-heavy + * `MessageRow` component. + * + * Renders nothing when `computePermissionRequest` returns null (no trusted + * sentinel, wrong signer, or non-interactive surface). + */ +import * as React from "react"; + +import { useIdentityQuery } from "@/shared/api/hooks"; +import { computePermissionRequest } from "@/shared/lib/computePermissionRequest"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { AttachmentGroup } from "@/shared/ui/attachment"; +import { PermissionRequestCard } from "@/shared/ui/permission-request-card"; + +export type PermissionRequestCardBlockProps = { + /** Message body content — may contain the sentinel or may not. */ + content: string; + /** Whether this is an interactive render surface. Non-interactive → no card. */ + interactive: boolean; + /** + * Normalized hex pubkey of the known agent that signed the kind-9 event. + * Undefined → card disabled (no agent signer on this message). + */ + agentPubkey: string | undefined; + /** Raw signer pubkey from the signed event envelope. */ + signerPubkey: string | undefined; + /** + * Signer pubkey of the most recent authorized kind-40003 edit, if any. + * Used to enforce edit authenticity: only agent-signed edits resolve the card. + */ + editSignerPubkey?: string; + /** Verified owner pubkey for the agent (from the agent's profile). */ + ownerPubkey?: string | null; + /** Channel ID for routing the permission decision click. */ + channelId: string; +}; + +export const PermissionRequestCardBlock = React.memo( + function PermissionRequestCardBlock({ + content, + interactive, + agentPubkey, + signerPubkey, + editSignerPubkey, + ownerPubkey, + channelId, + }: PermissionRequestCardBlockProps) { + const identityQuery = useIdentityQuery(); + const viewerPubkey = identityQuery.data?.pubkey; + + const request = computePermissionRequest( + content, + interactive, + agentPubkey, + signerPubkey, + editSignerPubkey, + ); + + if (request === null || !agentPubkey) return null; + + const isOwner = + !!viewerPubkey && + !!ownerPubkey && + normalizePubkey(viewerPubkey) === normalizePubkey(ownerPubkey); + + return ( + + + + ); + }, + (prev, next) => + prev.content === next.content && + prev.interactive === next.interactive && + prev.agentPubkey === next.agentPubkey && + prev.signerPubkey === next.signerPubkey && + prev.editSignerPubkey === next.editSignerPubkey && + prev.ownerPubkey === next.ownerPubkey && + prev.channelId === next.channelId, +); diff --git a/desktop/src/features/messages/ui/permissionRequestAuthPubkey.ts b/desktop/src/features/messages/ui/permissionRequestAuthPubkey.ts new file mode 100644 index 0000000000..932fcda80e --- /dev/null +++ b/desktop/src/features/messages/ui/permissionRequestAuthPubkey.ts @@ -0,0 +1,30 @@ +import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; +import type { TimelineMessage } from "@/features/messages/types"; + +/** + * 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, + isKnownAgentPubkey: (pubkey: string) => boolean, +): string | undefined { + if ( + message.kind === KIND_STREAM_MESSAGE && + message.signerPubkey && + isKnownAgentPubkey(message.signerPubkey) + ) { + return message.signerPubkey; + } + return undefined; +} diff --git a/desktop/src/shared/api/agentControl.ts b/desktop/src/shared/api/agentControl.ts index 677f0ffad4..47db543d85 100644 --- a/desktop/src/shared/api/agentControl.ts +++ b/desktop/src/shared/api/agentControl.ts @@ -29,3 +29,29 @@ export async function switchManagedAgentModel( modelId, }); } + +/** + * 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 { + 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 0000000000..839094e202 --- /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; + 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 0000000000..9f9b33cbdb --- /dev/null +++ b/desktop/src/shared/api/permissionPolicy.ts @@ -0,0 +1,48 @@ +/** + * 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. + * - `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" | "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"; + +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; +}; + +export type BackendProviderCandidate = { + id: string; + binaryPath: string; +}; diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 52aa8f19eb..b43700b3dc 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, @@ -117,52 +116,12 @@ type RawRelayAgent = { 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; - 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; @@ -618,7 +577,6 @@ export async function uploadMediaBytes( } export { editMessage } from "@/shared/api/editMessage"; - export async function deleteMessage( channelId: string, eventId: string, @@ -673,52 +631,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/types.ts b/desktop/src/shared/api/types.ts index 24ef625783..97d8ceb1af 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -305,6 +305,10 @@ export type ManagedAgentBackend = | { type: "provider"; id: string; config: Record }; import type { RestartDiffEntry } from "./restartDiff"; +import type { + PermissionPolicy, + PermissionPolicySource, +} from "./permissionPolicy"; export type { JsonValue, RestartChange, RestartDiffEntry } from "./restartDiff"; export type ManagedAgent = { pubkey: string; @@ -384,15 +388,22 @@ export type ManagedAgent = { * `"allowlist"`. Preserved across mode toggles. */ respondToAllowlist: string[]; + /** Effective permission policy at the last spawn. */ + permissionPolicy: PermissionPolicy; + /** Where `permissionPolicy` came from: agent, global_default, or built_in. */ + permissionPolicySource: PermissionPolicySource; + /** Policy active on the remote worker; non-null only after a successful deploy. Differs from `permissionPolicy` when drift exists. */ + 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; @@ -443,6 +454,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 = { @@ -457,28 +470,11 @@ export type ManagedAgentLog = { logPath: string; }; -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"; - -export type ControlResultFrame = { - type: "cancel_turn" | "switch_model"; - status: string; - modelId?: string; -}; +export type { + CancelManagedAgentTurnResult, + SwitchManagedAgentModelStatus, + ControlResultFrame, +} from "./permissionPolicy"; export type GitBashPrerequisite = { available: boolean; @@ -706,6 +702,8 @@ export type UpdateManagedAgentInput = { * (validated & normalized server-side). */ respondToAllowlist?: string[]; + /** Absent = don't touch. `null` = clear to inherit. Remote: read-only. */ + permissionPolicy?: PermissionPolicy | null; }; export type AgentPersona = { id: string; @@ -1012,6 +1010,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 0000000000..8c38fc6e28 --- /dev/null +++ b/desktop/src/shared/lib/computePermissionRequest.test.mjs @@ -0,0 +1,278 @@ +/** + * 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"; + +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" }, + hasDurableRule: false, + durableRuleNote: null, +}; + +const RESOLVED_PAYLOAD = { + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 9999999999, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + hasDurableRule: false, + durableRuleNote: null, + 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 ✓ + ); + assert.deepEqual(result, RESOLVED_PAYLOAD); +}); + +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_resolved_body_with_no_edit_arrived_parses_body_directly", () => { + // When editSignerPubkey is undefined, no edit-authenticity check runs. + // If the original event body happened to contain a resolved sentinel, we + // return it. This handles the edge case where the edit arrives before we + // query the original event. + const result = computePermissionRequest( + raw(RESOLVED_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + undefined, + ); + assert.deepEqual(result, RESOLVED_PAYLOAD); +}); + +// ── 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_archive_resolved_state_returns_resolved_payload", () => { + // Simulates archive/replay: the message body carries resolved payload + // (edit already applied), agentPubkey present, editSignerPubkey absent. + // computePermissionRequest must return the resolved payload — the card + // renders in non-actionable archived state. + const result = computePermissionRequest( + raw(RESOLVED_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + undefined, // no separate edit event needed in archive — body is resolved + ); + 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 0000000000..c3568f8c1d --- /dev/null +++ b/desktop/src/shared/lib/computePermissionRequest.ts @@ -0,0 +1,75 @@ +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 equal `agentPubkey` — only + * edits signed by the original agent may flip the card to resolved. + * Owner-signed or attacker-signed edits are rejected. + * + * 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, +): 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 (edit has arrived): verify the edit was signed by the + // original agent. Owner-signed or attacker-signed edits are rejected. + if ( + payload.state === "resolved" && + editSignerPubkey !== undefined && + editSignerPubkey !== null + ) { + if (normalizePubkey(editSignerPubkey) !== normalizePubkey(agentPubkey)) { + 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 0000000000..3adc7a16f2 --- /dev/null +++ b/desktop/src/shared/lib/permissionRequest.test.mjs @@ -0,0 +1,405 @@ +/** + * Named test matrix for the `permissionRequest` sentinel parser. + * + * All fixtures are verbatim from Duncan's frozen schema (event b31c716e). + * Tests cover: parse, reject, and sentinel identification. + * + * 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"; + +const mod = await import("./permissionRequest.js").catch( + () => import("./permissionRequest.ts"), +); +const { extractPermissionRequest, isPermissionRequestSentinel } = mod; + +// ── Fixtures (verbatim from event b31c716e — bare JSON as 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" }, + hasDurableRule: false, + durableRuleNote: null, +}; + +const PENDING_DURABLE = { + v: 1, + state: "pending", + requestNonce: "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 1786206732, + optionIds: ["opt-allow-once", "opt-allow-always", "opt-deny"], + labels: { + "opt-allow-once": "Allow once", + "opt-allow-always": "Always allow", + "opt-deny": "Deny", + }, + hasDurableRule: true, + durableRuleNote: + "Includes an 'Always allow' option — creates a machine-wide durable rule in Codex.", +}; + +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" }, + hasDurableRule: false, + durableRuleNote: null, + 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" }, + hasDurableRule: false, + durableRuleNote: null, + 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" }, + hasDurableRule: false, + durableRuleNote: null, + 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" }, + hasDurableRule: false, + durableRuleNote: null, + 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", + }); + assert.equal(result.hasDurableRule, false); + assert.equal(result.durableRuleNote, null); + // pending has no originalEventId, outcome, chosenOptionId + assert.ok(!("originalEventId" in result)); + assert.ok(!("outcome" in result)); + assert.ok(!("chosenOptionId" in result)); + }); + + it("test_pending_durable_rule_parses_correctly", () => { + const result = extractPermissionRequest(raw(PENDING_DURABLE)); + assert.ok(result !== null, "should parse"); + assert.equal(result.state, "pending"); + assert.equal(result.hasDurableRule, true); + assert.equal( + result.durableRuleNote, + "Includes an 'Always allow' option — creates a machine-wide durable rule in Codex.", + ); + assert.deepEqual(result.optionIds, [ + "opt-allow-once", + "opt-allow-always", + "opt-deny", + ]); + assert.equal(result.labels["opt-allow-always"], "Always allow"); + }); + + 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: [] }; + assert.equal(extractPermissionRequest(raw(bad)), null); + }); + + it("test_too_many_optionIds_returns_null", () => { + const ids = Array.from({ length: 11 }, (_, i) => `opt-${i}`); + const bad = { + ...PENDING_NORMAL, + optionIds: ids, + labels: Object.fromEntries(ids.map((id) => [id, `Option ${id}`])), + }; + 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-deny", "opt-extra"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + // "opt-extra" has no label + }; + 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_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); + }); +}); + +// ── 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 ─────────────────────────────────────────────── +// This exact string is produced by `build_sentinel_pending_payload` in +// crates/buzz-acp/src/acp.rs (captured by `kind9_content_fixture_structural_invariants`). +// It validates that the Desktop parser accepts the exact bytes the harness emits. +describe("harness integration fixture", () => { + // The em-dash character in durableRuleNote is U+2014 — identical to harness output + const HARNESS_KIND9_CONTENT = + '{"durableRuleNote":"Includes an \'Always allow\' option \u2014 creates a machine-wide durable rule in Codex.","expiresAt":1700000300,"hasDurableRule":true,"labels":{"opt-allow":"Allow once","opt-always":"Always allow","opt-reject":"Reject"},"optionIds":["opt-allow","opt-reject","opt-always"],"requestNonce":"test-nonce-fixture-abc123","sessionId":"sess-fixture-001","state":"pending","turnId":"turn-fixture-xyz","v":1}'; + + 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", + "opt-always", + ]); + assert.equal(result.hasDurableRule, true); + assert.equal(result.sessionId, "sess-fixture-001"); + assert.equal(result.turnId, "turn-fixture-xyz"); + }); + + it("test_harness_kind9_content_identified_as_sentinel", () => { + assert.equal(isPermissionRequestSentinel(HARNESS_KIND9_CONTENT), true); + }); +}); diff --git a/desktop/src/shared/lib/permissionRequest.ts b/desktop/src/shared/lib/permissionRequest.ts new file mode 100644 index 0000000000..c72bacd513 --- /dev/null +++ b/desktop/src/shared/lib/permissionRequest.ts @@ -0,0 +1,226 @@ +/** + * 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 chars) 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 chars). + */ +export type PermissionRequestPending = { + v: 1; + state: "pending"; + requestNonce: string; + sessionId: string | null; + turnId: string | null; + expiresAt: number; + /** Opaque option IDs. Size-bounded: ≤ 10. */ + optionIds: string[]; + /** Harness-provided display labels keyed by optionId. Each ≤ 200 chars. */ + labels: Record; + /** True when an `allow_always` option is present (D5 durable-rule disclosure). */ + hasDurableRule: boolean; + /** + * Human-readable durable-rule disclosure note. Non-null only when + * `hasDurableRule === true`. E.g. "Includes an 'Always allow' option — + * creates a machine-wide durable rule in Codex." + */ + durableRuleNote: 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; + hasDurableRule: boolean; + durableRuleNote: string | null; + /** Outcome of the permission request. */ + outcome: "applied" | "timed_out" | "cancelled" | "rejected"; + /** Non-null only when outcome === "applied". */ + chosenOptionId: string | null; +}; + +export type PermissionRequestPayload = + | PermissionRequestPending + | PermissionRequestResolved; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Maximum character length for any untrusted display string in the sentinel. */ +const MAX_LABEL_CHARS = 200; + +/** Maximum number of option IDs in a sentinel (PERMISSION_OPTIONS_MAX). */ +const MAX_OPTION_IDS = 10; + +/** 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 { + 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" && v.length <= MAX_LABEL_CHARS; +} + +function isNullableString(v: unknown): v is string | null { + return v === null || isSafeString(v); +} + +function isLabelsRecord(v: unknown): v is Record { + if (typeof v !== "object" || v === null || Array.isArray(v)) return false; + return Object.values(v as Record).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; + if (p.v !== 1) return false; + + // Shared fields present in both states + if (typeof p.requestNonce !== "string" || p.requestNonce.length === 0) { + 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; + } + if ( + !Array.isArray(p.optionIds) || + p.optionIds.length === 0 || + p.optionIds.length > MAX_OPTION_IDS || + !p.optionIds.every((id) => typeof id === "string" && id.length > 0) + ) { + return false; + } + if (!isLabelsRecord(p.labels)) return false; + // Every advertised optionId must have a label entry + if ( + !(p.optionIds as string[]).every( + (id) => typeof (p.labels as Record)[id] === "string", + ) + ) { + return false; + } + if (typeof p.hasDurableRule !== "boolean") return false; + if (!isNullableString(p.durableRuleNote)) return false; + + if (p.state === "pending") { + 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" + if (p.outcome === "applied") { + if (typeof p.chosenOptionId !== "string" || p.chosenOptionId.length === 0) + return false; + } else { + if (p.chosenOptionId !== null) 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 0000000000..670dc6dc3c --- /dev/null +++ b/desktop/src/shared/ui/permission-request-card.tsx @@ -0,0 +1,306 @@ +/** + * 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 { sendPermissionDecision } from "@/shared/api/agentControl"; +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; +}; + +/** + * 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. + */ +function outcomeLabel( + outcome: string, + chosenOptionId: string | null, + labels: Record, +): string { + if (outcome === "applied" && chosenOptionId !== null) { + const chosen = labels[chosenOptionId]; + return chosen ? `Approved: ${chosen}` : "Approved"; + } + 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, +}: { + agentPubkey: string; + channelId: string; + request: PermissionRequestPending; + /** Current time in seconds (driven by a parent ticking state). */ + nowSecs: number; +}) { + const [submitted, setSubmitted] = React.useState(null); + + const expired = request.expiresAt <= nowSecs; + + if (expired) { + return ( +
Timed out
+ ); + } + + if (submitted !== null) { + return ( +
Decision sent
+ ); + } + + return ( +
+
+ {request.optionIds.map((optionId) => { + const label = request.labels[optionId] ?? optionId; + return ( + + ); + })} +
+ {request.hasDurableRule && request.durableRuleNote !== null ? ( +

+ ⚠ {request.durableRuleNote} +

+ ) : null} +
+ ); +} + +/** + * 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 ( + + {" "} + · expires in {label} + + ); +} + +export function PermissionRequestCard({ + className, + request, + agentPubkey, + channelId, + isOwner, +}: PermissionRequestCardProps) { + if (request.state === "resolved") { + const resolvedLabel = outcomeLabel( + request.outcome, + request.chosenOptionId, + request.labels, + ); + return ( + + + + + + Permission request resolved + +
+ {resolvedLabel} +
+
+
+ ); + } + + // Pending state — one ticking `nowSecs` drives both the countdown display + // and button actionability so expiry is observed atomically. + return ( + + ); +} + +/** + * 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, +}: { + className?: string; + request: PermissionRequestPending; + agentPubkey: string; + channelId: string; + isOwner?: boolean; +}) { + 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 ( + + + + + + Permission request + {!expired ? ( + + ) : null} + + {isOwner ? ( + + ) : ( +
+ Waiting for owner approval +
+ )} +
+
+ ); +} diff --git a/desktop/tests/e2e/observer-feed-screenshots.spec.ts b/desktop/tests/e2e/observer-feed-screenshots.spec.ts index 44ff609c9e..3f50e60e41 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 36adea0487..e43c297c34 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": , - "timestamp": "", - "kind": "", - "agentIndex": | null, - "channelId": "" | null, - "sessionId": "" | null, - "turnId": "" | null, - "payload": { ... } + "seq": , + "timestamp": "", + "kind": "", + "agentIndex": | null, + "channelId": "" | null, + "sessionId": "" | null, + "turnId": "" | 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": "", + "actionable": true | false, + "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,84 @@ 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": "", + "modelId": "" +} +``` + +#### `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": "", + "requestNonce": "", + "optionId": "" +} +``` + +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" | "no_active_turn" | "channel_full" | "channel_closed" | "no_channel", + "requestNonce": "", + "optionId": "" +} +``` + +`status: "sent"` means the decision was delivered to the in-flight read loop. +Other statuses indicate delivery failure; the per-request timeout will fail the +entry closed. ## Ephemerality Contract @@ -132,7 +301,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=`; historical replay is not supported. +- Clients SHOULD subscribe with `since=` 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 +323,74 @@ 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`, `hasDurableRule`, …). Desktop +identifies it 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", , "", "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 durable-rule disclosure + +If any option in the request has `kind = "allow_always"`, the sentinel sets +`hasDurableRule: true` and populates `durableRuleNote` with a disclosure string. +Desktop MUST render this note visibly before the owner confirms an `allow_always` +selection. The label value in the sentinel comes directly from the ACP option's +`name` field, capped at 200 characters; render it verbatim. + +### 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 +409,12 @@ freshness window to prevent replay of captured events. Clients subscribe with: ```json -{"kinds": [24200], "#p": [""], "since": } +{"kinds": [24200], "#p": [""], "since": } ``` +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 +426,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 +439,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 +552,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)