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