Skip to content

feat(desktop): surface tool-permission asks as thread-reply cards (#4938) - #5106

Open
wpfleger96 wants to merge 87 commits into
mainfrom
duncan/permission-policy
Open

feat(desktop): surface tool-permission asks as thread-reply cards (#4938)#5106
wpfleger96 wants to merge 87 commits into
mainfrom
duncan/permission-policy

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 6, 2026

Copy link
Copy Markdown
Member

Part of #4938. Desktop half of tool-permission handling: agents can no longer act on session/request_permission without a decision. When an ask-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 current main and touches nothing under crates/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 is ask (surface a card); headless / bare-CLI callers default to reject since they have no UI to answer one.

  • desktop/src-tauri/src/managed_agents/permission_policy.rsPermissionPolicy enum, PermissionPolicySource attribution, and resolve_effective_permission_policy. BUZZ_ACP_PERMISSION_POLICY is 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 through AgentPermissionPolicyField and the fleet-wide default in AgentDefaultsEditor.
  • Injected as BUZZ_ACP_PERMISSION_POLICY at both local spawn and remote provider deploy. apply_deploy_result stamps 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_once and one reject_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.rsselect_card_actions picks exactly one validated allow_once + one reject_once from 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, and sessionId (straight from the adapter's unbounded session/new response), 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 union PermissionRequestPending | PermissionRequestResolved; rejects unknown versions and enforces exactly two unique option IDs, an exact label-key count, and chosenOptionId membership in optionIds. Every untrusted string leaf (labels, each optionId, 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/channelId derive 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: its originalEventId must equal the message ID and its requestNonce/sessionId/turnId must 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-provided labels[optionId] buttons (Allow-once / Reject — no Allow-for-Session, no durable-rule disclosure) and an expiry countdown to expiresAt; 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.tsx wraps the card with owner identity resolution and keeps the MessageRow memo budget clean; editSignerPubkey and the pre-edit body are threaded through formatTimelineMessages for the edit-authenticity and correlation checks.

Transcript labels

describePermissionOutcome / describePermissionTerminalReason render 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_decision fire-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 failing no_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 on awaitLiveSwitchOutcome), wrapped by permissionDecisionDelivery.ts which injects the real scheduler/sender. Retransmission is bounded by the card's expiresAt. sent and already_decided resolve 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.expiresAt rides the observer frame through plain serde_json::to_vec (no byte bound, no fixture) and is plumbed onto the card so the orchestrator can bound its own deadline. Because expires_at is Option<u64>, an archived or pre-upgrade frame may not carry it; resolveDecisionDeadlineSecs falls 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.

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>
@wpfleger96
wpfleger96 requested a review from a team as a code owner August 6, 2026 20:29
Hayt and others added 4 commits August 6, 2026 16:34
…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>
Duncan and others added 2 commits August 6, 2026 18:05
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>
@wpfleger96 wpfleger96 changed the title feat(acp): implement permission policy (#4938) feat(acp): implement harness-level tool permission policy (#4938) Aug 7, 2026
Duncan and others added 8 commits August 7, 2026 11:25
…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>
@LucasMoskun

Copy link
Copy Markdown

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 dontAsk; every unapproved request was rejected with no desktop approval surface.

The failure progression demonstrates why the full permission policy in this PR is needed rather than only the narrow #5263 workaround:

  1. Preauthorizing Bash(buzz) and Bash(buzz:*) in the trusted local Claude settings restored relay reads and replies.
  2. The next workflow failed because even read-only git status was denied.
  3. Preauthorizing git restored repository commands, but native Edit/Write operations remained subject to the same problem.
  4. The only complete manual workaround was an owner-controlled blanket local allowlist including bare Bash, Edit, Write, and the other permission-requiring Claude tools.

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 ask | allow | reject policy plus actionable desktop cards in this PR is the correct product boundary: explicit owner intent rather than either silent approval or silent rejection.

Suggested upgrade/regression coverage:

  • Start with an existing managed agent created before fix(acp): reject unattended permission requests #4609, then upgrade into the new permission-policy build.
  • Verify the effective inherited/per-agent policy is visible in the UI and changing it triggers the required restart.
  • With allow, verify a fresh worker can run standalone buzz, git, and file-edit operations without an interactive prompt.
  • With ask, verify concurrent approval cards render and resolve correctly for a locally managed agent connected to a self-hosted relay.
  • With reject, verify requests remain fail-closed.
  • Verify the selected policy follows the actual effective harness when a user switches Claude and Codex, rather than only the global default.

The final point matters because our same reproduction also encountered #5054: a stale per-agent agent_command_override could leave the actual harness different from the UI/global runtime selection. Permission policy and harness identity should be displayed from the same effective spawn configuration.

@wolfyy970 wolfyy970 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 not BUZZ_ACP_PERMISSION_MODE. Definition env is written after the policy, so acceptEdits can 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_message can 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.

@wolfyy970

Copy link
Copy Markdown

@wpfleger96 I prepared the first review fix as signed commit aeb8f71eb directly on top of your head.

It reserves BUZZ_ACP_PERMISSION_MODE across Desktop configuration, removes any inherited process value before launch, and rejects Reject + acceptEdits before startup. The focused ACP and Desktop tests, formatting, and strict Clippy pass; the adversarial re-review is clean.

You can cherry-pick it as-is. I kept it dependent on #5106 rather than opening another competing PR.

@wolfyy970

Copy link
Copy Markdown

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.

@wolfyy970

Copy link
Copy Markdown

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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Persist and display the remotely applied policy, not only current desired policy. build_deploy_payload resolves a policy into launch.policy_env, but ManagedAgentSummary later recomputes from the mutable record/global config. If the fleet default changes after deployment, Desktop can display reject while the remote worker still runs allow. 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.

  2. Do not render unknown or persistent options as ordinary green Allow buttons. PermissionDecisionButtons currently classifies everything not starting with reject as Allow. That makes unknown kinds and allow_always look equivalent to allow_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 for allow_once, reject_once, allow_always, and unknown kinds.

  3. 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-acp plus 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 offered allow_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.

Duncan and others added 2 commits September 1, 2026 11:09
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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Duncan and others added 2 commits September 1, 2026 12:29
…, 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>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Duncan and others added 3 commits September 1, 2026 15:55
…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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Duncan and others added 2 commits September 2, 2026 14:31
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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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–515 now leaves deliveryFailed unchanged on channel_full, matching the retransmit loop and keeping the original selection disabled. no_active_turn, channel_closed, and no_channel retain 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–761 binds 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants