From d8555e4126bc255330ea0ba99f9d87619427cf0c Mon Sep 17 00:00:00 2001 From: Cortes Ventures Date: Fri, 11 Sep 2026 20:55:01 -0400 Subject: [PATCH] fix(opencode): read /api/models with the management credential over the local transport GET /api/models is a management route, so the launcher catalogue read has to present the management credential: the data-plane admission key is refused there with `opencodex admin token required` (401) and OpenCode never launches. Supplying that credential also moved management authority onto the old read path, so this change closes both boundaries the review raised: - Destination: the resolved /api/* origin must be loopback. `probeHostname` keeps every wildcard/IPv4/IPv6 listener spelling dialing 127.0.0.1, and a non-loopback bind is refused before any token-bearing request is built. - Transport: the read goes through `directLocalHttpFetch`, which never consults proxy environment variables, never follows a redirect, and drops proxy headers. - Attested proxies answer over the single-use local management capability for /api/models (added to the read allowlist; the route is registered `mutates: false`), so no reusable credential leaves the process at all. A proxy without that capability falls back to the loopback token read. The child still receives the admission key through `buildOpencodeEnv`; the tests now assert that on the spawned env and inline config, and exercise proxy-env bypass, redirect refusal, nonlocal rejection, and a real local catalogue load over the socket. --- docs-site/src/content/docs/guides/opencode.md | 16 + src/cli/opencode.ts | 143 ++++++-- src/lib/local-management-capability.ts | 8 +- structure/clients/claude-desktop.md | 7 + structure/config.md | 1 + structure/ops/docs-and-release.md | 6 + structure/runtime.md | 11 + tests/providers/opencode-cli.test.ts | 342 +++++++++++++++++- tests/server/server-management-auth.test.ts | 20 + 9 files changed, 520 insertions(+), 34 deletions(-) diff --git a/docs-site/src/content/docs/guides/opencode.md b/docs-site/src/content/docs/guides/opencode.md index 2c6d7c7008..0297f5aeae 100644 --- a/docs-site/src/content/docs/guides/opencode.md +++ b/docs-site/src/content/docs/guides/opencode.md @@ -175,6 +175,22 @@ see [Remote access](/reference/configuration/#remote-access). This admission key own, and is unrelated to the upstream provider keys configured under [Providers](/guides/providers/). +## How the catalogue is read + +The launcher's own catalogue read is a management request, not a data-plane one. `ocx opencode` +fetches `GET /api/models` with the configured management credential (`OPENCODEX_ADMIN_AUTH_TOKEN`, +or the `admin-api-token` file in `~/.opencodex`) and refuses to send it anywhere but a loopback +`/api/*` origin, over a transport that ignores `HTTP(S)_PROXY` and never follows a redirect. When the +proxy answered the launcher's identity probe itself, the read uses a single-use, process-bound +capability instead, so no reusable credential is sent at all. + +If no management credential is configured, the launcher falls back to the admission key above. A +hardened proxy refuses that key on `/api/*` with `401 opencodex admin token required`, so set +`OPENCODEX_ADMIN_AUTH_TOKEN` (or the token file) on such a host. + +A non-loopback `hostname` is refused for this read. Bind the proxy to loopback, or enable a hub +management ingress, so this machine has a local `/api/*` address. + ## Reverting Nothing to undo — no generated config file is written under `~/.opencodex`. Run plain diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 09ea024746..00aeda7da2 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -19,7 +19,7 @@ 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 { getConfigDir, loadConfig } from "../config"; import { OPENCODE_API_KEY_ENV, OPENCODE_CONFIG_SCHEMA, @@ -40,9 +40,15 @@ import type { OpencodeV2ProviderBlock, } from "../clients/config-export"; import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog"; +import { isLoopbackHostname } from "../codex/loopback-target"; +import { configuredAdminToken } from "../lib/admin-secrets"; +import { localManagementOrigin } from "../lib/local-destinations"; +import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability"; import { commandInvocation } from "../lib/win-exec"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; import { providerCodexAccountMode } from "../providers/registry"; +import { directLocalHttpFetch } from "../server/direct-local-http"; +import { fetchBoundLocalManagementRead } from "../server/local-management-read-client"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; @@ -306,16 +312,95 @@ function opencodeBlocks( /** 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. */ +/** + * Fetch the live model catalog from a running proxy management API. + * + * `GET /api/models` sits behind `requireManagementAuth`, so `managementToken` has to be a + * management credential. It is deliberately NOT the data-plane admission key `buildOpencodeEnv` + * hands the child process, and two boundaries keep it local: + * + * 1. Destination — the resolved origin must be loopback. `probeHostname` already normalises every + * wildcard spelling to 127.0.0.1 and brackets bare IPv6 literals, so the supported + * wildcard/IPv4/IPv6 listener cases keep working, while a non-loopback bind is refused before + * any token-bearing request exists. + * 2. Transport — `directLocalHttpFetch` never consults proxy environment variables, never follows + * a redirect, and drops proxy headers. A global `fetch` can do all three, so the management + * credential does not travel through one. + * + * When the live proxy is process-attested (`live.source === "runtime"`) the read goes through the + * single-use local management capability instead, so no reusable credential leaves this process at + * all. A proxy that does not recognise that capability yet (an older build) falls back to the + * loopback token read below. + */ +export interface OpencodeProxyModelsDeps { + fetchImpl?: typeof fetch; + timeoutMs?: number; + /** + * `/api/*` origin for this read, normally `localManagementOrigin(config, live.port)`, which + * prefers a hub loopback management ingress. Defaults to the identity-probed proxy record. + */ + origin?: string; + /** Capability-read seam; defaults to the real single-use capability client. */ + boundRead?: typeof fetchBoundLocalManagementRead; +} + +/** True when `origin` is a plain-HTTP loopback destination this process may carry a token to. */ +export function isLocalManagementOrigin(origin: string): boolean { + try { + const url = new URL(origin); + return url.protocol === "http:" && !url.username && !url.password && isLoopbackHostname(url.hostname); + } catch { + return false; + } +} + +function opencodeProxyModelRows(response: Response, text: string): OpencodeProxyModelRow[] { + 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[]; +} + export async function fetchOpencodeProxyModels( live: LiveProxy, - apiKey: string, - deps: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}, + managementToken: string, + deps: OpencodeProxyModelsDeps = {}, ): Promise { - const baseUrl = `http://${probeHostname(live.hostname)}:${live.port}`; - const fetchImpl = deps.fetchImpl ?? fetch; + const attestedOrigin = `http://${probeHostname(live.hostname)}:${live.port}`; + const origin = deps.origin ?? attestedOrigin; + if (!isLocalManagementOrigin(origin)) { + throw new Error( + `Refusing to send the opencodex management credential to ${origin}: it is not a loopback address. ` + + "Bind the proxy to loopback, or enable the hub management ingress, so this host has a local /api/* address.", + ); + } + const target = new URL(origin); + // The capability is bound to the attested pid AND to the port the request arrives on, so it can + // only be presented to the proxy listener that minted it, never to a separate management ingress. + if (live.source === "runtime" + && target.port === String(live.port) + && target.hostname === new URL(attestedOrigin).hostname) { + const read = await (deps.boundRead ?? fetchBoundLocalManagementRead)( + live, + LOCAL_MANAGEMENT_READ_PATHS.models, + { timeoutMs: deps.timeoutMs ?? OPENCODE_PROXY_MODELS_TIMEOUT_MS }, + ); + if (read.kind === "response") return opencodeProxyModelRows(read.response, await read.response.text()); + } + const fetchImpl = deps.fetchImpl ?? directLocalHttpFetch; const headers = new Headers({ Accept: "application/json" }); - const token = apiKey.trim(); + const token = managementToken.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); @@ -335,7 +420,7 @@ export async function fetchOpencodeProxyModels( let text: string; try { response = await Promise.race([ - fetchImpl(`${baseUrl}/api/models`, { + fetchImpl(`${target.origin}/api/models`, { headers, signal: controller.signal, }), @@ -352,21 +437,7 @@ export async function fetchOpencodeProxyModels( } 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[]; + return opencodeProxyModelRows(response, text); } /** @@ -602,6 +673,26 @@ export function opencodeApiKey(config: OcxConfig, env: OpencodeLaunchEnv = proce return config.apiKeys?.[0]?.key || "ocx"; } +/** + * Credential for the launcher's `GET /api/models` read. + * + * That route is a management route, so `requireManagementAuth` only admits the admin credential — + * the data-plane admission key {@link opencodeApiKey} returns for the child process is refused there + * with `opencodex admin token required`. Prefer the configured admin token, the same credential every + * other headless management caller sends (`runningProxyUpdateHeaders`), and keep the admission key as + * the fallback for a host that has no admin token configured. + * + * The destination and transport are constrained by {@link fetchOpencodeProxyModels}: the credential + * only ever reaches a loopback `/api/*` origin, and an attested proxy answers the same read over a + * single-use capability that needs no reusable credential at all. + */ +export function opencodeManagementToken(config: OcxConfig, env: OpencodeLaunchEnv = process.env): string { + // Name the directory instead of passing `undefined`: an explicit `env` may describe a different + // OPENCODEX_HOME than this process, and the admin token file is read from that directory. + const configDir = env.OPENCODEX_HOME?.trim() || getConfigDir(); + return configuredAdminToken(configDir, env) ?? opencodeApiKey(config, env); +} + async function ensureProxyForOpencode(config: OcxConfig): Promise { const live = await findLiveProxy(); if (live) return live; @@ -650,9 +741,13 @@ export async function cmdOpencode(args: string[]): Promise { } const apiKey = opencodeApiKey(startupConfig); + const managementToken = opencodeManagementToken(startupConfig); let proxyModels: OpencodeProxyModelRow[]; try { - proxyModels = await fetchOpencodeProxyModels(live, apiKey); + proxyModels = await fetchOpencodeProxyModels(live, managementToken, { + // A hub reaches its own management API through the loopback ingress, not the public bind. + origin: localManagementOrigin(startupConfig, live.port), + }); } catch (error) { const reason = error instanceof Error ? error.message : String(error); console.error(`❌ Could not fetch the model catalog from the proxy: ${reason}`); diff --git a/src/lib/local-management-capability.ts b/src/lib/local-management-capability.ts index 2da0d0cc5d..315f1694ec 100644 --- a/src/lib/local-management-capability.ts +++ b/src/lib/local-management-capability.ts @@ -10,6 +10,11 @@ export const LOCAL_MANAGEMENT_CAPABILITY_TTL_MS = 10_000; export const LOCAL_MANAGEMENT_READ_PATHS = { codexAccounts: "/api/codex-auth/accounts", systemMemory: "/api/system/memory", + // `ocx opencode` needs the same process-attested read the CLI already performs for memory and + // Codex accounts: the launcher cannot start without the catalog, and the alternative is a + // reusable admin credential on the wire. The route is registered `mutates: false` in + // `src/server/management/route-registry.ts`, which is what makes it eligible for a read grant. + models: "/api/models", } as const; export type LocalManagementReadPath = @@ -32,7 +37,8 @@ export function parseExpectedLocalManagementPid(value: string | null): ExpectedL function isLocalManagementReadPath(path: string): path is LocalManagementReadPath { return path === LOCAL_MANAGEMENT_READ_PATHS.codexAccounts - || path === LOCAL_MANAGEMENT_READ_PATHS.systemMemory; + || path === LOCAL_MANAGEMENT_READ_PATHS.systemMemory + || path === LOCAL_MANAGEMENT_READ_PATHS.models; } function localReadCapabilityPayload( diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 54689b36a1..00a51f82b2 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -18,6 +18,13 @@ writes the resulting local Desktop configuration. No admin token, hub-profile up alias regeneration is part of this flow. Unsupported old hubs, invalid snapshots and unavailable Desktop models fail apply without a local-catalog or loopback fallback. +Local launchers read hub management state only through an authenticated, loopback-only origin. +`src/cli/opencode.ts` applies that rule to its `GET /api/models` catalogue read: the management +credential is refused a non-loopback destination before any request is built, the transport is the +direct local one (no proxy environment variables and no redirects), and the admission key handed to +the child process carries no management authority. An attested proxy answers that read over a +single-use local read capability instead of the credential itself. + Date-shaped Desktop IDs can overlap genuine native model IDs. When available discovery and mapping evidence cannot resolve one, Messages and count-tokens return HTTP 503 with the fixed `desktop_model_mapping_unavailable` error rather than classifying it as invalid. Unknown legacy hash aliases diff --git a/structure/config.md b/structure/config.md index b37d315fce..da1aff7d3c 100644 --- a/structure/config.md +++ b/structure/config.md @@ -50,6 +50,7 @@ matters for maintainers is which groups exist and who resolves them: | Retained state | `appOwnedMemoryBudgetMb` | Process-wide eviction target for app-owned logs, caches, blobs, and continuation payloads. Default 256 MiB, valid 64..4096; pinned state may temporarily exceed the target, but every pin-capable store has a finite local cap and their documented aggregate stays below `APP_OWNED_WORST_CASE_PINNED_BYTES` (512 MiB). Neither value caps RSS or native runtime memory. | | Transport | stream mode, timeouts, proxy settings, `websockets`, `emptyCompletionRetry` | `streamMode` persists in config.json; Windows services need a persisted input, and macOS uses it for explicit eager-relay opt-in. Empty-completion replay is an explicit top-level opt-in because its second upstream request may be billable. | | Credentials | `apiKeys` | Data-plane only; never admitted to `/api/*`. | +| Management credential | `OPENCODEX_ADMIN_AUTH_TOKEN`, `admin-api-token` file | Resolved by `configuredAdminToken`. `src/cli/opencode.ts` presents it on `GET /api/models`; a non-loopback destination is refused before that request is built. | | Lifecycle | `codexAutoStart`, shim/start behavior, resume-history sync, storage cleanup | Startup safety reads these; see [`gui-and-management-api.md`](gui-and-management-api.md). | Env values are resolved through `src/config.ts`, so a config value naming an env var never persists diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 9570086e21..550f236b3d 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -8,6 +8,12 @@ served at the site root, with Korean under `/ko`, Simplified Chinese under `/zh- Manual navigation is defined in `docs-site/astro.config.mjs`. When adding a public page, update the sidebar and either add localized copies or intentionally accept Starlight fallback behavior. +The `ocx opencode` guide (`docs-site/src/content/docs/guides/opencode.md`) documents the launcher +management-read contract: the management credential versus the child admission key, the loopback-only +destination, and the direct local transport. `src/cli/opencode.ts` owns that behavior, so changing +which credential the launcher sends on `/api/*` updates that guide together with `runtime.md`, +`config.md`, and `clients/claude-desktop.md` — the other documents assigned to `src/cli/`. + ## GitHub Pages `.github/workflows/deploy-docs.yml` publishes the docs to: diff --git a/structure/runtime.md b/structure/runtime.md index ae3cc34bbb..56e60a2090 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -191,6 +191,17 @@ not an authentication or entitlement decision. Routed Responses continuations whose local replay state is missing resolve their recovery decision from the selected wire protocol, not the model name; the contract lives in [Responses transport](transports/responses.md). +`src/cli/opencode.ts` reads its model catalogue from the authenticated management route +`GET /api/models`, so it presents the management credential (`configuredAdminToken`: +`OPENCODEX_ADMIN_AUTH_TOKEN`, then the hardened `admin-api-token` file) and keeps the data-plane +admission key for the child process environment alone. The read is loopback-only — the resolved +`/api/*` origin must be a loopback address, with `probeHostname` normalizing the wildcard and IPv6 +spellings — and it travels over `directLocalHttpFetch`, which ignores proxy environment variables +and never follows a redirect. A process-attested proxy answers the same read over the single-use +local management capability for `/api/models` (`src/lib/local-management-capability.ts`) rather than +a reusable credential; a proxy without that capability falls back to the loopback token read. See +[Config surface](config.md). + ## Remote Hub hardening ownership `src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. diff --git a/tests/providers/opencode-cli.test.ts b/tests/providers/opencode-cli.test.ts index bf3552d8c0..58b3b0e8c1 100644 --- a/tests/providers/opencode-cli.test.ts +++ b/tests/providers/opencode-cli.test.ts @@ -5,6 +5,8 @@ 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 { LOCAL_MANAGEMENT_READ_PATHS } from "../../src/lib/local-management-capability"; +import type { fetchBoundLocalManagementRead } from "../../src/server/local-management-read-client"; import { OPENCODE_API_KEY_ENV, OPENCODE_API_KEY_ENV_REF, @@ -25,6 +27,7 @@ import { opencodeCatalogFromProxyRows, opencodeGlobalConfigPath, opencodeLaunchNativeSlugs, + opencodeManagementToken, opencodeModelKey, opencodeNotFoundHint, opencodeProviderOverridePath, @@ -258,14 +261,22 @@ describe("ocx opencode proxy model catalog", () => { const rows = ["chosen", "other"].map(id => ({ provider: "pending", id, namespaced: `pending/${id}` })); expect(opencodeCatalogFromProxyRows(rows, pending)).toEqual([]); const liveness = await import("../../src/server/proxy-liveness"); - const finder = spyOn(liveness, "findLiveProxy").mockResolvedValue({ - port: 10123, hostname: "127.0.0.1", pid: null, source: "config", + // A real loopback listener: the launcher reads the catalog over the direct local transport, so + // the persistence side effect has to happen in a server rather than in a global-fetch mock. + let reads = 0; + const catalog = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + reads++; + expect(new URL(request.url).pathname).toBe("/api/models"); + expect(JSON.parse(readFileSync(configPath, "utf8")).providers.pending.initialModelSelection.status).toBe("pending"); + writeFileSync(configPath, JSON.stringify(ready)); + return Response.json(rows); + }, }); - const fetcher = spyOn(globalThis, "fetch").mockImplementation(async input => { - expect(String(input)).toBe("http://127.0.0.1:10123/api/models"); - expect(JSON.parse(readFileSync(configPath, "utf8")).providers.pending.initialModelSelection.status).toBe("pending"); - writeFileSync(configPath, JSON.stringify(ready)); - return Response.json(rows); + const finder = spyOn(liveness, "findLiveProxy").mockResolvedValue({ + port: catalog.port, hostname: "127.0.0.1", pid: null, source: "config", }); let inline = ""; // Exercise cmdOpencode through env construction without launching an installed @@ -286,14 +297,15 @@ describe("ocx opencode proxy model catalog", () => { writeFileSync(configPath, JSON.stringify(pending)); expect(await cmdOpencode([])).toBe(0); expect(finder).toHaveBeenCalledTimes(1); - expect(fetcher).toHaveBeenCalledTimes(1); + expect(reads).toBe(1); expect(spawn).toHaveBeenCalledTimes(1); const injected = JSON.parse(inline); expect(Object.keys(injected.provider.opencodex.models)).toEqual(["pending/chosen"]); expect(Object.keys(injected.providers.opencodex.models)).toEqual(["pending/chosen"]); expect(pending.providers.pending!.initialModelSelection!.status).toBe("pending"); } finally { - finder.mockRestore(); fetcher.mockRestore(); spawn.mockRestore(); stderr.mockRestore(); + await catalog.stop(true); + finder.mockRestore(); spawn.mockRestore(); stderr.mockRestore(); for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; @@ -718,6 +730,318 @@ describe("ocx opencode admission key", () => { }); }); +describe("ocx opencode management token", () => { + // GET /api/models is a management route, so the launcher must present the admin credential there; + // sending the data-plane admission key is what produced "opencodex admin token required" (401). + const TOUCHED = ["OPENCODEX_ADMIN_AUTH_TOKEN", "OPENCODEX_HOME", "OPENCODEX_API_AUTH_TOKEN", "OCX_API_TOKEN_FILE"] as const; + + function withEnv(overrides: Partial>, run: () => void): void { + const saved = new Map(TOUCHED.map(key => [key, process.env[key]])); + try { + for (const key of TOUCHED) delete process.env[key]; + for (const [key, value] of Object.entries(overrides)) process.env[key] = value; + run(); + } finally { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + } + + const adminToken = `ocx_admin_${"a".repeat(43)}`; + + test("the configured admin token wins over the admission key", () => { + withEnv({}, () => { + const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }); + expect(opencodeManagementToken(config, { OPENCODEX_ADMIN_AUTH_TOKEN: adminToken })).toBe(adminToken); + }); + }); + + test("falls back to the admin-api-token file in OPENCODEX_HOME", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-admin-")); + writeFileSync(join(dir, "admin-api-token"), `${adminToken}\n`, { mode: 0o600 }); + try { + withEnv({ OPENCODEX_HOME: dir }, () => { + const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }); + expect(opencodeManagementToken(config, { OPENCODEX_ADMIN_AUTH_TOKEN: " " })).toBe(adminToken); + }); + } finally { + removeTreeWithRetry(dir); + } + }); + + test("falls back to the admission key when no admin token is configured", () => { + // An explicit empty OPENCODEX_HOME keeps this independent of any admin token the runner's + // sandbox home may already carry. + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-no-admin-")); + try { + withEnv({ OPENCODEX_HOME: dir }, () => { + const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }); + expect(opencodeManagementToken(config, {})).toBe("sk-cfg"); + }); + } finally { + removeTreeWithRetry(dir); + } + }); + + test("cmdOpencode sends the admin token — not the admission key — on GET /api/models", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-opencode-admin-read-")); + const envKeys = [...TOUCHED, "CODEX_HOME", "XDG_CONFIG_HOME", OPENCODE_CONFIG_CONTENT_ENV]; + const previous = Object.fromEntries(envKeys.map(key => [key, process.env[key]])); + const liveness = await import("../../src/server/proxy-liveness"); + // A real loopback listener, not a global-fetch mock: the launcher reads the catalog over the + // direct local transport now, so the header has to be observed at the socket. + let sentPath: string | null = null; + let sentKey: string | null = null; + const catalog = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + sentPath = new URL(request.url).pathname; + sentKey = request.headers.get("X-OpenCodex-API-Key"); + return Response.json([]); + }, + }); + const finder = spyOn(liveness, "findLiveProxy").mockResolvedValue({ + port: catalog.port, hostname: "127.0.0.1", pid: null, source: "config", + }); + let spawnedEnv: Record | undefined; + const spawn = spyOn(childProcess, "spawn").mockImplementation(((( + _file: string, + _args: readonly string[], + options?: { env?: Record }, + ) => { + spawnedEnv = options?.env; + const child = new childProcess.ChildProcess(); + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }) as typeof childProcess.spawn)); + const stderr = spyOn(console, "error").mockImplementation(() => {}); + try { + for (const key of envKeys) delete process.env[key]; + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(home, "codex"); + process.env.XDG_CONFIG_HOME = join(home, "xdg"); + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = adminToken; + mkdirSync(process.env.CODEX_HOME); + writeFileSync(join(home, "config.json"), JSON.stringify( + cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }), + )); + expect(await cmdOpencode([])).toBe(0); + // The management credential authenticates the launcher's own read... + expect(sentPath).toBe("/api/models"); + expect(sentKey).toBe(adminToken); + expect(sentKey).not.toBe("sk-cfg"); + // ...while the child receives the data-plane admission key through the env slot its provider + // block references. The management credential is not serialized into the inline config. + expect(spawnedEnv?.[OPENCODE_API_KEY_ENV]).toBe("sk-cfg"); + const inlineConfig = spawnedEnv?.[OPENCODE_CONFIG_CONTENT_ENV] ?? ""; + expect(inlineConfig).toContain(OPENCODE_API_KEY_ENV_REF); + expect(inlineConfig).not.toContain(adminToken); + } finally { + await catalog.stop(true); + finder.mockRestore(); spawn.mockRestore(); stderr.mockRestore(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(home); + } + }); +}); + +describe("ocx opencode management read destination and transport", () => { + // The catalog read carries a reusable management credential, so the launcher owns two boundaries + // the transport cannot provide on its own: a loopback destination, and a transport that ignores + // proxy environment variables and redirects. Each test below is one of those controls. + const managementToken = `ocx_admin_${"b".repeat(43)}`; + const ROW = { namespaced: "opencode-go/glm-5.3", provider: "opencode-go", id: "glm-5.3" }; + const PROXY_ENV = ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"] as const; + + async function withProxyEnv(proxyUrl: string, run: () => Promise): Promise { + const saved = new Map(); + for (const key of [...PROXY_ENV, "NO_PROXY", "no_proxy"]) { + saved.set(key, process.env[key]); + delete process.env[key]; + } + for (const key of PROXY_ENV) process.env[key] = proxyUrl; + try { + await run(); + } finally { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + } + + test("loads the local catalog directly while proxy env points at another server", async () => { + const captured: string[] = []; + const capture = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + captured.push(new URL(request.url).pathname); + return Response.json({ error: "the proxy env destination must never see this" }, { status: 502 }); + }, + }); + const catalog = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => Response.json([ROW]) }); + try { + await withProxyEnv(`http://127.0.0.1:${capture.port}`, async () => { + const rows = await fetchOpencodeProxyModels( + { port: catalog.port, hostname: "127.0.0.1", pid: null, source: "config" }, + managementToken, + ); + expect(rows).toEqual([ROW]); + }); + expect(captured).toEqual([]); + } finally { + await capture.stop(true); + await catalog.stop(true); + } + }); + + test("refuses a redirect instead of re-sending the credential to its target", async () => { + const followed: string[] = []; + const target = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + followed.push(new URL(request.url).pathname); + return Response.json([ROW]); + }, + }); + const redirector = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => new Response(null, { + status: 302, + headers: { Location: `http://127.0.0.1:${target.port}/api/models` }, + }), + }); + try { + await expect(fetchOpencodeProxyModels( + { port: redirector.port, hostname: "127.0.0.1", pid: null, source: "config" }, + managementToken, + )).rejects.toThrow("Management request failed (302)"); + expect(followed).toEqual([]); + } finally { + await redirector.stop(true); + await target.stop(true); + } + }); + + test("rejects a non-loopback destination before any token-bearing request", async () => { + const calls: string[] = []; + const fetchImpl = async (input: RequestInfo | URL) => { + calls.push(String(input)); + return Response.json([ROW]); + }; + await expect(fetchOpencodeProxyModels( + { port: 10100, hostname: "192.168.4.10", pid: null, source: "config" }, + managementToken, + { fetchImpl }, + )).rejects.toThrow(/not a loopback address/); + await expect(fetchOpencodeProxyModels( + { port: 10100, hostname: "127.0.0.1", pid: null, source: "config" }, + managementToken, + { fetchImpl, origin: "http://hub.example.test:10100" }, + )).rejects.toThrow(/not a loopback address/); + expect(calls).toEqual([]); + }); + + test("keeps every supported local listener spelling dialing loopback", async () => { + const seen: string[] = []; + const fetchImpl = async (input: RequestInfo | URL) => { + seen.push(String(input)); + return Response.json([ROW]); + }; + for (const hostname of ["0.0.0.0", "::", "[::]", "localhost", "::1", "127.0.0.1", undefined]) { + await fetchOpencodeProxyModels({ port: 10100, hostname, pid: null, source: "config" }, managementToken, { fetchImpl }); + } + expect(seen).toEqual([ + "http://127.0.0.1:10100/api/models", + "http://127.0.0.1:10100/api/models", + "http://127.0.0.1:10100/api/models", + "http://localhost:10100/api/models", + "http://[::1]:10100/api/models", + "http://127.0.0.1:10100/api/models", + "http://127.0.0.1:10100/api/models", + ]); + }); + + test("an attested proxy answers over the single-use capability, never the admin token", async () => { + const bound: string[] = []; + const tokenCalls: string[] = []; + const boundRead: typeof fetchBoundLocalManagementRead = async (target, path) => { + bound.push(`${target.port}${path}`); + return { kind: "response", response: Response.json([ROW]), targetPid: 4242 }; + }; + const rows = await fetchOpencodeProxyModels( + { port: 10100, hostname: "127.0.0.1", pid: 4242, source: "runtime" }, + managementToken, + { + boundRead, + fetchImpl: async (input: RequestInfo | URL) => { + tokenCalls.push(String(input)); + return Response.json([ROW]); + }, + }, + ); + expect(rows).toEqual([ROW]); + expect(bound).toEqual([`10100${LOCAL_MANAGEMENT_READ_PATHS.models}`]); + expect(tokenCalls).toEqual([]); + }); + + test("falls back to the loopback token read when the capability is unavailable", async () => { + const sent: Array<{ url: string; key: string | null }> = []; + const boundRead: typeof fetchBoundLocalManagementRead = async () => ({ + kind: "unavailable", + reason: "capability-unavailable", + }); + const rows = await fetchOpencodeProxyModels( + { port: 10100, hostname: "127.0.0.1", pid: 4242, source: "runtime" }, + managementToken, + { + boundRead, + fetchImpl: async (input: RequestInfo | URL, init?: RequestInit) => { + sent.push({ url: String(input), key: new Headers(init?.headers).get("X-OpenCodex-API-Key") }); + return Response.json([ROW]); + }, + }, + ); + expect(rows).toEqual([ROW]); + expect(sent).toEqual([{ url: "http://127.0.0.1:10100/api/models", key: managementToken }]); + }); + + test("never presents the capability to a listener other than the attested one", async () => { + // A hub management ingress is the same process on another port, and the capability is bound to + // the port the request arrives on, so that read has to take the loopback token path instead. + const bound: string[] = []; + const sent: string[] = []; + const boundRead: typeof fetchBoundLocalManagementRead = async () => { + bound.push("capability"); + return { kind: "response", response: Response.json([ROW]), targetPid: 4242 }; + }; + const rows = await fetchOpencodeProxyModels( + { port: 10100, hostname: "100.64.0.9", pid: 4242, source: "runtime" }, + managementToken, + { + boundRead, + origin: "http://127.0.0.1:10104", + fetchImpl: async (input: RequestInfo | URL) => { + sent.push(String(input)); + return Response.json([ROW]); + }, + }, + ); + expect(rows).toEqual([ROW]); + expect(bound).toEqual([]); + expect(sent).toEqual(["http://127.0.0.1:10104/api/models"]); + }); +}); + 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-")); diff --git a/tests/server/server-management-auth.test.ts b/tests/server/server-management-auth.test.ts index 2e5f3db061..89f1851c4c 100644 --- a/tests/server/server-management-auth.test.ts +++ b/tests/server/server-management-auth.test.ts @@ -370,6 +370,26 @@ describe("management and data-plane credential separation", () => { }); expect(accounts.status).toBe(200); + // `ocx opencode` reads its catalogue through the same grant, so the route has to stay + // capability-readable while a query-bearing variant stays outside the grant. + const modelsHeaders = headersFor(LOCAL_MANAGEMENT_READ_PATHS.models, server.port, "J".repeat(43)); + const models = await fetch(new URL(LOCAL_MANAGEMENT_READ_PATHS.models, server.url), { + headers: modelsHeaders, + }); + expect(models.status).toBe(200); + expect(Array.isArray(await models.json())).toBe(true); + + const modelsReplay = await fetch(new URL(LOCAL_MANAGEMENT_READ_PATHS.models, server.url), { + headers: modelsHeaders, + }); + expect(modelsReplay.status).toBe(503); + + const modelsQuery = await fetch( + new URL(`${LOCAL_MANAGEMENT_READ_PATHS.models}?include=all`, server.url), + { headers: headersFor(LOCAL_MANAGEMENT_READ_PATHS.models, server.port, "K".repeat(43)) }, + ); + expect(modelsQuery.status).toBe(503); + const query = await fetch( new URL(`${LOCAL_MANAGEMENT_READ_PATHS.codexAccounts}?include=all`, server.url), {