Repository navigation
fix(plugins): make parked agent approvals reachable from the app - #665
Merged
Merged
Conversation
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
force-pushed
the
claude/approval-ui-missing-04ea47
branch
from
July 31, 2026 14:40
d358c5f to
730d30a
Compare
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>
Contributor
🖼️ UI changes in this PRNo UI changes detected in this PR. |
Contributor
|
Preview removed for PR #665. |
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_pageisrisk: 'high', sosurfaceForRiskreturns
'app'with no nonce — chat mechanically cannot release it, bydesign. The only release path is
AgentAuditRecorder.approveFromApp.That method had exactly one caller in the repo:
ai-chat-ceremony.ts, whoseceremony object is only constructed inside
AiChatPanelwhen the panel runsits 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
xnetAgentBridgeexposed onlystatus/start/stop. Theaction was genuinely parked with no reachable approver.
The fix
createApprovalBroker(new,@xnetjs/plugins) — headless park/settleover a recorder plus a change subscription.
maxWaitMsbounds how long acaller waits without un-parking the action, so a transport request timeout
never looks like a decision.
MCPServerparks medium+ risk calls instead of returning a pendingpayload, and exposes
listParkedApprovals(),onParkedApprovalsChanged(),approveParkedApproval(actionId, approverDID)and
denyParkedApproval()to its host.xnet:agent-approvals:*) into a newshell-level
AgentApprovalsLayer, mounted inWorkbenchrather than the AIpanel — 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.
ApprovalCardis extracted and shared, so a panel-parked action and abridge-parked one are the same decision presented the same way.
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
approveWithCodedistinguishes "wrong code" from "approved, and the tool thenthrew" — the recorder throws for both, and conflating them would report a bad
code while an approved action was failing underneath. And
xnet_denynowthrows rather than reporting
{ denied: true }when nothing was parked.Behaviour change for embedders
With
agentAuditconfigured, a medium+ risktools/callno longer resolvesimmediately with
{ pending: true }. It resolves when the operator decides,or — after
approvalWaitMs(default 55s) — with a still-pending payload thatleaves 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 twofailures in
packages/dashboard/src/__tests__/widget-behaviors.test.tsx, awaitForchart-fallback timing flake in a package this PR does not touch;green on re-run.)
pnpm typecheck— 99/99.pnpm lint— 0 errors.mcp-http-parked-approval.test.tsreproduces the reported scenario endto end over the real Streamable-HTTP transport:
xnet_compose_pagefor apage 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
AgentApprovalnodecarries the operator's DID. Denial resolves the call and creates no page.
approval-broker.test.tscovers 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_pageapplied without parking — it rides the write guardrail, notthe ceremony.
xnet_compose_pagetitled "Fascia" (high risk) parked, and the approvalcard 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.review gate holds.
{"applied":true,"pageId":"tD4S5ddhXFY59ExYWbCFs"}, the card cleared, andexactly 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.nodeinode.That measurement was wrong — both
findcalls 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