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

An agent run reuses its repository's bun install cache from the run before, instead of downloading every package again #3738

Merged opened this

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

Merged

scout/forge:scout/3673-an-agent-run-reuses-its-repository-s-bun → main 7 commits fork 9 threads · 9 unresolved

  • Merged. scout/3673-an-agent-run-reuses-its-repository-s-bun was merged into main as 6869110.

Review

Sign in to review this change.
10 files changed +278 −9
docs/CREW.md +2
@@ -6,6 +6,8 @@ the package registry, writes stay in the clone and the run's own `tmpDir`,
66 and the machine's temp directory, the forge's data and the operator's
77 credentials are unreadable. No command runs outside it on request.
88
9 Bun's install cache is one per repository, `<data>/crew/.cache/<repo id>/bun` (`BUN_INSTALL_CACHE_DIR`): every run on a repository shares it, no two repositories do, and deleting the repository deletes it.
10
911 ## Running this repository's tests inside it
1012
1113 On Linux the sandbox refuses the bind of a unix socket, and the forge's
docs/THREATS.md +7
@@ -87,6 +87,13 @@ person approved.
8787 forge's repositories, index, key and every other agent's clone are denied by
8888 name and by allow-list, and a listing that fails refuses the run.
8989 Test: `test/crew-sandbox.test.ts`
90 - The one place a run writes beside its clone and temp directory is its
91 repository's bun install cache (`crew/.cache/<repo id>/bun`), which every

Worth saying explicitly: 'every run' includes other agents' runs (a builder's run can poison what the reviewer's shell then executes with the reviewer's token). Accepted by #3673; not blocking.

92 run on that repository can write: a run can poison what the next run on the
93 same repository installs. It is per repository: the cache root is denied whole
94 and only this repository's directory re-allowed, so a cache made later is
95 denied too, and deleting the repository removes it.
96 Test: `test/crew-install-cache.test.ts`
9097 - The same policy holds against a real bubblewrap, not only as a string.
9198 Test: `test/crew-sandbox-real.test.ts`
9299 - An agent cannot push to the default branch or merge; a person merges.
src/actions/accounts.ts +2
@@ -23,6 +23,7 @@ import {
2323 setSuspended,
2424 type AccountRow,
2525 } from "../core/accounts.ts";
26 import { removeInstallCache } from "../crew/workdir.ts";
2627 import { dropMirror } from "../crew/mirror.ts";
2728 import { deleteRepo as destroyRepo, listReposOwnedBy } from "../core/repos.ts";
2829 import { listActive } from "../core/runs.ts";
@@ -185,6 +186,7 @@ async function removeOwnedRepos(
185186 const label = `${owner.username}/${repo.name}`;
186187 const deleted = await destroyRepo(app.db, app.reposRoot, repo);
187188 if (!deleted.ok) return label;
189 await removeInstallCache(app.config.dataDir, repo.id);
188190 dropMirror(app.config.dataDir, repo.id);
189191 removed.push(label);
190192 if (owner.kind === "org")
src/actions/repos.ts +2
@@ -62,6 +62,7 @@ import {
6262 requireWriter,
6363 type AccessError,
6464 } from "./access.ts";
65 import { removeInstallCache } from "../crew/workdir.ts";
6566
6667 export interface CreateRepoInput {
6768 /** Owner name: the actor themselves (default) or an org they can write to. */
@@ -503,6 +504,7 @@ export async function deleteRepo(
503504 if (!admin.ok) return admin;
504505 const deleted = await destroyRepo(app.db, app.reposRoot, repo);
505506 if (!deleted.ok) return deleted;
outdated

Untested: crew-install-cache.test.ts calls removeInstallCache directly. No test checks that deleting a repository, here or in actions/accounts.ts removeOwnedRepos, actually clears the cache. Add one through the action.

507 await removeInstallCache(app.config.dataDir, repo.id);
506508 dropMirror(app.config.dataDir, repo.id);
507509 storageChanged(app, repo.ownerId);
508510 // The row is gone (events cascade with it), so this event carries no repo.
src/crew/claude.ts +9 −1
@@ -122,7 +122,15 @@ export function claudeSettings(
122122 // An app under test listens on a local port.
123123 allowLocalBinding: true,
124124 },
125 filesystem: { denyRead: [...sandbox.denyRead] },
125 filesystem: {
126 denyRead: [...sandbox.denyRead],
127 ...(sandbox.readAllow.length === 0
128 ? {}
129 : { allowRead: [...sandbox.readAllow] }),
130 ...(sandbox.installCache === undefined
131 ? {}
132 : { allowWrite: [sandbox.installCache] }),
133 },
126134 },
127135 permissions: {
128136 allow: [...TOOLS],
src/crew/runner.ts +7 −3
@@ -137,7 +137,7 @@ import {
137137 type UsageOutcome,
138138 } from "../core/usage.ts";
139139 import { trackAgentRun, untrackAgentRun } from "./running.ts";
140 import { sandboxPolicy } from "./sandbox.ts";
140 import { installCacheEnv, sandboxPolicy } from "./sandbox.ts";
141141 import { grantSecrets, parseDeclaration, type Grant } from "./grant.ts";
142142 import { historyHolds, scrubTree } from "./leak.ts";
143143 import { removeClone, workdirFor } from "./workdir.ts";
@@ -1182,6 +1182,7 @@ async function work(
11821182 ],
11831183 enforced: options.sandboxEnforced,
11841184 runId: run.id,
1185 repoId: repo.id,
11851186 });
11861187 const sandbox = shellPolicy();
11871188 // The run's own temp directory, never the machine's shared one; it goes
@@ -1194,6 +1195,9 @@ async function work(
11941195 env["CLAUDE_CODE_TMPDIR"] = runTmp;
11951196 env["TMPDIR"] = runTmp;
outdated

High (untested behaviour; issue #3673 asks for it): nothing would fail if this block were deleted. Move it into a small function (sandbox.ts or workdir.ts) that sets the env and creates the directory, and test that function. The runner then only calls it.

11961197 }
1198 // Bun's install cache is the repository's, shared by its runs, so a
1199 // package is downloaded once rather than by every clone.
1200 installCacheEnv(sandbox, env);
11971201 // The sandbox refuses a unix socket's bind; the forge's own servers take
11981202 // a loopback port instead (`git/hook-address.ts`), so `bun test` runs.
11991203 if (sandbox.enforced) env["FORGE_TEST_SOCKET"] = "port";
@@ -1794,8 +1798,8 @@ async function prepareClone(
17941798 env,
17951799 });
17961800 // What a run leaves in its tree that is never the work: the temp
1797 // directory a sandboxed shell keeps beside the code (`.tmp/`), with bun's
1798 // install cache and a test's data in it, and `node_modules/`. The wrap-up
1801 // directory a sandboxed shell keeps beside the code (`.tmp/`), with a
1802 // test's data in it, and `node_modules/`. The wrap-up
17991803 // commits leftovers with `git add -A`, so the clone says so once, in its
18001804 // own exclude file — the repository's `.gitignore` is the repository's.
18011805 mkdirSync(join(dir, ".git", "info"), { recursive: true });
src/crew/sandbox.ts +39 −4
@@ -25,14 +25,17 @@
2525 * `CLAUDE_CODE_TMPDIR` and `TMPDIR`) under the agent's own `.t/`; the
2626 * machine's temp directory, which holds every other session's files and
2727 * the forge's own, is denied whole, and so are the agent's other runs'
28 * temp directories. A run that ends removes its own; what a forge that
29 * died left behind is swept at the next crew start (`sweepRunTmp`).
28 * temp directories. The shell also writes bun's install cache, one per
29 * repository under `crew/.cache/<repo id>/bun`: the runs on a repository
outdated

Low: rewrap this header line to the width of the rest of the comment.

30 * share it, and the other repositories' caches are denied. A run that ends
31 * removes its own; what a forge that died left behind is swept at the next
32 * crew start (`sweepRunTmp`).
3033 */
3134
32 import { readdirSync, rmSync, statSync } from "node:fs";
35 import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
3336 import { homedir, tmpdir } from "node:os";
3437 import { basename, isAbsolute, join, relative, sep } from "node:path";
35 import { crewRoot } from "./workdir.ts";
38 import { cacheRoot, crewRoot, installCacheDir } from "./workdir.ts";
3639
3740 export interface SandboxPolicy {
3841 /** Hostnames the shell may connect to. */
@@ -48,6 +51,26 @@ export interface SandboxPolicy {
4851 readonly enforced: boolean;
4952 /** This run's own temp directory, when the policy was asked for a run. */
5053 readonly tmpDir?: string | undefined;
54 /**
55 * The one place besides the clone and the temp directory the shell may
56 * write: its repository's install cache, shared by every run on it.
57 */
58 readonly installCache?: string | undefined;
59 /** Paths re-allowed within `denyRead`: this run's own repository's cache. */
60 readonly readAllow: readonly string[];
61 }
62
63 /**
64 * The run's env for its repository's install cache: the directory made, and
65 * bun told to use it. A policy with no cache (no repository) changes nothing.
66 */
67 export function installCacheEnv(
68 policy: SandboxPolicy,
69 env: Record<string, string>,
70 ): void {
71 if (policy.installCache === undefined) return;
72 mkdirSync(policy.installCache, { recursive: true });
73 env["BUN_INSTALL_CACHE_DIR"] = policy.installCache;
5174 }
5275
5376 /**
@@ -209,6 +232,8 @@ export function sandboxPolicy(input: {
209232 enforced?: boolean | undefined;
210233 /** The run this is for: it gets a temp directory of its own (`tmpDir`). */
211234 runId?: string | undefined;
235 /** The repository the run is on: its install cache is writable (`installCache`). */
236 repoId?: string | undefined;
212237 }): SandboxPolicy {
213238 const hosts = new Set<string>(["registry.npmjs.org"]);
214239 for (const url of input.urls) {
@@ -242,6 +267,10 @@ export function sandboxPolicy(input: {
242267 input.runId === undefined
outdated

Low: this comment describes deny.push(cacheRoot(data)), which sits outside the array below. Read here, it seems to describe the temp-directory line that follows it. Put the push in the array at this spot, after the comment, and drop the separate deny.push.

outdated

Nit, not blocking: cacheRoot(data) is pushed whole a few lines below, so this filter only drops a duplicate entry. It can go, or keep it with a word saying why.

243268 ? undefined
244269 : runTmpDir(data, input.agentName, input.runId);
270 const installCache =
271 input.repoId === undefined
272 ? undefined
273 : installCacheDir(data, input.repoId);
245274 const deny = [
246275 // The floor: named, whether or not the listing below sees them.
247276 ...ALWAYS_DENIED.map((name) => join(data, name)),
@@ -257,6 +286,10 @@ export function sandboxPolicy(input: {
257286 // The machine's temp directory: every other session's files, and the
258287 // forge's own (a cell's sockets, a checkout's index).
259288 ...tempDenied(data),
289 // Every repository's install cache, whole and not listed: one created
290 // after this policy was built is denied too. This run's own is
291 // re-allowed (`readAllow`) and written (`allowWrite`).
292 cacheRoot(data),

Good: the cache root is denied by name, not from the listing, so a cache created after this policy is built is still denied.

260293 ];
261294 const home = homedir();
262295 for (const secret of HOME_SECRETS) deny.push(join(home, secret));
@@ -264,6 +297,8 @@ export function sandboxPolicy(input: {
264297 allowHosts: [...hosts],
265298 denyRead: deny,
266299 enforced: input.enforced ?? true,
300 readAllow: installCache === undefined ? [] : [installCache],
267301 ...(tmpDir === undefined ? {} : { tmpDir }),
302 ...(installCache === undefined ? {} : { installCache }),
268303 };
269304 }
src/crew/workdir.ts +24
@@ -25,6 +25,30 @@ export function workdirFor(
2525 );
2626 }
2727
28 /**
29 * Every repository's install caches, side by side: `<data>/crew/.cache/`.
30 * Not an agent's name (a username cannot start with a dot), so no agent's
31 * directory is mistaken for it.
32 */
33 export const cacheRoot = (dataDir: string): string =>
34 join(crewRoot(dataDir), ".cache");
35
36 /**
37 * Bun's install cache for one repository: shared by every run on it, never
38 * by two repositories, so a private repository's packages stay with it.
39 */
40 export function installCacheDir(dataDir: string, repoId: string): string {
41 return join(cacheRoot(dataDir), repoId, "bun");
42 }
43
44 /** A removed repository takes its cache with it. */
45 export async function removeInstallCache(
46 dataDir: string,
47 repoId: string,
48 ): Promise<void> {
49 await rm(join(cacheRoot(dataDir), repoId), { recursive: true, force: true });
50 }
51
2852 /**
2953 * Remove a clone without blocking the event loop: a leftover process still
3054 * writing into it makes a removal fail with ENOTEMPTY, so it is tried again
test/crew-install-cache.test.ts added +125
@@ -0,0 +1,125 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { removeAccount } from "../src/actions/accounts.ts";
6 import { createRepo, deleteRepo } from "../src/actions/repos.ts";
7 import { signUp } from "../src/actions/users.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { getUserByName } from "../src/core/users.ts";
10 import { claudeSettings } from "../src/crew/claude.ts";
11 import { installCacheEnv, sandboxPolicy } from "../src/crew/sandbox.ts";
12 import { seedAdmin } from "./helpers/admin.ts";
13 import { promote } from "./helpers/members.ts";
14 import { proveAll } from "./helpers/verified.ts";
15 import { fixtureSlot } from "./helpers/slot.ts";
16 import { installCacheDir, removeInstallCache } from "../src/crew/workdir.ts";
17
18 const policy = (dataDir: string, repoId: string) =>
19 sandboxPolicy({
20 dataDir,
21 agentName: "scout",
22 urls: [],
23 runId: "run0000000000001",
24 repoId,
25 });
26
27 describe("the shared install cache", () => {
28 const data = mkdtempSync(join(tmpdir(), "fc-cache-"));
29
30 test("a run's policy names its repository's cache and lets the shell write there", () => {
31 const p = policy(data, "r1");
32 expect(p.installCache).toBe(installCacheDir(data, "r1"));
33 expect(p.installCache).toBe(join(data, "crew", ".cache", "r1", "bun"));
34 const fs = (claudeSettings(p)["sandbox"] as any).filesystem;
35 expect(fs.allowWrite).toEqual([p.installCache]);
36 // The cache root is denied whole, this repository's own re-allowed.
37 expect(p.denyRead).toContain(join(data, "crew", ".cache"));
38 expect(p.readAllow).toEqual([join(data, "crew", ".cache", "r1", "bun")]);
39 expect(fs.allowRead).toEqual(p.readAllow);
40 });
41
42 test("a run with no repository has no cache and no extra write", () => {
43 const p = sandboxPolicy({ dataDir: data, agentName: "scout", urls: [] });
44 expect(p.installCache).toBeUndefined();
45 const fs = (claudeSettings(p)["sandbox"] as any).filesystem;
46 expect(fs.allowWrite).toBeUndefined();
47 });
48
49 test("two repositories get two caches, and each run may read only its own", () => {
50 expect(installCacheDir(data, "r1")).not.toBe(installCacheDir(data, "r2"));
51 const one = policy(data, "r1");
52 const two = policy(data, "r2");
53 expect(one.readAllow).toEqual([join(data, "crew", ".cache", "r1", "bun")]);
54 expect(two.readAllow).toEqual([join(data, "crew", ".cache", "r2", "bun")]);
55 // Denied whole, so a cache made after the policy was built is denied too.
56 expect(one.denyRead).toContain(join(data, "crew", ".cache"));
57 expect(two.denyRead).toContain(join(data, "crew", ".cache"));
58 });
59
60 test("the run's env names the cache and the directory is made", () => {
61 const env: Record<string, string> = {};
62 installCacheEnv(policy(data, "r1"), env);
63 expect(env["BUN_INSTALL_CACHE_DIR"]).toBe(installCacheDir(data, "r1"));
64 expect(existsSync(installCacheDir(data, "r1"))).toBe(true);
65 const other: Record<string, string> = {};
66 installCacheEnv(policy(data, "r2"), other);
67 expect(other["BUN_INSTALL_CACHE_DIR"]).not.toBe(env["BUN_INSTALL_CACHE_DIR"]);
68 const none: Record<string, string> = {};
69 installCacheEnv(sandboxPolicy({ dataDir: data, agentName: "scout", urls: [] }), none);
70 expect(none).toEqual({});
71 });
72
73 test("removing a repository clears its cache and only its", async () => {
74 await removeInstallCache(data, "r1");
75 expect(existsSync(installCacheDir(data, "r1"))).toBe(false);
76 expect(existsSync(installCacheDir(data, "r2"))).toBe(true);
77 });
78 });
79
80 describe("a deleted repository's cache", () => {
81 let dataDir: string;
82 let app: App;
83 beforeAll(async () => {
84 dataDir = mkdtempSync(join(tmpdir(), "fc-cache-del-"));
85 app = createApp({ dataDir, port: 0 });
86 const admin = await seedAdmin(app, { username: "ada", email: "[email protected]", password: "owner-passphrase" });
87 if (!admin.ok) throw new Error("seed");
88 const bob = await signUp(app, { username: "bob", email: "[email protected]", password: "bob-passphrase" }, { slot: fixtureSlot(app) });
89 if (!bob.ok) throw new Error("signup");
90 proveAll(app);
91 promote(app.db);
92 });
93 afterAll(() => {
94 app.close();
95 rmSync(dataDir, { recursive: true, force: true });
96 });
97
98 const warm = (id: string) => {
99 mkdirSync(installCacheDir(dataDir, id), { recursive: true });
100 writeFileSync(join(installCacheDir(dataDir, id), "pkg"), "x");
101 };
102
103 test("deleting the repository clears its cache and not another's", async () => {
104 const bob = getUserByName(app.db, "bob")!;
105 const a = await createRepo(app, bob, { name: "a", visibility: "private" });
106 const b = await createRepo(app, bob, { name: "b", visibility: "private" });
107 if (!a.ok || !b.ok) throw new Error("repo");
108 warm(a.value.id);
109 warm(b.value.id);
110 expect((await deleteRepo(app, bob, a.value)).ok).toBe(true);
111 expect(existsSync(installCacheDir(dataDir, a.value.id))).toBe(false);
112 expect(existsSync(installCacheDir(dataDir, b.value.id))).toBe(true);
113 });
114
115 test("removing an account clears the caches of its repositories", async () => {
116 const admin = getUserByName(app.db, "ada")!;
117 const bob = getUserByName(app.db, "bob")!;
118 const c = await createRepo(app, bob, { name: "c", visibility: "private" });
119 if (!c.ok) throw new Error("repo");
120 warm(c.value.id);
121 const done = await removeAccount(app, admin, "bob");
122 expect(done.ok).toBe(true);
123 expect(existsSync(installCacheDir(dataDir, c.value.id))).toBe(false);
124 });
125 });
test/crew-sandbox-real.test.ts +61 −1
@@ -56,7 +56,7 @@ function runtimeSettings(
5656 denyRead: [],
5757 denyWrite: [],
5858 ...sandbox.filesystem,
59 allowWrite: [workDir],
59 allowWrite: [workDir, ...(sandbox.filesystem?.allowWrite ?? [])],
6060 },
6161 };
6262 }
@@ -190,3 +190,63 @@ own.stop(true);
190190 expect(report.leaks).toEqual([]);
191191 });
192192 });
193
194 describe.skipIf(nestedReason !== undefined)("a real sandboxed shell's install cache", () => {
195 let dataDir: string;
196 let report: { wrote: boolean; read: string; other: string[] | "refused" };
197
198 beforeAll(async () => {
199 dataDir = await mkdtemp(join(tmpdir(), "forge-real-cache-"));
200 const work = join(dataDir, "crew/scout/r1/1");
201 mkdirSync(work, { recursive: true });
202 mkdirSync(join(dataDir, "crew/.cache/r2/bun"), { recursive: true });
203 writeFileSync(join(dataDir, "crew/.cache/r2/bun/pkg"), CANARY);
204 const policy = sandboxPolicy({
205 dataDir,
206 agentName: "scout",
207 urls: [],
208 runId: "01m46abcdefghjkmnpqrstvwxy",
209 repoId: "r1",
210 });
211 mkdirSync(policy.installCache!, { recursive: true });
212 writeFileSync(
213 join(work, "probe.ts"),
214 `
215 import { readFileSync, readdirSync, writeFileSync } from "node:fs";
216 const cache = ${JSON.stringify(policy.installCache)};
217 let wrote = false;
218 let read = "";
219 try { writeFileSync(cache + "/pkg", "cached"); wrote = true; read = readFileSync(cache + "/pkg", "utf8"); } catch {}
220 let other: string[] | "refused";
221 try { other = readdirSync(${JSON.stringify(join(dataDir, "crew/.cache/r2"))}); } catch { other = "refused"; }
222 console.log(JSON.stringify({ wrote, read, other }));
223 `,
224 );
225 const settings = join(dataDir, "srt-settings.json");
226 const real = claudeSettings(policy).sandbox as Record<string, any>;
227 writeFileSync(settings, JSON.stringify(runtimeSettings(real, work)));
228 const proc = Bun.spawn(
229 [srt, "--settings", settings, "-c", `'${process.execPath}' probe.ts`],
230 { cwd: work, stdin: "ignore", stdout: "pipe", stderr: "pipe" },
231 );
232 const [out, err, code] = await Promise.all([
233 new Response(proc.stdout).text(),
234 new Response(proc.stderr).text(),
235 proc.exited,
236 ]);
237 if (code !== 0) throw new Error(`sandboxed shell exited ${code}: ${err}`);
238 report = JSON.parse(out.trim().split("\n").pop() ?? "{}");
239 });
240 afterAll(async () => {
241 await rm(dataDir, { recursive: true, force: true });
242 });
243
244 test("writes and reads its own repository's cache", () => {
245 expect(report.wrote).toBe(true);
246 expect(report.read).toBe("cached");
247 });
248
249 test("cannot list another repository's cache", () => {
250 expect(report.other).toBe("refused");
251 });
252 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs