feat(desktop): surface tool-permission asks as thread-reply cards (#4938) - #5106
feat(desktop): surface tool-permission asks as thread-reply cards (#4938)#5106wpfleger96 wants to merge 87 commits into
Conversation
Add a three-value BUZZ_ACP_PERMISSION_POLICY (allow | ask | reject) that gates how session/request_permission calls are handled: - reject (headless default): synchronous denial, byte-for-byte unchanged from today's dontAsk behavior; ResolvedPermissionConfig derives dontAsk mode so the adapter self-denies before Buzz sees the request. - allow: synchronous auto-selection of the unique allow_once option from the exact options in the request; zero/multiple allow_once candidates or malformed options fail closed with a denial. Never allow_always, never hardcoded IDs. - ask: interactive — emits an acp_read telemetry frame with an authorization envelope (requestNonce, actionable, reason) and registers a pending entry in a bounded map (cap=8) on AcpClient. The desktop delivers a permission_decision control frame carrying the nonce and chosen optionId; the read loop matches by nonce, validates the optionId against the captured option snapshot, and writes the ACP response. Per-request timeout min(300s, remaining hard deadline) fails closed. Key implementation details: - ResolvedPermissionConfig computed once at startup; transmits effective_mode via set_config_option for every agent that advertises the mode field (goose skipped). - Admission preflight (synchronous, before map insertion): options nonempty, count ≤ 16, every optionId unique+nonempty, required kind/name fields, duplicate live requestId → immediate denial with original untouched, map at cap → deny, serialized payload ≤ OBSERVER_MAX_PLAINTEXT_LEN. - Cancel during writing → PermissionPoisoned error: surfaces through cancel_with_cleanup_grace so classify_control_cancel_failure triggers respawn (not pool return). PermissionPoisoned added to is_transport_error. Pending entries drained with cancelled responses before session/cancel. - ask without observer or unresolved owner downgrades to reject with a loud warning. - acp_read generic emit suppressed for ask permission requests; replaced with a single post-preflight enveloped emit (one frame per request). - Decision receiver arm placed ahead of reader arm in the biased select! for inbound fairness. - ObserverEvent gains optional authorization: Option<AuthorizationEnvelope> with skip_serializing_if. Payload bytes remain raw ACP, never mutated. - NIP-AO.md reconciled: adds authorization envelope, permission_decision control type, control_result telemetry kind, switch_model control type, single-use nonce semantics, best-effort delivery with mandatory timeout, cancel-during-write poison behavior, and 5-minute desktop live lookback. Tests: 720 passing (31 new pinned tests covering mode matrix, admission preflight, allow selector, ask map lifecycle, cancel-during-writing poison, policy × mode combinations, and decision arm behavior). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…4938) Add per-agent and fleet-wide permission policy configuration with an actionable Allow/Deny card for the ask policy. **Rust (desktop/src-tauri)** - Add `permission_policy` module: `PermissionPolicy` enum (ask | allow | reject, lowercase serde), `PermissionPolicySource` (agent | global_default | built_in), and `resolve_effective_permission_policy` (precedence: per-agent > global > built-in ask) - Add `permission_policy: Option<PermissionPolicy>` to `ManagedAgentRecord` (per-agent override) and `GlobalAgentConfig` (fleet default) - Inject resolved policy as `BUZZ_ACP_PERMISSION_POLICY` env var at spawn; add to `RESERVED_ENV_KEYS` so users cannot override via env-vars UI - Include `permission_policy` in `SpawnSnapshot` / restart-diff so edits surface in the existing `needsRestart` flow - Expose `permission_policy` + `permission_policy_source` on `ManagedAgentSummary` (resolved values) - Extend `UpdateManagedAgentRequest` with double-Option `permission_policy` (None = unchanged, Some(None) = clear, Some(Some(v)) = set); reject edits to remotely deployed agents with a clear error message - Add remote-deployed agent path in `agents_deploy.rs`: read per-record policy, fall back to desktop default, inject into `policy_env` **TypeScript (desktop/src)** - `PermissionPolicy = "ask" | "allow" | "reject"` and `PermissionPolicySource = "agent" | "global_default" | "built_in"` in `types.ts`; add to `ManagedAgent`, `CreateManagedAgentInput`, and `UpdateManagedAgentInput` (null = clear per-agent override) - `tauri.ts`: add `permission_policy` / `permission_policy_source` to `RawManagedAgent` with safe defaults; map in `fromRawManagedAgent` - `agentSessionTypes.ts`: add `authorization?: { requestNonce, actionable, reason? }` to `ObserverEvent`; extend `lifecycle` `TranscriptItem` with `requestNonce`, `actionable`, `authorizationReason`, `options` - `agentSessionTranscript.ts`: add `pendingPermissionsByNonce` map; parse `authorization` envelope from `session/request_permission` events; handle `control_result/permission_decision` to retire cards on terminal outcomes, including the pinned uncertain message - `agentControl.ts`: add `sendPermissionDecision(pubkey, nonce, optionId)` fire-and-forget control API - `LifecycleActivity.tsx`: `PermissionDecisionButtons` component renders per-option buttons styled by kind (reject_* = destructive); local pending state with retry on error; rendered when `actionable && !outcome` - `AgentInstanceEditDialog.tsx`: permission policy select (Inherit / Ask / Allow / Reject) for local agents; read-only for remote-deployed agents with a shutdown+redeploy hint; shows effective value and source Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…utcome Per interface note from Paul (2026-08-06): control_result statuses (sent | no_active_turn | channel_full | channel_closed | no_channel) confirm whether the permission_decision click was delivered to the harness, not whether the permission was applied/denied. Terminal outcomes arrive as enveloped acp_write frames correlated by requestNonce. The card retirement matrix will be wired once Thufir's review of Duncan's buzz-acp contract lands and NIP-AO is pinned. Updated the control_result handler to preserve card actionability on delivery — the PermissionDecisionButtons component already handles button-level pending-state reset via its own catch handler if the fire-and-forget send fails. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…can/permission-policy * origin/hayt/permission-policy: fix(desktop): control_result is delivery confirmation, not terminal outcome feat(desktop): permission policy config + actionable Allow/Deny card (#4938) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Add the Auto variant to PermissionMode (wire string 'auto'; #4557 adds the same variant from the claude-config arc — this commit establishes the contradiction logic ahead of that merge so the rebase is mechanical). Auto mode = fully autonomous execution; model-gated (requires supportsAutoMode); the adapter self-approves all tool calls internally and never emits session/request_permission. Mode matrix: - allow + auto → compatible (transmit as-is; both want unattended approval) - ask + auto → startup error (card never fires — ask becomes a dead letter) - reject + auto → startup error (inverted-security worst case: policy says deny while adapter silently auto-approves everything) Tests: 4 new pinned tests (allow+auto ok, ask+auto error, reject+auto error, wire string correct). Total: 724 passing. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Harness (crates/buzz-acp/): - Remove legacy single-slot (pending_permission_id/permission_responded) from ask path; map is sole source of truth; Writing state drops stored option id - write_ndjson_no_observe: prevent duplicate generic+authorized telemetry on permission response paths - Deadline logic: select min(earliest pending deadline, hard deadline) when any Pending entries exist; suspend idle while pending; drain map on turn exit and cancel completion to prevent capacity leak across reused sessions - Pre-turn ask requests: force reject in non-turn reader (session/new path) so map entries can never be registered without a decision arm to resolve them - Admission preflight: measure annotated ObserverEvent size (raw + envelope overhead constant) not just raw msg; add OBSERVER_EVENT_ENVELOPE_MAX = 512 - permission_denial_response: malformed reject_once (missing/empty optionId) falls back to cancelled instead of returning Protocol error - ask+auto: change to compatible-with-warning; keep reject+auto hard error; auto is a model classifier not bypass mode (per adapter source review) - Dead state: Writing(String) -> Writing; is_permission_poisoned() removed; PermissionMode::is_default #[cfg(test)] - Tests: decision loop success, bad optionId idle-timeout, annotated-size preflight, malformed reject_once fallback, updated cancelled behavior tests Desktop (desktop/): - Thread channelId through PermissionDecisionButtons and sendPermissionDecision() - Key permission cards by nonce; fallback to turn-based key for legacy paths - control_result non-sent: set deliveryFailed on card; buttons re-enable via useEffect; add deliveryFailed field to TranscriptItem lifecycle type - Fleet-wide permission_policy: add to TS GlobalAgentConfig, EMPTY_GLOBAL_CONFIG, and AgentDefaultsEditor fleet defaults select control - Remote deploy: pass caller-resolved policy to build_launch_block; resolver tests in permission_policy.rs; deploy tests for all three policy sources - Terminal outcomes: timed_out and uncertain (pinned copy) in describePermissionOutcome - Tests: nonce-keyed card, concurrent cards, auth envelope, fallback key, channelId threading, delivery-failed/sent control_result (9 new) NIP-AO (docs/nips/NIP-AO.md): - switch_model: describe actual behavior; fix control_result statuses - acp_write example: actionable=false; correct payload shape Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
- Add #[derive(Debug)] to PermissionEntry so test assertions can format entry_state:? in the paused-time test - Update NIP-AO.md Authorization Envelope section: document the one-write/one-observe contract, enumerate terminal reason values (applied / timed_out / cancelled), and define the uncertain path (cancel-during-write = no acp_write, process respawned) Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…h_permission() helper Implements Thufir's minimal shape across three passes of residual defects: Harness (acp.rs): - Remove PermissionEntryState::Resolved — entries are removed from the map on every terminal transition (applied/timed_out/cancelled). The absence of a nonce is the replay guard; no tombstones means capacity counts only live (Pending|Writing) requests, fixing the 9th-request-in-one-turn bug. - Add finish_permission() terminal helper owning Pending→Writing→Resolved for all terminals. Exactly one write+flush, exactly one nonce-correlated authorized acp_write with the terminal reason. Any write failure poisons the process and emits a permission_terminal observer-only event so Desktop can retire the card (the uncertain path). - Cancel path: write failure also poisons and emits permission_terminal. cancel-during-write emits permission_terminal for the in-flight entry. - Re-arm idle deadline when live pending count reaches zero so a slow human decision grants a fresh idle window instead of insta-cancelling the turn. - Capacity check now counts only live (Pending|Writing) entries. - Clippy: fix assert_eq!(x, true) → assert!(x), while-let-loop, doc overindented list items in acp.rs and config.rs. - Fmt: cargo fmt applied. Desktop (agentSessionTranscript.ts): - acp_write authorized frames correlate exclusively by authorization.requestNonce (primary); JSON-RPC id correlation is a legacy fallback for non-ask paths. - Terminal copy derives from authorization.reason (applied/timed_out/cancelled/ uncertain) via describePermissionTerminalReason — timeout now renders 'Timed out' not 'Denied (reject_once)'. - set actionable: false on all retirement paths. - permission_terminal observer event handler retires the card via nonce. - turn_completed and turn_error backstop: retireAllLivePermissionCards() retires any still-live cards so missing telemetry and archive replay cannot reconstruct live controls. - Biome format applied. lib.rs: - fit_observer_event_to_budget: early return without mutation when event.authorization.is_some() — authorized frames are never leaf-trimmed or stubbed (NIP-AO §3 byte-for-byte requirement). - Enqueue suppresses over-cap authorized frames entirely (defense in depth). - Test: test_authorized_frame_payload_is_never_trimmed. Tests: - ask_production_path_emits_request_captures_nonce_and_delivers_decision: real script emits session/request_permission, harness captures nonce from in-process observer, routes decision through channel, asserts end_turn. - cancel_writes_exactly_one_response_per_pending_id_no_replay: registers entries via production path, captures nonces before cancel, verifies each emitted cancel nonce matches a registered entry nonce, verifies no replay. - ask_permission_idle_is_suspended_while_pending_entry_exists: paused-time, asserts entry present at 299s. - ask_permission_deadline_fires_at_300_seconds: paused-time, asserts entry removed at exactly 300s. - ask_permission_idle_rearmed_after_last_entry_resolves: paused-time, proves idle deadline re-armed after decision applied. - ask_nine_sequential_requests_all_succeed_after_capacity_recovery: nine sequential requests each decided before the next is queued; asserts 9 distinct authorized acp_write observer nonces. NIP-AO.md: - session_resolved = session establishment (not terminal). - Added turn_completed and turn_error rows as terminal lifecycle events. - uncertain path: permission_terminal observer event replaces wrong session_resolved reference. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
- Every ask terminal routes through finish_permission(); applied path stops on false (poison) immediately instead of looping back. Cancel path uses None idle sentinel instead of dummy instant. - Deadline equality: process expired entries first at entry.deadline == hard_deadline, then return HardTimeout — fail-closed response is always written before exit. - Wire-truth tests: production-path, cancel, and nine-request tests now capture child stdin NDJSON and assert parsed exact lines/ids. Temporal tests rebuilt around one continuously running loop per scenario. - Desktop nonce-present = nonce-only: unknown nonce drops the frame without falling back to the id map. Legacy fallback keyed by compound (channel:session:turn:id), never bare id. Both indexes cleaned on every terminal (acp_write, permission_terminal) and backstop (turn_completed, turn_error). New tests: FOREIGN-nonce drop + cleanup assertions on both indexes for all four terminal paths. - NIP-AO: permission_terminal in frame-kind table; synchronous policy outcomes (rejected/allowed/allow_failed_closed) in reason table with explanatory note distinguishing ask vs. synchronous paths. - Desktop: permission_terminal handler uses pinned uncertain copy; tests for live replay and lifecycle-only archive replay. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… two tests to pipe-level proof Thread one nonce through each synchronous denial path so the acp_read and acp_write telemetry frames always carry the same nonce. Before this change, emit_permission_read_non_actionable generated its own nonce internally while the caller passed a different nonce to finish_permission_sync, producing one logical challenge/answer pair with two different nonces. Desktop's nonce-only correlation rule left the read card live because the write could never find it by nonce. Fix: accept nonce as a parameter in emit_permission_read_non_actionable (removing its internal new_permission_nonce() call) and drop the now-dead caller_will_emit_read parameter from handle_permission_request and emit_permission_read_with_nonce. Upgrade two tests from telemetry-proxy assertions to direct pipe proofs: - ask_permission_entry_deadline_equal_to_loop_hard_deadline_writes_denial_before_exit: replace observer telemetry assertion with a capture script that reads the denial line from child stdin NDJSON and parses the wire response. - cancel_first_write_fails_stops_immediately_no_second_write: add a write-attempt counter (Arc<AtomicUsize> in write_ndjson_inner) to assert exactly ONE attempt was made and the loop stopped, not just that no successful writes occurred. Add Rust tests proving the nonce is shared: - sync_denial_malformed_options_read_and_write_carry_same_nonce - sync_denial_preflight_failure_read_and_write_carry_same_nonce Add TypeScript reducer tests: - buildTranscript_sync_denial_write_with_matching_nonce_retires_card - buildTranscript_sync_denial_write_with_mismatched_nonce_leaves_card_live Update NIP-AO schema prose and observer.rs field comment to explicitly document the permission_terminal exception to the authorization-only-on- acp_read/acp_write rule. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ct payload Replace the drop-and-restart select pattern with tokio::spawn (single continuously running future), assert Err(AcpError::HardTimeout), and prove the fail-closed payload via observer telemetry rather than file capture (start_paused = true makes real-time file I/O unreliable for virtual-time tests). Four assertions now in place: 1. HardTimeout returned by the continuously running loop 2. Attempt counter == 1 (incremented before I/O in write_ndjson_inner) 3. Exactly one timed_out acp_write in observer 4. Payload id=1, outcome=selected, optionId=opt-reject Zero production diff — all changes are within the test function. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ze ratchet Extract permission-related types from agentSessionTranscript.ts to agentSessionTranscriptPermissions.ts, from tauri.ts to tauriEditMessage.ts, and from types.ts to permissionPolicy.ts. Refactor AgentPermissionPolicyField to a self-managing forwardRef component and extract useRespondToField hook to OwnerOnlyAccessField.tsx to bring AgentInstanceEditDialog.tsx under its cap. Trim doc comments on new permission fields. Fix &mut borrow on apply_permission_policy_update call in agent_models.rs. All nine ratcheted files are now at or below their allowances. Zero semantic change — all exports and behavior are preserved via re-exports from the original modules. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: (32 commits) Recover from max-token response truncation (#5223) chore(release): release Buzz Desktop version 0.5.6 (#5214) fix(mobile): keep latest messages above composer (#4981) fix(sdk): preserve self-mention p tags in message and forum event builders (#4975) bump @tauri-apps/cli to ~2.11.4 to fix linux app icon issue (#4858) feat(desktop): adding rich link previews to messages (#3818) fix(buzz-agent): Responses reasoning summary, Anthropic display:summarized, ACP v2 messageId (#5195) fix(desktop): retain distinct agent instances in autocomplete (#5202) fix(desktop): defer channel visibility change to Save (#5203) feat(desktop): Projects follow-ups — access restrictions, fast loading, activity feed polish (#5073) refactor(cli): replace probe/decider/detail split with single typed extractor (#5191) fix(desktop): drop unhandled rejection from throwing window.Notification (#5143) fix(desktop): fence localStorage SecurityError from killing the React tree (#5142) fix(desktop): make terminal output selectable (#4980) fix(desktop): use WEBKIT_DMABUF_RENDERER_FORCE_SHM for NVIDIA/AppImage (#3654) (#4505) Make public starter channels best effort (#5192) Mobile: add anchored reaction popover (#5025) feat(mobile): add bee pull-to-refresh (#5059) Remove agent creation success modal (#5063) fix(buzz-agent): escalate LLM timeouts per retry and log per-call latency (#5130) ... Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> # Conflicts: # desktop/src/shared/api/tauri.ts
…agedAgentMapping.ts tauri.ts exceeded the file-size ratchet after main's #3818 moved editMessage out, shrinking the allowance from 1175 to 1161. The six permission-policy additions to tauri.ts that were within the old headroom now exceed the tighter baseline. Extract RawManagedAgent type and fromRawManagedAgent function into a dedicated shared/api/managedAgentMapping.ts module (mirrors main's editMessage.ts pattern). tauri.ts re-exports both for zero caller changes. Removes now-unused imports (ManagedAgentBackend, PermissionPolicy, PermissionPolicySource, RawRestartDiffEntry). Result: tauri.ts 1073 gate vs 1161 allowed (88-line margin). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…gn_with_default Four test-setup sites created GlobalAgentConfig::default() then immediately assigned permission_policy. Clippy 1.95 flags this as field-reassign-with-default. Rewrite each site to use a struct literal with ..Default::default(). Files: agents_deploy.rs (1 site), permission_policy.rs (3 sites). Zero semantic change — test-only correction. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
|
Field validation from Buzz Desktop 0.5.7 on macOS, using a locally managed Claude agent with Claude Code 2.1.220 against a self-hosted relay: The #4609 behavior created an abrupt upgrade regression for an existing managed agent. Before the desktop update, the agent could use the relay and perform implementation work. After updating, the same agent launched in The failure progression demonstrates why the full permission policy in this PR is needed rather than only the narrow #5263 workaround:
That workaround restores autonomy, but it is all-or-nothing, hidden from the Buzz UI, and easy for users to misdiagnose as a relay, model, or harness-switching problem. The per-agent and fleet-wide Suggested upgrade/regression coverage:
The final point matters because our same reproduction also encountered #5054: a stale per-agent |
wolfyy970
left a comment
There was a problem hiding this comment.
This is the right boundary for portable agents: the definition can request tools, while the execution target decides how permission requests are handled. I would not merge this head yet because four paths can make the saved policy differ from what actually runs.
- Desktop reserves
BUZZ_ACP_PERMISSION_POLICY, but notBUZZ_ACP_PERMISSION_MODE. Definition env is written after the policy, soacceptEditscan bypass Ask or Reject before ACP asks Buzz. Managed local and provider launches should derive the mode from the selected policy and strip user overrides. Bare CLI use can keep the explicit mode. - The permission receiver is taken by the first prompt and not restored. A new session's
initial_messagecan consume it before the real prompt. Heartbeats have no receiver at all, but can still emit an actionable card. Only emit an actionable Ask request when a routable decision channel exists. - Permission responses use several awaited writes. Cancel can drop one after partial output, then send another cancellation response. Every permission response path needs the same persistent Writing or poisoned state so cancellation cannot produce a second answer.
- Remote deployment records the policy in its launch payload, but the UI later recomputes the current desired value. Changing the global default can therefore display Reject while the remote process still runs Allow. Keep the applied policy in the deployment receipt and show Redeploy required when it drifts.
The UI should also say that this governs ACP permission requests, not tools that run without asking Buzz. Unknown option kinds and allow_always should not appear as an ordinary green Allow button.
The focused permission tests pass, but they do not cover these paths. The missing regressions are: definition mode override, initial-message then main-prompt approval, heartbeat Ask, cancel during a blocked permission write, and remote desired-versus-applied drift.
|
@wpfleger96 I prepared the first review fix as signed commit It reserves You can cherry-pick it as-is. I kept it dependent on #5106 rather than opening another competing PR. |
|
I pushed the second independent fix from my review as wolfyy970@a83200bf7. It keeps the permission-decision route alive across the initial message and the main prompt. Ask now fails closed and non-actionable when no live route exists, including heartbeat tasks. The focused Ask and permission tests pass, strict clippy passes, and the adversarial review is clean. This can be cherry-picked after aeb8f71. |
|
The third review fix is wolfyy970@eeaedd910. If a control signal interrupts a permission response after stdin has accepted some bytes, cleanup now reports the result as uncertain, sends no second JSON-RPC response, and forces process replacement. The regression backpressures a real child pipe and proves there is exactly one write attempt. All 740 buzz-acp library tests and strict Clippy pass. The adversarial review is clean. This can be cherry-picked after a83200b. |
wesbillman
left a comment
There was a problem hiding this comment.
Review at exact head e87f265d258a1816995f743e4fee85d4a2ee6c02 (and separately inspected the proposed follow-up stack through eeaedd910f3df5cdd3e6b937f6e920ee0f6acfa9).
The proposed three-commit stack addresses the mode-override, decision-route lifetime, and interrupted-write findings, but I do not think this is release-safe yet:
-
Persist and display the remotely applied policy, not only current desired policy.
build_deploy_payloadresolves a policy intolaunch.policy_env, butManagedAgentSummarylater recomputes from the mutable record/global config. If the fleet default changes after deployment, Desktop can displayrejectwhile the remote worker still runsallow. The deployment receipt/record needs the applied value, and the UI should show desired-vs-applied drift as redeploy-required. Add a regression covering deploy under Allow, mutate global default to Reject, and verify the UI continues to truthfully expose applied Allow plus drift. -
Do not render unknown or persistent options as ordinary green Allow buttons.
PermissionDecisionButtonscurrently classifies everything not starting withrejectas Allow. That makes unknown kinds andallow_alwayslook equivalent toallow_once. Only recognized one-shot and reject choices should be actionable under this feature, or persistent grants must receive explicit differentiated semantics/copy. Unknown kinds should fail closed/non-actionable. Add reducer/render tests forallow_once,reject_once,allow_always, and unknown kinds. -
Release validation must use actual adapters, not only scripts. This PR changes the mode sent into runtime-specific ACP adapters and claims to restore permission-requiring tools. Before release, exercise exact built
buzz-acpplus current managed Claude/Codex/Buzz Agent/Goose versions under Ask, Allow, and Reject. At minimum prove: Buzz read/reply; a representative workspace read and edit; Ask card round-trip; Allow selects only offeredallow_once; Reject denies; unattended escalation remains blocked; network/filesystem boundaries remain intact. Codex's separate workspace-write network defect still requires its adapter fix.
This is the correct architectural boundary and the harness work is impressively defensive, but CI green does not establish cross-adapter behavior. Please land the existing three fixes, resolve the two remaining truth/UI issues, and attach exact-runtime evidence before merging as the full recovery.
F1 — Combined ≤200 UTF-8 byte description with known-key extraction and secret-bearing key redaction. summarize_raw_input extracts command > file/path keys > cwd > reason > compact-JSON-fallback from rawInput; keys matching SECRET_KEY_PREFIXES are replaced with "<redacted>" in the fallback and excluded from named paths. DESCRIPTION_COMBINED_MAX_BYTES (200) replaces the independent DESCRIPTION_RAW_INPUT_BYTES (120) budget so the combined title+context string always fits in one sentinel field. Truncation appends '…'. New tests: scalar/empty-object rawInput → title only; token/password redaction; command priority over path; combined-bound invariant; production-seam sentinel-level bound verification. F2 — RESOLVED_DELIVERY_WINDOW_SECS raised 60→300s (aligned with relay card-maximum and PERMISSION_ASK_TIMEOUT_SECS admission window). New tests: bounded-exit on channel-close (terminal path); same event ID across clones verified. F3 — Cross-layer test: acp_read event with all four adapter option kinds driven through buildTranscript reducer → LifecycleActivity render, asserting exactly 2 buttons (allow_once + reject_once). Rust read-loop coverage: allow_always and reject_always decisions are silently ignored by the card_actions allowlist; only allow_once is accepted and resolves the entry. F4 — Existing early_decision_first_wins test retained; read-loop coverage now includes the allow_always/reject_always rejection path. Routing hazard (same-channel multi-thread) — handle_permission_decision_control now fans out to ALL tasks with matching channel_id instead of finding the first one. The nonce is unguessable so the owning read loop accepts it while siblings drop it on mismatch. Two regression tests: two_threads_same_channel_fan_out_routes_to_owning_thread (Thread B installed first, Thread A's decision still reaches A) and two_threads_same_channel_thread_b_not_stranded (Thread B receives its own decision independently after Thread A's is handled). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…nteractive delivery-seam test F2 (retransmit structural fix): move delivery-deadline check to after the first attempt. The first publish is now unconditional — an already-expired deadline supplied by the caller (e.g. ordinary-timeout path where entry_deadline was past at resolution time) no longer silently skips the one relay write that resolves the card. Retries still consult the deadline. F2 (test fixes): replace the channel-closed bounded-exit test with two focused tests: - retransmit_resolved_edit_unconditional_first_attempt_on_expired_deadline: supplies an already-expired deadline, verifies the kind-40003 event is still published. Mutation: revert !first_attempt guard → zero publishes. - retransmit_resolved_edit_always_uncertain_bounded_exit: uses test_pair_silent (always Uncertain) under paused time, advances past the 4s delivery window, verifies the loop exits. Mutation: large RESOLVED_DELIVERY_WINDOW_SECS → hangs. F3 (interactive delivery-seam): add _deliveryFn testability seam to PermissionDecisionButtons and thread it through LifecycleActivity. Add test_f3_interactive_delivery_seam_allow_once_and_reject_once_fire_delivery: builds an acp_read transcript card (four option kinds), renders via LifecycleActivity with a mock _deliveryFn, clicks allow_once button, asserts delivery called with opt-allow-once + nonce-interactive; clicks reject_once button in a fresh render, asserts delivery called with opt-reject-once. allow_always/reject_always produce no buttons (ACTIONABLE_KINDS contract). JS suite: 10/10 pass. Mutation: remove kind from ACTIONABLE_KINDS → button absent → delivery assertion fails. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ompact test fixtures Five files exceeded the file-size gate after the permission-policy additions and main merge. Reclaim mechanism per file: - types.ts (1028→923): Remove duplicate inline definitions of AgentPersona, CatalogSourceCoordinate, PersonaBehaviorInput, CreatePersonaInput, UpdatePersonaInput, SwitchManagedAgentModelStatus, and ControlResultFrame that were re-inlined by the merge. The re-export block already handles them; canonical definitions live in personaTypes.ts and permissionPolicy.ts. Add permissionPolicy field to AgentPersona and PersonaBehaviorInput in personaTypes.ts (canonical location) instead. - agent_models_tests.rs (1015→990): Remove 24-line dead AgentDefinition struct literal immediately shadowed by serde_json::from_str re-assignment of the same variable. - persona_events/tests.rs (1044→998): Replace two 20-27 line AgentDefinition struct literals in permission-policy tests with sample_persona() helper calls (field values verified to match assertions). - discovery/tests.rs (1822→1819): Remove duplicate doc comment line and redundant doc on record_with (2 lines; already-over-cap file may not grow). - readiness.rs (1742→1741): Trim one doc comment line (already-over-cap file may not grow). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Requesting changes at head 242dbf2680fee3fd19ca02ffdfaa60dc9579db9b, against base 59328d5ae38a51a618dd2fddd7faf1343d42096f.
The contract remains owner-controlled, one-request consent: Desktop defaults to Ask, headless defaults to Reject, only Allow-once / Reject are actionable, and the operation and eventual outcome must be truthful. The earlier pre-ACK overwrite, terminal-delivery deadline, and permanent observer option findings are repaired in source. Actual command/path context is now carried, but its truncation disclosure is broken. The main merge also introduced concrete integration regressions.
[P1] Restore the deploy-policy helper and reconcile all launch call sites
agents.rs:1172 still imports deploy::extract_applied_permission_policy, and the production provider path calls it at agents/provider_deploy.rs:107, but the main merge deleted its definition from agents_deploy.rs. The deploy module has neither that definition nor a replacement export. This is an unresolved import in the normal Desktop build, not just missing test coverage.
The same merge left three test call sites with the old arity: agents_deploy.rs:409–417 supplies seven arguments to the now-eight-argument build_launch_block_for_policy; :438–445 and :474–481 supply six to the now-seven-argument build_launch_block. Restoring the helper alone therefore still leaves the Tauri test target uncompilable.
Restore the exact-payload receipt helper and its invariant tests, and update all launch callers to supply the permission policy. Preserve the confirmed-policy receipt rather than removing its validation to silence the compiler. Validate both the normal Desktop target and the full Tauri test target after repair. These failures are established by source/signature resolution; no compilation was run in this review.
[P1] Do not acknowledge fan-out when the nonce-owning thread missed the decision
handle_permission_decision_control now sends each decision to every same-channel task. It records the nonce and returns sent if any enqueue succeeds, even when another queue returns Full. Non-owning read loops intentionally discard unknown nonces (acp.rs:2287–2300,2394–2399), so a successful sibling enqueue is not evidence that the owning loop received anything.
Concrete sequence: thread A has a pending card and is temporarily awaiting a different permission's ACP write (acp.rs:2348–2375, bounded at 30 seconds). Decisions for sibling cards fill A's eight-slot queue (lib.rs:4551–4558; PERMISSION_MAP_CAP = 8). A decision for A's own nonce then gets Full from A but Sent from a sibling whose queue has space. That sibling discards it, yet the dispatcher reports success and records the nonce as delivered. Desktop stops retransmission on sent, leaving A's unreceived decision to expire fail-closed while its card says Decision sent.
Tie success/receipt to delivery to the nonce-owning loop, or otherwise make this mixed-result case retryable/visibly failed instead of successful. Add the missing owner-queue-Full + sibling-queue-Sent case; the current two-thread test (lib.rs:11185–11225) exercises only queues with space. This is a concrete partial-fan-out loss, not the previously excluded speculative enqueue-versus-prompt-completion race.
[P2] Preserve the truncation marker through the final sentinel byte cap
description_from_request_permission reserves three bytes for (…), but that wrapper is five UTF-8 bytes. With the existing test's title fake__shell (11 bytes) and a 500-byte ASCII command, the context budget becomes 186 bytes and the formatted description becomes 202 bytes. The pending/resolved builders then recap it at 200 bytes (acp.rs:4145,4182); because byte 200 falls inside the ellipsis, truncate_to_bytes backs up before the ellipsis. The published card loses both its truncation marker and closing parenthesis.
The owner sees a command prefix without the required notice that the rest was omitted. Budget the actual UTF-8 suffix before truncating and preserve the omission marker in the final serialized sentinel. Add a producer-to-card regression asserting both the 200-byte bound and the surviving marker for ASCII and multibyte input. The current pure bound test at acp.rs:8119–8152 already contradicts this arithmetic, while the production-seam bound test checks size only.
[P2] Retire observer permission cards by terminal turn, not entire channel
agentSessionTranscript.ts:727–737 passes only the channel to retireAllLivePermissionCards. That helper disables every actionable permission card in the channel and deletes its nonce/legacy correlation indexes (agentSessionTranscriptPermissions.ts:248–292). The merged runtime now permits concurrent thread-scoped turns and emits the exact terminal turn ID (pool.rs:4824–4829), but the reducer ignores it.
Reproduction: enable thread-scoped sessions with multiple workers; thread A waits for permission while thread B in the same agent/channel completes normally. B's completion removes A's observer buttons even though A remains pending. A's separate thread card remains usable, but a subsequent A outcome cannot repair its observer row because handlePermissionWrite drops the now-unknown nonce (agentSessionTranscriptPermissions.ts:353–384). The real channel panel combines these events before reducing them (ManagedAgentSessionPanel.tsx:99–119).
Scope retirement and index cleanup to the terminal turn, using the appropriate session/process identity for error events. Cover A pending → B completed → A still actionable → A resolves, including archive reconstruction.
Validation and scope: read-only inspection of immutable GitHub metadata, diffs and exact-head source, with independent harness lifecycle/routing, UI, and deploy/policy integration lanes. Reviewed the agreed corrective findings and new main-merge interactions across Desktop thread/observer surfaces, local/remote policy projection and confirmed receipts, owner/option gates, publishing/pending/writing/terminal transitions, and concurrent thread routing. Reused prior exact-head review evidence for unchanged paths rather than reopening the design. No checkout, build, tests, PR code execution, live relay/provider workflow, screenshots, or assistive-technology validation. Mobile/web permission controls remain outside this Desktop delivery. Source inspection of regression tests is not a passing test run.
…, fix test callers Three compile errors introduced by the origin/main merge at ed3a25c: 1. agents_deploy.rs: extract_applied_permission_policy was dropped from the file during the merge; restore it from the pre-merge tree. The function was added in 0bc5683 and is still imported by agents.rs and called by provider_deploy.rs. 2. agents_deploy.rs: three test call sites for build_launch_block and build_launch_block_for_policy are missing the effective_permission_policy argument added in f4efd99. Pass None (no override, use resolved default). 3. permission_policy.rs: AgentDefinition test helper missing the description field added to the struct by main's cb31449 ('add public descriptions to agent personas'). Pass None. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ompaction The sample_persona() refactor in the ratchet commit left out a display_name override — sample_persona() returns 'Test Persona' but the NIP-AP vector fixture asserts 'Test Agent'. Add p.display_name = 'Test Agent' alongside the existing p.id override. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Requesting changes at head 00a04c13b46fd04a9c6da6b47642d45d093d44c3, against base 59328d5ae38a51a618dd2fddd7faf1343d42096f.
This is a bounded corrective re-review of the eight-file delta since 242dbf2680fee3fd19ca02ffdfaa60dc9579db9b, plus verification of the remaining prior findings. The missing deployment-policy helper and three old-arity launch test calls are repaired in source. The helper reads the exact rebuilt payload before provider invocation, and the confirmed applied-policy receipt remains intact; this is not a claim that the entire build/test suite passes.
The three findings below remain in byte-identical current-head paths. They are remaining prior blockers, not new design requirements. The contract is unchanged: owner-controlled one-request consent; Desktop defaults to Ask, headless to Reject; exactly Allow-once / Reject are actionable; operation and eventual outcome must be truthful; remote applied policy reflects the confirmed deployment payload.
[P1] Do not acknowledge fan-out when the nonce-owning thread missed the decision
handle_permission_decision_control still sends each decision to every same-channel task. It records the nonce and returns sent if any enqueue succeeds, even when another queue returns Full. Non-owning read loops intentionally discard unknown nonces (acp.rs:2287–2300,2394–2399), so a successful sibling enqueue is not evidence that the owning loop received anything.
Concrete sequence: thread A has a pending card and is temporarily awaiting a different permission's ACP write (acp.rs:2348–2375, bounded at 30 seconds). Decisions for sibling cards fill A's eight-slot queue (lib.rs:4551–4558; PERMISSION_MAP_CAP = 8). A decision for A's own nonce then gets Full from A but Sent from a sibling whose queue has space. That sibling discards it, yet the dispatcher reports success and records the nonce as delivered. Desktop stops retransmission on sent, leaving A's unreceived decision to expire fail-closed while its card says Decision sent.
Tie success/receipt to delivery to the nonce-owning loop, or otherwise make this mixed-result case retryable/visibly failed instead of successful. Add the missing owner-queue-Full + sibling-queue-Sent case; the current two-thread test (lib.rs:11185–11225) exercises only queues with space. This is a concrete partial-fan-out loss, not the previously excluded speculative enqueue-versus-prompt-completion race.
[P2] Preserve the truncation marker through the final sentinel byte cap
description_from_request_permission reserves three bytes for (…), but that wrapper is five UTF-8 bytes. With the existing test's title fake__shell (11 bytes) and a 500-byte ASCII command, the context budget becomes 186 bytes and the formatted description becomes 202 bytes. The pending/resolved builders then recap it at 200 bytes (acp.rs:4145,4182); because byte 200 falls inside the ellipsis, truncate_to_bytes backs up before the ellipsis. The published card loses both its truncation marker and closing parenthesis.
The owner sees a command prefix without the required notice that the rest was omitted. Budget the actual UTF-8 suffix before truncating and preserve the omission marker in the final serialized sentinel. Add a producer-to-card regression asserting both the 200-byte bound and the surviving marker for ASCII and multibyte input. The current pure bound test at acp.rs:8119–8152 already contradicts this arithmetic, while the production-seam bound test checks size only.
[P2] Retire observer permission cards by terminal turn, not entire channel
agentSessionTranscript.ts:727–737 passes only the channel to retireAllLivePermissionCards. That helper disables every actionable permission card in the channel and deletes its nonce/legacy correlation indexes (agentSessionTranscriptPermissions.ts:248–292). The merged runtime now permits concurrent thread-scoped turns and emits the exact terminal turn ID (pool.rs:4824–4829), but the reducer ignores it.
Reproduction: enable thread-scoped sessions with multiple workers; thread A waits for permission while thread B in the same agent/channel completes normally. B's completion removes A's observer buttons even though A remains pending. A's separate thread card remains usable, but a subsequent A outcome cannot repair its observer row because handlePermissionWrite drops the now-unknown nonce (agentSessionTranscriptPermissions.ts:353–384). The real channel panel combines these events before reducing them (ManagedAgentSessionPanel.tsx:99–119).
Scope retirement and index cleanup to the terminal turn, using the appropriate session/process identity for error events. Cover A pending → B completed → A still actionable → A resolves, including archive reconstruction.
Additional validation defect reported by the late independent audit: two new buzz-acp tests call .sign(&keys).unwrap() without awaiting the signing future (acp.rs:12307–12309,12369–12371), and the resolved-edit path retains an unused entry_deadline binding (acp.rs:1620). The collaborator reports exact-head Unit Tests E0599 failures and Rust/Windows lint failures under -D warnings for these lines. I verified the cited source expressions, but did not independently retrieve those CI logs or run compilation. Repair the signing calls (await the async API or use the synchronous signing API already used in this file), remove the obsolete binding, and validate the full affected Rust targets. These are in the unchanged acp.rs blob, not introduced by the eight-file corrective delta; they do not reopen the product design or replace the three user-visible blockers above.
Validation and scope: read-only immutable GitHub metadata, diffs and exact-head source, with direct deployment/helper/type/fixture corrective inspection. The delegated audit returned after initial submission and corroborated the deployment/helper/type/fixture conclusions; this body was updated in place rather than publishing a duplicate review. Rechecked the permission producer → fan-out → read-loop → Desktop acknowledgement path, description → pending/resolved sentinel cap, and terminal turn → live/archive observer reducer. Reused previous verified evidence for unchanged owner/option gates, first-valid pre-ACK decision, terminal-delivery retries, signed edit provenance and local/remote policy projection. No checkout, build, tests, PR-code execution or live relay/provider/UI workflow was performed. Mobile/web permission controls and the previously excluded speculative enqueue-versus-prompt-completion race are outside this corrective review. Source inspection is not a passing test run.
…channel interleaving Under start_paused, a single large tokio::time::advance() moves the clock but cannot flush cross-task channel interactions. The Uncertain path requires test_pair_silent to drop ack_tx (channel-driven) so ack_rx resolves before the 2s backoff sleep fires. A single yield_now() after a 34s advance was insufficient for this ping-pong; replace with a 500ms-step polling loop that gives both tasks a chance to run on each advance tick, matching the pattern already established in ordinary_timeout_publishes_resolved_edit. Also corrects the mutation-proof comment: the test constructs its own delivery_deadline from a 4s window — the production RESOLVED_DELIVERY_WINDOW_SECS constant is not involved; the real mutation is removing the deadline gate entirely. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: feat(buzz-agent): add DatabricksAuthCoordinator single-flight OAuth (#5545) fix(desktop): preserve keyring identity during recovery (#7203) feat(mobile): prepare `buzz-push-gateway` for deployment (#7158) ci: relax file-size ceilings by surface (#6485) fix(mobile): isolate extension linker flags; complete iOS build in CI (#7187) chore(ci): lower Codex security review effort (#7179) fix(dev-mcp): extend shell timeout cap to 20 minutes and align outer budgets (#7185) fix(dev): keep the canonical profile when launching from desktop/ (#7143) feat(buzz-auth): add production NIP-FI federated assertion runtime (#7109) Hide download action on voice notes (#7182) ci: run PostgreSQL tests in isolated lane (#6730) Add voice notes to desktop messages (#6978) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…ssions
Two bugs surfaced by the full nextest run after the origin/main merge:
F1 — description wrapper-overhead off-by-two:
wrapper_overhead was 3 ('(' + possible '…' + ')'), but the UTF-8
ellipsis U+2026 is 3 bytes on its own, so the truncated form
'(ctx…)' costs 1 + 3 + 1 = 5 bytes of overhead. Changing the
constant from 3 → 5 keeps the combined description within
DESCRIPTION_COMBINED_MAX_BYTES in both the truncated and
non-truncated paths.
F3 — allow_always_and_reject_always_decisions_are_ignored_by_read_loop:
read_until_response_with_idle_timeout takes the permission decision
receiver via .take() and drops it on return. The original test
called the loop three times with 200ms timeouts, causing the
receiver to be dropped after the first call; subsequent perm_tx
sends panicked with SendError. Restructured to pre-send all three
decisions (allow_always, reject_always, allow_once) into the channel
buffer before the loop runs once — the loop processes them in order,
ignores the two non-ruled options, and resolves on allow_once,
matching the pattern established in early_decision_first_wins.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ting
F1: reserve CONTEXT_RESERVE=10 bytes before capping title so a 200-byte
title no longer saturates context_budget to zero; two commands under the
same long title now produce distinct descriptions. Seam test upgraded to
deliver two different commands (cmd_a / cmd_b) under the same 200-byte
title and asserts assert_ne! on their descriptions — the saturation
mutation (title_cap_limit = DESCRIPTION_COMBINED_MAX_BYTES) makes this
red.
F2: fix false assertion text in the always-Uncertain bounded-exit test
(was claiming 'large RESOLVED_DELIVERY_WINDOW_SECS'; the injected 4s
delivery_window is what the mutation targets). Add two new old-expiry-
crossing tests:
- resolved_edit_retransmitted_across_old_card_expiry_disconnect: entry
resolved after its 1s card deadline; Uncertain on first attempt then
Accepted; asserts >=2 identical-id publishes.
- resolved_edit_retransmitted_across_old_card_expiry_lost_ok: delivery
window starts after simulated old-expiry advance; per-attempt timeout
sweeps and Accepted lands; asserts >=2 identical-id publishes.
Both assert only 1 publish when entry.deadline is used as delivery_deadline.
F3: restructure allow_always_and_reject_always_decisions_are_ignored_by_
read_loop into three sequential steps with fresh channels per step:
step 1 allow_always -> entry still Pending, zero applied acp_write events
step 2 reject_always -> entry still Pending, zero applied acp_write events
step 3 allow_once -> entry resolved, exactly one applied acp_write event
CardActions::accepts()->true mutation resolves the entry on step 1, firing
the intermediate pending assertion.
F4: extend early_decision_first_wins to Reject + Reject-dup + Allow +
Allow-dup before ACK; still asserts exactly one applied write carrying
opt-reject. The is_none() guard mutation (unconditional overwrite) lets
the last Allow win — assertion goes red.
Routing: add two_read_loops_same_channel_nonce_mismatch_dropped_by_sibling
in acp.rs — two AcpClient instances share a channel, nonce_a's decision
is delivered to both read loops; client_a applies it, client_b silently
drops it (nonce mismatch); client_b then resolves on its own decision.
Removing the nonce guard makes client_b apply nonce_a's decision -> assert
fires.
pool.rs doc: 'has already applied' -> 'has already forwarded' to match
suppress-only semantics of recently_decided.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
F1: replace prefix-only context truncation with head/tail layout (truncate_to_bytes_head_tail) so suffix differences survive even when the context budget is small (10 bytes). Same-prefix commands like sameprefix-a / sameprefix-b now produce distinct descriptions. Seam test rewritten to use same-prefix commands and drive through build_sentinel_pending_payload, asserting both extractor and sentinel descriptions differ. F3: add reject_once_resolves_entry_with_applied_write — a separate client that exercises the reject_once path and asserts resolution plus exactly one applied write. Proves the symmetric rejection proof Thufir's accepts()→ allow_id-only mutation missed. F4: add signed_observer_control_event_delivers_permission_decision — calls handle_relay_observer_control_event with a real owner-signed, NIP-44- encrypted kind-24200 frame, proving the full outer admission path (signature check, owner-pubkey guard, freshness window, NIP-44 decrypt, type dispatch) delivers to the in-flight mpsc without a live relay. MINOR: fix Desktop overclaim — retransmitPermissionDecision.ts and agentSessionTranscriptPermissions.ts comments changed from 'already applied' to 'previously forwarded/delivery suppressed', matching pool.rs wording. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The reject_once_resolves_entry_with_applied_write test previously asserted only map-empty and write-count=1. Thufir's mutation of permission_response_selected emitting a hardcoded opt-allow-once demonstrated the test would stay green on a wrong-optionId regression. Inspect the single applied write's payload and assert payload[result][outcome][optionId] == opt-reject-once, mirroring the first-wins test's payload assertion at the ACK path. Mutation proof: forcing permission_response_selected to emit opt-allow-once regardless of selection makes this test FAIL at the optionId assertion. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…an-out false-ack P1 (fan-out false-ack, lib.rs): The prior fix correctly gated nonce recording and 'sent' status on all_tx_accepted (every tx-equipped loop accepted). Adds the required regression test: saturate the owner queue while leaving a sibling queue open, deliver a decision -> status must be 'channel_full' not 'sent', nonce not recorded; drain owner queue, retransmit -> status 'sent', nonce recorded, owner loop receives the message. P2 (channel-wide card retirement, agentSessionTranscript.ts): Replace retireAllLivePermissionCards with retireLivePermissionCardsForTurn at both turn_completed and turn_error/agent_panic call sites. With concurrent thread-scoped turns, channel-wide retirement on turn B completing would retire thread A's still-pending cards and delete their nonce indexes, making a later A decision drop on an unknown nonce. Turn-scoped retirement confines the backstop to cards whose turnId matches the terminating turn; falls back to channel-wide only when no turn identity is present (legacy archive frames). retireLivePermissionCardsForTurn added to agentSessionTranscriptPermissions.ts with the same copy-on-write semantics as retireAllLivePermissionCards, scoping both the itemsById scan (item.turnId === turnId) and the pendingPermissions cleanup (ch:session:turn:id key format, parts[2] matches turnId). Regression tests added (agentSessionTranscript.test.mjs): - buildTranscript_turn_scoped_retirement_does_not_retire_sibling_thread_cards: thread A pending -> thread B turn_completed -> A still actionable -> A decision resolves correctly, including archive replay. - buildTranscript_turn_error_scoped_retirement_does_not_retire_sibling_thread_cards: same invariant for the turn_error terminal path. Mutation table: - P1: restore any_sent semantics -> mixed-result test fails at 'sent'!= 'channel_full' assertion. - P2: restore retireAllLivePermissionCards -> both sibling-isolation tests fail because A's card is retired by B's terminal event. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…with colon-bearing sessionId
P1 (Desktop retransmit): channel_full is a queue-saturation signal emitted when
the owning read loop's queue is momentarily full. The previous code settled the
retransmit loop with 'failed' on this status, re-enabling the card for a manual
retry. The fix: treat channel_full as transient in retransmitPermissionDecision.ts
— the loop stays subscribed, the scheduler keeps firing, and the card stays
disabled until the loop either receives 'sent'/'already_decided' (acked) or the
deadline expires (expired/fail-closed). The owning loop's first-wins dedup
tolerates duplicate deliveries once the queue drains. Authoritative routing
refusals (no_active_turn, channel_closed, no_channel) still settle 'failed'.
NIP-AO.md updated to describe channel_full as transient rather than listing it
among the four failure statuses. Stale lib.rs and permission-request-card.tsx
comments updated to match.
P2 MINOR (legacy key colon safety): retireLivePermissionCardsForTurn cleaned up
pendingPermissions legacy keys via positional split(':') at index 2, assuming
sessionId never contains ':'. ACP SessionId is an unrestricted string. Fix:
match by the item's turnId field (which already carries the authoritative
identity) rather than parsing the key. No key-format change; existing entries
continue to be cleaned up correctly, and colon-bearing session identities no
longer leave stale legacy keys.
Tests: retransmitPermissionDecision.test.mjs split into channel_full-stays-active
(+2 tests: keeps loop/resends until acked; expires cleanly at deadline) and
authoritative-statuses-fail (3 statuses, unchanged contract).
agentSessionTranscript.test.mjs adds colon-in-sessionId regression test.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested
Reviewed head a0309cdbd39f8f37a32dc1c285a479608d41c970 against base 5aed49b505a7e27f3b0e34dafa53d6c4e8cdcd64, focusing on the corrections since 00a04c13b46fd04a9c6da6b47642d45d093d44c3. Contract: owner-only, one-request Allow-once/Reject consent; truthful pending/delivery/outcome states; sibling turns must not retire each other’s requests.
P2: Keep observer-card controls disabled while channel_full retries remain active
handlePermissionDecisionResult, lines 500–522 still classifies channel_full as a delivery failure and increments deliveryFailed. But the corrected retransmit orchestrator, lines 114–121 deliberately keeps the original decision’s retry loop alive for that status.
These consumers receive the same production frame. LifecycleActivity, lines 111–118 clears its pending selection when the reducer increments the token, re-enabling both buttons. Another click then starts a second independent delivery loop (lines 148–165); each loop captures its own option ID, but both correlate acknowledgements only by request nonce.
Reproduction by source trace: fill the owning turn’s decision queue, click Allow once in the activity card, and deliver control_result(channel_full). Both buttons re-enable although Allow is still being retried. Click Reject while the queue remains full, then let it drain before the original Allow loop’s next tick. The older Allow can be delivered first and applied; its nonce-only sent acknowledgement also settles the Reject loop. The visible retry control therefore permits a competing decision while the supposedly failed choice is still live. The harness’s existing first-valid-decision rule is correct; the UI must not offer this second choice as a retry while the first remains in flight.
Smallest fix: make channel_full non-failing in the transcript reducer as well, leaving deliveryFailed unchanged and the existing selection disabled. Preserve re-enable behavior for no_active_turn, channel_closed, and no_channel. Add a reducer-to-rendered-card regression: click, feed channel_full, assert both controls stay disabled and no second delivery starts; then verify the terminal failure statuses still allow a retry. The pure orchestrator tests alone do not exercise this consumer.
Prior findings resolved
- The native mixed owner-Full/sibling-Sent fan-out no longer reports success or records the nonce; it retries until all relevant queues accept or the deadline expires.
- The combined description reserves the correct UTF-8 overhead and survives the sentinel cap with omission disclosure. The two un-awaited signing calls and unused tuple binding are repaired.
- Turn completion/error retirement uses the owning turn ID rather than disabling every pending card in the channel; the sibling card retains its nonce for a later outcome.
Source-only review: exact-head/base text and regression-test source inspected; no checkout, build, test execution, mutation run, or live workflow performed. Unchanged policy/launch-receipt, owner/edit-provenance, two-option, and terminal-retry paths were cross-checked against prior verified blob identities. Mobile/web controls and unrelated base-merge changes are outside this corrective review.
handlePermissionDecisionResult treated every non-sent/already_decided status as an authoritative delivery failure, incrementing deliveryFailed and triggering the PermissionDecisionButtons useEffect to call setPending(null). With channel_full now keeping the retransmit orchestrator alive, this caused two concurrent delivery loops for the same nonce: the original retransmit loop still subscribed, plus a second loop from the newly re-enabled button click. Add channel_full as an early-return alongside sent/already_decided in the reducer. Both channel_full producers (mixed owner-Full/sibling-Sent and pure-Full) are transient queue-saturation conditions; the orchestrator handles resend automatically and the card must stay disabled until the retry settles or the deadline expires. Update LifecycleActivity.tsx deliveryFailed prop doc and the re-enable comment to name the three authoritative statuses (no_active_turn, channel_closed, no_channel) and explain that channel_full does not increment the token. Tests: - Fix buildTranscript_control_result_second_failure_increments_delivery_failed to use no_channel for the second failure (channel_closed would still work, but channel_full was the prior value and is now transient) - Add buildTranscript_control_result_channel_full_does_not_mark_delivery_failed: transcript 79→80; mutation (removing the guard) → test red at expected undefined got 1 - Add test_channel_full_reducer_to_component_buttons_stay_disabled in LifecycleActivity.render.test.mjs: drives full pipeline acp_read → buildTranscript → click → channel_full through reducer → assert both buttons stay disabled + no second delivery starts; companion case (no_active_turn) asserts buttons DO re-enable; mutation → red at disabled assertion Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: feat(agents): harness-agnostic effort write path and spawn bridge (#4625) chore(db): drop Phase-A NIP-FI relay-side authority ledger (#7221) fix(acp): replace real user name in base prompt mention example (#7250) ci: split CI into reusable workflows (#7168) fix(desktop): retain automatic mentions only in threads (#7144) feat: add databricks fable 5.1 model capabilities (#7213) docs(nip-fi): rewrite NIP-FI as stateless OSS Buzz spec v2 (#7214) feat(relay): add detailed readiness metrics (#7149) feat(desktop): add Pi agent preset (#7208) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> # Conflicts: # desktop/src-tauri/src/commands/agent_models_update.rs # desktop/src-tauri/src/commands/agents_deploy.rs # desktop/src-tauri/src/managed_agents/types/requests.rs # desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx # desktop/src/shared/api/types.ts
…ale comments agentSessionTypes.ts deliveryFailed doc and LifecycleActivity.tsx PermissionDecisionButtons doc block still claimed every non-sent control_result increments the failure token / re-enables buttons. Update both to name the three authoritative statuses (no_active_turn, channel_closed, no_channel) and note that channel_full is transient and does not increment the token, matching the reducer and component comments fixed in 9659800. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Corrective review clear
Reviewed head 2e2f4df8933101bc8b25ac7c90fdc113156cd63a against base 0dbd036f5bff33e7ade75e7639f3218d424a6e73, following up on the prior review. No remaining actionable blocker found within the agreed corrective scope.
- The queue-full P2 is resolved.
agentSessionTranscriptPermissions.ts:510–515now leavesdeliveryFailedunchanged onchannel_full, matching the retransmit loop and keeping the original selection disabled.no_active_turn,channel_closed, andno_channelretain manual-retry behavior. The native producer and both desktop consumers now agree on this status distinction. - The requested regression is present.
LifecycleActivity.render.test.mjs:537–761binds the real transcript reducer to the mounted production card, checks both controls stay disabled after queue saturation, and checks a routing refusal re-enables retry. Its delivery promise is intentionally stalled; this is reducer-to-card coverage, not a single end-to-end retransmit/ACK/expiry test. - Policy integration remains intact through the base merge. Instance Save preserves its display-only policy contract; local launch stamps the injected policy; remote deployment derives its applied receipt from the actual launch payload and preserves the prior receipt on failure. Previously reviewed signer/correlation, bounded-schema, turn-retirement, and native dispatch paths retain their reviewed blobs.
This was source/metadata review only, including independent desktop and native integration lanes. No checkout, build, test, mutation, or live workflow was executed. Unrelated merged-base effort behavior and previously excluded surfaces were not reopened. This is a clear review comment, not approval.
Part of #4938. Desktop half of tool-permission handling: agents can no longer act on
session/request_permissionwithout a decision. When anask-policy agent hits a permission request, the harness publishes a clickable permission card as a thread reply in the originating channel; the owner clicks Allow-once or Reject, and the card edits itself to the resolved outcome. The buzz-agent wire half merged in #5712 — this PR builds against its behavior on currentmainand touches nothing undercrates/buzz-agent/**.Permission policy
Three policies —
allow|ask|reject— resolved per agent with precedence per-agent opt-in >GlobalAgentConfig> built-in default. Desktop's built-in default isask(surface a card); headless / bare-CLI callers default torejectsince they have no UI to answer one.desktop/src-tauri/src/managed_agents/permission_policy.rs—PermissionPolicyenum,PermissionPolicySourceattribution, andresolve_effective_permission_policy.BUZZ_ACP_PERMISSION_POLICYis a reserved env key so the env-vars UI cannot silently diverge the running harness from the saved setting.ManagedAgentRecord.permission_policy+GlobalAgentConfig.permission_policy(Rust and TypeScript), surfaced throughAgentPermissionPolicyFieldand the fleet-wide default inAgentDefaultsEditor.BUZZ_ACP_PERMISSION_POLICYat both local spawn and remote provider deploy.apply_deploy_resultstamps the exact policy sent as the confirmed applied receipt, so a later global-default flip is detectable as drift against the live worker; a failed redeploy retains the last confirmed policy rather than clearing known truth.Permission-request cards
The card is a frozen two-action contract: exactly one
allow_onceand onereject_once, enforced on both the producer and the parser so the two sides can never diverge and no durable machine-wide rule (allow_always) can ever surface as an actionable button.crates/buzz-acp/src/acp.rs—select_card_actionspicks exactly one validatedallow_once+ onereject_oncefrom the adapter's option list and fails closed (denies synchronously, emits no card) when either is absent or ambiguous. The read-loop decision gate accepts a click only when it matches one of those two snapshotted actions (CardActions::accepts), not on mere membership in the original option list — so decision acceptance is kind-checked, never label- or adapter-trusted. The sentinel builders enforce the frozen byte bounds: labels truncate on a char boundary at 200 UTF-8 bytes, andsessionId(straight from the adapter's unboundedsession/newresponse),turnId,requestNonce,chosenOptionId, and total serialized content fail closed when over-limit.desktop/src/shared/lib/permissionRequest.ts— parser for the frozen v1 schema: discriminated unionPermissionRequestPending | PermissionRequestResolved; rejects unknown versions and enforces exactly two unique option IDs, an exact label-key count, andchosenOptionIdmembership inoptionIds. Every untrusted string leaf (labels, eachoptionId,requestNonce,sessionId,turnId,chosenOptionId) is bounded at 200 UTF-8 bytes and the total serialized content at 4096 UTF-8 bytes — the same unit and values the harness enforces (SENTINEL_STRING_MAX_BYTES/SENTINEL_CONTENT_MAX_BYTES), so a card the producer emits always parses.agentPubkey/channelIdderive from the signed event envelope, never from sentinel JSON.desktop/src/shared/lib/computePermissionRequest.ts— signer gate (the kind-9 must be agent-signed) plus edit-authenticity gate (a kind-40003 edit must be signed by the same agent as the original kind-9; owner or attacker edits are rejected). A resolved edit must also correlate to this card: itsoriginalEventIdmust equal the message ID and itsrequestNonce/sessionId/turnIdmust match the pending body it overlays, so a same-signer agent cannot cross-apply a resolution meant for a different card.desktop/src/shared/ui/permission-request-card.tsx— pending card renders harness-providedlabels[optionId]buttons (Allow-once / Reject — no Allow-for-Session, no durable-rule disclosure) and an expiry countdown toexpiresAt; the resolved card shows the outcome. Actionable buttons render only for the verified owner; a click is fire-and-forget with a "Decision sent" disable and relay-error re-enable.PermissionRequestCardBlock.tsxwraps the card with owner identity resolution and keeps theMessageRowmemo budget clean;editSignerPubkeyand the pre-edit body are threaded throughformatTimelineMessagesfor the edit-authenticity and correlation checks.Transcript labels
describePermissionOutcome/describePermissionTerminalReasonrender harness display labels (label ?? verb) instead of raw ACP kind strings; the legacy compound-key path falls back to verb-only ("Approved" / "Denied").NIP-AO
Documents the permission sentinel card lifecycle: kind-9 pending publish, kind-40003 resolved edit, admission gating, the two-action contract, and sentinel authenticity.
Decision delivery
The desktop sends its
permission_decisionfire-and-forget over the observer control channel. If the harness socket is down when the decision is published, the decision is lost and the card strands. Both halves of the lifecycle are closed so a decision is delivered exactly once regardless of transient socket loss.crates/buzz-acp— inbound dedup: a retransmitted decision whose nonce was already applied (the deciding turn has ended) acks success-shaped (already_decided) instead of failingno_active_turn/channel_closed. Backed by a recently-decided nonce set with a retention window, so a late retransmit of a correctly-resolved decision never surfaces as an error.desktop/src/features/agents/lib/retransmitPermissionDecision.ts— a pure retransmit-until-acked orchestrator (modeled onawaitLiveSwitchOutcome), wrapped bypermissionDecisionDelivery.tswhich injects the real scheduler/sender. Retransmission is bounded by the card'sexpiresAt.sentandalready_decidedresolve the loop as"acked"(the harness routed or already applied the decision). The four failure statuses (no_active_turn,channel_full,channel_closed,no_channel) resolve it as"failed": the harness answered authoritatively but could not route the click; retransmitting the same nonce cannot change that, so the loop stops and the card re-enables for owner retry. A rejected send (transport failure) is swallowed and the next tick retries — the loop survives a briefly-down socket.AuthorizationEnvelope.expiresAtrides the observer frame through plainserde_json::to_vec(no byte bound, no fixture) and is plumbed onto the card so the orchestrator can bound its own deadline. Becauseexpires_atisOption<u64>, an archived or pre-upgrade frame may not carry it;resolveDecisionDeadlineSecsfalls back to a 300s freshness window measured from the frame timestamp. Live frames always carry it since harness and desktop ship together in this PR.