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:
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
|
||||
|
||||
@@ -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'>>>
|
||||
|
||||
@@ -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 }>>
|
||||
}
|
||||
|
||||
@@ -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'] = {
|
||||
|
||||
@@ -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) },
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:^"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
275
packages/host/runtime/tests/api-proxy-approval.spec.ts
Normal file
275
packages/host/runtime/tests/api-proxy-approval.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
152
packages/host/runtime/tests/api-proxy-permission.spec.ts
Normal file
152
packages/host/runtime/tests/api-proxy-permission.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user