fix: preserve native Claude CLI authentication provenance

This commit is contained in:
amosblomqvist
2026-10-28 18:17:50 +02:00
parent 64a36bebd2
commit c0de3f968d
6 changed files with 76 additions and 37 deletions
+28 -5
View File
@@ -36,6 +36,7 @@ import { parseCliLine } from "../src/agent/claude-code/cli-events.ts";
import { ClaudeCodeError } from "../src/agent/claude-code/constants.ts";
import { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } from "../src/agent/claude-code/live-processes.ts";
import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
import { authEnvRecord } from "../src/core/auth-env.ts";
import { createClaudeCodeStreamFunction, toClaudeArgs, toEffort, type RunClaude } from "../src/agent/claude-code/stream.ts";
import { toStreamJsonLine } from "../src/agent/claude-code/transcript.ts";
@@ -298,7 +299,9 @@ test("toClaudeArgs includes every isolation flag from Global Constraints", () =>
"--mcp-config",
"/private/mcp.json",
"--setting-sources",
"",
"user",
"--settings",
'{"disableAllHooks":true}',
"--disable-slash-commands",
"--no-session-persistence",
"--permission-prompts",
@@ -582,7 +585,7 @@ test("spawns with the model alias, effort, bridge tools, MCP_TOOL_TIMEOUT and th
assert.equal(call.env.CLAUDE_CONFIG_DIR, process.env.CLAUDE_CONFIG_DIR);
});
test("spawns without Anthropic API credentials so turns stay on the subscription", async (t) => {
test("preserves existing CLI environment authentication without a subscription guarantee", async (t) => {
const saved = { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN };
t.after(() => {
for (const [name, value] of Object.entries(saved)) {
@@ -597,8 +600,23 @@ test("spawns without Anthropic API credentials so turns stay on the subscription
const { calls } = await runScenario(t, readFixtureLines("text.jsonl"));
const call = getOnlyCall(calls);
assert.equal(call.env.ANTHROPIC_API_KEY, undefined);
assert.equal(call.env.ANTHROPIC_AUTH_TOKEN, undefined);
assert.equal(call.env.ANTHROPIC_API_KEY, "sk-ant-test");
assert.equal(call.env.ANTHROPIC_AUTH_TOKEN, "token-test");
});
test("does not inject credentials reflected only from LearnHarness's key store", async (t) => {
const name = "ANTHROPIC_API_KEY";
const record = authEnvRecord();
const saved = process.env[name];
t.after(() => {
record.fillGap.delete(name);
if (saved === undefined) delete process.env[name]; else process.env[name] = saved;
});
process.env[name] = "lh-only-key";
record.fillGap.add(name);
const { calls } = await runScenario(t, readFixtureLines("text.jsonl"));
assert.equal(getOnlyCall(calls).env[name], undefined);
assert.equal(process.env[name], "lh-only-key", "other LearnHarness providers keep their env reflection");
});
test("writes the transcript as one stream-json user line on stdin", async (t) => {
@@ -911,13 +929,18 @@ test("a mismatched MCP identity cannot admit a streamed call", async (t) => {
await assertCleanedUp(getOnlyCall(calls));
});
for (const change of ["model", "effort", "system prompt", "tool schema", "tool description", "history"] as const) {
for (const change of ["model", "effort", "system prompt", "tool schema", "tool description", "history", "authentication environment"] as const) {
test(`a changed ${change} prevents reuse of a held process`, async (t) => {
const read: ToolUse = { id: `toolu_reuse_${change}`, name: "read", arguments: { path: "a" } };
const waiting = createFakeRun(t, [...toToolUseMessage([read]), callTool(read), REPLY]);
const fresh = createFakeRun(t, readFixtureLines("text.jsonl"));
const stream = createClaudeCodeStreamFunction((args, options) => (waiting.calls.length === 0 ? waiting : fresh).runClaude(args, options));
const message = await runTurn(stream, [HI]);
if (change === "authentication environment") {
const saved = process.env.ANTHROPIC_AUTH_TOKEN;
t.after(() => { if (saved === undefined) delete process.env.ANTHROPIC_AUTH_TOKEN; else process.env.ANTHROPIC_AUTH_TOKEN = saved; });
process.env.ANTHROPIC_AUTH_TOKEN = "changed-cli-auth";
}
const messages = [change === "history" ? { ...HI, content: "replacement history after compaction" } : HI, message, toToolResult(read, "A")];
const context = normalizeContext({
systemPrompt: change === "system prompt" ? "Changed tutor instructions" : "You are a tutor.",
+5
View File
@@ -17,6 +17,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, describe, it } from "node:test";
import { externalCliEnv } from "../src/core/auth-env.ts";
// THIS PROCESS'S ENV IS THE TEST SUBJECT — strip any inherited catalog vars
// (a developer shell may export REAL keys; tests use throwaway values only,
@@ -72,10 +73,12 @@ describe("boot fill-gap + precedence", () => {
assert.ok(row.shadowedByEnv, "file value differs from the env value");
assert.equal(row.masked, `…${ENV_OWNED.slice(-4)} (${ENV_OWNED.length} chars)`);
assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-filefilefile", "file copy untouched");
assert.equal(externalCliEnv().OPENROUTER_API_KEY, ENV_OWNED, "external CLIs retain launch-env ownership");
});
it("unknown file var: fill-gapped into env, surfaced as a generic row", () => {
assert.equal(process.env.FUTURE_PLANS_TOKEN, "opaque-plans-token", "reflected at boot");
assert.equal(externalCliEnv().FUTURE_PLANS_TOKEN, undefined, "external CLIs exclude unknown file-only injections too");
const row = keyStatuses().find((s) => s.id === "FUTURE_PLANS_TOKEN")!;
assert.equal(row.info, null, "no curated form");
assert.equal(row.source, "file");
@@ -103,6 +106,7 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => {
const r = saveKey("DEEPGRAM_API_KEY", value);
assert.ok(r.ok);
assert.equal(process.env.DEEPGRAM_API_KEY, value, "env updated immediately");
assert.equal(externalCliEnv().DEEPGRAM_API_KEY, undefined);
assert.equal(authStore.authVars().DEEPGRAM_API_KEY, value, "file updated");
assert.equal(r.status.source, "file", "the reflected copy is the store's — the file is its origin of truth");
assert.ok(!r.status.shadowedByEnv);
@@ -112,6 +116,7 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => {
const r = saveKey("OPENROUTER_API_KEY", "sk-or-v1-newvalue12345");
assert.ok(r.ok);
assert.equal(process.env.OPENROUTER_API_KEY, ENV_OWNED, "real env untouched");
assert.equal(externalCliEnv().OPENROUTER_API_KEY, ENV_OWNED, "saving a shadowed key cannot remove real env ownership");
assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-newvalue12345", "file updated for later");
assert.ok(r.status.shadowedByEnv);
});