From a9024c972a7d9409d4449f6e53949d4fa8182458 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 22:20:08 +0900 Subject: [PATCH] fix(claude): authenticate the gateway model cache refresh Claude Code only refreshes its gateway-model cache when it holds a credential, so the launch path pre-writes that cache for it. The pre-write fetch sent no admission credential, so on a listener that requires data-plane admission it got 401 and the picker kept showing yesterday's aliases. Attach the admission credential in its own header domain (never Authorization, which belongs to upstream providers on other data-plane surfaces), following the same precedence the opencode launcher already uses: environment token, hardened service-token file, then a configured admission key. Closes #1713 Co-authored-by: Ingwannu --- src/claude/gateway-cache.ts | 45 +++++++++++++++++++-- src/cli/claude.ts | 2 +- src/server/system-env.ts | 2 +- tests/claude-gateway-cache.test.ts | 65 ++++++++++++++++++++++++++++-- 4 files changed, 105 insertions(+), 9 deletions(-) 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; + } + }); });