From 127731570912b105062cd621bb099c6906749806 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Tue, 20 Oct 2026 12:59:47 +0000 Subject: [PATCH 1/8] 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 | 2 +- deno.lock | 8 ++++---- src/base/agent-plugin.ts | 6 ++++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/deno.json b/deno.json index 090b4a7..fb22e03 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/path": "jsr:@std/path@^1.1.6", diff --git a/deno.lock b/deno.lock index 2808c61..57b26aa 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/fmt@^1.0.10": "1.0.10", @@ -13,8 +13,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", @@ -56,7 +56,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/path@^1.1.6", 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( From fd54585ae3f50e83052d4e6dcfa53cc4499e8f4f Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Tue, 20 Oct 2026 12:59:47 +0000 Subject: [PATCH 2/8] feat: own the platform model configuration in the plugin A start that carries a frozen model context gets the OpenCode provider configuration the Node used to generate: one ora-model provider for the frozen model, the token referenced through {env:...}, a private config directory and project configuration disabled. agent/start confirms the exact configuration once the CLI started with it, an Effect restart keeps it, and a start without a private config root is refused. --- AGENTS.md | 34 +++++- deno.json | 4 +- src/handlers/lifecycle.ts | 21 +++- src/main.ts | 18 ++- src/services/model-route.ts | 116 +++++++++++++++++++ src/services/opencode-client.ts | 37 +++++- tests/model_route_test.ts | 194 ++++++++++++++++++++++++++++++++ 7 files changed, 406 insertions(+), 18 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 ab46c10..105f3b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,6 +186,27 @@ it: Invocation env wins over provider env (`{ ...providerEnv(), ...invocation.env }`), so a caller can always override. +## Platform model route + +On an Ora Node, `agent/start` can carry a frozen `modelContext`: one model over +`openai-completions` or `anthropic-messages`, the platform proxy URL and a +temporary token. `src/services/model-route.ts` turns it into the OpenCode +provider configuration the Node used to write itself, now owned by the plugin +that drives the CLI, and the SDK refuses the start unless `onStart` returns the +exact `AgentModelApplied`. + +- The configuration travels as `OPENCODE_CONFIG_CONTENT` and names the token as + `{env:ORA_MODEL_ACCESS_TOKEN}`, so the text can be logged or persisted without + the token; only the CLI's own environment holds it. +- `OPENCODE_CONFIG_DIR` points at the session's private `XDG_CONFIG_HOME` and + project configuration is disabled, so neither the repository's nor the host's + `opencode.json` can replace the provider. A start whose host provides no + private configuration root is refused rather than run on the host's own. +- Settings must be empty — this release declares none — and the proxy must be an + HTTPS URL without credentials, query or fragment. +- `OpenCodeClient.useRoute` keeps the route for every later start, so an Effect + `reactivate` restart runs with the same configuration. + ## Process ownership Every subprocess goes through the host: `createHostProcesses(plugin)` → @@ -365,12 +386,13 @@ the run. - `deno task check` / `lint` / `format` / `simulate` / `build` / `sync` / `package`. -- `deno task test` runs `tests/package_test.ts`, the packaging checks that need - no CLI. `tests/host-simulator.ts` drives the plugin the way Ora's host does - and needs a real CLI, staged or on PATH. CI runs only `check` and `lint`, - deliberately: type checking resolves the whole module graph, so it is also - what catches an SDK version that is not published yet, and lint stays - meaningful even while that fails. +- `deno task test` runs the tests that need no CLI: `tests/package_test.ts` + covers packaging and `tests/model_route_test.ts` drives the platform model + route against a scripted spawn. `tests/host-simulator.ts` drives the plugin + the way Ora's host does and needs a real CLI, staged or on PATH. CI runs only + `check` and `lint`, deliberately: type checking resolves the whole module + graph, so it is also what catches an SDK version that is not published yet, + and lint stays meaningful even while that fails. - The SDK is imported from its published JSR package and pinned in `deno.json`; keep `deno.lock` synchronized when changing the SDK version. - `scripts/package.ts` and `scripts/upstream.ts` know nothing about OpenCode diff --git a/deno.json b/deno.json index fb22e03..42d0e10 100644 --- a/deno.json +++ b/deno.json @@ -12,10 +12,10 @@ "@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8.61" }, "tasks": { - "check": "deno check src/main.ts scripts/package.ts scripts/sync-upstream.ts tests/host-simulator.ts tests/package_test.ts", + "check": "deno check src/main.ts scripts/package.ts scripts/sync-upstream.ts tests/host-simulator.ts tests/package_test.ts tests/model_route_test.ts", "lint": "deno lint src scripts tests bundle.config.ts", "format": "deno fmt src scripts tests bundle.config.ts deno.json README.md AGENTS.md", - "test": "deno test --allow-read --allow-write tests/package_test.ts", + "test": "deno test --allow-read --allow-write --allow-env tests/package_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/src/handlers/lifecycle.ts b/src/handlers/lifecycle.ts index e59a51e..0929816 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 { OpenCodeClient } from "../services/opencode-client.ts"; import { logger } from "../services/log.ts"; +import { modelRoute } from "../services/model-route.ts"; const log = logger("lifecycle"); @@ -11,11 +15,17 @@ const log = logger("lifecycle"); * Ora calls this once per connection, before any session exists, so the CLI 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, which this plugin turns into OpenCode's + * provider configuration and confirms once the CLI started with it; a start without one keeps the + * CLI's own configuration, exactly as on a local desktop. `configDirectory` is the private + * configuration root of the session home the host provides. */ export async function startOpenCode( client: OpenCodeClient, context: AgentStartContext, -): Promise { + configDirectory: string | undefined, +): Promise { if (context.cwd.trim() === "") { log.warn("agent/start refused: empty cwd"); throw new PluginMethodError( @@ -23,10 +33,15 @@ export async function startOpenCode( "agent/start requires a non-empty cwd", ); } + const route = context.modelContext === undefined + ? undefined + : modelRoute(context.modelContext, configDirectory ?? ""); + client.useRoute(route); await client.start(context.cwd); log.info("OpenCode CLI 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 5a11405..878e2c5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import type { AcpSender, AgentModel, + AgentModelApplied, AgentStartContext, HostProcesses, JsonValue, @@ -90,7 +91,7 @@ class OpenCodeAgentPlugin extends AgentPlugin { override onStart = async ( context: AgentStartContext, send: AcpSender, - ): Promise => { + ): Promise => { this.#log.info("agent start requested", { context: { cwd: context.cwd, previousCwd: this.#cwd }, }); @@ -100,7 +101,11 @@ class OpenCodeAgentPlugin extends AgentPlugin { this.#send = send; this.#cwd = context.cwd; invalidateOpenCodeModels(context.cwd); - await startOpenCode(this.#client, context); + return await startOpenCode( + this.#client, + context, + readEnv("XDG_CONFIG_HOME"), + ); }; override onStop = async (): Promise => { @@ -152,4 +157,13 @@ function acpFrameSummary(frame: JsonValue): Record { }; } +/** Reads an env var, treating a missing read permission as an unset value. */ +function readEnv(name: string): string | undefined { + try { + return Deno.env.get(name); + } catch { + return undefined; + } +} + await runAgentPlugin(new OpenCodeAgentPlugin(), { pluginId: PLUGIN_ID }); diff --git a/src/services/model-route.ts b/src/services/model-route.ts new file mode 100644 index 0000000..c6dfb0e --- /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 one provider this plugin configures; OpenCode addresses models as `/`. */ +const PROVIDER = "ora-model"; + +/** The variable OpenCode's configuration names instead of the token itself. */ +const TOKEN_VARIABLE = "ORA_MODEL_ACCESS_TOKEN"; + +/** + * What one platform-model execution adds to the environment of the `opencode acp` process. + * + * This is the configuration the Node used to write for OpenCode, now owned by the plugin that + * drives the CLI. The configuration text references the token through `{env:...}`, so serializing + * it can never persist the token; only the child's own environment holds it. + */ +export interface ModelRoute { + readonly applied: AgentModelApplied; + readonly environment: Readonly>; +} + +/** + * Turns a frozen model context into OpenCode's provider configuration, refusing what it cannot + * apply. + * + * Both platform protocols map onto an AI SDK provider OpenCode bundles. Settings must be empty — + * this release declares none — and the proxy must be the platform's HTTPS endpoint; anything else + * would leave OpenCode on a route nobody admitted. `configDirectory` is a private directory under + * the session home the host provides; pointing `OPENCODE_CONFIG_DIR` there, together with + * disabling project configuration, keeps a repository's or the host's own `opencode.json` from + * replacing this provider. + */ +export function modelRoute( + context: AgentModelContext, + configDirectory: string, +): ModelRoute { + const { configuration } = context; + let settings: unknown; + try { + settings = JSON.parse(configuration.settingsJson); + } catch { + refuse(); + } + if ( + settings === null || typeof settings !== "object" || + Array.isArray(settings) || Object.keys(settings).length > 0 || + !platformProxy(context.proxyBaseUrl) || context.token.length === 0 || + configDirectory.length === 0 + ) { + refuse(); + } + const model = configuration.model; + const config = { + $schema: "https://opencode.ai/config.json", + model: `${PROVIDER}/${model.id}`, + small_model: `${PROVIDER}/${model.id}`, + enabled_providers: [PROVIDER], + provider: { + [PROVIDER]: { + npm: configuration.protocol === "anthropic-messages" + ? "@ai-sdk/anthropic" + : "@ai-sdk/openai-compatible", + name: "Ora model gateway", + options: { + baseURL: context.proxyBaseUrl, + apiKey: `{env:${TOKEN_VARIABLE}}`, + }, + models: { + [model.id]: { + name: model.name, + limit: { context: model.contextWindow, output: model.maxTokens }, + }, + }, + }, + }, + }; + return { + applied: { + adapterVersion: configuration.adapterVersion, + declarationFingerprint: configuration.declarationFingerprint, + configurationRevision: configuration.configurationRevision, + }, + environment: { + OPENCODE_CONFIG_CONTENT: JSON.stringify(config), + [TOKEN_VARIABLE]: context.token, + OPENCODE_CONFIG_DIR: configDirectory, + OPENCODE_DISABLE_PROJECT_CONFIG: "true", + OPENCODE_DISABLE_AUTOUPDATE: "true", + OPENCODE_DISABLE_MODELS_FETCH: "true", + }, + }; +} + +/** Accepts only an HTTPS proxy URL without credentials, query or fragment. */ +function platformProxy(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + return url.protocol === "https:" && url.username === "" && + url.password === "" && url.search === "" && url.hash === ""; +} + +/** Fails `agent/start` without describing the sensitive input. */ +function refuse(): never { + throw new PluginMethodError( + INVALID_PARAMS, + "this OpenCode release cannot apply the supplied model configuration", + ); +} diff --git a/src/services/opencode-client.ts b/src/services/opencode-client.ts index f23aaa6..5a931bf 100644 --- a/src/services/opencode-client.ts +++ b/src/services/opencode-client.ts @@ -1,6 +1,7 @@ import type { HostProcesses, JsonValue } from "@ora-space/plugin-sdk"; import { spawnOpenCode } from "./command.ts"; import { logger } from "./log.ts"; +import type { ModelRoute } from "./model-route.ts"; import { decodeLines, encodeLine } from "./ndjson.ts"; const log = logger("opencode-client"); @@ -24,7 +25,11 @@ export interface OpenCodeClientOptions { * Which program a spawn resolves to is `command.ts`'s decision and is deliberately not a * parameter here: this class owns the CLI's lifetime, not the question of where it lives. */ - spawn?: (args: string[], cwd: string) => SpawnedProcess; + spawn?: ( + args: string[], + cwd: string, + env: Record, + ) => SpawnedProcess; /** Receives every ACP frame emitted by the CLI, in output order. */ onAcpFrame?: (frame: JsonValue) => void; /** Invoked after the CLI exits on its own, never after an explicit stop. */ @@ -48,6 +53,7 @@ export class OpenCodeClient { readonly #spawn: ( args: string[], cwd: string, + env: Record, ) => SpawnedProcess | Promise; readonly #onAcpFrame: (frame: JsonValue) => void; readonly #onExited: () => void; @@ -55,10 +61,12 @@ export class OpenCodeClient { #processes: HostProcesses | undefined; #running: RunningProcess | undefined; #expectedExit = false; + /** The platform route every generation of the CLI starts with; absent on a local host. */ + #route: ModelRoute | undefined; constructor(options: OpenCodeClientOptions = {}) { this.#spawn = options.spawn ?? - ((args, cwd) => this.#spawnViaHost(args, cwd)); + ((args, cwd, env) => this.#spawnViaHost(args, cwd, env)); this.#onAcpFrame = options.onAcpFrame ?? (() => {}); this.#onExited = options.onExited ?? (() => {}); } @@ -78,6 +86,16 @@ export class OpenCodeClient { 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`; + * `undefined` returns to the CLI's own configuration. + */ + useRoute(route: ModelRoute | undefined): void { + this.#route = route; + } + /** * Spawns `opencode acp` in the given working directory and starts bridging its stdio. * @@ -97,14 +115,22 @@ export class OpenCodeClient { let process: SpawnedProcess; try { // V2 removed --cwd; the host-owned process cwd supplies the same directory. - process = await this.#spawn(["acp"], cwd); + process = await this.#spawn(["acp"], cwd, { + ...this.#route?.environment, + }); } catch (error) { log.warn("the CLI could not be spawned", { context: { cwd }, error }); throw error; } this.#running = { process, stdinWriter: process.stdin.getWriter() }; this.#attach(process); - log.info("CLI running", { context: { cwd, pid: process.pid } }); + log.info("CLI running", { + context: { + cwd, + pid: process.pid, + platformRoute: this.#route !== undefined, + }, + }); } /** @@ -254,13 +280,14 @@ export class OpenCodeClient { async #spawnViaHost( args: string[], cwd: string, + env: Record, ): Promise { if (this.#processes === undefined) { throw new Error( "OpenCodeClient cannot spawn before attachProcesses() runs", ); } - const child = await spawnOpenCode(this.#processes, { args, cwd }); + const child = await spawnOpenCode(this.#processes, { args, cwd, env }); return { stdin: new WritableStream({ write: (chunk) => child.write(chunk), diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts new file mode 100644 index 0000000..5a0adbc --- /dev/null +++ b/tests/model_route_test.ts @@ -0,0 +1,194 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { type AgentModelContext, INVALID_PARAMS } from "@ora-space/plugin-sdk"; +import { startOpenCode } from "../src/handlers/lifecycle.ts"; +import { modelRoute } from "../src/services/model-route.ts"; +import { + OpenCodeClient, + type SpawnedProcess, +} from "../src/services/opencode-client.ts"; + +const TOKEN = "synthetic-temporary-token"; +const CONFIG = "/session/home/.config"; + +/** The frozen context a Node hands this plugin for one platform-model execution. */ +function context( + overrides: Partial = {}, +): AgentModelContext { + return { + configuration: { + pluginId: "official/ora-space.opencode", + pluginVersion: "0.7.0", + target: "x86_64-unknown-linux-gnu", + packageSha256: "a".repeat(64), + schemaVersion: 2, + declarationFingerprint: "b".repeat(64), + adapterVersion: 1, + configurationRevision: 3, + settingsJson: "{}", + protocol: "openai-completions", + model: { + id: "team/model", + name: "Team model", + contextWindow: 32768, + maxTokens: 1024, + }, + ...overrides, + }, + proxyBaseUrl: "https://gateway.internal/runtime/openai/v1", + token: TOKEN, + }; +} + +Deno.test("the frozen model becomes the one OpenCode provider the Node used to write", () => { + const route = modelRoute(context(), CONFIG); + assertEquals(route.applied, { + adapterVersion: 1, + declarationFingerprint: "b".repeat(64), + configurationRevision: 3, + }); + const { OPENCODE_CONFIG_CONTENT, ...environment } = route.environment; + assertEquals(environment, { + ORA_MODEL_ACCESS_TOKEN: TOKEN, + OPENCODE_CONFIG_DIR: CONFIG, + OPENCODE_DISABLE_PROJECT_CONFIG: "true", + OPENCODE_DISABLE_AUTOUPDATE: "true", + OPENCODE_DISABLE_MODELS_FETCH: "true", + }); + assertEquals(JSON.parse(OPENCODE_CONFIG_CONTENT), { + $schema: "https://opencode.ai/config.json", + model: "ora-model/team/model", + small_model: "ora-model/team/model", + enabled_providers: ["ora-model"], + provider: { + "ora-model": { + npm: "@ai-sdk/openai-compatible", + name: "Ora model gateway", + options: { + baseURL: "https://gateway.internal/runtime/openai/v1", + apiKey: "{env:ORA_MODEL_ACCESS_TOKEN}", + }, + models: { + "team/model": { + name: "Team model", + limit: { context: 32768, output: 1024 }, + }, + }, + }, + }, + }); + // The configuration text names the variable, so serializing it never persists the token. + assertEquals(OPENCODE_CONFIG_CONTENT.includes(TOKEN), false); +}); + +Deno.test("Anthropic Messages selects the bundled Anthropic provider", () => { + const route = modelRoute( + context({ protocol: "anthropic-messages" }), + CONFIG, + ); + assertEquals( + JSON.parse(route.environment.OPENCODE_CONFIG_CONTENT).provider["ora-model"] + .npm, + "@ai-sdk/anthropic", + ); +}); + +Deno.test("configurations this release cannot apply are refused without echoing the token", () => { + const cases: [AgentModelContext, string][] = [ + [context({ settingsJson: '{"model":"other"}' }), CONFIG], + [context({ settingsJson: "[]" }), CONFIG], + [{ ...context(), proxyBaseUrl: "http://gateway.internal/v1" }, CONFIG], + [{ ...context(), proxyBaseUrl: "https://u:p@gateway.internal/v1" }, CONFIG], + [context(), ""], + ]; + for (const [input, directory] of cases) { + let caught: unknown; + try { + modelRoute(input, directory); + } 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 runs the CLI with its route and confirms it, and a restart keeps it", async () => { + const environments: Record[] = []; + const client = new OpenCodeClient({ + spawn: (_args, _cwd, env) => { + environments.push(env); + return silentProcess(); + }, + }); + const applied = await startOpenCode(client, { + cwd: "/workspace", + hostVersion: "1.0.0", + modelContext: context(), + }, CONFIG); + assertEquals(applied, modelRoute(context(), CONFIG).applied); + await client.start("/workspace"); + assertEquals(environments, [ + { ...modelRoute(context(), CONFIG).environment }, + { ...modelRoute(context(), CONFIG).environment }, + ]); + await client.stop(); +}); + +Deno.test("a local start keeps the CLI's own configuration and confirms nothing", async () => { + const environments: Record[] = []; + const client = new OpenCodeClient({ + spawn: (_args, _cwd, env) => { + environments.push(env); + return silentProcess(); + }, + }); + const applied = await startOpenCode(client, { + cwd: "/workspace", + hostVersion: "1.0.0", + }, undefined); + assertEquals(environments, [{}]); + assertEquals(applied, undefined); + await client.stop(); +}); + +Deno.test("a platform start without a private configuration root is refused before spawning", async () => { + let spawned = 0; + const client = new OpenCodeClient({ + spawn: () => { + spawned += 1; + return silentProcess(); + }, + }); + const error = await assertRejects(() => + startOpenCode(client, { + cwd: "/workspace", + hostVersion: "1.0.0", + modelContext: context(), + }, undefined) + ); + assertEquals((error as { code?: number }).code, INVALID_PARAMS); + assertEquals(spawned, 0); +}); + +/** A `SpawnedProcess` that never speaks and ends when killed. */ +function silentProcess(): SpawnedProcess { + let ended: (() => void) | undefined; + let stdout: ReadableStreamDefaultController | undefined; + return { + pid: 4321, + stdin: new WritableStream(), + stdout: new ReadableStream({ + start: (controller) => { + stdout = controller; + }, + }), + stderr: new ReadableStream({ start: (controller) => controller.close() }), + exited: new Promise((resolve) => { + ended = () => resolve(); + }), + kill: () => { + stdout?.close(); + ended?.(); + }, + }; +} From a81f51790e9f9d053e5d67116e225eb4300807c4 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 07:20:51 +0000 Subject: [PATCH 3/8] ci: run the CLI-free test suite deno task test needs no CLI, so CI now runs it after check and lint; the model route tests guard the platform start contract on every PR. --- .github/workflows/ci.yml | 3 +++ AGENTS.md | 9 +++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adee045..6484500 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,5 +32,8 @@ jobs: - name: Lint run: deno task lint + - name: Test + run: deno task test + - name: Archive integrity tests run: deno test scripts/upstream.test.ts diff --git a/AGENTS.md b/AGENTS.md index 105f3b3..ed6b74a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -389,10 +389,11 @@ the run. - `deno task test` runs the tests that need no CLI: `tests/package_test.ts` covers packaging and `tests/model_route_test.ts` drives the platform model route against a scripted spawn. `tests/host-simulator.ts` drives the plugin - the way Ora's host does and needs a real CLI, staged or on PATH. CI runs only - `check` and `lint`, deliberately: type checking resolves the whole module - graph, so it is also what catches an SDK version that is not published yet, - and lint stays meaningful even while that fails. + the way Ora's host does and needs a real CLI, staged or on PATH. CI runs + `check`, `lint`, `test` and the archive integrity tests, none of which needs a + CLI. Type checking resolves the whole module graph, so it is also what catches + an SDK version that is not published yet, and lint stays meaningful even while + that fails. - The SDK is imported from its published JSR package and pinned in `deno.json`; keep `deno.lock` synchronized when changing the SDK version. - `scripts/package.ts` and `scripts/upstream.ts` know nothing about OpenCode From 15f37e81c9ced85fad1f9e58f7a226bfd7531319 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:43:57 +0000 Subject: [PATCH 4/8] fix: override inherited OpenCode configuration variables on a platform route --- AGENTS.md | 6 ++++++ src/services/model-route.ts | 11 +++++++++++ tests/model_route_test.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index ed6b74a..b375848 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,6 +202,12 @@ exact `AgentModelApplied`. project configuration is disabled, so neither the repository's nor the host's `opencode.json` can replace the provider. A start whose host provides no private configuration root is refused rather than run on the host's own. +- The spawn environment only adds to what the host inherits, so configuration + variables that could reach the provider are overridden rather than removed: + `OPENCODE_CONFIG` (one more file to merge), `OPENCODE_MODELS_URL` and + `OPENCODE_MODELS_PATH` are set empty, which OpenCode 2.0.32 reads as unset, + and `OPENCODE_CONFIG_PROJECT_DISABLE` is set alongside + `OPENCODE_DISABLE_PROJECT_CONFIG` because it takes precedence over it. - Settings must be empty — this release declares none — and the proxy must be an HTTPS URL without credentials, query or fragment. - `OpenCodeClient.useRoute` keeps the route for every later start, so an Effect diff --git a/src/services/model-route.ts b/src/services/model-route.ts index c6dfb0e..c3bce54 100644 --- a/src/services/model-route.ts +++ b/src/services/model-route.ts @@ -89,6 +89,17 @@ export function modelRoute( [TOKEN_VARIABLE]: context.token, OPENCODE_CONFIG_DIR: configDirectory, OPENCODE_DISABLE_PROJECT_CONFIG: "true", + // OpenCode reads `OPENCODE_CONFIG_PROJECT_DISABLE ?? OPENCODE_DISABLE_PROJECT_CONFIG`, so an + // inherited `OPENCODE_CONFIG_PROJECT_DISABLE=false` would otherwise turn project config + // back on. + OPENCODE_CONFIG_PROJECT_DISABLE: "true", + // The host's spawn environment only adds variables, so inherited ones are cleared by setting + // them empty; OpenCode treats an empty value as unset. `OPENCODE_CONFIG` names one more + // config file to merge, which could add options to this provider; the models catalog + // source could describe it differently. + OPENCODE_CONFIG: "", + OPENCODE_MODELS_URL: "", + OPENCODE_MODELS_PATH: "", OPENCODE_DISABLE_AUTOUPDATE: "true", OPENCODE_DISABLE_MODELS_FETCH: "true", }, diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts index 5a0adbc..605fb43 100644 --- a/tests/model_route_test.ts +++ b/tests/model_route_test.ts @@ -51,6 +51,10 @@ Deno.test("the frozen model becomes the one OpenCode provider the Node used to w ORA_MODEL_ACCESS_TOKEN: TOKEN, OPENCODE_CONFIG_DIR: CONFIG, OPENCODE_DISABLE_PROJECT_CONFIG: "true", + OPENCODE_CONFIG_PROJECT_DISABLE: "true", + OPENCODE_CONFIG: "", + OPENCODE_MODELS_URL: "", + OPENCODE_MODELS_PATH: "", OPENCODE_DISABLE_AUTOUPDATE: "true", OPENCODE_DISABLE_MODELS_FETCH: "true", }); @@ -170,6 +174,34 @@ Deno.test("a platform start without a private configuration root is refused befo assertEquals(spawned, 0); }); +Deno.test("inherited OpenCode configuration variables cannot reach the route", async () => { + const inherited = { + OPENCODE_CONFIG: "/home/user/elsewhere.json", + OPENCODE_CONFIG_PROJECT_DISABLE: "false", + OPENCODE_MODELS_URL: "https://models.invalid", + OPENCODE_MODELS_PATH: "/home/user/models.json", + }; + const environments: Record[] = []; + const client = new OpenCodeClient({ + spawn: (_args, _cwd, env) => { + environments.push(env); + return silentProcess(); + }, + }); + await startOpenCode(client, { + cwd: "/workspace", + hostVersion: "1.0.0", + modelContext: context(), + }, CONFIG); + // The host merges the spawn environment over its own, so each inherited value is overridden. + const effective = { ...inherited, ...environments[0] }; + assertEquals(effective.OPENCODE_CONFIG, ""); + assertEquals(effective.OPENCODE_CONFIG_PROJECT_DISABLE, "true"); + assertEquals(effective.OPENCODE_MODELS_URL, ""); + assertEquals(effective.OPENCODE_MODELS_PATH, ""); + await client.stop(); +}); + /** A `SpawnedProcess` that never speaks and ends when killed. */ function silentProcess(): SpawnedProcess { let ended: (() => void) | undefined; From 0edbc91a4251d32bc4ef9605d372afc9aee197b7 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:44:55 +0000 Subject: [PATCH 5/8] feat: refuse ACP requests that would replace the platform provider or model --- AGENTS.md | 7 ++ src/services/model-guard.ts | 102 ++++++++++++++++++++ src/services/model-route.ts | 4 + src/services/opencode-client.ts | 34 ++++++- tests/model_route_test.ts | 163 +++++++++++++++++++++++++++++++- 5 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 src/services/model-guard.ts diff --git a/AGENTS.md b/AGENTS.md index b375848..2da3c72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,6 +212,13 @@ exact `AgentModelApplied`. HTTPS URL without credentials, query or fragment. - `OpenCodeClient.useRoute` keeps the route for every later start, so an Effect `reactivate` restart runs with the same configuration. +- The same ACP connection could ask the CLI to leave that provider, so on a + route `services/model-guard.ts` answers these requests itself and the CLI + never sees them: `authenticate`, `logout`, `auth/*`, `_auth/*`, `providers/*`, + and `session/set_model` or the `model` config option for any model but the + frozen `ora-model/` (an effort variant of it is allowed). OpenCode 2.0.32 + serves only `authenticate`, as a no-op; the guard is what keeps a later pin + that serves the rest from becoming a way around the route. ## Process ownership diff --git a/src/services/model-guard.ts b/src/services/model-guard.ts new file mode 100644 index 0000000..942986d --- /dev/null +++ b/src/services/model-guard.ts @@ -0,0 +1,102 @@ +import type { JsonValue } from "@ora-space/plugin-sdk"; + +/** The one model a platform-model execution may use, as OpenCode's ACP selector names it. */ +export interface FrozenModel { + /** OpenCode's `/` reference, e.g. `ora-model/vendor/model`. */ + readonly id: string; + readonly name: string; +} + +/** What the bridge does with one frame Ora sent towards the CLI. */ +export type InboundDecision = + /** Write this frame into the CLI. */ + | { readonly forward: JsonValue } + /** Answer Ora with this frame instead; the CLI 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 could hand the CLI another upstream, another credential, or none at all. + * + * OpenCode 2.0.32 answers `authenticate` as a no-op and serves none of the others, but ACP + * defines them (`providers/set` reroutes an agent, `logout` drops its login), and a later CLI pin + * that starts serving one must not quietly become a way around the frozen provider. + */ +function isRouteMutation(method: string): boolean { + return method === "authenticate" || method === "logout" || + method.startsWith("auth/") || method.startsWith("_auth/") || + method.startsWith("providers/"); +} + +/** + * Keeps one platform-model execution on its frozen provider and model. + * + * The provider itself is fixed by the configuration `model-route.ts` hands the CLI; this class + * only keeps a client from asking the CLI to leave it over ACP. + */ +export class ModelGuard { + readonly #model: FrozenModel; + + constructor(model: FrozenModel) { + this.#model = model; + } + + /** Decides what happens to one frame Ora sent towards the CLI. */ + 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`, + ); + } + const requested = method === "session/set_model" + ? params.modelId + : method === "session/set_config_option" && params.configId === "model" + ? params.value + : undefined; + if (requested !== undefined && !this.#isFrozen(requested)) { + return refuse( + frame, + MODEL_FROZEN, + `this execution is fixed to the model ${this.#model.id}`, + ); + } + return { forward: frame }; + } + + /** + * Accepts the frozen reference itself, and the same reference with an effort variant appended + * (`//`), which OpenCode's selector also understands; neither changes + * the model. + */ + #isFrozen(value: JsonValue): boolean { + return typeof value === "string" && + (value === this.#model.id || value.startsWith(`${this.#model.id}/`)); + } +} + +function refuse( + frame: { [key: string]: JsonValue }, + code: number, + message: string, +): InboundDecision { + return typeof frame.id === "string" || typeof frame.id === "number" + ? { reply: { jsonrpc: "2.0", id: frame.id, error: { code, message } } } + : { drop: true }; +} + +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 c3bce54..d108331 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 one provider this plugin configures; OpenCode addresses models as `/`. */ const PROVIDER = "ora-model"; @@ -20,6 +21,8 @@ const TOKEN_VARIABLE = "ORA_MODEL_ACCESS_TOKEN"; */ 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 environment: Readonly>; } @@ -84,6 +87,7 @@ export function modelRoute( declarationFingerprint: configuration.declarationFingerprint, configurationRevision: configuration.configurationRevision, }, + model: { id: `${PROVIDER}/${model.id}`, name: model.name }, environment: { OPENCODE_CONFIG_CONTENT: JSON.stringify(config), [TOKEN_VARIABLE]: context.token, diff --git a/src/services/opencode-client.ts b/src/services/opencode-client.ts index 5a931bf..e690a29 100644 --- a/src/services/opencode-client.ts +++ b/src/services/opencode-client.ts @@ -1,6 +1,7 @@ import type { HostProcesses, JsonValue } from "@ora-space/plugin-sdk"; import { spawnOpenCode } 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"; @@ -46,8 +47,9 @@ interface RunningProcess { * * The plugin owns the CLI's whole lifetime: spawn on `agent/start`, kill on `agent/stop`. Ora * never sees the child's stdio, which is what lets OpenCode use ACP methods this host has never - * heard of. Nothing here parses ACP; frames are re-framed between Ora's binary envelope and the - * CLI's NDJSON and otherwise passed through verbatim. + * heard of. Without a platform route nothing here parses ACP; frames are re-framed between Ora's + * binary envelope and the CLI's NDJSON and otherwise passed through verbatim. With one, + * {@link ModelGuard} reads the frames that could change the provider or the model. */ export class OpenCodeClient { readonly #spawn: ( @@ -63,6 +65,8 @@ export class OpenCodeClient { #expectedExit = false; /** The platform route every generation of the CLI 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; constructor(options: OpenCodeClientOptions = {}) { this.#spawn = options.spawn ?? @@ -94,6 +98,7 @@ export class OpenCodeClient { */ useRoute(route: ModelRoute | undefined): void { this.#route = route; + this.#guard = route === undefined ? undefined : new ModelGuard(route.model); } /** @@ -138,8 +143,33 @@ export class OpenCodeClient { * * Awaiting the write is what lets the CLI'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 provider or the model is answered here with an error and never reaches the + * CLI. */ 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; + } + frame = decision.forward; const running = this.#running; if (running === undefined) { throw new Error("the OpenCode agent is not running"); diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts index 605fb43..dbcf0a2 100644 --- a/tests/model_route_test.ts +++ b/tests/model_route_test.ts @@ -1,5 +1,9 @@ import { assertEquals, assertRejects } from "@std/assert"; -import { type AgentModelContext, INVALID_PARAMS } from "@ora-space/plugin-sdk"; +import { + type AgentModelContext, + INVALID_PARAMS, + type JsonValue, +} from "@ora-space/plugin-sdk"; import { startOpenCode } from "../src/handlers/lifecycle.ts"; import { modelRoute } from "../src/services/model-route.ts"; import { @@ -202,6 +206,163 @@ Deno.test("inherited OpenCode configuration variables cannot reach the route", a await client.stop(); }); +Deno.test("a client cannot re-authenticate or reroute the frozen provider", async () => { + const { cli, client, forwarded } = await platformClient(); + for ( + const [id, method] of [ + [11, "authenticate"], + [12, "logout"], + [13, "providers/set"], + [14, "providers/disable"], + ] as const + ) { + await client.writeAcp({ jsonrpc: "2.0", id, method, params: {} }); + } + await client.writeAcp({ jsonrpc: "2.0", method: "providers/disable" }); + assertEquals(cli.written, []); + assertEquals( + forwarded.map((frame) => (frame as { id: number }).id), + [11, 12, 13, 14], + ); + 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 { cli, client, forwarded } = await platformClient(); + const select = (id: number, value: string): JsonValue => ({ + jsonrpc: "2.0", + id, + method: "session/set_config_option", + params: { sessionId: "s1", configId: "model", value }, + }); + await client.writeAcp(select(1, "openai/gpt-5")); + await client.writeAcp({ + jsonrpc: "2.0", + id: 2, + method: "session/set_model", + params: { sessionId: "s1", modelId: "ora-model/other" }, + }); + // The frozen model, also with an effort variant, and every other option reach the CLI. + await client.writeAcp(select(3, "ora-model/team/model")); + await client.writeAcp(select(4, "ora-model/team/model/high")); + await client.writeAcp({ + jsonrpc: "2.0", + id: 5, + method: "session/set_config_option", + params: { sessionId: "s1", configId: "mode", value: "plan" }, + }); + assertEquals( + cli.written.map((frame) => (frame as { id: number }).id), + [3, 4, 5], + ); + assertEquals(forwarded, [ + { + jsonrpc: "2.0", + id: 1, + error: { + code: -32602, + message: "this execution is fixed to the model ora-model/team/model", + }, + }, + { + jsonrpc: "2.0", + id: 2, + error: { + code: -32602, + message: "this execution is fixed to the model ora-model/team/model", + }, + }, + ]); + await client.stop(); +}); + +Deno.test("a local start passes every ACP frame through untouched", async () => { + const cli = new ScriptedCli(); + const client = new OpenCodeClient({ spawn: () => cli.spawn() }); + await startOpenCode( + client, + { cwd: "/workspace", hostVersion: "1.0.0" }, + CONFIG, + ); + const frame = { jsonrpc: "2.0", id: 1, method: "authenticate", params: {} }; + await client.writeAcp(frame); + assertEquals(cli.written, [frame]); + await client.stop(); +}); + +/** A client started on the platform route over a scripted CLI. */ +async function platformClient() { + const cli = new ScriptedCli(); + const forwarded: JsonValue[] = []; + const client = new OpenCodeClient({ + spawn: () => cli.spawn(), + onAcpFrame: (frame) => forwarded.push(frame), + }); + await startOpenCode(client, { + cwd: "/workspace", + hostVersion: "1.0.0", + modelContext: context(), + }, CONFIG); + return { cli, client, forwarded }; +} + +/** An `opencode acp` stand-in that records what it is sent and prints what a test tells it to. */ +class ScriptedCli { + readonly written: JsonValue[] = []; + #stdout: ReadableStreamDefaultController | undefined; + #stderr: ReadableStreamDefaultController | undefined; + + spawn(): SpawnedProcess { + 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() !== "") this.written.push(JSON.parse(line)); + } + }, + }), + stdout: new ReadableStream({ + start: (controller) => { + this.#stdout = controller; + }, + }), + stderr: new ReadableStream({ + start: (controller) => { + this.#stderr = controller; + }, + }), + exited: new Promise((resolve) => { + ended = () => resolve(); + }), + kill: () => { + this.#stdout?.close(); + this.#stderr?.close(); + ended?.(); + }, + }; + } + + /** Writes one NDJSON frame to stdout. */ + emit(frame: JsonValue): void { + this.emitLine(JSON.stringify(frame)); + } + + /** Writes one raw line to stdout. */ + emitLine(line: string): void { + this.#stdout?.enqueue(new TextEncoder().encode(`${line}\n`)); + } + + /** Writes one diagnostic line to stderr. */ + emitStderr(line: string): void { + this.#stderr?.enqueue(new TextEncoder().encode(`${line}\n`)); + } +} + /** A `SpawnedProcess` that never speaks and ends when killed. */ function silentProcess(): SpawnedProcess { let ended: (() => void) | undefined; From bf700150ca5e8391b272a6eb8d3edd44eef75ce9 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:45:19 +0000 Subject: [PATCH 6/8] fix: run only the bundled OpenCode CLI on a platform model route --- AGENTS.md | 6 ++++++ src/services/command.ts | 34 ++++++++++++++++++++++++++++- src/services/opencode-client.ts | 7 ++++-- tests/model_route_test.ts | 38 +++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2da3c72..91c54bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,6 +162,12 @@ a build-time flag; `bundle.config.ts` picks which one a release is. See silently running a different CLI than the one a developer named is worse than failing. +A platform model route is the exception to all three: `spawnBundledOpenCode` +asks the host for the bundled CLI alone — no `ORA_OPENCODE_BIN`, no PATH — and +fails as `AGENT_UNUSABLE` when the package cannot run it. The route's +configuration was written for the pinned build, and another version may rank +configuration sources differently. + `assets/bin/` is git-ignored. It is staged only so `deno task simulate` can run against a real binary; released packages have `scripts/package.ts` download the CLI per target at package time. With nothing staged there the simulator answers diff --git a/src/services/command.ts b/src/services/command.ts index b4d38ce..1344c56 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, @@ -38,7 +39,7 @@ function pathCommands(): string[] { } /** Full path to a locally built OpenCode, overriding both ways of resolving one. */ -const BIN_OVERRIDE_ENV = "ORA_OPENCODE_BIN"; +export const BIN_OVERRIDE_ENV = "ORA_OPENCODE_BIN"; /** * Spawns the OpenCode CLI, whichever way this package was built to reach one. @@ -87,6 +88,37 @@ export function spawnOpenCode( ); } +/** + * Spawns only the CLI this package ships, for a platform-model execution. + * + * The platform route is configuration written for, and tested against, the pinned OpenCode build + * this package bundles; `ORA_OPENCODE_BIN` or an install on PATH could be any other version, with + * its own idea of which configuration sources outrank the one this plugin supplies. So neither is + * consulted, and a package that cannot run its own CLI fails the start as `AGENT_UNUSABLE` rather + * than looking further. + */ +export async function spawnBundledOpenCode( + processes: HostProcesses, + invocation: AgentInvocation, +): Promise { + const packageCommand = bundledBinaryPath(); + log.info("spawning the bundled OpenCode CLI for a platform route", { + context: { cwd: invocation.cwd, packageCommand }, + }); + try { + return await processes.spawn({ packageCommand, ...invocation }); + } catch (error) { + log.warn("the bundled OpenCode CLI could not be spawned", { + context: { cwd: invocation.cwd, packageCommand }, + error, + }); + throw new PluginMethodError( + AGENT_UNUSABLE, + "this package's bundled OpenCode CLI cannot run, and a platform model route uses no other", + ); + } +} + /** * Spawns the CLI a developer named explicitly, reporting an absent one as local configuration. * diff --git a/src/services/opencode-client.ts b/src/services/opencode-client.ts index e690a29..9ed1cfd 100644 --- a/src/services/opencode-client.ts +++ b/src/services/opencode-client.ts @@ -1,5 +1,5 @@ import type { HostProcesses, JsonValue } from "@ora-space/plugin-sdk"; -import { spawnOpenCode } from "./command.ts"; +import { spawnBundledOpenCode, spawnOpenCode } from "./command.ts"; import { logger } from "./log.ts"; import { ModelGuard } from "./model-guard.ts"; import type { ModelRoute } from "./model-route.ts"; @@ -317,7 +317,10 @@ export class OpenCodeClient { "OpenCodeClient cannot spawn before attachProcesses() runs", ); } - const child = await spawnOpenCode(this.#processes, { args, cwd, env }); + // A platform route runs only the CLI this package ships; see `spawnBundledOpenCode`. + const child = this.#route === undefined + ? await spawnOpenCode(this.#processes, { args, cwd, env }) + : await spawnBundledOpenCode(this.#processes, { args, cwd, env }); return { stdin: new WritableStream({ write: (chunk) => child.write(chunk), diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts index dbcf0a2..9aa4c10 100644 --- a/tests/model_route_test.ts +++ b/tests/model_route_test.ts @@ -1,9 +1,14 @@ import { assertEquals, assertRejects } from "@std/assert"; import { + AGENT_UNUSABLE, type AgentModelContext, + type HostChildProcessOptions, + HostRequestError, INVALID_PARAMS, type JsonValue, } from "@ora-space/plugin-sdk"; +import { bundledBinaryPath } from "../src/services/bundled-binary.ts"; +import { BIN_OVERRIDE_ENV } from "../src/services/command.ts"; import { startOpenCode } from "../src/handlers/lifecycle.ts"; import { modelRoute } from "../src/services/model-route.ts"; import { @@ -293,6 +298,39 @@ Deno.test("a local start passes every ACP frame through untouched", async () => await client.stop(); }); +Deno.test("a platform route spawns only the bundled CLI, ignoring an override and PATH", async () => { + const spawns: HostChildProcessOptions[] = []; + const client = new OpenCodeClient(); + 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_OVERRIDE_ENV); + Deno.env.set(BIN_OVERRIDE_ENV, "/usr/local/bin/opencode"); + try { + const error = await assertRejects(() => + startOpenCode(client, { + cwd: "/workspace", + hostVersion: "1.0.0", + modelContext: context(), + }, CONFIG) + ); + // 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_OVERRIDE_ENV); + else Deno.env.set(BIN_OVERRIDE_ENV, previous); + } + assertEquals(spawns.length, 1); + assertEquals(spawns[0].packageCommand, bundledBinaryPath()); + assertEquals(Object.hasOwn(spawns[0], "command"), false); + assertEquals(spawns[0].args, ["acp"]); +}); + /** A client started on the platform route over a scripted CLI. */ async function platformClient() { const cli = new ScriptedCli(); From 74fb4f3aebb0cbd244b83bc9ef9dfa371d0a717c Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:45:51 +0000 Subject: [PATCH 7/8] feat: expose only the frozen model on a platform model route --- AGENTS.md | 5 ++ src/main.ts | 8 ++++ src/services/model-guard.ts | 59 ++++++++++++++++++++++- src/services/opencode-client.ts | 9 +++- tests/model_route_test.ts | 84 +++++++++++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 91c54bb..0591497 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -225,6 +225,11 @@ exact `AgentModelApplied`. frozen `ora-model/` (an effort variant of it is allowed). OpenCode 2.0.32 serves only `authenticate`, as a no-op; the guard is what keeps a later pin that serves the rest from becoming a way around the route. +- Only the frozen model is ever offered. `agent/list_models` answers it without + a discovery probe, which would run on the host's own configuration, and every + `configOptions` model entry in a result or `config_option_update` is narrowed + to it — `enabled_providers` already limits the catalog, but managed settings + outrank this plugin's configuration. ## Process ownership diff --git a/src/main.ts b/src/main.ts index 878e2c5..61ebfe5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -119,6 +119,14 @@ class OpenCodeAgentPlugin extends AgentPlugin { override onListModels = ( context: AgentListModelsContext, ): Promise => { + // A platform route offers exactly its frozen model. Probing would start a CLI on the host's + // own configuration and credentials — 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/model-guard.ts b/src/services/model-guard.ts index 942986d..49b335a 100644 --- a/src/services/model-guard.ts +++ b/src/services/model-guard.ts @@ -38,7 +38,8 @@ function isRouteMutation(method: string): boolean { * Keeps one platform-model execution on its frozen provider and model. * * The provider itself is fixed by the configuration `model-route.ts` hands the CLI; this class - * only keeps a client from asking the CLI to leave it over ACP. + * keeps a client from asking the CLI to leave it over ACP, and narrows every model selector the + * CLI reports to the frozen model. */ export class ModelGuard { readonly #model: FrozenModel; @@ -47,6 +48,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 CLI. */ inbound(frame: JsonValue): InboundDecision { if (!isObject(frame) || typeof frame.method !== "string") { @@ -76,6 +82,57 @@ export class ModelGuard { return { forward: frame }; } + /** + * Narrows every model selector in one frame the CLI sent before Ora sees it. + * + * `enabled_providers` already limits OpenCode's catalog to the one provider this plugin writes, + * but configuration that outranks it (managed settings) is outside this plugin's control, so + * the selector Ora shows is narrowed here as well (ADR D5: visible session options never widen + * the set). Results and `config_option_update` notifications both carry one. + */ + outbound(frame: JsonValue): JsonValue { + if (!isObject(frame)) return frame; + if (isObject(frame.result)) { + return { ...frame, result: this.#narrowModels(frame.result) }; + } + if ( + frame.method === "session/update" && isObject(frame.params) && + isObject(frame.params.update) + ) { + return { + ...frame, + params: { + ...frame.params, + update: this.#narrowModels(frame.params.update), + }, + }; + } + return frame; + } + + #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; + } + /** * Accepts the frozen reference itself, and the same reference with an effort variant appended * (`//`), which OpenCode's selector also understands; neither changes diff --git a/src/services/opencode-client.ts b/src/services/opencode-client.ts index 9ed1cfd..482bfc3 100644 --- a/src/services/opencode-client.ts +++ b/src/services/opencode-client.ts @@ -1,7 +1,7 @@ import type { HostProcesses, JsonValue } from "@ora-space/plugin-sdk"; import { spawnBundledOpenCode, spawnOpenCode } 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"; @@ -75,6 +75,11 @@ export class OpenCodeClient { this.#onExited = options.onExited ?? (() => {}); } + /** The only model a platform route may use; `undefined` keeps the CLI's own discovery. */ + get frozenModel(): FrozenModel | undefined { + return this.#guard?.model; + } + get running(): boolean { return this.#running !== undefined; } @@ -268,7 +273,7 @@ export class OpenCodeClient { log.debug("CLI ACP frame received", { context: { pid: process.pid, ...summarize(frame) }, }); - this.#onAcpFrame(frame); + this.#onAcpFrame(this.#guard?.outbound(frame) ?? frame); } log.debug("CLI stdout reached EOF", { context: { pid: process.pid }, diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts index 9aa4c10..ba845ce 100644 --- a/tests/model_route_test.ts +++ b/tests/model_route_test.ts @@ -331,6 +331,81 @@ Deno.test("a platform route spawns only the bundled CLI, ignoring an override an assertEquals(spawns[0].args, ["acp"]); }); +Deno.test("every model selector the CLI reports offers only the frozen model", async () => { + const { cli, client, forwarded } = await platformClient(); + assertEquals(client.frozenModel, { + id: "ora-model/team/model", + name: "Team model", + }); + const mode = { id: "mode", category: "mode", currentValue: "build" }; + const picker = { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "openai/gpt-5", + options: [ + { value: "openai/gpt-5", name: "openai/GPT-5" }, + { value: "ora-model/team/model", name: "ora-model/Team model" }, + ], + }; + const frozen = { + ...picker, + currentValue: "ora-model/team/model", + options: [{ value: "ora-model/team/model", name: "Team model" }], + }; + cli.emit({ + jsonrpc: "2.0", + id: 1, + result: { sessionId: "s1", configOptions: [picker, mode] }, + }); + cli.emit({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "s1", + update: { + sessionUpdate: "config_option_update", + configOptions: [picker], + }, + }, + }); + await waitFor(() => forwarded.length === 2); + assertEquals(forwarded, [ + { + jsonrpc: "2.0", + id: 1, + result: { sessionId: "s1", configOptions: [frozen, mode] }, + }, + { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "s1", + update: { + sessionUpdate: "config_option_update", + 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("listOpenCodeModels("), + true, + ); + assertEquals(new OpenCodeClient().frozenModel, undefined); +}); + /** A client started on the platform route over a scripted CLI. */ async function platformClient() { const cli = new ScriptedCli(); @@ -423,3 +498,12 @@ function silentProcess(): SpawnedProcess { }, }; } + +/** 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 cfea22cb2a1cf0f0fe2dbe8bf1d400670a4ab241 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:46:28 +0000 Subject: [PATCH 8/8] fix: redact CLI errors and route secrets on a platform model route --- AGENTS.md | 6 +++ src/services/model-guard.ts | 42 +++++++++++++++-- src/services/model-route.ts | 17 +++++++ src/services/opencode-client.ts | 13 ++++-- tests/model_route_test.ts | 81 +++++++++++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0591497..e8b3a0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -230,6 +230,12 @@ exact `AgentModelApplied`. `configOptions` model entry in a result or `config_option_update` is narrowed to it — `enabled_providers` already limits the catalog, but managed settings outrank this plugin's configuration. +- Nothing the CLI prints is trusted to keep the route's secrets. Every stdout + and stderr line has the token and the gateway URL, origin, host and hostname + replaced with `[redacted]` before it is parsed, forwarded or logged, and every + CLI 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. ## Process ownership diff --git a/src/services/model-guard.ts b/src/services/model-guard.ts index 49b335a..5192875 100644 --- a/src/services/model-guard.ts +++ b/src/services/model-guard.ts @@ -20,6 +20,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 CLI error says instead; its own text can quote the gateway or a provider response. */ +const REDACTED_ERROR_MESSAGE = + "The OpenCode 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 could hand the CLI another upstream, another credential, or none at all. @@ -39,13 +44,30 @@ function isRouteMutation(method: string): boolean { * * The provider itself is fixed by the configuration `model-route.ts` hands the CLI; this class * keeps a client from asking the CLI to leave it over ACP, and narrows every model selector the - * CLI reports to the frozen model. + * CLI reports to the frozen model. It also keeps the route's secrets in the CLI: error text is + * replaced, and the token and gateway address are scrubbed from every line the CLI prints before + * it is forwarded or logged. */ export class ModelGuard { readonly #model: FrozenModel; + /** Longest first, so a URL is replaced whole before the host inside it is. */ + readonly #secrets: readonly string[]; - 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 CLI 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. */ @@ -83,7 +105,7 @@ export class ModelGuard { } /** - * Narrows every model selector in one frame the CLI sent before Ora sees it. + * Narrows every model selector in, and redacts any error of, one frame the CLI sent. * * `enabled_providers` already limits OpenCode's catalog to the one provider this plugin writes, * but configuration that outranks it (managed settings) is outside this plugin's control, so @@ -92,6 +114,20 @@ export class ModelGuard { */ outbound(frame: JsonValue): JsonValue { if (!isObject(frame)) return frame; + if (typeof frame.method !== "string" && "error" in frame) { + // The code still tells Ora what kind of failure this was; the text and data are the CLI's, + // and may carry a provider response or the gateway's address (ADR D2). + 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, result: this.#narrowModels(frame.result) }; } diff --git a/src/services/model-route.ts b/src/services/model-route.ts index d108331..e26a00d 100644 --- a/src/services/model-route.ts +++ b/src/services/model-route.ts @@ -23,6 +23,11 @@ export interface ModelRoute { readonly applied: AgentModelApplied; /** The one model this execution may use; every selector and switch is held to it. */ readonly model: FrozenModel; + /** + * What must never leave the CLI: the token, and the gateway address in every spelling a + * message is likely to use (full URL, host with port, bare hostname). + */ + readonly secrets: readonly string[]; readonly environment: Readonly>; } @@ -88,6 +93,7 @@ export function modelRoute( configurationRevision: configuration.configurationRevision, }, model: { id: `${PROVIDER}/${model.id}`, name: model.name }, + secrets: gatewaySecrets(context), environment: { OPENCODE_CONFIG_CONTENT: JSON.stringify(config), [TOKEN_VARIABLE]: context.token, @@ -122,6 +128,17 @@ function platformProxy(value: string): boolean { url.password === "" && url.search === "" && url.hash === ""; } +function gatewaySecrets(context: AgentModelContext): string[] { + const gateway = new URL(context.proxyBaseUrl); + return [ + context.token, + context.proxyBaseUrl, + gateway.origin, + gateway.host, + gateway.hostname, + ]; +} + /** Fails `agent/start` without describing the sensitive input. */ function refuse(): never { throw new PluginMethodError( diff --git a/src/services/opencode-client.ts b/src/services/opencode-client.ts index 482bfc3..e5c1d4f 100644 --- a/src/services/opencode-client.ts +++ b/src/services/opencode-client.ts @@ -103,7 +103,9 @@ export class OpenCodeClient { */ 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, route.secrets); } /** @@ -250,7 +252,9 @@ export class OpenCodeClient { */ 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; @@ -297,7 +301,10 @@ export class OpenCodeClient { try { for await (const line of decodeLines(process.stderr)) { if (line.length > 0) { - cliLog.info(line, { context: { pid: process.pid } }); + // A failed provider request can be echoed with its URL or headers. + cliLog.info(this.#guard?.scrub(line) ?? line, { + context: { pid: process.pid }, + }); } } } catch (error) { diff --git a/tests/model_route_test.ts b/tests/model_route_test.ts index ba845ce..f35e975 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, @@ -9,6 +11,7 @@ import { } from "@ora-space/plugin-sdk"; import { bundledBinaryPath } from "../src/services/bundled-binary.ts"; import { BIN_OVERRIDE_ENV } from "../src/services/command.ts"; +import { installLogger } from "../src/services/log.ts"; import { startOpenCode } from "../src/handlers/lifecycle.ts"; import { modelRoute } from "../src/services/model-route.ts"; import { @@ -406,6 +409,84 @@ Deno.test("model discovery on a platform route answers the frozen model without assertEquals(new OpenCodeClient().frozenModel, undefined); }); +Deno.test("CLI errors reach Ora with their code but without their text", async () => { + const { cli, client, forwarded } = await platformClient(); + cli.emit({ + jsonrpc: "2.0", + id: 7, + error: { + code: -32000, + message: "401 from https://other.invalid: bad key", + data: { body: "leaked" }, + }, + }); + cli.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 OpenCode agent could not complete the request", + }, + }, + { + jsonrpc: "2.0", + id: 8, + error: { + code: -32603, + message: "The OpenCode agent could not complete the request", + }, + }, + ]); + await client.stop(); +}); + +Deno.test("the token and gateway address never leave the CLI's output", async () => { + const records: string[] = []; + installLogger(createLogger({ write: (line) => records.push(line) })); + try { + const { cli, client, forwarded } = await platformClient(); + cli.emit({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "s1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: + `Error calling https://gateway.internal/runtime/openai/v1/chat/completions with ${TOKEN}; gateway.internal unreachable`, + }, + }, + }, + }); + cli.emitLine(`not json ${TOKEN} https://gateway.internal`); + cli.emitStderr( + `ERROR service=llm url=https://gateway.internal/runtime/openai/v1 key=${TOKEN}`, + ); + await waitFor(() => + forwarded.length === 1 && + records.some((record) => record.includes("service=llm")) && + 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, + "Error calling [redacted]/chat/completions with [redacted]; [redacted] unreachable", + ); + await client.stop(); + } finally { + installLogger(createLogger(createStderrLogSink())); + } +}); + /** A client started on the platform route over a scripted CLI. */ async function platformClient() { const cli = new ScriptedCli();