diff --git a/bun.lock b/bun.lock index 4c123e4c3c3..768641bf832 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "typescript": "7.0.2", }, "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk": "0.3.282", "wreq-js": "2.3.1", }, }, @@ -31,6 +32,28 @@ "qs": "^6.16.0", }, "packages": { + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.282", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.282", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.282", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.282", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.282", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.282", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.282", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.282", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.282" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-6UAerS1udzndLEx+0XW3gQWiICgfu/a+2fx/aLY3gUy+1JUQESbwYkhR40+D6d+yjueCslkLmvkPlZQFZDph6A=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.282", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NwvIIpEOORp16K7NgjWE2lt2Y+VmddvkkRoOpyKXz2H2bRixa5ToV+byp2gUaKVsCixBuAiQ2lL9e79gy5lbNQ=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.282", "", { "os": "darwin", "cpu": "x64" }, "sha512-MjVmeojCOIAdqt90j1B9ENfCwLIzMhSyzQJd6+rBn8KDKklduPLbs//rVhvIwR+MJBF67PDkczOb4VXiYPJRxA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.282", "", { "os": "linux", "cpu": "arm64" }, "sha512-hL/G7pCQnS9m8RIqxCpljaQsvITKRYdqoNeDsJGIqaFpd79redTwlovRUBtFMeDAtASwtha1wLyh24v+gUb58A=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.282", "", { "os": "linux", "cpu": "arm64" }, "sha512-2/3EtJKfNQfjn1MtTRh7EpcqlAI/UCEaiHSdlPH+AZPDnQXiHLW6JITJNdsc3MMuwjnbsiL4NJBnDr35qkIb9A=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.282", "", { "os": "linux", "cpu": "x64" }, "sha512-NxinW4k1Xh3xTCPKpsw+XXV+O3zwLtLQeJnFucc/LKQQ3p4pXJ7/hW/DIiYdmhgIIaMFkP+2xP8G5+Y8swxB2w=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.282", "", { "os": "linux", "cpu": "x64" }, "sha512-Q96hallUSHCCvmsMC08aO3YtivTbRIzzrgfdVjJAcOrQ+6tSwN0Ni4yMEwhWRnq3DVZlNDYIssd1/k7H4lS7Og=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.282", "", { "os": "win32", "cpu": "arm64" }, "sha512-4v9guW9NSDJj+cTP+aND3Wz6gAFV5OU/VP4CFjc79KgdwPpAtwm2fw5BKhneAuoUc21KU9gHWBtLLOdBvONoZw=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.282", "", { "os": "win32", "cpu": "x64" }, "sha512-oOMNLTcLGqPBHvrTVRXNL6q8LK7DL3blkuBBrAgsmkgr+pxi0E74eQEk7XQ5dYwhETa2/ZfNIwNevcYB7LAVtg=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.128.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-tl5cBFZC1jVFrTuQyvQObA4WmuNgcYVeXfriqOumgtLAHUm4gPUj18YnszBW60v0PrjDB8nBDOC6wd9CmHQFlA=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.14.0", "", {}, "sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w=="], "@hono/node-server": ["@hono/node-server@2.1.0", "", { "peerDependencies": { "hono": "^4" } }, "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg=="], @@ -87,6 +110,8 @@ "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-jRKv1NPLznMSZY5BEWciMF7zv0Tiyo2pQSxAJ3w+YWJ6y3VWNJQQQdLlV5Jx8lbOFDrJdrc9dD3GV17k3BP41A=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], @@ -191,6 +216,8 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -229,6 +256,8 @@ "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -295,10 +324,14 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index b8c6c7510c6..9f78983352d 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -963,29 +963,53 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa - **Quota:** No public quota API is used, so totals and reset times are unavailable. Insufficient-credit errors (vendor code 118) surface as HTTP 429 `insufficient_quota`. - **Operators:** Qoder Global is operated by BRIGHT ZENITH PRIVATE LIMITED under the [product service terms](https://qoder.com/product-service); Qoder CN by 通义云启(杭州)信息技术有限公司 with Alibaba Cloud. Verify `ocx provider test qoder` (or `qoder-cn`) after configuring. -### Claude Code CLI (subscription) +### Claude Agent SDK (subscription) + +> **Where this preset comes from, stated plainly.** The version that shipped in 2.65.0 was +> **against Anthropic's terms**: OpenCodex built a one-shot `claude -p` turn, replaced the harness +> system prompt with the caller's, dropped the session and stripped the harness's tools, then let a +> client that is not Claude Code drive the loop. A Claude subscription is licensed for Anthropic's +> own harnesses, and that construction spent it as an API behind a thin CLI veneer for a third-party +> agent — the usage accounts get suspended over, with the loss landing on the signed-in account +> rather than on OpenCodex. **This preset is the correction.** It takes the route Meridian takes: +> the turn runs through Anthropic's own harness, which owns the session, the prompt and the sign-in, +> so nothing impersonates Claude Code and OpenCodex forges no request. That is markedly less risky +> than building the request ourselves — and still a grey area, because the client is not Claude Code +> and the subscription is licensed for Anthropic's own harnesses. +> +> For automated clients the route without an interpretation question remains `anthropic-apikey`: +> console billing, and the plan's automated-access clause covers a key. OpenCodex can spend a Claude subscription through Anthropic's own harness instead of replaying a -Claude Code identity against the Messages API. The `claude-cli` preset runs the official Claude Code -CLI headlessly (`claude -p`, `stream-json`) once per turn: +Claude Code identity against the Messages API. The `claude-agent-sdk` preset drives Anthropic's +Claude Agent SDK — the harness and agent loop behind the Claude Code CLI — so the turn is a real +harness session rather than a converted Messages request: ```json { "providers": { - "claude-cli": { - "adapter": "claude-cli", + "claude-agent-sdk": { + "adapter": "claude-agent-sdk", "baseUrl": "https://api.anthropic.com" } } } ``` -- **Prerequisites:** `npm install -g @anthropic-ai/claude-code`, then sign in once with `claude` - (or `claude setup-token`). The CLI uses the machine's own Claude Code sign-in (the macOS Keychain - entry, or `~/.claude/.credentials.json` elsewhere). +- **Prerequisites:** a Claude Code sign-in on this machine, once: run `claude` and sign in (or + `claude setup-token`). The harness then spends that account (the macOS Keychain entry, or + `~/.claude/.credentials.json` elsewhere). The turn itself needs no separate install — it runs + through `@anthropic-ai/claude-agent-sdk`, which ships the Claude Code build it drives (about + 230 MB unpacked per platform; the same binary the `claude` npm package installs). That payload is + why the SDK is an **optional** dependency: an install that omits optional dependencies + (`bun install --omit=optional`) leaves it out, and the row then answers + `claude_agent_sdk_unavailable` until the package is installed. Compiled + builds, such as the desktop app's bundled proxy, cannot resolve a package path from inside their + own bundle: there the row drives the `claude` on `PATH` and reports `cli_not_found` when it is + missing. - **No credential stored:** this row holds no API key, and OpenCodex never reads, copies or forwards - a Claude token. The CLI owns the login and bills the account itself. A CLI that is not signed in - fails the turn with a sign-in error naming the command, instead of a generic `401`. + a Claude token. The harness owns the login and bills the account itself. An unauthenticated + harness fails the turn with a sign-in error naming the command, instead of a generic `401`. Classification follows the same fact: the preset is a keyless key row (`keyOptional`), so it needs no API key and no key field is offered for it. An API key saved on this row by other means is never handed to the harness — key billing belongs to the `anthropic-apikey` preset. @@ -1001,32 +1025,37 @@ CLI headlessly (`claude -p`, `stream-json`) once per turn: OpenCodex runs as, so every request routed through this row — from any client of the proxy — spends that one Claude account. There is no per-client account, no pooling and no multiplexing; giving several people their own Claude usage needs one proxy user per sign-in. -- **Input media:** the row publishes its models as text-only for v1. The CLI accepts an image frame - on its stream-json input, but no headless turn has been shown to hand those bytes to the model, so +- **Input media:** the row publishes its models as text-only for v1. The harness parses an image + block without complaint, but no headless turn has been shown to hand those bytes to the model, so an image sent straight to this provider is refused (`unsupported_input_modality`, the same refusal the Qoder presets make) instead of being silently dropped and answered blind. With the vision sidecar on the request path, images are captioned into text before they reach the row. -- **Isolation:** every turn runs in a scoped child environment with no inherited `ANTHROPIC_*` - variable (a `claude` already pointed at this proxy therefore cannot loop back into it), telemetry, - feedback and the auto-updater disabled, and `--tools ""`, `--strict-mcp-config` plus - `--setting-sources ""`. The harness loads no CLAUDE.md, skill, hook, plugin or MCP server from the - machine and can neither read, write, exec nor browse. No session is persisted between turns. -- **System prompt:** the caller's system and developer prompts replace the Claude Code preset - (`--system-prompt-file`), so the turn answers the client's contract rather than the harness - persona. The folded prompt is staged in a private per-turn file (mode `0600`) and passed by path, - because process arguments are world-readable through process listing; a request that carries - neither a system nor a developer prompt gets an empty file, which replaces the preset with nothing. -- **Tool ownership:** v1 is text and reasoning only, exactly like the CodeBuddy and Qoder presets: - with no tool channel, approval, sandboxing and execution stay with the client. The shared - capture-only tool bridge is the documented follow-up. +- **Isolation:** every turn runs in a scoped environment with no inherited `ANTHROPIC_*` variable + (a `claude` already pointed at this proxy therefore cannot loop back into it), telemetry, feedback + and the auto-updater disabled, built-in tools off (`tools: []`) and no setting sources, so the + harness loads no CLAUDE.md, skill, hook, plugin or MCP server from the machine and can neither + read, write, exec nor browse. Each turn runs in an empty scratch directory of its own, so the + working-directory and git-status context the harness preset reports describes no part of the + machine OpenCodex happens to run on. +- **Session:** each turn is its own harness session and nothing is persisted (`persistSession: false`), + so the operator's `~/.claude` transcript directory does not accumulate another client's + conversations. Continuity comes from the client replaying its transcript; the harness still owns + the turn's process, prompt and sign-in, and the shared prefix keeps the prompt-cache read cheap. +- **System prompt:** the caller's system and developer prompts are appended to the harness preset + rather than replacing it, so the turn is the genuine agent loop with the client's contract added + on top. No part of the prompt travels through a world-readable argument. +- **Tool ownership:** the client keeps it, like the CodeBuddy and Qoder presets: the request's tool + catalog is served to the model through an in-process MCP server that captures calls instead of + executing them, so approval, sandboxing and execution stay with the client. The advertised + schemas are the request's own JSON Schema, passed through unchanged; the server runs inside the + proxy process, so no extra executable, no `--mcp-config` file and no argument vector is involved. - **Destination:** the canonical row names `https://api.anthropic.com` because that is where the subscription's traffic lands. OpenCodex never sends that request itself, and overriding the base URL fails closed rather than handing the turn to another environment. -> **Terms:** this preset spends your Claude subscription through Anthropic's own CLI. Whether -> driving that harness headlessly from a proxy fits your plan's terms is a question between you and -> Anthropic. OpenCodex does not convert the login into an API key and does not reproduce the CLI's -> HTTP identity. +> **Terms:** see the warning at the top of this section. The harness runs the turn, signs in and +> bills, so OpenCodex converts no login into an API key and reproduces no Claude Code HTTP identity +> itself. That is the safer route, not a clean one. ### A6API credit quota diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 745e3536ad0..bf15a5b3ae2 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -194,7 +194,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | Field | Type | Meaning | | --- | --- | --- | -| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `claude-cli`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`), `codebuddy`, `qoder`. | +| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `claude-agent-sdk`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`), `codebuddy`, `qoder`. | | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; collision-safe key presets preserve an older same-named custom destination. | | `proxy?` | `string \| null` | Per-provider egress route. Omit it to inherit the global proxy decision; use `"direct"` or `null` to force direct egress; or provide an absolute `http://`, `https://`, `socks5://`, or `socks5h://` proxy URL. An empty string is rejected. | | `noProxy?` | `string \| string[]` | Destinations this provider reaches directly, using `NO_PROXY` host-pattern syntax. A match bypasses both this provider's own proxy and an inherited global proxy. | diff --git a/package.json b/package.json index f3f51f4cbca..676e39a7410 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "typescript": "7.0.2" }, "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk": "0.3.282", "wreq-js": "2.3.1" }, "overrides": { diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2cd657d98f6..b36bda3e855 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -286,7 +286,8 @@ "claude-auth-detect.test.ts": "claude-integration", "claude-auth-mode.test.ts": "claude-integration", "claude-authmode-migration.test.ts": "claude-integration", - "claude-cli-adapter.test.ts": "providers", + "claude-agent-sdk-adapter.test.ts": "providers", + "claude-provider-rename-migration.test.ts": "providers", "claude-cli.test.ts": "claude-integration", "claude-config-first-party.test.ts": "cli", "claude-code-thought-signature-scope.test.ts": "claude-integration", diff --git a/src/adapters/claude-agent-sdk/adapter.ts b/src/adapters/claude-agent-sdk/adapter.ts new file mode 100644 index 00000000000..384f5477399 --- /dev/null +++ b/src/adapters/claude-agent-sdk/adapter.ts @@ -0,0 +1,129 @@ +/** + * The Claude Agent SDK row: a Claude subscription spent through Anthropic's own harness. + * + * What this adapter is NOT is the construction that shipped in 2.65.0 — a one-shot `claude -p` turn + * with the caller's prompt swapped in for the harness preset, no session and the tools stripped — + * which was against Anthropic's terms: a subscription licensed for Anthropic's own harnesses, spent + * as an API for a third-party agent loop, and the usage accounts get suspended over. This adapter is + * the correction and takes the route Meridian takes, with the harness doing the work: the turn runs + * through the Claude Agent SDK (`./sdk-turn.ts` documents the run), the caller's instructions are + * APPENDED to the harness preset instead of replacing it, and the client's tool catalog reaches the + * model through an in-process MCP server that captures calls and executes nothing. Still a grey area + * — the client is not Claude Code — and `anthropic-apikey` remains the route here without an + * interpretation question. + */ +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import type { AdapterRequest, ProviderAdapter } from "../base"; +import { CLAUDE_CLI_PROFILES } from "./profiles"; +import { buildClaudeAgentSdkToolBridge } from "./sdk-bridge"; +import { runClaudeAgentSdkTurn, type ClaudeAgentSdkDeps } from "./sdk-turn"; + +export { CLAUDE_CLI_QUIET_ENV, buildChildEnv } from "./env"; +export type { ClaudeAgentSdkDeps } from "./sdk-turn"; +/** Historical name for the same seam: the row changed transport, not its injectables. */ +export type ClaudeCliAdapterDeps = ClaudeAgentSdkDeps; + +/** + * Refuse image input the way the Qoder presets do. + * + * The harness parses an image block in its input without complaint, but nothing verifies that a + * headless turn hands those bytes to the model, and an image the harness drops produces a confident + * answer to the wrong question. The row therefore publishes text-only models — `noVisionModels` on + * the registry entry — and refuses a direct image here; an operator with the vision sidecar on the + * request path still gets images captioned into text before they reach this adapter. + */ +function hasImageInput(parsed: OcxParsedRequest): boolean { + return parsed.context.messages.some(message => + Array.isArray(message.content) && message.content.some(part => part.type === "image"), + ); +} + +/** + * Turn the harness's unauthenticated turn into the one action a subscription user can take. + * + * An unauthenticated harness does not fail the process: it emits an ordinary terminal `result` frame + * with `is_error: true` and the text "Not logged in · Please run /login", which the shared mapper + * reports as a generic 401. Nothing in that reaches for the harness's own sign-in, so the operator is + * left guessing whether the key, the provider row or the account is wrong. + * + * The error code keeps its historical `claude_cli_` prefix: logs and client messages already carry + * that identifier, and renaming it would be churn without a reader. + */ +export function withClaudeLoginHint(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void { + return event => { + if (event.type === "error" && event.status === 401 && /not logged in|please run \/login/i.test(event.message)) { + emit({ + ...event, + code: "claude_cli_not_logged_in", + message: + "Claude Code is not signed in, so this subscription provider has no account to spend. " + + "Run `claude` once and sign in (or `claude setup-token`), then retry. " + + `CLI reported: ${event.message}`, + }); + return; + } + emit(event); + }; +} + +/** + * Create the Claude Agent SDK adapter: one harness-run turn per request. + * + * As with CodeBuddy and Qoder, `runTurn` owns the turn and the HTTP path is disabled — the harness + * performs the transport, and OpenCodex contributes the request projection, the tool catalog, the + * stream mapping and the turn's lifecycle. + */ +export function createClaudeAgentSdkAdapter( + provider: OcxProviderConfig, + deps: ClaudeAgentSdkDeps = {}, +): ProviderAdapter { + return { + name: "claude-agent-sdk", + + buildRequest(): AdapterRequest { + return { url: provider.baseUrl, method: "POST", headers: {}, body: "" }; + }, + async *parseStream(): AsyncGenerator { + yield { type: "error", message: "Claude Agent SDK adapter uses runTurn; the fetch/parseStream path is disabled." }; + }, + + async runTurn(parsed, incoming, emit): Promise { + if (hasImageInput(parsed)) { + emit({ + type: "error", + message: "Claude Agent SDK image input is not enabled because this route has no verified multimodal contract.", + status: 400, + errorType: "invalid_request_error", + code: "unsupported_input_modality", + retryable: false, + }); + return; + } + // The catalog is validated before the harness starts: an unbuildable one is the client's 400, + // not a turn that dies somewhere inside the agent loop. + let toolBridge; + try { + toolBridge = await buildClaudeAgentSdkToolBridge(parsed); + } catch (err) { + emit({ + type: "error", + message: `Invalid Claude tool catalog: ${err instanceof Error ? err.message : String(err)}`, + status: 400, + errorType: "invalid_request_error", + code: "tool_catalog_invalid", + retryable: false, + }); + return; + } + await runClaudeAgentSdkTurn({ + profiles: CLAUDE_CLI_PROFILES, + provider, + parsed, + incoming, + emit: withClaudeLoginHint(emit), + ...(toolBridge ? { toolBridge } : {}), + deps, + }); + }, + }; +} diff --git a/src/adapters/claude-agent-sdk/env.ts b/src/adapters/claude-agent-sdk/env.ts new file mode 100644 index 00000000000..e36a7e0acab --- /dev/null +++ b/src/adapters/claude-agent-sdk/env.ts @@ -0,0 +1,54 @@ +/** + * Scoped child environment and the quiet flags for one Claude Agent SDK turn. + * + * Split out of `adapter.ts` because the turn runner (the Agent SDK path) owns the child process + * now: the adapter only decides what the turn is, this module decides what it runs with. + */ +import { baseScopedEnv } from "../coding-agent/turn"; +import type { ClaudeCliProfile } from "./profiles"; + +/** + * Quiet the harness's own outbound traffic. + * + * The turn is infrastructure, not somebody's editor: nobody reads its usage metrics, its crash + * reports describe a process the operator never launched by hand, and an auto-updater swapping the + * binary underneath a running proxy is skew rather than a feature. The shared scoped env inherits + * none of these keys, so these values are the ones the turn runs with. + */ +export const CLAUDE_CLI_QUIET_ENV: Readonly> = { + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY: "1", + CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1", + DISABLE_AUTOUPDATER: "1", + DISABLE_TELEMETRY: "1", + DISABLE_ERROR_REPORTING: "1", + DISABLE_FEEDBACK_COMMAND: "1", +}; + +/** + * Build the scoped child-process environment for one Claude Agent SDK turn. + * + * No credential is layered here on purpose. The harness reads the operator's own sign-in (the + * macOS Keychain entry, or `~/.claude/.credentials.json` elsewhere), which is exactly the property + * this provider exists for: the token never enters OpenCodex, its config, or a child environment. + * + * The shared base env also drops every inherited `ANTHROPIC_*` variable, which is what keeps a + * `claude` the operator already points at this proxy from looping back into it. + * + * `USER` is the one inherited name added back, and it is not a credential: the harness resolves its + * own sign-in by account name, so a scoped env without it makes a signed-in machine answer "not + * logged in". Measured with `claude auth status` under `env -i`: `USER` alone reports + * `loggedIn: true`, `LOGNAME` alone or neither reports `loggedIn: false`. + * + * The Agent SDK replaces the child environment entirely with this map (it does not merge + * `process.env`), which is the same scoping the spawned CLI got before this row moved onto the SDK. + */ +export function buildChildEnv(_profile: ClaudeCliProfile, _apiKey: string): Record { + const env: Record = { + ...baseScopedEnv(), + ...CLAUDE_CLI_QUIET_ENV, + }; + const user = process.env.USER; + if (user) env.USER = user; + return env; +} diff --git a/src/adapters/claude-agent-sdk/harness-process.ts b/src/adapters/claude-agent-sdk/harness-process.ts new file mode 100644 index 00000000000..9404394c0ff --- /dev/null +++ b/src/adapters/claude-agent-sdk/harness-process.ts @@ -0,0 +1,768 @@ +/** + * Ownership of the harness child process for one Agent SDK turn. + * + * The SDK's own spawn is not enough to hold this row's promise. `spawnClaudeCodeProcess` replaces + * `ProcessTransport.spawnLocalProcess`, and the local spawn is the only place in the bundle that + * reads the child's stderr (`options.stderr` appears nowhere else) and the only place that sees the + * real process exit. Replacing it without taking both over would darken the harness's stderr + * evidence, so this module reproduces the pump and keeps the exit. + * + * What the SDK cannot report is that the process is gone. `Query.performCleanup` waits at most + * 2000 ms for `transport.waitForExit()`, while `ProcessTransport.close` schedules SIGTERM 2000 ms + * out and SIGKILL 5000 ms after that, both timers unref'd. `query.return()` therefore settles while + * a TERM-resistant harness is still running: a turn that deleted its scratch cwd and answered the + * client on that signal would leave the process, its pipes and its working directory behind, and + * repeated cancellations would accumulate them. + * + * Owning the child turns "the SDK gave up" into "the process is confirmed gone": a bounded + * TERM -> grace -> KILL ladder that awaits the child's `close` before the caller may clean up. The + * ceiling bounds the observation, not the kill - SIGKILL cannot be refused, so a child that never + * reports `close` is still dead when the ceiling elapses, it just cannot be watched any longer. + * + * `close` is not the same event as "the process ended", though. Node emits `exit` when the process + * is gone and `close` once its stdio is released, and a launcher hands its pipes to a descendant: + * the harness ends, the pipe stays open, `close` never arrives - and a ladder that watches only + * `close` waits out its entire ceiling and then calls an unresolved teardown a confirmed one. The + * two events are kept apart here, the stdio this turn owns is destroyed when the drain cannot finish + * on its own, the process tree is signalled where the platform gives us one, and whatever remains + * unresolved comes back as such instead of being rounded to "gone". + * + * The direct parent is not the tree either, and the difference decides whether the scratch cwd may + * be deleted. When the launcher exits but a descendant inherited its pipes, the pid this turn holds + * is gone while the descendant is not: `taskkill /T` on Windows then has no process left to walk + * from, and a ladder that skips the tree signal because "the parent exited" leaves that descendant + * running against a directory the turn is about to delete. So the tree signal runs for every child + * that has not closed, the group is signalled on POSIX even when the leader is gone, and a tree the + * platform would not signal is reported as an unresolved tree rather than folded into "gone". + * + * Finally, ownership has to bound the work and outlive the turn that started it. The bounded cleanup + * lease is reserved before the process is spawned, not after the ladder has begun: a harness this + * process cannot account for is never started, and a turn that would exceed the bound is refused + * instead of adding to the pile. The lease is held until that child's `close` proves the exit, and a + * survivor that never closes keeps holding it. A client can cancel the response and core returns the + * turn's own admission on that cancel - the harness is not the turn's, so nothing about it comes back + * on a timer, on an eviction, or because its local pipes were closed. + * + * One `close` is not even the parent's own: taking this turn's side of the pipes back is what makes + * Node emit it for a child whose stdio a descendant inherited, so the event can arrive for a process + * that died with a tree this ladder could not reach - or for one the ladder never saw die at all. + * Returning the capacity there would hand out a slot for a descendant that is still using the working + * directory. A child whose tree call was refused therefore keeps its lease past that `close`, and + * only the platform's answer that no member of the group is left - `kill(-pid, 0)` on POSIX, nothing + * at all on Windows - hands it back. The reason on the quarantine entry keeps naming what the ladder + * saw of the direct parent (`running`, `pipes-held`, `unresolved-tree`); the lease is the part that + * speaks for the tree. + */ +import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions as NodeSpawnOptions } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; +import type { Readable, Writable } from "node:stream"; +import type { SpawnedProcess } from "@anthropic-ai/claude-agent-sdk"; +import { createAdmissionGate, type AdmissionLease, type AdmissionMetrics } from "../../lib/admission"; + +/** + * Terminate the child's process tree where the platform supports it and report whether a signal + * reached anything. The direct child is signalled separately, so `false` here is a degraded path, + * not a dead end. + */ +export type HarnessTreeKillFn = (signal: NodeJS.Signals, pid: number) => boolean; + +/** Injectable spawn for tests; production uses `node:child_process`, as the sibling CLI turn does. */ +export type HarnessSpawnFn = (command: string, args: readonly string[], options: NodeSpawnOptions) => ChildProcess; + +/** + * The SDK's spawn request, kept structural so the adapter imports no SDK value at runtime. The + * fields are the ones the SDK documents for `spawnClaudeCodeProcess`. + */ +export interface HarnessSpawnRequest { + command: string; + args: string[]; + cwd?: string; + env: { [name: string]: string | undefined }; + /** The SDK's forwarded abort signal; see the second net in `createHarnessProcessSupervisor`. */ + signal: AbortSignal; +} + +export interface HarnessProcessSupervisorOptions { + /** + * Bounded stderr sink. A custom spawner switches the SDK's stderr pump off, so the harness's own + * error text has to be read here or a dead harness reports nothing about why it died. + */ + onStderr: (chunk: string) => void; + /** Injectable spawn for tests. */ + spawn?: HarnessSpawnFn; + /** Grace between SIGTERM and SIGKILL (ms). */ + killGraceMs?: number; + /** Ceiling for a child that never reports close after SIGKILL (ms). */ + reapTimeoutMs?: number; + /** Platform seam for the tree terminator and the process-group decision (tests). */ + platform?: NodeJS.Platform; + /** Test seam for the tree terminator; see `defaultKillProcessTree`. */ + killProcessTree?: HarnessTreeKillFn; +} + +/** One child the ladder could not settle, named rather than rounded to "gone". */ +export interface HarnessUnresolvedChild { + pid: number | undefined; + /** + * `pipes-held`: the process exited, but a descendant inherited its stdout/stderr, so `close` was + * withheld and the stdio had to be reclaimed instead of awaited. `running`: no exit was observed + * inside the ladder's ceilings, which in the presence of SIGKILL also means the signal never + * reached it. `unresolved-tree`: the direct process is gone while the tree it led could not be + * signalled, so a descendant behind it may still be alive - the one case where the working + * directory must not be released, because nothing here can name what is still using it. + * A child that is itself still running keeps `running` rather than the tree verdict: a live process + * this turn can name already fails `allExited`, which is the same conclusion by a shorter route. + */ + reason: "pipes-held" | "running" | "unresolved-tree"; + /** True when a signal the ladder sent was refused for a child that was not already gone. */ + signalFailed: boolean; +} + +/** What `terminate` could establish about the processes this turn started. */ +export interface HarnessCleanupOutcome { + /** Every child closed on its own: the process is gone and its stdio was released without help. */ + confirmed: boolean; + /** + * Nothing this turn started can still be running: no child without an observed exit, and no tree + * that could not be reached. This is the gate for releasing the scratch cwd, so an unreachable + * tree counts as "not established" instead of as gone. + */ + allExited: boolean; + /** The unresolved remainder; empty exactly when `confirmed`. */ + unresolved: HarnessUnresolvedChild[]; + /** Survivors handed to the bounded quarantine; each still holds its own cleanup lease. */ + quarantined: number; +} + +export interface HarnessProcessSupervisor { + /** The SDK's spawn hook; every harness process this turn starts registers here. */ + spawn: (request: HarnessSpawnRequest) => SpawnedProcess; + /** Bounded TERM -> KILL for everything still alive; reports what it could and could not settle. */ + terminate: () => Promise; +} + +const DEFAULT_KILL_GRACE_MS = 2_000; +const DEFAULT_REAP_TIMEOUT_MS = 5_000; + +/** + * How many harness processes this adapter owns at once. The lease is reserved before the spawn, so the + * number bounds the work rather than describing it afterwards: the process that would be the next + * harness is not started, and its turn is refused with `HARNESS_CAPACITY_CODE` instead of piling on. + * The number is deliberately above any realistic concurrency for a subscription route, so reaching it + * means harnesses are not dying - and then refusing new work is the honest answer. + */ +export const MAX_ACTIVE_HARNESS_TEARDOWNS = 32; + +/** + * When a quarantined survivor is old enough to be called out. It is only a warning: the entry and its + * lease stay, because a process that has not reported `close` has not been shown to be gone, and the + * accounting is the one thing about it this process can still keep true. + */ +const QUARANTINE_STALE_MS = 120_000; + +/** Error code for a turn refused because the bounded harness ownership is exhausted. */ +export const HARNESS_CAPACITY_CODE = "harness_capacity_exhausted"; + +/** Thrown from the spawn hook when no cleanup lease is free; the turn is refused, not started. */ +export class HarnessCapacityError extends Error { + readonly code: string = HARNESS_CAPACITY_CODE; + constructor(readonly limit: number) { + super( + `Claude Agent SDK harness capacity reached (${limit} owned processes); refusing to start another`, + ); + this.name = "HarnessCapacityError"; + } +} + +/** Recreated by the test seam, so a case that fills the bound does not leak it into the next one. */ +let harnessTeardownGate = createAdmissionGate("harness_teardowns", MAX_ACTIVE_HARNESS_TEARDOWNS); + +/** + * One teardown that did not settle, owned here rather than by the turn that started it. + * + * A turn's admission is released when its response body ends, and a client that cancels ends that + * body while the teardown is still running. Holding the lease and the child handles here is what + * separates "the turn is over" from "the harness is gone": the lease is not returned while an owned + * process is still alive, a later turn sweeps the survivors, and nothing is dropped silently. + */ +interface HarnessQuarantineEntry { + readonly children: HarnessChild[]; + readonly pid: number | undefined; + readonly reason: HarnessUnresolvedChild["reason"]; + readonly since: number; + /** Set once the age warning has been emitted, so a long-lived survivor is named once, not every turn. */ + warned: boolean; +} + +const quarantine: HarnessQuarantineEntry[] = []; + +/** + * True while any member of the process group this child led is still present. + * + * `kill(-pid, 0)` delivers nothing, so it can only answer the question and can never harm whoever is + * behind the answer - which matters, because a group id outlives its leader and can be reused + * afterwards. ESRCH is the group being empty, the one answer that establishes a tree is gone. + * Windows has no such question to ask: a dead parent's tree cannot be enumerated there, so the + * ownership stays instead of being guessed away. + */ +export type HarnessTreeProbeFn = (pid: number) => boolean; + +function defaultTreeMemberAlive(pid: number): boolean { + if (process.platform === "win32") return true; + try { + process.kill(-pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException | undefined)?.code !== "ESRCH"; + } +} + +/** + * Recreated by the test seam, like the gate above: a case that pins what a survivor keeps when its + * tree cannot be reached decides the answer itself instead of probing the machine it runs on. + */ +let harnessTreeProbe: HarnessTreeProbeFn = defaultTreeMemberAlive; + +/** Test seam: the probe is process state, so a case that asserts on it sets its own answer. */ +export function setHarnessTreeProbeForTests(probe?: HarnessTreeProbeFn): void { + harnessTreeProbe = probe ?? defaultTreeMemberAlive; +} + +/** One quarantined teardown, as reported to tests and diagnostics. */ +export interface HarnessQuarantineSnapshot { + pid: number | undefined; + reason: HarnessUnresolvedChild["reason"]; + /** Milliseconds since the teardown was quarantined. */ + ageMs: number; +} + +/** What the bounded cleanup accounting currently holds. */ +export function harnessTeardownMetrics(): Readonly { + return harnessTeardownGate.metrics(); +} + +/** The survivors this process still owns after their turns gave up. */ +export function harnessQuarantineSnapshot(now = Date.now()): HarnessQuarantineSnapshot[] { + return quarantine.map(entry => ({ pid: entry.pid, reason: entry.reason, ageMs: now - entry.since })); +} + +/** + * Sweep the quarantine: drop teardowns that settled, ask the platform about the ones whose tree was + * never reached, and retry the one thing that is still safe to retry for the rest. Called from the + * turn path, so the sweep costs nothing while the quarantine is empty and needs no timer of its own. + */ +export function reapHarnessQuarantine(now = Date.now()): number { + for (let index = quarantine.length - 1; index >= 0; index -= 1) { + const entry = quarantine[index]!; + // A closed handle is not a settled one while the tree behind it was never reached: the `close` + // the ladder's own pipe reclamation produced says the parent's stdio is gone and nothing else. + // The probe is asked here, and only an answer that no member of the group is left clears it. + for (const child of entry.children) if (child.treeOwnershipUnresolved) child.settleTreeOwnership(); + const live = entry.children.filter(child => child.ownsUnsettledProcess); + if (live.length === 0) { + // Either the survivor's `close` arrived between turns - an observation on a handle pinned to + // that process, not a pid that could have been recycled - or the tree probe found the group + // empty. The entry is only the record now, so it can go. + quarantine.splice(index, 1); + continue; + } + const ageMs = now - entry.since; + if (ageMs > QUARANTINE_STALE_MS && !entry.warned) { + // Named once. The entry and its lease stay, because a process that has not reported `close` + // has not been shown to be gone: returning its capacity here would put the leak back where it + // started, this time behind a count that says the harness is free. + entry.warned = true; + console.warn( + `opencodex: claude-agent-sdk harness ${entry.pid ?? "?"}:${entry.reason} is still owned after ` + + `${Math.round(ageMs / 1000)}s; its capacity stays reserved until it reports an exit`, + ); + } + // The last thing this turn knows how to do, retried where a later turn can see the result. Only + // the direct handle is signalled: it is pinned to the process that was spawned, so it cannot + // reach a recycled pid, and the tree was already signalled while the ladder still had it. + for (const child of live) { + try { child.kill("SIGKILL"); } catch { /* the process is gone; `close` carries it */ } + } + } + return quarantine.length; +} + +/** Test seam: the accounting is process state, so a case that asserts on it starts from empty. */ +export function resetHarnessQuarantineForTests(): void { + for (const entry of quarantine) for (const child of entry.children) child.releaseTeardownLease(); + quarantine.length = 0; + harnessTreeProbe = defaultTreeMemberAlive; + harnessTeardownGate = createAdmissionGate("harness_teardowns", MAX_ACTIVE_HARNESS_TEARDOWNS); +} + +/** + * Terminate the child and the descendants it leads. + * + * POSIX gets the process group the spawn created for the harness, which is how a child of the + * harness is reached when only the harness's own pid is known. Windows gets `taskkill /T /F`, the + * same tree terminator the spawned-CLI turn uses, because a `.cmd` shim there would otherwise leave + * the real CLI running. A runtime that refuses either call leaves the direct-child signal below, + * which is why this returns a verdict instead of throwing. + */ +function defaultKillProcessTree(signal: NodeJS.Signals, pid: number): boolean { + if (process.platform === "win32") { + try { + execFileSync(`${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`, ["/PID", String(pid), "/T", "/F"], { + stdio: "pipe", + windowsHide: true, + }); + return true; + } catch { + return false; + } + } + try { + process.kill(-pid, signal); + return true; + } catch (err) { + // ESRCH is the group being empty, not the signal being refused: the group id this spawn created + // stays usable while any member lives, so "no such process group" means there is no descendant + // left to reach. Reporting it as a failure would mark a settled tree as unreachable. + return (err as NodeJS.ErrnoException | undefined)?.code === "ESRCH"; + } +} + +type ExitListener = (code: number | null, signal: NodeJS.Signals | null) => void; +type ErrorListener = (error: Error) => void; +interface ExitEntry { listener: ExitListener; once: boolean } +interface ErrorEntry { listener: ErrorListener; once: boolean } + +/** + * One harness process, owned by this adapter rather than by the SDK. + * + * `SpawnedProcess` is the SDK's own interface, so the SDK drives this object exactly as it drives + * the local spawn it would otherwise use. Two additions carry the turn: `closed` is the real + * teardown signal (the child's `close`, i.e. exit plus drained stdio) and the listener entries are + * kept here so the ladder can still reach listeners the SDK registered. + */ +class HarnessChild implements SpawnedProcess { + readonly stdin: Writable; + readonly stdout: Readable; + private readonly child: ChildProcess; + private readonly exitEntries = new Set(); + private readonly errorEntries = new Set(); + /** + * The bounded cleanup lease reserved for this child before it was spawned. It is returned when the + * child's `close` proves it is gone, and it is what keeps a survivor out of the next turn's pile: + * the lease is not the turn's, so nothing about the turn ending releases it. + */ + private readonly teardownLease: AdmissionLease | null; + private leaseReleased = false; + private closedFlag = false; + private exitedFlag = false; + private signalFailures = 0; + private treeFailures = 0; + /** + * Set by the ladder when this child is handed to the quarantine as an unresolved tree. It is what + * keeps the direct child's `close` from returning the capacity afterwards: that event can be the + * ladder's own pipe reclamation talking, and it says nothing about the descendant behind it. + */ + private treeOwnershipUnsettled = false; + private resolveClosed: () => void = () => undefined; + private readonly closeBarrier = new Promise(resolve => { this.resolveClosed = resolve; }); + + constructor(child: ChildProcess, onStderr: (chunk: string) => void, teardownLease: AdmissionLease | null = null) { + const { stdin, stdout } = child; + if (stdin === null || stdout === null) { + throw new Error("the harness process was spawned without piped stdio"); + } + this.child = child; + this.teardownLease = teardownLease; + this.stdin = stdin; + this.stdout = stdout; + // The ladder signals the child as soon as the turn ends, while the SDK may still be writing into + // a pipe whose process is already gone. An EPIPE with no listener is an uncaught exception, so + // both ends are absorbed here; the exit still reaches the turn through `close` and the stderr + // tail, exactly as the sibling spawned-CLI turn handles the same race. + stdin.on("error", () => { /* the harness is gone; carried by close and the stderr tail */ }); + stdout.on("error", () => { /* the read end went away with the process */ }); + this.pumpStderr(child, onStderr); + // The process ending and its stdio being released are two events. Node withholds `close` while a + // descendant still holds the write end of a pipe, so `exit` is the only carrier of "this pid is + // gone" and the ladder needs it to tell a drain failure from a live process. + child.once("exit", () => { this.exitedFlag = true; }); + child.once("close", (code, signal) => this.deliverExit(code, signal)); + child.once("error", error => { + // A launch failure has no process to reap and is not guaranteed to emit `close` on every + // runtime, so it counts as gone here; otherwise the ladder would wait on a process that never + // existed. The same allowance the spawned-CLI turn makes for a synchronous spawn failure. + if (child.pid === undefined) this.deliverExit(null, null); + for (const entry of [...this.errorEntries]) { + if (entry.once) this.errorEntries.delete(entry); + entry.listener(error); + } + }); + } + + /** True once the child has closed, which is the only signal that its stdio is released too. */ + get closed(): boolean { + return this.closedFlag; + } + + /** True once the process itself is gone, whether or not its stdio has been released. */ + get exited(): boolean { + return this.exitedFlag; + } + + /** True once a signal was refused for a child that was not already gone. */ + get signalFailed(): boolean { + return this.signalFailures > 0; + } + + /** + * Give the reserved capacity back. Called when this child's `close` is observed - the one event + * that says the process is gone - and by the test seam that resets the module's accounting. + */ + releaseTeardownLease(): void { + if (this.leaseReleased) return; + this.leaseReleased = true; + this.teardownLease?.release(); + } + + /** + * The ladder could not deliver the tree call for this child, so what stands behind it is unproven - + * whether the direct process was already gone at the deadline or is only the one that goes away + * later. Ownership is latched before the local pipes are reclaimed, because reclaiming them is + * itself what makes Node emit the `close` that would otherwise hand the capacity to a descendant + * that is still there. + */ + markTreeOwnershipUnsettled(): void { + this.treeOwnershipUnsettled = true; + } + + /** True while this handle owns something the platform has not yet shown to be gone. */ + get ownsUnsettledProcess(): boolean { + return !this.closedFlag || this.treeOwnershipUnsettled; + } + + /** True while a descendant behind a gone parent may still be alive, and nothing has disproved it. */ + get treeOwnershipUnresolved(): boolean { + return this.treeOwnershipUnsettled; + } + + /** + * Ask the platform whether anything behind this child is left, and return the capacity only on an + * answer that says no. A child whose process is still running keeps its own answer - the probe is + * for the case where the parent's `close` already arrived and the ownership survived it. + */ + settleTreeOwnership(probe: HarnessTreeProbeFn = harnessTreeProbe): boolean { + if (!this.treeOwnershipUnsettled) return true; + if (!this.closedFlag) return false; + const pid = this.child.pid; + if (pid !== undefined && probe(pid)) return false; + this.treeOwnershipUnsettled = false; + this.releaseTeardownLease(); + return true; + } + + /** + * True when the tree could not be signalled while this child was already gone: the descendants + * behind a dead pid are then neither named nor reachable from here. + */ + get treeUnreachable(): boolean { + return this.treeFailures > 0; + } + + get pid(): number | undefined { + return this.child.pid; + } + + get closedWait(): Promise { + return this.closeBarrier; + } + + get killed(): boolean { + return this.child.killed; + } + + get exitCode(): number | null { + return this.child.exitCode; + } + + get signalCode(): NodeJS.Signals | null { + return this.child.signalCode; + } + + kill(signal: NodeJS.Signals): boolean { + return this.child.kill(signal); + } + + /** + * Signal this child and, where the platform supports it, the tree it leads. + * + * The direct signal is sent either way: the group call is how the harness's own children are + * reached, and the direct call is what still works when that path is unavailable. A refusal is + * only a failure while the child is not known to be gone - every runtime returns `false` for a pid + * that no longer exists, which is the outcome the ladder wanted. + * + * The exception is the one that matters: a refused tree call is not "nothing left to signal". It is + * a descendant still holding this child's pipes and using its working directory, with nothing here + * that can reach it - and that is true whether the parent was already gone when the call was made + * or exits later in the ladder. Recording it only in the first case would lose exactly the timing + * that matters: TERM and KILL reach no tree, the direct KILL ends the parent, the descendant keeps + * the pipes, and the teardown reads `pipes-held` with "everything exited" for a tree it never + * touched. + */ + requestTermination(signal: NodeJS.Signals, killTree: HarnessTreeKillFn | undefined): boolean { + const pid = this.child.pid; + let treeDelivered = false; + if (killTree !== undefined && pid !== undefined) { + try { + treeDelivered = killTree(signal, pid) === true; + } catch { + treeDelivered = false; + } + } + let directDelivered = false; + try { + directDelivered = this.child.kill(signal); + } catch { + directDelivered = false; + } + if (!treeDelivered && !directDelivered && !this.gone) this.signalFailures += 1; + if (!treeDelivered && killTree !== undefined && pid !== undefined) this.treeFailures += 1; + return treeDelivered || directDelivered; + } + + /** + * Release this side of the child's stdio. + * + * A descendant that inherited the harness's pipes keeps `close` withheld after the harness itself + * is gone; those pipes have no other owner, so the turn takes them back rather than awaiting a + * `close` that cannot arrive. Destroying the write end also hands a lingering harness the stdin + * EOF it is waiting for. Taking the pipes back is itself what makes Node emit that `close`, which + * is why the ladder latches its tree verdict before this runs. + */ + reclaimPipes(): void { + for (const stream of [this.stdin, this.stdout, this.child.stderr]) { + try { + stream?.destroy(); + } catch { + /* already released */ + } + } + } + + private get gone(): boolean { + return this.exitedFlag + || this.closedFlag + || this.child.exitCode !== null + || this.child.signalCode !== null + || this.child.pid === undefined; + } + + on(event: "exit", listener: ExitListener): void; + on(event: "error", listener: ErrorListener): void; + on(event: "exit" | "error", listener: ExitListener | ErrorListener): void { + if (event === "exit") this.exitEntries.add({ listener: listener as ExitListener, once: false }); + else this.errorEntries.add({ listener: listener as ErrorListener, once: false }); + } + + once(event: "exit", listener: ExitListener): void; + once(event: "error", listener: ErrorListener): void; + once(event: "exit" | "error", listener: ExitListener | ErrorListener): void { + if (event === "exit") this.exitEntries.add({ listener: listener as ExitListener, once: true }); + else this.errorEntries.add({ listener: listener as ErrorListener, once: true }); + } + + off(event: "exit", listener: ExitListener): void; + off(event: "error", listener: ErrorListener): void; + off(event: "exit" | "error", listener: ExitListener | ErrorListener): void { + if (event === "exit") this.removeEntry(this.exitEntries, listener as ExitListener); + else this.removeEntry(this.errorEntries, listener as ErrorListener); + } + + private removeEntry(entries: Set, listener: unknown): void { + for (const entry of entries) if (entry.listener === listener) entries.delete(entry); + } + + /** + * The SDK's local spawn pumped the child's stderr into `Options.stderr` through a `StringDecoder`, + * because a chunk boundary can split a UTF-8 sequence and a code-unit cut would corrupt the text. + * A custom spawner switches that pump off, so both halves are reproduced here. + */ + private pumpStderr(child: ChildProcess, onStderr: (chunk: string) => void): void { + const stderr = child.stderr; + if (stderr === null) return; + const decoder = new StringDecoder("utf8"); + stderr.on("data", chunk => onStderr(decoder.write(chunk))); + stderr.once("close", () => { + const tail = decoder.end(); + if (tail.length > 0) onStderr(tail); + }); + stderr.on("error", () => { + /* A read error ends the tail; the exit code and signal still reach the turn through close. */ + }); + } + + /** + * Report the exit to the SDK's listeners. `close` is the carrier rather than `exit`, which is the + * ordering the SDK's own local spawn keeps: exit errors carry the harness's stderr tail because the + * exit is not reported before stderr has drained. Listeners are not replayed for a later + * registration, exactly as a real child process that exits once behaves. + */ + private deliverExit(code: number | null, signal: NodeJS.Signals | null): void { + if (this.closedFlag) return; + this.closedFlag = true; + // `close` proves this process's stdio is released. For a tree the ladder could not reach it does + // not prove the descendant is gone, and the lease is exactly the accounting for "may still be + // alive": it comes back from the probe, not from an event the ladder produced itself. + if (!this.treeOwnershipUnsettled) this.releaseTeardownLease(); + this.resolveClosed(); + const entries = [...this.exitEntries]; + this.exitEntries.clear(); + for (const entry of entries) entry.listener(code, signal); + } +} + +/** Wait for every child to close, bounded by `ms`; the ladder's observation ceiling. */ +async function waitForClose(children: readonly HarnessChild[], ms: number): Promise { + if (children.every(child => child.closed)) return; + let timer: ReturnType | undefined; + await Promise.race([ + Promise.all(children.map(child => child.closedWait)), + new Promise(resolve => { timer = setTimeout(resolve, ms); }), + ]); + if (timer !== undefined) clearTimeout(timer); +} + +/** + * Hand a teardown's survivors to the quarantine. + * + * They arrive holding their own cleanup leases, because a lease belongs to the child it was reserved + * for and comes back when that child's `close` proves the exit. That is also why nothing is evicted + * here: the spawn hook refuses the process that would exceed the bound, so there is no entry to make + * room for, and releasing a live survivor's lease to make room would be the leak again. + */ +function quarantineSurvivors(children: HarnessChild[], reason: HarnessUnresolvedChild["reason"]): void { + quarantine.push({ children, pid: children[0]?.pid, reason, since: Date.now(), warned: false }); +} + +/** + * Create the per-turn owner of the harness process. + * + * `terminate` is the teardown the turn runs before it deletes its scratch cwd: SIGTERM, the grace + * window, SIGKILL for whatever survived it, and then one more bounded wait for the survivors to + * report `close`. A turn whose harness exits on stdin EOF never reaches the later stages, so the + * ordinary path costs nothing beyond the close it was waiting for anyway. + * + * What comes back is evidence, not a formality: `confirmed` means every child closed by itself, + * `allExited` survives a teardown where the stdio of a descendant had to be reclaimed, and a child + * that is still running is named instead of being reported as a clean exit. The caller deletes the + * working directory only on `allExited` and reports the rest. + */ +export function createHarnessProcessSupervisor(options: HarnessProcessSupervisorOptions): HarnessProcessSupervisor { + const spawnFn = options.spawn ?? nodeSpawn; + const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS; + const reapTimeoutMs = options.reapTimeoutMs ?? DEFAULT_REAP_TIMEOUT_MS; + const platform = options.platform ?? process.platform; + const killTree = options.killProcessTree ?? defaultKillProcessTree; + const children = new Set(); + + return { + spawn: request => { + // Reserved before the spawn, not in `terminate` afterwards: the bound has to decide whether + // this harness exists at all. A turn that cannot get a lease is refused here, and `sdk-turn.ts` + // answers it with `HARNESS_CAPACITY_CODE` instead of starting a process it cannot account for. + const lease = harnessTeardownGate.tryAcquire(); + if (lease === null) throw new HarnessCapacityError(MAX_ACTIVE_HARNESS_TEARDOWNS); + let child: HarnessChild; + try { + child = new HarnessChild( + spawnFn(request.command, request.args, { + ...(request.cwd !== undefined ? { cwd: request.cwd } : {}), + env: request.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + // POSIX: a process group of its own, so the harness's children are reachable by signal when + // the only pid we are handed is the harness's. The child is never `unref`'d, so this changes + // which processes a signal reaches, not whether the turn is held to the child. + detached: platform !== "win32", + }), + options.onStderr, + lease, + ); + } catch (err) { + // A launch failure leaves no process to account for, so the reserved capacity goes back. + lease.release(); + throw err; + } + children.add(child); + // The SDK documents its forwarded `signal` as the thing to hang teardown on: it aborts only + // after the graceful stdin-EOF window. It is wired as a second net here rather than passed to + // `spawn()`, so termination does not depend on a runtime forwarding that option. + if (request.signal.aborted) child.requestTermination("SIGTERM", killTree); + else request.signal.addEventListener("abort", () => child.requestTermination("SIGTERM", killTree), { once: true }); + return child; + }, + terminate: async () => { + const live = [...children].filter(child => !child.closed); + if (live.length === 0) { + return { confirmed: true, allExited: true, unresolved: [], quarantined: 0 }; + } + for (const child of live) child.requestTermination("SIGTERM", killTree); + await waitForClose(live, killGraceMs); + // The grace window is over, and every child that has not closed is a tree question now rather + // than a parent question. A child that reported `exit` without `close` had its pipes inherited, + // and the process group (POSIX) or the launcher's own tree (Windows) is the only handle left on + // whoever holds them - a descendant whose parent is already gone is still reachable that way, + // which is why the KILL pass covers all of them and not only the ones whose pid is still alive. + const survivors = live.filter(child => !child.closed); + if (survivors.length > 0) { + for (const child of survivors) child.requestTermination("SIGKILL", killTree); + await waitForClose(survivors, reapTimeoutMs); + } + // Handles are taken back after both observation windows, never between them: a child that + // exited inside the KILL window would otherwise keep this turn's stdio open to no purpose, and + // one that is still running gets the stdin EOF it may be waiting for. + const settlement = live + .filter(child => !child.closed) + .map(child => ({ + child, + entry: { + pid: child.pid, + // A child that is itself still running is the stronger fact and stays named as such; the + // tree question only decides what an *exited* child leaves behind, which is exactly the + // case where a descendant holding the inherited pipes can outlive its parent unseen. + reason: !child.exited + ? "running" as const + : child.treeUnreachable ? "unresolved-tree" as const : "pipes-held" as const, + signalFailed: child.signalFailed, + }, + })); + // Ownership is latched before the pipes go back, because reclaiming them is itself what makes + // Node emit the `close` for a child whose stdio a descendant inherited. A `close` that arrives + // that way must not return the capacity of a tree this ladder never reached - and "never + // reached" is the tree call being refused, not the label on the direct parent. A child that + // shrugged off every signal is still `running` at the deadline with exactly the same refused + // tree behind it, and the `close` that arrives later proves only that the parent is gone. + for (const item of settlement) { + if (item.child.treeUnreachable) item.child.markTreeOwnershipUnsettled(); + } + for (const child of live) if (!child.closed) child.reclaimPipes(); + const unresolved: HarnessUnresolvedChild[] = settlement.map(item => item.entry); + // Whatever is left stays owned: each child still holds the lease reserved for it at spawn, and + // the handles move to the quarantine where the next turn observes them. Capacity is not + // returned here - a child that closed already returned it through its own `close`, and a tree + // that could not be reached keeps it until the probe says the group is empty. + if (unresolved.length > 0) { + quarantineSurvivors(settlement.map(item => item.child), unresolved[0]!.reason); + } + return { + confirmed: unresolved.length === 0, + // "pipes-held" is a process this turn saw end; its stdio was taken back, and where the tree + // could be signalled the descendants behind it were killed with the group. "running" and + // "unresolved-tree" are the two states in which something this turn started may still be + // alive, and those are the ones that must not release the scratch cwd. + allExited: unresolved.every(entry => entry.reason === "pipes-held"), + unresolved, + quarantined: settlement.length, + }; + }, + }; +} diff --git a/src/adapters/claude-cli/profiles.ts b/src/adapters/claude-agent-sdk/profiles.ts similarity index 98% rename from src/adapters/claude-cli/profiles.ts rename to src/adapters/claude-agent-sdk/profiles.ts index 0445b05b638..55140078d46 100644 --- a/src/adapters/claude-cli/profiles.ts +++ b/src/adapters/claude-agent-sdk/profiles.ts @@ -20,7 +20,7 @@ export interface ClaudeCliProfile extends CodingAgentProviderProfile { } export const CLAUDE_CLI_PROFILE: ClaudeCliProfile = { - providerId: "claude-cli", + providerId: "claude-agent-sdk", family: "claude", // Not a vendor region switch: Claude Code has one destination, and the shared seam's region slot // carries the neutral value. The profile stays the single authority either way. diff --git a/src/adapters/claude-agent-sdk/sdk-bridge.ts b/src/adapters/claude-agent-sdk/sdk-bridge.ts new file mode 100644 index 00000000000..8894d78e0b2 --- /dev/null +++ b/src/adapters/claude-agent-sdk/sdk-bridge.ts @@ -0,0 +1,78 @@ +/** + * The capture-only MCP catalog, served from this process. + * + * This is the tool channel of the Agent SDK row and the reason it needs no second executable: the + * SDK registers an in-process MCP server with the harness over its control channel, so the client's + * catalog reaches the model without a temp module, a `--mcp-config` path or an argv entry. The + * server advertises the request's own schemas and NEVER answers a call — a `tools/call` handler that + * never settles is the capture: the runner reads the `tool_use` blocks off the stream, terminates the + * turn, and hands the call to the client, where approval and execution stay. + * + * The catalog validation, the name aliasing and the `mcp____` mapping are the ones the + * CodeBuddy bridge already owns for the same CLI protocol, so this module reuses that builder + * instead of growing a second interpretation of the same limits. + */ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { OcxParsedRequest } from "../../types"; +import { + CODEBUDDY_MCP_SERVER_NAME, + CODEBUDDY_TOOL_LIMITS, + buildCodeBuddyToolBridge, + type CodeBuddyMcpToolDefinition, +} from "../codebuddy/tool-bridge"; + +/** Server name the catalog is advertised under; part of every rendered tool name. */ +export const CLAUDE_AGENT_SDK_MCP_SERVER_NAME = CODEBUDDY_MCP_SERVER_NAME; + +export interface ClaudeAgentSdkToolBridge { + serverName: string; + tools: CodeBuddyMcpToolDefinition[]; + /** Rendered `mcp____` name -> the request's wire tool name. */ + emittedNameMap: Map; + /** Captured tool_use blocks accepted in one assistant message. */ + maxTurnToolCalls: number; + requireToolCall: boolean; + instance: McpServer; +} + +/** + * Build the in-process capture server for a request that carries a tool catalog. + * + * Returns undefined for a request with no tools, which makes the turn a plain text/reasoning pass, + * and throws for a catalog the shared builder rejects (the caller reports that as a 400). + * + * The MCP SDK is imported here rather than at module scope: it is a real dependency of the proxy, + * but only a turn that actually carries a catalog should pay for loading it. + */ +export async function buildClaudeAgentSdkToolBridge( + parsed: OcxParsedRequest, +): Promise { + const catalog = buildCodeBuddyToolBridge(parsed); + if (catalog.tools.length === 0) return undefined; + + const { McpServer: McpServerClass } = await import("@modelcontextprotocol/sdk/server/mcp.js"); + const { CallToolRequestSchema, ListToolsRequestSchema } = await import("@modelcontextprotocol/sdk/types.js"); + // The tools capability has to be declared up front: the low-level server asserts it when a + // `tools/list` handler is registered, and the high-level wrapper only declares it for tools it + // registered itself — this catalog is served straight from the request's schemas instead, so the + // advertised `inputSchema` is the client's own JSON Schema, byte for byte. + const instance = new McpServerClass( + { name: CODEBUDDY_MCP_SERVER_NAME, version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + instance.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: catalog.tools })); + instance.server.setRequestHandler(CallToolRequestSchema, async () => { + // Capture-only: the call is read off the stream by the runner, and an answer here would mean + // this process executed a tool the client is supposed to execute. + return await new Promise(() => undefined); + }); + + return { + serverName: CLAUDE_AGENT_SDK_MCP_SERVER_NAME, + tools: catalog.tools, + emittedNameMap: catalog.emittedNameMap, + maxTurnToolCalls: CODEBUDDY_TOOL_LIMITS.maxTurnToolCalls, + requireToolCall: catalog.requireToolCall, + instance, + }; +} diff --git a/src/adapters/claude-agent-sdk/sdk-options.ts b/src/adapters/claude-agent-sdk/sdk-options.ts new file mode 100644 index 00000000000..a8362488871 --- /dev/null +++ b/src/adapters/claude-agent-sdk/sdk-options.ts @@ -0,0 +1,137 @@ +/** + * Option assembly for one Claude Agent SDK turn. + * + * Kept pure and separate from the runner so the two properties this row is judged by are readable in + * one place: the harness keeps its own preset (the caller's instructions are APPENDED), and the + * client keeps tool ownership (no built-in tools, no settings, one in-process MCP catalog). + */ +import type { Options } from "@anthropic-ai/claude-agent-sdk"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { mapReasoningEffort } from "../../reasoning-effort"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../types"; +import { buildSystemPrompt } from "../coding-agent/protocol"; +import { TOOL_BRIDGE_SYSTEM_PROMPT } from "../coding-agent/tool-bridge-directive"; + +/** The SDK's `Options.effort` roster (sdk.d.ts `EffortLevel`); anything else is dropped, not sent. */ +const SDK_EFFORT_VALUES = new Set(["low", "medium", "high", "xhigh", "max"]); + +/** + * System prompt for a proxied turn: the harness preset, with the caller's contract appended. + * + * The preset stays on purpose. This row exists to let Anthropic's own harness run the turn, and a + * harness without its own instructions is not that: the previous construction REPLACED the preset, + * which is part of what made the shipped 2.65.0 turn a puppet rather than an agent. The caller's + * system and developer prompts follow as an appended block, and the capture-only bridge directive + * joins them when a tool catalog is advertised. + */ +export function buildAgentSdkSystemPrompt( + parsed: OcxParsedRequest, + hasToolCatalog: boolean, +): NonNullable { + const parts: string[] = []; + const system = buildSystemPrompt(parsed); + if (system) parts.push(system); + if (hasToolCatalog) parts.push(TOOL_BRIDGE_SYSTEM_PROMPT); + return { + type: "preset", + preset: "claude_code", + ...(parts.length > 0 ? { append: parts.join("\n\n") } : {}), + }; +} + +/** Map the caller's reasoning effort onto an effort level the SDK accepts, or drop it. */ +export function buildAgentSdkEffort( + provider: OcxProviderConfig, + parsed: OcxParsedRequest, +): Options["effort"] | undefined { + const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); + if (effort === undefined || !SDK_EFFORT_VALUES.has(effort)) return undefined; + return effort as Options["effort"]; +} + +/** The capture-only catalog as the SDK needs it: the in-process server plus its rendered names. */ +export interface AgentSdkToolCatalog { + serverName: string; + /** The in-process MCP server the SDK connects for this turn (see `./sdk-bridge.ts`). */ + instance: McpServer; + /** Rendered `mcp____` names, i.e. the only tools this turn may call. */ + allowedNames: readonly string[]; +} + +export interface AgentSdkOptionInput { + provider: OcxProviderConfig; + parsed: OcxParsedRequest; + /** Scoped child environment; the SDK REPLACES the whole environment with it. */ + env: Record; + abortController: AbortController; + /** Bounded stderr sink; the SDK hands raw CLI stderr lines here. */ + onStderr: (chunk: string) => void; + /** + * Working directory for the harness process. The `claude_code` preset puts the working + * directory and a git-status summary into its context, so the runner hands it an empty scratch + * directory: a proxied turn has no business reporting the proxy own path or tree. + */ + cwd: string; + toolCatalog?: AgentSdkToolCatalog; + /** Claude Code build to drive; the one the SDK ships is used when this is absent. */ + executablePath?: string; + /** + * The spawn hook that makes the turn own the harness process (see `./harness-process.ts`). The + * SDK's own cleanup does not wait for the process it started, so without this the turn cannot + * tell when the harness is really gone. + */ + spawnHarnessProcess?: Options["spawnClaudeCodeProcess"]; +} + +/** + * Build the Agent SDK options for one turn. + * + * The settings that matter, and why: + * - `tools: []` disables every built-in tool, so the harness can neither read, write, exec nor + * browse the operator's tree. + * - `settingSources: []` and `strictMcpConfig: true` keep CLAUDE.md, skills, hooks, plugins and the + * machine's own MCP servers out of a proxied turn: the request is what the client sent. + * - `persistSession: false` leaves no transcript behind. The client replays its own conversation, + * so the harness needs no memory of it, and the operator's `~/.claude` projects directory is not + * where another client's conversation belongs. + * - `includePartialMessages: true` is what produces `stream_event` deltas instead of one block at + * the end of the turn. + * - `cwd` is a neutral scratch directory rather than `process.cwd()`: the preset describes its + * working directory and git state to the model, and neither is part of the request the client sent. + * - The tool catalog is served from THIS process (`type: "sdk"`), so no extra executable, no argv + * and no temp file is involved in advertising it. + * - `spawnClaudeCodeProcess` replaces the SDK's local spawn so the runner owns the child: the SDK + * reports the exit on a bounded wait, which is not the same as the process being gone. + */ +export function buildAgentSdkTurnOptions(input: AgentSdkOptionInput): Options { + const { provider, parsed, toolCatalog } = input; + const effort = buildAgentSdkEffort(provider, parsed); + return { + model: parsed.modelId, + systemPrompt: buildAgentSdkSystemPrompt(parsed, toolCatalog !== undefined), + tools: [], + settingSources: [], + strictMcpConfig: true, + persistSession: false, + includePartialMessages: true, + cwd: input.cwd, + env: input.env, + abortController: input.abortController, + stderr: input.onStderr, + ...(effort !== undefined ? { effort } : {}), + ...(input.executablePath !== undefined ? { pathToClaudeCodeExecutable: input.executablePath } : {}), + ...(input.spawnHarnessProcess !== undefined ? { spawnClaudeCodeProcess: input.spawnHarnessProcess } : {}), + ...(toolCatalog !== undefined + ? { + mcpServers: { + [toolCatalog.serverName]: { + type: "sdk" as const, + name: toolCatalog.serverName, + instance: toolCatalog.instance, + }, + }, + allowedTools: [...toolCatalog.allowedNames], + } + : {}), + }; +} diff --git a/src/adapters/claude-agent-sdk/sdk-turn.ts b/src/adapters/claude-agent-sdk/sdk-turn.ts new file mode 100644 index 00000000000..12570c87c7d --- /dev/null +++ b/src/adapters/claude-agent-sdk/sdk-turn.ts @@ -0,0 +1,710 @@ +/** + * One proxied turn, run by Anthropic's own harness through the Claude Agent SDK. + * + * This is the correction the row is for. The 2.65.0 construction spawned `claude -p` with a replaced + * system prompt, no session and the tools stripped, and drove it from a foreign client — the harness + * as a puppet. Here the harness is the agent: its process, its session for the turn, its prompt + * preset with the caller's contract appended, its sign-in. OpenCodex contributes the projection, the + * tool catalog it is allowed to advertise, and the stream mapping. + * + * The bridge contract below is the same one `../coding-agent/turn.ts` enforces for the spawned-CLI + * families (init handshake before any call, exact catalog names, a per-turn call cap, `tool_choice` + * and incomplete-call fail-closed paths). Two transports, one contract; the branches are deliberately + * parallel to that file's so a change to one is visible next to the other. + */ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { retainedUtf8Bytes, truncateRetainedUtf8 } from "../../lib/admission"; +import { isStandaloneBinary } from "../../lib/standalone"; +import { isTranslatorBudgetExceededError } from "../../lib/translator-budget"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { modelRecordValue } from "../../reasoning-effort"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import type { IncomingMeta } from "../base"; +import { + resolveCodingAgentBinary, + resolveProfileByBaseUrl, + type CodingAgentProviderProfile, + type WhichFn, +} from "../coding-agent/profile"; +import { + buildConversationInput, + MAX_TOOL_BLOCK_STARTS, + mapStreamMessageToEvents, + projectedHistoryCharLimit, + releaseOpenToolBlocks, + toolBridgeInitError, + type StreamMessage, + type StreamParseState, +} from "../coding-agent/protocol"; +import { redactSecrets } from "../coding-agent/turn"; +import { buildChildEnv } from "./env"; +import { + createHarnessProcessSupervisor, + HarnessCapacityError, + reapHarnessQuarantine, + type HarnessSpawnFn, + type HarnessTreeKillFn, +} from "./harness-process"; +import type { ClaudeCliProfile } from "./profiles"; +import { buildAgentSdkTurnOptions } from "./sdk-options"; + +/** The Agent SDK surface this adapter uses; narrow on purpose, so the test seam stays small. */ +export interface ClaudeAgentSdkQuery { + [Symbol.asyncIterator](): AsyncIterator; + return?(value?: unknown): Promise>; +} + +export interface ClaudeAgentSdkModule { + query(params: { prompt: string | AsyncIterable; options: Record }): ClaudeAgentSdkQuery; +} + +/** Loads the Agent SDK package. Injectable so tests never start a real harness. */ +export type ClaudeAgentSdkLoader = () => Promise; + +export const loadClaudeAgentSdkModule: ClaudeAgentSdkLoader = async () => { + return await import("@anthropic-ai/claude-agent-sdk") as unknown as ClaudeAgentSdkModule; +}; + +/** Capture-only catalog handed to the runner by the adapter (see `./sdk-bridge.ts`). */ +export interface ClaudeAgentSdkToolBridge { + serverName: string; + emittedNameMap: Map; + maxTurnToolCalls: number; + requireToolCall: boolean; + instance: McpServer; +} + +/** Per-turn injectables: the SDK loader, PATH discovery, the two ceilings, and the owned child. */ +export interface ClaudeAgentSdkDeps { + loadSdk?: ClaudeAgentSdkLoader; + which?: WhichFn; + /** Test seam for the compiled-binary executable resolution. */ + isStandalone?: () => boolean; + /** Overall wall-clock ceiling for one turn (ms). */ + timeoutMs?: number; + /** How long to wait for an aborted turn to settle before answering the client (ms). */ + reapTimeoutMs?: number; + /** Grace between SIGTERM and SIGKILL for the harness child (ms). */ + killGraceMs?: number; + /** Test seam for the owned harness process; the default uses `node:child_process`. */ + spawnHarnessProcess?: HarnessSpawnFn; + /** Platform seam for the harness tree terminator and its process group (tests). */ + harnessPlatform?: NodeJS.Platform; + /** Test seam for the harness tree terminator; see `./harness-process.ts`. */ + killHarnessProcessTree?: HarnessTreeKillFn; + /** Creates the turn neutral working directory. Test seam; the default uses the system temp dir. */ + makeScratchDir?: () => Promise; + /** Removes that directory once the harness is gone. */ + removeScratchDir?: (dir: string) => Promise; +} + +/** + * The directory a proxied turn runs in. + * + * Empty on purpose. The harness preset reports its working directory and a git-status summary to + * the model, and with `process.cwd()` those describe the machine OpenCodex was started from - + * the operator own checkout, by file name. The client own workspace reaches the model through + * the request instead, which is the only place it belongs. + */ +async function makeScratchDir(): Promise { + return await mkdtemp(join(tmpdir(), "ocx-claude-agent-sdk-")); +} + +async function removeScratchDir(dir: string): Promise { + await rm(dir, { recursive: true, force: true }); +} + +const DEFAULT_TIMEOUT_MS = 300_000; +const DEFAULT_REAP_TIMEOUT_MS = 5_000; +/** Bound captured stderr so an error message can never carry an unbounded (or secret) payload. */ +const MAX_STDERR_BYTES = 8 * 1024; + +export interface ClaudeAgentSdkTurnInput { + profiles: readonly CodingAgentProviderProfile[]; + provider: OcxProviderConfig; + parsed: OcxParsedRequest; + incoming: IncomingMeta; + emit: (event: AdapterEvent) => void; + /** Absent for a text/reasoning-only turn. */ + toolBridge?: ClaudeAgentSdkToolBridge; + deps: ClaudeAgentSdkDeps; +} + +/** + * Project the replayed conversation into the single user frame the harness receives. + * + * The client replays its own transcript, so the frame is a projection rather than the harness's own + * memory: the same text the spawned-CLI route wrote to stdin, delivered through the SDK's prompt + * channel instead of a pipe. + */ +async function* projectedPrompt(frames: readonly string[]): AsyncGenerator { + for (const line of frames) { + let frame: { message?: unknown }; + try { + frame = JSON.parse(line) as { message?: unknown }; + } catch { + continue; + } + if (frame.message === undefined) continue; + yield { type: "user", message: frame.message, parent_tool_use_id: null }; + } +} + +function boundedStderr(chunks: readonly string[]): string { + const joined = chunks.join(""); + // Every chunk is already bounded at ingestion; this is the byte-exact bound on the joined result. + return (retainedUtf8Bytes(joined) > MAX_STDERR_BYTES + ? truncateRetainedUtf8(joined, MAX_STDERR_BYTES) + : joined).trim(); +} + +export async function runClaudeAgentSdkTurn(input: ClaudeAgentSdkTurnInput): Promise { + const { profiles, provider, parsed, incoming, emit, toolBridge, deps } = input; + const profile = resolveProfileByBaseUrl(profiles, provider.baseUrl); + const apiKey = provider.apiKey ?? ""; + + if (incoming.abortSignal?.aborted) { + emit({ type: "error", message: "Claude Agent SDK turn was aborted before start." }); + return; + } + + // Fail closed on a non-canonical destination before the harness starts (§十六): the subscription + // reaches exactly one host, and an overridden base URL would hand a signed-in account elsewhere. + if (!profile) { + emit({ + type: "error", + message: "Provider base URL is not a canonical region destination; the turn was not started.", + status: 400, + errorType: "invalid_request_error", + code: "non_canonical_destination", + retryable: false, + }); + return; + } + + // A compiled single-file binary cannot resolve the Claude Code build the SDK ships from inside its + // `$bunfs` module tree — the SDK documents that — so there the turn drives the `claude` on PATH, + // the binary this row required before it moved onto the SDK. A normal install lets the SDK use the + // build it ships, which is version-matched to the protocol it speaks. + let executablePath: string | undefined; + if ((deps.isStandalone ?? isStandaloneBinary)()) { + executablePath = resolveCodingAgentBinary(profile, deps.which); + if (!executablePath) { + emit({ + type: "error", + message: `${profile.label} executable is not on PATH and this build cannot use the copy bundled with the Agent SDK. Install it with: ${profile.installHint}`, + status: 500, + errorType: "upstream_error", + code: "cli_not_found", + retryable: false, + }); + return; + } + } + + const loadSdk = deps.loadSdk ?? loadClaudeAgentSdkModule; + let sdk: ClaudeAgentSdkModule; + try { + sdk = await loadSdk(); + } catch (err) { + emit({ + type: "error", + message: redactSecrets( + `The Claude Agent SDK could not be loaded: ${err instanceof Error ? err.message : String(err)}`, + profile.tokenEnv, + apiKey, + ), + status: 500, + errorType: "upstream_error", + code: "claude_agent_sdk_unavailable", + retryable: false, + }); + return; + } + + const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const reapTimeoutMs = deps.reapTimeoutMs ?? DEFAULT_REAP_TIMEOUT_MS; + const abortController = new AbortController(); + const onAbort = (): void => abortController.abort(); + incoming.abortSignal?.addEventListener("abort", onAbort, { once: true }); + + const stderrChunks: string[] = []; + let stderrBytes = 0; + const onStderr = (chunk: string): void => { + // Bound at ingestion and in UTF-8 bytes. The running total says nothing about the size of this + // chunk, so a single oversized callback would otherwise be retained whole and only cut after the + // join; a code-unit cut would also let multi-byte text carry several times the advertised bound. + const remaining = MAX_STDERR_BYTES - stderrBytes; + if (remaining <= 0) return; + const kept = retainedUtf8Bytes(chunk) <= remaining ? chunk : truncateRetainedUtf8(chunk, remaining); + if (kept.length === 0) return; + stderrChunks.push(kept); + stderrBytes += retainedUtf8Bytes(kept); + }; + + let scratchDir: string; + try { + scratchDir = await (deps.makeScratchDir ?? makeScratchDir)(); + } catch (err) { + emit({ + type: "error", + message: redactSecrets( + `Claude Agent SDK turn could not create its scratch directory: ` + (err instanceof Error ? err.message : String(err)), + profile.tokenEnv, + apiKey, + ), + status: 500, + errorType: "upstream_error", + code: "claude_agent_sdk_scratch_unavailable", + retryable: false, + }); + return; + } + + // The harness is owned here rather than by the SDK, because the SDK's cleanup is bounded short of + // the process it started: `query.return()` settling is not evidence that the child is gone, and the + // turn must not delete its scratch cwd or answer the client underneath a live process. The two + // things the SDK's own spawn did for the child that a custom spawner has to keep - the stderr pump + // and the real exit - are in `./harness-process.ts`. + // Sweep the quarantine once per turn. A survivor from an earlier turn is observed here: one that + // closed - and whose tree the ladder could reach - hands its bounded cleanup lease back, while one + // whose tree call was refused keeps both its entry and its lease until the platform reports the + // group empty. The age bound only names such a survivor, once; it does not drop it or give its + // capacity away, because a process that has not been shown to be gone is not capacity this turn may + // hand out again. No timer of its own, and no cost while the quarantine is empty. + reapHarnessQuarantine(); + + const harness = createHarnessProcessSupervisor({ + onStderr, + reapTimeoutMs, + ...(deps.killGraceMs !== undefined ? { killGraceMs: deps.killGraceMs } : {}), + ...(deps.spawnHarnessProcess !== undefined ? { spawn: deps.spawnHarnessProcess } : {}), + ...(deps.harnessPlatform !== undefined ? { platform: deps.harnessPlatform } : {}), + ...(deps.killHarnessProcessTree !== undefined ? { killProcessTree: deps.killHarnessProcessTree } : {}), + }); + + const options = buildAgentSdkTurnOptions({ + provider, + parsed, + cwd: scratchDir, + env: buildChildEnv(profile as ClaudeCliProfile, apiKey), + abortController, + onStderr, + spawnHarnessProcess: harness.spawn, + ...(executablePath !== undefined ? { executablePath } : {}), + ...(toolBridge !== undefined + ? { + toolCatalog: { + serverName: toolBridge.serverName, + instance: toolBridge.instance, + allowedNames: [...toolBridge.emittedNameMap.keys()], + }, + } + : {}), + }); + + // A terminal frame is held here instead of being emitted where it is decided; the tail delivers the + // one this turn owes the client. That ordering is load-bearing, not stylistic: the bridge closes the + // response body on the terminal frame, and the body's EOF is what releases the global turn-admission + // lease. Delivered from inside the event loop, it would admit the next turn while this one's harness + // is still being reaped - the same leak the process ownership in `./harness-process.ts` exists to + // close, one layer up. Non-terminal frames are unaffected and still stream as they arrive. + let terminalDecided = false; + let pendingTerminal: AdapterEvent | undefined; + const emitOnce = (event: AdapterEvent): void => { + if (event.type === "done" || event.type === "error" || event.type === "incomplete") { + if (terminalDecided) return; + terminalDecided = true; + pendingTerminal = event; + return; + } + emit(event); + }; + + const timeoutTimer = setTimeout(() => { + abortController.abort(); + emitOnce({ + type: "error", + message: `${profile.label} turn timed out.`, + status: 504, + errorType: "upstream_error", + code: "timeout", + retryable: true, + }); + }, timeoutMs); + + const historyCharLimit = projectedHistoryCharLimit( + modelRecordValue(provider.modelContextWindows, parsed.modelId) ?? provider.contextWindow, + ); + const promptFrames = buildConversationInput(parsed, { maxHistoryChars: historyCharLimit }); + + const state: StreamParseState = { + sawPartialText: false, + sawPartialThinking: false, + sawTerminalResult: false, + openToolBlocks: new Map(), + // The shared parser owns tool-state admission since dev's #6081/#6083: the per-turn ceiling and + // the request's translator budget are set on the state here, exactly as the spawned-CLI turn + // sets them, so a capture path cannot retain tool identity or argument fragments unbounded. + translatorBudget: incoming.translatorBudget, + maxToolBlockStarts: toolBridge?.maxTurnToolCalls, + strictToolBlockCapture: Boolean(toolBridge), + partialToolCallIds: toolBridge ? new Set() : undefined, + }; + + let query: ClaudeAgentSdkQuery | undefined; + let streamError: string | undefined; + let streamProtocolCode: string | undefined; + // A successful result frame that arrived after every captured tool call completed but before + // message_stop: the leg still ends with the synthesized done(tool_use) at message_stop, so this + // frame's usage (authoritative vendor accounting) is folded into the synthesis instead of ending + // the turn as a text completion the client would accept and then wait on. + let deferredResultDone: Extract | undefined; + let initValidated = false; + + try { + query = sdk.query({ prompt: projectedPrompt(promptFrames), options: options as unknown as Record }); + let failClosed = false; + // Read the stream against the turn's own abort signal instead of trusting the iterator to end. + // A timeout or a client disconnect has to stop the turn even while the harness keeps talking, and + // the SDK kills its process when the controller fires — this is what makes that reachable from + // here rather than only from inside the SDK. + const iterator = query[Symbol.asyncIterator](); + const aborted = new Promise<"aborted">(resolve => { + if (abortController.signal.aborted) { + resolve("aborted"); + return; + } + abortController.signal.addEventListener("abort", () => resolve("aborted"), { once: true }); + }); + while (true) { + const next = await Promise.race([ + iterator.next().then(result => ({ kind: "frame" as const, result })), + aborted.then(() => ({ kind: "aborted" as const, result: undefined })), + ]); + if (next.kind === "aborted") break; + if (next.result === undefined || next.result.done === true) break; + const message = next.result.value; + if (incoming.abortSignal?.aborted) break; + if (toolBridge) { + const initError = toolBridgeInitError(message, toolBridge.serverName); + if (initError) { + emitOnce({ + type: "error", + message: initError, + status: 502, + errorType: "upstream_error", + code: "tool_bridge_init_mismatch", + retryable: false, + }); + break; + } + if (message.type === "system" && message.subtype === "init") initValidated = true; + } + const mappedEvents = mapStreamMessageToEvents(message, state); + if (state.toolCallLimitExceeded) { + // The parser refuses the start that would pass the ceiling before it allocates the block, so + // this flag is the only signal that a call was dropped. Without it the turn would run on with + // the dropped call invisible to the completeness invariants below. + emitOnce({ + type: "error", + message: `Claude Agent SDK returned more than the ${Math.min(MAX_TOOL_BLOCK_STARTS, state.maxToolBlockStarts ?? MAX_TOOL_BLOCK_STARTS)}-tool-call turn limit.`, + status: 502, + errorType: "upstream_error", + code: "tool_call_limit", + retryable: false, + }); + break; + } + if (toolBridge && state.uncapturedToolUse) { + emitOnce({ + type: "error", + message: "Claude Agent SDK returned a tool call without a partial tool capture.", + status: 502, + errorType: "upstream_error", + code: "protocol_error", + retryable: false, + }); + break; + } + for (const event of mappedEvents) { + if (toolBridge && !initValidated && event.type === "done") { + emitOnce({ + type: "error", + message: "Claude Agent SDK ended before the tool bridge init handshake completed.", + status: 502, + errorType: "upstream_error", + code: "tool_bridge_init_missing", + retryable: false, + }); + failClosed = true; + break; + } + if (toolBridge && event.type === "tool_call_start") { + // The catalog is only real once the harness acknowledged the bridge in its init frame; a + // call that arrives before that means the model acted on a catalog this bridge never + // validated, so fail closed before the call is counted or renamed. + if (!initValidated) { + emitOnce({ + type: "error", + message: "Claude Agent SDK called a tool before the tool bridge init handshake completed.", + status: 502, + errorType: "upstream_error", + code: "tool_bridge_init_missing", + retryable: false, + }); + failClosed = true; + break; + } + const wireName = toolBridge.emittedNameMap.get(event.name); + if (wireName === undefined) { + emitOnce({ + type: "error", + message: "Claude Agent SDK called a tool outside the isolated catalog.", + status: 502, + errorType: "upstream_error", + code: "undeclared_tool_call", + retryable: false, + }); + failClosed = true; + break; + } + emitOnce({ ...event, name: wireName }); + continue; + } + if ( + toolBridge?.requireToolCall === true + && !terminalDecided + && event.type === "done" + && event.stopReason !== "tool_use" + && (state.completedToolCalls ?? 0) === 0 + ) { + // `tool_choice: required|named`: a text-only terminal result must not become a successful + // completion the client can accept, and the bridge has no way to force the harness either. + emitOnce({ + type: "error", + message: "Claude Agent SDK finished without calling the required tool.", + status: 502, + errorType: "upstream_error", + code: "tool_call_required", + retryable: false, + }); + failClosed = true; + break; + } + if ( + toolBridge + && !terminalDecided + && event.type === "done" + && (state.toolBlockStarts ?? 0) > 0 + && (state.completedToolCalls ?? 0) !== (state.toolBlockStarts ?? 0) + ) { + emitOnce({ + type: "error", + message: "Claude Agent SDK ended with an incomplete tool call.", + status: 502, + errorType: "upstream_error", + code: "protocol_error", + retryable: false, + }); + failClosed = true; + break; + } + if ( + toolBridge + && !terminalDecided + && event.type === "done" + && (state.toolBlockStarts ?? 0) > 0 + && (state.completedToolCalls ?? 0) === (state.toolBlockStarts ?? 0) + ) { + deferredResultDone = event; + continue; + } + emitOnce(event.type === "error" + ? { ...event, message: redactSecrets(event.message, profile.tokenEnv, apiKey) } + : event); + } + if (failClosed) break; + if ( + toolBridge + && !terminalDecided + && state.sawMessageStop + && (state.toolBlockStarts ?? 0) > 0 + && (state.completedToolCalls ?? 0) !== (state.toolBlockStarts ?? 0) + ) { + emitOnce({ + type: "error", + message: "Claude Agent SDK ended with an incomplete tool call.", + status: 502, + errorType: "upstream_error", + code: "protocol_error", + retryable: false, + }); + break; + } + if (toolBridge && !terminalDecided && state.sawMessageStop && (state.completedToolCalls ?? 0) > 0) { + // The capture handler never answers, so the harness parks after message_stop. The completed + // tool_use blocks ARE this turn's structured output: end the leg here, let the SDK abort the + // process, and hand the call to the client, which executes it and continues the conversation. + const terminalUsage = deferredResultDone?.usage ?? state.partialUsage; + emitOnce({ + type: "done", + stopReason: "tool_use", + endTurn: false, + ...(terminalUsage ? { usage: terminalUsage } : {}), + }); + break; + } + if (terminalDecided) break; + } + } catch (err) { + if (err instanceof HarnessCapacityError) { + // The spawn hook refused the process before it existed, so this turn never owned a harness. + // That refusal is the bound working: it is reported with its own code and as retryable rather + // than flattened into a harness that failed to start. + emitOnce({ + type: "error", + message: err.message, + status: 503, + errorType: "upstream_error", + code: err.code, + retryable: true, + }); + } + streamError = err instanceof Error ? err.message : String(err); + // A translator-budget overflow is a boundedness verdict with its own code; keep it instead of + // flattening it into the generic SDK error the rest of this branch reports. + if (isTranslatorBudgetExceededError(err)) streamProtocolCode = err.code; + } + + // Release the parser's retained tool identity, argument fragments and per-ID leases on every exit + // path: terminal frame, fail-closed break, abort, or a budget error thrown mid-stream. + releaseOpenToolBlocks(state); + clearTimeout(timeoutTimer); + incoming.abortSignal?.removeEventListener("abort", onAbort); + // End the turn from this side: aborting the query stops the harness process (and with it the + // in-process MCP server), and the wait is bounded so a harness that ignores the abort cannot hold + // the client's request open. + abortController.abort(); + // The SDK's teardown waits on its own clock - 2000 ms before it even schedules SIGTERM - while the + // completion the client sees is gated on the ladder below, which owns the child. Terminating here + // first keeps the guarantee this file exists for (no answer, and no admitted next turn, underneath + // a live harness) without billing the client for a clock that is not about the process: the SDK is + // left to unwind on a process that is already gone. + const sdkSettle = query?.return !== undefined + ? query.return(undefined).catch(() => undefined) + : Promise.resolve(); + let reapTimer: ReturnType | undefined; + const settleSdk = async (): Promise => { + await Promise.race([ + sdkSettle, + new Promise(resolve => { reapTimer = setTimeout(resolve, reapTimeoutMs); }), + ]); + if (reapTimer !== undefined) clearTimeout(reapTimer); + }; + // The SDK's cleanup waited on its own bounded clock, not on the process: it schedules SIGTERM two + // seconds out and SIGKILL five seconds after that, with both timers unref'd. The child is + // terminated here - bounded TERM, grace, KILL, and the process tree where the platform supports + // one - and the outcome says how far that got. `allExited` is the gate for the scratch cwd: a + // process that never exited may still be using it, and deleting it underneath one is exactly the + // premature cleanup this ownership exists to prevent. + const cleanup = await harness.terminate(); + if (cleanup.allExited) { + // The harness is gone; nothing of the operator is left in there. + await (deps.removeScratchDir ?? removeScratchDir)(scratchDir).catch(() => undefined); + } + if (!cleanup.confirmed) { + // Never silently: an unresolved teardown is the one state a caller cannot see for itself. + const survivors = cleanup.unresolved + .map(entry => `${entry.pid ?? "?"}:${entry.reason}${entry.signalFailed ? ":signal-refused" : ""}`) + .join(", "); + console.warn( + "opencodex: claude-agent-sdk harness cleanup unconfirmed (" + + (survivors.length > 0 ? survivors : "no child named") + + `; ${cleanup.quarantined} quarantined` + + `); ${cleanup.allExited ? "the scratch cwd was released" : `leaving ${scratchDir} in place`}`, + ); + } + + // A turn that still owns a process it could not take down has no success to hand over: the client + // would read the answer as the turn's whole outcome while the harness behind it outlives the turn, + // and a client that cancels repeats that without ever seeing why. The chain that stops it starts at + // the spawn hook, which refuses a harness the bound cannot cover in the first place. + const ownsUnfinishedTeardown = !cleanup.allExited; + const deliverTerminal = (): void => { + if (pendingTerminal === undefined) return; + if (pendingTerminal.type === "done" && ownsUnfinishedTeardown) { + const named = cleanup.unresolved.length > 0 + ? cleanup.unresolved.map(entry => `${entry.pid ?? "?"}:${entry.reason}`).join(", ") + : "cleanup capacity"; + emit({ + type: "error", + message: + `Claude Agent SDK turn ended with a harness process this server still owns (${named}); ` + + "the answer is withheld rather than reported as a completed turn.", + status: 502, + errorType: "upstream_error", + code: "harness_teardown_unresolved", + retryable: false, + }); + return; + } + emit(pendingTerminal); + }; + + if (terminalDecided) { + deliverTerminal(); + await settleSdk(); + return; + } + const harnessStderr = boundedStderr(stderrChunks); + const stderr = redactSecrets(harnessStderr, profile.tokenEnv, apiKey); + if (incoming.abortSignal?.aborted) { + emitOnce({ type: "error", message: `${profile.label} turn was aborted.`, retryable: false }); + } else if (streamError !== undefined) { + // An exit error used to arrive with the SDK's own stderr tail, and only the SDK local spawn this + // adapter replaced ever filled that tail. The harness's stderr reaches this turn through its own + // sink now, so the one message that reports a dead harness keeps the evidence instead of losing it. + const message = harnessStderr.length > 0 && !streamError.includes(harnessStderr) + ? `${streamError}: ${harnessStderr}` + : streamError; + emitOnce({ + type: "error", + message: redactSecrets(message, profile.tokenEnv, apiKey), + status: 502, + errorType: "upstream_error", + code: streamProtocolCode ?? "claude_agent_sdk_error", + retryable: false, + }); + } else if (toolBridge && deferredResultDone !== undefined && !state.sawMessageStop) { + emitOnce({ + type: "error", + message: "Claude Agent SDK delivered a terminal result before message_stop on a tool-bridge turn.", + status: 502, + errorType: "upstream_error", + code: "protocol_error", + retryable: false, + }); + } else if (!state.sawTerminalResult) { + emitOnce({ + type: "error", + message: stderr + ? `${profile.label} turn ended without a terminal result frame: ${stderr}` + : `${profile.label} turn ended without a terminal result frame`, + status: 502, + errorType: "upstream_error", + code: "protocol_error", + retryable: false, + }); + } + // The one terminal frame this turn owes the client, delivered only now that the harness behind it + // is gone. The bridge closes the response body on this frame and that EOF releases the global + // turn-admission lease, so a frame emitted earlier would let the next turn start while a + // TERM-resistant harness is still being reaped. + deliverTerminal(); + // Bounded, and no longer on the client's clock: the SDK's generator unwinds on its own terms after + // the answer, and nothing of the turn is waiting on it but this line. + await settleSdk(); +} diff --git a/src/adapters/claude-cli/adapter.ts b/src/adapters/claude-cli/adapter.ts deleted file mode 100644 index 28d302f0836..00000000000 --- a/src/adapters/claude-cli/adapter.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; -import type { AdapterRequest, ProviderAdapter } from "../base"; -import { mapReasoningEffort } from "../../reasoning-effort"; -import { buildSystemPrompt } from "../coding-agent/protocol"; -import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps } from "../coding-agent/turn"; -import { CLAUDE_CLI_PROFILES, type ClaudeCliProfile } from "./profiles"; - -export type { SpawnFn } from "../coding-agent/turn"; -export type ClaudeCliAdapterDeps = CodingAgentDeps; - -/** - * Quiet the CLI's own outbound traffic. - * - * The spawned turn is infrastructure, not somebody's editor: nobody reads its usage metrics, its - * crash reports describe a process the operator never launched by hand, and an auto-updater - * swapping the binary underneath a running proxy is skew rather than a feature. The shared scoped - * env inherits none of these keys, so these values are the ones the turn runs with. - */ -export const CLAUDE_CLI_QUIET_ENV: Readonly> = { - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", - CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY: "1", - CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1", - DISABLE_AUTOUPDATER: "1", - DISABLE_TELEMETRY: "1", - DISABLE_ERROR_REPORTING: "1", - DISABLE_FEEDBACK_COMMAND: "1", -}; - -/** - * Build the scoped child-process environment for one Claude Code turn. - * - * No credential is layered here on purpose. Claude Code reads the operator's own sign-in (the - * macOS Keychain entry, or `~/.claude/.credentials.json` elsewhere), which is exactly the property - * this provider exists for: the token never enters OpenCodex, its config, or a child environment. - * - * The shared base env also drops every inherited `ANTHROPIC_*` variable, which is what keeps a - * `claude` the operator already points at this proxy from looping back into it. - * - * `USER` is the one inherited name added back, and it is not a credential: the CLI resolves its own - * sign-in by account name, so a scoped env without it makes a signed-in machine answer "not logged - * in". Measured with `claude auth status` under `env -i`: `USER` alone reports `loggedIn: true`, - * `LOGNAME` alone or neither reports `loggedIn: false`. - */ -export function buildChildEnv(_profile: ClaudeCliProfile, _apiKey: string): Record { - const env: Record = { - ...baseScopedEnv(), - ...CLAUDE_CLI_QUIET_ENV, - }; - const user = process.env.USER; - if (user) env.USER = user; - return env; -} - -/** - * Build the headless Claude Code arguments for one turn. - * - * Tool ownership stays with the client: `--tools ""` disables every built-in tool and - * `--strict-mcp-config` (with no `--mcp-config`) keeps user, project and plugin MCP servers out, so - * the harness can neither read, write, exec nor browse the operator's tree. `--setting-sources ""` - * stops the CLI from loading CLAUDE.md, skills, hooks, plugins and output styles into a proxied - * turn, which is what makes the request deterministic instead of dependent on the host's setup. - * - * The system prompt REPLACES the Claude Code preset rather than appending to it. The caller's system - * and developer prompts are the contract this turn answers under; leaving the harness preset in - * place would put a second, contradictory instruction set in front of them and would describe tools - * this turn deliberately does not have. - * - * It travels as a `--system-prompt-file` path rather than inline, because argv is world-readable - * through process listing — the same reason the CodeBuddy adapter stages its folded prompt. The - * staging file is passed by `runTurn`, which always writes one: omitting the flag is not "no system - * prompt", it is "Claude Code's preset", so a caller that sends neither a system nor a developer - * prompt gets an empty file instead. Verified against 2.1.270 by reading the `prompt_snapshot` - * attachment the CLI writes into a session transcript — a file holding MARKER snapshots `["MARKER"]`, - * an empty file snapshots `[""]`, and an omitted flag snapshots the fourteen-block harness preset. - * - * `--no-session-persistence` keeps every turn stateless. The client replays its own conversation - * and `buildConversationInput` projects it into the single stream-json user frame the CLI accepts. - * - * There is deliberately no `--max-turns` here: the Claude Code CLI exposes no such flag (the Agent - * SDK sets it on the turn budget instead), and with no tool channel a single `-p` turn cannot loop. - */ -export function buildArgs( - _profile: ClaudeCliProfile, - parsed: OcxParsedRequest, - provider: OcxProviderConfig, - systemPromptFile?: string, -): string[] { - const args: string[] = [ - "-p", - "--output-format", "stream-json", - "--input-format", "stream-json", - "--include-partial-messages", - "--verbose", - "--no-session-persistence", - "--tools", "", - "--strict-mcp-config", - "--setting-sources", "", - "--model", parsed.modelId, - ]; - const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); - if (effort) args.push("--effort", effort); - if (systemPromptFile) args.push("--system-prompt-file", systemPromptFile); - return args; -} - -/** - * Refuse image input the way the Qoder presets do. - * - * The CLI parses an image frame in its stream-json input without complaint (verified against - * 2.1.270), but nothing verifies that a headless turn hands those bytes to the model, and an image - * the harness drops produces a confident answer to the wrong question. v1 therefore publishes - * text-only models — `noVisionModels` on the registry row — and refuses a direct image here; an - * operator with the vision sidecar on the request path still gets images captioned into text before - * they reach this adapter. - */ -function hasImageInput(parsed: OcxParsedRequest): boolean { - return parsed.context.messages.some(message => - Array.isArray(message.content) && message.content.some(part => part.type === "image"), - ); -} - -/** - * Turn the CLI's unauthenticated turn into the one action a subscription user can take. - * - * An unauthenticated `claude` does not fail the process: it emits an ordinary terminal `result` - * frame with `is_error: true` and the text "Not logged in · Please run /login", which the shared - * mapper reports as a generic 401. Nothing in that reaches for the CLI's own sign-in, so the - * operator is left guessing whether the key, the provider row or the account is wrong. - */ -export function withClaudeLoginHint(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void { - return event => { - if (event.type === "error" && event.status === 401 && /not logged in|please run \/login/i.test(event.message)) { - emit({ - ...event, - code: "claude_cli_not_logged_in", - message: - "Claude Code is not signed in, so this subscription provider has no account to spend. " + - "Run `claude` once and sign in (or `claude setup-token`), then retry. " + - `CLI reported: ${event.message}`, - }); - return; - } - emit(event); - }; -} - -/** - * Create the Claude Code CLI adapter: one headless, tools-disabled, sessionless turn per request. - * - * As with CodeBuddy and Qoder, `runTurn` owns the turn and the HTTP path is disabled — the CLI - * performs the transport, and OpenCodex contributes the request projection, the stream mapping and - * the process lifecycle. - */ -export function createClaudeCliAdapter(provider: OcxProviderConfig, deps: ClaudeCliAdapterDeps = {}): ProviderAdapter { - return { - name: "claude-cli", - - buildRequest(): AdapterRequest { - return { url: provider.baseUrl, method: "POST", headers: {}, body: "" }; - }, - async *parseStream(): AsyncGenerator { - yield { type: "error", message: "Claude Code CLI adapter uses runTurn; the fetch/parseStream path is disabled." }; - }, - - async runTurn(parsed, incoming, emit): Promise { - if (hasImageInput(parsed)) { - emit({ - type: "error", - message: "Claude Code CLI image input is not enabled because the CLI provider route has no verified multimodal contract.", - status: 400, - errorType: "invalid_request_error", - code: "unsupported_input_modality", - retryable: false, - }); - return; - } - // argv is world-readable via process listing, so the folded system+developer prompt is staged - // in a private per-turn file and passed by path. The file is written even when the caller - // sends no prompt at all: the flag has to be present either way, and an empty replacement is - // what keeps the harness preset out of the turn. - let promptDir: string | undefined; - let promptFile: string | undefined; - try { - promptDir = await mkdtemp(join(tmpdir(), "ocx-claude-cli-prompt-")); - promptFile = join(promptDir, "system-prompt.txt"); - await writeFile(promptFile, buildSystemPrompt(parsed) ?? "", { encoding: "utf8", mode: 0o600, flag: "wx" }); - } catch { - if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {}); - emit({ - type: "error", - message: "Claude Code system prompt could not be staged securely.", - status: 500, - errorType: "upstream_error", - code: "system_prompt_staging_failed", - retryable: false, - }); - return; - } - try { - await runCodingAgentTurn({ - profiles: CLAUDE_CLI_PROFILES, - provider, - parsed, - incoming, - emit: withClaudeLoginHint(emit), - buildArgs: (profile, req, prov) => buildArgs(profile as ClaudeCliProfile, req, prov, promptFile), - buildEnv: (profile, apiKey) => buildChildEnv(profile as ClaudeCliProfile, apiKey), - deps, - }); - } finally { - if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {}); - } - }, - }; -} diff --git a/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts index 705393ec3de..85db2254f54 100644 --- a/src/adapters/codebuddy/adapter.ts +++ b/src/adapters/codebuddy/adapter.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import type { AdapterRequest, ProviderAdapter } from "../base"; import { mapReasoningEffort } from "../../reasoning-effort"; import { buildSystemPrompt } from "../coding-agent/protocol"; +import { TOOL_BRIDGE_SYSTEM_PROMPT } from "../coding-agent/tool-bridge-directive"; import { baseScopedEnv, runCodingAgentTurn, @@ -27,19 +28,6 @@ export type CodeBuddyAdapterDeps = CodingAgentDeps; const CODEBUDDY_MCP_SERVER_PATH = fileURLToPath(new URL("./mcp-server.ts", import.meta.url)); -/** - * Tool-bridge contract lines appended to the system prompt when a catalog is advertised. - * Mirrors the capture-only design: the model may propose calls, the external Codex client - * alone performs approval, sandboxing, and execution. - */ -const TOOL_BRIDGE_SYSTEM_PROMPT = [ - "Your built-in tools and user-configured MCP servers are disabled.", - "When an isolated opencodex MCP catalog is present, you may call only those listed tools.", - "That MCP process captures call intent only; it never executes a tool. The external Codex client performs approval, sandboxing, and execution.", - "Do not claim that you executed commands, inspected files, or changed the workspace.", - "Tool-call and tool-result records in the conversation history are authoritative historical records from the external client. Use returned results, but never execute historical calls yourself.", -].join("\n"); - /** * Build the scoped child-process environment for a CodeBuddy turn (§六/§十四). * diff --git a/src/adapters/coding-agent/tool-bridge-directive.ts b/src/adapters/coding-agent/tool-bridge-directive.ts new file mode 100644 index 00000000000..4db88b4bf53 --- /dev/null +++ b/src/adapters/coding-agent/tool-bridge-directive.ts @@ -0,0 +1,15 @@ +/** + * Tool-bridge contract lines appended to the system prompt when a catalog is advertised. + * + * Shared by every family whose tools-disabled turn gains a capture-only catalog (CodeBuddy, Qoder's + * sibling path, and the Claude Agent SDK row): the model may propose calls, the bridge never + * answers them, and the external Codex client alone performs approval, sandboxing and execution. + * One copy, so the families cannot drift into describing the bridge differently. + */ +export const TOOL_BRIDGE_SYSTEM_PROMPT = [ + "Your built-in tools and user-configured MCP servers are disabled.", + "When an isolated opencodex MCP catalog is present, you may call only those listed tools.", + "That MCP process captures call intent only; it never executes a tool. The external Codex client performs approval, sandboxing, and execution.", + "Do not claim that you executed commands, inspected files, or changed the workspace.", + "Tool-call and tool-result records in the conversation history are authoritative historical records from the external client. Use returned results, but never execute historical calls yourself.", +].join("\n"); diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index c8c43748402..d5ffb8013a3 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -1,7 +1,7 @@ import { createAnthropicAdapter } from "./anthropic"; import { createAzureAdapter } from "./azure"; import type { ProviderAdapter } from "./base"; -import { createClaudeCliAdapter } from "./claude-cli/adapter"; +import { createClaudeAgentSdkAdapter } from "./claude-agent-sdk/adapter"; import { withClinePassDeepSeekV4ToolReplayCompatibility } from "./cline-pass-deepseek-v4-tool-replay"; import { withUniqueToolCallIds } from "./unique-tool-call-ids"; import { createCodeBuddyAdapter } from "./codebuddy/adapter"; @@ -17,6 +17,7 @@ import { createOllamaNativeAdapter } from "./ollama-native"; import { createResponsesPassthroughAdapter } from "./openai-responses"; import type { OcxProviderConfig } from "../types"; import { createAdapterTierMetadata } from "../providers/fastwire"; +import { resolveDeprecatedProviderId } from "../providers/deprecated-provider-aliases"; import { withInputMediaGuard } from "./input-media-guard"; export type AdapterCacheRetention = "none" | "short" | "long"; @@ -140,12 +141,12 @@ export const ADAPTER_REGISTRY = { contractParent: "codebuddy", create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createQoderAdapter(provider), }, - "claude-cli": { - // Claude Code speaks the same stream-json contract this repo already parses for CodeBuddy and - // Qoder, so the contract is inherited rather than restated. The family owns its args and env, - // and the CLI owns the credential: the adapter stores and injects none. + "claude-agent-sdk": { + // Anthropic's Claude Agent SDK drives the same harness this repo already reaches through the + // CodeBuddy and Qoder CLIs, so the contract is inherited rather than restated. The family owns + // its options and env, and the harness owns the credential: the adapter stores and injects none. contractParent: "codebuddy", - create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createClaudeCliAdapter(provider), + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createClaudeAgentSdkAdapter(provider), }, } as const satisfies Record; @@ -157,8 +158,14 @@ export function adapterDefinitions(): Array<[AdapterId, RegisteredAdapterDefinit } export function getAdapterDefinition(adapterId: unknown): RegisteredAdapterDefinition | undefined { - if (typeof adapterId !== "string" || !Object.hasOwn(ADAPTER_REGISTRY, adapterId)) return undefined; - return ADAPTER_REGISTRY[adapterId as AdapterId]; + if (typeof adapterId !== "string") return undefined; + // A retired provider id is also a retired adapter id, and the adapter string is the half a saved + // row can still carry: the rename projection leaves a row in place when the destination is taken, + // and a hand-edited config never runs the projection at all. No other form of that mechanism + // exists, so both lookups read one table and cannot disagree about what an id means. + const resolved = resolveDeprecatedProviderId(adapterId); + if (!Object.hasOwn(ADAPTER_REGISTRY, resolved)) return undefined; + return ADAPTER_REGISTRY[resolved as AdapterId]; } export function effectiveAdapterContract(adapterId: string): Readonly<{ diff --git a/src/providers/claude-provider-rename-migration.ts b/src/providers/claude-provider-rename-migration.ts new file mode 100644 index 00000000000..6a09f352b56 --- /dev/null +++ b/src/providers/claude-provider-rename-migration.ts @@ -0,0 +1,134 @@ +/** + * Rename the retired `claude-cli` provider id to `claude-agent-sdk`. + * + * 2.65.0 shipped the row as `claude-cli`: a hand-built `claude -p` turn that pushed each request + * through a one-shot CLI invocation with the harness prompt replaced. The row now drives + * Anthropic's Claude Agent SDK, where the harness owns the session, so the retired id names the + * transport it used to be rather than the one it uses. The old name also collided with + * `src/providers/claude-cli-identity.ts`, which forges a `claude-cli/` user agent for the + * Messages-API rows — the opposite mechanism, and a reader had to disambiguate the two by hand. + * + * Only config state moves. The row stores no credential (`keyOptional`: the harness keeps the + * sign-in in its own store), so unlike the devin merge there is no auth.json half and no rekey, and + * the shared startup pass owns persistence. + * + * Posture: the projection refuses and warns when `claude-agent-sdk` already exists, because two + * rows can describe two different sign-in setups and choosing a survivor is not this migration's + * call. `DEPRECATED_PROVIDER_ALIASES` in `./deprecated-provider-aliases` keeps the retired id resolvable meanwhile, + * so a refused row stays reachable instead of dangling. + */ +import { rewriteProviderReferences } from "./provider-id-rewrite"; +import type { OcxConfig } from "../types"; + +export const CLAUDE_AGENT_SDK_PROVIDER_ID = "claude-agent-sdk"; +export const CLAUDE_CLI_PROVIDER_ID = "claude-cli"; + +export interface ClaudeProviderRenameProjection { + config: OcxConfig; + changed: boolean; + warnings: string[]; +} + +export function projectClaudeProviderRename(config: OcxConfig): ClaudeProviderRenameProjection { + const providers = config.providers; + const legacyRow = providers?.[CLAUDE_CLI_PROVIDER_ID]; + // Only the row the retired preset seeded moves. A seeded row names the adapter outright and a row + // that omits it inherits the registry entry, so both describe this mechanism. A row that carries + // the retired NAME on some other adapter belongs to the operator - the name is plausible for an + // `anthropic` row, since `claude-cli-identity.ts` uses the same term - and it keeps its name, its + // transport and its billing. Nothing then rewrites a reference that points at it. + const foreignLegacyRow = legacyRow !== undefined + && legacyRow.adapter !== undefined + && legacyRow.adapter !== CLAUDE_CLI_PROVIDER_ID; + const hasLegacyRow = legacyRow !== undefined && !foreignLegacyRow; + const customRows = Object.entries(providers ?? {}).filter( + ([name, row]) => name !== CLAUDE_CLI_PROVIDER_ID && row?.adapter === CLAUDE_CLI_PROVIDER_ID, + ); + + // Project onto a clone. The rewriter is not transactional — earlier sites are already rewritten + // by the time it finds a collision — so a later refusal has to discard the whole projection + // instead of returning a half-renamed config. + const projected = structuredClone(config); + + if (hasLegacyRow) { + if (projected.providers?.[CLAUDE_AGENT_SDK_PROVIDER_ID]) { + return { + config, + changed: false, + warnings: [ + `provider "${CLAUDE_CLI_PROVIDER_ID}" was renamed to "${CLAUDE_AGENT_SDK_PROVIDER_ID}", but "` + + `${CLAUDE_AGENT_SDK_PROVIDER_ID}" already exists. Both were left untouched: two rows can ` + + "describe two different sign-in setups, which is not a decision this migration can make. " + + `Move any ${CLAUDE_CLI_PROVIDER_ID}-only settings onto "${CLAUDE_AGENT_SDK_PROVIDER_ID}" ` + + "and delete the unused entry, then restart.", + ], + }; + } + const moved = projected.providers![CLAUDE_CLI_PROVIDER_ID]!; + delete projected.providers![CLAUDE_CLI_PROVIDER_ID]; + moved.adapter = CLAUDE_AGENT_SDK_PROVIDER_ID; + projected.providers![CLAUDE_AGENT_SDK_PROVIDER_ID] = moved; + } + + const rewritten = foreignLegacyRow + ? { changed: 0, collisions: [] as string[] } + : rewriteProviderReferences(projected, CLAUDE_CLI_PROVIDER_ID, CLAUDE_AGENT_SDK_PROVIDER_ID); + if (rewritten.collisions.length > 0) { + return { + config, + changed: false, + warnings: [ + `provider "${CLAUDE_CLI_PROVIDER_ID}" was renamed to "${CLAUDE_AGENT_SDK_PROVIDER_ID}", but ` + + `${rewritten.collisions.join(", ")} already hold values for the destination. Nothing was ` + + "changed: choosing which value survives is not a decision this migration can make. " + + "Resolve those entries and restart.", + ], + }; + } + + // A custom-named row may name the retired adapter directly. Its own name is the operator's, so + // only the adapter string moves; the row keeps pointing at this mechanism either way. + let adapterRewrites = 0; + for (const row of Object.values(projected.providers ?? {})) { + if (row?.adapter === CLAUDE_CLI_PROVIDER_ID) { + row.adapter = CLAUDE_AGENT_SDK_PROVIDER_ID; + adapterRewrites += 1; + } + } + + const changed = hasLegacyRow || adapterRewrites > 0 || rewritten.changed > 0; + if (!changed && !foreignLegacyRow) return { config, changed: false, warnings: [] }; + + const warnings: string[] = []; + if (foreignLegacyRow) { + warnings.push( + `left provider "${CLAUDE_CLI_PROVIDER_ID}" and every reference to it untouched: the row runs ` + + `adapter "${legacyRow!.adapter}", not the retired preset, so it is your provider rather than ` + + `this rename and keeps its own transport. The id "${CLAUDE_CLI_PROVIDER_ID}" now resolves to ` + + `"${CLAUDE_AGENT_SDK_PROVIDER_ID}" in registry lookups, so rename the row if it was meant ` + + `to be the subscription mechanism.`, + ); + } + if (hasLegacyRow) { + warnings.push( + `moved provider "${CLAUDE_CLI_PROVIDER_ID}" to "${CLAUDE_AGENT_SDK_PROVIDER_ID}": the row now ` + + "drives Anthropic's Claude Agent SDK, and the retired id survives as a lookup alias. " + + `${rewritten.changed} reference(s) were re-pointed.`, + ); + } + if (adapterRewrites > 0) { + warnings.push( + `rewrote the adapter id on ${adapterRewrites} custom provider row(s): "${CLAUDE_CLI_PROVIDER_ID}" ` + + `is registered as "${CLAUDE_AGENT_SDK_PROVIDER_ID}".`, + ); + } + if (!hasLegacyRow && customRows.length === 0 && rewritten.changed > 0) { + warnings.push( + `re-pointed ${rewritten.changed} reference(s) from "${CLAUDE_CLI_PROVIDER_ID}" to "` + + `${CLAUDE_AGENT_SDK_PROVIDER_ID}"; no provider row carried the retired id.`, + ); + } + + if (!changed) return { config, changed: false, warnings }; + return { config: projected, changed: true, warnings }; +} diff --git a/src/providers/deprecated-provider-aliases.ts b/src/providers/deprecated-provider-aliases.ts new file mode 100644 index 00000000000..68da5ff2a94 --- /dev/null +++ b/src/providers/deprecated-provider-aliases.ts @@ -0,0 +1,23 @@ +/** + * Provider ids that were renamed and still have to resolve. + * + * `claude-cli` shipped in 2.65.0 and became `claude-agent-sdk` on 2026-09-24, when the row moved from + * a hand-built `claude -p` turn onto Anthropic's Claude Agent SDK. Saved rows and references are + * rewritten by `claude-provider-rename-migration`, but three paths read an id before or outside that + * pass: a config read that happens first, `ocx provider test claude-cli` typed by hand, and any row + * the projection refused to move because the destination was taken. Same shape as + * `DEPRECATED_OAUTH_PROVIDER_ALIASES` in `src/oauth/index.ts`, and deliberately not a second registry + * row: one row per mechanism is what the registry means. + * + * It sits beside `./registry` rather than inside it because that file runs at its size cap, and this + * table is a self-contained lookup with two consumers: `getProviderRegistryEntry` for the provider + * id, and `getAdapterDefinition` (`../adapters/registry`) for the adapter string a saved row + * still carries after a refused projection. + */ +export const DEPRECATED_PROVIDER_ALIASES: Readonly> = { + "claude-cli": "claude-agent-sdk", +}; + +export function resolveDeprecatedProviderId(id: string): string { + return DEPRECATED_PROVIDER_ALIASES[id] ?? id; +} diff --git a/src/providers/model-rename-startup.ts b/src/providers/model-rename-startup.ts index 0f15b695cd6..a1fc691ecfb 100644 --- a/src/providers/model-rename-startup.ts +++ b/src/providers/model-rename-startup.ts @@ -3,6 +3,7 @@ import { projectModelRenames } from "./model-rename-migration"; import { projectStaleContextWindows } from "./stale-context-window-migration"; import { projectStaleVisionClassifications } from "./stale-vision-classification-migration"; import { projectDevinCliAuthMode } from "./devin-cli-authmode-migration"; +import { projectClaudeProviderRename } from "./claude-provider-rename-migration"; import type { OcxConfig } from "../types"; /** @@ -13,14 +14,23 @@ import type { OcxConfig } from "../types"; * with its own persistence, adopt, and failure handling. */ export function projectStartupConfigRepairs(config: OcxConfig): ReturnType { - const renames = projectModelRenames(config); + // The provider rename runs first: every later pass keys on the provider id, and a row still + // filed under the retired name would be repaired against the wrong registry entry. + const claudeProvider = projectClaudeProviderRename(config); + const renames = projectModelRenames(claudeProvider.config); const windows = projectStaleContextWindows(renames.config); const vision = projectStaleVisionClassifications(windows.config); const devinCli = projectDevinCliAuthMode(vision.config); return { config: devinCli.config, - changed: renames.changed || windows.changed || vision.changed || devinCli.changed, - warnings: [...renames.warnings, ...windows.warnings, ...vision.warnings, ...devinCli.warnings], + changed: claudeProvider.changed || renames.changed || windows.changed || vision.changed || devinCli.changed, + warnings: [ + ...claudeProvider.warnings, + ...renames.warnings, + ...windows.warnings, + ...vision.warnings, + ...devinCli.warnings, + ], }; } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 97ef18e58ad..4914cf016b4 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -7,6 +7,7 @@ import type { } from "./registry/types"; import { PROVIDER_REGISTRY_CORE } from "./registry/entries-core"; import { PROVIDER_REGISTRY_EXTENDED } from "./registry/entries-extended"; +import { resolveDeprecatedProviderId } from "./deprecated-provider-aliases"; export type { ProviderAuthKind, @@ -39,18 +40,17 @@ for (const entry of PROVIDER_REGISTRY) { } export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | undefined { - return PROVIDER_REGISTRY.find(entry => entry.id === id); + return PROVIDER_REGISTRY.find(entry => entry.id === resolveDeprecatedProviderId(id)); } /** * Merge a registry row's `staticHeaders` beneath a provider's own headers. * - * The field is documented as "merged into every upstream request for this provider", but that - * was only ever true for a freshly seeded config: `providerConfigSeed` copies the block once + * The field is documented as "merged into every upstream request for this provider", but that was + * only ever true for a freshly seeded config: `providerConfigSeed` copies the block once * (`derive.ts`), `enrichProviderFromCatalog` fills it only when the whole block is absent, and - * nothing merged it at request time. So an install that predates a header — or that saved any - * header of its own — never received the new one, which is exactly what #2067 would have - * shipped for every existing opencode-free user. + * nothing merged it at request time. So an install that predates a header — or that saved any of + * its own — never received the new one. That is exactly what #2067 would have shipped to them. * * The comparison is case-insensitive on purpose. HTTP header names are case-insensitive, but a * plain object spread is not: merging a registry `User-Agent` over a user's `user-agent` diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 96defecfd92..06a6eb3a4d0 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -1456,20 +1456,46 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ note: "StepFun (阶跃星辰) official OpenAI-compatible API.", }, { - // Official Claude Code CLI as the transport for a Claude subscription (§三十一). The CLI owns - // the account: this row stores no token and the adapter reads and injects none, so the request - // path is Anthropic's own harness rather than a replayed Claude Code identity against the - // Messages API. `baseUrl` is the destination the subscription's traffic reaches; OpenCodex - // never sends it. Fails closed if the row's base URL is overridden. - // v1 runs tools-disabled (`--tools ""`, no `--mcp-config`) so the client keeps tool ownership: - // text/reasoning only until the shared capture-only tool bridge lands. Requires the CLI: - // `npm i -g @anthropic-ai/claude-code`, plus a signed-in session (`claude` -> /login). - // GOVERNANCE: whether a subscription login may be driven through a proxy for a third-party - // agent is Anthropic's call rather than OpenCodex's — flagged for maintainer review, as with - // the CodeBuddy rows above. - id: "claude-cli", - label: "Claude Code CLI (subscription)", - adapter: "claude-cli", + // Anthropic's Claude Agent SDK as the transport for a Claude subscription (§三十一). The + // harness owns the account: this row stores no token and the adapter reads and injects none, so + // the request path is Anthropic's own harness rather than a replayed Claude Code identity + // against the Messages API. `baseUrl` is the destination the subscription's traffic reaches; + // OpenCodex never sends it. Fails closed if the row's base URL is overridden. + // The harness keeps its own preset: the caller's instructions are appended to the harness system + // prompt instead of replacing it, and the client's tool catalog reaches the model through an + // in-process MCP server that captures calls rather than executing them. The build the harness runs + // comes with the SDK; a compiled binary cannot resolve that path and drives the `claude` on PATH + // instead. Either way the operator needs a signed-in Claude Code session (`claude` -> /login). + // Renamed from `claude-cli` on 2026-09-24 — the id 2.65.0 shipped — because the old name + // described the one-shot `claude -p` turn this row used to be and collided with + // `src/providers/claude-cli-identity.ts`, which forges a `claude-cli/` user agent for the + // Messages-API rows. The retired id survives via `DEPRECATED_PROVIDER_ALIASES` and + // `claude-provider-rename-migration`. + // TOO IMPORTANT TO BURY: what this row shipped as in 2.65.0 was clearly against Anthropic's + // terms, Meridian's route is safer, and this row is the correction that takes it. + // (1) WHAT SHIPPED IN 2.65.0 WAS AGAINST THE TERMS, not a borderline reading of them: an + // OpenCodex-made one-shot `claude -p` turn with the caller's prompt replacing the harness + // prompt, no session and the harness's tools stripped, driven by a client that is not Claude + // Code. A Claude subscription — licensed for Anthropic's own harnesses — spent as an API behind + // a thin CLI veneer for a third-party agent loop: the traffic accounts get suspended over, and + // the loss lands on the operator's account rather than on OpenCodex. + // (2) MERIDIAN'S ROUTE IS SAFER AND THIS ROW TAKES IT. Meridian never builds the request; the + // harness runs the turn. Neither does this row any more: the turn goes through Anthropic's + // Claude Agent SDK, the harness behind the Claude Code CLI, so the official harness owns the + // session, the prompt and the sign-in, the caller's instructions are appended to the harness + // prompt instead of replacing it, and the client's tool catalog is served through an in-process + // MCP server that captures calls instead of executing them. Nothing is impersonated and + // OpenCodex forges no request. + // (3) SAFER IS NOT CLEAN, AND NOTHING HERE PRETENDS OTHERWISE. The client is still not Claude + // Code, so the row stays a grey area. `anthropic-apikey` is the only route without an + // interpretation question (console billing; the plan's automated-access clause covers a key). + // The neighbouring forged track — OpenCodex building the Messages request and replaying a Claude + // Code identity, i.e. the injected instruction and the `claude-cli/` user agent in + // `src/providers/claude-cli-identity.ts` — is impersonation, and it is what the suspensions + // target. Whether either row exists at all sits with MAINTAINERS.md. + id: "claude-agent-sdk", + label: "Claude Agent SDK (subscription)", + adapter: "claude-agent-sdk", baseUrl: "https://api.anthropic.com", // `key` + `keyOptional`, deliberately not `local`. "local" (Ollama, vLLM, LM Studio) means the // traffic never leaves the machine and there is no credential to classify; this row reaches @@ -1482,6 +1508,9 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ // reachable from the Providers page. authKind: "key", keyOptional: true, + // Lookup metadata for surfaces that match on names (integrations, dashboards) rather than for + // provider resolution, which goes through `DEPRECATED_PROVIDER_ALIASES`. + extraMetadataAliases: ["claude-cli"], // There is no key console for a keyless row: the link that helps an operator is the one that // documents the install and sign-in this provider requires. dashboardUrl: "https://docs.claude.com/en/docs/claude-code/setup", @@ -1503,6 +1532,6 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ reasoningEfforts: ANTHROPIC_REASONING_EFFORTS, modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, - note: "Runs Claude subscription traffic through Anthropic's own harness: the official Claude Code CLI headlessly (`claude -p`), one turn per request. OpenCodex stores no Claude token, reads none and injects none — the CLI signs in and bills the account itself, which is why this row is keyless and an API key saved here never reaches the harness (use `anthropic-apikey` for key billing). The sign-in is the one of the user this proxy runs as, so every request served through this row — by any client of this proxy — spends that same account; OpenCodex neither pools nor multiplexes Claude sign-ins. Requires the CLI (`npm i -g @anthropic-ai/claude-code`) and a signed-in session (`claude` -> /login). v1 disables CLI tools (--tools \"\", --strict-mcp-config) so the client retains tool ownership: text/reasoning only for now. Subscription routing authorization flagged for maintainer review.", + note: "TOO IMPORTANT TO BURY: what this row shipped as in 2.65.0 was clearly against Anthropic's terms — OpenCodex ran a one-shot `claude -p` turn with the caller's prompt replacing the harness prompt, no session and the harness tools stripped, driven by a client that is not Claude Code. A Claude subscription is licensed for Anthropic's own harnesses, and that construction spent it as an API behind a thin CLI veneer for a third-party agent loop; accounts were suspended for exactly that, and the loss lands on the account signed in here. This row is the correction and takes the safer route, the one Meridian takes: the turn runs through Anthropic's Claude Agent SDK, so the harness owns the session, the prompt and the sign-in, the caller's instructions are appended to the harness prompt instead of replacing it, and the client's tool catalog is served through an in-process MCP server that captures calls instead of executing them. Nothing is impersonated and OpenCodex forges no request. Safer is not clean: the client is still not Claude Code, so the row stays a grey area, and `anthropic-apikey` remains the only route without an interpretation question (console billing; the plan's automated-access clause covers a key). Mechanics: the row stores no Claude token, reads none and injects none — the harness signs in and bills the account itself, which is why this row is keyless (an API key saved here never reaches the harness). The sign-in is the one of the user this proxy runs as, so every request served through this row — by any client of this proxy — spends that same account; OpenCodex neither pools nor multiplexes Claude sign-ins. Requires a signed-in Claude Code session (`claude` -> /login); the harness build itself ships with the Agent SDK, and a compiled binary drives the `claude` on PATH. Whether a subscription-for-a-foreign-client row should exist at all is a maintainer decision.", }, ]; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 71a01735d4e..e3702d0c8d7 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -30,16 +30,33 @@ Some adapters share another adapter's routed-tool semantics while retaining inde The inherited contract includes Meta Muse's host-gated 64-character tool-name alias when the constructed send URL is `api.meta.ai` (`src/responses/muse-tool-name-alias.ts`). - `mimo-free` inherits the `openai-chat` contract. -- `claude-cli` inherits the `codebuddy` contract. Claude Code speaks the same stream-json +- `claude-agent-sdk` inherits the `codebuddy` contract. Claude Code speaks the same stream-json protocol this repository already parses for CodeBuddy and Qoder, so the wire is inherited and the - family module (`src/adapters/claude-cli/`) supplies only its own arguments and child environment. - That profile is the first credentialless one: it omits `tokenEnv`, the CLI reads the operator's - own Claude Code sign-in, and the turn neither requires nor injects an API key. The proxy-safety - controls are set per invocation, through CLI arguments and the child environment, and - `tests/providers/claude-cli-adapter.test.ts` pins them: `--tools ""`, `--strict-mcp-config`, - `--setting-sources ""`, `--no-session-persistence`, no permission bypass, a folded prompt staged - in a 0600 per-turn file and passed as `--system-prompt-file` rather than as a world-readable - argument, and a child environment that carries no inherited `ANTHROPIC_*` value. + family module (`src/adapters/claude-agent-sdk/`) supplies its own option assembly + (`sdk-options.ts`), the in-process catalog server (`sdk-bridge.ts`), the scoped child environment + (`env.ts`), the SDK-driven runner (`sdk-turn.ts`), which owns the turn's lifecycle where the + spawned-CLI families hand theirs to `../coding-agent/turn.ts`, and the process ownership behind + that runner (`harness-process.ts`): the harness is spawned there, a bounded cleanup lease is + reserved before the spawn, and a tree the ladder could not signal keeps that lease past the + direct child's `close` until the platform reports the process group empty. That profile is the first credentialless one: it omits `tokenEnv`, the harness reads + the operator's own Claude Code sign-in, and the turn neither requires nor injects an API key. + The turn itself runs through Anthropic's Claude Agent SDK — the harness behind the Claude Code CLI + — and `tests/providers/claude-agent-sdk-adapter.test.ts` pins the options it sets: built-in tools + off (`tools: []`), no setting sources, no persisted session, an in-process MCP server as the only tool channel, a + per-turn scratch working directory so the preset reports no host path or git state, the + caller's prompt appended to the harness preset instead of replacing it, and a child environment + that carries no inherited `ANTHROPIC_*` value. The id shipped in 2.65.0 as `claude-cli`; that name + resolves through `DEPRECATED_PROVIDER_ALIASES` and is moved by + `src/providers/claude-provider-rename-migration.ts`. The row is a correction of what 2.65.0 + shipped, and it is documented as one: that version drove a one-shot `claude -p` turn with the + caller's prompt replacing the harness prompt, no session and no tools, from a client that is not + Claude Code — against Anthropic's terms, since a subscription is licensed for Anthropic's own + harnesses and that construction spent it as an API for a third-party agent. This row takes the + safer route instead, the one Meridian takes: the harness runs the turn, so nothing impersonates + Claude Code. Safer rather than clean — the client is still not Claude Code, and + `anthropic-apikey` is the only route without an interpretation question. The registry comment, + the row's `note`, the provider guide and the PR statement carry that chain in plain language, so + no reader takes the row for a clean path. Its registry row is `authKind: "key"` with `keyOptional: true`, NOT `local`: the turn leaves the machine for `api.anthropic.com`, and `local` (Ollama, vLLM, LM Studio) is the classification for traffic that never does. `keyOptional` is the existing exemption from key enforcement, and key diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index cc4fcab6dc2..0e7f1c46d13 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -24,7 +24,7 @@ const EXPECTED_ADAPTER_NAMES = { devin: "devin", "mimo-free": "mimo-free", qoder: "qoder", - "claude-cli": "claude-cli", + "claude-agent-sdk": "claude-agent-sdk", } as const; function provider(adapter: string): OcxProviderConfig { diff --git a/tests/adapters/adapter-tool-conformance.test.ts b/tests/adapters/adapter-tool-conformance.test.ts index 4b5a102908c..fa9c8e7778d 100644 --- a/tests/adapters/adapter-tool-conformance.test.ts +++ b/tests/adapters/adapter-tool-conformance.test.ts @@ -426,7 +426,7 @@ describe("registry-derived routed tool conformance", () => { } }); - const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder", "claude-cli"]); + const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder", "claude-agent-sdk"]); // The Devin adapter is runTurn-only: it streams Connect-RPC from runTurn, so // buildRequest returns a placeholder and tools never travel the wire path. // Both Devin provider rows share it and differ only in where the credential diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 784f61f6887..a40d2b24320 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -293,7 +293,8 @@ "claude-auth-detect.test.ts": "claude-integration", "claude-auth-mode.test.ts": "claude-integration", "claude-authmode-migration.test.ts": "claude-integration", - "claude-cli-adapter.test.ts": "providers", + "claude-agent-sdk-adapter.test.ts": "providers", + "claude-provider-rename-migration.test.ts": "providers", "claude-cli.test.ts": "claude-integration", "claude-config-first-party.test.ts": "cli", "claude-code-thought-signature-scope.test.ts": "claude-integration", diff --git a/tests/providers/claude-agent-sdk-adapter.test.ts b/tests/providers/claude-agent-sdk-adapter.test.ts new file mode 100644 index 00000000000..152818acc84 --- /dev/null +++ b/tests/providers/claude-agent-sdk-adapter.test.ts @@ -0,0 +1,1488 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { execFileSync, type ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { Readable, Writable } from "node:stream"; +import { + buildChildEnv, + CLAUDE_CLI_QUIET_ENV, + createClaudeAgentSdkAdapter, + withClaudeLoginHint, + type ClaudeAgentSdkDeps, + type ClaudeAgentSdkModule, + type ClaudeAgentSdkQuery, +} from "../../src/adapters/claude-agent-sdk/adapter"; +import { + buildAgentSdkEffort, + buildAgentSdkSystemPrompt, + buildAgentSdkTurnOptions, +} from "../../src/adapters/claude-agent-sdk/sdk-options"; +import { + buildClaudeAgentSdkToolBridge, + CLAUDE_AGENT_SDK_MCP_SERVER_NAME, +} from "../../src/adapters/claude-agent-sdk/sdk-bridge"; +import { baseScopedEnv } from "../../src/adapters/coding-agent/turn"; +import { CLAUDE_CLI_PROFILE, clearClaudeCliBinaryCache } from "../../src/adapters/claude-agent-sdk/profiles"; +import { + createHarnessProcessSupervisor, + HARNESS_CAPACITY_CODE, + harnessQuarantineSnapshot, + harnessTeardownMetrics, + HarnessCapacityError, + MAX_ACTIVE_HARNESS_TEARDOWNS, + reapHarnessQuarantine, + resetHarnessQuarantineForTests, + setHarnessTreeProbeForTests, +} from "../../src/adapters/claude-agent-sdk/harness-process"; +import { effectiveAdapterContract, getAdapterDefinition } from "../../src/adapters/registry"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { deriveProviderPresets, providerConfigSeed } from "../../src/providers/derive"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../src/types"; +import { bridgeToResponsesSSE } from "../../src/bridge"; +import { getActiveTurnCount, trackStreamLifetime, tryAdmitTurn } from "../../src/server/lifecycle"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; +import type { TranslatorBudget } from "../../src/lib/translator-budget"; + +// The binary-discovery cache and the harness quarantine are module-level (production seams); reset +// them so a test that reports a missing CLI cannot mask a later test's injected binary, and a case +// that asserts on survivors does not inherit the previous one's. +beforeEach(() => { clearClaudeCliBinaryCache(); resetHarnessQuarantineForTests(); }); + +interface FakeSdk { + module: ClaudeAgentSdkModule; + /** The options object the runner handed the SDK, per turn. */ + options: Record[]; + /** The prompt frames the runner wrote, per turn (drained from the async iterable). */ + prompts: unknown[][]; + state: { started: number; returned: number; harness?: unknown }; +} + +/** + * Fake Agent SDK: replays the given frames as the harness stream, drains the prompt the runner + * passes, and counts how often a turn was started or ended by `return()`. + * + * `park: true` models the capture-only leg: the harness stopped producing frames and is waiting on + * a tool call nothing will answer, which is exactly the state the runner has to end from its side. + */ +function fakeSdk(frames: readonly unknown[], behavior: { park?: boolean; spawnHarness?: boolean } = {}): FakeSdk { + const options: Record[] = []; + const prompts: unknown[][] = []; + const state: { started: number; returned: number; harness?: unknown } = { started: 0, returned: 0 }; + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + const module: ClaudeAgentSdkModule = { + query: params => { + state.started += 1; + options.push(params.options); + if (behavior.spawnHarness === true) { + // The SDK's contract: a custom spawner is handed the command the SDK resolved and returns the + // process object. Calling it here exercises the runner's ownership of that child without a + // real Claude Code binary on the machine. + const spawnHarness = params.options.spawnClaudeCodeProcess as (request: unknown) => unknown; + state.harness = spawnHarness({ + command: "claude", + args: ["--output-format", "stream-json"], + env: {}, + signal: new AbortController().signal, + }); + } + const collected: unknown[] = []; + prompts.push(collected); + const prompt = params.prompt; + const generator = (async function* () { + if (typeof prompt !== "string") { + for await (const frame of prompt) collected.push(frame); + } + for (const frame of frames) yield frame as Record; + if (behavior.park) await gate; + })(); + return { + [Symbol.asyncIterator]: () => generator, + return: async (value?: unknown) => { + state.returned += 1; + release?.(); + return await generator.return(value); + }, + } as ClaudeAgentSdkQuery; + }, + }; + return { module, options, prompts, state }; +} + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "claude-agent-sdk", + baseUrl: CLAUDE_CLI_PROFILE.canonicalBaseUrl, + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + ...overrides, + } as OcxProviderConfig; +} + +function parsed(overrides: Partial = {}): OcxParsedRequest { + return { + modelId: "claude-sonnet-5", + stream: true, + options: {}, + context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + ...overrides, + } as OcxParsedRequest; +} + +function tool(name: string): OcxTool { + return { + name, + description: `Tool ${name}`, + parameters: { type: "object", properties: { a: { type: "number" } } }, + }; +} + +function withTools(names: string[], overrides: Partial = {}): OcxParsedRequest { + return parsed({ + context: { messages: [{ role: "user", content: "use a tool", timestamp: 0 }], tools: names.map(tool) }, + ...overrides, + } as OcxParsedRequest); +} + +function incoming(abortSignal?: AbortSignal, translatorBudget = createTestTranslatorBudget()) { + return { + headers: new Headers(), + translatorBudget, + ...(abortSignal ? { abortSignal } : {}), + }; +} + +function initFrame(servers?: unknown[]) { + return { type: "system", subtype: "init", ...(servers ? { mcp_servers: servers } : {}) }; +} + +const bridgeConnected = { name: CLAUDE_AGENT_SDK_MCP_SERVER_NAME, status: "connected", source: "sdk" }; +const messageStop = { type: "stream_event", event: { type: "message_stop" } }; + +function textFrame(text: string) { + return { type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text } } }; +} + +function resultFrame(usage?: Record) { + return { type: "result", subtype: "success", is_error: false, ...(usage ? { usage } : {}) }; +} + +function toolCallFrames(id: string, name: string, args = "{}") { + return [ + { type: "stream_event", event: { type: "content_block_start", index: 0, content_block: { type: "tool_use", id, name } } }, + { type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: args } } }, + { type: "stream_event", event: { type: "content_block_stop", index: 0 } }, + ]; +} + +async function run( + adapter: ReturnType, + request: OcxParsedRequest, + signal?: AbortSignal, + translatorBudget?: TranslatorBudget, +): Promise { + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, incoming(signal, translatorBudget), event => events.push(event)); + return events; +} + +/** A push-driven event source, the shape the streaming server hands the bridge. */ +function channel() { + const items: T[] = []; + let wake: (() => void) | undefined; + let closed = false; + const knock = (): void => { const pending = wake; wake = undefined; pending?.(); }; + return { + push(item: T): void { items.push(item); knock(); }, + close(): void { closed = true; knock(); }, + async *stream(): AsyncGenerator { + while (true) { + while (items.length > 0) yield items.shift()!; + if (closed) return; + await new Promise(resolve => { wake = resolve; }); + } + }, + }; +} + +async function drainStream(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let text = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + return text; +} + +describe("claude-agent-sdk is an official-harness provider, not a Messages relay", () => { + test("the registry row and the adapter agree on the one canonical destination", () => { + const entry = PROVIDER_REGISTRY.find(candidate => candidate.id === "claude-agent-sdk"); + expect(entry).toBeDefined(); + expect(entry!.adapter).toBe("claude-agent-sdk"); + expect(entry!.baseUrl).toBe(CLAUDE_CLI_PROFILE.canonicalBaseUrl); + expect(entry!.defaultModel).toBe("claude-sonnet-5"); + expect(entry!.models).toContain(entry!.defaultModel!); + expect(entry!.modelContextWindows?.[entry!.defaultModel!]).toBeGreaterThan(0); + // Static roster: a live discovery request against this route answers 404 and is pure noise. + expect(entry!.liveModels).toBe(false); + // The harness parses an image block, but no headless turn was shown to hand those bytes to the + // model, so the row publishes text-only models instead of the Messages API rows' image + // modality: an advertised input the route cannot honour is how a picture gets answered blind. + expect(entry!.noVisionModels).toEqual(entry!.models ?? []); + expect(entry!.modelInputModalities).toBeUndefined(); + }); + + test("the row is a keyless key provider, not a local runtime, and needs no dashboardPreset flag", () => { + const entry = PROVIDER_REGISTRY.find(candidate => candidate.id === "claude-agent-sdk")!; + // "local" is the Ollama / vLLM / LM Studio classification: the traffic never leaves the machine + // and there is no credential to classify. This row's turn leaves for api.anthropic.com, and the + // account surface answers from `authKind` (`classifyAccount` in src/cli/account-api.ts), where + // "local" claimed there were no credentials at all — for a provider whose whole point is a + // credential the harness owns. + expect(entry.authKind).toBe("key"); + // Keyless is expressed by `keyOptional`, the flag key enforcement already honors + // (src/server/auth-cors.ts, src/providers/api-key-selection.ts) without pretending a key exists. + expect(entry.keyOptional).toBe(true); + // A key row must name where its credential comes from; deriveKeyLoginMap throws without this. + expect(entry.dashboardUrl).toBeTruthy(); + expect(entry.dashboardPreset).toBeUndefined(); + expect(providerConfigSeed(entry)).toMatchObject({ authMode: "key", keyOptional: true }); + expect(deriveProviderPresets().find(candidate => candidate.id === "claude-agent-sdk")) + .toMatchObject({ auth: "key", keyOptional: true }); + }); + + test("the adapter inherits the shared coding-agent contract instead of a second wire", () => { + expect(getAdapterDefinition("claude-agent-sdk")?.contractParent).toBe("codebuddy"); + expect(effectiveAdapterContract("claude-agent-sdk").wire).toBe("codebuddy"); + }); +}); + +describe("claude-agent-sdk options keep the harness in charge and tools with the client", () => { + test("keeps the harness preset and APPENDS the caller's contract to it", () => { + const prompt = buildAgentSdkSystemPrompt( + parsed({ context: { systemPrompt: ["Be terse."], messages: [] } } as OcxParsedRequest), + false, + ); + // The 2.65.0 construction replaced this preset; replacing it is what made the turn a puppet + // rather than the harness. `custom` would be that regression, so pin the shape, not just the text. + expect(prompt).toEqual({ type: "preset", preset: "claude_code", append: "Be terse." }); + }); + + test("a caller with no system prompt still gets the preset, not an empty replacement", () => { + expect(buildAgentSdkSystemPrompt(parsed(), false)).toEqual({ type: "preset", preset: "claude_code" }); + }); + + test("the capture-bridge directive joins the caller's prompt when a catalog is advertised", () => { + const prompt = buildAgentSdkSystemPrompt( + parsed({ context: { systemPrompt: ["Be terse."], messages: [] } } as OcxParsedRequest), + true, + ) as { append?: string }; + expect(prompt.append?.startsWith("Be terse.")).toBe(true); + expect(prompt.append).toContain("captures call intent only"); + }); + + test("disables built-in tools, settings, session persistence and every permission bypass", () => { + const options = buildAgentSdkTurnOptions({ + provider: provider(), + parsed: parsed(), + env: { HOME: "/Users/operator" }, + cwd: "/tmp/ocx-scratch", + abortController: new AbortController(), + onStderr: () => undefined, + }); + expect(options.tools).toEqual([]); + expect(options.settingSources).toEqual([]); + expect(options.strictMcpConfig).toBe(true); + expect(options.persistSession).toBe(false); + expect(options.includePartialMessages).toBe(true); + // Neutral on purpose: the preset reports its working directory and git state to the model. + expect(options.cwd).toBe("/tmp/ocx-scratch"); + expect(options.model).toBe("claude-sonnet-5"); + expect(options.permissionMode).toBeUndefined(); + expect(options.allowedTools).toBeUndefined(); + expect(options.mcpServers).toBeUndefined(); + // Absent means "the build the SDK ships", which is the version-matched one; the compiled-binary + // path sets it explicitly (see the preflight test below). + expect(options.pathToClaudeCodeExecutable).toBeUndefined(); + expect(options.env).toEqual({ HOME: "/Users/operator" }); + }); + + test("maps the caller's reasoning effort onto an SDK effort level", () => { + expect(buildAgentSdkEffort(provider(), parsed({ options: { reasoning: "high" } }))).toBe("high"); + }); + + test("drops an effort the SDK does not accept instead of sending it", () => { + const mapped = provider({ reasoningEffortMap: { high: "minimal" } }); + expect(buildAgentSdkEffort(mapped, parsed({ options: { reasoning: "high" } }))).toBeUndefined(); + }); + + test("serves the catalog as an in-process SDK MCP server that allows exactly its tools", async () => { + const catalog = await buildClaudeAgentSdkToolBridge(withTools(["alpha", "beta"])); + expect(catalog).toBeDefined(); + const options = buildAgentSdkTurnOptions({ + provider: provider(), + parsed: withTools(["alpha", "beta"]), + env: {}, + cwd: "/tmp/ocx-scratch", + abortController: new AbortController(), + onStderr: () => undefined, + toolCatalog: { + serverName: catalog!.serverName, + instance: catalog!.instance, + allowedNames: [...catalog!.emittedNameMap.keys()], + }, + }); + const servers = options.mcpServers as Record; + expect(Object.keys(servers)).toEqual([CLAUDE_AGENT_SDK_MCP_SERVER_NAME]); + expect(servers[CLAUDE_AGENT_SDK_MCP_SERVER_NAME]).toMatchObject({ + type: "sdk", + name: CLAUDE_AGENT_SDK_MCP_SERVER_NAME, + instance: catalog!.instance, + }); + expect(options.allowedTools).toEqual([...catalog!.emittedNameMap.keys()]); + // The advertised schema is the client's own JSON Schema, not a re-derived one. + expect(catalog!.tools[0]).toMatchObject({ + description: "Tool alpha", + inputSchema: { type: "object", properties: { a: { type: "number" } } }, + }); + }); + + test("a request without tools gets no MCP server at all", async () => { + expect(await buildClaudeAgentSdkToolBridge(parsed())).toBeUndefined(); + }); +}); + +describe("claude-agent-sdk child environment carries no credential and no proxy destination", () => { + test("an inherited ANTHROPIC_* variable cannot point the harness back at this proxy", () => { + const previous = { base: process.env.ANTHROPIC_BASE_URL, key: process.env.ANTHROPIC_API_KEY }; + process.env.ANTHROPIC_BASE_URL = "http://127.0.0.1:10100"; + process.env.ANTHROPIC_API_KEY = "inherited-key"; + try { + const env = buildChildEnv(CLAUDE_CLI_PROFILE, ""); + expect(Object.keys(env).filter(name => name.startsWith("ANTHROPIC_") || name.startsWith("CLAUDE_CODE_OAUTH"))).toEqual([]); + expect(JSON.stringify(env)).not.toContain("inherited-key"); + expect(JSON.stringify(env)).not.toContain("127.0.0.1:10100"); + } finally { + if (previous.base === undefined) delete process.env.ANTHROPIC_BASE_URL; + else process.env.ANTHROPIC_BASE_URL = previous.base; + if (previous.key === undefined) delete process.env.ANTHROPIC_API_KEY; + else process.env.ANTHROPIC_API_KEY = previous.key; + } + }); + + test("keeps the home directory the harness signs in from, and quiets its own telemetry", () => { + const env = buildChildEnv(CLAUDE_CLI_PROFILE, ""); + // The inherited HOME is the property the provider exists for and the one an operator must know + // about: the sign-in belongs to the user this proxy runs as, so every request served through + // this row — by any client of the proxy — spends that same Claude account. + expect(env.HOME).toBe(process.env.HOME); + expect(env.DISABLE_AUTOUPDATER).toBe("1"); + expect(env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe("1"); + }); + + test("a key configured on the row is never handed to the harness", () => { + // `keyOptional` makes the row keyless without making it key-*blind*: an operator who saved an + // API key in the dashboard or with `ocx provider add --api-key` must not silently believe it + // bills the turn. Nothing layers a credential onto the child environment. + const env = buildChildEnv(CLAUDE_CLI_PROFILE, "sk-ant-row-key"); + expect(JSON.stringify(env)).not.toContain("sk-ant-row-key"); + expect(Object.keys(env).filter(name => name.startsWith("ANTHROPIC_") || name.startsWith("CLAUDE_CODE_OAUTH"))).toEqual([]); + }); + + test("carries the account name the harness resolves its keychain sign-in by, and nothing else new", () => { + // Without USER the harness reports "not logged in" on a signed-in machine: it looks its own + // keychain entry up by account name. The value is a name, not a credential. + const previous = process.env.USER; + process.env.USER = "ocx-probe-user"; + try { + const env = buildChildEnv(CLAUDE_CLI_PROFILE, ""); + expect(env.USER).toBe("ocx-probe-user"); + // Derived from the two owners rather than restated, so a new quiet flag cannot silently + // become the third thing this environment carries. + expect(Object.keys(env).sort()).toEqual( + [...new Set([...Object.keys(baseScopedEnv()), ...Object.keys(CLAUDE_CLI_QUIET_ENV), "USER"])].sort(), + ); + } finally { + if (previous === undefined) delete process.env.USER; + else process.env.USER = previous; + } + }); + + test("adds no USER key when the parent has none", () => { + const previous = process.env.USER; + delete process.env.USER; + try { + expect("USER" in buildChildEnv(CLAUDE_CLI_PROFILE, "")).toBe(false); + } finally { + if (previous !== undefined) process.env.USER = previous; + } + }); +}); + +describe("claude-agent-sdk runTurn fails closed before the harness starts", () => { + test("a non-canonical base URL is refused", async () => { + const sdk = fakeSdk([]); + const adapter = createClaudeAgentSdkAdapter( + provider({ baseUrl: "https://evil.example.test" }), + { loadSdk: async () => sdk.module }, + ); + const events = await run(adapter, parsed()); + expect(sdk.state.started).toBe(0); + expect(events[0]).toMatchObject({ type: "error", code: "non_canonical_destination", retryable: false }); + }); + + test("an image is refused rather than handed to a harness that was never shown to carry it", async () => { + const sdk = fakeSdk([]); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module }); + const events = await run(adapter, parsed({ + context: { + messages: [{ + role: "user", + content: [ + { type: "text", text: "what is this?" }, + { type: "image", imageUrl: "data:image/png;base64,iVBORw0KGgo=" }, + ], + timestamp: 0, + }], + }, + } as OcxParsedRequest)); + // Same refusal the Qoder presets make: a dropped image answers the wrong question confidently, + // and no headless harness turn was shown to deliver image bytes to the model. + expect(sdk.state.started).toBe(0); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", status: 400, code: "unsupported_input_modality", retryable: false }); + }); + + test("an unusable tool catalog is the client's 400, not a turn that dies inside the loop", async () => { + const sdk = fakeSdk([]); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module }); + const tooMany = Array.from({ length: 129 }, (_value, index) => `tool-${index}`); + const events = await run(adapter, withTools(tooMany)); + expect(sdk.state.started).toBe(0); + expect(events[0]).toMatchObject({ type: "error", status: 400, code: "tool_catalog_invalid", retryable: false }); + }); + + test("a compiled binary drives the Claude Code on PATH, and names the install when it is missing", async () => { + const sdk = fakeSdk([initFrame(), resultFrame()]); + const missing = createClaudeAgentSdkAdapter(provider(), { + loadSdk: async () => sdk.module, + isStandalone: () => true, + which: () => undefined, + }); + const refused = await run(missing, parsed()); + expect(sdk.state.started).toBe(0); + expect(refused[0]).toMatchObject({ type: "error", code: "cli_not_found", retryable: false }); + expect(String((refused[0] as { message: string }).message)).toContain("npm install -g @anthropic-ai/claude-code"); + + const resolved = fakeSdk([initFrame(), resultFrame()]); + const adapter = createClaudeAgentSdkAdapter(provider(), { + loadSdk: async () => resolved.module, + isStandalone: () => true, + which: () => "/opt/homebrew/bin/claude", + }); + await run(adapter, parsed()); + expect(resolved.options[0]!.pathToClaudeCodeExecutable).toBe("/opt/homebrew/bin/claude"); + }); + + test("an SDK that cannot be loaded is reported as such, not as a provider failure", async () => { + const adapter = createClaudeAgentSdkAdapter(provider(), { + loadSdk: async () => { throw new Error("Cannot find module '@anthropic-ai/claude-agent-sdk'"); }, + }); + const events = await run(adapter, parsed()); + expect(events[0]).toMatchObject({ type: "error", status: 500, code: "claude_agent_sdk_unavailable" }); + expect(String((events[0] as { message: string }).message)).toContain("claude-agent-sdk"); + }); + + test("an aborted request never starts a turn", async () => { + const sdk = fakeSdk([]); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module }); + const events = await run(adapter, parsed(), AbortSignal.abort()); + expect(sdk.state.started).toBe(0); + expect(events[0]).toMatchObject({ type: "error", message: "Claude Agent SDK turn was aborted before start." }); + }); +}); + +describe("claude-agent-sdk runTurn streams a subscription turn", () => { + test("runs without any stored API key, because the harness owns the account", async () => { + const sdk = fakeSdk([ + initFrame(), + textFrame("Hel"), + textFrame("lo"), + { type: "stream_event", event: { type: "content_block_delta", delta: { type: "thinking_delta", thinking: "think" } } }, + resultFrame({ input_tokens: 7, output_tokens: 2 }), + ]); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module }); + const events = await run(adapter, parsed()); + expect(sdk.state.started).toBe(1); + expect(events.filter(event => event.type === "text_delta").map(event => (event as { text: string }).text).join("")).toBe("Hello"); + expect(events.some(event => event.type === "thinking_delta")).toBe(true); + expect(events.at(-1)).toMatchObject({ type: "done", usage: { inputTokens: 7, outputTokens: 2, totalTokens: 9 } }); + // The replayed conversation travels as the prompt the harness reads, and the turn is ended from + // this side once the stream is over. + expect(JSON.stringify(sdk.prompts[0])).toContain("hello"); + expect(sdk.state.returned).toBe(1); + expect(sdk.options[0]!.env).toMatchObject({ HOME: process.env.HOME! }); + }); + + test("runs in a scratch working directory and removes it once the harness is gone", async () => { + const sdk = fakeSdk([initFrame(), textFrame("ok"), resultFrame({ input_tokens: 1, output_tokens: 1 })]); + const removed: string[] = []; + const adapter = createClaudeAgentSdkAdapter(provider(), { + loadSdk: async () => sdk.module, + makeScratchDir: async () => "/tmp/ocx-turn-scratch", + removeScratchDir: async (dir) => { removed.push(dir); }, + }); + await run(adapter, parsed()); + expect(sdk.options[0]!.cwd).toBe("/tmp/ocx-turn-scratch"); + expect(removed).toEqual(["/tmp/ocx-turn-scratch"]); + }); + + test("a scratch directory that cannot be created fails the turn instead of leaking a cwd", async () => { + const sdk = fakeSdk([]); + const adapter = createClaudeAgentSdkAdapter(provider(), { + loadSdk: async () => sdk.module, + makeScratchDir: async () => { throw new Error("EROFS: read-only file system"); }, + }); + const events = await run(adapter, parsed()); + expect(sdk.state.started).toBe(0); + expect(events[0]).toMatchObject({ + type: "error", + status: 500, + code: "claude_agent_sdk_scratch_unavailable", + retryable: false, + }); + }); + + test("an unauthenticated harness becomes an actionable sign-in error", async () => { + // Verbatim shape of a real unauthenticated turn: a terminal `result` frame, no HTTP status. + const sdk = fakeSdk([{ + type: "result", + subtype: "success", + is_error: true, + result: "Not logged in · Please run /login", + }]); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module }); + const events = await run(adapter, parsed()); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", status: 401, code: "claude_cli_not_logged_in", retryable: false }); + expect(String((events[0] as { message: string }).message)).toContain("claude"); + }); + + test("the sign-in hint leaves every other error untouched", () => { + const events: AdapterEvent[] = []; + const hinted = withClaudeLoginHint(event => events.push(event)); + hinted({ type: "error", message: "upstream exploded", status: 502, code: "upstream_error" }); + hinted({ type: "error", message: "rate limited", status: 429, code: "rate_limit_exceeded" }); + hinted({ type: "text_delta", text: "hi" }); + expect(events).toEqual([ + { type: "error", message: "upstream exploded", status: 502, code: "upstream_error" }, + { type: "error", message: "rate limited", status: 429, code: "rate_limit_exceeded" }, + { type: "text_delta", text: "hi" }, + ]); + }); + + test("a stream that ends without a terminal result fails closed, with the harness's stderr", async () => { + const module: ClaudeAgentSdkModule = { + query: params => { + (params.options.stderr as (chunk: string) => void)("harness exploded before any frame"); + const generator = (async function* () { yield initFrame() as Record; })(); + return { + [Symbol.asyncIterator]: () => generator, + return: async (value?: unknown) => await generator.return(value), + } as ClaudeAgentSdkQuery; + }, + }; + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => module }); + const events = await run(adapter, parsed()); + expect(events.at(-1)).toMatchObject({ type: "error", status: 502, code: "protocol_error", retryable: false }); + expect(String((events.at(-1) as { message: string }).message)).toContain("harness exploded before any frame"); + }); + + test("one huge stderr chunk is cut at ingestion, in bytes rather than code units", async () => { + // 8 KiB of "…" is 24 KiB of UTF-8. Cutting code units only after the join retains the chunk + // in full and lets the message carry three times the advertised bound, so both the ingestion and the + // final cut have to be byte-exact. + const huge = "…".repeat(8 * 1024); + const module: ClaudeAgentSdkModule = { + query: params => { + (params.options.stderr as (chunk: string) => void)(huge); + const generator = (async function* () { yield initFrame() as Record; })(); + return { + [Symbol.asyncIterator]: () => generator, + return: async (value?: unknown) => await generator.return(value), + } as ClaudeAgentSdkQuery; + }, + }; + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => module }); + const events = await run(adapter, parsed()); + expect(events.at(-1)).toMatchObject({ type: "error", status: 502, code: "protocol_error", retryable: false }); + const message = String((events.at(-1) as { message: string }).message); + expect(message).toContain("[truncated by opencodex]"); + expect(Buffer.byteLength(message, "utf8")).toBeLessThanOrEqual(8 * 1024 + 128); + }); + + test("a turn that never produces a frame is bounded by the wall-clock ceiling", async () => { + const sdk = fakeSdk([], { park: true }); + const adapter = createClaudeAgentSdkAdapter(provider(), { + loadSdk: async () => sdk.module, + timeoutMs: 20, + reapTimeoutMs: 50, + }); + const events = await run(adapter, parsed()); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", status: 504, code: "timeout", retryable: true }); + // The turn ended from this side; the harness does not get to hold the request open. + expect(sdk.state.returned).toBe(1); + }); + + test("a client disconnect ends a parked turn", async () => { + const sdk = fakeSdk([initFrame()], { park: true }); + const adapter = createClaudeAgentSdkAdapter(provider(), { + loadSdk: async () => sdk.module, + reapTimeoutMs: 50, + }); + const controller = new AbortController(); + const events: AdapterEvent[] = []; + const turn = adapter.runTurn!(parsed(), incoming(controller.signal), event => events.push(event)); + setTimeout(() => controller.abort(), 10); + await turn; + expect(events.at(-1)).toMatchObject({ type: "error", retryable: false }); + expect(String((events.at(-1) as { message: string }).message)).toContain("aborted"); + expect(sdk.state.returned).toBe(1); + }); +}); + +describe("claude-agent-sdk serves the client's catalog through a capture-only MCP server", () => { + test("captures a call, renames it to the wire name, and ends the leg with done(tool_use)", async () => { + const catalog = await buildClaudeAgentSdkToolBridge(withTools(["alpha"])); + const emitted = [...catalog!.emittedNameMap.keys()][0]!; + const sdk = fakeSdk([ + initFrame([bridgeConnected]), + ...toolCallFrames("tu_1", emitted, "{\"a\":1}"), + messageStop, + ], { park: true }); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module, reapTimeoutMs: 50 }); + const events = await run(adapter, withTools(["alpha"])); + expect(events[0]).toMatchObject({ type: "tool_call_start", name: "alpha", id: "tu_1" }); + expect(events[1]).toMatchObject({ type: "tool_call_delta", arguments: "{\"a\":1}" }); + expect(events[2]).toMatchObject({ type: "tool_call_end" }); + // The capture handler never answers, so the harness parks after message_stop: the completed call + // IS this turn's output, and the client executes it. + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "tool_use", endTurn: false }); + expect(sdk.state.returned).toBe(1); + expect(sdk.options[0]!.allowedTools).toEqual([emitted]); + }); + + test("an init frame that does not report the bridge as connected fails closed", async () => { + const catalog = await buildClaudeAgentSdkToolBridge(withTools(["alpha"])); + const emitted = [...catalog!.emittedNameMap.keys()][0]!; + const sdk = fakeSdk([ + initFrame([{ name: CLAUDE_AGENT_SDK_MCP_SERVER_NAME, status: "failed" }]), + ...toolCallFrames("tu_1", emitted), + messageStop, + ]); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module }); + const events = await run(adapter, withTools(["alpha"])); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", status: 502, code: "tool_bridge_init_mismatch", retryable: false }); + }); + + test("a call before the init handshake fails closed", async () => { + const catalog = await buildClaudeAgentSdkToolBridge(withTools(["alpha"])); + const emitted = [...catalog!.emittedNameMap.keys()][0]!; + const sdk = fakeSdk([ + ...toolCallFrames("tu_1", emitted), + initFrame([bridgeConnected]), + messageStop, + ]); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module }); + const events = await run(adapter, withTools(["alpha"])); + expect(events.at(-1)).toMatchObject({ type: "error", status: 502, code: "tool_bridge_init_missing" }); + }); + + test("a call outside the isolated catalog fails closed", async () => { + const sdk = fakeSdk([ + initFrame([bridgeConnected]), + ...toolCallFrames("tu_1", `mcp__${CLAUDE_AGENT_SDK_MCP_SERVER_NAME}__not-in-the-catalog`), + messageStop, + ]); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module }); + const events = await run(adapter, withTools(["alpha"])); + expect(events.at(-1)).toMatchObject({ type: "error", status: 502, code: "undeclared_tool_call", retryable: false }); + }); + + test("more tool calls than the turn cap allows fails closed", async () => { + const catalog = await buildClaudeAgentSdkToolBridge(withTools(["alpha"])); + const emitted = [...catalog!.emittedNameMap.keys()][0]!; + const calls = Array.from({ length: 17 }, (_value, index) => toolCallFrames(`tu_${index}`, emitted)); + const sdk = fakeSdk([initFrame([bridgeConnected]), ...calls.flat(), messageStop]); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module }); + const events = await run(adapter, withTools(["alpha"])); + expect(events.at(-1)).toMatchObject({ type: "error", status: 502, code: "tool_call_limit", retryable: false }); + }); + + test("opened tool blocks beyond the turn cap fail closed before any of them closes", async () => { + // The parser buffers a block until its stop, so a stream that only opens blocks emits no + // tool_call_start at all. The cap therefore has to be enforced when the block opens: with the + // emission counted instead, this stream would run on and only fail at message_stop, and for a + // different reason. + const catalog = await buildClaudeAgentSdkToolBridge(withTools(["alpha"])); + const emitted = [...catalog!.emittedNameMap.keys()][0]!; + const opens = Array.from({ length: 17 }, (_value, index) => ({ + type: "stream_event", + event: { type: "content_block_start", index, content_block: { type: "tool_use", id: `tu_` + index, name: emitted } }, + })); + const sdk = fakeSdk([initFrame([bridgeConnected]), ...opens, messageStop]); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module }); + const events = await run(adapter, withTools(["alpha"])); + expect(events.at(-1)).toMatchObject({ type: "error", status: 502, code: "tool_call_limit", retryable: false }); + }); + + test("the shared tool-start ceiling fails a turn closed even without a tool bridge", async () => { + // The parser's own ceiling applies to every turn, bridge or not, and since #6081/#6083 it is + // enforced before the block is allocated: the refused start never reaches the completeness + // invariants, so the flag it leaves behind is the only record that a call went missing. + const opens = Array.from({ length: 17 }, (_value, index) => ({ + type: "stream_event", + event: { type: "content_block_start", index, content_block: { type: "tool_use", id: "tu_" + index, name: "alpha" } }, + })); + const sdk = fakeSdk([...opens, messageStop]); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module }); + const events = await run(adapter, parsed()); + expect(events.at(-1)).toMatchObject({ type: "error", status: 502, code: "tool_call_limit", retryable: false }); + }); + + test("a retained tool argument past the request budget keeps the budget's own error code", async () => { + // The parser charges tool identity and argument fragments to the request's translator budget. + // This adapter owns a capture path, so the charge has to reach it through the parse state, and + // the budget's verdict has to survive instead of becoming the generic SDK error. + const catalog = await buildClaudeAgentSdkToolBridge(withTools(["alpha"])); + const emitted = [...catalog!.emittedNameMap.keys()][0]!; + const sdk = fakeSdk([ + initFrame([bridgeConnected]), + ...toolCallFrames("tu_1", emitted, "x".repeat(64)), + messageStop, + ]); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module }); + const budget = createTestTranslatorBudget({ maxCallArgumentBytes: Buffer.byteLength(emitted) + 8 }); + const events = await run(adapter, withTools(["alpha"]), undefined, budget); + expect(events.at(-1)).toMatchObject({ + type: "error", status: 502, code: "translation_buffer_limit", retryable: false, + }); + }); + + test("a required tool call that never happens must not look like a completion", async () => { + const sdk = fakeSdk([ + initFrame([bridgeConnected]), + textFrame("I will not call it."), + resultFrame({ input_tokens: 3, output_tokens: 4 }), + ]); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module }); + const events = await run(adapter, withTools(["alpha"], { options: { toolChoice: "required" } })); + expect(events.at(-1)).toMatchObject({ type: "error", status: 502, code: "tool_call_required", retryable: false }); + }); + + test("a parallel batch that reuses one block index arrives as two complete calls", async () => { + // The shared parser buffers tool blocks now (#5945): CodeBuddy reuses one content-block index + // for a parallel batch, intermediate blocks never receive a stop, and only the last one does. + // The completeness invariants above read that state, so this pins the pairing they depend on. + const catalog = await buildClaudeAgentSdkToolBridge(withTools(["alpha", "beta"])); + const [first, second] = [...catalog!.emittedNameMap.keys()]; + const sdk = fakeSdk([ + initFrame([bridgeConnected]), + { type: "stream_event", event: { type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "tu_a", name: first } } }, + { type: "stream_event", event: { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: "{\"a\":1}" } } }, + { type: "stream_event", event: { type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "tu_b", name: second } } }, + { type: "stream_event", event: { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: "{\"b\":2}" } } }, + { type: "stream_event", event: { type: "content_block_stop", index: 2 } }, + messageStop, + ], { park: true }); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module, reapTimeoutMs: 50 }); + const events = await run(adapter, withTools(["alpha", "beta"])); + expect(events.filter(event => event.type === "tool_call_start").map(event => event.name)).toEqual(["alpha", "beta"]); + expect(events.filter(event => event.type === "tool_call_delta").map(event => event.arguments)).toEqual(["{\"a\":1}", "{\"b\":2}"]); + expect(events.filter(event => event.type === "tool_call_end")).toHaveLength(2); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "tool_use", endTurn: false }); + }); + + test("a result that arrives while a captured call is still open fails closed", async () => { + const catalog = await buildClaudeAgentSdkToolBridge(withTools(["alpha"])); + const emitted = [...catalog!.emittedNameMap.keys()][0]!; + const sdk = fakeSdk([ + initFrame([bridgeConnected]), + { type: "stream_event", event: { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "tu_1", name: emitted } } }, + resultFrame({ input_tokens: 1, output_tokens: 1 }), + ]); + const adapter = createClaudeAgentSdkAdapter(provider(), { loadSdk: async () => sdk.module }); + const events = await run(adapter, withTools(["alpha"])); + expect(events.at(-1)).toMatchObject({ type: "error", status: 502, code: "protocol_error", retryable: false }); + }); +}); + +/** + * The harness process a proxied turn starts, and what the turn does with it. + * + * The SDK's cleanup is bounded twice over: `Query.performCleanup` waits 2000 ms for + * `transport.waitForExit()`, and the transport schedules SIGTERM 2000 ms after close with SIGKILL + * 5000 ms after that, both timers unref'd. `query.return()` resolving is therefore not evidence that + * the harness is gone - and a turn that deleted its scratch cwd on that signal would leave the + * process, its pipes and its working directory behind. These cases pin the turn to the process: a + * bounded TERM -> grace -> KILL ladder, awaited through the child's real `close`. + */ +interface FakeHarnessChild extends EventEmitter { + pid: number; + stdin: Writable; + stdout: Readable; + stderr: Readable; + killed: boolean; + exitCode: number | null; + signalCode: string | null; + kill: (signal?: string) => boolean; +} + +function fakeHarnessChild(options: { + terminatesOn?: "SIGTERM" | "SIGKILL" | "never"; + stderr?: string; + /** A launcher/descendant inherited the harness's stdio: the process ends, `close` never arrives. */ + exitWithoutClose?: boolean; + /** + * The same inherited pipe, seen from the other side: `close` is withheld and it is the turn's own + * pipe reclamation that produces it - the event reports stdio, not the tree. With + * `terminatesOn: "never"` it also covers the child that ignored every signal, where the + * reclamation is when it dies and its exit and its close arrive together. + */ + closeOnPipeReclaim?: boolean; + /** The runtime refuses the signal: `kill()` reports that nothing was delivered. */ + refuseSignals?: boolean; + /** Runs when the child ends, so a test can pin the order of the teardown. */ + onEnd?: () => void; +} = {}) { + const terminatesOn = options.terminatesOn ?? "SIGKILL"; + const child = new EventEmitter() as FakeHarnessChild; + child.pid = 4711; + child.stdin = new Writable({ write(_chunk, _encoding, callback) { callback(); } }); + child.stdout = new Readable({ read() { /* the harness's stdout belongs to the SDK, not the test */ } }); + child.stderr = new Readable({ read() { /* data is emitted directly below */ } }); + child.killed = false; + child.exitCode = null; + child.signalCode = null; + const signals: string[] = []; + let closed = false; + let ended = false; + let exited = false; + /** + * The inherited pipe, seen from the other side: nothing this child does on its own releases + * `close`, and it is the turn taking its own side of the streams back that produces it. A child + * that ignored every signal only goes away then, so its exit and its close arrive together. + */ + const onPipeReclaim = (): void => { + if (closed) return; + closed = true; + if (!exited) { + exited = true; + child.exitCode = 0; + child.signalCode = "SIGKILL"; + child.emit("exit", null, "SIGKILL"); + } + child.emit("close", null, "SIGKILL"); + options.onEnd?.(); + }; + if (options.closeOnPipeReclaim === true) { + child.stdout.once("close", onPipeReclaim); + child.stdin.once("close", onPipeReclaim); + child.stderr?.once("close", onPipeReclaim); + } + child.kill = (signal?: string) => { + signals.push(signal ?? "SIGTERM"); + if (options.refuseSignals === true) return false; + // A stuck harness shrugs off SIGTERM; only the signal it does not survive ends it, and even then + // the process reports that end asynchronously through `close`, which is what the turn must await. + if (terminatesOn !== "never" && signal === terminatesOn && !ended) { + ended = true; + child.killed = true; + const ending = signal ?? "SIGTERM"; + setTimeout(() => { + if (options.stderr !== undefined) child.stderr.emit("data", Buffer.from(options.stderr)); + child.exitCode = 0; + child.signalCode = ending; + exited = true; + child.emit("exit", null, ending); + // With an inherited pipe the `close` is not this child's to send: it comes with the + // reclamation above, and for a child that ignored every signal that is also when it dies. + if (options.closeOnPipeReclaim === true) return; + if (options.exitWithoutClose !== true) { + closed = true; + child.emit("close", null, ending); + } + options.onEnd?.(); + }, 3); + } + return true; + }; + return { child, signals, closed: () => closed }; +} + +describe("claude-agent-sdk owns the harness process it starts", () => { + test("a TERM-resistant harness is killed and awaited before the scratch cwd goes", async () => { + const harness = fakeHarnessChild(); + const removed: string[] = []; + let closedAtRemoval: boolean | undefined; + const sdk = fakeSdk([initFrame(), textFrame("ok"), resultFrame()], { spawnHarness: true }); + const adapter = createClaudeAgentSdkAdapter(provider(), { + loadSdk: async () => sdk.module, + spawnHarnessProcess: () => harness.child as unknown as ChildProcess, + // A unit test never signals a real process group. + killHarnessProcessTree: () => false, + killGraceMs: 20, + reapTimeoutMs: 200, + makeScratchDir: async () => "/tmp/ocx-owned-harness", + removeScratchDir: async dir => { + closedAtRemoval = harness.closed(); + removed.push(dir); + }, + }); + + const events = await run(adapter, parsed()); + + // The SDK is handed the hook, so the turn owns the process instead of reading the SDK's clock. + expect(typeof sdk.options[0]!.spawnClaudeCodeProcess).toBe("function"); + // The turn still completes: the ladder is teardown, not another failure path. + expect(events.at(-1)).toMatchObject({ type: "done" }); + // TERM first, KILL only once the grace window passed with the process still alive. + expect(harness.signals).toEqual(["SIGTERM", "SIGKILL"]); + // The cwd is released on evidence: `query.return()` had already resolved while this child was + // still running, which is exactly the state the SDK leaves behind. + expect(removed).toEqual(["/tmp/ocx-owned-harness"]); + expect(closedAtRemoval).toBe(true); + }); + + test("the harness's stderr still reaches the turn through the owned pipe", async () => { + // The SDK's local spawn - the one the hook replaces - was the only thing that read the child's + // stderr and handed it to `Options.stderr`. Owning the process means owning that pipe as well, or + // a harness that dies reports nothing about why. + const harness = fakeHarnessChild({ terminatesOn: "SIGTERM", stderr: "harness exploded before any frame" }); + const sdk = fakeSdk([initFrame()], { spawnHarness: true }); + const adapter = createClaudeAgentSdkAdapter(provider(), { + loadSdk: async () => sdk.module, + spawnHarnessProcess: () => harness.child as unknown as ChildProcess, + // A unit test never signals a real process group. + killHarnessProcessTree: () => false, + killGraceMs: 20, + reapTimeoutMs: 200, + makeScratchDir: async () => "/tmp/ocx-owned-harness-stderr", + removeScratchDir: async () => undefined, + }); + + const events = await run(adapter, parsed()); + + expect(events.at(-1)).toMatchObject({ type: "error", status: 502, code: "protocol_error", retryable: false }); + expect(String((events.at(-1) as { message: string }).message)).toContain("harness exploded before any frame"); + }); +}); + +/** + * What the ladder can and cannot establish about the process it owns. + * + * `close` is a claim about stdio as much as about the process: a launcher hands its pipes to a + * descendant, the harness ends, the pipe stays open and `close` is withheld for good. A ladder that + * watches only `close` then waits out its entire ceiling and reports a clean teardown for a process + * tree it never confirmed. These cases pin the difference: exit without a drain, a refused signal, + * and a child that never ends at all. + */ +describe("claude-agent-sdk reports the harness teardown it could not confirm", () => { + function supervisorFor( + harness: ReturnType, + options: { killProcessTree?: (signal: NodeJS.Signals, pid: number) => boolean; platform?: NodeJS.Platform } = {}, + ) { + const supervisor = createHarnessProcessSupervisor({ + onStderr: () => undefined, + spawn: () => harness.child as unknown as ChildProcess, + killGraceMs: 10, + reapTimeoutMs: 20, + // A unit test never signals a real process group; the platform's tree call is a seam here. + killProcessTree: options.killProcessTree ?? (() => false), + ...(options.platform !== undefined ? { platform: options.platform } : {}), + }); + supervisor.spawn({ command: "claude", args: [], env: {}, signal: new AbortController().signal }); + return supervisor; + } + + test("an exit with an inherited pipe still gets the tree signal, then the stdio back", async () => { + const harness = fakeHarnessChild({ terminatesOn: "SIGTERM", exitWithoutClose: true }); + const treeSignals: NodeJS.Signals[] = []; + + const outcome = await supervisorFor(harness, { + killProcessTree: signal => { treeSignals.push(signal); return true; }, + }).terminate(); + + // The direct parent is gone - that much `exit` carries - while `close` never arrives, so the + // process question is the descendant holding those pipes, and the group is the only handle on it. + // The KILL pass runs for that child too: skipping it because the parent already exited is what + // left a TERM-resistant descendant with the cwd still underneath it. + expect(treeSignals).toEqual(["SIGTERM", "SIGKILL"]); + expect(harness.child.exitCode).toBe(0); + expect(harness.child.stdout.destroyed).toBe(true); + expect(harness.child.stderr.destroyed).toBe(true); + expect(outcome).toEqual({ + confirmed: false, + allExited: true, + unresolved: [{ pid: 4711, reason: "pipes-held", signalFailed: false }], + quarantined: 1, + }); + }); + + test("a tree that could not be signalled stays uncertain when the parent exits later in the ladder", async () => { + // The timing, not the state: TERM reaches no tree because the harness shrugs it off and the tree + // call is refused while the parent is still alive; the parent exits inside the KILL window, after + // the direct signal, with a descendant holding the inherited pipes. Reading the tree only at the + // instant of the signal loses this case - the child is `exited`, nothing recorded the unreached + // tree, and the verdict would be `pipes-held` with "everything exited" for a tree never touched. + const harness = fakeHarnessChild({ terminatesOn: "SIGKILL", exitWithoutClose: true }); + + const outcome = await supervisorFor(harness, { killProcessTree: () => false }).terminate(); + + expect(outcome).toEqual({ + confirmed: false, + allExited: false, + unresolved: [{ pid: 4711, reason: "unresolved-tree", signalFailed: false }], + quarantined: 1, + }); + }); + + test("a tree that cannot be signalled while the parent is gone is an unresolved tree, not a clean exit", async () => { + // The dead-parent case: `exit` arrived without `close`, so a descendant holds the pipes, and the + // platform cannot walk a tree from a pid that no longer exists (`taskkill /PID /T` on + // Windows). Nothing here can name or signal that descendant, so it is not rounded to "gone" and + // the working directory it may still be using is not released. + const harness = fakeHarnessChild({ terminatesOn: "SIGTERM", exitWithoutClose: true }); + + const outcome = await supervisorFor(harness, { platform: "win32", killProcessTree: () => false }).terminate(); + + expect(outcome).toEqual({ + confirmed: false, + allExited: false, + unresolved: [{ pid: 4711, reason: "unresolved-tree", signalFailed: false }], + quarantined: 1, + }); + expect(harnessQuarantineSnapshot()).toEqual([{ pid: 4711, reason: "unresolved-tree", ageMs: expect.any(Number) }]); + // The survivor keeps the bounded cleanup lease, which is the whole point of taking it: the + // teardown outlives its turn, and it does not do so unaccounted for. + expect(harnessTeardownMetrics().active).toBe(1); + }); + + test("a reclaimed pipe does not hand back the capacity of a tree that was never reached", async () => { + // The `close` this turn is entitled to see can be its own doing: a descendant inherited the + // harness's pipes, so the process ends without `close`, the ladder reclaims its side of those + // pipes, and Node then emits `close` for a process whose tree it never reached. Reading that + // event as the exit would return the lease and let the next sweep drop the entry, for a + // descendant that is still running against the working directory this turn kept. + let descendantAlive = true; + setHarnessTreeProbeForTests(() => descendantAlive); + const harness = fakeHarnessChild({ terminatesOn: "SIGTERM", closeOnPipeReclaim: true }); + + const outcome = await supervisorFor(harness, { killProcessTree: () => false }).terminate(); + + expect(outcome).toEqual({ + confirmed: false, + allExited: false, + unresolved: [{ pid: 4711, reason: "unresolved-tree", signalFailed: false }], + quarantined: 1, + }); + // The parent did close - because the ladder took its pipes back - and the lease is still held: + // that event speaks for stdio, and the descendant behind it is a different question. + await Bun.sleep(10); + expect(harness.closed()).toBe(true); + expect(harnessTeardownMetrics().active).toBe(1); + + // The next turn sweeps and asks the platform. A descendant that is still there keeps the entry + // and its lease; nothing is dropped silently because the parent's stdio went away. + expect(reapHarnessQuarantine()).toBe(1); + expect(harnessQuarantineSnapshot()).toMatchObject([{ pid: 4711, reason: "unresolved-tree" }]); + expect(harnessTeardownMetrics().active).toBe(1); + + // The descendant ends, the group is empty, and only then does the capacity come back. + descendantAlive = false; + expect(reapHarnessQuarantine()).toBe(0); + expect(harnessQuarantineSnapshot()).toEqual([]); + expect(harnessTeardownMetrics().active).toBe(0); + }); + + test("a quarantined survivor whose tree was reached hands its cleanup lease back when it closes", async () => { + // The tree was delivered here: the ladder reached the group every time it asked, so the direct + // child's later `close` is the whole answer and the capacity comes back with it. Where the tree + // call is refused instead, the entry's label still follows the direct parent and the lease stays + // with the tree - that is the case below. + const harness = fakeHarnessChild({ terminatesOn: "never" }); + + const outcome = await supervisorFor(harness, { killProcessTree: () => true }).terminate(); + expect(outcome.allExited).toBe(false); + expect(harnessTeardownMetrics().active).toBe(1); + + // The process reports its exit after the turn is over. The next turn's sweep sees it on the + // handle that was pinned to that process, so the lease comes back with it and nothing is left + // behind in the account. + harness.child.emit("exit", 0, "SIGKILL"); + harness.child.emit("close", 0, "SIGKILL"); + expect(reapHarnessQuarantine()).toBe(0); + expect(harnessQuarantineSnapshot()).toEqual([]); + expect(harnessTeardownMetrics().active).toBe(0); + }); + + test("a survivor the tree signal never reached keeps its lease when it closes later", async () => { + // The ladder's label is about the direct parent; the lease is about the tree. This child ignored + // every signal, so it is still `running` at the deadline - with the same refused tree call + // behind it as an already-exited one - and the reclamation that follows is where it finally + // dies. Its `close` therefore proves only that the parent is gone, while a descendant that + // inherited the pipes may still be alive, so the capacity stays until the platform can disprove + // the group. + let descendantAlive = true; + setHarnessTreeProbeForTests(() => descendantAlive); + const harness = fakeHarnessChild({ terminatesOn: "never", closeOnPipeReclaim: true }); + + const outcome = await supervisorFor(harness, { killProcessTree: () => false }).terminate(); + + expect(outcome).toEqual({ + confirmed: false, + allExited: false, + unresolved: [{ pid: 4711, reason: "running", signalFailed: false }], + quarantined: 1, + }); + await Bun.sleep(10); + // The close arrived with the reclamation, and the lease did not go with it. + expect(harness.closed()).toBe(true); + expect(harnessTeardownMetrics().active).toBe(1); + expect(reapHarnessQuarantine()).toBe(1); + // Still named as the running child the ladder saw, because that is the part it could see. + expect(harnessQuarantineSnapshot()).toMatchObject([{ pid: 4711, reason: "running" }]); + expect(harnessTeardownMetrics().active).toBe(1); + + descendantAlive = false; + expect(reapHarnessQuarantine()).toBe(0); + expect(harnessQuarantineSnapshot()).toEqual([]); + expect(harnessTeardownMetrics().active).toBe(0); + }); + + test("the bound refuses the next harness instead of accounting for it afterwards", () => { + // Capacity is reserved in the spawn hook, so the bound decides whether a process exists at all. + // Reporting it after the ladder would describe a pile that was allowed to grow. + const held: ReturnType[] = []; + for (let index = 0; index < MAX_ACTIVE_HARNESS_TEARDOWNS; index += 1) { + const harness = fakeHarnessChild({ terminatesOn: "never" }); + held.push(harness); + supervisorFor(harness); + } + expect(harnessTeardownMetrics().active).toBe(MAX_ACTIVE_HARNESS_TEARDOWNS); + + const refusedHarness = fakeHarnessChild(); + let refused: unknown; + try { + supervisorFor(refusedHarness); + } catch (err) { + refused = err; + } + + expect(refused).toBeInstanceOf(HarnessCapacityError); + expect((refused as HarnessCapacityError).code).toBe(HARNESS_CAPACITY_CODE); + expect(String((refused as Error).message)).toContain(`capacity reached (${MAX_ACTIVE_HARNESS_TEARDOWNS}`); + // Nothing was started for the refused turn, and the accounting did not move. + expect(refusedHarness.signals).toEqual([]); + expect(harnessTeardownMetrics().active).toBe(MAX_ACTIVE_HARNESS_TEARDOWNS); + }); + + test("an unobservable survivor keeps its capacity past the age warning", async () => { + // A process that has not reported `close` has not been shown to be gone. The age bound only says + // so out loud: the entry and its lease stay, because returning the capacity would put the leak + // back where it started - this time behind a count that says the harness is free. + const harness = fakeHarnessChild({ terminatesOn: "never" }); + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]): void => { warnings.push(args.map(String).join(" ")); }; + try { + const outcome = await supervisorFor(harness).terminate(); + expect(outcome.quarantined).toBe(1); + + const muchLater = Date.now() + 10 * 60_000; + expect(reapHarnessQuarantine(muchLater)).toBe(1); + expect(harnessQuarantineSnapshot(muchLater)).toMatchObject([{ pid: 4711, reason: "running" }]); + expect(harnessTeardownMetrics().active).toBe(1); + expect(warnings.join("\n")).toContain("capacity stays reserved until it reports an exit"); + + // Named once, not once per sweep. + expect(reapHarnessQuarantine(muchLater + 1_000)).toBe(1); + expect(warnings.filter(line => line.includes("capacity stays reserved"))).toHaveLength(1); + expect(harnessTeardownMetrics().active).toBe(1); + } finally { + console.warn = originalWarn; + } + }); + + test.skipIf(process.platform === "win32")( + "a real descendant whose launcher exits is killed with the group, not abandoned", + async () => { + // No EventEmitter and no injected verdict: a real launcher exits immediately and leaves a real + // descendant holding the inherited pipes, and that descendant ignores SIGTERM. The only thing + // that can end it is the group KILL - which the previous ladder skipped, because the direct + // parent had already exited. + const marker = `ocx-descendant-${Math.random().toString(36).slice(2, 10)}`; + const descendantAlive = (): boolean => { + try { + execFileSync("pgrep", ["-f", marker], { stdio: "pipe" }); + return true; + } catch { + return false; + } + }; + const supervisor = createHarnessProcessSupervisor({ onStderr: () => undefined, killGraceMs: 200, reapTimeoutMs: 3_000 }); + try { + supervisor.spawn({ + command: "/bin/sh", + args: ["-c", `sh -c 'trap "" TERM; while :; do sleep 0.2; done # ${marker}' & exit 0`], + env: {}, + signal: new AbortController().signal, + }); + await Bun.sleep(150); + expect(descendantAlive()).toBe(true); + + const outcome = await supervisor.terminate(); + + expect(outcome.confirmed).toBe(true); + expect(outcome.allExited).toBe(true); + expect(descendantAlive()).toBe(false); + } finally { + try { execFileSync("pkill", ["-f", marker], { stdio: "pipe" }); } catch { /* already gone */ } + } + }, + ); + + test("a refused signal is reported rather than read as a dead process", async () => { + const harness = fakeHarnessChild({ terminatesOn: "never", refuseSignals: true }); + + const outcome = await supervisorFor(harness).terminate(); + + expect(outcome).toEqual({ + confirmed: false, + allExited: false, + unresolved: [{ pid: 4711, reason: "running", signalFailed: true }], + quarantined: 1, + }); + }); + + test("a harness that survives the whole ladder is named as still running", async () => { + const harness = fakeHarnessChild({ terminatesOn: "never" }); + + const outcome = await supervisorFor(harness).terminate(); + + expect(harness.signals).toEqual(["SIGTERM", "SIGKILL"]); + expect(outcome).toEqual({ + confirmed: false, + allExited: false, + unresolved: [{ pid: 4711, reason: "running", signalFailed: false }], + quarantined: 1, + }); + }); +}); + +describe("claude-agent-sdk does not answer the client underneath a live harness", () => { + test("an unresolved teardown keeps the scratch cwd, names it, and withholds the answer", async () => { + const harness = fakeHarnessChild({ terminatesOn: "never" }); + const removed: string[] = []; + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]): void => { warnings.push(args.map(String).join(" ")); }; + try { + const sdk = fakeSdk([initFrame(), textFrame("ok"), resultFrame()], { spawnHarness: true }); + const adapter = createClaudeAgentSdkAdapter(provider(), { + loadSdk: async () => sdk.module, + spawnHarnessProcess: () => harness.child as unknown as ChildProcess, + killHarnessProcessTree: () => false, + killGraceMs: 10, + reapTimeoutMs: 20, + makeScratchDir: async () => "/tmp/ocx-unconfirmed-harness", + removeScratchDir: async dir => { removed.push(dir); }, + }); + + const events = await run(adapter, parsed()); + + // A completed answer is not this turn's to hand over while a process it started is still + // alive: the client would read it as the turn's whole outcome, and every repeat of the same + // state would add another harness nobody is accounting for. The cwd a survivor may still be + // using stays where it is, and the survivor is named instead of rounded to a clean exit. + expect(events.some(event => event.type === "done")).toBe(false); + expect(events.at(-1)).toMatchObject({ + type: "error", status: 502, code: "harness_teardown_unresolved", retryable: false, + }); + expect(removed).toEqual([]); + expect(warnings.join("\n")).toContain("leaving /tmp/ocx-unconfirmed-harness in place"); + expect(warnings.join("\n")).toContain("4711:running"); + expect(warnings.join("\n")).toContain("1 quarantined"); + expect(harnessQuarantineSnapshot()).toMatchObject([{ pid: 4711, reason: "running" }]); + } finally { + console.warn = originalWarn; + } + }); + + test("an unreachable tree is named, keeps the cwd, and never becomes a completed turn", async () => { + // The turn-level shape of the Windows dead-parent case: the harness ended while a descendant kept + // its pipes, and the tree could not be walked from a pid that is gone. + const harness = fakeHarnessChild({ terminatesOn: "SIGTERM", exitWithoutClose: true }); + const removed: string[] = []; + const originalWarn = console.warn; + console.warn = () => undefined; + try { + const sdk = fakeSdk([initFrame(), textFrame("ok"), resultFrame()], { spawnHarness: true }); + const adapter = createClaudeAgentSdkAdapter(provider(), { + loadSdk: async () => sdk.module, + spawnHarnessProcess: () => harness.child as unknown as ChildProcess, + killHarnessProcessTree: () => false, + harnessPlatform: "win32", + killGraceMs: 10, + reapTimeoutMs: 20, + makeScratchDir: async () => "/tmp/ocx-unresolved-tree", + removeScratchDir: async dir => { removed.push(dir); }, + }); + + const events = await run(adapter, parsed()); + + expect(events.at(-1)).toMatchObject({ type: "error", code: "harness_teardown_unresolved" }); + expect(removed).toEqual([]); + expect(harnessQuarantineSnapshot()).toMatchObject([{ pid: 4711, reason: "unresolved-tree" }]); + expect(harnessTeardownMetrics().active).toBe(1); + } finally { + console.warn = originalWarn; + } + }); + + test("a turn above the bound is refused before a harness exists", async () => { + // The refusal is the bound doing its job, so it is a first-class turn outcome - not a process + // that is started and then reported as unaccounted for. + const held = Array.from({ length: MAX_ACTIVE_HARNESS_TEARDOWNS }, () => { + const harness = fakeHarnessChild({ terminatesOn: "never" }); + createHarnessProcessSupervisor({ + onStderr: () => undefined, + spawn: () => harness.child as unknown as ChildProcess, + }).spawn({ command: "claude", args: [], env: {}, signal: new AbortController().signal }); + return harness; + }); + expect(held).toHaveLength(MAX_ACTIVE_HARNESS_TEARDOWNS); + let spawned = 0; + const released: string[] = []; + const sdk = fakeSdk([initFrame(), textFrame("ok"), resultFrame()], { spawnHarness: true }); + const adapter = createClaudeAgentSdkAdapter(provider(), { + loadSdk: async () => sdk.module, + spawnHarnessProcess: () => { spawned += 1; return fakeHarnessChild().child as unknown as ChildProcess; }, + killHarnessProcessTree: () => false, + makeScratchDir: async () => "/tmp/ocx-capacity-refused", + removeScratchDir: async dir => { released.push(dir); }, + }); + + const events = await run(adapter, parsed()); + + expect(spawned).toBe(0); + expect(events.some(event => event.type === "done")).toBe(false); + expect(events.at(-1)).toMatchObject({ + type: "error", status: 503, code: HARNESS_CAPACITY_CODE, retryable: true, + }); + // The refused turn never owned a harness, so nothing holds its working directory. + expect(released).toEqual(["/tmp/ocx-capacity-refused"]); + }); + + test("a cancelled turn hands its harness to the cleanup lease, not to nobody", async () => { + // The client disconnects, and core returns the turn's own admission on that cancel - that slot is + // the turn's, and the response body it belongs to is gone. The harness is not the turn's. It is + // still alive here, so the teardown holds a separate bounded lease until the process is confirmed + // gone, and the turn slot being back does not mean the process is. + // + // The turn itself is parked: the fake SDK never ends its stream, so the only thing that can end + // this turn is the cancellation - and the cancellation has to travel the way it does in the + // server, from the bridge's cancel through the adapter's own abort signal. + let turnSlotWhileHarnessAlive: number | undefined; + let cleanupLeasesWhileHarnessAlive: number | undefined; + const harness = fakeHarnessChild({ terminatesOn: "SIGKILL" }); + const sdk = fakeSdk([initFrame(), textFrame("ok")], { spawnHarness: true, park: true }); + const adapter = createClaudeAgentSdkAdapter(provider(), { + loadSdk: async () => sdk.module, + spawnHarnessProcess: () => harness.child as unknown as ChildProcess, + // Sampled on the KILL pass: the client's cancel is long past, the harness has shrugged off + // SIGTERM and is still running, and the next statement is the direct signal that ends it. + killHarnessProcessTree: signal => { + if (signal === "SIGKILL") { + turnSlotWhileHarnessAlive = getActiveTurnCount(); + cleanupLeasesWhileHarnessAlive = harnessTeardownMetrics().active; + } + return false; + }, + killGraceMs: 10, + reapTimeoutMs: 200, + makeScratchDir: async () => "/tmp/ocx-cancelled-harness", + removeScratchDir: async () => undefined, + }); + const lease = tryAdmitTurn(); + expect(lease).not.toBeNull(); + const turnAbort = new AbortController(); + const events = channel(); + const turn = adapter.runTurn!(parsed(), incoming(turnAbort.signal), event => events.push(event)) + .finally(() => events.close()); + const tracked = trackStreamLifetime( + // The bridge's cancel hook is where the server aborts the turn's upstream; the client's own + // disconnect reaches it as `reader.cancel()` below. + bridgeToResponsesSSE(events.stream(), "claude-sonnet-5", undefined, undefined, undefined, () => turnAbort.abort()), + new AbortController(), + () => undefined, + lease!, + ); + + const reader = tracked.getReader(); + await reader.read(); + await reader.cancel(); + await turn; + + // The ladder ran because of the cancellation, not because the harness's frames ran out. + expect(harness.signals).toEqual(["SIGTERM", "SIGKILL"]); + // Sampled while the harness was still alive, which is after the client's slot went back. + expect(turnSlotWhileHarnessAlive).toBe(0); + expect(cleanupLeasesWhileHarnessAlive).toBe(1); + // And once the process is confirmed gone, the bounded account is empty again. + expect(harnessTeardownMetrics().active).toBe(0); + expect(harnessQuarantineSnapshot()).toEqual([]); + }); + + test("the response body, and the admission it releases, waits for the owned harness", async () => { + // Deliberately not `await adapter.runTurn()`: the finding is about the streaming lifecycle. The + // bridge closes the response body on the terminal frame and the body's EOF releases the global + // turn-admission lease, so what has to hold is the ORDER - the harness reaped and its cwd released + // before the body ends, never after it. + const order: string[] = []; + const harness = fakeHarnessChild({ terminatesOn: "SIGTERM", onEnd: () => order.push("harness-gone") }); + const sdk = fakeSdk([initFrame(), textFrame("ok"), resultFrame()], { spawnHarness: true }); + const adapter = createClaudeAgentSdkAdapter(provider(), { + loadSdk: async () => sdk.module, + spawnHarnessProcess: () => harness.child as unknown as ChildProcess, + killHarnessProcessTree: () => false, + killGraceMs: 20, + reapTimeoutMs: 200, + makeScratchDir: async () => "/tmp/ocx-admission-order", + removeScratchDir: async () => { order.push("cwd-released"); }, + }); + const lease = tryAdmitTurn(); + expect(lease).not.toBeNull(); + const events = channel(); + const turn = adapter.runTurn!(parsed(), incoming(), event => events.push(event)).finally(() => events.close()); + let harnessClosedAtBodyEnd: boolean | undefined; + const tracked = trackStreamLifetime( + bridgeToResponsesSSE(events.stream(), "claude-sonnet-5"), + new AbortController(), + () => { harnessClosedAtBodyEnd = harness.closed(); order.push("body-end"); }, + lease!, + ); + + const body = await drainStream(tracked); + await turn; + + expect(body).toContain("response.completed"); + expect(harnessClosedAtBodyEnd).toBe(true); + expect(order).toEqual(["harness-gone", "cwd-released", "body-end"]); + // The lease the body carried is back, which is the moment the next turn may be admitted. + expect(getActiveTurnCount()).toBe(0); + }); +}); diff --git a/tests/providers/claude-cli-adapter.test.ts b/tests/providers/claude-cli-adapter.test.ts deleted file mode 100644 index 86d2c164b4f..00000000000 --- a/tests/providers/claude-cli-adapter.test.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { EventEmitter } from "node:events"; -import { existsSync, readFileSync, statSync } from "node:fs"; -import { Readable, Writable } from "node:stream"; -import type { ChildProcess } from "node:child_process"; -import { - buildArgs, - buildChildEnv, - CLAUDE_CLI_QUIET_ENV, - createClaudeCliAdapter, - withClaudeLoginHint, - type SpawnFn, -} from "../../src/adapters/claude-cli/adapter"; -import { baseScopedEnv } from "../../src/adapters/coding-agent/turn"; -import { CLAUDE_CLI_PROFILE, clearClaudeCliBinaryCache } from "../../src/adapters/claude-cli/profiles"; -import { effectiveAdapterContract, getAdapterDefinition } from "../../src/adapters/registry"; -import { PROVIDER_REGISTRY } from "../../src/providers/registry"; -import { deriveProviderPresets, providerConfigSeed } from "../../src/providers/derive"; -import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; -import { createTestTranslatorBudget } from "../helpers/translator-budget"; - -const enc = new TextEncoder(); - -// The binary-discovery cache is module-level (a production perf seam); reset it so a test that -// reports a missing CLI cannot mask a later test's injected binary. -beforeEach(() => clearClaudeCliBinaryCache()); - -interface FakeChild extends EventEmitter { - stdout: Readable; - stderr: Readable; - stdin: Writable; - killed: boolean; - exitCode: number | null; - kill: (signal?: string) => boolean; - written: string[]; -} - -function fakeChild(stdout: Uint8Array[], opts: { stderr?: string; exitCode?: number } = {}): FakeChild { - const child = new EventEmitter() as FakeChild; - child.stdout = Readable.from(stdout); - child.stderr = Readable.from(opts.stderr ? [enc.encode(opts.stderr)] : []); - child.written = []; - child.stdin = new Writable({ write(chunk, _enc, cb) { child.written.push(String(chunk)); cb(); } }); - child.killed = false; - child.exitCode = null; - child.kill = () => { child.killed = true; return true; }; - setTimeout(() => { child.exitCode = opts.exitCode ?? 0; child.emit("close", opts.exitCode ?? 0); }, 3); - return child; -} - -function provider(overrides: Partial = {}): OcxProviderConfig { - return { - adapter: "claude-cli", - baseUrl: CLAUDE_CLI_PROFILE.canonicalBaseUrl, - reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], - ...overrides, - } as OcxProviderConfig; -} - -function parsed(overrides: Partial = {}): OcxParsedRequest { - return { - modelId: "claude-sonnet-5", - stream: true, - options: {}, - context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, - ...overrides, - } as OcxParsedRequest; -} - -function incoming(abortSignal?: AbortSignal) { - return { headers: new Headers(), translatorBudget: createTestTranslatorBudget(), ...(abortSignal ? { abortSignal } : {}) }; -} - -async function run(adapter: ReturnType, p: OcxParsedRequest): Promise { - const events: AdapterEvent[] = []; - await adapter.runTurn!(p, incoming(), e => events.push(e)); - return events; -} - -describe("claude-cli is an official-harness provider, not a Messages relay", () => { - test("the registry row and the adapter agree on the one canonical destination", () => { - const entry = PROVIDER_REGISTRY.find(candidate => candidate.id === "claude-cli"); - expect(entry).toBeDefined(); - expect(entry!.adapter).toBe("claude-cli"); - expect(entry!.baseUrl).toBe(CLAUDE_CLI_PROFILE.canonicalBaseUrl); - expect(entry!.defaultModel).toBe("claude-sonnet-5"); - expect(entry!.models).toContain(entry!.defaultModel!); - expect(entry!.modelContextWindows?.[entry!.defaultModel!]).toBeGreaterThan(0); - // Static roster: a live discovery request against this route answers 404 and is pure noise. - expect(entry!.liveModels).toBe(false); - // The CLI parses an image frame, but no headless turn was shown to hand those bytes to the - // model, so the row publishes text-only models instead of the Messages API rows' image - // modality: an advertised input the route cannot honour is how a picture gets answered blind. - expect(entry!.noVisionModels).toEqual(entry!.models ?? []); - expect(entry!.modelInputModalities).toBeUndefined(); - }); - - test("the row is a keyless key provider, not a local runtime, and needs no dashboardPreset flag", () => { - const entry = PROVIDER_REGISTRY.find(candidate => candidate.id === "claude-cli")!; - // "local" is the Ollama / vLLM / LM Studio classification: the traffic never leaves the machine - // and there is no credential to classify. This row's turn leaves for api.anthropic.com, and the - // account surface answers from `authKind` (`classifyAccount` in src/cli/account-api.ts), where - // "local" claimed there were no credentials at all — for a provider whose whole point is a - // credential the CLI owns. - expect(entry.authKind).toBe("key"); - // Keyless is expressed by `keyOptional`, the flag key enforcement already honors - // (src/server/auth-cors.ts, src/providers/api-key-selection.ts) without pretending a key exists. - expect(entry.keyOptional).toBe(true); - // A key row must name where its credential comes from; deriveKeyLoginMap throws without this. - expect(entry.dashboardUrl).toBeTruthy(); - // They keyed a keyless row into the picker by hand. That is what the flag was for, and key rows - // are listed already, so it is gone rather than duplicated. - expect(entry.dashboardPreset).toBeUndefined(); - expect(providerConfigSeed(entry)).toMatchObject({ authMode: "key", keyOptional: true }); - expect(deriveProviderPresets().find(candidate => candidate.id === "claude-cli")) - .toMatchObject({ auth: "key", keyOptional: true }); - }); - - test("the adapter inherits the shared coding-agent contract instead of a second wire", () => { - expect(getAdapterDefinition("claude-cli")?.contractParent).toBe("codebuddy"); - expect(effectiveAdapterContract("claude-cli").wire).toBe("codebuddy"); - }); -}); - -describe("claude-cli headless arguments keep tool ownership with the client", () => { - test("disables built-in tools and every MCP source, and never requests a bypass", () => { - const args = buildArgs(CLAUDE_CLI_PROFILE, parsed(), provider()); - expect(args[0]).toBe("-p"); - expect(args[args.indexOf("--output-format") + 1]).toBe("stream-json"); - expect(args[args.indexOf("--input-format") + 1]).toBe("stream-json"); - expect(args[args.indexOf("--tools") + 1]).toBe(""); // "" = every built-in tool off - expect(args).toContain("--strict-mcp-config"); // and no MCP server from settings or plugins - expect(args).not.toContain("--mcp-config"); - expect(args).not.toContain("--dangerously-skip-permissions"); - expect(args).not.toContain("--allow-dangerously-skip-permissions"); - expect(args).not.toContain("--permission-mode"); - expect(args).toContain("--no-session-persistence"); - expect(args[args.indexOf("--model") + 1]).toBe("claude-sonnet-5"); - }); - - test("loads no user, project or local settings into a proxied turn", () => { - const args = buildArgs(CLAUDE_CLI_PROFILE, parsed(), provider()); - expect(args[args.indexOf("--setting-sources") + 1]).toBe(""); - expect(args).not.toContain("--append-system-prompt"); - }); - - test("the caller's system prompt REPLACES the harness preset", () => { - const args = buildArgs(CLAUDE_CLI_PROFILE, parsed({ - context: { systemPrompt: ["Be terse."], messages: [] }, - }), provider(), "/private/system-prompt.txt"); - expect(args[args.indexOf("--system-prompt-file") + 1]).toBe("/private/system-prompt.txt"); - // argv is world-readable through process listing, so the folded prompt is a path, not an argument. - expect(args).not.toContain("Be terse."); - expect(args).not.toContain("--system-prompt"); - }); - - test("no staged prompt means no flag at all, so runTurn always stages one", () => { - expect(buildArgs(CLAUDE_CLI_PROFILE, parsed(), provider())).not.toContain("--system-prompt-file"); - }); - - test("maps the caller's reasoning effort onto the CLI's --effort", () => { - const args = buildArgs(CLAUDE_CLI_PROFILE, parsed({ options: { reasoning: "high" } }), provider()); - expect(args[args.indexOf("--effort") + 1]).toBe("high"); - }); - - test("passes no --max-turns: the Claude Code CLI has no such flag", () => { - // CodeBuddy's CLI accepts --max-turns and this family shares its parser; the flag must not be - // copied across, or every turn dies on an unknown option. - expect(buildArgs(CLAUDE_CLI_PROFILE, parsed(), provider())).not.toContain("--max-turns"); - }); -}); - -describe("claude-cli child environment carries no credential and no proxy destination", () => { - test("an inherited ANTHROPIC_* variable cannot point the harness back at this proxy", () => { - const previous = { base: process.env.ANTHROPIC_BASE_URL, key: process.env.ANTHROPIC_API_KEY }; - process.env.ANTHROPIC_BASE_URL = "http://127.0.0.1:10100"; - process.env.ANTHROPIC_API_KEY = "inherited-key"; - try { - const env = buildChildEnv(CLAUDE_CLI_PROFILE, ""); - expect(Object.keys(env).filter(name => name.startsWith("ANTHROPIC_") || name.startsWith("CLAUDE_CODE_OAUTH"))).toEqual([]); - expect(JSON.stringify(env)).not.toContain("inherited-key"); - expect(JSON.stringify(env)).not.toContain("127.0.0.1:10100"); - } finally { - if (previous.base === undefined) delete process.env.ANTHROPIC_BASE_URL; - else process.env.ANTHROPIC_BASE_URL = previous.base; - if (previous.key === undefined) delete process.env.ANTHROPIC_API_KEY; - else process.env.ANTHROPIC_API_KEY = previous.key; - } - }); - - test("keeps the home directory the CLI signs in from, and quiets its own telemetry", () => { - const env = buildChildEnv(CLAUDE_CLI_PROFILE, ""); - // The inherited HOME is the property the provider exists for and the one an operator must know - // about: the sign-in belongs to the user this proxy runs as, so every request served through - // this row — by any client of the proxy — spends that same Claude account. - expect(env.HOME).toBe(process.env.HOME); - expect(env.DISABLE_AUTOUPDATER).toBe("1"); - expect(env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe("1"); - }); - - test("a key configured on the row is never handed to the harness", () => { - // `keyOptional` makes the row keyless without making it key-*blind*: an operator who saved an - // API key in the dashboard or with `ocx provider add --api-key` must not silently believe it - // bills the turn. Nothing layers a credential onto the child environment. - const env = buildChildEnv(CLAUDE_CLI_PROFILE, "sk-ant-row-key"); - expect(JSON.stringify(env)).not.toContain("sk-ant-row-key"); - expect(Object.keys(env).filter(name => name.startsWith("ANTHROPIC_") || name.startsWith("CLAUDE_CODE_OAUTH"))).toEqual([]); - }); - - test("carries the account name the CLI resolves its keychain sign-in by, and nothing else new", () => { - // Without USER the CLI reports "not logged in" on a signed-in machine: it looks its own keychain - // entry up by account name. The value is a name, not a credential — no token is added here. - const previous = process.env.USER; - process.env.USER = "ocx-probe-user"; - try { - const env = buildChildEnv(CLAUDE_CLI_PROFILE, ""); - expect(env.USER).toBe("ocx-probe-user"); - // Derived from the two owners rather than restated, so a new quiet flag cannot silently - // become the third thing this environment carries. - expect(Object.keys(env).sort()).toEqual( - [...new Set([...Object.keys(baseScopedEnv()), ...Object.keys(CLAUDE_CLI_QUIET_ENV), "USER"])].sort(), - ); - } finally { - if (previous === undefined) delete process.env.USER; - else process.env.USER = previous; - } - }); - - test("adds no USER key when the parent has none", () => { - const previous = process.env.USER; - delete process.env.USER; - try { - expect("USER" in buildChildEnv(CLAUDE_CLI_PROFILE, "")).toBe(false); - } finally { - if (previous !== undefined) process.env.USER = previous; - } - }); -}); - -describe("claude-cli runTurn fails closed before any spawn", () => { - test("a non-canonical base URL is refused", async () => { - let spawned = 0; - const spawn: SpawnFn = () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }; - const adapter = createClaudeCliAdapter(provider({ baseUrl: "https://evil.example.test" }), { spawn, which: () => "/usr/bin/claude" }); - const events = await run(adapter, parsed()); - expect(spawned).toBe(0); - expect(events[0]).toMatchObject({ type: "error", code: "non_canonical_destination", retryable: false }); - }); - - test("a missing CLI is a clear pre-flight error naming the install command", async () => { - let spawned = 0; - const adapter = createClaudeCliAdapter(provider(), { spawn: () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }, which: () => undefined }); - const events = await run(adapter, parsed()); - expect(spawned).toBe(0); - expect(events[0]).toMatchObject({ type: "error", code: "cli_not_found", retryable: false }); - expect(String((events[0] as { message: string }).message)).toContain("npm install -g @anthropic-ai/claude-code"); - }); - - test("an image is refused rather than handed to a harness that was never shown to carry it", async () => { - let spawned = 0; - const adapter = createClaudeCliAdapter(provider(), { - spawn: () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }, - which: () => "/opt/homebrew/bin/claude", - }); - const events = await run(adapter, parsed({ - context: { messages: [{ role: "user", content: [{ type: "text", text: "what is this?" }, { type: "image", imageUrl: "data:image/png;base64,iVBORw0KGgo=" }], timestamp: 0 }] }, - })); - // Same refusal the Qoder presets make: a dropped image answers the wrong question confidently, - // and no headless Claude Code turn was shown to deliver image bytes to the model. - expect(spawned).toBe(0); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ type: "error", status: 400, code: "unsupported_input_modality", retryable: false }); - }); -}); - -describe("claude-cli stages the folded prompt out of argv", () => { - test("keeps the folded prompt out of argv, in a private file that is removed afterwards", async () => { - const secret = "private-system-instruction"; - let promptFile = ""; - const adapter = createClaudeCliAdapter(provider(), { - which: () => "/opt/homebrew/bin/claude", - spawn: (_command, args) => { - expect(args).not.toContain(secret); - const index = args.indexOf("--system-prompt-file"); - expect(index).toBeGreaterThanOrEqual(0); - promptFile = args[index + 1] ?? ""; - expect(readFileSync(promptFile, "utf8")).toBe(secret); - if (process.platform !== "win32") expect(statSync(promptFile).mode & 0o777).toBe(0o600); - return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; - }, - killGraceMs: 20, - }); - - await run(adapter, parsed({ context: { systemPrompt: [secret], messages: [] } })); - expect(promptFile).not.toBe(""); - expect(existsSync(promptFile)).toBe(false); - }); - - test("a request with no system prompt stages an empty replacement, never the harness preset", async () => { - // Omitting the flag is not "no system prompt": it is Claude Code's own fourteen-block preset, - // which describes a harness with tools this turn does not have. An empty file is what the CLI - // snapshots as an empty system prompt (verified against 2.1.270 through the prompt_snapshot - // attachment), and it is the same request the Messages API path forwards with no system message. - let promptFile = ""; - const adapter = createClaudeCliAdapter(provider(), { - which: () => "/opt/homebrew/bin/claude", - spawn: (_command, args) => { - const index = args.indexOf("--system-prompt-file"); - expect(index).toBeGreaterThanOrEqual(0); - promptFile = args[index + 1] ?? ""; - expect(readFileSync(promptFile, "utf8")).toBe(""); - return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; - }, - killGraceMs: 20, - }); - - await run(adapter, parsed()); - expect(existsSync(promptFile)).toBe(false); - }); -}); - -describe("claude-cli runTurn streams a subscription turn", () => { - test("runs without any stored API key, because the CLI owns the account", async () => { - let spawned = 0; - const stdout = [ - enc.encode('{"type":"system","subtype":"init"}\n'), - enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hel"}}}\n'), - enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"lo"}}}\n'), - enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"think"}}}\n'), - enc.encode('{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":7,"output_tokens":2}}\n'), - ]; - const child = fakeChild(stdout); - const adapter = createClaudeCliAdapter(provider(), { - spawn: () => { spawned++; return child as unknown as ChildProcess; }, - which: () => "/opt/homebrew/bin/claude", - killGraceMs: 20, - }); - - const events = await run(adapter, parsed()); - expect(spawned).toBe(1); - expect(events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("")).toBe("Hello"); - expect(events.some(e => e.type === "thinking_delta")).toBe(true); - expect(events.at(-1)).toMatchObject({ type: "done", usage: { inputTokens: 7, outputTokens: 2, totalTokens: 9 } }); - expect(child.written.join("")).toContain('"text":"hello"'); - }); - - test("an unauthenticated CLI becomes an actionable sign-in error", async () => { - // Verbatim shape of a real 2.1.270 turn: exit code 1, `is_error` result, no HTTP status. - const stdout = [enc.encode(`${JSON.stringify({ - type: "result", - subtype: "success", - is_error: true, - result: "Not logged in · Please run /login", - })}\n`)]; - const adapter = createClaudeCliAdapter(provider(), { - spawn: () => fakeChild(stdout, { exitCode: 1 }) as unknown as ChildProcess, - which: () => "/opt/homebrew/bin/claude", - killGraceMs: 20, - }); - - const events = await run(adapter, parsed()); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ type: "error", status: 401, code: "claude_cli_not_logged_in", retryable: false }); - expect(String((events[0] as { message: string }).message)).toContain("claude"); - }); - - test("the sign-in hint leaves every other error untouched", () => { - const events: AdapterEvent[] = []; - const hinted = withClaudeLoginHint(event => events.push(event)); - hinted({ type: "error", message: "upstream exploded", status: 502, code: "upstream_error" }); - hinted({ type: "error", message: "rate limited", status: 429, code: "rate_limit_exceeded" }); - hinted({ type: "text_delta", text: "hi" }); - expect(events).toEqual([ - { type: "error", message: "upstream exploded", status: 502, code: "upstream_error" }, - { type: "error", message: "rate limited", status: 429, code: "rate_limit_exceeded" }, - { type: "text_delta", text: "hi" }, - ]); - }); -}); diff --git a/tests/providers/claude-provider-rename-migration.test.ts b/tests/providers/claude-provider-rename-migration.test.ts new file mode 100644 index 00000000000..39347061d22 --- /dev/null +++ b/tests/providers/claude-provider-rename-migration.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; +import { + CLAUDE_AGENT_SDK_PROVIDER_ID, + CLAUDE_CLI_PROVIDER_ID, + projectClaudeProviderRename, +} from "../../src/providers/claude-provider-rename-migration"; +import { resolveDeprecatedProviderId } from "../../src/providers/deprecated-provider-aliases"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { effectiveAdapterContract, getAdapterDefinition } from "../../src/adapters/registry"; +import { projectStartupConfigRepairs } from "../../src/providers/model-rename-startup"; +import type { OcxConfig } from "../../src/types"; + +const OLD = CLAUDE_CLI_PROVIDER_ID; +const NEW = CLAUDE_AGENT_SDK_PROVIDER_ID; + +/** + * A config as written while `claude-cli` was still the registry id: the provider row under the old + * key, plus one of every cross-config reference shape the shared rewriter owns. + */ +function migratableConfig(): OcxConfig { + return { + port: 10100, + defaultProvider: OLD, + providers: { + [OLD]: { adapter: OLD, baseUrl: "https://api.anthropic.com" }, + }, + disabledModels: [`${OLD}/claude-sonnet-5`, "anthropic/claude-sonnet-5"], + customModels: [{ id: "mine", provider: OLD, modelId: "claude-sonnet-5" }], + combos: { fast: { targets: [{ provider: OLD, model: "claude-sonnet-5" }] } }, + routingProfiles: { + policy: { + candidates: [ + { provider: OLD, model: "claude-sonnet-5" }, + { provider: "anthropic", model: "claude-sonnet-5" }, + ], + }, + }, + providerContextCaps: { [OLD]: 1_000_000, anthropic: 200_000 }, + } as unknown as OcxConfig; +} + +describe("claude provider rename projection", () => { + test("moves the row and re-points every reference shape", () => { + const projection = projectClaudeProviderRename(migratableConfig()); + expect(projection.changed).toBe(true); + + const providers = projection.config.providers!; + expect(providers[OLD]).toBeUndefined(); + expect(providers[NEW]!.baseUrl).toBe("https://api.anthropic.com"); + expect(providers[NEW]!.adapter).toBe(NEW); + + expect(projection.config.defaultProvider).toBe(NEW); + expect(projection.config.disabledModels).toEqual([`${NEW}/claude-sonnet-5`, "anthropic/claude-sonnet-5"]); + expect(projection.config.customModels![0]!.provider).toBe(NEW); + expect(projection.config.combos!.fast.targets[0]!.provider).toBe(NEW); + expect(projection.config.routingProfiles!.policy.candidates[0]!.provider).toBe(NEW); + + const caps = projection.config.providerContextCaps as Record; + expect(caps[NEW]).toBe(1_000_000); + expect(Object.hasOwn(caps, OLD)).toBe(false); + + expect(projection.warnings.join("\n")).toContain("moved provider"); + }); + + test("rewrites the adapter id on a custom-named row that named the retired adapter", () => { + const config = { + providers: { "my-claude": { adapter: OLD, baseUrl: "https://api.anthropic.com" } }, + } as unknown as OcxConfig; + const projection = projectClaudeProviderRename(config); + expect(projection.changed).toBe(true); + expect(projection.config.providers!["my-claude"]!.adapter).toBe(NEW); + expect(projection.config.providers!["my-claude"]!.baseUrl).toBe("https://api.anthropic.com"); + expect(projection.warnings.join("\n")).toContain("custom provider row"); + }); + + test("refuses to merge when the destination row already exists", () => { + const config = { + providers: { + [OLD]: { adapter: OLD, baseUrl: "https://api.anthropic.com" }, + [NEW]: { adapter: NEW, baseUrl: "https://api.anthropic.com" }, + }, + } as unknown as OcxConfig; + const projection = projectClaudeProviderRename(config); + expect(projection.changed).toBe(false); + expect(projection.config).toBe(config); + expect(projection.warnings.join("\n")).toContain("already exists"); + }); + + test("leaves a user-named claude-cli row on another adapter alone", () => { + // The name is plausible for an `anthropic` row: `claude-cli-identity.ts` uses the same + // term. Moving it would swap the operator transport and put its billing on the signed-in + // Claude account, so the projection hands the whole row - and every reference to it - back. + const config = { + defaultProvider: OLD, + providers: { [OLD]: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "sk-ant-x" } }, + disabledModels: [`${OLD}/claude-sonnet-5`], + } as unknown as OcxConfig; + const projection = projectClaudeProviderRename(config); + expect(projection.changed).toBe(false); + expect(projection.config).toBe(config); + expect(projection.config.providers![OLD]!.adapter).toBe("anthropic"); + expect(projection.config.defaultProvider).toBe(OLD); + expect(projection.config.disabledModels).toEqual([`${OLD}/claude-sonnet-5`]); + expect(projection.warnings.join("\n")).toContain("left provider"); + }); + + test("a refused row still names an adapter the registry can build", () => { + const config = { + providers: { + [OLD]: { adapter: OLD, baseUrl: "https://api.anthropic.com" }, + [NEW]: { adapter: NEW, baseUrl: "https://api.anthropic.com" }, + }, + } as unknown as OcxConfig; + const projection = projectClaudeProviderRename(config); + expect(projection.changed).toBe(false); + // The refusal decides which of two rows survives, not what an adapter id means: the retired + // adapter is gone from the registry, so a leftover string has to keep resolving to it. + const adapterId = projection.config.providers![OLD]!.adapter!; + expect(adapterId).toBe(OLD); + expect(getAdapterDefinition(adapterId)).toBe(getAdapterDefinition(NEW)); + expect(effectiveAdapterContract(adapterId).wire).toBe(effectiveAdapterContract(NEW).wire); + }); + + test("discards a half-applied projection when a destination key collides", () => { + const config = { + defaultProvider: OLD, + providers: { [OLD]: { adapter: OLD } }, + providerContextCaps: { [OLD]: 100, [NEW]: 200 }, + } as unknown as OcxConfig; + const projection = projectClaudeProviderRename(config); + expect(projection.changed).toBe(false); + expect(projection.config).toBe(config); + // The clone was already partly rewritten when the collision surfaced; returning the original + // is what keeps the caller from saving that half. + expect(projection.config.defaultProvider).toBe(OLD); + expect((projection.config.providers as Record)[OLD]).toBeDefined(); + expect(projection.warnings.join("\n")).toContain("already hold values"); + }); + + test("is a no-op when nothing names the retired id", () => { + const config = { providers: { anthropic: { adapter: "anthropic" } } } as unknown as OcxConfig; + const projection = projectClaudeProviderRename(config); + expect(projection.changed).toBe(false); + expect(projection.warnings).toEqual([]); + expect(projection.config).toBe(config); + }); + + test("the retired id still resolves to the renamed registry row", () => { + expect(resolveDeprecatedProviderId(OLD)).toBe(NEW); + expect(resolveDeprecatedProviderId("anthropic")).toBe("anthropic"); + expect(getProviderRegistryEntry(OLD)?.id).toBe(NEW); + expect(getProviderRegistryEntry(NEW)?.label).toBe("Claude Agent SDK (subscription)"); + }); + + test("the shared startup pass carries the rename", () => { + // The projection is only wired if the boot pass reaches it: a row renamed after the model and + // context-window repairs would be repaired against a registry entry that no longer exists. + const repaired = projectStartupConfigRepairs(migratableConfig()); + expect(repaired.changed).toBe(true); + expect(repaired.config.providers![NEW]).toBeDefined(); + expect(repaired.config.providers![OLD]).toBeUndefined(); + expect(repaired.warnings.join("\n")).toContain("moved provider"); + }); +}); diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index d6b75c5565f..75bd58aa035 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -46,7 +46,7 @@ const EXPECTED_KEY_PROVIDER_IDS = [ "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", "opper", "opencode-free", "xiaomi", "xiaomi-mimo", "kilo", "mimo-free", "mimo", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo", - "qoder", "qoder-cn", "codebuddy", "codebuddy-cn", "stepfun", "claude-cli", + "qoder", "qoder-cn", "codebuddy", "codebuddy-cn", "stepfun", "claude-agent-sdk", ]; describe("provider registry parity", () => { @@ -1110,9 +1110,9 @@ describe("provider registry parity", () => { expect(litellm?.authKind).toBe("key"); expect(providerConfigSeed(litellm!).keyOptional).toBe(true); - // claude-cli joins them as the first CLI-backed member: its row is `key` because the turn + // claude-agent-sdk joins them as the first CLI-backed member: its row is `key` because the turn // leaves this machine, and keyless because the Claude Code CLI reads the operator's sign-in. - expect(optionalKeyProviders).toEqual(["litellm", "opencode-free", "mimo-free", "claude-cli"]); + expect(optionalKeyProviders).toEqual(["litellm", "opencode-free", "mimo-free", "claude-agent-sdk"]); }); test("NVIDIA NIM is free-tier priced but still requires an API key", () => {