From d0936c01aa325579296413fed90bd3966731c1ec Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:49:58 +0900 Subject: [PATCH 1/3] fix(claude): keep proxy tokens on local destinations --- src/cli/claude.ts | 55 +++++++++-- tests/claude-auth-mode.test.ts | 166 +++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 7 deletions(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 4284ea6ce..cd4aac24d 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -12,6 +12,7 @@ import { injectClaudeAgentDefs } from "../claude/agents-inject"; import { effectiveModelEnv, resolveAutoContext } from "../claude/context-windows"; import { refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache"; import { commandInvocation } from "../lib/win-exec"; +import { isProxyAdmissionSecret } from "../server/auth-cors"; import { findLiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; import { configuredAdminToken } from "../lib/admin-secrets"; @@ -34,6 +35,29 @@ export type ClaudeEnvDeps = { preBunAnthropicSlots?: readonly AnthropicParentEnvSlot[] | null; }; +function isClaudeLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/\.$/, ""); + return normalized === "localhost" + || normalized === "127.0.0.1" + || normalized === "::1" + || normalized === "[::1]"; +} + +function targetsLocalClaudeProxy(value: string | undefined, port: number): boolean { + if (!value) return false; + try { + const parsed = new URL(value); + const effectivePort = parsed.port === "" ? 80 : Number(parsed.port); + return parsed.protocol === "http:" + && isClaudeLoopbackHostname(parsed.hostname) + && effectivePort === port + && parsed.username === "" + && parsed.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 @@ -54,7 +78,7 @@ export function buildClaudeEnv( // stale marker left in place would suppress the admission key and then be removed, // leaving the child with no token at all (audit R2-1). It is opencodex state, never // user auth, so dropping it unconditionally is safe. - if (env.ANTHROPIC_AUTH_TOKEN === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN; + if (env.ANTHROPIC_AUTH_TOKEN?.trim() === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN; // Step 1b — drop Anthropic credentials AND destinations that Bun may have synthesized // from a project `.env`/`.env.local`. The plain-Node launcher records genuine parent // exports before Bun starts and pairs that context with an argv proof, so with a @@ -95,8 +119,7 @@ export function buildClaudeEnv( if (existingBaseUrl) { try { const parsed = new URL(existingBaseUrl); - const isLoopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; - if (isLoopback && parsed.port !== "" && Number(parsed.port) !== port) { + if (isClaudeLoopbackHostname(parsed.hostname) && parsed.port !== "" && Number(parsed.port) !== port) { const replacement = `http://127.0.0.1:${port}`; console.error(`⚠ Replacing stale opencodex ANTHROPIC_BASE_URL ${existingBaseUrl} with ${replacement}.`); env.ANTHROPIC_BASE_URL = replacement; @@ -110,8 +133,26 @@ 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). - if ((config.apiKeys?.length ?? 0) > 0) { - setDefault("ANTHROPIC_AUTH_TOKEN", config.apiKeys![0].key); + const ownTokens = ownAdmissionTokens(config); + const targetsLocalProxy = targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, port); + const inheritedApiKey = env.ANTHROPIC_API_KEY; + if (typeof inheritedApiKey === "string" && isProxyAdmissionSecret(inheritedApiKey, config)) { + 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); + // 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 + // values recognized by the shared proxy-admission contract and preserve every + // other token. + if (inheritedTokenIsOurs && (!targetsLocalProxy || hasUserApiKey)) { + delete env.ANTHROPIC_AUTH_TOKEN; + } + if (targetsLocalProxy && !hasUserApiKey && ownTokens.length > 0) { + setDefault("ANTHROPIC_AUTH_TOKEN", ownTokens[0]); } // Detection reads the SANITIZED launch env — the exact object spawned below — so the // resolver and the spawned process cannot disagree. It deliberately does NOT read the @@ -126,9 +167,9 @@ export function buildClaudeEnv( ...defaultAuthDetectDeps(env as NodeJS.ProcessEnv), ...(deps.authDetect ?? {}), env: () => env as NodeJS.ProcessEnv, - ownTokens: ownAdmissionTokens(config), + ownTokens, })); - if (!env.ANTHROPIC_AUTH_TOKEN && resolved.markerMode === "proxy") { + if (!env.ANTHROPIC_AUTH_TOKEN && !hasUserApiKey && targetsLocalProxy && resolved.markerMode === "proxy") { env.ANTHROPIC_AUTH_TOKEN = PROXY_MARKER; } if (resolved.origin === "auto-unknown") { diff --git a/tests/claude-auth-mode.test.ts b/tests/claude-auth-mode.test.ts index 4504a055d..aad48a0b7 100644 --- a/tests/claude-auth-mode.test.ts +++ b/tests/claude-auth-mode.test.ts @@ -90,6 +90,22 @@ test("a stale marker is stripped when the mode resolves subscription", () => { expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined(); }); +test("a whitespace-wrapped stale marker cannot follow an external destination", () => { + const env = buildClaudeEnv( + cfg(), 10100, + { + ANTHROPIC_BASE_URL: "https://trusted-gateway.example", + ANTHROPIC_AUTH_TOKEN: ` ${PROXY_MARKER} `, + }, + {}, + { + authDetect: fileAuth("present"), + preBunAnthropicSlots: ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"], + }, + ); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); +}); + test("a stale marker is re-established when the mode still resolves proxy", () => { const env = buildClaudeEnv( cfg(), 10100, @@ -142,6 +158,18 @@ test("manual proxy injects the marker even when auth is present", () => { expect(env.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_MARKER); }); +test("manual proxy mode does not pair a marker with a user API key", () => { + const env = buildClaudeEnv( + cfg({ authMode: "proxy" }), 10100, + { ANTHROPIC_API_KEY: "user-api-key" }, + {}, + { authDetect: fileAuth("absent"), preBunAnthropicSlots: ["ANTHROPIC_API_KEY"] }, + ); + expect(env.ANTHROPIC_API_KEY).toBe("user-api-key"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined(); +}); + test("manual subscription withholds the marker even when auth is absent", () => { const env = buildClaudeEnv(cfg({ authMode: "subscription" }), 10100, {}, {}, { authDetect: fileAuth("absent") }); expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); @@ -235,6 +263,144 @@ test("a proof-bound parent base URL remains supported", () => { expect(env.ANTHROPIC_BASE_URL).toBe("https://trusted-gateway.example"); }); +test("a configured admission key is never injected into an external gateway", () => { + const env = buildClaudeEnv( + cfg(undefined, [{ key: "admission-key" }]), 10100, + { ANTHROPIC_BASE_URL: "https://trusted-gateway.example" }, + {}, + { authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_BASE_URL"] }, + ); + expect(env.ANTHROPIC_BASE_URL).toBe("https://trusted-gateway.example"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined(); +}); + +test("an HTTPS loopback URL is not treated as the local HTTP proxy", () => { + const env = buildClaudeEnv( + cfg({ authMode: "proxy" }, [{ key: "admission-key" }]), 10100, + { ANTHROPIC_BASE_URL: "https://localhost:10100" }, + {}, + { authDetect: fileAuth("absent"), preBunAnthropicSlots: ["ANTHROPIC_BASE_URL"] }, + ); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined(); +}); + +test("a same-port IPv6 loopback URL receives the configured admission key", () => { + const env = buildClaudeEnv( + cfg(undefined, [{ key: "admission-key" }]), 10100, + { ANTHROPIC_BASE_URL: "http://[::1]:10100" }, + {}, + { authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_BASE_URL"] }, + ); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("admission-key"); + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe("1"); +}); + +test("a stale IPv6 loopback URL is moved to the running proxy port", () => { + const env = buildClaudeEnv( + cfg(undefined, [{ key: "admission-key" }]), 10100, + { ANTHROPIC_BASE_URL: "http://[::1]:9999" }, + {}, + { authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_BASE_URL"] }, + ); + expect(env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:10100"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("admission-key"); +}); + +test("an inherited admission key is stripped when the destination is external", () => { + const env = buildClaudeEnv( + cfg(undefined, [{ key: "admission-key" }]), 10100, + { + ANTHROPIC_BASE_URL: "https://trusted-gateway.example", + ANTHROPIC_AUTH_TOKEN: "admission-key", + }, + {}, + { + authDetect: fileAuth("present"), + preBunAnthropicSlots: ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"], + }, + ); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); +}); + +test("a stale generated admission key is still recognized after key rotation", () => { + const env = buildClaudeEnv( + cfg(undefined, [{ key: "ocx_data_current" }]), 10100, + { + ANTHROPIC_BASE_URL: "https://trusted-gateway.example", + ANTHROPIC_AUTH_TOKEN: " ocx_data_rotated ", + }, + {}, + { + authDetect: fileAuth("present"), + preBunAnthropicSlots: ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"], + }, + ); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); +}); + +test("a proxy admission secret is never preserved in the API-key slot", () => { + const external = buildClaudeEnv( + cfg(undefined, [{ key: "ocx_data_current" }]), 10100, + { + ANTHROPIC_BASE_URL: "https://trusted-gateway.example", + ANTHROPIC_API_KEY: " ocx_data_rotated ", + }, + {}, + { + authDetect: fileAuth("present"), + preBunAnthropicSlots: ["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"], + }, + ); + expect(external.ANTHROPIC_API_KEY).toBeUndefined(); + expect(external.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + + const local = buildClaudeEnv( + cfg(undefined, [{ key: "ocx_data_current" }]), 10100, + { ANTHROPIC_API_KEY: "ocx_data_rotated" }, + {}, + { authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_API_KEY"] }, + ); + expect(local.ANTHROPIC_API_KEY).toBeUndefined(); + expect(local.ANTHROPIC_AUTH_TOKEN).toBe("ocx_data_current"); +}); + +test("an external gateway keeps a user-owned auth token", () => { + const env = buildClaudeEnv( + cfg(undefined, [{ key: "admission-key" }]), 10100, + { + ANTHROPIC_BASE_URL: "https://trusted-gateway.example", + ANTHROPIC_AUTH_TOKEN: "user-gateway-token", + }, + {}, + { + authDetect: fileAuth("absent"), + preBunAnthropicSlots: ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"], + }, + ); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("user-gateway-token"); +}); + +test("a user API key outranks an inherited local admission token", () => { + const env = buildClaudeEnv( + cfg(undefined, [{ key: "admission-key" }]), 10100, + { + ANTHROPIC_BASE_URL: "http://[::1]:10100", + ANTHROPIC_API_KEY: "user-api-key", + ANTHROPIC_AUTH_TOKEN: "admission-key", + }, + {}, + { + authDetect: fileAuth("absent"), + preBunAnthropicSlots: ["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"], + }, + ); + expect(env.ANTHROPIC_API_KEY).toBe("user-api-key"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined(); +}); + test("the legacy dotenv marker cannot forge parent provenance", () => { const env = buildClaudeEnv( cfg(), 10100, From 3d130d7ef594a1120222bc07c30fb58a9646b392 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:28:22 +0900 Subject: [PATCH 2/3] fix(claude): bind host auth to proxy credentials --- src/cli/claude.ts | 12 ++++++++++-- tests/claude-auth-mode.test.ts | 23 ++++++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index cd4aac24d..1d56bb0ac 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -121,7 +121,7 @@ export function buildClaudeEnv( const parsed = new URL(existingBaseUrl); if (isClaudeLoopbackHostname(parsed.hostname) && parsed.port !== "" && Number(parsed.port) !== port) { const replacement = `http://127.0.0.1:${port}`; - console.error(`⚠ Replacing stale opencodex ANTHROPIC_BASE_URL ${existingBaseUrl} with ${replacement}.`); + console.error(`⚠ Replacing stale opencodex ANTHROPIC_BASE_URL ${parsed.origin} with ${replacement}.`); env.ANTHROPIC_BASE_URL = replacement; } } catch { @@ -172,6 +172,14 @@ export function buildClaudeEnv( if (!env.ANTHROPIC_AUTH_TOKEN && !hasUserApiKey && targetsLocalProxy && resolved.markerMode === "proxy") { env.ANTHROPIC_AUTH_TOKEN = PROXY_MARKER; } + const finalAuthToken = env.ANTHROPIC_AUTH_TOKEN; + const hostOwnsAuthentication = targetsLocalProxy + && !hasUserApiKey + && typeof finalAuthToken === "string" + && ( + finalAuthToken.trim() === PROXY_MARKER + || isProxyAdmissionSecret(finalAuthToken, config) + ); if (resolved.origin === "auto-unknown") { console.error("⚠ Claude 인증을 확인하지 못했습니다 — 구독 방식으로 진행합니다. GUI에서 인증 모드를 직접 지정하면 이 판단을 덮어쓸 수 있습니다."); } @@ -193,7 +201,7 @@ export function buildClaudeEnv( // Claude Code 2.1.206+ also treats this as a host-auth assertion. Injecting it // without a host token makes a valid claude.ai subscription look logged out, // so the guard is only safe when opencodex actually owns authentication. - if (env.ANTHROPIC_AUTH_TOKEN) { + if (hostOwnsAuthentication) { setDefault("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "1"); } // Opt-in effort forcing (devlog 136 B6): opus-shaped aliases already carry diff --git a/tests/claude-auth-mode.test.ts b/tests/claude-auth-mode.test.ts index aad48a0b7..4e3f707b5 100644 --- a/tests/claude-auth-mode.test.ts +++ b/tests/claude-auth-mode.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { buildClaudeEnv } from "../src/cli/claude"; import { PROXY_MARKER, type AuthDetectDeps, type AuthPresence } from "../src/claude/auth-detect"; import { authModeIntent, resolveClaudeAuthMode } from "../src/claude/auth-mode"; @@ -308,6 +308,26 @@ test("a stale IPv6 loopback URL is moved to the running proxy port", () => { expect(env.ANTHROPIC_AUTH_TOKEN).toBe("admission-key"); }); +test("a stale loopback warning omits URL credentials, paths, and queries", () => { + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + const env = buildClaudeEnv( + cfg(undefined, [{ key: "admission-key" }]), 10100, + { ANTHROPIC_BASE_URL: "http://user:oauth-token@localhost:9999/private?token=query-secret" }, + {}, + { authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_BASE_URL"] }, + ); + expect(env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:10100"); + expect(error).toHaveBeenCalledWith(expect.stringContaining("http://localhost:9999")); + const warning = String(error.mock.calls[0]?.[0] ?? ""); + expect(warning).not.toContain("oauth-token"); + expect(warning).not.toContain("query-secret"); + expect(warning).not.toContain("user:"); + } finally { + error.mockRestore(); + } +}); + test("an inherited admission key is stripped when the destination is external", () => { const env = buildClaudeEnv( cfg(undefined, [{ key: "admission-key" }]), 10100, @@ -380,6 +400,7 @@ test("an external gateway keeps a user-owned auth token", () => { }, ); expect(env.ANTHROPIC_AUTH_TOKEN).toBe("user-gateway-token"); + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined(); }); test("a user API key outranks an inherited local admission token", () => { From c01de68e58f29ef02f296eb04731faf7112265c3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:08:37 +0900 Subject: [PATCH 3/3] test(claude): cover default-port stale proxy URL --- src/cli/claude.ts | 5 ++++- tests/claude-auth-mode.test.ts | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 1d56bb0ac..216213ae9 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -119,7 +119,10 @@ export function buildClaudeEnv( if (existingBaseUrl) { try { const parsed = new URL(existingBaseUrl); - if (isClaudeLoopbackHostname(parsed.hostname) && parsed.port !== "" && Number(parsed.port) !== port) { + const effectivePort = parsed.port === "" ? 80 : Number(parsed.port); + if (parsed.protocol === "http:" + && isClaudeLoopbackHostname(parsed.hostname) + && effectivePort !== port) { const replacement = `http://127.0.0.1:${port}`; console.error(`⚠ Replacing stale opencodex ANTHROPIC_BASE_URL ${parsed.origin} with ${replacement}.`); env.ANTHROPIC_BASE_URL = replacement; diff --git a/tests/claude-auth-mode.test.ts b/tests/claude-auth-mode.test.ts index 4e3f707b5..7b377e799 100644 --- a/tests/claude-auth-mode.test.ts +++ b/tests/claude-auth-mode.test.ts @@ -308,6 +308,17 @@ test("a stale IPv6 loopback URL is moved to the running proxy port", () => { expect(env.ANTHROPIC_AUTH_TOKEN).toBe("admission-key"); }); +test("a default-port loopback URL is moved to the running proxy port", () => { + const env = buildClaudeEnv( + cfg(undefined, [{ key: "admission-key" }]), 10100, + { ANTHROPIC_BASE_URL: "http://localhost" }, + {}, + { authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_BASE_URL"] }, + ); + expect(env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:10100"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("admission-key"); +}); + test("a stale loopback warning omits URL credentials, paths, and queries", () => { const error = spyOn(console, "error").mockImplementation(() => {}); try {