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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3673-an-agent-run-reuses-its-repository-s-bun → main 7 commits fork 9 threads · 9 unresolved
docs/CREW.md +2
| @@ -6,6 +6,8 @@ the package registry, writes stay in the clone and the run's own `tmpDir`, | ||
| 6 | 6 | and the machine's temp directory, the forge's data and the operator's |
| 7 | 7 | credentials are unreadable. No command runs outside it on request. |
| 8 | 8 | |
| 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 | ||
| 9 | 11 | ## Running this repository's tests inside it |
| 10 | 12 | |
| 11 | 13 | 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. | ||
| 87 | 87 | forge's repositories, index, key and every other agent's clone are denied by |
| 88 | 88 | name and by allow-list, and a listing that fails refuses the run. |
| 89 | 89 | 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 | |
| 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` | |
| 90 | 97 | - The same policy holds against a real bubblewrap, not only as a string. |
| 91 | 98 | Test: `test/crew-sandbox-real.test.ts` |
| 92 | 99 | - An agent cannot push to the default branch or merge; a person merges. |
src/actions/accounts.ts +2
| @@ -23,6 +23,7 @@ import { | ||
| 23 | 23 | setSuspended, |
| 24 | 24 | type AccountRow, |
| 25 | 25 | } from "../core/accounts.ts"; |
| 26 | import { removeInstallCache } from "../crew/workdir.ts"; | |
| 26 | 27 | import { dropMirror } from "../crew/mirror.ts"; |
| 27 | 28 | import { deleteRepo as destroyRepo, listReposOwnedBy } from "../core/repos.ts"; |
| 28 | 29 | import { listActive } from "../core/runs.ts"; |
| @@ -185,6 +186,7 @@ async function removeOwnedRepos( | ||
| 185 | 186 | const label = `${owner.username}/${repo.name}`; |
| 186 | 187 | const deleted = await destroyRepo(app.db, app.reposRoot, repo); |
| 187 | 188 | if (!deleted.ok) return label; |
| 189 | await removeInstallCache(app.config.dataDir, repo.id); | |
| 188 | 190 | dropMirror(app.config.dataDir, repo.id); |
| 189 | 191 | removed.push(label); |
| 190 | 192 | if (owner.kind === "org") |
src/actions/repos.ts +2
| @@ -62,6 +62,7 @@ import { | ||
| 62 | 62 | requireWriter, |
| 63 | 63 | type AccessError, |
| 64 | 64 | } from "./access.ts"; |
| 65 | import { removeInstallCache } from "../crew/workdir.ts"; | |
| 65 | 66 | |
| 66 | 67 | export interface CreateRepoInput { |
| 67 | 68 | /** Owner name: the actor themselves (default) or an org they can write to. */ |
| @@ -503,6 +504,7 @@ export async function deleteRepo( | ||
| 503 | 504 | if (!admin.ok) return admin; |
| 504 | 505 | const deleted = await destroyRepo(app.db, app.reposRoot, repo); |
| 505 | 506 | if (!deleted.ok) return deleted; |
outdated
Untested: |
||
| 507 | await removeInstallCache(app.config.dataDir, repo.id); | |
| 506 | 508 | dropMirror(app.config.dataDir, repo.id); |
| 507 | 509 | storageChanged(app, repo.ownerId); |
| 508 | 510 | // 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( | ||
| 122 | 122 | // An app under test listens on a local port. |
| 123 | 123 | allowLocalBinding: true, |
| 124 | 124 | }, |
| 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 | }, | |
| 126 | 134 | }, |
| 127 | 135 | permissions: { |
| 128 | 136 | allow: [...TOOLS], |
src/crew/runner.ts +7 −3
| @@ -137,7 +137,7 @@ import { | ||
| 137 | 137 | type UsageOutcome, |
| 138 | 138 | } from "../core/usage.ts"; |
| 139 | 139 | import { trackAgentRun, untrackAgentRun } from "./running.ts"; |
| 140 | import { sandboxPolicy } from "./sandbox.ts"; | |
| 140 | import { installCacheEnv, sandboxPolicy } from "./sandbox.ts"; | |
| 141 | 141 | import { grantSecrets, parseDeclaration, type Grant } from "./grant.ts"; |
| 142 | 142 | import { historyHolds, scrubTree } from "./leak.ts"; |
| 143 | 143 | import { removeClone, workdirFor } from "./workdir.ts"; |
| @@ -1182,6 +1182,7 @@ async function work( | ||
| 1182 | 1182 | ], |
| 1183 | 1183 | enforced: options.sandboxEnforced, |
| 1184 | 1184 | runId: run.id, |
| 1185 | repoId: repo.id, | |
| 1185 | 1186 | }); |
| 1186 | 1187 | const sandbox = shellPolicy(); |
| 1187 | 1188 | // The run's own temp directory, never the machine's shared one; it goes |
| @@ -1194,6 +1195,9 @@ async function work( | ||
| 1194 | 1195 | env["CLAUDE_CODE_TMPDIR"] = runTmp; |
| 1195 | 1196 | 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. |
||
| 1196 | 1197 | } |
| 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); | |
| 1197 | 1201 | // The sandbox refuses a unix socket's bind; the forge's own servers take |
| 1198 | 1202 | // a loopback port instead (`git/hook-address.ts`), so `bun test` runs. |
| 1199 | 1203 | if (sandbox.enforced) env["FORGE_TEST_SOCKET"] = "port"; |
| @@ -1794,8 +1798,8 @@ async function prepareClone( | ||
| 1794 | 1798 | env, |
| 1795 | 1799 | }); |
| 1796 | 1800 | // 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 | |
| 1799 | 1803 | // commits leftovers with `git add -A`, so the clone says so once, in its |
| 1800 | 1804 | // own exclude file — the repository's `.gitignore` is the repository's. |
| 1801 | 1805 | mkdirSync(join(dir, ".git", "info"), { recursive: true }); |
src/crew/sandbox.ts +39 −4
| @@ -25,14 +25,17 @@ | ||
| 25 | 25 | * `CLAUDE_CODE_TMPDIR` and `TMPDIR`) under the agent's own `.t/`; the |
| 26 | 26 | * machine's temp directory, which holds every other session's files and |
| 27 | 27 | * 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`). | |
| 30 | 33 | */ |
| 31 | 34 | |
| 32 | import { readdirSync, rmSync, statSync } from "node:fs"; | |
| 35 | import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs"; | |
| 33 | 36 | import { homedir, tmpdir } from "node:os"; |
| 34 | 37 | import { basename, isAbsolute, join, relative, sep } from "node:path"; |
| 35 | import { crewRoot } from "./workdir.ts"; | |
| 38 | import { cacheRoot, crewRoot, installCacheDir } from "./workdir.ts"; | |
| 36 | 39 | |
| 37 | 40 | export interface SandboxPolicy { |
| 38 | 41 | /** Hostnames the shell may connect to. */ |
| @@ -48,6 +51,26 @@ export interface SandboxPolicy { | ||
| 48 | 51 | readonly enforced: boolean; |
| 49 | 52 | /** This run's own temp directory, when the policy was asked for a run. */ |
| 50 | 53 | 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; | |
| 51 | 74 | } |
| 52 | 75 | |
| 53 | 76 | /** |
| @@ -209,6 +232,8 @@ export function sandboxPolicy(input: { | ||
| 209 | 232 | enforced?: boolean | undefined; |
| 210 | 233 | /** The run this is for: it gets a temp directory of its own (`tmpDir`). */ |
| 211 | 234 | runId?: string | undefined; |
| 235 | /** The repository the run is on: its install cache is writable (`installCache`). */ | |
| 236 | repoId?: string | undefined; | |
| 212 | 237 | }): SandboxPolicy { |
| 213 | 238 | const hosts = new Set<string>(["registry.npmjs.org"]); |
| 214 | 239 | for (const url of input.urls) { |
| @@ -242,6 +267,10 @@ export function sandboxPolicy(input: { | ||
| 242 | 267 | input.runId === undefined |
outdated
Low: this comment describes
outdated
Nit, not blocking: |
||
| 243 | 268 | ? undefined |
| 244 | 269 | : runTmpDir(data, input.agentName, input.runId); |
| 270 | const installCache = | |
| 271 | input.repoId === undefined | |
| 272 | ? undefined | |
| 273 | : installCacheDir(data, input.repoId); | |
| 245 | 274 | const deny = [ |
| 246 | 275 | // The floor: named, whether or not the listing below sees them. |
| 247 | 276 | ...ALWAYS_DENIED.map((name) => join(data, name)), |
| @@ -257,6 +286,10 @@ export function sandboxPolicy(input: { | ||
| 257 | 286 | // The machine's temp directory: every other session's files, and the |
| 258 | 287 | // forge's own (a cell's sockets, a checkout's index). |
| 259 | 288 | ...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. |
||
| 260 | 293 | ]; |
| 261 | 294 | const home = homedir(); |
| 262 | 295 | for (const secret of HOME_SECRETS) deny.push(join(home, secret)); |
| @@ -264,6 +297,8 @@ export function sandboxPolicy(input: { | ||
| 264 | 297 | allowHosts: [...hosts], |
| 265 | 298 | denyRead: deny, |
| 266 | 299 | enforced: input.enforced ?? true, |
| 300 | readAllow: installCache === undefined ? [] : [installCache], | |
| 267 | 301 | ...(tmpDir === undefined ? {} : { tmpDir }), |
| 302 | ...(installCache === undefined ? {} : { installCache }), | |
| 268 | 303 | }; |
| 269 | 304 | } |
src/crew/workdir.ts +24
| @@ -25,6 +25,30 @@ export function workdirFor( | ||
| 25 | 25 | ); |
| 26 | 26 | } |
| 27 | 27 | |
| 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 | ||
| 28 | 52 | /** |
| 29 | 53 | * Remove a clone without blocking the event loop: a leftover process still |
| 30 | 54 | * 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( | ||
| 56 | 56 | denyRead: [], |
| 57 | 57 | denyWrite: [], |
| 58 | 58 | ...sandbox.filesystem, |
| 59 | allowWrite: [workDir], | |
| 59 | allowWrite: [workDir, ...(sandbox.filesystem?.allowWrite ?? [])], | |
| 60 | 60 | }, |
| 61 | 61 | }; |
| 62 | 62 | } |
| @@ -190,3 +190,63 @@ own.stop(true); | ||
| 190 | 190 | expect(report.leaks).toEqual([]); |
| 191 | 191 | }); |
| 192 | 192 | }); |
| 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 | }); |
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.