Merge branch 'worktree-session-modes-rfc' (master: sandbox stack #169, skills #109, prompt snapshots #254)

The stack rebases onto a moved master through its base branch. Beyond
mechanical unions (both branches' demo scripts, example rows, service
roles, tool lists, acp deps, doc budgets — each side fit alone, the
union needs the higher ceilings), three semantic reconciliations:

- The ACP bridge now carries BOTH per-session surfaces: the sandbox
  stack's config options + approval answerer and this branch's session
  modes; session/new and session/load advertise modes AND configOptions
  side by side.
- The feature matrix supersedes the sandbox stance per the RFC's
  second-lander rule: session/set_mode and current_mode_update flip to
  shipped-by-dsh-mode, config-option rows stay as #169 wrote them, and
  §6 records both landed features under the picker-to-modes /
  knobs-to-config-options division.
- The snapshot pin grammar (#254: one header snapshot + declared deltas
  + a Markdown prompt golden) gains a symmetric declaration for what a
  delta cannot express: expectedHeaderSnapshots — a plan-mode flip
  resorts the canonical tool list, so its widening lands as a second
  full snapshot, now its own Markdown section. The pin-less-class and
  model-turn-only-pin amendments carry over; new fixtures cover the
  extended writer paths, and the plan-acp-agent scenarios re-recorded
  under the merged composition (the app now bundles the skill tool)
  with the suite's refresh mode wired through.
This commit is contained in:
kingwl
2026-07-12 20:08:00 +08:00
341 changed files with 27410 additions and 7518 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-acp
The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
@@ -31,10 +31,16 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
| `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" |
## Multi-session
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.)
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership: the `approval/request` answerer resolves the owning session through the reverse map and prompts only there.
## 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.
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.
@@ -67,18 +73,21 @@ When the client does NOT advertise the capability, none of the `_meta`/terminal
A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream). One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the durable boundary event (`closeTurn` appends it unconditionally; there is no `agent/*` turn mirror). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a peer `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang.
## Permission prompts
The bridge registers an `approval/request` waterfall listener — the ACP answerer of the [user-approval seam](../user-approval/README.md). When `ctx.approval` routes an `ask` for an agent the bridge owns, the listener resolves the owning session through the reverse map and issues `session/request_permission` with the request's `callId` as the `toolCall` reference (the editor attaches the prompt to the already-streamed call) and the one-shot options `allow_once`/`reject_once` (`allow_always` is deferred to the approval RFC's grant-storage question). Outcomes map `allow-once → allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled → cancelled`. A request for an agent the bridge does NOT own — or one without a `callId` to attach to — delegates via `next()` so another answerer or the seam's fail-closed `unavailable` default takes it. A rejected `requestPermission` RPC (client gone mid-prompt) propagates to the ApprovalService, which contains it as `unavailable`. Whether a call asks at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment; without such policy, tools keep the executor's full authority.
## Disposal & disconnect
Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../../core/agent/README.md) `dispose()` — which stops the loop (sets `disposed` + aborts the in-flight step), `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. A turn cut off mid-flight by teardown ends with reason `disposed` (not `aborted` — `dispose()` uses the disposed path, not `session/cancel`'s queue-aware `cancel()`). The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise).
## Known limitations (tracked TODOs)
- **`TODO(rfc010-permission-gate)`** — the `tools/pre-execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land.
- **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented.
## stdout is the protocol
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging.
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging.
## Running

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), and resumable session replay. The largest **unbuilt** areas are the **permission gate** (`session/request_permission`), **MCP passthrough**, **config options / model selection** (session modes ship via `dsh-mode` — see [§6](#6-session-modes--config-options--models)), **slash commands**, and **agent plans** — all of which both reference adapters ship — 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, per-session sandbox/approval config options, and **session modes** (the picker, via `@deepseek-ai/dsh-mode`). 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)
@@ -26,7 +26,7 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
| `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 | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-mode` mounted, `session/new`/`session/load` advertise `availableModes`/`currentModeId` and `session/set_mode` records the pending intent (optimistic `current_mode_update`; the logged `mode/set` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ❌ | ✅ | ✅ | Config options not modeled. |
| `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)). |
| 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`. |
@@ -39,7 +39,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| Method | Stable | Bridge | Claude | Codex | Notes |
|---|---|---|---|---|---|
| `session/update` | S | ✅ | ✅ | ✅ | The bridge's primary output channel (see [§4](#4-sessionupdate-variants)). |
| `session/request_permission` | S | ❌ | ✅ | ✅ | **The biggest gap.** Tools run with the executor's full authority; no user authorization round-trip. The `agent→sessionId` reverse map is already in place to route a future permission request. Tracked `TODO(rfc010-permission-gate)`. |
| `session/request_permission` | S | ✅ | ✅ | ✅ | The bridge answers the [`ctx.approval`](../user-approval/README.md) seam for the agents it owns: an `ask` from a hook/plugin becomes an editor prompt attached to the streamed tool call, one-shot `allow_once`/`reject_once` options only. Whether a call asks is policy (nothing asks by default); `allow_always` is deferred (grant storage). |
| `fs/read_text_file` | S | ❌ | ✅ | ❌ | The harness reads files directly (it does not see the editor's unsaved buffer state). Claude delegates; Codex does not. |
| `fs/write_text_file` | S | ❌ | ✅ | ❌ | Same — direct writes, no editor delegation. |
| `terminal/create` | S | ❌ | ❌ | ❌ | Neither reference adapter drives the client terminal API either — both, like the bridge, render shell output as tool-call content + a `_meta` channel (see [§5 Terminal](#terminal-rendering)). |
@@ -86,7 +86,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). |
| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. |
| `current_mode_update` | S | ✅ | ✅ | ✅ | Echoed optimistically on `session/set_mode` and re-notified on each logged `mode/set` that differs from the last sent (covers the `exit_plan_mode` tool flipping the session back). |
| `config_option_update` | S | ❌ | ✅ | ✅ | No config options. |
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). |
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
| `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. |
@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
## 6. Session modes / config options / models
Session modes ✅ (the [plan-mode RFC](../../../docs/rfc/implemented/feature/2026-07-07-plan-mode.md)): the picker maps 1:1 onto `dsh-mode`'s vocabulary — `ctx.modes.list()` fills `availableModes`, `session/set_mode` calls `set()` (pending intent, flushed at the turn boundary), and `current_mode_update` tracks both the optimistic echo and every logged flip. The division is picker-to-modes / knobs-to-config-options: individual environment knobs (sandbox mode, approval policy, the model) are NOT modes and belong to `session/set_config_option` — still unbuilt here, as is runtime model selection (the harness fixes the model per-bridge via `AcpConfig.model`). The ACP draft v2 direction reportedly slates session modes for removal in favor of config options; if that lands, the picker migrates mechanically (the mode state and both enforcement layers are wire-agnostic).
Session modes ✅ (the [plan-mode RFC](../../../docs/rfc/implemented/feature/2026-07-07-plan-mode.md)): the picker maps 1:1 onto `dsh-mode`'s vocabulary — `ctx.modes.list()` fills `availableModes`, `session/set_mode` calls `set()` (pending intent, flushed at the turn boundary), and `current_mode_update` tracks both the optimistic echo and every logged flip. Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): 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. The division is picker-to-modes / knobs-to-config-options: individual environment knobs are NOT modes, and a mode definition may later bundle env facts so a Codex-style preset stays a single mode. Runtime model selection is still not modeled — the harness fixes the model per-bridge via `AcpConfig.model` (both reference adapters ship a model selector). The ACP draft v2 direction reportedly slates session modes for removal in favor of config options; if that lands, the picker migrates mechanically (the mode state and both enforcement layers are wire-agnostic).
## 7. Content blocks
@@ -130,7 +130,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
| Feature | Stable | Bridge | Notes |
|---|---|---|---|
| `StopReason` mapping | S | ✅ | `turnEndToStopReason` is total over harness turn-end reasons → `end_turn`/`max_tokens`/`cancelled`. |
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md). |
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). |
| Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. |
| `_meta` extensibility | S | ⚠️ | Consumed (Zed terminal cap) and emitted (terminal `_meta`); no other custom extensions. |
| Background-task ownership isolation | — | ✅ | `bash_output`/`bash_kill` reject another session's task via an opaque owner token. |
@@ -140,15 +140,14 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
Ranked by how commonly the reference adapters ship them and how much UX they unlock:
1. **Permission gate** — `session/request_permission` + permission options. Tracked `TODO(rfc010-permission-gate)`; the reverse map is already wired and shared with `ask_user_question` routing. Foundational, and a prerequisite for modes.
2. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
3. **Config options / model selection** — session modes shipped with `dsh-mode`; the knob surface (`session/set_config_option`) is the sandbox stack's config phase.
4. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
5. **Slash commands** (`available_commands_update`).
6. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
7. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
9. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
10. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
1. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
2. **Model selection** — sandbox and approval config options are implemented; selecting the bridge's model at runtime remains open.
3. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
4. **Slash commands** (`available_commands_update`).
5. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
6. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
7. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
8. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
## Out of scope

View File

@@ -28,8 +28,11 @@
},
"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-mode": "^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",
@@ -39,11 +42,15 @@
"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-mode": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",

View File

@@ -22,8 +22,10 @@
* `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every
* `session/event` and `agent/*` event is routed strictly to its owning session
* record, so two sessions streaming at once never interleave their
* `session/update` notifications. The `tools/pre-execute` permission gate is
* deferred — see the TODO(rfc010-permission-gate) note below.
* `session/update` notifications. Permission prompts ride the same ownership
* map: the bridge answers `approval/request` for its own agents over
* `session/request_permission` (see the approval answerer below) — whether a
* call ASKS is policy (a hook or plugin returning `ask`), not the bridge's.
*
* stdout is the protocol: this plugin must run in an example that loads NO
* stdout logger (the console logger writes to stdout and would corrupt the
@@ -60,8 +62,11 @@ import {
type PlanEntry,
type PromptRequest,
type PromptResponse,
type SessionConfigOption,
type SessionModeState,
type SessionNotification,
type SetSessionConfigOptionRequest,
type SetSessionConfigOptionResponse,
type SetSessionModeRequest,
type SetSessionModeResponse,
type Stream,
@@ -72,6 +77,10 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentStatus } 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'
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
@@ -321,6 +330,19 @@ interface SessionRecord {
turn: number | undefined
logWatermark: number
} | undefined
/**
* Config switches accepted while the session was IDLE, not yet anchored in
* its log. The turn-enclosure contract makes a bare between-turns append
* invalid (the JSONL backend treats a post-`turn/end` tail as crash
* garbage, and dev invariants throw), so an idle switch waits here and is
* anchored at the next turn's prompt-submit — before anything in that
* turn assembles a prompt or runs a call, and last write
* per knob wins (an idle flip-flop anchors as one event). Until anchored,
* the switch lives only in bridge memory: the set/new/load responses
* 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 }
}
/**
@@ -595,8 +617,136 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (status === 'idle' || status === 'disposed') settleFromLog(rec)
})
// --- Approval answerer -----------------------------------------------------
// The bridge is the approval channel for the agents it owns: an `ask` routed
// through `ctx.approval` (dsh-tools asks and sandbox escalation) becomes
// an editor permission prompt attached to the already-streamed tool call. The
// listener occupies the single decision slot ONLY for its own agents — a
// foreign or call-less request delegates via next() so another answerer (or
// the fail-closed `unavailable` default) takes the question. A rejected
// `requestPermission` (client gone, bridge torn down) propagates and the
// ApprovalService contains it as `unavailable`. Options are one-shot only:
// allow_always is a grant-storage design the approval RFC defers, so the
// prompt never offers a durable grant the harness could not honor.
ctx.on('approval/request', (req, next) => {
const sessionId = bySession.get(req.agent)
// The protocol requires `toolCall` (the prompt renders attached to it), so
// a request without a callId has nothing to attach to — delegate.
if (sessionId === undefined || req.callId === undefined) return next()
return conn.requestPermission({
sessionId,
toolCall: { toolCallId: req.callId },
options: [
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
],
}).then(({ outcome }) => {
if (outcome.outcome === 'cancelled') return 'cancelled'
// Only the two advertised options exist; an unknown optionId from a
// non-conforming client counts as a rejection, never a grant.
return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected'
})
})
// --- 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
* opportunistically so this bridge keeps working in compositions without
* them.
*/
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
}
/**
* Whether the session's log currently has an open turn — the last boundary
* event is a `turn/start`. Decides whether a config switch may append NOW
* (enclosed) or must wait for the next turn (see
* {@link SessionRecord.pendingSwitches}). Read from the LOG, not
* `agent.status`: status stays `running` across the gap between two queued
* turns, where a bare append would still land outside any turn.
*/
const isTurnOpen = (agent: Agent): boolean => {
const events = agent.session.events
for (let index = events.length - 1; index >= 0; index -= 1) {
const type = (events[index] as SessionEvent).type
if (type === 'turn/start') return true
if (type === 'turn/end') return false
}
return false
}
/**
* Anchor a record's pending switches into its (just-opened) turn, last
* write per knob — skipping a value the session already effectively has,
* so a net-zero idle flip-flop anchors NOTHING (the log records switches,
* not select clicks).
*/
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)
}
}
// Idle-accepted switches anchor at the next turn's prompt-submit: the turn
// is open (the seam fires inside it, per drained message — the first flush
// empties the slot, later ones no-op), the loop has not yet assembled
// anything for it, and — unlike appending from inside a `session/event`
// listener — this seam fires OUTSIDE any log emit, so peer listeners
// (the dev invariants, persistence) observe the anchored events in strict
// log order. A turn with no prompt (an idle inject's one-shot injection
// turn) leaves the switch pending — it runs no step, so nothing executes
// or assembles under a stale value.
ctx.on('agent/prompt-submit', (agent, _content, _source, next) => {
const sessionId = bySession.get(agent)
const rec = sessionId === undefined ? undefined : sessions.get(sessionId)
if (rec !== undefined) flushPendingSwitches(rec)
return next()
})
const makeAgent = (connection: AgentSideConnection): AcpAgent => {
conn = connection
return {
@@ -652,8 +802,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
terminalEnabled: terminalOutputCap,
lastModeId: modes?.currentModeId,
inflight: undefined,
pendingSwitches: {},
})
const configOptions = configOptionsFor(handle.agent)
return Promise.resolve({
sessionId,
...modes !== undefined ? { modes } : {},
...configOptions.length > 0 ? { configOptions } : {},
})
return Promise.resolve({ sessionId, ...modes !== undefined ? { modes } : {} })
},
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
@@ -731,6 +887,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
terminalEnabled,
lastModeId: modes?.currentModeId,
inflight: undefined,
pendingSwitches: {},
}
sessions.set(sessionId, record)
// Replay the persisted event log to the client as session/update. Use
@@ -754,7 +911,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
for (const event of agent.session.events) {
streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal)
}
return modes !== undefined ? { modes } : {}
const configOptions = configOptionsFor(agent)
return {
...modes !== undefined ? { modes } : {},
...configOptions.length > 0 ? { configOptions } : {},
}
} finally {
loadingIds.delete(sessionId)
}
@@ -829,6 +990,62 @@ export function apply(ctx: Context, config: AcpConfig): void {
settlePrompt(rec, 'cancelled')
return Promise.resolve()
},
setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))
// Both advertised options are selects, 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`)
}
// The setters append ONE log-only event on this session's own log —
// the log is the store (the sandbox RFC § Per-session mode switching): execution, the
// prompt section, and the narrator all fold it from there, and a
// resumed session reports the override back through
// configOptionsFor. A switch while a turn is OPEN anchors
// immediately (the next step sees it); an IDLE switch waits in
// pendingSwitches for the next `turn/start` (turn-enclosure: a bare
// between-turns append would be dropped as crash tail on reload).
// Values are validated against the same closed lists the options
// 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)}`)
}
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)}`)
}
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
break
}
default:
throw invalidParams(`unknown config option ${JSON.stringify(params.configId)}`)
}
// The spec requires the COMPLETE refreshed config state in the response
// (a change may cascade); ours are independent, but the contract holds.
return Promise.resolve({ configOptions: configOptionsFor(rec.agent, rec.pendingSwitches) })
},
}
}

View File

@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { CallId } from '@deepseek-ai/dsh-llm'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
/**
* The bridge's `approval/request` answerer: an ask for an agent the bridge
* owns becomes a `session/request_permission` prompt attached to the tool
* call; foreign or call-less requests delegate down to the fail-closed
* default. Driven through `ctx.approval` — the same path dsh-tools' ask
* routing takes — against the harness's scriptable client.
*/
describe('acp bridge — approval answerer', () => {
let storageDir: string
let harness: BridgeHarness | undefined
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-approval-')) })
afterEach(async () => {
await harness?.dispose()
harness = undefined
await rm(storageDir, { recursive: true, force: true })
})
async function ownedAgentRequest(
h: BridgeHarness, overrides: Partial<ApprovalRequest> = {},
): Promise<{ agent: Agent; request: ApprovalRequest }> {
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = h.ctx.agents.get(AgentId(sessionId))
if (agent === undefined) throw new Error('newSession created no agent')
// In production an ask always fires mid-turn (tool execution); open one so
// request()'s turn-enclosure precondition holds for the direct drive below.
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
return { agent, request: { agent, toolName: 'echo', callId: CallId('call-9'), ...overrides } }
}
it('prompts the editor for an owned agent and maps allow-once → allowed-once', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.ctx.plugin(ApprovalService)
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
const { request } = await ownedAgentRequest(harness)
await expect(harness.ctx.approval.request(request)).resolves.toBe('allowed-once')
expect(harness.permissionRequests).toHaveLength(1)
const wire = harness.permissionRequests[0]
expect(wire?.toolCall).toEqual({ toolCallId: 'call-9' })
expect(wire?.options.map(o => ({ optionId: o.optionId, kind: o.kind }))).toEqual([
{ optionId: 'allow-once', kind: 'allow_once' },
{ optionId: 'reject-once', kind: 'reject_once' },
])
})
it('maps reject-once → rejected', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.ctx.plugin(ApprovalService)
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } })
const { request } = await ownedAgentRequest(harness)
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
})
it('maps a client cancellation → cancelled', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.ctx.plugin(ApprovalService)
harness.onPermission = () => ({ outcome: { outcome: 'cancelled' } })
const { request } = await ownedAgentRequest(harness)
await expect(harness.ctx.approval.request(request)).resolves.toBe('cancelled')
})
it('treats an unknown optionId from a non-conforming client as a rejection, never a grant', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.ctx.plugin(ApprovalService)
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-always-i-insist' } })
const { request } = await ownedAgentRequest(harness)
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
})
it('delegates a foreign agent down to the fail-closed default', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.ctx.plugin(ApprovalService)
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
// Not created through the bridge: no bySession entry, so the answerer must
// call next() — nobody else answers, so the seam fails closed.
const foreign = { session: { events: [{ type: 'turn/start' }], append: () => ({}) } } as unknown as Agent
await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'echo', callId: CallId('c') }))
.resolves.toBe('unavailable')
expect(harness.permissionRequests).toHaveLength(0)
})
it('delegates a call-less request — the protocol prompt must attach to a tool call', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.ctx.plugin(ApprovalService)
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
const { agent } = await ownedAgentRequest(harness)
await expect(harness.ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('unavailable')
expect(harness.permissionRequests).toHaveLength(0)
})
})

View File

@@ -0,0 +1,260 @@
/**
* 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.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
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 { 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.
*/
class SandboxedLocalExecutor extends LocalBashExecutor {
override get sandboxMode(): SandboxMode {
return 'read-only'
}
}
/** The exact option payloads the bridge advertises (pinned verbatim). */
function sandboxOption(currentValue: SandboxMode): object {
return {
id: 'sandbox-mode',
name: 'Sandbox',
description: 'The file sandbox mode bash commands in this session run under.',
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' },
],
}
}
describe('acp bridge — session config options', () => {
let storageDir: string
let h: BridgeHarness | undefined
let loader: BridgeHarness | undefined
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-config-')) })
afterEach(async () => {
if (h) await h.dispose()
if (loader) await loader.dispose()
h = loader = undefined
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> {
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.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
return harness
}
it('advertises no configOptions in a composition with neither knob', async () => {
h = await makeBridgeHarness({ storageDir })
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: {} })
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')])
})
it('an idle switch is pending (overlaid, not yet logged), then anchors INSIDE the next turn', async () => {
h = await bothKnobs({ 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')])
// 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.
const session = h.ctx.agents.list()[0]?.session
expect(session?.events.some(e => 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 === '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')
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')] })
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.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' }])
})
it('a net-zero idle flip-flop anchors NOTHING (switches are recorded, select clicks are not)', async () => {
h = await bothKnobs({ 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.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)
})
it('a mid-turn switch anchors immediately (the open turn encloses it)', async () => {
h = await bothKnobs({ 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' })
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')
expect(turnStart).toBeGreaterThanOrEqual(0)
expect(anchored).toBeGreaterThan(turnStart)
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)
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
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 }))
.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('a switch in one session never leaks into a concurrent one (state and pending both per-session)', async () => {
h = await bothKnobs()
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')])
// ...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')])
})
it('session/load reports a resumed session\'s overrides from its own log', async () => {
h = await bothKnobs({ script: [textResponse('ok')] })
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' })
// 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()
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('never')])
})
})

View File

@@ -37,6 +37,15 @@
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../user-approval"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../bash/bash"
}
]
}