Skip to content

feat(desktop): approval card for parked device tool calls (#20938, PR 2) - #20997

Open
sauranshbhardwaj wants to merge 8 commits into
BasedHardware:mainfrom
sauranshbhardwaj:feat/desktop-approval-card
Open

sauranshbhardwaj wants to merge 8 commits into
BasedHardware:mainfrom
sauranshbhardwaj:feat/desktop-approval-card

Conversation

@sauranshbhardwaj

@sauranshbhardwaj sauranshbhardwaj commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #20973 (PR 1a) and #20975 (PR 1b). Stacked on feat/desktop-approval-dispatch-relay, so the diff shows their commits too until they merge; I will rebase onto main as each lands. The PR 2-only change is the last three commits (47 files). Merge order: #20973 → #20975 → this → PR 3, in the same release window.

What changed and why

Card half of #20938 (PR 2, stacked on feat/desktop-approval-dispatch-relay, itself stacked on feat/desktop-approval-dispatch). PR 1a taught the kernel to park send_message, run_applescript, read_message_history, list_message_chats, list_mail_messages and capture_screen behind an invocation-bound approval dispatch; PR 1b connected that to the relay and told Swift about it with approval_requested and approval_resolved. This PR renders it. DesktopToolApprovalStore projects each approval_requested frame into a card and closes it on approval_resolved, whoever ended it: the user, expiry, cancellation, or an owner change. DesktopToolApprovalCard shows the tool's title and question, the exact target (resourceRef), the content preview the kernel bound the invocation to, the time the request stops waiting, and the answers: Allow Once, Allow for This Chat (1 h) only when the request offers allow_session (with its covers text under the buttons), and Deny. DesktopToolApprovalCardList mounts at the live edge of the thread whose kernel session asked, matched through AgentRuntimeStatusStore.sessionIdBySurface, so a card never shows under another conversation: main chat (QueryAnswerThread), a workstream (TaskChatPanel) and the agent pill (AIResponseView, which falls back to the main chat session when no agent pill is open). A capture_screen card is plain words only: "Take a screenshot", "Let Omi take a screenshot of your whole screen?", no target or preview rows (its screen resource is a grant key, not something to show), and only Allow Once or Deny: the runtime never offers a session grant for a screenshot, so a press of Allow for This Chat on such a card is refused locally like any answer the runtime did not offer. The copy and the options come from the runtime policy, so the card view needed no new code for it.

Answers go through signed direct control. DesktopCoordinatorService.resolveDispatchJSON calls resolve_desktop_dispatch with the full input: allow once sends resolution.decision: "allow" and no grant; allow for this chat adds a grant with runId: null, the dispatch's capability, operation and resourceRef, source: "user" and a 1 h expiresAtMs; deny sends decision: "deny". A refused or undelivered answer puts the card back to pending with the reason and the buttons return; the kernel's approval_resolved always wins over a local answer in flight, and a second press while answering or after the decision sends nothing. The card never runs anything: the only path to the tool is still the kernel's resolution, exactly as PR 1a left it.

The app declares desktop_tool_approval_cards through client_capabilities right after a successful runtime handshake, but only to a runtime whose init advertises desktop_tool_approval_requests; an older daemon without it still runs the chat and keeps its immediate approval_required, so this build never takes the chat down over a capability. With a current daemon PR 1b's interim gate turns parking on whenever this app is connected. The gate and the client_capabilities message stay as they are so this diff remains a reviewable UI change; removing them so parking is unconditional is a follow-up (PR 3) once the card has shipped in a beta. Resolved cards stay in the thread so an answer does not make the request vanish; the store keeps a bounded tail of fifty and never drops a pending card. Cards belong to one daemon process and one owner: the store is cleared on every runtime handshake, when the daemon exits and when the owner is revoked; a card whose deadline passes without a frame closes locally as expired and loses its buttons; and an answer the kernel refuses because the dispatch is no longer pending closes the card the way the kernel did rather than looping on "Couldn't send your answer". Model-authored text on the card is display-safe: bidirectional controls, zero-width and other invisible characters and control characters are shown as their code point (⟨U+202E⟩) rather than stripped, newlines are kept, and the raw resourceRef is what a session grant covers while the card shows the marked copy; the recipient is drawn in a monospaced run, a multi-line value is drawn as one ruled block so it cannot pose as another row, and a thread read by chat id still shows the handle it was given. Answers sent by the bridge carry resolvedBy: "desktop_automation"; the card's buttons answer as user.

Two automation bridge actions, tool_approval_snapshot (read-only) and answer_tool_approval (declaring all four effects, because an allow lets the parked tool run), let omi-ctl read and answer cards through the same store method the buttons call. They are non-production only like the rest of the bridge and are how the live run below was driven without Accessibility permission.

A card waiting on the person is not a stall. The founder's live run found the chat lane's 90 s tool no-progress guard (ChatProvider.perToolStallAbortMs) revoking the turn and interrupting the bridge while a card was up (app log: ChatProvider: tool no-progress guard fired at 90s (active_tools=1); interrupting bridge, 90 s after tool.started; the kernel then wrote run.cancellation_requested, attempt.cancelled, approval.resolved with decision: cancelled, run.cancelled), with the orange "taking longer than usual" banner up from 20 s. StallDetector now has a waitingOnUser state the turn's 500 ms tick loop sets from the approval store: while a card for the session is pending nothing promotes to slow or stalled, no tool is ever overdue, the tool row reads "Waiting for your approval" (new ToolCallStatus.waitingApproval, persisted as in-flight), and when the card closes every clock restarts from that moment so the 90 s covers only the tool's real execution. The composer's stop button still cancels the run and the card still reads "Cancelled, not run". Every lane shares this one guard, because the main chat, the agent pill and a workstream thread all send through ChatProvider.sendMessage with their own surface; the check is keyed by the session that turn resolved (pinnedSession.sessionId), so a card raised on the pill pauses the pill's turn and not the main chat's. The pill case is unit-tested, not live on the pill. Two residuals, documented rather than fixed: while a card is up every in-flight tool row of that turn reads "Waiting for your approval", not only the parked tool's, because the detector keys tools by the harness toolUseId and the card carries the kernel invocation id (the in-app model runs one tool at a time, so in practice it is the same row); and a card stuck in .answering behind a hung direct-control call keeps the guard paused until the card's local expiry, at most 180 s, since callRuntimeControlTool has no timeout of its own. On the daemon side (PR 1b's area, kept here because it is small) the hermes and openclaw ACP adapters' 150 s no-progress cancel now treats a run the kernel holds in waiting_approval as progress, through an isRunWaitingOnUser accessor the adapters get from the kernel.

One finding from the live run that PR 3 should take: left to itself, the in-app pi-mono model answers "send an iMessage" by calling create_desktop_dispatch rather than send_message, because the five tools' descriptions in omi-tool-manifest.ts say the call "always requires an approval dispatch". That model-authored dispatch is the pre-existing flow (no binding, no card, nothing in the app renders it, and it never expires), so the card appeared in the live run only when the model was told to call the tool directly; without that description change the in-app model often takes the old create_desktop_dispatch path and no card appears. Those descriptions should say the app asks the user itself; that change regenerates the Swift manifest digests, so it is prepared as a separate tiny branch, feat/device-tool-descriptions, stacked on this one to merge right after it.

Not in this PR: a "Needs approval" line in the pill's activity row, parking for the realtime voice surface (unchanged from PR 1b), deleting the interim gate, and the tool-description change above (its own branch).

Oversized-file growth is limited to the call sites that cannot leave those files; everything else is in new files (DesktopToolApprovalStore.swift, DesktopToolApprovalCard.swift, AgentRuntimeProcess+ToolApprovals.swift, Automation/DesktopAutomationToolApprovalActions.swift). e2e/flows/device-tool-approval-card.yaml covers them for the flow-coverage gate, and scripts/desktop_flow_contract.py lists the new actions file.

Line-Count-Exception: desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift | 4373 -> 4384 | two kinds in the closed message switch, the post-handshake clear and conditional capability send, a two-line routing case, the owner-revoke case that clears cards, and one clear on daemon exit; the statics, wire message, routing body and clearing live in AgentRuntimeProcess+ToolApprovals.swift, and messageOwnerIsCurrentlyAuthorized drops private so that extension can call it
Line-Count-Exception: desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift | 5172 -> 5173 | one registerToolApprovalActions() call in the builtin registration list; the actions live in Automation/DesktopAutomationToolApprovalActions.swift
Line-Count-Exception: desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift | 3018 -> 3020 | the approvalSurface argument at the one AIResponseView call site
Line-Count-Exception: desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift | 2162 -> 2172 | the "Waiting for your approval" caption under the tool row and its accessibility value; the status itself and the icon live in ChatProvider's enum and ToolActivityTimelineLayout
Line-Count-Exception: desktop/macos/Desktop/Sources/Providers/ChatProvider.swift | 6824 -> 6834 | the waitingApproval case of ToolCallStatus with its doc line, its detector mapping, the turn's session id captured for the stall clocks, and the tick-loop lines that ask the approval store about that session; the lookup lives in ChatProvider+ToolApprovalWait.swift

Product invariants affected

INV-AGENT-* (product/invariants/agent-control-plane.md), section Authority: the card is a display projection and holds no authority. Its allow path reuses resolve_desktop_dispatch over the same signed direct-control channel directControlTool already uses, it never calls a tool, and the session grant it mints is bounded to the dispatch's exact resource and 1 h. The relay still dispatches only on the kernel's approval.resolved. Guard tests: ApprovalCardTests (allow once sends no grant; allow for this chat sends a session-scoped grant for the exact resource and is refused locally when the runtime did not offer it; a second press sends nothing; the kernel's resolution wins over a local answer in flight; a refused answer returns the card to pending), AgentRuntimeProcessTests (the handshake now requires desktop_tool_approval_requests; the shell declares desktop_tool_approval_cards after the handshake).

INV-CHAT-1 (product/invariants/chat-continuity.md) is named because desktop/macos/Desktop/Sources/Chat/**, desktop/macos/Desktop/Sources/FloatingControlBar/** and desktop/macos/Desktop/Sources/MainWindow/Components/Chat*.swift match its path globs. Unaffected: the store is a process-local projection of daemon frames, writes no transcript state, and is cleared on every handshake, daemon exit and owner change.

INV-CHAT-2 (product/invariants/chat-scroll-placement.md) is named because desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift matches its glob. Unaffected: the card list is appended after the message rows inside the existing scroll content and touches no launch placement, anchoring, follow or virtualization logic; it never scrolls the reader, and a card appearing or closing changes content at the live edge only, exactly as a new message row does.

INV-AUTH-1 (product/invariants/auth-session.md) is named because desktop/macos/Desktop/Sources/Providers/ChatProvider.swift matches its glob. Unaffected: the change there is the waitingApproval status case, its detector mapping and two lines in the stall tick loop; it touches no session, token, owner-transition, 401 or Keychain path, and the lookup it adds reads a MainActor store keyed by the surface's kernel session.

INV-UI-1 and INV-UI-2 (product/invariants/brand-ui.md, product/invariants/desktop-ux-contract.md): the card draws with Ink, OmiType, OmiSpacing and PageGlass tokens and OmiButtonStyle only, no literal colors or fonts; text on the card and the "Waiting for your approval" caption uses only Ink.primary/Ink.secondary, never Ink.tertiary, which measures under WCAG AA on the glass panel (ShellGlassChromeTests failed on the first version's caption; the card file is now in that test's list of chat surfaces too); check_desktop_ux_contract.py reports no hand-rolled UX increase across the changed files.

How it was verified

From desktop/macos at the branch tip (fe0cbf0, stacked on PR 1b 7cc3022, PR 1a 10ef09e, main d3208a0). The capture_screen gate was folded into the stack after the first round of checks; the build, the focused suites, the Node run and the preflight below were re-run on this tip, while the ./test.sh run and the live packaged-app run were on the earlier tip (1158b11), before capture_screen was gated:

  • xcrun swift build -c debug --package-path Desktop -> clean (warnings are errors in this package).
  • Focused suites through scripts/dev-feedback.py --once swift: ApprovalCardTests 24/24 (new), AgentRuntimeProcessTests 74/74, StallDetectorTests 24/24 (3 new), ChatStallWatchdogTests 10/10 (2 new), ToolCallStatusTests 31/31, KernelTurnRecordedProjectionTests 41/41, AgentRuntimeStatusStoreTests 17/17, AuthorizedToolExecutionTests 15/15, DesktopCoordinatorServiceTests 32/32, DesktopAutomationActionEffectTests 6/6, AgentContinuityGauntletTests 5/5, ShellGlassChromeTests 14/14; 0 failures. The whole Swift suite on the top of the stack (this branch plus PR 3) ran 7900 tests with one failure, RecentScreensMenuContentTests.testDayOldFrameShowsACalendarDateNotAClockTime, which also fails on upstream/main (clock and locale); SubscriptionEntitlementServiceTests.testAuthChangeInvalidatesCache crashes the test process with signal 5 in a whole-suite run on upstream/main too, so that run skipped it.
  • ./test.sh -> its shell phase ran all 54 launcher checks plus check-e2e-flow-coverage.py --strict; the only test-level failure was tests/test-omi-dev.sh, which fails on this machine's global tag.gpgsign/tag.forceSignAnnotated (a bare git tag then wants a message) and passes with those two settings off. The script then stopped at its Python phase because backend/.venv did not exist; after backend/scripts/sync-python-deps.sh the same pytest command passed 269/269, and the Swift phase was run as scripts/swift-test-suites.sh: 108 suite invocations green, 2849 tests, and three failures outside this diff, each reproduced by the script's isolating rerun: RecentScreensMenuContentTests.testDayOldFrameShowsACalendarDateNotAClockTime, MeetingScreenEvidencePassTests.testRetryAfterFinalizeJoinsTheRunTheDeadlineLeftInFlight, DesktopAutomationWindowPresentationTests.testQuietPresentationAlsoParksNonactivatingOverlayPanels. None touches a file in this PR. Two fail alone for environmental reasons: RecentScreensMenuContentTests renders a 2027 frame as "13 Jan 2027 at 3:00 AM" where the test wants a bare calendar date (clock and locale), and DesktopAutomationWindowPresentationTests measures real AppKit window frames on this display (title-bar and display geometry). The third, MeetingScreenEvidencePassTests, is a timing race (screenshots store idle where ready is expected) that fails in the full run and in the one isolated run I made, and may pass alone. None was run on 1b's tip.
  • Node side: npm ci, npm run build clean, npx vitest run in agent -> 73 files, 959 tests passed (958 from PR 1b plus the ACP waiting-on-user test in runtime-adapter.test.ts).
  • python3 .github/scripts/check_desktop_ux_contract.py --changed-files <list> --base feat/desktop-approval-dispatch-relay -> "OK: INV-UI-2 - no hand-rolled UX increase across 13 changed desktop file(s)"; check_agent_doc_references.py -> OK; desktop-changelog.py validate -> OK.
  • scripts/pr-preflight --base feat/desktop-approval-dispatch-relay --pr-body-file <this body> -> "PR preflight passed: 38 checks" (lane ci, base 7cc3022, 47 files; Python 3.11, as for PR 1a/1b). The run drove three changes that are in the diff: the approval wiring moved out of AgentRuntimeProcess.swift into AgentRuntimeProcess+ToolApprovals.swift for the oversized-file ratchet, e2e/flows/device-tool-approval-card.yaml covers the four new Swift files (two hermetic bridge steps, a log expectation for the capability line, and the live steps as manual prose), and scripts/desktop_flow_contract.py lists the new Automation actions file so flow lint knows its actions. scripts/failure-class prepare --pr-body-file <this body> -> OK, requires_declaration: false (no fix: commits).

Real path, packaged app. OMI_APP_NAME="omi-sauransh-test" ./run.sh --yolo --no-wait built, signed and launched the named bundle (com.omi.omi-sauransh-test, dev backend, auth seeded from the production session) with this branch's app and daemon; omi-ctl wait-ready reported a signed-in main state and health a running runtime on protocol 2, and the daemon logged client_capabilities client=desktop-shell desktopToolApprovals=on. The card was driven through the new bridge actions (tool_approval_snapshot, answer_tool_approval), which call the same store method the buttons call; the accessibility driver on this machine was unavailable, so my harness never pressed a SwiftUI button; two cards were pressed by a person in the app during the run (below), which covered that path.

  • Pending: asking the main chat to call send_message directly parked the call; within 4 s the snapshot showed one pending card (send_message, resource +1 555 010 0001, options allow_once/allow_session/deny, "Waits until 12:59 PM") and the thread rendered it under the "Using send_message" row (image 001).
  • Deny: answer_tool_approval ... answer=deny -> store state denied, status "Denied, not run" (image 002); the kernel store shows the dispatch resolved by user with {"decision":"deny","selectedOptionId":"deny"}, the ledger row for that invocation failed with error_code=approval_denied and no dispatched_at_ms, and the model replied "The iMessage wasn't sent - the approval was denied." Nothing was sent.
  • Allow once, with a read tool so nothing is sent to anyone: a direct list_message_chats call parked (image 003); answer=allow_once -> "Allowed once" (image 004); the ledger shows exactly one row for it with one dispatched_at_ms and one completed_at_ms, and the app log one "Executing tool: list_message_chats with args: ["limit": 3]". The tool itself then failed because the fresh named bundle has no Full Disk Access, which the model reported; that is a TCC fact about the test bundle, not the approval path.
  • Allow once on a real send, pressed by a person: a third direct send_message card (+1 555 010 0002, text "Expiry check, do not send", raised at 12:59:33 for the expiry check below) was answered Allow Once in the app's own card by the founder at the machine five seconds later, while my harness was waiting for it to expire. The kernel store shows that dispatch resolved by user with {"decision":"allow","selectedOptionId":"allow_once"} and its ledger row succeeded with one dispatched_at_ms and one completed_at_ms, so the SwiftUI button path itself ran end to end once and Messages accepted the send to that reserved 555 number. No screenshot of that moment exists; the harness was asleep.
  • Expiry, before the fix: the founder ran the same read-only ask himself and left the card alone; at 20 s the thread showed the orange "This is taking longer than usual" banner with Cancel, and at 90 s the card flipped to "Cancelled, not run" without anyone touching it. The app log has ChatProvider: tool no-progress guard fired at 90s (active_tools=1); interrupting bridge 90 s after tool.started, and the kernel events for that run read run.waiting_approval, approval.requested, then run.cancellation_requested, attempt.cancelled, approval.resolved (decision: cancelled, resolvedBy: system, reason run_tool_attempt_terminal) and run.cancelled. That is the defect the stall fix above closes.
  • Expiry, after the fix, on the rebuilt bundle: a direct list_mail_messages card raised at 15:00:14 was left alone; at 30 s the tool row read "Waiting for your approval" with no banner (image 008); at its 180 s deadline the card read "Expired, not run" (image 007), the model replied "The request to read the latest mail expired before approval, so I couldn't retrieve it", the kernel store shows the dispatch expired by system (approval_wait_expired) and the ledger row failed with error_code=approval_expired and no dispatched_at_ms, and the app log for that process has no no-progress guard fired line.
  • Re-tested by a person on the final head (this branch plus PR 3, rebuilt bundle, 16:27–16:31): a plain "Send an iMessage to +1 555 010 0002 saying: testing Omi approvals" (no tool instructions) raised the card and Allow Once ran it once; the same ask answered Deny read "Denied, not run"; "List my recent Mail messages" left alone read "Expired, not run" — kernel approval.resolved with decision: expired, reason: approval_wait_expired, 180.005 s after approval.requested, and zero cancellation events for that run.
  • Allow for this chat was not exercised live (it would mint a real grant against the dev backend session); its request shape is pinned by ApprovalCardTests and the kernel side by PR 1a's control-tools.test.ts.

Not verified live: the workstream (TaskChatPanel) and agent pill (AIResponseView) hosts, which share the same DesktopToolApprovalCardList and differ only in the surface they pass; an answer refused by the kernel (ok: false) and a transport failure, both unit-tested; a Claude Code or pi lane through the stdio relay (the live run used the in-app pi-mono harness, whose device tools take the same relay). Two first-run setup gaps on this machine, both outside the diff: run.sh's bundle audit needs Homebrew pcre2 for the SDK's vendored ripgrep and a static ffmpeg at the git-ignored Desktop/Sources/Resources/ffmpeg; I installed pcre2 and fetched the arm64 build from the same source codemagic.yaml uses.

Tests

ApprovalCardTests (new, 24): a requested frame becomes a pending card with target, preview and three answers; Allow for This Chat is offered only when the runtime offers it; a malformed frame is ignored; allow once sends one resolution and no grant; allow for this chat sends a session-scoped 1 h grant for the exact resource (runId: null, resourcePattern = resourceRef, expiresAtMs = now + 3600 s) and is refused locally when not offered; deny sends a deny resolution and the card reads denied; a second press while answering or after the decision sends nothing; a refused answer (ok: false) and a transport failure both put the card back to pending with the reason; approval_resolved closes the card for allow, deny, expired and cancelled; a resolution that arrives before its request still closes the card; a resolution for another card or with an unknown decision changes nothing; the kernel's resolution wins over a local answer in flight; trimming drops resolved cards and never pending ones; DesktopCoordinatorService.resolveDispatchJSON passes the full resolution and grant through direct control; the bridge actions are registered with the declared effects and their helpers read and guard the shared store like the card; bridge answers carry resolvedBy: desktop_automation while the buttons answer as user; a pending card expires locally at its deadline and offers no buttons, and a press after the deadline sends nothing; a refusal because the dispatch is no longer pending closes the card like the kernel did; reset() drops every card and early resolutions stay bounded at fifty; preview text is display-safe with each hidden character shown as ⟨U+XXXX⟩ while the raw resourceRef still feeds the grant's resourcePattern, and a thread read by chat id keeps its handle; a capture_screen card shows only its plain title and question with no target or preview rows and only Allow Once and Deny, a press of Allow for This Chat sends nothing, and Allow Once sends one resolution with no grant; Clear Chat sends the main chat's journal clear, which is what ends the chat's grants and parked cards in the daemon (PR 1b), so "Allow for This Chat" does not outlive the chat. e2e/flows/device-tool-approval-card.yaml gains a manual step for that.

AgentRuntimeProcessTests: a runtime that advertises desktop_tool_approval_requests gets the client_capabilities message (protocol version, request id, desktop-shell, the capability) and an older runtime without it still passes the handshake and gets none; approval_requested and approval_resolved parse to their kinds; clearToolApprovalCards() empties the shared store. The three places that call it (handshake success, daemon exit, owner revoke) are reviewed rather than unit-tested: the test-quality ratchet forbids another production-source scan, and the message router is private.

StallDetectorTests (3 new): waiting on the person pauses every clock and restarts them when the card closes so the 90 s abort counts from the answer; a card appearing clears an already-stalled tool to waitingOnUser and nothing re-promotes while it is up; repeating the same wait state changes nothing. ChatStallWatchdogTests: the tick loop tells the detector about the wait, from the approval store keyed by the turn's own resolved session, before it asks which tools are overdue; a card on the pill's session pauses that turn and not the main chat's, and a closed card no longer pauses. ToolCallStatusTests and KernelTurnRecordedProjectionTests cover the new in-flight status. runtime-adapter.test.ts (Node, 1 new): a hermes attempt whose run the kernel holds in waiting_approval is not cancelled for lack of progress, and the cancel fires one timeout after the card closes.

UI evidence

Captured in the packaged named bundle built from this branch, through the bridge's in-process window capture (capture_main_window_png), while driving the card with omi-ctl; captured_by: agent, source: visual-audit in .agent-artifacts/ui-evidence/desktop-approval-card/evidence.json.

  • Pending card under the tool row, with target, preview, three answers, the covers note and the expiry line: .agent-artifacts/ui-evidence/desktop-approval-card/001-desktop-macos-approval-card-pending.png
  • After Deny: .agent-artifacts/ui-evidence/desktop-approval-card/002-desktop-macos-approval-card-denied.png
  • A second tool's pending card (list_message_chats) with the earlier denied card kept in the thread: .agent-artifacts/ui-evidence/desktop-approval-card/003-desktop-macos-approval-card-read-pending.png
  • After Allow Once: .agent-artifacts/ui-evidence/desktop-approval-card/004-desktop-macos-approval-card-allowed-once.png
  • A third tool's pending card (list_mail_messages): .agent-artifacts/ui-evidence/desktop-approval-card/005-desktop-macos-approval-card-mail-pending.png
  • The same card after a person pressed Allow Once in the app: .agent-artifacts/ui-evidence/desktop-approval-card/006-desktop-macos-approval-card-mail-allowed-by-person.png
  • The real expiry on the rebuilt bundle, "Expired, not run" at 180 s: .agent-artifacts/ui-evidence/desktop-approval-card/007-desktop-macos-approval-card-expired.png
  • The same card 30 s in, past the old stall threshold: tool row "Waiting for your approval", no banner, buttons and deadline intact: .agent-artifacts/ui-evidence/desktop-approval-card/008-desktop-macos-approval-card-waiting-no-banner.png

No capture of a capture_screen card: it is the same view with no target or preview rows, pinned by ApprovalCardTests, and was not driven live.

The captures above predate moving the card's field labels, covers note and pending status line, and the tool row's "Waiting for your approval" caption, from Ink.tertiary to Ink.secondary; those runs now draw one step darker, and the images have not been recaptured.

Known shape of this first version, visible in 002 and 004: a resolved card stays at the live edge of the thread, below the model's closing reply, rather than anchored to the turn that asked. Anchoring it to the turn needs the transcript to carry the dispatch id; left for a follow-up.

Failure class (fixes)

Failure-Class: none

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Reviewed the PR-2-only changes on head 1158b1 — this is careful, security-conscious work. Some notes from the static review:

Verified strengths

  • kernel-coordinator.ts resolveDesktopDispatch: invocation-bound approvals are strictly allow/deny (cancelled → deny), grant must exactly match the dispatch's capability/operation/resourceRef, finite expiry with the 24 h cap, session-owner assertion, and broker pending-state is restored on transaction rollback. createDesktopDispatch strips a forged invocation payload key so a model-authored dispatch can never impersonate a parked tool call.
  • relay-tool-approvals.ts: duplicate invocation ids fail as invocation_replayed, and the Swift execution timeout fails closed (swift_tool_timeout + outcome-unknown marking) below the long tool wait.
  • sqlite-store.ts startup reconciliation expires invocation-bound approval dispatches regardless of TTL — a parked call is never replayed after a daemon restart. Good property, well tested.
  • DesktopToolApprovalStore.swift: bounded 50-card tail, never drops a pending card, full reset on handshake/daemon exit/owner revoke; rendering model-authored text with bidi/zero-width controls shown as code points is a nice defensive touch.
  • StallDetector.waitingOnUser + the ChatProvider tick ordering (set before the overdue check, keyed by the turn's own session) is exactly the right fix for the 90 s guard firing under a card, and ChatStallWatchdogTests pins the ordering.

For maintainer attention

  • This is the user-consent layer for the most sensitive desktop capabilities (sending messages, reading Messages/Mail, running AppleScript), and it mints session grants (1 h card default / 24 h kernel cap). The lifetimes, the Deny-as-default wording, and the overall UX direction are product decisions that need maintainer sign-off — that's the main reason this is left for human review rather than approved, along with the stack (feat(desktop): park sensitive tool calls in waiting_approval and emit approval.requested (#20938, PR 1a) #20973 → feat(desktop): relay approval_requested to Swift and resume parked tool calls (#20938, PR 1b) #20975 → this → PR 3) and the fact that no executable CI runs here (the only check is an AI reviewer), so runtime verification rests on your local runs and the evidence artifacts.
  • .github/agent-docs/agent-grants-approval-ux.mdx changes guidance that AI coding/review agents in this repo read: the status line moves from "Design-only … do not treat this document as implementation scope" to live-behavior claims (five parked tools, resolve_desktop_dispatch as the only grant insertion path). I verified those claims match the code in this stack, but they only hold once the whole stack merges — worth confirming the doc's end-state wording is what you intend before merge.
  • answer_tool_approval on the automation bridge correctly declares all four effect classes and is non-production gated.

Thank you for the exceptionally detailed PR description and the UI evidence — it made reviewing this stack feasible.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added security-review Touches auth, provider routing, secrets, or security-sensitive surfaces needs-maintainer-review Needs a human maintainer to sign off before merge macOS labels Oct 8, 2026
@sauranshbhardwaj

Copy link
Copy Markdown
Contributor Author

Thanks, and glad the description and evidence made the stack reviewable.

  1. Lifetimes and Deny-as-default: both are my defaults and both are one constant each, so I'll set them to whatever you prefer. The reasoning: "Allow for this chat" lasts 1 h so a grant never outlives a working session, and the kernel refuses anything over 24 h whatever the client asks for; Deny is the focused default so a stray Return can never approve a send.
  2. agent-grants-approval-ux.mdx: agreed it's only true once the whole stack lands. If you'd rather agents never read it ahead of the code, I'll keep its status as design-only in these PRs and flip it in a one-line follow-up after feat(agent): tell the model the app asks for device tool approval itself (#20938, PR 3) #20998 merges.
  3. CI: every suite has been run locally on these heads (numbers on feat(agent): tell the model the app asks for device tool approval itself (#20938, PR 3) #20998). If a maintainer can approve the workflows for this stack, I'll watch the runs and fix anything they turn up.

…ool policy

A dispatch_required decision for a sensitive on-device tool now has a policy-owned shape for what the user is asked: the capability, operation and resource the grant would be matched against, a bounded display preview of the input (attachment paths reduced to a file name, text and scripts capped at the 4 KiB card bound with a truncation flag the broker fails closed on), and the answers. Deny is the default; allow_once is always offered; allow_session is offered only when there is an exact resource for a grant to cover and carries plain words for what it would cover, so a script grant is shown as covering only that exact script. The 180s expiry matches the mobile device-tool transport and stays under the pi-mono long tool wait minus the Swift executor timeout, so a late answer cannot run an effect the model already reported as failed. Only this module decides the question and the options; adapters and the model never choose allow or deny.
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Reviewed the PR-2-specific changes (the stacked diff includes #20973/#20975 until they land).

This renders the approval card for parked device tool calls, and the engineering holds together well:

  • desktop/macos/Desktop/Sources/Chat/DesktopToolApprovalStore.swift — clean separation: the store only projects approval_requested/approval_resolved frames into card state; every decision is applied by the runtime via resolve_desktop_dispatch, so a UI bug can't authorize anything by itself. allow_session mints a grant scoped to the exact capability/operation/resource with a 1h expiry.
  • desktop/macos/agent/src/runtime/kernel-coordinator.ts — good anti-forgery details: TOOL_APPROVAL_BINDING_PAYLOAD_KEY strips the invocation binding from model-created dispatches (a forged binding could never be resolved by a live invocation), and MAX_DESKTOP_DISPATCH_GRANT_TTL_MS caps dispatch-minted grants at 24h.
  • desktop/macos/agent/src/runtime/desktop-tool-policy.ts + run-tool-capability.ts — resourceRef is derived only from acted-on fields (to/script/chat_id/handle) with schema validation at the kernel boundary; unknown keys are rejected as invalid_tool_input, and oversized text/scripts fail as input_too_large_to_approve before anything is prepared. Also removes the screenshot tool from the pi-mono chat adapter advertisement (realtime voice surface only) — that closes an ungated full-screen capture path in typed chat.
  • desktop/macos/agent/src/runtime/sqlite-store.ts — startup reconciliation expires invocation-bound approval dispatches regardless of TTL, so a parked call can never be replayed after a daemon restart.
  • desktop/macos/Desktop/Sources/Chat/StallDetector.swift + ChatProvider+ToolApprovalWait.swift — the waitingOnUser state pausing the slow/stall clocks (keyed by the turn's own session, so the pill and a workstream each see their own card) is the right fix; a card waiting on the person is not a stall and the 90s abort must not fire under it. The new ToolCallStatus.waitingApproval case is handled consistently across the codec, timeline, and accessibility strings.
  • Tests are a real strength: ApprovalCardTests (666 lines) exercise the production store with a recording fake, and the relay/kernel/adapter additions (relay-tool-approvals.test.ts, control-tools.test.ts, runtime-adapter.test.ts) cover deny, allow-once-exactly-once, restart expiry, and the timeout-pause behavior. Changelog fragments and the e2e flow YAML are present.
  • .agent-artifacts/ui-evidence/desktop-approval-card/ follows the existing evidence-dir convention on main; the screenshots document the card states.

Notes for a maintainer before merge:

  1. This is stacked PR 2 of 4 (feat(desktop): park sensitive tool calls in waiting_approval and emit approval.requested (#20938, PR 1a) #20973 → feat(desktop): relay approval_requested to Swift and resume parked tool calls (#20938, PR 1b) #20975 → this → PR 3); both parents are still open, so the merge-order plan in the description needs coordination in one release window.
  2. The interim client_capabilities gate that turns parking on is self-declared by any connected client and defaults off. That's documented as temporary and the code says the follow-up flips the default and deletes the message — worth tracking that it actually happens.
  3. The 1-hour Allow-for-this-chat duration is a product choice sitting under the kernel's 24h grant cap — fine per the issue text, but it's a UX decision a maintainer should consciously confirm.
  4. .github/agent-docs/agent-grants-approval-ux.mdx changes status from design-only to describing this implementation. The wording matches the code as reviewed, but since agents read that directory as ground truth, please sanity-check it stays accurate to the final merged sequence.
  5. CI on this head shows only a neutral AI reviewer; the new Swift/vitest suites haven't run in CI here, so a maintainer-side run would be valuable.

No security or supply-chain concerns found in static review. Because this decides how users approve message sends, Messages/Mail reads, screenshots, and AppleScript execution — and it sits mid-stack — it needs human maintainer sign-off before merge rather than an automated one.

This review was produced by AI automation on behalf of the maintainers.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@sauranshbhardwaj

Copy link
Copy Markdown
Contributor Author

Thanks for the review. On the notes for maintainers:

  1. Merge order: feat(desktop): park sensitive tool calls in waiting_approval and emit approval.requested (#20938, PR 1a) #20973 → feat(desktop): relay approval_requested to Swift and resume parked tool calls (#20938, PR 1b) #20975 → this PR → feat(agent): tell the model the app asks for device tool approval itself (#20938, PR 3) #20998, in one release window. I'll rebase each one and rerun its build and tests as the one below it lands.
  2. The interim client_capabilities gate: I'll open the follow-up PR that flips the default and deletes the message and the gate as soon as this stack merges. Please hold me to it. I'll link it here when it's up.
  3. The 1-hour "Allow for This Chat": it's a product choice under the coordinator's 24-hour ceiling, kept short on purpose. It's a one-line change if you'd prefer a different duration. Screenshots never get a session grant at all; they're Allow Once only.
  4. agent-grants-approval-ux.mdx: I'll keep it accurate to whatever order the stack actually merges in. If you'd rather it stay design-only until all four PRs land, say so and I'll do that.
  5. CI: the Swift and vitest suites pass locally on each head; the counts are in the description. They'll run here once a maintainer approves the fork's workflow runs.

@sauranshbhardwaj
sauranshbhardwaj force-pushed the feat/desktop-approval-card branch from ebb2128 to a795670 Compare October 10, 2026 20:57
@sauranshbhardwaj

Copy link
Copy Markdown
Contributor Author

One fix since the review, caught by a whole-suite Swift run on the stack: ShellGlassChromeTests failed because the "Waiting for your approval" caption in ChatBubble.swift used Ink.tertiary on glass. Ink.swift documents that token as never used on glass (4.34:1, under WCAG AA).

  • The caption, and three rows on the card itself (field labels, the covers note, the pending status line), now use Ink.secondary.
  • DesktopToolApprovalCard.swift is added to that test's list of chat surfaces, so the rule now covers the card.
  • The whole suite on the stack top has no failures beyond the ones main also shows: the locale-dependent RecentScreensMenuContentTests case, and the SubscriptionEntitlementServiceTests process crash.

The UI evidence images predate the darker grey. I'll recapture them on the next packaged run.

… approval.requested

send_message, run_applescript, read_message_history, list_message_chats and list_mail_messages used to fail with approval_required whenever no scoped grant existed, and nothing in the app could create one. capture_screen did not fail at all: it took and shared a full-screen screenshot with no approval, although its own description promised one, so it now parks the same way under the stable resource "screen". A screenshot holds whatever is on screen, including windows the person keeps out of capture elsewhere, so each one is allowed once: its card offers no session grant, no grant row covers a capture, and the kernel refuses to mint a grant the card did not offer. The realtime voice screenshot tool is no longer advertised to pi-mono, where it sat ungated in every typed chat, pill and workstream run; realtime voice runs still get it through their surface projection. The capability broker now prepares the ledger row and inserts one approval dispatch bound to that exact invocation (id, input hash, daemon boot epoch) in the same transaction, the kernel moves the run and attempt to waiting_approval and appends run.waiting_approval plus approval.requested, and the broker refuses to dispatch the invocation until the dispatch is resolved. resolve_desktop_dispatch gains the two user answers: decision allow without a grant admits exactly this invocation once and mints nothing; allow with a grant whose runId is null mints a session-scoped grant through the existing insertGrant path, capped at 24 hours. Deny, cancellation, expiry, run termination and owner change fail the prepared invocation closed as failed, never outcome_unknown, each with its own approval.resolved, and the run resumes so the model receives approval_denied. A new kernel operation, endChatApprovalScope, ends what a chat granted when the person clears it: the main chat keeps one kernel session across clears, so it closes every approval still parked in that session and revokes every grant the person minted for it, with one grants.revoked audit event. Startup reconciliation expires any pending bound approval and records approval.resolved, so a restart never replays a parked call.

Every relay-callable tool is now classified on purpose (named in a policy set or declared unbundled, never by the read-only default), and a test holds the approval set to every relay-callable tool in a sensitive bundle, with each deliberate exception named and justified, so a new tool cannot ship unclassified or ungated by omission. Grants for desktop.context.screenshot_image must name their exact resource. The gated tools are held to their manifest schema at the kernel boundary and the resource a grant covers is derived only from the fields the tool acts on, so a model-supplied resource_ref can no longer become the grant pattern. A dispatch created through create_desktop_dispatch has its invocation payload key stripped and can never pose as a parked call. Authority is revalidated before a resolution is admitted and that check runs before the resolving transaction, so a revocation it triggers commits on its own; a rolled-back decision restores the broker's memory. The model still cannot resolve its own dispatch: the relay path keeps returning policy_denied without trustedUserControl.

Parking sits behind a kernel switch, desktopToolApprovalsEnabled, that defaults to off and is not reachable from the daemon in this change, so runtime behavior is unchanged until a client that renders approval cards turns it on. The Swift card PR flips the default and deletes the switch.
…ol calls

The stdio relay no longer answers a parked device tool call immediately. A new relay-tool-approvals module, taking the kernel and the frame and result writers, sends Swift an approval_requested frame carrying the dispatch id, the bound invocation, the policy preview, the options and the expiry, keeps the relay call pending, and settles it from the kernel's approval.resolved event: an allow decision hands the invocation to Swift through the ordinary authorized_tool_execution path exactly once, every other outcome returns approval_denied to the model. Every resolution is mirrored to Swift as approval_resolved so a card can close whatever ended it, including owner changes. Expiry fires from the runtime, a disconnected relay client cancels its own dispatch, and terminal run events fail parked calls like any other pending tool call. The Swift hand-off itself moves into the same module so the whole relay side is covered by hermetic tests with injected timers.

Clearing a chat through journal_clear_turns now ends what that chat granted before the journal is cleared: the kernel closes the session's parked approvals and revokes the grants the person minted for it, because the main chat keeps one kernel session across clears and an "Allow for This Chat" grant would otherwise cover the new conversation. No tool reaches this; only the app's own clear message does.

Parking is on only after a client declares desktop_tool_approval_cards through the new client_capabilities message; until the Swift card ships no client does, and the relay keeps today's immediate approval_required. The runtime advertises desktop_tool_approval_requests so that client can require it first. Realtime voice keeps today's behavior because its Swift tool wait is 180s and would race the approval TTL.
The pi-mono extension waits 30s for a normal Swift tool result and gives up with a timeout error. A human answering an approval card needs longer than that, and a late approval would then run a send the model had already reported as failed. The six device tools that park behind an approval, capture_screen among them, now declare the long timeout class, which the extension maps to its 10 minute wait, and a test holds every gated tool to it; the approval TTL of 180s plus the 120s Swift executor timeout stays well inside it. Swift does not consume timeoutClass outside the generated surfaces, so the regenerated files only carry the new manifest digests.
The approval UX design doc keeps its design status and points at the device tool surface doc for the runtime half that now exists; the Swift card, the Claude Code lane and the voice surface stay deferred and section 24 still applies. The device tool surface doc gains the ask-and-answer table, the schema and resource rules that stop the model naming what a grant covers, the session-grant options and their 24 hour cap, the restart and expiry behavior, and the interim client-capability gate. The changelog fragment is kind none because nothing is user-visible until the Swift card lands.
Render the kernel's approval_requested frame as a card in the main chat, workstream and agent pill threads, matched to the asking session, with the exact target, the content preview, the expiry and the answers the runtime offered; answer through signed direct control (allow once, allow for this chat with a 1 h session grant, deny) and close the card on approval_resolved whoever ended it. Declare desktop_tool_approval_cards after the handshake so the relay parks a sensitive call instead of failing it, require desktop_tool_approval_requests from the runtime, and add tool_approval_snapshot and answer_tool_approval bridge actions so omi-ctl can drive the card without Accessibility permission.
… stall

In the live run the chat's 90 s tool no-progress guard revoked the turn and interrupted the bridge while a send_message card was waiting for the person, so the kernel cancelled the run and the card read Cancelled, not run. Teach StallDetector a waitingOnUser state the turn's tick loop sets from the approval store: while a card for the session is pending nothing promotes to slow or stalled, no tool is ever overdue, the tool row reads Waiting for your approval, and when the card closes every clock restarts from that moment; the stop button still cancels. Give the hermes and openclaw ACP adapters the same rule, treating a run the kernel holds in waiting_approval as progress for their 150 s no-progress cancel.
Six in-process captures from the packaged named bundle: a parked send_message card, the same card after Deny, parked list_message_chats and list_mail_messages cards, and the Allowed once state after an answer, with evidence.json recording agent capture through the automation bridge.
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Reviewed the PR-2-only diff on fe0cbf0. This is careful work — some notes from the review, then why it stays blocked on human review.

What holds up well

  • relay-tool-approvals.ts keeps the relay side honest: parked approvals are deliberately non-durable (startup reconciliation fails the prepared ledger row and expires the dispatch), and the module only turns a kernel resolution into either one authorized_tool_execution or one failed tool_result.
  • kernel-coordinator.ts strips the invocation payload key from model-created dispatches, so a dispatch forged by the model can't impersonate a parked tool invocation; dispatch grants are capped at MAX_DESKTOP_DISPATCH_GRANT_TTL_MS (24 h) with source: "user" only.
  • sqlite-store.ts expires invocation-bound approval dispatches on daemon startup regardless of TTL and emits audit events — a parked call is never replayed.
  • capture_screen is allow-once only and no grant ever covers the screen resource.
  • The screenshot change in omi-tool-manifest.ts (removing the pi-mono advertisement so realtime-voice-only capture can't reach typed chat) closes a real gap, with coverage in omi-tool-manifest.test.ts and run-tool-capability.test.ts.
  • The stall pause in StallDetector.swift / ChatProvider.swift is keyed by the turn's own session (turnSessionId) and only runs while a card is actually pending, and DesktopToolApprovalStore.swift drops cards on expiry/owner-runtime revocation — so the 90 s abort pause can't be wedged open.

Worth a maintainer's eye

  • .github/agent-docs/agent-grants-approval-ux.mdx now tells agents that grants are live state with exactly one writer (resolve_desktop_dispatch, user-source, finite expiry). I verified the text against the code and it reads as accurate — but since this doc shapes what AI coding/review agents assume about grant state in this repo, the status flip deserves explicit maintainer sign-off along with the feature itself.
  • On this head, Desktop Swift Release Compile and the static-contract checks were skipped; only the debug build/test lane ran green. A release-config compile before merge would be cheap insurance given the size of the Swift surface touched.

Status

Because this is PR 2 of a stacked sequence implementing security-sensitive permission-grant UX (approval cards that mint scoped grants and pause the stall watchdog), it remains behind security-review / needs-maintainer-review — that's a product/security sign-off only a human maintainer can give, not a statement about the implementation quality, which looks strong. Thanks for the exceptionally thorough tests (ApprovalCardTests, relay-tool-approvals.test.ts, the +727 in control-tools.test.ts) and the UI evidence artifacts.

This review was produced by an automated reviewer acting for the maintainers. AI-generated feedback — human maintainers make the final call.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

This branch has not been deployed

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

Labels

macOS needs-maintainer-review Needs a human maintainer to sign off before merge security-review Touches auth, provider routing, secrets, or security-sensitive surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants