Public: anyone can read this; the crew can change it.

Confine ANTHROPIC_* and CLAUDE_CODE_* env construction to the runner module #12293

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/12279-confine-anthropic-and-claude-code-env-co → main 10 commits fork

  • Merged. torque/12279-confine-anthropic-and-claude-code-env-co was merged into main as 5595b9f.

Review

Sign in to review this change.
9 files changed +164 −78
src/core/subscriptions.ts +5 −41
@@ -28,6 +28,7 @@ import type { Database } from "bun:sqlite";
2828 import { id } from "../kernel/id.ts";
2929 import { err, ok, type Result } from "../kernel/result.ts";
3030 import { open, seal } from "../kernel/secret.ts";
31 import { accountEnv, envNameOf, hasEnvCredential } from "../crew/runner_env.ts";
3132 import { limitValue } from "../settings/limits.ts";
3233
3334 export type SubscriptionKind = "oauth" | "api_key";
@@ -192,10 +193,6 @@ const aad = (subscriptionId: string): string =>
192193 /** What a sealed account's token is bound to, for opening it elsewhere. */
193194 export const subscriptionAad = aad;
194195
195 /** The variable Claude Code reads a token of this kind from. */
196 export const envNameOf = (kind: SubscriptionKind): string =>
197 kind === "oauth" ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY";
198
199196 /** Longest accepted Anthropic-compatible base URL. */
200197 export const API_BASE_MAX = 200;
201198
@@ -253,45 +250,14 @@ export function parseApiBase(raw: string): string | undefined {
253250 : `https://${url.host}${path}`;
254251 }
255252
256 /** Hostname a run must be allowed to reach for this base URL, if any. */
257 export function hostOfApiBase(apiBase: string | null | undefined): string | undefined {
258 if (apiBase === null || apiBase === undefined || apiBase.trim() === "")
259 return undefined;
260 try {
261 return new URL(apiBase).hostname;
262 } catch {
263 return undefined;
264 }
265 }
266
267253 /**
268 * Environment variables a crew run needs for a picked account: the token
269 * under the variable Claude Code reads, plus `ANTHROPIC_BASE_URL` (and
270 * OpenRouter's `ANTHROPIC_AUTH_TOKEN` dance) when the account has a gateway.
254 * Environment variables a crew run needs for a picked account; the names
255 * live in `crew/runner_env.ts`.
271256 */
272257 export function credentialEnvOf(
273258 picked: Extract<Pick, { kind: "ready" }>,
274259 ): Record<string, string> {
275 const base = picked.apiBase;
276 if (base === null || base === undefined || base === "") {
277 return { [picked.envName]: picked.token };
278 }
279 const host = hostOfApiBase(base) ?? "";
280 const openrouter =
281 host === "openrouter.ai" || host.endsWith(".openrouter.ai");
282 if (openrouter) {
283 // Claude Code + OpenRouter: AUTH_TOKEN carries the key; API_KEY must be
284 // an empty string so Claude Code does not fall back to api.anthropic.com.
285 return {
286 ANTHROPIC_BASE_URL: base,
287 ANTHROPIC_AUTH_TOKEN: picked.token,
288 ANTHROPIC_API_KEY: "",
289 };
290 }
291 return {
292 ANTHROPIC_BASE_URL: base,
293 [picked.envName]: picked.token,
294 };
260 return accountEnv(picked);
295261 }
296262
297263 export function isSubscriptionName(name: string): boolean {
@@ -746,9 +712,7 @@ export function crewHasCredential(
746712 env: Record<string, string | undefined> = process.env,
747713 ): boolean {
748714 if (listSubscriptions(db).length > 0) return true;
749 return ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "FORGE_CLAUDE_CONFIG_DIR"].some(
750 (name) => (env[name] ?? "").trim() !== "",
751 );
715 return hasEnvCredential(env);
752716 }
753717
754718 /** What MCP `subscriptions` and `GET /api/v1/admin/subscriptions` list: each account, never its token. */
src/crew/claude.ts +2 −2
@@ -23,6 +23,7 @@ import { endedLine, liveTree } from "../runs/ended.ts";
2323 import { CANCEL_GRACE_MS } from "../runs/grace.ts";
2424 import { spellings, stripForgeDirs } from "../runs/paths.ts";
2525 import type { SandboxPolicy } from "./sandbox.ts";
26 import { quietEnv } from "./runner_env.ts";
2627
2728 import type {
2829 AgentOutcome,
@@ -155,8 +156,7 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> {
155156 Bun.spawn(present ? backgroundArgv(oomFirst(contained)) : argv, {
156157 cwd: input.cwd,
157158 env: childEnv({
158 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
159 DISABLE_TELEMETRY: "1",
159 ...quietEnv(),
160160 ...input.env,
161161 }),
162162 stdin: "pipe",
src/crew/plan.ts +3 −10
@@ -16,6 +16,7 @@
1616
1717 import { tmpdir } from "node:os";
1818 import { childEnv } from "../apps/process.ts";
19 import { oneShotEnv, quietEnv } from "./runner_env.ts";
1920 import { clip } from "../text/clip.ts";
2021
2122 export interface PlanAgent {
@@ -190,16 +191,8 @@ export async function plan(
190191 {
191192 cwd: tmpdir(),
192193 env: childEnv({
193 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
194 DISABLE_TELEMETRY: "1",
195 ...(process.env["ANTHROPIC_API_KEY"] === undefined
196 ? {}
197 : { ANTHROPIC_API_KEY: process.env["ANTHROPIC_API_KEY"] }),
198 ...(process.env["CLAUDE_CODE_OAUTH_TOKEN"] === undefined
199 ? {}
200 : {
201 CLAUDE_CODE_OAUTH_TOKEN: process.env["CLAUDE_CODE_OAUTH_TOKEN"],
202 }),
194 ...quietEnv(),
195 ...oneShotEnv(),
203196 }),
204197 stdin: "ignore",
205198 stdout: "pipe",
src/crew/runner.ts +4 −14
@@ -167,7 +167,6 @@ import { grantLeft } from "../core/model_grant.ts";
167167 import { dailyLeft, reserveCeiling } from "../core/account_ceiling.ts";
168168 import {
169169 credentialEnvOf,
170 hostOfApiBase,
171170 MIN_RUN_USD,
172171 pickSubscription,
173172 PLATFORM,
@@ -201,6 +200,7 @@ import {
201200 skipsFeedback,
202201 } from "./guard.ts";
203202 import { passportEnv } from "./passport_env.ts";
203 import { hostOfApiBase, isGatewayAddress, ownEnv, sandboxTmpEnv } from "./runner_env.ts";
204204 import { cappedByLent, fundedEnv, lentCreditLeft, runEnded } from "../spawn/funding.ts";
205205
206206 const log = createLog("crew");
@@ -1522,7 +1522,7 @@ async function work(
15221522 runTmp = sandbox.tmpDir;
15231523 if (runTmp !== undefined) {
15241524 mkdirSync(runTmp, { recursive: true });
1525 env["CLAUDE_CODE_TMPDIR"] = runTmp;
1525 Object.assign(env, sandboxTmpEnv(runTmp));
15261526 env["TMPDIR"] = runTmp;
15271527 }
15281528 // Bun's install cache is the repository's, shared by its runs, so a
@@ -2425,7 +2425,7 @@ function redactResult<T extends { result: string }>(r: T): T {
24252425 */
24262426 export function holdModelCredential(key: string, env: Record<string, string>): string[] {
24272427 const secrets = Object.entries(env)
2428 .filter(([name, value]) => name !== "ANTHROPIC_BASE_URL" && value.length >= 4)
2428 .filter(([name, value]) => !isGatewayAddress(name) && value.length >= 4)
24292429 .map(([, value]) => value);
24302430 holdValues(key, secrets);
24312431 return secrets;
@@ -2443,15 +2443,5 @@ export function credentialEnv(picked: Pick, dataDir?: string): Record<string, st
24432443 // "none" reaches here only under an owner's grant (an org's, #10991), which is the forge's account too.
24442444 const lent = (picked.kind === "env" || picked.kind === "none") && dataDir !== undefined ? fundedEnv(dataDir) : undefined;
24452445 if (lent !== undefined) return lent;
2446 const own: Record<string, string> = {};
2447 for (const name of [
2448 "ANTHROPIC_API_KEY",
2449 "CLAUDE_CODE_OAUTH_TOKEN",
2450 "ANTHROPIC_BASE_URL",
2451 "ANTHROPIC_AUTH_TOKEN",
2452 ]) {
2453 const value = process.env[name];
2454 if (value !== undefined) own[name] = value;
2455 }
2456 return own;
2446 return ownEnv();
24572447 }
src/crew/runner_env.ts added +108
@@ -0,0 +1,108 @@
1 /**
2 * Every environment variable a crew run's model reads, named in one place.
3 * Claude Code takes its credential and gateway from `ANTHROPIC_*` and
4 * `CLAUDE_CODE_*`; no other module spells those names. Plain data in, plain
5 * data out: nothing here reads the process environment on its own account.
6 */
7 import type { SubscriptionKind } from "../core/subscriptions.ts";
8
9 type Env = Record<string, string | undefined>;
10
11 const DEFAULT_BASE = "https://api.anthropic.com";
12
13 /** The variable Claude Code reads a token of this kind from. */
14 export const envNameOf = (kind: SubscriptionKind): string =>
15 kind === "oauth" ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY";
16
17 /** Hostname a run must be allowed to reach for this base URL, if any. */
18 export function hostOfApiBase(apiBase: string | null | undefined): string | undefined {
19 if (apiBase === null || apiBase === undefined || apiBase.trim() === "") return undefined;
20 try {
21 return new URL(apiBase).hostname;
22 } catch {
23 return undefined;
24 }
25 }
26
27 /**
28 * Variables for a picked account: the token under the variable Claude Code
29 * reads, plus the base URL (and OpenRouter's auth-token dance) when the
30 * account has a gateway.
31 */
32 export function accountEnv(a: {
33 readonly envName: string;
34 readonly token: string;
35 readonly apiBase?: string | null | undefined;
36 }): Record<string, string> {
37 const base = a.apiBase;
38 if (base === null || base === undefined || base === "") return { [a.envName]: a.token };
39 const host = hostOfApiBase(base) ?? "";
40 if (host === "openrouter.ai" || host.endsWith(".openrouter.ai")) {
41 // AUTH_TOKEN carries the key; API_KEY must be empty so Claude Code does
42 // not fall back to api.anthropic.com.
43 return { ANTHROPIC_BASE_URL: base, ANTHROPIC_AUTH_TOKEN: a.token, ANTHROPIC_API_KEY: "" };
44 }
45 return { ANTHROPIC_BASE_URL: base, [a.envName]: a.token };
46 }
47
48 /**
49 * A forge's own credential as it was started with, whole: what a run with
50 * no account of its own carries.
51 */
52 export function ownEnv(env: Env = process.env): Record<string, string> {
53 const own: Record<string, string> = {};
54 for (const name of ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"]) {
55 const value = env[name];
56 if (value !== undefined) own[name] = value;
57 }
58 return own;
59 }
60
61 /** Whether the environment holds a model token or a signed-in Claude config. */
62 export function hasEnvCredential(env: Env): boolean {
63 return ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "FORGE_CLAUDE_CONFIG_DIR"].some(
64 (name) => (env[name] ?? "").trim() !== "",
65 );
66 }
67
68 /** Points a run at a parent's door, paying with the credit token. */
69 export function lentEnv(door: string, token: string): Record<string, string> {
70 return { ANTHROPIC_BASE_URL: door, ANTHROPIC_API_KEY: token };
71 }
72
73 /** A variable that holds an address, not a secret: left readable in a run's log. */
74 export const isGatewayAddress = (name: string): boolean => name === "ANTHROPIC_BASE_URL";
75
76 /** The two variables that quiet Claude Code's background traffic. */
77 export const quietEnv = (): Record<string, string> => ({
78 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
79 DISABLE_TELEMETRY: "1",
80 });
81
82 /** Where Claude Code's sandbox keeps its temp files. */
83 export const sandboxTmpEnv = (dir: string): Record<string, string> => ({ CLAUDE_CODE_TMPDIR: dir });
84
85 /** The credential a one-shot call carries: the key or token the forge holds, nothing else. */
86 export function oneShotEnv(env: Env = process.env): Record<string, string> {
87 const out: Record<string, string> = {};
88 for (const name of ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]) {
89 const value = env[name];
90 if (value !== undefined) out[name] = value;
91 }
92 return out;
93 }
94
95 /** Where to send a model call made with the forge's own credential, and with which headers. */
96 export function upstreamOf(env: Env = process.env):
97 | { readonly base: string; readonly headers: Record<string, string> }
98 | undefined {
99 const base = (env["ANTHROPIC_BASE_URL"] ?? DEFAULT_BASE).replace(/\/+$/, "");
100 const key = env["ANTHROPIC_API_KEY"];
101 const auth = env["ANTHROPIC_AUTH_TOKEN"];
102 const oauth = env["CLAUDE_CODE_OAUTH_TOKEN"];
103 if (key !== undefined && key !== "") return { base, headers: { "x-api-key": key } };
104 if (auth !== undefined && auth !== "") return { base, headers: { authorization: `Bearer ${auth}` } };
105 if (oauth !== undefined && oauth !== "")
106 return { base, headers: { authorization: `Bearer ${oauth}`, "anthropic-beta": "oauth-2025-04-20" } };
107 return undefined;
108 }
src/spawn/funding.ts +2 −1
@@ -11,6 +11,7 @@
1111 import { join } from "node:path";
1212 import type { App } from "../app.ts";
1313 import { readPrivate, writePrivate } from "../kernel/file_modes.ts";
14 import { lentEnv } from "../crew/runner_env.ts";
1415 import { leftUsd, usdWords } from "./credit.ts";
1516 import { isParentLocal } from "./beat-door.ts";
1617
@@ -66,7 +67,7 @@ export function fundedEnv(dataDir: string): Record<string, string> | undefined {
6667 const local = process.env["FORGE_PARENT_LOCAL"] ?? "";
6768 const f = fundingOf(dataDir);
6869 if (f === undefined || !isParentLocal(local)) return undefined;
69 return { ANTHROPIC_BASE_URL: local, ANTHROPIC_API_KEY: f.token };
70 return lentEnv(local, f.token);
7071 }
7172
7273 type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
src/spawn/model_door.ts +2 −9
@@ -15,6 +15,7 @@
1515 import type { App } from "../app.ts";
1616 import { addSpend, creditOfToken, leftUsd, readCredit, spentWords, type Credit } from "./credit.ts";
1717 import { listPriceUsd, NO_USAGE, type Usage } from "./list_price.ts";
18 import { upstreamOf } from "../crew/runner_env.ts";
1819 import { readCapped } from "./bounded_read.ts";
1920 import { enter, MAX_IN_FLIGHT, noteBad } from "./door_guard.ts";
2021
@@ -33,15 +34,7 @@ export interface ModelDeps {
3334
3435 /** The parent's own credential from its environment, never from a request. */
3536 export function ownUpstream(): ModelUpstream | undefined {
36 const base = (process.env["ANTHROPIC_BASE_URL"] ?? "https://api.anthropic.com").replace(/\/+$/, "");
37 const key = process.env["ANTHROPIC_API_KEY"];
38 const auth = process.env["ANTHROPIC_AUTH_TOKEN"];
39 const oauth = process.env["CLAUDE_CODE_OAUTH_TOKEN"];
40 if (key !== undefined && key !== "") return { base, headers: { "x-api-key": key } };
41 if (auth !== undefined && auth !== "") return { base, headers: { authorization: `Bearer ${auth}` } };
42 if (oauth !== undefined && oauth !== "")
43 return { base, headers: { authorization: `Bearer ${oauth}`, "anthropic-beta": "oauth-2025-04-20" } };
44 return undefined;
37 return upstreamOf();
4538 }
4639
4740 export const MODEL_BODY_MAX = 16 * 1024 * 1024;
test/runner-env-confined.test.ts added +37
@@ -0,0 +1,37 @@
1 import { expect, test } from "bun:test";
2 import { Glob } from "bun";
3 import { accountEnv, hasEnvCredential, lentEnv, oneShotEnv, ownEnv, upstreamOf } from "../src/crew/runner_env.ts";
4
5 test("only runner_env.ts names a vendor variable in src", async () => {
6 const re = /\b(ANTHROPIC_[A-Z_]+|CLAUDE_CODE_[A-Z_]+)\b/;
7 const allowed = new Set(["src/crew/runner_env.ts", "src/packs/up.ts" /* a sentence telling a person which variable to set */]);
8 const bad: string[] = [];
9 for await (const f of new Glob("src/**/*.ts").scan(".")) {
10 if (allowed.has(f)) continue;
11 const code = (await Bun.file(f).text())
12 .split("\n")
13 .filter((l) => !/^\s*(\/\/|\/?\*)/.test(l) && re.test(l));
14 if (code.length > 0) bad.push(`${f}: ${code[0]!.trim()}`);
15 }
16 expect(bad).toEqual([]);
17 });
18
19 test("runner_env builds each environment", () => {
20 expect(accountEnv({ envName: "ANTHROPIC_API_KEY", token: "t" })).toEqual({ ANTHROPIC_API_KEY: "t" });
21 expect(accountEnv({ envName: "ANTHROPIC_API_KEY", token: "t", apiBase: "https://openrouter.ai/api" })).toEqual({
22 ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
23 ANTHROPIC_AUTH_TOKEN: "t",
24 ANTHROPIC_API_KEY: "",
25 });
26 expect(lentEnv("http://x", "k")).toEqual({ ANTHROPIC_BASE_URL: "http://x", ANTHROPIC_API_KEY: "k" });
27 expect(ownEnv({ ANTHROPIC_API_KEY: "a", PATH: "/bin" })).toEqual({ ANTHROPIC_API_KEY: "a" });
28 expect(oneShotEnv({ CLAUDE_CODE_OAUTH_TOKEN: "o", ANTHROPIC_BASE_URL: "u" })).toEqual({ CLAUDE_CODE_OAUTH_TOKEN: "o" });
29 expect(hasEnvCredential({})).toBe(false);
30 expect(hasEnvCredential({ FORGE_CLAUDE_CONFIG_DIR: "/x" })).toBe(true);
31 expect(upstreamOf({})).toBeUndefined();
32 expect(upstreamOf({ ANTHROPIC_API_KEY: "k", ANTHROPIC_BASE_URL: "https://g/" })).toEqual({
33 base: "https://g",
34 headers: { "x-api-key": "k" },
35 });
36 expect(upstreamOf({ CLAUDE_CODE_OAUTH_TOKEN: "o" })?.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
37 });
test/subscriptions.test.ts +1 −1
@@ -17,7 +17,6 @@ import {
1717 deleteSubscription,
1818 EVERYONE,
1919 getSubscription,
20 hostOfApiBase,
2120 kindOfToken,
2221 listGrants,
2322 listSubscriptions,
@@ -28,6 +27,7 @@ import {
2827 setGrants,
2928 wakeSubscription,
3029 } from "../src/core/subscriptions.ts";
30 import { hostOfApiBase } from "../src/crew/runner_env.ts";
3131 import { createUser, type User } from "../src/core/users.ts";
3232 import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts";
3333 import { isRateLimited, resumeAt } from "../src/crew/limits.ts";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs