Public: anyone can read this; the crew can change it.
crew-sandbox-real skips by a named cause: a syscall, a mount or a missing binary, not "nested or refused" #10755
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10745-crew-sandbox-real-skips-by-a-named-cause → main 4 commits fork 2 threads · 2 unresolved
4 files
changed +178
−8
src/crew/dispatch.ts +2
| @@ -96,6 +96,7 @@ import { | ||
| 96 | 96 | } from "./guard.ts"; |
| 97 | 97 | import { noteProtection } from "../kernel/protection.ts"; |
| 98 | 98 | import { dataInTemp, sandboxAvailable, sweepRunTmp } from "./sandbox.ts"; |
| 99 | import { sandboxCause } from "./sandbox_cause.ts"; | |
| 99 | 100 | import { mayDirectCrew } from "./direct.ts"; |
| 100 | 101 | import { |
| 101 | 102 | AUTONOMY_TICK_MS, |
| @@ -230,6 +231,7 @@ export async function startCrew( | ||
| 230 | 231 | ); |
| 231 | 232 | if (!sandbox.ok) |
| 232 | 233 | log.warn("agent sandbox not available here; runs are unrestricted", { |
| 234 | cause: sandboxCause(sandbox.reason).words, | |
| 233 | 235 | reason: sandbox.reason, |
| 234 | 236 | }); |
| 235 | 237 | const ready: CrewOptions = { |
src/crew/sandbox_cause.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * Why no sandbox can be made here, as one named cause. | |
| 3 | * | |
| 4 | * `sandboxAvailable()` (src/crew/sandbox.ts) answers a failure as text: a | |
| 5 | * missing tool, or bwrap's last stderr line wrapped as "user namespaces are | |
| 6 | * refused here (…)" whatever bwrap actually refused. A skip line that | |
| 7 | * repeats that text cannot tell a host that cannot sandbox from a hole in | |
| 8 | * the gate, so `sandboxCause` reads it into one of three named causes — | |
| 9 | * user namespaces refused, a mount denied, a missing binary — and keeps the | |
| 10 | * probe's own text for anything else (#10745). | |
| 11 | * | |
| 12 | * Plain text in, plain data out; it never runs the probe itself. | |
| 13 | */ | |
| 14 | ||
| 15 | export type SandboxCause = | |
| 16 | | { kind: "user-namespaces"; words: string } | |
| 17 | | { kind: "mount"; words: string } | |
| 18 | | { kind: "missing-binary"; binary: string; words: string } | |
| 19 | | { kind: "unknown"; words: string }; | |
| 20 | ||
| 21 | /** The fixed words each cause opens with, as the skip line says them. */ | |
| 22 | const CAUSE_WORDS = { | |
| 23 | "user-namespaces": "user namespaces refused", | |
| 24 | mount: "a mount denied", | |
| 25 | "missing-binary": "missing binary", | |
| 26 | } as const; | |
| 27 | ||
| 28 | const MISSING = [ | |
| 29 | /^(\S+) is not installed$/, | |
| 30 | /Executable not found in \$PATH: "([^"]+)"/, | |
| 31 | /ENOENT[^"]*?spawn (\S+)/, | |
| 32 | /^missing binary: (\S+)$/, | |
| 33 | ]; | |
| 34 | ||
| 35 | const NAMESPACE = /namespace|unshare|uid map|gid map|setgroups/i; | |
| 36 | const MOUNT = /mount|pivot_root|make \/ slave|remount/i; | |
| 37 | ||
| 38 | /** The bwrap line the probe wrapped, when it wrapped one. */ | |
| 39 | function bwrapLine(reason: string): string | undefined { | |
| 40 | const wrapped = /^user namespaces are refused here(?: \((.*)\))?$/s.exec(reason); | |
| 41 | if (wrapped !== null) return wrapped[1] ?? ""; | |
| 42 | return /^bwrap: /.test(reason) ? reason : undefined; | |
| 43 | } | |
| 44 | ||
| 45 | /** One named cause for the probe's failure text. */ | |
| 46 | export function sandboxCause(reason: string): SandboxCause { | |
| 47 | const text = reason.trim(); | |
| 48 | for (const pattern of MISSING) { | |
| 49 | const found = pattern.exec(text); | |
| 50 | if (found?.[1] !== undefined) { | |
| 51 | const binary = found[1].split("/").pop() ?? found[1]; | |
| 52 | return { | |
| 53 | kind: "missing-binary", | |
| 54 | binary, | |
| 55 | words: `${CAUSE_WORDS["missing-binary"]}: ${binary}`, | |
| 56 | }; | |
| 57 | } | |
| 58 | } | |
| 59 | const line = bwrapLine(text); | |
| 60 | if (line !== undefined) { | |
| 61 | const detail = line === "" ? "" : ` (${line})`; | |
| 62 | // A namespace refusal is read first: bwrap names the namespace when it | |
| 63 | // cannot make one, and only reaches its mounts once it has. | |
| 64 | if (line === "" || NAMESPACE.test(line)) | |
| 65 | return { | |
| 66 | kind: "user-namespaces", | |
| 67 | words: `${CAUSE_WORDS["user-namespaces"]}${detail}`, | |
| 68 | }; | |
| 69 | if (MOUNT.test(line)) | |
| 70 | return { kind: "mount", words: `${CAUSE_WORDS.mount}${detail}` }; | |
| 71 | } | |
| 72 | return { kind: "unknown", words: text }; | |
| 73 | } | |
| 74 | ||
| 75 | /** The skip line's reason: one named cause. */ | |
| 76 | export function skipReason(cause: SandboxCause): string { | |
| 77 | return `no sandbox can be made here: ${cause.words}`; | |
| 78 | } | |
| 79 |
test/crew-sandbox-cause.test.ts added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | /** | |
| 2 | * `sandboxCause` names why no sandbox can be made: each cause the probe | |
| 3 | * (`sandboxAvailable`) can report, and the unknown case that keeps its text | |
| 4 | * (#10745). | |
| 5 | */ | |
| 6 | import { describe, expect, test } from "bun:test"; | |
| 7 | import { sandboxCause, skipReason } from "../src/crew/sandbox_cause.ts"; | |
| 8 | ||
| 9 | const NO_NS = | |
| 10 | "bwrap: No permissions to create a new namespace, likely because " + | |
| 11 | "the kernel does not allow non-privileged user namespaces."; | |
| 12 | ||
| 13 | const wrapped = (line: string) => `user namespaces are refused here (${line})`; | |
| 14 | ||
| 15 | describe("sandboxCause", () => { | |
| 16 | test("user namespaces refused, as this probe reports them", () => { | |
| 17 | const cause = sandboxCause(wrapped(NO_NS)); | |
| 18 | expect(cause.kind).toBe("user-namespaces"); | |
| 19 | expect(cause.words).toBe(`user namespaces refused (${NO_NS})`); | |
| 20 | }); | |
| 21 | ||
| 22 | test("user namespaces refused with no bwrap line", () => { | |
| 23 | expect(sandboxCause("user namespaces are refused here")).toEqual({ | |
| 24 | kind: "user-namespaces", | |
| 25 | words: "user namespaces refused", | |
| 26 | }); | |
| 27 | }); | |
| 28 | ||
| 29 | test("a uid map refused is a namespace refused", () => { | |
| 30 | const line = "bwrap: setting up uid map: Permission denied"; | |
| 31 | expect(sandboxCause(wrapped(line)).kind).toBe("user-namespaces"); | |
| 32 | }); | |
| 33 | ||
| 34 | test("a mount denied, though the probe called it namespaces", () => { | |
| 35 | const line = | |
| 36 | "bwrap: Can't mount proc on /newroot/proc: Operation not permitted"; | |
| 37 | expect(sandboxCause(wrapped(line))).toEqual({ | |
| 38 | kind: "mount", | |
| 39 | words: `a mount denied (${line})`, | |
| 40 | }); | |
| 41 | const slave = "bwrap: Failed to make / slave: Operation not permitted"; | |
| 42 | expect(sandboxCause(wrapped(slave)).kind).toBe("mount"); | |
| 43 | }); | |
| 44 | ||
| 45 | test("a missing binary, by name", () => { | |
| 46 | expect(sandboxCause("bwrap is not installed")).toEqual({ | |
| 47 | kind: "missing-binary", | |
| 48 | binary: "bwrap", | |
| 49 | words: "missing binary: bwrap", | |
| 50 | }); | |
| 51 | expect(sandboxCause("socat is not installed").words).toBe( | |
| 52 | "missing binary: socat", | |
| 53 | ); | |
| 54 | expect(sandboxCause('Executable not found in $PATH: "bwrap"').words).toBe( | |
| 55 | "missing binary: bwrap", | |
| 56 | ); | |
| 57 | expect(sandboxCause("missing binary: srt").words).toBe( | |
| 58 | "missing binary: srt", | |
| 59 | ); | |
| 60 | }); | |
| 61 | ||
| 62 | test("an unknown failure keeps the probe's own text", () => { | |
| 63 | expect(sandboxCause("no sandbox on win32")).toEqual({ | |
| 64 | kind: "unknown", | |
| 65 | words: "no sandbox on win32", | |
| 66 | }); | |
| 67 | const odd = wrapped("bwrap: execvp /bin/true: Exec format error"); | |
| 68 | expect(sandboxCause(odd)).toEqual({ kind: "unknown", words: odd }); | |
| 69 | }); | |
| 70 | ||
| 71 | test("the skip reason opens with the cause, never the old catch-all", () => { | |
| 72 | const reason = skipReason(sandboxCause(wrapped(NO_NS))); | |
| 73 | expect( | |
| 74 | reason.startsWith("no sandbox can be made here: user namespaces refused"), | |
| 75 | ).toBe(true); | |
| 76 | expect(reason).not.toContain("nested"); | |
| 77 | expect(skipReason(sandboxCause("bwrap is not installed"))).toBe( | |
| 78 | "no sandbox can be made here: missing binary: bwrap", | |
| 79 | ); | |
| 80 | }); | |
| 81 | }); |
test/crew-sandbox-real.test.ts +16 −8
| @@ -8,7 +8,9 @@ | ||
| 8 | 8 | * shape, so dropping `filesystem.denyRead` or flipping `enabled` fails here. |
| 9 | 9 | * A sandbox cannot be made inside a sandbox, so where one cannot be made (a |
| 10 | 10 | * crew run, a container that refuses user namespaces) the test is skipped, |
| 11 | * naming itself and the reason on the console; the forge's check runs it. | |
| 11 | * naming itself and one cause on the console (`sandboxCause`: user | |
| 12 | * namespaces refused, a mount denied, a missing binary, or the probe's own | |
| 13 | * text); the forge's check runs it. | |
| 12 | 14 | * |
| 13 | 15 | * Outside localhost is checked twice: directly (the sandbox has a network of |
| 14 | 16 | * its own, so it must fail) and through the sandbox's proxy, which is the |
| @@ -31,19 +33,25 @@ import { join } from "node:path"; | ||
| 31 | 33 | import { rgPath } from "@vscode/ripgrep"; |
| 32 | 34 | import { claudeSettings } from "../src/crew/claude.ts"; |
| 33 | 35 | import { sandboxAvailable, sandboxPolicy } from "../src/crew/sandbox.ts"; |
| 36 | import { sandboxCause, skipReason } from "../src/crew/sandbox_cause.ts"; | |
| 34 | 37 | import { runtimeSettings, withoutParentProxy } from "./helpers/srt.ts"; |
| 35 | 38 | |
| 36 | 39 | const probe = await sandboxAvailable(); |
| 37 | 40 | const srt = join(import.meta.dir, "../node_modules/.bin/srt"); |
| 38 | const nestedReason = !probe.ok | |
| 39 | ? `no sandbox can be made here (nested in a crew sandbox or user namespaces refused): ${probe.reason}` | |
| 40 | : undefined; | |
| 41 | // One named cause (src/crew/sandbox_cause.ts), so the skip line tells a host | |
| 42 | // that cannot sandbox from a hole in the gate (#10745). | |
| 43 | const nestedReason = !probe.ok ? skipReason(sandboxCause(probe.reason)) : undefined; | |
| 41 | 44 | if (nestedReason !== undefined) |
| 42 | 45 | console.warn(`skipped: test/crew-sandbox-real.test.ts: ${nestedReason}`); |
| 43 | else if (!existsSync(rgPath) || !existsSync(srt)) | |
| 44 | throw new Error( | |
| 45 | "a sandbox can be made here but its runtime is missing (srt or @vscode/ripgrep's rg): run bun install", | |
| 46 | ); | |
| 46 | else | |
| 47 | for (const [binary, path] of [ | |
| 48 | ["srt", srt], | |
| 49 | ["rg", rgPath], | |
| 50 | ] as const) | |
| 51 | if (!existsSync(path)) | |
| 52 | throw new Error( | |
| 53 | `a sandbox can be made here but its runtime is not: ${sandboxCause(`missing binary: ${binary}`).words} (${path}): run bun install`, | |
| 54 | ); | |
| 47 | 55 | |
| 48 | 56 | const CANARY = "the-canary-must-not-be-read"; |
| 49 | 57 |
This loop runs over an empty set of matches, because
EXCLUDEDhas no crew-sandbox-real entry. It cannot fail, so delete it.