Skip to content

fix(plugins): make parked agent approvals reachable from the app - #665

Merged
crs48 merged 3 commits into
mainfrom
claude/approval-ui-missing-04ea47
Jul 31, 2026
Merged

crs48 merged 3 commits into
mainfrom
claude/approval-ui-missing-04ea47

Conversation

@crs48

@crs48 crs48 commented Jul 31, 2026 •

Copy link
Copy Markdown
Owner

The bug

Chatting with Claude Code in the desktop app, the agent said it had submitted a
page creation and was "awaiting in-app approval" — and the app showed no
approval UI at all. Five minutes later the action expired and the page was
never created, with nothing said about why.

Why

The bridge tier drives xNet through the in-process MCP server, built with
agentAudit. xnet_compose_page is risk: 'high', so surfaceForRisk
returns 'app' with no nonce — chat mechanically cannot release it, by
design. The only release path is AgentAuditRecorder.approveFromApp.

That method had exactly one caller in the repo: ai-chat-ceremony.ts, whose
ceremony object is only constructed inside AiChatPanel when the panel runs
its own model runtime with writes opted in. On the bridge tier the panel is
a display for a stream, so no ceremony exists and no card renders.

Meanwhile the bridge's recorder lives in the main process with a private
pending map, and xnetAgentBridge exposed only status/start/stop. The
action was genuinely parked with no reachable approver.

The fix

  • createApprovalBroker (new, @xnetjs/plugins) — headless park/settle
    over a recorder plus a change subscription. maxWaitMs bounds how long a
    caller waits without un-parking the action, so a transport request timeout
    never looks like a decision.
  • MCPServer parks medium+ risk calls instead of returning a pending
    payload, and exposes listParkedApprovals(),
    onParkedApprovalsChanged(), approveParkedApproval(actionId, approverDID)
    and denyParkedApproval() to its host.
  • Desktop projects those over IPC (xnet:agent-approvals:*) into a new
    shell-level AgentApprovalsLayer, mounted in Workbench rather than the AI
    panel — the agent driving the workspace is often a terminal, so an approval
    that only lives in a chat panel is one closed panel away from being lost
    again.
  • ApprovalCard is extracted and shared, so a panel-parked action and a
    bridge-parked one are the same decision presented the same way.
  • The panel ceremony is refactored onto the broker instead of keeping a second
    copy of park/settle.

Approving now also resumes the agent's original call with the real result, so
the turn continues instead of stranding the user between two systems.

Two failure modes worth calling out

approveWithCode distinguishes "wrong code" from "approved, and the tool then
threw" — the recorder throws for both, and conflating them would report a bad
code while an approved action was failing underneath. And xnet_deny now
throws rather than reporting { denied: true } when nothing was parked.

Behaviour change for embedders

With agentAudit configured, a medium+ risk tools/call no longer resolves
immediately with { pending: true }. It resolves when the operator decides,
or — after approvalWaitMs (default 55s) — with a still-pending payload that
leaves the action parked and approvable until its real TTL. Minor bump on
@xnetjs/plugins.

Verification

  • pnpm test — 11,412 passed, full suite green. (One earlier run had two
    failures in packages/dashboard/src/__tests__/widget-behaviors.test.tsx, a
    waitFor chart-fallback timing flake in a package this PR does not touch;
    green on re-run.)
  • pnpm typecheck — 99/99. pnpm lint — 0 errors.
  • New mcp-http-parked-approval.test.ts reproduces the reported scenario end
    to end over the real Streamable-HTTP transport: xnet_compose_page for a
    page titled "Fascia" parks, is visible to the host, nothing is written while
    it waits, the host releases it as the operator, the agent's still-open call
    resolves with the real result, the page lands, and the AgentApproval node
    carries the operator's DID. Denial resolves the call and creates no page.
  • New approval-broker.test.ts covers the bounded wait, TTL expiry, dispose,
    subscriptions, and the approved-but-failing vs bad-code distinction.

Verified live in the desktop app. Ran a second instance from this worktree
on an isolated profile and ports (XNET_PROFILE=wt-approval, 5174/9224),
attached over CDP, and drove the exact reported scenario against the real
main-process MCP server:

  • xnet_query (low risk) executed immediately — reads do not park.
  • xnet_create_page applied without parking — it rides the write guardrail, not
    the ceremony.
  • xnet_compose_page titled "Fascia" (high risk) parked, and the approval
    card rendered in the shell: HIGH RISK · Creating a page · via agent bridge · needs your explicit in-app approval as did:key:z6MkpeFWGu4Qsnr1… · expires 8:37:23 AM.
  • Approve was disabled before opening the review and enabled after — the
    review gate holds.
  • Clicking Approve resolved the agent's still-open HTTP call with
    {"applied":true,"pageId":"tD4S5ddhXFY59ExYWbCFs"}, the card cleared, and
    exactly one page titled "Fascia" existed afterwards.

An earlier revision of this description said live verification was unsafe
because the worktree and main checkout shared one better_sqlite3.node inode.
That measurement was wrong — both find calls had run from the main checkout,
so it compared a file to itself. Absolute paths show different inodes
(591072993 vs 716366368); the rebuild ran with another instance live and left it
untouched.

Note for anyone reproducing: the chat bridge daemon's fixed port 31416 is
machine-wide, so it collides with another running instance. That is the chat
transport only — the MCP server binds an ephemeral port and is unaffected.

🤖 Generated with Claude Code

@crs48
crs48 temporarily deployed to pr-665 July 31, 2026 14:39 — with GitHub Actions Inactive
A high-risk write from a bridged coding agent parked in the main-process
recorder and expired unseen: `AgentAuditRecorder.approveFromApp` had exactly
one caller, inside the AI panel's own ceremony, so nothing could release an
action parked anywhere else. Claude Code reported "awaiting in-app approval",
the app rendered nothing, and five minutes later the change was gone.

Adds `createApprovalBroker` — headless park/settle over a recorder, with a
change subscription and a wait bound that is separate from the approval TTL, so
a transport timeout never reads as a decision. `MCPServer` now parks medium+
risk calls and exposes list/approve/deny to its host; the desktop projects them
over IPC into a shell-level approvals layer, which renders the same card the AI
panel does. Approving resumes the agent's original call with the real result.

The panel ceremony is refactored onto the shared broker rather than keeping a
second copy of park/settle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Signed-off-by: xNet Test <test@xnet.dev>
@crs48
crs48 force-pushed the claude/approval-ui-missing-04ea47 branch from d358c5f to 730d30a Compare July 31, 2026 14:40
@crs48
crs48 temporarily deployed to pr-665 July 31, 2026 14:40 — with GitHub Actions Inactive
The story bundle resolves `@xnetjs/plugins` through the shim, so the three new
names the workbench imports (`createApprovalBroker`, `ParkedApproval`,
`ApprovalResolution`) have to be mirrored there. Also applies Prettier to the
three files that missed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: xNet Test <test@xnet.dev>
@crs48
crs48 temporarily deployed to pr-665 July 31, 2026 14:44 — with GitHub Actions Inactive
@github-actions

Copy link
Copy Markdown
Contributor

🖼️ UI changes in this PR

No UI changes detected in this PR.

github-actions Bot added a commit that referenced this pull request Jul 31, 2026
github-actions Bot added a commit that referenced this pull request Jul 31, 2026
@github-actions

github-actions Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Preview removed for PR #665.

@crs48
crs48 temporarily deployed to pr-665 July 31, 2026 15:33 — with GitHub Actions Inactive
github-actions Bot added a commit that referenced this pull request Jul 31, 2026
@crs48
crs48 merged commit 1317eef into main Jul 31, 2026
21 checks passed
@crs48
crs48 deleted the claude/approval-ui-missing-04ea47 branch July 31, 2026 15:51
github-actions Bot added a commit that referenced this pull request Jul 31, 2026

This branch was previously deployed

1 inactive deployment
pr-665 — 6e6a7fed Deployed Jul 31, 2026 by crs48 via deploy-preview #1306
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.

1 participant