Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions src/claude/gateway-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OcxConfig, "apiKeys">;
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;
Expand All @@ -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<string, string | undefined>);
}

/** 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<string | null> {
export async function refreshGatewayModelCacheFromProxy(
port: number,
options: GatewayModelCacheRefreshOptions = {},
): Promise<string | null> {
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 };
Expand All @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/cli/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ export async function cmdClaude(args: string[]): Promise<number> {
// 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.");
}
Expand Down
2 changes: 1 addition & 1 deletion src/server/system-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
65 changes: 62 additions & 3 deletions tests/claude-gateway-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -62,18 +62,77 @@ 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");
} finally {
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;
}
});
});
Loading