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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user