From 15d5fd305d1e1d7200dff1c39ac791a10556ef33 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Tue, 20 Oct 2026 12:57:40 +0000 Subject: [PATCH 01/14] build: require plugin SDK 0.11.0 for model adapter startup SDK 0.11.0 lets agent/start return the AgentModelApplied confirmation a platform-model start requires. deno.json and deno.lock pin the published 0.11.0. --- deno.json | 6 +++--- deno.lock | 8 ++++---- src/base/agent-plugin.ts | 10 +++++++--- 3 files changed, 14 insertions(+), 10 deletions(-) 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/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( From 97c9300d89577455b611104db82100e8dcedc2a9 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Tue, 20 Oct 2026 12:57:40 +0000 Subject: [PATCH 02/14] feat: apply the platform model route before confirming agent/start A start that carries a frozen model context accepts only the declared anthropic-messages connection with no Settings. The bridge sends the adapter's own providers/set with the token as x-api-key, consumes the answer before anything reaches Ora, and pins every model alias to the frozen model; agent/start confirms the configuration only once the adapter accepted the route, and an Effect restart reapplies it. --- AGENTS.md | 39 ++++- src/handlers/lifecycle.ts | 19 ++- src/main.ts | 5 +- src/services/claude-client.ts | 119 +++++++++++++-- src/services/model-route.ts | 116 +++++++++++++++ tests/model_route_test.ts | 264 ++++++++++++++++++++++++++++++++++ 6 files changed, 545 insertions(+), 17 deletions(-) create mode 100644 src/services/model-route.ts create mode 100644 tests/model_route_test.ts diff --git a/AGENTS.md b/AGENTS.md index ed3b11e..6043a8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -349,6 +349,39 @@ 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 only as the `x-api-key` header of that request, which the + platform gateway reads first on its Messages route; it is never placed in the + adapter's environment or logged. +- 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. +- `ClaudeClient.useRoute` keeps the route for every later start, so an Effect + `reactivate` restart reapplies it before the adapter counts as running. + +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. + ## Model discovery `claude-agent-acp` is a pure ACP server: it takes no subcommand, and its @@ -386,9 +419,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 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/main.ts b/src/main.ts index 36af659..ace88c8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import type { AcpSender, AgentModel, + AgentModelApplied, AgentStartContext, HostProcesses, JsonValue, @@ -95,7 +96,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 +106,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 => { diff --git a/src/services/claude-client.ts b/src/services/claude-client.ts index ed44fe5..483aad6 100644 --- a/src/services/claude-client.ts +++ b/src/services/claude-client.ts @@ -1,12 +1,24 @@ -import type { HostProcesses, JsonValue } from "@ora-space/plugin-sdk"; +import { + AGENT_UNUSABLE, + type HostProcesses, + type JsonValue, + PluginMethodError, +} from "@ora-space/plugin-sdk"; import { spawnClaude } from "./command.ts"; import { logger } from "./log.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; + +/** 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 +36,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. */ @@ -47,16 +59,25 @@ interface RunningProcess { * binary envelope and the adapter's NDJSON and otherwise passed through verbatim. */ 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; #expectedExit = false; + /** The platform route every generation of the adapter starts with; absent on a local host. */ + #route: ModelRoute | 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 ?? (() => {}); } @@ -76,11 +97,23 @@ 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; + } + /** * 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; @@ -94,14 +127,72 @@ 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; + if (route !== undefined) { + try { + await this.#applyRoute(route); + } catch (error) { + await this.stop(); + throw error; + } + } + 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. + */ + 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.writeAcp({ + 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]); + const accepted = answer !== undefined && typeof answer === "object" && + answer !== null && !Array.isArray(answer) && "result" in answer && + !("error" in answer); + if (!accepted) { + log.warn("the adapter did not accept the platform model route", { + context: { timedOut: answer === undefined }, + }); + throw new PluginMethodError( + AGENT_UNUSABLE, + "the Claude ACP adapter did not accept the platform model route", + ); + } + } finally { + clearTimeout(timer); + this.#pending.delete(id); + } } /** @@ -206,6 +297,13 @@ 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) }, }); @@ -248,13 +346,16 @@ 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 }); + const child = await spawnClaude(this.#processes, { cwd, env }); return { stdin: new WritableStream({ write: (chunk) => child.write(chunk), diff --git a/src/services/model-route.ts b/src/services/model-route.ts new file mode 100644 index 0000000..2655e4f --- /dev/null +++ b/src/services/model-route.ts @@ -0,0 +1,116 @@ +import { + type AgentModelApplied, + type AgentModelContext, + INVALID_PARAMS, + PluginMethodError, +} from "@ora-space/plugin-sdk"; + +/** The adapter's single client-managed provider slot, as `providers/list` names it. */ +const PROVIDER_ID = "main"; + +/** + * 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; + 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. 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); + if ( + configuration.protocol !== "anthropic-messages" || + settings === null || typeof settings !== "object" || + Array.isArray(settings) || Object.keys(settings).length > 0 || + proxy === undefined || context.token.length === 0 + ) { + refuse(); + } + const model = configuration.model.id; + return { + applied: { + adapterVersion: configuration.adapterVersion, + declarationFingerprint: configuration.declarationFingerprint, + configurationRevision: configuration.configurationRevision, + }, + 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, + // 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", + }, + }; +} + +/** + * 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/model_route_test.ts b/tests/model_route_test.ts new file mode 100644 index 0000000..7cb2d38 --- /dev/null +++ b/tests/model_route_test.ts @@ -0,0 +1,264 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { + AGENT_UNUSABLE, + type AgentModelContext, + INVALID_PARAMS, + type JsonValue, +} from "@ora-space/plugin-sdk"; +import { startClaude } from "../src/handlers/lifecycle.ts"; +import { + ClaudeClient, + type SpawnedProcess, +} from "../src/services/claude-client.ts"; +import { modelRoute } from "../src/services/model-route.ts"; + +const TOKEN = "synthetic-temporary-token"; + +/** 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", + target: "x86_64-unknown-linux-gnu", + 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, + }, + 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_DISABLE_NONESSENTIAL_TRAFFIC: "1", + DISABLE_AUTOUPDATER: "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({ 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(); +}); + +/** + * 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; + + constructor(readonly answer: "accept" | "reject") {} + + /** 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.emit( + this.answer === "accept" + ? { jsonrpc: "2.0", id: frame.id ?? null, result: {} } + : { + jsonrpc: "2.0", + id: frame.id ?? null, + error: { code: -32602, message: `rejected ${TOKEN}` }, + }, + ); + } + } + }, + }), + stdout: new ReadableStream({ + start: (controller) => { + this.#stdout = controller; + }, + }), + stderr: new ReadableStream({ start: (controller) => controller.close() }), + exited: new Promise((resolve) => { + ended = () => resolve(); + }), + kill: () => { + this.killed += 1; + this.#stdout?.close(); + ended?.(); + }, + }; + } + + /** 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"); +} From fa94f9714c0a422658d09eb548cfa7a891468e6d Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 07:21:07 +0000 Subject: [PATCH 03/14] ci: check, lint and test every pull request The repository had no CI, so a change reached review without its type check or tests having run. None of the tasks needs the Claude CLI. --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ AGENTS.md | 2 ++ 2 files changed, 36 insertions(+) create mode 100644 .github/workflows/ci.yml 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 6043a8e..58bf23c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -474,6 +474,8 @@ 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 From 764ef71f9e3211bc3f1040a88cf5fa27c1209815 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:39:57 +0000 Subject: [PATCH 04/14] feat: refuse ACP requests that would replace the platform route or model --- AGENTS.md | 14 +++ src/services/claude-client.ts | 43 ++++++- src/services/model-guard.ts | 221 ++++++++++++++++++++++++++++++++++ src/services/model-route.ts | 4 + tests/model_route_test.ts | 169 ++++++++++++++++++++++++++ 5 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 src/services/model-guard.ts diff --git a/AGENTS.md b/AGENTS.md index 58bf23c..60a64fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -382,6 +382,20 @@ 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/*` 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. + ## Model discovery `claude-agent-acp` is a pure ACP server: it takes no subcommand, and its diff --git a/src/services/claude-client.ts b/src/services/claude-client.ts index 483aad6..7151611 100644 --- a/src/services/claude-client.ts +++ b/src/services/claude-client.ts @@ -6,6 +6,7 @@ import { } from "@ora-space/plugin-sdk"; import { spawnClaude } from "./command.ts"; import { logger } from "./log.ts"; +import { ModelGuard } from "./model-guard.ts"; import type { ModelRoute } from "./model-route.ts"; import { decodeLines, encodeLine } from "./ndjson.ts"; @@ -55,8 +56,10 @@ 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: ( @@ -71,6 +74,8 @@ export class ClaudeClient { #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; @@ -105,6 +110,7 @@ export class ClaudeClient { */ useRoute(route: ModelRoute | undefined): void { this.#route = route; + this.#guard = route === undefined ? undefined : new ModelGuard(route.model); } /** @@ -165,7 +171,7 @@ export class ClaudeClient { timer = setTimeout(() => resolve(undefined), ROUTE_TIMEOUT_MS); }); try { - await this.writeAcp({ + await this.#write({ jsonrpc: "2.0", id, method: "providers/set", @@ -200,8 +206,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"); @@ -307,7 +342,7 @@ export class ClaudeClient { 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 }, diff --git a/src/services/model-guard.ts b/src/services/model-guard.ts new file mode 100644 index 0000000..98b347f --- /dev/null +++ b/src/services/model-guard.ts @@ -0,0 +1,221 @@ +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; + +/** + * 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/"); +} + +/** + * 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, and remembers each session's + * config options so re-selecting the frozen model can be answered without reaching the adapter. + */ +export class ModelGuard { + readonly #model: FrozenModel; + /** 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) { + this.#model = 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 }; + } + + /** Observes, and may rewrite, one frame the adapter sent before Ora sees it. */ + outbound(frame: JsonValue): JsonValue { + if (!isObject(frame)) return frame; + if (typeof frame.method === "string") { + this.#observeUpdate(frame); + return frame; + } + const sessionId = hasId(frame) + ? this.#takePendingSession(frame.id) + : undefined; + if (!isObject(frame.result)) return frame; + const owner = typeof frame.result.sessionId === "string" + ? frame.result.sessionId + : sessionId; + if (owner !== undefined && Array.isArray(frame.result.configOptions)) { + this.#configOptions.set(owner, frame.result.configOptions); + } + return frame; + } + + /** A `config_option_update` replaces the whole set a session reports. */ + #observeUpdate(frame: { [key: string]: JsonValue }): void { + if (frame.method !== "session/update" || !isObject(frame.params)) return; + const update = frame.params.update; + if ( + isObject(update) && Array.isArray(update.configOptions) && + typeof frame.params.sessionId === "string" + ) { + this.#configOptions.set(frame.params.sessionId, update.configOptions); + } + } + + #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 index 2655e4f..9d1e15b 100644 --- a/src/services/model-route.ts +++ b/src/services/model-route.ts @@ -4,6 +4,7 @@ import { 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"; @@ -21,6 +22,8 @@ const PROVIDER_ID = "main"; */ 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"; @@ -62,6 +65,7 @@ export function modelRoute(context: AgentModelContext): ModelRoute { declarationFingerprint: configuration.declarationFingerprint, configurationRevision: configuration.configurationRevision, }, + model: { id: model, name: configuration.model.name }, provider: { providerId: PROVIDER_ID, apiType: "anthropic", diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts index 7cb2d38..f073789 100644 --- a/tests/model_route_test.ts +++ b/tests/model_route_test.ts @@ -50,6 +50,7 @@ Deno.test("a frozen context becomes one pinned Messages route", () => { declarationFingerprint: "b".repeat(64), configurationRevision: 7, }, + model: { id: "team/claude", name: "Team Claude" }, provider: { providerId: "main", apiType: "anthropic", @@ -190,6 +191,174 @@ Deno.test("a local start keeps the adapter's own routing and confirms nothing", 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"], + ] 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], + ); + 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(); +}); + +/** A client started on the platform route, with the route request already answered. */ +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. * From 6d5ccd932cfa7042fedb61d649eb1cf7e87b76d4 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:40:53 +0000 Subject: [PATCH 05/14] fix: run only the bundled adapter and CLI on a platform model route --- AGENTS.md | 6 ++++ scripts/adapter-entry.mjs | 26 +++++++++++++--- src/services/claude-client.ts | 7 +++-- src/services/command.ts | 33 ++++++++++++++++++++ src/services/model-route.ts | 3 ++ tests/model_route_test.ts | 57 +++++++++++++++++++++++++++++++++++ 6 files changed, 125 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 60a64fe..39c966d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -375,6 +375,12 @@ bridge right after spawn and **before** `agent/start` returns: serves only the frozen model id. - `ClaudeClient.useRoute` keeps the route for every later start, so an Effect `reactivate` restart reapplies it before the adapter counts as running. +- 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/adapter-entry.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-`) diff --git a/scripts/adapter-entry.mjs b/scripts/adapter-entry.mjs index 23c0b43..702f4a7 100644 --- a/scripts/adapter-entry.mjs +++ b/scripts/adapter-entry.mjs @@ -19,6 +19,13 @@ 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"; +/** + * 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. + */ +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. * @@ -31,6 +38,20 @@ const BIN_ENV_VAR = "ORA_CLAUDE_BIN"; * machine that has it. */ function resolveClaudeCli() { + // `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 (process.env[BUNDLED_ONLY_ENV_VAR] === "1") { + if (!isExecutableFile(bundled) || isUnrunnableHere(bundled)) { + 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 = process.env[BIN_ENV_VAR]?.trim(); if (pinned) { if (!isExecutableFile(pinned)) { @@ -41,11 +62,6 @@ function resolveClaudeCli() { 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" }; } diff --git a/src/services/claude-client.ts b/src/services/claude-client.ts index 7151611..e67117d 100644 --- a/src/services/claude-client.ts +++ b/src/services/claude-client.ts @@ -4,7 +4,7 @@ import { type JsonValue, PluginMethodError, } from "@ora-space/plugin-sdk"; -import { spawnClaude } from "./command.ts"; +import { spawnBundledClaude, spawnClaude } from "./command.ts"; import { logger } from "./log.ts"; import { ModelGuard } from "./model-guard.ts"; import type { ModelRoute } from "./model-route.ts"; @@ -390,7 +390,10 @@ export class ClaudeClient { "ClaudeClient cannot spawn before attachProcesses() runs", ); } - const child = await spawnClaude(this.#processes, { cwd, env }); + // 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), 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-route.ts b/src/services/model-route.ts index 9d1e15b..a9881ab 100644 --- a/src/services/model-route.ts +++ b/src/services/model-route.ts @@ -85,6 +85,9 @@ export function modelRoute(context: AgentModelContext): ModelRoute { // 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", }, }; } diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts index f073789..e074eb8 100644 --- a/tests/model_route_test.ts +++ b/tests/model_route_test.ts @@ -2,6 +2,8 @@ import { assertEquals, assertRejects } from "@std/assert"; import { AGENT_UNUSABLE, type AgentModelContext, + type HostChildProcessOptions, + HostRequestError, INVALID_PARAMS, type JsonValue, } from "@ora-space/plugin-sdk"; @@ -10,6 +12,8 @@ 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 { modelRoute } from "../src/services/model-route.ts"; const TOKEN = "synthetic-temporary-token"; @@ -66,6 +70,7 @@ Deno.test("a frozen context becomes one pinned Messages route", () => { CLAUDE_CODE_SUBAGENT_MODEL: "team/claude", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_AUTOUPDATER: "1", + ORA_CLAUDE_BUNDLED_CLI_ONLY: "1", }, }); }); @@ -343,6 +348,58 @@ Deno.test("a local start passes every ACP frame through untouched", async () => 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 honours no CLI pin or local install on a platform route", async () => { + // Read as text: this file is compiled by Bun into the adapter binary and never imported here. + const entry = await Deno.readTextFile( + new URL("../scripts/adapter-entry.mjs", import.meta.url), + ); + const resolver = entry.slice(entry.indexOf("function resolveClaudeCli()")); + const bundledOnly = resolver.indexOf( + 'process.env[BUNDLED_ONLY_ENV_VAR] === "1"', + ); + assertEquals(entry.includes('"ORA_CLAUDE_BUNDLED_CLI_ONLY"'), true); + assertEquals(bundledOnly > 0, true); + // The bundled-only branch returns before the pin or the local search is ever read. + assertEquals( + bundledOnly < resolver.indexOf("process.env[BIN_ENV_VAR]"), + true, + ); + assertEquals(bundledOnly < resolver.indexOf("findLocalClaude()"), true); +}); + /** A client started on the platform route, with the route request already answered. */ async function platformClient() { const adapter = new ScriptedAdapter("accept"); From 29b4395a4babeb43a7a83572c152594fee56efa0 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:41:35 +0000 Subject: [PATCH 06/14] feat: expose only the frozen model on a platform model route --- AGENTS.md | 6 +++ src/main.ts | 8 +++ src/services/claude-client.ts | 7 ++- src/services/model-guard.ts | 80 +++++++++++++++++++++-------- tests/model_route_test.ts | 97 +++++++++++++++++++++++++++++++++++ 5 files changed, 177 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 39c966d..972c8da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -402,6 +402,12 @@ the adapter and answers these itself, so the adapter never sees them: 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. + ## Model discovery `claude-agent-acp` is a pure ACP server: it takes no subcommand, and its diff --git a/src/main.ts b/src/main.ts index ace88c8..852119c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -120,6 +120,14 @@ class ClaudeAgentPlugin extends AgentPlugin { override onListModels = ( context: AgentListModelsContext, ): Promise => { + // 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. + const frozen = this.#client.frozenModel; + if (frozen !== undefined) { + return Promise.resolve([ + { id: frozen.id, displayName: frozen.name, default: true }, + ]); + } if (this.#processes === undefined) { throw new Error( `${AGENT_METHODS.listModels} was called before activation`, diff --git a/src/services/claude-client.ts b/src/services/claude-client.ts index e67117d..5f70316 100644 --- a/src/services/claude-client.ts +++ b/src/services/claude-client.ts @@ -6,7 +6,7 @@ import { } from "@ora-space/plugin-sdk"; import { spawnBundledClaude, spawnClaude } from "./command.ts"; import { logger } from "./log.ts"; -import { ModelGuard } from "./model-guard.ts"; +import { type FrozenModel, ModelGuard } from "./model-guard.ts"; import type { ModelRoute } from "./model-route.ts"; import { decodeLines, encodeLine } from "./ndjson.ts"; @@ -87,6 +87,11 @@ export class ClaudeClient { 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; } diff --git a/src/services/model-guard.ts b/src/services/model-guard.ts index 98b347f..f2c2d30 100644 --- a/src/services/model-guard.ts +++ b/src/services/model-guard.ts @@ -66,8 +66,9 @@ const SESSION_SETUP = new Set([ * * 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, and remembers each session's - * config options so re-selecting the frozen model can be answered without reaching the adapter. + * 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. */ export class ModelGuard { readonly #model: FrozenModel; @@ -80,6 +81,11 @@ export class ModelGuard { this.#model = model; } + /** 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") { @@ -121,36 +127,70 @@ export class ModelGuard { return { forward: frame }; } - /** Observes, and may rewrite, one frame the adapter sent before Ora sees it. */ + /** Rewrites one frame the adapter sent before Ora sees it. */ outbound(frame: JsonValue): JsonValue { if (!isObject(frame)) return frame; - if (typeof frame.method === "string") { - this.#observeUpdate(frame); - return frame; - } + if (typeof frame.method === "string") return this.#narrowUpdate(frame); const sessionId = hasId(frame) ? this.#takePendingSession(frame.id) : undefined; if (!isObject(frame.result)) return frame; - const owner = typeof frame.result.sessionId === "string" - ? frame.result.sessionId + const result = this.#narrowModels(frame.result); + const owner = typeof result.sessionId === "string" + ? result.sessionId : sessionId; - if (owner !== undefined && Array.isArray(frame.result.configOptions)) { - this.#configOptions.set(owner, frame.result.configOptions); + if (owner !== undefined && Array.isArray(result.configOptions)) { + this.#configOptions.set(owner, result.configOptions); } - return frame; + return { ...frame, result }; } - /** A `config_option_update` replaces the whole set a session reports. */ - #observeUpdate(frame: { [key: string]: JsonValue }): void { - if (frame.method !== "session/update" || !isObject(frame.params)) return; + /** 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) && - typeof frame.params.sessionId === "string" - ) { - this.#configOptions.set(frame.params.sessionId, update.configOptions); + 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 { diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts index e074eb8..f886276 100644 --- a/tests/model_route_test.ts +++ b/tests/model_route_test.ts @@ -400,6 +400,103 @@ Deno.test("the compiled adapter honours no CLI pin or local install on a platfor assertEquals(bundledOnly < resolver.indexOf("findLocalClaude()"), 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 () => { + // Read as text: importing the entrypoint would start serving the host. + const main = await Deno.readTextFile( + new URL("../src/main.ts", import.meta.url), + ); + const handler = main.slice(main.indexOf("override onListModels")); + assertEquals( + handler.indexOf("this.#client.frozenModel") < + handler.indexOf("listClaudeModels("), + true, + ); + assertEquals(new ClaudeClient().frozenModel, undefined); +}); + /** A client started on the platform route, with the route request already answered. */ async function platformClient() { const adapter = new ScriptedAdapter("accept"); From 3ea3029248fa9fc8d8cb780670bcb567beab7b0f Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:42:36 +0000 Subject: [PATCH 07/14] fix: redact adapter errors and route secrets on a platform model route --- AGENTS.md | 7 +++ src/services/claude-client.ts | 27 ++++++++-- src/services/model-guard.ts | 40 +++++++++++++- tests/model_route_test.ts | 99 ++++++++++++++++++++++++++++++++++- 4 files changed, 168 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 972c8da..c771591 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -408,6 +408,13 @@ model entry (and a `models` state, should a pin send one) in a result or a answers that model without starting a discovery probe, which would run on the host's own credentials and routing. +Nothing the adapter prints is trusted to keep the route's secrets. Every stdout +and stderr line has the token, the gateway base URL, its host and its hostname +replaced with `[redacted]` before it is parsed, forwarded or logged — the +adapter itself logs `[providers/set] ... baseUrl=...` — and every adapter error +response keeps its `code` but loses its `message` and `data`, which can carry a +provider response. Diagnostics stay in the log rather than being dropped. + ## Model discovery `claude-agent-acp` is a pure ACP server: it takes no subcommand, and its diff --git a/src/services/claude-client.ts b/src/services/claude-client.ts index 5f70316..9be84e5 100644 --- a/src/services/claude-client.ts +++ b/src/services/claude-client.ts @@ -115,7 +115,9 @@ export class ClaudeClient { */ useRoute(route: ModelRoute | undefined): void { this.#route = route; - this.#guard = route === undefined ? undefined : new ModelGuard(route.model); + this.#guard = route === undefined + ? undefined + : new ModelGuard(route.model, routeSecrets(route)); } /** @@ -317,7 +319,9 @@ export class ClaudeClient { */ async #pumpStdout(process: SpawnedProcess): Promise { try { - for await (const line of decodeLines(process.stdout)) { + for await (const raw of decodeLines(process.stdout)) { + // Scrubbed before anything reads it, so neither Ora nor the log below sees a secret. + const line = this.#guard?.scrub(raw) ?? raw; let frame: JsonValue; try { frame = JSON.parse(line) as JsonValue; @@ -371,7 +375,10 @@ export class ClaudeClient { try { for await (const line of decodeLines(process.stderr)) { if (line.length > 0) { - adapterLog.info(line, { context: { pid: process.pid } }); + // The adapter logs the gateway it routes to, and the CLI may echo a failed request. + adapterLog.info(this.#guard?.scrub(line) ?? line, { + context: { pid: process.pid }, + }); } } } catch (error) { @@ -415,6 +422,20 @@ 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, + ]; +} + /** 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/model-guard.ts b/src/services/model-guard.ts index f2c2d30..5ae8497 100644 --- a/src/services/model-guard.ts +++ b/src/services/model-guard.ts @@ -19,6 +19,11 @@ export type InboundDecision = 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. @@ -69,16 +74,35 @@ const SESSION_SETUP = new Set([ * 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) { + 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, sent or logged. */ + 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. */ @@ -134,6 +158,20 @@ export class ModelGuard { 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" diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts index f886276..4ba8510 100644 --- a/tests/model_route_test.ts +++ b/tests/model_route_test.ts @@ -2,6 +2,8 @@ import { assertEquals, assertRejects } from "@std/assert"; import { AGENT_UNUSABLE, type AgentModelContext, + createLogger, + createStderrLogSink, type HostChildProcessOptions, HostRequestError, INVALID_PARAMS, @@ -14,6 +16,7 @@ import { } 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"; @@ -497,6 +500,84 @@ Deno.test("model discovery on a platform route answers the frozen model without assertEquals(new ClaudeClient().frozenModel, undefined); }); +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 output", async () => { + const records: string[] = []; + installLogger(createLogger({ write: (line) => records.push(line) })); + try { + 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`, + }, + }, + }, + }); + adapter.emitLine(`not json ${TOKEN} gateway.internal:8443`); + adapter.emitStderr( + `[providers/set] apiType=anthropic baseUrl=https://gateway.internal:8443/runtime/anthropic key=${TOKEN}`, + ); + await waitFor(() => + forwarded.length === 1 && + records.some((record) => record.includes("providers/set")) && + records.some((record) => record.includes("non-JSON")) + ); + const text = JSON.stringify(forwarded) + records.join("\n"); + assertEquals(text.includes(TOKEN), false); + assertEquals(text.includes("gateway.internal"), false); + 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(); + } finally { + installLogger(createLogger(createStderrLogSink())); + } +}); + /** A client started on the platform route, with the route request already answered. */ async function platformClient() { const adapter = new ScriptedAdapter("accept"); @@ -523,6 +604,7 @@ class ScriptedAdapter { readonly environments: Record[] = []; killed = 0; #stdout: ReadableStreamDefaultController | undefined; + #stderr: ReadableStreamDefaultController | undefined; constructor(readonly answer: "accept" | "reject") {} @@ -557,18 +639,33 @@ class ScriptedAdapter { this.#stdout = controller; }, }), - stderr: new ReadableStream({ start: (controller) => controller.close() }), + 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?.(); }, }; } + /** 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( From 8b3d905cdac911fb03735bf89520502cd978b2d2 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 12:20:38 +0000 Subject: [PATCH 08/14] fix: keep the adapter's own text out of logs and classify route failures On a platform route the adapter's stderr is no longer republished, only counted, and a stdout line that is not a frame is logged by length. Claude Code echoes a failed request with the upstream's body, address and host paths, which scrubbing known secrets left in place (ADR D2). A route the adapter refuses (invalid_params, method_not_found) stays AGENT_UNUSABLE; a gateway model listing that failed, any other error and a timeout now fail with -32603, which Ora retries. --- src/services/claude-client.ts | 125 +++++++++++++++++++----- src/services/model-guard.ts | 2 +- tests/model_route_test.ts | 174 ++++++++++++++++++++++++++-------- 3 files changed, 234 insertions(+), 67 deletions(-) diff --git a/src/services/claude-client.ts b/src/services/claude-client.ts index 9be84e5..d33b1e0 100644 --- a/src/services/claude-client.ts +++ b/src/services/claude-client.ts @@ -17,6 +17,20 @@ 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-"; @@ -146,8 +160,8 @@ export class ClaudeClient { throw error; } this.#running = { process, stdinWriter: process.stdin.getWriter() }; - this.#attach(process); const route = this.#route; + this.#attach(process, route === undefined ? "verbatim" : "sensitive"); if (route !== undefined) { try { await this.#applyRoute(route); @@ -167,6 +181,10 @@ export class ClaudeClient { * 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}`; @@ -190,18 +208,27 @@ export class ClaudeClient { }, }); const answer = await Promise.race([answered, timedOut]); - const accepted = answer !== undefined && typeof answer === "object" && + if ( + answer !== undefined && typeof answer === "object" && answer !== null && !Array.isArray(answer) && "result" in answer && - !("error" in answer); - if (!accepted) { - log.warn("the adapter did not accept the platform model route", { - context: { timedOut: answer === undefined }, - }); - throw new PluginMethodError( + !("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 did not accept the platform model route", + "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); @@ -281,10 +308,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 @@ -317,19 +349,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 raw of decodeLines(process.stdout)) { - // Scrubbed before anything reads it, so neither Ora nor the log below sees a secret. + // 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; } @@ -370,16 +408,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) { - // The adapter logs the gateway it routes to, and the CLI may echo a failed request. - adapterLog.info(this.#guard?.scrub(line) ?? 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", { @@ -436,6 +488,29 @@ function routeSecrets(route: ModelRoute): string[] { ]; } +/** + * 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/model-guard.ts b/src/services/model-guard.ts index 5ae8497..97edd4c 100644 --- a/src/services/model-guard.ts +++ b/src/services/model-guard.ts @@ -94,7 +94,7 @@ export class ModelGuard { .sort((a, b) => b.length - a.length); } - /** Replaces every secret in one line of adapter output before it is parsed, sent or logged. */ + /** 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) { diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts index 4ba8510..be0b9ad 100644 --- a/tests/model_route_test.ts +++ b/tests/model_route_test.ts @@ -534,43 +534,79 @@ Deno.test("adapter errors reach Ora with their code but without their text", asy await client.stop(); }); -Deno.test("the token and gateway address never leave the adapter's output", async () => { +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, 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`, - }, - }, - }, - }); - adapter.emitLine(`not json ${TOKEN} gateway.internal:8443`); + 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 key=${TOKEN}`, + "[providers/set] apiType=anthropic baseUrl=https://gateway.internal:8443/runtime/anthropic", ); + adapter.emitStderr(`API Error: 503 ${leaks.join(" ")} ${TOKEN}`); + adapter.closeStderr(); await waitFor(() => - forwarded.length === 1 && - records.some((record) => record.includes("providers/set")) && - records.some((record) => record.includes("non-JSON")) + records.some((record) => record.includes("non-JSON")) && + records.some((record) => record.includes("suppressed")) ); - const text = JSON.stringify(forwarded) + records.join("\n"); - assertEquals(text.includes(TOKEN), false); - assertEquals(text.includes("gateway.internal"), false); - assertEquals( - (forwarded[0] as { - params: { update: { content: { text: string } } }; - }).params.update.content.text, - "API Error calling [redacted]/v1/messages with [redacted]; [redacted] unreachable", + 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 { @@ -578,7 +614,25 @@ Deno.test("the token and gateway address never leave the adapter's output", asyn } }); -/** A client started on the platform route, with the route request already answered. */ +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); +}); + async function platformClient() { const adapter = new ScriptedAdapter("accept"); const forwarded: JsonValue[] = []; @@ -606,7 +660,19 @@ class ScriptedAdapter { #stdout: ReadableStreamDefaultController | undefined; #stderr: ReadableStreamDefaultController | undefined; - constructor(readonly answer: "accept" | "reject") {} + /** 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 { @@ -621,15 +687,7 @@ class ScriptedAdapter { const frame = JSON.parse(line) as { id?: string; method?: string }; this.written.push(frame as JsonValue); if (frame.method === "providers/set") { - this.emit( - this.answer === "accept" - ? { jsonrpc: "2.0", id: frame.id ?? null, result: {} } - : { - jsonrpc: "2.0", - id: frame.id ?? null, - error: { code: -32602, message: `rejected ${TOKEN}` }, - }, - ); + this.#answerRoute(frame.id ?? null); } } }, @@ -656,6 +714,40 @@ class ScriptedAdapter { }; } + #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`)); From 74b58e714f54e4bb0b72fc24d507220758e801a6 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 12:20:38 +0000 Subject: [PATCH 09/14] fix: report the adapter ready only once its route is applied A process waiting for providers/set was already running, so verify_ready could pass before the route existed. ClaudeClient.ready tracks the applied route per generation, and readiness reads it. --- src/handlers/effects.ts | 2 +- src/services/claude-client.ts | 13 +++++++++++++ tests/model_route_test.ts | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/handlers/effects.ts b/src/handlers/effects.ts index 8d6ebfd..19fde9f 100644 --- a/src/handlers/effects.ts +++ b/src/handlers/effects.ts @@ -239,7 +239,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 }, diff --git a/src/services/claude-client.ts b/src/services/claude-client.ts index d33b1e0..3cc8521 100644 --- a/src/services/claude-client.ts +++ b/src/services/claude-client.ts @@ -85,6 +85,8 @@ export class ClaudeClient { /** 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; @@ -110,6 +112,15 @@ export class ClaudeClient { 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. * @@ -146,6 +157,7 @@ export class ClaudeClient { 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 }, @@ -169,6 +181,7 @@ export class ClaudeClient { await this.stop(); throw error; } + this.#routeApplied = true; } log.info("adapter running", { context: { cwd, pid: process.pid, platformRoute: route !== undefined }, diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts index be0b9ad..575c73a 100644 --- a/tests/model_route_test.ts +++ b/tests/model_route_test.ts @@ -633,6 +633,25 @@ Deno.test("a route the adapter could not prepare fails the start as retryable", 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); +}); + async function platformClient() { const adapter = new ScriptedAdapter("accept"); const forwarded: JsonValue[] = []; From ea3fde8912407d5a083dac8f74dafbbf26c85184 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 12:20:38 +0000 Subject: [PATCH 10/14] fix: lower the Skill barrier when the reactivation restart fails A restart that failed left the barrier up and every held prompt waiting for the life of the process. The barrier is now released in a finally; held turns that no adapter can receive are dropped, as a frame arriving while the adapter is down is. --- src/handlers/effects.ts | 36 +++++++++++++++++++++++++++--------- tests/main_test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/handlers/effects.ts b/src/handlers/effects.ts index 19fde9f..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 }, @@ -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/tests/main_test.ts b/tests/main_test.ts index 669d4b4..e04501a 100644 --- a/tests/main_test.ts +++ b/tests/main_test.ts @@ -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( From 7eb2d798efb66b1328177846c55f000b67fdb5d3 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 12:20:38 +0000 Subject: [PATCH 11/14] fix: check the whole frozen context before applying a route Refuse a context frozen for another plugin or target, a token with a control character (the adapter would only fail later, retryably) and a non-positive maxTokens, and pass the frozen maxTokens as CLAUDE_CODE_MAX_OUTPUT_TOKENS. --- src/main.ts | 4 +--- src/services/model-route.ts | 23 +++++++++++++++++++++-- tests/model_route_test.ts | 27 ++++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/main.ts b/src/main.ts index 852119c..3d2796d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,9 +23,7 @@ import { } 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. diff --git a/src/services/model-route.ts b/src/services/model-route.ts index a9881ab..514401d 100644 --- a/src/services/model-route.ts +++ b/src/services/model-route.ts @@ -9,6 +9,16 @@ 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`. * @@ -39,7 +49,9 @@ export interface ModelRoute { * 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. The error never echoes the context, which carries the token. + * 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; @@ -50,11 +62,16 @@ export function modelRoute(context: AgentModelContext): ModelRoute { 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 + proxy === undefined || context.token.length === 0 || + CONTROL_CHARACTERS.test(context.token) || + !Number.isSafeInteger(maxTokens) || maxTokens <= 0 ) { refuse(); } @@ -81,6 +98,8 @@ export function modelRoute(context: AgentModelContext): ModelRoute { 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", diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts index 575c73a..2e398a5 100644 --- a/tests/model_route_test.ts +++ b/tests/model_route_test.ts @@ -29,7 +29,8 @@ function context( configuration: { pluginId: "official/ora-space.claude", pluginVersion: "0.4.0", - target: "x86_64-unknown-linux-gnu", + // 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), @@ -71,6 +72,7 @@ Deno.test("a frozen context becomes one pinned Messages route", () => { 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", @@ -92,6 +94,29 @@ Deno.test("configurations this release cannot apply are refused without echoing 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" }), From 1a9577f72b5be0fd0c0806d43ecac1fd08690418 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 12:20:38 +0000 Subject: [PATCH 12/14] fix: refuse _providers/* like providers/* The pinned adapter serves no _providers/* method, but the guard now covers the extension spelling the same way it covers _auth/*. --- src/services/model-guard.ts | 2 +- tests/model_route_test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/model-guard.ts b/src/services/model-guard.ts index 97edd4c..aaa88ad 100644 --- a/src/services/model-guard.ts +++ b/src/services/model-guard.ts @@ -36,7 +36,7 @@ const REDACTED = "[redacted]"; function isRouteMutation(method: string): boolean { return method === "authenticate" || method === "logout" || method.startsWith("auth/") || method.startsWith("_auth/") || - method.startsWith("providers/"); + method.startsWith("providers/") || method.startsWith("_providers/"); } /** diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts index 2e398a5..bdecc18 100644 --- a/tests/model_route_test.ts +++ b/tests/model_route_test.ts @@ -234,6 +234,7 @@ Deno.test("a client cannot replace, drop or re-authenticate the platform route", [14, "providers/set"], [15, "providers/disable"], [16, "providers/list"], + [17, "_providers/set"], ] as const ) { await client.writeAcp({ @@ -248,7 +249,7 @@ Deno.test("a client cannot replace, drop or re-authenticate the platform route", assertEquals(adapter.written.length, 1); assertEquals( forwarded.map((frame) => (frame as { id: number }).id), - [11, 12, 13, 14, 15, 16], + [11, 12, 13, 14, 15, 16, 17], ); for (const frame of forwarded) { assertEquals((frame as { error: { code: number } }).error.code, -32601); From c7c941960603b02d04f4ad0d30da4b17e4f48291 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 12:20:38 +0000 Subject: [PATCH 13/14] test: run model discovery and CLI resolution instead of reading their source agent/list_models moves into a handler a test can call with a started client, and the compiled adapter's CLI ladder moves into scripts/resolve-claude-cli.mjs, which packaging stages beside the entry point and tests run against real files. --- scripts/adapter-entry.mjs | 138 ++------------------------------ scripts/package.ts | 5 ++ scripts/resolve-claude-cli.mjs | 142 +++++++++++++++++++++++++++++++++ src/handlers/models.ts | 33 +++++++- src/main.ts | 23 +----- tests/main_test.ts | 10 +-- tests/model_route_test.ts | 116 ++++++++++++++++++++------- 7 files changed, 282 insertions(+), 185 deletions(-) create mode 100644 scripts/resolve-claude-cli.mjs diff --git a/scripts/adapter-entry.mjs b/scripts/adapter-entry.mjs index 702f4a7..d19abd9 100644 --- a/scripts/adapter-entry.mjs +++ b/scripts/adapter-entry.mjs @@ -11,137 +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"; - -/** - * 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. - */ -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. - */ -function resolveClaudeCli() { - // `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 (process.env[BUNDLED_ONLY_ENV_VAR] === "1") { - if (!isExecutableFile(bundled) || isUnrunnableHere(bundled)) { - 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 = 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" }; - } - - 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) { @@ -152,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/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 3d2796d..026659b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,6 @@ import type { HostProcesses, JsonValue, } from "@ora-space/plugin-sdk"; -import { AGENT_METHODS } from "@ora-space/plugin-sdk"; import { type AgentListModelsContext, AgentPlugin, @@ -19,7 +18,7 @@ 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"; @@ -117,24 +116,8 @@ class ClaudeAgentPlugin extends AgentPlugin { override onListModels = ( context: AgentListModelsContext, - ): Promise => { - // 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. - const frozen = this.#client.frozenModel; - if (frozen !== undefined) { - return Promise.resolve([ - { id: frozen.id, displayName: frozen.name, default: true }, - ]); - } - 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/tests/main_test.ts b/tests/main_test.ts index e04501a..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, diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts index bdecc18..a7d898e 100644 --- a/tests/model_route_test.ts +++ b/tests/model_route_test.ts @@ -1,15 +1,18 @@ -import { assertEquals, assertRejects } from "@std/assert"; +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, @@ -21,6 +24,28 @@ 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 = {}, @@ -410,23 +435,35 @@ Deno.test("a platform route spawns only the bundled adapter, ignoring a pin and assertEquals(spawns[0].env?.ORA_CLAUDE_BUNDLED_CLI_ONLY, "1"); }); -Deno.test("the compiled adapter honours no CLI pin or local install on a platform route", async () => { - // Read as text: this file is compiled by Bun into the adapter binary and never imported here. - const entry = await Deno.readTextFile( - new URL("../scripts/adapter-entry.mjs", import.meta.url), - ); - const resolver = entry.slice(entry.indexOf("function resolveClaudeCli()")); - const bundledOnly = resolver.indexOf( - 'process.env[BUNDLED_ONLY_ENV_VAR] === "1"', - ); - assertEquals(entry.includes('"ORA_CLAUDE_BUNDLED_CLI_ONLY"'), true); - assertEquals(bundledOnly > 0, true); - // The bundled-only branch returns before the pin or the local search is ever read. - assertEquals( - bundledOnly < resolver.indexOf("process.env[BIN_ENV_VAR]"), - true, - ); - assertEquals(bundledOnly < resolver.indexOf("findLocalClaude()"), true); +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 () => { @@ -513,17 +550,25 @@ Deno.test("every model selector the adapter reports offers only the frozen model }); Deno.test("model discovery on a platform route answers the frozen model without a probe", async () => { - // Read as text: importing the entrypoint would start serving the host. - const main = await Deno.readTextFile( - new URL("../src/main.ts", import.meta.url), - ); - const handler = main.slice(main.indexOf("override onListModels")); - assertEquals( - handler.indexOf("this.#client.frozenModel") < - handler.indexOf("listClaudeModels("), - true, + 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", ); - assertEquals(new ClaudeClient().frozenModel, undefined); }); Deno.test("adapter errors reach Ora with their code but without their text", async () => { @@ -678,6 +723,21 @@ Deno.test("an adapter waiting for its route is running but not ready", async () 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[] = []; From 014d0d57094ad0d00ce122f329454bd43f8b44ee Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 12:20:39 +0000 Subject: [PATCH 14/14] docs: describe the platform route's output, failures and token placement Correct the claim that the token stays in the providers/set header: the pinned adapter merges it into Claude Code's environment and programmatic settings, which the SDK passes as a --settings argument. --- AGENTS.md | 77 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c771591..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 @@ -366,21 +369,42 @@ bridge right after spawn and **before** `agent/start` returns: 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 only as the `x-api-key` header of that request, which the - platform gateway reads first on its Messages route; it is never placed in the - adapter's environment or logged. +- 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. + 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 running. + `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/adapter-entry.mjs` refuse `ORA_CLAUDE_BIN` and a local install the - same way. + `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-`) @@ -392,9 +416,9 @@ 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/*` and `providers/*` — `logout` and - `providers/disable` clear the route and send Claude Code back to its own - login, the others install a different upstream. +- `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`, @@ -408,12 +432,17 @@ model entry (and a `models` state, should a pin send one) in a result or a answers that model without starting a discovery probe, which would run on the host's own credentials and routing. -Nothing the adapter prints is trusted to keep the route's secrets. Every stdout -and stderr line has the token, the gateway base URL, its host and its hostname -replaced with `[redacted]` before it is parsed, forwarded or logged — the -adapter itself logs `[providers/set] ... baseUrl=...` — and every adapter error -response keeps its `code` but loses its `message` and `data`, which can carry a -provider response. Diagnostics stay in the log rather than being dropped. +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 @@ -474,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 @@ -512,7 +542,8 @@ Ora parses and validates that table today but does not yet enforce it. - 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