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

The forge's own env names and log lines are vendor-neutral, and the old names still read #12242

Merged opened this

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

Merged

mason/forge:mason/12127-the-forge-s-own-env-names-and-log-lines → main 10 commits fork

  • Merged. mason/12127-the-forge-s-own-env-names-and-log-lines was merged into main as eb43263.

Review

Sign in to review this change.
15 files changed +120 −32
docs/REFERENCE.md +4
@@ -603,3 +603,7 @@ A repository admin cuts a wave from two doors, both through `cutWaveFor` (`src/a
603603 Cmd-K finds a repository by its name first (exact, then prefix; your own ahead); a repository with an app has an "Open the app of …" option under it, and Cmd/Ctrl+Enter on the repository opens the app (`docs/features/cmd-k-repositories.md`).
604604
605605 In the terminal, the palette's **Your profile** (any signed-in person, never an agent; a guest is not offered it) shows username, display name and email; `n` sets the display name and `e` the email through `updateProfile`, the action behind Settings → Account. Refusals read as the page's do (`src/actions/profile_words.ts`, including one shared fallback for a refusal with no words of its own), and a changed email says a confirmation link was sent to it and that it is unverified until followed.
606
607 ## Agent runner settings
608
609 `FORGE_AGENT_RUNNER` names the agent runner's binary (default `claude` on `PATH`) and `FORGE_AGENT_CONFIG_DIR` the directory it keeps its sessions in. The older `FORGE_CLAUDE` and `FORGE_CLAUDE_CONFIG_DIR` are read too; when both are set the new name wins. The forge settles the old names to the chosen value at start, so the app planner and child forges follow it. The runner's own variables (`ANTHROPIC_*`, `CLAUDE_CODE_*`) are passed through untouched. The log says `crew is up runner=<binary> version=<v>`.
src/actions/apps.ts +2 −1
@@ -6,6 +6,7 @@
66 * the repository may look at it and open its environments.
77 */
88
9 import { runnerSettings } from "../crew/runner_env.ts";
910 import { getVariant } from "../core/variants.ts";
1011 import { pullDir } from "../core/pulls.ts";
1112 import { getIssue } from "../core/issues.ts";
@@ -426,7 +427,7 @@ export async function newApp(
426427 // costs at most `PLAN_TIMEOUT_MS` and never a wrong answer (crew/plan.ts):
427428 // the repository, the app and production above have already started.
428429 if (!agent && input.pickAgent && description !== "") {
429 const binary = process.env["FORGE_CLAUDE"] ?? "claude";
430 const binary = runnerSettings(process.env).binary;
430431 if (planningAvailable(binary)) {
431432 const roster = listAllAgents(app, user.value);
432433 const agents = roster.ok ? roster.value.filter((a) => a.roles.includes("builder")) : [];
src/core/subscriptions.ts +2 −2
@@ -28,7 +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";
31 import { accountEnv, envNameOf, runnerCredentialInEnv } from "../crew/runner_env.ts";
3232 import { limitValue } from "../settings/limits.ts";
3333
3434 export type SubscriptionKind = "oauth" | "api_key";
@@ -712,7 +712,7 @@ export function crewHasCredential(
712712 env: Record<string, string | undefined> = process.env,
713713 ): boolean {
714714 if (listSubscriptions(db).length > 0) return true;
715 return hasEnvCredential(env);
715 return runnerCredentialInEnv(env);
716716 }
717717
718718 /** What MCP `subscriptions` and `GET /api/v1/admin/subscriptions` list: each account, never its token. */
src/crew/conversation.ts +1 −1
@@ -146,7 +146,7 @@ export const turnDir = (dataDir: string): string => {
146146 };
147147
148148 /**
149 * Where Claude Code keeps conversation sessions: `FORGE_CLAUDE_CONFIG_DIR`
149 * Where Claude Code keeps conversation sessions: `FORGE_AGENT_CONFIG_DIR` (or the older `FORGE_CLAUDE_CONFIG_DIR`, read through `runnerSettings`)
150150 * when the operator named one, else a directory of its own under the data
151151 * dir, which outlives a release (the container's HOME does not). Owner-only:
152152 * it holds what the people said.
src/crew/dispatch.ts +5 −2
@@ -203,18 +203,21 @@ const handles = new WeakMap<App, CrewHandle>();
203203 /** The crew serving this forge, if one was started. */
204204 export const crewFor = (app: App): CrewHandle | undefined => handles.get(app);
205205
206 export { runnerSettings } from "./runner_env.ts";
207
206208 export async function startCrew(
207209 app: App,
208210 options: CrewOptions,
209211 ): Promise<CrewHandle> {
210212 const detected = await detectAgent(options.binary);
211213 if (!detected.ok)
212 log.warn("claude not usable; agent runs will fail", {
214 log.warn("agent runner not usable; agent runs will fail", {
213215 error: detected.error,
214216 });
215217 else
216218 log.info("crew is up", {
217 claude: detected.version,
219 runner: options.binary,
220 version: detected.version,
218221 concurrency: options.concurrency,
219222 });
220223 // The shell sandbox is policy, not decoration: where the host cannot
src/crew/runner_env.ts +28 −5
@@ -58,11 +58,34 @@ export function ownEnv(env: Env = process.env): Record<string, string> {
5858 return own;
5959 }
6060
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 );
61 /**
62 * The runner's own names: the forge's neutral `FORGE_AGENT_RUNNER` and
63 * `FORGE_AGENT_CONFIG_DIR`, with the older `FORGE_CLAUDE` and
64 * `FORGE_CLAUDE_CONFIG_DIR` read too; the new name wins. Nothing writes the
65 * environment: every reader calls `runnerSettings`.
66 */
67 export function runnerSettings(env: Env): { binary: string; configDir?: string } {
68 const pick = (a: string, b: string): string | undefined => {
69 const v = env[a];
70 return v !== undefined && v !== "" ? v : env[b] !== "" ? env[b] : undefined;
71 };
72 const configDir = pick("FORGE_AGENT_CONFIG_DIR", "FORGE_CLAUDE_CONFIG_DIR");
73 return {
74 binary: pick("FORGE_AGENT_RUNNER", "FORGE_CLAUDE") ?? "claude",
75 ...(configDir === undefined ? {} : { configDir }),
76 };
77 }
78
79 /** The runner binary only when the operator named one (a child inherits that choice). */
80 export function namedRunner(env: Env): string | undefined {
81 const named = (env["FORGE_AGENT_RUNNER"] ?? "") !== "" || (env["FORGE_CLAUDE"] ?? "") !== "";
82 return named ? runnerSettings(env).binary : undefined;
83 }
84
85 /** Whether the environment holds a model token or a signed-in runner config dir. */
86 export function runnerCredentialInEnv(env: Env): boolean {
87 if (["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"].some((n) => (env[n] ?? "").trim() !== "")) return true;
88 return (runnerSettings(env).configDir ?? "").trim() !== "";
6689 }
6790
6891 /** Points a run at a parent's door, paying with the credit token. */
src/main.ts +6 −7
@@ -80,7 +80,7 @@ import { settleLandings } from "./core/landing.ts";
8080 import { DRAIN_LINGER_MAX_S, drainLingerMs, startDraining } from "./http/health.ts";
8181 import { takePresence, warmBehind } from "./apps/presence.ts";
8282 import { STALE_AFTER_MS } from "./runs/lease.ts";
83 import { startCrew, sweepAssigned } from "./crew/dispatch.ts";
83 import { runnerSettings, startCrew, sweepAssigned } from "./crew/dispatch.ts";
8484 import { configureConversation, resumeInterrupted } from "./crew/conversation.ts";
8585 import { CREW_OFF_IN_PREVIEW, crewOffInPreview } from "./crew/preview.ts";
8686 import { typeSafeDecider } from "./crew/decide.ts";
@@ -595,6 +595,7 @@ const memorySampler = startMemorySampler(app);
595595 void offerStartingBoards(app)
596596 .then((slugs) => slugs.length > 0 && app.log.info("starting boards offered", { slugs: slugs.join(", ") }))
597597 .catch((error) => app.log.warn("starting boards not offered", { error: String(error) }));
598 const runner = runnerSettings(process.env);
598599 const crewHeldOff = crewOffInPreview();
599600 if (appsOn && crewHeldOff)
600601 app.log.info(CREW_OFF_IN_PREVIEW, {
@@ -604,7 +605,7 @@ const crew = appsOn && !crewHeldOff
604605 ? await startCrew(app, {
605606 forgeUrl,
606607 localUrl: servers.url,
607 binary: process.env.FORGE_CLAUDE ?? "claude",
608 binary: runner.binary,
608609 concurrency: positive(process.env.FORGE_CREW_CONCURRENCY, hosted ? 1 : Number(capacityDefault("crew.concurrency"))),
609610 budgetUsd: Number(process.env.FORGE_CREW_BUDGET_USD) || 5,
610611 runTimeoutMs: crewRunMs,
@@ -612,9 +613,7 @@ const crew = appsOn && !crewHeldOff
612613 .split(",")
613614 .map((h) => h.trim())
614615 .filter((h) => h !== ""),
615 ...(process.env.FORGE_CLAUDE_CONFIG_DIR === undefined
616 ? {}
617 : { configDir: process.env.FORGE_CLAUDE_CONFIG_DIR }),
616 ...(runner.configDir === undefined ? {} : { configDir: runner.configDir }),
618617 })
619618 : undefined;
620619 // A conversation turn the last process was in the middle of is resumed once
@@ -622,11 +621,11 @@ const crew = appsOn && !crewHeldOff
622621 const conversationsStop = new AbortController();
623622 if (crew !== undefined) {
624623 const conversation = {
625 binary: process.env.FORGE_CLAUDE ?? "claude",
624 binary: runner.binary,
626625 localUrl: servers.url,
627626 budgetUsd: Number(process.env.FORGE_CREW_BUDGET_USD) || 5,
628627 timeoutMs: crewRunMs,
629 ...(process.env.FORGE_CLAUDE_CONFIG_DIR === undefined ? {} : { configDir: process.env.FORGE_CLAUDE_CONFIG_DIR }),
628 ...(runner.configDir === undefined ? {} : { configDir: runner.configDir }),
630629 signal: conversationsStop.signal,
631630 };
632631 configureConversation(app, conversation);
src/spawn/cell.ts +4 −3
@@ -8,6 +8,7 @@
88 * (`spawn/placement.ts` `fallbackPlacement`), and the ledger says which.
99 */
1010
11 import { namedRunner } from "../crew/runner_env.ts";
1112 import { tmpdir } from "node:os";
1213 import { dirname, join } from "node:path";
1314 import { clientAddress } from "../http/peer.ts";
@@ -179,7 +180,7 @@ export function cellEnv(input: { port: number; dataDir: string; url: string; par
179180 ...(input.parentPort === undefined ? {} : { FORGE_PARENT_LOCAL: parentLocal(input.parentPort) }),
180181 // Which Claude Code its crew runs: the parent's own choice, a path and not
181182 // a secret, so a test can put a stand-in in the cell (#12001).
182 ...(input.claude === undefined || input.claude === "" ? {} : { FORGE_CLAUDE: input.claude }),
183 ...(input.claude === undefined || input.claude === "" ? {} : { FORGE_AGENT_RUNNER: input.claude }),
183184 });
184185 delete env["USER"];
185186 delete env["LOGNAME"];
@@ -187,12 +188,12 @@ export function cellEnv(input: { port: number; dataDir: string; url: string; par
187188 }
188189
189190 /**
190 * What a child is started with: `cellEnv`, plus the parent's `FORGE_CLAUDE`
191 * What a child is started with: `cellEnv`, plus the parent's runner (`FORGE_AGENT_RUNNER`, or the older `FORGE_CLAUDE`)
191192 * when it has one. The child's crew reads it (`main.ts`), so a parent run with
192193 * Claude Code somewhere off `PATH` has children that find it too.
193194 */
194195 export function childEnvFor(input: Parameters<typeof cellEnv>[0], parentEnv: Record<string, string | undefined> = process.env): Record<string, string> {
195 const claude = parentEnv["FORGE_CLAUDE"];
196 const claude = namedRunner(parentEnv);
196197 return cellEnv({ ...input, ...(claude === undefined ? {} : { claude }) });
197198 }
198199
src/web/pages/apps.ts +2 −1
@@ -12,6 +12,7 @@
1212 * 404 for everyone.
1313 */
1414
15 import { runnerSettings } from "../../crew/runner_env.ts";
1516 import { clearCache } from "../../actions/build_cache.ts";
1617 import { returnTo, withReturn } from "../return_to.ts";
1718 import { crewKeyCard } from "../studio/crew_key.ts";
@@ -299,7 +300,7 @@ const PICK_AGENT = "__pick__";
299300 /** Whether the form can offer "pick for me": the planner is on the path (timed as `planner`). */
300301 function plannerOffered(ctx: Ctx): boolean {
301302 return timePhase(ctx.request, "planner", () =>
302 planningAvailable(process.env["FORGE_CLAUDE"] ?? "claude"),
303 planningAvailable(runnerSettings(process.env).binary),
303304 );
304305 }
305306
src/web/pages/crew.ts +1 −1
@@ -140,7 +140,7 @@ export function crewReadiness(ctx: Ctx): Html {
140140 if (crew === undefined && isHostedChild(ctx.app))
141141 return line("No runner", html`This forge is hosted inside another forge's process, which has no crew runner for it: assigned issues will wait.`);
142142 if (crew === undefined)
143 return line("No runner", html`No crew runner is up on this forge, so assigned issues will wait: start it with apps on and Claude Code on <span class="mono">PATH</span> or at <span class="mono">FORGE_CLAUDE</span>.`);
143 return line("No runner", html`No crew runner is up on this forge, so assigned issues will wait: start it with apps on and Claude Code on <span class="mono">PATH</span> or at <span class="mono">FORGE_AGENT_RUNNER</span>.`);
144144 if (!crew.ready)
145145 return line("Cannot run", html`The crew cannot run: ${crew.detail}. Nothing picks up agents' work until this is fixed.`);
146146 const funded = fundingOf(ctx.app.config.dataDir);
test/runner-env-confined.test.ts +3 −3
@@ -1,6 +1,6 @@
11 import { expect, test } from "bun:test";
22 import { Glob } from "bun";
3 import { accountEnv, hasEnvCredential, lentEnv, oneShotEnv, ownEnv, upstreamOf } from "../src/crew/runner_env.ts";
3 import { accountEnv, runnerCredentialInEnv, lentEnv, oneShotEnv, ownEnv, upstreamOf } from "../src/crew/runner_env.ts";
44
55 test("only runner_env.ts names a vendor variable in src", async () => {
66 const re = /\b(ANTHROPIC_[A-Z_]+|CLAUDE_CODE_[A-Z_]+)\b/;
@@ -26,8 +26,8 @@ test("runner_env builds each environment", () => {
2626 expect(lentEnv("http://x", "k")).toEqual({ ANTHROPIC_BASE_URL: "http://x", ANTHROPIC_API_KEY: "k" });
2727 expect(ownEnv({ ANTHROPIC_API_KEY: "a", PATH: "/bin" })).toEqual({ ANTHROPIC_API_KEY: "a" });
2828 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);
29 expect(runnerCredentialInEnv({})).toBe(false);
30 expect(runnerCredentialInEnv({ FORGE_CLAUDE_CONFIG_DIR: "/x" })).toBe(true);
3131 expect(upstreamOf({})).toBeUndefined();
3232 expect(upstreamOf({ ANTHROPIC_API_KEY: "k", ANTHROPIC_BASE_URL: "https://g/" })).toEqual({
3333 base: "https://g",
test/runner-env-names.test.ts added +53
@@ -0,0 +1,53 @@
1 import { expect, test } from "bun:test";
2 import { runnerSettings } from "../src/crew/dispatch.ts";
3 import { namedRunner, runnerCredentialInEnv } from "../src/crew/runner_env.ts";
4 import { childEnvFor } from "../src/spawn/cell.ts";
5 import { planningAvailable } from "../src/crew/plan.ts";
6
7 test("FORGE_AGENT_RUNNER names the binary", () => {
8 expect(runnerSettings({ FORGE_AGENT_RUNNER: "/opt/r" }).binary).toBe("/opt/r");
9 });
10 test("the old FORGE_CLAUDE still names the binary", () => {
11 expect(runnerSettings({ FORGE_CLAUDE: "/opt/c" }).binary).toBe("/opt/c");
12 });
13 test("the new binary name wins; default is claude", () => {
14 expect(runnerSettings({ FORGE_AGENT_RUNNER: "/n", FORGE_CLAUDE: "/o" }).binary).toBe("/n");
15 expect(runnerSettings({}).binary).toBe("claude");
16 });
17 test("config dir: new name wins, old still read", () => {
18 expect(runnerSettings({ FORGE_AGENT_CONFIG_DIR: "/n" }).configDir).toBe("/n");
19 expect(runnerSettings({ FORGE_CLAUDE_CONFIG_DIR: "/o" }).configDir).toBe("/o");
20 expect(runnerSettings({ FORGE_AGENT_CONFIG_DIR: "/n", FORGE_CLAUDE_CONFIG_DIR: "/o" }).configDir).toBe("/n");
21 expect(runnerSettings({}).configDir).toBeUndefined();
22 });
23 test("nothing writes the environment: reading leaves it as it was", () => {
24 const env: Record<string, string | undefined> = { FORGE_AGENT_RUNNER: "/n", FORGE_AGENT_CONFIG_DIR: "/d" };
25 runnerSettings(env);
26 expect(env).toEqual({ FORGE_AGENT_RUNNER: "/n", FORGE_AGENT_CONFIG_DIR: "/d" });
27 });
28 test("a child is handed the neutral name, from either parent name", () => {
29 const base = { port: 1, dataDir: "/d", url: "http://x" };
30 expect(childEnvFor(base, { FORGE_AGENT_RUNNER: "/n" })["FORGE_AGENT_RUNNER"]).toBe("/n");
31 expect(childEnvFor(base, { FORGE_CLAUDE: "/o" })["FORGE_AGENT_RUNNER"]).toBe("/o");
32 expect(childEnvFor(base, { FORGE_CLAUDE: "/o" })["FORGE_CLAUDE"]).toBeUndefined();
33 expect(childEnvFor(base, {})["FORGE_AGENT_RUNNER"]).toBeUndefined();
34 expect(namedRunner({})).toBeUndefined();
35 });
36 test("the credential check follows the neutral config dir", () => {
37 expect(runnerCredentialInEnv({ FORGE_AGENT_CONFIG_DIR: "/d" })).toBe(true);
38 expect(runnerCredentialInEnv({ FORGE_CLAUDE_CONFIG_DIR: "/d" })).toBe(true);
39 expect(runnerCredentialInEnv({})).toBe(false);
40 });
41 test("app planning follows FORGE_AGENT_RUNNER in process.env", () => {
42 const before = { n: process.env["FORGE_AGENT_RUNNER"], o: process.env["FORGE_CLAUDE"] };
43 try {
44 delete process.env["FORGE_CLAUDE"];
45 process.env["FORGE_AGENT_RUNNER"] = "/no/such/runner";
46 expect(planningAvailable(runnerSettings(process.env).binary)).toBe(false);
47 process.env["FORGE_AGENT_RUNNER"] = "/bin/sh";
48 expect(planningAvailable(runnerSettings(process.env).binary)).toBe(true);
49 } finally {
50 for (const [k, v] of [["FORGE_AGENT_RUNNER", before.n], ["FORGE_CLAUDE", before.o]] as const)
51 v === undefined ? delete process.env[k] : (process.env[k] = v);
52 }
53 });
test/spawn-cell.test.ts +6 −6
@@ -376,14 +376,14 @@ describe("a child reaching its parent from inside its cell", () => {
376376 }
377377 });
378378
379 test("cellEnv carries FORGE_CLAUDE only when the parent names one (#12001)", () => {
379 test("cellEnv carries FORGE_AGENT_RUNNER only when the parent names one (#12001)", () => {
380380 const base = { port: 4100, dataDir: "/d/c", url: "https://kid--f.test" };
381 expect(cellEnv({ ...base, claude: "/d/c/build-claude" })["FORGE_CLAUDE"]).toBe("/d/c/build-claude");
382 expect(cellEnv(base)["FORGE_CLAUDE"]).toBeUndefined();
383 expect(cellEnv({ ...base, claude: "" })["FORGE_CLAUDE"]).toBeUndefined();
381 expect(cellEnv({ ...base, claude: "/d/c/build-claude" })["FORGE_AGENT_RUNNER"]).toBe("/d/c/build-claude");
382 expect(cellEnv(base)["FORGE_AGENT_RUNNER"]).toBeUndefined();
383 expect(cellEnv({ ...base, claude: "" })["FORGE_AGENT_RUNNER"]).toBeUndefined();
384384 // The call site `startOn` uses: the parent's own environment decides.
385 expect(childEnvFor(base, { FORGE_CLAUDE: "/opt/claude" })["FORGE_CLAUDE"]).toBe("/opt/claude");
386 expect(childEnvFor(base, {})["FORGE_CLAUDE"]).toBeUndefined();
385 expect(childEnvFor(base, { FORGE_CLAUDE: "/opt/claude" })["FORGE_AGENT_RUNNER"]).toBe("/opt/claude");
386 expect(childEnvFor(base, {})["FORGE_AGENT_RUNNER"]).toBeUndefined();
387387 });
388388
389389 test("a heartbeat goes to the relayed port when one is named, and to the public address otherwise", async () => {
test/spawn-child-apps.test.ts +1
@@ -249,6 +249,7 @@ describe("a spawned child's apps", () => {
249249
250250 test("a credential in the environment or a sealed account means the crew can run, and the note goes", async () => {
251251 expect(crewHasCredential(child.db, {})).toBe(false);
252 expect(crewHasCredential(child.db, { FORGE_AGENT_CONFIG_DIR: "/d" })).toBe(true);
252253 expect(crewHasCredential(child.db, { ANTHROPIC_API_KEY: "sk-ant-x" })).toBe(
253254 true,
254255 );
test/web-apps.test.ts +2
@@ -759,6 +759,8 @@ describe("apps, runs and the crew on the web", () => {
759759 const page = await bodyOf(alice, "/settings/crew");
760760 expect(page).toContain("No agents yet");
761761 expect(page).toContain("No crew runner is up");
762 expect(page).toContain("FORGE_AGENT_RUNNER");
763 expect(page).not.toContain("FORGE_CLAUDE");
762764 expect(page).toContain("<h2>New agent</h2>");
763765 expect(await bodyOf(alice, "/settings")).toContain('href="/settings/crew"');
764766

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs