diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 2c49bd2d8d..0f1a562738 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -41,7 +41,7 @@ import type { } from "../clients/config-export"; import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog"; import { commandInvocation } from "../lib/win-exec"; -import { configuredAdminToken } from "../lib/admin-secrets"; +import { configuredAdminToken, opencodeCatalogToken } from "../lib/admin-secrets"; import { localManagementOrigin } from "../lib/local-destinations"; import { directLocalHttpFetch } from "../server/direct-local-http"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; @@ -684,7 +684,7 @@ export async function cmdOpencode(args: string[]): Promise { try { const managementToken = configuredAdminToken(); if (!managementToken) throw new Error("No local admin token is available; check the running proxy's home."); - proxyModels = await fetchOpencodeProxyModels(live, managementToken, { + proxyModels = await fetchOpencodeProxyModels(live, opencodeCatalogToken(managementToken), { managementOrigin: localManagementOrigin({ ...startupConfig, hostname: live.hostname }, live.port), }); } catch (error) { diff --git a/src/lib/admin-secrets.ts b/src/lib/admin-secrets.ts index 04fe85c423..b3e46ba457 100644 --- a/src/lib/admin-secrets.ts +++ b/src/lib/admin-secrets.ts @@ -1,4 +1,4 @@ -import { timingSafeEqual } from "node:crypto"; +import { createHmac, timingSafeEqual } from "node:crypto"; import { lstatSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { getConfigDir } from "../config"; @@ -27,6 +27,14 @@ export function configuredAdminToken(configDir = getConfigDir(), env: NodeJS.Pro export const ADMIN_TOKEN_PREFIX = "ocx_admin_"; +/** + * Derive the bearer used by the OpenCode launcher for its one management read. + * A listener that captures this value cannot recover or replay the administrator token. + */ +export function opencodeCatalogToken(adminToken: string): string { + return `ocx_catalog_${createHmac("sha256", adminToken).update("opencode:/api/models:v1").digest("base64url")}`; +} + function secretTextEquals(left: string, right: string): boolean { const a = Buffer.from(left); const b = Buffer.from(right); diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 58ae3f7d60..944d3686b9 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -12,7 +12,7 @@ import { writeFileSync, } from "node:fs"; import { dirname, join } from "node:path"; -import { adminApiTokenFilePath } from "../lib/admin-secrets"; +import { adminApiTokenFilePath, opencodeCatalogToken } from "../lib/admin-secrets"; import { LOCAL_MANAGEMENT_CAPABILITY_HEADER, LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER, @@ -297,6 +297,7 @@ export function createManagementSessionControl(state: ManagementAuthState): Mana */ export type ManagementPrincipal = | "admin-token" + | "opencode-catalog-token" | "gui-session" | "gui-pair-capability" | "local-read-capability" @@ -484,6 +485,16 @@ function requestManagementCredential(req: Request): string | null { || null; } +function isOpencodeCatalogRequest(req: Request): boolean { + if (req.method !== "GET") return false; + try { + const url = new URL(req.url); + return url.pathname === "/api/models" && url.search === ""; + } catch { + return false; + } +} + function resolveManagementAdmission( req: Request, state: ManagementAuthState, @@ -500,6 +511,9 @@ function resolveManagementAdmission( else if (state.available) { const actual = requestManagementCredential(req); if (actual && equalSecret(actual, state.token)) principal = "admin-token"; + else if (actual && isOpencodeCatalogRequest(req) && equalSecret(actual, opencodeCatalogToken(state.token))) { + principal = "opencode-catalog-token"; + } else if (config && authorizeGuiSessionRequest(req, config, state).ok) principal = "gui-session"; } if (principal) admittedManagementRequests.set(req, principal); diff --git a/structure/runtime.md b/structure/runtime.md index a09d007d7b..2fed863173 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -341,7 +341,7 @@ Account quota surfaces use [safe probe diagnostics](transports/inventory.md#acco Translated Chat request construction uses the [inline-image budget](transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. -OpenCode catalog discovery in `src/cli/opencode.ts` uses the local admin credential and a validated numeric-loopback management origin. It dials through `src/server/direct-local-http.ts`, rejects redirects and preserves the request/body deadline. Hub ingress selection stays separate from exported inference settings. +OpenCode catalog discovery in `src/cli/opencode.ts` derives a catalog-only bearer from the local admin credential and uses a validated numeric-loopback management origin. `src/server/management-auth.ts` accepts that derived bearer only for the exact `GET /api/models` read, so a spoofed listener cannot capture reusable administrator authority. The launcher dials through `src/server/direct-local-http.ts`, rejects redirects and preserves the request/body deadline. Hub ingress selection stays separate from exported inference settings. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/tests/providers/opencode-cli.test.ts b/tests/providers/opencode-cli.test.ts index fc0fc0625b..671d00a915 100644 --- a/tests/providers/opencode-cli.test.ts +++ b/tests/providers/opencode-cli.test.ts @@ -1,5 +1,6 @@ import { describe, expect, spyOn, test } from "bun:test"; import * as directHttp from "../../src/server/direct-local-http"; +import { opencodeCatalogToken } from "../../src/lib/admin-secrets"; import * as childProcess from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -263,7 +264,7 @@ describe("ocx opencode proxy model catalog", () => { port: 10123, hostname: "127.0.0.1", pid: null, source: "config", }); const fetcher = spyOn(directHttp, "directLocalHttpFetch").mockImplementation(async (input, init) => { - expect(new Headers(init?.headers).get("x-opencodex-api-key")).toBe("fixture-admin-token"); + expect(new Headers(init?.headers).get("x-opencodex-api-key")).toBe(opencodeCatalogToken("fixture-admin-token")); 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)); diff --git a/tests/providers/opencode-management-transport.test.ts b/tests/providers/opencode-management-transport.test.ts index 0a86159953..f98ab7c1d9 100644 --- a/tests/providers/opencode-management-transport.test.ts +++ b/tests/providers/opencode-management-transport.test.ts @@ -8,6 +8,8 @@ import * as liveness from "../../src/server/proxy-liveness"; import { buildOpencodeEnv, buildOpencodeProviderBlocksFromCatalog, cmdOpencode, fetchOpencodeProxyModels } from "../../src/cli/opencode"; import { OPENCODE_API_KEY_ENV } from "../../src/clients/config-export"; import type { OcxConfig } from "../../src/types"; +import { opencodeCatalogToken } from "../../src/lib/admin-secrets"; +import { requireManagementAuth, type ManagementAuthState } from "../../src/server/management-auth"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { SERVER_BUDGET_MS } from "../helpers/test-budget"; @@ -113,7 +115,7 @@ test.each(["environment", "file", "missing", "unauthorized", "redirect", "ingres const finder = spyOn(liveness, "findLiveProxy").mockResolvedValue({ port: 10123, hostname: mode === "ingress" ? "192.0.2.1" : "127.0.0.1", pid: null, source: "config" }); const request = spyOn(directHttp, "directLocalHttpFetch").mockImplementation(async (input, init) => { expect(String(input)).toBe(`http://127.0.0.1:${mode === "ingress" ? 10124 : 10123}/api/models`); - expect(new Headers(init?.headers).get("x-opencodex-api-key")).toBe(mode === "file" ? fileAdmin : admin); + expect(new Headers(init?.headers).get("x-opencodex-api-key")).toBe(opencodeCatalogToken(mode === "file" ? fileAdmin : admin)); return mode === "unauthorized" ? new Response(null, { status: 401 }) : mode === "redirect" ? new Response(null, { status: 302 }) : Response.json(rows); }); let childEnv: NodeJS.ProcessEnv | undefined; @@ -138,6 +140,22 @@ test.each(["environment", "file", "missing", "unauthorized", "redirect", "ingres } finally { err.mockRestore(); spawn.mockRestore(); request.mockRestore(); finder.mockRestore(); } }); +test("captured catalog credential cannot authorize another management route", () => { + const state: ManagementAuthState = { + available: true, + token: admin, + source: "environment", + sessions: new Map(), + pairingGrants: new Map(), + }; + const credential = opencodeCatalogToken(admin); + const headers = { "x-opencodex-api-key": credential }; + expect(requireManagementAuth(new Request("http://127.0.0.1/api/models", { headers }), state)).toBeNull(); + expect(requireManagementAuth(new Request("http://127.0.0.1/api/models?extra=1", { headers }), state)?.status).toBe(401); + expect(requireManagementAuth(new Request("http://127.0.0.1/api/config", { headers }), state)?.status).toBe(401); + expect(requireManagementAuth(new Request("http://127.0.0.1/api/stop", { method: "POST", headers }), state)?.status).toBe(401); +}); + test("case-insensitive inherited admin names are removed without changing other child variables", () => { const blocks = buildOpencodeProviderBlocksFromCatalog(12345, [], undefined, config); const env = buildOpencodeEnv(blocks, dataKey, { opencodex_admin_auth_token: admin, KEEP: "value" });