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..c54be6956b --- /dev/null +++ b/docs-site/src/content/docs/guides/opencode.md @@ -0,0 +1,106 @@ +--- +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 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. + +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 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. 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}" +} +``` + +Non-loopback example: + +```json +"options": { + "baseURL": "http://192.168.1.10:10100/v1", + "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, then the hardened service token file, then +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..95f2d67d68 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -184,6 +184,20 @@ const helpEntries: Record = { "Claude Code settings: ocx claude config ...", ], }, + opencode: { + usage: "ocx opencode [opencode args...]", + summary: "Launch opencode wired to the proxy (runtime provider config).", + details: [ + "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.", + "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.", + ], + }, restart: { usage: "ocx restart", summary: "Stop the proxy and restart it (background). Equivalent to stop + ensure.", @@ -256,6 +270,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..966985087a --- /dev/null +++ b/src/cli/opencode.ts @@ -0,0 +1,701 @@ +/** + * `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 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 + * 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 { visibleNativeSlugs } from "../codex/catalog"; +import { shouldInjectApiAuthHeader } from "../codex/inject"; +import { commandInvocation } from "../lib/win-exec"; +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 { + [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; +} + +/** 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 }; +} + +export interface OpencodeProviderBlock { + npm: string; + name: string; + options: { + baseURL: string; + apiKey?: string; + headers?: Record; + }; + 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; + +/** 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); +} + +/** + * 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}`; +} + +/** 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 }; + // 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; +} + +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 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 buildOpencodeProviderBlockFromCatalog( + port: number, + catalogModels: readonly OpencodeCatalogModel[], + hostname?: string, + config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, +): OpencodeProviderBlock { + const models: Record = {}; + 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) }; + } + models[key] = entry; + } + return { + npm: OPENCODE_PROVIDER_NPM, + name: "OpenCodex", + options: opencodeProviderOptions(opencodeProxyBaseUrl(port, hostname), config), + models, + }; +} + +/** 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); +} + +/** 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; 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 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( + timedOut + ? "Management API timed out while fetching /api/models." + : `Management API is unreachable: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + clearTimeout(timeout); + } + 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. */ +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 = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, +): OpencodeGeneratedConfig { + 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. */ +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 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; + } +} + +/** + * 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 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) { + 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; + dir = parent; + } + 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() }; +} + +/** + * 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 + * key travels in the child env rather than in the inline config payload. + */ +export function buildOpencodeEnv( + providerBlock: OpencodeProviderBlock, + apiKey: string, + base: 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), + [OPENCODE_API_KEY_ENV]: apiKey, + }; +} + +/** + * 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 { + 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 { + const live = await findLiveProxy(); + 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)], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: opencodeProxyStartEnv(process.env) as NodeJS.ProcessEnv, + }); + // 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; + 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 live = await ensureProxyForOpencode(config); + if (!live) { + console.error("❌ Proxy did not become healthy after starting."); + return 1; + } + + const apiKey = opencodeApiKey(config); + let proxyModels: OpencodeProxyModelRow[]; + try { + 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 catalog = opencodeCatalogFromProxyRows(proxyModels, config); + const providerBlock = buildOpencodeProviderBlockFromCatalog( + live.port, + catalog, + live.hostname, + config, + ); + const baseUrl = providerBlock.options.baseURL; + 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()); + if (providerOverride) { + console.error(`ℹ ${providerOverride} also defines provider.${OPENCODE_PROVIDER_ID}; the runtime layer from ocx opencode overrides it for this launch.`); + } + + const builtEnv = buildOpencodeEnv(providerBlock, apiKey, 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 }); + 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/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 new file mode 100644 index 0000000000..00dbad5a1a --- /dev/null +++ b/tests/opencode-cli.test.ts @@ -0,0 +1,570 @@ +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, + OPENCODE_API_KEY_ENV_REF, + OPENCODE_CONFIG_CONTENT_ENV, + OPENCODE_PROVIDER_ID, + SCHEMA_REQUIRED_OUTPUT_BUDGET, + buildOpencodeConfig, + buildOpencodeEnv, + buildOpencodeProviderBlock, + buildOpencodeProviderBlockFromCatalog, + fetchOpencodeProxyModels, + isOpencodeRuntimeConfigError, + mergeOpencodeRuntimeConfig, + opencodeApiKey, + opencodeCatalogFromProxyRows, + opencodeGlobalConfigPath, + opencodeLaunchNativeSlugs, + opencodeModelKey, + opencodeNotFoundHint, + opencodeProviderOverridePath, + opencodeProxyBaseUrl, + opencodeProxyStartEnv, + 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("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(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(block.options.apiKey).toBeUndefined(); + expect(JSON.stringify(block.options)).not.toContain("sk-"); + }); + + test("loopback binds use apiKey and omit the dedicated admission header", () => { + const block = buildOpencodeProviderBlock( + 10100, + [], + [], + () => undefined, + "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(); + }); + + 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(); + }); + + 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", () => { + 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 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("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: { + 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({ + 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" } } })); + 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 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 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"); + }); +}); + +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 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"); + }); + + test("falls back to a placeholder on an open loopback proxy", () => { + expect(opencodeApiKey(cfg(), {})).toBe("ocx"); + }); +}); + +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")); + 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(); + }); +});