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
+1 -1
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'
@@ -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 }>>
}