diff --git a/src/agent/claude-code/provider.ts b/src/agent/claude-code/provider.ts index c190230..aca199b 100644 --- a/src/agent/claude-code/provider.ts +++ b/src/agent/claude-code/provider.ts @@ -7,7 +7,8 @@ const SHARED_MODEL_FIELDS = { provider: CLAUDE_CODE_PROVIDER, baseUrl: "", 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 }, reasoning: true, // The CLI has no thinking-off switch; effort low is the floor. diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts index 8c9c89a..4eda283 100644 --- a/src/agent/claude-code/stream.ts +++ b/src/agent/claude-code/stream.ts @@ -23,6 +23,7 @@ import { import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messages"; import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse"; +import { externalCliEnv } from "../../core/auth-env.ts"; import { getClaudeBinary } from "./binary.ts"; import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts"; import { ClaudeCodeError } from "./constants.ts"; @@ -99,7 +100,9 @@ export function toClaudeArgs(input: ClaudeArgsInput): string[] { "--mcp-config", input.mcpConfigFile, "--setting-sources", - "", + "user", + "--settings", + JSON.stringify({ disableAllHooks: true }), "--disable-slash-commands", "--no-session-persistence", "--permission-prompts", @@ -588,16 +591,10 @@ function continueLiveProcess(turn: ClaudeTurn, owner: symbol, sessionId: string return true; } -// Either one takes precedence over the logged-in subscription, so a key saved on the Keys page -// would bill turns to the API account. -const API_CREDENTIAL_ENV_NAMES = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]; - -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; +function toClaudeEnv(): NodeJS.ProcessEnv { + // Preserve native CLI auth/routing, including launch-environment credentials. + // Claude Code chooses the account and billing method, not LearnHarness. + return { ...externalCliEnv(), MCP_TOOL_TIMEOUT: String(MCP_TOOL_TIMEOUT_MS) }; } async function startProcess( @@ -608,6 +605,7 @@ async function startProcess( options: SimpleStreamOptions | undefined, owner: symbol, configuration: unknown, + env: NodeJS.ProcessEnv, ): Promise { if (options?.signal?.aborted) { turn.fail(ABORT_MESSAGE, "aborted"); return; } const input = toClaudeInput(context.messages); @@ -639,7 +637,7 @@ async function startProcess( mcpConfigFile: files.mcpConfigFile, allowedTools: bridge.allowedTools, }); - child = runClaude(args, { cwd: files.cwd, env: toClaudeEnv(process.env) }); + child = runClaude(args, { cwd: files.cwd, env }); } catch (error) { await removeTurnResources(bridge, files); throw error; @@ -661,10 +659,12 @@ export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunc const stream = createAssistantMessageEventStream(); 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; - 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); turn.fail(START_FAILED_MESSAGE, "error"); }); diff --git a/src/core/auth-env.ts b/src/core/auth-env.ts new file mode 100644 index 0000000..f2d8a65 --- /dev/null +++ b/src/core/auth-env.ts @@ -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; + /** Names reflected into process.env by LearnHarness's key store. */ + fillGap: Set; +} +const BOOT_KEY = Symbol.for("lh.authBoot"); + +export function authEnvRecord(): AuthBoot { + const g = globalThis as Record; + 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; +} diff --git a/src/server/keys.ts b/src/server/keys.ts index 706de52..7f65362 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -54,23 +54,9 @@ import { import { authStore } from "../store/auth.ts"; import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts"; -// The once-per-process boot record — globalThis anchor (HMR re-imports the -// module; the snapshot must not re-run and misread fill-gapped vars as real -// env). Same pattern as globalThis.__lhExtractionRuns. -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; - /** Vars WE put into process.env (the file's reflection) — deletable. */ - fillGap: Set; -} -const BOOT_KEY = Symbol.for("lh.authBoot"); - -function boot(): AuthBoot { - const g = globalThis as Record; - if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() }; - return g[BOOT_KEY]; -} +// Shared HMR-stable provenance lets external CLIs exclude file-only injections +// without importing this store-backed module into the agent layer. +import { authEnvRecord as boot } from "../core/auth-env.ts"; /** Is `id` a WRITABLE key name — curated catalog or model-provider env * var (provider-keys interlude: PUT/DELETE accept both). */ diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts index 0a61add..67885c7 100644 --- a/tests/claude-code-stream.test.ts +++ b/tests/claude-code-stream.test.ts @@ -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.", diff --git a/tests/server-keys.test.ts b/tests/server-keys.test.ts index 1ffd108..79e43e0 100644 --- a/tests/server-keys.test.ts +++ b/tests/server-keys.test.ts @@ -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); });