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.
This commit is contained in:
Turtle
2026-07-24 13:39:00 +08:00
parent 0133e80767
commit f0410d592d
69 changed files with 1744 additions and 139 deletions

View File

@@ -24,6 +24,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `dsh-host-runtime` and is still a stub there.
- **`respond` is routed by host-side pending tables** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final here; the approval and question registries that make late/duplicate answers meaningful live in `dsh-host-runtime`.
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.

View File

@@ -19,7 +19,7 @@ export interface ApiProxy {
}
// ---- Domain interfaces and payload entities ----
export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts'
export type { HistoryEntry, PermissionOption, SessionsApi, SessionSummary } from './sessions.ts'
export type { HostApi } from './host.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { ApprovalResponsePayload } from './approvals.ts'

View File

@@ -15,6 +15,8 @@ export interface RpcMethodMap {
'session.history': SessionsApi['history']
'session.prompt': SessionsApi['prompt']
'session.cancel': SessionsApi['cancel']
'session.permissions': SessionsApi['permissions']
'session.setPermission': SessionsApi['setPermission']
'host.describe': HostApi['describe']
}

View File

@@ -9,7 +9,7 @@ import { z } from 'zod'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type { HistoryEntry, SessionSummary } from './sessions.ts'
import type { HistoryEntry, PermissionOption, SessionSummary } from './sessions.ts'
import type { ToolEventView } from './events.ts'
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
@@ -108,3 +108,32 @@ export const sessionCancelRequestSchema = z.object({
export const sessionCancelValueSchema = z.object({
accepted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'session.cancel'>>>
/** One permission select option (a preset table key, or the derived `custom`). */
export const permissionOptionSchema = z.object({
value: z.string(),
name: z.string(),
description: z.string().optional(),
}) satisfies z.ZodType<Wire<PermissionOption>>
/** session.permissions request payload. */
export const sessionPermissionsRequestSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'session.permissions'>>>
/** session.permissions response value. */
export const sessionPermissionsValueSchema = z.object({
options: z.array(permissionOptionSchema),
currentValue: z.string(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.permissions'>>>
/** session.setPermission request payload. */
export const sessionSetPermissionRequestSchema = z.object({
sessionId: sessionIdSchema,
value: z.string(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.setPermission'>>>
/** session.setPermission response value. */
export const sessionSetPermissionValueSchema = z.object({
currentValue: z.string(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.setPermission'>>>

View File

@@ -44,6 +44,21 @@ export interface SessionSummary {
cwd?: string
}
/**
* One selectable permission preset (or the derived `custom` state) as the
* client renders it. Protocol-owned DTO (the ACP bridge precedent: each
* protocol owns its presentation shape); the host projects it from
* `ctx.permission` without exposing that service's types on the wire.
*/
export interface PermissionOption {
/** The machine value (`session.setPermission` vocabulary): a preset table key, or `custom`. */
value: string
/** The display label. */
name: string
/** One user-facing sentence on what the value means. */
description?: string
}
/** Session-domain unary methods (the map keys session.* of RpcMethodMap). */
export interface SessionsApi {
/** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */
@@ -70,4 +85,24 @@ export interface SessionsApi {
/** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */
cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ accepted: true }>>
/**
* Reads the session's permission select: every switchable preset plus the
* effective current value (`custom` when the knobs match no preset — shown,
* never a switch target). A host composed without the permission service
* returns empty options and `custom`; clients hide the control.
*/
permissions(request: RpcRequest<{ sessionId: SessionId }>):
Promise<RpcResponse<{ options: PermissionOption[]; currentValue: string }>>
/**
* Switches the session's permission preset. Mirrors the ACP bridge's
* turn-anchoring: inside an open turn the knob events append immediately;
* idle switches are held last-write-wins and flushed into the next prompted
* turn (approval-policy and sandbox-mode events must stay turn-enclosed for
* durable replay). A current-value echo is acknowledged without recording.
* Unknown values and a permission-less composition are bad-request.
*/
setPermission(request: RpcRequest<{ sessionId: SessionId; value: string }>):
Promise<RpcResponse<{ currentValue: string }>>
}

View File

@@ -19,7 +19,9 @@ import {
sessionCreateValueSchema,
sessionHistoryValueSchema,
sessionListValueSchema,
sessionPermissionsValueSchema,
sessionPromptValueSchema,
sessionSetPermissionValueSchema,
} from '../api/sessions.schema.ts'
/**
@@ -44,6 +46,8 @@ export interface IApiClient {
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
permissions(payload: RequestPayload<'session.permissions'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.permissions'>>>
setPermission(payload: RequestPayload<'session.setPermission'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.setPermission'>>>
}
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
@@ -66,6 +70,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.history': sessionHistoryValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.cancel': sessionCancelValueSchema,
'session.permissions': sessionPermissionsValueSchema,
'session.setPermission': sessionSetPermissionValueSchema,
'host.describe': hostDescribeValueSchema,
}
@@ -247,6 +253,8 @@ export abstract class AbstractApiClient implements IApiClient {
history: (payload, signal) => this.callUnary('session.history', payload, signal),
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
permissions: (payload, signal) => this.callUnary('session.permissions', payload, signal),
setPermission: (payload, signal) => this.callUnary('session.setPermission', payload, signal),
}
readonly host: IApiClient['host'] = {

View File

@@ -19,7 +19,9 @@ import {
sessionCreateRequestSchema,
sessionHistoryRequestSchema,
sessionListRequestSchema,
sessionPermissionsRequestSchema,
sessionPromptRequestSchema,
sessionSetPermissionRequestSchema,
} from '../api/sessions.schema.ts'
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
@@ -43,6 +45,8 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'session.permissions': { schema: sessionPermissionsRequestSchema, invoke: (api, r) => api.sessions.permissions(r) },
'session.setPermission': { schema: sessionSetPermissionRequestSchema, invoke: (api, r) => api.sessions.setPermission(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
}

View File

@@ -31,6 +31,8 @@ function scriptedApi(overrides: {
history: r => ok(r, { events: [], hasMore: false }),
prompt: r => ok(r, { accepted: true as const }),
cancel: r => ok(r, { accepted: true as const }),
permissions: r => ok(r, { options: [], currentValue: 'custom' }),
setPermission: r => ok(r, { currentValue: r.payload.value }),
...overrides.sessions,
},
host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host },

View File

@@ -36,6 +36,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async cancel(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
async permissions(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { options: [], currentValue: 'custom' } } }
},
async setPermission(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { currentValue: request.payload.value } } }
},
},
host: {
async describe(request) {
@@ -75,11 +81,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
})
it('covers create/prompt/cancel/describe passthrough', async () => {
it('covers create/prompt/cancel/permissions/setPermission/describe passthrough', async () => {
const c = client()
expect((await c.sessions.create({})).result.ok).toBe(true)
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
expect((await c.sessions.permissions({ sessionId: 's' as never })).result.ok).toBe(true)
expect((await c.sessions.setPermission({ sessionId: 's' as never, value: 'workspace-write' })).result.ok).toBe(true)
expect((await c.host.describe({})).result.ok).toBe(true)
})
})

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-host-runtime
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, the provider-neutral user-interaction service, and the sandboxed product path — `dsh-sandbox-local` + `dsh-sandbox-policy` behind the confined `dsh-bash-sandbox`/`dsh-fs-sandbox` families, with `dsh-user-approval` and `dsh-permission` on top), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
@@ -15,11 +15,15 @@ Which plugins mount and with what defaults is decided only here — shells must
| `cwd` | `process.cwd()` | Default project directory for a session whose create request omits `cwd`. |
| `sessionTitle` | 5 words / 40 fallback bytes / 80 accepted bytes | Deterministic fallback and accepted-title limits. |
| `sessionTitleLlm` | disabled | `true` enables the 5-word / 10-CJK-character, 4,096-input-byte, 64-output-token, 60-second first-message policy; an explicit config overrides it. An omitted route inherits the logged main-request provider and model. |
| `sandbox.mode` | `'workspace-write'` | File-sandbox mode sessions start from (`ctx.sandboxPolicy` default; per-session switches ride `sandbox/mode` events). |
| `sandbox.approvalPolicy` | `'ask'` | Approval policy for sessions without an `approval/policy` override. |
## ApiProxy implementation notes
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
The proxy is also the approval channel for the agents this host owns: an ask through `ctx.approval` becomes an answerable `approval/requested` mux frame with a stable rpcId held in a pending table, replayed verbatim on every mux open until settled. `respond` routes by the echoed rpcId (approvals first, then questions), validates the `ApprovalResponsePayload` audit correlation at the wire boundary, resolves the answerer, and broadcasts `approval/resolved`; the ask's own abort signal withdraws the question as `cancelled`. `session.permissions`/`session.setPermission` project `ctx.permission` (empty select when not composed); idle switches are held last-write-wins and flushed into the next prompted turn on `agent/prompt-submit`, because knob events must stay turn-enclosed for durable replay (the ACP bridge's anchoring pattern).
## Model Experience
Indirectly, through the non-blocking first-message title request owned by [`dsh-session-title-llm`](../../session-title/session-title-llm/README.md) when `sessionTitleLlm` is enabled, the provider/model defaults injected into created and resumed agents, the other model-facing plugins `bootHost` mounts, and the logged [workspace-instruction prefix](../../context/workspace-context/README.md#prompt-shape) when `workspaceContext` is enabled.
@@ -30,6 +34,6 @@ No main-request invalidation; when enabled, the auxiliary title request has its
## Known Limitations and Deferred Work
- **Question waits are process-memory state** — browser reconnects recover them, but a host process restart aborts the owning tool call instead of restoring the wait from persistence.
- **Question and approval waits are process-memory state** — browser reconnects recover them, but a host process restart aborts the owning tool call instead of restoring the wait from persistence.
- **`host.describe.version` is a placeholder** — it does not yet report the `apps/cli` package version.
- **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet.

View File

@@ -31,13 +31,17 @@
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
@@ -63,6 +67,7 @@
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^"

View File

@@ -1,19 +1,31 @@
/**
* Host-side ApiProxy implementation. Signature discipline: unary takes the
* narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
* Host-side ApiProxy implementation. Unary methods, both streams, and the
* pending-interaction registries are real: the approval registry turns
* `ctx.approval` asks into answerable `approval/requested` mux frames and the
* question provider does the same for `ask_user_question`; both are answered
* through `respond` (routed by the echoed rpcId). Signature discipline: unary
* takes the narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
*/
import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import type { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { hasOpenTurn } from '@deepseek-ai/dsh-session'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
// Side-effect type imports: resolve `ctx.approval` / `ctx.get('permission')`
// without value dependencies on the seams (both are optional compositions here).
import type {} from '@deepseek-ai/dsh-user-approval'
import type {} from '@deepseek-ai/dsh-permission'
import type {
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
import { approvalResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/approvals.schema'
import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -183,6 +195,36 @@ interface ToolCallData { callId: string; name: string; arguments: string }
/** The tool/result payload fields the presenter path reads. */
interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue }
/**
* One outstanding approval question: the stable server-request id, the frame
* material replayed to late mux subscribers, and the resolver that settles the
* answerer's promise back into `ctx.approval`.
*/
interface PendingApproval {
rpcId: RpcId
sessionId: SessionId
approvalId: ApprovalRequestId
toolName: string
callId?: CallId
reason?: string
resolve(outcome: ApprovalOutcome): void
}
/** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */
function requestedFrame(pending: PendingApproval): RpcRequest<MuxFrame> {
return {
rpcId: pending.rpcId,
payload: {
type: 'approval/requested',
sessionId: pending.sessionId,
approvalId: pending.approvalId,
toolName: pending.toolName,
...pending.callId === undefined ? {} : { callId: pending.callId },
...pending.reason === undefined ? {} : { reason: pending.reason },
},
}
}
/** One host-owned question wait, addressed by the stable server-request id. */
interface PendingQuestion {
rpcId: RpcId
@@ -283,6 +325,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
const resumes = new Map<SessionId, Promise<Agent>>()
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const pendingApprovals = new Map<RpcId, PendingApproval>()
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
/** Send one transient frame to every connected mux consumer. */
@@ -341,6 +384,95 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
}, 'api-proxy: user-interaction provider')
// --- Approval pending registry ------------------------------------------
// The proxy is the approval channel for every agent this host owns: an ask
// through `ctx.approval` becomes an answerable server-request on the mux
// stream (stable rpcId), settled by POST /api/respond. The entry survives
// client disconnects — mux-open replays still-pending requested frames with
// the same rpcId (the refresh-recovery baseline) — and withdraws on the
// ask's own abort signal (turn cancel), pushing `cancelled` to subscribers.
if (ctx.get('approval') !== undefined) {
ctx.on('approval/request', (req, next) => {
// The audit pair `approval/asked` is already appended by the service
// before dispatch, but dispatch rides a microtask: parallel tool calls
// can append several asked events before any answerer runs. THIS
// request's event is therefore the newest asked event that is still
// undecided, unclaimed by another pending entry, and — when the ask
// names a call — carries the same callId.
const events = req.agent.session.events
const claimed = new Set<ApprovalRequestId>()
for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId)
const decided = new Set<ApprovalRequestId>()
let approvalId: ApprovalRequestId | undefined
for (let i = events.length - 1; i >= 0; i -= 1) {
const event = events[i] as SessionEvent
if (event.type === 'approval/decided') {
decided.add(event.data.id)
} else if (event.type === 'approval/asked') {
if (decided.has(event.data.id) || claimed.has(event.data.id)) continue
if (req.callId !== undefined && event.data.callId !== req.callId) continue
approvalId = event.data.id
break
}
}
// No asked event means the request bypassed the service's audit path —
// not this channel's question; delegate to the fail-closed default.
if (approvalId === undefined) return next()
const id = approvalId
return new Promise<ApprovalOutcome>((resolve) => {
const settle = (outcome: ApprovalOutcome): void => {
/* v8 ignore next 3 -- defensive double-settle guard: respond() routes
through the pending table (a settled id is not-pending before it can
re-settle) and the first settle removes the abort listener, so no
reachable path settles twice; kept against future settle callers. */
if (!pendingApprovals.delete(pending.rpcId)) return
req.signal?.removeEventListener('abort', onAbort)
broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome })
// A cancelled ask was already settled by the service's own signal
// race, which discards this late resolution; resolving is a no-op
// there and keeps this promise from dangling forever.
resolve(outcome)
}
const onAbort = (): void => { settle('cancelled') }
const pending: PendingApproval = {
rpcId: RpcId(randomUUID()),
sessionId: req.agent.session.id,
approvalId: id,
toolName: req.toolName,
...req.callId === undefined ? {} : { callId: req.callId },
...req.reason === undefined ? {} : { reason: req.reason },
resolve: settle,
}
pendingApprovals.set(pending.rpcId, pending)
req.signal?.addEventListener('abort', onAbort, { once: true })
const envelope = requestedFrame(pending)
for (const queue of muxQueues) queue.push(envelope)
})
})
}
// --- Permission switch anchoring ----------------------------------------
// Knob events (`permission/preset`, `sandbox/mode`, `approval/policy`) must
// be turn-enclosed for durable replay, so an idle switch is held here
// last-write-wins and flushed when the next prompted turn opens (the ACP
// bridge's pendingSwitches pattern; prompt-submit is inside the new turn but
// before prompt assembly, so the switch is visible to that turn's request).
const pendingSwitches = new Map<SessionId, string>()
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) => {
const preset = pendingSwitches.get(agent.session.id)
if (preset !== undefined) {
pendingSwitches.delete(agent.session.id)
const presets = ctx.get('permission')
/* v8 ignore next -- a pending preset exists only if setPermission saw the
service; it cannot unmount between that and the next turn here. */
if (presets !== undefined) presets.set(agent.session, preset)
}
return next()
})
ctx.on('session/disposed', (session: Session) => {
pendingSwitches.delete(session.id)
})
/**
* Gate the cold path on the store: an id absent from it, or naming a legacy
* log without a cwd (pre-release stance: not served, no compatibility), is
@@ -468,6 +600,48 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
agent.cancel()
return Promise.resolve(ok(request, { accepted: true as const }))
},
async permissions(request) {
const { sessionId } = request.payload
const presets = ctx.get('permission')
// A permission-less composition advertises an empty select (client
// hides the control) rather than erroring — the control's absence is
// deployment shape, not a caller mistake.
if (presets === undefined) return ok(request, { options: [], currentValue: 'custom' })
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const events = found.agent.session.events
const currentValue = pendingSwitches.get(sessionId) ?? presets.current(events)
const options = [
...presets.names.map(name => presets.optionOf(name)),
// `custom` echoes the current derived state but is never a target.
...currentValue === 'custom' ? [presets.optionOf('custom')] : [],
]
return ok(request, { options, currentValue })
},
async setPermission(request) {
const { sessionId, value } = request.payload
const presets = ctx.get('permission')
if (presets === undefined) {
return err(request, { code: 'bad-request', message: 'no permission service is composed on this host', details: { issues: [] } })
}
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const agent = found.agent
// A current-value echo is acknowledged without recording a switch.
const current = pendingSwitches.get(sessionId) ?? presets.current(agent.session.events)
if (value === current) return ok(request, { currentValue: value })
if (!presets.names.includes(value)) {
return err(request, { code: 'bad-request', message: `unknown permission value ${JSON.stringify(value)}`, details: { issues: [] } })
}
// Turn-anchoring (the ACP bridge pattern): knob events must be enclosed
// by the durable log's turn boundary, so an idle switch is held
// last-write-wins and flushed into the next prompted turn.
if (hasOpenTurn(agent.session.events)) presets.set(agent.session, value)
else pendingSwitches.set(sessionId, value)
return ok(request, { currentValue: value })
},
},
host: {
@@ -499,6 +673,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
})
}
// Refresh recovery: still-pending approval questions replay with their
// stable rpcId so a reconnecting client can still answer them.
for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending))
// Per-session open-call table for result-view pairing. Bounded by the
// per-turn call count: entries clear on turn/end; a table miss (stream
// opened mid-turn) backscans the session's in-memory events instead.
@@ -564,6 +741,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// Route by the echoed rpcId (the wire correlation): approvals first,
// then questions — the two registries share one id space of UUIDs.
const approval = pendingApprovals.get(message.rpcId)
if (approval !== undefined) {
if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
const parsed = approvalResponsePayloadSchema.safeParse(message.result.value)
// The payload's audit correlation must match the entry the rpcId routed
// to — a mismatched answer is malformed, not merely late.
if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
approval.resolve(parsed.data.outcome)
return Promise.resolve({ accepted: true })
}
const pending = pendingQuestions.get(message.rpcId)
if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
if (!message.result.ok) {

View File

@@ -18,11 +18,17 @@ import TaskService from '@deepseek-ai/dsh-tasks'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import SandboxLocal from '@deepseek-ai/dsh-sandbox-local'
import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import SandboxBashExecutor from '@deepseek-ai/dsh-bash-sandbox'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import PermissionService from '@deepseek-ai/dsh-permission'
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
import * as toolTodo from '@deepseek-ai/dsh-tool-todo'
import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
import FsLocal from '@deepseek-ai/dsh-fs-local'
import FsSandbox from '@deepseek-ai/dsh-fs-sandbox'
import * as fsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as toolFs from '@deepseek-ai/dsh-tool-fs'
import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
@@ -73,6 +79,17 @@ export interface BootHostOptions {
sessionTitle?: SessionTitleConfig
/** Opt-in first-message model-title policy; `true` selects host defaults and an explicit config overrides them. */
sessionTitleLlm?: true | SessionTitleLlmConfig
/**
* Sandbox/approval composition knobs. The host always composes the confined
* bash + fs families over `ctx.sandboxPolicy` (the acp-agent composition);
* these fields choose the deployment defaults every session starts from.
*/
sandbox?: {
/** File-sandbox mode sessions start from (default `workspace-write`). */
mode?: SandboxMode
/** Approval policy for sessions without an override (default `ask`). */
approvalPolicy?: ApprovalPolicy
}
/**
* Default project directory for sessions created without an explicit cwd
* (defaults to the host process working directory). A session's cwd is its
@@ -131,16 +148,30 @@ export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, {})
await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot })
await ctx.plugin(LocalBashExecutor, {})
// The sandboxed product path (the acp-agent composition, sandbox Agent
// Note): per-platform runner provider, the shared policy home, the confined
// bash executor, and the approval seam its escalation asks through. Sessions
// start from the configured default mode; per-session switches ride the
// `sandbox/mode` / `approval/policy` events written by ctx.permission.
await ctx.plugin(SandboxLocal, {})
await ctx.plugin(SandboxPolicy, {
mode: options.sandbox?.mode ?? 'workspace-write',
workspaceRoot: defaults.cwd,
})
await ctx.plugin(SandboxBashExecutor, {})
await ctx.plugin(ApprovalService, { policy: options.sandbox?.approvalPolicy ?? 'ask' })
// Tool suite mirroring the demo:repl composition (repl-agent/cordis.yml +
// the agent-spine bundle) so web sessions get the same coding-agent tool
// face; deviations are noted inline.
await ctx.plugin(toolBash, {})
// Presets over the two knobs (requires the confining executor + approval).
await ctx.plugin(PermissionService, {})
await ctx.plugin(toolTodo)
await ctx.plugin(toolTasks, {})
// fs paths resolve against the host default project rather than the raw
// process cwd — the same source create() injects into session.cwd.
await ctx.plugin(FsLocal, { cwd: defaults.cwd })
// process cwd — the same source create() injects into session.cwd. The
// sandboxed backend fences write/edit by the same policy as bash.
await ctx.plugin(FsSandbox, { cwd: defaults.cwd })
await ctx.plugin(fsPolicy)
await ctx.plugin(toolFs, {})
await ctx.plugin(toolFsSearch, {})

View File

@@ -0,0 +1,275 @@
/**
* Approval pending registry over the proxy: an ask through `ctx.approval`
* becomes an answerable `approval/requested` mux frame (stable rpcId, replayed
* verbatim on a later mux open), `respond` routes by the echoed rpcId and
* validates the audit correlation, and the ask's abort signal withdraws the
* question with a broadcast `cancelled`.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId as mintRpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(ApprovalService)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
return { ctx, api }
}
/** A minimal agent stand-in inside an open turn (the service only reaches `.session`). */
function agentOf(ctx: Context): Agent {
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
return { session } as unknown as Agent
}
/** Open a mux stream and capture frames into an array (returns an on-demand waiter). */
function openMux(api: ApiProxy, abort: AbortController): { frames: MuxFrame[]; envelopes: RpcRequest<MuxFrame>[]; waitFor(type: MuxFrame['type']): Promise<MuxFrame> } {
const frames: MuxFrame[] = []
const envelopes: RpcRequest<MuxFrame>[] = []
const waiters: { type: MuxFrame['type']; resolve: (frame: MuxFrame) => void }[] = []
void (async () => {
for await (const envelope of api.events.mux({ rpcId: mintRpcId('t-mux'), payload: {} }, abort.signal)) {
frames.push(envelope.payload)
envelopes.push(envelope)
for (let i = waiters.length - 1; i >= 0; i -= 1) {
const waiter = waiters[i] as (typeof waiters)[number]
if (waiter.type === envelope.payload.type) {
waiters.splice(i, 1)
waiter.resolve(envelope.payload)
}
}
}
})()
return {
frames,
envelopes,
waitFor: (type) => {
const found = frames.find(frame => frame.type === type)
if (found !== undefined) return Promise.resolve(found)
return new Promise((resolve) => { waiters.push({ type, resolve }) })
},
}
}
function requestedOf(frame: MuxFrame): Extract<MuxFrame, { type: 'approval/requested' }> {
if (frame.type !== 'approval/requested') throw new Error(`expected approval/requested, got ${frame.type}`)
return frame
}
/** Wait until the stream delivered `count` frames of `type` (bounded poll; waitFor only covers the first). */
async function waitForCount(mux: { frames: MuxFrame[] }, type: MuxFrame['type'], count: number): Promise<void> {
for (let i = 0; i < 200 && mux.frames.filter(frame => frame.type === type).length < count; i += 1) {
await new Promise(resolve => setTimeout(resolve, 5))
}
expect(mux.frames.filter(frame => frame.type === type).length).toBeGreaterThanOrEqual(count)
}
function answer(rpcId: RpcId, sessionId: unknown, approvalId: ApprovalRequestId, outcome: 'allowed-once' | 'rejected'): Parameters<ApiProxy['respond']>[0] {
return { type: 'client-response', rpcId, result: { ok: true, value: { sessionId, approvalId, outcome } } }
}
describe('approval pending registry', () => {
it('round-trips ask → requested frame → respond → outcome + resolved broadcast', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const agent = agentOf(ctx)
const asked = ctx.approval.request({ agent, toolName: 'bash', reason: 'sandbox escalation' })
const requested = requestedOf(await mux.waitFor('approval/requested'))
expect(requested).toMatchObject({ toolName: 'bash', reason: 'sandbox escalation', sessionId: agent.session.id })
const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
const receipt = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once'))
expect(receipt).toEqual({ accepted: true })
await expect(asked).resolves.toBe('allowed-once')
const resolved = await mux.waitFor('approval/resolved')
expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'allowed-once' })
// The question settled: a duplicate answer is late, not re-decidable.
const dup = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'rejected'))
expect(dup).toEqual({ accepted: false, reason: 'not-pending' })
abort.abort()
})
it('replays a still-pending requested frame (same rpcId) on a later mux open', async () => {
const { ctx, api } = await harness()
const first = new AbortController()
const firstMux = openMux(api, first)
const agent = agentOf(ctx)
const asked = ctx.approval.request({ agent, toolName: 'write' })
const requested = requestedOf(await firstMux.waitFor('approval/requested'))
const firstEnvelope = firstMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
first.abort()
// A fresh subscriber (refresh recovery) sees the same stable rpcId.
const second = new AbortController()
const secondMux = openMux(api, second)
const replayed = requestedOf(await secondMux.waitFor('approval/requested'))
const secondEnvelope = secondMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
expect(secondEnvelope.rpcId).toBe(firstEnvelope.rpcId)
expect(replayed.approvalId).toBe(requested.approvalId)
const receipt = await api.respond(answer(secondEnvelope.rpcId, replayed.sessionId, replayed.approvalId, 'rejected'))
expect(receipt).toEqual({ accepted: true })
await expect(asked).resolves.toBe('rejected')
second.abort()
})
it('rejects malformed and mismatched answers as bad-response, unknown ids as not-pending', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const agent = agentOf(ctx)
void ctx.approval.request({ agent, toolName: 'bash' })
const requested = requestedOf(await mux.waitFor('approval/requested'))
const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
// Unknown rpcId: not routed to any pending entry.
expect(await api.respond(answer(mintRpcId('ghost'), requested.sessionId, requested.approvalId, 'rejected')))
.toEqual({ accepted: false, reason: 'not-pending' })
// Error-branch result: the client can only answer with a value.
expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
// Wrong audit correlation: the rpcId routed, but the payload disagrees.
expect(await api.respond(answer(envelope.rpcId, requested.sessionId, 'other-approval' as ApprovalRequestId, 'rejected')))
.toEqual({ accepted: false, reason: 'bad-response' })
// Malformed payload shape.
expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: true, value: { nonsense: 1 } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
abort.abort()
})
it('withdraws the question on the ask signal: cancelled outcome, resolved broadcast, late answer not-pending', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const agent = agentOf(ctx)
const cancel = new AbortController()
const asked = ctx.approval.request({ agent, toolName: 'bash', signal: cancel.signal })
const requested = requestedOf(await mux.waitFor('approval/requested'))
const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
cancel.abort()
await expect(asked).resolves.toBe('cancelled')
const resolved = await mux.waitFor('approval/resolved')
expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' })
expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once')))
.toEqual({ accepted: false, reason: 'not-pending' })
abort.abort()
})
it('carries callId on the frame and ignores a late abort after the answer settled', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const agent = agentOf(ctx)
const cancel = new AbortController()
const asked = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-9' as never, signal: cancel.signal })
const requested = requestedOf(await mux.waitFor('approval/requested'))
expect(requested.callId).toBe('call-9')
const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once')))
.toEqual({ accepted: true })
await expect(asked).resolves.toBe('allowed-once')
// Late abort: the pending entry is gone; settle's delete-guard returns.
cancel.abort()
expect(mux.frames.filter(f => f.type === 'approval/resolved')).toHaveLength(1)
abort.abort()
})
it('pairs parallel asks by callId: each requested frame carries its own audit id', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const agent = agentOf(ctx)
// Both asks append their approval/asked audit events before either
// answerer's microtask dispatch runs — the parallel tool-call window.
const askA = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-a' as never })
const askB = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-b' as never })
await waitForCount(mux, 'approval/requested', 2)
const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested')
const frameA = frames.find(e => requestedOf(e.payload).callId === 'call-a') as RpcRequest<MuxFrame>
const frameB = frames.find(e => requestedOf(e.payload).callId === 'call-b') as RpcRequest<MuxFrame>
// Each frame claimed the asked event with its own callId, not merely the newest.
const askedIdByCall = new Map(agent.session.events
.filter(event => event.type === 'approval/asked')
.map(event => [String(event.data.callId), event.data.id]))
expect(requestedOf(frameA.payload).approvalId).toBe(askedIdByCall.get('call-a'))
expect(requestedOf(frameB.payload).approvalId).toBe(askedIdByCall.get('call-b'))
// Answers route back to the right ask through the pairing.
expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected')))
.toEqual({ accepted: true })
expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once')))
.toEqual({ accepted: true })
await expect(askA).resolves.toBe('allowed-once')
await expect(askB).resolves.toBe('rejected')
abort.abort()
})
it('gives parallel callId-less asks distinct audit ids (claimed-entry skip); both stay answerable', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const agent = agentOf(ctx)
const askA = ctx.approval.request({ agent, toolName: 'alpha' })
const askB = ctx.approval.request({ agent, toolName: 'beta' })
await waitForCount(mux, 'approval/requested', 2)
const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested')
const frameA = frames.find(e => requestedOf(e.payload).toolName === 'alpha') as RpcRequest<MuxFrame>
const frameB = frames.find(e => requestedOf(e.payload).toolName === 'beta') as RpcRequest<MuxFrame>
// Without a callId the pairing is heuristic, but never shared: the second
// dispatch skips the id the first pending entry already claimed.
expect(requestedOf(frameA.payload).approvalId).not.toBe(requestedOf(frameB.payload).approvalId)
expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once')))
.toEqual({ accepted: true })
expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected')))
.toEqual({ accepted: true })
await expect(askA).resolves.toBe('allowed-once')
await expect(askB).resolves.toBe('rejected')
abort.abort()
})
it('delegates a dispatch whose only asked candidate is already decided (stale re-dispatch)', async () => {
const { ctx, api } = await harness()
void api // the answerer is registered; the fake below bypasses the service
// Bypass ApprovalService: a log whose sole asked event already has its
// decided partner must not be re-claimed — the answerer delegates.
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('approval/asked', { id: 'stale-ask' as ApprovalRequestId, toolName: 'bash' })
session.append('approval/decided', { id: 'stale-ask' as ApprovalRequestId, outcome: 'rejected' })
const agent = { session } as unknown as Agent
const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'bash' }, () => Promise.resolve('unavailable' as const))
expect(outcome).toBe('unavailable')
})
it('delegates an ask whose session log carries no asked audit event (foreign channel)', async () => {
const { ctx, api } = await harness()
void api // the answerer is registered; the fake below bypasses the audit path
// Bypass ApprovalService: dispatch the waterfall directly with a session
// that has no approval/asked event — the proxy answerer must call next().
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const agent = { session } as unknown as Agent
const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'x' }, () => Promise.resolve('unavailable' as const))
expect(outcome).toBe('unavailable')
})
})

View File

@@ -0,0 +1,152 @@
/**
* Permission select over the proxy: permissions() projects the preset table
* plus the derived current value (custom shown only when derived),
* setPermission() validates against the table and anchors idle switches to
* the next prompted turn (the ACP bridge's pendingSwitches pattern), and a
* permission-less composition serves an empty select instead of an error.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import PermissionService from '@deepseek-ai/dsh-permission'
import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { createApiProxy } from '../src/api-proxy.ts'
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
}
async function harness(options: { permission?: boolean } = {}): Promise<{ ctx: Context; api: ApiProxy; sessionId: SessionId }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
if (options.permission !== false) {
// The permission service requires a confining executor fact + approval.
ctx.provide('bash', {
sandboxMode: 'workspace-write',
resolve() { throw new Error('permission proxy tests do not execute bash') },
run() { throw new Error('permission proxy tests do not execute bash') },
start() { throw new Error('permission proxy tests do not execute bash') },
})
await ctx.plugin(ApprovalService)
await ctx.plugin(PermissionService, {})
}
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
// No agent-loop in this harness: register a bare live agent directly (the
// proxy only reaches `.session`); api-proxy-view.spec.ts precedent.
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
return { ctx, api, sessionId: session.id }
}
function expectOk<T>(response: { result: { ok: true; value: T } | { ok: false } }): T {
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
return response.result.value
}
describe('session.permissions', () => {
it('projects the preset table with the effective current value; custom is absent when a preset matches', async () => {
const { api, sessionId } = await harness()
const value = expectOk<{ options: { value: string }[]; currentValue: string }>(
await api.sessions.permissions(request({ sessionId })))
expect(value.currentValue).toBe('workspace-write')
expect(value.options.map(o => o.value)).toEqual(['workspace-write', 'danger-full-access'])
})
it('serves an empty select (custom) on a permission-less composition', async () => {
const { api, sessionId } = await harness({ permission: false })
const value = expectOk<{ options: unknown[]; currentValue: string }>(
await api.sessions.permissions(request({ sessionId })))
expect(value).toEqual({ options: [], currentValue: 'custom' })
})
it('appends the derived custom option when the knobs match no preset', async () => {
const { ctx, api, sessionId } = await harness()
const agent = ctx.agents.get(sessionId)
agent?.session.append('sandbox/mode', { mode: 'read-only' })
const value = expectOk<{ options: { value: string }[]; currentValue: string }>(
await api.sessions.permissions(request({ sessionId })))
expect(value.currentValue).toBe('custom')
expect(value.options.map(o => o.value)).toEqual(['workspace-write', 'danger-full-access', 'custom'])
})
it('propagates the agentFor error for a ghost session (persistence-less harness: internal)', async () => {
// The not-found/internal split is agentFor's documented gate and already
// covered by the history specs; here only the pass-through matters.
const { api } = await harness()
const response = await api.sessions.permissions(request({ sessionId: 'session-void' as SessionId }))
expect(response.result.ok).toBe(false)
})
})
describe('session.setPermission', () => {
it('holds an idle switch pending (visible in permissions()) and flushes it into the next prompted turn', async () => {
const { ctx, api, sessionId } = await harness()
const agent = ctx.agents.get(sessionId)
expect(agent).toBeDefined()
const switched = expectOk<{ currentValue: string }>(
await api.sessions.setPermission(request({ sessionId, value: 'danger-full-access' })))
expect(switched.currentValue).toBe('danger-full-access')
// No turn open: nothing appended yet; the pending value masks the fold.
expect(agent?.session.events.some(e => e.type === 'permission/preset')).toBe(false)
const echoed = expectOk<{ currentValue: string }>(
await api.sessions.permissions(request({ sessionId })))
expect(echoed.currentValue).toBe('danger-full-access')
// The waterfall flush path: prompt-submit inside the new turn writes through.
agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await ctx.waterfall('agent/prompt-submit', agent as never, [], { kind: 'user' } as never, new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }))
expect(agent?.session.events.map(e => e.type)).toContain('permission/preset')
expect(agent?.session.events.map(e => e.type)).toContain('sandbox/mode')
expect(agent?.session.events.map(e => e.type)).toContain('approval/policy')
})
it('writes through immediately inside an open turn', async () => {
const { ctx, api, sessionId } = await harness()
const agent = ctx.agents.get(sessionId)
agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expectOk(await api.sessions.setPermission(request({ sessionId, value: 'danger-full-access' })))
expect(agent?.session.events.map(e => e.type)).toContain('permission/preset')
})
it('acknowledges a current-value echo without recording a switch', async () => {
const { ctx, api, sessionId } = await harness()
const agent = ctx.agents.get(sessionId)
agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const echoed = expectOk<{ currentValue: string }>(
await api.sessions.setPermission(request({ sessionId, value: 'workspace-write' })))
expect(echoed.currentValue).toBe('workspace-write')
expect(agent?.session.events.some(e => e.type === 'permission/preset')).toBe(false)
})
it('propagates the agentFor error for a ghost session', async () => {
const { api } = await harness()
const response = await api.sessions.setPermission(request({ sessionId: 'session-void' as SessionId, value: 'workspace-write' }))
expect(response.result.ok).toBe(false)
})
it('rejects unknown values (custom included) and a permission-less composition as bad-request', async () => {
const { api, sessionId } = await harness()
for (const value of ['custom', 'nope']) {
const response = await api.sessions.setPermission(request({ sessionId, value }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
}
const bare = await harness({ permission: false })
const response = await bare.api.sessions.setPermission(request({ sessionId: bare.sessionId, value: 'workspace-write' }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
})
})

View File

@@ -48,16 +48,31 @@
"path": "../../session-persistence/session-persistence-jsonl"
},
{
"path": "../../bash/bash-local"
"path": "../../bash/bash-sandbox"
},
{
"path": "../../bash/tool-bash"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-local"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../ui/user-approval"
},
{
"path": "../../ui/permission"
},
{
"path": "../../compact/compact-basic"
},
{
"path": "../../fs/fs-local"
"path": "../../fs/fs-sandbox"
},
{
"path": "../../fs/fs-policy"