diff --git a/.changeset/claude-agent-sdk-harness.md b/.changeset/claude-agent-sdk-harness.md new file mode 100644 index 000000000..72d425117 --- /dev/null +++ b/.changeset/claude-agent-sdk-harness.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": minor +--- + +Experimental `claude-agent-sdk` execution harness (`ClaudeAgentSdkHarness`, `@bradygaster/squad-sdk/backend`): runs conversation turns on the Claude Agent SDK, with broker approvals through `canUseTool` (hash-bound, one at a time, a deny ends the turn) and no repository or user allow rules in play. `@anthropic-ai/claude-agent-sdk` is an optional peer dependency; `squad state-mcp --experimental-backend` registers the harness only when it is installed (`SQUAD_BACKEND_HARNESSES=none` turns it off). Installs without it are unchanged. Also adds `copilot-sdk` (`CopilotSdkHarness`) on `@github/copilot-sdk`, opt-in on the bridge via `SQUAD_BACKEND_HARNESSES=copilot-sdk`, a shared approval queue (`TurnApprovals`, conversation-scoped; approval records hold a redacted summary and the hash, never the tool input) and a shared turn lifecycle (`TurnHarness`: atomic acceptance, cancellation that survives startup, the next turn waiting for an interrupt to finish). Harness state (accepted deliveries, session ids) survives restarts; Claude needs an API credential (never a claude.ai login) and keeps settings deny rules; Copilot correlates events to its own turn and recovers dropped sessions; turns run in the worktree-aware repository root. diff --git a/docs/specification/client-backend-decoupling-v0.2.md b/docs/specification/client-backend-decoupling-v0.2.md index ef241a638..ac1005086 100644 --- a/docs/specification/client-backend-decoupling-v0.2.md +++ b/docs/specification/client-backend-decoupling-v0.2.md @@ -13,8 +13,12 @@ **Implementation status:** The contract in this draft is implemented as the experimental `@bradygaster/squad-sdk/backend` subpath (`LocalSquadBackend`, `DurableHttpSquadBackend`, `DefaultHarnessRegistry`) and bridged over MCP by -`squad state-mcp --experimental-backend`. No execution harness ships with it; -the MCP bridge records transcripts and events only. +`squad state-mcp --experimental-backend`. Two execution harnesses ship: +`claude-agent-sdk` (`ClaudeAgentSdkHarness`), registered by the MCP bridge by +default when the optional `@anthropic-ai/claude-agent-sdk` peer is installed, +and `copilot-sdk` (`CopilotSdkHarness`), registered only when listed in +`SQUAD_BACKEND_HARNESSES`. Without a registered harness the bridge records +transcripts and events only. **Known deviations:** Approval hashes are opaque harness-supplied strings (not broker-computed digests), lease epochs are client-asserted rather than @@ -415,8 +419,9 @@ The same contract is projected through three backend families. `LocalSquadBackend` runs the broker in process and stores each conversation as one JSON snapshot under `.squad/backend/conversations/.json`, serialized across processes by `.squad/backend/locks/.lock`. Harnesses are plugged in -through a `HarnessRegistry`; the SDK ships the registry and the harness -interface but no harness implementation. +through a `HarnessRegistry`; the SDK ships the registry, the harness +interface and two implementations, `claude-agent-sdk` (Claude Agent SDK) and +`copilot-sdk` (GitHub Copilot SDK). ### 10.2 MCP bridge @@ -454,7 +459,12 @@ replayed to another origin. - Lease epochs are client-asserted (see section 9). - Cursors are unsigned `base64url("c1::")` values, validated only for the conversation id and numeric form. -- No execution harness ships with the CLI or SDK. +- `claude-agent-sdk` needs the optional `@anthropic-ai/claude-agent-sdk` peer + and Anthropic API, Bedrock or Vertex credentials; `copilot-sdk` needs a + signed-in Copilot user and is opt-in on the MCP bridge. +- Both harnesses keep a pending approval in process memory, so a harness + restart loses it; the broker's record must then be cancelled and the session + resumed. ## 11. Protocol versioning and compatibility policy diff --git a/docs/specification/client-harness-adapters-v0.2.md b/docs/specification/client-harness-adapters-v0.2.md index 14e8583f4..7fd366195 100644 --- a/docs/specification/client-harness-adapters-v0.2.md +++ b/docs/specification/client-harness-adapters-v0.2.md @@ -229,10 +229,75 @@ registry lists harnesses during capability negotiation. `decision: 'approve' | 'deny'`, `reason?`, `epoch?`); the `hash` is the opaque string the harness itself supplied with its `approval-requested` event. -No `HarnessAdapter` implementation ships in the SDK or CLI today. The -registry, the SPI and the broker-side delivery guarantees exist and are tested -with in-memory harnesses; the `squad state-mcp` bridge runs without any -harness and refuses `squad_backend_send_message` accordingly. +Two `HarnessAdapter`s ship, in `packages/squad-sdk/src/backend/harnesses/`. +Both route approvals through one shared queue (`TurnApprovals`): requests are +hash-bound (sha256 of the canonical action), offered one at a time because the +broker holds one pending approval per turn, and a deny ends the turn. +Approvals are scoped to their conversation, since runtime tool-call ids are +not globally unique. The broker's approval record carries only a short, +redacted action summary and the hash: the tool input itself is never sent as +`payload`, so it stays out of backend state. The hash covers the full input. +Redaction masks credentials (named values, JSON keys, `Authorization` +schemes, credential flags, `-u user:password`, URL passwords, known token +formats) but never masks executable syntax: a value containing shell +expansion, substitution, chaining or redirection stays visible, so the +approver always sees what would run. + +Both also share one turn lifecycle (`TurnHarness`). Concurrent deliveries of +an operation share one acceptance, and a failed start forgets it so the retry +runs. A run is registered before its runtime starts, so a cancel during +startup or session creation is kept and the turn never reaches the runtime. +`cancel` acknowledges once the stop is requested; the conversation's next turn +starts only after the interrupt has finished, so late events from an aborted +turn are never attributed to the next one. An interrupt that fails stays +retryable, and the harness stops reusing the runtime session it may still be +using. With a state directory (`stateDir`; `squad state-mcp` always passes one) +accepted deliveries and each conversation's runtime session id survive a +restart: a redelivered turn is not run again, and the next turn resumes the +session instead of starting without context. + +`claude-agent-sdk` (`ClaudeAgentSdkHarness`) runs each admitted turn as a +Claude Agent SDK `query()`, resuming the conversation's session, and maps +`canUseTool` onto `approval-requested`: `approvalId` is the tool-use id, `hash` +is a sha256 of the canonical tool name and input, parallel tool calls are +offered one at a time (the broker holds one pending approval per turn), and a +deny ends the turn. It runs with `permissionMode: 'default'` and +`settingSources: []`, so no repository or user allow rule approves a call the +broker never sees; the `permissions.deny` rules from user, project and local +settings are carried over as `disallowedTools`, so they still apply. The real +SDK starts only with an API credential (`ANTHROPIC_API_KEY`, +`ANTHROPIC_AUTH_TOKEN`, or Bedrock, Vertex or Foundry settings), never a saved +claude.ai login. Claude Code still runs some read-only commands (such as +`echo`) without calling `canUseTool`, so those never reach the broker; anything +that writes does (live-verified 2026-11-10: a `touch` was offered, denied, and +not run). The SDK is an optional peer dependency; `squad state-mcp +--experimental-backend` registers the harness only when it is installed and +such a credential is configured (`SQUAD_BACKEND_HARNESSES=none` turns it off), +and otherwise refuses +`squad_backend_send_message`. + +`copilot-sdk` (`CopilotSdkHarness`) keeps one Copilot session per conversation +(created on the first turn, resumed by id afterwards). A turn is `send()` +followed by `session.idle` (completed) or `session.error` (failed); only +main-agent `assistant.message` content enters the transcript. A turn reacts +only to events after its own `user.message` (matched to the id `send()` +returns), so an idle or answer from an earlier, aborted turn can't complete or +join it. A session whose send failed, whose interrupt failed or whose turn +timed out (`turnTimeoutMs`, default 30 minutes) is dropped and resumed fresh. +`onPermissionRequest` goes through the same queue, with `toolCallId` as the +`approvalId` (`#2`, `#3` … when one tool call asks for several permissions); approve answers `approve-once`, and deny answers `reject` and +aborts the turn. The harness never answers with a remembered approval, but the +Copilot runtime can keep "approve permanently" decisions (URL prompts) in its +config directory; the `configDirectory` option points sessions at a +harness-only directory so approvals made in the user's own Copilot CLI don't +apply; the MCP bridge always sets one. Because `@github/copilot-sdk` is always installed, the MCP +bridge registers this harness only when `SQUAD_BACKEND_HARNESSES` lists +`copilot-sdk`. + +The shared contract suite (`test/harness-contract.ts`) runs the same +assertions against both harnesses (§15 item 1). Both run in the +worktree-aware repository root, so a linked worktree's turns edit that +worktree even when its squad state resolves to the main checkout. `describe` and `projectWorkspace` on `ClientAdapter` let the broker reason about host capabilities and file projection without @@ -623,6 +688,7 @@ module and keep host projections in one sibling folder per host family. | backend conversation contracts (experimental) | `packages/squad-sdk/src/backend/` (`@bradygaster/squad-sdk/backend`) | | client and harness adapter SPI | `packages/squad-sdk/src/client/adapter-contract.ts` | | host projection implementations | `packages/squad-sdk/src/client/adapters/` | +| execution harness implementations (experimental) | `packages/squad-sdk/src/backend/harnesses/` | | public client exports | `packages/squad-sdk/src/client/index.ts` (`@bradygaster/squad-sdk/client`) | | deprecated alias | `@bradygaster/squad-sdk/clients` via `src/clients/index.ts` | diff --git a/docs/src/content/docs/concepts/squad-2-0-architecture.md b/docs/src/content/docs/concepts/squad-2-0-architecture.md index a72215728..9dca4b297 100644 --- a/docs/src/content/docs/concepts/squad-2-0-architecture.md +++ b/docs/src/content/docs/concepts/squad-2-0-architecture.md @@ -85,7 +85,7 @@ The control plane lives in `@bradygaster/squad-sdk/backend` and owns conversatio - **Versioned hash-bound approvals** (`v1` requested → `v2` decided) where `respondApproval` verifies the immutable payload `hash`; - **Canonical health and error envelopes** (`BackendError`, `/health/live`, `/health/ready`). -In `squad state-mcp`, the 11 `squad_backend_*` MCP conversation tools are **opt-in**: pass `--experimental-backend` or set `SQUAD_EXPERIMENTAL_BACKEND=1`. `SQUAD_BACKEND_URL` alone does not enable them. The bridge ships no execution harness: `squad_backend_health` / `squad_backend_negotiate` report `executionAvailable: false`, and `squad_backend_send_message` refuses instead of queueing (`unavailable` when no harness is registered; auto-selects a single registered in-process harness when `harness` is omitted, or refuses with `invalid-request` when multiple harnesses are registered), so over MCP the bundled backend is a transcript and event-log surface. +In `squad state-mcp`, the 11 `squad_backend_*` MCP conversation tools are **opt-in**: pass `--experimental-backend` or set `SQUAD_EXPERIMENTAL_BACKEND=1`. `SQUAD_BACKEND_URL` alone does not enable them. The bridge registers the `claude-agent-sdk` harness when the optional `@anthropic-ai/claude-agent-sdk` peer is installed, and the `copilot-sdk` harness only when `SQUAD_BACKEND_HARNESSES` lists it (`SQUAD_BACKEND_HARNESSES=none` turns both off); without a harness `squad_backend_health` / `squad_backend_negotiate` report `executionAvailable: false`, and `squad_backend_send_message` refuses instead of queueing (`unavailable` when no harness is registered; auto-selects a single registered in-process harness when `harness` is omitted, or refuses with `invalid-request` when multiple harnesses are registered), so over MCP the bundled backend is a transcript and event-log surface. ### Plane 3: Execution Plane (`HarnessAdapter` SPI) diff --git a/docs/src/content/docs/reference/host-feature-matrix.md b/docs/src/content/docs/reference/host-feature-matrix.md index a78a66d93..a1ccb3064 100644 --- a/docs/src/content/docs/reference/host-feature-matrix.md +++ b/docs/src/content/docs/reference/host-feature-matrix.md @@ -67,8 +67,8 @@ Capability counts and validation counts are independent breakdowns of the same c | Host | ✅ supported | ⚠️ degraded | ❌ unsupported | — n/a | 🖥️ validated-live | 🧪 validated-test | 📄 static-only | ❔ untested | ⛔ blocked | of which expected-failure gaps | |------|-------------|-------------|----------------|-------|-------------------|-------------------|----------------|------------|------------|--------------------------------| -| `copilot` | 45 | 0 | 6 | 0 | 0 | 49 | 2 | 0 | 0 | 6 | -| `claude` | 43 | 0 | 5 | 3 | 18 | 30 | 3 | 0 | 0 | 5 | +| `copilot` | 45 | 0 | 6 | 0 | 1 | 48 | 2 | 0 | 0 | 6 | +| `claude` | 43 | 0 | 5 | 3 | 19 | 29 | 3 | 0 | 0 | 5 | | `kiro` | 40 | 2 | 6 | 3 | 0 | 47 | 3 | 0 | 1 | 6 | | `opencode` | 41 | 1 | 6 | 3 | 18 | 30 | 3 | 0 | 0 | 6 | | `cursor` | 40 | 2 | 6 | 3 | 0 | 47 | 3 | 0 | 1 | 6 | @@ -92,7 +92,7 @@ These digest gaps are pinned as expected failures, one test per host and gap: th - Capability and validation are separate axes: a `supported` cell may be only `static-only`, and an `unsupported` digest-gap cell is `validated-test` because an `it.fails` proves the gap. Read both columns. - Expected failures are pinned per host and per gap (one `it.fails` per ` []` pair); a section landing for one host turns exactly that host's test red. Those cells are `unsupported`, never `degraded`. -- Live evidence exists for Claude Code and OpenCode only (reference machine, 2026-10-22). Kiro, Codex, Antigravity and Cursor have no passing live run: their cells are `validated-test` only through host-independent tests, otherwise `static-only`, `untested` or `blocked`. +- Live evidence exists for Claude Code and OpenCode (reference machine, 2026-10-22) and for both execution harnesses on the experimental backend: `copilot-sdk` (2026-11-09) and `claude-agent-sdk` (2026-11-10). Kiro, Codex, Antigravity and Cursor have no passing live run: their cells are `validated-test` only through host-independent tests, otherwise `static-only`, `untested` or `blocked`. - The opt-in headless coordinator tests (`SQUAD_REAL_MODEL_TESTS=1`) verify the MCP bridge through a tee'd `squad state-mcp` transcript (a successful `tools/call` of `squad_state_read` with `key: "team.md"`), not by matching stdout. No other cell claims bridge-level verification. ## Matrix @@ -190,7 +190,7 @@ These digest gaps are pinned as expected failures, one test per host and gap: th | [Squad 2.0 `--client` projection (coordinator file + MCP config per host, rollback)](#client-projection) | a, d | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | | [`squad_state` MCP bridge reachable from the host (stdio, host-prefixed tool names)](#squad-state-mcp-bridge) | c, d | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🖥️ | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | | [Capability negotiation (client ∩ backend ∩ policy)](#capability-negotiation) | c | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | -| [Experimental conversation backend (`squad_backend_*`, durable-http, approvals, resume)](#experimental-backend) | c | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | +| [Experimental conversation backend (`squad_backend_*`, durable-http, approvals, resume)](#experimental-backend) | c | ✅ 🖥️ | ✅ 🖥️ | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | ✅ 🧪 | | [Specialists (subagents) can reach `squad_state` tools themselves](#subagent-mcp-inheritance) | d | ✅ 📄 | ✅ 🖥️ | ✅ ⛔ | ✅ 🖥️ | ✅ ⛔ | ✅ ❔ | ⚠️ ⛔ | ## Per-feature detail @@ -971,8 +971,8 @@ Depends on: **c** · docs: [concepts/squad-2-0-architecture](/squad/docs/concept | Host | Capability | Validation | Mechanism | Evidence | |------|------------|------------|-----------|----------| -| `copilot` | ✅ supported | 🧪 validated-test | Opt-in tools over the same MCP server; backend semantics are host-independent. | `validated-test:test/squad-20-real-clients-stdio.e2e.test.ts::completes the MCP handshake and lists squad_state_* and all squad_backend_* tools when opted in`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::Scenario 4: DurableHttpSquadBackend full HTTP lifecycle`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::Full non-bypass journey: host adapter → broker → harness → approval → restart/reconnect` | -| `claude` | ✅ supported | 🧪 validated-test | Opt-in tools over the same MCP server; backend semantics are host-independent. | `validated-test:test/squad-20-real-clients-stdio.e2e.test.ts::completes the MCP handshake and lists squad_state_* and all squad_backend_* tools when opted in`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::Scenario 4: DurableHttpSquadBackend full HTTP lifecycle`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::Full non-bypass journey: host adapter → broker → harness → approval → restart/reconnect` | +| `copilot` | ✅ supported | 🖥️ validated-live | Opt-in tools over the same MCP server; backend semantics are host-independent. The `copilot-sdk` harness (`CopilotSdkHarness`) executes turns on the Copilot SDK; opt-in on the bridge via `SQUAD_BACKEND_HARNESSES=copilot-sdk`. _Live: plain turns through the broker on the real Copilot backend (2026-11-09; again 2026-11-10 after event correlation, including with a harness-only config directory). Approvals are covered by the shared contract, not yet live._ | `validated-test:test/squad-20-real-clients-stdio.e2e.test.ts::completes the MCP handshake and lists squad_state_* and all squad_backend_* tools when opted in`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::Scenario 4: DurableHttpSquadBackend full HTTP lifecycle`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::Full non-bypass journey: host adapter → broker → harness → approval → restart/reconnect`
`validated-test:test/harness-contract.test.ts::*`
`validated-test:test/copilot-sdk-harness.test.ts::completes a turn through the broker`
`validated-test:test/copilot-sdk-harness.test.ts::completes a turn with a harness-only Copilot config directory (login still works)`
`validated-manual:lab:copilot-sdk-harness@2026-11-10` | +| `claude` | ✅ supported | 🖥️ validated-live | Opt-in tools over the same MCP server; backend semantics are host-independent. The `claude-agent-sdk` harness (`ClaudeAgentSdkHarness`) executes turns on the Claude Agent SDK when the optional peer is installed. _Live (2026-11-10): one turn through the broker, and a write command approved-or-denied through the broker (denied: not run). Claude Code runs some read-only commands without asking, so those never reach the broker._ | `validated-test:test/squad-20-real-clients-stdio.e2e.test.ts::completes the MCP handshake and lists squad_state_* and all squad_backend_* tools when opted in`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::Scenario 4: DurableHttpSquadBackend full HTTP lifecycle`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::Full non-bypass journey: host adapter → broker → harness → approval → restart/reconnect`
`validated-test:test/harness-contract.test.ts::*`
`validated-test:test/claude-agent-sdk-harness.test.ts::*`
`validated-test:test/claude-agent-sdk-harness.test.ts::routes a real tool call through broker approval and honours a deny`
`validated-manual:lab:claude-agent-sdk-harness@2026-11-10` | | `kiro` | ✅ supported | 🧪 validated-test | Opt-in tools over the same MCP server; backend semantics are host-independent. | `validated-test:test/squad-20-real-clients-stdio.e2e.test.ts::completes the MCP handshake and lists squad_state_* and all squad_backend_* tools when opted in`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::Scenario 4: DurableHttpSquadBackend full HTTP lifecycle`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::Full non-bypass journey: host adapter → broker → harness → approval → restart/reconnect` | | `opencode` | ✅ supported | 🧪 validated-test | Opt-in tools over the same MCP server; backend semantics are host-independent. | `validated-test:test/squad-20-real-clients-stdio.e2e.test.ts::completes the MCP handshake and lists squad_state_* and all squad_backend_* tools when opted in`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::Scenario 4: DurableHttpSquadBackend full HTTP lifecycle`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::Full non-bypass journey: host adapter → broker → harness → approval → restart/reconnect` | | `cursor` | ✅ supported | 🧪 validated-test | Opt-in tools over the same MCP server; backend semantics are host-independent. | `validated-test:test/squad-20-real-clients-stdio.e2e.test.ts::completes the MCP handshake and lists squad_state_* and all squad_backend_* tools when opted in`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::Scenario 4: DurableHttpSquadBackend full HTTP lifecycle`
`validated-test:test/squad-20-all-scenarios.e2e.test.ts::Full non-bypass journey: host adapter → broker → harness → approval → restart/reconnect` | @@ -1052,4 +1052,7 @@ Headless real-CLI runs behind the `validated-manual:lab:` evidence ent | 2026-10-22 | `kiro` | smoke-roster | ⛔ blocked | — | kiro-cli chat failed: "The monthly usage limit has been reached". | | 2026-10-22 | `antigravity` | smoke-roster | ⛔ blocked | — | agy -p failed: RESOURCE_EXHAUSTED 429 "Individual quota reached" (resets in ~75h). | | 2026-10-22 | `cursor` | smoke-roster | ⛔ blocked | — | cursor-agent -p failed: "You've hit your usage limit". | +| 2026-11-09 | `copilot` | copilot-sdk-harness | ✅ pass | — | copilot-sdk HarnessAdapter (@github/copilot-sdk 1.0.19, signed-in Copilot user) completed one turn through LocalSquadBackend: "Reply with exactly the word: pong" → completed, transcript ends in pong (4.4 s). Plain text only; no tool approval in this run. | +| 2026-11-10 | `claude` | claude-agent-sdk-harness | ✅ pass | `Bash` | claude-agent-sdk HarnessAdapter (@anthropic-ai/claude-agent-sdk 0.3.317, Anthropic API key) through LocalSquadBackend: a plain turn completed (6.7 s); a Bash `touch` reached the broker as an approval, was denied, ended the turn cancelled, and the file was not created (6.1 s). A read-only `echo` ran without an approval request (Claude Code treats it as safe). | +| 2026-11-10 | `copilot` | copilot-sdk-harness | ✅ pass | — | After review round 4 (event correlation to the turn's own user.message, durable state): one turn through LocalSquadBackend completed (5.7 s), and the same with a harness-only Copilot configDirectory in an empty temp dir (3.2 s): sign-in still works there, so state-mcp's isolated config directory is safe. | diff --git a/docs/src/content/docs/reference/sdk.md b/docs/src/content/docs/reference/sdk.md index 74edd8777..3eaf600b8 100644 --- a/docs/src/content/docs/reference/sdk.md +++ b/docs/src/content/docs/reference/sdk.md @@ -354,8 +354,10 @@ The root package also exports cost tracking, offline mode, runtime events, OpenT - `createSquadBackend(options)` builds a `SquadBackend` for `kind: 'local'`, `kind: 'mcp'`, or `kind: 'durable-http'`. SDK callers using `kind: 'durable-http'` (or `new DurableHttpSquadBackend(options)`) must explicitly pass an `https://` `baseUrl` (plain `http://` is allowed only for `localhost` / `127.0.0.1` / `::1`) and, when needed, an optional bearer `token`; redirects are never followed. Neither `createSquadBackend` nor `DurableHttpSquadBackend` reads `SQUAD_BACKEND_URL` or `SQUAD_BACKEND_TOKEN` from the environment — automatic environment-variable lookup applies only to the CLI's `squad state-mcp` bridge (`createMcpBackend()`) when the experimental backend tools are enabled. - `SquadBackend` exposes `negotiate`, `createConversation`, `sendMessage`, `getTranscript`, `getTurn`, `readEvents`, `getApprovals`, `respondApproval`, `cancelTurn`, `cancelRun`, and `health`. +- `ClaudeAgentSdkHarness` (`claude-agent-sdk`) runs turns on the Claude Agent SDK, an optional peer dependency (`@anthropic-ai/claude-agent-sdk`), with broker approvals through `canUseTool`. +- `CopilotSdkHarness` (`copilot-sdk`) runs turns on the GitHub Copilot SDK, with broker approvals through `onPermissionRequest`. Both share `TurnApprovals` (hash-bound, one pending approval per turn, deny ends the turn). - `HarnessAdapter` and `DefaultHarnessRegistry` let custom execution runtimes plug into the broker (`submit`, `subscribe`, `cancel`, `respondApproval`, `describe`), with `validateExtensions()` enforcing `x-.*` extension namespaces. -- In `squad state-mcp`, the 11 `squad_backend_*` MCP conversation tools are opt-in via `--experimental-backend` or `SQUAD_EXPERIMENTAL_BACKEND=1`. `SQUAD_BACKEND_URL` alone does not enable them. The bridge ships no execution harness: `squad_backend_health` / `squad_backend_negotiate` report `executionAvailable: false`, and `squad_backend_send_message` refuses instead of queueing (`unavailable` when no harness is registered; auto-selects a single registered in-process harness when `harness` is omitted, or refuses with `invalid-request` when multiple harnesses are registered), so over MCP the bundled backend is a transcript and event-log surface. +- In `squad state-mcp`, the 11 `squad_backend_*` MCP conversation tools are opt-in via `--experimental-backend` or `SQUAD_EXPERIMENTAL_BACKEND=1`. `SQUAD_BACKEND_URL` alone does not enable them. The bridge registers the `claude-agent-sdk` harness when the optional `@anthropic-ai/claude-agent-sdk` peer is installed, and the `copilot-sdk` harness only when `SQUAD_BACKEND_HARNESSES` lists it (`SQUAD_BACKEND_HARNESSES=none` turns both off); without a harness `squad_backend_health` / `squad_backend_negotiate` report `executionAvailable: false`, and `squad_backend_send_message` refuses instead of queueing (`unavailable` when no harness is registered; auto-selects a single registered in-process harness when `harness` is omitted, or refuses with `invalid-request` when multiple harnesses are registered), so over MCP the bundled backend is a transcript and event-log surface. ## Other Public Modules diff --git a/packages/squad-cli/src/cli/commands/state-mcp.ts b/packages/squad-cli/src/cli/commands/state-mcp.ts index 28af99e62..d457eb604 100644 --- a/packages/squad-cli/src/cli/commands/state-mcp.ts +++ b/packages/squad-cli/src/cli/commands/state-mcp.ts @@ -325,8 +325,11 @@ export function printStateMcpHelp(): void { 'squad_backend_* conversation tools. SQUAD_BACKEND_URL alone does not', 'enable them; once enabled it routes the tools to a remote durable backend', '(https required, plain http only for localhost) with SQUAD_BACKEND_TOKEN.', - 'No execution harness is bundled: the bridge records transcripts and', - 'events only.', + 'Execution uses the claude-agent-sdk harness when the optional', + '@anthropic-ai/claude-agent-sdk package is installed. Set', + 'SQUAD_BACKEND_HARNESSES (comma-separated: claude-agent-sdk, copilot-sdk,', + 'or none) to choose; without a harness the bridge records transcripts', + 'and events only.', '', ].join('\n')); } diff --git a/packages/squad-cli/src/cli/core/backend-mcp.ts b/packages/squad-cli/src/cli/core/backend-mcp.ts index b4fa36e9c..38cc749ee 100644 --- a/packages/squad-cli/src/cli/core/backend-mcp.ts +++ b/packages/squad-cli/src/cli/core/backend-mcp.ts @@ -1,8 +1,17 @@ +import { createHash } from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; import { resolveSquadState } from '@bradygaster/squad-sdk'; import { ALL_CONTENT_PART_TYPES, BACKEND_PROTOCOL_VERSION, BackendError, + ClaudeAgentSdkHarness, + CopilotSdkHarness, + DefaultHarnessRegistry, + claudeApiCredentialsConfigured, + isClaudeAgentSdkAvailable, + type HarnessRegistry, createSquadBackend, createStateBackendStore, type BackendContentPart, @@ -18,9 +27,10 @@ import { * `BackendErrorInfo`, `NegotiatedCapabilities`). Errors are returned in the * canonical `BackendErrorInfo` structure. * - * No execution harness ships with the CLI: the bridge is a transcript / event-log - * surface. `squad_backend_send_message` refuses to admit a turn when no harness is - * registered instead of queueing it forever. + * The local backend registers the `claude-agent-sdk` harness when the optional + * `@anthropic-ai/claude-agent-sdk` peer is installed (`SQUAD_BACKEND_HARNESSES=none` turns that off). + * Without a harness the bridge is a transcript / event-log surface, and + * `squad_backend_send_message` refuses to admit a turn instead of queueing it forever. */ export interface BackendMcpTool { name: string; @@ -131,10 +141,64 @@ export function isExperimentalBackendEnabled( * Backend selection: SQUAD_BACKEND_URL (+ SQUAD_BACKEND_TOKEN) → durable-http (https, or http to loopback), * otherwise the local backend over the repo's .squad directory. */ +/** + * Execution harnesses for the local backend. `SQUAD_BACKEND_HARNESSES` is a comma-separated list of harness + * ids, or `none`. The default is `claude-agent-sdk`, registered only when its optional SDK is installed. + * `copilot-sdk` is opt-in: its SDK is always installed, so registering it by default would make every + * experimental bridge run Copilot sessions. + */ +export interface LocalHarnessOptions { + /** Directory each harness keeps its durable state under (accepted deliveries, session ids). */ + stateDir?: string; + /** Override for tests: whether the optional Claude Agent SDK resolves. */ + claudeSdkAvailable?: () => boolean; +} + +/** + * Execution harnesses for the local backend. `SQUAD_BACKEND_HARNESSES` is a comma-separated list of harness + * ids, or `none`. The default is `claude-agent-sdk`, registered only when its optional SDK is installed **and** + * an API credential is configured (an Anthropic API key or Bedrock/Vertex/Foundry settings; never a claude.ai + * login). `copilot-sdk` is opt-in, since its SDK is always installed; it gets its own Copilot config directory so + * approvals remembered in the user's own Copilot CLI don't apply to bridge turns. + */ +export function createLocalHarnesses( + repoRoot: string, + env: NodeJS.ProcessEnv = process.env, + options: LocalHarnessOptions = {}, +): HarnessRegistry { + const registry = new DefaultHarnessRegistry(); + const wanted = (env['SQUAD_BACKEND_HARNESSES'] ?? 'claude-agent-sdk').split(',').map((h) => h.trim()).filter(Boolean); + if (wanted.includes('none')) return registry; + const dir = (id: string) => (options.stateDir ? path.join(options.stateDir, id) : undefined); + const claudeAvailable = options.claudeSdkAvailable ?? isClaudeAgentSdkAvailable; + if (wanted.includes('claude-agent-sdk') && claudeAvailable() && claudeApiCredentialsConfigured(env)) { + registry.register(new ClaudeAgentSdkHarness({ cwd: repoRoot, env, ...(dir('claude-agent-sdk') ? { stateDir: dir('claude-agent-sdk')! } : {}) })); + } + if (wanted.includes('copilot-sdk')) { + const copilotDir = dir('copilot-sdk'); + registry.register(new CopilotSdkHarness({ + cwd: repoRoot, + ...(copilotDir ? { stateDir: copilotDir, configDirectory: path.join(copilotDir, 'copilot-config') } : {}), + })); + } + return registry; +} + +/** + * Where harness state lives. The local backend keeps it beside its conversations (`/backend/harnesses`); a + * configured backend keeps its data outside the working tree, so harness state goes to a per-user cache instead. + */ +export function harnessStateDir(context: { paths: { teamSquadDir: string }; backend: { name: string } }): string { + if (context.backend.name === 'local') return path.join(context.paths.teamSquadDir, 'backend', 'harnesses'); + const id = createHash('sha256').update(path.resolve(context.paths.teamSquadDir)).digest('hex').slice(0, 16); + return path.join(os.homedir(), '.cache', 'squad', 'harnesses', id); +} + export function createMcpBackend( startDir: string, env: NodeJS.ProcessEnv = process.env, - resolveState: (dir: string) => Pick>, 'paths' | 'backend'> | null = resolveSquadState, + resolveState: (dir: string) => (Pick>, 'paths' | 'backend'> & { repoRoot?: string }) | null = resolveSquadState, + harnessesFor: (repoRoot: string, stateDir: string) => HarnessRegistry = (root, stateDir) => createLocalHarnesses(root, env, { stateDir }), ): SquadBackend { if (env['SQUAD_BACKEND_URL']) { return createSquadBackend({ kind: 'durable-http', baseUrl: env['SQUAD_BACKEND_URL'], token: env['SQUAD_BACKEND_TOKEN'] }); @@ -147,7 +211,11 @@ export function createMcpBackend( const state = context.backend.name === 'local' ? undefined : createStateBackendStore(context.backend, { lockAnchor: context.paths.teamSquadDir }); - return createSquadBackend({ kind: 'mcp', squadDir: context.paths.teamSquadDir, state }); + // Turns run in the worktree-aware repository root: a linked worktree without its own .squad resolves to the + // main checkout's squad directory, but its edits belong in the worktree. + const repoRoot = context.repoRoot ?? path.dirname(context.paths.projectDir ?? context.paths.teamSquadDir); + const harnesses = harnessesFor(repoRoot, harnessStateDir(context)); + return createSquadBackend({ kind: 'mcp', squadDir: context.paths.teamSquadDir, state, harnesses }); } /** Harness ids registered on an in-process backend; `undefined` when the backend is remote and does not expose them. */ @@ -183,7 +251,7 @@ export function createBackendMcpTools(getBackend: () => SquadBackend): BackendMc { name: 'squad_backend_send_message', description: 'Send a message; (runId, seq) is the idempotency key and epoch is the lease fencing token. ' - + 'Fails with code "unavailable" when no execution harness is registered (the bundled bridge ships none: it records transcripts only).', + + 'Fails with code "unavailable" when no execution harness is registered (without one, the bridge records transcripts only; claude-agent-sdk registers when its optional SDK is installed).', inputSchema: { type: 'object', properties: { conversationId: cid, runId: { type: 'string' }, seq, @@ -277,7 +345,7 @@ export function createBackendMcpTools(getBackend: () => SquadBackend): BackendMc { name: 'squad_backend_negotiate', description: 'Negotiate protocol version and capabilities. `harnesses` lists registered execution harnesses and ' - + '`executionAvailable` is false when there are none (the bundled bridge ships no harness).', + + '`executionAvailable` is false when there are none (the bridge then records transcripts only; claude-agent-sdk registers when its optional SDK is installed).', inputSchema: { type: 'object', properties: { protocols: { type: 'array', items: { type: 'string' } }, clientName: { type: 'string' }, diff --git a/packages/squad-cli/src/cli/core/command-help.ts b/packages/squad-cli/src/cli/core/command-help.ts index 96449938e..877973d08 100644 --- a/packages/squad-cli/src/cli/core/command-help.ts +++ b/packages/squad-cli/src/cli/core/command-help.ts @@ -266,8 +266,9 @@ const COMMAND_HELP: Record = { console.log(`state read/write tools to MCP-compatible hosts.\n`); console.log(`Options:`); console.log(` ${BOLD}--experimental-backend${RESET} Also expose the experimental squad_backend_* conversation`); - console.log(` tools (or set SQUAD_EXPERIMENTAL_BACKEND=1). Transcript/event-log`); - console.log(` only: no execution harness is bundled.\n`); + console.log(` tools (or set SQUAD_EXPERIMENTAL_BACKEND=1). Runs turns on the`); + console.log(` claude-agent-sdk harness when @anthropic-ai/claude-agent-sdk is`); + console.log(` installed; otherwise transcript/event-log only.\n`); }, doctor: (version) => { diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index 03743fc79..1e7d25a9f 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -252,6 +252,14 @@ "@github/copilot-sdk": ">=1.0.19 <1.0.29-0", "vscode-jsonrpc": "^9.0.3" }, + "peerDependencies": { + "@anthropic-ai/claude-agent-sdk": ">=0.3.300 <0.4.0" + }, + "peerDependenciesMeta": { + "@anthropic-ai/claude-agent-sdk": { + "optional": true + } + }, "optionalDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-metrics-otlp-grpc": "^0.223.0", diff --git a/packages/squad-sdk/src/backend/harnesses/approval-queue.ts b/packages/squad-sdk/src/backend/harnesses/approval-queue.ts new file mode 100644 index 000000000..ffb3ed92a --- /dev/null +++ b/packages/squad-sdk/src/backend/harnesses/approval-queue.ts @@ -0,0 +1,215 @@ +/** + * Turn-scoped approval queue shared by the shipped harnesses (harness-adapters §5). + * + * The broker holds one pending approval per turn and drops a second distinct request, so concurrent tool + * calls are queued and offered one at a time. Each request is bound to a hash of its canonical action; a + * response with a different hash never resolves it. A deny ends the turn, as the broker marks it + * `cancelled`: the call and every queued call are denied and `onDeny` fires so the harness can stop. + * + * What reaches the broker's approval record is minimal: the approval id, a short redacted summary of the action, + * and the hash. The tool input itself is never sent (no `payload`), so no redaction pattern has to be complete for + * a secret in it to stay out of backend state. The hash is taken over the full input, so a decision stays bound to + * exactly what will run. + */ +import { createHash } from 'node:crypto'; +import type { ApprovalResponse, HarnessApprovalRequest } from '../types.js'; + +export type ApprovalOutcome = { approved: true } | { approved: false; reason: string }; + +/** sha256 over canonical JSON (sorted keys): the approval's bound payload. */ +export function canonicalApprovalHash(payload: unknown): string { + const canonical = (v: unknown): unknown => + Array.isArray(v) + ? v.map(canonical) + : v && typeof v === 'object' + ? Object.fromEntries(Object.keys(v as object).sort().map((k) => [k, canonical((v as Record)[k])])) + : v; + return `sha256:${createHash('sha256').update(JSON.stringify(canonical(payload))).digest('hex')}`; +} + +const SENSITIVE_KEY = /pass(word|wd)?|secret|token|api[-_]?key|access[-_]?key|auth(orization)?|cookie|credential|private[-_]?key|session[-_]?id/i; +/** + * A credential name in free text: optional prefix segments (`X-`, `GITHUB_`, `AWS_SECRET_`) and then a sensitive + * word, so `X-API-Key`, `X-Auth-Token`, `Cookie`, `GITHUB_TOKEN` and `AWS_SECRET_ACCESS_KEY` all match, while + * words that merely start the same way (`author`, `tokenizer`) don't. + */ +const SECRET_NAME = + '(?:[A-Za-z0-9]+[-_])*(?:pass(?:word|wd)?|secret(?:[-_]access)?(?:[-_]?key)?|access[-_]?key|api[-_]?key|apikey|token|auth[-_]?token|authorization|cookie|credentials?|private[-_]?key|session[-_]?id)'; +/** + * Characters that make a value executable (expansion, command substitution, chaining, redirection, grouping). A + * masked value stops at the first of them, and a quoted value containing any of them is not masked at all: the + * approver must always see code that would run (`TOKEN="$(rm -rf ./src)"` stays readable). + */ +const SHELL_SYNTAX = /[$`;|&<>(){}\\]/; +const PLAIN_VALUE = '[^\\s"\'`$;|&<>(){}\\\\]+'; +/** `name: value`, `name=value`, and JSON-style `"name": "value"`. */ +const INLINE_SECRET = new RegExp(`(\\b${SECRET_NAME}["']?\\s*[=:]\\s*)("[^"]*"|'[^']*'|${PLAIN_VALUE})`, 'gi'); +/** `--password x`, `--token=x`, `--client-secret x` (a flag name ending in a credential word). */ +const SECRET_FLAG = new RegExp(`(--(?:[a-z0-9]+-)*(?:password|passwd|secret|token|api-?key|access-?key|auth-token|client-secret)(?:=|\\s+))(${PLAIN_VALUE})`, 'gi'); +/** `curl -u name:password` / `--user name:password`. */ +const USER_PASSWORD = new RegExp(`((?:^|\\s)(?:-u|--user)(?:=|\\s+)[^\\s:"']+:)(${PLAIN_VALUE})`, 'g'); +/** `Authorization: ` for any scheme (`token`, `Bearer`, `Basic`, …). */ +const AUTH_HEADER = new RegExp(`(\\b(?:proxy-)?authorization["']?\\s*:\\s*[A-Za-z][A-Za-z0-9-]*\\s+)(${PLAIN_VALUE})`, 'gi'); +const BEARER = /\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi; +/** + * Secrets with no name in front of them (positional arguments such as `gh auth login --with-token ghp_…`): + * well-known vendor token formats and JWTs. Plain hex (git SHAs, checksums), UUIDs and paths are deliberately + * left readable, since the approver needs to see what they're approving. + */ +const BARE_TOKEN = new RegExp( + [ + 'gh[pousr]_[A-Za-z0-9]{20,}', + 'github_pat_[A-Za-z0-9_]{20,}', + 'sk-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}', + 'xox[abposr]-[A-Za-z0-9-]{10,}', + '(?:AKIA|ASIA)[0-9A-Z]{16}', + 'AIza[0-9A-Za-z_-]{30,}', + 'glpat-[A-Za-z0-9_-]{16,}', + 'npm_[A-Za-z0-9]{30,}', + 'eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}', + ].map((p) => `(? value.startsWith('[redacted]') || SHELL_SYNTAX.test(value); + // Scheme-aware forms first, so `Authorization: Bearer ` loses the token rather than the scheme word. + return text + .replace(URL_PASSWORD, '$1[redacted]@') + .replace(BARE_TOKEN, '[redacted]') + .replace(OPAQUE, '[redacted]') + .replace(AUTH_HEADER, (m, head: string, value: string) => (keep(value) ? m : `${head}[redacted]`)) + .replace(BEARER, '$1 [redacted]') + .replace(SECRET_FLAG, (m, head: string, value: string) => (keep(value) ? m : `${head}[redacted]`)) + .replace(USER_PASSWORD, (m, head: string, value: string) => (keep(value) ? m : `${head}[redacted]`)) + .replace(INLINE_SECRET, (m, key: string, value: string) => { + const inner = value.replace(/^["']|["']$/g, ''); + // Scheme words are left for AUTH_HEADER/BEARER; quoted values with executable syntax stay visible. + return /^(bearer|basic|token)$/i.test(inner) || keep(inner) ? m : `${key}[redacted]`; + }); +} + +/** A copy of an approval payload safe to record: secret-looking keys masked, strings truncated, depth bounded. */ +export function redactApprovalPayload(value: unknown, depth = 0): unknown { + if (typeof value === 'string') { + const t = redactText(value); + return t.length > MAX_STRING ? `${t.slice(0, MAX_STRING)}… [${t.length - MAX_STRING} chars truncated]` : t; + } + if (value === null || typeof value !== 'object') return value; + if (depth >= 6) return '[truncated]'; + if (Array.isArray(value)) return value.slice(0, 50).map((v) => redactApprovalPayload(v, depth + 1)); + return Object.fromEntries( + Object.entries(value as Record).map(([k, v]) => [k, SENSITIVE_KEY.test(k) ? '[redacted]' : redactApprovalPayload(v, depth + 1)]), + ); +} + +/** A one-line, redacted summary of an action for the approval record. */ +export function summarizeAction(label: string, detail: unknown): string { + const text = typeof detail === 'string' ? detail : detail === undefined ? '' : JSON.stringify(redactApprovalPayload(detail)); + const flat = redactText(text).replace(/\s+/g, ' ').trim(); + return flat ? `${label}: ${flat.length > 300 ? `${flat.slice(0, 299)}…` : flat}` : label; +} + +interface Pending { + approvalId: string; + hash: string; + action: string; + payload: unknown; + resolve: (outcome: ApprovalOutcome) => void; + offered: boolean; +} + +export class TurnApprovals { + private readonly queue: Pending[] = []; + private closed?: string; + + constructor( + private readonly turnSeq: number, + /** Emits `approval-requested` for the turn. */ + private readonly offer: (request: HarnessApprovalRequest) => void, + /** Called after a decision lets the turn continue (`state: running`). */ + private readonly onApprove: () => void, + /** Called after a deny: the harness stops the turn. */ + private readonly onDeny: () => void, + ) {} + + /** Ask the broker; resolves when it decides, or as denied once the turn is closed. */ + request(approvalId: string, action: string, payload: unknown): Promise { + if (this.closed) return Promise.resolve({ approved: false, reason: this.closed }); + return new Promise((resolve) => { + this.queue.push({ approvalId, hash: canonicalApprovalHash(payload), action, payload, resolve, offered: false }); + this.offerNext(); + }); + } + + has(approvalId: string): boolean { + return this.queue.some((p) => p.approvalId === approvalId); + } + + /** + * Apply a broker decision. Returns false when the id isn't pending here (unknown, or already decided: a + * redelivered decision is then a no-op). Throws when the hash doesn't match; the call keeps waiting. + */ + respond(response: ApprovalResponse): boolean { + const i = this.queue.findIndex((p) => p.approvalId === response.approvalId); + if (i === -1) return false; + const pending = this.queue[i]!; + if (response.hash !== pending.hash) { + throw new Error(`Approval ${response.approvalId}: hash does not match the requested action`); + } + this.queue.splice(i, 1); + if (response.decision === 'approve') { + pending.resolve({ approved: true }); + this.onApprove(); + this.offerNext(); + return true; + } + const reason = response.reason ?? 'Denied by the Squad approval policy'; + pending.resolve({ approved: false, reason }); + this.close(reason); + this.onDeny(); + return true; + } + + /** Deny everything still queued and refuse new requests (cancel, deny, or end of turn). */ + close(reason: string): void { + this.closed ??= reason; + for (const p of this.queue.splice(0)) p.resolve({ approved: false, reason }); + } + + /** + * Drop one request whose call was aborted by the runtime itself. A request the broker already holds as pending + * can't be withdrawn from it, so dropping an offered request ends the turn instead of offering the next one into + * an occupied slot (the broker would discard it and the call would wait forever). + */ + drop(approvalId: string, reason: string): void { + const i = this.queue.findIndex((p) => p.approvalId === approvalId); + if (i === -1) return; + const [p] = this.queue.splice(i, 1); + p!.resolve({ approved: false, reason }); + if (p!.offered) { + this.close(reason); + this.onDeny(); + return; + } + this.offerNext(); + } + + private offerNext(): void { + if (this.queue.some((p) => p.offered)) return; + const next = this.queue[0]; + if (!next) return; + next.offered = true; + // No payload: the raw input stays in this process; the hash binds the decision to it. + this.offer({ approvalId: next.approvalId, turnSeq: this.turnSeq, action: next.action, hash: next.hash }); + } +} diff --git a/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts b/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts new file mode 100644 index 000000000..4cef97b2a --- /dev/null +++ b/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts @@ -0,0 +1,245 @@ +/** + * `claude-agent-sdk` HarnessAdapter (#2441, epic #2438 Phase 1): runs admitted turns on the Claude Agent SDK. + * + * The SDK (`@anthropic-ai/claude-agent-sdk`) is an optional peer dependency, loaded lazily on the first + * submission, so installs without it are unchanged. Only the small slice of its API used here is typed locally. + * + * Mapping (harness-adapters §5, backend `types.ts`): + * - `submit` → one `query()` per turn; the conversation's Claude session is resumed across turns. + * Submissions are deduplicated by `operationId` (or `(conversationId, runId, seq)`). + * - Assistant text → `message` events; run lifecycle → `state` events (`running`, `completed`, `failed`, + * `cancelled`), echoing the admission epoch. + * - `canUseTool` → `approval-requested`. The broker holds one pending approval per turn, so concurrent tool + * calls are queued and offered one at a time. `approvalId` is the SDK `toolUseID`; `hash` is a sha256 of the + * canonical tool name and input, and a response whose hash differs never resolves the call. Approve runs the + * original input unchanged. + * - A deny ends the turn, as the broker does (`cancelled`): the call and any queued ones are denied and the query + * is aborted. `cancel` does the same. + * - Credentials: the real SDK only starts with an API key or Bedrock/Vertex/Foundry settings, never a claude.ai login. + * - Deny rules from user and project settings are kept (as `disallowedTools`) although no settings are loaded. + * - Permission settings: `permissionMode: 'default'` and `settingSources: []`, so no repository or user allow + * rule approves a call the broker never sees. Endpoint-managed policy still applies (it always loads). + * + * Not durable across harness restarts: a pending callback lives in this process. On restart the broker's + * pending approval should be cancelled and the session resumed (see the #2441 notes). + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { HarnessCapabilities } from '../types.js'; +import { HarnessState } from './harness-state.js'; +import { canonicalApprovalHash, summarizeAction } from './approval-queue.js'; +import { TurnHarness, type TurnRun } from './turn-harness.js'; + +export const CLAUDE_AGENT_SDK_HARNESS_ID = 'claude-agent-sdk'; +const SDK_MODULE = '@anthropic-ai/claude-agent-sdk'; + +// ── The slice of the Agent SDK this adapter uses ──────────────────────────────────────────────── + +export type ClaudePermissionResult = + | { behavior: 'allow'; updatedInput: Record } + | { behavior: 'deny'; message: string; interrupt?: boolean }; + +export type ClaudeCanUseTool = ( + toolName: string, + input: Record, + options: { signal: AbortSignal; toolUseID: string; agentID?: string; decisionReason?: string }, +) => Promise; + +export interface ClaudeQueryOptions { + cwd?: string; + model?: string; + resume?: string; + maxTurns?: number; + abortController?: AbortController; + canUseTool?: ClaudeCanUseTool; + permissionMode?: 'default'; + settingSources?: string[]; + disallowedTools?: string[]; + env?: Record; + systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string }; +} + +/** A message from the SDK stream; only the fields read here are typed. */ +export interface ClaudeSdkMessage { + type: string; + subtype?: string; + session_id?: string; + is_error?: boolean; + result?: string; + errors?: string[]; + parent_tool_use_id?: string | null; + message?: { content?: Array<{ type: string; text?: string }> }; +} + +export interface ClaudeAgentSdkModule { + query(args: { prompt: string; options?: ClaudeQueryOptions }): AsyncIterable; +} + +export interface ClaudeAgentSdkHarnessOptions { + /** Working directory for every session (the repository root). */ + cwd: string; + model?: string; + maxTurns?: number; + /** Appended to the Claude Code system prompt. */ + appendSystemPrompt?: string; + /** Subprocess environment; defaults to `process.env`. */ + env?: Record; + /** Loads the SDK. Defaults to a lazy `import('@anthropic-ai/claude-agent-sdk')`. */ + loadSdk?: () => Promise; + /** Where accepted deliveries and session ids survive restarts (see HarnessState). In-memory when unset. */ + stateDir?: string; + /** Home directory whose `.claude/settings.json` deny rules apply (defaults to the OS home). */ + homeDir?: string; +} + +/** + * Whether the optional `@anthropic-ai/claude-agent-sdk` peer resolves from this package, without loading it. + * Used to register the harness only where it can run. + */ +/** + * Whether the environment names a supported API credential: an Anthropic API key or auth token, or Bedrock, + * Vertex or Foundry settings. Without one the Agent SDK would fall back to a saved claude.ai login, which this + * harness must not use (#2441). + */ +export function claudeApiCredentialsConfigured(env: Record = process.env): boolean { + const on = (v: string | undefined) => v === '1' || v?.toLowerCase() === 'true'; + return !!(env['ANTHROPIC_API_KEY'] || env['ANTHROPIC_AUTH_TOKEN'] || on(env['CLAUDE_CODE_USE_BEDROCK']) || on(env['CLAUDE_CODE_USE_VERTEX']) || on(env['CLAUDE_CODE_USE_FOUNDRY'])); +} + +/** + * `permissions.deny` rules from the user, project and local settings files. The harness loads no settings + * (`settingSources: []`) so that allow rules can't bypass the broker; their deny rules are carried over as + * `disallowedTools`, which applies them in every permission mode. + */ +export function claudeDenyRules(cwd: string, homeDir: string = os.homedir()): string[] { + const files = [ + path.join(homeDir, '.claude', 'settings.json'), + path.join(cwd, '.claude', 'settings.json'), + path.join(cwd, '.claude', 'settings.local.json'), + ]; + const rules = new Set(); + for (const file of files) { + try { + const deny = (JSON.parse(fs.readFileSync(file, 'utf8')) as { permissions?: { deny?: unknown } }).permissions?.deny; + if (Array.isArray(deny)) for (const r of deny) if (typeof r === 'string' && r.trim()) rules.add(r.trim()); + } catch { + // missing or unreadable settings: no rules from this file + } + } + return [...rules]; +} + +export function isClaudeAgentSdkAvailable(): boolean { + try { + import.meta.resolve(SDK_MODULE); + return true; + } catch { + return false; + } +} + +/** sha256 over canonical JSON (sorted keys) of the tool name and input: the approval's bound payload. */ +export function claudeApprovalHash(toolName: string, input: unknown): string { + return canonicalApprovalHash({ tool: toolName, input }); +} + +/** The most telling field of a tool input, for the approval summary (the full input stays in the hash). */ +function toolDetail(input: Record): unknown { + for (const k of ['command', 'file_path', 'notebook_path', 'path', 'url', 'pattern', 'query', 'subagent_type']) { + if (typeof input[k] === 'string') return input[k]; + } + return Object.keys(input).length ? { fields: Object.keys(input) } : undefined; +} + +export class ClaudeAgentSdkHarness extends TurnHarness { + readonly id = CLAUDE_AGENT_SDK_HARNESS_ID; + readonly capabilities: HarnessCapabilities = { + streaming: true, + approvals: true, + cancellation: true, + tools: true, + extensionNamespaces: ['x-claude'], + }; + protected readonly displayName = 'Claude Agent SDK'; + + private sdk?: Promise; + + constructor(private readonly options: ClaudeAgentSdkHarnessOptions) { + super(new HarnessState(options.stateDir)); + } + + protected async init(): Promise { + // Never run the real SDK on a saved claude.ai login (an injected SDK, as in tests, brings its own transport). + if (!this.options.loadSdk && !claudeApiCredentialsConfigured(this.options.env ?? process.env)) { + throw new Error( + `The ${CLAUDE_AGENT_SDK_HARNESS_ID} harness needs ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN, or Bedrock, Vertex or Foundry settings); it does not use a claude.ai login`, + ); + } + await this.loadSdk(); + } + + /** The query listens to the run's abort signal; the turn ends when its stream does. */ + protected async interrupt(): Promise {} + + protected async runTurn(run: TurnRun, prompt: string): Promise { + const sdk = await this.loadSdk(); + this.checkpoint(run); + const resume = this.state.session(run.conversationId); + const disallowedTools = claudeDenyRules(this.options.cwd, this.options.homeDir); + const stream = sdk.query({ + prompt, + options: { + cwd: this.options.cwd, + permissionMode: 'default', + settingSources: [], + ...(disallowedTools.length ? { disallowedTools } : {}), + abortController: run.abort, + canUseTool: this.canUseTool(run), + ...(this.options.model ? { model: this.options.model } : {}), + ...(this.options.maxTurns ? { maxTurns: this.options.maxTurns } : {}), + ...(this.options.env ? { env: this.options.env } : {}), + ...(this.options.appendSystemPrompt + ? { systemPrompt: { type: 'preset' as const, preset: 'claude_code' as const, append: this.options.appendSystemPrompt } } + : {}), + ...(resume ? { resume } : {}), + }, + }); + let failure: string | undefined; + for await (const message of stream) { + if (message.session_id) this.state.setSession(run.conversationId, message.session_id); + if (message.type === 'assistant' && !message.parent_tool_use_id) { + const text = (message.message?.content ?? []) + .filter((b) => b.type === 'text' && b.text) + .map((b) => b.text) + .join(''); + if (text) this.emit(run, { type: 'message', message: { runId: run.runId, seq: run.seq, role: 'assistant', content: text } }); + } else if (message.type === 'result') { + if (message.is_error || (message.subtype && message.subtype !== 'success')) { + failure = message.result?.trim() || message.errors?.join('; ') || message.subtype || 'error'; + } + } + } + return failure; + } + + private loadSdk(): Promise { + this.sdk ??= (this.options.loadSdk ?? (() => import(SDK_MODULE) as Promise))().catch((err) => { + this.sdk = undefined; + throw new Error( + `The ${CLAUDE_AGENT_SDK_HARNESS_ID} harness needs ${SDK_MODULE} (an optional peer dependency): ${err instanceof Error ? err.message : String(err)}`, + ); + }); + return this.sdk; + } + + private canUseTool(run: TurnRun): ClaudeCanUseTool { + return async (toolName, input, { signal, toolUseID }) => { + if (signal.aborted || run.abort.signal.aborted) return { behavior: 'deny', message: 'Turn cancelled', interrupt: true }; + signal.addEventListener('abort', () => run.approvals.drop(toolUseID, 'Turn cancelled')); + const outcome = await run.approvals.request(toolUseID, summarizeAction(toolName, toolDetail(input)), { tool: toolName, input }); + // Approve runs the original input unchanged, so what runs is what was hashed. + return outcome.approved ? { behavior: 'allow', updatedInput: input } : { behavior: 'deny', message: outcome.reason, interrupt: true }; + }; + } +} diff --git a/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts b/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts new file mode 100644 index 000000000..1dfc6af1c --- /dev/null +++ b/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts @@ -0,0 +1,251 @@ +/** + * `copilot-sdk` HarnessAdapter (#2441): runs admitted turns on the GitHub Copilot SDK (`@github/copilot-sdk`). + * + * Mapping (harness-adapters §5, backend `types.ts`): + * - One Copilot session per conversation, created on the first turn and reused (or resumed by id) for + * later turns. A turn is `send()` followed by waiting for `session.idle` (completed) or `session.error` + * (failed). Submissions are deduplicated by `operationId` (or `(conversationId, runId, seq)`). + * - Main-agent `assistant.message` content becomes `message` events; sub-agent output stays out of the + * transcript. + * - `onPermissionRequest` → `approval-requested` through the shared {@link TurnApprovals} queue (one pending + * per turn, hash-bound, a deny ends the turn). `approvalId` is the request's `toolCallId` (with `#2`, `#3` … + * when one tool call asks for several permissions). + * - A turn only reacts to events after its own `user.message` (matched to the id `send()` returns), so a late idle + * or answer from an aborted turn can't complete or join the next one. A session whose turn failed to send, failed + * to interrupt or timed out is dropped and resumed fresh for the next turn. + * Approve answers `approve-once`; deny answers `reject` and aborts the session's turn. + * - `cancel` aborts the turn; queued approvals resolve as denied. + * + * Like `claude-agent-sdk`, a pending approval lives in process memory and is lost if the harness restarts. + */ +import { randomUUID } from 'node:crypto'; +import type { HarnessCapabilities } from '../types.js'; +import { summarizeAction } from './approval-queue.js'; +import { HarnessState } from './harness-state.js'; +import { TurnHarness, type TurnRun } from './turn-harness.js'; + +export const COPILOT_SDK_HARNESS_ID = 'copilot-sdk'; + +// ── The slice of the Copilot SDK this adapter uses ────────────────────────────────────────────── + +export interface CopilotPermissionRequest { + kind: string; + toolCallId?: string; + [field: string]: unknown; +} + +export type CopilotPermissionResult = { kind: 'approve-once' } | { kind: 'reject'; feedback?: string }; + +export interface CopilotSessionEvent { + type: string; + agentId?: string; + data?: { content?: string; parentToolCallId?: string; message?: string; errorType?: string; messageId?: string }; +} + +export interface CopilotSessionLike { + readonly sessionId: string; + send(prompt: string): Promise; + on(handler: (event: CopilotSessionEvent) => void): () => void; + abort(): Promise; +} + +export interface CopilotSessionConfig { + workingDirectory?: string; + model?: string; + configDirectory?: string; + onPermissionRequest: (request: CopilotPermissionRequest, invocation: { sessionId: string }) => Promise; +} + +export interface CopilotClientLike { + start(): Promise; + createSession(config: CopilotSessionConfig): Promise; + resumeSession(sessionId: string, config: CopilotSessionConfig): Promise; +} + +export interface CopilotSdkHarnessOptions { + /** Working directory for every session (the repository root). */ + cwd: string; + model?: string; + /** + * Copilot config directory for harness sessions. The runtime can remember approvals across sessions there + * ("approve permanently", URL prompts only), so a session sharing the user's default directory may fetch a + * URL the user once approved in their own Copilot CLI without the broker being asked. Point this at a + * harness-only directory to rule that out. The harness itself only ever answers `approve-once`. + */ + configDirectory?: string; + /** Where accepted deliveries and session ids survive restarts (see HarnessState). In-memory when unset. */ + stateDir?: string; + /** A turn with no terminal event after this long fails and its session is dropped (default 30 minutes). */ + turnTimeoutMs?: number; + /** Builds the client. Defaults to `new CopilotClient({ workingDirectory: cwd })` from `@github/copilot-sdk`. */ + createClient?: () => Promise; +} + +/** The most telling field of a permission request, for the approval summary (the full request stays in the hash). */ +function requestDetail(r: CopilotPermissionRequest): unknown { + return r['fullCommandText'] ?? r['fileName'] ?? r['path'] ?? r['url'] ?? r['toolName']; +} + +export class CopilotSdkHarness extends TurnHarness { + readonly id = COPILOT_SDK_HARNESS_ID; + readonly capabilities: HarnessCapabilities = { + streaming: true, + approvals: true, + cancellation: true, + tools: true, + extensionNamespaces: ['x-copilot'], + }; + protected readonly displayName = 'GitHub Copilot SDK'; + + /** The live session per conversation, and the session id to resume when the object is gone. */ + private readonly sessions = new Map>(); + /** Sessions that finished creating (only these can be aborted). */ + private readonly ready = new Map(); + /** The run currently executing per conversation (permission requests are routed to it). */ + private readonly active = new Map(); + private client?: Promise; + + constructor(private readonly options: CopilotSdkHarnessOptions) { + super(new HarnessState(options.stateDir)); + } + + protected async init(): Promise { + await this.getClient(); + } + + /** Abort the session's turn and wait for the runtime to acknowledge it; the next turn waits on this. */ + protected async interrupt(run: TurnRun): Promise { + if (this.active.get(run.conversationId) !== run) return; + // A session still being created has nothing to abort; runTurn's checkpoint keeps the turn from sending. + await this.ready.get(run.conversationId)?.abort(); + } + + protected override interruptFailed(run: TurnRun): void { + this.dropSession(run.conversationId); // it may still be busy; resume a fresh session next time + } + + protected async runTurn(run: TurnRun, prompt: string): Promise { + this.active.set(run.conversationId, run); + let off: (() => void) | undefined; + let timer: ReturnType | undefined; + try { + const session = await this.session(run.conversationId); + this.checkpoint(run); // a cancel during session creation never reaches send() + let myMessageId: string | undefined; + let started = false; + const early: CopilotSessionEvent[] = []; + let resolveFinished!: (failure: string | undefined) => void; + const finished = new Promise((resolve) => (resolveFinished = resolve)); + // Cancel ends the wait without relying on an idle event after abort. + if (run.abort.signal.aborted) resolveFinished(undefined); + run.abort.signal.addEventListener('abort', () => resolveFinished(undefined), { once: true }); + const handle = (event: CopilotSessionEvent) => { + if (event.type === 'assistant.message' && !event.data?.parentToolCallId && event.data?.content) { + this.emit(run, { type: 'message', message: { runId: run.runId, seq: run.seq, role: 'assistant', content: event.data.content } }); + } else if (event.type === 'session.idle') { + resolveFinished(undefined); + } else if (event.type === 'session.error') { + resolveFinished(event.data?.message || event.data?.errorType || 'Copilot session error'); + } + }; + /** Start at our own user.message; events before it belong to an earlier (possibly aborted) turn. */ + const tryStart = () => { + if (started || myMessageId === undefined) return; + const i = early.findIndex((e) => e.type === 'user.message' && (e.data?.messageId === myMessageId || e.data?.messageId === undefined)); + if (i === -1) return; + started = true; + for (const e of early.splice(0).slice(i + 1)) handle(e); + }; + off = session.on((event) => { + // Sub-agent events (agentId) never complete or fail the main turn, and stay out of the transcript. + if (event.agentId) return; + if (started) return handle(event); + early.push(event); + tryStart(); + }); + try { + myMessageId = await session.send(prompt); + } catch (err) { + this.dropSession(run.conversationId); // a failed send may mean a dead transport: resume fresh next time + throw err; + } + tryStart(); + const timeoutMs = this.options.turnTimeoutMs ?? 30 * 60_000; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(`Copilot turn timed out after ${Math.round(timeoutMs / 1000)} s`), timeoutMs); + }); + const failure = await Promise.race([finished, timedOut]); + if (failure?.startsWith('Copilot turn timed out')) this.dropSession(run.conversationId); + return failure; + } finally { + if (timer) clearTimeout(timer); + off?.(); + if (this.active.get(run.conversationId) === run) this.active.delete(run.conversationId); + } + } + + /** Forget the live session object; the next turn resumes the conversation's session id afresh. */ + private dropSession(conversationId: string): void { + this.sessions.delete(conversationId); + this.ready.delete(conversationId); + } + + private getClient(): Promise { + this.client ??= (async () => { + const client = this.options.createClient + ? await this.options.createClient() + : (new (await import('@github/copilot-sdk')).CopilotClient({ workingDirectory: this.options.cwd }) as unknown as CopilotClientLike); + await client.start(); + return client; + })().catch((err) => { + this.client = undefined; + throw new Error(`The ${COPILOT_SDK_HARNESS_ID} harness could not start the Copilot client: ${err instanceof Error ? err.message : String(err)}`); + }); + return this.client; + } + + private sessionConfig(conversationId: string): CopilotSessionConfig { + return { + workingDirectory: this.options.cwd, + ...(this.options.model ? { model: this.options.model } : {}), + ...(this.options.configDirectory ? { configDirectory: this.options.configDirectory } : {}), + onPermissionRequest: async (request) => { + const run = this.active.get(conversationId); + if (!run) return { kind: 'reject', feedback: 'No Squad turn is running' }; + const { kind, toolCallId, ...detail } = request; + const outcome = await run.approvals.request(this.approvalId(run, toolCallId), summarizeAction(kind, requestDetail(request)), { kind, toolCallId, ...detail }); + return outcome.approved ? { kind: 'approve-once' } : { kind: 'reject', feedback: outcome.reason }; + }, + }; + } + + /** One approval id per permission request: a tool call asking several times gets `#2`, `#3` … */ + private readonly requestCounts = new WeakMap>(); + private approvalId(run: TurnRun, toolCallId: string | undefined): string { + if (!toolCallId) return `copilot-${randomUUID()}`; + let counts = this.requestCounts.get(run); + if (!counts) this.requestCounts.set(run, (counts = new Map())); + const n = (counts.get(toolCallId) ?? 0) + 1; + counts.set(toolCallId, n); + return n === 1 ? toolCallId : `${toolCallId}#${n}`; + } + + private session(conversationId: string): Promise { + let s = this.sessions.get(conversationId); + if (!s) { + s = (async () => { + const client = await this.getClient(); + const id = this.state.session(conversationId); + const session = id + ? await client.resumeSession(id, this.sessionConfig(conversationId)) + : await client.createSession(this.sessionConfig(conversationId)); + this.state.setSession(conversationId, session.sessionId); + this.ready.set(conversationId, session); + return session; + })(); + this.sessions.set(conversationId, s); + s.catch(() => this.sessions.delete(conversationId)); + } + return s; + } +} diff --git a/packages/squad-sdk/src/backend/harnesses/harness-state.ts b/packages/squad-sdk/src/backend/harnesses/harness-state.ts new file mode 100644 index 000000000..bfe3effd1 --- /dev/null +++ b/packages/squad-sdk/src/backend/harnesses/harness-state.ts @@ -0,0 +1,78 @@ +/** + * Durable harness state (harness-adapters §5: `submit` deduplication must hold across restarts). + * + * Remembers which deliveries were accepted (the most recent {@link MAX_REMEMBERED}) and which runtime session + * belongs to each conversation, so a restarted harness neither re-runs an accepted turn nor starts a + * conversation over with no context. With no directory it is in-memory only, which keeps tests and embedded + * uses simple; `squad state-mcp` always passes one. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +export const MAX_REMEMBERED = 10_000; +const FILE = 'harness-state.json'; + +interface Stored { + version: 1; + accepted: string[]; + sessions: Record; +} + +export class HarnessState { + private readonly accepted: string[] = []; + private readonly acceptedSet = new Set(); + private readonly sessions = new Map(); + private readonly file?: string; + + constructor(dir?: string) { + if (!dir) return; + this.file = path.join(dir, FILE); + let stored: Partial | undefined; + try { + stored = JSON.parse(fs.readFileSync(this.file, 'utf8')) as Partial; + } catch { + stored = undefined; // no state yet, or unreadable: start empty rather than refuse to run + } + for (const key of Array.isArray(stored?.accepted) ? stored!.accepted : []) { + if (typeof key === 'string') this.remember(key); + } + for (const [conversationId, sessionId] of Object.entries(stored?.sessions ?? {})) { + if (typeof sessionId === 'string') this.sessions.set(conversationId, sessionId); + } + } + + hasAccepted(key: string): boolean { + return this.acceptedSet.has(key); + } + + addAccepted(key: string): void { + if (this.acceptedSet.has(key)) return; + this.remember(key); + this.save(); + } + + session(conversationId: string): string | undefined { + return this.sessions.get(conversationId); + } + + setSession(conversationId: string, sessionId: string): void { + if (this.sessions.get(conversationId) === sessionId) return; + this.sessions.set(conversationId, sessionId); + this.save(); + } + + private remember(key: string): void { + this.accepted.push(key); + this.acceptedSet.add(key); + while (this.accepted.length > MAX_REMEMBERED) this.acceptedSet.delete(this.accepted.shift()!); + } + + private save(): void { + if (!this.file) return; + const stored: Stored = { version: 1, accepted: this.accepted, sessions: Object.fromEntries(this.sessions) }; + fs.mkdirSync(path.dirname(this.file), { recursive: true }); + const tmp = `${this.file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(stored), { mode: 0o600 }); + fs.renameSync(tmp, this.file); + } +} diff --git a/packages/squad-sdk/src/backend/harnesses/turn-harness.ts b/packages/squad-sdk/src/backend/harnesses/turn-harness.ts new file mode 100644 index 000000000..36b4881de --- /dev/null +++ b/packages/squad-sdk/src/backend/harnesses/turn-harness.ts @@ -0,0 +1,233 @@ +/** + * Turn lifecycle shared by the shipped harnesses (harness-adapters §5 SPI). + * + * A subclass supplies three things: start its runtime (`init`), run one turn (`runTurn`), and interrupt a + * turn (`interrupt`). The base class owns everything the broker relies on: + * - **Atomic acceptance.** Concurrent deliveries of one operation share a single acceptance promise; a failed + * start forgets the operation so the broker's retry runs it. + * - **Cancellation that survives startup.** The run is registered before the runtime starts, so a cancel that + * arrives during startup or session creation is kept and the turn never reaches the runtime. + * - **Order and quiescence.** Turns of one conversation run in order, and the next turn starts only after the + * previous one has fully stopped, including an in-flight interrupt (so late events from an aborted turn can't + * be attributed to the next one). + * - **Conversation-scoped approvals** through {@link TurnApprovals}. + * - **Durability across restarts** through {@link HarnessState}: accepted deliveries are remembered so a restarted + * harness doesn't re-run them, and each conversation's runtime session id is kept so it can be resumed. + * - **Failed interrupts stay retryable**: a later cancel tries again, and the subclass hears about the failure + * (`interruptFailed`) so it can stop reusing a runtime session that may still be busy. + * - **Bounded memory** for long-running bridges: a finished turn is dropped (a late cancel or decision for it is a + * no-op anyway), idle conversation tails are dropped, and the record of accepted deliveries keeps the most + * recent {@link MAX_ACCEPTED}, which is what makes redelivery of a finished turn a no-op. + */ + +/** How many accepted delivery ids are remembered for deduplication. */ +export const MAX_ACCEPTED = 10_000; +import type { + ApprovalResponse, + HarnessAdapter, + HarnessCapabilities, + HarnessDescriptor, + HarnessEventEnvelope, + HarnessSubmission, + LeaseEpoch, +} from '../types.js'; +import { partsToText } from '../types.js'; +import { TurnApprovals } from './approval-queue.js'; +import { HarnessState } from './harness-state.js'; + +export interface TurnRun { + readonly conversationId: string; + readonly runId: string; + readonly seq: number; + readonly epoch?: LeaseEpoch; + /** Aborted on cancel or deny; runtimes listen to it. */ + readonly abort: AbortController; + readonly approvals: TurnApprovals; + /** Settles when an interrupt has finished (the runtime is quiet). */ + quiesce: Promise; + /** The last interrupt failed; a later cancel retries it. */ + interruptFailed?: boolean; + done: Promise; +} + +/** Thrown by {@link TurnHarness.checkpoint} when the turn was cancelled during an await. */ +export class TurnCancelled extends Error { + constructor() { + super('Turn cancelled'); + this.name = 'TurnCancelled'; + } +} + +export abstract class TurnHarness implements HarnessAdapter { + abstract readonly id: string; + abstract readonly capabilities: HarnessCapabilities; + protected abstract readonly displayName: string; + + private readonly listeners = new Map void>>(); + private readonly accepted = new Map>(); + private readonly runs = new Map(); + private readonly tails = new Map>(); + + constructor(protected readonly state: HarnessState = new HarnessState()) {} + + /** Start the runtime (idempotent; may be called concurrently). Rejecting fails the delivery. */ + protected abstract init(): Promise; + /** Run one turn; resolves with a failure message, or undefined on success. Call `checkpoint` after awaits. */ + protected abstract runTurn(run: TurnRun, prompt: string): Promise; + /** Stop the runtime's work for this turn; resolves once it is quiet. */ + protected abstract interrupt(run: TurnRun): Promise; + /** The interrupt for this turn failed: stop reusing whatever runtime state it may still be using. */ + protected interruptFailed(_run: TurnRun, _error: unknown): void {} + + describe(): HarnessDescriptor { + return { id: this.id, displayName: this.displayName, capabilities: this.capabilities }; + } + + subscribe(conversationId: string, listener: (event: HarnessEventEnvelope) => void): () => void { + let set = this.listeners.get(conversationId); + if (!set) this.listeners.set(conversationId, (set = new Set())); + set.add(listener); + return () => { + set!.delete(listener); + }; + } + + submit(submission: HarnessSubmission): Promise { + const key = submission.operationId ?? `${submission.conversationId}:${submission.runId}:${submission.seq}`; + const existing = this.accepted.get(key); + if (existing) return existing; // at-least-once delivery, including concurrent duplicates + // Accepted before a restart: never run it again. + if (this.state.hasAccepted(key)) return Promise.resolve(); + const runKey = this.runKey(submission.conversationId, submission.runId, submission.seq); + const run = this.runs.get(runKey) ?? this.createRun(submission); + this.runs.set(runKey, run); + const acceptance = (async () => { + try { + await this.init(); + } catch (err) { + this.accepted.delete(key); // the broker's retry must run it + if (!run.abort.signal.aborted) this.runs.delete(runKey); + throw err; + } + this.state.addAccepted(key); + this.schedule(run, submission); + })(); + this.accepted.set(key, acceptance); + while (this.accepted.size > MAX_ACCEPTED) this.accepted.delete(this.accepted.keys().next().value!); + return acceptance; + } + + /** + * Acknowledges once the stop is requested. It doesn't wait for the runtime to go quiet (the broker awaits this + * call); ordering is kept because the conversation's next turn waits for the interrupt to finish. + */ + async cancel(conversationId: string, runId: string, seq: number): Promise { + const run = this.runs.get(this.runKey(conversationId, runId, seq)); + if (run) this.stop(run); + } + + async respondApproval(conversationId: string, response: ApprovalResponse): Promise { + for (const run of this.runs.values()) { + if (run.conversationId === conversationId && run.approvals.respond(response)) return; + } + // Unknown or already decided in this conversation: a redelivered decision is a no-op. + } + + /** Waits for a submitted turn to finish (tests and orderly shutdown); resolves at once for a finished turn. */ + async settled(conversationId: string, runId: string, seq: number): Promise { + await this.runs.get(this.runKey(conversationId, runId, seq))?.done.catch(() => undefined); + } + + /** Turns currently held in memory (diagnostics and tests). */ + get activeTurns(): number { + return this.runs.size; + } + + protected emit(run: TurnRun, event: Record): void { + const envelope = { ...event, runId: run.runId, seq: run.seq, ...(run.epoch !== undefined ? { epoch: run.epoch } : {}) } as HarnessEventEnvelope; + for (const l of this.listeners.get(run.conversationId) ?? []) l(envelope); + } + + /** Throw {@link TurnCancelled} if the turn was cancelled; call after every await before touching the runtime. */ + protected checkpoint(run: TurnRun): void { + if (run.abort.signal.aborted) throw new TurnCancelled(); + } + + // ── internals ───────────────────────────────────────────────────────────────────────────────── + + private runKey(conversationId: string, runId: string, seq: number): string { + return `${conversationId}\u0000${runId}\u0000${seq}`; + } + + private createRun(s: HarnessSubmission): TurnRun { + const run: TurnRun = { + conversationId: s.conversationId, + runId: s.runId, + seq: s.seq, + ...(s.epoch !== undefined ? { epoch: s.epoch } : {}), + abort: new AbortController(), + approvals: undefined as unknown as TurnApprovals, + quiesce: Promise.resolve(), + done: Promise.resolve(), + }; + (run as { approvals: TurnApprovals }).approvals = new TurnApprovals( + run.seq, + (request) => this.emit(run, { type: 'approval-requested', request }), + () => this.emit(run, { type: 'state', state: 'running' }), + () => this.stop(run), // a deny ends the turn, as the broker marks it cancelled + ); + return run; + } + + private stop(run: TurnRun): void { + if (run.abort.signal.aborted && !run.interruptFailed) return; + run.abort.abort(); + run.approvals.close('Turn cancelled'); + run.interruptFailed = false; + run.quiesce = this.interrupt(run).catch((err) => { + // Not quiet: keep the cancel retryable and let the subclass stop reusing the runtime state. + run.interruptFailed = true; + this.interruptFailed(run, err); + }); + } + + private schedule(run: TurnRun, s: HarnessSubmission): void { + const prompt = s.messages + .filter((m) => m.role === 'user') + .map((m) => (m.parts?.length ? partsToText(m.parts) : m.content)) + .join('\n\n'); + const previous = this.tails.get(run.conversationId) ?? Promise.resolve(); + run.done = previous.then(() => this.execute(run, prompt)); + const tail = run.done.catch(() => undefined).then(() => { + // Finished: forget the run, and the conversation's tail if no later turn was queued behind it. + this.runs.delete(this.runKey(run.conversationId, run.runId, run.seq)); + if (this.tails.get(run.conversationId) === tail) this.tails.delete(run.conversationId); + }); + this.tails.set(run.conversationId, tail); + } + + private async execute(run: TurnRun, prompt: string): Promise { + try { + if (run.abort.signal.aborted) { + this.emit(run, { type: 'state', state: 'cancelled' }); + return; + } + this.emit(run, { type: 'state', state: 'running' }); + let failure: string | undefined; + try { + failure = await this.runTurn(run, prompt); + } catch (err) { + if (!(err instanceof TurnCancelled) && !run.abort.signal.aborted) { + failure = err instanceof Error ? err.message : String(err); + } + } + if (run.abort.signal.aborted) this.emit(run, { type: 'state', state: 'cancelled' }); + else if (failure) this.emit(run, { type: 'state', state: 'failed', error: failure.slice(0, 500) }); + else this.emit(run, { type: 'state', state: 'completed' }); + } finally { + run.approvals.close('Turn ended'); + // The next turn of this conversation starts only once an interrupt has finished. + await run.quiesce; + } + } +} diff --git a/packages/squad-sdk/src/backend/index.ts b/packages/squad-sdk/src/backend/index.ts index 7e313cf3f..6c16e7997 100644 --- a/packages/squad-sdk/src/backend/index.ts +++ b/packages/squad-sdk/src/backend/index.ts @@ -17,6 +17,15 @@ export type { LocalSquadBackendOptions, FileStateStoreOptions } from './local-ba export { createStateBackendStore } from './state-backend-store.js'; export type { StateBackendLike, StateBackendStoreOptions } from './state-backend-store.js'; export { DurableHttpSquadBackend, validateBaseUrl } from './durable-http-backend.js'; +export { ClaudeAgentSdkHarness, CLAUDE_AGENT_SDK_HARNESS_ID, claudeApprovalHash, claudeApiCredentialsConfigured, claudeDenyRules, isClaudeAgentSdkAvailable } from './harnesses/claude-agent-sdk.js'; +export { HarnessState, MAX_REMEMBERED } from './harnesses/harness-state.js'; +export { CopilotSdkHarness, COPILOT_SDK_HARNESS_ID } from './harnesses/copilot-sdk.js'; +export type { CopilotSdkHarnessOptions, CopilotClientLike, CopilotSessionLike, CopilotSessionConfig, CopilotSessionEvent, CopilotPermissionRequest, CopilotPermissionResult } from './harnesses/copilot-sdk.js'; +export { TurnApprovals, canonicalApprovalHash, redactApprovalPayload, redactText, summarizeAction } from './harnesses/approval-queue.js'; +export { TurnHarness, TurnCancelled } from './harnesses/turn-harness.js'; +export type { TurnRun } from './harnesses/turn-harness.js'; +export type { ApprovalOutcome } from './harnesses/approval-queue.js'; +export type { ClaudeAgentSdkHarnessOptions, ClaudeAgentSdkModule, ClaudeSdkMessage, ClaudeQueryOptions, ClaudeCanUseTool, ClaudePermissionResult } from './harnesses/claude-agent-sdk.js'; export type { DurableHttpOptions } from './durable-http-backend.js'; /** diff --git a/scripts/release/test-reconciliation.mjs b/scripts/release/test-reconciliation.mjs index 61a37565e..e20833568 100644 --- a/scripts/release/test-reconciliation.mjs +++ b/scripts/release/test-reconciliation.mjs @@ -41,6 +41,7 @@ export const CATEGORY_RULES = Object.freeze([ { category: 'env-gated', file: /squad-20-preview-tarball-matrix\.test\.ts$/, reason: 'SQUAD_PREVIEW_TARBALL_MATRIX=1 not set (exact-tarball matrix runs only in the attested preview workflow mode)' }, { category: 'env-gated', file: /clients-real-cli\.test\.ts$/, reason: 'SQUAD_REAL_CLI_TESTS=1 not set / host CLI binaries absent' }, { category: 'env-gated', file: /squad-20-real-clients-stdio\.e2e\.test\.ts$/, reason: 'SQUAD_REAL_CLI_TESTS=1 not set / host CLI binaries absent' }, + { category: 'env-gated', file: /(claude-agent-sdk|copilot-sdk)-harness\.test\.ts$/, title: /live \(real (Agent|Copilot) SDK\)/, reason: 'SQUAD_REAL_CLI_TESTS=1 not set / execution-harness credentials absent (Anthropic API key or Bedrock/Vertex for Claude; a signed-in Copilot user for Copilot)' }, { category: 'env-gated', file: /squad-20-host-feature-matrix\.test\.ts$/, reason: 'live host evidence requires the real CLI binaries' }, { category: 'env-gated', file: /mcp-spec-init\.test\.ts$/, title: /squad\.exe/, reason: 'it.runIf(win32): Windows-only PATH/.exe resolution' }, { diff --git a/test-fixtures/spec/suite-v0.2/host-feature-matrix.json b/test-fixtures/spec/suite-v0.2/host-feature-matrix.json index cfc92bde9..960e8f9a0 100644 --- a/test-fixtures/spec/suite-v0.2/host-feature-matrix.json +++ b/test-fixtures/spec/suite-v0.2/host-feature-matrix.json @@ -4377,23 +4377,33 @@ "cells": { "copilot": { "capability": "supported", - "validation": "validated-test", - "mechanism": "Opt-in tools over the same MCP server; backend semantics are host-independent.", + "validation": "validated-live", + "mechanism": "Opt-in tools over the same MCP server; backend semantics are host-independent. The `copilot-sdk` harness (`CopilotSdkHarness`) executes turns on the Copilot SDK; opt-in on the bridge via `SQUAD_BACKEND_HARNESSES=copilot-sdk`.", "evidence": [ "validated-test:test/squad-20-real-clients-stdio.e2e.test.ts::completes the MCP handshake and lists squad_state_* and all squad_backend_* tools when opted in", "validated-test:test/squad-20-all-scenarios.e2e.test.ts::Scenario 4: DurableHttpSquadBackend full HTTP lifecycle", - "validated-test:test/squad-20-all-scenarios.e2e.test.ts::Full non-bypass journey: host adapter → broker → harness → approval → restart/reconnect" - ] + "validated-test:test/squad-20-all-scenarios.e2e.test.ts::Full non-bypass journey: host adapter → broker → harness → approval → restart/reconnect", + "validated-test:test/harness-contract.test.ts::*", + "validated-test:test/copilot-sdk-harness.test.ts::completes a turn through the broker", + "validated-test:test/copilot-sdk-harness.test.ts::completes a turn with a harness-only Copilot config directory (login still works)", + "validated-manual:lab:copilot-sdk-harness@2026-11-10" + ], + "notes": "Live: plain turns through the broker on the real Copilot backend (2026-11-09; again 2026-11-10 after event correlation, including with a harness-only config directory). Approvals are covered by the shared contract, not yet live." }, "claude": { "capability": "supported", - "validation": "validated-test", - "mechanism": "Opt-in tools over the same MCP server; backend semantics are host-independent.", + "validation": "validated-live", + "mechanism": "Opt-in tools over the same MCP server; backend semantics are host-independent. The `claude-agent-sdk` harness (`ClaudeAgentSdkHarness`) executes turns on the Claude Agent SDK when the optional peer is installed.", "evidence": [ "validated-test:test/squad-20-real-clients-stdio.e2e.test.ts::completes the MCP handshake and lists squad_state_* and all squad_backend_* tools when opted in", "validated-test:test/squad-20-all-scenarios.e2e.test.ts::Scenario 4: DurableHttpSquadBackend full HTTP lifecycle", - "validated-test:test/squad-20-all-scenarios.e2e.test.ts::Full non-bypass journey: host adapter → broker → harness → approval → restart/reconnect" - ] + "validated-test:test/squad-20-all-scenarios.e2e.test.ts::Full non-bypass journey: host adapter → broker → harness → approval → restart/reconnect", + "validated-test:test/harness-contract.test.ts::*", + "validated-test:test/claude-agent-sdk-harness.test.ts::*", + "validated-test:test/claude-agent-sdk-harness.test.ts::routes a real tool call through broker approval and honours a deny", + "validated-manual:lab:claude-agent-sdk-harness@2026-11-10" + ], + "notes": "Live (2026-11-10): one turn through the broker, and a write command approved-or-denied through the broker (denied: not run). Claude Code runs some read-only commands without asking, so those never reach the broker." }, "kiro": { "capability": "supported", @@ -4981,6 +4991,32 @@ "outcome": "blocked", "observedTools": [], "notes": "cursor-agent -p failed: \"You've hit your usage limit\"." + }, + { + "host": "copilot", + "scenario": "copilot-sdk-harness", + "date": "2026-11-09", + "outcome": "pass", + "observedTools": [], + "notes": "copilot-sdk HarnessAdapter (@github/copilot-sdk 1.0.19, signed-in Copilot user) completed one turn through LocalSquadBackend: \"Reply with exactly the word: pong\" → completed, transcript ends in pong (4.4 s). Plain text only; no tool approval in this run." + }, + { + "host": "claude", + "scenario": "claude-agent-sdk-harness", + "date": "2026-11-10", + "outcome": "pass", + "observedTools": [ + "Bash" + ], + "notes": "claude-agent-sdk HarnessAdapter (@anthropic-ai/claude-agent-sdk 0.3.317, Anthropic API key) through LocalSquadBackend: a plain turn completed (6.7 s); a Bash `touch` reached the broker as an approval, was denied, ended the turn cancelled, and the file was not created (6.1 s). A read-only `echo` ran without an approval request (Claude Code treats it as safe)." + }, + { + "host": "copilot", + "scenario": "copilot-sdk-harness", + "date": "2026-11-10", + "outcome": "pass", + "observedTools": [], + "notes": "After review round 4 (event correlation to the turn's own user.message, durable state): one turn through LocalSquadBackend completed (5.7 s), and the same with a harness-only Copilot configDirectory in an empty temp dir (3.2 s): sign-in still works there, so state-mcp's isolated config directory is safe." } ], "expectedFailures": [ @@ -5083,7 +5119,7 @@ "limitations": [ "Capability and validation are separate axes: a `supported` cell may be only `static-only`, and an `unsupported` digest-gap cell is `validated-test` because an `it.fails` proves the gap. Read both columns.", "Expected failures are pinned per host and per gap (one `it.fails` per ` []` pair); a section landing for one host turns exactly that host's test red. Those cells are `unsupported`, never `degraded`.", - "Live evidence exists for Claude Code and OpenCode only (reference machine, 2026-10-22). Kiro, Codex, Antigravity and Cursor have no passing live run: their cells are `validated-test` only through host-independent tests, otherwise `static-only`, `untested` or `blocked`.", + "Live evidence exists for Claude Code and OpenCode (reference machine, 2026-10-22) and for both execution harnesses on the experimental backend: `copilot-sdk` (2026-11-09) and `claude-agent-sdk` (2026-11-10). Kiro, Codex, Antigravity and Cursor have no passing live run: their cells are `validated-test` only through host-independent tests, otherwise `static-only`, `untested` or `blocked`.", "The opt-in headless coordinator tests (`SQUAD_REAL_MODEL_TESTS=1`) verify the MCP bridge through a tee'd `squad state-mcp` transcript (a successful `tools/call` of `squad_state_read` with `key: \"team.md\"`), not by matching stdout. No other cell claims bridge-level verification." ] } diff --git a/test/claude-agent-sdk-harness.test.ts b/test/claude-agent-sdk-harness.test.ts new file mode 100644 index 000000000..ad3158c37 --- /dev/null +++ b/test/claude-agent-sdk-harness.test.ts @@ -0,0 +1,328 @@ +/** + * `claude-agent-sdk` HarnessAdapter (#2441). + * + * Deterministic tests replay scripted SDK exchanges through the real adapter and the real + * LocalSquadBackend: the protocol rules that must hold on every PR (dedupe, hash binding, replay, + * one-approval-at-a-time, cancel, permission isolation). The live tests run the real Agent SDK and need + * `SQUAD_REAL_CLI_TESTS=1` plus credentials (`ANTHROPIC_API_KEY`, or Bedrock/Vertex settings). + */ +import { describe, it, expect } from 'vitest'; +import { + BACKEND_PROTOCOL_VERSION, + ClaudeAgentSdkHarness, + DefaultHarnessRegistry, + LocalSquadBackend, + claudeApprovalHash, + type ClaudeAgentSdkModule, + type ClaudeQueryOptions, + type ClaudeSdkMessage, + type HarnessEventEnvelope, +} from '../packages/squad-sdk/src/backend/index.js'; + +const HARNESS = 'claude-agent-sdk'; +const tick = () => new Promise((r) => setTimeout(r, 0)); +async function until(check: () => boolean | Promise, ms = 2000): Promise { + const end = Date.now() + ms; + while (!(await check())) { + if (Date.now() > end) throw new Error('timed out waiting for condition'); + await new Promise((r) => setTimeout(r, 5)); + } +} + +interface ToolCall { name: string; input: Record; id: string } + +/** + * Replays one recorded exchange: optional tool calls (each through `canUseTool`, concurrently, as Claude + * does for parallel calls), then the assistant text and a result event. + */ +function replay(script: { text?: string; tools?: ToolCall[]; result?: Partial } = {}) { + const calls: Array<{ prompt: string; options: ClaudeQueryOptions }> = []; + const decisions: Array<{ id: string; behavior: string; input?: unknown }> = []; + const sdk: ClaudeAgentSdkModule = { + query({ prompt, options = {} }) { + calls.push({ prompt, options }); + return (async function* () { + const session = `sess-${calls.length}`; + yield { type: 'system', subtype: 'init', session_id: session }; + if (script.tools?.length) { + const results = await Promise.all( + script.tools.map((t) => options.canUseTool!(t.name, t.input, { signal: options.abortController!.signal, toolUseID: t.id })), + ); + results.forEach((r, i) => decisions.push({ id: script.tools![i]!.id, behavior: r.behavior, ...(r.behavior === 'allow' ? { input: r.updatedInput } : {}) })); + } + if (options.abortController?.signal.aborted) throw new Error('aborted'); + yield { type: 'assistant', session_id: session, parent_tool_use_id: null, message: { content: [{ type: 'text', text: script.text ?? 'done' }] } }; + yield { type: 'result', subtype: 'success', is_error: false, result: script.text ?? 'done', session_id: session, ...script.result }; + })(); + }, + }; + return { sdk, calls, decisions }; +} + +async function setup(sdk: ClaudeAgentSdkModule) { + const harness = new ClaudeAgentSdkHarness({ cwd: process.cwd(), loadSdk: async () => sdk }); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(harness); + const backend = new LocalSquadBackend({ harnesses }); + await backend.createConversation({ conversationId: 'c' }); + const send = (runId: string, seq: number, content = `task ${seq}`) => + backend.sendMessage('c', { runId, seq, role: 'user', content, harness: HARNESS }); + return { harness, backend, send }; +} + +describe('claude-agent-sdk harness: deterministic protocol checks', () => { + it('runs a turn end to end and resumes the Claude session on the next turn', async () => { + const { sdk, calls } = replay({ text: 'hello from claude' }); + const { harness, backend, send } = await setup(sdk); + await send('r1', 1, 'say hello'); + await harness.settled('c', 'r1', 1); + expect((await backend.getTurn('c', 1)).state).toBe('completed'); + expect((await backend.getTranscript('c')).map((m) => [m.role, m.content])).toEqual([['user', 'say hello'], ['assistant', 'hello from claude']]); + await send('r2', 2); + await harness.settled('c', 'r2', 2); + expect(calls[0]!.options.resume).toBeUndefined(); + expect(calls[1]!.options.resume).toBe('sess-1'); + }); + + it('isolates permissions: default mode and no filesystem settings', async () => { + const { sdk, calls } = replay(); + const { harness, send } = await setup(sdk); + await send('r1', 1); + await harness.settled('c', 'r1', 1); + expect(calls[0]!.options).toMatchObject({ permissionMode: 'default', settingSources: [] }); + }); + + it('deduplicates redelivered submissions', async () => { + const { sdk, calls } = replay(); + const harness = new ClaudeAgentSdkHarness({ cwd: process.cwd(), loadSdk: async () => sdk }); + const s = { conversationId: 'c', runId: 'r1', seq: 1, operationId: 'c:r1:1', messages: [{ runId: 'r1', seq: 1, role: 'user' as const, content: 'x' }] }; + await harness.submit(s); + await harness.submit(s); + await harness.settled('c', 'r1', 1); + expect(calls).toHaveLength(1); + }); + + it('approve: the request carries a hash of the action, and the original input runs', async () => { + const input = { command: 'npm test', description: 'run tests' }; + const { sdk, decisions } = replay({ tools: [{ name: 'Bash', input, id: 'toolu_1' }] }); + const { harness, backend, send } = await setup(sdk); + await send('r1', 1); + await until(async () => (await backend.getApprovals('c')).length === 1); + const [approval] = await backend.getApprovals('c'); + expect(approval).toMatchObject({ approvalId: 'toolu_1', state: 'pending', hash: claudeApprovalHash('Bash', input) }); + expect((await backend.getTurn('c', 1)).state).toBe('awaiting-approval'); + await backend.respondApproval('c', { approvalId: 'toolu_1', hash: approval!.hash, decision: 'approve' }); + await harness.settled('c', 'r1', 1); + expect(decisions).toEqual([{ id: 'toolu_1', behavior: 'allow', input }]); + expect((await backend.getTurn('c', 1)).state).toBe('completed'); + }); + + it('deny: the tool call is denied with the reason', async () => { + const { sdk, decisions } = replay({ tools: [{ name: 'Bash', input: { command: 'rm -rf build' }, id: 'toolu_2' }] }); + const { harness, backend, send } = await setup(sdk); + await send('r1', 1); + await until(async () => (await backend.getApprovals('c')).length === 1); + const [a] = await backend.getApprovals('c'); + await backend.respondApproval('c', { approvalId: 'toolu_2', hash: a!.hash, decision: 'deny', reason: 'not now' }); + await harness.settled('c', 'r1', 1); + expect(decisions).toEqual([{ id: 'toolu_2', behavior: 'deny' }]); + }); + + it('hash mismatch: the response is refused and the call keeps waiting', async () => { + const { sdk, decisions } = replay({ tools: [{ name: 'Edit', input: { file_path: 'a.ts' }, id: 'toolu_3' }] }); + const harness = new ClaudeAgentSdkHarness({ cwd: process.cwd(), loadSdk: async () => sdk }); + const events: HarnessEventEnvelope[] = []; + harness.subscribe('c', (e) => events.push(e)); + await harness.submit({ conversationId: 'c', runId: 'r1', seq: 1, messages: [{ runId: 'r1', seq: 1, role: 'user', content: 'x' }] }); + await until(() => events.some((e) => e.type === 'approval-requested')); + await expect(harness.respondApproval('c', { approvalId: 'toolu_3', hash: 'sha256:forged', decision: 'approve' })).rejects.toThrow(/hash does not match/); + await tick(); + expect(decisions).toEqual([]); + const req = events.find((e) => e.type === 'approval-requested') as Extract; + await harness.respondApproval('c', { approvalId: 'toolu_3', hash: req.request.hash, decision: 'approve' }); + await harness.settled('c', 'r1', 1); + expect(decisions).toHaveLength(1); + }); + + it('replay: a redelivered decision is a no-op', async () => { + const { sdk, decisions } = replay({ tools: [{ name: 'Bash', input: { command: 'ls' }, id: 'toolu_4' }] }); + const harness = new ClaudeAgentSdkHarness({ cwd: process.cwd(), loadSdk: async () => sdk }); + const events: HarnessEventEnvelope[] = []; + harness.subscribe('c', (e) => events.push(e)); + await harness.submit({ conversationId: 'c', runId: 'r1', seq: 1, messages: [{ runId: 'r1', seq: 1, role: 'user', content: 'x' }] }); + await until(() => events.some((e) => e.type === 'approval-requested')); + const hash = claudeApprovalHash('Bash', { command: 'ls' }); + await harness.respondApproval('c', { approvalId: 'toolu_4', hash, decision: 'approve' }, 'op-1'); + await harness.respondApproval('c', { approvalId: 'toolu_4', hash, decision: 'deny' }, 'op-1'); + await harness.settled('c', 'r1', 1); + expect(decisions).toEqual([{ id: 'toolu_4', behavior: 'allow', input: { command: 'ls' } }]); + }); + + it('parallel tool calls are offered one at a time (one pending approval per turn)', async () => { + const tools = [ + { name: 'Bash', input: { command: 'a' }, id: 'toolu_a' }, + { name: 'Bash', input: { command: 'b' }, id: 'toolu_b' }, + ]; + const { sdk, decisions } = replay({ tools }); + const { harness, backend, send } = await setup(sdk); + await send('r1', 1); + await until(async () => (await backend.getApprovals('c')).length === 1); + await tick(); + expect((await backend.getApprovals('c')).map((a) => a.approvalId)).toEqual(['toolu_a']); + await backend.respondApproval('c', { approvalId: 'toolu_a', hash: claudeApprovalHash('Bash', { command: 'a' }), decision: 'approve' }); + await until(async () => (await backend.getApprovals('c')).some((a) => a.approvalId === 'toolu_b' && a.state === 'pending')); + await backend.respondApproval('c', { approvalId: 'toolu_b', hash: claudeApprovalHash('Bash', { command: 'b' }), decision: 'deny' }); + await harness.settled('c', 'r1', 1); + expect(decisions.map((d) => [d.id, d.behavior])).toEqual([['toolu_a', 'allow'], ['toolu_b', 'deny']]); + expect((await backend.getTurn('c', 1)).state).toBe('cancelled'); // a deny ends the turn + }); + + it('a deny ends the turn: queued parallel calls are denied without being offered', async () => { + const tools = [ + { name: 'Bash', input: { command: 'a' }, id: 'toolu_x' }, + { name: 'Bash', input: { command: 'b' }, id: 'toolu_y' }, + ]; + const { sdk, decisions } = replay({ tools }); + const { harness, backend, send } = await setup(sdk); + await send('r1', 1); + await until(async () => (await backend.getApprovals('c')).length === 1); + await backend.respondApproval('c', { approvalId: 'toolu_x', hash: claudeApprovalHash('Bash', { command: 'a' }), decision: 'deny' }); + await harness.settled('c', 'r1', 1); + expect(decisions.map((d) => [d.id, d.behavior])).toEqual([['toolu_x', 'deny'], ['toolu_y', 'deny']]); + expect((await backend.getApprovals('c')).map((a) => a.approvalId)).toEqual(['toolu_x']); + expect((await backend.getTurn('c', 1)).state).toBe('cancelled'); + }); + + it('cancel aborts the query and denies a pending approval', async () => { + const { sdk, decisions } = replay({ tools: [{ name: 'Bash', input: { command: 'sleep 99' }, id: 'toolu_c' }] }); + const { harness, backend, send } = await setup(sdk); + await send('r1', 1); + await until(async () => (await backend.getApprovals('c')).length === 1); + await backend.cancelTurn('c', 1); + await harness.cancel('c', 'r1', 1); + await harness.cancel('c', 'r1', 1); // idempotent + await harness.settled('c', 'r1', 1); + expect(decisions).toEqual([{ id: 'toolu_c', behavior: 'deny' }]); + expect((await backend.getTurn('c', 1)).state).toBe('cancelled'); + }); + + it('an error result fails the turn', async () => { + const { sdk } = replay({ result: { subtype: 'error_max_turns', is_error: true, result: '' } }); + const { harness, backend, send } = await setup(sdk); + await send('r1', 1); + await harness.settled('c', 'r1', 1); + expect(await backend.getTurn('c', 1)).toMatchObject({ state: 'failed', error: 'error_max_turns' }); + }); + + it('without the SDK installed, submit fails so the broker retries, with a clear message', async () => { + const harness = new ClaudeAgentSdkHarness({ cwd: process.cwd(), loadSdk: async () => { throw new Error('Cannot find module'); } }); + await expect(harness.submit({ conversationId: 'c', runId: 'r1', seq: 1, messages: [] })).rejects.toThrow(/@anthropic-ai\/claude-agent-sdk \(an optional peer dependency\)/); + }); + + it('describes itself for capability negotiation', () => { + expect(new ClaudeAgentSdkHarness({ cwd: '.' }).describe()).toMatchObject({ + id: HARNESS, + capabilities: { approvals: true, cancellation: true, streaming: true, tools: true, extensionNamespaces: ['x-claude'] }, + }); + }); +}); + +const live = process.env['SQUAD_REAL_CLI_TESTS'] === '1' && + !!(process.env['ANTHROPIC_API_KEY'] || process.env['CLAUDE_CODE_USE_BEDROCK'] || process.env['CLAUDE_CODE_USE_VERTEX']); + +describe.skipIf(!live)('claude-agent-sdk harness: live (real Agent SDK)', () => { + it('completes a turn through the broker', async () => { + const harness = new ClaudeAgentSdkHarness({ cwd: process.cwd(), maxTurns: 2 }); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(harness); + const backend = new LocalSquadBackend({ harnesses }); + await backend.createConversation({ conversationId: 'live' }); + await backend.sendMessage('live', { runId: 'r1', seq: 1, role: 'user', content: 'Reply with exactly the word: pong', harness: HARNESS }); + await harness.settled('live', 'r1', 1); + expect((await backend.getTurn('live', 1)).state).toBe('completed'); + expect((await backend.getTranscript('live')).at(-1)?.content.toLowerCase()).toContain('pong'); + }, 120_000); + + it('routes a real tool call through broker approval and honours a deny', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + // A command that writes: Claude Code runs some read-only commands (such as echo) without asking. + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-claude-live-')); + const marker = path.join(cwd, 'squad-live-marker.txt'); + const harness = new ClaudeAgentSdkHarness({ cwd, maxTurns: 4 }); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(harness); + const backend = new LocalSquadBackend({ harnesses }); + await backend.createConversation({ conversationId: 'live2' }); + const events: unknown[] = []; + harness.subscribe('live2', (e) => events.push(e)); + const diagnose = async () => JSON.stringify({ + turn: await backend.getTurn('live2', 1).catch((e) => String(e)), + approvals: await backend.getApprovals('live2'), + events, + }, null, 2); + try { + await backend.sendMessage('live2', { runId: 'r1', seq: 1, role: 'user', content: 'Use the Bash tool to run exactly this command, nothing else: touch squad-live-marker.txt', harness: HARNESS }); + // Wait for the approval, or for the turn to end without one (then report what happened). + await until(async () => { + if ((await backend.getApprovals('live2')).some((a) => a.state === 'pending')) return true; + const state = (await backend.getTurn('live2', 1)).state; + if (state === 'completed' || state === 'failed' || state === 'cancelled') throw new Error(`turn ended (${state}) without an approval request:\n${await diagnose()}`); + return false; + }, 120_000).catch(async (err) => { + throw new Error(`${err instanceof Error ? err.message : String(err)}\n${String(err).includes('turn ended') ? '' : await diagnose()}`); + }); + const pending = (await backend.getApprovals('live2')).find((a) => a.state === 'pending')!; + expect(pending.action).toMatch(/^Bash: /); + expect(pending.action).toContain('touch'); + await backend.respondApproval('live2', { approvalId: pending.approvalId, hash: pending.hash, decision: 'deny', reason: 'live test denies tool use' }); + await harness.settled('live2', 'r1', 1); + expect((await backend.getTurn('live2', 1)).state).toBe('cancelled'); // a broker deny ends the turn + expect(fs.existsSync(marker), 'the denied command must not have run').toBe(false); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } + }, 180_000); +}); + +describe('claude-agent-sdk harness: state-mcp registration', () => { + it('registers only when the SDK is installed, and SQUAD_BACKEND_HARNESSES=none turns it off', async () => { + const { createLocalHarnesses } = await import('../packages/squad-cli/src/cli/core/backend-mcp.js'); + const key = { ANTHROPIC_API_KEY: 'test-key' }; + const yes = { claudeSdkAvailable: () => true }; + expect(createLocalHarnesses('/repo', key, yes).list().map((h) => h.id)).toEqual([HARNESS]); + expect(createLocalHarnesses('/repo', key, { claudeSdkAvailable: () => false }).list()).toEqual([]); + expect(createLocalHarnesses('/repo', {}, yes).list()).toEqual([]); // no API credential: never a claude.ai login + expect(createLocalHarnesses('/repo', { CLAUDE_CODE_USE_BEDROCK: '1' }, yes).list().map((h) => h.id)).toEqual([HARNESS]); + expect(createLocalHarnesses('/repo', { ...key, SQUAD_BACKEND_HARNESSES: 'none' }, yes).list()).toEqual([]); + expect(createLocalHarnesses('/repo', { ...key, SQUAD_BACKEND_HARNESSES: 'other' }, yes).list()).toEqual([]); + expect(createLocalHarnesses('/repo', { SQUAD_BACKEND_HARNESSES: 'copilot-sdk' }, yes).list().map((h) => h.id)).toEqual(['copilot-sdk']); + expect(createLocalHarnesses('/repo', { ...key, SQUAD_BACKEND_HARNESSES: 'copilot-sdk, claude-agent-sdk' }, yes).list().map((h) => h.id)).toEqual([HARNESS, 'copilot-sdk']); + expect(createLocalHarnesses('/repo', {}, { claudeSdkAvailable: () => false }).list()).toEqual([]); // copilot-sdk is never a default + }); + + it('createMcpBackend wires the registry into the local backend', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const { createMcpBackend } = await import('../packages/squad-cli/src/cli/core/backend-mcp.js'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-harness-reg-')); + fs.mkdirSync(path.join(root, '.squad')); + fs.writeFileSync(path.join(root, '.squad', 'team.md'), '# Team\n'); + try { + let seenRoot = ''; + const { sdk } = replay({ text: 'via mcp' }); + const backend = createMcpBackend(root, {}, undefined, (repoRoot) => { + seenRoot = repoRoot; + const r = new DefaultHarnessRegistry(); + r.register(new ClaudeAgentSdkHarness({ cwd: repoRoot, loadSdk: async () => sdk })); + return r; + }); + expect(fs.realpathSync(seenRoot)).toBe(fs.realpathSync(root)); + expect((await backend.negotiate({ protocols: [BACKEND_PROTOCOL_VERSION] })).harnesses).toEqual([HARNESS]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/copilot-sdk-harness.test.ts b/test/copilot-sdk-harness.test.ts new file mode 100644 index 000000000..bcdbc9baa --- /dev/null +++ b/test/copilot-sdk-harness.test.ts @@ -0,0 +1,301 @@ +/** + * `copilot-sdk` HarnessAdapter (#2441): Copilot-specific behaviour beyond the shared contract + * (test/harness-contract.ts). Deterministic tests use a scripted Copilot client; the live test runs the real + * Copilot SDK behind `SQUAD_REAL_CLI_TESTS=1` with a signed-in Copilot user. + */ +import { describe, it, expect } from 'vitest'; +import { + CopilotSdkHarness, + DefaultHarnessRegistry, + LocalSquadBackend, + type CopilotClientLike, + type CopilotPermissionResult, + type CopilotSessionConfig, + type CopilotSessionEvent, +} from '../packages/squad-sdk/src/backend/index.js'; + +async function until(check: () => boolean | Promise, ms = 3000): Promise { + const end = Date.now() + ms; + while (!(await check())) { + if (Date.now() > end) throw new Error('timed out waiting for condition'); + await new Promise((r) => setTimeout(r, 5)); + } +} + +/** A Copilot client whose sessions run `turn` for every send. */ +function client(turn: (ctx: { + fire: (e: CopilotSessionEvent) => void; + ask: (r: Parameters[0]) => Promise; + aborted: () => boolean; +}) => Promise) { + const log = { created: 0, resumed: [] as string[], aborts: 0, decisions: [] as CopilotPermissionResult[], started: 0 }; + const c: CopilotClientLike = { + async start() { log.started++; }, + async resumeSession(id, config) { log.resumed.push(id); return make(config, id); }, + async createSession(config) { log.created++; return make(config, `s-${log.created}`); }, + }; + function make(config: CopilotSessionConfig, id: string) { + const handlers = new Set<(e: CopilotSessionEvent) => void>(); + let aborted = false; + return { + sessionId: id, + on(h: (e: CopilotSessionEvent) => void) { handlers.add(h); return () => handlers.delete(h); }, + async abort() { aborted = true; log.aborts++; }, + async send() { + const messageId = `msg-${++sent}`; + handlers.forEach((h) => h({ type: 'user.message', data: { messageId } })); + void turn({ + fire: (e) => handlers.forEach((h) => h(e)), + ask: async (r) => { const d = await config.onPermissionRequest(r, { sessionId: id }); log.decisions.push(d); return d; }, + aborted: () => aborted, + }); + return messageId; + }, + }; + } + let sent = 0; + return { client: c, log }; +} + +async function setup(c: CopilotClientLike) { + const harness = new CopilotSdkHarness({ cwd: process.cwd(), createClient: async () => c }); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(harness); + const backend = new LocalSquadBackend({ harnesses }); + await backend.createConversation({ conversationId: 'c' }); + const send = (runId: string, seq: number, content = `task ${seq}`) => + backend.sendMessage('c', { runId, seq, role: 'user', content, harness: 'copilot-sdk' }); + return { harness, backend, send }; +} + +describe('copilot-sdk harness', () => { + it('reuses one Copilot session per conversation and starts the client once', async () => { + const { client: c, log } = client(async ({ fire }) => { fire({ type: 'assistant.message', data: { content: 'hi' } }); fire({ type: 'session.idle' }); }); + const { harness, backend, send } = await setup(c); + await send('r1', 1); + await harness.settled('c', 'r1', 1); + await send('r2', 2); + await harness.settled('c', 'r2', 2); + expect(log).toMatchObject({ created: 1, started: 1, resumed: [] }); + expect((await backend.getTranscript('c')).filter((m) => m.role === 'assistant')).toHaveLength(2); + }); + + it('keeps sub-agent output out of the transcript', async () => { + const { client: c } = client(async ({ fire }) => { + fire({ type: 'assistant.message', agentId: 'sub-1', data: { content: 'sub-agent chatter' } }); + fire({ type: 'assistant.message', data: { content: 'tool-call narration', parentToolCallId: 'call_9' } }); + fire({ type: 'assistant.message', data: { content: 'final answer' } }); + fire({ type: 'session.idle' }); + }); + const { harness, backend, send } = await setup(c); + await send('r1', 1); + await harness.settled('c', 'r1', 1); + expect((await backend.getTranscript('c')).map((m) => m.content)).toEqual(['task 1', 'final answer']); + }); + + it('answers approve-once on approve and reject (plus abort) on deny', async () => { + const { client: c, log } = client(async ({ fire, ask, aborted }) => { + const d = await ask({ kind: 'write', toolCallId: 'call_w', fileName: 'src/a.ts' }); + if (d.kind === 'approve-once') await ask({ kind: 'shell', toolCallId: 'call_s', fullCommandText: 'rm -rf dist' }); + if (!aborted()) fire({ type: 'assistant.message', data: { content: 'done' } }); + fire({ type: 'session.idle' }); + }); + const { harness, backend, send } = await setup(c); + await send('r1', 1); + await until(async () => (await backend.getApprovals('c')).length === 1); + const [w] = await backend.getApprovals('c'); + expect(w).toMatchObject({ approvalId: 'call_w', action: 'write: src/a.ts' }); + await backend.respondApproval('c', { approvalId: 'call_w', hash: w!.hash, decision: 'approve' }); + await until(async () => (await backend.getApprovals('c')).length === 2); + const s = (await backend.getApprovals('c'))[1]!; + expect(s.action).toBe('shell: rm -rf dist'); + await backend.respondApproval('c', { approvalId: 'call_s', hash: s.hash, decision: 'deny', reason: 'not that' }); + await harness.settled('c', 'r1', 1); + expect(log.decisions).toEqual([{ kind: 'approve-once' }, { kind: 'reject', feedback: 'not that' }]); + expect(log.aborts).toBe(1); + expect((await backend.getTurn('c', 1)).state).toBe('cancelled'); + }); + + it('gives a request without a toolCallId its own approval id', async () => { + const { client: c } = client(async ({ fire, ask }) => { await ask({ kind: 'url', url: 'https://example.com' }); fire({ type: 'session.idle' }); }); + const { harness, backend, send } = await setup(c); + await send('r1', 1); + await until(async () => (await backend.getApprovals('c')).length === 1); + const [a] = await backend.getApprovals('c'); + expect(a!.approvalId).toMatch(/^copilot-[0-9a-f-]{36}$/); + await backend.respondApproval('c', { approvalId: a!.approvalId, hash: a!.hash, decision: 'approve' }); + await harness.settled('c', 'r1', 1); + expect((await backend.getTurn('c', 1)).state).toBe('completed'); + }); + + it('a session.error fails the turn with its message', async () => { + const { client: c } = client(async ({ fire }) => fire({ type: 'session.error', data: { errorType: 'quota', message: 'Rate limit exceeded' } })); + const { harness, backend, send } = await setup(c); + await send('r1', 1); + await harness.settled('c', 'r1', 1); + expect(await backend.getTurn('c', 1)).toMatchObject({ state: 'failed', error: 'Rate limit exceeded' }); + }); + + it('cancel aborts the session without waiting for session.idle', async () => { + const { client: c, log } = client(async ({ ask }) => { await ask({ kind: 'shell', toolCallId: 'call_x', fullCommandText: 'sleep 99' }); /* never idles */ }); + const { harness, backend, send } = await setup(c); + await send('r1', 1); + await until(async () => (await backend.getApprovals('c')).length === 1); + await backend.cancelTurn('c', 1); + await harness.cancel('c', 'r1', 1); + await harness.settled('c', 'r1', 1); + expect(log.aborts).toBe(1); + expect((await backend.getTurn('c', 1)).state).toBe('cancelled'); + }); + + it('a client that cannot start fails the delivery, so the broker retries', async () => { + const harness = new CopilotSdkHarness({ cwd: '.', createClient: async () => { throw new Error('not signed in'); } }); + await expect(harness.submit({ conversationId: 'c', runId: 'r1', seq: 1, messages: [] })).rejects.toThrow(/could not start the Copilot client: not signed in/); + }); + + it('passes a harness-only configDirectory to every session, and none by default', async () => { + const seen: Array = []; + const make = (configDirectory?: string) => new CopilotSdkHarness({ + cwd: '.', ...(configDirectory ? { configDirectory } : {}), + createClient: async () => ({ + async start() {}, + async resumeSession(_id, config) { return this.createSession(config); }, + async createSession(config) { + seen.push(config.configDirectory); + let emit: (e: CopilotSessionEvent) => void = () => undefined; + return { sessionId: 's', on(h) { emit = h; return () => undefined; }, async abort() {}, async send() { setTimeout(() => { emit({ type: 'user.message', data: { messageId: 'm' } }); emit({ type: 'session.idle' }); }, 0); return 'm'; } }; + }, + }), + }); + for (const h of [make('/tmp/squad-copilot-harness'), make()]) { + await h.submit({ conversationId: 'c', runId: 'r1', seq: 1, messages: [{ runId: 'r1', seq: 1, role: 'user', content: 'x' }] }); + await h.settled('c', 'r1', 1); + } + expect(seen).toEqual(['/tmp/squad-copilot-harness', undefined]); + }); + + it('describes itself for capability negotiation', () => { + expect(new CopilotSdkHarness({ cwd: '.' }).describe()).toMatchObject({ id: 'copilot-sdk', capabilities: { approvals: true, extensionNamespaces: ['x-copilot'] } }); + }); +}); + +describe.skipIf(process.env['SQUAD_REAL_CLI_TESTS'] !== '1')('copilot-sdk harness: live (real Copilot SDK)', () => { + /** One plain turn through the broker; a short timeout so a correlation miss fails fast instead of hanging. */ + async function liveTurn(extra: Partial[0]> = {}) { + const harness = new CopilotSdkHarness({ cwd: process.cwd(), turnTimeoutMs: 120_000, ...extra }); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(harness); + const backend = new LocalSquadBackend({ harnesses }); + await backend.createConversation({ conversationId: 'live' }); + await backend.sendMessage('live', { runId: 'r1', seq: 1, role: 'user', content: 'Reply with exactly the word: pong', harness: 'copilot-sdk' }); + await harness.settled('live', 'r1', 1); + const turn = await backend.getTurn('live', 1); + expect(turn.error).toBeUndefined(); + expect(turn.state).toBe('completed'); + expect((await backend.getTranscript('live')).at(-1)?.content.toLowerCase()).toContain('pong'); + } + + it('completes a turn through the broker', async () => { + await liveTurn(); + }, 180_000); + + it('completes a turn with a harness-only Copilot config directory (login still works)', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-copilot-config-')); + try { + await liveTurn({ configDirectory }); + } finally { + fs.rmSync(configDirectory, { recursive: true, force: true }); + } + }, 180_000); +}); + +describe('copilot-sdk harness: review regressions (#2451)', () => { + /** A client whose session creation and abort the test controls, recording every send. */ + function controlled() { + let releaseCreate!: () => void; + const created = new Promise((r) => (releaseCreate = r)); + let releaseAbort!: () => void; + let abortHeld = false; + const sends: string[] = []; + let fire: (e: CopilotSessionEvent) => void = () => undefined; + const c: CopilotClientLike = { + async start() {}, + async resumeSession(id, config) { return c.createSession(config); }, + async createSession() { + await created; + const handlers = new Set<(e: CopilotSessionEvent) => void>(); + fire = (e) => handlers.forEach((h) => h(e)); + return { + sessionId: 's-1', + on(h: (e: CopilotSessionEvent) => void) { handlers.add(h); return () => handlers.delete(h); }, + async send(prompt: string) { sends.push(prompt); const messageId = `m-${sends.length}`; queueMicrotask(() => fire({ type: 'user.message', data: { messageId } })); return messageId; }, + async abort() { if (abortHeld) await new Promise((r) => (releaseAbort = r)); }, + }; + }, + }; + return { + client: c, sends, releaseCreate, + holdAbort: () => { abortHeld = true; }, + releaseAbort: () => releaseAbort(), + fire: (e: CopilotSessionEvent) => fire(e), + }; + } + + it('a cancel while the session is being created never reaches send()', async () => { + const k = controlled(); + const { harness, backend, send } = await setup(k.client); + await send('r1', 1); + await new Promise((r) => setTimeout(r, 5)); + await backend.cancelTurn('c', 1); + await harness.cancel('c', 'r1', 1); + k.releaseCreate(); + await harness.settled('c', 'r1', 1); + expect(k.sends).toEqual([]); + expect((await backend.getTurn('c', 1)).state).toBe('cancelled'); + }); + + it('the next turn waits for a slow abort, and late events from the aborted turn are not attributed to it', async () => { + const k = controlled(); + k.releaseCreate(); + const { harness, backend, send } = await setup(k.client); + await send('r1', 1, 'first'); + await until(() => k.sends.length === 1); + await send('r2', 2, 'second'); + k.holdAbort(); + await backend.cancelTurn('c', 1); + const cancelling = harness.cancel('c', 'r1', 1); + await new Promise((r) => setTimeout(r, 20)); + expect(k.sends).toEqual(['first']); // r2 must not start while r1 is still aborting + k.fire({ type: 'assistant.message', data: { content: 'late answer from r1' } }); + k.fire({ type: 'session.idle' }); + k.releaseAbort(); + await cancelling; + await until(() => k.sends.length === 2); + k.fire({ type: 'assistant.message', data: { content: 'answer for r2' } }); + k.fire({ type: 'session.idle' }); + await harness.settled('c', 'r2', 2); + const turn2 = await backend.getTurn('c', 2); + expect(turn2.state).toBe('completed'); + expect(turn2.messages.map((m) => m.content)).toEqual(['second', 'answer for r2']); + expect((await backend.getTranscript('c')).map((m) => m.content)).not.toContain('late answer from r1'); + }); + + for (const childEvent of [{ type: 'session.idle' }, { type: 'session.error', data: { message: 'child failed' } }] as CopilotSessionEvent[]) { + it(`a sub-agent's ${childEvent.type} does not end the main turn`, async () => { + const { client: c } = client(async ({ fire }) => { + fire({ ...childEvent, agentId: 'child-agent' }); + await new Promise((r) => setTimeout(r, 10)); + fire({ type: 'assistant.message', data: { content: 'main answer' } }); + fire({ type: 'session.idle' }); + }); + const { harness, backend, send } = await setup(c); + await send('r1', 1); + await harness.settled('c', 'r1', 1); + expect((await backend.getTurn('c', 1)).state).toBe('completed'); + expect((await backend.getTranscript('c')).at(-1)?.content).toBe('main answer'); + }); + } +}); diff --git a/test/harness-contract.test.ts b/test/harness-contract.test.ts new file mode 100644 index 000000000..a04554ec3 --- /dev/null +++ b/test/harness-contract.test.ts @@ -0,0 +1,153 @@ +/** + * Runs the shared HarnessAdapter contract against every harness in the repository. + */ +import { describe, it, expect } from 'vitest'; +import { harnessContract, type ContractScript } from './harness-contract.js'; +import { + ClaudeAgentSdkHarness, + CopilotSdkHarness, + type ClaudeAgentSdkModule, + type CopilotClientLike, + type CopilotSessionEvent, + type HarnessAdapter, +} from '../packages/squad-sdk/src/backend/index.js'; + +const dispatched = new WeakMap(); + +/** Startup as scripted: wait for the gate, fail the first `failStarts` attempts. */ +function starter(script: ContractScript) { + let failures = script.failStarts ?? 0; + return async () => { + await script.gate; + if (failures-- > 0) throw new Error('scripted startup failure'); + }; +} + +/** Scripted Agent SDK: requests approval for each tool in parallel, then replies. */ +function scriptedClaudeSdk(script: ContractScript, count: { n: number }): ClaudeAgentSdkModule { + return { + query({ options = {} }) { + count.n++; + return (async function* () { + yield { type: 'system', subtype: 'init', session_id: 'contract-session' }; + await Promise.all( + (script.tools ?? []).map((t, i) => + options.canUseTool!(t.name, t.input, { signal: options.abortController!.signal, toolUseID: `toolu_${i}` })), + ); + if (options.abortController?.signal.aborted) throw new Error('aborted'); + yield { type: 'assistant', parent_tool_use_id: null, message: { content: [{ type: 'text', text: script.reply }] } }; + yield { type: 'result', subtype: 'success', is_error: false, result: script.reply }; + })(); + }, + }; +} + +harnessContract('claude-agent-sdk', { + create: (script) => { + const count = { n: 0 }; + const start = starter(script); + const h = new ClaudeAgentSdkHarness({ cwd: process.cwd(), loadSdk: async () => { await start(); return scriptedClaudeSdk(script, count); } }); + dispatched.set(h, count); + return h; + }, + dispatches: (h) => dispatched.get(h)!.n, + settled: (h: HarnessAdapter, c, r, s) => (h as ClaudeAgentSdkHarness).settled(c, r, s), +}); + +/** + * Scripted Copilot client: `send()` returns at once (as the real SDK does); permission requests for each tool + * then run in parallel, followed by the assistant message and `session.idle`. + */ +export function scriptedCopilotClient(script: ContractScript, count: { n: number } = { n: 0 }, start = starter(script)): CopilotClientLike & { sessions: number; resumed: string[] } { + const client = { + sessions: 0, + resumed: [] as string[], + async start() { await start(); }, + async resumeSession(id: string, config: Parameters[0]) { + client.resumed.push(id); + return client.createSession(config, id); + }, + async createSession(config: Parameters[0], id = `copilot-session-${++client.sessions}`) { + const handlers = new Set<(e: CopilotSessionEvent) => void>(); + let aborted = false; + const fire = (e: CopilotSessionEvent) => handlers.forEach((h) => h(e)); + return { + sessionId: id, + on(h: (e: CopilotSessionEvent) => void) { handlers.add(h); return () => handlers.delete(h); }, + async abort() { aborted = true; }, + async send() { + count.n++; + const messageId = `msg-${count.n}`; + // As the real runtime does: our user.message, then permission requests, the reply and idle. + queueMicrotask(() => fire({ type: 'user.message', data: { messageId } })); + void (async () => { + await Promise.resolve(); + const results = await Promise.all((script.tools ?? []).map((t, i) => + config.onPermissionRequest({ kind: 'shell', toolCallId: `call_${i}`, fullCommandText: String(t.input['command'] ?? t.name), ...t.input }, { sessionId: id }))); + if (aborted || results.some((r) => r.kind === 'reject')) { fire({ type: 'session.idle' }); return; } + fire({ type: 'assistant.message', data: { content: script.reply } }); + fire({ type: 'session.idle' }); + })(); + return messageId; + }, + }; + }, + }; + return client; +} + +harnessContract('copilot-sdk', { + create: (script) => { + const count = { n: 0 }; + const start = starter(script); // one startup script across retries + const h = new CopilotSdkHarness({ cwd: process.cwd(), createClient: async () => scriptedCopilotClient(script, count, start) }); + dispatched.set(h, count); + return h; + }, + dispatches: (h) => dispatched.get(h)!.n, + settled: (h: HarnessAdapter, c, r, s) => (h as CopilotSdkHarness).settled(c, r, s), +}); + +describe('approval record redaction (TurnApprovals)', () => { + it('masks header-style and env-style credentials, keeping the surrounding syntax', async () => { + const { redactText } = await import('../packages/squad-sdk/src/backend/index.js'); + expect(redactText('curl -H "X-API-Key: abc123secret" https://x')).toBe('curl -H "X-API-Key: [redacted]" https://x'); + expect(redactText('curl -H "X-Auth-Token: tok987654321" https://x')).toBe('curl -H "X-Auth-Token: [redacted]" https://x'); + expect(redactText('curl -H "Cookie: session=deadbeefcafe" https://x')).toBe('curl -H "Cookie: [redacted]" https://x'); + expect(redactText('curl -H "Authorization: Bearer sk-live-0123456789abcdef" x')).toBe('curl -H "Authorization: Bearer [redacted]" x'); + expect(redactText('curl -H "Proxy-Authorization: Basic dXNlcjpwYXNzd29yZA==" x')).toBe('curl -H "Proxy-Authorization: Basic [redacted]" x'); + expect(redactText('export GITHUB_TOKEN=ghp_abcdefghijklmnop')).toBe('export GITHUB_TOKEN=[redacted]'); + expect(redactText('AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG aws s3 ls')).toBe('AWS_SECRET_ACCESS_KEY=[redacted] aws s3 ls'); + expect(redactText('https://api.example.com?token=s3cr3t&page=2')).toBe('https://api.example.com?token=[redacted]&page=2'); + }); + + it('masks bare token-shaped secrets and URL passwords', async () => { + const { redactText } = await import('../packages/squad-sdk/src/backend/index.js'); + expect(redactText('gh auth login --with-token ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8')).toBe('gh auth login --with-token [redacted]'); + expect(redactText('curl https://bob:hunter2pass@api.example.com/v1')).toBe('curl https://bob:[redacted]@api.example.com/v1'); + expect(redactText('node deploy.js sk-ant-api03-AbCdEfGhIjKlMnOpQrSt')).toBe('node deploy.js [redacted]'); + expect(redactText('aws configure set aws_access_key_id AKIAIOSFODNN7EXAMPLE')).toBe('aws configure set aws_access_key_id [redacted]'); + expect(redactText('call --jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U')).toBe('call --jwt [redacted]'); + expect(redactText('slack-notify xoxb-1234567890-abcdefghij')).toBe('slack-notify [redacted]'); + expect(redactText('run --secret Zm9vYmFyQmF6UXV4MTIzNDU2Nzg5MEFCQ0RFRkdISUpLTE1O')).toBe('run --secret [redacted]'); + }); + + it('keeps hashes, ids and paths readable for the approver', async () => { + const { redactText } = await import('../packages/squad-sdk/src/backend/index.js'); + for (const s of [ + 'git checkout 9273a84c84ff51eb990d909b96c2e96b4c647c5a', + 'sha256sum: 5f2b4c1e9a7d3f6b8e0c2a4d6f8b0e2c4a6d8f0b2e4c6a8d0f2b4e6c8a0d2f4b', + 'rm -rf node_modules/@anthropic-ai/claude-agent-sdk/dist/ConfigurationManagerFactoryImplementation.js', + 'id 3f2504e0-4f89-11d3-9a0c-0305e82c3301', + 'npm install @github/copilot-sdk@1.0.19', + 'git push origin squad/2441-claude-agent-sdk-harness', + ]) expect(redactText(s)).toBe(s); + }); + + it('leaves look-alike words alone', async () => { + const { redactText } = await import('../packages/squad-sdk/src/backend/index.js'); + for (const s of ['git log --author=brady --since=1.week', 'mkdir -p build/out', 'python tokenizer.py --input a.txt', 'npm test -- --run', 'session: started']) { + expect(redactText(s)).toBe(s); + } + }); +}); diff --git a/test/harness-contract.ts b/test/harness-contract.ts new file mode 100644 index 000000000..18de83ede --- /dev/null +++ b/test/harness-contract.ts @@ -0,0 +1,211 @@ +/** + * Shared HarnessAdapter contract (harness-adapters §5 SPI; §15 promotion criteria 1, 3 and 5). + * + * Every harness runs the same assertions through the real LocalSquadBackend. A harness supplies a driver + * that builds an adapter whose model/tool behaviour is scripted (no network), so the protocol is checked + * deterministically: dedupe, event identity, approvals (hash binding, replay, one at a time), cancel. + */ +import { describe, it, expect } from 'vitest'; +import { + DefaultHarnessRegistry, + LocalSquadBackend, + type HarnessAdapter, + type HarnessEventEnvelope, +} from '../packages/squad-sdk/src/backend/index.js'; + +export interface ContractToolCall { name: string; input: Record } + +export interface ContractScript { + reply: string; + tools?: ContractToolCall[]; + /** Runtime startup waits for this (to cancel or redeliver during startup). */ + gate?: Promise; + /** Runtime startup fails this many times before succeeding. */ + failStarts?: number; +} + +export interface HarnessContractDriver { + /** + * Harness under test, scripted to answer `reply` and to request approval for each of `tools`, in parallel. + * Tool-call ids are deterministic per turn (the same in every conversation), as runtimes don't guarantee + * global uniqueness. + */ + create(script: ContractScript): HarnessAdapter; + /** How many turns reached the runtime (query/send) for this harness. */ + dispatches(harness: HarnessAdapter): number; + /** Wait until the harness has finished the turn. */ + settled(harness: HarnessAdapter, conversationId: string, runId: string, seq: number): Promise; +} + +async function until(check: () => boolean | Promise, ms = 3000): Promise { + const end = Date.now() + ms; + while (!(await check())) { + if (Date.now() > end) throw new Error('timed out waiting for condition'); + await new Promise((r) => setTimeout(r, 5)); + } +} + +export function harnessContract(name: string, driver: HarnessContractDriver): void { + async function setup(script: ContractScript) { + const harness = driver.create(script); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(harness); + const backend = new LocalSquadBackend({ harnesses }); + await backend.createConversation({ conversationId: 'k' }); + const events: HarnessEventEnvelope[] = []; + harness.subscribe('k', (e) => events.push(e)); + const send = (runId: string, seq: number, conversationId = 'k') => + backend.sendMessage(conversationId, { runId, seq, role: 'user', content: `task ${seq}`, harness: harness.id }); + const settled = (runId: string, seq: number, conversationId = 'k') => driver.settled(harness, conversationId, runId, seq); + return { harness, backend, events, send, settled }; + } + + describe(`HarnessAdapter contract: ${name}`, () => { + it('describes itself consistently', () => { + const h = driver.create({ reply: 'x' }); + expect(h.id).toMatch(/^[a-z][a-z0-9-]*$/); + expect(h.describe()).toMatchObject({ id: h.id, capabilities: h.capabilities }); + for (const ns of h.capabilities.extensionNamespaces) expect(ns).toMatch(/^x-[a-z]/); + }); + + it('completes a turn, and every event echoes the turn identity and epoch', async () => { + const { backend, events, send, settled } = await setup({ reply: 'contract reply' }); + const admission = await send('r1', 1); + await settled('r1', 1); + expect((await backend.getTurn('k', 1)).state).toBe('completed'); + expect((await backend.getTranscript('k')).map((m) => m.role)).toEqual(['user', 'assistant']); + expect(events.length).toBeGreaterThan(0); + for (const e of events) expect(e).toMatchObject({ runId: 'r1', seq: 1, epoch: admission.epoch }); + expect(events.filter((e) => e.type === 'state').at(-1)).toMatchObject({ state: 'completed' }); + }); + + it('treats a redelivered submission as a no-op', async () => { + const harness = driver.create({ reply: 'once' }); + const events: HarnessEventEnvelope[] = []; + harness.subscribe('k', (e) => events.push(e)); + const s = { conversationId: 'k', runId: 'r1', seq: 1, operationId: 'k:r1:1', messages: [{ runId: 'r1', seq: 1, role: 'user' as const, content: 'x' }] }; + await harness.submit(s); + await harness.submit(s); + await driver.settled(harness, 'k', 'r1', 1); + expect(events.filter((e) => e.type === 'message')).toHaveLength(1); + }); + + it('binds an approval to its hash, honours approve, and ignores a replayed decision', async () => { + const { harness, backend, send, settled } = await setup({ reply: 'after tool', tools: [{ name: 'Bash', input: { command: 'echo ok' } }] }); + await send('r1', 1); + await until(async () => (await backend.getApprovals('k')).length === 1); + const [a] = await backend.getApprovals('k'); + expect(a!.hash).toBeTruthy(); + await expect(harness.respondApproval('k', { approvalId: a!.approvalId, hash: `${a!.hash}-forged`, decision: 'approve' })).rejects.toThrow(); + await backend.respondApproval('k', { approvalId: a!.approvalId, hash: a!.hash, decision: 'approve' }); + await harness.respondApproval('k', { approvalId: a!.approvalId, hash: a!.hash, decision: 'deny' }, 'replay'); + await settled('r1', 1); + expect((await backend.getTurn('k', 1)).state).toBe('completed'); + }); + + it('offers one approval at a time, and a deny ends the turn', async () => { + const tools = [{ name: 'Bash', input: { command: 'a' } }, { name: 'Bash', input: { command: 'b' } }]; + const { backend, send, settled } = await setup({ reply: 'never', tools }); + await send('r1', 1); + await until(async () => (await backend.getApprovals('k')).length === 1); + await new Promise((r) => setTimeout(r, 20)); + const pending = await backend.getApprovals('k'); + expect(pending).toHaveLength(1); + await backend.respondApproval('k', { approvalId: pending[0]!.approvalId, hash: pending[0]!.hash, decision: 'deny' }); + await settled('r1', 1); + expect((await backend.getTurn('k', 1)).state).toBe('cancelled'); + expect(await backend.getApprovals('k')).toHaveLength(1); + }); + + it('cancels idempotently', async () => { + const { harness, backend, send, settled } = await setup({ reply: 'never', tools: [{ name: 'Bash', input: { command: 'sleep' } }] }); + await send('r1', 1); + await until(async () => (await backend.getApprovals('k')).length === 1); + await backend.cancelTurn('k', 1); + await harness.cancel('k', 'r1', 1); + await harness.cancel('k', 'r1', 1); + await settled('r1', 1); + expect((await backend.getTurn('k', 1)).state).toBe('cancelled'); + }); + + it('keeps a cancel that arrives while the runtime is starting: the turn never reaches it', async () => { + let release!: () => void; + const gate = new Promise((r) => (release = r)); + const { harness, backend, send, settled } = await setup({ reply: 'never', gate }); + const sending = send('r1', 1); // the harness's submit is held at startup + await until(async () => (await backend.getTurn('k', 1).catch(() => undefined)) !== undefined); + await backend.cancelTurn('k', 1); + await harness.cancel('k', 'r1', 1); + release(); + await sending; + await new Promise((r) => setTimeout(r, 10)); + await settled('r1', 1); + expect(driver.dispatches(harness)).toBe(0); + expect((await backend.getTurn('k', 1)).state).toBe('cancelled'); + }); + + it('runs concurrent duplicate deliveries once, and a retry after a failed start once', async () => { + let release!: () => void; + const gate = new Promise((r) => (release = r)); + const harness = driver.create({ reply: 'once', gate }); + const s = { conversationId: 'k', runId: 'r1', seq: 1, operationId: 'k:r1:1', messages: [{ runId: 'r1', seq: 1, role: 'user' as const, content: 'x' }] }; + const both = Promise.all([harness.submit(s), harness.submit(s)]); + release(); + await both; + await driver.settled(harness, 'k', 'r1', 1); + expect(driver.dispatches(harness)).toBe(1); + + const flaky = driver.create({ reply: 'after retry', failStarts: 1 }); + await expect(flaky.submit(s)).rejects.toThrow(); + await flaky.submit(s); + await flaky.submit(s); + await driver.settled(flaky, 'k', 'r1', 1); + expect(driver.dispatches(flaky)).toBe(1); + }); + + it('scopes approvals to their conversation when tool-call ids repeat across conversations', async () => { + const { harness, backend, send, settled } = await setup({ reply: 'ok', tools: [{ name: 'Bash', input: { command: 'same' } }] }); + await backend.createConversation({ conversationId: 'k2' }); + await send('r1', 1); + await send('r1', 1, 'k2'); + await until(async () => (await backend.getApprovals('k')).length === 1 && (await backend.getApprovals('k2')).length === 1); + const [a] = await backend.getApprovals('k'); + const [b] = await backend.getApprovals('k2'); + expect(a!.approvalId).toBe(b!.approvalId); + await backend.respondApproval('k2', { approvalId: b!.approvalId, hash: b!.hash, decision: 'approve' }); + await settled('r1', 1, 'k2'); + expect((await backend.getTurn('k2', 1)).state).toBe('completed'); + expect((await backend.getTurn('k', 1)).state).toBe('awaiting-approval'); + await backend.respondApproval('k', { approvalId: a!.approvalId, hash: a!.hash, decision: 'deny' }); + await settled('r1', 1); + }); + + it('records only a redacted summary (no payload) while the hash binds the full action', async () => { + const command = 'curl -H "Authorization: Bearer sk-live-0123456789abcdef" https://api.example.com?token=s3cr3t'; + const { backend, send, settled } = await setup({ reply: 'ok', tools: [{ name: 'Bash', input: { command, apiKey: 'AKIA-SECRET' } }] }); + await send('r1', 1); + await until(async () => (await backend.getApprovals('k')).length === 1); + const [a] = await backend.getApprovals('k'); + expect(a!.payload).toBeUndefined(); // the raw input never leaves the harness + const recorded = JSON.stringify(a); + for (const secret of ['sk-live-0123456789abcdef', 's3cr3t', 'AKIA-SECRET']) expect(recorded).not.toContain(secret); + expect(a!.action).toContain('curl'); + await backend.respondApproval('k', { approvalId: a!.approvalId, hash: a!.hash, decision: 'approve' }); + await settled('r1', 1); + expect((await backend.getTurn('k', 1)).state).toBe('completed'); + }); + + it('forgets finished turns, while a late redelivery of one stays a no-op', async () => { + const { harness, send, settled } = await setup({ reply: 'done' }); + await send('r1', 1); + await settled('r1', 1); + await new Promise((r) => setTimeout(r, 0)); + expect((harness as unknown as { activeTurns: number }).activeTurns).toBe(0); + await harness.submit({ conversationId: 'k', runId: 'r1', seq: 1, operationId: 'k:r1:1', messages: [] }); + await harness.submit({ conversationId: 'k', runId: 'r1', seq: 1, messages: [] }); + await new Promise((r) => setTimeout(r, 10)); + expect(driver.dispatches(harness)).toBe(1); + await harness.cancel('k', 'r1', 1); // late cancel of a finished turn: no-op + }); + }); +} \ No newline at end of file diff --git a/test/harness-review-round4.test.ts b/test/harness-review-round4.test.ts new file mode 100644 index 000000000..c05b66e58 --- /dev/null +++ b/test/harness-review-round4.test.ts @@ -0,0 +1,306 @@ +/** + * Regressions for review round 4 on github/squad#2451 (Copilot review of the execution harnesses). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + ClaudeAgentSdkHarness, + CopilotSdkHarness, + DefaultHarnessRegistry, + LocalSquadBackend, + TurnApprovals, + claudeDenyRules, + redactText, + type ClaudeAgentSdkModule, + type ClaudeQueryOptions, + type CopilotClientLike, + type CopilotPermissionResult, + type CopilotSessionConfig, + type CopilotSessionEvent, + type HarnessApprovalRequest, +} from '../packages/squad-sdk/src/backend/index.js'; + +const tmpDirs: string[] = []; +const tmp = (prefix: string) => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tmpDirs.push(d); + return d; +}; +afterEach(() => { + for (const d of tmpDirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); +}); + +async function until(check: () => boolean | Promise, ms = 3000): Promise { + const end = Date.now() + ms; + while (!(await check())) { + if (Date.now() > end) throw new Error('timed out waiting for condition'); + await new Promise((r) => setTimeout(r, 5)); + } +} + +const submission = (runId: string, seq: number, operationId = `c:${runId}:${seq}`) => ({ + conversationId: 'c', runId, seq, operationId, messages: [{ runId, seq, role: 'user' as const, content: `task ${seq}` }], +}); + +/** Scripted Agent SDK that records every query's options. */ +function claudeSdk() { + const calls: ClaudeQueryOptions[] = []; + const sdk: ClaudeAgentSdkModule = { + query({ options = {} }) { + calls.push(options); + return (async function* () { + yield { type: 'system', subtype: 'init', session_id: `sess-${calls.length}` }; + yield { type: 'assistant', parent_tool_use_id: null, message: { content: [{ type: 'text', text: 'ok' }] } }; + yield { type: 'result', subtype: 'success', is_error: false, result: 'ok' }; + })(); + }, + }; + return { sdk, calls }; +} + +/** Scripted Copilot client: each session runs `turn` per send, after emitting our user.message. */ +function copilotClient(turn: (ctx: { + fire: (e: CopilotSessionEvent) => void; + ask: (r: Parameters[0]) => Promise; +}) => Promise = async ({ fire }) => { fire({ type: 'assistant.message', data: { content: 'ok' } }); fire({ type: 'session.idle' }); }, +opts: { failAbort?: () => boolean; failSend?: () => boolean; silentUserMessage?: boolean } = {}) { + const log = { created: [] as string[], resumed: [] as string[], aborts: 0, sends: 0 }; + const c: CopilotClientLike = { + async start() {}, + async createSession(config) { const id = `s-${log.created.length + 1}`; log.created.push(id); return make(config, id); }, + async resumeSession(id, config) { log.resumed.push(id); return make(config, id); }, + }; + function make(config: CopilotSessionConfig, id: string) { + const handlers = new Set<(e: CopilotSessionEvent) => void>(); + const fire = (e: CopilotSessionEvent) => handlers.forEach((h) => h(e)); + return { + sessionId: id, + on(h: (e: CopilotSessionEvent) => void) { handlers.add(h); return () => handlers.delete(h); }, + async abort() { log.aborts++; if (opts.failAbort?.()) throw new Error('abort RPC failed'); }, + async send() { + log.sends++; + if (opts.failSend?.()) throw new Error('transport closed'); + const messageId = `m-${log.sends}`; + queueMicrotask(() => { + if (!opts.silentUserMessage) fire({ type: 'user.message', data: { messageId } }); + void turn({ fire, ask: (r) => config.onPermissionRequest(r, { sessionId: id }) }); + }); + return messageId; + }, + }; + } + return { client: c, log }; +} + +describe('durability across harness restarts (submit dedupe, session ids)', () => { + it('claude-agent-sdk: a restarted harness neither re-runs an accepted turn nor loses the session', async () => { + const stateDir = tmp('squad-harness-state-'); + const { sdk, calls } = claudeSdk(); + const first = new ClaudeAgentSdkHarness({ cwd: '.', stateDir, loadSdk: async () => sdk }); + await first.submit(submission('r1', 1)); + await first.settled('c', 'r1', 1); + expect(calls).toHaveLength(1); + + const restarted = new ClaudeAgentSdkHarness({ cwd: '.', stateDir, loadSdk: async () => sdk }); + await restarted.submit(submission('r1', 1)); // redelivery after restart + await new Promise((r) => setTimeout(r, 10)); + expect(calls).toHaveLength(1); + await restarted.submit(submission('r2', 2)); + await restarted.settled('c', 'r2', 2); + expect(calls).toHaveLength(2); + expect(calls[1]!.resume).toBe('sess-1'); + }); + + it('copilot-sdk: a restarted harness resumes the conversation session and skips accepted turns', async () => { + const stateDir = tmp('squad-harness-state-'); + const a = copilotClient(); + const first = new CopilotSdkHarness({ cwd: '.', stateDir, createClient: async () => a.client }); + await first.submit(submission('r1', 1)); + await first.settled('c', 'r1', 1); + const b = copilotClient(); + const restarted = new CopilotSdkHarness({ cwd: '.', stateDir, createClient: async () => b.client }); + await restarted.submit(submission('r1', 1)); + await restarted.submit(submission('r2', 2)); + await restarted.settled('c', 'r2', 2); + expect(b.log.sends).toBe(1); // only r2 + expect(b.log.resumed).toEqual(['s-1']); + expect(b.log.created).toEqual([]); + }); +}); + +describe('copilot-sdk: event correlation and recovery', () => { + async function broker(client: CopilotClientLike, extra: Partial[0]> = {}) { + const harness = new CopilotSdkHarness({ cwd: '.', createClient: async () => client, ...extra }); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(harness); + const backend = new LocalSquadBackend({ harnesses }); + await backend.createConversation({ conversationId: 'c' }); + const send = (runId: string, seq: number) => backend.sendMessage('c', { runId, seq, role: 'user', content: `task ${seq}`, harness: 'copilot-sdk' }); + return { harness, backend, send }; + } + + it('ignores an idle and an answer that arrive before its own user.message (from an earlier turn)', async () => { + let fireOutside: ((e: CopilotSessionEvent) => void) | undefined; + const { client } = copilotClient(async ({ fire }) => { + fireOutside = fire; + await new Promise((r) => setTimeout(r, 10)); + fire({ type: 'assistant.message', data: { content: 'the real answer' } }); + fire({ type: 'session.idle' }); + }, { silentUserMessage: true }); + const { harness, backend, send } = await broker(client); + const sending = send('r1', 1); + await until(() => fireOutside !== undefined); + await sending; + fireOutside!({ type: 'assistant.message', data: { content: 'stale answer from before' } }); + fireOutside!({ type: 'session.idle' }); + fireOutside!({ type: 'user.message', data: { messageId: 'm-1' } }); + await harness.settled('c', 'r1', 1); + expect((await backend.getTurn('c', 1)).state).toBe('completed'); + expect((await backend.getTranscript('c')).map((m) => m.content)).toEqual(['task 1', 'the real answer']); + }); + + it('a failed interrupt stays retryable and the next turn gets a fresh session', async () => { + let abortFails = true; + const { client, log } = copilotClient(async () => { /* never idles */ }, { failAbort: () => abortFails }); + const { harness, backend, send } = await broker(client); + await send('r1', 1); + await until(() => log.sends === 1); + await backend.cancelTurn('c', 1); + await harness.cancel('c', 'r1', 1); + await harness.settled('c', 'r1', 1); + expect(log.aborts).toBe(1); + abortFails = false; + await harness.cancel('c', 'r1', 1); // retried + await send('r2', 2); + await until(() => log.sends === 2); + expect(log.resumed).toEqual(['s-1']); // the old session object was dropped + }); + + it('a send that fails drops the session, and the next turn resumes it', async () => { + let failNext = true; + const { client, log } = copilotClient(undefined, { failSend: () => { const f = failNext; failNext = false; return f; } }); + const { harness, backend, send } = await broker(client); + await send('r1', 1); + await harness.settled('c', 'r1', 1); + expect((await backend.getTurn('c', 1)).state).toBe('failed'); + await send('r2', 2); + await harness.settled('c', 'r2', 2); + expect((await backend.getTurn('c', 2)).state).toBe('completed'); + expect(log.resumed).toEqual(['s-1']); + }); + + it('a turn with no terminal event times out, fails, and drops its session', async () => { + const { client, log } = copilotClient(async () => { /* silent */ }); + const { harness, backend, send } = await broker(client, { turnTimeoutMs: 30 }); + await send('r1', 1); + await harness.settled('c', 'r1', 1); + expect(await backend.getTurn('c', 1)).toMatchObject({ state: 'failed', error: expect.stringContaining('timed out') }); + await send('r2', 2); + await until(() => log.sends === 2); + expect(log.resumed).toEqual(['s-1']); + }); + + it('gives each permission request of one tool call its own approval id', async () => { + const { client } = copilotClient(async ({ fire, ask }) => { + await ask({ kind: 'url', toolCallId: 'call_f', url: 'https://example.com' }); + await ask({ kind: 'shell', toolCallId: 'call_f', fullCommandText: 'sandbox escalation' }); + fire({ type: 'assistant.message', data: { content: 'fetched' } }); + fire({ type: 'session.idle' }); + }); + const { harness, backend, send } = await broker(client); + await send('r1', 1); + for (const id of ['call_f', 'call_f#2']) { + await until(async () => (await backend.getApprovals('c')).some((a) => a.approvalId === id && a.state === 'pending')); + const a = (await backend.getApprovals('c')).find((x) => x.approvalId === id)!; + await backend.respondApproval('c', { approvalId: id, hash: a.hash, decision: 'approve' }); + } + await harness.settled('c', 'r1', 1); + expect((await backend.getTurn('c', 1)).state).toBe('completed'); + }); +}); + +describe('approval queue: dropping an offered request', () => { + it('ends the turn instead of offering the next request into the occupied slot', async () => { + const offered: HarnessApprovalRequest[] = []; + let denied = 0; + const q = new TurnApprovals(1, (r) => offered.push(r), () => undefined, () => denied++); + const first = q.request('a', 'Bash: a', { tool: 'Bash', input: { command: 'a' } }); + const second = q.request('b', 'Bash: b', { tool: 'Bash', input: { command: 'b' } }); + q.drop('a', 'tool call aborted'); + await expect(first).resolves.toMatchObject({ approved: false }); + await expect(second).resolves.toMatchObject({ approved: false }); + expect(offered.map((r) => r.approvalId)).toEqual(['a']); + expect(denied).toBe(1); + }); +}); + +describe('claude-agent-sdk: credentials and deny rules', () => { + it('refuses to start the real SDK without an API credential (no claude.ai login fallback)', async () => { + const h = new ClaudeAgentSdkHarness({ cwd: '.', env: {} }); + await expect(h.submit(submission('r1', 1))).rejects.toThrow(/ANTHROPIC_API_KEY.*does not use a claude\.ai login/); + }); + + it('carries user, project and local deny rules into disallowedTools', async () => { + const home = tmp('squad-home-'); + const cwd = tmp('squad-proj-'); + const write = (f: string, deny: string[]) => { + fs.mkdirSync(path.dirname(f), { recursive: true }); + fs.writeFileSync(f, JSON.stringify({ permissions: { allow: ['Bash(*)'], deny } })); + }; + write(path.join(home, '.claude', 'settings.json'), ['Read(~/.ssh/**)']); + write(path.join(cwd, '.claude', 'settings.json'), ['Read(./.env)']); + write(path.join(cwd, '.claude', 'settings.local.json'), ['Bash(rm *)', 'Read(./.env)']); + expect(claudeDenyRules(cwd, home).sort()).toEqual(['Bash(rm *)', 'Read(./.env)', 'Read(~/.ssh/**)']); + + const { sdk, calls } = claudeSdk(); + const h = new ClaudeAgentSdkHarness({ cwd, homeDir: home, loadSdk: async () => sdk }); + await h.submit(submission('r1', 1)); + await h.settled('c', 'r1', 1); + expect(calls[0]).toMatchObject({ settingSources: [], permissionMode: 'default' }); + expect([...calls[0]!.disallowedTools!].sort()).toEqual(['Bash(rm *)', 'Read(./.env)', 'Read(~/.ssh/**)']); // allow rules are not carried + }); +}); + +describe('state-mcp bridge setup', () => { + it('runs turns in the worktree-aware repoRoot and keeps harness state beside the backend', async () => { + const { createMcpBackend, createLocalHarnesses, harnessStateDir } = await import('../packages/squad-cli/src/cli/core/backend-mcp.js'); + const main = tmp('squad-main-'); + fs.mkdirSync(path.join(main, '.squad')); + const worktree = tmp('squad-worktree-'); + let seen: { root: string; stateDir: string } | undefined; + createMcpBackend(worktree, {}, () => ({ + paths: { teamSquadDir: path.join(main, '.squad'), projectDir: path.join(main, '.squad') } as never, + backend: { name: 'local' } as never, + repoRoot: worktree, + }), (root, stateDir) => { + seen = { root, stateDir }; + return new DefaultHarnessRegistry(); + }); + expect(seen).toEqual({ root: worktree, stateDir: path.join(main, '.squad', 'backend', 'harnesses') }); + expect(harnessStateDir({ paths: { teamSquadDir: '/t/.squad' }, backend: { name: 'git-notes' } })).toContain(path.join('.cache', 'squad', 'harnesses')); + + const registry = createLocalHarnesses(worktree, { SQUAD_BACKEND_HARNESSES: 'copilot-sdk' }, { stateDir: '/state' }); + const copilot = registry.get('copilot-sdk') as unknown as { options: { configDirectory?: string; stateDir?: string } }; + expect(copilot.options).toMatchObject({ stateDir: path.join('/state', 'copilot-sdk'), configDirectory: path.join('/state', 'copilot-sdk', 'copilot-config') }); + }); +}); + +describe('redaction: more credential forms, never hiding executable syntax', () => { + it('masks JSON keys, Authorization schemes, credential flags and -u passwords', () => { + expect(redactText('{"password":"fixture-db-password","user":"app"}')).toBe('{"password":[redacted],"user":"app"}'); + expect(redactText('curl -H "Authorization: token ghx0123456789" x')).toBe('curl -H "Authorization: token [redacted]" x'); + expect(redactText('mysql --password hunter2 db')).toBe('mysql --password [redacted] db'); + expect(redactText('psql --password=hunter2 db')).toBe('psql --password=[redacted] db'); + expect(redactText('curl --user alice:wonderland https://x')).toBe('curl --user alice:[redacted] https://x'); + expect(redactText('curl -u alice:wonderland https://x')).toBe('curl -u alice:[redacted] https://x'); + }); + + it('keeps executable content visible to the approver', () => { + expect(redactText('TOKEN="$(rm -rf ./src)" npm test')).toBe('TOKEN="$(rm -rf ./src)" npm test'); + expect(redactText('token=x;touch${IFS}/tmp/pwned')).toBe('token=[redacted];touch${IFS}/tmp/pwned'); + expect(redactText('export API_KEY=`cat /etc/passwd`')).toBe('export API_KEY=`cat /etc/passwd`'); + expect(redactText('gh pr create --token-file ./t.txt')).toBe('gh pr create --token-file ./t.txt'); + }); +}); diff --git a/test/squad-20-host-feature-matrix.test.ts b/test/squad-20-host-feature-matrix.test.ts index 00c194951..992d2ba6b 100644 --- a/test/squad-20-host-feature-matrix.test.ts +++ b/test/squad-20-host-feature-matrix.test.ts @@ -56,7 +56,7 @@ const CAPABILITIES: Capability[] = ['supported', 'degraded', 'unsupported', 'n/a const VALIDATION_LEVELS: Validation[] = ['validated-live', 'validated-test', 'static-only', 'untested', 'blocked']; const EVIDENCE_RE = /^(validated-test:[^:]+::.+|validated-manual:lab:[a-z0-9-]+@\d{4}-\d{2}-\d{2}|static-only)$/; /** Hosts with a passing live run on the reference machine; only these may carry `validated-live`. */ -const LIVE_HOSTS = new Set(['claude', 'opencode']); +const LIVE_HOSTS = new Set(['claude', 'opencode', 'copilot']); const ROSTER = '| Name | Role | Charter | Status |\n|---|---|---|---|\n| Noether | Lead Architect | `.squad/agents/noether/charter.md` | ✅ Active |\n| Galois | Backend Engineer | `.squad/agents/galois/charter.md` | ✅ Active |\n'; const THIS_FILE = 'test/squad-20-host-feature-matrix.test.ts'; @@ -84,7 +84,7 @@ describe('host-feature-matrix.json integrity', () => { // The validation level must be backed by evidence of that kind — the two axes stay honest. const kinds = new Set(c!.evidence.map((v) => v.split(':')[0])); if (c!.validation === 'validated-live') { - expect(LIVE_HOSTS.has(h), `${f.id}/${h}: validated-live is only possible for hosts with a passing live run (Claude Code, OpenCode)`).toBe(true); + expect(LIVE_HOSTS.has(h), `${f.id}/${h}: validated-live is only possible for hosts with a passing live run (Claude Code, OpenCode, Copilot)`).toBe(true); expect(kinds.has('validated-manual'), `${f.id}/${h}: validated-live needs a validated-manual:lab entry`).toBe(true); } if (c!.validation === 'validated-test') expect(kinds.has('validated-test'), `${f.id}/${h}: validated-test needs a validated-test entry`).toBe(true); diff --git a/test/squad-20-real-clients-stdio.e2e.test.ts b/test/squad-20-real-clients-stdio.e2e.test.ts index 70a9426f1..0a59f9a53 100644 --- a/test/squad-20-real-clients-stdio.e2e.test.ts +++ b/test/squad-20-real-clients-stdio.e2e.test.ts @@ -92,6 +92,8 @@ class McpStdioClient { delete baseEnv['SQUAD_BACKEND_URL']; delete baseEnv['SQUAD_BACKEND_TOKEN']; delete baseEnv['SQUAD_EXPERIMENTAL_BACKEND']; + // No execution harness here, whatever is installed or inherited: these tests cover the transcript-only bridge. + baseEnv['SQUAD_BACKEND_HARNESSES'] = 'none'; this.proc = spawn(spec.command, spec.args, { cwd, env: { ...baseEnv, ...spec.env }, stdio: ['pipe', 'pipe', 'pipe'] }); this.proc.stdout.setEncoding('utf8'); this.proc.stdout.on('data', (d: string) => this.onData(d)); @@ -349,7 +351,7 @@ describe.each(CLIENTS)('real stdio MCP server launched from the %s config', (cli const conv = await server!.call('squad_backend_create_conversation', { conversationId: `${client}-conv`, title: `real ${client}` }); expect(conv.json).toMatchObject({ conversationId: `${client}-conv`, epoch: 1 }); - // no execution harness ships with the CLI: the bridge refuses to admit a turn instead of queueing it forever + // no harness is registered here (the optional Claude Agent SDK isn't installed): the bridge refuses to admit a turn instead of queueing it forever const sent = await server!.call('squad_backend_send_message', { conversationId: `${client}-conv`, runId: `${client}-run`, seq: 1, correlationId: 'corr-real', parts: [{ type: 'text', text: 'hello from a real client' }, { type: 'resource', uri: 'file:///README.md', title: 'README' }],