From a768ecac91d9c2a4f4108c0488a1e1d6722b3bdf Mon Sep 17 00:00:00 2001 From: mihneaptu <223163739+mihneaptu@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:40:27 +0300 Subject: [PATCH 1/6] feat(cli): add `ocx opencode` launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode reads providers from a JSON config rather than env slots, so the `ocx claude` env-injection pattern does not transfer. This adds a launcher that generates a provider block from the proxy's visible catalog and points OPENCODE_CONFIG at it. The user's own opencode.json is never written to. Their effective config is read (explicit OPENCODE_CONFIG first, then the XDG global path), merged forward into a generated copy under the opencodex config dir, and only the `opencodex` provider key is overwritten. Carrying the base config forward keeps the command correct whether opencode merges the OPENCODE_CONFIG layer or replaces it, and leaves plain `opencode` completely unchanged. Credential handling: the generated file is written to disk and outlives the child, so it carries opencode's documented `{env:VAR}` reference instead of the admission key, and the real value is passed only through the child environment. The key resolves OPENCODEX_API_AUTH_TOKEN before config.apiKeys, matching fetchClaudeContextWindows — a non-loopback bind requires the env token and may have no apiKeys at all, where a placeholder would 401 every request. Model limits: limit.context is emitted only from an authoritative context window, including native slugs via nativeOpenAiContextWindow. opencode's schema rejects a limit block carrying context without output and CatalogModel has no authoritative output field, so a documented budget rides along, clamped to the context window so a small-context model is never emitted with output > context. Robustness: opencode.json is parsed as JSONC (strict JSON first, tolerant only on failure) because opencode documents that syntax and a commented config would otherwise be rejected as malformed; the generated file is written atomically so a concurrent launch cannot read a torn file; the detached proxy-start child gets an error listener so a failed spawn reports through the health poll instead of throwing; and a project-level opencode.json defining provider.opencodex is detected and warned about, since opencode loads that layer last and it outranks the generated block. Verified against a live proxy: 76 models registered, 47 carrying authoritative limits with no output > context violations, `opencode models opencodex` lists all of them, and an end-to-end run through opencodex/kiro/claude-haiku-4.5 returns a completion. --- docs-site/astro.config.mjs | 1 + docs-site/src/content/docs/guides/opencode.md | 90 ++++ src/cli/help.ts | 13 + src/cli/index.ts | 4 + src/cli/opencode.ts | 422 ++++++++++++++++++ tests/opencode-cli.test.ts | 233 ++++++++++ 6 files changed, 763 insertions(+) create mode 100644 docs-site/src/content/docs/guides/opencode.md create mode 100644 src/cli/opencode.ts create mode 100644 tests/opencode-cli.test.ts diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 01b6c508f2..f86b7ea19e 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -88,6 +88,7 @@ export default defineConfig({ { label: "Model Ordering", translations: { ko: "모델 정렬에 관하여", "zh-CN": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順" }, slug: "guides/model-ordering" }, { label: "Claude Code", translations: { ko: "Claude Code", "zh-CN": "Claude Code", ru: "Claude Code", ja: "Claude Code" }, slug: "guides/claude-code" }, { label: "Grok Build", translations: { ko: "Grok Build", "zh-CN": "Grok Build", ru: "Grok Build", ja: "Grok Build" }, slug: "guides/grok-build" }, + { label: "opencode", translations: { ko: "opencode", "zh-CN": "opencode", ru: "opencode", ja: "opencode" }, slug: "guides/opencode" }, { label: "Sidecars: Web Search & Vision", translations: { ko: "사이드카: 웹 검색 & 비전", "zh-CN": "边车:网络搜索与视觉", ru: "Сайдкары: веб-поиск и зрение", ja: "サイドカー: ウェブ検索 & ビジョン" }, slug: "guides/sidecars" }, { label: "Web Dashboard", translations: { ko: "웹 대시보드", "zh-CN": "网页控制台", ru: "Веб-дашборд", ja: "ウェブダッシュボード" }, slug: "guides/web-dashboard" }, { label: "Sub-agent Surface", translations: { ko: "서브에이전트 서피스", "zh-CN": "子代理界面", ru: "Интерфейс подагентов", ja: "サブエージェントサーフェス" }, slug: "guides/sub-agent-surface" }, diff --git a/docs-site/src/content/docs/guides/opencode.md b/docs-site/src/content/docs/guides/opencode.md new file mode 100644 index 0000000000..e7ce8dd3fb --- /dev/null +++ b/docs-site/src/content/docs/guides/opencode.md @@ -0,0 +1,90 @@ +--- +title: opencode +description: Use any routed model from opencode — opencodex injects a runtime provider block and leaves your own opencode config untouched. +--- + +opencode reads its providers from merged JSON config layers rather than environment +variables, so there is no `ANTHROPIC_BASE_URL`-style slot to inject. `ocx opencode` +bridges that gap: it ensures the proxy is running, builds a provider block from the +visible catalog, and injects it through OpenCode's inline runtime layer +(`OPENCODE_CONFIG_CONTENT`). + +## Quickstart + +```bash +ocx opencode +``` + +This ensures the proxy is running and launches opencode with only the generated +`provider.opencodex` block injected for that process. Extra arguments pass through: +`ocx opencode run "hello"`. + +Routed models appear in the picker under the `opencodex` provider: + +```text +opencodex/kiro/glm-5 +opencodex/gpt-5.6-sol # native slugs stay unprefixed +``` + +## Your own config is never modified + +The launcher does not read, copy, or rewrite `~/.config/opencode/opencode.json`, +project `opencode.json` / `opencode.jsonc`, or any other on-disk config layer. Your +existing providers, agents, keybinds, MCP entries, and relative `{file:…}` references +keep resolving from their original files. + +For this launch only, opencodex adds the generated `provider.opencodex` block through +OpenCode's inline runtime layer. That layer merges after global/custom/project config +and overrides only conflicting keys for the child process. + +| Layer | Behavior with `ocx opencode` | +| --- | --- | +| Global / custom / project config | Left on disk exactly as you wrote it | +| Inline runtime (`OPENCODE_CONFIG_CONTENT`) | Receives only the generated `provider.opencodex` block | +| Relative `{file:…}` paths | Still resolve against the config file that originally defined them | + +If a project config also defines `provider.opencodex`, the launcher prints an +informational note: the runtime layer from `ocx opencode` overrides it for that launch. + +## The admission key is not written to disk + +When the proxy requires an API key, the inline runtime config carries opencode's +`{env:…}` reference rather than the secret: + +```json +"options": { + "baseURL": "http://127.0.0.1:10100/v1", + "apiKey": "{env:OPENCODEX_OPENCODE_API_KEY}" +} +``` + +The real value is passed only through the child process environment. +`OPENCODEX_API_AUTH_TOKEN` takes precedence over a configured API key, which is what +a non-loopback bind requires. + +## Reverting + +Nothing to undo — no generated config file is written under `~/.opencodex`. Run plain +`opencode` and it reads your own config exactly as before. + +## Model limits + +`limit.context` is written only when the catalog reports an authoritative context window; when it +does not, the whole `limit` block is omitted and opencode keeps its own defaults. + +opencode's schema rejects a `limit` block carrying `context` without `output`, and the catalog has +no authoritative per-model output field, so an `output` budget of `32000` is emitted alongside it, +clamped down to the context window so a small-context model is never given `output > context`. +That figure exists to satisfy the schema — it is not a claim about any specific model's true +maximum. + +The `opencodex` provider block is regenerated on every launch, so per-model tweaks made inside it +will not survive. Keep custom entries under a provider key of your own instead. + +## Requirements + +opencode must be installed and on `PATH`: + +```bash +npm install -g opencode-ai +``` diff --git a/src/cli/help.ts b/src/cli/help.ts index 7933147c33..a519ccda97 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -184,6 +184,18 @@ const helpEntries: Record = { "Claude Code settings: ocx claude config ...", ], }, + opencode: { + usage: "ocx opencode [opencode args...]", + summary: "Launch opencode wired to the proxy (generated provider config).", + details: [ + "Ensures the proxy is running, then execs `opencode` with OPENCODE_CONFIG pointed at a", + "generated config in the opencodex config dir. Your own opencode.json is never modified —", + "its settings are merged forward into the generated copy, and only the `opencodex`", + "provider key is overwritten.", + "Routed models appear in the model picker as opencodex//.", + "Stop using `ocx opencode` and plain `opencode` behaves exactly as before.", + ], + }, restart: { usage: "ocx restart", summary: "Stop the proxy and restart it (background). Equivalent to stop + ensure.", @@ -256,6 +268,7 @@ Usage: ocx config Validated configuration show/get/set/import/export ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile + ocx opencode [args...] Launch opencode wired to the proxy (runtime provider config) ocx help [command] Show help ocx --version | -v Print version diff --git a/src/cli/index.ts b/src/cli/index.ts index 9c27a77010..196e52be87 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1005,6 +1005,10 @@ switch (command) { break; } process.exit(await cmdClaude(args.slice(1))); + } + case "opencode": { + const { cmdOpencode } = await import("./opencode"); + process.exit(await cmdOpencode(args.slice(1))); } case "help": case "--help": diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts new file mode 100644 index 0000000000..82772e929d --- /dev/null +++ b/src/cli/opencode.ts @@ -0,0 +1,422 @@ +/** + * `ocx opencode [opencode args...]` — launch opencode wired to the local proxy. + * + * Mirrors `ocx claude` (src/cli/claude.ts): ensure the proxy is running, then exec the + * client with stdio inherited. The wiring channel differs — opencode reads providers + * from merged JSON config layers rather than env slots. + * + * The launcher never reads, copies, or rewrites the user's opencode config files. It + * injects only the generated `provider.opencodex` block through OpenCode's inline runtime + * layer (`OPENCODE_CONFIG_CONTENT`), which outranks project/global/custom config and + * avoids duplicating API keys, MCP credentials, or breaking relative `{file:…}` paths. + * + * The admission key is never serialized into that inline config. The provider block + * carries opencode's documented `{env:VAR}` reference and the real value is passed + * only through the child process environment. + */ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { loadConfig } from "../config"; +import { commandInvocation } from "../lib/win-exec"; +import { findLiveProxy } from "../server/proxy-liveness"; +import type { OcxConfig } from "../types"; + +export interface OpencodeLaunchEnv { + [key: string]: string | undefined; +} + +/** One proxy-routed model destined for the generated provider block. */ +export interface OpencodeRoutedModel { + provider: string; + id: string; + /** Authoritative context window (CatalogModel.contextWindow); optional. */ + contextWindow?: number; + /** Authoritative display label (CatalogModel.displayName); optional. */ + displayName?: string; +} + +export interface OpencodeModelEntry { + name: string; + limit?: { context: number; output: number }; +} + +export interface OpencodeProviderBlock { + npm: string; + name: string; + options: { baseURL: string; apiKey: string }; + models: Record; +} + +export interface OpencodeGeneratedConfig { + $schema: string; + provider: Record; +} + +/** Provider key owned by this launcher; the only key it ever injects at runtime. */ +export const OPENCODE_PROVIDER_ID = "opencodex"; + +const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json"; + +const PROJECT_CONFIG_FILENAMES = ["opencode.json", "opencode.jsonc"] as const; + +/** + * OpenCode's inline runtime config layer. It merges after project/global/custom config + * and carries only the generated provider block for this launch. + */ +export const OPENCODE_CONFIG_CONTENT_ENV = "OPENCODE_CONFIG_CONTENT"; + +/** + * The proxy speaks the OpenAI-compatible shape at /v1, which opencode reaches through + * the AI SDK's openai-compatible package (the same wiring users hand-write today). + */ +const OPENCODE_PROVIDER_NPM = "@ai-sdk/openai-compatible"; + +/** + * Env var carrying the proxy admission key to the child. The inline config only ever + * holds the `{env:...}` reference, so the secret never lands on disk (AGENTS.md treats + * token serialization as a release blocker). opencode substitutes it at load time. + */ +export const OPENCODE_API_KEY_ENV = "OPENCODEX_OPENCODE_API_KEY"; + +/** + * opencode's config schema rejects a `limit` block that carries `context` without + * `output`, but CatalogModel has no authoritative per-model output field. Dropping + * `limit` entirely would also throw away the authoritative context window we DO have, + * so the block is emitted with this budget standing in for the missing half. + * + * The value matches REASONING_MAX_TOKENS_CEILING in src/adapters/anthropic.ts — the + * project's existing "safe ceiling across current models" figure. It is a ceiling for + * schema validity, NOT a claim about any specific model's true maximum, and it is + * clamped to the context window so a small-context model can never be emitted with + * output > context. + */ +export const SCHEMA_REQUIRED_OUTPUT_BUDGET = 32_000; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Strip `//` and block comments outside string literals. Escape-aware so a quote inside + * an escaped sequence cannot flip string state and expose config text to the stripper. + */ +function stripJsonComments(text: string): string { + let out = ""; + let inString = false; + let inLine = false; + let inBlock = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + const next = text[i + 1]; + if (inLine) { + if (ch === "\n") { + inLine = false; + out += ch; + } + continue; + } + if (inBlock) { + // Newlines are preserved so JSON.parse error positions stay meaningful. + if (ch === "\n") out += ch; + else if (ch === "*" && next === "/") { inBlock = false; i++; } + continue; + } + if (inString) { + out += ch; + if (ch === "\\") { + const escaped = text[i + 1]; + if (escaped !== undefined) { out += escaped; i++; } + continue; + } + if (ch === "\"") inString = false; + continue; + } + if (ch === "\"") { inString = true; out += ch; continue; } + if (ch === "/" && next === "/") { inLine = true; i++; continue; } + if (ch === "/" && next === "*") { inBlock = true; i++; continue; } + out += ch; + } + return out; +} + +/** Drop commas that sit directly before `}` or `]`, ignoring string contents. */ +function stripTrailingCommas(text: string): string { + let out = ""; + let inString = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + if (inString) { + out += ch; + if (ch === "\\") { + const escaped = text[i + 1]; + if (escaped !== undefined) { out += escaped; i++; } + continue; + } + if (ch === "\"") inString = false; + continue; + } + if (ch === "\"") { inString = true; out += ch; continue; } + if (ch === ",") { + let j = i + 1; + while (j < text.length && /\s/.test(text[j]!)) j++; + if (text[j] === "}" || text[j] === "]") continue; + } + out += ch; + } + return out; +} + +/** + * opencode documents opencode.json as JSONC, so a valid user config may carry comments + * or trailing commas. Strict JSON.parse runs first and untouched — the tolerant path is + * only attempted when that throws, keeping well-formed configs away from the stripper. + */ +export function parseJsonc(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return JSON.parse(stripTrailingCommas(stripJsonComments(text))); + } +} + +/** + * Resolve the user's global opencode config path. opencode uses the XDG layout on every + * platform (including Windows, where it is %USERPROFILE%\.config\opencode). + */ +export function opencodeGlobalConfigPath( + env: OpencodeLaunchEnv = process.env, + home: string = homedir(), +): string { + const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length > 0 ? env.XDG_CONFIG_HOME : join(home, ".config"); + return join(xdg, "opencode", "opencode.json"); +} + +/** Model key as the proxy routes it: `provider/id` for routed models, bare slug for native OpenAI entries. */ +export function opencodeModelKey(provider: string, id: string): string { + return provider === "native" ? id : `${provider}/${id}`; +} + +/** + * Build the `opencodex` provider block from the proxy's visible catalog. + * + * `limit.context` is emitted ONLY from an authoritative context window — never guessed. + * When none is available the whole `limit` block is dropped and opencode keeps its own + * defaults; when one is present, `limit.output` rides along (opencode's schema requires + * the pair) clamped to the context window. + */ +export function buildOpencodeProviderBlock( + port: number, + nativeSlugs: readonly string[], + routedModels: readonly OpencodeRoutedModel[], + nativeContextWindow: (slug: string) => number | undefined = () => undefined, +): OpencodeProviderBlock { + const models: Record = {}; + const candidates: OpencodeRoutedModel[] = [ + ...nativeSlugs.map(id => ({ provider: "native", id, contextWindow: nativeContextWindow(id) })), + ...routedModels, + ]; + for (const { provider, id, contextWindow, displayName } of candidates) { + const key = opencodeModelKey(provider, id); + if (models[key]) continue; // first entry wins; native slugs are registered first + const entry: OpencodeModelEntry = { + name: displayName && displayName.length > 0 ? `${displayName} (${provider})` : `${id} (${provider})`, + }; + if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) { + const context = Math.floor(contextWindow); + entry.limit = { context, output: Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context) }; + } + models[key] = entry; + } + return { + npm: OPENCODE_PROVIDER_NPM, + name: "OpenCodex", + options: { baseURL: `http://127.0.0.1:${port}/v1`, apiKey: `{env:${OPENCODE_API_KEY_ENV}}` }, + models, + }; +} + +/** Inline runtime config carrying only the provider block this launcher owns. */ +export function buildOpencodeConfig( + port: number, + nativeSlugs: readonly string[], + routedModels: readonly OpencodeRoutedModel[], + nativeContextWindow: (slug: string) => number | undefined = () => undefined, +): OpencodeGeneratedConfig { + return { + $schema: OPENCODE_CONFIG_SCHEMA, + provider: { [OPENCODE_PROVIDER_ID]: buildOpencodeProviderBlock(port, nativeSlugs, routedModels, nativeContextWindow) }, + }; +} + +/** Serialize the inline runtime config OpenCode merges on launch. */ +export function serializeOpencodeRuntimeConfig(config: OpencodeGeneratedConfig): string { + return JSON.stringify(config); +} + +function findGitRoot(start: string): string | null { + let dir = start; + while (true) { + if (existsSync(join(dir, ".git"))) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +function projectConfigDefinesProvider(dir: string): string | null { + for (const name of PROJECT_CONFIG_FILENAMES) { + const candidate = join(dir, name); + if (!existsSync(candidate)) continue; + try { + const parsed = parseJsonc(readFileSync(candidate, "utf8")); + if (isRecord(parsed) && isRecord(parsed.provider) && OPENCODE_PROVIDER_ID in parsed.provider) { + return candidate; + } + } catch { + // opencode will report its own parse failure; not this command's business. + } + } + return null; +} + +/** + * Detect a project-level opencode.json/jsonc that defines our provider key anywhere + * between cwd and the nearest Git root. Useful only for an informational note: the inline + * runtime layer from `OPENCODE_CONFIG_CONTENT` outranks project config. + */ +export function projectConfigOverridesProvider(cwd: string): string | null { + const gitRoot = findGitRoot(cwd); + let dir = cwd; + while (true) { + const hit = projectConfigDefinesProvider(dir); + if (hit) return hit; + if (gitRoot && dir === gitRoot) break; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +/** + * Env assembly (unit-tested). The inline runtime config carries only the generated + * provider block; the user's existing config layers and `OPENCODE_CONFIG` export stay + * untouched. The admission key travels here rather than in the inline config. + */ +export function buildOpencodeEnv( + runtimeConfig: OpencodeGeneratedConfig, + apiKey: string, + base: OpencodeLaunchEnv, +): OpencodeLaunchEnv { + return { + ...base, + [OPENCODE_CONFIG_CONTENT_ENV]: serializeOpencodeRuntimeConfig(runtimeConfig), + [OPENCODE_API_KEY_ENV]: apiKey, + }; +} + +/** + * Admission key for the proxy. The environment token wins over a configured API key — + * a non-loopback bind requires OPENCODEX_API_AUTH_TOKEN and may have no apiKeys at all, + * in which case a placeholder would 401 every request. Same precedence as + * fetchClaudeContextWindows in src/cli/claude.ts. + */ +export function opencodeApiKey(config: OcxConfig, env: OpencodeLaunchEnv = process.env): string { + return env.OPENCODEX_API_AUTH_TOKEN || config.apiKeys?.[0]?.key || "ocx"; +} + +async function ensureProxyForOpencode(config: OcxConfig): Promise { + const live = await findLiveProxy(); + if (live) return live.port; + const cfgPort = config.port; + const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100; + const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(pinPort)], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { ...process.env, OCX_SERVICE: "1" }, + }); + // Without a listener an 'error' (bad argv[1], EMFILE, AV denial) throws synchronously + // and kills this process; the health poll below already reports the failure properly. + child.on("error", () => { /* handled by the deadline loop returning null */ }); + child.unref(); + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + const started = await findLiveProxy(); + if (started) return started.port; + await new Promise(resolve => setTimeout(resolve, 250)); + } + return null; +} + +const OPENCODE_INSTALL_HINT = "❌ `opencode` CLI not found. Install it first: npm install -g opencode-ai"; + +/** + * cmd.exe reports command-not-found as exit 9009 (the win32 launcher routes `.cmd` + * shims through cmd.exe, so ENOENT never fires there). Signal exits are not hints. + * Same contract as claudeNotFoundHint (devlog 260715_cross_platform_audit/020). + */ +export function opencodeNotFoundHint( + code: number | null, + signal: NodeJS.Signals | null, + platform: NodeJS.Platform = process.platform, +): string | null { + return platform === "win32" && code === 9009 && !signal ? OPENCODE_INSTALL_HINT : null; +} + +export async function cmdOpencode(args: string[]): Promise { + const config = loadConfig(); + const port = await ensureProxyForOpencode(config); + if (!port) { + console.error("❌ Proxy did not become healthy after starting."); + return 1; + } + + const { fetchAllModels } = await import("../server/management-api"); + const { filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } = await import("../codex/catalog"); + let allModels: Awaited>; + try { + allModels = await fetchAllModels(config); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`❌ Could not fetch the model catalog from the proxy: ${reason}`); + return 1; + } + const routed = filterCatalogVisibleModels(allModels, config).map(m => ({ + provider: m.provider, + id: m.id, + contextWindow: m.contextWindow, + displayName: m.displayName, + })); + const nativeSlugs = [...visibleNativeSlugs(config)]; + + const runtimeConfig = buildOpencodeConfig(port, nativeSlugs, routed, nativeOpenAiContextWindow); + const modelCount = nativeSlugs.length + routed.length; + console.error(`✅ opencode wired to http://127.0.0.1:${port}/v1 — ${modelCount} model(s) under provider \`${OPENCODE_PROVIDER_ID}\`.`); + console.error(" Your existing opencode config files are left untouched; only the runtime provider block is injected."); + const projectOverride = projectConfigOverridesProvider(process.cwd()); + if (projectOverride) { + console.error(`ℹ ${projectOverride} also defines provider.${OPENCODE_PROVIDER_ID}; the runtime layer from ocx opencode overrides it for this launch.`); + } + + const env = buildOpencodeEnv(runtimeConfig, opencodeApiKey(config), process.env); + return await new Promise(resolve => { + const inv = commandInvocation("opencode", args); + const child = spawn(inv.file, inv.args, { stdio: "inherit", env: env as NodeJS.ProcessEnv, ...inv.options }); + child.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") { + console.error(OPENCODE_INSTALL_HINT); + } else { + console.error(`❌ Failed to launch opencode: ${err.message}`); + } + resolve(1); + }); + child.on("exit", (code, signal) => { + const hint = opencodeNotFoundHint(code, signal); + if (hint) console.error(hint); + resolve(signal ? 1 : code ?? 0); + }); + }); +} diff --git a/tests/opencode-cli.test.ts b/tests/opencode-cli.test.ts new file mode 100644 index 0000000000..1a308cb7c0 --- /dev/null +++ b/tests/opencode-cli.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + OPENCODE_API_KEY_ENV, + OPENCODE_CONFIG_CONTENT_ENV, + OPENCODE_PROVIDER_ID, + SCHEMA_REQUIRED_OUTPUT_BUDGET, + buildOpencodeConfig, + buildOpencodeEnv, + buildOpencodeProviderBlock, + opencodeApiKey, + opencodeGlobalConfigPath, + opencodeModelKey, + opencodeNotFoundHint, + parseJsonc, + projectConfigOverridesProvider, + serializeOpencodeRuntimeConfig, +} from "../src/cli/opencode"; +import type { OcxConfig } from "../src/types"; + +function cfg(extra?: Partial): OcxConfig { + return { + port: 10100, + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://x/v1" } }, + ...extra, + } as OcxConfig; +} + +describe("ocx opencode provider block", () => { + test("points at the live proxy port over the OpenAI-compatible surface", () => { + const block = buildOpencodeProviderBlock(10123, [], []); + expect(block.options.baseURL).toBe("http://127.0.0.1:10123/v1"); + expect(block.npm).toBe("@ai-sdk/openai-compatible"); + }); + + test("apiKey is an env reference, never a literal secret", () => { + const block = buildOpencodeProviderBlock(10100, [], []); + expect(block.options.apiKey).toBe(`{env:${OPENCODE_API_KEY_ENV}}`); + expect(JSON.stringify(block)).not.toContain("sk-"); + }); + + test("routed models key on provider/id, native slugs stay bare", () => { + const block = buildOpencodeProviderBlock(10100, ["gpt-5.6-sol"], [ + { provider: "kiro", id: "glm-5" }, + ]); + expect(Object.keys(block.models).sort()).toEqual(["gpt-5.6-sol", "kiro/glm-5"]); + }); + + test("limit.context is emitted only from an authoritative contextWindow — never guessed", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "kiro", id: "with-window", contextWindow: 200_000 }, + { provider: "kiro", id: "no-window" }, + { provider: "kiro", id: "zero-window", contextWindow: 0 }, + ]); + expect(block.models["kiro/with-window"]?.limit?.context).toBe(200_000); + expect(block.models["kiro/no-window"]?.limit).toBeUndefined(); + expect(block.models["kiro/zero-window"]?.limit).toBeUndefined(); + }); + + test("limit.output rides along with context because opencode's schema requires the pair", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "kiro", id: "m", contextWindow: 200_000 }, + ]); + expect(block.models["kiro/m"]?.limit).toEqual({ context: 200_000, output: SCHEMA_REQUIRED_OUTPUT_BUDGET }); + }); + + test("limit.output is clamped to the context window for small-context models", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "local", id: "tiny", contextWindow: 8_192 }, + ]); + expect(block.models["local/tiny"]?.limit).toEqual({ context: 8_192, output: 8_192 }); + }); + + test("native slugs pick up authoritative context windows from the resolver", () => { + const block = buildOpencodeProviderBlock(10100, ["gpt-5.4", "unknown-native"], [], slug => + slug === "gpt-5.4" ? 1_000_000 : undefined); + expect(block.models["gpt-5.4"]?.limit).toEqual({ context: 1_000_000, output: SCHEMA_REQUIRED_OUTPUT_BUDGET }); + expect(block.models["unknown-native"]?.limit).toBeUndefined(); + }); + + test("displayName is used for the label when the catalog provides one", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "kiro", id: "glm-5", displayName: "GLM-5" }, + { provider: "kiro", id: "qwen3-coder-next" }, + ]); + expect(block.models["kiro/glm-5"]?.name).toBe("GLM-5 (kiro)"); + expect(block.models["kiro/qwen3-coder-next"]?.name).toBe("qwen3-coder-next (kiro)"); + }); + + test("duplicate keys keep the first entry instead of throwing", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "kiro", id: "dup", displayName: "First" }, + { provider: "kiro", id: "dup", displayName: "Second" }, + ]); + expect(block.models["kiro/dup"]?.name).toBe("First (kiro)"); + }); + + test("model key helper distinguishes native from routed", () => { + expect(opencodeModelKey("native", "gpt-5.6-sol")).toBe("gpt-5.6-sol"); + expect(opencodeModelKey("kiro", "glm-5")).toBe("kiro/glm-5"); + }); +}); + +describe("ocx opencode runtime config", () => { + test("serializes only the generated provider block for OPENCODE_CONFIG_CONTENT", () => { + const runtime = buildOpencodeConfig(10100, [], [{ provider: "kiro", id: "glm-5" }]); + const parsed = JSON.parse(serializeOpencodeRuntimeConfig(runtime)) as { provider?: Record }; + expect(Object.keys(parsed.provider ?? {})).toEqual([OPENCODE_PROVIDER_ID]); + expect(parsed.provider?.[OPENCODE_PROVIDER_ID]).toBeTruthy(); + }); +}); + +describe("ocx opencode JSONC parsing", () => { + test("plain JSON parses unchanged", () => { + expect(parseJsonc('{"a":1}')).toEqual({ a: 1 }); + }); + + test("line and block comments are accepted", () => { + expect(parseJsonc('{\n // lead\n "a": 1 /* trail */\n}')).toEqual({ a: 1 }); + }); + + test("trailing commas are accepted", () => { + expect(parseJsonc('{"a":[1,2,],"b":2,}')).toEqual({ a: [1, 2], b: 2 }); + }); + + test("comment-like and comma-like text inside strings is preserved", () => { + expect(parseJsonc('{"url":"http://x/v1","note":"a // b /* c */","t":"x,"}')) + .toEqual({ url: "http://x/v1", note: "a // b /* c */", t: "x," }); + }); + + test("escaped quotes do not break string tracking", () => { + expect(parseJsonc('{"a":"he said \\"hi\\" // not a comment"}')) + .toEqual({ a: 'he said "hi" // not a comment' }); + }); + + test("genuinely malformed input still throws", () => { + expect(() => parseJsonc("{ not json")).toThrow(); + }); +}); + +describe("ocx opencode project-layer detection", () => { + test("detects a project config that redefines our provider key", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-")); + writeFileSync(join(dir, "opencode.json"), JSON.stringify({ provider: { [OPENCODE_PROVIDER_ID]: { npm: "x" } } })); + expect(projectConfigOverridesProvider(dir)).toBe(join(dir, "opencode.json")); + }); + + test("detects opencode.jsonc and parent directories up to the git root", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-root-")); + mkdirSync(join(root, "packages", "app"), { recursive: true }); + mkdirSync(join(root, ".git")); + writeFileSync(join(root, "packages", "opencode.jsonc"), `{ + // project override + "provider": { "${OPENCODE_PROVIDER_ID}": { "npm": "x" } } + }`); + expect(projectConfigOverridesProvider(join(root, "packages", "app"))).toBe(join(root, "packages", "opencode.jsonc")); + }); + + test("does not walk above the git root", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-stop-")); + const parent = join(root, "parent"); + const repo = join(parent, "repo"); + mkdirSync(repo, { recursive: true }); + mkdirSync(join(repo, ".git")); + writeFileSync(join(root, "opencode.json"), JSON.stringify({ provider: { [OPENCODE_PROVIDER_ID]: { npm: "x" } } })); + expect(projectConfigOverridesProvider(join(repo, "src"))).toBeNull(); + }); + + test("ignores a project config that defines other providers", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-")); + writeFileSync(join(dir, "opencode.json"), JSON.stringify({ provider: { other: { npm: "x" } } })); + expect(projectConfigOverridesProvider(dir)).toBeNull(); + }); + + test("no project config is not a warning", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-")); + expect(projectConfigOverridesProvider(dir)).toBeNull(); + }); +}); + +describe("ocx opencode env assembly", () => { + test("OPENCODE_CONFIG_CONTENT carries only the runtime provider block", () => { + const runtime = buildOpencodeConfig(10100, [], [{ provider: "kiro", id: "glm-5" }]); + const env = buildOpencodeEnv(runtime, "sk-ocx-123", { OPENCODE_CONFIG: "/user/mine.json", PATH: "/bin" }); + expect(env.OPENCODE_CONFIG).toBe("/user/mine.json"); + expect(env.PATH).toBe("/bin"); + const parsed = JSON.parse(env[OPENCODE_CONFIG_CONTENT_ENV]!) as { provider?: Record }; + expect(Object.keys(parsed.provider ?? {})).toEqual([OPENCODE_PROVIDER_ID]); + }); + + test("the admission key travels in the child env, matching the config's {env:…} reference", () => { + const env = buildOpencodeEnv(buildOpencodeConfig(10100, [], []), "sk-ocx-123", {}); + expect(env[OPENCODE_API_KEY_ENV]).toBe("sk-ocx-123"); + }); +}); + +describe("ocx opencode admission key", () => { + test("the environment token wins over a configured API key", () => { + const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }); + expect(opencodeApiKey(config, { OPENCODEX_API_AUTH_TOKEN: "sk-env" })).toBe("sk-env"); + }); + + test("falls back to the configured proxy API key", () => { + const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }); + expect(opencodeApiKey(config, {})).toBe("sk-cfg"); + }); + + test("falls back to a placeholder on an open loopback proxy", () => { + expect(opencodeApiKey(cfg(), {})).toBe("ocx"); + }); +}); + +describe("ocx opencode global config path", () => { + test("global path follows XDG_CONFIG_HOME when set", () => { + expect(opencodeGlobalConfigPath({ XDG_CONFIG_HOME: "/xdg" }, "/home/u")).toBe(join("/xdg", "opencode", "opencode.json")); + expect(opencodeGlobalConfigPath({}, "/home/u")).toBe(join("/home/u", ".config", "opencode", "opencode.json")); + }); +}); + +describe("ocx opencode not-found hint", () => { + test("cmd.exe reports command-not-found as 9009", () => { + expect(opencodeNotFoundHint(9009, null, "win32")).toContain("npm install -g opencode-ai"); + }); + + test("signal exits and other platforms are not hints", () => { + expect(opencodeNotFoundHint(9009, "SIGTERM", "win32")).toBeNull(); + expect(opencodeNotFoundHint(9009, null, "linux")).toBeNull(); + expect(opencodeNotFoundHint(0, null, "win32")).toBeNull(); + }); +}); From 40f4b3f650e32424ffc34e960997b59fb24185e6 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:24:05 +0200 Subject: [PATCH 2/6] fix(cli): harden ocx opencode launcher review follow-ups Merge inherited OPENCODE_CONFIG_CONTENT instead of replacing it, probe the live proxy hostname for baseURL, resolve admission from env/service token/ config keys, add x-opencodex-api-key on non-loopback binds, omit native slugs in Codex Direct mode, and widen global override detection. Stabilize the Windows storage-policy concurrency test timing. --- docs-site/src/content/docs/guides/opencode.md | 18 +- src/cli/help.ts | 11 +- src/cli/opencode.ts | 230 ++++++++++++++---- tests/api-storage-policy.test.ts | 4 +- tests/opencode-cli.test.ts | 164 ++++++++++++- 5 files changed, 352 insertions(+), 75 deletions(-) diff --git a/docs-site/src/content/docs/guides/opencode.md b/docs-site/src/content/docs/guides/opencode.md index e7ce8dd3fb..cff699d54f 100644 --- a/docs-site/src/content/docs/guides/opencode.md +++ b/docs-site/src/content/docs/guides/opencode.md @@ -28,8 +28,9 @@ opencodex/gpt-5.6-sol # native slugs stay unprefixed ## Your own config is never modified -The launcher does not read, copy, or rewrite `~/.config/opencode/opencode.json`, -project `opencode.json` / `opencode.jsonc`, or any other on-disk config layer. Your +The launcher does not copy or rewrite `~/.config/opencode/opencode.json`, +project `opencode.json` / `opencode.jsonc`, or any other on-disk config layer. It may +read global or project config to detect a `provider.opencodex` override, while your existing providers, agents, keybinds, MCP entries, and relative `{file:…}` references keep resolving from their original files. @@ -49,18 +50,23 @@ informational note: the runtime layer from `ocx opencode` overrides it for that ## The admission key is not written to disk When the proxy requires an API key, the inline runtime config carries opencode's -`{env:…}` reference rather than the secret: +`{env:…}` reference rather than the secret. Non-loopback binds also send +`x-opencodex-api-key` from the same env var so proxy admission stays separate from any +upstream Authorization header: ```json "options": { "baseURL": "http://127.0.0.1:10100/v1", - "apiKey": "{env:OPENCODEX_OPENCODE_API_KEY}" + "apiKey": "{env:OPENCODEX_OPENCODE_API_KEY}", + "headers": { + "x-opencodex-api-key": "{env:OPENCODEX_OPENCODE_API_KEY}" + } } ``` The real value is passed only through the child process environment. -`OPENCODEX_API_AUTH_TOKEN` takes precedence over a configured API key, which is what -a non-loopback bind requires. +`OPENCODEX_API_AUTH_TOKEN` takes precedence, then the hardened service token file, then +a configured API key — which is what a non-loopback bind requires. ## Reverting diff --git a/src/cli/help.ts b/src/cli/help.ts index a519ccda97..111b092815 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -186,12 +186,13 @@ const helpEntries: Record = { }, opencode: { usage: "ocx opencode [opencode args...]", - summary: "Launch opencode wired to the proxy (generated provider config).", + summary: "Launch opencode wired to the proxy (runtime provider config).", details: [ - "Ensures the proxy is running, then execs `opencode` with OPENCODE_CONFIG pointed at a", - "generated config in the opencodex config dir. Your own opencode.json is never modified —", - "its settings are merged forward into the generated copy, and only the `opencodex`", - "provider key is overwritten.", + "Ensures the proxy is running, then execs `opencode` with the generated `provider.opencodex`", + "block injected through OpenCode's inline runtime layer (`OPENCODE_CONFIG_CONTENT`). Any", + "existing inline config in the environment is preserved and only `provider.opencodex` is", + "overwritten for this launch.", + "Your on-disk opencode.json / opencode.jsonc files are never modified.", "Routed models appear in the model picker as opencodex//.", "Stop using `ocx opencode` and plain `opencode` behaves exactly as before.", ], diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 82772e929d..a5ca9be0fc 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -5,10 +5,11 @@ * client with stdio inherited. The wiring channel differs — opencode reads providers * from merged JSON config layers rather than env slots. * - * The launcher never reads, copies, or rewrites the user's opencode config files. It - * injects only the generated `provider.opencodex` block through OpenCode's inline runtime - * layer (`OPENCODE_CONFIG_CONTENT`), which outranks project/global/custom config and - * avoids duplicating API keys, MCP credentials, or breaking relative `{file:…}` paths. + * The launcher never copies or rewrites the user's opencode config files. It may read + * global/project config to detect an existing `provider.opencodex` override, then injects + * only the generated provider block through OpenCode's inline runtime layer + * (`OPENCODE_CONFIG_CONTENT`), which outranks project/global/custom config and avoids + * duplicating API keys, MCP credentials, or breaking relative `{file:…}` paths. * * The admission key is never serialized into that inline config. The provider block * carries opencode's documented `{env:VAR}` reference and the real value is passed @@ -19,8 +20,12 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { loadConfig } from "../config"; +import { visibleNativeSlugs } from "../codex/catalog"; +import { shouldInjectApiAuthHeader } from "../codex/inject"; import { commandInvocation } from "../lib/win-exec"; -import { findLiveProxy } from "../server/proxy-liveness"; +import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; +import { providerCodexAccountMode } from "../providers/registry"; +import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; export interface OpencodeLaunchEnv { @@ -45,7 +50,11 @@ export interface OpencodeModelEntry { export interface OpencodeProviderBlock { npm: string; name: string; - options: { baseURL: string; apiKey: string }; + options: { + baseURL: string; + apiKey: string; + headers?: Record; + }; models: Record; } @@ -198,6 +207,36 @@ export function opencodeModelKey(provider: string, id: string): string { return provider === "native" ? id : `${provider}/${id}`; } +/** Compose the OpenAI-compatible proxy base URL from a live probe result. */ +export function opencodeProxyBaseUrl(port: number, hostname?: string): string { + return `http://${probeHostname(hostname)}:${port}/v1`; +} + +/** Env reference shared by apiKey and the dedicated proxy admission header. */ +export const OPENCODE_API_KEY_ENV_REF = `{env:${OPENCODE_API_KEY_ENV}}`; + +/** + * Native OpenAI slugs advertised to opencode. Omitted in Codex Direct mode because native + * chat-completions require the caller's real ChatGPT OAuth bearer, not proxy admission. + */ +export function opencodeLaunchNativeSlugs(config: OcxConfig): string[] { + if (providerCodexAccountMode("openai", config.providers?.openai) === "direct") return []; + return [...visibleNativeSlugs(config)]; +} + +function opencodeProviderOptions(baseURL: string, config: OcxConfig): OpencodeProviderBlock["options"] { + const options: OpencodeProviderBlock["options"] = { + baseURL, + apiKey: OPENCODE_API_KEY_ENV_REF, + }; + // Non-loopback binds accept proxy admission only via x-opencodex-api-key so Authorization + // stays free for Codex Direct upstream credentials when applicable. + if (shouldInjectApiAuthHeader(config)) { + options.headers = { "x-opencodex-api-key": OPENCODE_API_KEY_ENV_REF }; + } + return options; +} + /** * Build the `opencodex` provider block from the proxy's visible catalog. * @@ -211,6 +250,8 @@ export function buildOpencodeProviderBlock( nativeSlugs: readonly string[], routedModels: readonly OpencodeRoutedModel[], nativeContextWindow: (slug: string) => number | undefined = () => undefined, + hostname?: string, + config?: OcxConfig, ): OpencodeProviderBlock { const models: Record = {}; const candidates: OpencodeRoutedModel[] = [ @@ -232,22 +273,74 @@ export function buildOpencodeProviderBlock( return { npm: OPENCODE_PROVIDER_NPM, name: "OpenCodex", - options: { baseURL: `http://127.0.0.1:${port}/v1`, apiKey: `{env:${OPENCODE_API_KEY_ENV}}` }, + options: opencodeProviderOptions(opencodeProxyBaseUrl(port, hostname), config ?? loadConfig()), models, }; } +export type OpencodeRuntimeConfigError = { error: string }; + +/** True when mergeOpencodeRuntimeConfig rejected inherited inline config. */ +export function isOpencodeRuntimeConfigError( + value: OpencodeGeneratedConfig | OpencodeRuntimeConfigError, +): value is OpencodeRuntimeConfigError { + return "error" in value; +} + +/** + * Merge inherited `OPENCODE_CONFIG_CONTENT` and override only `provider.opencodex`. + * When no inline layer is present, emit the minimal runtime object for this launcher. + */ +export function mergeOpencodeRuntimeConfig( + inheritedContent: string | undefined, + providerBlock: OpencodeProviderBlock, +): OpencodeGeneratedConfig | OpencodeRuntimeConfigError { + if (!inheritedContent?.trim()) { + return { + $schema: OPENCODE_CONFIG_SCHEMA, + provider: { [OPENCODE_PROVIDER_ID]: providerBlock }, + }; + } + let parsed: unknown; + try { + parsed = JSON.parse(inheritedContent); + } catch { + return { error: "OPENCODE_CONFIG_CONTENT is not valid JSON." }; + } + if (!isRecord(parsed)) { + return { error: "OPENCODE_CONFIG_CONTENT must be a JSON object." }; + } + const existingProvider = parsed.provider; + if (existingProvider !== undefined && !isRecord(existingProvider)) { + return { error: "OPENCODE_CONFIG_CONTENT provider must be a JSON object when present." }; + } + return { + ...parsed, + $schema: typeof parsed.$schema === "string" ? parsed.$schema : OPENCODE_CONFIG_SCHEMA, + provider: { + ...(isRecord(existingProvider) ? existingProvider : {}), + [OPENCODE_PROVIDER_ID]: providerBlock, + }, + } as OpencodeGeneratedConfig; +} + /** Inline runtime config carrying only the provider block this launcher owns. */ export function buildOpencodeConfig( port: number, nativeSlugs: readonly string[], routedModels: readonly OpencodeRoutedModel[], nativeContextWindow: (slug: string) => number | undefined = () => undefined, + hostname?: string, + config?: OcxConfig, ): OpencodeGeneratedConfig { - return { - $schema: OPENCODE_CONFIG_SCHEMA, - provider: { [OPENCODE_PROVIDER_ID]: buildOpencodeProviderBlock(port, nativeSlugs, routedModels, nativeContextWindow) }, - }; + const merged = mergeOpencodeRuntimeConfig( + undefined, + buildOpencodeProviderBlock(port, nativeSlugs, routedModels, nativeContextWindow, hostname, config), + ); + if (isOpencodeRuntimeConfigError(merged)) { + throw new Error(merged.error); + } + return merged; } /** Serialize the inline runtime config OpenCode merges on launch. */ @@ -265,33 +358,35 @@ function findGitRoot(start: string): string | null { } } -function projectConfigDefinesProvider(dir: string): string | null { - for (const name of PROJECT_CONFIG_FILENAMES) { - const candidate = join(dir, name); - if (!existsSync(candidate)) continue; - try { - const parsed = parseJsonc(readFileSync(candidate, "utf8")); - if (isRecord(parsed) && isRecord(parsed.provider) && OPENCODE_PROVIDER_ID in parsed.provider) { - return candidate; - } - } catch { - // opencode will report its own parse failure; not this command's business. - } +function configFileDefinesProvider(path: string): boolean { + if (!existsSync(path)) return false; + try { + const parsed = parseJsonc(readFileSync(path, "utf8")); + return isRecord(parsed) && isRecord(parsed.provider) && OPENCODE_PROVIDER_ID in parsed.provider; + } catch { + return false; } - return null; } /** - * Detect a project-level opencode.json/jsonc that defines our provider key anywhere - * between cwd and the nearest Git root. Useful only for an informational note: the inline - * runtime layer from `OPENCODE_CONFIG_CONTENT` outranks project config. + * Detect global or project-level opencode.json/jsonc that defines our provider key. + * Informational only: the inline runtime layer from `OPENCODE_CONFIG_CONTENT` outranks both. */ -export function projectConfigOverridesProvider(cwd: string): string | null { +export function opencodeProviderOverridePath( + cwd: string, + env: OpencodeLaunchEnv = process.env, + home: string = homedir(), +): string | null { + const globalPath = opencodeGlobalConfigPath(env, home); + if (configFileDefinesProvider(globalPath)) return globalPath; + const gitRoot = findGitRoot(cwd); let dir = cwd; while (true) { - const hit = projectConfigDefinesProvider(dir); - if (hit) return hit; + for (const name of PROJECT_CONFIG_FILENAMES) { + const candidate = join(dir, name); + if (configFileDefinesProvider(candidate)) return candidate; + } if (gitRoot && dir === gitRoot) break; const parent = dirname(dir); if (parent === dir) break; @@ -300,16 +395,28 @@ export function projectConfigOverridesProvider(cwd: string): string | null { return null; } +/** @deprecated Use {@link opencodeProviderOverridePath}. */ +export function projectConfigOverridesProvider(cwd: string): string | null { + return opencodeProviderOverridePath(cwd); +} + +function serviceTokenLookupEnv(env: OpencodeLaunchEnv): OpencodeLaunchEnv { + if (env.OCX_API_TOKEN_FILE?.trim()) return env; + return { ...env, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() }; +} + /** - * Env assembly (unit-tested). The inline runtime config carries only the generated - * provider block; the user's existing config layers and `OPENCODE_CONFIG` export stay - * untouched. The admission key travels here rather than in the inline config. + * Env assembly (unit-tested). Inherited inline config is merged and only + * `provider.opencodex` is replaced; disk config layers stay untouched. The admission + * key travels in the child env rather than in the inline config payload. */ export function buildOpencodeEnv( - runtimeConfig: OpencodeGeneratedConfig, + providerBlock: OpencodeProviderBlock, apiKey: string, base: OpencodeLaunchEnv, -): OpencodeLaunchEnv { +): OpencodeLaunchEnv | OpencodeRuntimeConfigError { + const runtimeConfig = mergeOpencodeRuntimeConfig(base[OPENCODE_CONFIG_CONTENT_ENV], providerBlock); + if (isOpencodeRuntimeConfigError(runtimeConfig)) return runtimeConfig; return { ...base, [OPENCODE_CONFIG_CONTENT_ENV]: serializeOpencodeRuntimeConfig(runtimeConfig), @@ -318,18 +425,20 @@ export function buildOpencodeEnv( } /** - * Admission key for the proxy. The environment token wins over a configured API key — - * a non-loopback bind requires OPENCODEX_API_AUTH_TOKEN and may have no apiKeys at all, - * in which case a placeholder would 401 every request. Same precedence as - * fetchClaudeContextWindows in src/cli/claude.ts. + * Admission key for the proxy: env token, hardened service token file, configured API + * key, then the open-loopback placeholder. Never serialized into runtime config. */ export function opencodeApiKey(config: OcxConfig, env: OpencodeLaunchEnv = process.env): string { - return env.OPENCODEX_API_AUTH_TOKEN || config.apiKeys?.[0]?.key || "ocx"; + const envToken = env.OPENCODEX_API_AUTH_TOKEN?.trim(); + if (envToken) return envToken; + const serviceToken = loadServiceTokenFromFile(serviceTokenLookupEnv(env)); + if (serviceToken) return serviceToken; + return config.apiKeys?.[0]?.key || "ocx"; } -async function ensureProxyForOpencode(config: OcxConfig): Promise { +async function ensureProxyForOpencode(config: OcxConfig): Promise { const live = await findLiveProxy(); - if (live) return live.port; + if (live) return live; const cfgPort = config.port; const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100; const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(pinPort)], { @@ -345,7 +454,7 @@ async function ensureProxyForOpencode(config: OcxConfig): Promise const deadline = Date.now() + 8_000; while (Date.now() < deadline) { const started = await findLiveProxy(); - if (started) return started.port; + if (started) return started; await new Promise(resolve => setTimeout(resolve, 250)); } return null; @@ -368,14 +477,14 @@ export function opencodeNotFoundHint( export async function cmdOpencode(args: string[]): Promise { const config = loadConfig(); - const port = await ensureProxyForOpencode(config); - if (!port) { + const live = await ensureProxyForOpencode(config); + if (!live) { console.error("❌ Proxy did not become healthy after starting."); return 1; } const { fetchAllModels } = await import("../server/management-api"); - const { filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } = await import("../codex/catalog"); + const { filterCatalogVisibleModels, nativeOpenAiContextWindow } = await import("../codex/catalog"); let allModels: Awaited>; try { allModels = await fetchAllModels(config); @@ -390,18 +499,31 @@ export async function cmdOpencode(args: string[]): Promise { contextWindow: m.contextWindow, displayName: m.displayName, })); - const nativeSlugs = [...visibleNativeSlugs(config)]; - - const runtimeConfig = buildOpencodeConfig(port, nativeSlugs, routed, nativeOpenAiContextWindow); + const nativeSlugs = opencodeLaunchNativeSlugs(config); + + const providerBlock = buildOpencodeProviderBlock( + live.port, + nativeSlugs, + routed, + nativeOpenAiContextWindow, + live.hostname, + config, + ); + const baseUrl = providerBlock.options.baseURL; const modelCount = nativeSlugs.length + routed.length; - console.error(`✅ opencode wired to http://127.0.0.1:${port}/v1 — ${modelCount} model(s) under provider \`${OPENCODE_PROVIDER_ID}\`.`); + console.error(`✅ opencode wired to ${baseUrl} — ${modelCount} model(s) under provider \`${OPENCODE_PROVIDER_ID}\`.`); console.error(" Your existing opencode config files are left untouched; only the runtime provider block is injected."); - const projectOverride = projectConfigOverridesProvider(process.cwd()); - if (projectOverride) { - console.error(`ℹ ${projectOverride} also defines provider.${OPENCODE_PROVIDER_ID}; the runtime layer from ocx opencode overrides it for this launch.`); + const providerOverride = opencodeProviderOverridePath(process.cwd()); + if (providerOverride) { + console.error(`ℹ ${providerOverride} also defines provider.${OPENCODE_PROVIDER_ID}; the runtime layer from ocx opencode overrides it for this launch.`); } - const env = buildOpencodeEnv(runtimeConfig, opencodeApiKey(config), process.env); + const builtEnv = buildOpencodeEnv(providerBlock, opencodeApiKey(config), process.env); + if ("error" in builtEnv) { + console.error(`❌ ${builtEnv.error}`); + return 1; + } + const env = builtEnv; return await new Promise(resolve => { const inv = commandInvocation("opencode", args); const child = spawn(inv.file, inv.args, { stdio: "inherit", env: env as NodeJS.ProcessEnv, ...inv.options }); diff --git a/tests/api-storage-policy.test.ts b/tests/api-storage-policy.test.ts index 499b078ff5..ef87247660 100644 --- a/tests/api-storage-policy.test.ts +++ b/tests/api-storage-policy.test.ts @@ -283,7 +283,7 @@ describe("storage cleanup policy API", () => { }, { timeout: 30_000 }); test("blocked worker completion preserves concurrent policy PUT edits", async () => { - setStorageCleanupPolicyJobTestHooks({ blockMs: 800 }); + setStorageCleanupPolicyJobTestHooks({ blockMs: 1_200 }); seedArchived(isolatedCodexHome!.path); const server = startServer(0); try { @@ -317,7 +317,7 @@ describe("storage cleanup policy API", () => { } // Let the worker load the start-of-job snapshot, then edit during the hold window. - await Bun.sleep(120); + await Bun.sleep(450); const put = await fetch(new URL("/api/storage/cleanup-policy", server.url), { method: "PUT", diff --git a/tests/opencode-cli.test.ts b/tests/opencode-cli.test.ts index 1a308cb7c0..ec9a7adc7e 100644 --- a/tests/opencode-cli.test.ts +++ b/tests/opencode-cli.test.ts @@ -4,16 +4,22 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { OPENCODE_API_KEY_ENV, + OPENCODE_API_KEY_ENV_REF, OPENCODE_CONFIG_CONTENT_ENV, OPENCODE_PROVIDER_ID, SCHEMA_REQUIRED_OUTPUT_BUDGET, buildOpencodeConfig, buildOpencodeEnv, buildOpencodeProviderBlock, + isOpencodeRuntimeConfigError, + mergeOpencodeRuntimeConfig, opencodeApiKey, opencodeGlobalConfigPath, + opencodeLaunchNativeSlugs, opencodeModelKey, opencodeNotFoundHint, + opencodeProviderOverridePath, + opencodeProxyBaseUrl, parseJsonc, projectConfigOverridesProvider, serializeOpencodeRuntimeConfig, @@ -36,12 +42,45 @@ describe("ocx opencode provider block", () => { expect(block.npm).toBe("@ai-sdk/openai-compatible"); }); + test("uses probeHostname for IPv6 and specific-interface binds", () => { + expect(buildOpencodeProviderBlock(10100, [], [], () => undefined, "::1").options.baseURL) + .toBe("http://[::1]:10100/v1"); + expect(buildOpencodeProviderBlock(10100, [], [], () => undefined, "192.168.4.10").options.baseURL) + .toBe("http://192.168.4.10:10100/v1"); + expect(opencodeProxyBaseUrl(8080, "fe80::1")).toBe("http://[fe80::1]:8080/v1"); + }); + test("apiKey is an env reference, never a literal secret", () => { const block = buildOpencodeProviderBlock(10100, [], []); - expect(block.options.apiKey).toBe(`{env:${OPENCODE_API_KEY_ENV}}`); + expect(block.options.apiKey).toBe(OPENCODE_API_KEY_ENV_REF); expect(JSON.stringify(block)).not.toContain("sk-"); }); + test("non-loopback binds add x-opencodex-api-key via env reference", () => { + const block = buildOpencodeProviderBlock( + 10100, + [], + [], + () => undefined, + "0.0.0.0", + cfg({ hostname: "0.0.0.0" }), + ); + expect(block.options.headers).toEqual({ "x-opencodex-api-key": OPENCODE_API_KEY_ENV_REF }); + expect(JSON.stringify(block.options.headers)).not.toContain("sk-"); + }); + + test("loopback binds omit the dedicated admission header", () => { + const block = buildOpencodeProviderBlock( + 10100, + [], + [], + () => undefined, + "127.0.0.1", + cfg({ hostname: "127.0.0.1" }), + ); + expect(block.options.headers).toBeUndefined(); + }); + test("routed models key on provider/id, native slugs stay bare", () => { const block = buildOpencodeProviderBlock(10100, ["gpt-5.6-sol"], [ { provider: "kiro", id: "glm-5" }, @@ -111,6 +150,33 @@ describe("ocx opencode runtime config", () => { expect(Object.keys(parsed.provider ?? {})).toEqual([OPENCODE_PROVIDER_ID]); expect(parsed.provider?.[OPENCODE_PROVIDER_ID]).toBeTruthy(); }); + + test("merges inherited inline settings and overrides only provider.opencodex", () => { + const inherited = JSON.stringify({ + model: "other/default", + agents: { coder: { model: "x" } }, + provider: { + other: { npm: "@other/pkg", name: "Other" }, + [OPENCODE_PROVIDER_ID]: { npm: "stale", name: "Stale" }, + }, + }); + const block = buildOpencodeProviderBlock(10100, [], [{ provider: "kiro", id: "glm-5" }]); + const merged = mergeOpencodeRuntimeConfig(inherited, block); + expect(isOpencodeRuntimeConfigError(merged)).toBe(false); + if (isOpencodeRuntimeConfigError(merged)) return; + expect(merged.model).toBe("other/default"); + expect(merged.agents).toEqual({ coder: { model: "x" } }); + expect(merged.provider.other).toEqual({ npm: "@other/pkg", name: "Other" }); + expect(merged.provider[OPENCODE_PROVIDER_ID]).toEqual(block); + }); + + test("rejects invalid inherited OPENCODE_CONFIG_CONTENT", () => { + const block = buildOpencodeProviderBlock(10100, [], []); + expect(mergeOpencodeRuntimeConfig("{ not json", block)).toEqual({ error: "OPENCODE_CONFIG_CONTENT is not valid JSON." }); + expect(mergeOpencodeRuntimeConfig("[]", block)).toEqual({ error: "OPENCODE_CONFIG_CONTENT must be a JSON object." }); + expect(mergeOpencodeRuntimeConfig(JSON.stringify({ provider: "bad" }), block)) + .toEqual({ error: "OPENCODE_CONFIG_CONTENT provider must be a JSON object when present." }); + }); }); describe("ocx opencode JSONC parsing", () => { @@ -141,7 +207,51 @@ describe("ocx opencode JSONC parsing", () => { }); }); +describe("ocx opencode native slug selection", () => { + test("omits native slugs in Codex Direct mode", () => { + const config = cfg({ + providers: { + mock: { adapter: "openai-chat", baseUrl: "http://x/v1" }, + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + }, + }); + expect(opencodeLaunchNativeSlugs(config)).toEqual([]); + const block = buildOpencodeProviderBlock(10100, opencodeLaunchNativeSlugs(config), [], () => undefined, undefined, config); + expect(Object.keys(block.models)).toEqual([]); + }); + + test("keeps native slugs in pool mode", () => { + const config = cfg({ + providers: { + mock: { adapter: "openai-chat", baseUrl: "http://x/v1" }, + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + }); + expect(opencodeLaunchNativeSlugs(config).length).toBeGreaterThan(0); + }); +}); + describe("ocx opencode project-layer detection", () => { + test("detects a global config that redefines our provider key", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-opencode-global-")); + const globalDir = join(home, ".config", "opencode"); + mkdirSync(globalDir, { recursive: true }); + const globalPath = join(globalDir, "opencode.json"); + writeFileSync(globalPath, JSON.stringify({ provider: { [OPENCODE_PROVIDER_ID]: { npm: "x" } } })); + expect(opencodeProviderOverridePath(join(home, "project"), { XDG_CONFIG_HOME: join(home, ".config") }, home)) + .toBe(globalPath); + }); + test("detects a project config that redefines our provider key", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-")); writeFileSync(join(dir, "opencode.json"), JSON.stringify({ provider: { [OPENCODE_PROVIDER_ID]: { npm: "x" } } })); @@ -183,17 +293,47 @@ describe("ocx opencode project-layer detection", () => { describe("ocx opencode env assembly", () => { test("OPENCODE_CONFIG_CONTENT carries only the runtime provider block", () => { - const runtime = buildOpencodeConfig(10100, [], [{ provider: "kiro", id: "glm-5" }]); - const env = buildOpencodeEnv(runtime, "sk-ocx-123", { OPENCODE_CONFIG: "/user/mine.json", PATH: "/bin" }); - expect(env.OPENCODE_CONFIG).toBe("/user/mine.json"); - expect(env.PATH).toBe("/bin"); - const parsed = JSON.parse(env[OPENCODE_CONFIG_CONTENT_ENV]!) as { provider?: Record }; + const block = buildOpencodeProviderBlock(10100, [], [{ provider: "kiro", id: "glm-5" }]); + const built = buildOpencodeEnv(block, "sk-ocx-123", { OPENCODE_CONFIG: "/user/mine.json", PATH: "/bin" }); + expect(isOpencodeRuntimeConfigError(built)).toBe(false); + if (isOpencodeRuntimeConfigError(built)) return; + expect(built.OPENCODE_CONFIG).toBe("/user/mine.json"); + expect(built.PATH).toBe("/bin"); + const parsed = JSON.parse(built[OPENCODE_CONFIG_CONTENT_ENV]!) as { provider?: Record }; expect(Object.keys(parsed.provider ?? {})).toEqual([OPENCODE_PROVIDER_ID]); }); + test("preserves inherited inline settings in OPENCODE_CONFIG_CONTENT", () => { + const block = buildOpencodeProviderBlock(10100, [], [{ provider: "kiro", id: "glm-5" }]); + const inherited = JSON.stringify({ + model: "custom/model", + provider: { other: { npm: "@other/pkg" } }, + }); + const built = buildOpencodeEnv(block, "sk-ocx-123", { [OPENCODE_CONFIG_CONTENT_ENV]: inherited }); + expect(isOpencodeRuntimeConfigError(built)).toBe(false); + if (isOpencodeRuntimeConfigError(built)) return; + const parsed = JSON.parse(built[OPENCODE_CONFIG_CONTENT_ENV]!) as { + model?: string; + provider?: Record; + }; + expect(parsed.model).toBe("custom/model"); + expect(parsed.provider?.other).toEqual({ npm: "@other/pkg" }); + expect(parsed.provider?.[OPENCODE_PROVIDER_ID]).toEqual(block); + }); + + test("surfaces invalid inherited OPENCODE_CONFIG_CONTENT as an error", () => { + const block = buildOpencodeProviderBlock(10100, [], []); + expect(buildOpencodeEnv(block, "sk-ocx-123", { [OPENCODE_CONFIG_CONTENT_ENV]: "[]" })) + .toEqual({ error: "OPENCODE_CONFIG_CONTENT must be a JSON object." }); + }); + test("the admission key travels in the child env, matching the config's {env:…} reference", () => { - const env = buildOpencodeEnv(buildOpencodeConfig(10100, [], []), "sk-ocx-123", {}); - expect(env[OPENCODE_API_KEY_ENV]).toBe("sk-ocx-123"); + const block = buildOpencodeProviderBlock(10100, [], []); + const built = buildOpencodeEnv(block, "sk-ocx-123", {}); + expect(isOpencodeRuntimeConfigError(built)).toBe(false); + if (isOpencodeRuntimeConfigError(built)) return; + expect(built[OPENCODE_API_KEY_ENV]).toBe("sk-ocx-123"); + expect(built[OPENCODE_CONFIG_CONTENT_ENV]).not.toContain("sk-ocx-123"); }); }); @@ -203,6 +343,14 @@ describe("ocx opencode admission key", () => { expect(opencodeApiKey(config, { OPENCODEX_API_AUTH_TOKEN: "sk-env" })).toBe("sk-env"); }); + test("falls back to the hardened service token file before config.apiKeys", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-token-")); + const tokenFile = join(dir, "service-api-token"); + writeFileSync(tokenFile, "sk-service\n", "utf8"); + const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }); + expect(opencodeApiKey(config, { OCX_API_TOKEN_FILE: tokenFile })).toBe("sk-service"); + }); + test("falls back to the configured proxy API key", () => { const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }); expect(opencodeApiKey(config, {})).toBe("sk-cfg"); From e4544a29f3dad131e0910fe1c73dd3dda8f082d5 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:34:19 +0200 Subject: [PATCH 3/6] fix(cli): pass service token file to ocx opencode auto-start When ocx opencode spawns a detached ocx start and OPENCODEX_API_AUTH_TOKEN is absent, propagate OCX_API_TOKEN_FILE (or the default hardened path) so handleStart can load the service token before a non-loopback bind. --- src/cli/opencode.ts | 14 +++++++++++++- tests/opencode-cli.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index a5ca9be0fc..898244e21f 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -405,6 +405,18 @@ function serviceTokenLookupEnv(env: OpencodeLaunchEnv): OpencodeLaunchEnv { return { ...env, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() }; } +/** + * Child env for a detached `ocx start` from `ocx opencode`. When the admission token is + * not already in the environment, pass through an existing `OCX_API_TOKEN_FILE` or the + * default hardened service token path so `handleStart` can load it before bind. + */ +export function opencodeProxyStartEnv(base: OpencodeLaunchEnv = process.env): OpencodeLaunchEnv { + const withTokenFile = base.OPENCODEX_API_AUTH_TOKEN?.trim() + ? base + : serviceTokenLookupEnv(base); + return { ...withTokenFile, OCX_SERVICE: "1" }; +} + /** * Env assembly (unit-tested). Inherited inline config is merged and only * `provider.opencodex` is replaced; disk config layers stay untouched. The admission @@ -445,7 +457,7 @@ async function ensureProxyForOpencode(config: OcxConfig): Promise { }); }); +describe("ocx opencode proxy auto-start env", () => { + test("passes OCX_API_TOKEN_FILE to ocx start when only the hardened service token exists", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-start-")); + const tokenFile = join(dir, "service-api-token"); + writeFileSync(tokenFile, "sk-service-only\n", "utf8"); + const config = cfg({ hostname: "0.0.0.0", apiKeys: [] as OcxConfig["apiKeys"] }); + + const startEnv = opencodeProxyStartEnv({ OCX_API_TOKEN_FILE: tokenFile }); + expect(startEnv.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(startEnv.OCX_API_TOKEN_FILE).toBe(tokenFile); + expect(startEnv.OCX_SERVICE).toBe("1"); + expect(JSON.stringify(startEnv)).not.toContain("sk-service-only"); + expect(loadServiceTokenFromFile(startEnv)).toBe("sk-service-only"); + expect(opencodeApiKey(config, startEnv)).toBe("sk-service-only"); + }); + + test("defaults OCX_API_TOKEN_FILE when admission env token is absent", () => { + const startEnv = opencodeProxyStartEnv({ hostname: "0.0.0.0" }); + expect(startEnv.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(startEnv.OCX_API_TOKEN_FILE).toBe(serviceApiTokenFilePath()); + expect(startEnv.OCX_SERVICE).toBe("1"); + }); + + test("does not inject OCX_API_TOKEN_FILE when OPENCODEX_API_AUTH_TOKEN is already set", () => { + const startEnv = opencodeProxyStartEnv({ OPENCODEX_API_AUTH_TOKEN: "sk-env", hostname: "0.0.0.0" }); + expect(startEnv.OPENCODEX_API_AUTH_TOKEN).toBe("sk-env"); + expect(startEnv.OCX_API_TOKEN_FILE).toBeUndefined(); + }); +}); + describe("ocx opencode global config path", () => { test("global path follows XDG_CONFIG_HOME when set", () => { expect(opencodeGlobalConfigPath({ XDG_CONFIG_HOME: "/xdg" }, "/home/u")).toBe(join("/xdg", "opencode", "opencode.json")); From 2cfe07baa147ebbdaadf45693bb680c97976e8b1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:37:44 +0200 Subject: [PATCH 4/6] fix(cli): address CodeRabbit opencode review follow-ups Use a deterministic loopback default config in provider-block helpers instead of loadConfig(), send proxy admission via apiKey on loopback and only x-opencodex-api-key on non-loopback binds, and split the docs/help examples accordingly. --- docs-site/src/content/docs/guides/opencode.md | 20 ++++++++++++---- src/cli/help.ts | 3 ++- src/cli/opencode.ts | 23 ++++++++++++------- tests/opencode-cli.test.ts | 6 +++-- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/docs-site/src/content/docs/guides/opencode.md b/docs-site/src/content/docs/guides/opencode.md index cff699d54f..c54be6956b 100644 --- a/docs-site/src/content/docs/guides/opencode.md +++ b/docs-site/src/content/docs/guides/opencode.md @@ -44,20 +44,30 @@ and overrides only conflicting keys for the child process. | Inline runtime (`OPENCODE_CONFIG_CONTENT`) | Receives only the generated `provider.opencodex` block | | Relative `{file:…}` paths | Still resolve against the config file that originally defined them | -If a project config also defines `provider.opencodex`, the launcher prints an +If a global or project config also defines `provider.opencodex`, the launcher prints an informational note: the runtime layer from `ocx opencode` overrides it for that launch. ## The admission key is not written to disk When the proxy requires an API key, the inline runtime config carries opencode's -`{env:…}` reference rather than the secret. Non-loopback binds also send -`x-opencodex-api-key` from the same env var so proxy admission stays separate from any -upstream Authorization header: +`{env:…}` reference rather than the secret. Loopback binds use that reference as +`apiKey`; non-loopback binds send it only through `x-opencodex-api-key` so proxy +admission stays separate from any upstream `Authorization` header. + +Loopback example: ```json "options": { "baseURL": "http://127.0.0.1:10100/v1", - "apiKey": "{env:OPENCODEX_OPENCODE_API_KEY}", + "apiKey": "{env:OPENCODEX_OPENCODE_API_KEY}" +} +``` + +Non-loopback example: + +```json +"options": { + "baseURL": "http://192.168.1.10:10100/v1", "headers": { "x-opencodex-api-key": "{env:OPENCODEX_OPENCODE_API_KEY}" } diff --git a/src/cli/help.ts b/src/cli/help.ts index 111b092815..95f2d67d68 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -192,7 +192,8 @@ const helpEntries: Record = { "block injected through OpenCode's inline runtime layer (`OPENCODE_CONFIG_CONTENT`). Any", "existing inline config in the environment is preserved and only `provider.opencodex` is", "overwritten for this launch.", - "Your on-disk opencode.json / opencode.jsonc files are never modified.", + "Global/project opencode.json may be read to warn about an existing provider.opencodex", + "override; on-disk files are never modified.", "Routed models appear in the model picker as opencodex//.", "Stop using `ocx opencode` and plain `opencode` behaves exactly as before.", ], diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 898244e21f..55f827627d 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -52,7 +52,7 @@ export interface OpencodeProviderBlock { name: string; options: { baseURL: string; - apiKey: string; + apiKey?: string; headers?: Record; }; models: Record; @@ -103,6 +103,14 @@ export const OPENCODE_API_KEY_ENV = "OPENCODEX_OPENCODE_API_KEY"; */ export const SCHEMA_REQUIRED_OUTPUT_BUDGET = 32_000; +/** Deterministic loopback default for exported provider-block helpers in tests. */ +export const OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -225,15 +233,14 @@ export function opencodeLaunchNativeSlugs(config: OcxConfig): string[] { } function opencodeProviderOptions(baseURL: string, config: OcxConfig): OpencodeProviderBlock["options"] { - const options: OpencodeProviderBlock["options"] = { - baseURL, - apiKey: OPENCODE_API_KEY_ENV_REF, - }; + const options: OpencodeProviderBlock["options"] = { baseURL }; // Non-loopback binds accept proxy admission only via x-opencodex-api-key so Authorization // stays free for Codex Direct upstream credentials when applicable. if (shouldInjectApiAuthHeader(config)) { options.headers = { "x-opencodex-api-key": OPENCODE_API_KEY_ENV_REF }; + return options; } + options.apiKey = OPENCODE_API_KEY_ENV_REF; return options; } @@ -251,7 +258,7 @@ export function buildOpencodeProviderBlock( routedModels: readonly OpencodeRoutedModel[], nativeContextWindow: (slug: string) => number | undefined = () => undefined, hostname?: string, - config?: OcxConfig, + config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, ): OpencodeProviderBlock { const models: Record = {}; const candidates: OpencodeRoutedModel[] = [ @@ -273,7 +280,7 @@ export function buildOpencodeProviderBlock( return { npm: OPENCODE_PROVIDER_NPM, name: "OpenCodex", - options: opencodeProviderOptions(opencodeProxyBaseUrl(port, hostname), config ?? loadConfig()), + options: opencodeProviderOptions(opencodeProxyBaseUrl(port, hostname), config), models, }; } @@ -331,7 +338,7 @@ export function buildOpencodeConfig( routedModels: readonly OpencodeRoutedModel[], nativeContextWindow: (slug: string) => number | undefined = () => undefined, hostname?: string, - config?: OcxConfig, + config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, ): OpencodeGeneratedConfig { const merged = mergeOpencodeRuntimeConfig( undefined, diff --git a/tests/opencode-cli.test.ts b/tests/opencode-cli.test.ts index 110618113e..cd54a62821 100644 --- a/tests/opencode-cli.test.ts +++ b/tests/opencode-cli.test.ts @@ -68,10 +68,11 @@ describe("ocx opencode provider block", () => { cfg({ hostname: "0.0.0.0" }), ); expect(block.options.headers).toEqual({ "x-opencodex-api-key": OPENCODE_API_KEY_ENV_REF }); - expect(JSON.stringify(block.options.headers)).not.toContain("sk-"); + expect(block.options.apiKey).toBeUndefined(); + expect(JSON.stringify(block.options)).not.toContain("sk-"); }); - test("loopback binds omit the dedicated admission header", () => { + test("loopback binds use apiKey and omit the dedicated admission header", () => { const block = buildOpencodeProviderBlock( 10100, [], @@ -80,6 +81,7 @@ describe("ocx opencode provider block", () => { "127.0.0.1", cfg({ hostname: "127.0.0.1" }), ); + expect(block.options.apiKey).toBe(OPENCODE_API_KEY_ENV_REF); expect(block.options.headers).toBeUndefined(); }); From 0713778ec91184c1b8d77e0b68bf388bab149018 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:00:32 +0200 Subject: [PATCH 5/6] fix(cli): build ocx opencode catalog from proxy /api/models Fetch the live model list from the running proxy instead of calling fetchAllModels in the CLI process, so env-backed provider keys resolve in the proxy environment and namespaced selectors carry display metadata. --- src/cli/opencode.ts | 176 ++++++++++++++++++++++++++++++------- tests/opencode-cli.test.ts | 134 ++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 33 deletions(-) diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 55f827627d..b7fda5405a 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -42,6 +42,27 @@ export interface OpencodeRoutedModel { displayName?: string; } +/** Row shape from authenticated GET /api/models on the running proxy. */ +export interface OpencodeProxyModelRow { + provider?: string; + id?: string; + namespaced?: string; + native?: boolean; + disabled?: boolean; + displayName?: string; + contextWindow?: number; +} + +/** Visible catalog entry keyed by the proxy's canonical namespaced selector. */ +export interface OpencodeCatalogModel { + namespaced: string; + native?: boolean; + provider?: string; + id?: string; + contextWindow?: number; + displayName?: string; +} + export interface OpencodeModelEntry { name: string; limit?: { context: number; output: number }; @@ -244,33 +265,36 @@ function opencodeProviderOptions(baseURL: string, config: OcxConfig): OpencodePr return options; } +function opencodeModelEntryLabel(model: OpencodeCatalogModel): string { + const providerLabel = model.native ? "native" : (model.provider ?? "routed"); + const id = model.id ?? model.namespaced; + if (model.displayName && model.displayName.length > 0) { + return `${model.displayName} (${providerLabel})`; + } + return `${id} (${providerLabel})`; +} + /** - * Build the `opencodex` provider block from the proxy's visible catalog. + * Build the `opencodex` provider block from proxy catalog rows keyed by each row's + * canonical `namespaced` selector. * * `limit.context` is emitted ONLY from an authoritative context window — never guessed. * When none is available the whole `limit` block is dropped and opencode keeps its own * defaults; when one is present, `limit.output` rides along (opencode's schema requires * the pair) clamped to the context window. */ -export function buildOpencodeProviderBlock( +export function buildOpencodeProviderBlockFromCatalog( port: number, - nativeSlugs: readonly string[], - routedModels: readonly OpencodeRoutedModel[], - nativeContextWindow: (slug: string) => number | undefined = () => undefined, + catalogModels: readonly OpencodeCatalogModel[], hostname?: string, config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, ): OpencodeProviderBlock { const models: Record = {}; - const candidates: OpencodeRoutedModel[] = [ - ...nativeSlugs.map(id => ({ provider: "native", id, contextWindow: nativeContextWindow(id) })), - ...routedModels, - ]; - for (const { provider, id, contextWindow, displayName } of candidates) { - const key = opencodeModelKey(provider, id); - if (models[key]) continue; // first entry wins; native slugs are registered first - const entry: OpencodeModelEntry = { - name: displayName && displayName.length > 0 ? `${displayName} (${provider})` : `${id} (${provider})`, - }; + for (const model of catalogModels) { + const key = model.namespaced; + if (models[key]) continue; // first entry wins; native rows lead /api/models + const entry: OpencodeModelEntry = { name: opencodeModelEntryLabel(model) }; + const { contextWindow } = model; if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) { const context = Math.floor(contextWindow); entry.limit = { context, output: Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context) }; @@ -285,6 +309,102 @@ export function buildOpencodeProviderBlock( }; } +/** Back-compat helper for unit tests that assemble slugs/routed rows directly. */ +export function buildOpencodeProviderBlock( + port: number, + nativeSlugs: readonly string[], + routedModels: readonly OpencodeRoutedModel[], + nativeContextWindow: (slug: string) => number | undefined = () => undefined, + hostname?: string, + config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, +): OpencodeProviderBlock { + const catalog: OpencodeCatalogModel[] = [ + ...nativeSlugs.map(id => ({ + namespaced: id, + native: true, + provider: "openai", + id, + contextWindow: nativeContextWindow(id), + })), + ...routedModels.map(model => ({ + namespaced: opencodeModelKey(model.provider, model.id), + native: false, + provider: model.provider, + id: model.id, + contextWindow: model.contextWindow, + displayName: model.displayName, + })), + ]; + return buildOpencodeProviderBlockFromCatalog(port, catalog, hostname, config); +} + +/** Fetch the live model catalog from a running proxy's management API. */ +export async function fetchOpencodeProxyModels( + live: LiveProxy, + apiKey: string, + deps: { fetchImpl?: typeof fetch } = {}, +): Promise { + const baseUrl = `http://${probeHostname(live.hostname)}:${live.port}`; + const fetchImpl = deps.fetchImpl ?? fetch; + const headers = new Headers({ Accept: "application/json" }); + const token = apiKey.trim(); + if (token) headers.set("X-OpenCodex-API-Key", token); + + let response: Response; + try { + response = await fetchImpl(`${baseUrl}/api/models`, { headers }); + } catch (error) { + throw new Error( + `Management API is unreachable: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const text = await response.text(); + let body: unknown = null; + if (text) { + try { body = JSON.parse(text); } + catch { body = text; } + } + if (!response.ok) { + const message = body && typeof body === "object" && typeof (body as Record).error === "string" + ? (body as Record).error + : `Management request failed (${response.status})`; + throw new Error(message); + } + if (!Array.isArray(body)) { + throw new Error("Management API returned an unexpected /api/models payload."); + } + return body as OpencodeProxyModelRow[]; +} + +/** + * Visible OpenCode catalog entries from proxy /api/models rows. Disabled rows are omitted; + * native rows are omitted in Codex Direct mode. + */ +export function opencodeCatalogFromProxyRows( + rows: readonly OpencodeProxyModelRow[], + config: OcxConfig, +): OpencodeCatalogModel[] { + const omitNative = providerCodexAccountMode("openai", config.providers?.openai) === "direct"; + const seen = new Set(); + const catalog: OpencodeCatalogModel[] = []; + for (const row of rows) { + const namespaced = row.namespaced?.trim(); + if (!namespaced || row.disabled === true) continue; + if (omitNative && row.native === true) continue; + if (seen.has(namespaced)) continue; + seen.add(namespaced); + catalog.push({ + namespaced, + native: row.native === true, + provider: row.provider, + id: row.id, + contextWindow: row.contextWindow, + displayName: row.displayName, + }); + } + return catalog; +} + export type OpencodeRuntimeConfigError = { error: string }; /** True when mergeOpencodeRuntimeConfig rejected inherited inline config. */ @@ -502,34 +622,24 @@ export async function cmdOpencode(args: string[]): Promise { return 1; } - const { fetchAllModels } = await import("../server/management-api"); - const { filterCatalogVisibleModels, nativeOpenAiContextWindow } = await import("../codex/catalog"); - let allModels: Awaited>; + const apiKey = opencodeApiKey(config); + let proxyModels: OpencodeProxyModelRow[]; try { - allModels = await fetchAllModels(config); + proxyModels = await fetchOpencodeProxyModels(live, apiKey); } catch (error) { const reason = error instanceof Error ? error.message : String(error); console.error(`❌ Could not fetch the model catalog from the proxy: ${reason}`); return 1; } - const routed = filterCatalogVisibleModels(allModels, config).map(m => ({ - provider: m.provider, - id: m.id, - contextWindow: m.contextWindow, - displayName: m.displayName, - })); - const nativeSlugs = opencodeLaunchNativeSlugs(config); - - const providerBlock = buildOpencodeProviderBlock( + const catalog = opencodeCatalogFromProxyRows(proxyModels, config); + const providerBlock = buildOpencodeProviderBlockFromCatalog( live.port, - nativeSlugs, - routed, - nativeOpenAiContextWindow, + catalog, live.hostname, config, ); const baseUrl = providerBlock.options.baseURL; - const modelCount = nativeSlugs.length + routed.length; + const modelCount = catalog.length; console.error(`✅ opencode wired to ${baseUrl} — ${modelCount} model(s) under provider \`${OPENCODE_PROVIDER_ID}\`.`); console.error(" Your existing opencode config files are left untouched; only the runtime provider block is injected."); const providerOverride = opencodeProviderOverridePath(process.cwd()); @@ -537,7 +647,7 @@ export async function cmdOpencode(args: string[]): Promise { console.error(`ℹ ${providerOverride} also defines provider.${OPENCODE_PROVIDER_ID}; the runtime layer from ocx opencode overrides it for this launch.`); } - const builtEnv = buildOpencodeEnv(providerBlock, opencodeApiKey(config), process.env); + const builtEnv = buildOpencodeEnv(providerBlock, apiKey, process.env); if ("error" in builtEnv) { console.error(`❌ ${builtEnv.error}`); return 1; diff --git a/tests/opencode-cli.test.ts b/tests/opencode-cli.test.ts index cd54a62821..5e9cc383e7 100644 --- a/tests/opencode-cli.test.ts +++ b/tests/opencode-cli.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { clearModelCache } from "../src/codex/model-cache"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../src/lib/service-secrets"; import { OPENCODE_API_KEY_ENV, @@ -12,9 +13,12 @@ import { buildOpencodeConfig, buildOpencodeEnv, buildOpencodeProviderBlock, + buildOpencodeProviderBlockFromCatalog, + fetchOpencodeProxyModels, isOpencodeRuntimeConfigError, mergeOpencodeRuntimeConfig, opencodeApiKey, + opencodeCatalogFromProxyRows, opencodeGlobalConfigPath, opencodeLaunchNativeSlugs, opencodeModelKey, @@ -211,6 +215,136 @@ describe("ocx opencode JSONC parsing", () => { }); }); +describe("ocx opencode proxy model catalog", () => { + const ENV_KEY = "OCX_TEST_OPENCODE_PROXY_ONLY_KEY"; + const RESOLVED = "proxy-only-resolved-key"; + const PROVIDER = "proxyenv"; + + test("uses /api/models namespaced selectors and resolves env-backed provider keys only in the proxy", async () => { + const originalFetch = globalThis.fetch; + let requestedAuth: string | undefined; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const target = String(url); + if (target.includes("proxyenv.test")) { + requestedAuth = new Headers(init?.headers).get("authorization") ?? undefined; + if (requestedAuth === `Bearer ${RESOLVED}`) { + return new Response(JSON.stringify({ + data: [{ id: "live-via-proxy-env", context_length: 128_000 }], + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("unauthorized", { status: 401 }); + } + return originalFetch(url, init); + }) as typeof fetch; + + const config = { + port: 10100, + defaultProvider: PROVIDER, + providers: { + [PROVIDER]: { + adapter: "openai-chat", + authMode: "key", + baseUrl: "https://proxyenv.test/v1", + apiKey: `\${${ENV_KEY}}`, + models: ["static-fallback"], + }, + }, + } as OcxConfig; + + const previous = process.env[ENV_KEY]; + delete process.env[ENV_KEY]; + clearModelCache(PROVIDER); + try { + const { fetchAllModels } = await import("../src/server/management/shared"); + const cliModels = await fetchAllModels(config); + const cliIds = cliModels.filter(m => m.provider === PROVIDER).map(m => m.id).sort(); + expect(cliIds).toEqual(["static-fallback"]); + expect(cliIds).not.toContain("live-via-proxy-env"); + + process.env[ENV_KEY] = RESOLVED; + clearModelCache(PROVIDER); + requestedAuth = undefined; + + const { handleManagementAPI } = await import("../src/server/management-api"); + const modelsRes = await handleManagementAPI( + new Request("http://localhost/api/models"), + new URL("http://localhost/api/models"), + config, + ); + const rows = await modelsRes!.json() as Array<{ + namespaced?: string; + contextWindow?: number; + }>; + expect(requestedAuth).toBe(`Bearer ${RESOLVED}`); + + const liveRow = rows.find(r => r.namespaced === `${PROVIDER}/live-via-proxy-env`); + expect(liveRow).toBeTruthy(); + expect(liveRow?.contextWindow).toBe(128_000); + + const catalog = opencodeCatalogFromProxyRows(rows, config); + expect(catalog.map(m => m.namespaced)).toContain(`${PROVIDER}/live-via-proxy-env`); + + const block = buildOpencodeProviderBlockFromCatalog(10100, catalog, undefined, config); + expect(block.models[`${PROVIDER}/live-via-proxy-env`]?.limit?.context).toBe(128_000); + expect(block.models[`${PROVIDER}/live-via-proxy-env`]?.name).toBe("live-via-proxy-env (proxyenv)"); + + const fetched = await fetchOpencodeProxyModels( + { port: 10100, hostname: "127.0.0.1", pid: 1 }, + "sk-mgmt", + { + fetchImpl: async (url, init) => { + expect(String(url)).toBe("http://127.0.0.1:10100/api/models"); + expect(new Headers(init?.headers).get("X-OpenCodex-API-Key")).toBe("sk-mgmt"); + return new Response(JSON.stringify(rows), { status: 200 }); + }, + }, + ); + expect(fetched.find(r => r.namespaced === `${PROVIDER}/live-via-proxy-env`)).toBeTruthy(); + } finally { + globalThis.fetch = originalFetch; + clearModelCache(PROVIDER); + if (previous === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = previous; + } + }); + + test("opencodeCatalogFromProxyRows omits disabled and direct-mode native rows", () => { + const directConfig = cfg({ + providers: { + mock: { adapter: "openai-chat", baseUrl: "http://x/v1" }, + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + }, + }); + const rows = [ + { namespaced: "gpt-5.6-sol", native: true, disabled: false, provider: "openai", id: "gpt-5.6-sol" }, + { namespaced: "gpt-5.5", native: true, disabled: true, provider: "openai", id: "gpt-5.5" }, + { namespaced: "kiro/glm-5", native: false, disabled: false, provider: "kiro", id: "glm-5", displayName: "GLM-5" }, + ]; + expect(opencodeCatalogFromProxyRows(rows, directConfig).map(m => m.namespaced)).toEqual(["kiro/glm-5"]); + + const poolConfig = cfg({ + providers: { + mock: { adapter: "openai-chat", baseUrl: "http://x/v1" }, + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + }); + expect(opencodeCatalogFromProxyRows(rows, poolConfig).map(m => m.namespaced)).toEqual([ + "gpt-5.6-sol", + "kiro/glm-5", + ]); + }); +}); + describe("ocx opencode native slug selection", () => { test("omits native slugs in Codex Direct mode", () => { const config = cfg({ From 066d8c947868ab181864c81507360afb41f8acf9 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:08:09 +0200 Subject: [PATCH 6/6] fix(cli): bound ocx opencode /api/models fetch deadline Abort stalled proxy catalog requests after 8s so ocx opencode fails fast instead of hanging before OpenCode launches. --- src/cli/opencode.ts | 36 ++++++++++++++++++++++++++++++++---- tests/opencode-cli.test.ts | 21 +++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index b7fda5405a..966985087a 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -338,27 +338,55 @@ export function buildOpencodeProviderBlock( return buildOpencodeProviderBlockFromCatalog(port, catalog, hostname, config); } +/** Default deadline for authenticated GET /api/models during `ocx opencode` launch. */ +export const OPENCODE_PROXY_MODELS_TIMEOUT_MS = 8_000; + /** Fetch the live model catalog from a running proxy's management API. */ export async function fetchOpencodeProxyModels( live: LiveProxy, apiKey: string, - deps: { fetchImpl?: typeof fetch } = {}, + deps: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}, ): Promise { const baseUrl = `http://${probeHostname(live.hostname)}:${live.port}`; const fetchImpl = deps.fetchImpl ?? fetch; const headers = new Headers({ Accept: "application/json" }); const token = apiKey.trim(); if (token) headers.set("X-OpenCodex-API-Key", token); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), deps.timeoutMs ?? OPENCODE_PROXY_MODELS_TIMEOUT_MS); + const abortIfTimedOut = (): Promise => new Promise((_, reject) => { + if (controller.signal.aborted) { + reject(new DOMException("The operation was aborted.", "AbortError")); + return; + } + controller.signal.addEventListener( + "abort", + () => reject(new DOMException("The operation was aborted.", "AbortError")), + { once: true }, + ); + }); let response: Response; + let text: string; try { - response = await fetchImpl(`${baseUrl}/api/models`, { headers }); + response = await Promise.race([ + fetchImpl(`${baseUrl}/api/models`, { + headers, + signal: controller.signal, + }), + abortIfTimedOut(), + ]); + text = await Promise.race([response.text(), abortIfTimedOut()]); } catch (error) { + const timedOut = error instanceof Error && error.name === "AbortError"; throw new Error( - `Management API is unreachable: ${error instanceof Error ? error.message : String(error)}`, + timedOut + ? "Management API timed out while fetching /api/models." + : `Management API is unreachable: ${error instanceof Error ? error.message : String(error)}`, ); + } finally { + clearTimeout(timeout); } - const text = await response.text(); let body: unknown = null; if (text) { try { body = JSON.parse(text); } diff --git a/tests/opencode-cli.test.ts b/tests/opencode-cli.test.ts index 5e9cc383e7..00dbad5a1a 100644 --- a/tests/opencode-cli.test.ts +++ b/tests/opencode-cli.test.ts @@ -308,6 +308,27 @@ describe("ocx opencode proxy model catalog", () => { } }); + test("fetchOpencodeProxyModels aborts stalled /api/models fetch and body reads", async () => { + const live = { port: 10100, hostname: "127.0.0.1", pid: 1 }; + const stall = (init?: RequestInit) => new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("The operation was aborted.", "AbortError"))); + }); + + await expect(fetchOpencodeProxyModels(live, "sk-mgmt", { + timeoutMs: 25, + fetchImpl: async (_url, init) => stall(init), + })).rejects.toThrow("Management API timed out while fetching /api/models."); + + await expect(fetchOpencodeProxyModels(live, "sk-mgmt", { + timeoutMs: 25, + fetchImpl: async () => ({ + ok: true, + status: 200, + text: () => new Promise(() => {}), + } as Response), + })).rejects.toThrow("Management API timed out while fetching /api/models."); + }); + test("opencodeCatalogFromProxyRows omits disabled and direct-mode native rows", () => { const directConfig = cfg({ providers: {