Skip to content
Closed
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
15 changes: 6 additions & 9 deletions src/claude/auth-mode.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
/**
* Claude auth-mode resolution.
*
* The resolver answers exactly ONE question: does the opencodex-owned dummy token
* (`ANTHROPIC_AUTH_TOKEN=opencodex-proxy`) get injected? That is narrower than "how
* will Claude authenticate" — native passthrough additionally needs an `sk-ant-`
* credential on the incoming request — so the field is `markerMode`, not
* `effectiveAuthMode` (devlog/_plan/260726_claude_auth_auto/002 R2-1).
*
* The admission-key axis is separate and untouched: when the proxy requires an
* admission key, `buildClaudeEnv` injects it regardless of mode.
* The resolver answers which authentication mode the Claude launcher should honor.
* Native passthrough additionally needs an `sk-ant-` credential on the incoming request,
* so the field is `markerMode`, not `effectiveAuthMode` (devlog/_plan/260726_claude_auth_auto/002
* R2-1). The launchers use subscription mode to keep proxy-owned marker and admission
* credentials out of Claude's environment; proxy mode may inject them for gateway auth.
*/
import type { OcxConfig } from "../types";
import type { AuthDetectResult, AuthSourceId } from "./auth-detect";
Expand All @@ -18,7 +15,7 @@ export type MarkerMode = "proxy" | "subscription";
export type AuthModeOrigin = "manual" | "auto-present" | "auto-absent" | "auto-unknown";

export interface ResolvedAuthMode {
/** Does the owned dummy marker get injected. NOT a claim about native auth. */
/** Proxy-owned auth mode for launchers. NOT a claim about native auth. */
markerMode: MarkerMode;
origin: AuthModeOrigin;
/** The detector source that proved presence (origin auto-present only). */
Expand Down
31 changes: 16 additions & 15 deletions src/cli/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,9 @@ export function buildClaudeEnv(
}
// Subscription-preserving default (teamclaude --no-mitm / Vercel gateway pattern):
// setting ANTHROPIC_AUTH_TOKEN/API_KEY disables claude.ai connectors and overrides
// 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).
// the user's Claude login. Resolve the mode before adding any proxy-owned credential:
// subscription launches must keep their OAuth, while proxy launches may use the
// admission key or dummy marker (see server/claude-messages.ts).
const ownTokens = ownAdmissionTokens(config);
const targetsLocalProxy = targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, port);
const inheritedApiKey = env.ANTHROPIC_API_KEY;
Expand All @@ -172,24 +172,25 @@ export function buildClaudeEnv(
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
// raw base: the provenance strip above already removed dotenv-only credentials, and
// letting a value the child never receives decide the marker left an auto-mode user
// with neither the credential NOR the proxy marker (#701 audit round 2). Injected deps
// are spread FIRST and `env` bound LAST, and the injection type excludes `env`, so a
// test fake cannot break that. `ownTokens` is bound last for the same reason: it is
// config-derived, and a fake that replaced it could make our own admission key look
// like user auth.
// Detection reads the sanitized launch env before proxy-owned credentials are added.
// The provenance strip above removed dotenv-only credentials, and ownTokens keeps a
// configured admission key from being mistaken for user auth (#701 audit round 2).
const resolved = resolveClaudeAuthMode(config, detectClaudeAuth({
...defaultAuthDetectDeps(env as NodeJS.ProcessEnv),
...(deps.authDetect ?? {}),
env: () => env as NodeJS.ProcessEnv,
ownTokens,
}));
if (resolved.markerMode === "subscription") {
// A prior system-env snapshot may have left our admission key in the inherited
// environment. It belongs to the proxy data plane, not Claude subscription OAuth.
const token = env.ANTHROPIC_AUTH_TOKEN?.trim();
if (token && (token === PROXY_MARKER || isProxyAdmissionSecret(token, config))) {
delete env.ANTHROPIC_AUTH_TOKEN;
}
} else if (targetsLocalProxy && !hasUserApiKey && ownTokens.length > 0) {
setDefault("ANTHROPIC_AUTH_TOKEN", ownTokens[0]);
}
if (!env.ANTHROPIC_AUTH_TOKEN && !hasUserApiKey && targetsLocalProxy && resolved.markerMode === "proxy") {
env.ANTHROPIC_AUTH_TOKEN = PROXY_MARKER;
}
Expand Down
103 changes: 78 additions & 25 deletions src/server/system-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { accessSync, constants, readFileSync, writeFileSync, unlinkSync, mkdirSy
import { delimiter, join } from "node:path";
import { getConfigDir } from "../config";
import { resolveAutoContext, type AutoContextMode } from "../claude/context-windows";
import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens } from "../claude/auth-detect";
import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens, type AuthDetectDeps } from "../claude/auth-detect";
import { resolveClaudeAuthMode } from "../claude/auth-mode";
import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "../cli/launcher-context";
import { isProxyAdmissionSecret } from "./auth-cors";
import type { OcxConfig } from "../types";
import { recordOwnedConfigPath } from "../lib/config-ownership";
import { providerContextCap } from "../providers/context-cap";
Expand All @@ -21,8 +23,43 @@ import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
* NOTE this is a SNAPSHOT: the file only changes when this runs (proxy start, `ocx
* ensure`, or a settings save). `ocx claude` re-resolves live on every launch.
*/
function systemEnvMarkerMode(config: OcxConfig): "proxy" | "subscription" {
return resolveClaudeAuthMode(config, detectClaudeAuth(defaultAuthDetectDeps(process.env, ownAdmissionTokens(config)))).markerMode;
export type SystemEnvDeps = {
/** Test seam; production uses the authenticated Node-launcher context. */
preBunAnthropicSlots?: readonly AnthropicParentEnvSlot[] | null;
/** Test seam for auth sources; `env` and `ownTokens` stay bound below. */
authDetect?: Omit<Partial<AuthDetectDeps>, "env" | "ownTokens">;
};

/**
* Bun may synthesize Anthropic variables from a project `.env` before this module runs.
* Only values recorded by the plain-Node launcher are trusted as parent exports. Direct
* Bun/service launches have no proof-bound slot list, so they fail closed and let the
* file/keychain auth sources decide instead of allowing dotenv to select subscription mode.
*/
function systemEnvAnthropicEnv(
env: NodeJS.ProcessEnv,
preBunAnthropicSlots: readonly AnthropicParentEnvSlot[] | null | undefined,
): NodeJS.ProcessEnv {
const trustedSlots = preBunAnthropicSlots === undefined
? trustedNodeLauncherContext()?.anthropicEnvSlots ?? []
: preBunAnthropicSlots ?? [];
const exported = new Set<AnthropicParentEnvSlot>(trustedSlots);
const sanitized = { ...env };
for (const name of ANTHROPIC_PARENT_ENV_SLOTS) {
if (sanitized[name] !== undefined && !exported.has(name)) delete sanitized[name];
}
return sanitized;
}

function systemEnvMarkerMode(config: OcxConfig, deps: SystemEnvDeps = {}): "proxy" | "subscription" {
const env = systemEnvAnthropicEnv(process.env, deps.preBunAnthropicSlots);
const ownTokens = ownAdmissionTokens(config);
return resolveClaudeAuthMode(config, detectClaudeAuth({
...defaultAuthDetectDeps(env, ownTokens),
...(deps.authDetect ?? {}),
env: () => env,
ownTokens,
})).markerMode;
}

// ---------------------------------------------------------------------------
Expand All @@ -39,7 +76,13 @@ function shellValue(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}

function writeShellEnvFile(port: number, config: OcxConfig, modelEnv: Record<string, string> = {}, auto?: AutoContextMode): void {
function writeShellEnvFile(
port: number,
config: OcxConfig,
modelEnv: Record<string, string> = {},
auto?: AutoContextMode,
deps: SystemEnvDeps = {},
): void {
const lines = [
`# Generated by opencodex — do not edit manually`,
`export ANTHROPIC_BASE_URL=${shellValue(`http://127.0.0.1:${port}`)}`,
Expand All @@ -49,10 +92,12 @@ function writeShellEnvFile(port: number, config: OcxConfig, modelEnv: Record<str
// exported in their shell wins even though launchctl knows nothing about it.
const conditional = (name: string, value: string) =>
`[ -z "\${${name}+x}" ] && export ${name}=${shellValue(value)}`;
if (config.apiKeys?.length) {
lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
} else if (systemEnvMarkerMode(config) === "proxy") {
lines.push(conditional("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER));
if (systemEnvMarkerMode(config, deps) === "proxy") {
if (config.apiKeys?.length) {
lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
} else {
lines.push(conditional("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER));
}
}
// Model slots (default + tiers + legacy small-fast) with [1m] applied (devlog 260712 B2).
if (modelEnv.ANTHROPIC_MODEL) {
Expand Down Expand Up @@ -319,7 +364,11 @@ async function computeEffectiveModelEnv(config: OcxConfig, auto?: AutoContextMod
return { modelEnv: effectiveModelEnv(config.claudeCode, windows ?? {}, auto), windows: windows ?? {} };
}

export async function injectSystemEnv(port: number, config: OcxConfig): Promise<SystemEnvResult> {
export async function injectSystemEnv(
port: number,
config: OcxConfig,
deps: SystemEnvDeps = {},
): Promise<SystemEnvResult> {
if (process.platform !== "darwin") return { injected: false, reason: "not macOS" };
if (config.claudeCode?.enabled === false) return { injected: false, reason: "claude disabled" };

Expand Down Expand Up @@ -351,21 +400,25 @@ export async function injectSystemEnv(port: number, config: OcxConfig): Promise<
try {
inject("ANTHROPIC_BASE_URL", ownedBaseUrl(port));
inject("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "1");
if (config.apiKeys?.length) {
inject("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
} else if (systemEnvMarkerMode(config) === "proxy" && launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === undefined) {
inject("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER);
} else if (systemEnvMarkerMode(config) !== "proxy"
&& injectedKeys.includes("ANTHROPIC_AUTH_TOKEN")
&& launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === PROXY_MARKER) {
// Subscription switch-back (devlog 260720_claude_authmode_persist): remove ONLY
// the opencodex-owned dummy token so a launchd-started Claude regains its own
// claude.ai OAuth. User-set tokens (not tracked in injectedKeys, or carrying a
// different value) are never touched.
unsetLaunchctlEnv("ANTHROPIC_AUTH_TOKEN");
const dummyIdx = injectedKeys.indexOf("ANTHROPIC_AUTH_TOKEN");
if (dummyIdx >= 0) injectedKeys.splice(dummyIdx, 1);
writeTracking(port, injectedKeys);
const markerMode = systemEnvMarkerMode(config, deps);
if (markerMode === "proxy") {
if (config.apiKeys?.length) {
inject("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
} else if (launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === undefined) {
inject("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER);
}
} else if (injectedKeys.includes("ANTHROPIC_AUTH_TOKEN")) {
const currentToken = launchctlGetenv("ANTHROPIC_AUTH_TOKEN");
if (currentToken
&& (currentToken === PROXY_MARKER || isProxyAdmissionSecret(currentToken, config))) {
// Subscription switch-back (devlog 260720_claude_authmode_persist): remove
// opencodex-owned dummy or admission tokens so a launchd-started Claude regains
// its own claude.ai OAuth. User-set tokens are never touched.
unsetLaunchctlEnv("ANTHROPIC_AUTH_TOKEN");
const tokenIdx = injectedKeys.indexOf("ANTHROPIC_AUTH_TOKEN");
if (tokenIdx >= 0) injectedKeys.splice(tokenIdx, 1);
writeTracking(port, injectedKeys);
}
}
// Lever keys (devlog 136 B6): user-wins — skip any key the user already set in the
// launchd domain, and track ONLY the keys we actually injected so revert cannot
Expand Down Expand Up @@ -399,7 +452,7 @@ export async function injectSystemEnv(port: number, config: OcxConfig): Promise<
}

// Shell-hook env file: works for new shells in already-running Terminal.app.
writeShellEnvFile(port, config, modelEnv, auto);
writeShellEnvFile(port, config, modelEnv, auto, deps);

// Gateway-model cache pre-write (devlog 030): plain `claude` sessions read the
// picker list from ~/.claude/cache/gateway-models.json and cannot refresh it
Expand Down
53 changes: 39 additions & 14 deletions tests/claude-auth-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function cfg(claudeCode?: OcxConfig["claudeCode"], apiKeys?: { key: string }[]):

function detection(presence: AuthPresence, staleProxyMarker = false) {
const deps: AuthDetectDeps = {
readClaudeJson: () => (presence === "present" ? { oauthAccount: { emailAddress: "user@example.com" } } : undefined),
readClaudeJson: () => (presence === "present" ? { oauthAccount: { emailAddress: "user-fixture" } } : undefined),
credentialsFileExists: () => false,
keychainProbe: () => (presence === "unknown" ? "unknown" : "absent"),
env: () => (staleProxyMarker ? { ANTHROPIC_AUTH_TOKEN: PROXY_MARKER } : {}),
Expand All @@ -34,7 +34,7 @@ function detection(presence: AuthPresence, staleProxyMarker = false) {
// still reads the real launch base (which is the point of the binding).
function fileAuth(presence: AuthPresence): Omit<Partial<AuthDetectDeps>, "env"> {
return {
readClaudeJson: () => (presence === "present" ? { oauthAccount: { emailAddress: "user@example.com" } } : undefined),
readClaudeJson: () => (presence === "present" ? { oauthAccount: { emailAddress: "user-fixture" } } : undefined),
credentialsFileExists: () => false,
keychainProbe: () => (presence === "unknown" ? "unknown" : "absent"),
};
Expand Down Expand Up @@ -116,10 +116,11 @@ test("a stale marker is re-established when the mode still resolves proxy", () =
expect(env.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_MARKER);
});

// The ordering blocker: a stale marker must not suppress the configured admission key.
test("a stale marker never suppresses the admission key", () => {
// Proxy mode owns the Claude auth slot, so a stale marker must not suppress
// the configured admission key.
test("proxy mode replaces a stale marker with the admission key", () => {
const env = buildClaudeEnv(
cfg(undefined, [{ key: "admission-key" }]), 10100,
cfg({ authMode: "proxy" }, [{ key: "admission-key" }]), 10100,
{ ANTHROPIC_AUTH_TOKEN: PROXY_MARKER },
{},
{ authDetect: fileAuth("present") },
Expand All @@ -134,6 +135,30 @@ test("auto-subscription emits no host-managed assertion (#253 class)", () => {
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined();
});

test("auto-subscription keeps configured admission keys out of Claude auth", () => {
const env = buildClaudeEnv(
cfg(undefined, [{ key: "admission-key" }]),
10100,
{},
{},
{ authDetect: fileAuth("present") },
);
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined();
});

test("auto-proxy uses a configured admission key when Claude auth is absent", () => {
const env = buildClaudeEnv(
cfg(undefined, [{ key: "admission-key" }]),
10100,
{},
{},
{ authDetect: fileAuth("absent") },
);
expect(env.ANTHROPIC_AUTH_TOKEN).toBe("admission-key");
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe("1");
});

test("auto-absent emits both the marker and the host assertion", () => {
const env = buildClaudeEnv(cfg(), 10100, {}, {}, { authDetect: fileAuth("absent") });
expect(env.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_MARKER);
Expand Down Expand Up @@ -219,10 +244,10 @@ test("explicit subscription mode also drops a dotenv-only credential", () => {
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
});

// The admission key is opencodex's own gate, not user auth: it is injected after the strip.
// The admission key is opencodex's own gate, not user auth: proxy mode injects it after the strip.
test("the configured admission key survives the dotenv strip", () => {
const env = buildClaudeEnv(
cfg(undefined, [{ key: "admission-key" }]), 10100,
cfg({ authMode: "proxy" }, [{ key: "admission-key" }]), 10100,
{ ANTHROPIC_API_KEY: "sk-ant-dotenv" },
{},
{ authDetect: fileAuth("present"), preBunAnthropicSlots: [] },
Expand Down Expand Up @@ -288,7 +313,7 @@ test("an HTTPS loopback URL is not treated as the local HTTP proxy", () => {

test("a same-port IPv6 loopback URL receives the configured admission key", () => {
const env = buildClaudeEnv(
cfg(undefined, [{ key: "admission-key" }]), 10100,
cfg({ authMode: "proxy" }, [{ key: "admission-key" }]), 10100,
{ ANTHROPIC_BASE_URL: "http://[::1]:10100" },
{},
{ authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_BASE_URL"] },
Expand All @@ -299,7 +324,7 @@ test("a same-port IPv6 loopback URL receives the configured admission key", () =

test("a stale IPv6 loopback URL is moved to the running proxy port", () => {
const env = buildClaudeEnv(
cfg(undefined, [{ key: "admission-key" }]), 10100,
cfg({ authMode: "proxy" }, [{ key: "admission-key" }]), 10100,
{ ANTHROPIC_BASE_URL: "http://[::1]:9999" },
{},
{ authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_BASE_URL"] },
Expand All @@ -310,7 +335,7 @@ test("a stale IPv6 loopback URL is moved to the running proxy port", () => {

test("a default-port loopback URL is moved to the running proxy port", () => {
const env = buildClaudeEnv(
cfg(undefined, [{ key: "admission-key" }]), 10100,
cfg({ authMode: "proxy" }, [{ key: "admission-key" }]), 10100,
{ ANTHROPIC_BASE_URL: "http://localhost" },
{},
{ authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_BASE_URL"] },
Expand All @@ -323,8 +348,8 @@ 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" },
cfg({ authMode: "proxy" }, [{ key: "admission-key" }]), 10100,
{ ANTHROPIC_BASE_URL: "http://localhost:9999/private?token=query-secret" },
{},
{ authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_BASE_URL"] },
);
Expand Down Expand Up @@ -388,7 +413,7 @@ test("a proxy admission secret is never preserved in the API-key slot", () => {
expect(external.ANTHROPIC_AUTH_TOKEN).toBeUndefined();

const local = buildClaudeEnv(
cfg(undefined, [{ key: "ocx_data_current" }]), 10100,
cfg({ authMode: "proxy" }, [{ key: "ocx_data_current" }]), 10100,
{ ANTHROPIC_API_KEY: "ocx_data_rotated" },
{},
{ authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_API_KEY"] },
Expand Down Expand Up @@ -533,7 +558,7 @@ test("a leftover settings.json env block cannot hijack an auto-resolved proxy la
});

test("an admission-key launch is defended the same way", () => {
const launch = buildClaudeEnv(cfg(undefined, [{ key: "admission-key" }]), 10100, {}, {}, { authDetect: fileAuth("present") });
const launch = buildClaudeEnv(cfg({ authMode: "proxy" }, [{ key: "admission-key" }]), 10100, {}, {}, { authDetect: fileAuth("present") });
const merged = simulateClaudeCodeSettingsMerge(launch, CC_SWITCH_LEFTOVER);
expect(merged.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:10100");
expect(merged.ANTHROPIC_AUTH_TOKEN).toBe("admission-key");
Expand Down
Loading
Loading