From 79067c0b8910a47a2cc098fee305eb43c7005b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:52:41 +0800 Subject: [PATCH] feat(claude): apply frozen platform model bindings in the owned adapter --- .github/workflows/ci.yml | 21 +++ AGENTS.md | 13 +- README.md | 21 +++ deno.json | 6 +- orax.toml | 2 +- scripts/adapter-entry.mjs | 7 + src/base/agent-plugin.ts | 6 +- src/handlers/lifecycle.ts | 37 +++++- src/main.ts | 7 +- src/services/claude-client.ts | 83 ++++++++++-- src/services/command.ts | 19 +++ src/services/model-adapter.ts | 146 +++++++++++++++++++++ tests/model_adapter_test.ts | 239 ++++++++++++++++++++++++++++++++++ 13 files changed, 577 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/services/model-adapter.ts create mode 100644 tests/model_adapter_test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..435bc43 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - run: deno task check + - run: deno task lint + - run: deno task test diff --git a/AGENTS.md b/AGENTS.md index ed3b11e..d2c20d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -382,13 +382,12 @@ a start/stop cycle around a picker opening makes the next open pay full price. every `console` method through the plugin logger before any plugin code runs. A single `console.log` reaching stdout is read by the host as a corrupt frame and takes the plugin down. -- **ACP payloads are never parsed** on the bridge. Frames are re-framed between - 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`. +- **ACP routing** stays opaque for legacy starts and unrelated frames. When + `modelContext` is present, `services/model-adapter.ts` owns the model/routing + fields and safe error envelopes; read + [Platform model bindings](README.md#platform-model-bindings) before changing + this seam. Effect tracking reads only envelope `method`/`id`. Keep payload + values out of logs; private provider diagnostics stay suppressed. - **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/README.md b/README.md index 7d36e07..c6c7c4b 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,27 @@ takes care of getting Claude Code to pick them up — no manual restart needed. | -------------------- | ---------------------------------------------------------------------------- | | `ORA_CLAUDE_ACP_BIN` | Pin an exact path to the Claude ACP adapter binary, bypassing `PATH` lookup. | +## Platform model bindings + +The SDK 0.11 startup contract can supply one frozen `modelContext`. This mode +requires the bundled adapter/native CLI pair and a private execution HOME from +the host. It uses only Anthropic Messages, pins the primary model and every +model alias, disables fallback, and overrides query settings sources and routing +before the first prompt. Discovery returns only the bound model. Legacy starts +omit the context and retain their existing behavior. + +The temporary token is supplied only in the child environment. Programmatic +settings contain no token literal: the pinned SDK serializes them into CLI +arguments. Provider/authentication replacement and another model are refused; +private diagnostics are suppressed while protocol error codes remain intact. +Node owns renewal, revocation and process cleanup. Settings are validated JSON +text; this declaration currently contributes no additional options. + +Declaration packaging remains an explicit `--agent-configuration` opt-in after +compatible hosts are deployed. Every target and the published static copy must +contain identical declaration bytes. Keep the default publication gate off +during host rollout. + ## License Apache-2.0 diff --git a/deno.json b/deno.json index 819fccc..02583d6 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@ora-space/claude-agent", - "version": "0.3.23", + "version": "0.3.24", "exports": "./src/main.ts", "minimumDependencyAge": 0, "imports": { @@ -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_adapter_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_adapter_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/orax.toml b/orax.toml index 93e8125..111e006 100644 --- a/orax.toml +++ b/orax.toml @@ -3,7 +3,7 @@ title = "Claude Code" identifier = "ora-space.claude" namespace = "official" kind = "agent" -version = "0.3.23" +version = "0.3.24" description = "Ora Space Claude Code Agent" homepage = "https://github.com/ora-space/claude-code-agent" license = "Apache-2.0" diff --git a/scripts/adapter-entry.mjs b/scripts/adapter-entry.mjs index 23c0b43..849396c 100644 --- a/scripts/adapter-entry.mjs +++ b/scripts/adapter-entry.mjs @@ -31,6 +31,13 @@ const BIN_ENV_VAR = "ORA_CLAUDE_BIN"; * machine that has it. */ function resolveClaudeCli() { + if (process.env.ORA_CLAUDE_PLATFORM_MODEL === "1") { + const bundled = join(dirname(process.execPath), CLAUDE_BINARY_NAME); + if (!isExecutableFile(bundled) || isUnrunnableHere(bundled)) { + throw new Error("platform model requires the bundled CLI"); + } + return { path: bundled, source: "bundled" }; + } const pinned = process.env[BIN_ENV_VAR]?.trim(); if (pinned) { if (!isExecutableFile(pinned)) { diff --git a/src/base/agent-plugin.ts b/src/base/agent-plugin.ts index 638b09c..38a520d 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, @@ -93,7 +94,7 @@ export abstract class AgentPlugin { 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 +146,8 @@ export async function runAgentPlugin( () => invoke(routes, AGENT_METHOD_ROUTES.onStart, startContext, send) as | void - | Promise, + | AgentModelApplied + | Promise, ), stop: () => traced( diff --git a/src/handlers/lifecycle.ts b/src/handlers/lifecycle.ts index 6dd071a..a0f887c 100644 --- a/src/handlers/lifecycle.ts +++ b/src/handlers/lifecycle.ts @@ -1,5 +1,12 @@ -import type { AgentStartContext } from "@ora-space/plugin-sdk"; -import { INVALID_PARAMS, PluginMethodError } from "@ora-space/plugin-sdk"; +import type { + AgentModelApplied, + AgentStartContext, +} from "@ora-space/plugin-sdk"; +import { + AGENT_UNUSABLE, + INVALID_PARAMS, + PluginMethodError, +} from "@ora-space/plugin-sdk"; import type { ClaudeClient } from "../services/claude-client.ts"; import { logger } from "../services/log.ts"; @@ -15,7 +22,7 @@ const log = logger("lifecycle"); 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 +30,32 @@ export async function startClaude( "agent/start requires a non-empty cwd", ); } - await client.start(context.cwd); + try { + client.setModelContext(context.modelContext); + await client.start(context.cwd); + } catch (error) { + await client.stop(); + if (context.modelContext) { + throw new PluginMethodError( + error instanceof PluginMethodError && error.code === AGENT_UNUSABLE + ? AGENT_UNUSABLE + : INVALID_PARAMS, + "claude_model_adapter_failed", + ); + } + throw error; + } log.info("Claude ACP adapter started for agent/start", { context: { cwd: context.cwd }, }); + const frozen = context.modelContext?.configuration; + if (frozen) { + return { + adapterVersion: frozen.adapterVersion, + declarationFingerprint: frozen.declarationFingerprint, + configurationRevision: frozen.configurationRevision, + }; + } } /** diff --git a/src/main.ts b/src/main.ts index 36af659..8bc19dd 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 => { @@ -119,6 +120,8 @@ class ClaudeAgentPlugin extends AgentPlugin { override onListModels = ( context: AgentListModelsContext, ): Promise => { + const frozen = this.#client.frozenModel; + if (frozen) return Promise.resolve([frozen]); 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 ed44fe5..97b4553 100644 --- a/src/services/claude-client.ts +++ b/src/services/claude-client.ts @@ -1,5 +1,16 @@ -import type { HostProcesses, JsonValue } from "@ora-space/plugin-sdk"; -import { spawnClaude } from "./command.ts"; +import type { + AgentModel, + AgentModelContext, + HostProcesses, + JsonValue, +} from "@ora-space/plugin-sdk"; +import { spawnClaude, spawnClaudeModel } from "./command.ts"; +import { + claudeGatewayBase, + claudeModelEnvironment, + pinClaudeModelFrame, + redactClaudeModelError, +} from "./model-adapter.ts"; import { logger } from "./log.ts"; import { decodeLines, encodeLine } from "./ndjson.ts"; @@ -54,6 +65,21 @@ export class ClaudeClient { #processes: HostProcesses | undefined; #running: RunningProcess | undefined; #expectedExit = false; + #modelContext: AgentModelContext | undefined; + + /** A start replaces the execution context; Effect restarts retain that same frozen binding. */ + setModelContext(context: AgentModelContext | undefined): void { + if (context) claudeGatewayBase(context); + this.#modelContext = context; + } + + /** Active discovery never probes ambient credentials or uses a cache shared by workspace cwd. */ + get frozenModel(): AgentModel | undefined { + const model = this.#modelContext?.configuration.model; + return model + ? { id: model.id, displayName: model.name, default: true } + : undefined; + } constructor(options: ClaudeClientOptions = {}) { this.#spawn = options.spawn ?? ((cwd) => this.#spawnViaHost(cwd)); @@ -100,7 +126,7 @@ export class ClaudeClient { throw error; } this.#running = { process, stdinWriter: process.stdin.getWriter() }; - this.#attach(process); + this.#attach(process, this.#modelContext !== undefined); log.info("adapter running", { context: { cwd, pid: process.pid } }); } @@ -111,6 +137,24 @@ export class ClaudeClient { * an unbounded queue inside this process. */ async writeAcp(frame: JsonValue): Promise { + if (this.#modelContext) { + const pinned = pinClaudeModelFrame(frame, this.#modelContext); + if (pinned === undefined) { + if ( + frame !== null && typeof frame === "object" && + !Array.isArray(frame) && + (typeof frame.id === "number" || typeof frame.id === "string") + ) { + this.#onAcpFrame({ + jsonrpc: "2.0", + id: frame.id, + error: { code: -32602, message: "The execution model is frozen" }, + }); + } + return; + } + frame = pinned; + } const running = this.#running; if (running === undefined) { throw new Error("the Claude agent is not running"); @@ -149,9 +193,9 @@ 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); + #attach(process: SpawnedProcess, sensitive: boolean): void { + void this.#pumpStdout(process, sensitive); + void this.#pumpStderr(process, sensitive); void process.exited.then(() => { // A process that is no longer `#running` was already superseded by a later `start()` (an // Effect restart, for instance); its death is old news, not a live agent going away, so it @@ -184,7 +228,10 @@ 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, + sensitive: boolean, + ): Promise { try { for await (const line of decodeLines(process.stdout)) { let frame: JsonValue; @@ -194,7 +241,10 @@ export class ClaudeClient { // 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. log.warn("dropping a non-JSON stdout line from the adapter", { - context: { pid: process.pid, line: line.slice(0, 512) }, + context: { + pid: process.pid, + line: sensitive ? undefined : line.slice(0, 512), + }, }); continue; } @@ -209,6 +259,7 @@ export class ClaudeClient { log.debug("adapter ACP frame received", { context: { pid: process.pid, ...summarize(frame) }, }); + if (sensitive) frame = redactClaudeModelError(frame); this.#onAcpFrame(frame); } log.debug("adapter stdout reached EOF", { @@ -229,11 +280,16 @@ export class ClaudeClient { * recorded at `info` under its own target rather than guessed at; the host's per-plugin level * decides whether it is kept. */ - async #pumpStderr(process: SpawnedProcess): Promise { + async #pumpStderr( + process: SpawnedProcess, + sensitive: boolean, + ): Promise { try { for await (const line of decodeLines(process.stderr)) { if (line.length > 0) { - adapterLog.info(line, { context: { pid: process.pid } }); + if (!sensitive) { + adapterLog.info(line, { context: { pid: process.pid } }); + } } } } catch (error) { @@ -254,7 +310,12 @@ export class ClaudeClient { "ClaudeClient cannot spawn before attachProcesses() runs", ); } - const child = await spawnClaude(this.#processes, { cwd }); + const child = this.#modelContext + ? await spawnClaudeModel(this.#processes, { + cwd, + env: claudeModelEnvironment(this.#modelContext), + }) + : await spawnClaude(this.#processes, { cwd }); return { stdin: new WritableStream({ write: (chunk) => child.write(chunk), diff --git a/src/services/command.ts b/src/services/command.ts index 2b84116..1358f9d 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,24 @@ export function spawnClaude( return spawnResolved(processes, invocation); } +/** A frozen execution must use its verified package, never a host pin or PATH fallback. */ +export async function spawnClaudeModel( + processes: HostProcesses, + invocation: AgentInvocation, +): Promise { + try { + return await processes.spawn({ + packageCommand: bundledAdapterPath(), + ...invocation, + }); + } catch { + throw new PluginMethodError( + AGENT_UNUSABLE, + "The frozen package's bundled adapter cannot run", + ); + } +} + /** * 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-adapter.ts b/src/services/model-adapter.ts new file mode 100644 index 0000000..1265b8d --- /dev/null +++ b/src/services/model-adapter.ts @@ -0,0 +1,146 @@ +import type { AgentModelContext, JsonValue } from "@ora-space/plugin-sdk"; +import { join } from "@std/path"; + +/** A bounded failure never includes a credential, runtime token or third-party response. */ +export function modelAdapterFailure(): Error { + return new Error("claude_model_adapter_failed"); +} + +/** Validates the single Messages binding and derives the native SDK's endpoint prefix. */ +export function claudeGatewayBase(context: AgentModelContext): string { + const configuration = context.configuration; + if ( + configuration.protocol !== "anthropic-messages" || + configuration.target !== Deno.build.target || + configuration.pluginId.split("/").at(-1) !== "ora-space.claude" || + /[\r\n]/.test(context.token) + ) throw modelAdapterFailure(); + let settings: unknown; + try { + settings = JSON.parse(configuration.settingsJson); + } catch { + throw modelAdapterFailure(); + } + if (!isObject(settings) || Object.keys(settings).length !== 0) { + throw modelAdapterFailure(); + } + let url: URL; + try { + url = new URL(context.proxyBaseUrl); + } catch { + throw modelAdapterFailure(); + } + if ( + url.protocol !== "https:" || url.username || url.password || url.search || + url.hash || url.pathname !== "/runtime/anthropic/v1" + ) throw modelAdapterFailure(); + url.pathname = "/runtime/anthropic"; + return url.toString().replace(/\/$/, ""); +} + +/** Host-owned child environment: authority stays in memory, never CLI arguments or settings files. */ +export function claudeModelEnvironment( + context: AgentModelContext, + home = Deno.env.get("HOME") ?? "", +): Record { + const base = claudeGatewayBase(context); + if (!home) throw modelAdapterFailure(); + const model = context.configuration.model.id; + return { + ORA_CLAUDE_PLATFORM_MODEL: "1", + CLAUDE_CONFIG_DIR: join(home, ".claude-platform"), + ANTHROPIC_BASE_URL: base, + ANTHROPIC_API_KEY: "", + ANTHROPIC_AUTH_TOKEN: context.token, + CLAUDE_CODE_OAUTH_TOKEN: "", + ANTHROPIC_CUSTOM_HEADERS: "", + CLAUDE_CODE_USE_BEDROCK: "0", + CLAUDE_CODE_USE_VERTEX: "0", + CLAUDE_MODEL_CONFIG: "", + ANTHROPIC_MODEL: model, + ANTHROPIC_DEFAULT_OPUS_MODEL: model, + ANTHROPIC_DEFAULT_SONNET_MODEL: model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: model, + CLAUDE_CODE_SUBAGENT_MODEL: model, + CLAUDE_CODE_MAX_OUTPUT_TOKENS: String( + context.configuration.model.maxTokens, + ), + DISABLE_NON_ESSENTIAL_TRAFFIC: "1", + CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION: "false", + }; +} + +/** Pins every query-creation path and refuses public calls that could replace the provider. */ +export function pinClaudeModelFrame( + frame: JsonValue, + context: AgentModelContext, + home = Deno.env.get("HOME") ?? "", +): JsonValue | undefined { + if (!isObject(frame) || typeof frame.method !== "string") return frame; + const method = frame.method; + if ( + method === "authenticate" || method === "logout" || + method.startsWith("providers/") || method.startsWith("_auth/") + ) return undefined; + const params = isObject(frame.params) ? frame.params : {}; + const model = context.configuration.model.id; + if (method === "session/set_model" && params.modelId !== model) { + return undefined; + } + if ( + method === "session/set_config_option" && params.configId === "model" && + params.value !== model + ) return undefined; + if ( + !["session/new", "session/load", "session/resume", "session/fork"].includes( + method, + ) + ) return frame; + const meta = isObject(params._meta) ? params._meta : {}; + const claude = isObject(meta.claudeCode) ? meta.claudeCode : {}; + const options = isObject(claude.options) ? claude.options : {}; + const lockedOptions = { ...options }; + // The pinned SDK rejects a fallback equal to the primary model; single-model runs disable it. + delete lockedOptions.fallbackModel; + const env = claudeModelEnvironment(context, home); + // The SDK serializes programmatic settings into CLI arguments. The token must stay in env only. + delete env.ANTHROPIC_AUTH_TOKEN; + return { + ...frame, + params: { + ...params, + modelId: model, + _meta: { + ...meta, + claudeCode: { + ...claude, + options: { + ...lockedOptions, + model, + settingSources: [], + env, + settings: { apiKeyHelper: "", env }, + extraArgs: {}, + promptSuggestions: false, + }, + }, + }, + }, + }; +} + +/** Preserves protocol error classification while dropping private provider diagnostics. */ +export function redactClaudeModelError(frame: JsonValue): JsonValue { + if (!isObject(frame) || !("error" in frame)) return frame; + const candidate = isObject(frame.error) ? frame.error.code : undefined; + const code = typeof candidate === "number" && Number.isInteger(candidate) && + candidate >= -2147483648 && candidate <= 2147483647 + ? candidate + : -32603; + return { ...frame, error: { code, message: "Agent operation failed" } }; +} + +/** Narrows only JSON objects; no untrusted value is rendered or executed. */ +function isObject(value: unknown): value is { [key: string]: JsonValue } { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/tests/model_adapter_test.ts b/tests/model_adapter_test.ts new file mode 100644 index 0000000..400b559 --- /dev/null +++ b/tests/model_adapter_test.ts @@ -0,0 +1,239 @@ +import { assertEquals, assertRejects, assertThrows } from "@std/assert"; +import type { + AgentModelContext, + HostChildProcessOptions, + HostProcesses, + JsonValue, +} from "@ora-space/plugin-sdk"; +import { + claudeGatewayBase, + claudeModelEnvironment, + pinClaudeModelFrame, + redactClaudeModelError, +} from "../src/services/model-adapter.ts"; +import { AGENT_UNUSABLE, PluginMethodError } from "@ora-space/plugin-sdk"; + +Deno.test("model-mode error privacy keeps protocol classification available", () => { + const token = context().token; + const frame = redactClaudeModelError({ + jsonrpc: "2.0", + id: 7, + error: { code: -32601, message: token, data: { private: token } }, + }); + assertEquals(frame, { + jsonrpc: "2.0", + id: 7, + error: { code: -32601, message: "Agent operation failed" }, + }); +}); +import { spawnClaudeModel } from "../src/services/command.ts"; +import { ClaudeClient } from "../src/services/claude-client.ts"; +import { startClaude } from "../src/handlers/lifecycle.ts"; + +/** Synthetic binding; no upstream credential or user profile is read by these tests. */ +function context(): AgentModelContext { + return { + configuration: { + pluginId: "official/ora-space.claude", + pluginVersion: "1.0.0", + target: Deno.build.target, + packageSha256: "a".repeat(64), + schemaVersion: 2, + declarationFingerprint: "b".repeat(64), + adapterVersion: 1, + configurationRevision: 7, + settingsJson: "{}", + protocol: "anthropic-messages", + model: { + id: "vendor/model", + name: "Frozen Model", + contextWindow: 128000, + maxTokens: 8192, + }, + }, + proxyBaseUrl: "https://gateway.invalid/runtime/anthropic/v1", + token: "synthetic-runtime-token", + }; +} + +Deno.test("Claude pins every alias to the frozen model and keeps its token out of settings and ACP", () => { + const frozen = context(); + const env = claudeModelEnvironment(frozen, "/private/execution"); + for ( + const name of [ + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "CLAUDE_CODE_SUBAGENT_MODEL", + ] + ) assertEquals(env[name], "vendor/model"); + assertEquals(env.ANTHROPIC_AUTH_TOKEN, frozen.token); + assertEquals( + claudeGatewayBase(frozen), + "https://gateway.invalid/runtime/anthropic", + ); + for ( + const method of [ + "session/new", + "session/load", + "session/resume", + "session/fork", + ] + ) { + const pinned = pinClaudeModelFrame( + { + jsonrpc: "2.0", + id: 1, + method, + params: { + cwd: "/project", + _meta: { + claudeCode: { + options: { + model: "other", + settingSources: ["project"], + env: { + ANTHROPIC_BASE_URL: "https://other.invalid", + ANTHROPIC_API_KEY: "stray-key", + }, + settings: ".claude/settings.json", + extraArgs: { settings: "other.json" }, + }, + }, + }, + }, + }, + frozen, + "/private/execution", + ); + const text = JSON.stringify(pinned); + assertEquals(text.includes(frozen.token), false); + assertEquals(text.includes("other.invalid"), false); + assertEquals(text.includes("stray-key"), false); + assertEquals(text.includes('"settingSources":[]'), true); + assertEquals(text.includes('"model":"vendor/model"'), true); + assertEquals(text.includes('"fallbackModel"'), false); + } +}); + +Deno.test("Claude refuses provider replacement, another model and incompatible contexts", () => { + const frozen = context(); + for ( + const method of [ + "authenticate", + "logout", + "providers/set", + "providers/disable", + "_auth/login", + ] + ) assertEquals(pinClaudeModelFrame({ id: 1, method }, frozen), undefined); + assertEquals( + pinClaudeModelFrame({ + id: 1, + method: "session/set_model", + params: { modelId: "other" }, + }, frozen), + undefined, + ); + assertEquals( + pinClaudeModelFrame({ + id: 1, + method: "session/set_config_option", + params: { configId: "model", value: "other" }, + }, frozen), + undefined, + ); + const other = context(); + other.configuration.protocol = "openai-completions"; + assertThrows( + () => claudeGatewayBase(other), + Error, + "claude_model_adapter_failed", + ); + const invalid = context(); + invalid.proxyBaseUrl = "https://other.invalid/v1?token=private"; + assertThrows( + () => claudeGatewayBase(invalid), + Error, + "claude_model_adapter_failed", + ); +}); + +Deno.test("the declared route names only the bundled adapter and cannot fall back to PATH", async () => { + const calls: HostChildProcessOptions[] = []; + const processes: HostProcesses = { + spawn: (options) => { + calls.push(options); + return Promise.reject(new Error("absent package")); + }, + }; + const error = await assertRejects( + () => spawnClaudeModel(processes, { cwd: "/private" }), + PluginMethodError, + ); + assertEquals(error.code, AGENT_UNUSABLE); + assertEquals(calls.length, 1); + assertEquals(calls[0]?.command, undefined); + assertEquals( + calls[0]?.packageCommand?.includes("assets/bin/claude-agent-acp"), + true, + ); +}); + +Deno.test("startup acknowledges exact application and model changes never reach the child", async () => { + const writes: JsonValue[] = []; + const output = new TransformStream(); + const errors = new TransformStream(); + const frames: JsonValue[] = []; + const exited = Promise.withResolvers(); + const client = new ClaudeClient({ + spawn: () => ({ + stdin: new WritableStream({ + write: (chunk) => { + writes.push(JSON.parse(new TextDecoder().decode(chunk))); + }, + }), + stdout: output.readable, + stderr: errors.readable, + pid: 7, + kill: () => { + void output.writable.close(); + void errors.writable.close(); + exited.resolve(); + }, + exited: exited.promise, + }), + onAcpFrame: (frame) => frames.push(frame), + }); + const frozen = context(); + const applied = await startClaude(client, { + cwd: "/private", + hostVersion: "0", + modelContext: frozen, + }); + assertEquals(applied, { + adapterVersion: 1, + declarationFingerprint: "b".repeat(64), + configurationRevision: 7, + }); + assertEquals(client.frozenModel, { + id: "vendor/model", + displayName: "Frozen Model", + default: true, + }); + await client.writeAcp({ + jsonrpc: "2.0", + id: 1, + method: "session/set_model", + params: { modelId: "other" }, + }); + assertEquals(writes, []); + assertEquals(frames, [{ + jsonrpc: "2.0", + id: 1, + error: { code: -32602, message: "The execution model is frozen" }, + }]); + await client.stop(); + await exited.promise; +});