Skip to content
Merged
3 changes: 2 additions & 1 deletion packages/cli/src/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
isSupervised,
harnessReadPaths,
harnessReadFiles,
BUN_TEMP_DIR,
runtimeNonoOptions,
runtimeNonoProfile,
approveRuntimeNonoOptions,
Expand Down Expand Up @@ -909,7 +910,7 @@ export async function runAgent(args: AgentArgs): Promise<void> {
// 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);
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/utils/nono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions packages/cli/test/helpers/launcher-grants.ts
Original file line number Diff line number Diff line change
@@ -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.`,
);
}
7 changes: 4 additions & 3 deletions packages/cli/test/helpers/runtime-launch-fixture.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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");
Expand Down
105 changes: 105 additions & 0 deletions packages/cli/test/launcher-grants.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
7 changes: 4 additions & 3 deletions packages/cli/test/sandbox-launch-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
Loading