From cec04e0d8ae190563252499ed7ea745e696624a3 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 9 Nov 2026 17:38:55 -0800 Subject: [PATCH 01/12] feat(backend): claude-agent-sdk HarnessAdapter on the Claude Agent SDK Adds the `claude-agent-sdk` harness (experimental `./backend` subpath), running admitted turns on @anthropic-ai/claude-agent-sdk, an optional peer dependency loaded lazily (no lockfile change; @github/copilot-sdk stays a hard dependency). - One query() per turn, in order per conversation, resuming the conversation's Claude session; submissions deduplicated by operationId or (conversationId, runId, seq). - canUseTool -> approval-requested with approvalId = toolUseID and a sha256 hash of the canonical tool name and input; a response with a different hash never resolves the call; approve runs the original input. - The broker holds one pending approval per turn, so parallel tool calls are queued and offered one at a time; a deny ends the turn (as the broker marks it cancelled), denying queued calls and aborting. - permissionMode 'default' and settingSources [] so no repository or user allow rule approves a call the broker never sees. Tests: 13 deterministic protocol tests replay scripted SDK exchanges through the real adapter and LocalSquadBackend; 2 live tests run the real SDK behind SQUAD_REAL_CLI_TESTS=1 and credentials (not yet run). Refs #2441 Co-Authored-By: Claude Opus 5.5 --- packages/squad-sdk/package.json | 8 + .../src/backend/harnesses/claude-agent-sdk.ts | 329 ++++++++++++++++++ packages/squad-sdk/src/backend/index.ts | 2 + test/claude-agent-sdk-harness.test.ts | 259 ++++++++++++++ 4 files changed, 598 insertions(+) create mode 100644 packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts create mode 100644 test/claude-agent-sdk-harness.test.ts 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/claude-agent-sdk.ts b/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts new file mode 100644 index 000000000..c34883f60 --- /dev/null +++ b/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts @@ -0,0 +1,329 @@ +/** + * `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. + * - 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 { createHash } from 'node:crypto'; +import type { + ApprovalResponse, + HarnessAdapter, + HarnessCapabilities, + HarnessDescriptor, + HarnessEventEnvelope, + HarnessSubmission, + LeaseEpoch, +} from '../types.js'; +import { partsToText } from '../types.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[]; + 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; +} + +/** 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 { + 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({ tool: toolName, input }))).digest('hex')}`; +} + +interface PendingApproval { + approvalId: string; + hash: string; + toolName: string; + input: Record; + resolve: (result: ClaudePermissionResult) => void; + offered: boolean; +} + +interface Run { + conversationId: string; + runId: string; + seq: number; + epoch?: LeaseEpoch; + abort: AbortController; + approvals: PendingApproval[]; + done: Promise; +} + +export class ClaudeAgentSdkHarness implements HarnessAdapter { + readonly id = CLAUDE_AGENT_SDK_HARNESS_ID; + readonly capabilities: HarnessCapabilities = { + streaming: true, + approvals: true, + cancellation: true, + tools: true, + extensionNamespaces: ['x-claude'], + }; + + private readonly listeners = new Map void>>(); + private readonly seen = new Set(); + private readonly runs = new Map(); + /** Claude session per conversation, resumed by later turns. */ + private readonly sessions = new Map(); + /** Turns of one conversation run in order. */ + private readonly tails = new Map>(); + private sdk?: Promise; + + constructor(private readonly options: ClaudeAgentSdkHarnessOptions) {} + + describe(): HarnessDescriptor { + return { id: this.id, displayName: 'Claude Agent SDK', 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); + }; + } + + async submit(submission: HarnessSubmission): Promise { + const key = submission.operationId ?? `${submission.conversationId}:${submission.runId}:${submission.seq}`; + if (this.seen.has(key)) return; // at-least-once delivery: a retry of an accepted submission is a no-op + // Fail the delivery (so the broker retries) only when the SDK can't be loaded at all. + await this.loadSdk(); + this.seen.add(key); + const run: Run = { + conversationId: submission.conversationId, + runId: submission.runId, + seq: submission.seq, + epoch: submission.epoch, + abort: new AbortController(), + approvals: [], + done: Promise.resolve(), + }; + this.runs.set(this.runKey(run.conversationId, run.runId, run.seq), run); + const prompt = submission.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)); + this.tails.set(run.conversationId, run.done.catch(() => undefined)); + } + + async cancel(conversationId: string, runId: string, seq: number): Promise { + const run = this.runs.get(this.runKey(conversationId, runId, seq)); + if (!run || run.abort.signal.aborted) return; // idempotent + run.abort.abort(); + for (const a of run.approvals.splice(0)) a.resolve({ behavior: 'deny', message: 'Turn cancelled', interrupt: true }); + } + + async respondApproval(_conversationId: string, response: ApprovalResponse): Promise { + for (const run of this.runs.values()) { + const i = run.approvals.findIndex((a) => a.approvalId === response.approvalId); + if (i === -1) continue; + const pending = run.approvals[i]!; + // The response must be bound to exactly this request; anything else leaves the call waiting. + if (response.hash !== pending.hash) { + throw new Error(`Approval ${response.approvalId}: hash does not match the requested action`); + } + run.approvals.splice(i, 1); + if (response.decision === 'approve') { + pending.resolve({ behavior: 'allow', updatedInput: pending.input }); + this.emit(run, { type: 'state', state: 'running' }); + this.offerNext(run); + return; + } + // The broker ends the turn on a deny (`cancelled`), so stop the query too: deny this call and every + // queued one without offering them, then abort. + const reason = response.reason ?? 'Denied by the Squad approval policy'; + pending.resolve({ behavior: 'deny', message: reason, interrupt: true }); + for (const a of run.approvals.splice(0)) a.resolve({ behavior: 'deny', message: reason, interrupt: true }); + run.abort.abort(); + return; + } + // Unknown or already decided: redelivery of a decision that was applied is a no-op. + } + + /** Waits for a submitted turn to finish (tests and orderly shutdown). */ + async settled(conversationId: string, runId: string, seq: number): Promise { + await this.runs.get(this.runKey(conversationId, runId, seq))?.done.catch(() => undefined); + } + + // ── internals ───────────────────────────────────────────────────────────────────────────────── + + private runKey(conversationId: string, runId: string, seq: number): string { + return `${conversationId}\u0000${runId}\u0000${seq}`; + } + + 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 emit(run: Run, 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); + } + + /** Offer the oldest unoffered approval when none is outstanding (one pending approval per turn). */ + private offerNext(run: Run): void { + if (run.approvals.some((a) => a.offered)) return; + const next = run.approvals[0]; + if (!next) return; + next.offered = true; + this.emit(run, { + type: 'approval-requested', + request: { + approvalId: next.approvalId, + turnSeq: run.seq, + action: `${next.toolName}: ${JSON.stringify(next.input).slice(0, 500)}`, + hash: next.hash, + payload: { tool: next.toolName, input: next.input }, + }, + }); + } + + private canUseTool(run: Run): ClaudeCanUseTool { + return (toolName, input, { signal, toolUseID }) => + new Promise((resolve) => { + if (signal.aborted || run.abort.signal.aborted) { + resolve({ behavior: 'deny', message: 'Turn cancelled', interrupt: true }); + return; + } + const pending: PendingApproval = { approvalId: toolUseID, hash: claudeApprovalHash(toolName, input), toolName, input, resolve, offered: false }; + signal.addEventListener('abort', () => { + const i = run.approvals.indexOf(pending); + if (i !== -1) run.approvals.splice(i, 1); + resolve({ behavior: 'deny', message: 'Turn cancelled', interrupt: true }); + }); + run.approvals.push(pending); + this.offerNext(run); + }); + } + + private async execute(run: Run, prompt: string): Promise { + if (run.abort.signal.aborted) { + this.emit(run, { type: 'state', state: 'cancelled' }); + return; + } + this.emit(run, { type: 'state', state: 'running' }); + try { + const sdk = await this.loadSdk(); + const resume = this.sessions.get(run.conversationId); + const stream = sdk.query({ + prompt, + options: { + cwd: this.options.cwd, + permissionMode: 'default', + settingSources: [], + 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.sessions.set(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'; + } + } + } + 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' }); + } catch (err) { + if (run.abort.signal.aborted) this.emit(run, { type: 'state', state: 'cancelled' }); + else this.emit(run, { type: 'state', state: 'failed', error: (err instanceof Error ? err.message : String(err)).slice(0, 500) }); + } finally { + for (const a of run.approvals.splice(0)) a.resolve({ behavior: 'deny', message: 'Turn ended', interrupt: true }); + } + } +} diff --git a/packages/squad-sdk/src/backend/index.ts b/packages/squad-sdk/src/backend/index.ts index 7e313cf3f..e2366026a 100644 --- a/packages/squad-sdk/src/backend/index.ts +++ b/packages/squad-sdk/src/backend/index.ts @@ -17,6 +17,8 @@ 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 } from './harnesses/claude-agent-sdk.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/test/claude-agent-sdk-harness.test.ts b/test/claude-agent-sdk-harness.test.ts new file mode 100644 index 000000000..134aedd92 --- /dev/null +++ b/test/claude-agent-sdk-harness.test.ts @@ -0,0 +1,259 @@ +/** + * `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 { + 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 harness = new ClaudeAgentSdkHarness({ cwd: process.cwd(), maxTurns: 3 }); + const harnesses = new DefaultHarnessRegistry(); + harnesses.register(harness); + const backend = new LocalSquadBackend({ harnesses }); + await backend.createConversation({ conversationId: 'live2' }); + await backend.sendMessage('live2', { runId: 'r1', seq: 1, role: 'user', content: 'Use the Bash tool to run: echo squad-live-check', harness: HARNESS }); + await until(async () => (await backend.getApprovals('live2')).some((a) => a.state === 'pending'), 90_000); + const pending = (await backend.getApprovals('live2')).find((a) => a.state === 'pending')!; + expect(pending.action).toMatch(/^Bash: /); + 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(['completed', 'failed']).toContain((await backend.getTurn('live2', 1)).state); + }, 180_000); +}); From a88687b388e5b970db42bda01d599ff74f4d9e67 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 9 Nov 2026 17:46:15 -0800 Subject: [PATCH 02/12] feat(backend): register claude-agent-sdk in state-mcp; shared harness contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `squad state-mcp --experimental-backend` registers the claude-agent-sdk harness when the optional @anthropic-ai/claude-agent-sdk peer resolves (isClaudeAgentSdkAvailable), running in the repository root; SQUAD_BACKEND_HARNESSES=none turns it off. Without the SDK nothing changes. - test/harness-contract.ts: a reusable HarnessAdapter contract (describe, event identity and epoch, redelivery, hash-bound approvals with replay, one approval at a time with deny ending the turn, idempotent cancel) through the real LocalSquadBackend, run for claude-agent-sdk. No Copilot harness exists yet, so it runs against one harness for now. - Specs, docs and help no longer say no harness ships: harness-adapters §5 and a §14 layout row, the decoupling spec, the architecture doc, the SDK reference and both state-mcp help texts. Changeset added. Refs #2441 Co-Authored-By: Claude Opus 5.5 --- .changeset/claude-agent-sdk-harness.md | 6 + .../client-backend-decoupling-v0.2.md | 17 ++- .../client-harness-adapters-v0.2.md | 19 ++- .../docs/concepts/squad-2-0-architecture.md | 2 +- docs/src/content/docs/reference/sdk.md | 3 +- .../squad-cli/src/cli/commands/state-mcp.ts | 6 +- .../squad-cli/src/cli/core/backend-mcp.ts | 38 +++++- .../squad-cli/src/cli/core/command-help.ts | 5 +- .../src/backend/harnesses/claude-agent-sdk.ts | 13 ++ packages/squad-sdk/src/backend/index.ts | 2 +- test/claude-agent-sdk-harness.test.ts | 35 ++++++ test/harness-contract.test.ts | 33 +++++ test/harness-contract.ts | 116 ++++++++++++++++++ test/squad-20-real-clients-stdio.e2e.test.ts | 2 +- 14 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 .changeset/claude-agent-sdk-harness.md create mode 100644 test/harness-contract.test.ts create mode 100644 test/harness-contract.ts diff --git a/.changeset/claude-agent-sdk-harness.md b/.changeset/claude-agent-sdk-harness.md new file mode 100644 index 000000000..bab7b9409 --- /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. diff --git a/docs/specification/client-backend-decoupling-v0.2.md b/docs/specification/client-backend-decoupling-v0.2.md index ef241a638..666920f75 100644 --- a/docs/specification/client-backend-decoupling-v0.2.md +++ b/docs/specification/client-backend-decoupling-v0.2.md @@ -13,8 +13,10 @@ **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`. One execution harness ships: +`claude-agent-sdk` (`ClaudeAgentSdkHarness`), registered by the MCP bridge when +the optional `@anthropic-ai/claude-agent-sdk` peer is installed. Without it 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 +417,8 @@ 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 one implementation, `claude-agent-sdk` (Claude Agent SDK). ### 10.2 MCP bridge @@ -454,7 +456,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. +- The only shipped harness is `claude-agent-sdk`; it needs the optional + `@anthropic-ai/claude-agent-sdk` peer and Anthropic API, Bedrock or Vertex + credentials. There is no Copilot `HarnessAdapter` yet. +- `claude-agent-sdk` keeps 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 4a8427179..5c7ef2318 100644 --- a/docs/specification/client-harness-adapters-v0.2.md +++ b/docs/specification/client-harness-adapters-v0.2.md @@ -229,10 +229,20 @@ 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. +One `HarnessAdapter` ships: `claude-agent-sdk` (`ClaudeAgentSdkHarness`, in +`packages/squad-sdk/src/backend/harnesses/`). It 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 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), and otherwise refuses +`squad_backend_send_message`. No Copilot harness exists yet, so the shared +contract suite (`test/harness-contract.ts`) runs against `claude-agent-sdk` +only. `describe` and `projectWorkspace` on `ClientAdapter` let the broker reason about host capabilities and file projection without @@ -532,6 +542,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..5dfb5570b 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 only when the optional `@anthropic-ai/claude-agent-sdk` peer is installed (`SQUAD_BACKEND_HARNESSES=none` turns it 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/sdk.md b/docs/src/content/docs/reference/sdk.md index 74edd8777..2e632bca1 100644 --- a/docs/src/content/docs/reference/sdk.md +++ b/docs/src/content/docs/reference/sdk.md @@ -354,8 +354,9 @@ 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`. - `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 only when the optional `@anthropic-ai/claude-agent-sdk` peer is installed (`SQUAD_BACKEND_HARNESSES=none` turns it 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..cebf77a46 100644 --- a/packages/squad-cli/src/cli/commands/state-mcp.ts +++ b/packages/squad-cli/src/cli/commands/state-mcp.ts @@ -325,8 +325,10 @@ 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', + '(SQUAD_BACKEND_HARNESSES=none turns it off); otherwise 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..20c57903d 100644 --- a/packages/squad-cli/src/cli/core/backend-mcp.ts +++ b/packages/squad-cli/src/cli/core/backend-mcp.ts @@ -1,8 +1,13 @@ +import path from 'node:path'; import { resolveSquadState } from '@bradygaster/squad-sdk'; import { ALL_CONTENT_PART_TYPES, BACKEND_PROTOCOL_VERSION, BackendError, + ClaudeAgentSdkHarness, + DefaultHarnessRegistry, + isClaudeAgentSdkAvailable, + type HarnessRegistry, createSquadBackend, createStateBackendStore, type BackendContentPart, @@ -18,9 +23,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 +137,28 @@ 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 (default `claude-agent-sdk`), or `none`; a listed harness is registered only when it can run here. + */ +export function createLocalHarnesses( + repoRoot: string, + env: NodeJS.ProcessEnv = process.env, + claudeSdkAvailable: () => boolean = isClaudeAgentSdkAvailable, +): 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') && wanted.includes('claude-agent-sdk') && claudeSdkAvailable()) { + registry.register(new ClaudeAgentSdkHarness({ cwd: repoRoot })); + } + return registry; +} + export function createMcpBackend( startDir: string, env: NodeJS.ProcessEnv = process.env, resolveState: (dir: string) => Pick>, 'paths' | 'backend'> | null = resolveSquadState, + harnessesFor: (repoRoot: string) => HarnessRegistry = (root) => createLocalHarnesses(root, env), ): SquadBackend { if (env['SQUAD_BACKEND_URL']) { return createSquadBackend({ kind: 'durable-http', baseUrl: env['SQUAD_BACKEND_URL'], token: env['SQUAD_BACKEND_TOKEN'] }); @@ -147,7 +171,9 @@ 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 }); + // The repository root is the parent of the project's squad directory (the team dir in local mode). + const harnesses = harnessesFor(path.dirname(context.paths.projectDir ?? context.paths.teamSquadDir)); + 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 +209,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 +303,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 1353211ff..4c4887896 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/src/backend/harnesses/claude-agent-sdk.ts b/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts index c34883f60..3b3d201e5 100644 --- a/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts +++ b/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts @@ -90,6 +90,19 @@ export interface ClaudeAgentSdkHarnessOptions { loadSdk?: () => Promise; } +/** + * 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. + */ +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 { const canonical = (v: unknown): unknown => diff --git a/packages/squad-sdk/src/backend/index.ts b/packages/squad-sdk/src/backend/index.ts index e2366026a..85a7074e6 100644 --- a/packages/squad-sdk/src/backend/index.ts +++ b/packages/squad-sdk/src/backend/index.ts @@ -17,7 +17,7 @@ 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 } from './harnesses/claude-agent-sdk.js'; +export { ClaudeAgentSdkHarness, CLAUDE_AGENT_SDK_HARNESS_ID, claudeApprovalHash, isClaudeAgentSdkAvailable } from './harnesses/claude-agent-sdk.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/test/claude-agent-sdk-harness.test.ts b/test/claude-agent-sdk-harness.test.ts index 134aedd92..781130a19 100644 --- a/test/claude-agent-sdk-harness.test.ts +++ b/test/claude-agent-sdk-harness.test.ts @@ -8,6 +8,7 @@ */ import { describe, it, expect } from 'vitest'; import { + BACKEND_PROTOCOL_VERSION, ClaudeAgentSdkHarness, DefaultHarnessRegistry, LocalSquadBackend, @@ -257,3 +258,37 @@ describe.skipIf(!live)('claude-agent-sdk harness: live (real Agent SDK)', () => expect(['completed', 'failed']).toContain((await backend.getTurn('live2', 1)).state); }, 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'); + expect(createLocalHarnesses('/repo', {}, () => true).list().map((h) => h.id)).toEqual([HARNESS]); + expect(createLocalHarnesses('/repo', {}, () => false).list()).toEqual([]); + expect(createLocalHarnesses('/repo', { SQUAD_BACKEND_HARNESSES: 'none' }, () => true).list()).toEqual([]); + expect(createLocalHarnesses('/repo', { SQUAD_BACKEND_HARNESSES: 'other' }, () => true).list()).toEqual([]); + }); + + 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/harness-contract.test.ts b/test/harness-contract.test.ts new file mode 100644 index 000000000..749d251b0 --- /dev/null +++ b/test/harness-contract.test.ts @@ -0,0 +1,33 @@ +/** + * Runs the shared HarnessAdapter contract against every harness in the repository. + * A Copilot harness, when one exists, adds a driver here. + */ +import { harnessContract } from './harness-contract.js'; +import { + ClaudeAgentSdkHarness, + type ClaudeAgentSdkModule, + type HarnessAdapter, +} from '../packages/squad-sdk/src/backend/index.js'; + +/** Scripted Agent SDK: requests approval for each tool in parallel, then replies. */ +function scriptedClaudeSdk(script: { reply: string; tools?: Array<{ name: string; input: Record }> }): ClaudeAgentSdkModule { + return { + query({ options = {} }) { + 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) => new ClaudeAgentSdkHarness({ cwd: process.cwd(), loadSdk: async () => scriptedClaudeSdk(script) }), + settled: (h: HarnessAdapter, c, r, s) => (h as ClaudeAgentSdkHarness).settled(c, r, s), +}); diff --git a/test/harness-contract.ts b/test/harness-contract.ts new file mode 100644 index 000000000..48ffeb0e1 --- /dev/null +++ b/test/harness-contract.ts @@ -0,0 +1,116 @@ +/** + * 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 HarnessContractDriver { + /** Harness under test, scripted to answer `reply` and to request approval for each of `tools`, in parallel. */ + create(script: { reply: string; tools?: ContractToolCall[] }): HarnessAdapter; + /** 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: { reply: string; tools?: ContractToolCall[] }) { + 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) => + backend.sendMessage('k', { runId, seq, role: 'user', content: `task ${seq}`, harness: harness.id }); + const settled = (runId: string, seq: number) => driver.settled(harness, 'k', 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'); + }); + }); +} diff --git a/test/squad-20-real-clients-stdio.e2e.test.ts b/test/squad-20-real-clients-stdio.e2e.test.ts index 70a9426f1..596669ad0 100644 --- a/test/squad-20-real-clients-stdio.e2e.test.ts +++ b/test/squad-20-real-clients-stdio.e2e.test.ts @@ -349,7 +349,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' }], From 384314c6cf18f20c7a44a1b7bd80f286fefb4510 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 9 Nov 2026 17:55:08 -0800 Subject: [PATCH 03/12] feat(backend): copilot-sdk HarnessAdapter; shared approval queue; contract on both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - copilot-sdk (CopilotSdkHarness) on @github/copilot-sdk: one Copilot session per conversation (created, then resumed by id), send() then session.idle / session.error; only main-agent assistant.message content enters the transcript. onPermissionRequest goes to broker approvals: approve-once on approve, reject plus abort on deny; toolCallId is the approvalId when present. Cancel aborts without waiting for idle. - TurnApprovals (approval-queue.ts): the hash-bound, one-at-a-time, deny-ends-the-turn queue both harnesses now share; claude-agent-sdk moved onto it with its hash format unchanged. - The shared HarnessAdapter contract runs against copilot-sdk and claude-agent-sdk (harness-adapters §15 item 1), plus Copilot-specific tests and one live test behind SQUAD_REAL_CLI_TESTS=1. - state-mcp registers copilot-sdk only when SQUAD_BACKEND_HARNESSES lists it, since its SDK is always installed. - Specs, docs, help and the changeset describe both harnesses. Refs #2441 Co-Authored-By: Claude Opus 5.5 --- .changeset/claude-agent-sdk-harness.md | 2 +- .../client-backend-decoupling-v0.2.md | 21 +- .../client-harness-adapters-v0.2.md | 25 +- .../docs/concepts/squad-2-0-architecture.md | 2 +- docs/src/content/docs/reference/sdk.md | 3 +- .../squad-cli/src/cli/commands/state-mcp.ts | 7 +- .../squad-cli/src/cli/core/backend-mcp.ts | 11 +- .../src/backend/harnesses/approval-queue.ts | 108 +++++++ .../src/backend/harnesses/claude-agent-sdk.ts | 103 ++----- .../src/backend/harnesses/copilot-sdk.ts | 278 ++++++++++++++++++ packages/squad-sdk/src/backend/index.ts | 4 + test/claude-agent-sdk-harness.test.ts | 3 + test/copilot-sdk-harness.test.ts | 170 +++++++++++ test/harness-contract.test.ts | 46 ++- 14 files changed, 680 insertions(+), 103 deletions(-) create mode 100644 packages/squad-sdk/src/backend/harnesses/approval-queue.ts create mode 100644 packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts create mode 100644 test/copilot-sdk-harness.test.ts diff --git a/.changeset/claude-agent-sdk-harness.md b/.changeset/claude-agent-sdk-harness.md index bab7b9409..bcb04c119 100644 --- a/.changeset/claude-agent-sdk-harness.md +++ b/.changeset/claude-agent-sdk-harness.md @@ -3,4 +3,4 @@ "@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. +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`, and a shared approval queue (`TurnApprovals`) both harnesses use. diff --git a/docs/specification/client-backend-decoupling-v0.2.md b/docs/specification/client-backend-decoupling-v0.2.md index 666920f75..ac1005086 100644 --- a/docs/specification/client-backend-decoupling-v0.2.md +++ b/docs/specification/client-backend-decoupling-v0.2.md @@ -13,10 +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`. One execution harness ships: -`claude-agent-sdk` (`ClaudeAgentSdkHarness`), registered by the MCP bridge when -the optional `@anthropic-ai/claude-agent-sdk` peer is installed. Without it the -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 @@ -418,7 +420,8 @@ The same contract is projected through three backend families. 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, the harness -interface and one implementation, `claude-agent-sdk` (Claude Agent SDK). +interface and two implementations, `claude-agent-sdk` (Claude Agent SDK) and +`copilot-sdk` (GitHub Copilot SDK). ### 10.2 MCP bridge @@ -456,10 +459,10 @@ 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. -- The only shipped harness is `claude-agent-sdk`; it needs the optional - `@anthropic-ai/claude-agent-sdk` peer and Anthropic API, Bedrock or Vertex - credentials. There is no Copilot `HarnessAdapter` yet. -- `claude-agent-sdk` keeps a pending approval in process memory, so a harness +- `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. diff --git a/docs/specification/client-harness-adapters-v0.2.md b/docs/specification/client-harness-adapters-v0.2.md index 5c7ef2318..87d3f9536 100644 --- a/docs/specification/client-harness-adapters-v0.2.md +++ b/docs/specification/client-harness-adapters-v0.2.md @@ -229,8 +229,12 @@ 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. -One `HarnessAdapter` ships: `claude-agent-sdk` (`ClaudeAgentSdkHarness`, in -`packages/squad-sdk/src/backend/harnesses/`). It runs each admitted turn as a +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. + +`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 @@ -240,9 +244,20 @@ deny ends the turn. It runs with `permissionMode: 'default'` and broker never sees. The 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), and otherwise refuses -`squad_backend_send_message`. No Copilot harness exists yet, so the shared -contract suite (`test/harness-contract.ts`) runs against `claude-agent-sdk` -only. +`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. +`onPermissionRequest` goes through the same queue, with `toolCallId` as the +`approvalId`; approve answers `approve-once`, and deny answers `reject` and +aborts the turn. 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). `describe` and `projectWorkspace` on `ClientAdapter` let the broker reason about host capabilities and file projection without 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 5dfb5570b..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 registers the `claude-agent-sdk` harness only when the optional `@anthropic-ai/claude-agent-sdk` peer is installed (`SQUAD_BACKEND_HARNESSES=none` turns it 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. +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/sdk.md b/docs/src/content/docs/reference/sdk.md index 2e632bca1..3eaf600b8 100644 --- a/docs/src/content/docs/reference/sdk.md +++ b/docs/src/content/docs/reference/sdk.md @@ -355,8 +355,9 @@ 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 registers the `claude-agent-sdk` harness only when the optional `@anthropic-ai/claude-agent-sdk` peer is installed (`SQUAD_BACKEND_HARNESSES=none` turns it 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. +- 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 cebf77a46..d457eb604 100644 --- a/packages/squad-cli/src/cli/commands/state-mcp.ts +++ b/packages/squad-cli/src/cli/commands/state-mcp.ts @@ -326,9 +326,10 @@ export function printStateMcpHelp(): void { 'enable them; once enabled it routes the tools to a remote durable backend', '(https required, plain http only for localhost) with SQUAD_BACKEND_TOKEN.', 'Execution uses the claude-agent-sdk harness when the optional', - '@anthropic-ai/claude-agent-sdk package is installed', - '(SQUAD_BACKEND_HARNESSES=none turns it off); otherwise the bridge', - 'records transcripts and events only.', + '@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 20c57903d..43c4c8da3 100644 --- a/packages/squad-cli/src/cli/core/backend-mcp.ts +++ b/packages/squad-cli/src/cli/core/backend-mcp.ts @@ -5,6 +5,7 @@ import { BACKEND_PROTOCOL_VERSION, BackendError, ClaudeAgentSdkHarness, + CopilotSdkHarness, DefaultHarnessRegistry, isClaudeAgentSdkAvailable, type HarnessRegistry, @@ -139,7 +140,9 @@ export function isExperimentalBackendEnabled( */ /** * Execution harnesses for the local backend. `SQUAD_BACKEND_HARNESSES` is a comma-separated list of harness - * ids (default `claude-agent-sdk`), or `none`; a listed harness is registered only when it can run here. + * 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 function createLocalHarnesses( repoRoot: string, @@ -148,9 +151,9 @@ export function createLocalHarnesses( ): 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') && wanted.includes('claude-agent-sdk') && claudeSdkAvailable()) { - registry.register(new ClaudeAgentSdkHarness({ cwd: repoRoot })); - } + if (wanted.includes('none')) return registry; + if (wanted.includes('claude-agent-sdk') && claudeSdkAvailable()) registry.register(new ClaudeAgentSdkHarness({ cwd: repoRoot })); + if (wanted.includes('copilot-sdk')) registry.register(new CopilotSdkHarness({ cwd: repoRoot })); return registry; } 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..dc7dd440e --- /dev/null +++ b/packages/squad-sdk/src/backend/harnesses/approval-queue.ts @@ -0,0 +1,108 @@ +/** + * 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. + */ +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')}`; +} + +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. */ + 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 }); + this.offerNext(); + } + + private offerNext(): void { + if (this.queue.some((p) => p.offered)) return; + const next = this.queue[0]; + if (!next) return; + next.offered = true; + this.offer({ approvalId: next.approvalId, turnSeq: this.turnSeq, action: next.action, hash: next.hash, payload: next.payload }); + } +} diff --git a/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts b/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts index 3b3d201e5..8a1019df5 100644 --- a/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts +++ b/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts @@ -21,7 +21,6 @@ * 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 { createHash } from 'node:crypto'; import type { ApprovalResponse, HarnessAdapter, @@ -32,6 +31,7 @@ import type { LeaseEpoch, } from '../types.js'; import { partsToText } from '../types.js'; +import { TurnApprovals, canonicalApprovalHash } from './approval-queue.js'; export const CLAUDE_AGENT_SDK_HARNESS_ID = 'claude-agent-sdk'; const SDK_MODULE = '@anthropic-ai/claude-agent-sdk'; @@ -105,22 +105,7 @@ export function isClaudeAgentSdkAvailable(): boolean { /** 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 { - 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({ tool: toolName, input }))).digest('hex')}`; -} - -interface PendingApproval { - approvalId: string; - hash: string; - toolName: string; - input: Record; - resolve: (result: ClaudePermissionResult) => void; - offered: boolean; + return canonicalApprovalHash({ tool: toolName, input }); } interface Run { @@ -129,7 +114,7 @@ interface Run { seq: number; epoch?: LeaseEpoch; abort: AbortController; - approvals: PendingApproval[]; + approvals: TurnApprovals; done: Promise; } @@ -179,9 +164,15 @@ export class ClaudeAgentSdkHarness implements HarnessAdapter { seq: submission.seq, epoch: submission.epoch, abort: new AbortController(), - approvals: [], + approvals: undefined as unknown as TurnApprovals, done: Promise.resolve(), }; + run.approvals = new TurnApprovals( + run.seq, + (request) => this.emit(run, { type: 'approval-requested', request }), + () => this.emit(run, { type: 'state', state: 'running' }), + () => run.abort.abort(), + ); this.runs.set(this.runKey(run.conversationId, run.runId, run.seq), run); const prompt = submission.messages .filter((m) => m.role === 'user') @@ -195,35 +186,13 @@ export class ClaudeAgentSdkHarness implements HarnessAdapter { async cancel(conversationId: string, runId: string, seq: number): Promise { const run = this.runs.get(this.runKey(conversationId, runId, seq)); if (!run || run.abort.signal.aborted) return; // idempotent + run.approvals.close('Turn cancelled'); run.abort.abort(); - for (const a of run.approvals.splice(0)) a.resolve({ behavior: 'deny', message: 'Turn cancelled', interrupt: true }); } async respondApproval(_conversationId: string, response: ApprovalResponse): Promise { - for (const run of this.runs.values()) { - const i = run.approvals.findIndex((a) => a.approvalId === response.approvalId); - if (i === -1) continue; - const pending = run.approvals[i]!; - // The response must be bound to exactly this request; anything else leaves the call waiting. - if (response.hash !== pending.hash) { - throw new Error(`Approval ${response.approvalId}: hash does not match the requested action`); - } - run.approvals.splice(i, 1); - if (response.decision === 'approve') { - pending.resolve({ behavior: 'allow', updatedInput: pending.input }); - this.emit(run, { type: 'state', state: 'running' }); - this.offerNext(run); - return; - } - // The broker ends the turn on a deny (`cancelled`), so stop the query too: deny this call and every - // queued one without offering them, then abort. - const reason = response.reason ?? 'Denied by the Squad approval policy'; - pending.resolve({ behavior: 'deny', message: reason, interrupt: true }); - for (const a of run.approvals.splice(0)) a.resolve({ behavior: 'deny', message: reason, interrupt: true }); - run.abort.abort(); - return; - } - // Unknown or already decided: redelivery of a decision that was applied is a no-op. + for (const run of this.runs.values()) if (run.approvals.respond(response)) return; + // Unknown or already decided: a redelivered decision is a no-op. } /** Waits for a submitted turn to finish (tests and orderly shutdown). */ @@ -252,40 +221,18 @@ export class ClaudeAgentSdkHarness implements HarnessAdapter { for (const l of this.listeners.get(run.conversationId) ?? []) l(envelope); } - /** Offer the oldest unoffered approval when none is outstanding (one pending approval per turn). */ - private offerNext(run: Run): void { - if (run.approvals.some((a) => a.offered)) return; - const next = run.approvals[0]; - if (!next) return; - next.offered = true; - this.emit(run, { - type: 'approval-requested', - request: { - approvalId: next.approvalId, - turnSeq: run.seq, - action: `${next.toolName}: ${JSON.stringify(next.input).slice(0, 500)}`, - hash: next.hash, - payload: { tool: next.toolName, input: next.input }, - }, - }); - } - private canUseTool(run: Run): ClaudeCanUseTool { - return (toolName, input, { signal, toolUseID }) => - new Promise((resolve) => { - if (signal.aborted || run.abort.signal.aborted) { - resolve({ behavior: 'deny', message: 'Turn cancelled', interrupt: true }); - return; - } - const pending: PendingApproval = { approvalId: toolUseID, hash: claudeApprovalHash(toolName, input), toolName, input, resolve, offered: false }; - signal.addEventListener('abort', () => { - const i = run.approvals.indexOf(pending); - if (i !== -1) run.approvals.splice(i, 1); - resolve({ behavior: 'deny', message: 'Turn cancelled', interrupt: true }); - }); - run.approvals.push(pending); - this.offerNext(run); - }); + 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, + `${toolName}: ${JSON.stringify(input).slice(0, 500)}`, + { 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 }; + }; } private async execute(run: Run, prompt: string): Promise { @@ -336,7 +283,7 @@ export class ClaudeAgentSdkHarness implements HarnessAdapter { if (run.abort.signal.aborted) this.emit(run, { type: 'state', state: 'cancelled' }); else this.emit(run, { type: 'state', state: 'failed', error: (err instanceof Error ? err.message : String(err)).slice(0, 500) }); } finally { - for (const a of run.approvals.splice(0)) a.resolve({ behavior: 'deny', message: 'Turn ended', interrupt: true }); + run.approvals.close('Turn ended'); } } } 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..c2846e03e --- /dev/null +++ b/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts @@ -0,0 +1,278 @@ +/** + * `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` when present. + * 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 { + ApprovalResponse, + HarnessAdapter, + HarnessCapabilities, + HarnessDescriptor, + HarnessEventEnvelope, + HarnessSubmission, + LeaseEpoch, +} from '../types.js'; +import { partsToText } from '../types.js'; +import { TurnApprovals } from './approval-queue.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 }; +} + +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; + 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; + /** Builds the client. Defaults to `new CopilotClient({ workingDirectory: cwd })` from `@github/copilot-sdk`. */ + createClient?: () => Promise; +} + +interface Run { + conversationId: string; + runId: string; + seq: number; + epoch?: LeaseEpoch; + cancelled: boolean; + approvals: TurnApprovals; + done: Promise; + /** Ends the wait for `session.idle` (cancel doesn't rely on the runtime sending one after abort). */ + wake?: () => void; +} + +/** A short, readable description of a permission request for the broker's approval record. */ +function describeRequest(r: CopilotPermissionRequest): string { + const detail = r['fullCommandText'] ?? r['fileName'] ?? r['path'] ?? r['url'] ?? r['toolName'] ?? ''; + return `${r.kind}${detail ? `: ${String(detail).slice(0, 400)}` : ''}`; +} + +export class CopilotSdkHarness implements HarnessAdapter { + readonly id = COPILOT_SDK_HARNESS_ID; + readonly capabilities: HarnessCapabilities = { + streaming: true, + approvals: true, + cancellation: true, + tools: true, + extensionNamespaces: ['x-copilot'], + }; + + private readonly listeners = new Map void>>(); + private readonly seen = new Set(); + private readonly runs = new Map(); + /** The live session per conversation, and the session id to resume when the object is gone. */ + private readonly sessions = new Map>(); + private readonly sessionIds = new Map(); + /** The run currently executing per conversation (permission requests are routed to it). */ + private readonly active = new Map(); + private readonly tails = new Map>(); + private client?: Promise; + + constructor(private readonly options: CopilotSdkHarnessOptions) {} + + describe(): HarnessDescriptor { + return { id: this.id, displayName: 'GitHub Copilot SDK', 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); + }; + } + + async submit(submission: HarnessSubmission): Promise { + const key = submission.operationId ?? `${submission.conversationId}:${submission.runId}:${submission.seq}`; + if (this.seen.has(key)) return; + await this.getClient(); // a client that can't start fails the delivery, so the broker retries + this.seen.add(key); + const run: Run = { + conversationId: submission.conversationId, + runId: submission.runId, + seq: submission.seq, + epoch: submission.epoch, + cancelled: false, + approvals: undefined as unknown as TurnApprovals, + done: Promise.resolve(), + }; + run.approvals = new TurnApprovals( + run.seq, + (request) => this.emit(run, { type: 'approval-requested', request }), + () => this.emit(run, { type: 'state', state: 'running' }), + () => void this.stop(run), + ); + this.runs.set(this.runKey(run.conversationId, run.runId, run.seq), run); + const prompt = submission.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)); + this.tails.set(run.conversationId, run.done.catch(() => undefined)); + } + + async cancel(conversationId: string, runId: string, seq: number): Promise { + const run = this.runs.get(this.runKey(conversationId, runId, seq)); + if (!run || run.cancelled) return; + await this.stop(run); + } + + async respondApproval(_conversationId: string, response: ApprovalResponse): Promise { + for (const run of this.runs.values()) if (run.approvals.respond(response)) return; + // Unknown or already decided: a redelivered decision is a no-op. + } + + /** Waits for a submitted turn to finish (tests and orderly shutdown). */ + async settled(conversationId: string, runId: string, seq: number): Promise { + await this.runs.get(this.runKey(conversationId, runId, seq))?.done.catch(() => undefined); + } + + // ── internals ───────────────────────────────────────────────────────────────────────────────── + + private runKey(conversationId: string, runId: string, seq: number): string { + return `${conversationId}\u0000${runId}\u0000${seq}`; + } + + 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 } : {}), + 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(toolCallId ?? `copilot-${randomUUID()}`, describeRequest(request), { kind, ...detail }); + return outcome.approved ? { kind: 'approve-once' } : { kind: 'reject', feedback: outcome.reason }; + }, + }; + } + + private session(conversationId: string): Promise { + let s = this.sessions.get(conversationId); + if (!s) { + s = (async () => { + const client = await this.getClient(); + const id = this.sessionIds.get(conversationId); + const session = id + ? await client.resumeSession(id, this.sessionConfig(conversationId)) + : await client.createSession(this.sessionConfig(conversationId)); + this.sessionIds.set(conversationId, session.sessionId); + return session; + })(); + this.sessions.set(conversationId, s); + s.catch(() => this.sessions.delete(conversationId)); + } + return s; + } + + private emit(run: Run, 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); + } + + private async stop(run: Run): Promise { + run.cancelled = true; + run.approvals.close('Turn cancelled'); + run.wake?.(); + if (this.active.get(run.conversationId) === run) { + const session = await this.sessions.get(run.conversationId)?.catch(() => undefined); + await session?.abort().catch(() => undefined); + } + } + + private async execute(run: Run, prompt: string): Promise { + if (run.cancelled) { + this.emit(run, { type: 'state', state: 'cancelled' }); + return; + } + this.emit(run, { type: 'state', state: 'running' }); + this.active.set(run.conversationId, run); + let off: (() => void) | undefined; + try { + const session = await this.session(run.conversationId); + const finished = new Promise((resolve) => { + run.wake = () => resolve(undefined); + off = session.on((event) => { + if (event.type === 'assistant.message' && !event.agentId && !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') { + resolve(undefined); + } else if (event.type === 'session.error') { + resolve(event.data?.message || event.data?.errorType || 'Copilot session error'); + } + }); + }); + await session.send(prompt); + const failure = await finished; + if (run.cancelled) 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' }); + } catch (err) { + if (run.cancelled) this.emit(run, { type: 'state', state: 'cancelled' }); + else this.emit(run, { type: 'state', state: 'failed', error: (err instanceof Error ? err.message : String(err)).slice(0, 500) }); + } finally { + off?.(); + run.approvals.close('Turn ended'); + if (this.active.get(run.conversationId) === run) this.active.delete(run.conversationId); + } + } +} diff --git a/packages/squad-sdk/src/backend/index.ts b/packages/squad-sdk/src/backend/index.ts index 85a7074e6..43d209f15 100644 --- a/packages/squad-sdk/src/backend/index.ts +++ b/packages/squad-sdk/src/backend/index.ts @@ -18,6 +18,10 @@ 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, isClaudeAgentSdkAvailable } from './harnesses/claude-agent-sdk.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 } from './harnesses/approval-queue.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/test/claude-agent-sdk-harness.test.ts b/test/claude-agent-sdk-harness.test.ts index 781130a19..d22acad64 100644 --- a/test/claude-agent-sdk-harness.test.ts +++ b/test/claude-agent-sdk-harness.test.ts @@ -266,6 +266,9 @@ describe('claude-agent-sdk harness: state-mcp registration', () => { expect(createLocalHarnesses('/repo', {}, () => false).list()).toEqual([]); expect(createLocalHarnesses('/repo', { SQUAD_BACKEND_HARNESSES: 'none' }, () => true).list()).toEqual([]); expect(createLocalHarnesses('/repo', { SQUAD_BACKEND_HARNESSES: 'other' }, () => true).list()).toEqual([]); + expect(createLocalHarnesses('/repo', { SQUAD_BACKEND_HARNESSES: 'copilot-sdk' }, () => true).list().map((h) => h.id)).toEqual(['copilot-sdk']); + expect(createLocalHarnesses('/repo', { SQUAD_BACKEND_HARNESSES: 'copilot-sdk, claude-agent-sdk' }, () => true).list().map((h) => h.id)).toEqual([HARNESS, 'copilot-sdk']); + expect(createLocalHarnesses('/repo', {}, () => false).list()).toEqual([]); // copilot-sdk is never a default }); it('createMcpBackend wires the registry into the local backend', async () => { diff --git a/test/copilot-sdk-harness.test.ts b/test/copilot-sdk-harness.test.ts new file mode 100644 index 000000000..d60ece221 --- /dev/null +++ b/test/copilot-sdk-harness.test.ts @@ -0,0 +1,170 @@ +/** + * `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() { + 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 'msg'; + }, + }; + } + 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('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)', () => { + it('completes a turn through the broker', async () => { + const harness = new CopilotSdkHarness({ cwd: process.cwd() }); + 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); + expect((await backend.getTurn('live', 1)).state).toBe('completed'); + expect((await backend.getTranscript('live')).at(-1)?.content.toLowerCase()).toContain('pong'); + }, 180_000); +}); diff --git a/test/harness-contract.test.ts b/test/harness-contract.test.ts index 749d251b0..ed4e32142 100644 --- a/test/harness-contract.test.ts +++ b/test/harness-contract.test.ts @@ -1,11 +1,13 @@ /** * Runs the shared HarnessAdapter contract against every harness in the repository. - * A Copilot harness, when one exists, adds a driver here. */ import { harnessContract } from './harness-contract.js'; import { ClaudeAgentSdkHarness, + CopilotSdkHarness, type ClaudeAgentSdkModule, + type CopilotClientLike, + type CopilotSessionEvent, type HarnessAdapter, } from '../packages/squad-sdk/src/backend/index.js'; @@ -31,3 +33,45 @@ harnessContract('claude-agent-sdk', { create: (script) => new ClaudeAgentSdkHarness({ cwd: process.cwd(), loadSdk: async () => scriptedClaudeSdk(script) }), 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: { reply: string; tools?: Array<{ name: string; input: Record }> }): CopilotClientLike & { sessions: number; resumed: string[] } { + const client = { + sessions: 0, + resumed: [] as string[], + async 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() { + void (async () => { + const results = await Promise.all((script.tools ?? []).map((t, i) => + config.onPermissionRequest({ kind: 'shell', toolCallId: `call_${i}`, fullCommandText: String(t.input['command'] ?? t.name) }, { 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 'message-id'; + }, + }; + }, + }; + return client; +} + +harnessContract('copilot-sdk', { + create: (script) => new CopilotSdkHarness({ cwd: process.cwd(), createClient: async () => scriptedCopilotClient(script) }), + settled: (h: HarnessAdapter, c, r, s) => (h as CopilotSdkHarness).settled(c, r, s), +}); From d7e5c9c76436302030e412d9a19815815a95d0f5 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 9 Nov 2026 18:16:07 -0800 Subject: [PATCH 04/12] docs(matrix): record the live copilot-sdk harness run - liveRuns: copilot / copilot-sdk-harness / 2026-11-09 / pass. One plain-text turn through LocalSquadBackend on the real Copilot backend (@github/copilot-sdk 1.0.19, signed-in user, 4.4 s); no tool approval. - experimental-backend.copilot -> validated-live with the lab entry, the live test and the shared harness contract; experimental-backend.claude cites the contract and harness tests (live runs need an API key). - The matrix test allows validated-live for copilot now that it has a recorded passing run; the limitations note says so. Refs #2441 Co-Authored-By: Claude Opus 5.5 --- .../docs/reference/host-feature-matrix.md | 11 ++++--- .../spec/suite-v0.2/host-feature-matrix.json | 31 ++++++++++++++----- test/squad-20-host-feature-matrix.test.ts | 4 +-- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/docs/src/content/docs/reference/host-feature-matrix.md b/docs/src/content/docs/reference/host-feature-matrix.md index c81ef9c47..49006d39b 100644 --- a/docs/src/content/docs/reference/host-feature-matrix.md +++ b/docs/src/content/docs/reference/host-feature-matrix.md @@ -67,7 +67,7 @@ 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 | +| `copilot` | 45 | 0 | 6 | 0 | 1 | 48 | 2 | 0 | 0 | 6 | | `claude` | 41 | 1 | 6 | 3 | 18 | 30 | 3 | 0 | 0 | 6 | | `kiro` | 40 | 2 | 6 | 3 | 0 | 47 | 3 | 0 | 1 | 6 | | `opencode` | 41 | 1 | 6 | 3 | 18 | 30 | 3 | 0 | 0 | 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 the Copilot `copilot-sdk` execution harness (2026-11-09, experimental backend only). 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: one plain-text turn through the broker on the real Copilot backend (2026-11-09). 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-manual:lab:copilot-sdk-harness@2026-11-09` | +| `claude` | ✅ supported | 🧪 validated-test | 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 harness runs need an Anthropic API key (or Bedrock/Vertex); not yet run._ | `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::*` | | `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,5 @@ 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. | 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 aab13212f..297a223fa 100644 --- a/test-fixtures/spec/suite-v0.2/host-feature-matrix.json +++ b/test-fixtures/spec/suite-v0.2/host-feature-matrix.json @@ -4371,23 +4371,30 @@ "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-manual:lab:copilot-sdk-harness@2026-11-09" + ], + "notes": "Live: one plain-text turn through the broker on the real Copilot backend (2026-11-09). 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.", + "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::*" + ], + "notes": "Live harness runs need an Anthropic API key (or Bedrock/Vertex); not yet run." }, "kiro": { "capability": "supported", @@ -4975,6 +4982,14 @@ "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." } ], "expectedFailures": [ @@ -5078,7 +5093,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 the Copilot `copilot-sdk` execution harness (2026-11-09, experimental backend only). 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/squad-20-host-feature-matrix.test.ts b/test/squad-20-host-feature-matrix.test.ts index 9d6af2e35..fc355f861 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); From a0206ca2ec3aaae0029cd917e14c65025e3d09e7 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 9 Nov 2026 18:55:23 -0800 Subject: [PATCH 05/12] fix(backend): harden harness turn lifecycle; redact approval records Review of #2451. Both harnesses now share TurnHarness, which owns the broker-facing lifecycle: - Atomic acceptance: concurrent deliveries of one operation share one acceptance; a failed start forgets it so the broker's retry runs. - Cancellation survives startup: the run is registered before the runtime starts, and every setup await is followed by a checkpoint, so a cancel during SDK load, client start or session creation never reaches query()/send(). - Ordering: the conversation's next turn starts only after an interrupt has finished (Copilot awaits session.abort()), so late events from an aborted turn can't be attributed to the next one. cancel() acknowledges once the stop is requested, since the broker awaits it. - Approvals are scoped to their conversation (tool-call ids repeat). Copilot: events from sub-agents (agentId) no longer complete or fail the main turn; only sessions that finished creating are aborted. Approval records: TurnApprovals stores a short summary and a redacted payload (secret-looking keys and inline credentials masked, long strings truncated); the hash still covers the full action. Tests: five new shared contract checks for both harnesses (startup cancel, concurrent and retried delivery, cross-conversation approval ids, redaction) and four Copilot-specific ones (cancel during session creation, slow abort with a queued turn and late events, sub-agent idle and error). Refs #2441 Co-Authored-By: Claude Opus 5.5 --- .changeset/claude-agent-sdk-harness.md | 2 +- .../client-harness-adapters-v0.2.md | 12 + .../src/backend/harnesses/approval-queue.ts | 44 +++- .../src/backend/harnesses/claude-agent-sdk.ts | 206 +++++------------- .../src/backend/harnesses/copilot-sdk.ts | 195 +++++------------ .../src/backend/harnesses/turn-harness.ts | 197 +++++++++++++++++ packages/squad-sdk/src/backend/index.ts | 4 +- test/copilot-sdk-harness.test.ts | 88 ++++++++ test/harness-contract.test.ts | 41 +++- test/harness-contract.ts | 95 +++++++- 10 files changed, 572 insertions(+), 312 deletions(-) create mode 100644 packages/squad-sdk/src/backend/harnesses/turn-harness.ts diff --git a/.changeset/claude-agent-sdk-harness.md b/.changeset/claude-agent-sdk-harness.md index bcb04c119..89ed9c16a 100644 --- a/.changeset/claude-agent-sdk-harness.md +++ b/.changeset/claude-agent-sdk-harness.md @@ -3,4 +3,4 @@ "@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`, and a shared approval queue (`TurnApprovals`) both harnesses use. +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, redacted approval records) and a shared turn lifecycle (`TurnHarness`: atomic acceptance, cancellation that survives startup, the next turn waiting for an interrupt to finish). diff --git a/docs/specification/client-harness-adapters-v0.2.md b/docs/specification/client-harness-adapters-v0.2.md index 463764b05..72cd0f144 100644 --- a/docs/specification/client-harness-adapters-v0.2.md +++ b/docs/specification/client-harness-adapters-v0.2.md @@ -233,6 +233,18 @@ 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 a short action +summary and a redacted payload (secret-looking keys and inline credentials +masked, long strings truncated); the hash still covers the full action. + +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. `claude-agent-sdk` (`ClaudeAgentSdkHarness`) runs each admitted turn as a Claude Agent SDK `query()`, resuming the conversation's session, and maps diff --git a/packages/squad-sdk/src/backend/harnesses/approval-queue.ts b/packages/squad-sdk/src/backend/harnesses/approval-queue.ts index dc7dd440e..8e0287286 100644 --- a/packages/squad-sdk/src/backend/harnesses/approval-queue.ts +++ b/packages/squad-sdk/src/backend/harnesses/approval-queue.ts @@ -5,6 +5,10 @@ * 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 minimized: the payload is redacted (secret-looking keys and + * inline credentials masked, long strings truncated) and the action is a short summary. The hash is still taken + * over the full, unredacted action, so a decision stays bound to exactly what will run. */ import { createHash } from 'node:crypto'; import type { ApprovalResponse, HarnessApprovalRequest } from '../types.js'; @@ -22,6 +26,38 @@ export function canonicalApprovalHash(payload: unknown): string { return `sha256:${createHash('sha256').update(JSON.stringify(canonical(payload))).digest('hex')}`; } +const SENSITIVE_KEY = /pass(word|wd)?|secret|token|api[-_]?key|auth(orization)?|cookie|credential|private[-_]?key|session[-_]?id/i; +const INLINE_SECRET = /((?:pass(?:word|wd)?|secret|token|api[-_]?key|authorization)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi; +const BEARER = /\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi; +const MAX_STRING = 1000; + +/** Mask inline credentials (`token=…`, `Authorization: Bearer …`) in free text. */ +export function redactText(text: string): string { + // Bearer/basic tokens first, so `Authorization: Bearer ` loses the token rather than the scheme word. + return text.replace(BEARER, '$1 [redacted]').replace(INLINE_SECRET, (m, key: string, value: string) => (value === 'Bearer' || value === 'Basic' || value.startsWith('[redacted]') ? 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; @@ -103,6 +139,12 @@ export class TurnApprovals { const next = this.queue[0]; if (!next) return; next.offered = true; - this.offer({ approvalId: next.approvalId, turnSeq: this.turnSeq, action: next.action, hash: next.hash, payload: next.payload }); + this.offer({ + approvalId: next.approvalId, + turnSeq: this.turnSeq, + action: next.action, + hash: next.hash, + payload: redactApprovalPayload(next.payload), + }); } } diff --git a/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts b/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts index 8a1019df5..dabdfd185 100644 --- a/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts +++ b/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts @@ -21,17 +21,9 @@ * 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 type { - ApprovalResponse, - HarnessAdapter, - HarnessCapabilities, - HarnessDescriptor, - HarnessEventEnvelope, - HarnessSubmission, - LeaseEpoch, -} from '../types.js'; -import { partsToText } from '../types.js'; -import { TurnApprovals, canonicalApprovalHash } from './approval-queue.js'; +import type { HarnessCapabilities } from '../types.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'; @@ -108,17 +100,15 @@ export function claudeApprovalHash(toolName: string, input: unknown): string { return canonicalApprovalHash({ tool: toolName, input }); } -interface Run { - conversationId: string; - runId: string; - seq: number; - epoch?: LeaseEpoch; - abort: AbortController; - approvals: TurnApprovals; - done: Promise; +/** 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 implements HarnessAdapter { +export class ClaudeAgentSdkHarness extends TurnHarness { readonly id = CLAUDE_AGENT_SDK_HARNESS_ID; readonly capabilities: HarnessCapabilities = { streaming: true, @@ -127,83 +117,60 @@ export class ClaudeAgentSdkHarness implements HarnessAdapter { tools: true, extensionNamespaces: ['x-claude'], }; + protected readonly displayName = 'Claude Agent SDK'; - private readonly listeners = new Map void>>(); - private readonly seen = new Set(); - private readonly runs = new Map(); /** Claude session per conversation, resumed by later turns. */ private readonly sessions = new Map(); - /** Turns of one conversation run in order. */ - private readonly tails = new Map>(); private sdk?: Promise; - constructor(private readonly options: ClaudeAgentSdkHarnessOptions) {} - - describe(): HarnessDescriptor { - return { id: this.id, displayName: 'Claude Agent SDK', capabilities: this.capabilities }; + constructor(private readonly options: ClaudeAgentSdkHarnessOptions) { + super(); } - 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); - }; - } - - async submit(submission: HarnessSubmission): Promise { - const key = submission.operationId ?? `${submission.conversationId}:${submission.runId}:${submission.seq}`; - if (this.seen.has(key)) return; // at-least-once delivery: a retry of an accepted submission is a no-op - // Fail the delivery (so the broker retries) only when the SDK can't be loaded at all. + protected async init(): Promise { await this.loadSdk(); - this.seen.add(key); - const run: Run = { - conversationId: submission.conversationId, - runId: submission.runId, - seq: submission.seq, - epoch: submission.epoch, - abort: new AbortController(), - approvals: undefined as unknown as TurnApprovals, - done: Promise.resolve(), - }; - run.approvals = new TurnApprovals( - run.seq, - (request) => this.emit(run, { type: 'approval-requested', request }), - () => this.emit(run, { type: 'state', state: 'running' }), - () => run.abort.abort(), - ); - this.runs.set(this.runKey(run.conversationId, run.runId, run.seq), run); - const prompt = submission.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)); - this.tails.set(run.conversationId, run.done.catch(() => undefined)); } - async cancel(conversationId: string, runId: string, seq: number): Promise { - const run = this.runs.get(this.runKey(conversationId, runId, seq)); - if (!run || run.abort.signal.aborted) return; // idempotent - run.approvals.close('Turn cancelled'); - run.abort.abort(); - } - - async respondApproval(_conversationId: string, response: ApprovalResponse): Promise { - for (const run of this.runs.values()) if (run.approvals.respond(response)) return; - // Unknown or already decided: a redelivered decision is a no-op. - } - - /** Waits for a submitted turn to finish (tests and orderly shutdown). */ - async settled(conversationId: string, runId: string, seq: number): Promise { - await this.runs.get(this.runKey(conversationId, runId, seq))?.done.catch(() => undefined); - } - - // ── internals ───────────────────────────────────────────────────────────────────────────────── - - private runKey(conversationId: string, runId: string, seq: number): string { - return `${conversationId}\u0000${runId}\u0000${seq}`; + /** 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.sessions.get(run.conversationId); + const stream = sdk.query({ + prompt, + options: { + cwd: this.options.cwd, + permissionMode: 'default', + settingSources: [], + 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.sessions.set(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 { @@ -216,74 +183,13 @@ export class ClaudeAgentSdkHarness implements HarnessAdapter { return this.sdk; } - private emit(run: Run, 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); - } - - private canUseTool(run: Run): ClaudeCanUseTool { + 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, - `${toolName}: ${JSON.stringify(input).slice(0, 500)}`, - { tool: toolName, input }, - ); + 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 }; }; } - - private async execute(run: Run, prompt: string): Promise { - if (run.abort.signal.aborted) { - this.emit(run, { type: 'state', state: 'cancelled' }); - return; - } - this.emit(run, { type: 'state', state: 'running' }); - try { - const sdk = await this.loadSdk(); - const resume = this.sessions.get(run.conversationId); - const stream = sdk.query({ - prompt, - options: { - cwd: this.options.cwd, - permissionMode: 'default', - settingSources: [], - 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.sessions.set(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'; - } - } - } - 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' }); - } catch (err) { - if (run.abort.signal.aborted) this.emit(run, { type: 'state', state: 'cancelled' }); - else this.emit(run, { type: 'state', state: 'failed', error: (err instanceof Error ? err.message : String(err)).slice(0, 500) }); - } finally { - run.approvals.close('Turn ended'); - } - } } diff --git a/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts b/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts index c2846e03e..e30ef5de8 100644 --- a/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts +++ b/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts @@ -15,17 +15,9 @@ * 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 { - ApprovalResponse, - HarnessAdapter, - HarnessCapabilities, - HarnessDescriptor, - HarnessEventEnvelope, - HarnessSubmission, - LeaseEpoch, -} from '../types.js'; -import { partsToText } from '../types.js'; -import { TurnApprovals } from './approval-queue.js'; +import type { HarnessCapabilities } from '../types.js'; +import { summarizeAction } from './approval-queue.js'; +import { TurnHarness, type TurnRun } from './turn-harness.js'; export const COPILOT_SDK_HARNESS_ID = 'copilot-sdk'; @@ -72,25 +64,12 @@ export interface CopilotSdkHarnessOptions { createClient?: () => Promise; } -interface Run { - conversationId: string; - runId: string; - seq: number; - epoch?: LeaseEpoch; - cancelled: boolean; - approvals: TurnApprovals; - done: Promise; - /** Ends the wait for `session.idle` (cancel doesn't rely on the runtime sending one after abort). */ - wake?: () => void; +/** 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']; } -/** A short, readable description of a permission request for the broker's approval record. */ -function describeRequest(r: CopilotPermissionRequest): string { - const detail = r['fullCommandText'] ?? r['fileName'] ?? r['path'] ?? r['url'] ?? r['toolName'] ?? ''; - return `${r.kind}${detail ? `: ${String(detail).slice(0, 400)}` : ''}`; -} - -export class CopilotSdkHarness implements HarnessAdapter { +export class CopilotSdkHarness extends TurnHarness { readonly id = COPILOT_SDK_HARNESS_ID; readonly capabilities: HarnessCapabilities = { streaming: true, @@ -99,83 +78,60 @@ export class CopilotSdkHarness implements HarnessAdapter { tools: true, extensionNamespaces: ['x-copilot'], }; + protected readonly displayName = 'GitHub Copilot SDK'; - private readonly listeners = new Map void>>(); - private readonly seen = new Set(); - private readonly runs = new Map(); /** The live session per conversation, and the session id to resume when the object is gone. */ private readonly sessions = new Map>(); private readonly sessionIds = 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 readonly tails = new Map>(); + private readonly active = new Map(); private client?: Promise; - constructor(private readonly options: CopilotSdkHarnessOptions) {} - - describe(): HarnessDescriptor { - return { id: this.id, displayName: 'GitHub Copilot SDK', 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); - }; + constructor(private readonly options: CopilotSdkHarnessOptions) { + super(); } - async submit(submission: HarnessSubmission): Promise { - const key = submission.operationId ?? `${submission.conversationId}:${submission.runId}:${submission.seq}`; - if (this.seen.has(key)) return; - await this.getClient(); // a client that can't start fails the delivery, so the broker retries - this.seen.add(key); - const run: Run = { - conversationId: submission.conversationId, - runId: submission.runId, - seq: submission.seq, - epoch: submission.epoch, - cancelled: false, - approvals: undefined as unknown as TurnApprovals, - done: Promise.resolve(), - }; - run.approvals = new TurnApprovals( - run.seq, - (request) => this.emit(run, { type: 'approval-requested', request }), - () => this.emit(run, { type: 'state', state: 'running' }), - () => void this.stop(run), - ); - this.runs.set(this.runKey(run.conversationId, run.runId, run.seq), run); - const prompt = submission.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)); - this.tails.set(run.conversationId, run.done.catch(() => undefined)); + protected async init(): Promise { + await this.getClient(); } - async cancel(conversationId: string, runId: string, seq: number): Promise { - const run = this.runs.get(this.runKey(conversationId, runId, seq)); - if (!run || run.cancelled) return; - await this.stop(run); + /** 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(); } - async respondApproval(_conversationId: string, response: ApprovalResponse): Promise { - for (const run of this.runs.values()) if (run.approvals.respond(response)) return; - // Unknown or already decided: a redelivered decision is a no-op. - } - - /** Waits for a submitted turn to finish (tests and orderly shutdown). */ - async settled(conversationId: string, runId: string, seq: number): Promise { - await this.runs.get(this.runKey(conversationId, runId, seq))?.done.catch(() => undefined); - } - - // ── internals ───────────────────────────────────────────────────────────────────────────────── - - private runKey(conversationId: string, runId: string, seq: number): string { - return `${conversationId}\u0000${runId}\u0000${seq}`; + protected async runTurn(run: TurnRun, prompt: string): Promise { + this.active.set(run.conversationId, run); + let off: (() => void) | undefined; + try { + const session = await this.session(run.conversationId); + this.checkpoint(run); // a cancel during session creation never reaches send() + const finished = new Promise((resolve) => { + // Cancel ends the wait without relying on an idle event after abort. + if (run.abort.signal.aborted) resolve(undefined); + run.abort.signal.addEventListener('abort', () => resolve(undefined), { once: true }); + 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 (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') { + resolve(undefined); + } else if (event.type === 'session.error') { + resolve(event.data?.message || event.data?.errorType || 'Copilot session error'); + } + }); + }); + await session.send(prompt); + return await finished; + } finally { + off?.(); + if (this.active.get(run.conversationId) === run) this.active.delete(run.conversationId); + } } private getClient(): Promise { @@ -200,7 +156,7 @@ export class CopilotSdkHarness implements HarnessAdapter { 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(toolCallId ?? `copilot-${randomUUID()}`, describeRequest(request), { kind, ...detail }); + const outcome = await run.approvals.request(toolCallId ?? `copilot-${randomUUID()}`, summarizeAction(kind, requestDetail(request)), { kind, ...detail }); return outcome.approved ? { kind: 'approve-once' } : { kind: 'reject', feedback: outcome.reason }; }, }; @@ -216,6 +172,7 @@ export class CopilotSdkHarness implements HarnessAdapter { ? await client.resumeSession(id, this.sessionConfig(conversationId)) : await client.createSession(this.sessionConfig(conversationId)); this.sessionIds.set(conversationId, session.sessionId); + this.ready.set(conversationId, session); return session; })(); this.sessions.set(conversationId, s); @@ -223,56 +180,4 @@ export class CopilotSdkHarness implements HarnessAdapter { } return s; } - - private emit(run: Run, 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); - } - - private async stop(run: Run): Promise { - run.cancelled = true; - run.approvals.close('Turn cancelled'); - run.wake?.(); - if (this.active.get(run.conversationId) === run) { - const session = await this.sessions.get(run.conversationId)?.catch(() => undefined); - await session?.abort().catch(() => undefined); - } - } - - private async execute(run: Run, prompt: string): Promise { - if (run.cancelled) { - this.emit(run, { type: 'state', state: 'cancelled' }); - return; - } - this.emit(run, { type: 'state', state: 'running' }); - this.active.set(run.conversationId, run); - let off: (() => void) | undefined; - try { - const session = await this.session(run.conversationId); - const finished = new Promise((resolve) => { - run.wake = () => resolve(undefined); - off = session.on((event) => { - if (event.type === 'assistant.message' && !event.agentId && !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') { - resolve(undefined); - } else if (event.type === 'session.error') { - resolve(event.data?.message || event.data?.errorType || 'Copilot session error'); - } - }); - }); - await session.send(prompt); - const failure = await finished; - if (run.cancelled) 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' }); - } catch (err) { - if (run.cancelled) this.emit(run, { type: 'state', state: 'cancelled' }); - else this.emit(run, { type: 'state', state: 'failed', error: (err instanceof Error ? err.message : String(err)).slice(0, 500) }); - } finally { - off?.(); - run.approvals.close('Turn ended'); - if (this.active.get(run.conversationId) === run) this.active.delete(run.conversationId); - } - } } 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..fbc335513 --- /dev/null +++ b/packages/squad-sdk/src/backend/harnesses/turn-harness.ts @@ -0,0 +1,197 @@ +/** + * 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}. + */ +import type { + ApprovalResponse, + HarnessAdapter, + HarnessCapabilities, + HarnessDescriptor, + HarnessEventEnvelope, + HarnessSubmission, + LeaseEpoch, +} from '../types.js'; +import { partsToText } from '../types.js'; +import { TurnApprovals } from './approval-queue.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; + 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>(); + + /** 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; + + 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 + 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.schedule(run, submission); + })(); + this.accepted.set(key, acceptance); + 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). */ + async settled(conversationId: string, runId: string, seq: number): Promise { + await this.runs.get(this.runKey(conversationId, runId, seq))?.done.catch(() => undefined); + } + + 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) return; + run.abort.abort(); + run.approvals.close('Turn cancelled'); + run.quiesce = this.interrupt(run).catch(() => undefined); + } + + 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)); + this.tails.set(run.conversationId, run.done.catch(() => undefined)); + } + + 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 43d209f15..20650cc51 100644 --- a/packages/squad-sdk/src/backend/index.ts +++ b/packages/squad-sdk/src/backend/index.ts @@ -20,7 +20,9 @@ export { DurableHttpSquadBackend, validateBaseUrl } from './durable-http-backend export { ClaudeAgentSdkHarness, CLAUDE_AGENT_SDK_HARNESS_ID, claudeApprovalHash, isClaudeAgentSdkAvailable } from './harnesses/claude-agent-sdk.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 } from './harnesses/approval-queue.js'; +export { TurnApprovals, canonicalApprovalHash, redactApprovalPayload, 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/test/copilot-sdk-harness.test.ts b/test/copilot-sdk-harness.test.ts index d60ece221..0c2c349a3 100644 --- a/test/copilot-sdk-harness.test.ts +++ b/test/copilot-sdk-harness.test.ts @@ -168,3 +168,91 @@ describe.skipIf(process.env['SQUAD_REAL_CLI_TESTS'] !== '1')('copilot-sdk harnes expect((await backend.getTranscript('live')).at(-1)?.content.toLowerCase()).toContain('pong'); }, 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); return 'm'; }, + 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 index ed4e32142..76a775e16 100644 --- a/test/harness-contract.test.ts +++ b/test/harness-contract.test.ts @@ -1,7 +1,7 @@ /** * Runs the shared HarnessAdapter contract against every harness in the repository. */ -import { harnessContract } from './harness-contract.js'; +import { harnessContract, type ContractScript } from './harness-contract.js'; import { ClaudeAgentSdkHarness, CopilotSdkHarness, @@ -11,10 +11,22 @@ import { 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: { reply: string; tools?: Array<{ name: string; input: Record }> }): ClaudeAgentSdkModule { +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( @@ -30,7 +42,14 @@ function scriptedClaudeSdk(script: { reply: string; tools?: Array<{ name: string } harnessContract('claude-agent-sdk', { - create: (script) => new ClaudeAgentSdkHarness({ cwd: process.cwd(), loadSdk: async () => scriptedClaudeSdk(script) }), + 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), }); @@ -38,11 +57,11 @@ harnessContract('claude-agent-sdk', { * 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: { reply: string; tools?: Array<{ name: string; input: Record }> }): CopilotClientLike & { sessions: number; resumed: string[] } { +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() {}, + async start() { await start(); }, async resumeSession(id: string, config: Parameters[0]) { client.resumed.push(id); return client.createSession(config, id); @@ -56,9 +75,10 @@ export function scriptedCopilotClient(script: { reply: string; tools?: Array<{ n on(h: (e: CopilotSessionEvent) => void) { handlers.add(h); return () => handlers.delete(h); }, async abort() { aborted = true; }, async send() { + count.n++; void (async () => { const results = await Promise.all((script.tools ?? []).map((t, i) => - config.onPermissionRequest({ kind: 'shell', toolCallId: `call_${i}`, fullCommandText: String(t.input['command'] ?? t.name) }, { sessionId: id }))); + 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' }); @@ -72,6 +92,13 @@ export function scriptedCopilotClient(script: { reply: string; tools?: Array<{ n } harnessContract('copilot-sdk', { - create: (script) => new CopilotSdkHarness({ cwd: process.cwd(), createClient: async () => scriptedCopilotClient(script) }), + 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), }); diff --git a/test/harness-contract.ts b/test/harness-contract.ts index 48ffeb0e1..fa7263554 100644 --- a/test/harness-contract.ts +++ b/test/harness-contract.ts @@ -15,9 +15,24 @@ import { 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. */ - create(script: { reply: string; tools?: ContractToolCall[] }): HarnessAdapter; + /** + * 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; } @@ -31,7 +46,7 @@ async function until(check: () => boolean | Promise, ms = 3000): Promis } export function harnessContract(name: string, driver: HarnessContractDriver): void { - async function setup(script: { reply: string; tools?: ContractToolCall[] }) { + async function setup(script: ContractScript) { const harness = driver.create(script); const harnesses = new DefaultHarnessRegistry(); harnesses.register(harness); @@ -39,9 +54,9 @@ export function harnessContract(name: string, driver: HarnessContractDriver): vo await backend.createConversation({ conversationId: 'k' }); const events: HarnessEventEnvelope[] = []; harness.subscribe('k', (e) => events.push(e)); - const send = (runId: string, seq: number) => - backend.sendMessage('k', { runId, seq, role: 'user', content: `task ${seq}`, harness: harness.id }); - const settled = (runId: string, seq: number) => driver.settled(harness, 'k', runId, seq); + 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 }; } @@ -112,5 +127,71 @@ export function harnessContract(name: string, driver: HarnessContractDriver): vo 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 a redacted approval while the hash still 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'); + const recorded = JSON.stringify({ action: a!.action, payload: a!.payload }); + 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'); + }); }); -} +} \ No newline at end of file From 70c5d5284f40853050491073b3f8c0ef85b6ed27 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 9 Nov 2026 19:07:54 -0800 Subject: [PATCH 06/12] fix(backend): redact header- and env-style credentials in approval records Code scanning review on #2451. Inline credential names now allow prefix segments (X-, GITHUB_, AWS_SECRET_) and cover cookie, access-key and auth-token names, so Cookie headers and AWS_SECRET_ACCESS_KEY are masked too (X-API-Key and X-Auth-Token already were). Values stop at quotes and &, so the surrounding command or URL stays intact. Look-alikes such as --author=, tokenizer.py and mkdir -p are left alone. Tests cover both. Refs #2441 Co-Authored-By: Claude Opus 5.5 --- .../src/backend/harnesses/approval-queue.ts | 12 ++++++++-- packages/squad-sdk/src/backend/index.ts | 2 +- test/harness-contract.test.ts | 22 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/squad-sdk/src/backend/harnesses/approval-queue.ts b/packages/squad-sdk/src/backend/harnesses/approval-queue.ts index 8e0287286..c71d46441 100644 --- a/packages/squad-sdk/src/backend/harnesses/approval-queue.ts +++ b/packages/squad-sdk/src/backend/harnesses/approval-queue.ts @@ -26,8 +26,16 @@ export function canonicalApprovalHash(payload: unknown): string { return `sha256:${createHash('sha256').update(JSON.stringify(canonical(payload))).digest('hex')}`; } -const SENSITIVE_KEY = /pass(word|wd)?|secret|token|api[-_]?key|auth(orization)?|cookie|credential|private[-_]?key|session[-_]?id/i; -const INLINE_SECRET = /((?:pass(?:word|wd)?|secret|token|api[-_]?key|authorization)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi; +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)'; +/** `name: value` / `name=value`; the value stops at whitespace, quotes and `&`, so surrounding syntax survives. */ +const INLINE_SECRET = new RegExp(`(\\b${SECRET_NAME}\\s*[=:]\\s*)("[^"]*"|'[^']*'|[^\\s"'&]+)`, 'gi'); const BEARER = /\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi; const MAX_STRING = 1000; diff --git a/packages/squad-sdk/src/backend/index.ts b/packages/squad-sdk/src/backend/index.ts index 20650cc51..97b80c3a4 100644 --- a/packages/squad-sdk/src/backend/index.ts +++ b/packages/squad-sdk/src/backend/index.ts @@ -20,7 +20,7 @@ export { DurableHttpSquadBackend, validateBaseUrl } from './durable-http-backend export { ClaudeAgentSdkHarness, CLAUDE_AGENT_SDK_HARNESS_ID, claudeApprovalHash, isClaudeAgentSdkAvailable } from './harnesses/claude-agent-sdk.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, summarizeAction } from './harnesses/approval-queue.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'; diff --git a/test/harness-contract.test.ts b/test/harness-contract.test.ts index 76a775e16..db186be66 100644 --- a/test/harness-contract.test.ts +++ b/test/harness-contract.test.ts @@ -1,6 +1,7 @@ /** * 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, @@ -102,3 +103,24 @@ harnessContract('copilot-sdk', { 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('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); + } + }); +}); From 181f83f0037ee83d4d504dfd0754d3082b73cb95 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 9 Nov 2026 19:43:11 -0800 Subject: [PATCH 07/12] fix(backend): mask bare tokens and URL passwords in approval records Code scanning review on #2451. redactText now also masks secrets with no name in front of them: well-known vendor token formats (GitHub, OpenAI and Anthropic, Slack, AWS, Google, GitLab, npm), JWTs, long opaque mixed-case base64-like tokens, and the password in scheme://user:pass@host. Plain hex (git SHAs, checksums), UUIDs, paths and package specs stay readable on purpose, since the approver has to see what they're approving. Tests cover both sides. Refs #2441 Co-Authored-By: Claude Opus 5.5 --- .../src/backend/harnesses/approval-queue.ts | 32 +++++++++++++++++-- test/harness-contract.test.ts | 23 +++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/squad-sdk/src/backend/harnesses/approval-queue.ts b/packages/squad-sdk/src/backend/harnesses/approval-queue.ts index c71d46441..efd0c50b5 100644 --- a/packages/squad-sdk/src/backend/harnesses/approval-queue.ts +++ b/packages/squad-sdk/src/backend/harnesses/approval-queue.ts @@ -37,12 +37,40 @@ const SECRET_NAME = /** `name: value` / `name=value`; the value stops at whitespace, quotes and `&`, so surrounding syntax survives. */ const INLINE_SECRET = new RegExp(`(\\b${SECRET_NAME}\\s*[=:]\\s*)("[^"]*"|'[^']*'|[^\\s"'&]+)`, '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) => `(?` loses the token rather than the scheme word. - return text.replace(BEARER, '$1 [redacted]').replace(INLINE_SECRET, (m, key: string, value: string) => (value === 'Bearer' || value === 'Basic' || value.startsWith('[redacted]') ? m : `${key}[redacted]`)); + return text + .replace(URL_PASSWORD, '$1[redacted]@') + .replace(BARE_TOKEN, '[redacted]') + .replace(OPAQUE, '[redacted]') + .replace(BEARER, '$1 [redacted]') + .replace(INLINE_SECRET, (m, key: string, value: string) => (value === 'Bearer' || value === 'Basic' || value.startsWith('[redacted]') ? m : `${key}[redacted]`)); } /** A copy of an approval payload safe to record: secret-looking keys masked, strings truncated, depth bounded. */ diff --git a/test/harness-contract.test.ts b/test/harness-contract.test.ts index db186be66..989b7f3cd 100644 --- a/test/harness-contract.test.ts +++ b/test/harness-contract.test.ts @@ -117,6 +117,29 @@ describe('approval record redaction (TurnApprovals)', () => { 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']) { From 047a949bc90a4877324c8f39c9beaba3d67ac7c7 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 9 Nov 2026 20:03:01 -0800 Subject: [PATCH 08/12] fix(backend): drop approval payloads, isolate Copilot config, bound memory - Approval records carry only the id, a redacted summary and the hash; the tool input is never sent as `payload` (nothing read it), so secrets in tool arguments stay out of backend state regardless of redaction coverage. The hash still binds the decision to the full input. - copilot-sdk: optional `configDirectory` for harness sessions. The Copilot runtime can keep "approve permanently" decisions (URL prompts) in its config directory, so a session sharing the user's default could skip the broker for a URL approved in their own CLI. Default unchanged. - TurnHarness drops finished turns and idle conversation tails, and keeps the most recent 10,000 accepted delivery ids, so long-running bridges don't grow without bound; late redelivery of a finished turn stays a no-op. Refs #2441 Co-Authored-By: Claude Opus 5.5 --- .changeset/claude-agent-sdk-harness.md | 2 +- .../client-harness-adapters-v0.2.md | 12 +++++++---- .../src/backend/harnesses/approval-queue.ts | 16 ++++++-------- .../src/backend/harnesses/copilot-sdk.ts | 9 ++++++++ .../src/backend/harnesses/turn-harness.ts | 21 +++++++++++++++++-- test/copilot-sdk-harness.test.ts | 21 +++++++++++++++++++ test/harness-contract.ts | 18 ++++++++++++++-- 7 files changed, 80 insertions(+), 19 deletions(-) diff --git a/.changeset/claude-agent-sdk-harness.md b/.changeset/claude-agent-sdk-harness.md index 89ed9c16a..2ed7131f8 100644 --- a/.changeset/claude-agent-sdk-harness.md +++ b/.changeset/claude-agent-sdk-harness.md @@ -3,4 +3,4 @@ "@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, redacted approval records) and a shared turn lifecycle (`TurnHarness`: atomic acceptance, cancellation that survives startup, the next turn waiting for an interrupt to finish). +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). diff --git a/docs/specification/client-harness-adapters-v0.2.md b/docs/specification/client-harness-adapters-v0.2.md index 72cd0f144..584ccf353 100644 --- a/docs/specification/client-harness-adapters-v0.2.md +++ b/docs/specification/client-harness-adapters-v0.2.md @@ -234,9 +234,9 @@ 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 a short action -summary and a redacted payload (secret-looking keys and inline credentials -masked, long strings truncated); the hash still covers the full action. +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. Both also share one turn lifecycle (`TurnHarness`). Concurrent deliveries of an operation share one acceptance, and a failed start forgets it so the retry @@ -264,7 +264,11 @@ followed by `session.idle` (completed) or `session.error` (failed); only main-agent `assistant.message` content enters the transcript. `onPermissionRequest` goes through the same queue, with `toolCallId` as the `approvalId`; approve answers `approve-once`, and deny answers `reject` and -aborts the turn. Because `@github/copilot-sdk` is always installed, the MCP +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. Because `@github/copilot-sdk` is always installed, the MCP bridge registers this harness only when `SQUAD_BACKEND_HARNESSES` lists `copilot-sdk`. diff --git a/packages/squad-sdk/src/backend/harnesses/approval-queue.ts b/packages/squad-sdk/src/backend/harnesses/approval-queue.ts index efd0c50b5..c682474f2 100644 --- a/packages/squad-sdk/src/backend/harnesses/approval-queue.ts +++ b/packages/squad-sdk/src/backend/harnesses/approval-queue.ts @@ -6,9 +6,10 @@ * 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 minimized: the payload is redacted (secret-looking keys and - * inline credentials masked, long strings truncated) and the action is a short summary. The hash is still taken - * over the full, unredacted action, so a decision stays bound to exactly what will run. + * 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'; @@ -175,12 +176,7 @@ export class TurnApprovals { const next = this.queue[0]; if (!next) return; next.offered = true; - this.offer({ - approvalId: next.approvalId, - turnSeq: this.turnSeq, - action: next.action, - hash: next.hash, - payload: redactApprovalPayload(next.payload), - }); + // 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/copilot-sdk.ts b/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts index e30ef5de8..a4f186670 100644 --- a/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts +++ b/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts @@ -47,6 +47,7 @@ export interface CopilotSessionLike { export interface CopilotSessionConfig { workingDirectory?: string; model?: string; + configDirectory?: string; onPermissionRequest: (request: CopilotPermissionRequest, invocation: { sessionId: string }) => Promise; } @@ -60,6 +61,13 @@ 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; /** Builds the client. Defaults to `new CopilotClient({ workingDirectory: cwd })` from `@github/copilot-sdk`. */ createClient?: () => Promise; } @@ -152,6 +160,7 @@ export class CopilotSdkHarness extends TurnHarness { 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' }; diff --git a/packages/squad-sdk/src/backend/harnesses/turn-harness.ts b/packages/squad-sdk/src/backend/harnesses/turn-harness.ts index fbc335513..45e91cab5 100644 --- a/packages/squad-sdk/src/backend/harnesses/turn-harness.ts +++ b/packages/squad-sdk/src/backend/harnesses/turn-harness.ts @@ -11,7 +11,13 @@ * 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}. + * - **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, @@ -93,6 +99,7 @@ export abstract class TurnHarness implements HarnessAdapter { 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; } @@ -112,11 +119,16 @@ export abstract class TurnHarness implements HarnessAdapter { // 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). */ + /** 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); @@ -167,7 +179,12 @@ export abstract class TurnHarness implements HarnessAdapter { .join('\n\n'); const previous = this.tails.get(run.conversationId) ?? Promise.resolve(); run.done = previous.then(() => this.execute(run, prompt)); - this.tails.set(run.conversationId, run.done.catch(() => undefined)); + 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 { diff --git a/test/copilot-sdk-harness.test.ts b/test/copilot-sdk-harness.test.ts index 0c2c349a3..e94b79987 100644 --- a/test/copilot-sdk-harness.test.ts +++ b/test/copilot-sdk-harness.test.ts @@ -150,6 +150,27 @@ describe('copilot-sdk harness', () => { 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 idle: (e: CopilotSessionEvent) => void = () => undefined; + return { sessionId: 's', on(h) { idle = h; return () => undefined; }, async abort() {}, async send() { setTimeout(() => idle({ 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'] } }); }); diff --git a/test/harness-contract.ts b/test/harness-contract.ts index fa7263554..18de83ede 100644 --- a/test/harness-contract.ts +++ b/test/harness-contract.ts @@ -180,18 +180,32 @@ export function harnessContract(name: string, driver: HarnessContractDriver): vo await settled('r1', 1); }); - it('records a redacted approval while the hash still binds the full action', async () => { + 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'); - const recorded = JSON.stringify({ action: a!.action, payload: a!.payload }); + 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 From c3d411a17029b028abb7eea9c682c1be0b71d506 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 9 Nov 2026 20:19:46 -0800 Subject: [PATCH 09/12] test(backend): live-verify the claude-agent-sdk harness; record it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The live approval test asks for a write (`touch` in a temp directory), since Claude Code runs some read-only commands such as `echo` without calling canUseTool. It expects the broker deny to end the turn cancelled, checks the file was not created, and prints the turn, approvals and harness events on failure. - Both Claude live tests passed 2026-11-10 (Agent SDK 0.3.317, Anthropic API key): a plain turn through the broker, and a write that reached the broker, was denied and did not run. - Matrix: liveRuns claude / claude-agent-sdk-harness, and experimental-backend.claude -> validated-live. Spec §5 notes that read-only commands can bypass canUseTool. Refs #2441 Co-Authored-By: Claude Opus 5.5 --- .../client-harness-adapters-v0.2.md | 5 ++- .../docs/reference/host-feature-matrix.md | 9 ++-- .../spec/suite-v0.2/host-feature-matrix.json | 20 +++++++-- test/claude-agent-sdk-harness.test.ts | 43 +++++++++++++++---- 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/docs/specification/client-harness-adapters-v0.2.md b/docs/specification/client-harness-adapters-v0.2.md index 584ccf353..9308ae649 100644 --- a/docs/specification/client-harness-adapters-v0.2.md +++ b/docs/specification/client-harness-adapters-v0.2.md @@ -253,7 +253,10 @@ 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 SDK is an optional peer dependency; `squad state-mcp +broker never sees. 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 (`SQUAD_BACKEND_HARNESSES=none` turns it off), and otherwise refuses `squad_backend_send_message`. diff --git a/docs/src/content/docs/reference/host-feature-matrix.md b/docs/src/content/docs/reference/host-feature-matrix.md index 0bf7a5f4c..65a884d2d 100644 --- a/docs/src/content/docs/reference/host-feature-matrix.md +++ b/docs/src/content/docs/reference/host-feature-matrix.md @@ -68,7 +68,7 @@ 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 | 1 | 48 | 2 | 0 | 0 | 6 | -| `claude` | 43 | 0 | 5 | 3 | 18 | 30 | 3 | 0 | 0 | 5 | +| `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 (reference machine, 2026-10-22) and for the Copilot `copilot-sdk` execution harness (2026-11-09, experimental backend only). 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 @@ -972,7 +972,7 @@ Depends on: **c** · docs: [concepts/squad-2-0-architecture](/squad/docs/concept | Host | Capability | Validation | Mechanism | Evidence | |------|------------|------------|-----------|----------| | `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: one plain-text turn through the broker on the real Copilot backend (2026-11-09). 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-manual:lab:copilot-sdk-harness@2026-11-09` | -| `claude` | ✅ supported | 🧪 validated-test | 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 harness runs need an Anthropic API key (or Bedrock/Vertex); not yet run._ | `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::*` | +| `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` | @@ -1053,4 +1053,5 @@ Headless real-CLI runs behind the `validated-manual:lab:` evidence ent | 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). | 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 cb83077ad..6eb14777f 100644 --- a/test-fixtures/spec/suite-v0.2/host-feature-matrix.json +++ b/test-fixtures/spec/suite-v0.2/host-feature-matrix.json @@ -4391,16 +4391,18 @@ }, "claude": { "capability": "supported", - "validation": "validated-test", + "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/harness-contract.test.ts::*", - "validated-test:test/claude-agent-sdk-harness.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 harness runs need an Anthropic API key (or Bedrock/Vertex); not yet run." + "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", @@ -4996,6 +4998,16 @@ "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)." } ], "expectedFailures": [ @@ -5098,7 +5110,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 (reference machine, 2026-10-22) and for the Copilot `copilot-sdk` execution harness (2026-11-09, experimental backend only). 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 index d22acad64..05a82d501 100644 --- a/test/claude-agent-sdk-harness.test.ts +++ b/test/claude-agent-sdk-harness.test.ts @@ -244,18 +244,45 @@ describe.skipIf(!live)('claude-agent-sdk harness: live (real Agent SDK)', () => }, 120_000); it('routes a real tool call through broker approval and honours a deny', async () => { - const harness = new ClaudeAgentSdkHarness({ cwd: process.cwd(), maxTurns: 3 }); + 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' }); - await backend.sendMessage('live2', { runId: 'r1', seq: 1, role: 'user', content: 'Use the Bash tool to run: echo squad-live-check', harness: HARNESS }); - await until(async () => (await backend.getApprovals('live2')).some((a) => a.state === 'pending'), 90_000); - const pending = (await backend.getApprovals('live2')).find((a) => a.state === 'pending')!; - expect(pending.action).toMatch(/^Bash: /); - 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(['completed', 'failed']).toContain((await backend.getTurn('live2', 1)).state); + 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); }); From d77a8f892e038a546f6ed02a7abe1a2866bcd49b Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 9 Nov 2026 20:36:35 -0800 Subject: [PATCH 10/12] fix(backend): harness durability, Claude credentials and deny rules, Copilot correlation Copilot review of #2451 (round 4). Durability (HarnessState, `stateDir`): accepted delivery ids and each conversation's runtime session id survive restarts, so a redelivered turn never runs twice and the next turn resumes its session. state-mcp keeps this beside the local backend, or in a per-user cache for configured backends. Lifecycle: a failed interrupt stays retryable and tells the harness to stop reusing the session; dropping an approval the broker already holds ends the turn instead of offering the next one into the occupied slot. Claude: the real SDK starts only with an API credential (API key, auth token, Bedrock, Vertex or Foundry), never a saved claude.ai login, and state-mcp registers it only then. Deny rules from user, project and local settings are carried over as disallowedTools, since settingSources: [] would otherwise drop them. Copilot: a turn reacts only to events after its own user.message (matched to send()'s id), so late events from an aborted turn are ignored; a failed send, failed interrupt or timeout (turnTimeoutMs, default 30 min) drops the cached session; each permission request of one tool call gets its own approval id; state-mcp gives the harness its own Copilot config directory. Redaction: JSON keys, Authorization schemes, credential flags and -u user:password are masked; executable syntax is never masked. Bridge: turns run in the worktree-aware repoRoot. The stdio e2e test pins SQUAD_BACKEND_HARNESSES=none. Refs #2441 Co-Authored-By: Claude Opus 5.5 --- .changeset/claude-agent-sdk-harness.md | 2 +- .../client-harness-adapters-v0.2.md | 37 ++- .../squad-cli/src/cli/core/backend-mcp.ts | 53 ++- .../src/backend/harnesses/approval-queue.ts | 45 ++- .../src/backend/harnesses/claude-agent-sdk.ts | 60 +++- .../src/backend/harnesses/copilot-sdk.ts | 107 ++++-- .../src/backend/harnesses/harness-state.ts | 78 +++++ .../src/backend/harnesses/turn-harness.ts | 23 +- packages/squad-sdk/src/backend/index.ts | 3 +- test/claude-agent-sdk-harness.test.ts | 18 +- test/copilot-sdk-harness.test.ts | 11 +- test/harness-contract.test.ts | 6 +- test/harness-review-round4.test.ts | 306 ++++++++++++++++++ test/squad-20-real-clients-stdio.e2e.test.ts | 2 + 14 files changed, 685 insertions(+), 66 deletions(-) create mode 100644 packages/squad-sdk/src/backend/harnesses/harness-state.ts create mode 100644 test/harness-review-round4.test.ts diff --git a/.changeset/claude-agent-sdk-harness.md b/.changeset/claude-agent-sdk-harness.md index 2ed7131f8..72d425117 100644 --- a/.changeset/claude-agent-sdk-harness.md +++ b/.changeset/claude-agent-sdk-harness.md @@ -3,4 +3,4 @@ "@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). +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-harness-adapters-v0.2.md b/docs/specification/client-harness-adapters-v0.2.md index 9308ae649..7fd366195 100644 --- a/docs/specification/client-harness-adapters-v0.2.md +++ b/docs/specification/client-harness-adapters-v0.2.md @@ -237,6 +237,11 @@ 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 @@ -244,7 +249,12 @@ 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. +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 @@ -253,30 +263,41 @@ 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. Claude Code still runs some read-only commands (such as +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 -(`SQUAD_BACKEND_HARNESSES=none` turns it off), and otherwise refuses +--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. +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`; approve answers `approve-once`, and deny answers `reject` and +`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. Because `@github/copilot-sdk` is always installed, the MCP +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). +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 diff --git a/packages/squad-cli/src/cli/core/backend-mcp.ts b/packages/squad-cli/src/cli/core/backend-mcp.ts index 43c4c8da3..38cc749ee 100644 --- a/packages/squad-cli/src/cli/core/backend-mcp.ts +++ b/packages/squad-cli/src/cli/core/backend-mcp.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; +import os from 'node:os'; import path from 'node:path'; import { resolveSquadState } from '@bradygaster/squad-sdk'; import { @@ -7,6 +9,7 @@ import { ClaudeAgentSdkHarness, CopilotSdkHarness, DefaultHarnessRegistry, + claudeApiCredentialsConfigured, isClaudeAgentSdkAvailable, type HarnessRegistry, createSquadBackend, @@ -144,24 +147,58 @@ export function isExperimentalBackendEnabled( * `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, - claudeSdkAvailable: () => boolean = isClaudeAgentSdkAvailable, + 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; - if (wanted.includes('claude-agent-sdk') && claudeSdkAvailable()) registry.register(new ClaudeAgentSdkHarness({ cwd: repoRoot })); - if (wanted.includes('copilot-sdk')) registry.register(new CopilotSdkHarness({ cwd: repoRoot })); + 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, - harnessesFor: (repoRoot: string) => HarnessRegistry = (root) => createLocalHarnesses(root, env), + 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'] }); @@ -174,8 +211,10 @@ export function createMcpBackend( const state = context.backend.name === 'local' ? undefined : createStateBackendStore(context.backend, { lockAnchor: context.paths.teamSquadDir }); - // The repository root is the parent of the project's squad directory (the team dir in local mode). - const harnesses = harnessesFor(path.dirname(context.paths.projectDir ?? context.paths.teamSquadDir)); + // 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 }); } diff --git a/packages/squad-sdk/src/backend/harnesses/approval-queue.ts b/packages/squad-sdk/src/backend/harnesses/approval-queue.ts index c682474f2..ffb3ed92a 100644 --- a/packages/squad-sdk/src/backend/harnesses/approval-queue.ts +++ b/packages/squad-sdk/src/backend/harnesses/approval-queue.ts @@ -35,8 +35,21 @@ const SENSITIVE_KEY = /pass(word|wd)?|secret|token|api[-_]?key|access[-_]?key|au */ 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)'; -/** `name: value` / `name=value`; the value stops at whitespace, quotes and `&`, so surrounding syntax survives. */ -const INLINE_SECRET = new RegExp(`(\\b${SECRET_NAME}\\s*[=:]\\s*)("[^"]*"|'[^']*'|[^\\s"'&]+)`, 'gi'); +/** + * 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_…`): @@ -63,15 +76,26 @@ const OPAQUE = /(?` loses the token rather than the scheme word. + const keep = (value: string) => 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(INLINE_SECRET, (m, key: string, value: string) => (value === 'Bearer' || value === 'Basic' || value.startsWith('[redacted]') ? m : `${key}[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. */ @@ -162,12 +186,21 @@ export class TurnApprovals { for (const p of this.queue.splice(0)) p.resolve({ approved: false, reason }); } - /** Drop one request whose call was aborted by the runtime itself. */ + /** + * 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(); } diff --git a/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts b/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts index dabdfd185..4cef97b2a 100644 --- a/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts +++ b/packages/squad-sdk/src/backend/harnesses/claude-agent-sdk.ts @@ -15,13 +15,19 @@ * 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'; @@ -49,6 +55,7 @@ export interface ClaudeQueryOptions { canUseTool?: ClaudeCanUseTool; permissionMode?: 'default'; settingSources?: string[]; + disallowedTools?: string[]; env?: Record; systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string }; } @@ -80,12 +87,49 @@ export interface ClaudeAgentSdkHarnessOptions { 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); @@ -119,15 +163,19 @@ export class ClaudeAgentSdkHarness extends TurnHarness { }; protected readonly displayName = 'Claude Agent SDK'; - /** Claude session per conversation, resumed by later turns. */ - private readonly sessions = new Map(); private sdk?: Promise; constructor(private readonly options: ClaudeAgentSdkHarnessOptions) { - super(); + 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(); } @@ -137,13 +185,15 @@ export class ClaudeAgentSdkHarness extends TurnHarness { protected async runTurn(run: TurnRun, prompt: string): Promise { const sdk = await this.loadSdk(); this.checkpoint(run); - const resume = this.sessions.get(run.conversationId); + 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 } : {}), @@ -157,7 +207,7 @@ export class ClaudeAgentSdkHarness extends TurnHarness { }); let failure: string | undefined; for await (const message of stream) { - if (message.session_id) this.sessions.set(run.conversationId, message.session_id); + 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) diff --git a/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts b/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts index a4f186670..1dfc6af1c 100644 --- a/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts +++ b/packages/squad-sdk/src/backend/harnesses/copilot-sdk.ts @@ -8,7 +8,11 @@ * - 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` when present. + * 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. * @@ -17,6 +21,7 @@ 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'; @@ -34,7 +39,7 @@ export type CopilotPermissionResult = { kind: 'approve-once' } | { kind: 'reject export interface CopilotSessionEvent { type: string; agentId?: string; - data?: { content?: string; parentToolCallId?: string; message?: string; errorType?: string }; + data?: { content?: string; parentToolCallId?: string; message?: string; errorType?: string; messageId?: string }; } export interface CopilotSessionLike { @@ -68,6 +73,10 @@ export interface CopilotSdkHarnessOptions { * 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; } @@ -90,7 +99,6 @@ export class CopilotSdkHarness extends TurnHarness { /** The live session per conversation, and the session id to resume when the object is gone. */ private readonly sessions = new Map>(); - private readonly sessionIds = 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). */ @@ -98,7 +106,7 @@ export class CopilotSdkHarness extends TurnHarness { private client?: Promise; constructor(private readonly options: CopilotSdkHarnessOptions) { - super(); + super(new HarnessState(options.stateDir)); } protected async init(): Promise { @@ -112,36 +120,76 @@ export class CopilotSdkHarness extends TurnHarness { 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() - const finished = new Promise((resolve) => { - // Cancel ends the wait without relying on an idle event after abort. - if (run.abort.signal.aborted) resolve(undefined); - run.abort.signal.addEventListener('abort', () => resolve(undefined), { once: true }); - 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 (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') { - resolve(undefined); - } else if (event.type === 'session.error') { - resolve(event.data?.message || event.data?.errorType || 'Copilot session error'); - } - }); + 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(); }); - await session.send(prompt); - return await finished; + 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 @@ -165,22 +213,33 @@ export class CopilotSdkHarness extends TurnHarness { 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(toolCallId ?? `copilot-${randomUUID()}`, summarizeAction(kind, requestDetail(request)), { kind, ...detail }); + 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.sessionIds.get(conversationId); + const id = this.state.session(conversationId); const session = id ? await client.resumeSession(id, this.sessionConfig(conversationId)) : await client.createSession(this.sessionConfig(conversationId)); - this.sessionIds.set(conversationId, session.sessionId); + this.state.setSession(conversationId, session.sessionId); this.ready.set(conversationId, session); return session; })(); 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 index 45e91cab5..36b4881de 100644 --- a/packages/squad-sdk/src/backend/harnesses/turn-harness.ts +++ b/packages/squad-sdk/src/backend/harnesses/turn-harness.ts @@ -11,6 +11,10 @@ * 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. @@ -29,6 +33,7 @@ import type { } 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; @@ -40,6 +45,8 @@ export interface TurnRun { 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; } @@ -61,12 +68,16 @@ export abstract class TurnHarness implements HarnessAdapter { 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 }; @@ -85,6 +96,8 @@ export abstract class TurnHarness implements HarnessAdapter { 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); @@ -96,6 +109,7 @@ export abstract class TurnHarness implements HarnessAdapter { if (!run.abort.signal.aborted) this.runs.delete(runKey); throw err; } + this.state.addAccepted(key); this.schedule(run, submission); })(); this.accepted.set(key, acceptance); @@ -166,10 +180,15 @@ export abstract class TurnHarness implements HarnessAdapter { } private stop(run: TurnRun): void { - if (run.abort.signal.aborted) return; + if (run.abort.signal.aborted && !run.interruptFailed) return; run.abort.abort(); run.approvals.close('Turn cancelled'); - run.quiesce = this.interrupt(run).catch(() => undefined); + 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 { diff --git a/packages/squad-sdk/src/backend/index.ts b/packages/squad-sdk/src/backend/index.ts index 97b80c3a4..6c16e7997 100644 --- a/packages/squad-sdk/src/backend/index.ts +++ b/packages/squad-sdk/src/backend/index.ts @@ -17,7 +17,8 @@ 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, isClaudeAgentSdkAvailable } from './harnesses/claude-agent-sdk.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'; diff --git a/test/claude-agent-sdk-harness.test.ts b/test/claude-agent-sdk-harness.test.ts index 05a82d501..ad3158c37 100644 --- a/test/claude-agent-sdk-harness.test.ts +++ b/test/claude-agent-sdk-harness.test.ts @@ -289,13 +289,17 @@ describe.skipIf(!live)('claude-agent-sdk harness: live (real Agent SDK)', () => 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'); - expect(createLocalHarnesses('/repo', {}, () => true).list().map((h) => h.id)).toEqual([HARNESS]); - expect(createLocalHarnesses('/repo', {}, () => false).list()).toEqual([]); - expect(createLocalHarnesses('/repo', { SQUAD_BACKEND_HARNESSES: 'none' }, () => true).list()).toEqual([]); - expect(createLocalHarnesses('/repo', { SQUAD_BACKEND_HARNESSES: 'other' }, () => true).list()).toEqual([]); - expect(createLocalHarnesses('/repo', { SQUAD_BACKEND_HARNESSES: 'copilot-sdk' }, () => true).list().map((h) => h.id)).toEqual(['copilot-sdk']); - expect(createLocalHarnesses('/repo', { SQUAD_BACKEND_HARNESSES: 'copilot-sdk, claude-agent-sdk' }, () => true).list().map((h) => h.id)).toEqual([HARNESS, 'copilot-sdk']); - expect(createLocalHarnesses('/repo', {}, () => false).list()).toEqual([]); // copilot-sdk is never a default + 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 () => { diff --git a/test/copilot-sdk-harness.test.ts b/test/copilot-sdk-harness.test.ts index e94b79987..2e40f4a45 100644 --- a/test/copilot-sdk-harness.test.ts +++ b/test/copilot-sdk-harness.test.ts @@ -42,15 +42,18 @@ function client(turn: (ctx: { 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 'msg'; + return messageId; }, }; } + let sent = 0; return { client: c, log }; } @@ -159,8 +162,8 @@ describe('copilot-sdk harness', () => { async resumeSession(_id, config) { return this.createSession(config); }, async createSession(config) { seen.push(config.configDirectory); - let idle: (e: CopilotSessionEvent) => void = () => undefined; - return { sessionId: 's', on(h) { idle = h; return () => undefined; }, async abort() {}, async send() { setTimeout(() => idle({ type: 'session.idle' }), 0); return 'm'; } }; + 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'; } }; }, }), }); @@ -209,7 +212,7 @@ describe('copilot-sdk harness: review regressions (#2451)', () => { return { sessionId: 's-1', on(h: (e: CopilotSessionEvent) => void) { handlers.add(h); return () => handlers.delete(h); }, - async send(prompt: string) { sends.push(prompt); return 'm'; }, + 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)); }, }; }, diff --git a/test/harness-contract.test.ts b/test/harness-contract.test.ts index 989b7f3cd..a04554ec3 100644 --- a/test/harness-contract.test.ts +++ b/test/harness-contract.test.ts @@ -77,14 +77,18 @@ export function scriptedCopilotClient(script: ContractScript, count: { n: number 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 'message-id'; + return messageId; }, }; }, 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-real-clients-stdio.e2e.test.ts b/test/squad-20-real-clients-stdio.e2e.test.ts index 596669ad0..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)); From 1f32bbdf1ea665c6e3bcc87e1b85dd5bca2693c3 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 9 Nov 2026 20:39:33 -0800 Subject: [PATCH 11/12] ci(release): explain the env-gated live harness tests in test reconciliation "Verify exact SHA and build evidence" failed on #2451: the three live harness tests (claude-agent-sdk x2, copilot-sdk x1) skip without SQUAD_REAL_CLI_TESTS=1 and credentials, and had no reviewed rule, so they were unexplained. Add an env-gated rule scoped to the two harness test files and their "live (real ... SDK)" describe blocks only. Refs #2441 Co-Authored-By: Claude Opus 5.5 --- scripts/release/test-reconciliation.mjs | 1 + 1 file changed, 1 insertion(+) 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' }, { From 6cdea5dde5c56435dd71d524be9b2c3ea3602bb4 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 9 Nov 2026 20:41:26 -0800 Subject: [PATCH 12/12] test(backend): live-verify copilot-sdk after event correlation; isolated config dir Two live Copilot runs on 2026-11-10 (@github/copilot-sdk 1.0.19, signed-in user), both passing: a plain turn through the broker (5.7 s), confirming the new correlation to the turn's own user.message, and the same turn with a harness-only configDirectory in an empty temp dir (3.2 s), confirming sign-in still works there. Live turns now use a 2-minute timeout so a correlation miss fails fast. Recorded in the host feature matrix. Refs #2441 Co-Authored-By: Claude Opus 5.5 --- .../docs/reference/host-feature-matrix.md | 3 ++- .../spec/suite-v0.2/host-feature-matrix.json | 13 ++++++++-- test/copilot-sdk-harness.test.ts | 25 ++++++++++++++++--- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/reference/host-feature-matrix.md b/docs/src/content/docs/reference/host-feature-matrix.md index 65a884d2d..a1ccb3064 100644 --- a/docs/src/content/docs/reference/host-feature-matrix.md +++ b/docs/src/content/docs/reference/host-feature-matrix.md @@ -971,7 +971,7 @@ Depends on: **c** · docs: [concepts/squad-2-0-architecture](/squad/docs/concept | Host | Capability | Validation | Mechanism | Evidence | |------|------------|------------|-----------|----------| -| `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: one plain-text turn through the broker on the real Copilot backend (2026-11-09). 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-manual:lab:copilot-sdk-harness@2026-11-09` | +| `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` | @@ -1054,4 +1054,5 @@ Headless real-CLI runs behind the `validated-manual:lab:` evidence ent | 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/test-fixtures/spec/suite-v0.2/host-feature-matrix.json b/test-fixtures/spec/suite-v0.2/host-feature-matrix.json index 6eb14777f..960e8f9a0 100644 --- a/test-fixtures/spec/suite-v0.2/host-feature-matrix.json +++ b/test-fixtures/spec/suite-v0.2/host-feature-matrix.json @@ -4385,9 +4385,10 @@ "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-manual:lab:copilot-sdk-harness@2026-11-09" + "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: one plain-text turn through the broker on the real Copilot backend (2026-11-09). Approvals are covered by the shared contract, not yet live." + "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", @@ -5008,6 +5009,14 @@ "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": [ diff --git a/test/copilot-sdk-harness.test.ts b/test/copilot-sdk-harness.test.ts index 2e40f4a45..bcdbc9baa 100644 --- a/test/copilot-sdk-harness.test.ts +++ b/test/copilot-sdk-harness.test.ts @@ -180,16 +180,35 @@ describe('copilot-sdk harness', () => { }); describe.skipIf(process.env['SQUAD_REAL_CLI_TESTS'] !== '1')('copilot-sdk harness: live (real Copilot SDK)', () => { - it('completes a turn through the broker', async () => { - const harness = new CopilotSdkHarness({ cwd: process.cwd() }); + /** 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); - expect((await backend.getTurn('live', 1)).state).toBe('completed'); + 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); });