diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d48716b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +# Runs the repository's own tasks rather than spelling out commands. None of them needs the Claude +# CLI; type checking also catches an @ora-space/plugin-sdk version that is not published yet. + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Type check + run: deno task check + + - name: Lint + run: deno task lint + + - name: Test + run: deno task test diff --git a/AGENTS.md b/AGENTS.md index ed3b11e..252e5a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,7 +125,10 @@ The published adapter is a Node program that finds its CLI with `createRequire(...).resolve("@anthropic-ai/claude-agent-sdk-/claude")` — a `node_modules` lookup that cannot work inside a compiled single-file binary. `scripts/adapter-entry.mjs` answers that question before handing control to the -adapter unchanged, by setting `CLAUDE_CODE_EXECUTABLE` from its own ladder: +adapter unchanged, by setting `CLAUDE_CODE_EXECUTABLE` from the ladder in +`scripts/resolve-claude-cli.mjs` (a module of its own because importing the +entry point starts the adapter, and the ladder is what tests have to run; +packaging copies both beside the installed dependencies): 1. **`ORA_CLAUDE_BIN`** — pins one exact CLI, and fails if it points at nothing. 2. **the bundled CLI beside it**, found through `process.execPath`, which is the @@ -349,6 +352,98 @@ must never clear the new process's tracking or fire `onExited`. The shared `#expectedExit` flag reflects the newer generation's intent by then, which is why the identity check comes first. +## Platform model route + +On an Ora Node, `agent/start` can carry a frozen `modelContext`: one +`anthropic-messages` model, the platform proxy URL and a temporary token. The +plugin, not the host, turns that into Claude Code's routing +(`src/services/model-route.ts`), and the SDK refuses the start unless `onStart` +returns the exact `AgentModelApplied` — so ACP never begins on an unapplied +route. + +The route is applied through the adapter's own `providers/set`, sent by the +bridge right after spawn and **before** `agent/start` returns: + +- `claude-agent-acp` puts a provider route into Claude Code's programmatic + settings tier with `apiKeyHelper` cleared. That is the only reason a + repository's `.claude/settings.json` (or a user setting) cannot restore a + different `ANTHROPIC_BASE_URL` and send the token there. Setting + `ANTHROPIC_BASE_URL` in the spawn environment would not have that property. +- The token travels as the `x-api-key` header of that request, which the + platform gateway reads first on its Messages route. This plugin never puts it + in the adapter's spawn environment or a log, but the pinned adapter does not + keep it in that request either: it merges the header into the Claude Code + process environment (`ANTHROPIC_CUSTOM_HEADERS`) **and** into the `env` of the + programmatic settings, and the Claude Agent SDK passes those settings to the + CLI as a `--settings` argument. The token is therefore readable in the CLI's + command line by anyone who can list that process. It is valid for one + execution only, but it is not confined to memory and the execution's own + environment, and that is a known gap until the route stops reaching the + settings. +- The model aliases Claude Code resolves by itself (main, small/fast, subagent, + `opus`/`sonnet`/`haiku`) are pinned to the frozen model in the spawn + environment. A setting that overrides one fails closed at the gateway, which + serves only the frozen model id. `CLAUDE_CODE_MAX_OUTPUT_TOKENS` is the frozen + model's own `maxTokens`. +- A context frozen for another plugin id or another target triple, a token with + a control character in it (a header cannot carry one, and the adapter would + only fail later, retryably), or a non-positive `maxTokens` is refused with + `INVALID_PARAMS` before anything starts. +- `ClaudeClient.useRoute` keeps the route for every later start, so an Effect + `reactivate` restart reapplies it before the adapter counts as ready. + `ClaudeClient.ready`, not `running`, is what readiness and the Effect replay + read: a process waiting for `providers/set` is running but on no route yet. +- A route that fails is classified by the adapter's code alone. `invalid_params` + or `method_not_found` means this adapter refuses the configuration and fails + as `AGENT_UNUSABLE`, which Ora does not retry. Anything else, or no answer + within ten seconds, fails with `-32603`, which Ora retries: the pinned adapter + lists the gateway's models inside `providers/set`, and a listing that failed + (a 503, a timeout) answers as an internal error that the next start can pass. +- Only the pair this package ships runs on a route. `spawnBundledClaude` asks + the host for the bundled adapter alone — no `ORA_CLAUDE_ACP_BIN`, no PATH — + and fails as `AGENT_UNUSABLE` when the package cannot run it; + `ORA_CLAUDE_BUNDLED_CLI_ONLY=1` in the route environment makes + `scripts/resolve-claude-cli.mjs` refuse `ORA_CLAUDE_BIN` and a local install + the same way. + +This is the second narrow exception to "ACP payloads are never parsed": the +bridge writes one request of its own (`id` prefixed `ora-claude-model-route-`) +and consumes its answer by id before forwarding anything, so Ora never sees +either frame. A different `claude-agent-acp` pin must be checked for the same +`providers/set` behaviour before it ships with a declaration. + +Applying the route once is not enough: the same ACP connection could undo it. On +a platform route `services/model-guard.ts` reads Ora's frames before they reach +the adapter and answers these itself, so the adapter never sees them: + +- `authenticate`, `logout`, `auth/*`, `_auth/*`, `providers/*` and + `_providers/*` — `logout` and `providers/disable` clear the route and send + Claude Code back to its own login, the others install a different upstream. +- `session/set_model` and the `model` config option for anything but the frozen + model. Re-selecting the frozen model is answered as a no-op. +- `_meta.claudeCode.options` on session setup loses `model`, `fallbackModel`, + `env`, `settings`, `extraArgs` and the executable overrides: the adapter + spreads them over its own environment, where they would outrank the pinned + aliases. + +The adapter's own model selector is narrowed the same way: every `configOptions` +model entry (and a `models` state, should a pin send one) in a result or a +`config_option_update` offers only the frozen model, and `agent/list_models` +answers that model without starting a discovery probe, which would run on the +host's own credentials and routing. + +Nothing the adapter prints on a route is trusted. Its stderr is not logged at +all, only how many lines it wrote, and a stdout line that is not a frame is +logged by length, not content: the adapter logs `[providers/set] ... baseUrl=`, +and Claude Code echoes a failed request with the upstream's response body, its +host and path, and paths of the machine it runs on. Scrubbing known secrets out +of that text would still keep the rest. Every adapter error response keeps its +`code` but loses its `message` and `data`. ACP frames are still forwarded, with +the token, the gateway base URL, its host and its hostname replaced with +`[redacted]`; an agent message can quote an upstream error in its own text, and +nothing distinguishes that from the model's answer, so such text is scrubbed but +not removed. On a local host all of this is logged verbatim, as before. + ## Model discovery `claude-agent-acp` is a pure ACP server: it takes no subcommand, and its @@ -386,9 +481,9 @@ a start/stop cycle around a picker opening makes the next open pay full price. Ora's binary envelope and the adapter's NDJSON and otherwise passed through verbatim — this is also what lets Claude Code's ACP extensions (session modes, steering, its `_meta` capabilities) reach Ora without this plugin knowing they - exist. `handlers/effects.ts` is the one exception, and a deliberately narrow - one: it reads `method` and `id` off the envelope to track turns, and never - looks at `params`. + exist. `handlers/effects.ts` is one exception, and a deliberately narrow one: + it reads `method` and `id` off the envelope to track turns, and never looks at + `params`; the platform model route above is the other. - **Throw the right error code.** `AGENT_NOT_INSTALLED` (`-32001`) is retried quietly by Ora as expected local configuration; `AGENT_UNUSABLE` (`-32002`) is reported once and not retried; `-32000` is how an Effect Consumer says "not @@ -408,11 +503,12 @@ Targets are how a `plugin.log` is read: `plugin` (activation, start/stop, deactivation), `host-call` (every `agent/*` method with duration and outcome), `lifecycle`, `acp` (frame envelopes only — `method` and `id`, never `params`), `claude-client` (spawn, pid, exit, pipe failures), `claude-agent-acp` (the live -adapter's own stderr, one record per line at `info`), `effects` (barrier, drain, -restart, readiness), `models` and `acp-probe` (discovery; the probe adapter's -stderr is `claude-agent-acp.probe` at `debug`), and `command` (which adapter was -resolved and how). ACP payloads are never logged: a `session/prompt` carries the -user's text, and the log has no business keeping it. +adapter's own stderr, one record per line at `info`; nothing on a platform +route, where `claude-client` records only a line count), `effects` (barrier, +drain, restart, readiness), `models` and `acp-probe` (discovery; the probe +adapter's stderr is `claude-agent-acp.probe` at `debug`), and `command` (which +adapter was resolved and how). ACP payloads are never logged: a `session/prompt` +carries the user's text, and the log has no business keeping it. ## Manifest @@ -441,10 +537,13 @@ Ora parses and validates that table today but does not yet enforce it. `HostProcesses` that scripts an ACP peer, and effect coordination against a `ClaudeClient` with a scripted `spawn`. `deno task simulate` needs a real `claude-agent-acp` on PATH (`npm i -g @agentclientprotocol/claude-agent-acp`). +- CI (`.github/workflows/ci.yml`) runs `check`, `lint` and `test` on every pull + request and push to `main`; none of them needs a CLI. - One test reads `src/main.ts` as text to assert the effect wiring is present. That is deliberate: the entrypoint calls `runAgentPlugin` at module scope, so importing it would start serving the host, and the wiring it guards is exactly - the kind whose absence is invisible at runtime. + the kind whose absence is invisible at runtime. Anything else `main.ts` would + decide belongs in a handler a test can call, as `agent/list_models` does. - Bump `orax.toml` `version` before handing someone a `.orax` to import. `install_local` refuses a version that is already installed and never retires older ones, so reusing a number silently leaves the old code running. Keep diff --git a/deno.json b/deno.json index 819fccc..8d22e43 100644 --- a/deno.json +++ b/deno.json @@ -4,7 +4,7 @@ "exports": "./src/main.ts", "minimumDependencyAge": 0, "imports": { - "@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.10.0", + "@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.11.0", "@std/assert": "jsr:@std/assert@^1.0.0", "@std/cli": "jsr:@std/cli@^1.0.32", "@std/crypto": "jsr:@std/crypto@^1.0.5", @@ -14,10 +14,10 @@ "@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8.61" }, "tasks": { - "check": "deno check src/main.ts tests/main_test.ts tests/host-simulator.ts scripts/package.ts scripts/sync-upstream.ts", + "check": "deno check src/main.ts tests/main_test.ts tests/model_route_test.ts tests/host-simulator.ts scripts/package.ts scripts/sync-upstream.ts", "lint": "deno lint src scripts tests bundle.config.ts", "format": "deno fmt src scripts tests bundle.config.ts deno.json package.json README.md AGENTS.md", - "test": "deno test --allow-read --allow-env --allow-write tests/main_test.ts", + "test": "deno test --allow-read --allow-env --allow-write tests/main_test.ts tests/model_route_test.ts", "simulate": "deno run --allow-run --allow-read --allow-env --allow-net tests/host-simulator.ts", "dev": "deno run --no-prompt --allow-run --allow-read --allow-env --allow-net src/main.ts", "build": "deno bundle src/main.ts -o dist/main.js", diff --git a/deno.lock b/deno.lock index f611f47..b9f4a62 100644 --- a/deno.lock +++ b/deno.lock @@ -1,7 +1,7 @@ { "version": "5", "specifiers": { - "jsr:@ora-space/plugin-sdk@0.10.0": "0.10.0", + "jsr:@ora-space/plugin-sdk@0.11.0": "0.11.0", "jsr:@std/assert@1": "1.0.19", "jsr:@std/cli@^1.0.32": "1.0.32", "jsr:@std/crypto@^1.0.5": "1.1.0", @@ -15,8 +15,8 @@ "jsr:@zip-js/zip-js@^2.8.61": "2.9.0" }, "jsr": { - "@ora-space/plugin-sdk@0.10.0": { - "integrity": "f678d20ea69f3da2a692faa174743818806c95b998ae23e5b11284c7a6d37c94" + "@ora-space/plugin-sdk@0.11.0": { + "integrity": "9f48ecd28061d152f9018720499804bdaa5dd2c6be15f297e3b580940764f75b" }, "@std/assert@1.0.19": { "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", @@ -64,7 +64,7 @@ }, "workspace": { "dependencies": [ - "jsr:@ora-space/plugin-sdk@0.10.0", + "jsr:@ora-space/plugin-sdk@0.11.0", "jsr:@std/assert@1", "jsr:@std/cli@^1.0.32", "jsr:@std/crypto@^1.0.5", diff --git a/scripts/adapter-entry.mjs b/scripts/adapter-entry.mjs index 23c0b43..d19abd9 100644 --- a/scripts/adapter-entry.mjs +++ b/scripts/adapter-entry.mjs @@ -11,121 +11,8 @@ * too: stdout is the ACP channel and nothing but ACP frames may ever be written to it, and every * diagnostic goes to stderr. */ -import { existsSync, statSync } from "node:fs"; -import { delimiter, dirname, join } from "node:path"; -import { homedir } from "node:os"; import { ADAPTER_BINARY_NAME, CLAUDE_BINARY_NAME } from "./paths.generated.mjs"; - -/** Pins one exact Claude Code CLI, outranking both the bundled copy and any local install. */ -const BIN_ENV_VAR = "ORA_CLAUDE_BIN"; - -/** - * Resolves the Claude Code CLI this adapter should drive, preferring the one shipped beside it. - * - * The bundled CLI comes first because it is the build the adapter was published against: the - * adapter's own `package.json` pins an exact Claude Agent SDK version, and that SDK names one - * exact CLI build, so the pair in this package is the pair upstream tested. A local install is a - * fallback for the cases where the bundled one cannot run at all, and it is deliberately not - * preferred over the bundled one even when it is newer — the bridge between adapter and CLI is an - * internal interface, and an unpaired combination fails in ways that only reproduce on the - * machine that has it. - */ -function resolveClaudeCli() { - const pinned = process.env[BIN_ENV_VAR]?.trim(); - if (pinned) { - if (!isExecutableFile(pinned)) { - throw new Error( - `${BIN_ENV_VAR} points at ${pinned}, which does not exist`, - ); - } - return { path: pinned, source: "pinned" }; - } - - // `process.execPath` is this compiled binary's own absolute path, whatever the working - // directory is, which is the only way to find the package this binary was installed into: a - // plugin is told no host path, and the package-relative paths the host understands are not - // resolvable from inside a process it spawned. - const bundled = join(dirname(process.execPath), CLAUDE_BINARY_NAME); - if (isExecutableFile(bundled) && !isUnrunnableHere(bundled)) { - return { path: bundled, source: "bundled" }; - } - - const local = findLocalClaude(); - if (local) { - return { path: local, source: "local" }; - } - - throw new Error( - `no Claude Code CLI to run: this package ships none at ${bundled}, none was found on PATH, ` + - `and ${BIN_ENV_VAR} is not set`, - ); -} - -/** - * Reports whether the bundled CLI is one this machine cannot execute despite it being present. - * - * Only Linux can get here. Packages are built per target triple and Ora refuses to install one - * built for another, so on Windows and macOS a bundled binary that exists also runs. Linux is the - * exception because libc is not part of the triple: the `-gnu` build this package ships is a - * dynamically linked binary whose loader (`/lib/ld-musl-*` vs `/lib64/ld-linux-*`) does not exist - * on a musl system, so it fails at exec time with an error that says nothing about libc. Checking - * up front turns that into a fallback rather than a crash. - */ -function isUnrunnableHere(bundled) { - if (process.platform !== "linux") return false; - const loader = process.arch === "arm64" - ? "/lib/ld-musl-aarch64.so.1" - : "/lib/ld-musl-x86_64.so.1"; - if (!existsSync(loader)) return false; - warn( - `the bundled Claude Code CLI at ${bundled} is a glibc build and this system uses musl; ` + - `looking for a local install instead`, - ); - return true; -} - -/** - * Finds a Claude Code CLI the user installed themselves. - * - * Every spelling a real installer produces is tried, not just the bare name: the native installer - * writes `claude`/`claude.exe`, while npm and bun leave a `.cmd`/`.bat` shim on Windows. Omitting - * one is indistinguishable from "not installed" and fails far from its cause. `.exe` is tried - * first, following Windows' own `PATHEXT` precedence, so what gets spawned is the CLI itself - * rather than a shell wrapper around it. - */ -function findLocalClaude() { - const names = process.platform === "win32" - ? ["claude.exe", "claude.cmd", "claude.bat", "claude"] - : ["claude"]; - // The native installer's location is searched too: it is not always on the PATH of a process - // spawned by a desktop application, which inherits a login environment rather than a shell one. - const directories = [ - ...(process.env.PATH ?? "").split(delimiter).filter(Boolean), - join(homedir(), ".local", "bin"), - ]; - for (const directory of directories) { - for (const name of names) { - const candidate = join(directory, name); - if (isExecutableFile(candidate)) { - warn( - `using the Claude Code CLI found at ${candidate}; it is not the build this adapter ` + - `was published with, so report any protocol failure with both versions`, - ); - return candidate; - } - } - } - return undefined; -} - -/** Reports whether a path names a regular file, the only thing worth trying to spawn. */ -function isExecutableFile(path) { - try { - return statSync(path).isFile(); - } catch { - return false; - } -} +import { resolveClaudeCli } from "./resolve-claude-cli.mjs"; /** Writes one diagnostic to stderr; stdout carries ACP frames and nothing else. */ function warn(message) { @@ -136,6 +23,11 @@ function warn(message) { // the `node_modules` lookup that a compiled binary cannot perform. It is set unconditionally: // `ORA_CLAUDE_BIN` is this package's own knob, and honoring a stray `CLAUDE_CODE_EXECUTABLE` from // the surrounding environment would silently run a different CLI than the package ships. -process.env.CLAUDE_CODE_EXECUTABLE = resolveClaudeCli().path; +process.env.CLAUDE_CODE_EXECUTABLE = resolveClaudeCli({ + env: process.env, + execPath: process.execPath, + claudeBinaryName: CLAUDE_BINARY_NAME, + warn, +}).path; await import("@agentclientprotocol/claude-agent-acp/dist/index.js"); diff --git a/scripts/package.ts b/scripts/package.ts index cbc49ca..efe1a1e 100644 --- a/scripts/package.ts +++ b/scripts/package.ts @@ -224,6 +224,11 @@ async function compileAdapter(plan: TargetPlan, staged: string): Promise { join("scripts", "adapter-entry.mjs"), join(ADAPTER_DIR, "entry.mjs"), ); + // The entry point imports the CLI resolver by this relative name. + await Deno.copyFile( + join("scripts", "resolve-claude-cli.mjs"), + join(ADAPTER_DIR, "resolve-claude-cli.mjs"), + ); await Deno.mkdir(dirname(staged), { recursive: true }); await run( "bun", diff --git a/scripts/resolve-claude-cli.mjs b/scripts/resolve-claude-cli.mjs new file mode 100644 index 0000000..bb01714 --- /dev/null +++ b/scripts/resolve-claude-cli.mjs @@ -0,0 +1,142 @@ +/** + * Resolves the Claude Code CLI the compiled `claude-agent-acp` drives; see `adapter-entry.mjs`. + * + * Kept apart from the entry point because that module starts the adapter the moment it is + * imported, and the order of this ladder is exactly what a test has to be able to run: on a + * platform route nothing but the bundled CLI may ever be chosen. + */ +import { existsSync, statSync } from "node:fs"; +import { delimiter, dirname, join } from "node:path"; +import { homedir } from "node:os"; + +/** Pins one exact Claude Code CLI, outranking both the bundled copy and any local install. */ +export const BIN_ENV_VAR = "ORA_CLAUDE_BIN"; + +/** + * Set by the plugin for a platform-model execution: only the bundled CLI may run, never a pin or + * a local install. The route the plugin applied was tested against this exact pair, and another + * CLI build could read provider settings differently. + */ +export const BUNDLED_ONLY_ENV_VAR = "ORA_CLAUDE_BUNDLED_CLI_ONLY"; + +/** + * Resolves the Claude Code CLI this adapter should drive, preferring the one shipped beside it. + * + * The bundled CLI comes first because it is the build the adapter was published against: the + * adapter's own `package.json` pins an exact Claude Agent SDK version, and that SDK names one + * exact CLI build, so the pair in this package is the pair upstream tested. A local install is a + * fallback for the cases where the bundled one cannot run at all, and it is deliberately not + * preferred over the bundled one even when it is newer — the bridge between adapter and CLI is an + * internal interface, and an unpaired combination fails in ways that only reproduce on the + * machine that has it. + */ +export function resolveClaudeCli({ + env, + execPath, + claudeBinaryName, + warn, +}) { + // `execPath` is the compiled binary's own absolute path, whatever the working directory is, + // which is the only way to find the package this binary was installed into: a plugin is told no + // host path, and the package-relative paths the host understands are not resolvable from inside + // a process it spawned. + const bundled = join(dirname(execPath), claudeBinaryName); + if (env[BUNDLED_ONLY_ENV_VAR] === "1") { + if (!isExecutableFile(bundled) || isUnrunnableHere(bundled, warn)) { + throw new Error( + `${BUNDLED_ONLY_ENV_VAR} requires the bundled Claude Code CLI at ${bundled}, which cannot run here`, + ); + } + return { path: bundled, source: "bundled" }; + } + + const pinned = env[BIN_ENV_VAR]?.trim(); + if (pinned) { + if (!isExecutableFile(pinned)) { + throw new Error( + `${BIN_ENV_VAR} points at ${pinned}, which does not exist`, + ); + } + return { path: pinned, source: "pinned" }; + } + + if (isExecutableFile(bundled) && !isUnrunnableHere(bundled, warn)) { + return { path: bundled, source: "bundled" }; + } + + const local = findLocalClaude(env, warn); + if (local) { + return { path: local, source: "local" }; + } + + throw new Error( + `no Claude Code CLI to run: this package ships none at ${bundled}, none was found on PATH, ` + + `and ${BIN_ENV_VAR} is not set`, + ); +} + +/** + * Reports whether the bundled CLI is one this machine cannot execute despite it being present. + * + * Only Linux can get here. Packages are built per target triple and Ora refuses to install one + * built for another, so on Windows and macOS a bundled binary that exists also runs. Linux is the + * exception because libc is not part of the triple: the `-gnu` build this package ships is a + * dynamically linked binary whose loader (`/lib/ld-musl-*` vs `/lib64/ld-linux-*`) does not exist + * on a musl system, so it fails at exec time with an error that says nothing about libc. Checking + * up front turns that into a fallback rather than a crash. + */ +function isUnrunnableHere(bundled, warn) { + if (process.platform !== "linux") return false; + const loader = process.arch === "arm64" + ? "/lib/ld-musl-aarch64.so.1" + : "/lib/ld-musl-x86_64.so.1"; + if (!existsSync(loader)) return false; + warn( + `the bundled Claude Code CLI at ${bundled} is a glibc build and this system uses musl; ` + + `looking for a local install instead`, + ); + return true; +} + +/** + * Finds a Claude Code CLI the user installed themselves. + * + * Every spelling a real installer produces is tried, not just the bare name: the native installer + * writes `claude`/`claude.exe`, while npm and bun leave a `.cmd`/`.bat` shim on Windows. Omitting + * one is indistinguishable from "not installed" and fails far from its cause. `.exe` is tried + * first, following Windows' own `PATHEXT` precedence, so what gets spawned is the CLI itself + * rather than a shell wrapper around it. + */ +function findLocalClaude(env, warn) { + const names = process.platform === "win32" + ? ["claude.exe", "claude.cmd", "claude.bat", "claude"] + : ["claude"]; + // The native installer's location is searched too: it is not always on the PATH of a process + // spawned by a desktop application, which inherits a login environment rather than a shell one. + const directories = [ + ...(env.PATH ?? "").split(delimiter).filter(Boolean), + join(homedir(), ".local", "bin"), + ]; + for (const directory of directories) { + for (const name of names) { + const candidate = join(directory, name); + if (isExecutableFile(candidate)) { + warn( + `using the Claude Code CLI found at ${candidate}; it is not the build this adapter ` + + `was published with, so report any protocol failure with both versions`, + ); + return candidate; + } + } + } + return undefined; +} + +/** Reports whether a path names a regular file, the only thing worth trying to spawn. */ +function isExecutableFile(path) { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} diff --git a/src/base/agent-plugin.ts b/src/base/agent-plugin.ts index 638b09c..b6d06eb 100644 --- a/src/base/agent-plugin.ts +++ b/src/base/agent-plugin.ts @@ -3,6 +3,7 @@ import { AGENT_METHODS, type AgentEffectDefinition, type AgentModel, + type AgentModelApplied, type AgentStartContext, createHostProcesses, defineAgent, @@ -88,12 +89,14 @@ export abstract class AgentPlugin { * * `send` stays valid for the rest of the process and is how every agent-originated ACP frame * reaches the host. Throw `PluginMethodError(AGENT_NOT_INSTALLED, ...)` when the underlying CLI - * is missing, which the host treats as expected local configuration instead of a fault. + * is missing, which the host treats as expected local configuration instead of a fault. A start + * carrying `modelContext` must return the exact `AgentModelApplied` once that configuration is in + * effect; the SDK fails the start otherwise, so ACP never begins on an unapplied route. */ abstract onStart( context: AgentStartContext, send: AcpSender, - ): void | Promise; + ): void | AgentModelApplied | Promise; /** [agent/acp] Receives one ACP frame the host is forwarding to the agent. */ abstract onAcp(frame: JsonValue): void | Promise; @@ -145,7 +148,8 @@ export async function runAgentPlugin( () => invoke(routes, AGENT_METHOD_ROUTES.onStart, startContext, send) as | void - | Promise, + | AgentModelApplied + | Promise, ), stop: () => traced( diff --git a/src/handlers/effects.ts b/src/handlers/effects.ts index 8d6ebfd..f1c4a01 100644 --- a/src/handlers/effects.ts +++ b/src/handlers/effects.ts @@ -212,16 +212,25 @@ export class SkillEffectCoordinator { method: "effect/reactivate", context: { targetId: context.targetId, cwd, held: this.#held.length }, }); - if (cwd !== undefined) { - invalidateClaudeModels(cwd); - await this.#client.start(cwd); - } else { - log.warn("effect/reactivate has no workspace to restart the adapter in", { - method: "effect/reactivate", - context: { targetId: context.targetId }, - }); + try { + if (cwd !== undefined) { + invalidateClaudeModels(cwd); + await this.#client.start(cwd); + } else { + log.warn( + "effect/reactivate has no workspace to restart the adapter in", + { + method: "effect/reactivate", + context: { targetId: context.targetId }, + }, + ); + } + } finally { + // A restart that failed (a route the adapter refused or never answered) must not keep the + // barrier up: nothing would ever lower it, and every prompt held behind it would wait for + // the life of the process. `#release` drops what no adapter is running to receive. + await this.#release(); } - await this.#release(); log.info("Skill barrier released", { method: "effect/reactivate", context: { targetId: context.targetId }, @@ -239,7 +248,7 @@ export class SkillEffectCoordinator { * returned. */ #verifyReady(context: AgentEffectReadinessContext): JsonValue { - if (!this.#client.running) { + if (!this.#client.ready) { log.info("effect/verify_ready: not ready, adapter not running", { method: "effect/verify_ready", context: { targetId: context.targetId }, @@ -283,6 +292,15 @@ export class SkillEffectCoordinator { * pass instead of being stranded behind a barrier that is about to come down. */ async #release(): Promise { + if (!this.#client.ready && this.#held !== undefined) { + // The same rule as a frame arriving while the adapter is down (`forwardAcpFrame`): the start + // that failed already told Ora this agent is gone, and nothing here can serve the turns. + log.warn("dropping held turns: the Claude agent is not running", { + context: { dropped: this.#held.length }, + }); + this.#held = undefined; + return; + } let replayed = 0; while (this.#held !== undefined && this.#held.length > 0) { const frame = this.#held.shift(); diff --git a/src/handlers/lifecycle.ts b/src/handlers/lifecycle.ts index 6dd071a..6f38f7a 100644 --- a/src/handlers/lifecycle.ts +++ b/src/handlers/lifecycle.ts @@ -1,7 +1,11 @@ -import type { AgentStartContext } from "@ora-space/plugin-sdk"; +import type { + AgentModelApplied, + AgentStartContext, +} from "@ora-space/plugin-sdk"; import { INVALID_PARAMS, PluginMethodError } from "@ora-space/plugin-sdk"; import type { ClaudeClient } from "../services/claude-client.ts"; import { logger } from "../services/log.ts"; +import { modelRoute } from "../services/model-route.ts"; const log = logger("lifecycle"); @@ -11,11 +15,15 @@ const log = logger("lifecycle"); * Ora calls this once per connection, before any session exists, so the adapter is already * accepting ACP frames when the host runs its own `initialize` handshake. Per-session directories * travel later in ACP `session/new`, not here. + * + * A platform-model start carries a frozen model context. It is confirmed only after the adapter + * accepted the route built from it, which is what the host waits for before it lets ACP begin; a + * start without one keeps the adapter's own routing, exactly as on a local desktop. */ export async function startClaude( client: ClaudeClient, context: AgentStartContext, -): Promise { +): Promise { if (context.cwd.trim() === "") { log.warn("agent/start refused: empty cwd"); throw new PluginMethodError( @@ -23,10 +31,15 @@ export async function startClaude( "agent/start requires a non-empty cwd", ); } + const route = context.modelContext === undefined + ? undefined + : modelRoute(context.modelContext); + client.useRoute(route); await client.start(context.cwd); log.info("Claude ACP adapter started for agent/start", { - context: { cwd: context.cwd }, + context: { cwd: context.cwd, platformRoute: route !== undefined }, }); + return route?.applied; } /** diff --git a/src/handlers/models.ts b/src/handlers/models.ts index ddeebe5..05316e3 100644 --- a/src/handlers/models.ts +++ b/src/handlers/models.ts @@ -3,8 +3,13 @@ import type { HostProcesses, JsonValue, } from "@ora-space/plugin-sdk"; -import { INVALID_PARAMS, PluginMethodError } from "@ora-space/plugin-sdk"; +import { + AGENT_METHODS, + INVALID_PARAMS, + PluginMethodError, +} from "@ora-space/plugin-sdk"; import { AcpProbe } from "../services/acp-probe.ts"; +import type { ClaudeClient } from "../services/claude-client.ts"; import { spawnClaude } from "../services/command.ts"; import { logger } from "../services/log.ts"; @@ -41,6 +46,32 @@ export function invalidateAllClaudeModels(): void { catalogs.clear(); } +/** + * Serves `agent/list_models`: the frozen model on a platform route, discovery everywhere else. + * + * A platform route offers exactly its frozen model. Probing would start an adapter on the host's + * own credentials and routing — a run nobody admitted — and list what that finds (ADR D5). + * Discovery is answered for the Workspace the host named, not for the directory `agent/start` was + * given: that one is neutral, and a user can open pickers for a project this connection never ran + * in. + */ +export function listAgentModels( + client: Pick, + processes: HostProcesses | undefined, + cwd: string, +): Promise { + const frozen = client.frozenModel; + if (frozen !== undefined) { + return Promise.resolve([ + { id: frozen.id, displayName: frozen.name, default: true }, + ]); + } + if (processes === undefined) { + throw new Error(`${AGENT_METHODS.listModels} was called before activation`); + } + return listClaudeModels(processes, cwd); +} + /** * Serves `agent/list_models` for one workspace by asking Claude Code's adapter itself. * diff --git a/src/main.ts b/src/main.ts index 36af659..026659b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,11 @@ import type { AcpSender, AgentModel, + AgentModelApplied, AgentStartContext, HostProcesses, JsonValue, } from "@ora-space/plugin-sdk"; -import { AGENT_METHODS } from "@ora-space/plugin-sdk"; import { type AgentListModelsContext, AgentPlugin, @@ -18,13 +18,11 @@ import { startClaude, stopClaude } from "./handlers/lifecycle.ts"; import { invalidateAllClaudeModels, invalidateClaudeModels, - listClaudeModels, + listAgentModels, } from "./handlers/models.ts"; import { ClaudeClient } from "./services/claude-client.ts"; import { logger } from "./services/log.ts"; - -/** Must match `ora.id` in package.json, which is also this agent's identity inside Ora. */ -const PLUGIN_ID = "ora-space.claude"; +import { PLUGIN_ID } from "./services/model-route.ts"; /** * Publishes Claude Code as an Ora agent. @@ -95,7 +93,7 @@ class ClaudeAgentPlugin extends AgentPlugin { override onStart = async ( context: AgentStartContext, send: AcpSender, - ): Promise => { + ): Promise => { this.#log.info("agent start requested", { context: { cwd: context.cwd, previousCwd: this.#cwd }, }); @@ -105,7 +103,7 @@ class ClaudeAgentPlugin extends AgentPlugin { this.#send = send; this.#cwd = context.cwd; invalidateClaudeModels(context.cwd); - await startClaude(this.#client, context); + return await startClaude(this.#client, context); }; override onStop = async (): Promise => { @@ -118,16 +116,8 @@ class ClaudeAgentPlugin extends AgentPlugin { override onListModels = ( context: AgentListModelsContext, - ): Promise => { - if (this.#processes === undefined) { - throw new Error( - `${AGENT_METHODS.listModels} was called before activation`, - ); - } - // Discovery is answered for the Workspace the host named, not for `#cwd`: `agent/start` gets a - // neutral directory, and a user can open pickers for a project this connection never ran in. - return listClaudeModels(this.#processes, context.cwd); - }; + ): Promise => + listAgentModels(this.#client, this.#processes, context.cwd); override onAcp = (frame: JsonValue): Promise | void => forwardAcpFrame(this.#client, this.#effects, frame); diff --git a/src/services/claude-client.ts b/src/services/claude-client.ts index ed44fe5..3cc8521 100644 --- a/src/services/claude-client.ts +++ b/src/services/claude-client.ts @@ -1,12 +1,39 @@ -import type { HostProcesses, JsonValue } from "@ora-space/plugin-sdk"; -import { spawnClaude } from "./command.ts"; +import { + AGENT_UNUSABLE, + type HostProcesses, + type JsonValue, + PluginMethodError, +} from "@ora-space/plugin-sdk"; +import { spawnBundledClaude, spawnClaude } from "./command.ts"; import { logger } from "./log.ts"; +import { type FrozenModel, ModelGuard } from "./model-guard.ts"; +import type { ModelRoute } from "./model-route.ts"; import { decodeLines, encodeLine } from "./ndjson.ts"; const log = logger("claude-client"); /** The adapter's own stderr, republished line by line under its own target. */ const adapterLog = logger("claude-agent-acp"); +/** How long the adapter may take to accept a platform route before `agent/start` fails. */ +const ROUTE_TIMEOUT_MS = 10_000; + +/** + * JSON-RPC codes with which the adapter refuses the route itself: the request was malformed for + * it, or this adapter has no `providers/set` at all. Either is a property of this package and the + * configuration it was handed, so retrying the same start cannot succeed. + */ +const ROUTE_REFUSED_CODES = new Set([-32601, -32602]); + +/** + * What a route that could not be prepared is reported as: the adapter answers a gateway listing + * that failed (a 503, a timeout) with an internal error, and that or a silent adapter can pass on + * the next attempt. Ora retries any start failure that is neither of the two agent codes. + */ +const ROUTE_NOT_PREPARED = -32603; + +/** Prefix of the request ids this bridge sends on its own; Ora's ids are numbers or its own text. */ +const ROUTE_REQUEST_PREFIX = "ora-claude-model-route-"; + /** The subset of a spawned child process this bridge depends on, so tests can substitute one. */ export interface SpawnedProcess { stdin: WritableStream; @@ -24,7 +51,7 @@ export interface ClaudeClientOptions { * Which program a spawn resolves to is `command.ts`'s decision and is deliberately not a * parameter here: this class owns the adapter's lifetime, not the question of where it lives. */ - spawn?: (cwd: string) => SpawnedProcess; + spawn?: (cwd: string, env: Record) => SpawnedProcess; /** Receives every ACP frame emitted by the adapter, in output order. */ onAcpFrame?: (frame: JsonValue) => void; /** Invoked after the adapter exits on its own, never after an explicit stop. */ @@ -43,28 +70,57 @@ interface RunningProcess { * directory from the spawn cwd, and receives every per-session directory through ACP `session/new`. * The plugin owns its whole lifetime — spawn on `agent/start`, kill on `agent/stop`, respawn on an * Effect `reactivate` — so Ora never sees the child's stdio, which is what lets Claude Code use ACP - * methods this host has never heard of. Nothing here parses ACP; frames are re-framed between Ora's - * binary envelope and the adapter's NDJSON and otherwise passed through verbatim. + * methods this host has never heard of. Without a platform route nothing here parses ACP; frames + * are re-framed between Ora's binary envelope and the adapter's NDJSON and otherwise passed + * through verbatim. With one, {@link ModelGuard} reads the frames that could change the route or + * the model. */ export class ClaudeClient { - readonly #spawn: (cwd: string) => SpawnedProcess | Promise; + readonly #spawn: ( + cwd: string, + env: Record, + ) => SpawnedProcess | Promise; readonly #onAcpFrame: (frame: JsonValue) => void; readonly #onExited: () => void; /** Supplied by `attachProcesses` once the plugin's `Plugin` instance exists; see `main.ts`. */ #processes: HostProcesses | undefined; #running: RunningProcess | undefined; + /** Whether the running generation's route, if any, was accepted; reset by every start. */ + #routeApplied = false; #expectedExit = false; + /** The platform route every generation of the adapter starts with; absent on a local host. */ + #route: ModelRoute | undefined; + /** Holds every ACP frame of a platform-model execution to its route and model. */ + #guard: ModelGuard | undefined; + /** This bridge's own outstanding requests, answered from stdout and never forwarded to Ora. */ + readonly #pending = new Map void>(); + #nextRequest = 0; constructor(options: ClaudeClientOptions = {}) { - this.#spawn = options.spawn ?? ((cwd) => this.#spawnViaHost(cwd)); + this.#spawn = options.spawn ?? + ((cwd, env) => this.#spawnViaHost(cwd, env)); this.#onAcpFrame = options.onAcpFrame ?? (() => {}); this.#onExited = options.onExited ?? (() => {}); } + /** The only model a platform route may use; `undefined` keeps the adapter's own discovery. */ + get frozenModel(): FrozenModel | undefined { + return this.#guard?.model; + } + get running(): boolean { return this.#running !== undefined; } + /** + * Whether the adapter is up and on the route it must use: a process that is still waiting for + * `providers/set` is running, but it would resolve a session on whatever routing it found. + */ + get ready(): boolean { + return this.#running !== undefined && + (this.#route === undefined || this.#routeApplied); + } + /** * Supplies the host-managed process client this plugin spawns `claude-agent-acp` through. * @@ -76,16 +132,32 @@ export class ClaudeClient { this.#processes = processes; } + /** + * Fixes the platform route for every later start, including an Effect restart. + * + * One plugin process serves one execution, so the route is set once per `agent/start` and never + * switched between users while sessions run; `undefined` returns to the adapter's own routing. + */ + useRoute(route: ModelRoute | undefined): void { + this.#route = route; + this.#guard = route === undefined + ? undefined + : new ModelGuard(route.model, routeSecrets(route)); + } + /** * Spawns the ACP adapter in the given working directory and starts bridging its stdio. * * Any previous child is stopped first so a restart cannot leave two adapters writing frames into - * the same host connection. + * the same host connection. With a platform route, the adapter only counts as started once it + * accepted that route: a session created before `providers/set` would run on whatever routing + * Claude Code found by itself. */ async start(cwd: string): Promise { const restarting = this.#running !== undefined; await this.stop(); this.#expectedExit = false; + this.#routeApplied = false; log.info(restarting ? "restarting the adapter" : "starting the adapter", { context: { cwd }, @@ -94,14 +166,86 @@ export class ClaudeClient { // have stays retryable, while a pin naming a missing executable says so by name. let process: SpawnedProcess; try { - process = await this.#spawn(cwd); + process = await this.#spawn(cwd, { ...this.#route?.environment }); } catch (error) { log.warn("the adapter could not be spawned", { context: { cwd }, error }); throw error; } this.#running = { process, stdinWriter: process.stdin.getWriter() }; - this.#attach(process); - log.info("adapter running", { context: { cwd, pid: process.pid } }); + const route = this.#route; + this.#attach(process, route === undefined ? "verbatim" : "sensitive"); + if (route !== undefined) { + try { + await this.#applyRoute(route); + } catch (error) { + await this.stop(); + throw error; + } + this.#routeApplied = true; + } + log.info("adapter running", { + context: { cwd, pid: process.pid, platformRoute: route !== undefined }, + }); + } + + /** + * Hands the adapter its platform route through `providers/set` and waits for it to be accepted. + * + * The request goes straight into the adapter's stdin with an id of this bridge's own, and its + * answer is consumed by `#pumpStdout` before anything is forwarded, so Ora never sees either + * frame. Neither the params nor an error payload is logged: the header carries the token. + * + * A refusal is classified by its code alone (ADR D2 keeps an incompatible configuration apart + * from a failed preparation): the adapter refusing the request is final, while a gateway listing + * that failed or an adapter that never answered may pass on the next start. + */ + async #applyRoute(route: ModelRoute): Promise { + const id = `${ROUTE_REQUEST_PREFIX}${++this.#nextRequest}`; + const answered = new Promise((resolve) => { + this.#pending.set(id, resolve); + }); + let timer: number | undefined; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), ROUTE_TIMEOUT_MS); + }); + try { + await this.#write({ + jsonrpc: "2.0", + id, + method: "providers/set", + params: { + providerId: route.provider.providerId, + apiType: route.provider.apiType, + baseUrl: route.provider.baseUrl, + headers: { ...route.provider.headers }, + }, + }); + const answer = await Promise.race([answered, timedOut]); + if ( + answer !== undefined && typeof answer === "object" && + answer !== null && !Array.isArray(answer) && "result" in answer && + !("error" in answer) + ) { + return; + } + const code = errorCode(answer); + const refused = code !== undefined && ROUTE_REFUSED_CODES.has(code); + log.warn("the adapter did not accept the platform model route", { + context: { timedOut: answer === undefined, code, refused }, + }); + throw refused + ? new PluginMethodError( + AGENT_UNUSABLE, + "the Claude ACP adapter refused the platform model route", + ) + : new PluginMethodError( + ROUTE_NOT_PREPARED, + "the Claude ACP adapter could not prepare the platform model route", + ); + } finally { + clearTimeout(timer); + this.#pending.delete(id); + } } /** @@ -109,8 +253,37 @@ export class ClaudeClient { * * Awaiting the write is what lets the adapter's backpressure reach the host instead of growing * an unbounded queue inside this process. + * + * On a platform route, every frame from Ora passes the {@link ModelGuard} first: a request that + * would replace the route or the model is answered here with an error and never reaches the + * adapter, which is the only way to keep a client from undoing what `providers/set` applied. */ async writeAcp(frame: JsonValue): Promise { + const decision = this.#guard?.inbound(frame) ?? { forward: frame }; + if ("reply" in decision) { + log.info( + "answered a host ACP request the platform route does not allow", + { + context: summarize(frame), + }, + ); + this.#onAcpFrame(decision.reply); + return; + } + if ("drop" in decision) { + log.info( + "dropped a host ACP notification the platform route does not allow", + { + context: summarize(frame), + }, + ); + return; + } + await this.#write(decision.forward); + } + + /** Writes one frame into the adapter's stdin as-is; this bridge's own requests come here. */ + async #write(frame: JsonValue): Promise { const running = this.#running; if (running === undefined) { throw new Error("the Claude agent is not running"); @@ -148,10 +321,15 @@ export class ClaudeClient { } } - /** Wires stdout, stderr, and exit bookkeeping for one live child. */ - #attach(process: SpawnedProcess): void { - void this.#pumpStdout(process); - void this.#pumpStderr(process); + /** + * Wires stdout, stderr, and exit bookkeeping for one live child. + * + * `output` is fixed per generation: on a platform route the adapter's own text can carry the + * gateway's answer, a provider error body or a host path, none of which may reach the log. + */ + #attach(process: SpawnedProcess, output: AdapterOutput): void { + void this.#pumpStdout(process, output); + void this.#pumpStderr(process, output); void process.exited.then(() => { // A process that is no longer `#running` was already superseded by a later `start()` (an // Effect restart, for instance); its death is old news, not a live agent going away, so it @@ -184,17 +362,25 @@ export class ClaudeClient { * rejects non-object frames anyway, and one stray diagnostic line must not end every live session * on this agent. */ - async #pumpStdout(process: SpawnedProcess): Promise { + async #pumpStdout( + process: SpawnedProcess, + output: AdapterOutput, + ): Promise { try { - for await (const line of decodeLines(process.stdout)) { + for await (const raw of decodeLines(process.stdout)) { + // Scrubbed before anything reads it, so a secret inside a frame never reaches Ora. + const line = this.#guard?.scrub(raw) ?? raw; let frame: JsonValue; try { frame = JSON.parse(line) as JsonValue; } catch { - // The line itself is logged: it is the adapter's own output on its protocol channel, - // and the only clue to what went wrong with the pairing. + // Locally the line itself is logged: it is the adapter's own output on its protocol + // channel, and the only clue to what went wrong with the pairing. On a platform route + // it is the one thing that is not a frame, so nothing vouches for what it quotes. log.warn("dropping a non-JSON stdout line from the adapter", { - context: { pid: process.pid, line: line.slice(0, 512) }, + context: output === "verbatim" + ? { pid: process.pid, line: line.slice(0, 512) } + : { pid: process.pid, length: line.length }, }); continue; } @@ -206,10 +392,17 @@ export class ClaudeClient { }); continue; } + const own = typeof frame.id === "string" + ? this.#pending.get(frame.id) + : undefined; + if (own !== undefined && !("method" in frame)) { + own(frame); + continue; + } log.debug("adapter ACP frame received", { context: { pid: process.pid, ...summarize(frame) }, }); - this.#onAcpFrame(frame); + this.#onAcpFrame(this.#guard?.outbound(frame) ?? frame); } log.debug("adapter stdout reached EOF", { context: { pid: process.pid }, @@ -228,13 +421,30 @@ export class ClaudeClient { * The adapter is a third party whose stderr severity this plugin cannot know, so every line is * recorded at `info` under its own target rather than guessed at; the host's per-plugin level * decides whether it is kept. + * + * On a platform route no line is recorded, only how many there were (ADR D2: raw provider and + * CLI errors are not shown). The adapter logs the gateway it routes to, and Claude Code echoes a + * failed request with the gateway's response body; scrubbing known secrets out of that text + * would still keep the body, the upstream path and the host's own paths. */ - async #pumpStderr(process: SpawnedProcess): Promise { + async #pumpStderr( + process: SpawnedProcess, + output: AdapterOutput, + ): Promise { + let suppressed = 0; try { for await (const line of decodeLines(process.stderr)) { - if (line.length > 0) { - adapterLog.info(line, { context: { pid: process.pid } }); + if (line.length === 0) continue; + if (output === "sensitive") { + suppressed += 1; + continue; } + adapterLog.info(line, { context: { pid: process.pid } }); + } + if (suppressed > 0) { + log.info("suppressed the adapter's stderr on a platform route", { + context: { pid: process.pid, lines: suppressed }, + }); } } catch (error) { log.warn("claude-agent-acp stderr read failed", { @@ -248,13 +458,19 @@ export class ClaudeClient { * Asks the host to spawn and own the adapter process, adapting its `HostChildProcess` handle * onto `SpawnedProcess` so every other method above stays unaware of who owns the OS process. */ - async #spawnViaHost(cwd: string): Promise { + async #spawnViaHost( + cwd: string, + env: Record, + ): Promise { if (this.#processes === undefined) { throw new Error( "ClaudeClient cannot spawn before attachProcesses() runs", ); } - const child = await spawnClaude(this.#processes, { cwd }); + // A platform route runs only the pair this package ships; see `spawnBundledClaude`. + const child = this.#route === undefined + ? await spawnClaude(this.#processes, { cwd, env }) + : await spawnBundledClaude(this.#processes, { cwd, env }); return { stdin: new WritableStream({ write: (chunk) => child.write(chunk), @@ -271,6 +487,43 @@ export class ClaudeClient { } } +/** + * What must never leave the adapter on a platform route: the token, and the gateway address in + * every spelling a message is likely to use (base URL, host with port, bare hostname). + */ +function routeSecrets(route: ModelRoute): string[] { + const gateway = new URL(route.provider.baseUrl); + return [ + ...Object.values(route.provider.headers), + route.provider.baseUrl, + gateway.host, + gateway.hostname, + ]; +} + +/** + * How much of the adapter's own text this plugin may keep: all of it on a local host, none of it + * on a platform route. + */ +type AdapterOutput = "verbatim" | "sensitive"; + +/** The JSON-RPC error code of one answer, if it is an error with an integer code. */ +function errorCode(answer: JsonValue | undefined): number | undefined { + if ( + answer === undefined || answer === null || typeof answer !== "object" || + Array.isArray(answer) + ) { + return undefined; + } + const error = answer.error; + if (error === null || typeof error !== "object" || Array.isArray(error)) { + return undefined; + } + return typeof error.code === "number" && Number.isInteger(error.code) + ? error.code + : undefined; +} + /** The envelope fields of one frame that are safe to log: never its params or result. */ function summarize(frame: JsonValue): Record { if (typeof frame !== "object" || frame === null || Array.isArray(frame)) { diff --git a/src/services/command.ts b/src/services/command.ts index 2b84116..2f2424a 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -1,5 +1,6 @@ import { AGENT_NOT_INSTALLED, + AGENT_UNUSABLE, type AgentInvocation, type HostChildProcess, type HostProcesses, @@ -76,6 +77,38 @@ export function spawnClaude( return spawnResolved(processes, invocation); } +/** + * Spawns only the adapter this package ships, for a platform-model execution. + * + * A platform route is applied to, and tested against, the adapter and CLI pair this package + * bundles; `ORA_CLAUDE_ACP_BIN` or an install on PATH could be any other build, with any other + * idea of what `providers/set` means. So neither is consulted, and a package that cannot run its + * own adapter fails the start as `AGENT_UNUSABLE` rather than looking further. The CLI the + * adapter drives is held to the bundled one by `ORA_CLAUDE_BUNDLED_CLI_ONLY` in the route's + * environment, which `scripts/adapter-entry.mjs` reads. + */ +export async function spawnBundledClaude( + processes: HostProcesses, + invocation: AgentInvocation, +): Promise { + const packageCommand = bundledAdapterPath(); + log.info("spawning the bundled adapter for a platform route", { + context: { cwd: invocation.cwd, packageCommand }, + }); + try { + return await processes.spawn({ packageCommand, ...invocation }); + } catch (error) { + log.warn("the bundled adapter could not be spawned", { + context: { cwd: invocation.cwd, packageCommand }, + error, + }); + throw new PluginMethodError( + AGENT_UNUSABLE, + "this package's bundled Claude ACP adapter cannot run, and a platform model route uses no other", + ); + } +} + /** * Runs the bundled adapter, or resolves one off PATH, replacing the SDK's generic "not found" * message with one that names the npm package a user can install and the env var that bypasses diff --git a/src/services/model-guard.ts b/src/services/model-guard.ts new file mode 100644 index 0000000..aaa88ad --- /dev/null +++ b/src/services/model-guard.ts @@ -0,0 +1,299 @@ +import type { JsonValue } from "@ora-space/plugin-sdk"; + +/** The one model a platform-model execution may use, as the gateway and Ora name it. */ +export interface FrozenModel { + readonly id: string; + readonly name: string; +} + +/** What the bridge does with one frame Ora sent towards the adapter. */ +export type InboundDecision = + /** Write this frame, possibly rewritten, into the adapter. */ + | { readonly forward: JsonValue } + /** Answer Ora with this frame instead; the adapter never sees the request. */ + | { readonly reply: JsonValue } + /** A notification nobody may send in this execution; there is nothing to answer. */ + | { readonly drop: true }; + +/** JSON-RPC "method not found": these methods do not exist for a frozen execution. */ +const METHOD_NOT_AVAILABLE = -32601; +/** JSON-RPC "invalid params": a model other than the frozen one was asked for. */ +const MODEL_FROZEN = -32602; +/** What every adapter error says instead; its own text can quote the gateway, a header or a body. */ +const REDACTED_ERROR_MESSAGE = + "The Claude agent could not complete the request"; +/** What a secret is replaced with wherever it would otherwise reach Ora or a log. */ +const REDACTED = "[redacted]"; + +/** + * Methods that would hand the adapter another upstream, another credential, or none at all. + * + * `providers/set` would replace the route this plugin applied, while `providers/disable` and + * `logout` clear it and send Claude Code back to its native routing and login. `authenticate` + * installs a gateway of the caller's choosing. `providers/list` is read-only, but nothing Ora + * needs is in it, and the route is this plugin's business alone. + */ +function isRouteMutation(method: string): boolean { + return method === "authenticate" || method === "logout" || + method.startsWith("auth/") || method.startsWith("_auth/") || + method.startsWith("providers/") || method.startsWith("_providers/"); +} + +/** + * Session options a client may put in `_meta.claudeCode.options` that choose the model, the CLI, + * or the environment and settings Claude Code resolves both from. + * + * `claude-agent-acp` spreads the caller's options over its own and the caller's `env` over the + * process environment, so a `model`, an `ANTHROPIC_MODEL` or a `--model` in `extraArgs` would + * outrank the aliases this plugin pins. Every other option is left alone. + */ +const PINNED_SESSION_OPTIONS = [ + "model", + "fallbackModel", + "env", + "settings", + "extraArgs", + "executable", + "executableArgs", + "pathToClaudeCodeExecutable", +]; + +/** Requests that create or restore a session, and therefore carry session options. */ +const SESSION_SETUP = new Set([ + "session/new", + "session/load", + "session/resume", + "session/fork", +]); + +/** + * Keeps one platform-model execution on its frozen route and model. + * + * The route itself is applied once, through `providers/set`; this class only keeps anyone else + * from undoing it over ACP. It refuses the methods that could move the adapter to another + * upstream or model, strips session options that would do the same, narrows every model selector + * the adapter reports to the frozen model, and remembers each session's config options so + * re-selecting the frozen model can be answered without reaching the adapter. + * + * It also keeps the route's secrets in the adapter: adapter error text is replaced, and the token + * and gateway address are scrubbed from every line the adapter prints before it is forwarded or + * logged (ADR D2: raw provider and CLI errors are not shown, tokens never enter logs). + */ +export class ModelGuard { + readonly #model: FrozenModel; + /** Longest first, so a URL is replaced whole before the host inside it is. */ + readonly #secrets: readonly string[]; + /** Ora's outstanding requests whose answer belongs to a session the request named. */ + readonly #pendingSessions = new Map(); + /** The config options each session last reported, to answer a no-op model change. */ + readonly #configOptions = new Map(); + + constructor(model: FrozenModel, secrets: readonly string[] = []) { + this.#model = model; + this.#secrets = [...new Set(secrets.filter((secret) => secret !== ""))] + .sort((a, b) => b.length - a.length); + } + + /** Replaces every secret in one line of adapter output before it is parsed or sent. */ + scrub(text: string): string { + let scrubbed = text; + for (const secret of this.#secrets) { + if (scrubbed.includes(secret)) { + scrubbed = scrubbed.split(secret).join(REDACTED); + } + } + return scrubbed; + } + + /** The model `agent/list_models` reports: the frozen one, never anything discovered. */ + get model(): FrozenModel { + return this.#model; + } + + /** Decides what happens to one frame Ora sent towards the adapter. */ + inbound(frame: JsonValue): InboundDecision { + if (!isObject(frame) || typeof frame.method !== "string") { + return { forward: frame }; + } + const method = frame.method; + const params = isObject(frame.params) ? frame.params : {}; + if (isRouteMutation(method)) { + return refuse( + frame, + METHOD_NOT_AVAILABLE, + `${method} is not available: this execution's model route is fixed`, + ); + } + if (method === "session/set_model") { + return params.modelId === this.#model.id + ? answer(frame, {}) + : refuse(frame, MODEL_FROZEN, this.#frozenMessage()); + } + const sessionId = typeof params.sessionId === "string" + ? params.sessionId + : undefined; + if (method === "session/set_config_option" && params.configId === "model") { + const known = sessionId === undefined + ? undefined + : this.#configOptions.get(sessionId); + // The only selectable value is the one already in effect, so choosing it changes nothing + // and is answered with what the session last reported. + return params.value === this.#model.id && known !== undefined + ? answer(frame, { configOptions: known }) + : refuse(frame, MODEL_FROZEN, this.#frozenMessage()); + } + if (sessionId !== undefined && hasId(frame)) { + this.#pendingSessions.set(idKey(frame.id), sessionId); + } + if (SESSION_SETUP.has(method)) { + return { forward: pinSessionOptions(frame, params) }; + } + return { forward: frame }; + } + + /** Rewrites one frame the adapter sent before Ora sees it. */ + outbound(frame: JsonValue): JsonValue { + if (!isObject(frame)) return frame; + if (typeof frame.method === "string") return this.#narrowUpdate(frame); + const sessionId = hasId(frame) + ? this.#takePendingSession(frame.id) + : undefined; + if ("error" in frame) { + // The code still tells Ora what kind of failure this was; the text and data are the + // adapter's, and may carry a provider response or the gateway's address. + const code = isObject(frame.error) ? frame.error.code : undefined; + return { + ...frame, + error: { + code: typeof code === "number" && Number.isInteger(code) + ? code + : -32603, + message: REDACTED_ERROR_MESSAGE, + }, + }; + } + if (!isObject(frame.result)) return frame; + const result = this.#narrowModels(frame.result); + const owner = typeof result.sessionId === "string" + ? result.sessionId + : sessionId; + if (owner !== undefined && Array.isArray(result.configOptions)) { + this.#configOptions.set(owner, result.configOptions); + } + return { ...frame, result }; + } + + /** Narrows the selector a `config_option_update` carries; it replaces a session's whole set. */ + #narrowUpdate(frame: { [key: string]: JsonValue }): JsonValue { + if (frame.method !== "session/update" || !isObject(frame.params)) { + return frame; + } + const update = frame.params.update; + if (!isObject(update) || !Array.isArray(update.configOptions)) return frame; + const narrowed = this.#narrowModels(update); + if (typeof frame.params.sessionId === "string") { + this.#configOptions.set( + frame.params.sessionId, + narrowed.configOptions as JsonValue[], + ); + } + return { ...frame, params: { ...frame.params, update: narrowed } }; + } + + /** + * Replaces every model selector in one payload with one that offers only the frozen model. + * + * The adapter builds its list from Claude Code's aliases narrowed by what the gateway lists, so + * it can offer `default`, `sonnet` or a gateway id beside the frozen one. Every alias resolves + * to the frozen model through the pinned environment, but listing them would still offer a + * choice this execution does not have (ADR D5: visible session options never widen the set). + */ + #narrowModels( + payload: { [key: string]: JsonValue }, + ): { [key: string]: JsonValue } { + const narrowed = { ...payload }; + const only = { value: this.#model.id, name: this.#model.name }; + if (Array.isArray(payload.configOptions)) { + narrowed.configOptions = payload.configOptions.map((option) => + isObject(option) && + (option.id === "model" || option.category === "model") + ? { ...option, currentValue: only.value, options: [only] } + : option + ); + } + if (isObject(payload.models)) { + narrowed.models = { + ...payload.models, + currentModelId: this.#model.id, + availableModels: [{ modelId: this.#model.id, name: this.#model.name }], + }; + } + return narrowed; + } + + #takePendingSession(id: string | number): string | undefined { + const key = idKey(id); + const sessionId = this.#pendingSessions.get(key); + this.#pendingSessions.delete(key); + return sessionId; + } + + #frozenMessage(): string { + return `this execution is fixed to the model ${this.#model.id}`; + } +} + +/** Drops the caller's model, CLI and environment choices from one session-setup request. */ +function pinSessionOptions( + frame: { [key: string]: JsonValue }, + params: { [key: string]: JsonValue }, +): JsonValue { + const meta = params._meta; + if (!isObject(meta) || !isObject(meta.claudeCode)) return frame; + const options = meta.claudeCode.options; + if (!isObject(options)) return frame; + if (!PINNED_SESSION_OPTIONS.some((key) => key in options)) return frame; + const pinned = { ...options }; + for (const key of PINNED_SESSION_OPTIONS) delete pinned[key]; + return { + ...frame, + params: { + ...params, + _meta: { ...meta, claudeCode: { ...meta.claudeCode, options: pinned } }, + }, + }; +} + +function answer( + frame: { [key: string]: JsonValue }, + result: JsonValue, +): InboundDecision { + return hasId(frame) + ? { reply: { jsonrpc: "2.0", id: frame.id, result } } + : { drop: true }; +} + +function refuse( + frame: { [key: string]: JsonValue }, + code: number, + message: string, +): InboundDecision { + return hasId(frame) + ? { reply: { jsonrpc: "2.0", id: frame.id, error: { code, message } } } + : { drop: true }; +} + +function hasId( + frame: { [key: string]: JsonValue }, +): frame is { [key: string]: JsonValue; id: string | number } { + return typeof frame.id === "string" || typeof frame.id === "number"; +} + +/** Keeps `1` and `"1"` apart: JSON-RPC treats them as different ids. */ +function idKey(id: string | number): string { + return `${typeof id}:${id}`; +} + +function isObject(value: unknown): value is { [key: string]: JsonValue } { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/services/model-route.ts b/src/services/model-route.ts new file mode 100644 index 0000000..514401d --- /dev/null +++ b/src/services/model-route.ts @@ -0,0 +1,142 @@ +import { + type AgentModelApplied, + type AgentModelContext, + INVALID_PARAMS, + PluginMethodError, +} from "@ora-space/plugin-sdk"; +import type { FrozenModel } from "./model-guard.ts"; + +/** The adapter's single client-managed provider slot, as `providers/list` names it. */ +const PROVIDER_ID = "main"; + +/** This plugin's id without its namespace; the host registers it under the namespace it owns. */ +export const PLUGIN_ID = "ora-space.claude"; + +/** + * Bytes a header value cannot carry. The adapter would refuse such a token only when it lists the + * gateway's models, as a failed preparation that a retry repeats forever. + */ +// deno-lint-ignore no-control-regex +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; + +/** + * Everything one platform-model execution needs from this plugin, derived once at `agent/start`. + * + * `provider` becomes the adapter's `providers/set` call: the adapter puts that route into Claude + * Code's programmatic settings tier with `apiKeyHelper` cleared, so a repository's + * `.claude/settings.json` or a user setting cannot silently send the request, and the token in its + * header, to another base URL. `environment` pins every model alias Claude Code resolves on its own + * (the main model, the auxiliary small/fast model, subagents and the `opus`/`sonnet`/`haiku` + * shortcuts) to the one frozen model; the gateway still refuses any other model id, so a setting + * that overrides an alias fails closed instead of reaching a model nobody admitted. + */ +export interface ModelRoute { + readonly applied: AgentModelApplied; + /** The one model this execution may use; every selector and switch is held to it. */ + readonly model: FrozenModel; + readonly provider: { + readonly providerId: string; + readonly apiType: "anthropic"; + readonly baseUrl: string; + readonly headers: Readonly>; + }; + readonly environment: Readonly>; +} + +/** + * Turns a frozen model context into this plugin's Claude Code route, refusing what it cannot apply. + * + * The declaration admits one `anthropic-messages` connection and no Settings fields, so any other + * protocol, any Settings key, or a proxy that is not the platform's Messages endpoint is a + * configuration this release does not know how to honour; starting anyway would run Claude Code on + * whatever route it found by itself. A configuration frozen for another plugin or another target + * is refused too: the Node checks both before it starts anything, and this costs one comparison. + * The error never echoes the context, which carries the token. + */ +export function modelRoute(context: AgentModelContext): ModelRoute { + const { configuration } = context; + let settings: unknown; + try { + settings = JSON.parse(configuration.settingsJson); + } catch { + refuse(); + } + const proxy = parseProxy(context.proxyBaseUrl); + const { maxTokens } = configuration.model; + if ( + configuration.protocol !== "anthropic-messages" || + configuration.pluginId.split("/").at(-1) !== PLUGIN_ID || + configuration.target !== Deno.build.target || + settings === null || typeof settings !== "object" || + Array.isArray(settings) || Object.keys(settings).length > 0 || + proxy === undefined || context.token.length === 0 || + CONTROL_CHARACTERS.test(context.token) || + !Number.isSafeInteger(maxTokens) || maxTokens <= 0 + ) { + refuse(); + } + const model = configuration.model.id; + return { + applied: { + adapterVersion: configuration.adapterVersion, + declarationFingerprint: configuration.declarationFingerprint, + configurationRevision: configuration.configurationRevision, + }, + model: { id: model, name: configuration.model.name }, + provider: { + providerId: PROVIDER_ID, + apiType: "anthropic", + baseUrl: proxy, + // The gateway reads `x-api-key` first on its Messages route; the adapter's own placeholder + // bearer token then never authenticates anything. + headers: { "x-api-key": context.token }, + }, + environment: { + ANTHROPIC_MODEL: model, + ANTHROPIC_SMALL_FAST_MODEL: model, + ANTHROPIC_DEFAULT_OPUS_MODEL: model, + ANTHROPIC_DEFAULT_SONNET_MODEL: model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: model, + CLAUDE_CODE_SUBAGENT_MODEL: model, + // The frozen model's own output bound; Claude Code would otherwise ask for its default. + CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(maxTokens), + // Telemetry, error reporting, update checks and feedback surveys would otherwise leave the + // sandbox for Anthropic's own endpoints, which the platform route does not cover. + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + DISABLE_AUTOUPDATER: "1", + // Holds the adapter to the CLI bundled beside it: `ORA_CLAUDE_BIN` and a local install are + // ignored, see `scripts/adapter-entry.mjs`. + ORA_CLAUDE_BUNDLED_CLI_ONLY: "1", + }, + }; +} + +/** + * Reduces the platform's Messages proxy to the base URL Claude Code appends `/v1/messages` to. + * + * Only an HTTPS URL without credentials, query or fragment whose path ends in `/v1` is accepted; + * anything else is not the endpoint the Node was granted. + */ +function parseProxy(value: string): string | undefined { + let url: URL; + try { + url = new URL(value); + } catch { + return undefined; + } + if ( + url.protocol !== "https:" || url.username !== "" || url.password !== "" || + url.search !== "" || url.hash !== "" || !url.pathname.endsWith("/v1") + ) { + return undefined; + } + return `${url.origin}${url.pathname.slice(0, -"/v1".length)}`; +} + +/** Fails `agent/start` without describing the sensitive input. */ +function refuse(): never { + throw new PluginMethodError( + INVALID_PARAMS, + "this Claude release cannot apply the supplied model configuration", + ); +} diff --git a/tests/main_test.ts b/tests/main_test.ts index 669d4b4..04e1841 100644 --- a/tests/main_test.ts +++ b/tests/main_test.ts @@ -252,14 +252,14 @@ Deno.test("the bundled adapter is asked for before anything on PATH", async () = assertEquals(Object.hasOwn(spawns[0], "command"), false); }); -Deno.test("the compiled adapter pins its CLI with this package's own variable", async () => { +Deno.test("the compiled adapter never honours an inherited CLAUDE_CODE_EXECUTABLE", async () => { const entry = await Deno.readTextFile( new URL("../scripts/adapter-entry.mjs", import.meta.url), ); - assertEquals(entry.includes('"ORA_CLAUDE_BIN"'), true); - // Read as text because this file is compiled by Bun into a separate process and never imported - // here. `CLAUDE_CODE_EXECUTABLE` may only ever be written, never read: honouring an inherited - // one would run a different CLI than the package ships, and nothing would say so. + // Read as text because importing this file starts the adapter; which CLI it resolves is tested + // through `resolve-claude-cli.mjs` itself. `CLAUDE_CODE_EXECUTABLE` may only ever be written, + // never read: honouring an inherited one would run a different CLI than the package ships, and + // nothing would say so. assertEquals( entry.includes("process.env.CLAUDE_CODE_EXECUTABLE ="), true, @@ -499,6 +499,36 @@ Deno.test("a repeated reactivation does not restart an already rescanned adapter await client.stop(); }); +Deno.test("a restart that fails still lowers the barrier", async () => { + const written: JsonValue[] = []; + let spawns = 0; + const client = new ClaudeClient({ + spawn: () => { + spawns += 1; + // The second spawn is the reactivation restart; a route the adapter refused fails it alike. + if (spawns > 1) throw new Error("the adapter could not be spawned"); + return scriptedProcess(written); + }, + }); + await client.start("/workspace"); + const effects = new SkillEffectCoordinator(client, () => "/workspace"); + await effects.definition.coordinate({ targetId: "t1", resourceIds: ["r1"] }); + const held = { jsonrpc: "2.0", id: 9, method: "session/prompt" }; + await forwardAcpFrame(client, effects, held); + + await assertRejects(() => + Promise.resolve(effects.definition.reactivate({ + targetId: "t1", + resourceIds: ["r1"], + })) + ); + // Nothing is left behind the barrier: a later turn is no longer absorbed, and nothing was + // written into an adapter that is not running. + const later = { jsonrpc: "2.0", id: 10, method: "session/prompt" }; + assertEquals(effects.intercept(later), false); + assertEquals(written, []); +}); + Deno.test("NDJSON reassembles frames split across chunks and CRLF", async () => { const chunks = ['{"a":1}\r\n{"b":', '2}\n\n{"c":3}']; const stream = ReadableStream.from( diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts new file mode 100644 index 0000000..a7d898e --- /dev/null +++ b/tests/model_route_test.ts @@ -0,0 +1,881 @@ +import { assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { dirname, join } from "@std/path"; +import { + AGENT_UNUSABLE, + type AgentModelContext, + createLogger, + createStderrLogSink, + type HostChildProcessOptions, + type HostProcesses, + HostRequestError, + INVALID_PARAMS, + type JsonValue, +} from "@ora-space/plugin-sdk"; +import { startClaude } from "../src/handlers/lifecycle.ts"; +import { listAgentModels } from "../src/handlers/models.ts"; +import { + ClaudeClient, + type SpawnedProcess, +} from "../src/services/claude-client.ts"; +import { bundledAdapterPath } from "../src/services/bundled-binary.ts"; +import { BIN_ENV_VAR } from "../src/services/command.ts"; +import { installLogger } from "../src/services/log.ts"; +import { modelRoute } from "../src/services/model-route.ts"; + +const TOKEN = "synthetic-temporary-token"; + +/** + * The CLI resolver compiled into the adapter binary. It is plain Node ESM, loaded through a + * computed specifier so `deno check` does not follow it into `node:` typings this repository does + * not install; the test still runs the module itself. + */ +const { + BIN_ENV_VAR: CLI_BIN_ENV_VAR, + BUNDLED_ONLY_ENV_VAR, + resolveClaudeCli, +} = await import( + String(new URL("../scripts/resolve-claude-cli.mjs", import.meta.url)) +) as { + BIN_ENV_VAR: string; + BUNDLED_ONLY_ENV_VAR: string; + resolveClaudeCli: (options: { + env: Record; + execPath: string; + claudeBinaryName: string; + warn: (message: string) => void; + }) => { path: string; source: string }; +}; + +/** The frozen context a Node hands this plugin for one platform-model execution. */ +function context( + overrides: Partial = {}, +): AgentModelContext { + return { + configuration: { + pluginId: "official/ora-space.claude", + pluginVersion: "0.4.0", + // Whatever runs the tests is the target the Node froze this configuration for. + target: Deno.build.target, + packageSha256: "a".repeat(64), + schemaVersion: 2, + declarationFingerprint: "b".repeat(64), + adapterVersion: 1, + configurationRevision: 7, + settingsJson: "{}", + protocol: "anthropic-messages", + model: { + id: "team/claude", + name: "Team Claude", + contextWindow: 200_000, + maxTokens: 8192, + }, + }, + proxyBaseUrl: "https://gateway.internal:8443/runtime/anthropic/v1", + token: TOKEN, + ...overrides, + }; +} + +Deno.test("a frozen context becomes one pinned Messages route", () => { + assertEquals(modelRoute(context()), { + applied: { + adapterVersion: 1, + declarationFingerprint: "b".repeat(64), + configurationRevision: 7, + }, + model: { id: "team/claude", name: "Team Claude" }, + provider: { + providerId: "main", + apiType: "anthropic", + baseUrl: "https://gateway.internal:8443/runtime/anthropic", + headers: { "x-api-key": TOKEN }, + }, + environment: { + ANTHROPIC_MODEL: "team/claude", + ANTHROPIC_SMALL_FAST_MODEL: "team/claude", + ANTHROPIC_DEFAULT_OPUS_MODEL: "team/claude", + ANTHROPIC_DEFAULT_SONNET_MODEL: "team/claude", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "team/claude", + CLAUDE_CODE_SUBAGENT_MODEL: "team/claude", + CLAUDE_CODE_MAX_OUTPUT_TOKENS: "8192", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + DISABLE_AUTOUPDATER: "1", + ORA_CLAUDE_BUNDLED_CLI_ONLY: "1", + }, + }); +}); + +Deno.test("configurations this release cannot apply are refused without echoing the token", () => { + const refused: AgentModelContext[] = [ + context({ + configuration: { + ...context().configuration, + protocol: "openai-completions", + }, + }), + context({ + configuration: { + ...context().configuration, + settingsJson: '{"model":"opus"}', + }, + }), + context({ + configuration: { + ...context().configuration, + pluginId: "official/ora-space.opencode", + }, + }), + context({ + configuration: { + ...context().configuration, + target: Deno.build.target === "x86_64-unknown-linux-gnu" + ? "aarch64-unknown-linux-gnu" + : "x86_64-unknown-linux-gnu", + }, + }), + context({ + configuration: { + ...context().configuration, + model: { ...context().configuration.model, maxTokens: 0 }, + }, + }), + // A header value cannot carry these; the adapter would only fail later, retryably. + context({ token: `${TOKEN}\r\nx-other: 1` }), + context({ token: `${TOKEN}\u0000` }), + context({ proxyBaseUrl: "http://gateway.internal/runtime/anthropic/v1" }), + context({ proxyBaseUrl: "https://gateway.internal/runtime/anthropic" }), + context({ proxyBaseUrl: "https://gateway.internal/v1?redirect=1" }), + ]; + for (const input of refused) { + let caught: unknown; + try { + modelRoute(input); + } catch (error) { + caught = error; + } + assertEquals((caught as { code?: number }).code, INVALID_PARAMS); + assertEquals(String((caught as Error).message).includes(TOKEN), false); + } +}); + +Deno.test("a platform start is confirmed only after the adapter accepted its route", async () => { + const adapter = new ScriptedAdapter("accept"); + const forwarded: JsonValue[] = []; + const client = new ClaudeClient({ + spawn: (_cwd, env) => adapter.spawn(env), + onAcpFrame: (frame) => forwarded.push(frame), + }); + const applied = await startClaude(client, { + cwd: "/workspace", + hostVersion: "1.0.0", + modelContext: context(), + }); + assertEquals(applied, modelRoute(context()).applied); + assertEquals(adapter.environments, [modelRoute(context()).environment]); + // The token reaches the adapter only inside its own provider request, never its environment. + assertEquals( + Object.values(adapter.environments[0]).some((value) => + value.includes(TOKEN) + ), + false, + ); + assertEquals(adapter.written, [{ + jsonrpc: "2.0", + id: "ora-claude-model-route-1", + method: "providers/set", + params: { + providerId: "main", + apiType: "anthropic", + baseUrl: "https://gateway.internal:8443/runtime/anthropic", + headers: { "x-api-key": TOKEN }, + }, + }]); + // The adapter's answer belongs to this bridge; Ora's frames still pass through untouched. + adapter.emit({ jsonrpc: "2.0", id: 1, result: {} }); + await waitFor(() => forwarded.length === 1); + assertEquals(forwarded, [{ jsonrpc: "2.0", id: 1, result: {} }]); + await client.stop(); +}); + +Deno.test("an Effect restart reapplies the same route before the adapter counts as running", async () => { + const adapter = new ScriptedAdapter("accept"); + const client = new ClaudeClient({ + spawn: (_cwd, env) => adapter.spawn(env), + }); + await startClaude(client, { + cwd: "/workspace", + hostVersion: "1.0.0", + modelContext: context(), + }); + await client.start("/workspace"); + assertEquals( + adapter.written.map((frame) => (frame as { id: string }).id), + ["ora-claude-model-route-1", "ora-claude-model-route-2"], + ); + assertEquals(adapter.environments.length, 2); + await client.stop(); +}); + +Deno.test("a rejected route fails the start and stops the adapter", async () => { + const adapter = new ScriptedAdapter("reject"); + const client = new ClaudeClient({ + spawn: (_cwd, env) => adapter.spawn(env), + }); + const error = await assertRejects(() => + startClaude(client, { + cwd: "/workspace", + hostVersion: "1.0.0", + modelContext: context(), + }) + ); + assertEquals((error as { code?: number }).code, AGENT_UNUSABLE); + assertEquals(String((error as Error).message).includes(TOKEN), false); + assertEquals(client.running, false); + assertEquals(adapter.killed, 1); +}); + +Deno.test("a local start keeps the adapter's own routing and confirms nothing", async () => { + const adapter = new ScriptedAdapter("accept"); + const client = new ClaudeClient({ + spawn: (_cwd, env) => adapter.spawn(env), + }); + const applied = await startClaude(client, { + cwd: "/workspace", + hostVersion: "1.0.0", + }); + assertEquals(applied, undefined); + assertEquals(adapter.environments, [{}]); + assertEquals(adapter.written, []); + await client.stop(); +}); + +Deno.test("a client cannot replace, drop or re-authenticate the platform route", async () => { + const { adapter, client, forwarded } = await platformClient(); + for ( + const [id, method] of [ + [11, "authenticate"], + [12, "logout"], + [13, "auth/logout"], + [14, "providers/set"], + [15, "providers/disable"], + [16, "providers/list"], + [17, "_providers/set"], + ] as const + ) { + await client.writeAcp({ + jsonrpc: "2.0", + id, + method, + params: { providerId: "main", baseUrl: "https://other.invalid" }, + }); + } + // A notification has nobody to answer and is simply not delivered. + await client.writeAcp({ jsonrpc: "2.0", method: "providers/disable" }); + assertEquals(adapter.written.length, 1); + assertEquals( + forwarded.map((frame) => (frame as { id: number }).id), + [11, 12, 13, 14, 15, 16, 17], + ); + for (const frame of forwarded) { + assertEquals((frame as { error: { code: number } }).error.code, -32601); + } + await client.stop(); +}); + +Deno.test("a client can only ever select the frozen model", async () => { + const { adapter, client, forwarded } = await platformClient(); + await client.writeAcp({ + jsonrpc: "2.0", + id: 1, + method: "session/set_model", + params: { sessionId: "s1", modelId: "opus" }, + }); + await client.writeAcp({ + jsonrpc: "2.0", + id: 2, + method: "session/set_config_option", + params: { sessionId: "s1", configId: "model", value: "sonnet" }, + }); + await client.writeAcp({ + jsonrpc: "2.0", + id: 3, + method: "session/set_model", + params: { sessionId: "s1", modelId: "team/claude" }, + }); + // Re-selecting the frozen model is a no-op answered with what the session last reported. + await client.writeAcp({ + jsonrpc: "2.0", + id: 4, + method: "session/load", + params: { sessionId: "s1", cwd: "/workspace", mcpServers: [] }, + }); + const options = [{ id: "mode", currentValue: "default", options: [] }]; + adapter.emit({ jsonrpc: "2.0", id: 4, result: { configOptions: options } }); + await waitFor(() => forwarded.length === 4); + await client.writeAcp({ + jsonrpc: "2.0", + id: 5, + method: "session/set_config_option", + params: { sessionId: "s1", configId: "model", value: "team/claude" }, + }); + // Every other option still reaches the adapter. + await client.writeAcp({ + jsonrpc: "2.0", + id: 6, + method: "session/set_config_option", + params: { sessionId: "s1", configId: "mode", value: "plan" }, + }); + assertEquals( + adapter.written.map((frame) => (frame as { id: string | number }).id), + ["ora-claude-model-route-1", 4, 6], + ); + assertEquals(forwarded[0], { + jsonrpc: "2.0", + id: 1, + error: { + code: -32602, + message: "this execution is fixed to the model team/claude", + }, + }); + assertEquals( + (forwarded[1] as { error: { code: number } }).error.code, + -32602, + ); + assertEquals(forwarded[2], { jsonrpc: "2.0", id: 3, result: {} }); + assertEquals(forwarded[4], { + jsonrpc: "2.0", + id: 5, + result: { configOptions: options }, + }); + await client.stop(); +}); + +Deno.test("session options cannot choose another model, CLI or environment", async () => { + const { adapter, client } = await platformClient(); + await client.writeAcp({ + jsonrpc: "2.0", + id: 1, + method: "session/new", + params: { + cwd: "/workspace", + mcpServers: [], + _meta: { + claudeCode: { + options: { + model: "opus", + fallbackModel: "sonnet", + env: { ANTHROPIC_MODEL: "opus" }, + settings: { model: "opus" }, + extraArgs: { model: "opus" }, + executable: "node", + executableArgs: ["--inspect"], + pathToClaudeCodeExecutable: "/tmp/claude", + maxTurns: 3, + }, + }, + other: true, + }, + }, + }); + assertEquals(adapter.written[1], { + jsonrpc: "2.0", + id: 1, + method: "session/new", + params: { + cwd: "/workspace", + mcpServers: [], + _meta: { claudeCode: { options: { maxTurns: 3 } }, other: true }, + }, + }); + await client.stop(); +}); + +Deno.test("a local start passes every ACP frame through untouched", async () => { + const adapter = new ScriptedAdapter("accept"); + const client = new ClaudeClient({ + spawn: (_cwd, env) => adapter.spawn(env), + }); + await startClaude(client, { cwd: "/workspace", hostVersion: "1.0.0" }); + const frame = { jsonrpc: "2.0", id: 1, method: "providers/set", params: {} }; + await client.writeAcp(frame); + assertEquals(adapter.written, [frame]); + await client.stop(); +}); + +Deno.test("a platform route spawns only the bundled adapter, ignoring a pin and PATH", async () => { + const spawns: HostChildProcessOptions[] = []; + const client = new ClaudeClient(); + client.attachProcesses({ + spawn: (options) => { + spawns.push(options); + return Promise.reject( + new HostRequestError("package_command_missing", "not in this package"), + ); + }, + }); + const previous = Deno.env.get(BIN_ENV_VAR); + Deno.env.set(BIN_ENV_VAR, "/usr/local/bin/claude-agent-acp"); + try { + const error = await assertRejects(() => + startClaude(client, { + cwd: "/workspace", + hostVersion: "1.0.0", + modelContext: context(), + }) + ); + // Not retried as a missing install: the package itself cannot serve a platform route. + assertEquals((error as { code?: number }).code, AGENT_UNUSABLE); + } finally { + if (previous === undefined) Deno.env.delete(BIN_ENV_VAR); + else Deno.env.set(BIN_ENV_VAR, previous); + } + assertEquals(spawns.length, 1); + assertEquals(spawns[0].packageCommand, bundledAdapterPath()); + assertEquals(Object.hasOwn(spawns[0], "command"), false); + assertEquals(spawns[0].env?.ORA_CLAUDE_BUNDLED_CLI_ONLY, "1"); +}); + +Deno.test("the compiled adapter runs only its bundled CLI on a platform route", async () => { + const root = await Deno.makeTempDir(); + try { + const { execPath, bundled, pinned, localDir } = await cliLayout(root); + const resolve = (env: Record) => + resolveClaudeCli({ + env, + execPath, + claudeBinaryName: "claude", + warn: () => {}, + }); + const everything = { [CLI_BIN_ENV_VAR]: pinned, PATH: localDir }; + // Locally the pin outranks the bundled copy. + assertEquals(resolve(everything), { path: pinned, source: "pinned" }); + // On a route neither the pin nor a local install is ever read. + assertEquals( + resolve({ ...everything, [BUNDLED_ONLY_ENV_VAR]: "1" }), + { path: bundled, source: "bundled" }, + ); + await Deno.remove(bundled); + assertEquals(resolve(everything).source, "pinned"); + assertThrows( + () => resolve({ ...everything, [BUNDLED_ONLY_ENV_VAR]: "1" }), + Error, + BUNDLED_ONLY_ENV_VAR, + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("every model selector the adapter reports offers only the frozen model", async () => { + const { adapter, client, forwarded } = await platformClient(); + assertEquals(client.frozenModel, { id: "team/claude", name: "Team Claude" }); + const mode = { id: "mode", category: "mode", currentValue: "default" }; + const picker = { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "default", + options: [ + { value: "default", name: "Default" }, + { value: "opus", name: "Opus" }, + { value: "sonnet", name: "Sonnet" }, + ], + }; + const frozen = { + ...picker, + currentValue: "team/claude", + options: [{ value: "team/claude", name: "Team Claude" }], + }; + adapter.emit({ + jsonrpc: "2.0", + id: 1, + result: { + sessionId: "s1", + configOptions: [mode, picker], + models: { + currentModelId: "default", + availableModels: [{ modelId: "opus", name: "Opus" }], + }, + }, + }); + adapter.emit({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "s2", + update: { + sessionUpdate: "config_option_update", + configOptions: [picker], + }, + }, + }); + await waitFor(() => forwarded.length === 2); + assertEquals(forwarded[0], { + jsonrpc: "2.0", + id: 1, + result: { + sessionId: "s1", + configOptions: [mode, frozen], + models: { + currentModelId: "team/claude", + availableModels: [{ modelId: "team/claude", name: "Team Claude" }], + }, + }, + }); + assertEquals(forwarded[1], { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "s2", + update: { + sessionUpdate: "config_option_update", + configOptions: [frozen], + }, + }, + }); + // The no-op answer repeats the narrowed set, never the adapter's own list. + await client.writeAcp({ + jsonrpc: "2.0", + id: 2, + method: "session/set_config_option", + params: { sessionId: "s2", configId: "model", value: "team/claude" }, + }); + assertEquals(forwarded[2], { + jsonrpc: "2.0", + id: 2, + result: { configOptions: [frozen] }, + }); + await client.stop(); +}); + +Deno.test("model discovery on a platform route answers the frozen model without a probe", async () => { + const { client } = await platformClient(); + const spawns: HostChildProcessOptions[] = []; + const processes: HostProcesses = { + spawn: (options) => { + spawns.push(options); + return Promise.reject(new Error("a probe must not start")); + }, + }; + assertEquals(await listAgentModels(client, processes, "/workspace"), [ + { id: "team/claude", displayName: "Team Claude", default: true }, + ]); + assertEquals(spawns, []); + await client.stop(); + // Without a route the same call is discovery, which needs the host's processes. + assertThrows( + () => listAgentModels(new ClaudeClient(), undefined, "/workspace"), + Error, + "before activation", + ); +}); + +Deno.test("adapter errors reach Ora with their code but without their text", async () => { + const { adapter, client, forwarded } = await platformClient(); + adapter.emit({ + jsonrpc: "2.0", + id: 7, + error: { + code: -32000, + message: "401 from https://other.invalid: bad key", + data: { headers: { "x-api-key": "leaked" } }, + }, + }); + adapter.emit({ jsonrpc: "2.0", id: 8, error: "not an object" }); + await waitFor(() => forwarded.length === 2); + assertEquals(forwarded, [ + { + jsonrpc: "2.0", + id: 7, + error: { + code: -32000, + message: "The Claude agent could not complete the request", + }, + }, + { + jsonrpc: "2.0", + id: 8, + error: { + code: -32603, + message: "The Claude agent could not complete the request", + }, + }, + ]); + await client.stop(); +}); + +Deno.test("the token and gateway address never leave the adapter's frames", async () => { + const { adapter, client, forwarded } = await platformClient(); + adapter.emit({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "s1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: + `API Error calling https://gateway.internal:8443/runtime/anthropic/v1/messages with ${TOKEN}; gateway.internal unreachable`, + }, + }, + }, + }); + await waitFor(() => forwarded.length === 1); + assertEquals( + (forwarded[0] as { + params: { update: { content: { text: string } } }; + }).params.update.content.text, + "API Error calling [redacted]/v1/messages with [redacted]; [redacted] unreachable", + ); + await client.stop(); +}); + +Deno.test("the adapter's own text never reaches the log on a platform route", async () => { + const records: string[] = []; + installLogger(createLogger({ write: (line) => records.push(line) })); + try { + const { adapter, client } = await platformClient(); + // What a failed request looks like once the CLI and adapter have echoed it: the upstream's + // body and address, and paths of the host this runs on. + const leaks = [ + 'upstream host=api.provider.example path=/v1/messages body={"error":"overloaded","request_id":"req_123"}', + "/home/node-user/.cache/ora/workspaces/7f3a", + ]; + adapter.emitLine(`not json ${leaks.join(" ")}`); + adapter.emitStderr( + "[providers/set] apiType=anthropic baseUrl=https://gateway.internal:8443/runtime/anthropic", + ); + adapter.emitStderr(`API Error: 503 ${leaks.join(" ")} ${TOKEN}`); + adapter.closeStderr(); + await waitFor(() => + records.some((record) => record.includes("non-JSON")) && + records.some((record) => record.includes("suppressed")) + ); + const text = records.join("\n"); + for (const secret of [TOKEN, "gateway.internal", ...leaks]) { + assertEquals(text.includes(secret), false, secret); + } + assertEquals(text.includes('"lines":2'), true); + await client.stop(); + } finally { + installLogger(createLogger(createStderrLogSink())); + } +}); + +Deno.test("a local adapter's diagnostics are kept verbatim", async () => { + const records: string[] = []; + installLogger(createLogger({ write: (line) => records.push(line) })); + try { + const adapter = new ScriptedAdapter("accept"); + const client = new ClaudeClient({ + spawn: (_cwd, env) => adapter.spawn(env), + }); + await client.start("/workspace"); + adapter.emitStderr("[claude-agent-acp] using the local CLI"); + adapter.emitLine("not json from a local pairing"); + await waitFor(() => + records.some((record) => record.includes("using the local CLI")) && + records.some((record) => record.includes("from a local pairing")) + ); + await client.stop(); + } finally { + installLogger(createLogger(createStderrLogSink())); + } +}); + +Deno.test("a route the adapter could not prepare fails the start as retryable", async () => { + const adapter = new ScriptedAdapter("fail"); + const client = new ClaudeClient({ + spawn: (_cwd, env) => adapter.spawn(env), + }); + const error = await assertRejects(() => + startClaude(client, { + cwd: "/workspace", + hostVersion: "1.0.0", + modelContext: context(), + }) + ); + // Not AGENT_UNUSABLE: a gateway that failed to list its models may answer the next start. + assertEquals((error as { code?: number }).code, -32603); + assertEquals(String((error as Error).message).includes("503"), false); + assertEquals(client.running, false); + assertEquals(adapter.killed, 1); +}); + +Deno.test("an adapter waiting for its route is running but not ready", async () => { + const adapter = new ScriptedAdapter("hold"); + const client = new ClaudeClient({ + spawn: (_cwd, env) => adapter.spawn(env), + }); + const started = startClaude(client, { + cwd: "/workspace", + hostVersion: "1.0.0", + modelContext: context(), + }); + await waitFor(() => adapter.written.length === 1); + assertEquals([client.running, client.ready], [true, false]); + adapter.acceptRoute(); + await started; + assertEquals([client.running, client.ready], [true, true]); + await client.stop(); + assertEquals(client.ready, false); +}); + +/** A bundled CLI beside a fake adapter, a pinned one, and one on a fake PATH. */ +async function cliLayout(root: string) { + const files = { + execPath: join(root, "package", "claude-agent-acp"), + bundled: join(root, "package", "claude"), + pinned: join(root, "pinned", "claude"), + local: join(root, "local", "claude"), + }; + for (const path of Object.values(files)) { + await Deno.mkdir(dirname(path), { recursive: true }); + await Deno.writeTextFile(path, ""); + } + return { ...files, localDir: dirname(files.local) }; +} + +async function platformClient() { + const adapter = new ScriptedAdapter("accept"); + const forwarded: JsonValue[] = []; + const client = new ClaudeClient({ + spawn: (_cwd, env) => adapter.spawn(env), + onAcpFrame: (frame) => forwarded.push(frame), + }); + await startClaude(client, { + cwd: "/workspace", + hostVersion: "1.0.0", + modelContext: context(), + }); + return { adapter, client, forwarded }; +} + +/** + * A `claude-agent-acp` stand-in that answers `providers/set` and records what it was given. + * + * Only the provider request is answered; every other frame is recorded and left to the test. + */ +class ScriptedAdapter { + readonly written: JsonValue[] = []; + readonly environments: Record[] = []; + killed = 0; + #stdout: ReadableStreamDefaultController | undefined; + #stderr: ReadableStreamDefaultController | undefined; + + /** The `providers/set` request a `hold` adapter has not answered yet. */ + #heldRoute: string | undefined; + + /** + * `accept` and `reject` answer the route at once; `fail` answers it the way the pinned adapter + * reports a gateway model listing that failed; `hold` leaves it to {@link acceptRoute}. + */ + constructor(readonly answer: "accept" | "reject" | "fail" | "hold") {} + + /** Accepts the route a `hold` adapter is sitting on. */ + acceptRoute(): void { + this.emit({ jsonrpc: "2.0", id: this.#heldRoute ?? null, result: {} }); + } + + /** Spawns one generation; a later spawn supersedes the earlier one's stdout. */ + spawn(env: Record): SpawnedProcess { + this.environments.push(env); + let ended: (() => void) | undefined; + return { + pid: 4321, + stdin: new WritableStream({ + write: (chunk) => { + for (const line of new TextDecoder().decode(chunk).split("\n")) { + if (line.trim() === "") continue; + const frame = JSON.parse(line) as { id?: string; method?: string }; + this.written.push(frame as JsonValue); + if (frame.method === "providers/set") { + this.#answerRoute(frame.id ?? null); + } + } + }, + }), + stdout: new ReadableStream({ + start: (controller) => { + this.#stdout = controller; + }, + }), + stderr: new ReadableStream({ + start: (controller) => { + this.#stderr = controller; + }, + }), + exited: new Promise((resolve) => { + ended = () => resolve(); + }), + kill: () => { + this.killed += 1; + this.#stdout?.close(); + this.#stderr?.close(); + ended?.(); + }, + }; + } + + #answerRoute(id: string | null): void { + switch (this.answer) { + case "accept": + this.emit({ jsonrpc: "2.0", id, result: {} }); + return; + case "reject": + this.emit({ + jsonrpc: "2.0", + id, + error: { code: -32602, message: `rejected ${TOKEN}` }, + }); + return; + case "fail": + this.emit({ + jsonrpc: "2.0", + id, + error: { + code: -32603, + message: + 'Model discovery for provider "main" failed: Model listing GET /runtime/anthropic/v1/models 503', + }, + }); + return; + case "hold": + this.#heldRoute = id ?? undefined; + return; + } + } + + /** Closes the current generation's stderr, as an adapter that exits does. */ + closeStderr(): void { + this.#stderr?.close(); + } + + /** Writes one diagnostic line to the current generation's stderr. */ + emitStderr(line: string): void { + this.#stderr?.enqueue(new TextEncoder().encode(`${line}\n`)); + } + + /** Writes one raw line to the current generation's stdout. */ + emitLine(line: string): void { + this.#stdout?.enqueue(new TextEncoder().encode(`${line}\n`)); + } + + /** Writes one NDJSON frame to the current generation's stdout. */ + emit(frame: JsonValue): void { + this.#stdout?.enqueue( + new TextEncoder().encode(`${JSON.stringify(frame)}\n`), + ); + } +} + +/** Polls a condition the bridge settles asynchronously, failing after a bounded wait. */ +async function waitFor(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 200; attempt++) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("condition was never met"); +}