Files
deepseek-harness/packages/ui/user-approval
Tianyi Cui 3529b3c166 fix(scope): close final ownership races
Drain idle injection flushes before agent teardown, snapshot approval and subagent provider inputs, and gate subagent lifecycle events on real child readiness. Align the RFCs and generated contracts with the hardened behavior.
2026-07-12 00:31:46 +08:00
..

@deepseek-ai/dsh-user-approval

User-approval seam. Owns the ctx.approval service (ApprovalService) and the one-shot permission vocabulary the harness shares: ApprovalRequest (agent + tool identity + reason + abort signal), the closed ApprovalOutcome union (allowed-once / rejected / cancelled / unavailable), the ApprovalRequestId brand pairing the two log-only audit events (approval/asked / approval/decided), and the approval/request waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI.

The contract in one line: ctx.approval.request(req) puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and always resolves to an outcome, never rejects: an aborted signal yields cancelled, a throwing or missing answerer yields unavailable, and allowed-once is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service shallow-freezes a detached request record before dispatch, preserving the exact agent and AbortSignal identities while making later caller mutation unable to redirect scope, payload, cancellation, or either audit event. Session observers run after an event enters the append-only log; if one throws, the service recognizes that the audit is already authoritative, contains the observer failure, and completes the pair. The one precondition: ask from inside an open turn — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask throws before appending anything.

The service is the mechanism, answerers are the policy. Answerers are approval/request waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling next(), or delegate an agent you don't recognize by calling next() — the chain's built-in default is unavailable, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by req.agent: a listener registered through agent.ctx receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use prepend listeners only for decide-or-delegate gates.

The seam also owns the per-session POLICY tier (the sandbox RFC § Per-session mode switching): ApprovalPolicy is 'ask' (delegate to the answerers) or 'never' (deterministically reject without prompting anyone; the strict CI/unattended stance), with effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy — the session log is the store, written only through setApprovalPolicy(session, policy). The service decides 'never' inside request() itself, before dispatching the waterfall ('never''rejected' with the audit pair still landing; no listener registration, including a later prepend, can precede it), states 'never' — and only 'never' in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced agent/pre-step notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last request/header* reads changed by the user, otherwise changed by the operator/config).

One seam serves both ask paths of the sandbox RFC: the tools/pre-execute ask decision (routed by @deepseek-ai/dsh-tools when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's sandbox_permissions gate in @deepseek-ai/dsh-tool-bashthe sandbox RFC § Escalation). The full design: the approval-seam RFC.

Answerers today: the ACP bridge (@deepseek-ai/dsh-acp) forwards to the editor's session/request_permission prompt for agents it owns. The audit events are log-only session records — the model only ever sees the tool result the asker derives from the outcome.