From 9acffae1b3edf4fd3cfb7770e9eb26f133ecc2f6 Mon Sep 17 00:00:00 2001 From: Andy McClenaghan Date: Tue, 5 May 2026 12:53:08 +1000 Subject: [PATCH 1/4] Add ACP agent backend support --- docs/configuration.md | 2 +- docs/models.md | 16 +- packages/deepsec/build.mjs | 1 + packages/deepsec/package.json | 1 + .../deepsec/src/__tests__/preflight.test.ts | 7 + packages/deepsec/src/agent-defaults.ts | 2 + packages/deepsec/src/cli.ts | 30 +- packages/deepsec/src/commands/process.ts | 47 +- packages/deepsec/src/commands/revalidate.ts | 47 +- packages/deepsec/src/preflight.ts | 10 +- packages/processor/package.json | 1 + .../processor/src/__tests__/acp-agent.test.ts | 101 ++++ .../processor/src/__tests__/registry.test.ts | 5 +- packages/processor/src/agents/acp-agent.ts | 536 ++++++++++++++++++ packages/processor/src/index.ts | 2 + pnpm-lock.yaml | 14 + 16 files changed, 811 insertions(+), 11 deletions(-) create mode 100644 packages/processor/src/__tests__/acp-agent.test.ts create mode 100644 packages/processor/src/agents/acp-agent.ts diff --git a/docs/configuration.md b/docs/configuration.md index 8966b49..115a54d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,7 +27,7 @@ see [`samples/webapp/deepsec.config.ts`](../samples/webapp/deepsec.config.ts). | `projects` | `ProjectDeclaration[]` | The codebases deepsec knows about. | | `plugins` | `DeepsecPlugin[]` | Loaded in order; later plugins override single-slot capabilities. | | `matchers` | `{ only?: string[]; exclude?: string[] }` | Filter the matcher set used by `scan`. | -| `defaultAgent` | `string` | Default `--agent` value (`claude-agent-sdk` or `codex`). See [models.md](models.md). | +| `defaultAgent` | `string` | Default `--agent` value (`claude-agent-sdk`, `codex`, or `acp`). See [models.md](models.md). | | `dataDir` | `string` | Override the `data/` directory. Defaults to `./data`. | ## ProjectDeclaration diff --git a/docs/models.md b/docs/models.md index 0858f74..5853ac2 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,11 +1,12 @@ # Models -deepsec talks to LLMs through two interchangeable backends: +deepsec talks to LLMs through interchangeable backends: | Backend | Default model | Used by | |-----------------------------|-----------------------|------------------------------| | `claude-agent-sdk` (default) | `claude-opus-4-7` | `process`, `revalidate` | | `codex` | `gpt-5.5` | `process`, `revalidate` | +| `acp` | `acp-default` | `process`, `revalidate` | | `claude-agent-sdk` (triage) | `claude-sonnet-4-6` | `triage` (Claude-only) | Both backends route through [Vercel AI Gateway](https://vercel.com/ai-gateway) @@ -28,6 +29,19 @@ pnpm deepsec process --project-id my-app --agent codex # Codex backend, specific model: pnpm deepsec process --project-id my-app --agent codex --model gpt-5.4 +# ACP backend, Alta/Rovo Dev bridge via `atlas alta agent run`: +pnpm deepsec process --project-id my-app --agent acp --acp-agent rovo-dev + +# ACP backend, any other ACP source accepted by `atlas alta agent run`: +pnpm deepsec process --project-id my-app --agent acp --acp-agent my-agent + +# ACP backend, registry agent from https://agentclientprotocol.com: +pnpm deepsec process --project-id my-app --agent acp --acp-registry-agent claude-acp + +# ACP backend, fully custom bridge command/args: +pnpm deepsec process --project-id my-app --agent acp --acp-command 'my-acp serve --stdio' +pnpm deepsec process --project-id my-app --agent acp --acp-command my-acp --acp-args '["serve","--stdio"]' + # Triage uses Claude; pass a cheaper model if you want: pnpm deepsec triage --project-id my-app --model claude-haiku-4-5 ``` diff --git a/packages/deepsec/build.mjs b/packages/deepsec/build.mjs index c0eb21a..d133b39 100644 --- a/packages/deepsec/build.mjs +++ b/packages/deepsec/build.mjs @@ -10,6 +10,7 @@ const repoRoot = resolve(__dirname, "../.."); // Externalized at runtime: native binaries, heavy SDKs, and jiti (which // bundles its own esbuild — re-bundling it produces broken output). const external = [ + "@agentclientprotocol/sdk", "@anthropic-ai/claude-agent-sdk", "@openai/codex", "@openai/codex-sdk", diff --git a/packages/deepsec/package.json b/packages/deepsec/package.json index 848fb20..9c814c4 100644 --- a/packages/deepsec/package.json +++ b/packages/deepsec/package.json @@ -32,6 +32,7 @@ "prepublishOnly": "pnpm -w validate && node -e \"const fs=require('node:fs');for(const f of ['dist/cli.mjs','dist/config.mjs','dist/config.d.ts','dist/sandbox/request-proxy.mjs','dist/docs/getting-started.md','dist/samples/webapp/deepsec.config.ts','SKILL.md','README.md','LICENSE','NOTICE']){if(!fs.existsSync(f)){console.error('Missing: '+f);process.exit(1)}}console.log('Pre-publish checks passed.')\"" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.21.0", "@anthropic-ai/claude-agent-sdk": "^0.2.119", "@openai/codex": "^0.125.0", "@openai/codex-sdk": "^0.125.0", diff --git a/packages/deepsec/src/__tests__/preflight.test.ts b/packages/deepsec/src/__tests__/preflight.test.ts index 8c337a2..834a021 100644 --- a/packages/deepsec/src/__tests__/preflight.test.ts +++ b/packages/deepsec/src/__tests__/preflight.test.ts @@ -98,6 +98,13 @@ describe("assertAgentCredential", () => { expect(() => assertAgentCredential("codex")).toThrow(/AI_GATEWAY_API_KEY/); }); + it("skips Deepsec-managed credential checks for ACP agents", () => { + // ACP bridges own their auth flow (for example, local Atlas/Alta login), + // so missing Claude/OpenAI env vars should not block --agent acp. + expect(() => assertAgentCredential("acp")).not.toThrow(); + expect(() => assertAgentCredential("acp", { inSandbox: true })).not.toThrow(); + }); + it("skips the credential check for custom plugin agents", () => { // Tests use this so a stub agent registered via plugins[] doesn't // require fake ANTHROPIC_AUTH_TOKEN env vars. diff --git a/packages/deepsec/src/agent-defaults.ts b/packages/deepsec/src/agent-defaults.ts index edc94bd..660f16d 100644 --- a/packages/deepsec/src/agent-defaults.ts +++ b/packages/deepsec/src/agent-defaults.ts @@ -6,6 +6,8 @@ export function defaultModelForAgent(agentType: string): string { switch (agentType) { case "codex": return "gpt-5.5"; + case "acp": + return "acp-default"; default: return "claude-opus-4-7"; } diff --git a/packages/deepsec/src/cli.ts b/packages/deepsec/src/cli.ts index 1da621b..d64eb5e 100755 --- a/packages/deepsec/src/cli.ts +++ b/packages/deepsec/src/cli.ts @@ -138,11 +138,22 @@ program .option("--run-id ", "Resume a specific processing run") .option( "--agent ", - "Agent plugin type: claude-agent-sdk or codex (default: defaultAgent in deepsec.config.ts, else claude-agent-sdk)", + "Agent plugin type: claude-agent-sdk, codex, or acp (default: defaultAgent in deepsec.config.ts, else claude-agent-sdk)", ) .option( "--model ", - "Model to use (default: claude-opus-4-7 for claude-agent-sdk, gpt-5.5 for codex)", + "Model to use (default: claude-opus-4-7 for claude-agent-sdk, gpt-5.5 for codex, acp-default for acp)", + ) + .option("--acp-agent ", "Alta/Atlas ACP source for --agent acp, e.g. rovo-dev") + .option("--acp-registry-agent ", "ACP registry agent id, e.g. claude-acp or codex-acp") + .option("--acp-registry-url ", "ACP registry URL (default: public latest registry)") + .option( + "--acp-command ", + "Custom ACP bridge command; may include args when --acp-args is omitted", + ) + .option( + "--acp-args ", + "Custom ACP bridge args as JSON array or whitespace string; used with --acp-command", ) .option("--max-turns ", "Max conversation turns per batch (default: 150)", parseInt) .option( @@ -182,11 +193,22 @@ program .option("--run-id ", "Resume a specific revalidation run") .option( "--agent ", - "Agent plugin type: claude-agent-sdk or codex (default: defaultAgent in deepsec.config.ts, else claude-agent-sdk)", + "Agent plugin type: claude-agent-sdk, codex, or acp (default: defaultAgent in deepsec.config.ts, else claude-agent-sdk)", ) .option( "--model ", - "Model to use (default: claude-opus-4-7 for claude-agent-sdk, gpt-5.5 for codex)", + "Model to use (default: claude-opus-4-7 for claude-agent-sdk, gpt-5.5 for codex, acp-default for acp)", + ) + .option("--acp-agent ", "Alta/Atlas ACP source for --agent acp, e.g. rovo-dev") + .option("--acp-registry-agent ", "ACP registry agent id, e.g. claude-acp or codex-acp") + .option("--acp-registry-url ", "ACP registry URL (default: public latest registry)") + .option( + "--acp-command ", + "Custom ACP bridge command; may include args when --acp-args is omitted", + ) + .option( + "--acp-args ", + "Custom ACP bridge args as JSON array or whitespace string; used with --acp-command", ) .option("--max-turns ", "Max conversation turns per batch (default: 150)", parseInt) .option( diff --git a/packages/deepsec/src/commands/process.ts b/packages/deepsec/src/commands/process.ts index 27cf556..4da5080 100644 --- a/packages/deepsec/src/commands/process.ts +++ b/packages/deepsec/src/commands/process.ts @@ -68,12 +68,49 @@ function parseCsv(v: string | undefined): string[] | undefined { return parts.length > 0 ? parts : undefined; } +function parseAcpArgs(v: string | undefined): string[] | undefined { + if (!v) return undefined; + const trimmed = v.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith("[")) { + const parsed = JSON.parse(trimmed); + if (!Array.isArray(parsed)) throw new Error("--acp-args JSON must be an array of strings"); + return parsed.map(String); + } + return trimmed.split(/\s+/).filter(Boolean); +} + +function buildAgentConfig(opts: { + model: string; + maxTurns?: number; + acpAgent?: string; + acpRegistryAgent?: string; + acpRegistryUrl?: string; + acpCommand?: string; + acpArgs?: string; +}): Record { + return { + model: opts.model, + ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}), + ...(opts.acpAgent ? { acpAgent: opts.acpAgent } : {}), + ...(opts.acpRegistryAgent ? { acpRegistryAgent: opts.acpRegistryAgent } : {}), + ...(opts.acpRegistryUrl ? { acpRegistryUrl: opts.acpRegistryUrl } : {}), + ...(opts.acpCommand ? { acpCommand: opts.acpCommand } : {}), + ...(opts.acpArgs ? { acpArgs: parseAcpArgs(opts.acpArgs) } : {}), + }; +} + export async function processCommand(opts: { projectId?: string; runId?: string; agent?: string; model?: string; maxTurns?: number; + acpAgent?: string; + acpRegistryAgent?: string; + acpRegistryUrl?: string; + acpCommand?: string; + acpArgs?: string; /** Commander yields `true` when bare; string (unparsed) when an arg is provided */ reinvestigate?: boolean | string; limit?: number; @@ -135,7 +172,15 @@ export async function processCommand(opts: { projectId, runId: opts.runId, agentType, - config: { model, ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}) }, + config: buildAgentConfig({ + model, + maxTurns: opts.maxTurns, + acpAgent: opts.acpAgent, + acpRegistryAgent: opts.acpRegistryAgent, + acpRegistryUrl: opts.acpRegistryUrl, + acpCommand: opts.acpCommand, + acpArgs: opts.acpArgs, + }), reinvestigate, limit: opts.limit, concurrency: opts.concurrency, diff --git a/packages/deepsec/src/commands/revalidate.ts b/packages/deepsec/src/commands/revalidate.ts index 952478e..f189baa 100644 --- a/packages/deepsec/src/commands/revalidate.ts +++ b/packages/deepsec/src/commands/revalidate.ts @@ -52,12 +52,49 @@ function parseCsv(v: string | undefined): string[] | undefined { return parts.length > 0 ? parts : undefined; } +function parseAcpArgs(v: string | undefined): string[] | undefined { + if (!v) return undefined; + const trimmed = v.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith("[")) { + const parsed = JSON.parse(trimmed); + if (!Array.isArray(parsed)) throw new Error("--acp-args JSON must be an array of strings"); + return parsed.map(String); + } + return trimmed.split(/\s+/).filter(Boolean); +} + +function buildAgentConfig(opts: { + model: string; + maxTurns?: number; + acpAgent?: string; + acpRegistryAgent?: string; + acpRegistryUrl?: string; + acpCommand?: string; + acpArgs?: string; +}): Record { + return { + model: opts.model, + ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}), + ...(opts.acpAgent ? { acpAgent: opts.acpAgent } : {}), + ...(opts.acpRegistryAgent ? { acpRegistryAgent: opts.acpRegistryAgent } : {}), + ...(opts.acpRegistryUrl ? { acpRegistryUrl: opts.acpRegistryUrl } : {}), + ...(opts.acpCommand ? { acpCommand: opts.acpCommand } : {}), + ...(opts.acpArgs ? { acpArgs: parseAcpArgs(opts.acpArgs) } : {}), + }; +} + export async function revalidateCommand(opts: { projectId?: string; runId?: string; agent?: string; model?: string; maxTurns?: number; + acpAgent?: string; + acpRegistryAgent?: string; + acpRegistryUrl?: string; + acpCommand?: string; + acpArgs?: string; minSeverity?: string; force?: boolean; limit?: number; @@ -92,7 +129,15 @@ export async function revalidateCommand(opts: { projectId, runId: opts.runId, agentType, - config: { model, ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}) }, + config: buildAgentConfig({ + model, + maxTurns: opts.maxTurns, + acpAgent: opts.acpAgent, + acpRegistryAgent: opts.acpRegistryAgent, + acpRegistryUrl: opts.acpRegistryUrl, + acpCommand: opts.acpCommand, + acpArgs: opts.acpArgs, + }), minSeverity, force: opts.force, limit: opts.limit, diff --git a/packages/deepsec/src/preflight.ts b/packages/deepsec/src/preflight.ts index 28f2061..cb118e3 100644 --- a/packages/deepsec/src/preflight.ts +++ b/packages/deepsec/src/preflight.ts @@ -50,6 +50,10 @@ function isCodex(agentType: string | undefined): boolean { return agentType === "codex"; } +function isAcp(agentType: string | undefined): boolean { + return agentType === "acp"; +} + /** * Walk `$PATH` looking for a binary. Used as a positive signal that an * agent CLI (`claude`, `codex`) is set up on this host — if it's @@ -107,7 +111,7 @@ function hasLocalCodexAgent(): boolean { // via plugins (deepsec.config.ts → plugins: [{ agents: [...] }]) handle // their own credential resolution, so we skip the check for anything // other than these. -const KNOWN_BACKENDS = new Set(["claude-agent-sdk", "codex"]); +const KNOWN_BACKENDS = new Set(["claude-agent-sdk", "codex", "acp"]); /** * Verify the orchestrator has an AI credential the chosen agent can use. @@ -129,6 +133,10 @@ export function assertAgentCredential( options: { inSandbox?: boolean } = {}, ): void { if (agentType !== undefined && !KNOWN_BACKENDS.has(agentType)) return; + // ACP agents own their authentication/credential flow behind the ACP bridge + // (for example, `atlas alta agent run` uses the local Atlas/Alta setup). + // Deepsec just connects to the bridge and should not require Claude/OpenAI env vars. + if (isAcp(agentType)) return; const anthropic = process.env.ANTHROPIC_AUTH_TOKEN; const openai = process.env.OPENAI_API_KEY; diff --git a/packages/processor/package.json b/packages/processor/package.json index b674c58..1c1b3df 100644 --- a/packages/processor/package.json +++ b/packages/processor/package.json @@ -9,6 +9,7 @@ "build": "tsc" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.21.0", "@anthropic-ai/claude-agent-sdk": "^0.2.119", "@openai/codex": "^0.125.0", "@openai/codex-sdk": "^0.125.0", diff --git a/packages/processor/src/__tests__/acp-agent.test.ts b/packages/processor/src/__tests__/acp-agent.test.ts new file mode 100644 index 0000000..25680ea --- /dev/null +++ b/packages/processor/src/__tests__/acp-agent.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildAcpInvocation } from "../agents/acp-agent.js"; + +const registry = { + version: "1.0.0", + agents: [ + { + id: "claude-acp", + distribution: { npx: { package: "@agentclientprotocol/claude-agent-acp@0.32.0" } }, + }, + { + id: "auggie", + distribution: { + npx: { + package: "@augmentcode/auggie@0.25.2", + args: ["--acp"], + env: { AUGMENT_DISABLE_AUTO_UPDATE: "1" }, + }, + }, + }, + { + id: "uv-agent", + distribution: { uvx: { package: "uv-agent-acp", args: ["serve"], env: { UV_AGENT: "1" } } }, + }, + { + id: "binary-agent", + distribution: { binary: { "darwin-aarch64": { cmd: "./agent" } } }, + }, + ], +}; + +describe("ACP invocation resolution", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("requires an explicit ACP bridge selection", async () => { + await expect(buildAcpInvocation("/repo", {})).rejects.toThrow( + /ACP agent selection is required/, + ); + }); + + it("uses an explicit Alta source", async () => { + const invocation = await buildAcpInvocation("/repo", { acpAgent: "my-agent" }); + expect(invocation.args).toEqual(["alta", "agent", "run", "--workspace", "/repo", "my-agent"]); + }); + + it("accepts a full custom command string", async () => { + await expect( + buildAcpInvocation("/repo", { acpCommand: "node ./server.js --stdio 'quoted arg'" }), + ).resolves.toMatchObject({ + command: "node", + args: ["./server.js", "--stdio", "quoted arg"], + label: "node ./server.js --stdio 'quoted arg'", + }); + }); + + it("uses custom acpArgs without shell splitting", async () => { + await expect( + buildAcpInvocation("/repo", { acpCommand: "my-acp", acpArgs: ["serve", "--stdio value"] }), + ).resolves.toMatchObject({ command: "my-acp", args: ["serve", "--stdio value"] }); + }); + + it("resolves npx ACP registry agents", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => registry })), + ); + await expect( + buildAcpInvocation("/repo", { acpRegistryAgent: "auggie", acpEnv: { EXTRA: "1" } }), + ).resolves.toEqual({ + command: "npx", + args: ["-y", "@augmentcode/auggie@0.25.2", "--acp"], + env: { AUGMENT_DISABLE_AUTO_UPDATE: "1", EXTRA: "1" }, + label: "auggie via registry npx (@augmentcode/auggie@0.25.2)", + }); + }); + + it("resolves uvx ACP registry agents", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => registry })), + ); + await expect(buildAcpInvocation("/repo", { acpRegistryAgent: "uv-agent" })).resolves.toEqual({ + command: "uvx", + args: ["uv-agent-acp", "serve"], + env: { UV_AGENT: "1" }, + label: "uv-agent via registry uvx (uv-agent-acp)", + }); + }); + + it("gives an actionable error for binary-only registry agents", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => registry })), + ); + await expect(buildAcpInvocation("/repo", { acpRegistryAgent: "binary-agent" })).rejects.toThrow( + /binary-only.*--acp-command\/--acp-args/, + ); + }); +}); diff --git a/packages/processor/src/__tests__/registry.test.ts b/packages/processor/src/__tests__/registry.test.ts index 744e48a..301688c 100644 --- a/packages/processor/src/__tests__/registry.test.ts +++ b/packages/processor/src/__tests__/registry.test.ts @@ -52,10 +52,11 @@ describe("AgentRegistry", () => { }); describe("createDefaultAgentRegistry", () => { - it("registers both backends", () => { + it("registers built-in backends", () => { const registry = createDefaultAgentRegistry(); expect(registry.get("claude-agent-sdk")).toBeDefined(); expect(registry.get("codex")).toBeDefined(); - expect(registry.types().sort()).toEqual(["claude-agent-sdk", "codex"]); + expect(registry.get("acp")).toBeDefined(); + expect(registry.types().sort()).toEqual(["acp", "claude-agent-sdk", "codex"]); }); }); diff --git a/packages/processor/src/agents/acp-agent.ts b/packages/processor/src/agents/acp-agent.ts new file mode 100644 index 0000000..ae4ef46 --- /dev/null +++ b/packages/processor/src/agents/acp-agent.ts @@ -0,0 +1,536 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { Readable, Writable } from "node:stream"; +import * as acp from "@agentclientprotocol/sdk"; +import type { RefusalReport } from "@deepsec/core"; +import { + backoff, + buildInvestigatePrompt, + buildRevalidatePrompt, + isTransientError, + MAX_ATTEMPTS, + parseInvestigateResults, + parseRefusalReport, + parseRevalidateVerdicts, + REFUSAL_FOLLOWUP_PROMPT, +} from "./shared.js"; +import type { + AgentPlugin, + AgentProgress, + BatchMeta, + InvestigateOutput, + InvestigateParams, + RevalidateOutput, + RevalidateParams, +} from "./types.js"; + +const DEFAULT_MODEL = "acp-default"; +const DEFAULT_ACP_REGISTRY_URL = + "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; +const DEBUG = process.env.DEEPSEC_AGENT_DEBUG === "1"; + +interface AcpConfig { + model?: string; + maxTurns?: number; + /** Alta/Atlas source for `atlas alta agent run --workspace `. */ + acpAgent?: string; + /** Registry agent id from https://agentclientprotocol.com, e.g. `claude-acp` or `codex-acp`. */ + acpRegistryAgent?: string; + /** Registry JSON URL. Defaults to the public latest registry. */ + acpRegistryUrl?: string; + /** Custom ACP bridge command. May be just an executable, or a quoted command string when acpArgs is omitted. */ + acpCommand?: string; + acpArgs?: string[]; + acpEnv?: Record; +} + +interface AcpInvocation { + command: string; + args: string[]; + env?: Record; + label: string; +} + +interface AcpRegistryAgent { + id: string; + name?: string; + distribution?: { + npx?: { package?: string; args?: string[]; env?: Record }; + uvx?: { package?: string; args?: string[]; env?: Record }; + binary?: unknown; + }; +} + +interface AcpRegistry { + agents?: AcpRegistryAgent[]; +} + +let registryCache: { url: string; registry: AcpRegistry } | undefined; + +interface AcpTurnResult { + text: string; + sessionId?: string; + stopReason?: string; + turnCount: number; + toolUseCount: number; + meta: Partial; +} + +function stringRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return Object.fromEntries( + Object.entries(value as Record).map(([k, v]) => [k, String(v)]), + ); +} + +function asAcpConfig(config: Record): AcpConfig { + return { + model: typeof config.model === "string" ? config.model : undefined, + maxTurns: typeof config.maxTurns === "number" ? config.maxTurns : undefined, + acpAgent: typeof config.acpAgent === "string" ? config.acpAgent : undefined, + acpRegistryAgent: + typeof config.acpRegistryAgent === "string" ? config.acpRegistryAgent : undefined, + acpRegistryUrl: typeof config.acpRegistryUrl === "string" ? config.acpRegistryUrl : undefined, + acpCommand: typeof config.acpCommand === "string" ? config.acpCommand : undefined, + acpArgs: Array.isArray(config.acpArgs) ? config.acpArgs.map(String) : undefined, + acpEnv: stringRecord(config.acpEnv), + }; +} + +function splitCommandString(command: string): string[] { + const parts: string[] = []; + let current = ""; + let quote: '"' | "'" | undefined; + let escaping = false; + for (const ch of command.trim()) { + if (escaping) { + current += ch; + escaping = false; + continue; + } + if (ch === "\\") { + escaping = true; + continue; + } + if (quote) { + if (ch === quote) quote = undefined; + else current += ch; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (/\s/.test(ch)) { + if (current) { + parts.push(current); + current = ""; + } + continue; + } + current += ch; + } + if (escaping) current += "\\"; + if (quote) throw new Error(`Unterminated quote in --acp-command: ${command}`); + if (current) parts.push(current); + return parts; +} + +function customInvocation(config: AcpConfig): AcpInvocation | undefined { + if (!config.acpCommand) return undefined; + if (config.acpArgs) { + return { + command: config.acpCommand, + args: config.acpArgs, + env: config.acpEnv, + label: `${config.acpCommand} ${config.acpArgs.join(" ")}`, + }; + } + const parts = splitCommandString(config.acpCommand); + if (parts.length === 0) throw new Error("--acp-command must not be empty"); + return { command: parts[0], args: parts.slice(1), env: config.acpEnv, label: config.acpCommand }; +} + +async function fetchRegistry(url: string): Promise { + if (registryCache?.url === url) return registryCache.registry; + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to fetch ACP registry ${url}: HTTP ${response.status}`); + const registry = (await response.json()) as AcpRegistry; + registryCache = { url, registry }; + return registry; +} + +async function registryInvocation(config: AcpConfig): Promise { + if (!config.acpRegistryAgent) return undefined; + const url = config.acpRegistryUrl ?? DEFAULT_ACP_REGISTRY_URL; + const registry = await fetchRegistry(url); + const entry = registry.agents?.find((agent) => agent.id === config.acpRegistryAgent); + if (!entry) { + const examples = + registry.agents + ?.slice(0, 8) + .map((agent) => agent.id) + .join(", ") ?? "none"; + throw new Error( + `ACP registry agent not found: ${config.acpRegistryAgent}. Examples: ${examples}`, + ); + } + + const npx = entry.distribution?.npx; + if (npx?.package) { + return { + command: "npx", + args: ["-y", npx.package, ...(npx.args ?? []).map(String)], + env: { ...npx.env, ...config.acpEnv }, + label: `${entry.id} via registry npx (${npx.package})`, + }; + } + + const uvx = entry.distribution?.uvx; + if (uvx?.package) { + return { + command: "uvx", + args: [uvx.package, ...(uvx.args ?? []).map(String)], + env: { ...uvx.env, ...config.acpEnv }, + label: `${entry.id} via registry uvx (${uvx.package})`, + }; + } + + if (entry.distribution?.binary) { + throw new Error( + `ACP registry agent ${entry.id} is binary-only. Install it first, then pass a custom bridge with --acp-command/--acp-args.`, + ); + } + + throw new Error(`ACP registry agent ${entry.id} has no supported npx or uvx distribution.`); +} + +export async function buildAcpInvocation( + projectRoot: string, + config: AcpConfig, +): Promise { + const custom = customInvocation(config); + if (custom) return custom; + const registry = await registryInvocation(config); + if (registry) return registry; + if (config.acpAgent) { + return { + command: "atlas", + args: ["alta", "agent", "run", "--workspace", projectRoot, config.acpAgent], + env: config.acpEnv, + label: `atlas alta agent run ${config.acpAgent}`, + }; + } + + throw new Error( + "ACP agent selection is required. Pass --acp-registry-agent , --acp-command , or --acp-agent for atlas alta agent run.", + ); +} + +function shortToolMessage(update: Record): string { + const title = typeof update.title === "string" ? update.title : undefined; + const status = typeof update.status === "string" ? update.status : undefined; + const id = typeof update.toolCallId === "string" ? update.toolCallId : undefined; + const base = title || id || "tool call"; + return status ? `${base} (${status})` : base; +} + +function extractUsage(update: Record): BatchMeta["usage"] | undefined { + const usage = update.usage; + if (!usage || typeof usage !== "object") return undefined; + const u = usage as Record; + const inputTokens = Number(u.inputTokens ?? u.input_tokens ?? 0); + const outputTokens = Number(u.outputTokens ?? u.output_tokens ?? 0); + return { + inputTokens: Number.isFinite(inputTokens) ? inputTokens : 0, + outputTokens: Number.isFinite(outputTokens) ? outputTokens : 0, + cacheReadInputTokens: Number(u.cacheReadInputTokens ?? u.cache_read_input_tokens ?? 0) || 0, + cacheCreationInputTokens: + Number(u.cacheCreationInputTokens ?? u.cache_creation_input_tokens ?? 0) || 0, + }; +} + +class DeepsecAcpClient implements acp.Client { + readonly textChunks: string[] = []; + readonly progress: AgentProgress[] = []; + turnCount = 0; + toolUseCount = 0; + usage: BatchMeta["usage"] | undefined; + + async requestPermission( + params: acp.RequestPermissionRequest, + ): Promise { + const reject = + params.options.find((o) => o.kind === "reject_always") ?? + params.options.find((o) => o.kind === "reject_once"); + if (reject) { + this.progress.push({ + type: "tool_use", + message: `permission requested for ${params.toolCall.title ?? params.toolCall.toolCallId}; rejecting to keep deepsec read-only`, + }); + return { outcome: { outcome: "selected", optionId: reject.optionId } }; + } + return { outcome: { outcome: "cancelled" } }; + } + + async sessionUpdate(params: acp.SessionNotification): Promise { + const update = params.update as Record; + switch (update.sessionUpdate) { + case "agent_message_chunk": { + const content = update.content as Record | undefined; + if (content?.type === "text" && typeof content.text === "string") { + this.textChunks.push(content.text); + } + break; + } + case "agent_thought_chunk": + this.turnCount++; + this.progress.push({ type: "thinking", message: `ACP agent thinking (${this.turnCount})` }); + break; + case "tool_call": + case "tool_call_update": + this.toolUseCount++; + this.progress.push({ type: "tool_use", message: shortToolMessage(update) }); + break; + case "usage_update": { + this.usage = extractUsage(update); + break; + } + } + } +} + +async function killAgent(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.killed) return; + child.kill("SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 100)); + if (child.exitCode === null && !child.killed) child.kill("SIGKILL"); +} + +async function* runAcpPrompt(params: { + projectRoot: string; + prompt: string; + config: AcpConfig; +}): AsyncGenerator { + const invocation = await buildAcpInvocation(params.projectRoot, params.config); + const child = spawn(invocation.command, invocation.args, { + cwd: params.projectRoot, + env: { ...process.env, ...invocation.env }, + stdio: ["pipe", "pipe", "pipe"], + }); + + const stderrChunks: string[] = []; + child.stderr.on("data", (chunk) => { + const text = String(chunk); + stderrChunks.push(text); + if (DEBUG) console.error(`[deepsec:acp] ${text}`); + }); + + const client = new DeepsecAcpClient(); + const input = Writable.toWeb(child.stdin) as WritableStream; + const output = Readable.toWeb(child.stdout) as ReadableStream; + const stream = acp.ndJsonStream(input, output); + const connection = new acp.ClientSideConnection(() => client, stream); + + let sessionId: string | undefined; + try { + await connection.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientInfo: { name: "deepsec", version: "0.0.0" }, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } }, + }); + + const session = await connection.newSession({ cwd: params.projectRoot, mcpServers: [] }); + sessionId = session.sessionId; + + const promptPromise = connection.prompt({ + sessionId, + prompt: [{ type: "text", text: params.prompt }], + }); + + while (true) { + const done = await Promise.race([ + promptPromise.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 100)), + ]); + while (client.progress.length > 0) { + yield client.progress.shift()!; + } + if (done) break; + } + + const promptResult = await promptPromise; + while (client.progress.length > 0) { + yield client.progress.shift()!; + } + + return { + text: client.textChunks.join(""), + sessionId, + stopReason: promptResult.stopReason, + turnCount: client.turnCount, + toolUseCount: client.toolUseCount, + meta: { + agentSessionId: sessionId, + numTurns: client.turnCount, + usage: client.usage, + }, + }; + } catch (err) { + const stderr = stderrChunks.join("").trim(); + const message = err instanceof Error ? err.message : String(err); + throw new Error(stderr ? `${message}\nACP stderr:\n${stderr.slice(-3000)}` : message); + } finally { + await killAgent(child); + } +} + +async function collectAcpTurn( + promptParams: { projectRoot: string; prompt: string; config: AcpConfig }, + onProgress?: (progress: AgentProgress) => void, +): Promise { + const gen = runAcpPrompt(promptParams); + let next = await gen.next(); + while (!next.done) { + onProgress?.(next.value); + next = await gen.next(); + } + return next.value; +} + +async function runRefusalFollowUp( + projectRoot: string, + config: AcpConfig, +): Promise { + try { + const result = await collectAcpTurn({ projectRoot, prompt: REFUSAL_FOLLOWUP_PROMPT, config }); + return parseRefusalReport(result.text); + } catch { + return undefined; + } +} + +export class AcpAgentPlugin implements AgentPlugin { + type = "acp"; + + async *investigate(params: InvestigateParams): AsyncGenerator { + const { batch, projectRoot, promptTemplate, projectInfo } = params; + const config = asAcpConfig(params.config); + const model = config.model ?? DEFAULT_MODEL; + const agentName = + config.acpRegistryAgent ?? config.acpAgent ?? config.acpCommand ?? "(not configured)"; + + yield { + type: "started", + message: `Investigating ${batch.length} file(s) with ACP agent ${agentName} (${model})`, + }; + + const prompt = buildInvestigatePrompt({ promptTemplate, projectInfo, batch }); + const startTime = Date.now(); + let turn: AcpTurnResult | undefined; + let lastError = ""; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + if (attempt > 1) { + yield { + type: "thinking" as const, + message: `Retrying ACP batch after transient error (attempt ${attempt}/${MAX_ATTEMPTS}): ${lastError.slice(0, 200)}`, + }; + turn = undefined; + lastError = ""; + } + + try { + turn = yield* runAcpPrompt({ projectRoot, prompt, config }); + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + yield { type: "error" as const, message: `ACP agent error: ${lastError.slice(0, 300)}` }; + } + + if (turn?.text) break; + if (attempt >= MAX_ATTEMPTS || !isTransientError(lastError)) break; + await backoff(attempt); + } + + const resultText = turn?.text ?? ""; + const durationMs = Date.now() - startTime; + const refusal = await runRefusalFollowUp(projectRoot, config); + if (refusal?.refused) { + yield { + type: "thinking" as const, + message: `Refusal detected: ${refusal.reason ?? refusal.skipped?.map((s) => s.filePath ?? "?").join(", ") ?? "see raw"}`, + }; + } + + const tokensStr = turn?.meta.usage + ? ` ${turn.meta.usage.inputTokens + turn.meta.usage.outputTokens} tokens` + : ""; + yield { + type: "complete", + message: `Investigation complete (${(durationMs / 1000).toFixed(1)}s, ${turn?.turnCount ?? 0} turns, ${turn?.toolUseCount ?? 0} tool calls${tokensStr}${refusal?.refused ? " ⚠️ refusal" : ""})`, + }; + + return { + results: parseInvestigateResults(resultText, batch), + meta: { durationMs, ...turn?.meta, refusal }, + }; + } + + async *revalidate(params: RevalidateParams): AsyncGenerator { + const { batch, projectRoot, projectInfo, force = false } = params; + const config = asAcpConfig(params.config); + const model = config.model ?? DEFAULT_MODEL; + const built = buildRevalidatePrompt({ batch, projectRoot, projectInfo, force }); + + yield { + type: "started", + message: `Revalidating ${built.totalFindings} finding(s) across ${batch.length} file(s) with ACP (${model})`, + }; + + const startTime = Date.now(); + let turn: AcpTurnResult | undefined; + let lastError = ""; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + if (attempt > 1) { + yield { + type: "thinking" as const, + message: `Retrying ACP revalidation after transient error (attempt ${attempt}/${MAX_ATTEMPTS}): ${lastError.slice(0, 200)}`, + }; + turn = undefined; + lastError = ""; + } + + try { + turn = yield* runAcpPrompt({ projectRoot, prompt: built.prompt, config }); + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + yield { type: "error" as const, message: `ACP agent error: ${lastError.slice(0, 300)}` }; + } + + if (turn?.text) break; + if (attempt >= MAX_ATTEMPTS || !isTransientError(lastError)) break; + await backoff(attempt); + } + + const resultText = turn?.text ?? ""; + const verdicts = parseRevalidateVerdicts(resultText); + const durationMs = Date.now() - startTime; + const refusal = await runRefusalFollowUp(projectRoot, config); + if (refusal?.refused) { + yield { + type: "thinking" as const, + message: `Refusal detected during revalidation: ${refusal.reason ?? "see raw"}`, + }; + } + + yield { + type: "complete", + message: `Revalidation complete (${(durationMs / 1000).toFixed(1)}s, ${turn?.turnCount ?? 0} turns, ${verdicts.length} verdicts${refusal?.refused ? " ⚠️ refusal" : ""})`, + }; + + return { + verdicts, + meta: { durationMs, ...turn?.meta, refusal }, + }; + } +} diff --git a/packages/processor/src/index.ts b/packages/processor/src/index.ts index 6f83f65..d5c9413 100644 --- a/packages/processor/src/index.ts +++ b/packages/processor/src/index.ts @@ -14,6 +14,7 @@ import { writeRunMeta, } from "@deepsec/core"; import { noiseScore } from "@deepsec/scanner"; +import { AcpAgentPlugin } from "./agents/acp-agent.js"; import { ClaudeAgentSdkPlugin } from "./agents/claude-agent-sdk.js"; import { CodexAgentSdkPlugin } from "./agents/codex-sdk.js"; import { AgentRegistry } from "./agents/registry.js"; @@ -152,6 +153,7 @@ export function createDefaultAgentRegistry(): AgentRegistry { const registry = new AgentRegistry(); registry.register(new ClaudeAgentSdkPlugin()); registry.register(new CodexAgentSdkPlugin()); + registry.register(new AcpAgentPlugin()); // Plugins can contribute additional backends via `agents: []` in their // DeepsecPlugin export. The shape is validated by AgentRegistry at use. for (const a of getRegistry().agents as AgentPlugin[]) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7628472..a0ca32c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: packages/deepsec: dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.21.0 + version: 0.21.0(zod@4.4.1) '@anthropic-ai/claude-agent-sdk': specifier: ^0.2.119 version: 0.2.123(zod@4.4.1) @@ -76,6 +79,9 @@ importers: packages/processor: dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.21.0 + version: 0.21.0(zod@4.4.1) '@anthropic-ai/claude-agent-sdk': specifier: ^0.2.119 version: 0.2.123(zod@4.4.1) @@ -103,6 +109,14 @@ importers: packages: + /@agentclientprotocol/sdk@0.21.0(zod@4.4.1): + resolution: {integrity: sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + dependencies: + zod: 4.4.1 + dev: false + /@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.123: resolution: {integrity: sha512-tYAXCjlXZQklsUs0J//gip3fZQRzhlH5OCgvNXV70qe7A1iiwHqO2KPGvEHV1L+deEKQoMZmTaCOrQpN6zju3w==} cpu: [arm64] From c06f7b0bc17484c28e9ce85915fff7118c3e5273 Mon Sep 17 00:00:00 2001 From: Andy McClenaghan Date: Tue, 5 May 2026 12:56:59 +1000 Subject: [PATCH 2/4] Require explicit ACP registry or command --- docs/models.md | 6 ------ .../deepsec/src/__tests__/preflight.test.ts | 3 +-- packages/deepsec/src/cli.ts | 2 -- packages/deepsec/src/commands/process.ts | 4 ---- packages/deepsec/src/commands/revalidate.ts | 4 ---- packages/deepsec/src/preflight.ts | 3 +-- .../processor/src/__tests__/acp-agent.test.ts | 5 ----- packages/processor/src/agents/acp-agent.ts | 19 +++---------------- 8 files changed, 5 insertions(+), 41 deletions(-) diff --git a/docs/models.md b/docs/models.md index 5853ac2..57f134a 100644 --- a/docs/models.md +++ b/docs/models.md @@ -29,12 +29,6 @@ pnpm deepsec process --project-id my-app --agent codex # Codex backend, specific model: pnpm deepsec process --project-id my-app --agent codex --model gpt-5.4 -# ACP backend, Alta/Rovo Dev bridge via `atlas alta agent run`: -pnpm deepsec process --project-id my-app --agent acp --acp-agent rovo-dev - -# ACP backend, any other ACP source accepted by `atlas alta agent run`: -pnpm deepsec process --project-id my-app --agent acp --acp-agent my-agent - # ACP backend, registry agent from https://agentclientprotocol.com: pnpm deepsec process --project-id my-app --agent acp --acp-registry-agent claude-acp diff --git a/packages/deepsec/src/__tests__/preflight.test.ts b/packages/deepsec/src/__tests__/preflight.test.ts index 834a021..2bc149d 100644 --- a/packages/deepsec/src/__tests__/preflight.test.ts +++ b/packages/deepsec/src/__tests__/preflight.test.ts @@ -99,8 +99,7 @@ describe("assertAgentCredential", () => { }); it("skips Deepsec-managed credential checks for ACP agents", () => { - // ACP bridges own their auth flow (for example, local Atlas/Alta login), - // so missing Claude/OpenAI env vars should not block --agent acp. + // ACP bridges own their auth flow, so missing Claude/OpenAI env vars should not block --agent acp. expect(() => assertAgentCredential("acp")).not.toThrow(); expect(() => assertAgentCredential("acp", { inSandbox: true })).not.toThrow(); }); diff --git a/packages/deepsec/src/cli.ts b/packages/deepsec/src/cli.ts index d64eb5e..7b004e7 100755 --- a/packages/deepsec/src/cli.ts +++ b/packages/deepsec/src/cli.ts @@ -144,7 +144,6 @@ program "--model ", "Model to use (default: claude-opus-4-7 for claude-agent-sdk, gpt-5.5 for codex, acp-default for acp)", ) - .option("--acp-agent ", "Alta/Atlas ACP source for --agent acp, e.g. rovo-dev") .option("--acp-registry-agent ", "ACP registry agent id, e.g. claude-acp or codex-acp") .option("--acp-registry-url ", "ACP registry URL (default: public latest registry)") .option( @@ -199,7 +198,6 @@ program "--model ", "Model to use (default: claude-opus-4-7 for claude-agent-sdk, gpt-5.5 for codex, acp-default for acp)", ) - .option("--acp-agent ", "Alta/Atlas ACP source for --agent acp, e.g. rovo-dev") .option("--acp-registry-agent ", "ACP registry agent id, e.g. claude-acp or codex-acp") .option("--acp-registry-url ", "ACP registry URL (default: public latest registry)") .option( diff --git a/packages/deepsec/src/commands/process.ts b/packages/deepsec/src/commands/process.ts index 4da5080..192288e 100644 --- a/packages/deepsec/src/commands/process.ts +++ b/packages/deepsec/src/commands/process.ts @@ -83,7 +83,6 @@ function parseAcpArgs(v: string | undefined): string[] | undefined { function buildAgentConfig(opts: { model: string; maxTurns?: number; - acpAgent?: string; acpRegistryAgent?: string; acpRegistryUrl?: string; acpCommand?: string; @@ -92,7 +91,6 @@ function buildAgentConfig(opts: { return { model: opts.model, ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}), - ...(opts.acpAgent ? { acpAgent: opts.acpAgent } : {}), ...(opts.acpRegistryAgent ? { acpRegistryAgent: opts.acpRegistryAgent } : {}), ...(opts.acpRegistryUrl ? { acpRegistryUrl: opts.acpRegistryUrl } : {}), ...(opts.acpCommand ? { acpCommand: opts.acpCommand } : {}), @@ -106,7 +104,6 @@ export async function processCommand(opts: { agent?: string; model?: string; maxTurns?: number; - acpAgent?: string; acpRegistryAgent?: string; acpRegistryUrl?: string; acpCommand?: string; @@ -175,7 +172,6 @@ export async function processCommand(opts: { config: buildAgentConfig({ model, maxTurns: opts.maxTurns, - acpAgent: opts.acpAgent, acpRegistryAgent: opts.acpRegistryAgent, acpRegistryUrl: opts.acpRegistryUrl, acpCommand: opts.acpCommand, diff --git a/packages/deepsec/src/commands/revalidate.ts b/packages/deepsec/src/commands/revalidate.ts index f189baa..c7920a8 100644 --- a/packages/deepsec/src/commands/revalidate.ts +++ b/packages/deepsec/src/commands/revalidate.ts @@ -67,7 +67,6 @@ function parseAcpArgs(v: string | undefined): string[] | undefined { function buildAgentConfig(opts: { model: string; maxTurns?: number; - acpAgent?: string; acpRegistryAgent?: string; acpRegistryUrl?: string; acpCommand?: string; @@ -76,7 +75,6 @@ function buildAgentConfig(opts: { return { model: opts.model, ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}), - ...(opts.acpAgent ? { acpAgent: opts.acpAgent } : {}), ...(opts.acpRegistryAgent ? { acpRegistryAgent: opts.acpRegistryAgent } : {}), ...(opts.acpRegistryUrl ? { acpRegistryUrl: opts.acpRegistryUrl } : {}), ...(opts.acpCommand ? { acpCommand: opts.acpCommand } : {}), @@ -90,7 +88,6 @@ export async function revalidateCommand(opts: { agent?: string; model?: string; maxTurns?: number; - acpAgent?: string; acpRegistryAgent?: string; acpRegistryUrl?: string; acpCommand?: string; @@ -132,7 +129,6 @@ export async function revalidateCommand(opts: { config: buildAgentConfig({ model, maxTurns: opts.maxTurns, - acpAgent: opts.acpAgent, acpRegistryAgent: opts.acpRegistryAgent, acpRegistryUrl: opts.acpRegistryUrl, acpCommand: opts.acpCommand, diff --git a/packages/deepsec/src/preflight.ts b/packages/deepsec/src/preflight.ts index cb118e3..4cab467 100644 --- a/packages/deepsec/src/preflight.ts +++ b/packages/deepsec/src/preflight.ts @@ -133,8 +133,7 @@ export function assertAgentCredential( options: { inSandbox?: boolean } = {}, ): void { if (agentType !== undefined && !KNOWN_BACKENDS.has(agentType)) return; - // ACP agents own their authentication/credential flow behind the ACP bridge - // (for example, `atlas alta agent run` uses the local Atlas/Alta setup). + // ACP agents own their authentication/credential flow behind the selected ACP bridge. // Deepsec just connects to the bridge and should not require Claude/OpenAI env vars. if (isAcp(agentType)) return; diff --git a/packages/processor/src/__tests__/acp-agent.test.ts b/packages/processor/src/__tests__/acp-agent.test.ts index 25680ea..51c5b56 100644 --- a/packages/processor/src/__tests__/acp-agent.test.ts +++ b/packages/processor/src/__tests__/acp-agent.test.ts @@ -40,11 +40,6 @@ describe("ACP invocation resolution", () => { ); }); - it("uses an explicit Alta source", async () => { - const invocation = await buildAcpInvocation("/repo", { acpAgent: "my-agent" }); - expect(invocation.args).toEqual(["alta", "agent", "run", "--workspace", "/repo", "my-agent"]); - }); - it("accepts a full custom command string", async () => { await expect( buildAcpInvocation("/repo", { acpCommand: "node ./server.js --stdio 'quoted arg'" }), diff --git a/packages/processor/src/agents/acp-agent.ts b/packages/processor/src/agents/acp-agent.ts index ae4ef46..88253d1 100644 --- a/packages/processor/src/agents/acp-agent.ts +++ b/packages/processor/src/agents/acp-agent.ts @@ -31,8 +31,6 @@ const DEBUG = process.env.DEEPSEC_AGENT_DEBUG === "1"; interface AcpConfig { model?: string; maxTurns?: number; - /** Alta/Atlas source for `atlas alta agent run --workspace `. */ - acpAgent?: string; /** Registry agent id from https://agentclientprotocol.com, e.g. `claude-acp` or `codex-acp`. */ acpRegistryAgent?: string; /** Registry JSON URL. Defaults to the public latest registry. */ @@ -86,7 +84,6 @@ function asAcpConfig(config: Record): AcpConfig { return { model: typeof config.model === "string" ? config.model : undefined, maxTurns: typeof config.maxTurns === "number" ? config.maxTurns : undefined, - acpAgent: typeof config.acpAgent === "string" ? config.acpAgent : undefined, acpRegistryAgent: typeof config.acpRegistryAgent === "string" ? config.acpRegistryAgent : undefined, acpRegistryUrl: typeof config.acpRegistryUrl === "string" ? config.acpRegistryUrl : undefined, @@ -205,24 +202,15 @@ async function registryInvocation(config: AcpConfig): Promise { const custom = customInvocation(config); if (custom) return custom; const registry = await registryInvocation(config); if (registry) return registry; - if (config.acpAgent) { - return { - command: "atlas", - args: ["alta", "agent", "run", "--workspace", projectRoot, config.acpAgent], - env: config.acpEnv, - label: `atlas alta agent run ${config.acpAgent}`, - }; - } - throw new Error( - "ACP agent selection is required. Pass --acp-registry-agent , --acp-command , or --acp-agent for atlas alta agent run.", + "ACP agent selection is required. Pass --acp-registry-agent or --acp-command .", ); } @@ -416,8 +404,7 @@ export class AcpAgentPlugin implements AgentPlugin { const { batch, projectRoot, promptTemplate, projectInfo } = params; const config = asAcpConfig(params.config); const model = config.model ?? DEFAULT_MODEL; - const agentName = - config.acpRegistryAgent ?? config.acpAgent ?? config.acpCommand ?? "(not configured)"; + const agentName = config.acpRegistryAgent ?? config.acpCommand ?? "(not configured)"; yield { type: "started", From 67738fe654473e5ca6340332ef624849691516e0 Mon Sep 17 00:00:00 2001 From: Andy McClenaghan Date: Tue, 5 May 2026 13:01:39 +1000 Subject: [PATCH 3/4] Ignore external ACP SDK for knip --- knip.json | 1 + 1 file changed, 1 insertion(+) diff --git a/knip.json b/knip.json index 990c602..5384d0c 100644 --- a/knip.json +++ b/knip.json @@ -23,6 +23,7 @@ }, "ignore": ["fixtures/vulnerable-app/**", "samples/**"], "ignoreDependencies": [ + "@agentclientprotocol/sdk", "@anthropic-ai/claude-agent-sdk", "@openai/codex", "@openai/codex-sdk", From f350917108f28214e016a32478cc2a59c15e159f Mon Sep 17 00:00:00 2001 From: Andy McClenaghan Date: Tue, 5 May 2026 14:25:39 +1000 Subject: [PATCH 4/4] Fix ACP agent shutdown escalation --- .../processor/src/__tests__/acp-agent.test.ts | 63 ++++++++++++++++++- packages/processor/src/agents/acp-agent.ts | 34 ++++++++-- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/packages/processor/src/__tests__/acp-agent.test.ts b/packages/processor/src/__tests__/acp-agent.test.ts index 51c5b56..98b2751 100644 --- a/packages/processor/src/__tests__/acp-agent.test.ts +++ b/packages/processor/src/__tests__/acp-agent.test.ts @@ -1,5 +1,7 @@ +import { EventEmitter } from "node:events"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildAcpInvocation } from "../agents/acp-agent.js"; +import { buildAcpInvocation, killAgent } from "../agents/acp-agent.js"; const registry = { version: "1.0.0", @@ -94,3 +96,62 @@ describe("ACP invocation resolution", () => { ); }); }); + +class FakeChildProcess extends EventEmitter { + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + killed = false; + kill = vi.fn((signal?: NodeJS.Signals | number) => { + this.killed = true; + if (signal === "SIGTERM") return true; + if (signal === "SIGKILL") { + this.signalCode = "SIGKILL"; + this.emit("exit", null, "SIGKILL"); + return true; + } + return true; + }); +} + +function killFakeAgent(child: FakeChildProcess): Promise { + return killAgent(child as unknown as ChildProcessWithoutNullStreams); +} + +describe("ACP agent shutdown", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("escalates to SIGKILL when SIGTERM does not exit the bridge", async () => { + vi.useFakeTimers(); + const child = new FakeChildProcess(); + + const killed = killFakeAgent(child); + await vi.advanceTimersByTimeAsync(100); + await killed; + + expect(child.kill).toHaveBeenCalledTimes(2); + expect(child.kill).toHaveBeenNthCalledWith(1, "SIGTERM"); + expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL"); + }); + + it("does not escalate when the bridge exits after SIGTERM", async () => { + vi.useFakeTimers(); + const child = new FakeChildProcess(); + child.kill.mockImplementationOnce(() => { + child.killed = true; + setTimeout(() => { + child.signalCode = "SIGTERM"; + child.emit("exit", null, "SIGTERM"); + }, 10); + return true; + }); + + const killed = killFakeAgent(child); + await vi.advanceTimersByTimeAsync(10); + await killed; + + expect(child.kill).toHaveBeenCalledTimes(1); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); +}); diff --git a/packages/processor/src/agents/acp-agent.ts b/packages/processor/src/agents/acp-agent.ts index 88253d1..2f4e1cd 100644 --- a/packages/processor/src/agents/acp-agent.ts +++ b/packages/processor/src/agents/acp-agent.ts @@ -287,11 +287,37 @@ class DeepsecAcpClient implements acp.Client { } } -async function killAgent(child: ChildProcessWithoutNullStreams): Promise { - if (child.exitCode !== null || child.killed) return; +function hasExited(child: ChildProcessWithoutNullStreams): boolean { + return child.exitCode !== null || child.signalCode !== null; +} + +function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise { + if (hasExited(child)) return Promise.resolve(true); + + return new Promise((resolve) => { + let settled = false; + const finish = (exited: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + child.off("exit", onExit); + resolve(exited); + }; + const onExit = () => finish(true); + const timeout = setTimeout(() => finish(hasExited(child)), timeoutMs); + child.once("exit", onExit); + }); +} + +export async function killAgent(child: ChildProcessWithoutNullStreams): Promise { + if (hasExited(child)) return; + child.kill("SIGTERM"); - await new Promise((resolve) => setTimeout(resolve, 100)); - if (child.exitCode === null && !child.killed) child.kill("SIGKILL"); + const exitedAfterSigterm = await waitForExit(child, 100); + if (!exitedAfterSigterm && !hasExited(child)) { + child.kill("SIGKILL"); + await waitForExit(child, 100); + } } async function* runAcpPrompt(params: {