diff --git a/src/claude/gateway-cache.ts b/src/claude/gateway-cache.ts index 28abceff0e..aeedf3e652 100644 --- a/src/claude/gateway-cache.ts +++ b/src/claude/gateway-cache.ts @@ -13,12 +13,21 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; +import type { OcxConfig } from "../types"; export interface GatewayModelRow { id: string; display_name?: string; } +export interface GatewayModelCacheRefreshOptions { + timeoutMs?: number; + configDir?: string; + admissionConfig?: Pick; + env?: NodeJS.ProcessEnv; +} + /** Claude Code config dir (CLAUDE_CONFIG_DIR override honored, like the CLI). */ export function claudeConfigDir(): string { const custom = process.env.CLAUDE_CONFIG_DIR; @@ -45,14 +54,42 @@ export function writeGatewayModelCache(baseUrl: string, models: readonly Gateway } } +/** + * Hardened service-token file, the same precedence `ocx opencode` uses. A service + * install writes the admission token to disk rather than the interactive environment, + * so an interactive `ocx claude` with neither env token nor configured key would + * otherwise still get a 401 and keep a stale picker list. + */ +function serviceFileToken(env: NodeJS.ProcessEnv): string | null { + const lookup = env.OCX_API_TOKEN_FILE?.trim() + ? env + : { ...env, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() }; + return loadServiceTokenFromFile(lookup as Record); +} + /** Fetch the anthropic-flavor /v1/models from the local proxy and write the cache. */ -export async function refreshGatewayModelCacheFromProxy(port: number, timeoutMs = 3_000, configDir?: string): Promise { +export async function refreshGatewayModelCacheFromProxy( + port: number, + options: GatewayModelCacheRefreshOptions = {}, +): Promise { try { + const headers = new Headers({ "anthropic-version": "2023-06-01" }); + // A wildcard/non-loopback listener requires data-plane admission even for a + // request sent to its local 127.0.0.1 address. Reuse the same dedicated + // credential domain as /v1/models admission; never place it in Authorization, + // which can belong to an upstream provider on other data-plane surfaces. + const envToken = (options.env ?? process.env).OPENCODEX_API_AUTH_TOKEN?.trim(); + const configuredToken = options.admissionConfig?.apiKeys + ?.find(entry => entry.key.trim().length > 0) + ?.key.trim(); + const admissionToken = envToken || serviceFileToken(options.env ?? process.env) || configuredToken; + if (admissionToken) headers.set("x-opencodex-api-key", admissionToken); + // ?ids=cli pins the readable claude-ocx id family deterministically (audit 051 // #5): the cache prewrite must not depend on UA sniffing. const res = await fetch(`http://127.0.0.1:${port}/v1/models?limit=1000&ids=cli`, { - headers: { "anthropic-version": "2023-06-01" }, - signal: AbortSignal.timeout(timeoutMs), + headers, + signal: AbortSignal.timeout(options.timeoutMs ?? 3_000), }); if (!res.ok) return null; const body = await res.json() as { data?: unknown }; @@ -63,7 +100,7 @@ export async function refreshGatewayModelCacheFromProxy(port: number, timeoutMs id: m.id as string, display_name: typeof m.display_name === "string" ? m.display_name : undefined, })); - return writeGatewayModelCache(`http://127.0.0.1:${port}`, models, configDir); + return writeGatewayModelCache(`http://127.0.0.1:${port}`, models, options.configDir); } catch { return null; } diff --git a/src/cli/claude.ts b/src/cli/claude.ts index ccf97e3826..07f9599f50 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -317,7 +317,7 @@ export async function cmdClaude(args: string[]): Promise { // Pre-write the CLI's gateway-model cache (devlog 030): without a token the CLI // never refreshes it, so the picker would keep showing yesterday's aliases. try { - const cachePath = await refreshGatewayModelCacheFromProxy(port); + const cachePath = await refreshGatewayModelCacheFromProxy(port, { admissionConfig: config }); if (cachePath === null) { console.error("⚠ Gateway model cache could not be refreshed; the model picker may be stale."); } diff --git a/src/server/system-env.ts b/src/server/system-env.ts index b92b9f4fa8..10515d66ec 100644 --- a/src/server/system-env.ts +++ b/src/server/system-env.ts @@ -335,7 +335,7 @@ export async function injectSystemEnv(port: number, config: OcxConfig): Promise< // without a token — keep it in sync with this proxy's /v1/models. Best-effort. try { const { refreshGatewayModelCacheFromProxy } = await import("../claude/gateway-cache"); - await refreshGatewayModelCacheFromProxy(port); + await refreshGatewayModelCacheFromProxy(port, { admissionConfig: config }); } catch { /* best-effort */ } // Roster agent definitions (devlog 070): same launch-time sync for plain `claude`. diff --git a/tests/claude-gateway-cache.test.ts b/tests/claude-gateway-cache.test.ts index 1f0565c7a5..186d6dd550 100644 --- a/tests/claude-gateway-cache.test.ts +++ b/tests/claude-gateway-cache.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { claudeConfigDir, refreshGatewayModelCacheFromProxy, writeGatewayModelCache } from "../src/claude/gateway-cache"; @@ -62,13 +62,23 @@ describe("Claude Code gateway-model cache pre-write (devlog 260712 030)", () => const originalFetch = globalThis.fetch; let requestedUrl = ""; try { - globalThis.fetch = (async (input: RequestInfo | URL) => { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { requestedUrl = String(input); + const headers = new Headers(init?.headers); + expect(headers.get("x-opencodex-api-key")).toBe("env-admission"); + expect(headers.get("authorization")).toBeNull(); return new Response(JSON.stringify({ data: [{ id: "claude-ocx-native--gpt-5.6-sol", display_name: "gpt-5.6-sol (native)" }] }), { headers: { "content-type": "application/json" }, }); }) as typeof fetch; - const path = await refreshGatewayModelCacheFromProxy(10100, 1000, dir); + const path = await refreshGatewayModelCacheFromProxy(10100, { + timeoutMs: 1000, + configDir: dir, + admissionConfig: { + apiKeys: [{ id: "configured", name: "Configured", key: "configured-admission", createdAt: "" }], + }, + env: { OPENCODEX_API_AUTH_TOKEN: " env-admission " }, + }); expect(requestedUrl).toContain("ids=cli"); const body = JSON.parse(readFileSync(path!, "utf8")); expect(body.models[0].id).toBe("claude-ocx-native--gpt-5.6-sol"); @@ -76,4 +86,53 @@ describe("Claude Code gateway-model cache pre-write (devlog 260712 030)", () => globalThis.fetch = originalFetch; } }); + + test("proxy refresh falls back to a configured admission key", async () => { + const dir = tempDir(); + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + expect(headers.get("x-opencodex-api-key")).toBe("configured-admission"); + return new Response(JSON.stringify({ data: [] }), { + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + expect(await refreshGatewayModelCacheFromProxy(10100, { + configDir: dir, + admissionConfig: { + apiKeys: [{ id: "configured", name: "Configured", key: "configured-admission", createdAt: "" }], + }, + env: {}, + })).not.toBeNull(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("proxy refresh uses the hardened service token file before a configured key", async () => { + const dir = tempDir(); + const tokenFile = join(tempDir(), "service-api-token"); + writeFileSync(tokenFile, " service-file-admission\n", "utf8"); + const originalFetch = globalThis.fetch; + let seen: string | null = null; + try { + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + seen = new Headers(init?.headers).get("x-opencodex-api-key"); + return new Response(JSON.stringify({ data: [] }), { + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + await refreshGatewayModelCacheFromProxy(10100, { + configDir: dir, + admissionConfig: { + apiKeys: [{ id: "configured", name: "Configured", key: "configured-admission", createdAt: "" }], + }, + env: { OCX_API_TOKEN_FILE: tokenFile }, + }); + expect(seen).toBe("service-file-admission"); + } finally { + globalThis.fetch = originalFetch; + } + }); });