feat(permission): user-facing permission presets — one Permissions select over the two knobs
A preset names a bundle of the two mechanism knobs — request = workspace-write + ask, yolo = danger-full-access + never — so the editor shows ONE 'Permissions' select where the sandbox-mode and approval-policy tiers stay orthogonal capabilities (the Codex /approvals shape: presets over two dials). ctx.permission (dsh-permission) owns the config-defined table, validates the default preset's bundle against the composed knob defaults at load (fails loud), and writes a switch THROUGH: one log-only permission/preset event (the audit fact reverse-mapping cannot recover — the planned 'agent' preset shares request's knob values and differs only in composed policy) plus each knob event via its own setter, deduped — a net-zero switch appends nothing. Every knob consumer keeps reading its own fold, untouched. The current preset DERIVES from the effective knob values — the fold breaks bundle ties, a knob state outside the table is the reserved 'custom' value (a state, not an error: shown while it holds, switchable FROM, never a target), and defaultPreset disappears (zero-event state reverse-maps from the composition defaults). The ACP bridge drops the two per-knob selects for the one preset select (advertised only when ctx.permission is composed); pending/anchor/no-op semantics carry over unchanged, with the no-op echo acknowledged before vocabulary validation so a client re-pushing a derived 'custom' current never errors. The sandbox variant example composes the service with a workspace-write default; the permission-switching, escalation-approved and escalation-rejected scenarios are re-recorded under it (escalations now target an outside-workspace /tmp path under danger-full-access, self-cleaning) and config-options is re-authored on the single-select wire.
This commit is contained in:
@@ -74,10 +74,8 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
// Side-effect type import: resolves `ctx.get('permission')` to the service.
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
|
||||
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
|
||||
@@ -326,7 +324,7 @@ interface SessionRecord {
|
||||
* overlay it truthfully, and a restart before the next turn reverts it —
|
||||
* which `session/load` then reports honestly from the log's fold.
|
||||
*/
|
||||
pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy }
|
||||
pendingSwitches: { preset?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,46 +543,37 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// --- The ACP Agent method surface -----------------------------------------
|
||||
|
||||
/**
|
||||
* The session config options this composition can honor, with current
|
||||
* values folded from the AGENT'S OWN session log (`effectiveSandboxMode` /
|
||||
* `effectiveApprovalPolicy` — the log is the per-session store, so a
|
||||
* `session/load` reports a resumed session's overrides with no catch-up
|
||||
* machinery), overlaid with the record's not-yet-anchored pending switches
|
||||
* (see {@link SessionRecord.pendingSwitches}). Capability-gated like every
|
||||
* advertised lever: the sandbox option exists only when the mounted
|
||||
* executor confines (`ctx.get('bash')?.sandboxMode` defined), the approval
|
||||
* option only when the approval seam is composed — both read
|
||||
* The session config options this composition can honor: ONE `Mode`
|
||||
* select over the composed preset table (`ctx.permission` — the product
|
||||
* layer bundling the sandbox-mode and approval-policy knobs), its current
|
||||
* value folded from the AGENT'S OWN session log (the log is the
|
||||
* per-session store, so a `session/load` reports a resumed session's
|
||||
* preset with no catch-up machinery), overlaid with the record's
|
||||
* not-yet-anchored pending switch (see
|
||||
* {@link SessionRecord.pendingSwitches}). Capability-gated like every
|
||||
* advertised lever: no preset service composed, no options — read
|
||||
* opportunistically so this bridge keeps working in compositions without
|
||||
* them.
|
||||
* it.
|
||||
*/
|
||||
const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => {
|
||||
const options: SessionConfigOption[] = []
|
||||
const defaultMode = ctx.get('bash')?.sandboxMode
|
||||
if (defaultMode !== undefined) {
|
||||
options.push({
|
||||
id: 'sandbox-mode',
|
||||
name: 'Sandbox',
|
||||
description: 'The file sandbox mode bash commands in this session run under.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue: pending.sandboxMode ?? effectiveSandboxMode(agent.session.events) ?? defaultMode,
|
||||
options: SANDBOX_MODES.map(mode => ({ value: mode, name: mode })),
|
||||
})
|
||||
}
|
||||
const approval = ctx.get('approval')
|
||||
if (approval !== undefined) {
|
||||
options.push({
|
||||
id: 'approval-policy',
|
||||
name: 'Approvals',
|
||||
description: 'ask: permission prompts reach you; never: they are rejected automatically.',
|
||||
type: 'select',
|
||||
// `?? 'ask'` also shields against a provided stand-in whose config
|
||||
// never went through the plugin schema (tests do this).
|
||||
currentValue: pending.approvalPolicy ?? effectiveApprovalPolicy(agent.session.events) ?? approval.config.policy ?? 'ask',
|
||||
options: APPROVAL_POLICIES.map(policy => ({ value: policy, name: policy })),
|
||||
})
|
||||
}
|
||||
return options
|
||||
const presets = ctx.get('permission')
|
||||
if (presets === undefined) return []
|
||||
const currentValue = pending.preset ?? presets.current(agent.session.events)
|
||||
return [{
|
||||
id: 'permission',
|
||||
name: 'Permissions',
|
||||
description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [
|
||||
...presets.names.map((name: string) => presets.optionOf(name)),
|
||||
// The derived not-a-preset state: visible exactly while it IS the
|
||||
// current value (a knob state outside the table), switchable FROM,
|
||||
// never a target — set() below rejects it like any unknown name.
|
||||
...currentValue === 'custom' ? [presets.optionOf('custom')] : [],
|
||||
],
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -614,15 +603,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
const flushPendingSwitches = (rec: SessionRecord): void => {
|
||||
const pending = rec.pendingSwitches
|
||||
rec.pendingSwitches = {}
|
||||
const events = rec.agent.session.events
|
||||
if (pending.sandboxMode !== undefined
|
||||
&& pending.sandboxMode !== (effectiveSandboxMode(events) ?? ctx.get('bash')?.sandboxMode)) {
|
||||
setSandboxMode(rec.agent.session, pending.sandboxMode)
|
||||
}
|
||||
if (pending.approvalPolicy !== undefined
|
||||
&& pending.approvalPolicy !== (effectiveApprovalPolicy(events) ?? ctx.get('approval')?.config.policy ?? 'ask')) {
|
||||
setApprovalPolicy(rec.agent.session, pending.approvalPolicy)
|
||||
}
|
||||
if (pending.preset === undefined) return
|
||||
const presets = ctx.get('permission')
|
||||
/* v8 ignore next -- a pending preset exists only if the service answered the
|
||||
switch; it cannot unmount between that and the next turn in any composition. */
|
||||
if (presets === undefined) return
|
||||
presets.set(rec.agent.session, pending.preset)
|
||||
}
|
||||
|
||||
// Idle-accepted switches anchor at the next turn's prompt-submit: the turn
|
||||
@@ -881,32 +867,22 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// advertised; an id this composition never advertised (or an unknown
|
||||
// one) rejects.
|
||||
switch (params.configId) {
|
||||
case 'sandbox-mode': {
|
||||
const defaultMode = ctx.get('bash')?.sandboxMode
|
||||
if (defaultMode === undefined || !SANDBOX_MODES.includes(params.value as SandboxMode)) {
|
||||
throw invalidParams(`unknown sandbox-mode value ${JSON.stringify(params.value)}`)
|
||||
case 'permission': {
|
||||
const presets = ctx.get('permission')
|
||||
if (presets === undefined) {
|
||||
throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`)
|
||||
}
|
||||
const value = params.value as SandboxMode
|
||||
// A no-op switch (the value the session already shows — pending,
|
||||
// else fold, else default) is acknowledged without recording
|
||||
// anything: clients that re-push current selections on session
|
||||
// start must not mint override events out of thin air.
|
||||
const current = rec.pendingSwitches.sandboxMode ?? effectiveSandboxMode(rec.agent.session.events) ?? defaultMode
|
||||
if (value === current) break
|
||||
if (isTurnOpen(rec.agent)) setSandboxMode(rec.agent.session, value)
|
||||
else rec.pendingSwitches.sandboxMode = value
|
||||
break
|
||||
}
|
||||
case 'approval-policy': {
|
||||
const approval = ctx.get('approval')
|
||||
if (approval === undefined || !APPROVAL_POLICIES.includes(params.value as ApprovalPolicy)) {
|
||||
throw invalidParams(`unknown approval-policy value ${JSON.stringify(params.value)}`)
|
||||
// else derived) is acknowledged FIRST and records nothing:
|
||||
// clients re-push current selections on session start, and the
|
||||
// derived 'custom' current is only ever valid as such an echo.
|
||||
const current = rec.pendingSwitches.preset ?? presets.current(rec.agent.session.events)
|
||||
if (params.value === current) break
|
||||
if (!presets.names.includes(params.value)) {
|
||||
throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`)
|
||||
}
|
||||
const value = params.value as ApprovalPolicy
|
||||
const current = rec.pendingSwitches.approvalPolicy ?? effectiveApprovalPolicy(rec.agent.session.events) ?? approval.config.policy ?? 'ask'
|
||||
if (value === current) break
|
||||
if (isTurnOpen(rec.agent)) setApprovalPolicy(rec.agent.session, value)
|
||||
else rec.pendingSwitches.approvalPolicy = value
|
||||
if (isTurnOpen(rec.agent)) presets.set(rec.agent.session, params.value)
|
||||
else rec.pendingSwitches.preset = params.value
|
||||
break
|
||||
}
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user