Skip to content

Commit d5b9338

Browse files
committed
fix(claude): stop a project dotenv from overriding claude.ai subscription auth
Closes #701. The bundled Bun runtime auto-loads a project `.env`/`.env.local` before any opencodex code evaluates, so an `ANTHROPIC_API_KEY` sitting in whatever directory the user launched from was indistinguishable from a deliberate shell export by the time buildClaudeEnv read process.env. Claude Code disables claude.ai connectors the moment either token slot is populated, so a healthy subscription silently fell through to API billing and reported a credit-balance error. The npm launcher runs under Node, which does NOT auto-load dotenv, so it is the last point that still knows the difference. It now records which Anthropic slots were already non-empty in `OCX_PRE_BUN_ANTHROPIC_ENV` and passes that to the Bun child; buildClaudeEnv drops any slot populated now but absent then, and deletes the marker before it reaches Claude Code. A genuine shell export still wins, in every auth mode, so auto-mode API-key auth keeps working. Two details the audit forced: - The discriminator is provenance, not `authMode === "subscription"`. Auto is stored as an ABSENT key and is the GUI default, and auto+present resolves to subscription, so keying off the literal would have missed most affected users. - Auth detection is rebound to the sanitized env. Leaving it on the raw base let a credential the child never receives decide the marker, which left an auto-mode user with no credential AND no PROXY_MARKER. A global `--no-env-file` was rejected: config interpolation and provider settings legitimately read the project environment. The transport test spawns real processes because the empty-string marker ("the launcher ran and saw no slots") must stay distinct from an absent one ("no launcher, change nothing"); the unit tests inject that marker directly and would stay green if a platform ever collapsed the two.
1 parent e449521 commit d5b9338

6 files changed

Lines changed: 216 additions & 10 deletions

File tree

bin/ocx.mjs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,25 @@ const bun = resolveBun();
361361
// signal delivered only to this launcher (Codex app, IDE terminal, service wrapper,
362362
// or `kill -INT <launcherPid>`) killed the launcher and ORPHANED the Bun proxy —
363363
// port left bound, pid/runtime-port files left behind, Codex config not restored.
364-
const child = spawn(bun, [cliPath, ...process.argv.slice(2)], { stdio: "inherit" });
364+
//
365+
// Provenance seam for issue #701: THIS launcher runs under Node, which does not
366+
// auto-load a project `.env`/`.env.local`; the Bun child does, before any opencodex
367+
// code evaluates. So this is the last point that can still tell a real shell export
368+
// from a working-directory dotenv value, and we record which Anthropic credential
369+
// slots already existed. `src/cli/claude.ts` then treats anything present in the Bun
370+
// child but missing from this list as ambient project pollution rather than user auth,
371+
// which stopped a project dotenv from silently moving a claude.ai subscriber onto API
372+
// billing. An EMPTY value is meaningful (the launcher ran and saw no slots) and is
373+
// distinct from the variable being absent (no launcher at all — change nothing), so
374+
// this must stay a plain assignment and never be collapsed to a falsy check.
375+
// Disabling Bun's dotenv wholesale with --no-env-file is NOT an option: config
376+
// interpolation and provider settings legitimately read the project environment.
377+
const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]
378+
.filter(name => typeof process.env[name] === "string" && process.env[name] !== "");
379+
const child = spawn(bun, [cliPath, ...process.argv.slice(2)], {
380+
stdio: "inherit",
381+
env: { ...process.env, OCX_PRE_BUN_ANTHROPIC_ENV: preBunAnthropicSlots.join(",") },
382+
});
365383

366384
// Windows has no real POSIX signals (no SIGHUP); forwarding is best-effort there.
367385
const FORWARDED = process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"];

docs-site/src/content/docs/guides/claude-code.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ ocx claude
5555
| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | Legacy context override when `maxContextTokens` is set (conditional) |
5656
Variables you export yourself always win. Extra arguments pass through: `ocx claude -p "hello"`.
5757

58+
One exception is about *where* a variable comes from, not about precedence. The bundled Bun
59+
runtime auto-loads a project `.env` / `.env.local`, so a stray `ANTHROPIC_API_KEY` in the
60+
directory you happen to launch from used to look identical to a deliberate export — and it
61+
silently disabled a healthy claude.ai subscription in favour of API billing. `ocx claude` now
62+
ignores Anthropic credentials that only a project dotenv introduced. A value you exported in
63+
your shell still wins, in every auth mode. To use an API key deliberately, export it
64+
(`export ANTHROPIC_API_KEY=...`) rather than leaving it in a project file.
65+
5866
## Auth mode
5967

6068
Claude Code needs a token in `ANTHROPIC_AUTH_TOKEN` to talk to a gateway, but setting that

src/cli/claude.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,27 @@ export function buildClaudeEnv(
4848
// leaving the child with no token at all (audit R2-1). It is opencodex state, never
4949
// user auth, so dropping it unconditionally is safe.
5050
if (env.ANTHROPIC_AUTH_TOKEN === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN;
51+
// Step 1b — drop Anthropic credentials that the bundled Bun runtime synthesized from a
52+
// project `.env`/`.env.local` (issue #701). Claude Code disables claude.ai connectors the
53+
// moment either token slot is populated, so an ambient project file silently moved a
54+
// subscriber onto API billing while their OAuth login stayed healthy. The npm launcher
55+
// runs under Node, which does NOT auto-load dotenv, so it records the slots that existed
56+
// before Bun started; anything populated now but absent then came from the working
57+
// directory, not from the user. A genuine shell export is still honored, which keeps
58+
// auto-mode API-key auth working. An ABSENT marker means provenance is unknowable
59+
// (a direct `bun src/cli/index.ts` run, a test, or an older launcher), and then we
60+
// change nothing rather than guess — an EMPTY marker is different: the launcher ran
61+
// and saw no pre-existing slots.
62+
const preBunSlots = base.OCX_PRE_BUN_ANTHROPIC_ENV;
63+
if (preBunSlots !== undefined) {
64+
const exported = new Set(preBunSlots.split(",").filter(name => name.length > 0));
65+
for (const name of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] as const) {
66+
const value = env[name];
67+
if (value !== undefined && value !== "" && !exported.has(name)) delete env[name];
68+
}
69+
}
70+
// Never forward the seam itself to Claude Code.
71+
delete env.OCX_PRE_BUN_ANTHROPIC_ENV;
5172
const setDefault = (name: string, value: string | undefined) => {
5273
if (value === undefined || value.length === 0) return;
5374
if (env[name] !== undefined && env[name] !== "") return; // user wins
@@ -76,15 +97,19 @@ export function buildClaudeEnv(
7697
if ((config.apiKeys?.length ?? 0) > 0) {
7798
setDefault("ANTHROPIC_AUTH_TOKEN", config.apiKeys![0].key);
7899
}
79-
// Detection reads the SAME base env this launch will use, so the resolver and the
80-
// spawned process cannot disagree. Injected deps are spread FIRST and `env` bound
81-
// LAST, and the injection type excludes `env`, so a test fake cannot break that.
82-
// `ownTokens` is bound last for the same reason: it is config-derived, and a fake
83-
// that replaced it could make our own admission key look like user auth.
100+
// Detection reads the SANITIZED launch env — the exact object spawned below — so the
101+
// resolver and the spawned process cannot disagree. It deliberately does NOT read the
102+
// raw base: the provenance strip above already removed dotenv-only credentials, and
103+
// letting a value the child never receives decide the marker left an auto-mode user
104+
// with neither the credential NOR the proxy marker (#701 audit round 2). Injected deps
105+
// are spread FIRST and `env` bound LAST, and the injection type excludes `env`, so a
106+
// test fake cannot break that. `ownTokens` is bound last for the same reason: it is
107+
// config-derived, and a fake that replaced it could make our own admission key look
108+
// like user auth.
84109
const resolved = resolveClaudeAuthMode(config, detectClaudeAuth({
85-
...defaultAuthDetectDeps(base as NodeJS.ProcessEnv),
110+
...defaultAuthDetectDeps(env as NodeJS.ProcessEnv),
86111
...(deps.authDetect ?? {}),
87-
env: () => base as NodeJS.ProcessEnv,
112+
env: () => env as NodeJS.ProcessEnv,
88113
ownTokens: ownAdmissionTokens(config),
89114
}));
90115
if (!env.ANTHROPIC_AUTH_TOKEN && resolved.markerMode === "proxy") {

tests/claude-auth-mode.test.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ test("auto-absent emits both the marker and the host assertion", () => {
124124
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe("1");
125125
});
126126

127-
// A user-exported API key is auth: detection sees it through the base-env binding,
128-
// so no proxy token is injected and no auth-conflict warning is provoked.
127+
// A user-exported API key is auth: detection sees it through the sanitized launch-env
128+
// binding, so no proxy token is injected and no auth-conflict warning is provoked.
129129
test("an exported ANTHROPIC_API_KEY keeps the token slot untouched", () => {
130130
const env = buildClaudeEnv(
131131
cfg(), 10100,
@@ -147,6 +147,89 @@ test("manual subscription withholds the marker even when auth is absent", () =>
147147
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
148148
});
149149

150+
// ---------------------------------------------------------------------------
151+
// #701 — project dotenv must not outrank a claude.ai subscription.
152+
//
153+
// Bun auto-loads `.env`/`.env.local` before any opencodex code runs, so process.env alone
154+
// cannot tell ambient pollution from a real shell export. The Node launcher runs BEFORE
155+
// that and records which slots already existed; these tests drive that marker directly.
156+
// An absent marker means provenance is unknowable, so behavior must not change.
157+
const PRE_BUN = "OCX_PRE_BUN_ANTHROPIC_ENV";
158+
159+
// The reported failure: auto mode, healthy claude.ai login, key only from the dotenv.
160+
test("auto mode drops an Anthropic key that only Bun's dotenv introduced", () => {
161+
const env = buildClaudeEnv(
162+
cfg(), 10100,
163+
{ ANTHROPIC_API_KEY: "sk-ant-from-project-dotenv", [PRE_BUN]: "" },
164+
{},
165+
{ authDetect: fileAuth("present") },
166+
);
167+
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
168+
expect(env[PRE_BUN]).toBeUndefined();
169+
});
170+
171+
// A real shell export must still win — that is auto-mode API-key auth, which is supported.
172+
test("a shell-exported Anthropic key survives the dotenv strip", () => {
173+
const env = buildClaudeEnv(
174+
cfg(), 10100,
175+
{ ANTHROPIC_API_KEY: "sk-ant-user", [PRE_BUN]: "ANTHROPIC_API_KEY" },
176+
{},
177+
{ authDetect: fileAuth("present") },
178+
);
179+
expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user");
180+
expect(env[PRE_BUN]).toBeUndefined();
181+
});
182+
183+
test("explicit subscription mode also drops a dotenv-only credential", () => {
184+
const env = buildClaudeEnv(
185+
cfg({ authMode: "subscription" }), 10100,
186+
{ ANTHROPIC_API_KEY: "sk-ant-from-project-dotenv", ANTHROPIC_AUTH_TOKEN: "token-from-dotenv", [PRE_BUN]: "" },
187+
{},
188+
{ authDetect: fileAuth("present") },
189+
);
190+
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
191+
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
192+
});
193+
194+
// The admission key is opencodex's own gate, not user auth: it is injected after the strip.
195+
test("the configured admission key survives the dotenv strip", () => {
196+
const env = buildClaudeEnv(
197+
cfg(undefined, [{ key: "admission-key" }]), 10100,
198+
{ ANTHROPIC_API_KEY: "sk-ant-from-project-dotenv", [PRE_BUN]: "" },
199+
{},
200+
{ authDetect: fileAuth("present") },
201+
);
202+
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
203+
expect(env.ANTHROPIC_AUTH_TOKEN).toBe("admission-key");
204+
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe("1");
205+
});
206+
207+
// Without the launcher marker (direct `bun src/cli/index.ts`, or an older launcher)
208+
// provenance is unknowable, so an inherited key keeps its current meaning.
209+
test("without the launcher marker an inherited key is left alone", () => {
210+
const env = buildClaudeEnv(
211+
cfg(), 10100,
212+
{ ANTHROPIC_API_KEY: "sk-ant-user" },
213+
{},
214+
{ authDetect: fileAuth("present") },
215+
);
216+
expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user");
217+
});
218+
219+
// Stripping the key must ALSO flip detection to absent so the proxy marker is injected.
220+
// Binding detection to the pre-strip base left this user with no credential and no marker.
221+
test("a stripped dotenv key lets detection fall through to the proxy marker", () => {
222+
const env = buildClaudeEnv(
223+
cfg(), 10100,
224+
{ ANTHROPIC_API_KEY: "sk-ant-from-project-dotenv", [PRE_BUN]: "" },
225+
{},
226+
{ authDetect: fileAuth("absent") },
227+
);
228+
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
229+
expect(env.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_MARKER);
230+
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe("1");
231+
});
232+
150233
// ---------------------------------------------------------------------------
151234
// H2 (040_hardening): the settings.json env-hijack defence, proven per resolution.
152235
//
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { spawnSync } from "node:child_process";
3+
import { mkdtempSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
7+
/**
8+
* #701 transport proof.
9+
*
10+
* The dotenv-provenance fix rests on one runtime assumption: an EMPTY-STRING environment
11+
* value survives a spawn and stays distinguishable from an absent variable. The whole
12+
* design hinges on it, because "the launcher ran and saw zero pre-existing Anthropic
13+
* slots" is encoded as `OCX_PRE_BUN_ANTHROPIC_ENV=""` while "no launcher at all, change
14+
* nothing" is encoded as the variable being absent.
15+
*
16+
* The unit tests in claude-auth-mode.test.ts inject that marker directly into a plain
17+
* object, so they would stay green even if a platform collapsed `""` to unset in real
18+
* process spawning — and the production fix would silently become a no-op for exactly
19+
* the case that matters most. This test spawns real processes instead, so the assumption
20+
* is proven by execution on whatever platform CI runs (Linux, Windows, macOS).
21+
*/
22+
describe("empty-string env transport across a real spawn (#701)", () => {
23+
const dir = mkdtempSync(join(tmpdir(), "ocx-dotenv-provenance-"));
24+
const probe = join(dir, "probe.mjs");
25+
writeFileSync(
26+
probe,
27+
'const v = process.env.OCX_PRE_BUN_ANTHROPIC_ENV;\n'
28+
+ 'process.stdout.write(JSON.stringify({ type: typeof v, value: v ?? null, own: "OCX_PRE_BUN_ANTHROPIC_ENV" in process.env }));\n',
29+
);
30+
31+
function probeWith(env: NodeJS.ProcessEnv | undefined): { type: string; value: string | null; own: boolean } {
32+
const result = spawnSync(process.execPath, [probe], {
33+
encoding: "utf8",
34+
...(env ? { env } : {}),
35+
});
36+
expect(result.status).toBe(0);
37+
return JSON.parse(result.stdout) as { type: string; value: string | null; own: boolean };
38+
}
39+
40+
test("an empty marker arrives as an own property whose value is the empty string", () => {
41+
const seen = probeWith({ ...process.env, OCX_PRE_BUN_ANTHROPIC_ENV: "" });
42+
expect(seen.type).toBe("string");
43+
expect(seen.value).toBe("");
44+
expect(seen.own).toBe(true);
45+
});
46+
47+
test("a populated marker arrives verbatim", () => {
48+
const seen = probeWith({ ...process.env, OCX_PRE_BUN_ANTHROPIC_ENV: "ANTHROPIC_API_KEY" });
49+
expect(seen.value).toBe("ANTHROPIC_API_KEY");
50+
});
51+
52+
// The distinction the fix depends on: absent is NOT the same as empty.
53+
test("an absent marker stays absent rather than becoming an empty string", () => {
54+
const inherited = { ...process.env };
55+
delete inherited.OCX_PRE_BUN_ANTHROPIC_ENV;
56+
const seen = probeWith(inherited);
57+
expect(seen.type).toBe("undefined");
58+
expect(seen.own).toBe(false);
59+
});
60+
});

tests/ocx-launcher-source.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,16 @@ describe("ocx.mjs npm launcher (source invariants)", () => {
2222
expect(source).toContain('if (explicit === "preview" || explicit === "latest") return explicit;');
2323
expect(source).not.toMatch(/if \(tagIndex !== -1 && process\.argv\[tagIndex \+ 1\]\) return process\.argv/);
2424
});
25+
26+
// #701: the launcher is the only place that still knows whether an Anthropic credential
27+
// came from a real shell export or from a project dotenv, because Node does not
28+
// auto-load `.env` while the Bun child does. Losing this half silently returns the
29+
// proxy to billing a subscriber's API key from an ambient file, and the runtime half in
30+
// src/cli/claude.ts would keep passing its own unit tests while doing nothing.
31+
test("the Bun child receives the pre-Bun Anthropic provenance marker", () => {
32+
expect(source).toContain("const preBunAnthropicSlots = [\"ANTHROPIC_API_KEY\", \"ANTHROPIC_AUTH_TOKEN\"]");
33+
expect(source).toContain("OCX_PRE_BUN_ANTHROPIC_ENV: preBunAnthropicSlots.join(\",\")");
34+
// The marker must be computed from the launcher's OWN env, before Bun's dotenv load.
35+
expect(source).toContain("typeof process.env[name] === \"string\" && process.env[name] !== \"\"");
36+
});
2537
});

0 commit comments

Comments
 (0)