From c76f53d10cfdc1d86457eac3b4c6e1ac9516028d 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:54:35 +0800 Subject: [PATCH] feat(opencode): own frozen model conversion and gated declaration packaging --- AGENTS.md | 19 ++- README.md | 23 ++++ assets/config.json | 1 + deno.json | 6 +- deno.lock | 13 +- orax.toml | 2 +- scripts/agent-configuration.ts | 81 +++++++++++++ scripts/package.ts | 56 ++++++++- src/base/agent-plugin.ts | 6 +- src/handlers/lifecycle.ts | 37 +++++- src/main.ts | 7 +- src/services/command.ts | 19 +++ src/services/model-adapter.ts | 108 +++++++++++++++++ src/services/opencode-client.ts | 81 +++++++++++-- tests/agent_configuration_test.ts | 38 ++++++ tests/model_adapter_test.ts | 193 ++++++++++++++++++++++++++++++ 16 files changed, 651 insertions(+), 39 deletions(-) create mode 100644 assets/config.json create mode 100644 scripts/agent-configuration.ts create mode 100644 src/services/model-adapter.ts create mode 100644 tests/agent_configuration_test.ts create mode 100644 tests/model_adapter_test.ts diff --git a/AGENTS.md b/AGENTS.md index 1a99f05..cb80428 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -230,11 +230,12 @@ nothing. 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 CLI's NDJSON and otherwise passed through - verbatim. `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/authentication guards 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 @@ -346,11 +347,9 @@ the run. - `deno task check` / `lint` / `format` / `simulate` / `build` / `sync` / `package`. -- There is no unit test suite here; `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. +- Unit tests use synthetic model bindings and declaration archives. The host + simulator needs a real CLI. Run check, lint and test as the development gate; + an unpublished SDK dependency remains a visible type-check failure in CI. - 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/README.md b/README.md index 9e55660..0fb1d5c 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,29 @@ Answers are reused for five minutes per workspace, so a picker that re-renders does not restart the CLI, while a model that appears after a provider login shows up the next time the picker is opened. +## Platform model bindings + +With the SDK 0.11 startup contract, a host can supply a frozen `modelContext`. +The adapter creates one OpenAI Chat Completions or Anthropic Messages provider +with the exact model ID, including embedded slashes. Primary and auxiliary +requests use that binding. Configuration content references an environment +token; private execution HOME/state and disabled project configuration keep +routing separate from ambient profiles. Discovery uses the active binding. + +Declared execution requires the bundled CLI and refuses authentication or model +replacement. Private diagnostics are suppressed while protocol error codes are +preserved. Node owns renewal, revocation and cleanup. The declaration +contributes no additional Settings; the exact Settings JSON startup field +remains available for future declared options. Legacy starts retain their +existing path. + +Packaging adds a declaration only with `--agent-configuration`, for bundled +artifacts after compatible hosts are deployed. Each target is read back and +checked against the same immutable bytes. Upload the generated +`dist/agent-configuration.json` with the release and its declaration +association. Default packaging publishes no declaration. Unit tests use +synthetic bindings and archives and require no installed CLI. + ## Known limits - Each model discovery leaves one empty session behind in OpenCode's own session diff --git a/assets/config.json b/assets/config.json new file mode 100644 index 0000000..37b708b --- /dev/null +++ b/assets/config.json @@ -0,0 +1 @@ +{"schemaVersion":2,"agent":{"modelAccess":{"contractVersion":1,"protocols":["openai-completions","anthropic-messages"],"modelCardinality":"single"}}} diff --git a/deno.json b/deno.json index 94661b7..a548f6c 100644 --- a/deno.json +++ b/deno.json @@ -1,9 +1,10 @@ { "name": "@ora-space/opencode-agent", - "version": "0.6.3", + "version": "0.6.12", "exports": "./src/main.ts", "minimumDependencyAge": 0, "imports": { + "@std/assert": "jsr:@std/assert@^1.0.0", "@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.10.0", "@std/cli": "jsr:@std/cli@^1.0.32", "@std/path": "jsr:@std/path@^1.1.6", @@ -11,8 +12,9 @@ "@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", + "check": "deno check src/main.ts scripts/package.ts scripts/sync-upstream.ts tests/host-simulator.ts tests/model_adapter_test.ts tests/agent_configuration_test.ts", "lint": "deno lint src scripts tests bundle.config.ts", + "test": "deno test --allow-read --allow-write --allow-env tests/model_adapter_test.ts tests/agent_configuration_test.ts", "format": "deno fmt src scripts tests bundle.config.ts deno.json README.md AGENTS.md", "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", diff --git a/deno.lock b/deno.lock index cae57f3..2808c61 100644 --- a/deno.lock +++ b/deno.lock @@ -2,8 +2,10 @@ "version": "5", "specifiers": { "jsr:@ora-space/plugin-sdk@0.10.0": "0.10.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", + "jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/path@^1.1.6": "1.1.6", "jsr:@std/streams@^1.0.17": "1.1.2", @@ -14,11 +16,17 @@ "@ora-space/plugin-sdk@0.10.0": { "integrity": "f678d20ea69f3da2a692faa174743818806c95b998ae23e5b11284c7a6d37c94" }, + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, "@std/cli@1.0.32": { "integrity": "188b3a100d6202d64e3f5bd3d799c7fa4f6d77f92cc65eb7f641c1fa0aa92a66", "dependencies": [ "jsr:@std/fmt", - "jsr:@std/internal" + "jsr:@std/internal@^1.0.14" ] }, "@std/fmt@1.0.10": { @@ -30,7 +38,7 @@ "@std/path@1.1.6": { "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.14" ] }, "@std/streams@1.1.2": { @@ -49,6 +57,7 @@ "workspace": { "dependencies": [ "jsr:@ora-space/plugin-sdk@0.10.0", + "jsr:@std/assert@1", "jsr:@std/cli@^1.0.32", "jsr:@std/path@^1.1.6", "jsr:@std/tar@~0.1.10", diff --git a/orax.toml b/orax.toml index 1e92bb7..bf1dfd6 100644 --- a/orax.toml +++ b/orax.toml @@ -3,7 +3,7 @@ title = "OpenCode" identifier = "ora-space.opencode" namespace = "official" kind = "agent" -version = "0.6.11" +version = "0.6.12" description = "Ora Space OpenCode Agent" homepage = "https://github.com/ora-space/opencode-agent" license = "Apache-2.0" diff --git a/scripts/agent-configuration.ts b/scripts/agent-configuration.ts new file mode 100644 index 0000000..4b65bf4 --- /dev/null +++ b/scripts/agent-configuration.ts @@ -0,0 +1,81 @@ +import { BlobReader, Uint8ArrayWriter, ZipReader } from "@zip-js/zip-js"; + +/** The package-relative declaration validated by the host's shared compiler. */ +export const AGENT_DECLARATION_ENTRY = "assets/config.json"; +/** Static copy distributed alongside an explicitly opted-in release. */ +export const AGENT_DECLARATION_COPY = "agent-configuration.json"; + +/** Exact bytes held once for every target, its copy and the final release association. */ +export interface AgentDeclarationPublication { + bytes: Uint8Array; + schemaVersion: number; + sha256: string; +} + +/** Reads only an explicitly requested declaration; default packaging never calls this function. */ +export async function readAgentDeclaration( + path: string, +): Promise { + const stat = await Deno.lstat(path); + if (!stat.isFile || stat.size > 256 * 1024) { + throw new Error("invalid Agent declaration file"); + } + const bytes = await Deno.readFile(path); + if (bytes.length > 256 * 1024) { + throw new Error("Agent declaration exceeds size limit"); + } + const parsed: unknown = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(bytes), + ); + if ( + typeof parsed !== "object" || parsed === null || + !("schemaVersion" in parsed) || parsed.schemaVersion !== 2 + ) throw new Error("unsupported Agent declaration schema"); + return { bytes, schemaVersion: 2, sha256: await digest(bytes) }; +} + +/** Emits no association until the caller explicitly provided verified publication bytes. */ +export function agentDeclarationManifest( + publication: AgentDeclarationPublication | undefined, +): string { + return publication + ? `\n\n[agent_configuration]\nschema_version = ${publication.schemaVersion}\ndeclaration_sha256 = "${publication.sha256}"\ncopy = "${AGENT_DECLARATION_COPY}"` + : ""; +} + +/** Rejects missing, duplicate or modified packaged copies before their association is published. */ +export async function verifyPackagedAgentDeclaration( + path: string, + publication: AgentDeclarationPublication, +): Promise { + const archive = await Deno.readFile(path); + const reader = new ZipReader(new BlobReader(new Blob([archive]))); + try { + const entries = (await reader.getEntries()).filter((entry) => + entry.filename === AGENT_DECLARATION_ENTRY + ); + const entry = entries[0]; + if ( + entries.length !== 1 || !entry || entry.directory || + entry.uncompressedSize > 256 * 1024 + ) throw new Error("package Agent declaration is missing or invalid"); + const bytes = await entry.getData(new Uint8ArrayWriter()); + if (await digest(bytes) !== publication.sha256) { + throw new Error("package Agent declaration differs from publication"); + } + } finally { + await reader.close(); + } +} + +/** Hashes immutable source bytes, rather than a parsed/reserialized approximation. */ +async function digest(bytes: Uint8Array): Promise { + const value = await crypto.subtle.digest( + "SHA-256", + new Uint8Array(bytes).buffer, + ); + return Array.from( + new Uint8Array(value), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); +} diff --git a/scripts/package.ts b/scripts/package.ts index 0caa07b..2ae50c5 100644 --- a/scripts/package.ts +++ b/scripts/package.ts @@ -21,6 +21,14 @@ import { UntarStream } from "@std/tar"; import { BlobReader, ZipReader, ZipWriter } from "@zip-js/zip-js"; import bundle from "../bundle.config.ts"; import { LOCK_PATH, readLock } from "./upstream.ts"; +import { + AGENT_DECLARATION_COPY, + AGENT_DECLARATION_ENTRY, + agentDeclarationManifest, + type AgentDeclarationPublication, + readAgentDeclaration, + verifyPackagedAgentDeclaration, +} from "./agent-configuration.ts"; /** Operating systems a package can be built for, as `Deno.build.os` spells them. */ export type TargetOs = typeof Deno.build.os; @@ -246,9 +254,21 @@ async function findLogoFiles(): Promise { * A universal package declares none: it carries no binary whose host compatibility could be wrong, * and an `[artifact]` section on it would make Ora check the one thing it cannot promise. */ -async function stagePluginFiles(target: string | undefined): Promise { +async function stagePluginFiles( + target: string | undefined, + publication?: AgentDeclarationPublication, +): Promise { await Deno.mkdir(STAGE_DIR, { recursive: true }); await Deno.copyFile(join(DIST, "main.js"), join(STAGE_DIR, "main.js")); + if (publication) { + await Deno.mkdir(dirname(join(STAGE_DIR, AGENT_DECLARATION_ENTRY)), { + recursive: true, + }); + await Deno.writeFile( + join(STAGE_DIR, AGENT_DECLARATION_ENTRY), + publication.bytes, + ); + } for (const extra of [...await findLogoFiles(), "README.md"]) { await Deno.copyFile(extra, join(STAGE_DIR, extra)).catch(() => {}); } @@ -269,6 +289,7 @@ async function buildBundledPackage( upstreamTag: string, expectedSha256: string, fileName: string, + publication?: AgentDeclarationPublication, ): Promise { await Deno.remove(STAGE_DIR, { recursive: true }).catch(() => {}); const staged = join(STAGE_DIR, plan.binaryPath); @@ -301,8 +322,14 @@ async function buildBundledPackage( // package uses for it. await extractEntry(archive, basename(plan.binaryPath), staged); - await stagePluginFiles(plan.triple); + await stagePluginFiles(plan.triple, publication); await writeOrax(STAGE_DIR, join(PACKAGES_DIR, fileName), plan.binaryPath); + if (publication) { + await verifyPackagedAgentDeclaration( + join(PACKAGES_DIR, fileName), + publication, + ); + } await Deno.remove(STAGE_DIR, { recursive: true }); await Deno.remove(archive).catch(() => {}); } @@ -330,11 +357,15 @@ async function buildRelease( identifier: string, tag: string, repo: string, + publication?: AgentDeclarationPublication, ): Promise { const base = `https://github.com/${repo}/releases/download/${tag}`; let manifest = (await Deno.readTextFile("orax.toml")).trimEnd(); if (config.cli === "user_installed") { + if (publication) { + throw new Error("declared model adapters require bundled artifacts"); + } const fileName = `${identifier}-${tag}.orax`; await buildUniversalPackage(fileName); const digest = await sha256Hex(join(PACKAGES_DIR, fileName)); @@ -374,6 +405,7 @@ async function buildRelease( lock.tag, pinned.sha256, fileName, + publication, ); const digest = await sha256Hex(join(PACKAGES_DIR, fileName)); @@ -383,11 +415,15 @@ async function buildRelease( } await Deno.remove(DOWNLOAD_DIR, { recursive: true }).catch(() => {}); console.log(`\nUpstream CLI: ${lock.tag}`); - return manifest; + return manifest + agentDeclarationManifest(publication); } async function main(): Promise { - const flags = parseArgs(Deno.args, { string: ["tag", "repo"] }); + const flags = parseArgs(Deno.args, { + string: ["tag", "repo"], + boolean: ["agent-configuration"], + default: { "agent-configuration": false }, + }); const tag = flags.tag ?? Deno.env.get("GITHUB_REF_NAME"); const repo = flags.repo ?? Deno.env.get("GITHUB_REPOSITORY"); if (tag === undefined || repo === undefined) { @@ -397,7 +433,17 @@ async function main(): Promise { const identifier = await manifestField("identifier"); await Deno.mkdir(PACKAGES_DIR, { recursive: true }); - const manifest = await buildRelease(identifier, tag, repo); + const publication = flags["agent-configuration"] + ? await readAgentDeclaration(AGENT_DECLARATION_ENTRY) + : undefined; + const manifest = await buildRelease(identifier, tag, repo, publication); + const copy = join(DIST, AGENT_DECLARATION_COPY); + if (publication) await Deno.writeFile(copy, publication.bytes); + else {try { + await Deno.remove(copy); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + }} // The marketplace index needs the release form of the manifest, which carries the download URLs // and digests. It is only knowable once the packages exist, so it is generated here rather than 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 e59a51e..5f2556b 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 { OpenCodeClient } from "../services/opencode-client.ts"; import { logger } from "../services/log.ts"; @@ -15,7 +22,7 @@ const log = logger("lifecycle"); export async function startOpenCode( client: OpenCodeClient, 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 startOpenCode( "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, + "opencode_model_adapter_failed", + ); + } + throw error; + } log.info("OpenCode CLI 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 5a11405..7cf7faa 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,7 @@ 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); }; override onStop = async (): Promise => { @@ -114,6 +115,8 @@ class OpenCodeAgentPlugin 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/command.ts b/src/services/command.ts index b4d38ce..24f6777 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, @@ -87,6 +88,24 @@ export function spawnOpenCode( ); } +/** Declared execution uses only its verified bundled CLI, without developer or PATH overrides. */ +export async function spawnOpenCodeModel( + processes: HostProcesses, + invocation: AgentInvocation, +): Promise { + try { + return await processes.spawn({ + packageCommand: bundledBinaryPath(), + ...invocation, + }); + } catch { + throw new PluginMethodError( + AGENT_UNUSABLE, + "The frozen package's bundled CLI cannot run", + ); + } +} + /** * Spawns the CLI a developer named explicitly, reporting an absent one as local configuration. * diff --git a/src/services/model-adapter.ts b/src/services/model-adapter.ts new file mode 100644 index 0000000..a4e88ee --- /dev/null +++ b/src/services/model-adapter.ts @@ -0,0 +1,108 @@ +import type { AgentModelContext, JsonValue } from "@ora-space/plugin-sdk"; +import { join } from "@std/path"; + +/** Frozen OpenCode model identity retains the entire upstream ID, including embedded slashes. */ +export function openCodeModelId(context: AgentModelContext): string { + return "ora-model/" + context.configuration.model.id; +} + +/** Converts the neutral binding into one private provider; the token is only an env reference. */ +export function openCodeModelEnvironment( + context: AgentModelContext, + home = Deno.env.get("HOME") ?? "", +): Record { + const frozen = context.configuration; + if ( + !home || frozen.target !== Deno.build.target || + frozen.pluginId.split("/").at(-1) !== "ora-space.opencode" + ) throw new Error("opencode_model_adapter_failed"); + let settings: unknown; + let url: URL; + try { + settings = JSON.parse(frozen.settingsJson); + url = new URL(context.proxyBaseUrl); + } catch { + throw new Error("opencode_model_adapter_failed"); + } + const endpoint = frozen.protocol === "openai-completions" + ? "/runtime/openai/v1" + : "/runtime/anthropic/v1"; + if ( + !isObject(settings) || Object.keys(settings).length !== 0 || + url.protocol !== "https:" || url.pathname !== endpoint || url.username || + url.password || url.search || url.hash + ) throw new Error("opencode_model_adapter_failed"); + const id = frozen.model.id; + const config = { + "$schema": "https://opencode.ai/config.json", + model: openCodeModelId(context), + small_model: openCodeModelId(context), + enabled_providers: ["ora-model"], + provider: { + "ora-model": { + npm: frozen.protocol === "openai-completions" + ? "@ai-sdk/openai-compatible" + : "@ai-sdk/anthropic", + name: "Ora model gateway", + options: { + baseURL: context.proxyBaseUrl, + apiKey: "{env:ORA_MODEL_ACCESS_TOKEN}", + }, + models: { + [id]: { + name: frozen.model.name, + limit: { + context: frozen.model.contextWindow, + output: frozen.model.maxTokens, + }, + }, + }, + }, + }, + }; + return { + OPENCODE_CONFIG_CONTENT: JSON.stringify(config), + ORA_MODEL_ACCESS_TOKEN: context.token, + OPENCODE_CONFIG: "", + OPENCODE_CONFIG_DIR: join(home, ".opencode-platform"), + OPENCODE_DISABLE_AUTOUPDATE: "true", + OPENCODE_DISABLE_MODELS_FETCH: "true", + OPENCODE_DISABLE_PROJECT_CONFIG: "true", + }; +} + +/** A single-model execution refuses credential/provider changes and out-of-binding model choices. */ +export function permitsOpenCodeFrame( + frame: JsonValue, + context: AgentModelContext, +): boolean { + if (!isObject(frame) || typeof frame.method !== "string") return true; + if ( + frame.method === "authenticate" || frame.method === "logout" || + frame.method.startsWith("providers/") + ) return false; + const params = isObject(frame.params) ? frame.params : {}; + if (frame.method === "session/set_model") { + return params.modelId === openCodeModelId(context); + } + if ( + frame.method === "session/set_config_option" && params.configId === "model" + ) return params.value === openCodeModelId(context); + return true; +} + +/** Keeps protocol error codes available while removing private provider messages and data. */ +export function redactOpenCodeModelError(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" } }; +} + +/** Only JSON objects can carry protocol envelope members. */ +function isObject(value: unknown): value is { [key: string]: JsonValue } { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/services/opencode-client.ts b/src/services/opencode-client.ts index f7f45f2..5f538b1 100644 --- a/src/services/opencode-client.ts +++ b/src/services/opencode-client.ts @@ -1,5 +1,16 @@ -import type { HostProcesses, JsonValue } from "@ora-space/plugin-sdk"; -import { spawnOpenCode } from "./command.ts"; +import type { + AgentModel, + AgentModelContext, + HostProcesses, + JsonValue, +} from "@ora-space/plugin-sdk"; +import { spawnOpenCode, spawnOpenCodeModel } from "./command.ts"; +import { + openCodeModelEnvironment, + openCodeModelId, + permitsOpenCodeFrame, + redactOpenCodeModelError, +} from "./model-adapter.ts"; import { logger } from "./log.ts"; import { decodeLines, encodeLine } from "./ndjson.ts"; @@ -55,6 +66,23 @@ export class OpenCodeClient { #processes: HostProcesses | undefined; #running: RunningProcess | undefined; #expectedExit = false; + #modelContext: AgentModelContext | undefined; + + /** Effect restarts retain this exact binding; a later start replaces the execution context. */ + setModelContext(context: AgentModelContext | undefined): void { + this.#modelContext = context; + } + + /** Discovery is scoped to the active binding, never the module's workspace-cwd cache. */ + get frozenModel(): AgentModel | undefined { + return this.#modelContext + ? { + id: openCodeModelId(this.#modelContext), + displayName: this.#modelContext.configuration.model.name, + default: true, + } + : undefined; + } constructor(options: OpenCodeClientOptions = {}) { this.#spawn = options.spawn ?? @@ -102,7 +130,7 @@ export class OpenCodeClient { throw error; } this.#running = { process, stdinWriter: process.stdin.getWriter() }; - this.#attach(process); + this.#attach(process, this.#modelContext !== undefined); log.info("CLI running", { context: { cwd, pid: process.pid } }); } @@ -113,6 +141,21 @@ export class OpenCodeClient { * unbounded queue inside this process. */ async writeAcp(frame: JsonValue): Promise { + if ( + this.#modelContext && !permitsOpenCodeFrame(frame, this.#modelContext) + ) { + 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; + } const running = this.#running; if (running === undefined) { throw new Error("the OpenCode agent is not running"); @@ -151,9 +194,9 @@ export class OpenCodeClient { } /** 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 @@ -186,7 +229,10 @@ export class OpenCodeClient { * Ora 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; @@ -196,7 +242,10 @@ export class OpenCodeClient { // The line itself is logged: it is the CLI'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 CLI", { - context: { pid: process.pid, line: line.slice(0, 512) }, + context: { + pid: process.pid, + line: sensitive ? undefined : line.slice(0, 512), + }, }); continue; } @@ -211,6 +260,7 @@ export class OpenCodeClient { log.debug("CLI ACP frame received", { context: { pid: process.pid, ...summarize(frame) }, }); + if (sensitive) frame = redactOpenCodeModelError(frame); this.#onAcpFrame(frame); } log.debug("CLI stdout reached EOF", { @@ -231,11 +281,14 @@ export class OpenCodeClient { * 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) { - cliLog.info(line, { context: { pid: process.pid } }); + if (!sensitive) cliLog.info(line, { context: { pid: process.pid } }); } } } catch (error) { @@ -259,7 +312,13 @@ export class OpenCodeClient { "OpenCodeClient cannot spawn before attachProcesses() runs", ); } - const child = await spawnOpenCode(this.#processes, { args, cwd }); + const child = this.#modelContext + ? await spawnOpenCodeModel(this.#processes, { + args, + cwd, + env: openCodeModelEnvironment(this.#modelContext), + }) + : await spawnOpenCode(this.#processes, { args, cwd }); return { stdin: new WritableStream({ write: (chunk) => child.write(chunk), diff --git a/tests/agent_configuration_test.ts b/tests/agent_configuration_test.ts new file mode 100644 index 0000000..c1975f4 --- /dev/null +++ b/tests/agent_configuration_test.ts @@ -0,0 +1,38 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { join } from "@std/path"; +import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip-js/zip-js"; +import { + AGENT_DECLARATION_ENTRY, + agentDeclarationManifest, + readAgentDeclaration, + verifyPackagedAgentDeclaration, +} from "../scripts/agent-configuration.ts"; + +Deno.test("declaration publication is explicit and binds the exact bytes of every packaged copy", async () => { + const root = await Deno.makeTempDir(); + try { + const path = join(root, "declaration.json"); + const bytes = new TextEncoder().encode( + '{"schemaVersion":2,"agent":{"modelAccess":{"contractVersion":1,"protocols":["openai-completions","anthropic-messages"],"modelCardinality":"single"}}}\n', + ); + await Deno.writeFile(path, bytes); + const publication = await readAgentDeclaration(path); + assertEquals(publication.bytes, bytes); + assertEquals(agentDeclarationManifest(undefined), ""); + assertEquals( + agentDeclarationManifest(publication).includes(publication.sha256), + true, + ); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add(AGENT_DECLARATION_ENTRY, new Uint8ArrayReader(bytes)); + const artifact = join(root, "package.orax"); + await Deno.writeFile(artifact, await writer.close()); + await verifyPackagedAgentDeclaration(artifact, publication); + const altered = { ...publication, sha256: "a".repeat(64) }; + await assertRejects(() => + verifyPackagedAgentDeclaration(artifact, altered) + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); diff --git a/tests/model_adapter_test.ts b/tests/model_adapter_test.ts new file mode 100644 index 0000000..3df31b2 --- /dev/null +++ b/tests/model_adapter_test.ts @@ -0,0 +1,193 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import type { + AgentModelContext, + HostChildProcessOptions, + HostProcesses, + JsonValue, +} from "@ora-space/plugin-sdk"; +import { + openCodeModelEnvironment, + openCodeModelId, + permitsOpenCodeFrame, + redactOpenCodeModelError, +} from "../src/services/model-adapter.ts"; +import { AGENT_UNUSABLE, PluginMethodError } from "@ora-space/plugin-sdk"; + +Deno.test("OpenCode model errors retain codes while removing private diagnostics", () => { + const token = context().token; + assertEquals( + redactOpenCodeModelError({ + jsonrpc: "2.0", + id: 7, + error: { code: -32601, message: token, data: { private: token } }, + }), + { + jsonrpc: "2.0", + id: 7, + error: { code: -32601, message: "Agent operation failed" }, + }, + ); +}); +import { spawnOpenCodeModel } from "../src/services/command.ts"; +import { OpenCodeClient } from "../src/services/opencode-client.ts"; +import { startOpenCode } from "../src/handlers/lifecycle.ts"; + +/** No credential or ambient profile is involved in the synthetic binding. */ +function context(): AgentModelContext { + return { + configuration: { + pluginId: "official/ora-space.opencode", + pluginVersion: "1.0.0", + target: Deno.build.target, + packageSha256: "a".repeat(64), + schemaVersion: 2, + declarationFingerprint: "b".repeat(64), + adapterVersion: 1, + configurationRevision: 0, + settingsJson: "{}", + protocol: "openai-completions", + model: { + id: "vendor/model", + name: "Frozen", + contextWindow: 128000, + maxTokens: 8192, + }, + }, + proxyBaseUrl: "https://gateway.invalid/runtime/openai/v1", + token: "synthetic-runtime-token", + }; +} + +Deno.test("OpenCode converts both protocols without persisting token literals or splitting model IDs", () => { + for ( + const protocol of ["openai-completions", "anthropic-messages"] as const + ) { + const frozen = context(); + frozen.configuration.protocol = protocol; + frozen.proxyBaseUrl = "https://gateway.invalid/runtime/" + + (protocol === "openai-completions" ? "openai" : "anthropic") + "/v1"; + const env = openCodeModelEnvironment(frozen, "/private/execution"); + assertEquals(env.ORA_MODEL_ACCESS_TOKEN, frozen.token); + assertEquals(env.OPENCODE_CONFIG_CONTENT.includes(frozen.token), false); + const config = JSON.parse(env.OPENCODE_CONFIG_CONTENT); + assertEquals(config.model, "ora-model/vendor/model"); + assertEquals(config.small_model, config.model); + assertEquals(config.enabled_providers, ["ora-model"]); + assertEquals(config.provider["ora-model"].options, { + baseURL: frozen.proxyBaseUrl, + apiKey: "{env:ORA_MODEL_ACCESS_TOKEN}", + }); + assertEquals(Object.keys(config.provider["ora-model"].models), [ + "vendor/model", + ]); + assertEquals(env.OPENCODE_DISABLE_PROJECT_CONFIG, "true"); + } +}); + +Deno.test("OpenCode model and authentication mutations cannot replace the frozen provider", () => { + const frozen = context(); + for (const method of ["authenticate", "logout", "providers/set"]) { + assertEquals(permitsOpenCodeFrame({ id: 1, method }, frozen), false); + } + assertEquals( + permitsOpenCodeFrame({ + id: 1, + method: "session/set_model", + params: { modelId: "other/model" }, + }, frozen), + false, + ); + assertEquals( + permitsOpenCodeFrame({ + id: 1, + method: "session/set_config_option", + params: { configId: "model", value: "other/model" }, + }, frozen), + false, + ); + assertEquals( + permitsOpenCodeFrame({ + id: 1, + method: "session/set_model", + params: { modelId: openCodeModelId(frozen) }, + }, frozen), + true, + ); +}); + +Deno.test("the declared OpenCode route never falls back to a local CLI", async () => { + const calls: HostChildProcessOptions[] = []; + const processes: HostProcesses = { + spawn: (options) => { + calls.push(options); + return Promise.reject(new Error("absent package")); + }, + }; + const error = await assertRejects( + () => spawnOpenCodeModel(processes, { cwd: "/private", args: ["acp"] }), + PluginMethodError, + ); + assertEquals(error.code, AGENT_UNUSABLE); + assertEquals(calls.length, 1); + assertEquals(calls[0]?.command, undefined); + assertEquals(calls[0]?.packageCommand?.includes("assets/bin/opencode"), true); +}); + +Deno.test("OpenCode applies the context before acknowledging startup and blocks a later model switch", async () => { + const writes: JsonValue[] = []; + const frames: JsonValue[] = []; + const output = new TransformStream(); + const errors = new TransformStream(); + const exited = Promise.withResolvers(); + const client = new OpenCodeClient({ + 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(); + assertEquals( + await startOpenCode(client, { + cwd: "/private", + hostVersion: "0", + modelContext: frozen, + }), + { + adapterVersion: 1, + declarationFingerprint: "b".repeat(64), + configurationRevision: 0, + }, + ); + 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" }, + }]); + assertEquals(client.frozenModel, { + id: "ora-model/vendor/model", + displayName: "Frozen", + default: true, + }); + await client.stop(); + await exited.promise; +});