refactor(web): drop the permission RPC pair and turn-anchoring machinery

The session.permissions/setPermission unary pair, the PermissionOption wire
DTO, the client Session wrappers, and the fixture/fake mirrors all leave the
wire: the read side moves to the 'permissions' session projection and the
write side moves to the /permission command in follow-up commits, so the
web protocol gains no permission methods at all.

The pendingSwitches + prompt-submit flush + hasOpenTurn move also goes.
Knob events no longer need turn enclosure: the persistence scanner keeps
standalone events after the last turn/end as part of the preserved prefix
(remove-synthetic-log-only-turns), none of the three knob invariants demand
an open turn, and the setters append bare events. An idle switch commits
immediately; hasOpenTurn stays a user-approval private fold (its audit pair
is the one contract that still requires enclosure).

The old PermissionSelect chip and its mount-time fetch die with the RPCs
(the resident composer broke the mount-once assumption); the projection-fed
replacement lands with the Access seat swap.
This commit is contained in:
imccyu
2026-07-28 21:35:26 +08:00
parent a66d1e335f
commit c6b552e817
36 changed files with 37 additions and 561 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, PermissionOption, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
@@ -24,8 +24,6 @@ export interface RpcMethodMap {
'session.selectModel': SessionsApi['selectModel']
'session.prompt': SessionsApi['prompt']
'session.cancel': SessionsApi['cancel']
'session.permissions': SessionsApi['permissions']
'session.setPermission': SessionsApi['setPermission']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'host.openPath': HostApi['openPath']
@@ -11,7 +11,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, PermissionOption, SessionProjectionsBlock, SessionSummary,
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
@@ -207,31 +207,3 @@ 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'>>>
@@ -145,21 +145,6 @@ 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. */
@@ -216,23 +201,4 @@ 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 }>>
}
@@ -22,10 +22,8 @@ import {
sessionHistoryValueSchema,
sessionListValueSchema,
sessionModelsValueSchema,
sessionPermissionsValueSchema,
sessionPromptValueSchema,
sessionSelectModelValueSchema,
sessionSetPermissionValueSchema,
} from '../api/sessions.schema.ts'
import {
workspaceCreateValueSchema,
@@ -61,8 +59,6 @@ export interface IApiClient {
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
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'>>>
@@ -103,8 +99,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.selectModel': sessionSelectModelValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.cancel': sessionCancelValueSchema,
'session.permissions': sessionPermissionsValueSchema,
'session.setPermission': sessionSetPermissionValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.openPath': hostOpenPathValueSchema,
@@ -308,8 +302,6 @@ export abstract class AbstractApiClient implements IApiClient {
selectModel: (payload, signal) => this.callUnary('session.selectModel', 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'] = {
@@ -20,10 +20,8 @@ import {
sessionHistoryRequestSchema,
sessionListRequestSchema,
sessionModelsRequestSchema,
sessionPermissionsRequestSchema,
sessionPromptRequestSchema,
sessionSelectModelRequestSchema,
sessionSetPermissionRequestSchema,
} from '../api/sessions.schema.ts'
import {
hostDescribeRequestSchema, hostOpenPathRequestSchema, hostPickDirectoryRequestSchema,
@@ -62,8 +60,6 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(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) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
@@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(ApprovalService)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
return { ctx, api }
}
@@ -1,152 +0,0 @@
/**
* 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')
})
})
@@ -45,8 +45,6 @@ function scriptedApi(overrides: {
}),
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: {
@@ -73,12 +73,6 @@ 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) {
@@ -184,7 +178,7 @@ 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/permissions/setPermission/describe passthrough', async () => {
it('covers create/prompt/cancel/describe passthrough', async () => {
const c = client()
expect((await c.sessions.create({})).result.ok).toBe(true)
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
@@ -206,8 +200,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
})
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)
})