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

A builder's clone has its dependencies installed before the run starts, so the type check always has its types #6597

Merged opened this

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

Merged

scout/forge:scout/6572-a-builder-s-clone-has-its-dependencies-i → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/6572-a-builder-s-clone-has-its-dependencies-i was merged into main as 8b57bf7.

Review

Sign in to review this change.
7 files changed +328 −4
README.md +12
@@ -64,6 +64,18 @@ days. A ratio over nothing is `null`, and an agent with no pull has `all` and
6464 `recent` null. Any token of a forge admin may read it (401 with none, 403 for
6565 anyone else); the tool is listed for admins only.
6666
67 A crew clone's dependencies are installed before the agent starts
68 (`installDependencies` in `src/crew/workdir.ts`), once per lockfile hash, so
69 the type check has its types. That install runs on the forge side, outside the
70 agent's shell sandbox, because the sandbox is built around the agent and the
71 install has to finish before it starts. It is bounded instead: a child env of
72 `PATH`, `HOME`, `TMPDIR` and the repository's install cache only (no `FORGE_*`
73 variable or key), `--ignore-scripts` so no package runs code, `--frozen-lockfile`,
74 and a 120 s kill. What remains is the registry and tarball fetch, which a
75 repository's `bunfig.toml` can point at a host of its choosing; that is the same
76 reach the agent's own `bun install` has through the sandbox's allowed hosts, and
77 a failed or killed install only logs and lets the run start.
78
6779 Git hosting, code browsing, and a JSON API in one fast Bun process, with
6880 **zero runtime dependencies** — the `package.json` dependency list is empty and
6981 stays that way. The one exception is dev-only: `@anthropic-ai/sandbox-runtime`
src/crew/runner.ts +19 −1
@@ -144,7 +144,14 @@ import { trackAgentRun, untrackAgentRun } from "./running.ts";
144144 import { installCacheEnv, sandboxPolicy } from "./sandbox.ts";
145145 import { grantSecrets, parseDeclaration, type Grant } from "./grant.ts";
146146 import { historyHolds, scrubTree } from "./leak.ts";
147 import { removeClone, workdirFor } from "./workdir.ts";
147 import {
148 installCacheDir,
149 installDependencies,
150 installNote,
151 type InstallRunner,
152 removeClone,
153 workdirFor,
154 } from "./workdir.ts";
148155 import { readersAmong, voiceOf } from "./direct.ts";
149156 import { feedbackSince } from "./news.ts";
150157 import { handedOverPulls, RETRY_SUMMARY, SKIP_SUMMARY, skipsFeedback } from "./guard.ts";
@@ -178,6 +185,8 @@ export interface CrewOptions {
178185 readonly tickMs?: number | undefined;
179186 /** The clock a run's timing line reads; tests pass a fake. @default Date.now */
180187 readonly now?: (() => number) | undefined;
188 /** What runs `bun install` in a clone; tests pass a stand-in. @default bun */
189 readonly installRunner?: InstallRunner | undefined;
181190 }
182191
183192 export { crewRoot, workdirFor } from "./workdir.ts";
@@ -980,6 +989,15 @@ async function work(
980989 );
981990 watch.lap("clone");
982991 if (!prepared.ok) return { state: "failed", summary: prepared.error };
outdated

This wiring is the feature, and no test covers it. Extract it or inject the runner so a test can prove the order (clone, install, agent) and that a failed install does not stop the run.

992 // Dependencies before the agent starts, so the type check has its types;
993 // a failure is logged and the run goes on.
994 const deps = await installDependencies(
995 dir,
996 installCacheDir(app.config.dataDir, repo.id),
997 options.installRunner,
998 );
999 const note = installNote(deps);
1000 if (note !== undefined) write(note);
9831001
9841002 // What the agent is told.
9851003 // The session belongs to the task, which spans the issue and the pull
src/crew/workdir.ts +120 −1
@@ -4,8 +4,9 @@
44 * and the sandbox policy agree on the layout without importing each other.
55 */
66
7 import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
78 import { rm } from "node:fs/promises";
8 import { join } from "node:path";
9 import { dirname, join } from "node:path";
910 import type { Agent } from "../core/agents.ts";
1011 import type { Repo } from "../core/repos.ts";
1112
@@ -73,3 +74,121 @@ export async function removeClone(
7374 }
7475 }
7576 }
77
78 const LOCKFILES = ["bun.lock", "bun.lockb"];
79 const INSTALLED_MARK = join("node_modules", ".forge-lock-hash");
80
81 /** Runs `bun install` in a directory with the given env; resolves to its exit code and stderr. */
82 export type InstallRunner = (
83 dir: string,
84 env: Record<string, string>,
85 ) => Promise<{ code: number; stderr: string }>;
86
87 /** How long an install may take before it is killed and the run starts without it. */
88 export const INSTALL_TIMEOUT_MS = 120_000;
89
90 /**
91 * What an install sees of the forge's environment: a repository's package.json
outdated

No timeout: a stalled registry blocks the whole run before the agent starts. Kill the process after a bound (e.g. 120s) and return it as an error.

92 * is untrusted, so no FORGE_* variable, key or token goes with it.
93 */
outdated

env: {...process.env, ...env} hands every forge secret (FORGE_TOKEN, keys) to bun install and any lifecycle script a repo's trustedDependencies runs. Use an allowlisted env, and add --ignore-scripts to the args above.

94 export function installEnv(
95 cacheDir: string,
96 base: Record<string, string | undefined> = process.env,
97 ): Record<string, string> {
98 return {
99 PATH: base["PATH"] ?? "/usr/local/bin:/usr/bin:/bin",
100 HOME: join(dirname(cacheDir), "home"),
101 TMPDIR: base["TMPDIR"] ?? "/tmp",
102 BUN_INSTALL_CACHE_DIR: cacheDir,
103 };
104 }
105
106 /** Run a command with exactly `env`; killed after `timeoutMs` and reported as a failure. */
107 export async function runBounded(
108 argv: string[],
109 dir: string,
110 env: Record<string, string>,
111 timeoutMs: number,
112 ): Promise<{ code: number; stderr: string }> {
113 const proc = Bun.spawn(argv, {
114 cwd: dir,
115 env,
116 stdout: "ignore",
117 stderr: "pipe",
118 });
119 let timedOut = false;
120 const timer = setTimeout(() => {
121 timedOut = true;
122 proc.kill("SIGKILL");
123 }, timeoutMs);
124 try {
125 const [stderr, code] = await Promise.all([
126 new Response(proc.stderr).text(),
127 proc.exited,
128 ]);
129 return timedOut
130 ? { code, stderr: `timed out after ${timeoutMs} ms` }
131 : { code, stderr };
132 } finally {
133 clearTimeout(timer);
134 }
135 }
136
137 const bunInstall: InstallRunner = (dir, env) =>
138 runBounded(
139 ["bun", "install", "--frozen-lockfile", "--ignore-scripts"],
140 dir,
141 env,
142 INSTALL_TIMEOUT_MS,
143 );
144
145 /** The run log's line for an install's outcome, if it has one. */
146 export function installNote(result: {
147 installed: boolean;
148 error?: string;
149 }): string | undefined {
150 if (result.error !== undefined)
151 return `dependency install failed: ${result.error}`;
152 return result.installed ? "dependencies installed" : undefined;
153 }
154
155 /**
156 * Install a clone's dependencies from the repository's shared cache before the
157 * agent starts, so the type check has its types. Only a repository with a
158 * package.json and a lockfile is installed; a workdir whose node_modules was
159 * installed for the same lockfile hash is left alone. Never throws: a failure
160 * comes back as a message for the log, and the run starts regardless.
161 */
162 export async function installDependencies(
163 dir: string,
164 cacheDir: string,
165 run: InstallRunner = bunInstall,
166 ): Promise<{ installed: boolean; error?: string }> {
167 try {
168 const lock = LOCKFILES.map((name) => join(dir, name)).find((p) =>
169 existsSync(p),
170 );
171 if (lock === undefined || !existsSync(join(dir, "package.json")))
172 return { installed: false };
173 const hash = new Bun.CryptoHasher("sha256")
174 .update(readFileSync(lock))
175 .digest("hex");
176 const mark = join(dir, INSTALLED_MARK);
177 if (existsSync(mark) && readFileSync(mark, "utf8") === hash)
178 return { installed: false };
179 const env = installEnv(cacheDir);
180 mkdirSync(cacheDir, { recursive: true });
181 mkdirSync(env["HOME"]!, { recursive: true });
182 const result = await run(dir, env);
183 if (result.code !== 0)
184 return {
185 installed: false,
186 error: `bun install exited ${result.code}: ${result.stderr.slice(0, 200)}`,
187 };
188 mkdirSync(join(dir, "node_modules"), { recursive: true });
189 writeFileSync(mark, hash);
190 return { installed: true };
191 } catch (error) {
192 return { installed: false, error: String(error) };
193 }
194 }
test/crew-install.test.ts added +69
@@ -0,0 +1,69 @@
1 /**
2 * The crew installs a clone's dependencies after the clone and before the
3 * agent starts (#6572); a failed install is logged and the run goes on.
4 */
5
6 import { afterAll, describe, expect, test } from "bun:test";
7 import { existsSync } from "node:fs";
8 import { join } from "node:path";
9 import { assignIssue, openIssue } from "../src/actions/issues.ts";
10 import type { InstallRunner } from "../src/crew/workdir.ts";
11 import { readRunLog } from "../src/runs/log.ts";
12 import {
13 agentRunSettled,
14 startStandInCrew,
15 stopStandInCrew,
16 type StandInCrew,
17 } from "./helpers/crew-standin.ts";
18
19 const FILES = [
20 { path: "package.json", content: "{}\n" },
21 { path: "bun.lock", content: "lock\n" },
22 ];
23
24 async function oneRun(standIn: StandInCrew): Promise<string> {
25 const { app, alice, bot, repo } = standIn;
26 const issue = openIssue(app, alice, repo, { title: "Weed", body: "x" });
27 if (!issue.ok) throw new Error("issue");
28 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
29 const run = await agentRunSettled(app, repo, () => true, 30_000);
30 return readRunLog(app.config.dataDir, run.id).lines.join("\n");
31 }
32
33 describe("the crew installs dependencies before the agent starts", () => {
34 const stands: StandInCrew[] = [];
35 afterAll(async () => {
36 for (const s of stands) await stopStandInCrew(s);
37 });
38
39 test("the runner is called in the clone, after the clone and before the agent", async () => {
40 const calls: { cloned: boolean; agentStarted: boolean }[] = [];
41 let promptFile = "";
42 const installRunner: InstallRunner = async (dir) => {
43 calls.push({
44 cloned: existsSync(join(dir, ".git")) && existsSync(join(dir, "bun.lock")),
45 agentStarted: existsSync(promptFile),
46 });
47 return { code: 0, stderr: "" };
48 };
49 const standIn = await startStandInCrew({ installRunner, files: FILES });
50 stands.push(standIn);
51 promptFile = standIn.promptFile;
52 const log = await oneRun(standIn);
53 expect(calls).toEqual([{ cloned: true, agentStarted: false }]);
54 expect(existsSync(promptFile)).toBe(true);
55 expect(log).toContain("dependencies installed");
56 }, 60_000);
57
58 test("a failing install is logged and the agent still starts", async () => {
59 const standIn = await startStandInCrew({
60 installRunner: async () => ({ code: 1, stderr: "registry down" }),
61 files: FILES,
62 });
63 stands.push(standIn);
64 const log = await oneRun(standIn);
65 expect(log).toContain("dependency install failed");
66 expect(log).toContain("registry down");
67 expect(existsSync(standIn.promptFile)).toBe(true);
68 }, 60_000);
69 });
test/helpers/crew-standin.ts +12 −1
@@ -22,6 +22,7 @@ import { startChecks, type ChecksHandle } from "../../src/checks/service.ts";
2222 import type { Repo } from "../../src/core/repos.ts";
2323 import { listRuns, type Run } from "../../src/core/runs.ts";
2424 import { createUser, type User } from "../../src/core/users.ts";
25 import type { InstallRunner } from "../../src/crew/workdir.ts";
2526 import { serve, type Servers } from "../../src/http/server.ts";
2627
2728 export interface StandInCrew {
@@ -47,6 +48,10 @@ export async function startStandInCrew(
4748 readonly reviewBudgetMs?: number;
4849 readonly tickMs?: number;
4950 readonly now?: () => number;
51 /** Stands in for `bun install` in a clone. */
52 readonly installRunner?: InstallRunner;
53 /** Files committed to main beside the README. */
54 readonly files?: { path: string; content: string }[];
5055 } = {},
5156 ): Promise<StandInCrew> {
5257 const dataDir = await mkdtemp(join(tmpdir(), "forge-crew-"));
@@ -184,6 +189,9 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW
184189 reviewBudgetMs: options.reviewBudgetMs ?? 4_000,
185190 ...(options.tickMs === undefined ? {} : { tickMs: options.tickMs }),
186191 ...(options.now === undefined ? {} : { now: options.now }),
192 ...(options.installRunner === undefined
193 ? {}
194 : { installRunner: options.installRunner }),
187195 });
188196 const a = await createUser(app.db, {
189197 origin: "seed",
@@ -209,7 +217,10 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW
209217 const repo = created.value;
210218 const seeded = await commitToBranch(app, alice, repo, {
211219 message: "Start",
212 files: [{ path: "README.md", content: "# garden\n" }],
220 files: [
221 { path: "README.md", content: "# garden\n" },
222 ...(options.files ?? []),
223 ],
213224 });
214225 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
215226 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
test/spawn-pipes.test.ts +1
@@ -22,6 +22,7 @@ const PIPING: Record<string, { sites: string[]; reason: string }> = {
2222 "src/crew/plan.ts": { sites: ["stdout"], reason: "stdout awaited with the exit; stderr ignored" },
2323 "src/crew/runner.ts": { sites: ["stdout"], reason: "lsof stdout awaited; stderr ignored" },
2424 "src/crew/sandbox.ts": { sites: ["stderr"], reason: "stderr awaited with the exit; stdout ignored" },
25 "src/crew/workdir.ts": { sites: ["stderr"], reason: "stderr read to its end alongside the exit; stdout ignored" },
2526 "src/git/env.ts": { sites: ["stdout+stderr", "stdout+stderr"], reason: "stdout and stderr both read to the end, finally clears the timer" },
2627 "src/git/local.ts": { sites: ["stdout+stderr", "stdout+stderr"], reason: "stdout and stderr both read to the end" },
2728 "src/git/read.ts": { sites: ["stdout"], reason: "cat-file stdout is read by the long-lived reader; stderr ignored" },
test/workdir.test.ts +95 −1
@@ -1,5 +1,14 @@
11 import { describe, expect, test } from "bun:test";
2 import { removeClone } from "../src/crew/workdir.ts";
2 import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3 import { join } from "node:path";
4 import {
5 installDependencies,
6 installEnv,
7 installNote,
8 runBounded,
9 removeClone,
10 type InstallRunner,
11 } from "../src/crew/workdir.ts";
312
413 describe("removing a clone", () => {
514 test("a removal that fails twice is tried again, and the loop turns between tries", async () => {
@@ -32,3 +41,88 @@ describe("removing a clone", () => {
3241 expect(calls).toBe(3);
3342 });
3443 });
44
45 describe("installing dependencies", () => {
46 const fixture = (lock: boolean) => {
47 const dir = mkdtempSync(join(process.env["TMPDIR"] ?? "/tmp", "wd-"));
48 writeFileSync(join(dir, "package.json"), "{}");
49 if (lock) writeFileSync(join(dir, "bun.lock"), "lock-a");
50 return dir;
51 };
52 const fake = (counter: { n: number }, code = 0): InstallRunner => async (dir) => {
53 counter.n++;
54 mkdirSync(join(dir, "node_modules"), { recursive: true });
55 return { code, stderr: "boom" };
56 };
57
58 test("a workdir with a lockfile gets node_modules, and a second preparation does not reinstall", async () => {
59 const dir = fixture(true);
60 const count = { n: 0 };
61 const cache = join(dir, ".cache");
62 expect((await installDependencies(dir, cache, fake(count))).installed).toBe(true);
63 expect(existsSync(join(dir, "node_modules"))).toBe(true);
64 expect((await installDependencies(dir, cache, fake(count))).installed).toBe(false);
65 expect(count.n).toBe(1);
66 writeFileSync(join(dir, "bun.lock"), "lock-b");
67 await installDependencies(dir, cache, fake(count));
68 expect(count.n).toBe(2);
69 });
70
71 test("no lockfile means no install; a failed install is reported, not thrown", async () => {
72 const count = { n: 0 };
73 await installDependencies(fixture(false), "/x", fake(count));
74 expect(count.n).toBe(0);
75 const dir = fixture(true);
76 const result = await installDependencies(dir, join(dir, ".c"), fake(count, 1));
77 expect(result.error).toContain("exited 1");
78 const thrown = await installDependencies(dir, join(dir, ".c"), async () => {
79 throw new Error("spawn");
80 });
81 expect(thrown.error).toContain("spawn");
82 });
83 });
84
85 describe("the install's bounds", () => {
86 test("the child env is an allowlist: no FORGE_* variable, no token", () => {
87 const env = installEnv("/c/bun", {
88 PATH: "/bin",
89 TMPDIR: "/t",
90 FORGE_TOKEN: "s",
91 FORGE_SECRET: "k",
92 ANTHROPIC_API_KEY: "a",
93 });
94 expect(Object.keys(env).sort()).toEqual(["BUN_INSTALL_CACHE_DIR", "HOME", "PATH", "TMPDIR"]);
95 expect(Object.keys(installEnv("/c/bun")).some((k) => k.startsWith("FORGE_"))).toBe(false);
96 });
97
98 test("the runner is handed that env, not the forge's", async () => {
99 process.env["FORGE_TOKEN"] = "secret";
100 let seen: Record<string, string> = {};
101 try {
102 const dir = mkdtempSync(join(process.env["TMPDIR"] ?? "/tmp", "wd-"));
103 writeFileSync(join(dir, "package.json"), "{}");
104 writeFileSync(join(dir, "bun.lock"), "x");
105 await installDependencies(dir, join(dir, "c", "bun"), async (_d, env) => {
106 seen = env;
107 return { code: 0, stderr: "" };
108 });
109 } finally {
110 delete process.env["FORGE_TOKEN"];
111 }
112 expect(Object.keys(seen).some((k) => k.startsWith("FORGE_"))).toBe(false);
113 });
114
115 test("a hung install is killed and reported", async () => {
116 const started = Date.now();
117 const result = await runBounded(["sleep", "30"], ".", { PATH: process.env["PATH"] ?? "" }, 100);
118 expect(Date.now() - started).toBeLessThan(5000);
119 expect(result.code).not.toBe(0);
120 expect(result.stderr).toContain("timed out");
121 });
122
123 test("the run log's line for each outcome", () => {
124 expect(installNote({ installed: true })).toBe("dependencies installed");
125 expect(installNote({ installed: false })).toBeUndefined();
126 expect(installNote({ installed: false, error: "boom" })).toBe("dependency install failed: boom");
127 });
128 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs