Files
deepseek-harness/packages/host/runtime/tests/api-proxy-permission.spec.ts
Turtle f0410d592d 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.
2026-07-24 19:15:04 +08:00

153 lines
7.9 KiB
TypeScript

/**
* 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')
})
})