Skip to content
Open
26 changes: 23 additions & 3 deletions src/claude/gateway-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export interface GatewayModelCacheRefreshOptions {
configDir?: string;
admissionConfig?: Pick<OcxConfig, "apiKeys">;
env?: NodeJS.ProcessEnv;
fetchImpl?: typeof fetch;
}

export interface GatewayModelTarget {
baseUrl: string;
admissionToken: string;
}

/** Claude Code config dir (CLAUDE_CONFIG_DIR override honored, like the CLI). */
Expand Down Expand Up @@ -70,6 +76,14 @@ function serviceFileToken(env: NodeJS.ProcessEnv): string | null {
/** Fetch the anthropic-flavor /v1/models from the local proxy and write the cache. */
export async function refreshGatewayModelCacheFromProxy(
port: number,
options?: GatewayModelCacheRefreshOptions,
): Promise<string | null>;
export async function refreshGatewayModelCacheFromProxy(
target: GatewayModelTarget,
options?: GatewayModelCacheRefreshOptions,
): Promise<string | null>;
export async function refreshGatewayModelCacheFromProxy(
portOrTarget: number | GatewayModelTarget,
options: GatewayModelCacheRefreshOptions = {},
): Promise<string | null> {
try {
Expand All @@ -82,12 +96,18 @@ export async function refreshGatewayModelCacheFromProxy(
const configuredToken = options.admissionConfig?.apiKeys
?.find(entry => entry.key.trim().length > 0)
?.key.trim();
const admissionToken = envToken || serviceFileToken(options.env ?? process.env) || configuredToken;
const admissionToken = typeof portOrTarget === "number"
? envToken || serviceFileToken(options.env ?? process.env) || configuredToken
: portOrTarget.admissionToken;
if (admissionToken) headers.set("x-opencodex-api-key", admissionToken);

const baseUrl = typeof portOrTarget === "number"
? `http://127.0.0.1:${portOrTarget}`
: new URL(portOrTarget.baseUrl).origin;

// ?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`, {
const res = await (options.fetchImpl ?? fetch)(`${baseUrl}/v1/models?limit=1000&ids=cli`, {
headers,
signal: AbortSignal.timeout(options.timeoutMs ?? 3_000),
});
Expand All @@ -100,7 +120,7 @@ export async function refreshGatewayModelCacheFromProxy(
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, options.configDir);
return writeGatewayModelCache(baseUrl, models, options.configDir);
} catch {
return null;
}
Expand Down
134 changes: 113 additions & 21 deletions src/cli/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,22 @@ import { resolveClaudeAuthMode } from "../claude/auth-mode";
import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
import { selfLaunchArgv } from "../lib/self-launch-argv";
import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "./launcher-context";
import { readClientConnectionState } from "../client/state";
import { readServiceApiTokenState } from "../lib/service-secrets";
import { DEFAULT_CATALOG_PATH } from "../codex/paths";
import { readFileSync } from "node:fs";
import { aliasForNative, aliasForRoute } from "../claude/alias";
import { desktop3pAlias } from "../claude/desktop-3p";

export interface ClaudeLaunchEnv {
[key: string]: string | undefined;
}

export interface ClaudeRoutingTarget {
baseUrl: string;
admissionToken: string;
}

/**
* Injectable IO for tests. `env` is deliberately NOT injectable: it is bound to the
* launch base so detection and the spawned process can never disagree (audit R3-3).
Expand Down Expand Up @@ -61,6 +72,20 @@ function targetsLocalClaudeProxy(value: string | undefined, port: number): boole
}
}

function targetsClaudeRoutingTarget(value: string | undefined, target: ClaudeRoutingTarget): boolean {
if (!value) return false;
try {
const actual = new URL(value);
const expected = new URL(target.baseUrl);
return actual.origin === expected.origin
&& (actual.pathname === "/" || actual.pathname === "")
&& !actual.username
&& !actual.password;
} catch {
return false;
}
}

/**
* Pure env assembly (unit-tested): never sets ANTHROPIC_API_KEY (setting both
* token vars triggers Claude Code's auth-conflict warning, 003 E1), and never
Expand All @@ -70,11 +95,14 @@ function targetsLocalClaudeProxy(value: string | undefined, port: number): boole
*/
export function buildClaudeEnv(
config: OcxConfig,
port: number,
portOrTarget: number | ClaudeRoutingTarget,
base: ClaudeLaunchEnv,
contextWindows: Record<string, number> = {},
deps: ClaudeEnvDeps = {},
): ClaudeLaunchEnv {
const explicitTarget = typeof portOrTarget === "number" ? null : portOrTarget;
const port = typeof portOrTarget === "number" ? portOrTarget : null;
const managedBaseUrl = explicitTarget ? new URL(explicitTarget.baseUrl).origin : `http://127.0.0.1:${port}`;
const env: ClaudeLaunchEnv = { ...base };
// Step 1 — strip OUR OWN dummy from the inherited environment before anything reads
// or writes the token slot. setDefault below preserves any non-empty value, so a
Expand Down Expand Up @@ -120,9 +148,9 @@ export function buildClaudeEnv(
if (deps.allowRootSkipPermissions === true) {
setDefault("IS_SANDBOX", "1");
}
setDefault("ANTHROPIC_BASE_URL", `http://127.0.0.1:${port}`);
setDefault("ANTHROPIC_BASE_URL", managedBaseUrl);
const existingBaseUrl = env.ANTHROPIC_BASE_URL;
if (existingBaseUrl) {
if (existingBaseUrl && port !== null) {
try {
const parsed = new URL(existingBaseUrl);
const effectivePort = parsed.port === "" ? 80 : Number(parsed.port);
Expand All @@ -142,16 +170,20 @@ export function buildClaudeEnv(
// the user's Claude login. Only inject a token when the proxy actually requires an
// admission key; otherwise Claude Code keeps its own OAuth and sends it to us —
// native claude models then pass through verbatim (see server/claude-messages.ts).
const ownTokens = ownAdmissionTokens(config);
const targetsLocalProxy = targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, port);
const ownTokens = explicitTarget ? [explicitTarget.admissionToken] : ownAdmissionTokens(config);
const targetsLocalProxy = explicitTarget
? targetsClaudeRoutingTarget(env.ANTHROPIC_BASE_URL, explicitTarget)
: targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, port!);
const isOwnAdmissionToken = (value: string): boolean =>
ownTokens.includes(value) || isProxyAdmissionSecret(value, config);
const inheritedApiKey = env.ANTHROPIC_API_KEY;
if (typeof inheritedApiKey === "string" && isProxyAdmissionSecret(inheritedApiKey, config)) {
if (typeof inheritedApiKey === "string" && isOwnAdmissionToken(inheritedApiKey)) {
delete env.ANTHROPIC_API_KEY;
}
const hasUserApiKey = Boolean(env.ANTHROPIC_API_KEY?.trim());
const inheritedAuthToken = env.ANTHROPIC_AUTH_TOKEN;
const inheritedTokenIsOurs = typeof inheritedAuthToken === "string"
&& isProxyAdmissionSecret(inheritedAuthToken, config);
&& isOwnAdmissionToken(inheritedAuthToken);
// system-env may have injected the proxy's admission key into the parent. A
// proof-bound external BASE_URL is still user-owned, so never let our inherited
// key follow it. A user API key also wins on a local launch; remove only the token
Expand Down Expand Up @@ -187,7 +219,7 @@ export function buildClaudeEnv(
&& typeof finalAuthToken === "string"
&& (
finalAuthToken.trim() === PROXY_MARKER
|| isProxyAdmissionSecret(finalAuthToken, config)
|| isOwnAdmissionToken(finalAuthToken)
);
if (resolved.origin === "auto-unknown") {
console.error("⚠ Claude 인증을 확인하지 못했습니다 — 구독 방식으로 진행합니다. GUI에서 인증 모드를 직접 지정하면 이 판단을 덮어쓸 수 있습니다.");
Expand Down Expand Up @@ -270,6 +302,40 @@ export async function fetchClaudeContextWindows(config: OcxConfig, port: number,
}
}

export function readConnectedClaudeContextWindows(path = DEFAULT_CATALOG_PATH): Record<string, number> {
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as { models?: unknown };
if (!Array.isArray(parsed.models)) return {};
const out: Record<string, number> = {};
const put = (key: string, value: number) => { if (out[key] === undefined) out[key] = value; };
for (const row of parsed.models) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const entry = row as Record<string, unknown>;
const slug = typeof entry.slug === "string" ? entry.slug : "";
const contextWindow = typeof entry.context_window === "number" && entry.context_window > 0
? entry.context_window
: undefined;
if (!slug || contextWindow === undefined) continue;
put(slug, contextWindow);
const slash = slug.indexOf("/");
if (slash > 0 && slash < slug.length - 1) {
const provider = slug.slice(0, slash);
const id = slug.slice(slash + 1);
const routeAlias = aliasForRoute(provider, id);
if (routeAlias) put(routeAlias, contextWindow);
put(desktop3pAlias(provider, id), contextWindow);
} else {
const nativeAlias = aliasForNative(slug);
if (nativeAlias) put(nativeAlias, contextWindow);
put(desktop3pAlias("native", slug), contextWindow);
}
}
return out;
} catch {
return {};
}
}

async function ensureProxyForClaude(): Promise<number | null> {
const live = await findLiveProxy();
if (live) return live.port;
Expand Down Expand Up @@ -328,21 +394,45 @@ export async function cmdClaude(args: string[]): Promise<number> {
console.error("Claude inbound is disabled (config.claudeCode.enabled=false — flip the Claude ON toggle in the GUI or edit config).");
return 1;
}
const port = await ensureProxyForClaude();
if (!port) {
console.error("❌ Proxy did not become healthy after starting.");
const clientState = readClientConnectionState();
if (clientState.kind === "invalid" || clientState.kind === "mismatched") {
console.error(`Client state is ${clientState.kind}: ${clientState.reason}`);
return 1;
}
const contextWindows = await fetchClaudeContextWindows(config, port);
let route: number | ClaudeRoutingTarget;
let contextWindows: Record<string, number>;
if (clientState.kind === "connected") {
if (!clientState.value.selectedClients.includes("claude")) {
console.error("Claude is not selected for this remote hub connection.");
return 1;
}
const token = readServiceApiTokenState();
if (token.kind !== "present" || token.fingerprint !== clientState.value.tokenFingerprint) {
console.error(token.kind === "absent" ? "Connected service token is missing." : "Connected service token ownership changed.");
return 1;
}
route = { baseUrl: clientState.value.serverUrl, admissionToken: token.token };
contextWindows = readConnectedClaudeContextWindows();
} else {
const port = await ensureProxyForClaude();
if (!port) {
console.error("❌ Proxy did not become healthy after starting.");
return 1;
}
route = port;
contextWindows = await fetchClaudeContextWindows(config, port);
}
const allowRootSkipPermissions = shouldAllowRootSkipPermissions(args);
const env = buildClaudeEnv(config, port, process.env, contextWindows, { allowRootSkipPermissions });
const env = buildClaudeEnv(config, route, process.env, contextWindows, { allowRootSkipPermissions });
if (allowRootSkipPermissions) {
console.error(rootSkipPermissionsNotice(env));
}
// 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, { admissionConfig: config });
const cachePath = typeof route === "number"
? await refreshGatewayModelCacheFromProxy(route, { admissionConfig: config })
: await refreshGatewayModelCacheFromProxy(route, { admissionConfig: config });
if (cachePath === null) {
console.error("⚠ Gateway model cache could not be refreshed; the model picker may be stale.");
}
Expand All @@ -351,14 +441,16 @@ export async function cmdClaude(args: string[]): Promise<number> {
console.error(`⚠ Gateway model cache could not be refreshed: ${message}`);
}
// Sync roster agents (devlog 070): subagentModels + self -> ~/.claude/agents/ocx-*.md.
try {
const written = injectClaudeAgentDefs(config, contextWindows);
if (written === null) {
console.error("⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions.");
if (typeof route === "number") {
try {
const written = injectClaudeAgentDefs(config, contextWindows);
if (written === null) {
console.error("⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions.");
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
}
return await new Promise<number>(resolve => {
const inv = commandInvocation("claude", args);
Expand Down
Loading
Loading