From 1094d66dbc729fe2faa59dddd9f045d8f344b804 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:30:56 +0200 Subject: [PATCH 1/5] feat(provider): add a Claude Code CLI subscription provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `anthropic` reaches Claude by replaying Claude Code's HTTP identity (OAuth headers, a hashed session id, beta flags) against the Messages API. That spends a subscription outside Anthropic's own harness, and the account — not the proxy — carries the consequence. `claude-cli` makes the harness the transport instead: one headless `claude -p` turn per request over the family's existing stream-json seam (`src/adapters/coding-agent/`). The CLI's own sign-in is the only credential; OpenCodex stores none, reads none and injects none, so this row needs no API key. The profile is the family's first credentialless one: `tokenEnv` is optional, and the shared turn skips the key preflight for such a profile while still redacting the generic secret shapes. Arguments keep the family posture — `--tools ""`, `--strict-mcp-config`, `--setting-sources ""`, `--no-session-persistence`, no permission bypass — and the scoped child env inherits no `ANTHROPIC_*` value, so a `claude` already pointed at this proxy cannot loop back into it. Telemetry, feedback and the auto-updater are off for the spawned turn. `--system-prompt` replaces the Claude Code preset with the caller's system and developer prompts instead of stacking a second instruction set in front of them. v1 is text and reasoning only, exactly like `codebuddy` and `qoder`: with no tool channel the client keeps approval, sandboxing and execution. The shared capture-only tool bridge is the documented follow-up. An unauthenticated CLI ends the turn on a normal `result` frame rather than a failure, so the adapter rewrites that 401 into the action it implies (sign in with `claude`), instead of leaving the operator to guess between key, row and account. Verified against the installed CLI 2.1.270: the full argument set is accepted and a stream-json turn reaches the account check, ending on the terminal `result` frame "Not logged in · Please run /login". --- .../src/content/docs/guides/providers.md | 42 +++ .../docs/reference/configuration/providers.md | 2 +- scripts/test-layout/layout.json | 1 + src/adapters/claude-cli/adapter.ts | 142 ++++++++++ src/adapters/claude-cli/profiles.ts | 38 +++ src/adapters/codebuddy/profiles.ts | 2 + src/adapters/coding-agent/profile.ts | 16 +- src/adapters/coding-agent/turn.ts | 22 +- src/adapters/qoder/profiles.ts | 2 + src/adapters/registry.ts | 8 + src/providers/registry/entries-extended.ts | 36 +++ structure/adapters/registry.md | 8 + .../adapter-registry-authority.test.ts | 1 + .../adapters/adapter-tool-conformance.test.ts | 2 +- tests/fixtures/test-layout-expected.json | 1 + tests/providers/claude-cli-adapter.test.ts | 244 ++++++++++++++++++ 16 files changed, 555 insertions(+), 12 deletions(-) create mode 100644 src/adapters/claude-cli/adapter.ts create mode 100644 src/adapters/claude-cli/profiles.ts create mode 100644 tests/providers/claude-cli-adapter.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index c89793a4788..a195560d2c6 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -912,6 +912,48 @@ 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) + +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: + +```json +{ + "providers": { + "claude-cli": { + "adapter": "claude-cli", + "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). +- **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`. +- **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`), so the turn answers the client's contract rather than the harness persona. +- **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. +- **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. + ### A6API credit quota A custom `openai-chat` provider using `authMode: "key"` and the canonical diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 6cb260fa128..9dac2a3bb6c 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`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`), `codebuddy`, `qoder`. | +| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `claude-cli`, `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/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f729f1289df..735584531f1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -355,6 +355,7 @@ "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-cli.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", diff --git a/src/adapters/claude-cli/adapter.ts b/src/adapters/claude-cli/adapter.ts new file mode 100644 index 00000000000..1d862f4506a --- /dev/null +++ b/src/adapters/claude-cli/adapter.ts @@ -0,0 +1,142 @@ +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. + */ +export function buildChildEnv(_profile: ClaudeCliProfile, _apiKey: string): Record { + return { + ...baseScopedEnv(), + ...CLAUDE_CLI_QUIET_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. + * + * `--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. + * + * `--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): 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); + const system = buildSystemPrompt(parsed); + if (system) args.push("--system-prompt", system); + return args; +} + +/** + * 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 { + await runCodingAgentTurn({ + profiles: CLAUDE_CLI_PROFILES, + provider, + parsed, + incoming, + emit: withClaudeLoginHint(emit), + buildArgs: (profile, req, prov) => buildArgs(profile as ClaudeCliProfile, req, prov), + buildEnv: (profile, apiKey) => buildChildEnv(profile as ClaudeCliProfile, apiKey), + deps, + }); + }, + }; +} diff --git a/src/adapters/claude-cli/profiles.ts b/src/adapters/claude-cli/profiles.ts new file mode 100644 index 00000000000..0445b05b638 --- /dev/null +++ b/src/adapters/claude-cli/profiles.ts @@ -0,0 +1,38 @@ +import { clearCodingAgentBinaryCache, type CodingAgentProviderProfile } from "../coding-agent/profile"; + +/** + * Profile for the official Claude Code CLI — Anthropic's own harness (§三十一). + * + * The CLI owns the account: OpenCodex stores no Claude token, reads none, and injects none, so a + * subscription turn is spent through the harness Anthropic ships instead of a replayed Claude Code + * identity against the Messages API. There is exactly one destination, so the profile carries the + * single-binary shape of the shared coding-agent seam and no `tokenEnv`. + * + * Evidence (verified 2026-09-24 against the installed CLI 2.1.270): the full argument set built by + * `./adapter.ts` is accepted — including `--tools ""`, `--setting-sources ""`, `--effort` and + * `--system-prompt` — and a stream-json turn reaches the account check, ending on a terminal + * `result` frame with `is_error: true` and "Not logged in · Please run /login" rather than on an + * unknown option. The success-path frame shapes are the shared parser's, unchanged from the family + * this profile joins (`../coding-agent/protocol.ts`). + */ +export interface ClaudeCliProfile extends CodingAgentProviderProfile { + family: "claude"; +} + +export const CLAUDE_CLI_PROFILE: ClaudeCliProfile = { + providerId: "claude-cli", + 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. + region: "global", + label: "Claude Code", + canonicalBaseUrl: "https://api.anthropic.com", + binaryCandidates: ["claude"], + installHint: "npm install -g @anthropic-ai/claude-code", + documentationUrl: "https://docs.claude.com/en/docs/claude-code/cli-reference", +}; + +export const CLAUDE_CLI_PROFILES: readonly ClaudeCliProfile[] = [CLAUDE_CLI_PROFILE]; + +/** Binary-discovery cache is shared across coding-agent families; re-exported for test isolation. */ +export const clearClaudeCliBinaryCache = clearCodingAgentBinaryCache; diff --git a/src/adapters/codebuddy/profiles.ts b/src/adapters/codebuddy/profiles.ts index f06edb8ed5c..6c70a4bd47a 100644 --- a/src/adapters/codebuddy/profiles.ts +++ b/src/adapters/codebuddy/profiles.ts @@ -16,6 +16,8 @@ import { clearCodingAgentBinaryCache, type CodingAgentProviderProfile } from ".. */ export interface CodeBuddyProfile extends CodingAgentProviderProfile { family: "codebuddy"; + /** Required for this family: the CLI is driven with a vendor API key. */ + tokenEnv: string; /** Official `CODEBUDDY_INTERNET_ENVIRONMENT` value for this region. */ internetEnvironment: "public" | "internal"; } diff --git a/src/adapters/coding-agent/profile.ts b/src/adapters/coding-agent/profile.ts index 7298469767c..d1f9d0bd33e 100644 --- a/src/adapters/coding-agent/profile.ts +++ b/src/adapters/coding-agent/profile.ts @@ -13,8 +13,11 @@ export interface CodingAgentProviderProfile { /** Canonical OpenCodex provider id this profile serves. */ providerId: string; /** Vendor family; selects the arg/env builder in the family adapter. */ - family: "codebuddy" | "qoder"; - /** Region; drives the vendor's own region switch and keeps credentials deterministic. */ + family: "claude" | "codebuddy" | "qoder"; + /** + * Region; drives the vendor's own region switch and keeps credentials deterministic. + * A family with a single destination carries `global`. + */ region: "global" | "cn"; /** Human label for diagnostics/error copy (never sent upstream). */ label: string; @@ -26,8 +29,13 @@ export interface CodingAgentProviderProfile { canonicalBaseUrl: string; /** Executable names to resolve on PATH, in preference order. */ binaryCandidates: readonly string[]; - /** Official credential environment variable consumed by the CLI. */ - tokenEnv: string; + /** + * Official credential environment variable consumed by the CLI. + * + * Omitted when the CLI owns its own sign-in (Claude Code reads the operator's `claude` session), + * where the adapter neither requires nor injects a key and has no secret to redact. + */ + tokenEnv?: string; /** Install command surfaced when the CLI is missing (§二十六). */ installHint: string; /** Official documentation for the automation surface. */ diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index ae5aa1c743d..67014960621 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -79,13 +79,20 @@ export function baseScopedEnv(): Record { return env; } -/** Redact the profile's credential and common secret shapes before surfacing diagnostics. */ -export function redactSecrets(text: string, tokenEnv: string, credential?: string): string { - const escaped = tokenEnv.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +/** + * Redact the profile's credential and common secret shapes before surfacing diagnostics. + * + * `tokenEnv` is absent for a credentialless profile (the CLI owns its sign-in), which only drops + * the `NAME=value` rule; the generic secret shapes are redacted either way. + */ +export function redactSecrets(text: string, tokenEnv: string | undefined, credential?: string): string { let redacted = text; if (credential) redacted = redacted.split(credential).join("[redacted]"); + if (tokenEnv) { + const escaped = tokenEnv.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + redacted = redacted.replace(new RegExp(`(${escaped}\\s*[:=]\\s*)\\S+`, "gi"), "$1[redacted]"); + } return redacted - .replace(new RegExp(`(${escaped}\\s*[:=]\\s*)\\S+`, "gi"), "$1[redacted]") .replace(/(authorization\s*[:=]\s*)\S+/gi, "$1[redacted]") .replace(/\b(sk-[A-Za-z0-9_-]{6,})\b/g, "[redacted]"); } @@ -173,8 +180,11 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise 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. + contractParent: "codebuddy", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createClaudeCliAdapter(provider), + }, } as const satisfies Record; export type AdapterId = keyof typeof ADAPTER_REGISTRY; diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index d96f6605043..017255b3b38 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -108,6 +108,12 @@ import { STEPFUN_MODEL_INPUT_MODALITIES, STEPFUN_NO_VISION_MODELS, STEPFUN_REASONING_EFFORTS, + ANTHROPIC_MODELS, + ANTHROPIC_MODEL_CONTEXT_WINDOWS, + ANTHROPIC_MODEL_INPUT_MODALITIES, + ANTHROPIC_MODEL_REASONING_EFFORTS, + ANTHROPIC_REASONING_EFFORTS, + ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, } from "./model-seeds"; export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ @@ -1420,4 +1426,34 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ reasoningEfforts: STEPFUN_REASONING_EFFORTS, 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", + baseUrl: "https://api.anthropic.com", + authKind: "local", + // Offered as a dashboard preset without claiming `featured` placement (the same seam cursor + // uses): `deriveProviderPresets` otherwise only lists featured and key rows, which would make + // a keyless CLI provider unreachable from the Providers page. + dashboardPreset: true, + defaultModel: "claude-sonnet-5", + models: [...ANTHROPIC_MODELS], + modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + modelInputModalities: { ...ANTHROPIC_MODEL_INPUT_MODALITIES }, + reasoningEfforts: ANTHROPIC_REASONING_EFFORTS, + modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, + defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + note: "Runs your own Claude subscription through Anthropic's official Claude Code CLI (`claude -p`). OpenCodex stores no Claude token and reads none: the CLI signs in and bills the account itself, so this row needs no API key. 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.", + }, ]; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index f6933c5ad05..d7bd000668d 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -30,6 +30,14 @@ 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 + 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. Everything that + makes the turn safe to run under a proxy is argument-level, and `tests/providers/claude-cli-adapter.test.ts` + pins it: `--tools ""`, `--strict-mcp-config`, `--setting-sources ""`, `--no-session-persistence`, + no permission bypass, and a child environment that carries no inherited `ANTHROPIC_*` value. - `cursor` stays direct because its `runTurn` transport and gated native-file fallback are distinct. - `devin` is direct for a related reason. It streams Cognition's `ApiServerService/GetChatMessage` over Connect-RPC from `runTurn` with hand-written protobuf diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index b0b43c8b197..cc4fcab6dc2 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -24,6 +24,7 @@ const EXPECTED_ADAPTER_NAMES = { devin: "devin", "mimo-free": "mimo-free", qoder: "qoder", + "claude-cli": "claude-cli", } 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 94715811077..d25fd7228f8 100644 --- a/tests/adapters/adapter-tool-conformance.test.ts +++ b/tests/adapters/adapter-tool-conformance.test.ts @@ -419,7 +419,7 @@ describe("registry-derived routed tool conformance", () => { } }); - const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder"]); + const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder", "claude-cli"]); // 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 28b4f0a5913..f1b25983f07 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -187,6 +187,7 @@ "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-cli.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", diff --git a/tests/providers/claude-cli-adapter.test.ts b/tests/providers/claude-cli-adapter.test.ts new file mode 100644 index 00000000000..5a5c0cb147d --- /dev/null +++ b/tests/providers/claude-cli-adapter.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { Readable, Writable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { + buildArgs, + buildChildEnv, + createClaudeCliAdapter, + withClaudeLoginHint, + type SpawnFn, +} from "../../src/adapters/claude-cli/adapter"; +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 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); + // No credential is stored for this row: the CLI signs in for itself. + expect(entry!.authKind).toBe("local"); + expect(entry!.defaultModel).toBe("claude-sonnet-5"); + expect(entry!.models).toContain(entry!.defaultModel!); + expect(entry!.modelContextWindows?.[entry!.defaultModel!]).toBeGreaterThan(0); + }); + + 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()); + expect(args[args.indexOf("--system-prompt") + 1]).toBe("Be terse."); + }); + + test("no system prompt is passed when the request carries none", () => { + const args = buildArgs(CLAUDE_CLI_PROFILE, parsed(), provider()); + expect(args).not.toContain("--system-prompt"); + }); + + 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, ""); + expect(env.HOME).toBe(process.env.HOME); + expect(env.DISABLE_AUTOUPDATER).toBe("1"); + expect(env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe("1"); + }); +}); + +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"); + }); +}); + +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" }, + ]); + }); +}); From c2fa1bde6bacfd24432d70fdd2113a54af89ed63 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:48:31 +0200 Subject: [PATCH 2/5] fix(provider): classify claude-cli as a keyless key row, not a local runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `local` is the Ollama / vLLM / LM Studio classification: the traffic never leaves the machine, so there is no credential to classify. This row reaches api.anthropic.com, so the marker misreported it wherever auth is classified — `classifyAccount` answered "local provider ... has no credentials" for a provider whose whole point is a credential the Claude Code CLI owns, and the dashboard filed the row as a local runtime. `key` + `keyOptional` is what is true: the turn leaves this machine, and no API key is required because the CLI signs in for itself. `deriveProviderPresets` already lists key rows, so `dashboardPreset` is gone rather than duplicated, and `deriveKeyLoginMap` is given the `dashboardUrl` every key row owes (the setup page, since a keyless row has no key console). A key configured on the row is still never handed to the harness; the new case pins that, and the inherited HOME — one sign-in serves every request on this proxy — is now stated in the registry note and the provider guide instead of only in the adapter comment. --- .../src/content/docs/guides/providers.md | 7 ++++ src/providers/registry/entries-extended.ts | 21 +++++++---- structure/adapters/registry.md | 4 +++ tests/providers/claude-cli-adapter.test.ts | 36 +++++++++++++++++-- .../provider-registry-parity.test.ts | 6 ++-- 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index a195560d2c6..36ca9a85966 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -935,6 +935,13 @@ CLI headlessly (`claude -p`, `stream-json`) once per turn: - **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`. + 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. +- **One sign-in serves the whole proxy:** the harness reads the Claude Code sign-in of the user + 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. - **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 diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 017255b3b38..3b79842baf8 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -1442,11 +1442,20 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ label: "Claude Code CLI (subscription)", adapter: "claude-cli", baseUrl: "https://api.anthropic.com", - authKind: "local", - // Offered as a dashboard preset without claiming `featured` placement (the same seam cursor - // uses): `deriveProviderPresets` otherwise only lists featured and key rows, which would make - // a keyless CLI provider unreachable from the Providers page. - dashboardPreset: true, + // `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 + // api.anthropic.com, so `local` misreported it wherever auth is classified — the account + // surface answered "local provider ... has no credentials" (`classifyAccount`, + // src/cli/account-api.ts) and the dashboard filed the row as a local runtime. What IS true is + // keyless: the CLI reads the operator's own sign-in, so `keyOptional` is the existing flag that + // exempts a row from key enforcement without claiming a key exists. Key rows are also what + // `deriveProviderPresets` lists, so this entry needs no `dashboardPreset` flag to stay + // reachable from the Providers page. + authKind: "key", + keyOptional: true, + // 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", defaultModel: "claude-sonnet-5", models: [...ANTHROPIC_MODELS], modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, @@ -1454,6 +1463,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 your own Claude subscription through Anthropic's official Claude Code CLI (`claude -p`). OpenCodex stores no Claude token and reads none: the CLI signs in and bills the account itself, so this row needs no API key. 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: "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.", }, ]; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index d7bd000668d..3501e82efb7 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -38,6 +38,10 @@ Some adapters share another adapter's routed-tool semantics while retaining inde makes the turn safe to run under a proxy is argument-level, and `tests/providers/claude-cli-adapter.test.ts` pins it: `--tools ""`, `--strict-mcp-config`, `--setting-sources ""`, `--no-session-persistence`, no permission bypass, and a child environment that carries no inherited `ANTHROPIC_*` value. + 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 + rows are what `deriveProviderPresets` lists, so the entry needs no `dashboardPreset` flag either. - `cursor` stays direct because its `runTurn` transport and gated native-file fallback are distinct. - `devin` is direct for a related reason. It streams Cognition's `ApiServerService/GetChatMessage` over Connect-RPC from `runTurn` with hand-written protobuf diff --git a/tests/providers/claude-cli-adapter.test.ts b/tests/providers/claude-cli-adapter.test.ts index 5a5c0cb147d..1e7a407d50d 100644 --- a/tests/providers/claude-cli-adapter.test.ts +++ b/tests/providers/claude-cli-adapter.test.ts @@ -12,6 +12,7 @@ import { 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"; @@ -79,13 +80,32 @@ describe("claude-cli is an official-harness provider, not a Messages relay", () expect(entry).toBeDefined(); expect(entry!.adapter).toBe("claude-cli"); expect(entry!.baseUrl).toBe(CLAUDE_CLI_PROFILE.canonicalBaseUrl); - // No credential is stored for this row: the CLI signs in for itself. - expect(entry!.authKind).toBe("local"); expect(entry!.defaultModel).toBe("claude-sonnet-5"); expect(entry!.models).toContain(entry!.defaultModel!); expect(entry!.modelContextWindows?.[entry!.defaultModel!]).toBeGreaterThan(0); }); + 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"); @@ -158,10 +178,22 @@ describe("claude-cli child environment carries no credential and no proxy destin 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([]); + }); }); describe("claude-cli runTurn fails closed before any spawn", () => { diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 2bb927063a8..124ef6edbee 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", + "qoder", "qoder-cn", "codebuddy", "codebuddy-cn", "stepfun", "claude-cli", ]; describe("provider registry parity", () => { @@ -1107,7 +1107,9 @@ describe("provider registry parity", () => { expect(litellm?.authKind).toBe("key"); expect(providerConfigSeed(litellm!).keyOptional).toBe(true); - expect(optionalKeyProviders).toEqual(["litellm", "opencode-free", "mimo-free"]); + // claude-cli 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"]); }); test("NVIDIA NIM is free-tier priced but still requires an API key", () => { From 292a322bef1433a63161d2a4da3ddbdd75d00891 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:06:12 +0200 Subject: [PATCH 3/5] fix(provider): always replace the harness preset, and publish text-only models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, both settled against the installed CLI 2.1.270 instead of the argument list. Omitting `--system-prompt` is not "no system prompt": it is Claude Code's own fourteen-block preset, which describes a harness with tools this turn deliberately does not have. The `prompt_snapshot` attachment the CLI writes into a session transcript makes the difference visible — `--system-prompt "MARKER"` snapshots exactly that text, `--system-prompt ""` snapshots the empty string, and omitting the flag snapshots the preset. The flag is now always passed, with the empty string standing in for a caller that sends neither a system nor a developer prompt, which is the same request the Messages API path forwards as no system message at all. The row published the Messages API image modality for all ten models while the adapter, the guide and the note described v1 as text and reasoning only. Nothing verified that a headless turn hands image bytes to the model, and an image the harness drops produces a confident answer to the wrong question, so the models are published text-only (`noVisionModels`) and the adapter refuses direct image input the way the Qoder presets do. A vision sidecar on the request path still captions images into text. The preset counts in the eight guides, eight quickstarts and the structure ops record move from 97/80 to 98/81 with this row, which the derived-count gate asserts in every locale. --- .../docs/fr/getting-started/quickstart.md | 2 +- .../src/content/docs/fr/guides/providers.md | 2 +- .../docs/getting-started/quickstart.md | 2 +- .../src/content/docs/guides/providers.md | 7 +++- .../docs/ja/getting-started/quickstart.md | 2 +- .../src/content/docs/ja/guides/providers.md | 2 +- .../docs/ko/getting-started/quickstart.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../docs/ru/getting-started/quickstart.md | 2 +- .../src/content/docs/ru/guides/providers.md | 2 +- .../docs/tr/getting-started/quickstart.md | 2 +- .../src/content/docs/tr/guides/providers.md | 2 +- .../docs/zh-cn/getting-started/quickstart.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- .../docs/zh-tw/getting-started/quickstart.md | 2 +- .../content/docs/zh-tw/guides/providers.md | 2 +- src/adapters/claude-cli/adapter.ts | 37 ++++++++++++++++++- src/providers/registry/entries-extended.ts | 7 +++- structure/ops/docs-and-release.md | 2 +- tests/providers/claude-cli-adapter.test.ts | 29 ++++++++++++++- 20 files changed, 90 insertions(+), 22 deletions(-) diff --git a/docs-site/src/content/docs/fr/getting-started/quickstart.md b/docs-site/src/content/docs/fr/getting-started/quickstart.md index 06099448225..ecd35fd1800 100644 --- a/docs-site/src/content/docs/fr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/fr/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` vous accompagne dans les étapes suivantes : -1. **Choix d’un fournisseur** — sélectionnez l’un des 97 préréglages intégrés au registre, ou `custom` pour saisir une +1. **Choix d’un fournisseur** — sélectionnez l’un des 98 préréglages intégrés au registre, ou `custom` pour saisir une URL de base et un adaptateur. 2. **Clé API** — collez une clé ou référencez une variable d’environnement telle que `${ANTHROPIC_API_KEY}`. 3. **Modèle par défaut** — pour les fournisseurs clés, locaux et personnalisés, acceptez le préréglage ou saisissez un identifiant de modèle. diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 6888d4b327f..debf0f51f2c 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -295,7 +295,7 @@ existante n'est pas concernée. ## 3. Catalogue des clés API -opencodex fournit 97 préréglages intégrés : 80 à clé, 13 OAuth, trois locaux et un préréglage par défaut de +opencodex fournit 98 préréglages intégrés : 81 à clé, 13 OAuth, trois locaux et un préréglage par défaut de transfert ChatGPT. Dans le tableau de bord, le sélecteur **Ajouter un fournisseur** ouvre le tableau de bord du fournisseur à clé, valide la clé et l'enregistre ; la validation dépend du fournisseur. Parmi les entrées notables : diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 69944caca58..db6a4894d34 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` walks you through: -1. **Pick a provider** — choose one of the 97 built-in registry presets or `custom` to type a base +1. **Pick a provider** — choose one of the 98 built-in registry presets or `custom` to type a base URL and adapter. 2. **API key** — paste a key, or reference an environment variable like `${ANTHROPIC_API_KEY}`. 3. **Default model** — for key, local, and custom providers, accept the preset or enter a model id. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 36ca9a85966..d4a5ffb9cbf 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -443,7 +443,7 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses ## 3. API-key catalog -opencodex ships 97 built-in presets: 80 key-based, 13 OAuth, three local, and one default +opencodex ships 98 built-in presets: 81 key-based, 13 OAuth, three local, and one default ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard, validates the key, and stores it; validation is provider-specific. Notable entries: @@ -942,6 +942,11 @@ 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 + 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 diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index 0661da7173b..ae29e0b8d14 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` では次の手順を説明します。 -1. **プロバイダーを選択してください** — 97 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 +1. **プロバイダーを選択してください** — 98 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 2. **API キー** — キーを貼り付けるか、`${ANTHROPIC_API_KEY}` のような環境変数を参照します。 3. **デフォルト モデル** — キー、ローカル、カスタム プロバイダーの場合は、プリセットを受け入れるか、モデル ID を入力します。 4. **プロキシ ポート** — デフォルトは `10100` です。 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 98e4e155d72..c0c76b7a8c1 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -193,7 +193,7 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht ## 3. API キーカタログ -opencodex には組み込みプリセットが 97 個含まれています。キー方式 80、OAuth 13、ローカル 3、 +opencodex には組み込みプリセットが 98 個含まれています。キー方式 81、OAuth 13、ローカル 3、 デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 入力したキーを検証した後保存します(検証はプロバイダー固有です)。主な項目は以下のとおりです: diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md index cc53424c63e..f27de93b0e3 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init`은 다음 과정을 안내합니다: -1. **프로바이더 선택** — 내장 레지스트리 프리셋 97개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. +1. **프로바이더 선택** — 내장 레지스트리 프리셋 98개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. 2. **API 키** — 키를 붙여넣거나 `${ANTHROPIC_API_KEY}` 같은 환경 변수를 참조합니다. 3. **기본 모델** — 키, 로컬, custom 프로바이더에서는 프리셋을 그대로 쓰거나 모델 ID를 직접 입력합니다. 4. **프록시 포트** — 기본값은 `10100`입니다. diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 40a7d6296de..ce779d0e7dd 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -190,7 +190,7 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 -opencodex에는 빌트인 프리셋이 97개 들어 있습니다. 키 방식 80개, OAuth 13개, 로컬 3개, +opencodex에는 빌트인 프리셋이 98개 들어 있습니다. 키 방식 81개, OAuth 13개, 로컬 3개, 기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, 입력한 키를 검증한 뒤 저장합니다(검증은 프로바이더별로 다릅니다). 주요 항목은 다음과 같습니다: diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md index a8a0abc08b0..825ec591936 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` проведёт вас по следующим шагам: -1. **Выбор провайдера** — выберите один из 97 встроенных пресетов реестра или `custom`, чтобы +1. **Выбор провайдера** — выберите один из 98 встроенных пресетов реестра или `custom`, чтобы ввести базовый URL и адаптер вручную. 2. **API-ключ** — вставьте ключ или сошлитесь на переменную окружения вида `${ANTHROPIC_API_KEY}`. 3. **Модель по умолчанию** — для провайдеров с ключом, локальных и `custom` примите значение из diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 27b449045f3..a83fb7145f9 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -206,7 +206,7 @@ Inline JSON и лишние позиционные аргументы откло ## 3. Каталог API-ключей -opencodex поставляется с 97 встроенными пресетами: 80 на основе ключей, 13 OAuth, три локальных и +opencodex поставляется с 98 встроенными пресетами: 81 на основе ключей, 13 OAuth, три локальных и один пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу выдачи ключей провайдера, проверяет ключ и сохраняет его; проверка зависит от провайдера. Наиболее заметные записи: diff --git a/docs-site/src/content/docs/tr/getting-started/quickstart.md b/docs-site/src/content/docs/tr/getting-started/quickstart.md index fe77fcae635..6214216d4f4 100644 --- a/docs-site/src/content/docs/tr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/tr/getting-started/quickstart.md @@ -14,7 +14,7 @@ ocx init `ocx init` adım adım size rehberlik eder: -1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 97 önayardan birini +1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 98 önayardan birini veya bir temel URL ile adaptör yazmak için `custom` seçeneğini belirleyin. 2. **API anahtarı** — bir anahtar yapıştırın veya `${ANTHROPIC_API_KEY}` gibi bir ortam değişkenine başvurun. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 073b57dc688..58c0441070f 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -327,7 +327,7 @@ olmayan bir makineden oturum açmak bundan etkilenmez. ## 3. API anahtarı kataloğu -opencodex 97 yerleşik önayar ile birlikte gelir: 80 anahtar tabanlı, 13 +opencodex 98 yerleşik önayar ile birlikte gelir: 81 anahtar tabanlı, 13 OAuth, üç yerel ve bir varsayılan ChatGPT iletme önayarı. Kontrol panelinin **Sağlayıcı ekle** seçicisi bir anahtar sağlayıcısının kontrol panelini açar, anahtarı doğrular ve saklar; doğrulama sağlayıcıya özgüdür. Dikkate değer diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md index 7a7d9d01c58..8682fa62205 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` 会引导你完成: -1. **选择 provider** — 从内置 registry 的 97 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 +1. **选择 provider** — 从内置 registry 的 98 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 2. **API key** — 粘贴一个 key,或引用一个环境变量,例如 `${ANTHROPIC_API_KEY}`。 3. **默认模型** — 对于 key、本地和 custom provider,接受预设值或输入模型 id。 4. **代理端口** — 默认为 `10100`。 diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 43223c280c2..280c2d9414d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -181,7 +181,7 @@ Kiro 登录需要 Kiro CLI:Unix 使用 `curl -fsSL https://cli.kiro.dev/instal ## 3. API 密钥目录 -opencodex 内置 97 个预设:80 个密钥预设、13 个 OAuth 预设、3 个本地预设,以及 1 个默认的 +opencodex 内置 98 个预设:81 个密钥预设、13 个 OAuth 预设、3 个本地预设,以及 1 个默认的 ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。 验证因提供商而异。主要条目包括: diff --git a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md index 70607e4c8a6..5cfb4c80415 100644 --- a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` 會引導你完成: -1. **選擇 provider** —— 從內建 registry 的 97 個預設中選擇一個,或選擇 `custom` 手動輸入 +1. **選擇 provider** —— 從內建 registry 的 98 個預設中選擇一個,或選擇 `custom` 手動輸入 base URL 和 adapter。 2. **API key** —— 貼上一個 key,或引用一個環境變數,例如 `${ANTHROPIC_API_KEY}`。 3. **預設模型** —— 對於 API key、本機和 custom provider,可接受預設值或輸入模型 id。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 1ec5b07d1ef..95f6eff34ab 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -249,7 +249,7 @@ database 並移除目前的 WAL、SHM 與 journal sidecar,再發布先前的 s ## 3. API 金鑰目錄 -opencodex 內建 97 個 preset:80 個 key-based、13 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward +opencodex 內建 98 個 preset:81 個 key-based、13 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward preset。儀表板的 **Add provider** picker 會開啟 key provider 的 dashboard、驗證金鑰並儲存;驗證方式 依 provider 而異。主要條目如下。 diff --git a/src/adapters/claude-cli/adapter.ts b/src/adapters/claude-cli/adapter.ts index 1d862f4506a..a75a25ba622 100644 --- a/src/adapters/claude-cli/adapter.ts +++ b/src/adapters/claude-cli/adapter.ts @@ -57,6 +57,13 @@ export function buildChildEnv(_profile: ClaudeCliProfile, _apiKey: string): Reco * in place would put a second, contradictory instruction set in front of them and would describe * tools this turn deliberately does not have. * + * That flag is therefore always present, including when the caller sends no system or developer + * prompt at all: omitting it is not "no system prompt", it is "Claude Code's preset", and the + * replacement for an absent caller prompt is the empty string. Verified against 2.1.270 by reading + * the `prompt_snapshot` attachment the CLI writes into a session transcript — `--system-prompt + * "MARKER"` snapshots exactly that text, `--system-prompt ""` snapshots the empty string, and + * omitting the 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. * @@ -78,11 +85,26 @@ export function buildArgs(_profile: ClaudeCliProfile, parsed: OcxParsedRequest, ]; const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); if (effort) args.push("--effort", effort); - const system = buildSystemPrompt(parsed); - if (system) args.push("--system-prompt", system); + args.push("--system-prompt", buildSystemPrompt(parsed) ?? ""); 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. * @@ -127,6 +149,17 @@ export function createClaudeCliAdapter(provider: OcxProviderConfig, deps: Claude }, 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; + } await runCodingAgentTurn({ profiles: CLAUDE_CLI_PROFILES, provider, diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 3b79842baf8..0fa67971456 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -1459,7 +1459,12 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ defaultModel: "claude-sonnet-5", models: [...ANTHROPIC_MODELS], modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, - modelInputModalities: { ...ANTHROPIC_MODEL_INPUT_MODALITIES }, + // Text-only for v1, not the image modality the Messages API rows publish. The CLI parses an + // image frame (verified against 2.1.270), but a headless turn has no verified contract that the + // harness hands those bytes to the model, and advertising a modality the route cannot honour + // makes a route selection pick this row for a picture it then answers blind. The adapter refuses + // direct image input for the same reason; the vision sidecar still captions images into text. + noVisionModels: [...ANTHROPIC_MODELS], reasoningEfforts: ANTHROPIC_REASONING_EFFORTS, modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index bcc051a9e32..9344a6f5732 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -84,7 +84,7 @@ Manual navigation is defined in `docs-site/astro.config.mjs`. When adding a publ sidebar and either add localized copies or intentionally accept Starlight fallback behavior. Provider preset totals are recounted from the current registry when a preset lands. The -documented split is 97 total: 80 key-based, 13 OAuth, three local, and one default +documented split is 98 total: 81 key-based, 13 OAuth, three local, and one default ChatGPT-forward preset. The English provider guide, all seven translated copies, and all eight quickstarts carry the same counts. diff --git a/tests/providers/claude-cli-adapter.test.ts b/tests/providers/claude-cli-adapter.test.ts index 1e7a407d50d..b920b346144 100644 --- a/tests/providers/claude-cli-adapter.test.ts +++ b/tests/providers/claude-cli-adapter.test.ts @@ -83,6 +83,11 @@ describe("claude-cli is an official-harness provider, not a Messages relay", () expect(entry!.defaultModel).toBe("claude-sonnet-5"); expect(entry!.models).toContain(entry!.defaultModel!); expect(entry!.modelContextWindows?.[entry!.defaultModel!]).toBeGreaterThan(0); + // 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", () => { @@ -141,9 +146,13 @@ describe("claude-cli headless arguments keep tool ownership with the client", () expect(args[args.indexOf("--system-prompt") + 1]).toBe("Be terse."); }); - test("no system prompt is passed when the request carries none", () => { + test("a request with no system prompt REPLACES the harness preset with nothing", () => { + // 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. The empty replacement is what + // the Messages API path produces for the same request. (Verified against 2.1.270 through the + // prompt_snapshot attachment the CLI writes into a session transcript.) const args = buildArgs(CLAUDE_CLI_PROFILE, parsed(), provider()); - expect(args).not.toContain("--system-prompt"); + expect(args[args.indexOf("--system-prompt") + 1]).toBe(""); }); test("maps the caller's reasoning effort onto the CLI's --effort", () => { @@ -214,6 +223,22 @@ describe("claude-cli runTurn fails closed before any spawn", () => { 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 runTurn streams a subscription turn", () => { From 209b0990e404bdacf9563af72bb94d75375ffefa Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:39:51 +0200 Subject: [PATCH 4/5] fix(provider): sign the claude-cli child in, pin its roster, and stage the prompt file Three defects the first signed-in run exposed, plus the argv hardening: - `buildChildEnv` passes `USER` through. The CLI resolves its own keychain sign-in by account name, so a scoped environment without it answers "not logged in" on a machine that is signed in (`env -i` probe: `USER` alone -> `loggedIn: true`, `LOGNAME` alone or neither -> `false`). The value is a name, not a credential; no token is added. - The row is `liveModels: false`, the same static-roster switch the CodeBuddy and Volcengine plan rows use. This route serves no model list, and a real start logged `Provider model discovery for "claude-cli" failed with HTTP 404` before falling back to the configured ids anyway. - The folded system prompt moves off argv into a per-turn `0600` file passed as `--system-prompt-file`, removed in a `finally`: argv is world-readable through process listing. A caller that sends no system or developer prompt still gets a file, empty, so the harness preset stays out of the turn. --- .../src/content/docs/guides/providers.md | 5 +- src/adapters/claude-cli/adapter.ts | 89 ++++++++++++----- src/providers/registry/entries-extended.ts | 6 ++ structure/adapters/registry.md | 4 +- tests/providers/claude-cli-adapter.test.ts | 96 +++++++++++++++++-- 5 files changed, 166 insertions(+), 34 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index d4a5ffb9cbf..a0663d011c5 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -953,7 +953,10 @@ CLI headlessly (`claude -p`, `stream-json`) once per turn: `--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`), so the turn answers the client's contract rather than the harness persona. + (`--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. diff --git a/src/adapters/claude-cli/adapter.ts b/src/adapters/claude-cli/adapter.ts index a75a25ba622..28d302f0836 100644 --- a/src/adapters/claude-cli/adapter.ts +++ b/src/adapters/claude-cli/adapter.ts @@ -1,3 +1,6 @@ +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"; @@ -35,12 +38,20 @@ export const CLAUDE_CLI_QUIET_ENV: Readonly> = { * * 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 { - return { + const env: Record = { ...baseScopedEnv(), ...CLAUDE_CLI_QUIET_ENV, }; + const user = process.env.USER; + if (user) env.USER = user; + return env; } /** @@ -52,17 +63,18 @@ export function buildChildEnv(_profile: ClaudeCliProfile, _apiKey: string): Reco * 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. * - * `--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. + * 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. * - * That flag is therefore always present, including when the caller sends no system or developer - * prompt at all: omitting it is not "no system prompt", it is "Claude Code's preset", and the - * replacement for an absent caller prompt is the empty string. Verified against 2.1.270 by reading - * the `prompt_snapshot` attachment the CLI writes into a session transcript — `--system-prompt - * "MARKER"` snapshots exactly that text, `--system-prompt ""` snapshots the empty string, and - * omitting the flag snapshots the fourteen-block harness preset. + * 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. @@ -70,7 +82,12 @@ export function buildChildEnv(_profile: ClaudeCliProfile, _apiKey: string): Reco * 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): string[] { +export function buildArgs( + _profile: ClaudeCliProfile, + parsed: OcxParsedRequest, + provider: OcxProviderConfig, + systemPromptFile?: string, +): string[] { const args: string[] = [ "-p", "--output-format", "stream-json", @@ -85,7 +102,7 @@ export function buildArgs(_profile: ClaudeCliProfile, parsed: OcxParsedRequest, ]; const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); if (effort) args.push("--effort", effort); - args.push("--system-prompt", buildSystemPrompt(parsed) ?? ""); + if (systemPromptFile) args.push("--system-prompt-file", systemPromptFile); return args; } @@ -160,16 +177,42 @@ export function createClaudeCliAdapter(provider: OcxProviderConfig, deps: Claude }); return; } - await runCodingAgentTurn({ - profiles: CLAUDE_CLI_PROFILES, - provider, - parsed, - incoming, - emit: withClaudeLoginHint(emit), - buildArgs: (profile, req, prov) => buildArgs(profile as ClaudeCliProfile, req, prov), - buildEnv: (profile, apiKey) => buildChildEnv(profile as ClaudeCliProfile, apiKey), - deps, - }); + // 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/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 0fa67971456..80806b66b00 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -1458,6 +1458,12 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ dashboardUrl: "https://docs.claude.com/en/docs/claude-code/setup", defaultModel: "claude-sonnet-5", models: [...ANTHROPIC_MODELS], + // Static roster, exactly like the CodeBuddy rows. Without this the catalog treats the row as a + // live-discovery candidate and requests a model list the CLI route never serves: a real start + // logged `Provider model discovery for "claude-cli" failed with HTTP 404` and then fell back to + // these ids anyway. `liveModels: false` makes the configured roster authoritative and skips the + // request entirely (src/codex/catalog/provider-models.ts). + liveModels: false, modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, // Text-only for v1, not the image modality the Messages API rows publish. The CLI parses an // image frame (verified against 2.1.270), but a headless turn has no verified contract that the diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 3501e82efb7..db3cdcc29a3 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -37,7 +37,9 @@ Some adapters share another adapter's routed-tool semantics while retaining inde own Claude Code sign-in, and the turn neither requires nor injects an API key. Everything that makes the turn safe to run under a proxy is argument-level, and `tests/providers/claude-cli-adapter.test.ts` pins it: `--tools ""`, `--strict-mcp-config`, `--setting-sources ""`, `--no-session-persistence`, - no permission bypass, and a child environment that carries no inherited `ANTHROPIC_*` value. + 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. 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/providers/claude-cli-adapter.test.ts b/tests/providers/claude-cli-adapter.test.ts index b920b346144..86d2c164b4f 100644 --- a/tests/providers/claude-cli-adapter.test.ts +++ b/tests/providers/claude-cli-adapter.test.ts @@ -1,14 +1,17 @@ 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"; @@ -83,6 +86,8 @@ describe("claude-cli is an official-harness provider, not a Messages relay", () 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. @@ -142,17 +147,15 @@ describe("claude-cli headless arguments keep tool ownership with the client", () test("the caller's system prompt REPLACES the harness preset", () => { const args = buildArgs(CLAUDE_CLI_PROFILE, parsed({ context: { systemPrompt: ["Be terse."], messages: [] }, - }), provider()); - expect(args[args.indexOf("--system-prompt") + 1]).toBe("Be terse."); + }), 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("a request with no system prompt REPLACES the harness preset with nothing", () => { - // 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. The empty replacement is what - // the Messages API path produces for the same request. (Verified against 2.1.270 through the - // prompt_snapshot attachment the CLI writes into a session transcript.) - const args = buildArgs(CLAUDE_CLI_PROFILE, parsed(), provider()); - expect(args[args.indexOf("--system-prompt") + 1]).toBe(""); + 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", () => { @@ -203,6 +206,35 @@ describe("claude-cli child environment carries no credential and no proxy destin 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", () => { @@ -241,6 +273,52 @@ describe("claude-cli runTurn fails closed before any spawn", () => { }); }); +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; From 01f6b77fc4825e9814b5a47c189e0d64f4f91d3a Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:51:01 +0200 Subject: [PATCH 5/5] docs(structure): name both control surfaces in the claude-cli registry note --- structure/adapters/registry.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index db3cdcc29a3..71a01735d4e 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -34,12 +34,12 @@ Some adapters share another adapter's routed-tool semantics while retaining inde 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. Everything that - makes the turn safe to run under a proxy is argument-level, and `tests/providers/claude-cli-adapter.test.ts` - pins it: `--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. + 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. 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