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
70 changes: 61 additions & 9 deletions src/cli/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -95,10 +119,12 @@ 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) {
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 ${existingBaseUrl} with ${replacement}.`);
console.error(`⚠ Replacing stale opencodex ANTHROPIC_BASE_URL ${parsed.origin} with ${replacement}.`);
env.ANTHROPIC_BASE_URL = replacement;
}
} catch {
Expand All @@ -110,8 +136,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]);
Comment thread
luvs01 marked this conversation as resolved.
}
// 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
Expand All @@ -126,11 +170,19 @@ 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;
}
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에서 인증 모드를 직접 지정하면 이 판단을 덮어쓸 수 있습니다.");
}
Expand All @@ -152,7 +204,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
Expand Down
200 changes: 199 additions & 1 deletion tests/claude-auth-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -235,6 +263,176 @@ 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("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 {
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,
{
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");
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined();
});

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,
Expand Down
Loading