feat(acp): the bridge approval answerer + scripted permission answers

The ACP bridge registers the first real approval answerer: an ask for an
agent it owns becomes session/request_permission attached to the already-
streamed tool call (one-shot allow_once/reject_once only), outcomes map
conservatively (unknown optionId never grants, client cancel → cancelled),
and foreign or call-less requests delegate down the waterfall. The snapshot
harness accepts scripted permissionAnswers (FIFO; an unscripted prompt
answers cancelled, fail closed) so recorded scenarios can drive the wire
keylessly.
This commit is contained in:
kingwl
2026-07-09 15:36:08 +08:00
parent ef35007d75
commit 80d8726601
18 changed files with 302 additions and 30 deletions

View File

@@ -31,12 +31,11 @@ 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`](../../approval/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" |
## 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)`.)
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.
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.
## Per-session cwd
@@ -67,13 +66,16 @@ 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 [approval seam](../../approval/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

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**, **session modes / config options / model selection**, **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), and resumable session replay. The largest **unbuilt** areas are **MCP passthrough**, **session modes / config options / model selection**, **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).
## 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 not modeled (see [§6 Modes](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ❌ | ✅ | ✅ | Config options not modeled. |
| `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 | ❌ | ✅ | ✅ | Config options not modeled yet — the sandbox RFC's per-session mode switching stages them ([sandbox RFC](../../../docs/rfc/proposed/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`](../../approval/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 | ❌ | ✅ | ✅ | No session modes. |
| `config_option_update` | S | ❌ | ✅ | ✅ | No config options. |
| `config_option_update` | S | ❌ | ✅ | ✅ | No config options yet. |
| `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
❌ None modeled. Both reference adapters ship modes (Claude: a "plan" auto-mode; Codex: read-only / agent / agent-full-access mapping to its approval+sandbox policy), the newer config-option surface, and runtime model selection. The harness fixes the model per-bridge via `AcpConfig.model`. These are coupled to the unbuilt **permission gate** (a mode often selects an approval policy), so they are natural follow-ups to it.
Session modes and config options are not modeled yet: the sandbox RFC's per-session mode switching ([sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)) stages config options as the surface (modes are slated for removal in ACP v2, and one mode list cannot carry two orthogonal knobs). Runtime model selection is also not modeled — the harness fixes the model per-bridge via `AcpConfig.model` (both reference adapters ship a model selector).
## 7. Content blocks
@@ -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. **Modes / config options / model selection** — coupled to the permission gate.
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. **Modes / config options / model selection** — the permission round-trip landed with the approval seam; the config surface (`sandbox-mode`/`approval-policy` options) is the sandbox RFC's staged config phase.
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,6 +28,7 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-approval": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
@@ -38,9 +39,12 @@
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-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-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "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
@@ -74,6 +76,9 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
import type {} from '@deepseek-ai/dsh-session-persistence'
// Side-effect type import: declaration-merges the `approval/request` waterfall
// the bridge answers for its own agents (see the approval answerer below).
import type {} from '@deepseek-ai/dsh-approval'
import {
UserInteractionError,
type AskUserQuestionAnswer,
@@ -555,6 +560,37 @@ 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 today, sandbox escalation later) 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 -----------------------------------------
const makeAgent = (connection: AgentSideConnection): AcpAgent => {
@@ -765,6 +801,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
settlePrompt(rec, 'cancelled')
return Promise.resolve()
},
}
}

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-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

@@ -34,6 +34,12 @@
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../approval/approval"
},
{
"path": "../../bash/bash"
}
]
}