fix: preserve native Claude CLI authentication provenance
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<void> {
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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 { 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<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];
|
||||
}
|
||||
// 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). */
|
||||
|
||||
Reference in New Issue
Block a user