Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md
T
Turtle f0410d592d feat(web): permission presets and approval answering for the web UI
The web host now composes the sandboxed product path (sandbox-local +
sandbox-policy behind bash-sandbox/fs-sandbox, with user-approval and
permission on top); BootHostOptions.sandbox carries the deployment
defaults (workspace-write + ask).

createApiProxy owns the approval pending registry: a ctx.approval ask
becomes an answerable approval/requested mux frame with a stable rpcId,
replayed verbatim on every mux open until settled; respond routes by the
echoed rpcId, validates the ApprovalResponsePayload audit correlation,
and broadcasts approval/resolved; the ask's abort signal withdraws the
question as cancelled.

session.permissions / session.setPermission project ctx.permission into
a protocol-owned PermissionOption select; idle switches are held
last-write-wins and
flushed into the next prompted turn (the ACP bridge's anchoring
pattern). The shared hasOpenTurn fold moved to dsh-session,
deduplicating the private copies in user-approval, the ACP bridge, and
the proxy.

Client, per the designer draft: a pending approval takes over the
composer (ApprovalPanel replaces the InputBar — amber strip,
justification headline, paired command, one-shot refuse/allow, keyed by
rpcId so a queued second approval remounts live; the resolved frame
restores the composer); the sidebar session row shows an amber
waiting-approval dot that outranks the running ring (manager-tracked
approvalId set, idempotent under mux-open replays, cleared per
connection generation, lit for uninstantiated sessions too); the
permission selector is a composer bottom-row chip over an invisible
native select, with a presentation-only title-case transform
(workspace-write renders as Workspace Write; wire names untouched). Question placeholders stay in the message flow. The
connection fixture mirrors the host behavior for keyless browser
acceptance.
2026-07-24 19:15:04 +08:00

5.8 KiB

Agent Note: Web UI permission presets and approval answering

Status: implemented

English | 中文

Problem

The web host booted an unconfined agent: bootHost composed dsh-bash-local and dsh-fs-local, so every web session ran with full file access, no approval channel, and no permission control — while the ACP composition had shipped the complete sandboxed product path (sandbox provider + policy home + confined bash/fs + approval + presets) for months. The web wire contract had already reserved the seats — approval/requested/approval/resolved mux frames, POST /api/respond with ApprovalResponsePayload, client-side pendingBuffers — but the host respond was a stub, no answerer bridged ctx.approval to the stream, no RPC exposed the permission select, and the PendingCard rendered approvals as visible-but-unanswerable.

Decision

The web host composes the same sandboxed product path as the acp-agent composition: dsh-sandbox-local, dsh-sandbox-policy, dsh-bash-sandbox, dsh-fs-sandbox, dsh-user-approval, and dsh-permission, with BootHostOptions.sandbox supplying the deployment defaults (mode, default workspace-write; approvalPolicy, default ask).

createApiProxy owns the approval pending registry. Its approval/request waterfall answerer reads the approval id from the session's just-appended approval/asked audit event (an ask with no audit event is a foreign channel and delegates), mints one stable rpcId per question, broadcasts the answerable approval/requested frame to every open mux stream, and replays still-pending frames verbatim on each mux open — the refresh-recovery baseline the contract already promised. respond routes by the echoed rpcId, validates ApprovalResponsePayload with the existing zod schema, cross-checks the payload's audit correlation against the routed entry, resolves the answerer, and broadcasts approval/resolved; the ask's abort signal withdraws the question as cancelled.

The permission select rides two new unary RPCs, session.permissions and session.setPermission, projecting ctx.permission into a protocol-owned PermissionOption DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on agent/prompt-submit, because knob events must stay turn-enclosed for durable replay; the shared hasOpenTurn fold moved to dsh-session and replaced the private copies in dsh-user-approval, the ACP bridge, and the proxy.

Client-side, Session gained permissions and setPermission, and approval answering rides the runtime's PendingWait carrier. Per the designer draft, a pending approval takes over the composer: ApprovalPanel registers as a selector-routed entry of the conversation-declared conversation.composer chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the PendingApproval domain face in ui-conversation's contract owns the ApprovalResponsePayload wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Question placeholders stay in the message flow. The sidebar mirrors the blocked state with an amber warning dot that outranks the running ring: the manager tracks per-session outstanding approvalIds (idempotent under mux-open replays, cleared per connection generation so the reopen replay is authoritative) rather than reading Session instances, so the dot lights for sessions never instantiated. The composer's bottom-row chip hosts the PermissionSelect control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session.

Alternatives considered

Reuse the ACP session/set_config_option shape on the web wire. Rejected: the web contract's unary method registry (RpcMethodMap + per-method zod schemas) is its own dialect; a generic config-option surface would bypass the compiler-locked schema table for one select. A dedicated method pair keeps both sides derivable from the signature.

A session event for pending approvals instead of a proxy-side registry. Rejected: approval requests are transient interaction state, not durable session data — the approval/asked/decided audit pair already logs the durable half. Persisting requested frames would re-ask dead questions on replay.

Registering the answerer only when a mux subscriber exists. Rejected: the pending entry must survive client disconnects (refresh recovery is the point), so the registry outlives any one stream; a subscriber-gated answerer would fail asks closed during a reload window.

Optimistic card removal on click. Rejected: the broadcast resolved frame is the truth; removing on click would hide a question that a rejected receipt or transport failure left standing. The panel disables its buttons locally and re-arms them on failure instead.

Consequences

Web sessions now start confined (workspace-write + ask by default) and a sandbox-denial escalation reaches the browser as an answerable card; the deployment can widen or narrow the default through BootHostOptions.sandbox without touching the assembly. Question answering shipped separately through the same registry pattern (ui-question over the question pending table). The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage: proxy registry and permission RPC unit suites, session-object and fixture unit suites, and the keyless web smoke exercises the fixture-mode approval answer and preset switch in a real browser.