Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop

This commit is contained in:
Tianyi Cui
2026-07-14 11:56:47 +08:00
164 changed files with 3282 additions and 1693 deletions

View File

@@ -32,7 +32,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
| `session/request_permission` | `approval/request` listener | the bridge is the [`ctx.approval`](../user-approval/README.md) answerer for the agents it owns: an `ask` (a hook or `tools/pre-execute` plugin) becomes an editor prompt attached to the streamed tool call, offering one-shot `allow_once`/`reject_once` options only; a foreign or call-less request delegates down the answerer chain (fail-closed `unavailable` default). See "Permission prompts" |
| `session/set_config_option` | `setSandboxMode` / `setApprovalPolicy` | per-session knob switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
| `session/set_config_option` | `ctx.permission.set()` | per-session permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
## Multi-session
@@ -40,7 +40,7 @@ The bridge multiplexes N sessions over one connection. Live sessions are held in
## Session config options
The bridge advertises one independent `select` per composable knob in the `session/new`/`session/load` responses — `sandbox-mode` (`read-only`/`workspace-write`/`danger-full-access`, category `mode`) iff the mounted executor confines (`ctx.get('bash')?.sandboxMode` defined), `approval-policy` (`ask`/`never`) iff the approval seam is composed — with each session's `currentValue` folded from its OWN log (`effectiveSandboxMode`/`effectiveApprovalPolicy` ?? the composition default), so `session/load` reports a resumed session's overrides with no catch-up machinery. `session/set_config_option` validates the value against the same closed vocabulary, routes to the domain's write path (`setSandboxMode`/`setApprovalPolicy` — ONE log-only event on that session's log), and returns the complete refreshed state per the spec. Anchoring honors turn-enclosure: a switch while a turn is open appends immediately (openness read from the LOG — `agent.status` stays `running` between queued turns); an idle switch is held on the session record and anchored at the next turn's `agent/prompt-submit` (inside the turn, before anything assembles, last write per knob — an idle flip-flop anchors as one event), because appending from inside a `session/event` listener would reorder events for later-registered peers. Until anchored the switch lives in bridge memory only: responses overlay it truthfully, and a crash before the next turn reverts it — `session/load` then reports the fold's truth. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6.
When `ctx.permission` is composed, the bridge advertises one `permission` select (category `mode`) in `session/new` and `session/load`; its options come from the deployment's preset table and its current value is `PermissionService.current(session.events)`, including the derived, switch-away-only `custom` state when the effective knobs match no preset. `session/set_config_option` accepts only advertised preset names, calls `PermissionService.set()` to write the preset through to the sandbox-mode and approval-policy events, and returns the complete refreshed state. A switch during an open turn appends immediately; an idle switch stays on the session record and anchors at the next turn's `agent/prompt-submit`, inside the turn and before request assembly. Until that anchor, responses overlay the pending value and a crash reverts to the durable fold. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); preset contract: [`dsh-permission`](../permission/README.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6.
Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload.

View File

@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
## At a glance
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, and per-session sandbox/approval config options. The largest **unbuilt** areas are **MCP passthrough**, runtime model selection, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, and per-session permission presets. The largest **unbuilt** areas are **MCP passthrough**, runtime model selection, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
## 1. Agent methods (client → agent)
@@ -25,8 +25,8 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
| `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. |
| `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. |
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry the two orthogonal knobs (see [§6](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ✅ | ✅ | ✅ | Two capability-gated selects — `sandbox-mode` (confining executor mounted) and `approval-policy` (approval seam composed); values validated against the domain vocabularies, one log-only event per switch on the session's own log, complete refreshed state in the response ([sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). |
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ✅ | ✅ | ✅ | One `permission` select when `ctx.permission` is composed; values come from the deployment preset table, a switch writes its preset event through to both knob events, and the response carries the complete refreshed state ([sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). |
| model selection | S | ❌ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. The bridge fixes the model per-bridge via config; no runtime switch. Codex still uses the legacy `unstable_setSessionModel` ext method. |
| `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. |
| `session/delete` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.delete`. |
@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
## 6. Session modes / config options / models
Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): the bridge advertises one independent `select` per composable knob — `sandbox-mode` iff the mounted executor confines, `approval-policy` iff the approval seam is composed — with per-session current values folded from each session's own log, and honors `session/set_config_option` end to end (idle switches anchor at the next turn under the turn-enclosure contract). Session MODES stay deliberately unmodeled: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry two orthogonal knobs. Runtime model selection is still not modeled — the harness fixes the model per-bridge via `AcpConfig.model` (both reference adapters ship a model selector).
Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): when `ctx.permission` is composed, the bridge advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; `session/set_config_option` switches the preset end to end, with idle switches anchoring at the next turn under the turn-enclosure contract. Session modes stay deliberately unmodeled because config options replace them in ACP v2. Runtime model selection is still not modeled — the harness fixes the model per bridge via `AcpConfig.model` (both reference adapters ship a model selector).
## 7. Content blocks

View File

@@ -28,36 +28,38 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-permission": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -72,10 +72,8 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type { Agent } 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
@@ -323,7 +321,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 }
}
/**
@@ -544,46 +542,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')] : [],
],
}]
}
/**
@@ -613,15 +602,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
@@ -856,7 +842,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))
// Both advertised options are selects, so the boolean-shaped variant of
// The advertised option is a select, so the boolean-shaped variant of
// the request is a protocol misuse regardless of configId.
if (typeof params.value !== 'string') {
throw invalidParams(`config option ${params.configId} is a select; boolean values are not accepted`)
@@ -873,32 +859,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:

View File

@@ -1,10 +1,10 @@
/**
* Session config options over the bridge: the two per-session knobs
* (`sandbox-mode`, `approval-policy`) advertised from composition capability,
* their current values folded from each session's own log, switching via
* `session/set_config_option` (one log-only event per switch — the log is the
* store), and a resumed session reporting its overrides back on
* `session/load` with no catch-up machinery.
* Session config options over the bridge: ONE user-facing `Permissions`
* select (`ctx.permission`'s preset table — each choice bundles a sandbox
* mode and an approval policy), its current value folded from each session's
* own log, switching via `session/set_config_option` (the preset event plus
* its knob write-throughs — the log is the store), and a resumed session
* reporting its preset back on `session/load` with no catch-up machinery.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -14,50 +14,37 @@ import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import PermissionService from '@deepseek-ai/dsh-permission'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
/**
* The REAL local executor reporting a confining default — `sandboxMode` is
* the documented capability override point (`dsh-bash-sandbox` overrides it
* the same way), so the bridge sees exactly what a sandboxing composition
* advertises without this suite dragging in a kernel sandbox stack.
* advertises without this suite dragging in a kernel sandbox stack. It
* reports `workspace-write`: the shipped preset's bundle, which
* the permission service validates the composition defaults against.
*/
class SandboxedLocalExecutor extends LocalBashExecutor {
override get sandboxMode(): SandboxMode {
return 'read-only'
return 'workspace-write'
}
}
/** The exact option payloads the bridge advertises (pinned verbatim). */
function sandboxOption(currentValue: SandboxMode): object {
/** The exact option payload the bridge advertises (pinned verbatim). */
function permissionOption(currentValue: string): object {
return {
id: 'sandbox-mode',
name: 'Sandbox',
description: 'The file sandbox mode bash commands in this session run under.',
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: [
{ value: 'read-only', name: 'read-only' },
{ value: 'workspace-write', name: 'workspace-write' },
{ value: 'danger-full-access', name: 'danger-full-access' },
],
}
}
function approvalOption(currentValue: ApprovalPolicy): object {
return {
id: 'approval-policy',
name: 'Approvals',
description: 'ask: permission prompts reach you; never: they are rejected automatically.',
type: 'select',
currentValue,
options: [
{ value: 'ask', name: 'ask' },
{ value: 'never', name: 'never' },
{ value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace; anything wider asks for your approval.' },
{ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access, no approval prompts.' },
],
}
}
@@ -75,144 +62,113 @@ describe('acp bridge — session config options', () => {
await rm(storageDir, { recursive: true, force: true })
})
/** A harness whose composition can honor both knobs (sandboxed executor + approval seam). */
async function bothKnobs(options: { policy?: ApprovalPolicy; script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
/** A harness composing the full preset stack (confining executor + approval seam + permission presets). */
async function presetStack(options: { script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} })
// The dev invariants police turn-enclosure: an idle switch that appended
// outside a turn would throw right here in the suite, not in production.
await harness.ctx.plugin(Invariants)
await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
await harness.ctx.plugin(ApprovalService, options.policy !== undefined ? { policy: options.policy } : {})
await harness.ctx.plugin(ApprovalService)
await harness.ctx.plugin(PermissionService)
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
return harness
}
it('advertises no configOptions in a composition with neither knob', async () => {
it('advertises no configOptions without the permission service — even with both knobs composed', async () => {
h = await makeBridgeHarness({ storageDir })
await h.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
await h.ctx.plugin(ApprovalService)
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions).toBeUndefined()
})
it('a non-confining executor advertises no sandbox option (nothing would honor it)', async () => {
h = await makeBridgeHarness({ storageDir, withBash: true })
await h.ctx.plugin(ApprovalService)
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
it('advertises the Permissions select with the default preset current', async () => {
h = await presetStack()
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions).toEqual([approvalOption('ask')])
})
it('advertises both knobs with capability-derived currents (config default included)', async () => {
h = await bothKnobs({ policy: 'never' })
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')])
expect(res.configOptions).toEqual([permissionOption('workspace-write')])
})
it('an idle switch is pending (overlaid, not yet logged), then anchors INSIDE the next turn', async () => {
h = await bothKnobs({ script: [textResponse('ok')] })
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const afterSandbox = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
expect(afterSandbox.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('ask')])
const afterApproval = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
expect(afterApproval.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('never')])
const after = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
expect(after.configOptions).toEqual([permissionOption('danger-full-access')])
// Idle: nothing in the log yet — turn-enclosure forbids a bare append
// (the dev invariants in this suite would throw), so the switch lives on
// the record until a turn opens.
// Idle: nothing in the log yet — turn-enclosure forbids a bare append.
const session = h.ctx.agents.list()[0]?.session
expect(session?.events.some(e => e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
// The next turn anchors both switches inside itself, one event per knob.
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = session?.events ?? []
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }])
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
const turnStart = events.findIndex(e => e.type === 'turn/start')
const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode')
const anchored = events.findIndex(e => e.type === 'permission/preset')
expect(turnStart).toBeGreaterThanOrEqual(0)
expect(anchored).toBeGreaterThan(turnStart)
})
it('an idle flip-flop anchors as ONE event (last write per knob wins)', async () => {
h = await bothKnobs({ script: [textResponse('ok')] })
it('an idle flip-flop anchors as ONE switch (last write wins)', async () => {
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
expect(again.configOptions).toEqual([permissionOption('danger-full-access')])
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = h.ctx.agents.list()[0]?.session.events ?? []
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
// Idle again AFTER a completed turn (the log now ends in turn/end): a new
// switch pends rather than appending outside the closed turn.
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' })
expect(again.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' })
expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(1)
})
it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => {
h = await bothKnobs({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
// Re-pushing the composition default (what clients that echo current
// selections on session start do) must not mint an override event.
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' })
expect(echo.configOptions?.find(option => option.id === 'approval-policy')).toMatchObject({ currentValue: 'ask' })
// Re-sending a PENDING value keeps the pending switch alive (it is what
// the session shows), rather than cancelling it.
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
expect(repeat.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'workspace-write' })
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = h.ctx.agents.list()[0]?.session.events ?? []
expect(events.filter(e => e.type === 'approval/policy')).toHaveLength(0)
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }])
expect(events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
// Between turns (a closed turn in the log) a switch still pends — the
// enclosure fold walks past the turn/end — and anchors with the NEXT turn.
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
expect(h.ctx.agents.list()[0]?.session.events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
})
it('a net-zero idle flip-flop anchors NOTHING (switches are recorded, select clicks are not)', async () => {
h = await bothKnobs({ script: [textResponse('ok')] })
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' })
expect(back.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' })
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
expect(back.configOptions).toEqual([permissionOption('workspace-write')])
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = h.ctx.agents.list()[0]?.session.events ?? []
expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(0)
expect(events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
})
it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => {
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
expect(echo.configOptions).toEqual([permissionOption('workspace-write')])
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
expect(repeat.configOptions).toEqual([permissionOption('danger-full-access')])
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = h.ctx.agents.list()[0]?.session.events ?? []
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
})
it('a mid-turn switch anchors immediately (the open turn encloses it)', async () => {
h = await bothKnobs({ script: ['hang'] })
h = await presetStack({ script: ['hang'] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const hung = h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
// Give the loop a tick to open the turn (the turns.spec hang idiom).
await new Promise(resolve => setTimeout(resolve, 30))
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const events = h.ctx.agents.list()[0]?.session.events ?? []
const turnStart = events.findIndex(e => e.type === 'turn/start')
const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode')
const anchored = events.findIndex(e => e.type === 'permission/preset')
expect(turnStart).toBeGreaterThanOrEqual(0)
expect(anchored).toBeGreaterThan(turnStart)
expect(events.some(e => e.type === 'bash/sandbox-mode')).toBe(true)
expect(events.some(e => e.type === 'approval/policy')).toBe(true)
await h.client.cancel({ sessionId })
await hung
})
it('tolerates a provided approval stand-in whose config skipped the plugin schema', async () => {
h = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
h.ctx.provide('approval', { config: {} } as unknown as InstanceType<typeof ApprovalService>)
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions).toEqual([approvalOption('ask')])
const sessionId = res.sessionId
// The schema-less config also shields the no-op guard ('ask' by the ?? fallback)…
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' })
expect(echo.configOptions).toEqual([approvalOption('ask')])
// …and the anchor-time comparison: a real switch under the stand-in still anchors.
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = h.ctx.agents.list()[0]?.session.events ?? []
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
})
it('rejects unknown ids, unadvertised ids, boolean values, and out-of-vocabulary values', async () => {
h = await makeBridgeHarness({ storageDir })
await h.ctx.plugin(ApprovalService)
@@ -221,40 +177,72 @@ describe('acp bridge — session config options', () => {
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'reasoning-effort', value: 'max' }))
.rejects.toThrow(/unknown config option/)
// sandbox-mode exists as a concept but THIS composition never advertised it.
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }))
.rejects.toThrow(/unknown sandbox-mode value/)
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', type: 'boolean', value: true }))
// `permission` exists as a concept but THIS composition never advertised it.
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }))
.rejects.toThrow(/unknown permission value/)
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', type: 'boolean', value: true }))
.rejects.toThrow(/select; boolean values are not accepted/)
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'always' }))
.rejects.toThrow(/unknown approval-policy value/)
})
it('rejects an out-of-vocabulary preset on an advertising composition', async () => {
h = await presetStack()
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'plan' }))
.rejects.toThrow(/unknown permission value/)
})
it('a switch in one session never leaks into a concurrent one (state and pending both per-session)', async () => {
h = await bothKnobs()
h = await presetStack()
const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
// B sees its own composition defaults, not A's pending switch...
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'approval-policy', value: 'never' })
expect(bAfter.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')])
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
// B sees the composition default, not A's pending switch...
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'permission', value: 'workspace-write' })
expect(bAfter.configOptions).toEqual([permissionOption('workspace-write')])
// ...and A keeps its own state, untouched by B's.
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
expect(aAfter.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('ask')])
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
expect(aAfter.configOptions).toEqual([permissionOption('danger-full-access')])
})
it('session/load reports a resumed session\'s overrides from its own log', async () => {
h = await bothKnobs({ script: [textResponse('ok')] })
it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => {
h = await presetStack()
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
// Drift a knob out from under the table (a plugin writing the knob
// directly — the raw setters remain public mechanism), inside its own
// turn: the dev invariants enforce turn-enclosure here too.
const agent = h.ctx.agents.list()[0]
if (agent === undefined) throw new Error('expected an agent')
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
agent.session.append('bash/sandbox-mode', { mode: 'read-only' })
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// The echo of the derived current is a no-op, not an unknown-value error…
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })
const option = echo.configOptions?.[0]
expect(option).toMatchObject({ currentValue: 'custom' })
if (option === undefined || !('options' in option)) throw new Error('expected a select option')
expect(option.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access', 'custom'])
// …while custom as a TARGET from a real preset stays rejected: switching
// away is ordinary, and the custom entry disappears from the options.
const away = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const afterOption = away.configOptions?.[0]
expect(afterOption).toMatchObject({ currentValue: 'danger-full-access' })
if (afterOption === undefined || !('options' in afterOption)) throw new Error('expected a select option')
expect(afterOption.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access'])
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' }))
.rejects.toThrow(/unknown permission value/)
})
it('session/load reports a resumed session\'s preset from its own log', async () => {
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
// One turn checkpoints the log (the switch events flush with it).
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist me' }] })
await h.dispose()
h = undefined
loader = await bothKnobs()
loader = await presetStack()
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('never')])
expect(res.configOptions).toEqual([permissionOption('danger-full-access')])
})
})

View File

@@ -38,6 +38,9 @@
{
"path": "../user-approval"
},
{
"path": "../permission"
},
{
"path": "../../sandbox/sandbox"
},