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..1e5262c7 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 its readers stay in step (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/helpers/launcher-grants.ts b/packages/cli/test/helpers/launcher-grants.ts new file mode 100644 index 00000000..a5bdb755 --- /dev/null +++ b/packages/cli/test/helpers/launcher-grants.ts @@ -0,0 +1,74 @@ +/** + * 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 { 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"; + +/** 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 = realOrResolved(path); + for (const grant of LAUNCHER_FIXED_GRANTS) { + const root = realOrResolved(grant); + if (target === root || target.startsWith(root.endsWith("/") ? root : `${root}/`)) return grant; + } + 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 + * 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(candidates: string[] = ["/var/tmp", tmpdir()]): string { + for (const candidate of candidates) { + if (canCreateDirIn(candidate) && launcherGrantCovering(candidate) === null) return realpathSync(candidate); + } + const existing = candidates.filter((c) => canCreateDirIn(c)); + if (existing.length === 0) { + throw new Error( + `no usable base directory exists or is writable (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 '${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/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/launcher-grants.test.ts b/packages/cli/test/launcher-grants.test.ts new file mode 100644 index 00000000..c66de05c --- /dev/null +++ b/packages/cli/test/launcher-grants.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +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"; + +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(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("skips a non-writable directory candidate and returns the next writable uncovered one", () => { + 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); + let writable = false; + try { + 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); + rmSync(parent, { recursive: true, force: true }); + } + }); + + 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/); + 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(); + }); + + 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 }); + } + }); +}); diff --git a/packages/cli/test/sandbox-launch-control.test.ts b/packages/cli/test/sandbox-launch-control.test.ts index 4d1bbfb3..a69352c2 100644 --- a/packages/cli/test/sandbox-launch-control.test.ts +++ b/packages/cli/test/sandbox-launch-control.test.ts @@ -31,6 +31,7 @@ import { } from "node:fs"; import { spawnSync } from "node:child_process"; 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"; @@ -117,9 +118,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 = sandboxHomeBase(); const home = mkdtempSync(join(base, "tps-reexec-argv-")); try { const nonoDir = join(home, "nono");