fix: preserve native Claude CLI authentication provenance
This commit is contained in:
@@ -7,7 +7,8 @@ const SHARED_MODEL_FIELDS = {
|
|||||||
provider: CLAUDE_CODE_PROVIDER,
|
provider: CLAUDE_CODE_PROVIDER,
|
||||||
baseUrl: "",
|
baseUrl: "",
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
// Billed to the user's Claude subscription, not per token.
|
// Placeholder rates, not a billing guarantee: the CLI's own auth/routing
|
||||||
|
// determines charges. Cost presentation remains a separate product decision.
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
// The CLI has no thinking-off switch; effort low is the floor.
|
// The CLI has no thinking-off switch; effort low is the floor.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messages";
|
import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messages";
|
||||||
import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse";
|
import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse";
|
||||||
|
|
||||||
|
import { externalCliEnv } from "../../core/auth-env.ts";
|
||||||
import { getClaudeBinary } from "./binary.ts";
|
import { getClaudeBinary } from "./binary.ts";
|
||||||
import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts";
|
import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts";
|
||||||
import { ClaudeCodeError } from "./constants.ts";
|
import { ClaudeCodeError } from "./constants.ts";
|
||||||
@@ -99,7 +100,9 @@ export function toClaudeArgs(input: ClaudeArgsInput): string[] {
|
|||||||
"--mcp-config",
|
"--mcp-config",
|
||||||
input.mcpConfigFile,
|
input.mcpConfigFile,
|
||||||
"--setting-sources",
|
"--setting-sources",
|
||||||
"",
|
"user",
|
||||||
|
"--settings",
|
||||||
|
JSON.stringify({ disableAllHooks: true }),
|
||||||
"--disable-slash-commands",
|
"--disable-slash-commands",
|
||||||
"--no-session-persistence",
|
"--no-session-persistence",
|
||||||
"--permission-prompts",
|
"--permission-prompts",
|
||||||
@@ -588,16 +591,10 @@ function continueLiveProcess(turn: ClaudeTurn, owner: symbol, sessionId: string
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Either one takes precedence over the logged-in subscription, so a key saved on the Keys page
|
function toClaudeEnv(): NodeJS.ProcessEnv {
|
||||||
// would bill turns to the API account.
|
// Preserve native CLI auth/routing, including launch-environment credentials.
|
||||||
const API_CREDENTIAL_ENV_NAMES = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
|
// Claude Code chooses the account and billing method, not LearnHarness.
|
||||||
|
return { ...externalCliEnv(), MCP_TOOL_TIMEOUT: String(MCP_TOOL_TIMEOUT_MS) };
|
||||||
function toClaudeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
||||||
const claudeEnv: NodeJS.ProcessEnv = { ...env, MCP_TOOL_TIMEOUT: String(MCP_TOOL_TIMEOUT_MS) };
|
|
||||||
|
|
||||||
for (const name of API_CREDENTIAL_ENV_NAMES) delete claudeEnv[name];
|
|
||||||
|
|
||||||
return claudeEnv;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startProcess(
|
async function startProcess(
|
||||||
@@ -608,6 +605,7 @@ async function startProcess(
|
|||||||
options: SimpleStreamOptions | undefined,
|
options: SimpleStreamOptions | undefined,
|
||||||
owner: symbol,
|
owner: symbol,
|
||||||
configuration: unknown,
|
configuration: unknown,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (options?.signal?.aborted) { turn.fail(ABORT_MESSAGE, "aborted"); return; }
|
if (options?.signal?.aborted) { turn.fail(ABORT_MESSAGE, "aborted"); return; }
|
||||||
const input = toClaudeInput(context.messages);
|
const input = toClaudeInput(context.messages);
|
||||||
@@ -639,7 +637,7 @@ async function startProcess(
|
|||||||
mcpConfigFile: files.mcpConfigFile,
|
mcpConfigFile: files.mcpConfigFile,
|
||||||
allowedTools: bridge.allowedTools,
|
allowedTools: bridge.allowedTools,
|
||||||
});
|
});
|
||||||
child = runClaude(args, { cwd: files.cwd, env: toClaudeEnv(process.env) });
|
child = runClaude(args, { cwd: files.cwd, env });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await removeTurnResources(bridge, files);
|
await removeTurnResources(bridge, files);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -661,10 +659,12 @@ export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunc
|
|||||||
const stream = createAssistantMessageEventStream();
|
const stream = createAssistantMessageEventStream();
|
||||||
const turn = new ClaudeTurn(stream, model);
|
const turn = new ClaudeTurn(stream, model);
|
||||||
|
|
||||||
const configuration = configurationForReuse(model, context, toEffort(options?.reasoning));
|
const env = toClaudeEnv();
|
||||||
|
// An existing child must not retain stale authentication/routing after env changes.
|
||||||
|
const configuration = { request: configurationForReuse(model, context, toEffort(options?.reasoning)), env };
|
||||||
if (continueLiveProcess(turn, owner, options?.sessionId, configuration, context.messages, options?.signal)) return stream;
|
if (continueLiveProcess(turn, owner, options?.sessionId, configuration, context.messages, options?.signal)) return stream;
|
||||||
|
|
||||||
startProcess(runClaude, turn, model, context, options, owner, configuration).catch((error: unknown) => {
|
startProcess(runClaude, turn, model, context, options, owner, configuration, env).catch((error: unknown) => {
|
||||||
console.error("claude CLI turn failed:", error);
|
console.error("claude CLI turn failed:", error);
|
||||||
turn.fail(START_FAILED_MESSAGE, "error");
|
turn.fail(START_FAILED_MESSAGE, "error");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** Shared provenance only: no credential-store reads in the agent layer. */
|
||||||
|
interface AuthBoot {
|
||||||
|
/** Names owned by the original launch environment. */
|
||||||
|
realEnv: Set<string>;
|
||||||
|
/** Names reflected into process.env by LearnHarness's key store. */
|
||||||
|
fillGap: Set<string>;
|
||||||
|
}
|
||||||
|
const BOOT_KEY = Symbol.for("lh.authBoot");
|
||||||
|
|
||||||
|
export function authEnvRecord(): AuthBoot {
|
||||||
|
const g = globalThis as Record<symbol, AuthBoot | undefined>;
|
||||||
|
if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() };
|
||||||
|
return g[BOOT_KEY];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** External CLIs own their authentication. Never pass LearnHarness-only injections. */
|
||||||
|
export function externalCliEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||||
|
const result = { ...env };
|
||||||
|
const { realEnv, fillGap } = authEnvRecord();
|
||||||
|
for (const name of fillGap) {
|
||||||
|
if (!realEnv.has(name)) delete result[name];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
+3
-17
@@ -54,23 +54,9 @@ import {
|
|||||||
import { authStore } from "../store/auth.ts";
|
import { authStore } from "../store/auth.ts";
|
||||||
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
|
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
|
||||||
|
|
||||||
// The once-per-process boot record — globalThis anchor (HMR re-imports the
|
// Shared HMR-stable provenance lets external CLIs exclude file-only injections
|
||||||
// module; the snapshot must not re-run and misread fill-gapped vars as real
|
// without importing this store-backed module into the agent layer.
|
||||||
// env). Same pattern as globalThis.__lhExtractionRuns.
|
import { authEnvRecord as boot } from "../core/auth-env.ts";
|
||||||
interface AuthBoot {
|
|
||||||
/** Vars that were in the REAL env at first init — env precedence's
|
|
||||||
* untouchable set. Fill-gapped vars are everything else we set. */
|
|
||||||
realEnv: Set<string>;
|
|
||||||
/** Vars WE put into process.env (the file's reflection) — deletable. */
|
|
||||||
fillGap: Set<string>;
|
|
||||||
}
|
|
||||||
const BOOT_KEY = Symbol.for("lh.authBoot");
|
|
||||||
|
|
||||||
function boot(): AuthBoot {
|
|
||||||
const g = globalThis as Record<symbol, AuthBoot | undefined>;
|
|
||||||
if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() };
|
|
||||||
return g[BOOT_KEY];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Is `id` a WRITABLE key name — curated catalog or model-provider env
|
/** Is `id` a WRITABLE key name — curated catalog or model-provider env
|
||||||
* var (provider-keys interlude: PUT/DELETE accept both). */
|
* var (provider-keys interlude: PUT/DELETE accept both). */
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { parseCliLine } from "../src/agent/claude-code/cli-events.ts";
|
|||||||
import { ClaudeCodeError } from "../src/agent/claude-code/constants.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 { 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 { 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 { createClaudeCodeStreamFunction, toClaudeArgs, toEffort, type RunClaude } from "../src/agent/claude-code/stream.ts";
|
||||||
import { toStreamJsonLine } from "../src/agent/claude-code/transcript.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",
|
"--mcp-config",
|
||||||
"/private/mcp.json",
|
"/private/mcp.json",
|
||||||
"--setting-sources",
|
"--setting-sources",
|
||||||
"",
|
"user",
|
||||||
|
"--settings",
|
||||||
|
'{"disableAllHooks":true}',
|
||||||
"--disable-slash-commands",
|
"--disable-slash-commands",
|
||||||
"--no-session-persistence",
|
"--no-session-persistence",
|
||||||
"--permission-prompts",
|
"--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);
|
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 };
|
const saved = { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN };
|
||||||
t.after(() => {
|
t.after(() => {
|
||||||
for (const [name, value] of Object.entries(saved)) {
|
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 { calls } = await runScenario(t, readFixtureLines("text.jsonl"));
|
||||||
const call = getOnlyCall(calls);
|
const call = getOnlyCall(calls);
|
||||||
|
|
||||||
assert.equal(call.env.ANTHROPIC_API_KEY, undefined);
|
assert.equal(call.env.ANTHROPIC_API_KEY, "sk-ant-test");
|
||||||
assert.equal(call.env.ANTHROPIC_AUTH_TOKEN, undefined);
|
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) => {
|
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));
|
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) => {
|
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 read: ToolUse = { id: `toolu_reuse_${change}`, name: "read", arguments: { path: "a" } };
|
||||||
const waiting = createFakeRun(t, [...toToolUseMessage([read]), callTool(read), REPLY]);
|
const waiting = createFakeRun(t, [...toToolUseMessage([read]), callTool(read), REPLY]);
|
||||||
const fresh = createFakeRun(t, readFixtureLines("text.jsonl"));
|
const fresh = createFakeRun(t, readFixtureLines("text.jsonl"));
|
||||||
const stream = createClaudeCodeStreamFunction((args, options) => (waiting.calls.length === 0 ? waiting : fresh).runClaude(args, options));
|
const stream = createClaudeCodeStreamFunction((args, options) => (waiting.calls.length === 0 ? waiting : fresh).runClaude(args, options));
|
||||||
const message = await runTurn(stream, [HI]);
|
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 messages = [change === "history" ? { ...HI, content: "replacement history after compaction" } : HI, message, toToolResult(read, "A")];
|
||||||
const context = normalizeContext({
|
const context = normalizeContext({
|
||||||
systemPrompt: change === "system prompt" ? "Changed tutor instructions" : "You are a tutor.",
|
systemPrompt: change === "system prompt" ? "Changed tutor instructions" : "You are a tutor.",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import * as fs from "node:fs";
|
|||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { after, describe, it } from "node:test";
|
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
|
// 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,
|
// (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.ok(row.shadowedByEnv, "file value differs from the env value");
|
||||||
assert.equal(row.masked, `…${ENV_OWNED.slice(-4)} (${ENV_OWNED.length} chars)`);
|
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(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", () => {
|
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(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")!;
|
const row = keyStatuses().find((s) => s.id === "FUTURE_PLANS_TOKEN")!;
|
||||||
assert.equal(row.info, null, "no curated form");
|
assert.equal(row.info, null, "no curated form");
|
||||||
assert.equal(row.source, "file");
|
assert.equal(row.source, "file");
|
||||||
@@ -103,6 +106,7 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => {
|
|||||||
const r = saveKey("DEEPGRAM_API_KEY", value);
|
const r = saveKey("DEEPGRAM_API_KEY", value);
|
||||||
assert.ok(r.ok);
|
assert.ok(r.ok);
|
||||||
assert.equal(process.env.DEEPGRAM_API_KEY, value, "env updated immediately");
|
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(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.equal(r.status.source, "file", "the reflected copy is the store's — the file is its origin of truth");
|
||||||
assert.ok(!r.status.shadowedByEnv);
|
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");
|
const r = saveKey("OPENROUTER_API_KEY", "sk-or-v1-newvalue12345");
|
||||||
assert.ok(r.ok);
|
assert.ok(r.ok);
|
||||||
assert.equal(process.env.OPENROUTER_API_KEY, ENV_OWNED, "real env untouched");
|
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.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-newvalue12345", "file updated for later");
|
||||||
assert.ok(r.status.shadowedByEnv);
|
assert.ok(r.status.shadowedByEnv);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user