From 3177f472745b9dd6e7bba8f32675633b2dde2a20 Mon Sep 17 00:00:00 2001 From: flint Date: Sat, 10 Oct 2026 10:17:59 +0000 Subject: [PATCH 1/8] test(sandbox): the launch-control HOME sits outside every launcher grant (cli#558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launch-control suite created its test HOME under a fixed /var/tmp. A launcher grants bun's temp dir (/tmp) and the toolchain/interpreter read roots, and the runtime-options gate refuses a HOME that falls inside any of them — before the case under test runs. With TMPDIR pointed at /tmp on macOS the test HOME could land inside that grant and the suite reported a wall of misleading early refusals. Choose the HOME base from the launcher's own grant sources (BUN_TEMP_DIR and harnessReadPaths) so it always sits outside every launcher grant, whatever TMPDIR is; if no candidate does, refuse up front with one message naming TMPDIR and the grant it falls inside. The list is read from the launcher, not duplicated here. --- packages/cli/src/commands/agent.ts | 3 +- packages/cli/src/utils/nono.ts | 7 +++ .../cli/test/sandbox-launch-control.test.ts | 49 +++++++++++++++++-- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 73cd471b..98005a73 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -26,6 +26,7 @@ import { isSupervised, harnessReadPaths, harnessReadFiles, + BUN_TEMP_DIR, runtimeNonoOptions, runtimeNonoProfile, approveRuntimeNonoOptions, @@ -909,7 +910,7 @@ export async function runAgent(args: AgentArgs): Promise { // configured TMPDIR (cli#350 r4g). On macOS launchd sets TMPDIR // to /var/folders/…, so /tmp would otherwise not be granted at // all; on Linux TMPDIR is usually /tmp and the Set dedupes. - allow: [...new Set([mailDir, tmpDir, "/tmp", config.workspace, agentDir, ...(runtimeGrants.allow ?? [])])], + allow: [...new Set([mailDir, tmpDir, BUN_TEMP_DIR, config.workspace, agentDir, ...(runtimeGrants.allow ?? [])])], }; const profile = runtimeNonoProfile(selectedRuntime); const approval = approveRuntimeNonoOptions(selectedRuntime, { ...launchOptions, cwd: process.cwd() }, process.env, launchId); diff --git a/packages/cli/src/utils/nono.ts b/packages/cli/src/utils/nono.ts index 2ee76ce6..60026cfc 100644 --- a/packages/cli/src/utils/nono.ts +++ b/packages/cli/src/utils/nono.ts @@ -263,6 +263,13 @@ export function systemReadFiles(): string[] { return systemReadFileCandidates().filter((f) => existsSync(f)); } +/** + * Bun's own temp dir: `/tmp` regardless of TMPDIR (cli#350 r4g). The agent + * launcher grants it beside the configured TMPDIR; defined once so the launcher + * and anything that must sit outside its grants cannot drift (cli#558). + */ +export const BUN_TEMP_DIR = "/tmp"; + /** * Read grants every TPS harness family needs: the agent identity dir, the bun * cache, the running interpreter's own directory, plus the system roots. One diff --git a/packages/cli/test/sandbox-launch-control.test.ts b/packages/cli/test/sandbox-launch-control.test.ts index 4d1bbfb3..4468a365 100644 --- a/packages/cli/test/sandbox-launch-control.test.ts +++ b/packages/cli/test/sandbox-launch-control.test.ts @@ -19,6 +19,7 @@ */ import { describe, test, expect, beforeAll } from "bun:test"; import { resolve, join } from "node:path"; +import { tmpdir } from "node:os"; import { existsSync, mkdtempSync, @@ -30,7 +31,7 @@ import { rmSync, } from "node:fs"; import { spawnSync } from "node:child_process"; -import { evaluateLaunchControl } from "../src/utils/nono.js"; +import { evaluateLaunchControl, harnessReadPaths, BUN_TEMP_DIR } from "../src/utils/nono.js"; import meow from "meow"; import { buildPlist } from "../src/commands/mail-watch.js"; import { generateOfficePlist, generateTunnelPlist } from "../src/commands/office-supervision.js"; @@ -40,6 +41,46 @@ const SANDBOX_REQUIRED = "--sandbox-required"; const NO_SANDBOX = "--no-sandbox"; const SUPERVISED = "TPS_SUPERVISED"; +/** + * The launcher's grants that do NOT move with the agent's HOME: bun's temp dir + * and the toolchain/interpreter read roots (cli#350 r4g, cli#341 S1b). A test + * HOME created inside any of them makes the launcher's runtime-options gate + * refuse before the case under test runs (cli#558), so the launch-control HOME + * must sit outside every one. Read from the launcher's own definitions so this + * list cannot become a second copy that drifts from the code. + */ +const LAUNCHER_FIXED_GRANTS = [BUN_TEMP_DIR, ...harnessReadPaths()]; + +/** The launcher grant that covers `path` (equal, or an ancestor directory), or null. */ +function launcherGrantCovering(path: string): string | null { + const target = resolve(path); + for (const grant of LAUNCHER_FIXED_GRANTS) { + const root = resolve(grant); + if (target === root || target.startsWith(root.endsWith("/") ? root : `${root}/`)) return grant; + } + return null; +} + +/** + * A base directory for the launch-control test HOME that lies outside every + * launcher grant, whatever TMPDIR is (cli#558). `/var/tmp` is the launcher's + * sibling temp root and sits outside the always-granted `/tmp`; the process temp + * dir is the fallback. If TMPDIR has been pointed at a directory that covers both + * candidates, refuse up front, naming TMPDIR and the grant it falls inside, + * rather than let the gate turn every case into a misleading early refusal. + */ +function launchControlHomeBase(): string { + for (const candidate of ["/var/tmp", tmpdir()]) { + if (existsSync(candidate) && launcherGrantCovering(candidate) === null) return candidate; + } + const grant = launcherGrantCovering(tmpdir()); + throw new Error( + `the launch-control tests need a HOME outside every launcher grant, but TMPDIR=${tmpdir()} ` + + `falls inside the launcher's '${grant}' grant — point TMPDIR at a directory outside it ` + + `(for example /var/tmp) and re-run.`, + ); +} + /** Run the built launcher with piped stdio (stdin/stdout are NOT a TTY). */ function runLauncher(args: string[], env: Record = {}) { return spawnSync("bun", [TPS_BIN, ...args], { @@ -117,9 +158,9 @@ describe("T3 — missing --sandbox-required is refused in non-TTY", () => { describe("T5 — the pinned-path launch spawns nono and the child argv asserts the flags", () => { test("agent start --sandbox-required with a fake nono at NONO_BIN: the run argv carries both flags", () => { - // OUTSIDE /tmp: the launch grants /tmp too (cli#350 r4g), so a /tmp HOME would - // sit inside that grant and the overlap assert would refuse before spawning. - const base = "/var/tmp"; + // A HOME inside a launcher grant makes the runtime-options gate refuse + // before the case under test, whatever TMPDIR is (cli#558). + const base = launchControlHomeBase(); const home = mkdtempSync(join(base, "tps-reexec-argv-")); try { const nonoDir = join(home, "nono"); From 2c91ad233152a395b5ee3101e2d62dc5242269f2 Mon Sep 17 00:00:00 2001 From: flint Date: Sat, 10 Oct 2026 10:21:35 +0000 Subject: [PATCH 2/8] test(sandbox): tighten the grant-scope comments (cli#558) --- packages/cli/src/utils/nono.ts | 2 +- packages/cli/test/sandbox-launch-control.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/nono.ts b/packages/cli/src/utils/nono.ts index 60026cfc..1e5262c7 100644 --- a/packages/cli/src/utils/nono.ts +++ b/packages/cli/src/utils/nono.ts @@ -266,7 +266,7 @@ export function systemReadFiles(): string[] { /** * Bun's own temp dir: `/tmp` regardless of TMPDIR (cli#350 r4g). The agent * launcher grants it beside the configured TMPDIR; defined once so the launcher - * and anything that must sit outside its grants cannot drift (cli#558). + * and its readers stay in step (cli#558). */ export const BUN_TEMP_DIR = "/tmp"; diff --git a/packages/cli/test/sandbox-launch-control.test.ts b/packages/cli/test/sandbox-launch-control.test.ts index 4468a365..67c84954 100644 --- a/packages/cli/test/sandbox-launch-control.test.ts +++ b/packages/cli/test/sandbox-launch-control.test.ts @@ -47,7 +47,7 @@ const SUPERVISED = "TPS_SUPERVISED"; * HOME created inside any of them makes the launcher's runtime-options gate * refuse before the case under test runs (cli#558), so the launch-control HOME * must sit outside every one. Read from the launcher's own definitions so this - * list cannot become a second copy that drifts from the code. + * list stays in step with the code. */ const LAUNCHER_FIXED_GRANTS = [BUN_TEMP_DIR, ...harnessReadPaths()]; @@ -67,7 +67,7 @@ function launcherGrantCovering(path: string): string | null { * sibling temp root and sits outside the always-granted `/tmp`; the process temp * dir is the fallback. If TMPDIR has been pointed at a directory that covers both * candidates, refuse up front, naming TMPDIR and the grant it falls inside, - * rather than let the gate turn every case into a misleading early refusal. + * rather than let the gate report a misleading early refusal. */ function launchControlHomeBase(): string { for (const candidate of ["/var/tmp", tmpdir()]) { From 5c8224ddbc8943b97c885cff89dfa1070c415f04 Mon Sep 17 00:00:00 2001 From: flint Date: Sat, 10 Oct 2026 10:32:54 +0000 Subject: [PATCH 3/8] test(sandbox): the runtime-launch fixture's sandbox base sits outside every launcher grant (cli#558) --- packages/cli/test/helpers/launcher-grants.ts | 46 +++++++++++++++++++ .../test/helpers/runtime-launch-fixture.ts | 7 +-- .../cli/test/sandbox-launch-control.test.ts | 46 ++----------------- 3 files changed, 53 insertions(+), 46 deletions(-) create mode 100644 packages/cli/test/helpers/launcher-grants.ts diff --git a/packages/cli/test/helpers/launcher-grants.ts b/packages/cli/test/helpers/launcher-grants.ts new file mode 100644 index 00000000..21965d60 --- /dev/null +++ b/packages/cli/test/helpers/launcher-grants.ts @@ -0,0 +1,46 @@ +/** + * The launcher's grants that are not under the test sandbox HOME, read from the + * launcher's own definitions (cli#558). A test sandbox HOME created inside any + * of them makes the launcher's runtime-options gate refuse before the case + * under test runs, so every test sandbox base must sit outside all of them. + * + * The two callers are the launch-control test and the runtime-launch fixture: + * one shared list and one shared chooser keep them from drifting apart. + */ +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { BUN_TEMP_DIR, harnessReadPaths } from "../../src/utils/nono.js"; + +/** Bun's temp dir and the toolchain/interpreter read roots (cli#350 r4g, cli#341 S1b). */ +export const LAUNCHER_FIXED_GRANTS = [BUN_TEMP_DIR, ...harnessReadPaths()]; + +/** The launcher grant that covers `path` (equal, or an ancestor directory), or null. */ +export function launcherGrantCovering(path: string): string | null { + const target = resolve(path); + for (const grant of LAUNCHER_FIXED_GRANTS) { + const root = resolve(grant); + if (target === root || target.startsWith(root.endsWith("/") ? root : `${root}/`)) return grant; + } + return null; +} + +/** + * A base directory for a test sandbox HOME that lies outside every launcher + * grant (cli#558). `/var/tmp` is the sibling of the always-granted `/tmp` and + * sits outside it; the process temp dir is the fallback. If a TMPDIR leaves + * neither candidate outside the grants, refuse up front, naming TMPDIR and the + * grant tmpdir() falls inside, rather than let the launcher's gate report a + * misleading early refusal. + */ +export function sandboxHomeBase(): string { + for (const candidate of ["/var/tmp", tmpdir()]) { + if (existsSync(candidate) && launcherGrantCovering(candidate) === null) return candidate; + } + const grant = launcherGrantCovering(tmpdir()); + throw new Error( + `a test sandbox HOME needs a base outside every launcher grant, but TMPDIR=${tmpdir()} ` + + `falls inside the launcher's '${grant}' grant — point TMPDIR at a directory outside it ` + + `(for example /var/tmp) and re-run.`, + ); +} diff --git a/packages/cli/test/helpers/runtime-launch-fixture.ts b/packages/cli/test/helpers/runtime-launch-fixture.ts index d5fedb2f..6c8e27ba 100644 --- a/packages/cli/test/helpers/runtime-launch-fixture.ts +++ b/packages/cli/test/helpers/runtime-launch-fixture.ts @@ -1,6 +1,6 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; import { join } from "node:path"; +import { sandboxHomeBase } from "./launcher-grants.js"; export interface Sandbox { root: string; @@ -44,8 +44,9 @@ function seedHome(home: string, ws: string): void { } export function makeSandbox(): Sandbox { - const base = process.platform === "linux" ? "/var/tmp" : tmpdir(); - const root = mkdtempSync(join(base, "tps-363-rt-")); + // The base must lie outside every launcher grant (cli#558): a HOME inside one + // makes the launcher's runtime-options gate refuse before the case under test. + const root = mkdtempSync(join(sandboxHomeBase(), "tps-363-rt-")); const home = join(root, "home"); const tmp = join(root, "tmp"); const ws = join(root, "ws"); diff --git a/packages/cli/test/sandbox-launch-control.test.ts b/packages/cli/test/sandbox-launch-control.test.ts index 67c84954..a69352c2 100644 --- a/packages/cli/test/sandbox-launch-control.test.ts +++ b/packages/cli/test/sandbox-launch-control.test.ts @@ -19,7 +19,6 @@ */ import { describe, test, expect, beforeAll } from "bun:test"; import { resolve, join } from "node:path"; -import { tmpdir } from "node:os"; import { existsSync, mkdtempSync, @@ -31,7 +30,8 @@ import { rmSync, } from "node:fs"; import { spawnSync } from "node:child_process"; -import { evaluateLaunchControl, harnessReadPaths, BUN_TEMP_DIR } from "../src/utils/nono.js"; +import { evaluateLaunchControl } from "../src/utils/nono.js"; +import { sandboxHomeBase } from "./helpers/launcher-grants.js"; import meow from "meow"; import { buildPlist } from "../src/commands/mail-watch.js"; import { generateOfficePlist, generateTunnelPlist } from "../src/commands/office-supervision.js"; @@ -41,46 +41,6 @@ const SANDBOX_REQUIRED = "--sandbox-required"; const NO_SANDBOX = "--no-sandbox"; const SUPERVISED = "TPS_SUPERVISED"; -/** - * The launcher's grants that do NOT move with the agent's HOME: bun's temp dir - * and the toolchain/interpreter read roots (cli#350 r4g, cli#341 S1b). A test - * HOME created inside any of them makes the launcher's runtime-options gate - * refuse before the case under test runs (cli#558), so the launch-control HOME - * must sit outside every one. Read from the launcher's own definitions so this - * list stays in step with the code. - */ -const LAUNCHER_FIXED_GRANTS = [BUN_TEMP_DIR, ...harnessReadPaths()]; - -/** The launcher grant that covers `path` (equal, or an ancestor directory), or null. */ -function launcherGrantCovering(path: string): string | null { - const target = resolve(path); - for (const grant of LAUNCHER_FIXED_GRANTS) { - const root = resolve(grant); - if (target === root || target.startsWith(root.endsWith("/") ? root : `${root}/`)) return grant; - } - return null; -} - -/** - * A base directory for the launch-control test HOME that lies outside every - * launcher grant, whatever TMPDIR is (cli#558). `/var/tmp` is the launcher's - * sibling temp root and sits outside the always-granted `/tmp`; the process temp - * dir is the fallback. If TMPDIR has been pointed at a directory that covers both - * candidates, refuse up front, naming TMPDIR and the grant it falls inside, - * rather than let the gate report a misleading early refusal. - */ -function launchControlHomeBase(): string { - for (const candidate of ["/var/tmp", tmpdir()]) { - if (existsSync(candidate) && launcherGrantCovering(candidate) === null) return candidate; - } - const grant = launcherGrantCovering(tmpdir()); - throw new Error( - `the launch-control tests need a HOME outside every launcher grant, but TMPDIR=${tmpdir()} ` + - `falls inside the launcher's '${grant}' grant — point TMPDIR at a directory outside it ` + - `(for example /var/tmp) and re-run.`, - ); -} - /** Run the built launcher with piped stdio (stdin/stdout are NOT a TTY). */ function runLauncher(args: string[], env: Record = {}) { return spawnSync("bun", [TPS_BIN, ...args], { @@ -160,7 +120,7 @@ describe("T5 — the pinned-path launch spawns nono and the child argv asserts t test("agent start --sandbox-required with a fake nono at NONO_BIN: the run argv carries both flags", () => { // A HOME inside a launcher grant makes the runtime-options gate refuse // before the case under test, whatever TMPDIR is (cli#558). - const base = launchControlHomeBase(); + const base = sandboxHomeBase(); const home = mkdtempSync(join(base, "tps-reexec-argv-")); try { const nonoDir = join(home, "nono"); From 2ffe3e7dcde61e020ee95a3c74f3cc5d6b6441e1 Mon Sep 17 00:00:00 2001 From: flint Date: Sat, 10 Oct 2026 04:21:57 -0700 Subject: [PATCH 4/8] test(cli): the sandbox-base refusal is reachable and tested; no-candidate gets its own message Co-Authored-By: Claude Opus 5.5 --- packages/cli/test/helpers/launcher-grants.ts | 20 ++++++++---- packages/cli/test/launcher-grants.test.ts | 32 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 packages/cli/test/launcher-grants.test.ts diff --git a/packages/cli/test/helpers/launcher-grants.ts b/packages/cli/test/helpers/launcher-grants.ts index 21965d60..8070ed11 100644 --- a/packages/cli/test/helpers/launcher-grants.ts +++ b/packages/cli/test/helpers/launcher-grants.ts @@ -33,14 +33,22 @@ export function launcherGrantCovering(path: string): string | null { * grant tmpdir() falls inside, rather than let the launcher's gate report a * misleading early refusal. */ -export function sandboxHomeBase(): string { - for (const candidate of ["/var/tmp", tmpdir()]) { +export function sandboxHomeBase(candidates: string[] = ["/var/tmp", tmpdir()]): string { + for (const candidate of candidates) { if (existsSync(candidate) && launcherGrantCovering(candidate) === null) return candidate; } - const grant = launcherGrantCovering(tmpdir()); + const existing = candidates.filter((c) => existsSync(c)); + if (existing.length === 0) { + throw new Error( + `no usable base directory exists (checked ${candidates.join(", ")}); ` + + "a test sandbox HOME needs a base outside every launcher grant.", + ); + } + const grant = launcherGrantCovering(existing[0]); + const suggestion = existing.includes("/var/tmp") ? "" : " (for example /var/tmp)"; throw new Error( - `a test sandbox HOME needs a base outside every launcher grant, but TMPDIR=${tmpdir()} ` + - `falls inside the launcher's '${grant}' grant — point TMPDIR at a directory outside it ` + - `(for example /var/tmp) and re-run.`, + `a test sandbox HOME needs a base outside every launcher grant, but '${existing[0]}' ` + + `(TMPDIR=${tmpdir()}) falls inside the launcher's '${grant}' grant — point TMPDIR at a ` + + `directory outside it${suggestion} and re-run.`, ); } diff --git a/packages/cli/test/launcher-grants.test.ts b/packages/cli/test/launcher-grants.test.ts new file mode 100644 index 00000000..f6378aec --- /dev/null +++ b/packages/cli/test/launcher-grants.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { launcherGrantCovering, sandboxHomeBase } from "./helpers/launcher-grants.js"; + +describe("sandboxHomeBase", () => { + test("refuses when every existing candidate is inside a launcher grant, naming TMPDIR and the grant", () => { + expect(() => sandboxHomeBase(["/tmp", "/tmp/x"])).toThrow(/TMPDIR=/); + expect(() => sandboxHomeBase(["/tmp", "/tmp/x"])).toThrow(/'\/tmp'/); + }); + + test("suggests /var/tmp when it was not among the rejected candidates", () => { + expect(() => sandboxHomeBase(["/tmp"])).toThrow(/for example \/var\/tmp/); + }); + + test("returns a candidate that lies outside every grant", () => { + expect(launcherGrantCovering("/var/tmp")).toBeNull(); + expect(sandboxHomeBase(["/tmp", "/var/tmp"])).toBe("/var/tmp"); + }); + + test("names the missing candidates when none of them exists", () => { + const missing = ["/var/tmp/launcher-grants-missing-a", "/var/tmp/launcher-grants-missing-b"]; + expect(() => sandboxHomeBase(missing)).toThrow(/no usable base directory exists/); + expect(() => sandboxHomeBase(missing)).toThrow(/missing-a, \/var\/tmp\/launcher-grants-missing-b/); + expect(() => sandboxHomeBase(missing)).not.toThrow(/'null'/); + }); +}); + +describe("launcherGrantCovering", () => { + test("returns the grant for a path under it and null across the prefix boundary", () => { + expect(launcherGrantCovering("/tmp/a")).toBe("/tmp"); + expect(launcherGrantCovering("/tmpfoo")).toBeNull(); + }); +}); From 84a8691f4c32244bbc2b78d95d656a1dd15c2408 Mon Sep 17 00:00:00 2001 From: flint Date: Sat, 10 Oct 2026 05:04:13 -0700 Subject: [PATCH 5/8] test(cli): the sandbox base and grant checks compare real paths (darwin /var/tmp symlink) Co-Authored-By: Claude Opus 5.5 --- packages/cli/test/helpers/launcher-grants.ts | 20 ++++++++--- packages/cli/test/launcher-grants.test.ts | 38 +++++++++++++++++++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/cli/test/helpers/launcher-grants.ts b/packages/cli/test/helpers/launcher-grants.ts index 8070ed11..e1123800 100644 --- a/packages/cli/test/helpers/launcher-grants.ts +++ b/packages/cli/test/helpers/launcher-grants.ts @@ -7,19 +7,29 @@ * The two callers are the launch-control test and the runtime-launch fixture: * one shared list and one shared chooser keep them from drifting apart. */ -import { existsSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; -import { resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { BUN_TEMP_DIR, harnessReadPaths } from "../../src/utils/nono.js"; /** Bun's temp dir and the toolchain/interpreter read roots (cli#350 r4g, cli#341 S1b). */ export const LAUNCHER_FIXED_GRANTS = [BUN_TEMP_DIR, ...harnessReadPaths()]; +function realOrResolved(path: string): string { + const abs = resolve(path); + try { + return realpathSync(abs); + } catch { + const parent = dirname(abs); + return parent === abs ? abs : join(realOrResolved(parent), basename(abs)); + } +} + /** The launcher grant that covers `path` (equal, or an ancestor directory), or null. */ export function launcherGrantCovering(path: string): string | null { - const target = resolve(path); + const target = realOrResolved(path); for (const grant of LAUNCHER_FIXED_GRANTS) { - const root = resolve(grant); + const root = realOrResolved(grant); if (target === root || target.startsWith(root.endsWith("/") ? root : `${root}/`)) return grant; } return null; @@ -35,7 +45,7 @@ export function launcherGrantCovering(path: string): string | null { */ export function sandboxHomeBase(candidates: string[] = ["/var/tmp", tmpdir()]): string { for (const candidate of candidates) { - if (existsSync(candidate) && launcherGrantCovering(candidate) === null) return candidate; + if (existsSync(candidate) && launcherGrantCovering(candidate) === null) return realpathSync(candidate); } const existing = candidates.filter((c) => existsSync(c)); if (existing.length === 0) { diff --git a/packages/cli/test/launcher-grants.test.ts b/packages/cli/test/launcher-grants.test.ts index f6378aec..a301b0b3 100644 --- a/packages/cli/test/launcher-grants.test.ts +++ b/packages/cli/test/launcher-grants.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { launcherGrantCovering, sandboxHomeBase } from "./helpers/launcher-grants.js"; describe("sandboxHomeBase", () => { @@ -13,7 +16,23 @@ describe("sandboxHomeBase", () => { test("returns a candidate that lies outside every grant", () => { expect(launcherGrantCovering("/var/tmp")).toBeNull(); - expect(sandboxHomeBase(["/tmp", "/var/tmp"])).toBe("/var/tmp"); + expect(sandboxHomeBase(["/tmp", "/var/tmp"])).toBe(realpathSync("/var/tmp")); + }); + + test("returns the real path of a candidate reached through a symlink", () => { + const dir = mkdtempSync(join(tmpdir(), "lg-link-")); + try { + const link = join(dir, "link"); + try { + symlinkSync(realpathSync("/var/tmp"), link); + } catch (err) { + console.warn(`symlink creation refused, case skipped: ${err}`); + return; + } + expect(sandboxHomeBase([link])).toBe(realpathSync("/var/tmp")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); test("names the missing candidates when none of them exists", () => { @@ -29,4 +48,21 @@ describe("launcherGrantCovering", () => { expect(launcherGrantCovering("/tmp/a")).toBe("/tmp"); expect(launcherGrantCovering("/tmpfoo")).toBeNull(); }); + + test("recognises a path under a symlink to /tmp as covered by /tmp", () => { + const dir = mkdtempSync(join(tmpdir(), "lg-link-")); + try { + const link = join(dir, "tmplink"); + try { + symlinkSync("/tmp", link); + } catch (err) { + console.warn(`symlink creation refused, case skipped: ${err}`); + return; + } + expect(launcherGrantCovering(join(link, "a"))).toBe("/tmp"); + expect(launcherGrantCovering(realpathSync("/tmp"))).toBe("/tmp"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); From 63b9ec039dae66593682f15a11aee787afdd1691 Mon Sep 17 00:00:00 2001 From: flint Date: Sat, 10 Oct 2026 05:41:49 -0700 Subject: [PATCH 6/8] test(cli): the sandbox base skips a candidate where a test directory cannot be created Co-Authored-By: Claude Opus 5.5 --- packages/cli/test/helpers/launcher-grants.ts | 18 ++++++++++++++---- packages/cli/test/launcher-grants.test.ts | 20 +++++++++++++++++++- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/cli/test/helpers/launcher-grants.ts b/packages/cli/test/helpers/launcher-grants.ts index e1123800..a5bdb755 100644 --- a/packages/cli/test/helpers/launcher-grants.ts +++ b/packages/cli/test/helpers/launcher-grants.ts @@ -7,7 +7,7 @@ * The two callers are the launch-control test and the runtime-launch fixture: * one shared list and one shared chooser keep them from drifting apart. */ -import { existsSync, realpathSync } from "node:fs"; +import { mkdtempSync, realpathSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { BUN_TEMP_DIR, harnessReadPaths } from "../../src/utils/nono.js"; @@ -35,6 +35,16 @@ export function launcherGrantCovering(path: string): string | null { return null; } +function canCreateDirIn(candidate: string): boolean { + try { + if (!statSync(candidate).isDirectory()) return false; + rmSync(mkdtempSync(join(candidate, "lg-probe-")), { recursive: true }); + return true; + } catch { + return false; + } +} + /** * A base directory for a test sandbox HOME that lies outside every launcher * grant (cli#558). `/var/tmp` is the sibling of the always-granted `/tmp` and @@ -45,12 +55,12 @@ export function launcherGrantCovering(path: string): string | null { */ export function sandboxHomeBase(candidates: string[] = ["/var/tmp", tmpdir()]): string { for (const candidate of candidates) { - if (existsSync(candidate) && launcherGrantCovering(candidate) === null) return realpathSync(candidate); + if (canCreateDirIn(candidate) && launcherGrantCovering(candidate) === null) return realpathSync(candidate); } - const existing = candidates.filter((c) => existsSync(c)); + const existing = candidates.filter((c) => canCreateDirIn(c)); if (existing.length === 0) { throw new Error( - `no usable base directory exists (checked ${candidates.join(", ")}); ` + + `no usable base directory exists or is writable (checked ${candidates.join(", ")}); ` + "a test sandbox HOME needs a base outside every launcher grant.", ); } diff --git a/packages/cli/test/launcher-grants.test.ts b/packages/cli/test/launcher-grants.test.ts index a301b0b3..0a72c00d 100644 --- a/packages/cli/test/launcher-grants.test.ts +++ b/packages/cli/test/launcher-grants.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { chmodSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { launcherGrantCovering, sandboxHomeBase } from "./helpers/launcher-grants.js"; @@ -35,6 +35,24 @@ describe("sandboxHomeBase", () => { } }); + test("skips a non-writable directory candidate and returns the next writable uncovered one", () => { + const dir = mkdtempSync(join(tmpdir(), "lg-ro-")); + const next = mkdtempSync(join(tmpdir(), "lg-rw-")); + try { + chmodSync(dir, 0o500); + try { + rmSync(mkdtempSync(join(dir, "w-"))); + console.warn("directory is still writable (running as root?), case skipped"); + return; + } catch {} + expect(sandboxHomeBase([dir, next])).toBe(realpathSync(next)); + } finally { + chmodSync(dir, 0o700); + rmSync(next, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); + } + }); + test("names the missing candidates when none of them exists", () => { const missing = ["/var/tmp/launcher-grants-missing-a", "/var/tmp/launcher-grants-missing-b"]; expect(() => sandboxHomeBase(missing)).toThrow(/no usable base directory exists/); From 2994d554b270782769182bcf49d092abf8fd44d2 Mon Sep 17 00:00:00 2001 From: flint Date: Sat, 10 Oct 2026 05:56:49 -0700 Subject: [PATCH 7/8] test(cli): the non-writable-candidate test builds its candidates outside every launcher grant Co-Authored-By: Claude Opus 5.5 --- packages/cli/test/launcher-grants.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/cli/test/launcher-grants.test.ts b/packages/cli/test/launcher-grants.test.ts index 0a72c00d..5c8ece18 100644 --- a/packages/cli/test/launcher-grants.test.ts +++ b/packages/cli/test/launcher-grants.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { chmodSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { launcherGrantCovering, sandboxHomeBase } from "./helpers/launcher-grants.js"; @@ -36,9 +36,12 @@ describe("sandboxHomeBase", () => { }); test("skips a non-writable directory candidate and returns the next writable uncovered one", () => { - const dir = mkdtempSync(join(tmpdir(), "lg-ro-")); - const next = mkdtempSync(join(tmpdir(), "lg-rw-")); + const parent = mkdtempSync(join(sandboxHomeBase(), "lg-skip-")); + const dir = join(parent, "ro"); + const next = join(parent, "rw"); try { + mkdirSync(dir); + mkdirSync(next); chmodSync(dir, 0o500); try { rmSync(mkdtempSync(join(dir, "w-"))); @@ -48,8 +51,7 @@ describe("sandboxHomeBase", () => { expect(sandboxHomeBase([dir, next])).toBe(realpathSync(next)); } finally { chmodSync(dir, 0o700); - rmSync(next, { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); + rmSync(parent, { recursive: true, force: true }); } }); From 39ff4f7ee71c97ddf19d976de0cb0f39c444a648 Mon Sep 17 00:00:00 2001 From: flint Date: Sat, 10 Oct 2026 06:56:46 -0700 Subject: [PATCH 8/8] test(cli): the read-only candidate case skips by a real probe; a file candidate runs for every uid Co-Authored-By: Claude Opus 5.5 --- packages/cli/test/launcher-grants.test.ts | 25 +++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/cli/test/launcher-grants.test.ts b/packages/cli/test/launcher-grants.test.ts index 5c8ece18..c66de05c 100644 --- a/packages/cli/test/launcher-grants.test.ts +++ b/packages/cli/test/launcher-grants.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { launcherGrantCovering, sandboxHomeBase } from "./helpers/launcher-grants.js"; @@ -43,11 +43,15 @@ describe("sandboxHomeBase", () => { mkdirSync(dir); mkdirSync(next); chmodSync(dir, 0o500); + let writable = false; try { - rmSync(mkdtempSync(join(dir, "w-"))); - console.warn("directory is still writable (running as root?), case skipped"); - return; + rmSync(mkdtempSync(join(dir, "w-")), { recursive: true }); + writable = true; } catch {} + if (writable) { + console.warn("a 0o500 directory is still writable (running as root?), case skipped"); + return; + } expect(sandboxHomeBase([dir, next])).toBe(realpathSync(next)); } finally { chmodSync(dir, 0o700); @@ -55,6 +59,19 @@ describe("sandboxHomeBase", () => { } }); + test("skips a candidate that is a regular file and returns the next writable directory", () => { + const parent = mkdtempSync(join(sandboxHomeBase(), "lg-file-")); + try { + const file = join(parent, "file"); + const next = join(parent, "dir"); + writeFileSync(file, ""); + mkdirSync(next); + expect(sandboxHomeBase([file, next])).toBe(realpathSync(next)); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + test("names the missing candidates when none of them exists", () => { const missing = ["/var/tmp/launcher-grants-missing-a", "/var/tmp/launcher-grants-missing-b"]; expect(() => sandboxHomeBase(missing)).toThrow(/no usable base directory exists/);