diff --git a/.changelog/unreleased/fixed-516-nono-gate-resolution.md b/.changelog/unreleased/fixed-516-nono-gate-resolution.md new file mode 100644 index 00000000..0ff02594 --- /dev/null +++ b/.changelog/unreleased/fixed-516-nono-gate-resolution.md @@ -0,0 +1,3 @@ +- **The nono profile gate resolves the workspace agent package.** The gate job + installs the workspace and builds the agent package before running, so its + launch-args checks use the local package rather than a published one. diff --git a/.changelog/unreleased/fixed-516-sandboxed-launch.md b/.changelog/unreleased/fixed-516-sandboxed-launch.md new file mode 100644 index 00000000..122d659d --- /dev/null +++ b/.changelog/unreleased/fixed-516-sandboxed-launch.md @@ -0,0 +1,9 @@ +- **Sandboxed agent launch keeps `--config`, prepares the worktree with checkout deferred, and ignores flags inside a wrapped + payload.** For a selected runtime whose workspace has a base repository, `tps + agent start` reads `--config` once into a private snapshot used for parsing, + the attested re-exec and its read-file grant; when a base repository exists and the workspace is absent, + the parent creates the worktree with the checkout suppressed + and the hook path and fsmonitor disabled. Relative workspaces use the source config + pathname’s directory; the child uses the parent’s absolute workspace and refuses + a pending-worktree mismatch with `WorktreeLifecycleError` before checkout. + The sandbox-flag scan reads parsed arguments before a wrapped payload. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c209ba8..f64bfa10 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -530,6 +530,18 @@ jobs: - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2 with: bun-version: "1.3.10" + # The gate imports this tree's launch-args helper, which imports the + # workspace agent package. Without the install and build, bun resolves a + # published @tpsdev-ai/agent instead of this tree's. + - name: Dependency release-age gate (cli#529) + run: node scripts/check-dep-ages.mjs --ci + - uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2 + with: + mode: firewall-free + - name: Install workspace dependencies + run: sfw bun install --frozen-lockfile + - name: Build agent package + run: cd packages/agent && bun run build - name: Resolve the pinned nono commit id: pin run: | diff --git a/docker-compose.yml b/docker-compose.yml index a31ee992..0d6fd681 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,7 @@ services: # (DEFAULT_NONO_PATHS in packages/cli/src/utils/launch-attestation.ts). attested: build: *test-build - command: node scripts/test-suite.mjs attested packages/cli/test/launch-attestation-real-nono.test.ts packages/cli/test/launch-attestation.test.ts packages/cli/test/runtime-attested-launch.test.ts + command: node scripts/test-suite.mjs attested packages/cli/test/launch-attestation-real-nono.test.ts packages/cli/test/launch-attestation.test.ts packages/cli/test/runtime-attested-launch.test.ts packages/cli/test/sandboxed-launch-516.test.ts user: tps working_dir: /app environment: diff --git a/packages/cli/bin/tps.ts b/packages/cli/bin/tps.ts index 10211ddf..0c0b23e6 100755 --- a/packages/cli/bin/tps.ts +++ b/packages/cli/bin/tps.ts @@ -3,7 +3,7 @@ import { requireLocalAgentId } from "../src/utils/local-agent.js"; import meow from "meow"; import { cliFlagDefinitions } from "../src/utils/cli-flags.js"; import { parseCliArgs } from "../src/utils/cli-args.js"; -import { enforceLaunchControl, readLaunchFlags } from "../src/utils/nono.js"; +import { enforceLaunchControl, ownFlag, readLaunchFlags, SANDBOXED_FLAG } from "../src/utils/nono.js"; // Injected at compile time via --define flag; falls back to "dev" in dev mode. declare const INJECTED_VERSION: string; @@ -17,8 +17,13 @@ try { process.exit(1); } -const launchFlags = readLaunchFlags(process.argv); -if (launchFlags.refusal) enforceLaunchControl({ argv: process.argv }); +/** Parsed CLI arguments, with `--` separating payload. */ +function ownArgv(): string[] { + return [process.argv[0]!, process.argv[1]!, ...helpArgs.argv]; +} + +const launchFlags = readLaunchFlags(ownArgv()); +if (launchFlags.refusal) enforceLaunchControl({ argv: ownArgv() }); const cli = meow( ` @@ -103,37 +108,28 @@ const [command, ...rest] = cli.input; * an interactive TTY, and refuses a non-interactive agent launch that did not * assert `--sandbox-required`. Implementation lives in src/utils/nono.ts. */ -async function enforceLaunchControlOrExit(): Promise { - const { enforceLaunchControl, isInteractiveTty, NO_SANDBOX_FLAG, SANDBOXED_FLAG } = await import( +async function enforceLaunchControlOrExit() { + const { enforceLaunchControl, isInteractiveTty, NO_SANDBOX_FLAG } = await import( "../src/utils/nono.js" ); - // Under `--sandboxed` this process must HOLD THE LAUNCHER'S RELEASE before the - // gate may honour the flag (cli#350 round 4e). The handshake lives here, not - // in the gate: it is I/O (connect, report the pid, prove the canaries, wait — - // bounded — for `CONFINED `), while the gate stays pure. - // Nothing this process can observe about itself is proof; the launcher's view - // from OUTSIDE the sandbox is. - // - // cli#350 r4f — attest whenever a LAUNCHER launched this process, keyed on the - // locator (set by the launcher and only by it), NEVER on the TTY: the launcher - // requires the release on every launch, so a TTY child that skipped the - // handshake made every interactive `tps agent start` refuse with - // `no released child within …`. A human who types `--sandboxed` at a terminal - // carries no locator, so confinement stays undefined and rule 1b refuses it. + const { attestConfinement, LAUNCH_SOCK_ENV } = await import( + "../src/utils/launch-attestation.js" + ); let confinement: { released: boolean; reason?: string } | undefined; - if (process.argv.includes(SANDBOXED_FLAG)) { - const { attestConfinement, LAUNCH_SOCK_ENV } = await import( - "../src/utils/launch-attestation.js" - ); - if (process.env[LAUNCH_SOCK_ENV]) { - const attestation = await attestConfinement(); - confinement = { released: attestation.ok, reason: attestation.reason }; - } + let worktreeBinding: import("../src/utils/agent-lifecycle.js").WorktreeLaunchBinding | undefined; + if (ownFlag(ownArgv(), SANDBOXED_FLAG) && process.env[LAUNCH_SOCK_ENV]) { + const attestation = await attestConfinement(); + confinement = { released: attestation.ok, reason: attestation.reason }; + worktreeBinding = attestation.worktreeBinding; } - enforceLaunchControl({ command, rest, argv: process.argv, parsedFlags: cli.flags, confinement }); - if (launchFlags.noSandbox && isInteractiveTty()) { + const sandboxed = confinement?.released === true; + enforceLaunchControl({ command, rest, argv: ownArgv(), parsedFlags: cli.flags, + confinement: { released: sandboxed, reason: confinement?.reason } }); + const flags = readLaunchFlags(ownArgv(), cli.flags); + if (flags.noSandbox && isInteractiveTty()) { console.warn(`⚠️ ${NO_SANDBOX_FLAG}: running WITHOUT nono isolation (interactive override).`); } + return { ...flags, sandboxed, worktreeBinding: confinement?.released ? worktreeBinding : undefined }; } // nono availability check (skip for --quiet-nono-check or office/mail relay commands) @@ -276,7 +272,7 @@ async function main() { return; } - await enforceLaunchControlOrExit(); + const launchDecision = await enforceLaunchControlOrExit(); await checkNono(); switch (command) { case "init": { @@ -480,14 +476,16 @@ async function main() { const message = msgIdx >= 0 ? process.argv.slice(msgIdx + 1).join(" ") : undefined; await runAgent({ action: "run", config: configPath, id: agentId, message }); } else if (action === "start") { - const runtimeArg = process.argv.some((arg) => arg === "--runtime" || arg.startsWith("--runtime=")) ? cli.flags.runtime : undefined; + const payloadStart = helpArgs.argv.indexOf("--"); + const ownArgs = payloadStart === -1 ? helpArgs.argv : helpArgs.argv.slice(0, payloadStart); + const runtimeArg = ownArgs.some((arg) => arg === "--runtime" || arg.startsWith("--runtime=")) ? cli.flags.runtime : undefined; const attestedRuntime = runtimeArg === "claude-code" || runtimeArg === "codex" || runtimeArg === "gemini"; if (runtimeArg !== undefined && runtimeArg !== "openclaw" && !attestedRuntime) { console.error(`❌ refusing to launch runtime '${runtimeArg}': unsupported runtime`); process.exit(78); } - const sandboxed = process.argv.includes("--sandboxed"); - const noSandbox = launchFlags.noSandbox; + const sandboxed = launchDecision.sandboxed; + const noSandbox = launchDecision.noSandbox; // Selected runners execute after launcher release or an interactive // TTY `--no-sandbox` opt-out. if (attestedRuntime && (sandboxed || noSandbox)) { @@ -498,11 +496,6 @@ async function main() { const { load: parseYaml } = await import("js-yaml"); const cfgPath = configPath ?? join(homedir(), ".tps", "agents", agentId!, "agent.yaml"); - /** Resolve the git worktree metadata dir for a workspace. - * A worktree's .git is a file containing "gitdir: /path/to/.git/worktrees/". - * Codex workspace-write sandbox restricts writes to the workspace dir; the worktree - * metadata dir (index, HEAD, etc.) is outside that boundary and must be added to - * extraDirs so git commit/push can write index.lock. */ function resolveWorktreeGitDir(workspace: string): string | null { try { const dotGit = join(workspace, ".git"); @@ -514,7 +507,12 @@ async function main() { return null; } const agentCfg = parseYaml(readFileSync(cfgPath, "utf-8")) as any; - const agentWorkspace = agentCfg.workspace ?? join(homedir(), "ops", "tps"); + const { agentWorkspace: resolveWorkspace } = await import("../src/utils/agent-workspace.js"); + const agentWorkspace = resolveWorkspace( + cfgPath, + agentCfg.workspace ?? join(homedir(), "ops", "tps"), + sandboxed ? process.env : undefined, + ); // OPS-47: Build workspace provider from agent.yaml config const { GitWorkspaceProvider } = await import("../src/utils/workspace-provider.js"); @@ -544,12 +542,25 @@ async function main() { } } - const { onStart, onStop } = await import("../src/utils/agent-lifecycle.js"); - const startResult = onStart(agentId!, agentWorkspace); - if (startResult.changed) console.log(`[${agentId}] worktree: ${startResult.reason}`); + // Complete this launch's deferred checkout before starting the runtime. + const { onStart, onStop, checkoutWorktree, worktreeLaunchBinding, worktreeLaunchGit } = await import("../src/utils/agent-lifecycle.js"); + const pendingCheckout = process.env.TPS_WORKTREE_PENDING; + let worktreeCreated = false; + if (sandboxed && pendingCheckout !== undefined) { + const checkedOut = checkoutWorktree(agentWorkspace); + console.log(`[${agentId}] worktree: ${checkedOut.reason}`); + } else if (!sandboxed) { + const startResult = onStart(agentId!, agentWorkspace); + worktreeCreated = startResult.changed; + if (startResult.changed) console.log(`[${agentId}] worktree: ${startResult.reason}`); + } try { if (runtimeArg === "codex") { + const worktreeGit = worktreeLaunchGit(sandboxed + ? launchDecision.worktreeBinding ?? { kind: "none" } + : worktreeLaunchBinding(agentWorkspace)); + const { runCodexRuntime } = await import("../src/utils/codex-runtime.js"); const { parseReviewTriggerConfig } = await import("../src/utils/pr-review-trigger.js"); await runCodexRuntime({ @@ -561,7 +572,7 @@ async function main() { join(homedir(), ".tps", "mail", agentId!), join(homedir(), "ops", "tps"), join(homedir(), "ops", "flair"), - ...((worktreeGitDir) => worktreeGitDir ? [worktreeGitDir] : [])(resolveWorktreeGitDir(agentWorkspace)), + ...(worktreeGit ? [worktreeGit.gitDir, worktreeGit.gitCommonDir] : []), ...(agentCfg.codex?.extraDirs ?? []), ], taskTimeoutMs: agentCfg.taskTimeoutMs, @@ -610,8 +621,10 @@ async function main() { }); } } finally { - const stopResult = onStop(agentId!, agentWorkspace); - if (stopResult.changed) console.log(`[${agentId}] worktree removed: ${stopResult.reason}`); + if (worktreeCreated) { + const stopResult = onStop(agentId!, agentWorkspace); + if (stopResult.changed) console.log(`[${agentId}] worktree removed: ${stopResult.reason}`); + } } } else { await runAgent({ @@ -620,7 +633,7 @@ async function main() { id: agentId, sandbox: !noSandbox, sandboxed, - sandboxRequired: launchFlags.sandboxRequired, + sandboxRequired: launchDecision.sandboxRequired, // Carry the runtime into the re-exec so the sandboxed child runs the // runtime runner (cli#363 slice B); undefined for the default path. runtime: attestedRuntime ? runtimeArg : undefined, @@ -1498,6 +1511,6 @@ async function main() { main() .then(() => process.exit(0)) .catch((err) => { - console.error(err?.message || err); + console.error(err?.name === "WorktreeLifecycleError" ? `${err.name}: ${err.message}` : err?.message || err); process.exit(1); }); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index ba716b5f..35e49d7b 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -11,12 +11,13 @@ */ import { AgentRuntime, loadAgentConfig } from "@tpsdev-ai/agent"; +import { agentWorkspace } from "../utils/agent-workspace.js"; import yaml from "js-yaml"; import { generateKeyPair, saveKeyPair, loadKeyPair, verify } from "../utils/identity.js"; import { createFlairClient, FlairAgentKeyRegisterError, FlairRequestError, isEncryptedPrivateKeyPem, loadFlairSigningKey } from "../utils/flair-client.js"; import { applyRole, loadPlugins } from "../plugins/index.js"; import { createInterface as createPromptInterface } from "node:readline/promises"; -import { accessSync, constants, createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, watch, writeFileSync } from "node:fs"; +import { accessSync, constants, createReadStream, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, watch, writeFileSync } from "node:fs"; import { sign as cryptoSign } from "node:crypto"; import { homedir } from "node:os"; import { join, resolve as resolvePathMod } from "node:path"; @@ -25,17 +26,15 @@ import { isNonoStrict, isInteractiveTty, isSupervised, - harnessReadPaths, - harnessReadFiles, - BUN_TEMP_DIR, + agentLaunchOptions, runtimeNonoOptions, runtimeNonoProfile, - approveRuntimeNonoOptions, sandboxProfileGrantRefusal, REFUSAL_EXIT_CODE, SUPERVISED_REFUSAL_EXIT_CODE, + canonicalPath, } from "../utils/nono.js"; -import { launchAttested, resolveNonoBinary } from "../utils/launch-attestation.js"; +import { grantCovers, launchAttested, resolveNonoBinary } from "../utils/launch-attestation.js"; import { requireLocalAgentId } from "../utils/local-agent.js"; export interface AgentArgs { @@ -1074,7 +1073,55 @@ export async function healthcheckAgent(args: AgentArgs): Promise { } // ─── Entry ─────────────────────────────────────────────────────────────────── -export async function runAgent(args: AgentArgs): Promise { +export async function runAgent( + args: AgentArgs, + testHooks?: { afterConfigSnapshot?: (path: string) => void }, +): Promise { + const original = args.config ?? (args.id ? join(homedir(), ".tps", "agents", args.id, "agent.yaml") : undefined); + if (args.action !== "start" || args.sandboxed || !original || !existsSync(original)) { + return runAgentCommand(args); + } + const workspaceConfigPath = resolvePathMod(original); + const parent = canonicalPath(join(homedir(), ".tps", "launch")); + const createdParent = mkdirSync(parent, { recursive: true, mode: 0o700 }); + const snapshotRoot = mkdtempSync(join(parent, "config-")); + const cleanup = () => { + rmSync(snapshotRoot, { recursive: true, force: true }); + if (createdParent) { + try { + rmdirSync(parent); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST") throw err; + } + } + }; + process.once("exit", cleanup); + const signalHandlers = (["SIGINT", "SIGTERM", "SIGHUP"] as const).map((signal) => { + const handler = () => { + cleanup(); + process.removeListener(signal, handler); + if (process.listenerCount(signal) === 0) process.kill(process.pid, signal); + }; + process.once(signal, handler); + return { signal, handler }; + }); + try { + const configPath = join(snapshotRoot, "agent.yaml"); + writeFileSync(configPath, readFileSync(workspaceConfigPath), { mode: 0o600, flag: "wx" }); + testHooks?.afterConfigSnapshot?.(configPath); + return await runAgentCommand({ ...args, config: configPath }, { workspaceConfigPath, snapshotRoot }); + } finally { + cleanup(); + process.removeListener("exit", cleanup); + for (const { signal, handler } of signalHandlers) process.removeListener(signal, handler); + } +} + +async function runAgentCommand( + args: AgentArgs, + snapshot?: { workspaceConfigPath: string; snapshotRoot: string }, +): Promise { switch (args.action) { case "create": return createAgent(args); @@ -1113,6 +1160,8 @@ export async function runAgent(args: AgentArgs): Promise { console.error(" or: tps agent run --config --message "); process.exit(1); } + // Resolve relative --config before reading or re-executing (cli#516). + configPath = resolvePathMod(configPath); if (!existsSync(configPath)) { console.error(`Config not found: ${configPath}`); console.error(`Run 'tps agent create --id ${args.id ?? ""}' first.`); @@ -1120,6 +1169,11 @@ export async function runAgent(args: AgentArgs): Promise { } const config = loadAgentConfig(configPath); + config.workspace = agentWorkspace(snapshot?.workspaceConfigPath ?? configPath, config.workspace, args.sandboxed ? process.env : undefined); + if (snapshot) { + const document = yaml.load(readFileSync(configPath, "utf-8")) as Record; + writeFileSync(configPath, yaml.dump({ ...document, workspace: config.workspace }, { lineWidth: -1 })); + } // cli#351 r5 — the launch grant must NOT come from a file the sandboxed // workload can rewrite (`agentDir` is granted r+w, so the agent can edit @@ -1230,48 +1284,90 @@ export async function runAgent(args: AgentArgs): Promise { // the sandboxed child re-enters bin/tps.ts, which runs the runtime // runner on the execution side. if (selectedRuntime) relaunch.push("--runtime", selectedRuntime); + // Parse, re-exec, and grant the parent's private config snapshot (cli#516). + relaunch.push("--config", configPath); // THE ATTESTED LAUNCH (cli#350 round 4e): the launcher creates the // private dir, plants the canaries, spawns nono by absolute path, // and releases the child over its own socket only after `nono ps` // binds a live session to the pid it spawned and to the pid the // child reports, with the OUTSIDE canary still unreadable to it. - const runtimeGrants = runtimeNonoOptions(selectedRuntime); - const launchOptions = { - workdir: config.workspace, - // CHANGE (cli#341 S1b): this used to grant a read of the - // filesystem root. A root grant is refused outright by nono - // 0.70+ (exit 1). Use the explicit toolchain read set instead — - // the macOS roots Kern validated, and their Linux equivalents - // (systemReadPaths()). - read: harnessReadPaths(), - // Exactly this agent's own identity files, not the shared - // identity directory (cli#351 r4). - readFiles: harnessReadFiles(launchId), - allowFiles: runtimeGrants.allowFiles, - // Bun's own temp dir is /tmp regardless of TMPDIR, and an - // unreadable temp dir is fatal to it — grant BOTH /tmp and the - // 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, BUN_TEMP_DIR, config.workspace, agentDir, ...(runtimeGrants.allow ?? [])])], - }; const profile = runtimeNonoProfile(selectedRuntime); - const approval = approveRuntimeNonoOptions(selectedRuntime, { ...launchOptions, cwd: process.cwd() }, process.env, launchId); - if (approval.refusal) { - console.error(`❌ refusing to launch runtime '${selectedRuntime ?? "AgentRuntime"}': ${approval.refusal}`); + const launchInput = { + configPath, + cwd: process.cwd(), + workspace: config.workspace, + launchId, + runtime: selectedRuntime, + mailDir, + tmpDir, + agentDir, + }; + const approveLaunch = () => { + const result = agentLaunchOptions(launchInput); + if (result.refusal) throw new Error(`refusing to launch runtime '${selectedRuntime ?? "AgentRuntime"}': ${result.refusal}`); + if (snapshot) { + const writable = [result.options.cwd ?? process.cwd(), result.options.workdir, ...(result.options.allow ?? [])]; + if (writable.some((path) => path && grantCovers(canonicalPath(path), snapshot.snapshotRoot)) || + (result.options.allowFiles ?? []).some((path) => canonicalPath(path) === configPath)) { + throw new Error("refusing a writable config snapshot directory"); + } + } + return result; + }; + const { onStart, onStop } = await import("../utils/agent-lifecycle.js"); + let worktreeCreated = false; + let cleanupBinding: import("../utils/agent-lifecycle.js").WorktreeCleanupBinding | undefined; + let approval: ReturnType; + try { + approval = approveLaunch(); + const profileRefusal = sandboxProfileGrantRefusal(profile, process.env, selectedRuntime, nonoAvailable, snapshot?.snapshotRoot); + if (profileRefusal) throw new Error(`refusing to launch profile '${profile}': ${profileRefusal}`); + const start = selectedRuntime ? onStart(launchId, config.workspace, { deferCheckout: true }) : undefined; + worktreeCreated = start?.changed ?? false; + cleanupBinding = start?.cleanupBinding; + approval = approveLaunch(); + } catch (err) { + console.error(`❌ ${(err as Error).message}`); + if (worktreeCreated) { + try { + onStop(launchId, config.workspace, cleanupBinding); + } catch (cleanupErr) { + console.error(`❌ ${(cleanupErr as Error).message}`); + } + } process.exit(isSupervised() ? SUPERVISED_REFUSAL_EXIT_CODE : REFUSAL_EXIT_CODE); } - const profileRefusal = sandboxProfileGrantRefusal(profile, process.env, selectedRuntime, nonoAvailable); - if (profileRefusal) { - console.error(`❌ refusing to launch profile '${profile}': ${profileRefusal}`); - process.exit(isSupervised() ? SUPERVISED_REFUSAL_EXIT_CODE : REFUSAL_EXIT_CODE); + // The child checks the worktree out only when THIS launch created + // it. Remove any inherited TPS_WORKTREE_PENDING before launch and + // set it only after a worktree was actually created, so an + // inherited value never names someone else's worktree (cli#516). + const launchEnv: NodeJS.ProcessEnv = { ...process.env }; + launchEnv.TPS_AGENT_WORKSPACE = config.workspace; + delete launchEnv.TPS_WORKTREE_PENDING; + if (worktreeCreated) launchEnv.TPS_WORKTREE_PENDING = config.workspace; + let exitCode: number; + try { + exitCode = await launchAttested( + profile, + approval.options, + relaunch, + { + runtimeDirectories: approval.runtimeDirectories, + worktreeBinding: approval.worktreeBinding, + env: launchEnv, + }, + ); + } finally { + // Attempt cleanup only after this launch created a worktree (cli#516). + if (worktreeCreated) { + try { + onStop(launchId, config.workspace, cleanupBinding); + } catch (err) { + console.error(`❌ ${(err as Error).message}`); + process.exit(isSupervised() ? SUPERVISED_REFUSAL_EXIT_CODE : REFUSAL_EXIT_CODE); + } + } } - const exitCode = await launchAttested( - profile, - approval.options, - relaunch, - { runtimeDirectories: approval.runtimeDirectories }, - ); process.exit(exitCode); } } else if (!sandboxed && nonoAvailable) { diff --git a/packages/cli/src/utils/agent-lifecycle.ts b/packages/cli/src/utils/agent-lifecycle.ts index 823d334c..fa3b8b57 100644 --- a/packages/cli/src/utils/agent-lifecycle.ts +++ b/packages/cli/src/utils/agent-lifecycle.ts @@ -9,9 +9,9 @@ * agentId, etc.) rather than holding global state. */ -import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, statSync, realpathSync, readlinkSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { homedir } from "node:os"; import { FlairClient } from "./flair-client.js"; import type { WorkspaceStateRecord, OrgEvent } from "./flair-client.js"; @@ -224,10 +224,54 @@ export async function writeTaskMemory( export interface AgentLifecycleResult { changed: boolean; reason: string; + /** Git metadata for a worktree launch with deferred checkout. */ + gitDir?: string; + /** + * Absolute path of the worktree's git common directory — the base + * repository's metadata that `gitDir`'s `commondir` file points to. Present + * in the same case as `gitDir`. + */ + gitCommonDir?: string; + baseWorkingTree?: string; + cleanupBinding?: WorktreeCleanupBinding; +} + +export interface WorktreeCleanupBinding { + workspace: string; + baseWorkingTree: string; + gitDir: string; + gitCommonDir: string; + registryEntry: string; + recordedGitdir: string; +} + +/** + * A worktree setup, checkout or cleanup operation failed (cli#516). Named so a + * caller can tell a worktree failure from any other error and report it as one; + * the message names the operation that failed. + */ +export class WorktreeLifecycleError extends Error { + constructor(message: string) { + super(message); + this.name = "WorktreeLifecycleError"; + } +} + +function gitEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith("GIT_")) env[key] = value; + } + // Disable inherited GIT_* variables and system/global config entry points; + // local config includes remain enabled. + env.GIT_CONFIG_NOSYSTEM = "1"; + env.GIT_CONFIG_GLOBAL = "/dev/null"; + env.GIT_TERMINAL_PROMPT = "0"; + return env; } function git(cwd: string, args: string[]): { ok: boolean; stdout: string; stderr: string } { - const out = spawnSync("git", args, { cwd, encoding: "utf-8" }); + const out = spawnSync("git", args, { cwd, encoding: "utf-8", env: gitEnv() }); return { ok: out.status === 0, stdout: (out.stdout ?? "").trim(), @@ -235,68 +279,416 @@ function git(cwd: string, args: string[]): { ok: boolean; stdout: string; stderr }; } -function isGitRepo(path: string): boolean { +type RepoProbe = + | { kind: "repo"; path: string } + | { kind: "not-a-repo" } + | { kind: "error"; detail: string }; + +function probeRepo(path: string): RepoProbe { + try { + let candidate; + try { + candidate = lstatSync(path); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return { kind: "not-a-repo" }; + throw err; + } + path = realpathSync(path); + candidate = lstatSync(path); + if (!candidate.isDirectory()) { + return { kind: "error", detail: "base path is not a plain directory" }; + } + readdirSync(path); + const markerPath = join(path, ".git"); + let marker; + try { + marker = lstatSync(markerPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return { kind: "not-a-repo" }; + throw err; + } + if (marker.isSymbolicLink() || (!marker.isDirectory() && !marker.isFile())) { + return { kind: "error", detail: "git marker is not a regular file or directory" }; + } + if (marker.isDirectory()) readdirSync(markerPath); + else readFileSync(markerPath); + } catch (err) { + return { kind: "error", detail: (err as Error).message }; + } const out = git(path, ["rev-parse", "--is-inside-work-tree"]); - return out.ok && out.stdout === "true"; + if (out.ok && out.stdout === "true") return { kind: "repo", path }; + return { kind: "error", detail: out.stderr || out.stdout || "git probe produced no output" }; } -function parseWorktreePaths(baseRepo: string): Set { - const out = git(baseRepo, ["worktree", "list", "--porcelain"]); - if (!out.ok) { - throw new Error(out.stderr || out.stdout || "git worktree list failed"); +function resolveBaseRepo(workspacePath: string): string | null { + const candidates = [ + resolve(join(homedir(), "ops", "tps")), + resolve(join(dirname(workspacePath), "tps")), + ]; + for (const candidate of candidates) { + const probe = probeRepo(candidate); + if (probe.kind === "repo") return probe.path; + if (probe.kind === "error") { + throw new WorktreeLifecycleError( + `git probe failed for the base repository ${candidate}: ${probe.detail}`, + ); + } } - const paths = new Set(); - for (const line of out.stdout.split("\n")) { - if (line.startsWith("worktree ")) { - paths.add(resolve(line.slice("worktree ".length).trim())); + return null; +} + +export type WorktreeLaunchBinding = + | { kind: "none" } + | { kind: "unregistered"; baseWorkingTree: string } + | { kind: "registered"; gitDir: string; gitCommonDir: string; baseWorkingTree: string }; + +/** + * Classify a launch workspace against the trusted base repository. + * + * Registry ownership is decided from the trusted base's registry alone: the + * recorded `.git` path is compared to the workspace's `.git` path, so a + * registered entry is found even when its marker is missing, is a directory, + * or its workspace directory is absent. A registered workspace must then + * carry a regular `.git` file; anything else is refused before any grant is + * computed. An unregistered regular `.git` file gets no metadata grants; + * launch remains subject to grant and profile checks. + */ +export function worktreeLaunchBinding(workspacePath: string): WorktreeLaunchBinding { + let workspace = resolve(workspacePath); + try { + const workspaceStat = statSync(workspace); + if (!workspaceStat.isDirectory()) { + throw new WorktreeLifecycleError(`worktree marker probe failed: workspace is not a directory: ${workspace}`); } + workspace = realpathSync(workspace); + } catch (err) { + if (err instanceof WorktreeLifecycleError) throw err; + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + workspace = resolvableWorkspacePath(workspace); + // The workspace directory is absent, but a registered worktree keeps its + // ownership in the trusted base's registry. Refuse it before any grant is + // computed; an absent unregistered path keeps the caller's cwd policy. + const trustedBase = resolveBaseRepo(workspace); + if ( + trustedBase && + trustedBaseWorktreeGitDir(workspace, resolvableWorkspacePath(workspace), trustedBase) + ) { + throw new WorktreeLifecycleError( + `worktree workspace is missing for a registered worktree: ${workspace}`, + ); + } + return { kind: "none" }; + } + throw new WorktreeLifecycleError(`worktree marker probe failed: ${(err as Error).message}`); + } + const markerPath = join(workspace, ".git"); + let marker: ReturnType | undefined; + try { + marker = lstatSync(markerPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw new WorktreeLifecycleError(`worktree marker probe failed: ${(err as Error).message}`); + } + } + // A symlink marker is refused wherever it appears, before any base lookup. + if (marker?.isSymbolicLink()) throw new WorktreeLifecycleError(`worktree git marker is a symlink: ${workspace}`); + const trustedBase = resolveBaseRepo(workspace); + // No tps base repository: not a tps-managed worktree, so no metadata grants. + if (!trustedBase) return { kind: "none" }; + let resolvedWorkspace: string; + try { + resolvedWorkspace = realpathSync(workspace); + } catch (err) { + throw new WorktreeLifecycleError(`worktree path could not be resolved for ${workspace}: ${(err as Error).message}`); } - return paths; + if (resolvedWorkspace === trustedBase) return { kind: "none" }; + const registered = trustedBaseWorktreeGitDir(workspace, resolvedWorkspace, trustedBase); + if (!marker) { + if (registered) { + throw new WorktreeLifecycleError(`worktree git marker is missing for a registered worktree: ${workspace}`); + } + return { kind: "none" }; + } + if (registered) { + if (!marker.isFile()) { + throw new WorktreeLifecycleError(`worktree git marker is not a regular file for a registered worktree: ${workspace}`); + } + return { kind: "registered", ...worktreeGitMetadata(workspace, trustedBase) }; + } + // A regular `.git` marker is treated as unregistered; a plain + // directory or an ordinary repository keeps the caller's cwd policy. + if (!marker.isFile()) return { kind: "none" }; + return { kind: "unregistered", baseWorkingTree: trustedBase }; } -function resolveBaseRepo(workspacePath: string): string | null { - const defaultBase = resolve(join(homedir(), "ops", "tps")); - if (isGitRepo(defaultBase)) return defaultBase; +export function worktreeLaunchGit(binding: WorktreeLaunchBinding): { gitDir: string; gitCommonDir: string; baseWorkingTree: string } | undefined { + if (binding.kind !== "registered") return undefined; + return { gitDir: binding.gitDir, gitCommonDir: binding.gitCommonDir, baseWorkingTree: binding.baseWorkingTree }; +} - const derivedBase = resolve(join(dirname(workspacePath), "tps")); - if (isGitRepo(derivedBase)) return derivedBase; +function resolvableWorkspacePath(workspace: string, links = 0): string { + if (links > 40) throw new WorktreeLifecycleError(`worktree path has too many symbolic links: ${workspace}`); + try { + return realpathSync(workspace); + } catch { + let target: string; + try { + target = readlinkSync(workspace); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "EINVAL" && code !== "ENOENT") throw err; + const parent = dirname(workspace); + return parent === workspace ? workspace : join(resolvableWorkspacePath(parent, links), basename(workspace)); + } + return resolvableWorkspacePath(resolve(dirname(workspace), target), links + 1); + } +} - return null; +/** Find `workspace`'s git directory in `baseRepo`'s worktree registry. + * Relative gitdirs require the base's relativeworktrees extension and format >= 1. */ +function trustedBaseWorktreeGitDir(workspace: string, resolvedWorkspace: string, baseRepo: string): string | undefined { + const common = git(baseRepo, ["rev-parse", "--git-common-dir"]); + if (!common.ok || !common.stdout) { + throw new WorktreeLifecycleError(`trusted base common git directory could not be resolved for ${baseRepo}`); + } + let commonDir: string; + try { + commonDir = realpathSync(resolve(baseRepo, common.stdout)); + } catch (err) { + throw new WorktreeLifecycleError( + `trusted base common git directory could not be resolved for ${baseRepo}: ${(err as Error).message}`, + ); + } + const registry = join(commonDir, "worktrees"); + let entries: string[]; + try { + entries = readdirSync(registry); + } catch (err) { + // No registry directory means the base has no linked worktrees. + if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw new WorktreeLifecycleError(`worktree registry could not be read for ${baseRepo}: ${(err as Error).message}`); + } + const markerPaths = new Set([ + resolve(join(workspace, ".git")), + resolve(join(resolvedWorkspace, ".git")), + ]); + let registeredGitDir: string | undefined; + for (const entry of entries) { + const gitDir = join(registry, entry); + try { + const recorded = readFileSync(join(gitDir, "gitdir"), "utf-8").trim(); + if (!recorded) throw new Error("empty gitdir"); + if (!isAbsolute(recorded)) { + const extension = git(baseRepo, ["config", "--local", "--includes", "--bool", "extensions.relativeworktrees"]); + const format = git(baseRepo, ["config", "--local", "--includes", "--int", "core.repositoryformatversion"]); + if (!extension.ok || extension.stdout !== "true" || !format.ok || !(Number(format.stdout) >= 1)) { + throw new Error("relative gitdir requires extensions.relativeworktrees=true and core.repositoryformatversion >= 1"); + } + } + const recordedPath = resolve(gitDir, recorded); + const markerPath = join(resolvableWorkspacePath(dirname(recordedPath)), basename(recordedPath)); + if (markerPaths.has(markerPath)) registeredGitDir = realpathSync(gitDir); + } catch (err) { + throw new WorktreeLifecycleError( + `worktree registry entry could not be resolved for ${baseRepo}: ${(err as Error).message}`, + ); + } + } + return registeredGitDir; } -export function onStart(agentId: string, workspacePath?: string): AgentLifecycleResult { +/** + * Create the agent's worktree when a base repository exists and the workspace + * is absent. An existing worktree, an existing ordinary workspace, the base + * repository itself, and a workspace with no base repository are left + * untouched and reported as changed:false. + * + * With `deferCheckout: true`, setup disables hooks and fsmonitor and skips checkout. + * + * Throws `WorktreeLifecycleError` when repository inspection or a git operation fails. + */ +export function onStart( + agentId: string, + workspacePath?: string, + opts: { deferCheckout?: boolean } = {}, +): AgentLifecycleResult { if (!workspacePath) { return { changed: false, reason: "no workspace path configured" }; } - const workspace = resolve(workspacePath); + let workspace = resolve(workspacePath); + try { + workspace = realpathSync(workspace); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw new WorktreeLifecycleError(`worktree path could not be resolved for ${workspace}: ${(err as Error).message}`); + } + } const baseRepo = resolveBaseRepo(workspace); if (!baseRepo) { - return { changed: false, reason: "base repo not found or not a git repo" }; + return { changed: false, reason: "no base repository for the workspace" }; } if (workspace === baseRepo) { return { changed: false, reason: "workspace is base repo; skipping worktree add" }; } - const worktrees = parseWorktreePaths(baseRepo); - if (worktrees.has(workspace)) { - return { changed: false, reason: "worktree already exists" }; + if (trustedBaseWorktreeGitDir(workspace, workspace, baseRepo)) { + return { changed: false, reason: "worktree already exists", ...(opts.deferCheckout ? worktreeGitMetadata(workspace, baseRepo) : {}) }; } if (existsSync(workspace)) { return { changed: false, reason: "workspace exists but is not a worktree; leaving untouched" }; } - const add = git(baseRepo, ["worktree", "add", workspace, "--detach"]); + const defer = opts.deferCheckout === true; + const args = [ + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + "worktree", + "add", + ...(defer ? ["--no-checkout"] : []), + workspace, + "--detach", + ]; + const add = git(baseRepo, args); if (!add.ok) { - throw new Error(`failed to create worktree for ${agentId}: ${add.stderr || add.stdout}`); + throw new WorktreeLifecycleError( + `worktree setup failed for ${agentId} (${baseRepo} -> ${workspace}): ${add.stderr || add.stdout}`, + ); + } + + const rollback = () => git(baseRepo, ["worktree", "remove", "--force", workspace]); + try { + const metadata = worktreeGitMetadata(workspace, baseRepo); + const registryEntry = join(metadata.gitDir, "gitdir"); + return { + changed: true, + reason: defer ? "worktree created; checkout deferred" : "worktree created", + ...(defer ? metadata : {}), + cleanupBinding: { + ...metadata, + workspace: realpathSync(workspace), + registryEntry, + recordedGitdir: readFileSync(registryEntry, "utf-8"), + }, + }; + } catch (err) { + const removed = rollback(); + if (!removed.ok) { + throw new WorktreeLifecycleError(`${(err as Error).message}; worktree cleanup failed: ${removed.stderr || removed.stdout}`); + } + if (err instanceof WorktreeLifecycleError) throw err; + throw new WorktreeLifecycleError(`worktree setup binding failed for ${workspace}: ${(err as Error).message}`); + } +} + +function worktreeGitMetadata(workspace: string, baseRepo: string): { gitDir: string; gitCommonDir: string; baseWorkingTree: string } { + const gitDir = git(workspace, ["rev-parse", "--absolute-git-dir"]); + if (!gitDir.ok || !gitDir.stdout) { + throw new WorktreeLifecycleError( + `worktree git directory could not be resolved for ${workspace}: ${gitDir.stderr || gitDir.stdout || "git rev-parse produced no output"}`, + ); + } + try { + const trustedCommon = git(baseRepo, ["rev-parse", "--git-common-dir"]); + if (!trustedCommon.ok || !trustedCommon.stdout) { + throw new Error("trusted base common git directory could not be resolved"); + } + const gitCommonDir = realpathSync(resolve(baseRepo, trustedCommon.stdout)); + if (realpathSync(worktreeCommonDir(gitDir.stdout)) !== gitCommonDir) { + throw new Error("worktree metadata does not belong to the trusted base"); + } + const trustedGitDir = trustedBaseWorktreeGitDir(workspace, realpathSync(workspace), baseRepo); + if (!trustedGitDir || realpathSync(gitDir.stdout) !== realpathSync(trustedGitDir)) { + throw new Error("worktree git directory does not belong to the trusted base"); + } + return { gitDir: realpathSync(trustedGitDir), gitCommonDir, baseWorkingTree: baseRepo }; + } catch (err) { + throw new WorktreeLifecycleError(`worktree metadata binding failed for ${workspace}: ${(err as Error).message}`); + } +} + +/** + * Resolve a linked worktree's common git directory from its git directory + * (cli#516). The git directory holds a `commondir` file naming the base + * repository's metadata directory (objects, refs, config) relative to it. + */ +export function worktreeCommonDir(gitDir: string): string { + let raw: string; + try { + raw = readFileSync(join(gitDir, "commondir"), "utf-8").trim(); + } catch (err) { + throw new WorktreeLifecycleError( + `worktree common git directory could not be read for ${gitDir}: ${(err as Error).message}`, + ); } + if (!raw) throw new WorktreeLifecycleError(`the commondir file in ${gitDir} is empty`); + return resolve(gitDir, raw); +} - return { changed: true, reason: "worktree created" }; +/** + * Populate a worktree the parent created with `--no-checkout` (cli#516). This + * checkout may run repository-configured filters and hooks, so the caller must + * run it inside the sandbox. Throws `WorktreeLifecycleError` on failure. + */ +export function checkoutWorktree(workspacePath: string): AgentLifecycleResult { + const workspace = resolve(workspacePath); + const out = git(workspace, ["checkout", "--detach"]); + if (!out.ok) { + throw new WorktreeLifecycleError( + `worktree checkout failed for ${workspace}: ${out.stderr || out.stdout}`, + ); + } + return { changed: true, reason: "worktree checked out" }; } -export function onStop(_agentId: string, workspacePath?: string): AgentLifecycleResult { +/** True when `path` exists; only ENOENT counts as absent (cli#516). Any other + * lstat failure — e.g. EACCES on a parent, a non-directory in the path — is + * thrown, so a failed probe is never read as "nothing there". */ +function pathExists(path: string): boolean { + try { + lstatSync(path); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return false; + throw err; + } +} + +export function onStop(_agentId: string, workspacePath?: string, binding?: WorktreeCleanupBinding): AgentLifecycleResult { + if (binding) { + const probe = probeRepo(binding.baseWorkingTree); + if (probe.kind !== "repo") { + throw new WorktreeLifecycleError( + `git probe failed for the base repository ${binding.baseWorkingTree}: ${probe.kind === "error" ? probe.detail : "base repository is absent"}`, + ); + } + try { + const marker = readFileSync(join(binding.workspace, ".git"), "utf-8").trim(); + if ( + realpathSync(binding.workspace) !== binding.workspace || + realpathSync(binding.gitDir) !== binding.gitDir || + readFileSync(binding.registryEntry, "utf-8") !== binding.recordedGitdir || + !marker.startsWith("gitdir: ") || + realpathSync(resolve(binding.workspace, marker.slice("gitdir: ".length))) !== binding.gitDir || + realpathSync(worktreeCommonDir(binding.gitDir)) !== binding.gitCommonDir + ) { + throw new Error("setup binding changed"); + } + } catch (err) { + throw new WorktreeLifecycleError(`worktree cleanup binding changed for ${binding.workspace}: ${(err as Error).message}`); + } + const remove = git(binding.baseWorkingTree, ["worktree", "remove", binding.workspace, "--force"]); + if (!remove.ok) { + throw new WorktreeLifecycleError( + `worktree cleanup failed for ${_agentId} (${binding.workspace}): ${remove.stderr || remove.stdout}`, + ); + } + return { changed: true, reason: "worktree removed" }; + } if (!workspacePath) { return { changed: false, reason: "no workspace path configured" }; } @@ -307,18 +699,33 @@ export function onStop(_agentId: string, workspacePath?: string): AgentLifecycle return { changed: false, reason: "base repo not found or not a git repo" }; } - if (!existsSync(workspace)) { + let workspaceExists: boolean; + try { + workspaceExists = pathExists(workspace); + } catch (err) { + throw new WorktreeLifecycleError( + `workspace probe failed for ${workspace}: ${(err as Error).message}`, + ); + } + if (!workspaceExists) { return { changed: false, reason: "workspace does not exist" }; } - const worktrees = parseWorktreePaths(baseRepo); - if (!worktrees.has(workspace)) { + let resolvedWorkspace: string; + try { + resolvedWorkspace = realpathSync(workspace); + } catch (err) { + throw new WorktreeLifecycleError(`worktree path could not be resolved for ${workspace}: ${(err as Error).message}`); + } + if (!trustedBaseWorktreeGitDir(workspace, resolvedWorkspace, baseRepo)) { return { changed: false, reason: "workspace is not a managed worktree; leaving untouched" }; } const remove = git(baseRepo, ["worktree", "remove", workspace, "--force"]); if (!remove.ok) { - throw new Error(`failed to remove worktree: ${remove.stderr || remove.stdout}`); + throw new WorktreeLifecycleError( + `worktree cleanup failed for ${_agentId} (${workspace}): ${remove.stderr || remove.stdout}`, + ); } return { changed: true, reason: "worktree removed" }; diff --git a/packages/cli/src/utils/agent-workspace.ts b/packages/cli/src/utils/agent-workspace.ts new file mode 100644 index 00000000..dee0f345 --- /dev/null +++ b/packages/cli/src/utils/agent-workspace.ts @@ -0,0 +1,16 @@ +import { dirname, isAbsolute, resolve } from "node:path"; +import { WorktreeLifecycleError } from "./agent-lifecycle.js"; + +export function agentWorkspace( + configPath: string, + configuredWorkspace: string, + childEnv?: NodeJS.ProcessEnv, +): string { + // Relative workspaces use the config pathname's directory. + const workspace = childEnv?.TPS_AGENT_WORKSPACE ?? resolve(dirname(configPath), configuredWorkspace); + const pending = childEnv?.TPS_WORKTREE_PENDING; + if (!isAbsolute(workspace) || (pending !== undefined && (!isAbsolute(pending) || pending !== workspace))) { + throw new WorktreeLifecycleError("pending worktree workspace mismatch"); + } + return workspace; +} diff --git a/packages/cli/src/utils/codex-runtime.ts b/packages/cli/src/utils/codex-runtime.ts index 8418d72e..768d3af4 100644 --- a/packages/cli/src/utils/codex-runtime.ts +++ b/packages/cli/src/utils/codex-runtime.ts @@ -173,6 +173,18 @@ interface RunCodexOptions { onStall?: () => void; } +export function codexExecArgs(config: CodexRuntimeConfig): string[] { + const args = [ + "exec", "--json", "--ephemeral", + ...(config.model ? ["--model", config.model] : []), + "--sandbox", config.sandboxMode ?? "workspace-write", + "--cd", config.workspace, + "-", + ]; + for (const dir of config.extraDirs ?? []) args.push("--add-dir", dir); + return args; +} + async function runCodex( message: MailMessage, config: CodexRuntimeConfig, @@ -181,18 +193,10 @@ async function runCodex( ): Promise { await syncWorkspaceBeforeTask(config); const systemPrompt = await buildSystemPrompt(message, config); - const sandboxMode = config.sandboxMode ?? "workspace-write"; const prompt = [systemPrompt, "", `[Mail from: ${message.from}]`, message.body].join("\n"); - const args = [ - "exec", "--json", "--ephemeral", - ...(config.model ? ["--model", config.model] : []), - "--sandbox", sandboxMode, - "--cd", config.workspace, - "-", - ]; - for (const dir of config.extraDirs ?? []) args.push("--add-dir", dir); + const args = codexExecArgs(config); const logPath = config.sessionLogPath ?? join(homedir(), ".tps", "agents", config.agentId, "session.log"); appendFileSync(logPath, `\n${"=".repeat(60)}\n[${new Date().toISOString()}] Task from ${message.from} (codex)\n${"=".repeat(60)}\n`, "utf-8"); diff --git a/packages/cli/src/utils/launch-attestation.ts b/packages/cli/src/utils/launch-attestation.ts index 1ce93633..27dc7869 100644 --- a/packages/cli/src/utils/launch-attestation.ts +++ b/packages/cli/src/utils/launch-attestation.ts @@ -36,6 +36,7 @@ * process that spawns it — that process already owns it. */ +import type { WorktreeLaunchBinding } from "./agent-lifecycle.js"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { createServer, Socket, type Server } from "node:net"; import { randomBytes, createHash } from "node:crypto"; @@ -467,6 +468,7 @@ export interface Attestation { ok: boolean; reason?: string; sessionId?: string; + worktreeBinding?: WorktreeLaunchBinding; } /** @@ -474,7 +476,7 @@ export interface Attestation { * this process's pid (self-reported: Node/Bun expose no peer credentials on * either OS — `getpeereid` returns no pid on macOS, and 4b measured that * inherited fds are stripped across `nono run`), attempt both canary reads, - * report them, and require exactly `CONFINED ` with + * report them, and require a `CONFINED ` release with * `` equal to this pid. Anything else — no locator, connect refused, * a wrong pid, a timeout — is a refusal; the caller names the launcher. */ @@ -505,7 +507,7 @@ export async function attestConfinement( sock.write(`CANARY INSIDE ${inside}\n`); const line = await nextLine(sock, waitMs); if (typeof line !== "string") return { ok: false, reason: line.reason }; - const release = line.trim().match(/^CONFINED (\S+) (\d+)$/); + const release = line.trim().match(/^CONFINED (\S+) (\d+)(?: (\S+))?$/); if (!release) { return { ok: false, reason: `launcher wrote '${line.trim()}', not a CONFINED release` }; } @@ -516,7 +518,13 @@ export async function attestConfinement( reason: `the release names pid ${releasedPid}, not this process (${process.pid})`, }; } - return { ok: true, sessionId: release[1] }; + let worktreeBinding: WorktreeLaunchBinding | undefined; + try { + if (release[3]) worktreeBinding = JSON.parse(Buffer.from(release[3], "base64url").toString("utf8")); + } catch { + return { ok: false, reason: "launcher sent an invalid worktree binding" }; + } + return { ok: true, sessionId: release[1], worktreeBinding }; } finally { sock.end(); sock.destroy(); @@ -733,6 +741,7 @@ export function findBoundSession( } export interface LaunchOptions { + worktreeBinding?: WorktreeLaunchBinding; env?: NodeJS.ProcessEnv; runtimeDirectories?: readonly string[]; /** Test seam: where the private dir is created (default: $HOME). */ @@ -894,7 +903,10 @@ export async function launchAttested( // its own pid back, so a release minted for another pid is // useless to it. try { - conn.write(`CONFINED ${v.session.session_id} ${v.pid}\n`); + const binding = opts.worktreeBinding + ? ` ${Buffer.from(JSON.stringify(opts.worktreeBinding)).toString("base64url")}` + : ""; + conn.write(`CONFINED ${v.session.session_id} ${v.pid}${binding}\n`); } catch { /* the child died first; the exit handler refuses the launch */ } diff --git a/packages/cli/src/utils/nono.ts b/packages/cli/src/utils/nono.ts index 1e5262c7..1d41061b 100644 --- a/packages/cli/src/utils/nono.ts +++ b/packages/cli/src/utils/nono.ts @@ -50,6 +50,7 @@ import { createHash } from "node:crypto"; import { join, dirname, basename, isAbsolute, resolve, parse, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; +import { worktreeLaunchBinding, worktreeLaunchGit, type WorktreeLaunchBinding } from "./agent-lifecycle.js"; import { providerAuthPath, runtimeCredentialFiles, runtimeProviders, type CredentialRuntime } from "./runtime-credentials.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -275,12 +276,12 @@ export const BUN_TEMP_DIR = "/tmp"; * cache, the running interpreter's own directory, plus the system roots. One * definition so `agent start` and `mail watch` cannot drift apart (cli#341 S1b). */ -export function harnessReadPaths(): string[] { +export function harnessReadPaths(home: string = homedir()): string[] { // NOTE: no identity directory here. The identity dir holds every agent's key // on a shared-UID host; granting it read would let one agent read all others. // The launch grants exactly the launching agent's own key via // harnessReadFiles(agentId) → --read-file (cli#351 r4). - return [join(homedir(), ".bun"), dirname(process.execPath), ...systemReadPaths()]; + return [join(home, ".bun"), dirname(process.execPath), ...systemReadPaths()]; } /** @@ -288,15 +289,107 @@ export function harnessReadPaths(): string[] { * the public key beside it (the runtime resolves `~/.tps/identity/.key`; * the `.pub` is granted too since it is the same agent's own material). */ -export function harnessReadFiles(agentId?: string): string[] { +export function harnessReadFiles(agentId?: string, home: string = homedir()): string[] { const files = [...systemReadFiles()]; if (agentId) { - const idDir = join(homedir(), ".tps", "identity"); + const idDir = join(home, ".tps", "identity"); files.push(join(idDir, `${agentId}.key`), join(idDir, `${agentId}.pub`)); } return files; } +export interface AgentLaunchGrantInput { + configPath: string; + cwd?: string; + workspace: string; + launchId: string; + runtime?: string; + /** The HOME the launch runs under; defaults to this process's. */ + home?: string; + /** The environment runtime grants resolve against; defaults to process.env. */ + env?: NodeJS.ProcessEnv; + mailDir: string; + tmpDir: string; + agentDir: string; +} + +function agentLaunchGrants( + input: AgentLaunchGrantInput & { worktreeGit?: { gitDir: string; gitCommonDir: string } }, +): { read: string[]; readFiles: string[]; allowFiles: string[]; allow: string[] } { + const home = input.home ?? homedir(); + const runtimeGrants = runtimeNonoOptions(input.runtime, input.env); + return { + read: [ + ...new Set([ + ...harnessReadPaths(home), + ...(input.worktreeGit ? [input.worktreeGit.gitCommonDir] : []), + ]), + ], + readFiles: [...harnessReadFiles(input.launchId, home), canonicalPath(input.configPath)], + allowFiles: runtimeGrants.allowFiles ?? [], + allow: [ + ...new Set([ + input.mailDir, + input.tmpDir, + BUN_TEMP_DIR, + input.workspace, + input.agentDir, + ...(runtimeGrants.allow ?? []), + ...(input.worktreeGit ? [input.worktreeGit.gitDir] : []), + ]), + ], + }; +} + +export class WorktreeGrantError extends Error { + constructor(path: string) { + super(`WorktreeGrantError: grant overlaps the base working tree: ${path}`); + this.name = "WorktreeGrantError"; + } +} + +export function agentLaunchOptions(input: AgentLaunchGrantInput): ReturnType & { worktreeBinding: WorktreeLaunchBinding } { + const binding = worktreeLaunchBinding(input.workspace); + const worktreeGit = worktreeLaunchGit(binding); + // An unregistered worktree gets no metadata grants and must not inherit the + // caller's cwd as a writable grant: the sandbox's working directory is the + // workspace in both worktree cases. + const cwd = worktreeGit || binding.kind === "unregistered" + ? input.workspace + : input.cwd ?? process.cwd(); + const grants = { + ...agentLaunchGrants({ ...input, worktreeGit }), + cwd, + workdir: input.workspace, + }; + const approval = approveRuntimeNonoOptions(input.runtime, grants, input.env, input.launchId, input.configPath); + if (binding.kind === "none") return { ...approval, worktreeBinding: binding }; + const options = approval.refusal ? grants : approval.options; + const base = canonicalPath(binding.baseWorkingTree); + const compare = (p: string) => caseInsensitivePath(p) ? p.toLowerCase() : p; + const allowedInsideBase = new Map([ + ["readFiles", compare(canonicalPath(input.configPath))], + ]); + if (worktreeGit) { + allowedInsideBase.set("read", compare(canonicalPath(worktreeGit.gitCommonDir))); + allowedInsideBase.set("allow", compare(canonicalPath(worktreeGit.gitDir))); + } + const check = (path: string, kind: string) => { + const canon = compare(canonicalPath(path)); + if (canon !== compare(base) && allowedInsideBase.get(kind) === canon) return; + if (pathsOverlap(canon, compare(base))) throw new WorktreeGrantError(path); + }; + for (const key of ["cwd", "workdir", "read", "allow", "readFiles", "allowFiles"] as const) { + const value = options[key]; + for (const path of typeof value === "string" ? [value] : value ?? []) check(path, key); + } + for (const grant of sandboxProfileGrants(runtimeNonoProfile(input.runtime), input.env ?? process.env)) { + const kind = { read_file: "readFiles", allow_file: "allowFiles", write_file: "allowFiles", write: "allow" }[grant.kind] ?? grant.kind; + check(grant.path, kind); + } + return { ...approval, worktreeBinding: binding }; +} + export function runtimeNonoProfile(runtime?: string): NonoProfile { return runtime === "claude-code" || runtime === "codex" || runtime === "gemini" ? `tps-agent-run-${runtime}` @@ -481,6 +574,7 @@ export function sandboxProfileGrantRefusal( env: NodeJS.ProcessEnv = process.env, runtime?: string, bin: string | undefined = env.NONO_BIN, + configSnapshotDir?: string, ): string | null { let grants: ProfileGrant[]; try { @@ -505,6 +599,9 @@ export function sandboxProfileGrantRefusal( for (const grant of grants) { const comparison = asComparison(canonicalPath(grant.path)); const writable = ["allow", "write", "allow_file", "write_file"].includes(grant.kind); + if (writable && configSnapshotDir && pathsOverlap(comparison, asComparison(canonicalPath(configSnapshotDir)))) { + return `the sandbox profile '${profile}' grants write access to the config snapshot directory`; + } const profileDir = writable && profileDirs.find((d) => pathsOverlap(comparison, d.comparison)); if (profileDir) { return `the sandbox profile '${profile}' grants filesystem.${grant.kind} '${grant.path}', which overlaps the sandbox profile directory ${profileDir.path}`; @@ -611,7 +708,7 @@ function appendUnresolved(ancestor: string, tail: string[]): string { return join(ancestor, ...components); } -function canonicalPath(p: string, cwd: string = process.cwd(), links = 0): string { +export function canonicalPath(p: string, cwd: string = process.cwd(), links = 0): string { if (links > 40) throw new Error(`cannot resolve ${p}: too many symbolic links`); const inputRoot = parse(p).root; const base = sep === "\\" && inputRoot ? resolve(cwd, inputRoot) : cwd; @@ -658,6 +755,7 @@ export function approveRuntimeNonoOptions( grants: NonoOptions, env: NodeJS.ProcessEnv = process.env, agentId?: string, + configPath?: string, ): { options: NonoOptions; runtimeDirectories: string[]; refusal: string | null } { const options: NonoOptions = {}; const runtimeDirectories: string[] = []; @@ -792,7 +890,7 @@ export function approveRuntimeNonoOptions( ? [...runtimeCredentialFiles(runtime as CredentialRuntime, env), ...(runtimeNonoOptions(runtime, env).allowFiles ?? [])] : []; const ownTargets = ownFiles.map((p) => join(canonical(dirname(resolve(p))), basename(p))); - const systemTargets = systemReadFiles().map(canonical); + const readOnlyTargets = [...systemReadFiles(), ...(configPath ? [configPath] : [])].map(canonical); const fileGrants = [ ...(grants.readFiles ?? []).map((p) => ({ path: p, label: `the read-file grant '${p}'`, readOnly: true })), ...(grants.allowFiles ?? []).map((p) => ({ path: p, label: `the writable-file grant '${p}'`, readOnly: false })), @@ -806,7 +904,7 @@ export function approveRuntimeNonoOptions( if (root && !permittedTpsFile) { return result(`${grant.label} (${canon}) overlaps the TPS credential root ${root.label} (${root.path})`); } - if (!root && !ownTargets.some((p) => equals(p, canon)) && !(grant.readOnly && systemTargets.some((p) => equals(p, canon)))) { + if (!root && !ownTargets.some((p) => equals(p, canon)) && !(grant.readOnly && readOnlyTargets.some((p) => equals(p, canon)))) { return result(`${grant.label} (${canon}) is not a permitted file for runtime '${runtime}'`); } } @@ -1211,6 +1309,17 @@ export interface ConfinementVerdict { reason?: string; } +/** + * True when `flag` is one of tps's OWN arguments: it appears before the `--` + * that separates a wrapped payload (`--message`, `mail watch --exec`, + * secrets-guard's command) (cli#516). A `--sandboxed` inside a payload is not + * this process's flag, so it must not trip the `--sandboxed` gate. + */ +export function ownFlag(argv: readonly string[], flag: string): boolean { + const end = argv.indexOf("--"); + return (end === -1 ? argv : argv.slice(0, end)).includes(flag); +} + /** * Commands that hand control to an agent. These are exactly the commands that * generated units invoke, and the ones that must assert `--sandbox-required` @@ -1318,7 +1427,7 @@ export function evaluateLaunchControl(input: LaunchControlInput = {}): LaunchCon // spawned and to this pid, with enforcement verified behaviourally). The TTY // is irrelevant (cli#350 r4f): an un-released `--sandboxed` is refused // everywhere, because the interactive opt-out is `--no-sandbox`. - if (argv.includes(SANDBOXED_FLAG) && !(input.confinement?.released ?? false)) { + if (ownFlag(argv, SANDBOXED_FLAG) && !(input.confinement?.released ?? false)) { const hint = nonoLaunchHint(); return deny( `${SANDBOXED_FLAG} is refused: no launcher released this process` + diff --git a/packages/cli/test/agent-launch-options.test.ts b/packages/cli/test/agent-launch-options.test.ts new file mode 100644 index 00000000..34d99d03 --- /dev/null +++ b/packages/cli/test/agent-launch-options.test.ts @@ -0,0 +1,679 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, relative } from "node:path"; +import { onStart, onStop, worktreeLaunchBinding, WorktreeLifecycleError } from "../src/utils/agent-lifecycle.js"; +import { agentLaunchOptions, approveRuntimeNonoOptions, buildNonoArgs, WorktreeGrantError, type AgentLaunchGrantInput } from "../src/utils/nono.js"; +import { sandboxHomeBase } from "./helpers/launcher-grants.js"; + +function detectRelativeWorktreesSupport(): boolean { + const root = mkdtempSync(join(tmpdir(), "relativeworktrees-probe-")); + try { + const git = (args: string[]) => spawnSync("git", args, { cwd: root, encoding: "utf8" }); + const initialized = git(["init", "-q"]); + expect(initialized.status, initialized.stderr).toBe(0); + const baseline = git(["rev-parse", "--is-inside-work-tree"]); + expect(baseline.status, baseline.stderr).toBe(0); + expect(baseline.stdout.trim()).toBe("true"); + writeFileSync(join(root, ".git", "config"), "[core]\nrepositoryformatversion = 1\n[extensions]\nrelativeworktrees = true\n"); + const probe = git(["rev-parse", "--is-inside-work-tree"]); + if (probe.status === 0) { + expect(probe.stdout.trim()).toBe("true"); + return true; + } + expect(probe.status).not.toBeNull(); + expect(probe.stderr).toContain("unknown repository extension"); + expect(probe.stderr).toContain("relativeworktrees"); + return false; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const supportsRelativeWorktrees = detectRelativeWorktreesSupport(); +const relativeWorktreesLabel = `relativeworktrees ${supportsRelativeWorktrees ? "supported" : "unsupported"}`; + +test(`real Git extension probe agrees with git version (${relativeWorktreesLabel})`, () => { + const version = spawnSync("git", ["version"], { encoding: "utf8" }); + expect(version.status, version.stderr).toBe(0); + const parsed = /^git version (\d+)\.(\d+)(?:\.|\s|$)/.exec(version.stdout); + expect(parsed).not.toBeNull(); + const major = Number(parsed![1]); + const minor = Number(parsed![2]); + expect(supportsRelativeWorktrees).toBe(major > 2 || (major === 2 && minor >= 48)); +}); + +function fixture(run: (input: AgentLaunchGrantInput, base: string, root: string) => void): void { + const root = realpathSync(mkdtempSync(join(sandboxHomeBase(), "launch-options-"))); + const previousCwd = process.cwd(); + try { + const base = join(root, "tps"); + const workspace = join(root, "ws"); + const home = join(root, "home"); + mkdirSync(base); + mkdirSync(home); + const git = (args: string[]) => { + const result = spawnSync("git", args, { cwd: base, encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + return result.stdout; + }; + git(["init", "-q"]); + git(["-c", "user.name=fixture", "-c", "user.email=fixture@example.com", "commit", "--allow-empty", "-qm", "init"]); + const created = onStart("probe", workspace, { deferCheckout: true }); + expect(created.changed).toBe(true); + const existing = onStart("probe", workspace, { deferCheckout: true }); + expect(existing.changed).toBe(false); + expect(existing.gitDir).toBe(created.gitDir); + expect(worktreeLaunchBinding(workspace)).toEqual({ + kind: "registered", gitDir: created.gitDir, gitCommonDir: created.gitCommonDir, baseWorkingTree: base, + }); + const configPath = join(home, "agent.yaml"); + writeFileSync(configPath, "agentId: probe\n"); + process.chdir(base); + run({ + configPath, workspace, cwd: root, launchId: "probe", runtime: "codex", home, + env: { HOME: home }, mailDir: join(home, ".tps", "mail"), tmpDir: join(root, "tmp"), + agentDir: join(home, ".tps", "agents", "probe"), + }, base, root); + } finally { + process.chdir(previousCwd); + rmSync(root, { recursive: true, force: true }); + } +} + +for (const config of ["outside", "base", "symlink"] as const) { + test(`final launcher arguments with base cwd and ${config} config`, () => fixture((input, base, root) => { + const target = config === "outside" ? input.configPath : join(base, "agent.yaml"); + writeFileSync(target, "agentId: probe\n"); + let configPath = target; + if (config === "symlink") { + configPath = join(root, "config-link.yaml"); + symlinkSync(target, configPath); + } + const result = agentLaunchOptions({ ...input, cwd: base, configPath }); + expect(result.refusal).toBeNull(); + const args = buildNonoArgs("tps-agent-run-codex", result.options, ["payload"], input.env); + expect(args[args.indexOf(realpathSync(target)) - 1]).toBe("--read-file"); + expect(result.options.readFiles).toContain(realpathSync(target)); + expect(args).not.toContain("--allow-cwd"); + expect(result.options.cwd).toBe(input.workspace); + expect(args.slice(args.indexOf("--allow"), args.indexOf("--allow") + 2)).toEqual(["--allow", input.workspace]); + for (let index = 0; index < args.indexOf("--"); index++) { + if (!["--allow", "--read", "--workdir", "--allow-file", "--read-file"].includes(args[index]!)) continue; + const path = args[++index]!; + const remainder = relative(path, base); + expect(remainder === "" || (!remainder.startsWith("..") && !isAbsolute(remainder)), path).toBe(false); + } + })); +} + +for (const source of ["mailDir", "tmpDir", "agentDir", "runtime", "symlink"] as const) { + test(`refuses a base-containing grant from ${source}`, () => fixture((input, base, root) => { + if (source === "runtime") input.env = { HOME: input.home, CODEX_HOME: base }; + else if (source === "symlink") { + const alias = join(root, "alias"); + symlinkSync(base, alias); + input.tmpDir = alias; + } else input[source] = source === "tmpDir" ? root : base; + expect(() => agentLaunchOptions(input)).toThrow(WorktreeGrantError); + })); +} + +test("config file approval retains credential checks", () => fixture((input) => { + const foreignKey = join(input.home!, ".tps", "identity", "other.key"); + const result = approveRuntimeNonoOptions("codex", { readFiles: [foreignKey] }, input.env, "probe", foreignKey); + expect(result.refusal).toContain("~/.tps/identity"); +})); + +test("post-add metadata failure rolls back the real worktree", () => fixture((_input, base, root) => { + const gitBin = spawnSync("which", ["git"], { encoding: "utf8" }).stdout.trim(); + const bin = join(root, "bin"); + mkdirSync(bin); + const wrapper = join(bin, "git"); + writeFileSync(wrapper, [ + "#!/bin/sh", + 'if [ "$1" = "rev-parse" ] && [ "$2" = "--absolute-git-dir" ]; then echo "injected metadata failure" >&2; exit 79; fi', + `exec "${gitBin}" "$@"`, "", + ].join("\n")); + chmodSync(wrapper, 0o755); + const oldPath = process.env.PATH; + const workspace = join(root, "new-worktree"); + try { + process.env.PATH = `${bin}:${oldPath}`; + expect(() => onStart("probe", workspace, { deferCheckout: true })).toThrow(WorktreeLifecycleError); + expect(existsSync(workspace)).toBe(false); + const listed = spawnSync(gitBin, ["worktree", "list", "--porcelain"], { cwd: base, encoding: "utf8" }); + expect(listed.status).toBe(0); + expect(listed.stdout).not.toContain(workspace); + } finally { + process.env.PATH = oldPath; + } +})); + +test("refuses a base grant declared by the selected profile", () => fixture((input, base) => { + const profiles = join(input.home!, ".config", "nono", "profiles"); + mkdirSync(profiles, { recursive: true }); + writeFileSync(join(profiles, "tps-agent-run-codex.json"), JSON.stringify({ filesystem: { read: [base] } })); + expect(() => agentLaunchOptions(input)).toThrow(WorktreeGrantError); +})); + +test("refuses a profile read grant for a base subdirectory", () => fixture((input, base) => { + const source = join(base, "src"); + mkdirSync(source); + writeFileSync(join(source, "index.ts"), "export {};\n"); + const profiles = join(input.home!, ".config", "nono", "profiles"); + mkdirSync(profiles, { recursive: true }); + writeFileSync(join(profiles, "tps-agent-run-codex.json"), JSON.stringify({ filesystem: { read: [source] } })); + expect(() => agentLaunchOptions(input)).toThrow(WorktreeGrantError); + expect(() => agentLaunchOptions(input)).toThrow(`grant overlaps the base working tree: ${source}`); +})); + +test("refuses a tmpDir inside the base working tree", () => fixture((input, base) => { + const tmpDir = join(base, "tmp"); + mkdirSync(tmpDir); + expect(() => agentLaunchOptions({ ...input, tmpDir })).toThrow(WorktreeGrantError); +})); + +test("refuses TMPDIR at the registered Git common directory", () => fixture((input, base) => { + const previousTmpDir = process.env.TMPDIR; + try { + process.env.TMPDIR = realpathSync(join(base, ".git")); + expect(() => agentLaunchOptions({ ...input, tmpDir: process.env.TMPDIR || "/tmp" })).toThrow(WorktreeGrantError); + } finally { + if (previousTmpDir === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = previousTmpDir; + } +})); + +for (const kind of ["allow", "write", "allow_file", "write_file"] as const) { + test(`refuses a profile ${kind} grant at the registered Git common directory`, () => fixture((input, base) => { + const profiles = join(input.home!, ".config", "nono", "profiles"); + mkdirSync(profiles, { recursive: true }); + writeFileSync(join(profiles, "tps-agent-run-codex.json"), JSON.stringify({ filesystem: { [kind]: [join(base, ".git")] } })); + expect(() => agentLaunchOptions(input)).toThrow(WorktreeGrantError); + })); +} + +test("refuses a writable config snapshot grant inside the base", () => fixture((input, base) => { + const configPath = join(base, "snapshot.yaml"); + writeFileSync(configPath, "agentId: probe\n"); + expect(() => agentLaunchOptions({ ...input, configPath, tmpDir: configPath })).toThrow(WorktreeGrantError); +})); + +for (const kind of ["allow", "write", "allow_file", "write_file"] as const) { + test(`refuses a profile ${kind} grant for the config snapshot inside the base`, () => fixture((input, base) => { + const configPath = join(base, "snapshot.yaml"); + writeFileSync(configPath, "agentId: probe\n"); + const profiles = join(input.home!, ".config", "nono", "profiles"); + mkdirSync(profiles, { recursive: true }); + writeFileSync(join(profiles, "tps-agent-run-codex.json"), JSON.stringify({ filesystem: { [kind]: [configPath] } })); + expect(() => agentLaunchOptions({ ...input, configPath })).toThrow(WorktreeGrantError); + })); +} + +for (const kind of ["read", "read_file"] as const) { + test(`refuses a profile ${kind} grant at the registered Git directory`, () => fixture((input) => { + const binding = worktreeLaunchBinding(input.workspace); + expect(binding.kind).toBe("registered"); + if (binding.kind !== "registered") throw new Error("expected registered worktree"); + const profiles = join(input.home!, ".config", "nono", "profiles"); + mkdirSync(profiles, { recursive: true }); + writeFileSync(join(profiles, "tps-agent-run-codex.json"), JSON.stringify({ filesystem: { [kind]: [binding.gitDir] } })); + expect(() => agentLaunchOptions(input)).toThrow(WorktreeGrantError); + })); +} + +test("refuses a grant beside the registered worktree metadata entry", () => fixture((input, base) => { + const tmpDir = join(base, ".git", "worktrees", "ws-adjacent"); + mkdirSync(tmpDir); + writeFileSync(join(tmpDir, "gitdir"), `${join(base, "adjacent-workspace", ".git")}\n`); + expect(() => agentLaunchOptions({ ...input, tmpDir })).toThrow(WorktreeGrantError); +})); + +test("launch grants retain the bound metadata paths and config snapshot file", () => fixture((input, base) => { + const configPath = join(base, "snapshot.yaml"); + writeFileSync(configPath, "agentId: probe\n"); + const binding = worktreeLaunchBinding(input.workspace); + expect(binding.kind).toBe("registered"); + if (binding.kind !== "registered") throw new Error("expected registered worktree"); + expect(binding.gitDir).toBe(realpathSync(join(base, ".git", "worktrees", "ws"))); + expect(binding.gitCommonDir).toBe(realpathSync(join(base, ".git"))); + const result = agentLaunchOptions({ ...input, configPath }); + expect(result.refusal).toBeNull(); + expect(result.options.allow).toContain(binding.gitDir); + expect(result.options.read).toContain(binding.gitCommonDir); + expect(result.options.readFiles).toContain(realpathSync(configPath)); + const args = buildNonoArgs("tps-agent-run-codex", result.options, ["payload"], input.env); + expect(args[args.indexOf(binding.gitDir) - 1]).toBe("--allow"); + expect(args[args.indexOf(binding.gitCommonDir) - 1]).toBe("--read"); + expect(args[args.indexOf(realpathSync(configPath)) - 1]).toBe("--read-file"); +})); + +test("an existing worktree omits the base from explicit launch grants without a selected runtime", () => fixture((input, base) => { + const result = agentLaunchOptions({ ...input, runtime: undefined, cwd: base }); + expect(result.refusal).toBeNull(); + expect(result.options.cwd).toBe(input.workspace); + expect(result.options.allow).not.toContain(base); + expect(result.options.read).not.toContain(base); +})); + +for (const marker of ["gitfile", "commondir", "common-only"] as const) { + test(`launch requires trusted worktree metadata (${marker})`, () => fixture((input, base, root) => { + const other = join(root, "other"); + mkdirSync(other); + const g = (args: string[]) => { + const result = spawnSync("git", args, { cwd: other, encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); + }; + g(["init", "-q"]); + g(["-c", "user.name=fixture", "-c", "user.email=fixture@example.com", "commit", "--allow-empty", "-qm", "init"]); + if (marker === "gitfile") { + const linked = join(root, "other-worktree"); + g(["worktree", "add", "--detach", linked]); + writeFileSync(join(input.workspace, ".git"), readFileSync(join(linked, ".git"))); + } else { + const metadata = spawnSync("git", ["rev-parse", "--absolute-git-dir"], { cwd: input.workspace, encoding: "utf8" }); + expect(metadata.status).toBe(0); + if (marker === "common-only") g(["config", "core.worktree", base]); + writeFileSync(join(metadata.stdout.trim(), "commondir"), join(other, ".git")); + } + let approval: ReturnType | undefined; + expect(() => { approval = agentLaunchOptions(input); }).toThrow(WorktreeLifecycleError); + expect(() => agentLaunchOptions(input)).toThrow("worktree metadata binding failed"); + expect(approval).toBeUndefined(); + })); +} + +function unrelatedWorktree(root: string, name: string): { repo: string; ws: string } { + const repo = join(root, `${name}-repo`); + mkdirSync(repo, { recursive: true }); + const g = (args: string[]) => { + const result = spawnSync("git", args, { cwd: repo, encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + }; + g(["init", "-q"]); + g(["-c", "user.name=fixture", "-c", "user.email=fixture@example.com", "commit", "--allow-empty", "-qm", "init"]); + const ws = join(root, `${name}-ws`); + g(["worktree", "add", "--detach", ws]); + return { repo, ws }; +} + +test("an unrelated project's worktree receives no metadata grants", () => fixture((input, _base, root) => { + // A trusted base exists beside it (root/tps), but its registry does not list this workspace. + const { repo, ws } = unrelatedWorktree(root, "unrelated"); + const result = agentLaunchOptions({ ...input, workspace: ws, runtime: undefined, cwd: ws }); + expect(result.refusal).toBeNull(); + expect(result.options.read).not.toContain(join(repo, ".git")); + expect(result.options.allow).not.toContain(join(repo, ".git")); +})); + +test("an unrelated project's worktree launched from the base gets the workspace as its cwd grant", () => fixture((input, base, root) => { + const { ws } = unrelatedWorktree(root, "unregistered-cwd"); + const result = agentLaunchOptions({ ...input, workspace: ws, runtime: undefined, cwd: base }); + expect(result.refusal).toBeNull(); + expect(result.options.cwd).toBe(ws); + expect(result.options.allow).not.toContain(base); + const args = buildNonoArgs("tps-agent-run", result.options, ["payload"], input.env); + expect(args[args.indexOf("--allow") + 1]).toBe(ws); +})); + +test("an unregistered worktree refuses a base grant declared by the profile", () => fixture((input, base, root) => { + const { ws } = unrelatedWorktree(root, "unregistered-profile"); + const profiles = join(input.home!, ".config", "nono", "profiles"); + mkdirSync(profiles, { recursive: true }); + writeFileSync(join(profiles, "tps-agent-run.json"), JSON.stringify({ filesystem: { read: [base] } })); + expect(() => agentLaunchOptions({ ...input, workspace: ws, runtime: undefined, cwd: base })).toThrow(WorktreeGrantError); +})); + +for (const marker of ["missing", "directory"] as const) { + test(`a registered worktree with a ${marker} marker is refused on the default launch path`, () => fixture((input) => { + const gitPath = join(input.workspace, ".git"); + rmSync(gitPath, { recursive: true, force: true }); + if (marker === "directory") mkdirSync(gitPath); + expect(() => agentLaunchOptions({ ...input, runtime: undefined })).toThrow(WorktreeLifecycleError); + expect(() => agentLaunchOptions({ ...input, runtime: undefined })).toThrow( + marker === "directory" + ? `worktree git marker is not a regular file for a registered worktree: ${input.workspace}` + : `worktree git marker is missing for a registered worktree: ${input.workspace}`, + ); + })); +} + +test("a registered worktree with a missing directory is refused before grants", () => fixture((input, _base, root) => { + // Grants are computed from configPath; a symlink loop there throws when grant + // computation runs, so the named ownership error proves it did not. + const loop = join(root, "config-loop"); + symlinkSync(loop, loop); + rmSync(input.workspace, { recursive: true, force: true }); + let error: unknown; + try { + agentLaunchOptions({ ...input, runtime: undefined, configPath: loop }); + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(WorktreeLifecycleError); + expect((error as Error).message).toBe( + `worktree workspace is missing for a registered worktree: ${input.workspace}`, + ); +})); + +test("an absent unregistered path keeps the caller's cwd policy", () => fixture((input, base, root) => { + const absent = join(root, "absent-workspace"); + const result = agentLaunchOptions({ ...input, workspace: absent, runtime: undefined, cwd: base }); + expect(result.refusal).toBeNull(); + expect(result.options.cwd).toBe(base); +})); + +test("a registry read failure is a named WorktreeLifecycleError", () => fixture((input, base) => { + const common = spawnSync("git", ["rev-parse", "--git-common-dir"], { cwd: base, encoding: "utf8" }); + expect(common.status).toBe(0); + const registry = join(base, common.stdout.trim(), "worktrees"); + rmSync(registry, { recursive: true, force: true }); + writeFileSync(registry, "not a directory\n"); + expect(() => agentLaunchOptions({ ...input, runtime: undefined })).toThrow(WorktreeLifecycleError); + expect(() => agentLaunchOptions({ ...input, runtime: undefined })).toThrow("worktree registry could not be read"); +})); + +test("a symlink marker is refused with no trusted base repository", () => fixture((input, _base, root) => { + const { ws } = unrelatedWorktree(join(root, "elsewhere"), "symlinked-nobase"); + rmSync(join(ws, ".git")); + symlinkSync(join(root, "missing-target"), join(ws, ".git")); + expect(() => agentLaunchOptions({ ...input, workspace: ws, runtime: undefined })).toThrow(WorktreeLifecycleError); +})); + +test("a worktree with no trusted base repository receives no metadata grants", () => fixture((input, _base, root) => { + // No sibling tps next to this workspace, and the test home has no ~/ops/tps. + const { repo, ws } = unrelatedWorktree(join(root, "elsewhere"), "nobase"); + const result = agentLaunchOptions({ ...input, workspace: ws, runtime: undefined, cwd: ws }); + expect(result.refusal).toBeNull(); + expect(result.options.read).not.toContain(join(repo, ".git")); +})); + +test("a symlinked base uses resolved metadata grants", () => fixture((input, base, root) => { + const resolvedBase = join(root, "projects", "repo"); + mkdirSync(join(root, "projects")); + renameSync(base, resolvedBase); + symlinkSync(resolvedBase, base); + const result = agentLaunchOptions(input); + expect(result.refusal).toBeNull(); + expect(result.options.read).toContain(join(resolvedBase, ".git")); + expect(result.options.read).not.toContain(join(base, ".git")); + expect(result.options.allow).toContain(realpathSync(join(resolvedBase, ".git", "worktrees", "ws"))); +})); + +test("launch options accept a registered worktree beside a prunable entry", () => fixture((input, base, root) => { + const stale = join(root, "a-stale"); + const added = spawnSync("git", ["worktree", "add", "--detach", stale], { cwd: base, encoding: "utf8" }); + expect(added.status, added.stderr).toBe(0); + rmSync(stale, { recursive: true }); + expect(agentLaunchOptions(input).refusal).toBeNull(); +})); + +for (const runtime of [undefined, "codex"] as const) { + test(`a workspace symlink uses its registered target's launch options (${runtime ?? "default"})`, () => fixture((input, base, root) => { + const aliases = join(root, "aliases"); + mkdirSync(aliases); + writeFileSync(join(aliases, "tps"), "not a base directory\n"); + const alias = join(aliases, "workspace"); + symlinkSync(input.workspace, alias); + const direct = { ...input, runtime, cwd: base }; + const linked = { ...direct, workspace: alias }; + expect(worktreeLaunchBinding(alias)).toEqual(worktreeLaunchBinding(input.workspace)); + expect(worktreeLaunchBinding(alias).kind).toBe("registered"); + expect(onStart("probe", alias, { deferCheckout: true })) + .toEqual(onStart("probe", input.workspace, { deferCheckout: true })); + const target = agentLaunchOptions(direct); + const result = agentLaunchOptions(linked); + expect(target.refusal).toBeNull(); + expect(result).toEqual(target); + expect(result.options.cwd).toBe(input.workspace); + expect(result.options.workdir).toBe(input.workspace); + expect(result.options.allow).not.toContain(alias); + expect(buildNonoArgs("tps-agent-run", result.options, ["payload"], input.env)) + .toEqual(buildNonoArgs("tps-agent-run", target.options, ["payload"], input.env)); + expect(() => agentLaunchOptions({ ...direct, tmpDir: base })).toThrow(WorktreeGrantError); + expect(() => agentLaunchOptions({ ...linked, tmpDir: base })).toThrow(WorktreeGrantError); + expect(agentLaunchOptions({ ...linked, workspace: `${input.workspace}/../ws` }).refusal) + .toContain("unsupported parent-directory segment '..'"); + })); +} + +test("a workspace symlink to the trusted base uses the base's launch options", () => fixture((input, base, root) => { + const aliases = join(root, "aliases"); + mkdirSync(aliases); + writeFileSync(join(aliases, "tps"), "not a base directory\n"); + const alias = join(aliases, "base"); + symlinkSync(base, alias); + expect(worktreeLaunchBinding(alias)).toEqual(worktreeLaunchBinding(base)); + expect(worktreeLaunchBinding(alias).kind).toBe("none"); + expect(onStart("probe", alias, { deferCheckout: true })) + .toEqual(onStart("probe", base, { deferCheckout: true })); + const target = agentLaunchOptions({ ...input, workspace: base, cwd: base }); + const result = agentLaunchOptions({ ...input, workspace: alias, cwd: base }); + expect(target.refusal).toBeNull(); + expect(result).toEqual(target); + expect(result.options.workdir).toBe(base); + expect(result.options.allow).not.toContain(alias); + expect(buildNonoArgs("tps-agent-run", result.options, ["payload"], input.env)) + .toEqual(buildNonoArgs("tps-agent-run", target.options, ["payload"], input.env)); +})); + +test("a dangling workspace symlink to a registered target is refused before grants", () => fixture((input, _base, root) => { + const aliases = join(root, "aliases"); + mkdirSync(aliases); + const alias = join(aliases, "workspace"); + symlinkSync(relative(aliases, input.workspace), alias); + rmSync(input.workspace, { recursive: true }); + const loop = join(root, "config-loop"); + symlinkSync(loop, loop); + for (const workspace of [input.workspace, alias]) { + let error: unknown; + try { + agentLaunchOptions({ ...input, workspace, configPath: loop }); + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(WorktreeLifecycleError); + expect((error as Error).message).toBe( + `worktree workspace is missing for a registered worktree: ${input.workspace}`, + ); + } +})); + +for (const layout of ["absolute", "relative"] as const) { + for (const marker of ["absolute", "relative"] as const) { + test(`a ${layout} registry gitdir ${layout === "relative" && !supportsRelativeWorktrees ? "is refused by the base repository probe" : "binds launch metadata and preserves an existing worktree"} (${marker} marker; ${relativeWorktreesLabel})`, () => fixture((input, base, root) => { + const workspace = join(root, "relative-worktree"); + const created = onStart("probe", workspace, { deferCheckout: true }); + expect(created.changed).toBe(true); + const metadata = spawnSync("git", ["rev-parse", "--absolute-git-dir"], { cwd: workspace, encoding: "utf8" }); + expect(metadata.status, metadata.stderr).toBe(0); + const gitDir = realpathSync(metadata.stdout.trim()); + if (layout === "relative") { + for (const [key, value] of [["core.repositoryformatversion", "1"], ["extensions.relativeworktrees", "true"]] as const) { + const configured = spawnSync("git", ["config", "--local", key, value], { cwd: base, encoding: "utf8" }); + expect(configured.status, configured.stderr).toBe(0); + } + } + const recordedPath = join(gitDir, "gitdir"); + const recorded = `${layout === "absolute" ? join(workspace, ".git") : relative(dirname(recordedPath), join(workspace, ".git"))}\n`; + writeFileSync(recordedPath, recorded); + const gitfile = `gitdir: ${marker === "absolute" ? gitDir : relative(workspace, gitDir)}\n`; + writeFileSync(join(workspace, ".git"), gitfile); + expect(isAbsolute(readFileSync(recordedPath, "utf8").trim())).toBe(layout === "absolute"); + const gitBin = spawnSync("which", ["git"], { encoding: "utf8" }).stdout.trim(); + const bin = join(root, "bin"); + mkdirSync(bin); + const listed = join(root, "worktree-listed"); + const wrapper = join(bin, "git"); + writeFileSync(wrapper, [ + "#!/bin/sh", + `if [ "$1" = "worktree" ] && [ "$2" = "list" ]; then touch "${listed}"; fi`, + `exec "${gitBin}" "$@"`, "", + ].join("\n")); + chmodSync(wrapper, 0o755); + const oldPath = process.env.PATH; + try { + process.env.PATH = `${bin}:${oldPath}`; + if (layout === "relative" && !supportsRelativeWorktrees) { + for (const operation of [ + () => worktreeLaunchBinding(workspace), + () => agentLaunchOptions({ ...input, workspace, cwd: base }), + () => onStart("probe", workspace, { deferCheckout: true }), + () => onStop("probe", workspace), + ]) { + expect(operation).toThrow(WorktreeLifecycleError); + expect(operation).toThrow("git probe failed for the base repository"); + } + expect(readFileSync(join(workspace, ".git"), "utf8")).toBe(gitfile); + expect(readFileSync(recordedPath, "utf8")).toBe(recorded); + expect(existsSync(join(gitDir, "index"))).toBe(false); + expect(existsSync(listed)).toBe(false); + expect(existsSync(workspace)).toBe(true); + expect(existsSync(gitDir)).toBe(true); + return; + } + const gitCommonDir = realpathSync(join(base, ".git")); + expect(worktreeLaunchBinding(workspace)).toEqual({ kind: "registered", gitDir, gitCommonDir, baseWorkingTree: base }); + const result = agentLaunchOptions({ ...input, workspace, cwd: base }); + expect(result.refusal).toBeNull(); + expect(result.options.cwd).toBe(workspace); + expect(result.options.allow).toContain(gitDir); + expect(result.options.read).toContain(gitCommonDir); + const existing = onStart("probe", workspace, { deferCheckout: true }); + expect(existing.changed).toBe(false); + expect(existing.gitDir).toBe(gitDir); + expect(readFileSync(join(workspace, ".git"), "utf8")).toBe(gitfile); + expect(readFileSync(recordedPath, "utf8")).toBe(recorded); + expect(existsSync(join(gitDir, "index"))).toBe(false); + expect(worktreeLaunchBinding(workspace)).toEqual({ kind: "registered", gitDir, gitCommonDir, baseWorkingTree: base }); + expect(agentLaunchOptions({ ...input, workspace, cwd: base })).toEqual(result); + expect(existsSync(listed)).toBe(false); + expect(onStop("probe", workspace).changed).toBe(true); + expect(existsSync(listed)).toBe(false); + expect(existsSync(workspace)).toBe(false); + expect(existsSync(gitDir)).toBe(false); + } finally { + process.env.PATH = oldPath; + } + })); + } +} + +for (const config of ["absent", "false", "format-zero"] as const) { + test(`relative registry gitdir with ${config} extension config is a named lifecycle error (${relativeWorktreesLabel})`, () => fixture((input, base) => { + const metadata = spawnSync("git", ["rev-parse", "--absolute-git-dir"], { cwd: input.workspace, encoding: "utf8" }); + expect(metadata.status, metadata.stderr).toBe(0); + const gitDir = realpathSync(metadata.stdout.trim()); + for (const [key, value] of [ + ["core.repositoryformatversion", config === "format-zero" ? "0" : "1"], + ["extensions.relativeworktrees", config === "false" ? "false" : "true"], + ] as const) { + if (config === "absent" && key === "extensions.relativeworktrees") continue; + const configured = spawnSync("git", ["config", "--local", key, value], { cwd: base, encoding: "utf8" }); + expect(configured.status, configured.stderr).toBe(0); + } + writeFileSync(join(gitDir, "gitdir"), `${relative(gitDir, join(input.workspace, ".git"))}\n`); + for (const operation of [ + () => worktreeLaunchBinding(input.workspace), + () => agentLaunchOptions(input), + () => onStart("probe", input.workspace, { deferCheckout: true }), + () => onStop("probe", input.workspace), + ]) { + expect(operation).toThrow(WorktreeLifecycleError); + expect(operation).toThrow((supportsRelativeWorktrees ? config === "format-zero" : config === "false") + ? "git probe failed for the base repository" + : "relative gitdir requires extensions.relativeworktrees=true and core.repositoryformatversion >= 1"); + } + expect(existsSync(input.workspace)).toBe(true); + expect(existsSync(gitDir)).toBe(true); + })); +} + +for (const corruption of ["unreadable", "unresolvable", "empty"] as const) { + test(`lifecycle and launch reject a ${corruption} registry entry`, () => fixture((input, base) => { + const registry = join(base, ".git", "worktrees", "invalid"); + mkdirSync(registry); + const recordedPath = join(registry, "gitdir"); + if (corruption === "unreadable") mkdirSync(recordedPath); + else if (corruption === "unresolvable") { + const loop = join(registry, "loop"); + symlinkSync("loop", loop); + writeFileSync(recordedPath, `${loop}/.git\n`); + } else writeFileSync(recordedPath, "\n"); + for (const operation of [ + () => onStart("probe", input.workspace, { deferCheckout: true }), + () => onStop("probe", input.workspace), + () => worktreeLaunchBinding(input.workspace), + ]) { + expect(operation).toThrow(WorktreeLifecycleError); + expect(operation).toThrow("worktree registry entry could not be resolved"); + } + })); +} + +for (const rewrite of [false, true]) { + test(`cleanup uses the setup binding with registry rewrite ${rewrite}`, () => fixture((_input, _base, root) => { + const workspace = join(root, "cleanup-worktree"); + const start = onStart("probe", workspace, { deferCheckout: true }); + expect(start.changed).toBe(true); + expect(start.cleanupBinding).toBeDefined(); + const binding = start.cleanupBinding!; + expect(binding.workspace).toBe(realpathSync(workspace)); + expect(binding.gitDir).toBe(start.gitDir!); + if (rewrite) { + writeFileSync(binding.registryEntry, join(root, "repointed", ".git") + "\n"); + let result: ReturnType | undefined; + let error: unknown; + try { + result = onStop("probe", workspace, binding); + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(WorktreeLifecycleError); + expect((error as Error).message).toContain(`worktree cleanup binding changed for ${workspace}`); + expect(result).toBeUndefined(); + expect(existsSync(workspace)).toBe(true); + expect(existsSync(binding.gitDir)).toBe(true); + } else { + expect(onStop("probe", workspace, binding).changed).toBe(true); + expect(existsSync(workspace)).toBe(false); + expect(existsSync(binding.gitDir)).toBe(false); + } + })); +} + +for (const target of ["dangling", "loop"] as const) { + test(`cleanup names a ${target} workspace symlink resolution failure`, () => fixture((_input, _base, root) => { + const workspace = join(root, "cleanup-link"); + symlinkSync(target === "loop" ? workspace : join(root, "absent"), workspace); + let error: unknown; + try { + onStop("probe", workspace); + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(WorktreeLifecycleError); + expect((error as Error).message).toContain(`worktree path could not be resolved for ${workspace}`); + })); +} + +test("cleanup reports a retained worktree removal failure", () => fixture((_input, base, root) => { + const workspace = join(root, "locked-worktree"); + const start = onStart("probe", workspace, { deferCheckout: true }); + expect(start.changed).toBe(true); + expect(start.cleanupBinding).toBeDefined(); + const locked = spawnSync("git", ["worktree", "lock", workspace], { cwd: base, encoding: "utf8" }); + expect(locked.status, locked.stderr).toBe(0); + let error: unknown; + try { + onStop("probe", workspace, start.cleanupBinding); + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(WorktreeLifecycleError); + expect((error as Error).message).toContain(`worktree cleanup failed for probe (${workspace})`); + expect(existsSync(workspace)).toBe(true); +})); diff --git a/packages/cli/test/agent-workspace.test.ts b/packages/cli/test/agent-workspace.test.ts new file mode 100644 index 00000000..40cb8a0a --- /dev/null +++ b/packages/cli/test/agent-workspace.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const workspaceModule = resolve(import.meta.dir, "../dist/src/utils/agent-workspace.js"); +const lifecycleModule = resolve(import.meta.dir, "../dist/src/utils/agent-lifecycle.js"); +const configModule = resolve(import.meta.dir, "../../agent/dist/config.js"); +const agentModule = resolve(import.meta.dir, "../dist/src/commands/agent.js"); +const runtimeModule = resolve(import.meta.dir, "../../agent/dist/index.js"); + +test("default child uses the parent's absolute workspace with a symlinked config and relative workspace", () => { + const root = mkdtempSync(join(tmpdir(), "workspace-symlink-")); + try { + const launch = join(root, "launch"); + const target = join(root, "target"); + mkdirSync(launch); + mkdirSync(target); + const config = join(target, "agent.yaml"); + const link = join(launch, "agent.yaml"); + writeFileSync(config, `agentId: probe\nworkspace: worktree\nmailDir: ${join(root, "mail")}\nmemoryPath: ${join(root, "memory.jsonl")}\nllm:\n provider: ollama\n model: probe\n`); + symlinkSync(config, link); + const childScript = ` + import { AgentRuntime } from ${JSON.stringify(runtimeModule)}; + import { runAgent } from ${JSON.stringify(agentModule)}; + AgentRuntime.prototype.start = async function () { console.log(this.config.workspace); }; + await runAgent({ action: 'start', config: ${JSON.stringify(realpathSync(link))}, sandboxed: true }); + `; + const parentScript = ` + import { spawnSync } from 'node:child_process'; + import { loadAgentConfig } from ${JSON.stringify(configModule)}; + import { agentWorkspace } from ${JSON.stringify(workspaceModule)}; + const workspace = agentWorkspace(${JSON.stringify(link)}, loadAgentConfig(${JSON.stringify(link)}).workspace); + console.log(workspace); + const child = spawnSync('node', ['--input-type=module', '-e', ${JSON.stringify(childScript)}], { + encoding: 'utf8', env: { ...process.env, TPS_AGENT_WORKSPACE: workspace }, + }); + process.stdout.write(child.stdout); + process.stderr.write(child.stderr); + process.exit(child.status ?? 1); + `; + const result = spawnSync("node", ["--input-type=module", "-e", parentScript], { + cwd: root, encoding: "utf8", env: { ...process.env, HOME: root }, timeout: 20_000, + }); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + const workspace = join(launch, "worktree"); + expect(result.stdout).toBe(`${workspace}\n${workspace}\n`); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +for (const marker of ["matching", "different", "relative"] as const) { + test(`real-file relative workspace child launch with ${marker} pending marker`, () => { + const root = mkdtempSync(join(tmpdir(), "workspace-launch-")); + try { + const home = join(root, "home"); + const base = join(home, "ops", "tps"); + const configDir = join(root, "config"); + const workspace = join(configDir, "worktree"); + mkdirSync(base, { recursive: true }); + mkdirSync(configDir); + const git = (args: string[]) => spawnSync("git", args, { cwd: base, encoding: "utf8" }); + expect(git(["init", "-q"]).status).toBe(0); + writeFileSync(join(base, "tracked.txt"), "checked out\n"); + expect(git(["add", "tracked.txt"]).status).toBe(0); + expect(git(["-c", "user.name=fixture", "-c", "user.email=fixture@example.com", "commit", "-qm", "init"]).status).toBe(0); + const config = join(configDir, "agent.yaml"); + writeFileSync(config, "agentId: probe\nworkspace: worktree\n"); + const childScript = ` + import { readFileSync } from 'node:fs'; + import { loadAgentConfig } from ${JSON.stringify(configModule)}; + import { agentWorkspace } from ${JSON.stringify(workspaceModule)}; + import { checkoutWorktree } from ${JSON.stringify(lifecycleModule)}; + const config = loadAgentConfig(${JSON.stringify(config)}); + const workspace = agentWorkspace(${JSON.stringify(config)}, config.workspace, process.env); + checkoutWorktree(workspace); + console.log(workspace); + console.log(readFileSync(workspace + '/tracked.txt', 'utf8')); + `; + const parentScript = ` + import { spawnSync } from 'node:child_process'; + import { loadAgentConfig } from ${JSON.stringify(configModule)}; + import { agentWorkspace } from ${JSON.stringify(workspaceModule)}; + import { onStart } from ${JSON.stringify(lifecycleModule)}; + const config = loadAgentConfig(${JSON.stringify(config)}); + const workspace = agentWorkspace(${JSON.stringify(config)}, config.workspace); + const start = onStart('probe', workspace, { deferCheckout: true }); + if (!start.changed) throw new Error('worktree not created'); + const child = spawnSync('node', ['--input-type=module', '-e', ${JSON.stringify(childScript)}], { + cwd: workspace, encoding: 'utf8', + env: { ...process.env, TPS_AGENT_WORKSPACE: workspace, + TPS_WORKTREE_PENDING: ${JSON.stringify(marker)} === 'matching' ? workspace : ${JSON.stringify(marker)} === 'different' ? workspace + '-other' : '../worktree' }, + }); + process.stdout.write(child.stdout); + process.stderr.write(child.stderr); + process.exit(child.status ?? 1); + `; + const result = spawnSync("node", ["--input-type=module", "-e", parentScript], { + cwd: root, encoding: "utf8", env: { ...process.env, HOME: home }, timeout: 20_000, + }); + if (marker === "matching") { + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(result.stdout).toBe(`${workspace}\nchecked out\n\n`); + } else { + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("WorktreeLifecycleError: pending worktree workspace mismatch"); + expect(result.stdout).toBe(""); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} diff --git a/packages/cli/test/helpers/launcher-grants.ts b/packages/cli/test/helpers/launcher-grants.ts index a5bdb755..1f328f38 100644 --- a/packages/cli/test/helpers/launcher-grants.ts +++ b/packages/cli/test/helpers/launcher-grants.ts @@ -1,13 +1,7 @@ -/** - * 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"; +/** Fixture bases checked against the fixed launcher grants. */ +import { spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } 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"; @@ -25,7 +19,7 @@ function realOrResolved(path: string): string { } } -/** The launcher grant that covers `path` (equal, or an ancestor directory), or null. */ +/** The fixed 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) { @@ -45,14 +39,7 @@ function canCreateDirIn(candidate: string): boolean { } } -/** - * 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. - */ +/** A writable fixture base outside the fixed launcher grants. */ export function sandboxHomeBase(candidates: string[] = ["/var/tmp", tmpdir()]): string { for (const candidate of candidates) { if (canCreateDirIn(candidate) && launcherGrantCovering(candidate) === null) return realpathSync(candidate); @@ -61,14 +48,123 @@ export function sandboxHomeBase(candidates: string[] = ["/var/tmp", tmpdir()]): 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.", + "a test sandbox HOME needs a base outside every fixed 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 ` + + `a test sandbox HOME needs a base outside every fixed launcher grant, but '${existing[0]}' ` + + `(TMPDIR=${tmpdir()}) falls inside the fixed launcher '${grant}' grant — point TMPDIR at a ` + `directory outside it${suggestion} and re-run.`, ); } + +/** The prefix emitted after an unsuccessful sandboxed read. */ +export const NONO_PROBE_DENIED_PREFIX = "TPS-NONO-PROBE-DENIED-"; + +/** What real nono did with a sandboxed read of a file under a candidate base. */ +export type NonoProbeVerdict = + | { kind: "denied" } + | { kind: "granted" } + | { kind: "refused"; stderr: string }; + +/** Accept a candidate only after an unsandboxed nonce read succeeds and the sandboxed read reports a permission error. */ +export function probeNonoBuiltinRead( + nono: string, + base: string, + env: NodeJS.ProcessEnv = process.env, +): NonoProbeVerdict { + const scratch = mkdtempSync(join(base, "tps-nono-probe-")); + try { + const home = join(scratch, "home"); + const profileDir = join(home, ".config", "nono", "profiles"); + const cwd = join(scratch, "cwd"); + mkdirSync(profileDir, { recursive: true }); + mkdirSync(cwd); + const profile = join(profileDir, "tps-nono-probe.json"); + writeFileSync( + profile, + JSON.stringify({ + $schema: "https://nono.sh/schemas/nono-profile.schema.json", + extends: "default", + meta: { name: "tps-nono-probe" }, + }), + ); + const file = join(scratch, "probe-file"); + const nonce = `tps-nono-probe-nonce-${randomBytes(8).toString("hex")}`; + writeFileSync(file, `${nonce}\n`); + const token = randomBytes(8).toString("hex"); + const marker = `${NONO_PROBE_DENIED_PREFIX}${token}`; + const probeEnv = { ...env, HOME: home, XDG_STATE_HOME: join(scratch, "nono-state"), LC_ALL: "C" }; + const baseline = spawnSync("cat", [file], { cwd, encoding: "utf-8", timeout: 20_000, env: probeEnv }); + if (baseline.error || baseline.status !== 0 || baseline.stdout.trim() !== nonce) { + throw new Error(`the unsandboxed read probe failed: ${baseline.error?.message ?? baseline.stderr}`); + } + const args = [ + "run", "-s", "--profile", profile, "--", + "sh", "-c", `if cat "$1"; then exit 0; else status=$?; printf "${NONO_PROBE_DENIED_PREFIX}%s\\n" "$2"; exit "$status"; fi`, "sh", file, token, + ]; + const r = spawnSync(nono, args, { + cwd, + encoding: "utf-8", + timeout: 20_000, + env: probeEnv, + }); + const stdout = (r.stdout ?? "").trim(); + const stderr = (r.stderr ?? "").trim(); + const permissionDenied = stderr.split("\n").some((line) => + line === `cat: ${file}: Permission denied` || line === `cat: ${file}: Operation not permitted`); + if (!r.error && r.status === 1 && stdout === marker && permissionDenied) return { kind: "denied" }; + if (stdout === marker) throw new Error(`the sandboxed read probe failed: exit ${r.status}, stderr ${JSON.stringify(stderr)}`); + if (!r.error && r.status === 0 && stdout === nonce) return { kind: "granted" }; + if (!r.error && typeof r.status === "number" && r.status !== 0 && stdout === "") { + return { kind: "refused", stderr }; + } + throw new Error( + `the nono read probe under ${base} gave an unknown answer, which is never read as "not granted": ` + + `\`${nono} ${args.join(" ")}\` ` + + (r.error ? `failed to run (${r.error.message})` : `exited ${r.status ?? `on signal ${r.signal}`}`) + + `, stdout ${JSON.stringify(stdout)}, stderr ${JSON.stringify(stderr)}`, + ); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +/** The default candidates for a real-nono fixture base, in order of preference. */ +export function realNonoHomeBaseCandidates(platform: NodeJS.Platform = process.platform): string[] { + return ["/var/tmp", tmpdir(), ...(platform === "darwin" ? ["/Users/Shared"] : [])]; +} + +/** A writable base outside the fixed launcher grants where the nono read probe is denied. */ +export function realNonoHomeBase( + nono: string, + candidates: string[] = realNonoHomeBaseCandidates(), + env: NodeJS.ProcessEnv = process.env, +): string { + const rejected: string[] = []; + for (const candidate of candidates) { + if (!canCreateDirIn(candidate)) { + rejected.push(`${candidate}: not a writable directory`); + continue; + } + const grant = launcherGrantCovering(candidate); + if (grant !== null) { + rejected.push(`${candidate}: inside the fixed launcher '${grant}' grant`); + continue; + } + const base = realpathSync(candidate); + const verdict = probeNonoBuiltinRead(nono, base, env); + if (verdict.kind === "denied") return base; + rejected.push( + verdict.kind === "granted" + ? `${candidate}: nono's default policy lets a sandboxed process read a file under it` + : `${candidate}: the probe produced no output and exited nonzero: ${verdict.stderr || "(no stderr)"}`, + ); + } + throw new Error( + "a real-nono fixture HOME needs a base outside every fixed launcher grant where nono's " + + `built-in policy denies a scratch file under it, and no candidate qualifies — ${rejected.join("; ")}`, + ); +} diff --git a/packages/cli/test/launch-attestation-real-nono.test.ts b/packages/cli/test/launch-attestation-real-nono.test.ts index b9ded5c0..d2583e56 100644 --- a/packages/cli/test/launch-attestation-real-nono.test.ts +++ b/packages/cli/test/launch-attestation-real-nono.test.ts @@ -17,7 +17,7 @@ */ import { describe, test, expect, beforeAll, setDefaultTimeout } from "bun:test"; import { spawn, spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { homedir } from "node:os"; import { resolveNonoBinary, checkNonoPin } from "../src/utils/launch-attestation.js"; @@ -28,6 +28,37 @@ const TPS_BIN = resolve(import.meta.dir, "../dist/bin/tps.js"); const NODE = process.env.TPS_TEST_NODE ?? (spawnSync("which", ["node"], { encoding: "utf-8" }).stdout?.trim() || "node"); +async function stopRealLaunch(child: ReturnType, output: string): Promise { + const releasedPid = /released under nono session \S+ \(child pid (\d+)\)/.exec(output)?.[1]; + const targets = new Set(); + if (releasedPid) targets.add(Number(releasedPid)); + else if (child.pid) targets.add(-child.pid); + if (child.pid) targets.add(child.pid); + const alive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code !== "ESRCH"; + } + }; + for (const signal of ["SIGTERM", "SIGKILL"] as const) { + for (const pid of targets) { + try { + process.kill(pid, signal); + } catch { + /* checked below */ + } + } + const deadline = Date.now() + 3_000; + do { + if ([...targets].every((pid) => !alive(pid))) return true; + await new Promise((r) => setTimeout(r, 25)); + } while (Date.now() < deadline); + } + return false; +} + const SANDBOX_REQUIRED = "--sandbox-required"; // cli#350 r4g — these tests wait on a real release window (up to 45s / 60s); @@ -169,12 +200,12 @@ real("4e positive — real nono (Landlock/Seatbelt): the premise", () => { real("4e positive — the attested launch against the pinned nono", () => { test("tps agent start is RELEASED: real nono, canaries verified, session bound to the spawned pid", async () => { const sb = sandbox(); + let child: ReturnType | undefined; + let text = ""; try { - // Spawned ASYNC: once released, the agent runtime keeps running (it is a - // long-lived process), so the fixture reads the launch output until the - // release line appears and then stops the tree. - const child = spawn(NODE, [TPS_BIN, "agent", "start", "--id", "probe", SANDBOX_REQUIRED], { + child = spawn(NODE, [TPS_BIN, "agent", "start", "--id", "probe", SANDBOX_REQUIRED], { cwd: sb.ws, + detached: true, env: { ...process.env, HOME: sb.home, @@ -182,27 +213,38 @@ real("4e positive — the attested launch against the pinned nono", () => { TPS_LAUNCH_TIMEOUT_MS: "20000", }, }); - let text = ""; + const launcher = child; + const pidFile = join(sb.ws, ".tps-agent.pid"); const released = await new Promise((resolvePromise) => { const timer = setTimeout(() => resolvePromise(false), 45_000); const onData = (chunk: Buffer) => { text += chunk.toString("utf-8"); - if (text.includes("released under nono session")) { + if (/released under nono session \S+ \(child pid \d+\)/.test(text)) { clearTimeout(timer); resolvePromise(true); } }; - child.stdout?.on("data", onData); - child.stderr?.on("data", onData); - child.on("exit", () => { + launcher.stdout?.on("data", onData); + launcher.stderr?.on("data", onData); + launcher.on("exit", () => { clearTimeout(timer); - resolvePromise(text.includes("released under nono session")); + resolvePromise(/released under nono session \S+ \(child pid \d+\)/.test(text)); }); }); - child.kill("SIGTERM"); - await new Promise((r) => setTimeout(r, 500)); - child.kill("SIGKILL"); - expect(released).toBe(true); + if (released) { + await new Promise((resolveReady) => { + const deadline = Date.now() + 5_000; + const poll = () => { + if (existsSync(pidFile) || launcher.exitCode !== null || launcher.signalCode !== null || Date.now() >= deadline) { + resolveReady(); + } else { + setTimeout(poll, 10); + } + }; + poll(); + }); + } + expect(released, text).toBe(true); // The release line is written only after the launcher bound the live nono // session to the pid it spawned and to the pid the child reported, and only // after the child reported the OUTSIDE canary DENIED and its granted twin @@ -212,18 +254,11 @@ real("4e positive — the attested launch against the pinned nono", () => { expect(text).not.toContain("READ the OUTSIDE canary"); // The agent actually ran inside that session (the runtime writes its pid // file first thing). - const pidFile = join(sb.ws, ".tps-agent.pid"); - expect(existsSync(pidFile)).toBe(true); - if (existsSync(pidFile)) { - const pid = Number.parseInt(readFileSync(pidFile, "utf-8").trim(), 10); - try { - process.kill(pid, "SIGTERM"); - } catch { - /* already gone */ - } - } + expect(existsSync(pidFile), text).toBe(true); } finally { + const stopped = !child || await stopRealLaunch(child, text); rmSync(sb.root, { recursive: true, force: true }); + expect(stopped, "teardown could not confirm the released child and launcher are gone").toBe(true); } }); }); @@ -261,6 +296,7 @@ real("4e+r4f positive — a TTY parent still RELEASES (real nono)", () => { : ["-qec", cmd, "/dev/null"]; const child = spawn("script", argv, { cwd: sb.ws, + detached: true, env: { ...process.env, HOME: sb.home, @@ -269,35 +305,23 @@ real("4e+r4f positive — a TTY parent still RELEASES (real nono)", () => { }, }); let text = ""; - const released = await new Promise((resolvePromise) => { - const timer = setTimeout(() => resolvePromise(false), 60_000); - const onData = (chunk: Buffer) => { - text += chunk.toString("utf-8"); - if (text.includes("released under nono session")) { + try { + const released = await new Promise((resolvePromise) => { + const timer = setTimeout(() => resolvePromise(false), 60_000); + const onData = (chunk: Buffer) => { + text += chunk.toString("utf-8"); + if (/released under nono session \S+ \(child pid \d+\)/.test(text)) { + clearTimeout(timer); + resolvePromise(true); + } + }; + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + child.on("exit", () => { clearTimeout(timer); - resolvePromise(true); - } - }; - child.stdout?.on("data", onData); - child.stderr?.on("data", onData); - child.on("exit", () => { - clearTimeout(timer); - resolvePromise(text.includes("released under nono session")); + resolvePromise(/released under nono session \S+ \(child pid \d+\)/.test(text)); + }); }); - }); - // Stop the tree: the released agent is long-lived. - const pidFile = join(sb.ws, ".tps-agent.pid"); - if (existsSync(pidFile)) { - try { - process.kill(Number.parseInt(readFileSync(pidFile, "utf-8").trim(), 10), "SIGTERM"); - } catch { - /* already gone */ - } - } - child.kill("SIGTERM"); - await new Promise((r) => setTimeout(r, 500)); - child.kill("SIGKILL"); - try { // Pre-r4f the child skipped the handshake at a TTY and the launcher // refused at its window; with the locator keyed instead of the TTY, the // interactive path is RELEASED like the piped one. @@ -305,7 +329,9 @@ real("4e+r4f positive — a TTY parent still RELEASES (real nono)", () => { expect(text).toContain("released under nono session"); expect(text).not.toContain("no released child within"); } finally { + const stopped = await stopRealLaunch(child, text); rmSync(sb.root, { recursive: true, force: true }); + expect(stopped, "teardown could not confirm the released child and launcher are gone").toBe(true); } }); }); diff --git a/packages/cli/test/launch-attestation.test.ts b/packages/cli/test/launch-attestation.test.ts index 6891d741..ddc6b05e 100644 --- a/packages/cli/test/launch-attestation.test.ts +++ b/packages/cli/test/launch-attestation.test.ts @@ -42,6 +42,7 @@ import { writeFileSync, } from "node:fs"; import { connect } from "node:net"; +import type { WorktreeLaunchBinding } from "../src/utils/agent-lifecycle.js"; import { join, resolve } from "node:path"; import { homedir, tmpdir } from "node:os"; import { @@ -647,6 +648,7 @@ async function runLauncherInProcess(opts: { publishStore?: boolean; storeSessions?: unknown; peerEnv?: Record; + worktreeBinding?: WorktreeLaunchBinding; failFirst?: { disableCanaryCheck?: boolean; disablePidBinding?: boolean }; }): Promise { const sb = opts.sb; @@ -745,6 +747,7 @@ exit 0 }, home: sb.home, failFirstSeam: opts.failFirst, + worktreeBinding: opts.worktreeBinding, } ); await peerProc.done; @@ -809,6 +812,7 @@ function attempt() { sock.write("CANARY INSIDE " + (mode === "blind-denied" ? "DENIED" : "READ:" + inside) + "\\n"); }); sock.on("data", (chunk) => { + if (process.env.FAKE_NONO_RELEASE_LOG) require("node:fs").appendFileSync(process.env.FAKE_NONO_RELEASE_LOG, chunk); if (String(chunk).includes("CONFINED")) { if (process.env.FAKE_NONO_STOP) { try { require("node:fs").writeFileSync(process.env.FAKE_NONO_STOP, "released"); } catch {} @@ -896,6 +900,55 @@ describe("4e fixtures — the launcher's own checks (scripted peer)", () => { }); }); +test("the launcher release carries its retained worktree binding", async () => { + const sb = makeSandbox("worktree-binding"); + try { + const worktreeBinding: WorktreeLaunchBinding = { + kind: "registered", gitDir: join(sb.root, "git dir"), + gitCommonDir: join(sb.root, "common dir"), baseWorkingTree: join(sb.root, "base"), + }; + const log = join(sb.root, "release"); + const run = await runLauncherInProcess({ + sb, peer: "confined", worktreeBinding, peerEnv: { FAKE_NONO_RELEASE_LOG: log }, + }); + expect(run.exitCode, run.stderr).toBe(0); + const release = readFileSync(log, "utf8").trim().split(" "); + expect(release[0]).toBe("CONFINED"); + expect(JSON.parse(Buffer.from(release[3]!, "base64url").toString("utf8"))).toEqual(worktreeBinding); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } +}); + +for (const payload of ["registered", "unregistered", "none", "invalid JSON"] as const) { + test(`the child reads the release worktree binding: ${payload}`, async () => { + const sb = makeSandbox("binding-child"); + const dir = sb.root; + const { createServer } = await import("node:net"); + const binding: WorktreeLaunchBinding = payload === "registered" + ? { kind: "registered", gitDir: join(dir, "git dir"), gitCommonDir: join(dir, "common"), baseWorkingTree: join(dir, "base") } + : payload === "unregistered" ? { kind: "unregistered", baseWorkingTree: join(dir, "base") } : { kind: "none" }; + const token = Buffer.from(payload === "invalid JSON" ? "{" : JSON.stringify(binding)).toString("base64url"); + const server = createServer((conn) => { + conn.once("data", () => conn.write(`CONFINED fixture ${process.pid} ${token}\n`)); + }); + try { + const sockPath = join(dir, "launch.sock"); + await new Promise((resolvePromise, reject) => { + server.once("error", reject); + server.listen(sockPath, resolvePromise); + }); + const result = await attestConfinement({ [LAUNCH_SOCK_ENV]: sockPath }, 1000); + expect(result.ok).toBe(payload !== "invalid JSON"); + if (payload === "invalid JSON") expect(result.reason).toBe("launcher sent an invalid worktree binding"); + else expect(result.worktreeBinding).toEqual(binding); + } finally { + server.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); +} + describe("4e FAILS-FIRST — each check is what refuses its fixture", () => { test("canary check reverted → the unconfined-child fixture goes GREEN", async () => { const sb = makeSandbox("fails-first-canary"); diff --git a/packages/cli/test/launcher-grants.test.ts b/packages/cli/test/launcher-grants.test.ts index 31ff9e66..0fcceec8 100644 --- a/packages/cli/test/launcher-grants.test.ts +++ b/packages/cli/test/launcher-grants.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { chmodSync, mkdirSync, mkdtempSync, readdirSync, 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"; +import { launcherGrantCovering, probeNonoBuiltinRead, realNonoHomeBase, sandboxHomeBase } from "./helpers/launcher-grants.js"; /** * Whether a write into the directory under test is refused for want of @@ -45,7 +45,7 @@ function modeRestrictionEnforced(): boolean { const modeRestrictionEnforcedHere = modeRestrictionEnforced(); describe("sandboxHomeBase", () => { - test("refuses when every existing candidate is inside a launcher grant, naming TMPDIR and the grant", () => { + test("refuses when every existing candidate is inside a fixed launcher grant, naming TMPDIR and the grant", () => { expect(() => sandboxHomeBase(["/tmp", "/tmp/x"])).toThrow(/TMPDIR=/); expect(() => sandboxHomeBase(["/tmp", "/tmp/x"])).toThrow(/'\/tmp'/); }); @@ -54,7 +54,7 @@ describe("sandboxHomeBase", () => { expect(() => sandboxHomeBase(["/tmp"])).toThrow(/for example \/var\/tmp/); }); - test("returns a candidate that lies outside every grant", () => { + test("returns a candidate that lies outside every fixed launcher grant", () => { expect(launcherGrantCovering("/var/tmp")).toBeNull(); expect(sandboxHomeBase(["/tmp", "/var/tmp"])).toBe(realpathSync("/var/tmp")); }); @@ -122,10 +122,10 @@ describe("launcherGrantCovering", () => { expect(launcherGrantCovering("/tmpfoo")).toBeNull(); }); - test("resolves a link outside every grant to the grant its real location sits in", () => { + test("resolves a link outside every fixed launcher grant to the grant its real location sits in", () => { const dir = mkdtempSync(join(sandboxHomeBase(), "lg-link-")); try { - // The link's own directory is outside every grant, so a match on the + // The link's own directory is outside every fixed launcher grant, so a match on the // spelled-out path alone cannot pass: only resolving the link reaches /tmp. expect(launcherGrantCovering(dir)).toBeNull(); const link = join(dir, "tmplink"); @@ -169,3 +169,153 @@ describe("writeRefusedForPermission", () => { } }); }); + +/** A fake nono that selects a read result by profile location. */ +function fakeProbeNono(dir: string): string { + const denyBin = join(dir, "deny-bin"); + mkdirSync(denyBin); + writeFileSync(join(denyBin, "cat"), '#!/bin/sh\necho "cat: $1: Operation not permitted" >&2\nexit 1\n'); + chmodSync(join(denyBin, "cat"), 0o755); + for (const [name, message, status] of [ + ["missing-file", "No such file or directory", 1], + ["missing-command", "command not found", 127], + ] as const) { + const toolDir = join(dir, name); + mkdirSync(toolDir); + writeFileSync(join(toolDir, "cat"), `#!/bin/sh\necho "cat: $1: ${message}" >&2\nexit ${status}\n`); + chmodSync(join(toolDir, "cat"), 0o755); + } + const bin = join(dir, "nono"); + writeFileSync( + bin, + [ + "#!/usr/bin/env bash", + 'if [ "${1:-}" != "run" ]; then echo "fake nono: unexpected $*" >&2; exit 64; fi', + 'profile=""', + 'while [ $# -gt 0 ] && [ "$1" != "--" ]; do', + ' if [ "$1" = "--profile" ]; then profile="$2"; shift; fi', + " shift", + "done", + "shift", + 'under() { [ -n "$1" ] && case "$profile" in "$1"/*) return 0 ;; esac; return 1; }', + 'if under "${FAKE_REFUSED_UNDER:-}"; then echo "nono: Sandbox initialization failed: fake refusal" >&2; exit 1; fi', + 'if under "${FAKE_UNKNOWN_UNDER:-}"; then exit 0; fi', + 'if under "${FAKE_GRANTED_UNDER:-}"; then exec "$@"; fi', + `if under "\${FAKE_MISSING_FILE_UNDER:-}"; then PATH="${join(dir, "missing-file")}:$PATH" exec "$@"; fi`, + `if under "\${FAKE_MISSING_COMMAND_UNDER:-}"; then PATH="${join(dir, "missing-command")}:$PATH" exec "$@"; fi`, + `PATH="${denyBin}:$PATH" exec "$@"`, + "", + ].join("\n"), + ); + chmodSync(bin, 0o755); + return bin; +} + +/** A parent dir (outside every fixed launcher grant) with one candidate dir per name. */ +function probeCandidates(...names: string[]): { parent: string; dirs: Record } { + const parent = mkdtempSync(join(sandboxHomeBase(), "lg-nono-")); + const dirs: Record = {}; + for (const name of names) { + dirs[name] = join(parent, name); + mkdirSync(dirs[name]); + } + return { parent, dirs }; +} + +describe("realNonoHomeBase", () => { + test("accepts a candidate whose sandboxed read nono denies, and leaves no probe behind", () => { + const { parent, dirs } = probeCandidates("free"); + try { + const nono = fakeProbeNono(parent); + expect(realNonoHomeBase(nono, [dirs.free], process.env)).toBe(realpathSync(dirs.free)); + expect(readdirSync(dirs.free)).toEqual([]); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("rejects a candidate whose sandboxed read nono grants, and returns the next one", () => { + const { parent, dirs } = probeCandidates("granted", "free"); + try { + const nono = fakeProbeNono(parent); + const env = { ...process.env, FAKE_GRANTED_UNDER: realpathSync(dirs.granted) }; + expect(realNonoHomeBase(nono, [dirs.granted, dirs.free], env)).toBe(realpathSync(dirs.free)); + expect(() => realNonoHomeBase(nono, [dirs.granted], env)).toThrow( + /granted: nono's default policy lets a sandboxed process read a file under it/, + ); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("rejects a candidate whose probe produces no output with a nonzero exit, quoting its stderr, and returns the next one", () => { + const { parent, dirs } = probeCandidates("refused", "free"); + try { + const nono = fakeProbeNono(parent); + const env = { ...process.env, FAKE_REFUSED_UNDER: realpathSync(dirs.refused) }; + expect(realNonoHomeBase(nono, [dirs.refused, dirs.free], env)).toBe(realpathSync(dirs.free)); + expect(() => realNonoHomeBase(nono, [dirs.refused], env)).toThrow( + /refused: the probe produced no output and exited nonzero: nono: Sandbox initialization failed: fake refusal/, + ); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("an unknown answer throws instead of reading as not granted or moving on", () => { + const { parent, dirs } = probeCandidates("unknown", "free"); + try { + const nono = fakeProbeNono(parent); + const env = { ...process.env, FAKE_UNKNOWN_UNDER: realpathSync(dirs.unknown) }; + expect(() => realNonoHomeBase(nono, [dirs.unknown, dirs.free], env)).toThrow( + /unknown answer, which is never read as "not granted".*exited 0, stdout "", stderr ""/, + ); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + for (const failure of ["FILE", "COMMAND"] as const) { + test(`reports the sandboxed read's missing ${failure.toLowerCase()} as a probe failure`, () => { + const { parent, dirs } = probeCandidates("candidate"); + try { + const nono = fakeProbeNono(parent); + expect(() => realNonoHomeBase(nono, [dirs.candidate], { + ...process.env, [`FAKE_MISSING_${failure}_UNDER`]: realpathSync(dirs.candidate), + })).toThrow(/sandboxed read probe failed/); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + } + + test("requires a successful unsandboxed nonce read", () => { + const { parent, dirs } = probeCandidates("candidate"); + try { + const nono = fakeProbeNono(parent); + expect(() => probeNonoBuiltinRead(nono, dirs.candidate, { + ...process.env, PATH: join(parent, "deny-bin"), + })).toThrow(/unsandboxed read probe failed.*Operation not permitted/); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("names every rejected candidate when none qualifies", () => { + const { parent, dirs } = probeCandidates("granted", "refused"); + try { + const nono = fakeProbeNono(parent); + const env = { + ...process.env, + FAKE_GRANTED_UNDER: realpathSync(dirs.granted), + FAKE_REFUSED_UNDER: realpathSync(dirs.refused), + }; + const missing = join(parent, "missing"); + expect(() => realNonoHomeBase(nono, [dirs.granted, dirs.refused, "/tmp", missing], env)).toThrow( + /granted: nono's default policy lets .*; .*refused: the probe produced no output and exited nonzero: .*fake refusal; \/tmp: inside the fixed launcher '\/tmp' grant; .*missing: not a writable directory/, + ); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/test/runtime-dir-credential-overlap.test.ts b/packages/cli/test/runtime-dir-credential-overlap.test.ts index 51364308..1f0c2739 100644 --- a/packages/cli/test/runtime-dir-credential-overlap.test.ts +++ b/packages/cli/test/runtime-dir-credential-overlap.test.ts @@ -422,7 +422,10 @@ describe("cli#483 — launcher grant checks", () => { expect(handoff.options.workdir).toBe(sb.ws); expect(handoff.options.allow).toContain(custom); expect(handoff.options.read.length).toBeGreaterThan(0); - expect(handoff.cmd.slice(-2)).toEqual(["--runtime", "claude-code"]); + // cli#516: the re-exec also carries --config, so assert the runtime by + // position rather than by the argv tail. + expect(handoff.cmd[handoff.cmd.indexOf("--runtime") + 1]).toBe("claude-code"); + expect(handoff.cmd).toContain("--config"); expect(handoff.cmd).toContain("--sandboxed"); expect(handoff.cmd).toContain("--sandbox-required"); } diff --git a/packages/cli/test/sandboxed-launch-516.test.ts b/packages/cli/test/sandboxed-launch-516.test.ts new file mode 100644 index 00000000..3f225b23 --- /dev/null +++ b/packages/cli/test/sandboxed-launch-516.test.ts @@ -0,0 +1,1611 @@ +/** + * cli#516 — sandboxed agent launch. + * + * Config and worktree launcher cases use fake nono to record argv; flag cases + * invoke the built CLI directly. Real-nono cases skip when unavailable. + */ +import { describe, test, expect, beforeAll, setDefaultTimeout } from "bun:test"; +import { resolve, join, dirname, relative } from "node:path"; +import { randomBytes } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { resolveNonoBinary } from "../src/utils/launch-attestation.js"; +import { + NONO_MIN_VERSION, + canonicalPath, + harnessReadFiles, + agentLaunchOptions, + buildNonoArgs, + nonoVersion, + runtimeNonoProfile, + versionAtLeast, +} from "../src/utils/nono.js"; +import { checkoutWorktree, onStart, onStop, WorktreeLifecycleError } from "../src/utils/agent-lifecycle.js"; +import { realNonoHomeBase, sandboxHomeBase, NONO_PROBE_DENIED_PREFIX } from "./helpers/launcher-grants.js"; + +const TPS_BIN = resolve(import.meta.dir, "../dist/bin/tps.js"); +/** The shipped CLI is a node script; the real-nono case must run it with node + * (bun inside a nono sandbox sees an empty environment, so the launcher's + * socket locator does not reach a bun child). */ +const NODE = + process.env.TPS_TEST_NODE ?? + (spawnSync("which", ["node"], { encoding: "utf-8" }).stdout?.trim() || "node"); +async function stopRealLaunch(child: ReturnType, output: string): Promise { + const releasedPid = /released under nono session \S+ \(child pid (\d+)\)/.exec(output)?.[1]; + const targets = new Set(); + if (releasedPid) targets.add(Number(releasedPid)); + else if (child.pid) targets.add(-child.pid); + if (child.pid) targets.add(child.pid); + const alive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code !== "ESRCH"; + } + }; + for (const signal of ["SIGTERM", "SIGKILL"] as const) { + for (const pid of targets) { + try { + process.kill(pid, signal); + } catch { + /* checked below */ + } + } + const deadline = Date.now() + 3_000; + do { + if ([...targets].every((pid) => !alive(pid))) return true; + await new Promise((r) => setTimeout(r, 25)); + } while (Date.now() < deadline); + } + return false; +} + +const SANDBOX_REQUIRED = "--sandbox-required"; +const NONO_BIN_ENV = "NONO_BIN"; +const ACCEPTS_ONLY = "accepts only 'true' or 'false'"; + +setDefaultTimeout(30_000); + +beforeAll(() => { + if (!existsSync(TPS_BIN)) { + throw new Error(`tps binary not found at ${TPS_BIN}. Run 'bun run build' first.`); + } +}); + +/** Run the built launcher with piped stdio (stdin/stdout are NOT a TTY). */ +function runLauncher( + args: string[], + opts: { cwd?: string; env?: Record } = {}, +) { + return spawnSync("bun", [TPS_BIN, ...args], { + encoding: "utf-8", + cwd: opts.cwd, + timeout: 20_000, + env: { ...process.env, ...opts.env }, + }); +} + +function output(r: { stdout?: string | null; stderr?: string | null }): string { + return `${r.stdout ?? ""}${r.stderr ?? ""}`; +} + +/** A fake nono: answers the probe subcommands and logs `run` invocations. */ +function fakeNonoScript(extra: string): string { + return [ + "#!/usr/bin/env bash", + 'if [ "${1:-}" = "--version" ]; then echo "nono 0.74.0"; exit 0; fi', + 'if [ "${1:-}" = "profile" ]; then exit 0; fi', + 'if [ "${1:-}" = "why" ]; then echo \'{"status":"denied","reason":"path_not_granted"}\'; exit 0; fi', + 'if [ "${1:-}" = "run" ]; then', + extra, + ' echo "ARGV $*" >> "${NONO_ARGV_LOG:?}"', + " exit 0", + "fi", + "exit 0", + "", + ].join("\n"); +} + +/** ARGV lines the fake nono logged for a `run` (the launcher's spawn). */ +function fakeNonoRuns(root: string): string[] { + const log = join(root, "nono.log"); + if (!existsSync(log)) return []; + return readFileSync(log, "utf-8") + .split("\n") + .filter((l) => l.startsWith("ARGV run")) + .map((l) => l.slice(5)); +} + +function writeProfilePair(profileDir: string, runProfile: string): void { + writeFileSync( + join(profileDir, "tps-base-fixture.json"), + JSON.stringify({ + $schema: "https://nono.sh/schemas/nono-profile.schema.json", + meta: { name: "tps-base-fixture" }, + workdir: { access: "readwrite" }, + filesystem: { read: ["/usr", "/bin", "/lib", "/lib64"], deny: [] }, + }), + ); + writeFileSync( + join(profileDir, `${runProfile}.json`), + JSON.stringify({ + $schema: "https://nono.sh/schemas/nono-profile.schema.json", + extends: "tps-base-fixture", + meta: { name: runProfile }, + }), + ); +} + +interface Sandbox { + root: string; + home: string; + run: string; + tmp: string; + ws: string; + nonoBin: string; +} + +function makeSandbox(base: string = sandboxHomeBase()): Sandbox { + // The base must sit outside every fixed 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(base, "tps-516-")); + const home = join(root, "home"); + const run = join(root, "run"); + const tmp = join(root, "tmp"); + const ws = join(root, "ws"); + const nonoDir = join(root, "nono"); + for (const d of [ + home, + run, + tmp, + ws, + nonoDir, + join(home, ".config", "nono", "profiles"), + join(home, ".tps", "agents", "probe"), + join(home, ".tps", "mail"), + join(home, ".tps", "identity"), + ]) { + mkdirSync(d, { recursive: true }); + } + writeProfilePair(join(home, ".config", "nono", "profiles"), "tps-agent-run"); + // Seed fixture identity files. + writeFileSync(join(home, ".tps", "identity", "probe.key"), randomBytes(32)); + writeFileSync( + join(home, ".tps", "identity", "probe.pub"), + "fixture-pub\n", + ); + return { root, home, run, tmp, ws, nonoBin: join(nonoDir, "nono") }; +} + +function agentYaml(sb: Sandbox, workspace: string): string { + return ( + "agentId: probe\nname: probe\n" + + `workspace: ${workspace}\n` + + `mailDir: ${join(sb.home, ".tps", "mail")}\n` + + `memoryPath: ${join(sb.home, ".tps", "agents", "probe", "memory.jsonl")}\n` + + "llm:\n provider: ollama\n model: probe-model\n" + ); +} + +function installFakeNono(sb: Sandbox, extra: string): void { + writeFileSync(sb.nonoBin, fakeNonoScript(extra)); + chmodSync(sb.nonoBin, 0o755); +} + +function cliEnv(sb: Sandbox, extra: Record = {}): Record { + return { + ...(process.env as Record), + HOME: sb.home, + TMPDIR: sb.tmp, + [NONO_BIN_ENV]: sb.nonoBin, + NONO_ARGV_LOG: join(sb.root, "nono.log"), + TPS_LAUNCH_TIMEOUT_MS: "1500", + ...extra, + }; +} + +for (const scenario of ["registered", "unregistered", "rewritten after setup", "no trusted base"] as const) { + test(`additional Codex metadata directories use the setup registry binding: ${scenario}`, () => { + const sb = makeSandbox(); + try { + const base = join(sb.home, "ops", "tps"); + const other = join(sb.root, "other"); + const g = (cwd: string, args: string[]) => { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); + }; + for (const repo of scenario === "no trusted base" ? [other] : [base, other]) { + mkdirSync(repo, { recursive: true }); + g(repo, ["init", "-q"]); + g(repo, ["-c", "user.name=fixture", "-c", "user.email=fixture@example.com", "commit", "--allow-empty", "-qm", "init"]); + } + const registered = scenario === "registered" || scenario === "rewritten after setup"; + let expected: string[] = []; + if (registered) { + rmSync(sb.ws, { recursive: true }); + g(base, ["worktree", "add", "--detach", sb.ws]); + expected = [g(sb.ws, ["rev-parse", "--absolute-git-dir"]), g(base, ["rev-parse", "--absolute-git-dir"])]; + } else { + writeFileSync(join(sb.ws, ".git"), `gitdir: ${join(other, ".git")}\n`); + } + installFakeNono(sb, " :"); + writeProfilePair(join(sb.home, ".config", "nono", "profiles"), "tps-agent-run-codex"); + writeFileSync(join(sb.home, ".tps", "agents", "probe", "agent.yaml"), + agentYaml(sb, sb.ws) + "workspaceProvider:\n type: none\n"); + const attestationModule = resolve(import.meta.dir, "../dist/src/utils/launch-attestation.js"); + const agentModule = resolve(import.meta.dir, "../dist/src/commands/agent.js"); + const codexModule = resolve(import.meta.dir, "../dist/src/utils/codex-runtime.js"); + const preload = join(sb.root, "child-preload.ts"); + writeFileSync(preload, ` + import { mock } from 'bun:test'; + const attestation = await import(${JSON.stringify(attestationModule)}); + mock.module(${JSON.stringify(attestationModule)}, () => ({ ...attestation, + attestConfinement: async () => ({ ok: true, worktreeBinding: JSON.parse(process.env.FIXTURE_BINDING) }), + })); + const codex = await import(${JSON.stringify(codexModule)}); + mock.module(${JSON.stringify(codexModule)}, () => ({ ...codex, + runCodexRuntime: async (config) => console.log('CODEX_ARGS ' + JSON.stringify(codex.codexExecArgs(config))), + })); + `); + const script = ` + import { mock } from 'bun:test'; + import { writeFileSync } from 'node:fs'; + import { spawnSync } from 'node:child_process'; + const attestation = await import(${JSON.stringify(attestationModule)}); + mock.module(${JSON.stringify(attestationModule)}, () => ({ ...attestation, + launchAttested: async (_profile, options, cmd, opts) => { + console.log('SETUP_BINDING ' + JSON.stringify(opts.worktreeBinding)); + if (${JSON.stringify(scenario)} === 'rewritten after setup') { + writeFileSync(${JSON.stringify(join(sb.ws, ".git"))}, ${JSON.stringify(`gitdir: ${join(other, ".git")}\n`)}); + } + const child = spawnSync('bun', ['--preload', ${JSON.stringify(preload)}, ${JSON.stringify(TPS_BIN)}, ...cmd.slice(cmd.indexOf('agent'))], { + encoding: 'utf8', cwd: ${JSON.stringify(sb.ws)}, + env: { ...opts.env, TPS_LAUNCH_SOCK: 'fixture', FIXTURE_BINDING: JSON.stringify(opts.worktreeBinding) }, + }); + process.stdout.write(child.stdout); process.stderr.write(child.stderr); + return child.status ?? 1; + }, + })); + const { runAgent } = await import(${JSON.stringify(agentModule)}); + await runAgent({ action: 'start', id: 'probe', runtime: 'codex', sandboxRequired: true }); + `; + const result = spawnSync("bun", ["-e", script], { + cwd: sb.run, encoding: "utf8", timeout: 20_000, env: cliEnv(sb), + }); + expect(result.status, output(result)).toBe(0); + const bindingLine = result.stdout.split("\n").find((line) => line.startsWith("SETUP_BINDING ")); + expect(bindingLine, output(result)).toBeDefined(); + const binding = JSON.parse(bindingLine!.slice("SETUP_BINDING ".length)); + expect(binding.kind).toBe(registered ? "registered" : scenario === "unregistered" ? "unregistered" : "none"); + if (registered) expect([binding.gitDir, binding.gitCommonDir]).toEqual(expected); + const argsLine = result.stdout.split("\n").find((line) => line.startsWith("CODEX_ARGS ")); + expect(argsLine, output(result)).toBeDefined(); + const args: string[] = JSON.parse(argsLine!.slice("CODEX_ARGS ".length)); + const addDirs = args.flatMap((arg, index) => arg === "--add-dir" ? [args[index + 1]!] : []); + expect(addDirs).toEqual([ + join(sb.home, ".tps", "mail", "probe"), join(sb.home, "ops", "tps"), join(sb.home, "ops", "flair"), ...expected, + ]); + expect(addDirs).not.toContain(join(other, ".git")); + if (scenario === "rewritten after setup") { + expect(readFileSync(join(sb.ws, ".git"), "utf8")).toBe(`gitdir: ${join(other, ".git")}\n`); + } + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); +} + +// --------------------------------------------------------------------------- +// (1) — `--config` survives the attested re-exec +// --------------------------------------------------------------------------- + +describe("cli#516 (1) — the attested re-exec uses a config snapshot", () => { + test("agent start re-execs with the config snapshot and grants its file", () => { + const sb = makeSandbox(); + try { + installFakeNono(sb, " :"); + // The default config lives in the agent dir; the custom one, elsewhere. + writeFileSync( + join(sb.home, ".tps", "agents", "probe", "agent.yaml"), + agentYaml(sb, sb.ws), + ); + const customDir = join(sb.root, "custom"); + const customConfig = join(customDir, "agent.yaml"); + mkdirSync(customDir, { recursive: true }); + writeFileSync(customConfig, agentYaml(sb, sb.ws)); + + const result = runLauncher( + ["agent", "start", "--config", customConfig, SANDBOX_REQUIRED], + { cwd: sb.run, env: cliEnv(sb) }, + ); + + const child = fakeNonoRuns(sb.root).find((l) => l.includes("agent start")) ?? ""; + const snapshot = /--config (\S+)/.exec(child)?.[1]; + expect(snapshot, output(result)).toBeDefined(); + expect(snapshot).toStartWith(join(sb.home, ".tps", "launch", "config-")); + expect(child, output(result)).toContain(`--config ${snapshot}`); + expect(child).toContain(`--read-file ${snapshot}`); + expect(child).not.toContain(`--read-file ${customConfig}`); + expect(existsSync(snapshot!)).toBe(false); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + test("a relative --config is carried as an absolute snapshot path", () => { + const sb = makeSandbox(); + try { + installFakeNono(sb, " :"); + // The config sits at a RELATIVE path from the launch cwd (`sb.run`), and + // its workspace is neither the cwd nor the config's directory. + const customDir = join(sb.run, "rel"); + mkdirSync(customDir, { recursive: true }); + const customAbs = join(customDir, "agent.yaml"); + writeFileSync(customAbs, agentYaml(sb, sb.ws)); + + runLauncher( + ["agent", "start", "--config", join("rel", "agent.yaml"), SANDBOX_REQUIRED], + { cwd: sb.run, env: cliEnv(sb) }, + ); + + const child = fakeNonoRuns(sb.root).find((l) => l.includes("agent start")) ?? ""; + const snapshot = /--config (\S+)/.exec(child)?.[1]; + expect(snapshot).toBeDefined(); + expect(snapshot).toStartWith(join(sb.home, ".tps", "launch", "config-")); + expect(child).toContain(`--config ${snapshot}`); + expect(child).toContain(`--read-file ${snapshot}`); + expect(child).not.toContain(`--read-file ${customAbs}`); + expect(existsSync(snapshot!)).toBe(false); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); +}); + +test.each(["../ws", "nested/worktree"])("config snapshot, child and workspace grant resolve %s against the original config directory", (configuredWorkspace) => { + const sb = makeSandbox(); + try { + const workspace = resolve(sb.run, configuredWorkspace); + mkdirSync(workspace, { recursive: true }); + const target = join(sb.run, "agent.yaml"); + const alternate = join(sb.root, "alternate.yaml"); + const link = join(sb.run, "config.yaml"); + writeFileSync(target, agentYaml(sb, configuredWorkspace)); + writeFileSync(alternate, agentYaml(sb, sb.run)); + symlinkSync(target, link); + installFakeNono(sb, " :"); + const attestationModule = resolve(import.meta.dir, "../dist/src/utils/launch-attestation.js"); + const agentModule = resolve(import.meta.dir, "../dist/src/commands/agent.js"); + const loader = ` + const agent = await import(${JSON.stringify(resolve(import.meta.dir, "../../agent/dist/index.js"))}); + const load = agent.loadAgentConfig; + mock.module(${JSON.stringify(resolve(import.meta.dir, "../../agent/dist/index.js"))}, () => ({ ...agent, + AgentRuntime: class { + constructor(config) { this.config = config; } + async start() { console.log('WORKSPACE ' + this.config.workspace); } + }, + loadAgentConfig: (path) => { + const config = load(path); + console.log('PARSED ' + JSON.stringify({ path, workspace: config.workspace })); + return config; + }, + })); + `; + const preload = join(sb.root, "snapshot-child.ts"); + writeFileSync(preload, ` + import { mock } from 'bun:test'; + ${loader} + const attestation = await import(${JSON.stringify(attestationModule)}); + mock.module(${JSON.stringify(attestationModule)}, () => ({ ...attestation, + attestConfinement: async () => ({ ok: true, worktreeBinding: { kind: 'none' } }), + })); + `); + const result = spawnSync("bun", ["-e", ` + import { mock } from 'bun:test'; + import { readFileSync, statSync, unlinkSync, symlinkSync } from 'node:fs'; + import { dirname } from 'node:path'; + import { spawnSync } from 'node:child_process'; + ${loader} + const attestation = await import(${JSON.stringify(attestationModule)}); + mock.module(${JSON.stringify(attestationModule)}, () => ({ ...attestation, + launchAttested: async (_profile, options, cmd, opts) => { + const path = cmd[cmd.indexOf('--config') + 1]; + console.log('LAUNCH ' + JSON.stringify({ options, cmd, path, + content: readFileSync(path, 'utf8'), + fileMode: statSync(path).mode & 0o777, dirMode: statSync(dirname(path)).mode & 0o777, + owner: statSync(dirname(path)).uid, uid: process.getuid(), + })); + const child = spawnSync('bun', ['--preload', ${JSON.stringify(preload)}, ${JSON.stringify(TPS_BIN)}, ...cmd.slice(cmd.indexOf('agent'))], { + encoding: 'utf8', cwd: options.cwd ?? process.cwd(), env: { ...opts.env, TPS_AGENT_WORKSPACE: undefined, TPS_LAUNCH_SOCK: 'fixture' }, + }); + process.stdout.write(child.stdout); process.stderr.write(child.stderr); + return child.status ?? 1; + }, + })); + const { runAgent } = await import(${JSON.stringify(agentModule)}); + await runAgent({ action: 'start', id: 'probe', config: ${JSON.stringify(link)}, sandboxRequired: true }, { + afterConfigSnapshot: (path) => { + unlinkSync(${JSON.stringify(link)}); + symlinkSync(${JSON.stringify(alternate)}, ${JSON.stringify(link)}); + console.log('SNAPSHOT ' + path); + }, + }); + `], { cwd: sb.run, encoding: "utf8", timeout: 20_000, env: cliEnv(sb) }); + expect(result.status, output(result)).toBe(0); + const lines = result.stdout.split("\n"); + const snapshot = lines.find((line) => line.startsWith("SNAPSHOT "))?.slice(9); + expect(snapshot, output(result)).toBeDefined(); + const parsed = lines.filter((line) => line.startsWith("PARSED ")).map((line) => JSON.parse(line.slice(7))); + expect(parsed).toEqual([{ path: snapshot, workspace: configuredWorkspace }, { path: snapshot, workspace }]); + const launch = JSON.parse(lines.find((line) => line.startsWith("LAUNCH "))!.slice(7)); + expect(launch.path).toBe(snapshot); + expect(launch.options.workdir).toBe(canonicalPath(workspace)); + expect(launch.content).toBe(agentYaml(sb, workspace)); + expect(launch.options.allow).toContain(canonicalPath(workspace)); + expect(launch.options.allow).not.toContain(resolve(dirname(snapshot!), configuredWorkspace)); + expect(launch.fileMode).toBe(0o600); + expect(launch.dirMode).toBe(0o700); + expect(launch.owner).toBe(launch.uid); + expect(launch.options.readFiles).toEqual([...harnessReadFiles("probe", sb.home).map((path) => canonicalPath(path)), snapshot]); + for (const path of [launch.options.cwd ?? sb.run, launch.options.workdir, ...launch.options.allow].filter(Boolean)) { + expect(snapshot === path || snapshot!.startsWith(`${path}/`)).toBe(false); + } + expect(launch.options.readFiles).not.toContain(target); + expect(launch.options.readFiles).not.toContain(alternate); + expect(launch.options.readFiles).not.toContain(link); + const grants = buildNonoArgs("tps-agent-run", launch.options, launch.cmd); + expect(grants).toContain(snapshot!); + expect(grants).not.toContain(target); + expect(grants).not.toContain(alternate); + expect(grants).not.toContain(link); + expect(lines).toContain(`WORKSPACE ${workspace}`); + expect(existsSync(snapshot!)).toBe(false); + expect(existsSync(dirname(snapshot!))).toBe(false); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } +}); + +test("the parent removes its config snapshot on SIGTERM", () => { + const sb = makeSandbox(); + try { + const config = join(sb.run, "agent.yaml"); + writeFileSync(config, agentYaml(sb, sb.ws)); + installFakeNono(sb, " :"); + const agentModule = resolve(import.meta.dir, "../dist/src/commands/agent.js"); + const attestationModule = resolve(import.meta.dir, "../dist/src/utils/launch-attestation.js"); + const result = spawnSync("bun", ["-e", ` + import { mock } from 'bun:test'; + const attestation = await import(${JSON.stringify(attestationModule)}); + mock.module(${JSON.stringify(attestationModule)}, () => ({ ...attestation, + launchAttested: async () => { + process.kill(process.pid, 'SIGTERM'); + await new Promise(() => {}); + }, + })); + const { runAgent } = await import(${JSON.stringify(agentModule)}); + await runAgent({ action: 'start', config: ${JSON.stringify(config)}, sandboxRequired: true }, { + afterConfigSnapshot: (path) => console.log('SNAPSHOT ' + path), + }); + `], { cwd: sb.run, encoding: "utf8", timeout: 20_000, env: cliEnv(sb) }); + expect(result.signal, output(result)).toBe("SIGTERM"); + const snapshot = result.stdout.split("\n").find((line) => line.startsWith("SNAPSHOT "))?.slice(9); + expect(snapshot).toBeDefined(); + expect(existsSync(snapshot!)).toBe(false); + expect(existsSync(dirname(snapshot!))).toBe(false); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// (2) — worktree setup runs before the sandbox; a git operation failure is a +// named error (a workspace with no base repository is a no-op, not a failure) +// --------------------------------------------------------------------------- + +describe("cli#516 (2) — sandboxed launch defers worktree checkout", () => { + test("the managed worktree exists before the sandbox is entered", () => { + const sb = makeSandbox(); + try { + // The base repo sits at the default ~/ops/tps; the workspace is its + // worktree and must NOT exist yet, so setup has real work to do. + const base = join(sb.home, "ops", "tps"); + mkdirSync(base, { recursive: true }); + const g = (args: string[]) => spawnSync("git", args, { cwd: base, encoding: "utf-8" }); + g(["init", "-q"]); + g(["config", "user.email", "agent-a@tps.dev"]); + g(["config", "user.name", "agent-a"]); + writeFileSync(join(base, "README.md"), "# base\n"); + g(["add", "README.md"]); + g(["commit", "-qm", "init"]); + rmSync(sb.ws, { recursive: true, force: true }); + + installFakeNono( + sb, + ` if [ -d "\${WORKTREE_WS:?}" ]; then echo "WORKTREE_AT_SANDBOX_START=yes" >> "\${NONO_ARGV_LOG:?}"; else echo "WORKTREE_AT_SANDBOX_START=no" >> "\${NONO_ARGV_LOG:?}"; fi`, + ); + writeProfilePair(join(sb.home, ".config", "nono", "profiles"), "tps-agent-run-codex"); + writeFileSync( + join(sb.home, ".tps", "agents", "probe", "agent.yaml"), + agentYaml(sb, sb.ws).replace("provider: ollama", "provider: openai"), + ); + + runLauncher( + ["agent", "start", "--id", "probe", "--runtime", "codex", SANDBOX_REQUIRED], + { cwd: sb.run, env: cliEnv(sb, { WORKTREE_WS: sb.ws }) }, + ); + + const log = existsSync(join(sb.root, "nono.log")) + ? readFileSync(join(sb.root, "nono.log"), "utf-8") + : ""; + expect(log).toContain("WORKTREE_AT_SANDBOX_START=yes"); + // Cleanup ran after the sandbox: the worktree is gone again. + expect(existsSync(sb.ws)).toBe(false); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + for (const dangling of [false, true]) { + test(dangling ? "a dangling base symlink stops launch before nono" : "a valid base symlink launches with resolved grants", () => { + const sb = makeSandbox(); + try { + const base = join(sb.home, "ops", "tps"); + const target = join(sb.root, "projects", "repo"); + mkdirSync(dirname(base), { recursive: true }); + if (!dangling) { + mkdirSync(target, { recursive: true }); + for (const args of [["init", "-q"], ["-c", "user.name=fixture", "-c", "user.email=fixture@example.com", "commit", "--allow-empty", "-qm", "init"]]) { + expect(spawnSync("git", args, { cwd: target }).status).toBe(0); + } + } + symlinkSync(target, base); + rmSync(sb.ws, { recursive: true }); + installFakeNono(sb, " :"); + writeProfilePair(join(sb.home, ".config", "nono", "profiles"), "tps-agent-run-codex"); + writeFileSync(join(sb.home, ".tps", "agents", "probe", "agent.yaml"), agentYaml(sb, sb.ws)); + const result = runLauncher( + ["agent", "start", "--id", "probe", "--runtime", "codex", SANDBOX_REQUIRED], + { cwd: sb.run, env: cliEnv(sb) }, + ); + if (dangling) { + expect(output(result)).toContain("git probe failed for the base repository"); + expect(fakeNonoRuns(sb.root)).toEqual([]); + } else { + expect(fakeNonoRuns(sb.root).length, output(result)).toBeGreaterThan(0); + expect(fakeNonoRuns(sb.root).join("\n")).toContain(`--read ${target}/.git`); + expect(fakeNonoRuns(sb.root).join("\n")).not.toContain(`--read ${base}/.git`); + } + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + } + + test("a blocked workspace parent stops launch before the sandbox", () => { + const sb = makeSandbox(); + try { + const base = join(sb.home, "ops", "tps"); + mkdirSync(base, { recursive: true }); + const g = (args: string[]) => spawnSync("git", args, { cwd: base, encoding: "utf-8" }); + g(["init", "-q"]); + g(["config", "user.email", "agent-a@tps.dev"]); + g(["config", "user.name", "agent-a"]); + writeFileSync(join(base, "README.md"), "# base\n"); + g(["add", "README.md"]); + g(["commit", "-qm", "init"]); + const blocker = join(sb.root, "blocker"); + writeFileSync(blocker, "not a directory\n"); + const blockedWs = join(blocker, "ws"); + + installFakeNono(sb, " :"); + writeProfilePair(join(sb.home, ".config", "nono", "profiles"), "tps-agent-run-codex"); + writeFileSync( + join(sb.home, ".tps", "agents", "probe", "agent.yaml"), + agentYaml(sb, blockedWs).replace("provider: ollama", "provider: openai"), + ); + + const r = runLauncher( + ["agent", "start", "--id", "probe", "--runtime", "codex", SANDBOX_REQUIRED], + { cwd: sb.run, env: cliEnv(sb) }, + ); + expect(output(r)).toContain("worktree marker probe failed"); + expect(fakeNonoRuns(sb.root)).toEqual([]); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + test("a metadata failure after worktree creation removes the worktree", () => { + const sb = makeSandbox(); + try { + const base = join(sb.home, "ops", "tps"); + mkdirSync(base, { recursive: true }); + const gitBin = spawnSync("which", ["git"], { encoding: "utf8" }).stdout.trim(); + const g = (args: string[]) => spawnSync(gitBin, args, { cwd: base, encoding: "utf8" }); + expect(g(["init", "-q"]).status).toBe(0); + expect(g(["-c", "user.name=fixture", "-c", "user.email=fixture@example.com", "commit", "--allow-empty", "-qm", "init"]).status).toBe(0); + rmSync(sb.ws, { recursive: true }); + const bin = join(sb.root, "bin"); + mkdirSync(bin); + const wrapper = join(bin, "git"); + writeFileSync(wrapper, [ + "#!/bin/sh", + 'if [ "$1" = "rev-parse" ] && [ "$2" = "--absolute-git-dir" ]; then echo "injected metadata failure" >&2; exit 79; fi', + `exec "${gitBin}" "$@"`, + "", + ].join("\n")); + chmodSync(wrapper, 0o755); + installFakeNono(sb, " :"); + writeProfilePair(join(sb.home, ".config", "nono", "profiles"), "tps-agent-run-codex"); + writeFileSync(join(sb.home, ".tps", "agents", "probe", "agent.yaml"), agentYaml(sb, sb.ws)); + const result = runLauncher( + ["agent", "start", "--id", "probe", "--runtime", "codex", SANDBOX_REQUIRED], + { cwd: sb.run, env: cliEnv(sb, { PATH: `${bin}:${process.env.PATH}` }) }, + ); + expect(output(result)).toContain("injected metadata failure"); + expect(fakeNonoRuns(sb.root)).toEqual([]); + expect(existsSync(sb.ws)).toBe(false); + expect(g(["worktree", "list", "--porcelain"]).stdout).not.toContain(sb.ws); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + test("a post-checkout hook in the base repository never runs during parent-side setup", () => { + const sb = makeSandbox(); + try { + const base = join(sb.home, "ops", "tps"); + mkdirSync(base, { recursive: true }); + const g = (args: string[]) => spawnSync("git", args, { cwd: base, encoding: "utf-8" }); + g(["init", "-q"]); + g(["config", "user.email", "agent-a@tps.dev"]); + g(["config", "user.name", "agent-a"]); + writeFileSync(join(base, "README.md"), "# base\n"); + g(["add", "README.md"]); + g(["commit", "-qm", "init"]); + rmSync(sb.ws, { recursive: true, force: true }); + // A marker post-checkout hook: if git runs it, the file appears. + const marker = join(sb.root, "hook-ran"); + const hook = join(base, ".git", "hooks", "post-checkout"); + writeFileSync(hook, `#!/bin/sh\ntouch "${marker}"\n`); + chmodSync(hook, 0o755); + + installFakeNono( + sb, + ` if [ -d "\${WORKTREE_WS:?}" ]; then echo "WORKTREE_AT_SANDBOX_START=yes" >> "\${NONO_ARGV_LOG:?}"; else echo "WORKTREE_AT_SANDBOX_START=no" >> "\${NONO_ARGV_LOG:?}"; fi`, + ); + writeProfilePair(join(sb.home, ".config", "nono", "profiles"), "tps-agent-run-codex"); + writeFileSync( + join(sb.home, ".tps", "agents", "probe", "agent.yaml"), + agentYaml(sb, sb.ws).replace("provider: ollama", "provider: openai"), + ); + + runLauncher( + ["agent", "start", "--id", "probe", "--runtime", "codex", SANDBOX_REQUIRED], + { cwd: sb.run, env: cliEnv(sb, { WORKTREE_WS: sb.ws }) }, + ); + + // Setup registered the worktree without checkout. + const log = existsSync(join(sb.root, "nono.log")) + ? readFileSync(join(sb.root, "nono.log"), "utf-8") + : ""; + expect(log).toContain("WORKTREE_AT_SANDBOX_START=yes"); + + expect(existsSync(marker)).toBe(false); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + test("a smudge filter and an fsmonitor hook from the base repository do not run during parent-side setup", () => { + const sb = makeSandbox(); + try { + const base = join(sb.home, "ops", "tps"); + mkdirSync(base, { recursive: true }); + const g = (args: string[]) => spawnSync("git", args, { cwd: base, encoding: "utf-8" }); + g(["init", "-q"]); + g(["config", "user.email", "agent-a@tps.dev"]); + g(["config", "user.name", "agent-a"]); + // The locally configured commands each write a marker when run. + const smudgeMarker = join(sb.root, "smudge-ran"); + const fsmonitorMarker = join(sb.root, "fsmonitor-ran"); + const smudge = join(sb.root, "smudge.sh"); + writeFileSync(smudge, `#!/bin/sh\ntouch "${smudgeMarker}"\ncat\n`); + chmodSync(smudge, 0o755); + const fsmonitor = join(sb.root, "fsmonitor.sh"); + writeFileSync(fsmonitor, `#!/bin/sh\ntouch "${fsmonitorMarker}"\necho\nexit 0\n`); + chmodSync(fsmonitor, 0o755); + g(["config", "filter.marker.smudge", smudge]); + g(["config", "core.fsmonitor", fsmonitor]); + writeFileSync(join(base, ".gitattributes"), "* filter=marker\n"); + writeFileSync(join(base, "README.md"), "# base\n"); + g(["add", "-A"]); + g(["commit", "-qm", "init"]); + rmSync(sb.ws, { recursive: true, force: true }); + // Clear markers before measuring the launch. + rmSync(smudgeMarker, { force: true }); + rmSync(fsmonitorMarker, { force: true }); + + installFakeNono( + sb, + ` if [ -e "\${WORKTREE_WS:?}/README.md" ]; then echo "WORKTREE_CHECKED_OUT=yes" >> "\${NONO_ARGV_LOG:?}"; else echo "WORKTREE_CHECKED_OUT=no" >> "\${NONO_ARGV_LOG:?}"; fi`, + ); + writeProfilePair(join(sb.home, ".config", "nono", "profiles"), "tps-agent-run-codex"); + writeFileSync( + join(sb.home, ".tps", "agents", "probe", "agent.yaml"), + agentYaml(sb, sb.ws).replace("provider: ollama", "provider: openai"), + ); + + runLauncher( + ["agent", "start", "--id", "probe", "--runtime", "codex", SANDBOX_REQUIRED], + { cwd: sb.run, env: cliEnv(sb, { WORKTREE_WS: sb.ws }) }, + ); + + const log = existsSync(join(sb.root, "nono.log")) + ? readFileSync(join(sb.root, "nono.log"), "utf-8") + : ""; + // The parent registered the worktree but left it UNCHECKED OUT, so the + // checkout (and with it any filter or hook) is still to come, inside the + // sandbox… + expect(log).toContain("WORKTREE_CHECKED_OUT=no"); + // …and neither marker-writing executable ran in the parent. + expect(existsSync(smudgeMarker)).toBe(false); + expect(existsSync(fsmonitorMarker)).toBe(false); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + test("a child registry rewrite makes parent cleanup fail", () => { + const sb = makeSandbox(); + try { + const base = join(sb.home, "ops", "tps"); + mkdirSync(base, { recursive: true }); + const g = (args: string[]) => spawnSync("git", args, { cwd: base, encoding: "utf8" }); + expect(g(["init", "-q"]).status).toBe(0); + expect(g(["-c", "user.name=fixture", "-c", "user.email=fixture@example.com", "commit", "--allow-empty", "-qm", "init"]).status).toBe(0); + rmSync(sb.ws, { recursive: true }); + installFakeNono(sb, ` printf '%s/repointed/.git\\n' "\${BASE_REPO:?}" > "\${BASE_REPO:?}/.git/worktrees/ws/gitdir"`); + writeProfilePair(join(sb.home, ".config", "nono", "profiles"), "tps-agent-run-codex"); + writeFileSync(join(sb.home, ".tps", "agents", "probe", "agent.yaml"), agentYaml(sb, sb.ws)); + const attestationModule = resolve(import.meta.dir, "../dist/src/utils/launch-attestation.js"); + const agentModule = resolve(import.meta.dir, "../dist/src/commands/agent.js"); + const script = ` + import { mock } from 'bun:test'; + import { spawnSync } from 'node:child_process'; + const attestation = await import(${JSON.stringify(attestationModule)}); + mock.module(${JSON.stringify(attestationModule)}, () => ({ + ...attestation, + launchAttested: async () => { + const child = spawnSync(process.env.NONO_BIN, ['run'], { env: process.env, encoding: 'utf8' }); + if (child.status !== 0) throw new Error(child.stderr); + return 0; + }, + })); + const { runAgent } = await import(${JSON.stringify(agentModule)}); + await runAgent({ action: 'start', id: 'probe', runtime: 'codex', sandboxRequired: true }); + `; + const result = spawnSync("bun", ["-e", script], { + cwd: sb.run, encoding: "utf8", timeout: 20_000, + env: cliEnv(sb, { BASE_REPO: base }), + }); + expect(result.status, output(result)).not.toBe(0); + expect(output(result)).toContain(`worktree cleanup binding changed for ${sb.ws}`); + expect(fakeNonoRuns(sb.root).length).toBeGreaterThan(0); + expect(readFileSync(join(base, ".git", "worktrees", "ws", "gitdir"), "utf8")).toBe(`${base}/repointed/.git\n`); + expect(existsSync(sb.ws)).toBe(true); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + test("an existing worktree survives the launch", () => { + const sb = makeSandbox(); + try { + const base = join(sb.home, "ops", "tps"); + mkdirSync(base, { recursive: true }); + const g = (args: string[]) => spawnSync("git", args, { cwd: base, encoding: "utf-8" }); + g(["init", "-q"]); + g(["config", "user.email", "agent-a@tps.dev"]); + g(["config", "user.name", "agent-a"]); + writeFileSync(join(base, "README.md"), "# base\n"); + g(["add", "README.md"]); + g(["commit", "-qm", "init"]); + // An EXISTING worktree this launch did not create. + rmSync(sb.ws, { recursive: true, force: true }); + g(["worktree", "add", sb.ws, "--detach"]); + + installFakeNono(sb, " :"); + writeProfilePair(join(sb.home, ".config", "nono", "profiles"), "tps-agent-run-codex"); + writeFileSync( + join(sb.home, ".tps", "agents", "probe", "agent.yaml"), + agentYaml(sb, sb.ws).replace("provider: ollama", "provider: openai"), + ); + + runLauncher( + ["agent", "start", "--id", "probe", "--runtime", "codex", SANDBOX_REQUIRED], + { cwd: sb.run, env: cliEnv(sb) }, + ); + + // Still present and still registered: cleanup skipped it. + expect(readFileSync(join(sb.ws, "README.md"), "utf-8")).toBe("# base\n"); + const listed = g(["worktree", "list", "--porcelain"]).stdout; + expect(listed).toContain(sb.ws); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + test("an inherited TPS_WORKTREE_PENDING does not check an existing worktree out", () => { + const sb = makeSandbox(); + try { + const base = join(sb.home, "ops", "tps"); + mkdirSync(base, { recursive: true }); + const g = (args: string[]) => spawnSync("git", args, { cwd: base, encoding: "utf-8" }); + g(["init", "-q"]); + g(["config", "user.email", "agent-a@tps.dev"]); + g(["config", "user.name", "agent-a"]); + writeFileSync(join(base, "README.md"), "# base\n"); + g(["add", "README.md"]); + g(["commit", "-qm", "init"]); + // An EXISTING branch worktree this launch did not create. + g(["branch", "feature"]); + rmSync(sb.ws, { recursive: true, force: true }); + g(["worktree", "add", sb.ws, "feature"]); + + // The child checks the worktree out only when it inherits this marker + // (bin/tps.ts). Record the child's environment: it must not carry the + // marker, because this launch created no worktree. + installFakeNono( + sb, + ` if [ -n "\${TPS_WORKTREE_PENDING:-}" ]; then echo "CHILD_MARKER=$TPS_WORKTREE_PENDING" >> "\${NONO_ARGV_LOG:?}"; else echo "CHILD_MARKER=absent" >> "\${NONO_ARGV_LOG:?}"; fi`, + ); + writeProfilePair(join(sb.home, ".config", "nono", "profiles"), "tps-agent-run-codex"); + writeFileSync( + join(sb.home, ".tps", "agents", "probe", "agent.yaml"), + agentYaml(sb, sb.ws).replace("provider: ollama", "provider: openai"), + ); + + runLauncher( + ["agent", "start", "--id", "probe", "--runtime", "codex", SANDBOX_REQUIRED], + { cwd: sb.run, env: cliEnv(sb, { TPS_WORKTREE_PENDING: sb.ws }) }, + ); + + const log = existsSync(join(sb.root, "nono.log")) + ? readFileSync(join(sb.root, "nono.log"), "utf-8") + : ""; + expect(log).toContain("CHILD_MARKER=absent"); + // The worktree survived and is still on its branch. + expect(readFileSync(join(sb.ws, "README.md"), "utf-8")).toBe("# base\n"); + const head = spawnSync("git", ["-C", sb.ws, "symbolic-ref", "-q", "HEAD"], { + encoding: "utf-8", + }); + expect(head.stdout.trim()).toBe("refs/heads/feature"); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + for (const kind of ["absent", "plain", "file", "path-link", "marker-link", "marker-dangling"] as const) { + test(`setup and cleanup classify a ${kind} base candidate consistently`, () => { + const sb = makeSandbox(); + try { + const base = join(sb.root, "tps"); + mkdirSync(dirname(base), { recursive: true }); + if (kind === "file") writeFileSync(base, "fixture"); + else if (kind === "path-link") symlinkSync(join(sb.root, "missing"), base); + else if (kind !== "absent") { + mkdirSync(base); + if (kind.startsWith("marker-")) { + const target = join(sb.root, "metadata"); + if (kind === "marker-link") { + expect(spawnSync("git", ["init", "--bare", target]).status).toBe(0); + } + symlinkSync(target, join(base, ".git")); + } + } + for (const operation of [onStart, onStop]) { + if (kind === "absent" || kind === "plain") { + expect(operation("probe", sb.ws).changed).toBe(false); + } else { + expect(() => operation("probe", sb.ws)).toThrow(WorktreeLifecycleError); + } + } + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + } + + for (const metadata of ["config", "gitdir"] as const) { + test(`setup and cleanup reject unresolvable base ${metadata} metadata with real Git`, () => { + const sb = makeSandbox(); + try { + const base = join(sb.root, "tps"); + mkdirSync(base); + const g = (args: string[]) => spawnSync("git", args, { cwd: base, encoding: "utf-8" }); + expect(g(["init", "-q"]).status).toBe(0); + expect(g(["rev-parse", "--is-inside-work-tree"]).stdout.trim()).toBe("true"); + if (metadata === "config") { + writeFileSync(join(base, ".git", "config"), "this is not a git config\n"); + } else { + rmSync(join(base, ".git"), { recursive: true }); + writeFileSync(join(base, ".git"), `gitdir: ${join(base, "missing-gitdir")}\n`); + } + expect(g(["rev-parse", "--is-inside-work-tree"]).status).not.toBe(0); + for (const operation of [onStart, onStop]) { + expect(() => operation("probe", sb.ws)).toThrow(WorktreeLifecycleError); + } + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + } + + for (const metadata of ["config", "gitdir"] as const) { + test(`unresolvable base ${metadata} metadata stops setup before nono starts`, () => { + const sb = makeSandbox(); + try { + const base = join(sb.home, "ops", "tps"); + mkdirSync(base, { recursive: true }); + const g = (args: string[]) => spawnSync("git", args, { cwd: base, encoding: "utf-8" }); + expect(g(["init", "-q"]).status).toBe(0); + expect(g(["config", "user.email", "agent-a@tps.dev"]).status).toBe(0); + expect(g(["config", "user.name", "agent-a"]).status).toBe(0); + writeFileSync(join(base, "README.md"), "# base\n"); + expect(g(["add", "README.md"]).status).toBe(0); + expect(g(["commit", "-qm", "init"]).status).toBe(0); + rmSync(sb.ws, { recursive: true, force: true }); + if (metadata === "config") { + writeFileSync(join(base, ".git", "config"), "this is not a git config\n"); + } else { + rmSync(join(base, ".git"), { recursive: true }); + writeFileSync(join(base, ".git"), `gitdir: ${join(base, "missing-gitdir")}\n`); + expect(g(["rev-parse", "--is-inside-work-tree"]).status).not.toBe(0); + } + + installFakeNono(sb, " :"); + writeProfilePair(join(sb.home, ".config", "nono", "profiles"), "tps-agent-run-codex"); + writeFileSync( + join(sb.home, ".tps", "agents", "probe", "agent.yaml"), + agentYaml(sb, sb.ws).replace("provider: ollama", "provider: openai"), + ); + + const r = runLauncher( + ["agent", "start", "--id", "probe", "--runtime", "codex", SANDBOX_REQUIRED], + { cwd: sb.run, env: cliEnv(sb) }, + ); + + expect(output(r)).toContain("git probe failed"); + // The failure is reported instead of proceeding, and nono never starts. + expect(fakeNonoRuns(sb.root)).toEqual([]); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + test(`unresolvable base ${metadata} metadata reports a cleanup failure`, () => { + const sb = makeSandbox(); + try { + const base = join(sb.home, "ops", "tps"); + mkdirSync(base, { recursive: true }); + const g = (args: string[]) => spawnSync("git", args, { cwd: base, encoding: "utf-8" }); + expect(g(["init", "-q"]).status).toBe(0); + expect(g(["config", "user.email", "agent-a@tps.dev"]).status).toBe(0); + expect(g(["config", "user.name", "agent-a"]).status).toBe(0); + writeFileSync(join(base, "README.md"), "# base\n"); + expect(g(["add", "README.md"]).status).toBe(0); + expect(g(["commit", "-qm", "init"]).status).toBe(0); + rmSync(sb.ws, { recursive: true, force: true }); + + installFakeNono(sb, metadata === "config" + ? ` printf 'this is not a git config\\n' > "\${BASE_REPO:?}/.git/config"` + : ` mv "\${BASE_REPO:?}/.git" "\${BASE_REPO:?}/saved-git" + printf 'gitdir: %s/missing-gitdir\\n' "\${BASE_REPO:?}" > "\${BASE_REPO:?}/.git"`); + writeProfilePair(join(sb.home, ".config", "nono", "profiles"), "tps-agent-run-codex"); + writeFileSync( + join(sb.home, ".tps", "agents", "probe", "agent.yaml"), + agentYaml(sb, sb.ws).replace("provider: ollama", "provider: openai"), + ); + + const r = runLauncher( + ["agent", "start", "--id", "probe", "--runtime", "codex", SANDBOX_REQUIRED], + { cwd: sb.run, env: cliEnv(sb, { BASE_REPO: base }) }, + ); + + expect(output(r)).toContain("git probe failed"); + expect(fakeNonoRuns(sb.root).length).toBeGreaterThan(0); + expect(g(["rev-parse", "--is-inside-work-tree"]).status).not.toBe(0); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + } + + test("cleanup reports a workspace probe failure, not absence, when the path cannot be probed", () => { + const sb = makeSandbox(); + try { + // A child process is given a HOME whose base repository resolves, so the + // workspace probe is the one that runs. The workspace's parent is a + // regular file, so lstat on the workspace fails with ENOTDIR on every uid + // (root included), where existsSync reports false. + const base = join(sb.home, "ops", "tps"); + mkdirSync(base, { recursive: true }); + const g = (args: string[]) => spawnSync("git", args, { cwd: base, encoding: "utf-8" }); + expect(g(["init", "-q"]).status).toBe(0); + const blocker = join(sb.root, "not-a-dir"); + writeFileSync(blocker, "fixture\n"); + const ws = join(blocker, "ws"); + + const mod = resolve(import.meta.dir, "../src/utils/agent-lifecycle.js"); + const script = + `import(${JSON.stringify(mod)}).then((m) => {` + + ` try { m.onStop("probe", ${JSON.stringify(ws)}); console.log("NO-THROW"); }` + + ` catch (err) { console.log("THREW:" + err.name); } });`; + const r = spawnSync("bun", ["-e", script], { + cwd: import.meta.dir, + encoding: "utf-8", + timeout: 30_000, + env: { ...process.env, HOME: sb.home }, + }); + expect(output(r)).toContain("THREW:WorktreeLifecycleError"); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// (3) — the sandbox-flag scan reads the arguments before a wrapped payload +// --------------------------------------------------------------------------- + +describe("cli#516 (3) — --sandbox/--sandboxed inside a message or wrapped command is not a flag", () => { + test("a message body containing --sandbox is not refused as a flag", () => { + const sb = makeSandbox(); + try { + const r = runLauncher( + ["agent", "run", "--id", "ghost", "--message", "build", "--sandbox", "now"], + { cwd: sb.run, env: { ...process.env, HOME: sb.home } }, + ); + expect(output(r)).not.toContain(ACCEPTS_ONLY); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + test("a wrapped command containing --sandbox is not refused as a flag", () => { + const sb = makeSandbox(); + try { + const r = runLauncher(["secrets-guard", "printf", "--sandbox", "now"], { + cwd: sb.run, + env: { ...process.env, HOME: sb.home }, + }); + expect(output(r)).not.toContain(ACCEPTS_ONLY); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + test("an invalid --sandbox value tps itself owns is still refused by name", () => { + const sb = makeSandbox(); + try { + const r = runLauncher( + ["agent", "start", "--runtime", "codex", "--sandbox", "1", "--no-sandbox"], + { cwd: sb.run, env: { ...process.env, HOME: sb.home } }, + ); + expect(output(r)).toContain("--sandbox accepts only 'true' or 'false'"); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + test("a message payload containing --sandboxed is not refused as a launcher claim", () => { + const sb = makeSandbox(); + try { + const r = runLauncher( + ["agent", "run", "--id", "ghost", "--message", "hi", "--sandboxed"], + { cwd: sb.run, env: { ...process.env, HOME: sb.home } }, + ); + expect(output(r)).not.toContain("no launcher released this process"); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + test("a wrapped command containing --sandboxed is not refused as a launcher claim", () => { + const sb = makeSandbox(); + try { + const r = runLauncher(["secrets-guard", "printf", "hi", "--sandboxed"], { + cwd: sb.run, + env: { ...process.env, HOME: sb.home }, + }); + expect(output(r)).not.toContain("no launcher released this process"); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + + for (const payload of [true, false]) { + test(`codex start --sandbox-required requires confinement (payload=${payload})`, () => { + const sb = makeSandbox(); + try { + const config = join(sb.home, ".tps", "agents", "probe", "agent.yaml"); + writeFileSync(config, agentYaml(sb, sb.ws)); + const r = runLauncher([ + "agent", "start", "--id", "probe", "--runtime", "codex", + "--config", config, SANDBOX_REQUIRED, + ...(payload ? ["--", "--sandboxed"] : ["--sandboxed"]), + ], { + cwd: sb.run, + env: cliEnv(sb, { TPS_LAUNCH_SOCK: undefined }), + }); + expect(r.status, output(r)).toBe(78); + expect(output(r)).toContain(payload + ? "refusing to launch runtime 'codex': no nono at the pinned absolute path" + : "--sandboxed is refused: no launcher released this process"); + expect(fakeNonoRuns(sb.root)).toEqual([]); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + } + + for (const flag of ["absent", "payload", "own"] as const) { + test(`attestation requires an own --sandboxed argument (${flag})`, () => { + const own = flag === "own"; + const sb = makeSandbox(); + try { + const attempts = join(sb.root, "attestation-attempts"); + const preload = join(sb.root, "observe-connect.mjs"); + writeFileSync(preload, [ + 'import { Socket } from "node:net";', + 'import { appendFileSync } from "node:fs";', + 'const connect = Socket.prototype.connect;', + 'Socket.prototype.connect = function (...args) {', + ` appendFileSync(${JSON.stringify(attempts)}, "connect\\n");`, + ' return connect.apply(this, args);', + '};', + ].join("\n")); + const result = spawnSync(NODE, ["--import", preload, TPS_BIN, + ...(own ? ["agent", "start", "--sandboxed"] : ["agent", "run", "--id", "ghost", "--message", "hi", ...(flag === "payload" ? ["--", "--sandboxed"] : [])]), + ], { + encoding: "utf8", cwd: sb.run, timeout: 20_000, + env: cliEnv(sb, { TPS_LAUNCH_SOCK: join(sb.root, "inherited.sock") }), + }); + expect(result.error).toBeUndefined(); + expect(existsSync(attempts), output(result)).toBe(own); + if (own) expect(output(result)).toContain("--sandboxed is refused: no launcher released this process"); + else expect(output(result)).not.toContain("no launcher released this process"); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); + } + + test("an actual, unattested --sandboxed flag is still refused", () => { + const sb = makeSandbox(); + try { + const r = runLauncher(["agent", "start", "--sandboxed"], { + cwd: sb.run, + env: { ...process.env, HOME: sb.home }, + }); + expect(output(r)).toContain("no launcher released this process"); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Real nono, where reachable by an absolute path +// --------------------------------------------------------------------------- + +const resolvedNono = resolveNonoBinary(); +const resolvedNonoVersion = resolvedNono.bin ? nonoVersion(resolvedNono.bin) : null; +const realNono = + resolvedNono.bin && resolvedNonoVersion && versionAtLeast(resolvedNonoVersion, NONO_MIN_VERSION) + ? resolvedNono.bin + : null; +const realNonoSkip = !resolvedNono.bin + ? `no nono at an absolute path (${resolvedNono.reason ?? "unknown"})` + : `nono ${resolvedNonoVersion ?? "unknown"} at ${resolvedNono.bin} is below the ${NONO_MIN_VERSION} floor`; + +// The Docker lane mounts the pinned nono at the pinned location +// (DEFAULT_NONO_PATHS); the fixture grants the toolchain, the repo checkout and +// the running interpreter, and HOME/.bun. +const SYSTEM_READ = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/opt", "/opt/homebrew", "/private/etc/ssl"]; + +function seedRealNonoProfiles(home: string): void { + const profileDir = join(home, ".config", "nono", "profiles"); + mkdirSync(profileDir, { recursive: true }); + // The monorepo root (packages/cli/test -> the checkout), where the hoisted + // node_modules the CLI resolves its dependencies from lives. + const repoRoot = resolve(import.meta.dir, "..", "..", ".."); + // On macOS, the checkout, interpreter and ~/.bun roots are granted as exact + // directories rather than their ancestors (the fixed SYSTEM_READ roots are + // ancestor grants by design): a read grant is recursive, so an ancestor of + // the checkout or interpreter reaches every sibling of it. With bun under + // ~/.bun that would be /Users and the operator's whole home, the fixture + // HOME's base among them, which the launcher then (correctly) refuses. Under + // macOS Seatbelt, node and bun run a script and a binary below an exact grant + // without one (measured). On the Linux Docker lane /usr is granted and is an + // ancestor of the interpreter's directory; that lane's grants are otherwise + // unchanged: /app has no ancestor below /, and the oven/bun image's + // /usr/local/bin already sits under /usr. + const read = [ + ...new Set([ + ...SYSTEM_READ, + join(home, ".bun"), + repoRoot, + dirname(process.execPath), + ]), + ]; + writeFileSync( + join(profileDir, "tps-base-fixture.json"), + JSON.stringify({ + $schema: "https://nono.sh/schemas/nono-profile.schema.json", + meta: { name: "tps-base-fixture" }, + workdir: { access: "readwrite" }, + filesystem: { read, deny: [] }, + }), + ); + writeFileSync( + join(profileDir, "tps-agent-run.json"), + JSON.stringify({ + $schema: "https://nono.sh/schemas/nono-profile.schema.json", + extends: "tps-base-fixture", + meta: { name: "tps-agent-run" }, + workdir: { access: "readwrite" }, + }), + ); + writeFileSync( + join(profileDir, "tps-agent-run-codex.json"), + JSON.stringify({ + $schema: "https://nono.sh/schemas/nono-profile.schema.json", + extends: "tps-base-fixture", + meta: { name: "tps-agent-run-codex" }, + workdir: { access: "readwrite" }, + }), + ); +} + +/** Read a file, reporting absence with false rather than a stat-then-read pair. */ +function canRead(path: string): boolean { + try { + readFileSync(path, "utf-8"); + return true; + } catch { + return false; + } +} + +/** Wait (bounded) until `predicate` is true, or the deadline passes. */ +async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return true; + await new Promise((r) => setTimeout(r, 200)); + } + return predicate(); +} + +let realNonoBase: string | undefined; +function makeRealNonoSandbox(): Sandbox { + realNonoBase ??= realNonoHomeBase(realNono as string); + return makeSandbox(realNonoBase); +} + +/** Wait (bounded) for `predicate`, giving up early once the launcher exits. */ +function waitWhileRunning( + child: ReturnType, + predicate: () => boolean, + timeoutMs: number, +): Promise { + let exited = child.exitCode !== null || child.signalCode !== null; + child.once("exit", () => { + exited = true; + }); + return waitFor(() => predicate() || exited, timeoutMs).then(() => predicate()); +} + +const realNonoSuite = realNono ? describe : describe.skip; +realNonoSuite( + realNono ? `cli#516 — real nono (${realNono})` : `cli#516 — real nono SKIPPED: ${realNonoSkip}`, + () => { + for (const fromBase of [false, true]) { + test(`the fixture launch denies an untracked base file with base cwd/config ${fromBase}`, () => { + const sb = makeRealNonoSandbox(); + try { + // A trusted base location: the launch resolves the sibling `tps` next + // to the workspace (this call runs in-process, so ~/ops/tps would be + // the test runner's home, not the sandbox's). + const base = join(dirname(sb.ws), "tps"); + mkdirSync(base, { recursive: true }); + const g = (args: string[], cwd = base) => + spawnSync("git", args, { cwd, encoding: "utf-8" }); + g(["init", "-q"]); + g(["config", "user.email", "agent-a@tps.dev"]); + g(["config", "user.name", "agent-a"]); + writeFileSync(join(base, "checked-out.txt"), "from the base repository\n"); + g(["add", "checked-out.txt"]); + g(["commit", "-qm", "init"]); + // Untracked in the base repository's working tree, never committed. + writeFileSync(join(base, "untracked-in-base.txt"), "base-untracked\n"); + rmSync(sb.ws, { recursive: true, force: true }); + + // Create a deferred worktree as the launch's parent does, and resolve + // its git metadata the same way, so the grant set below is built from + // the real worktree layout. + g(["-C", base, "worktree", "add", "--no-checkout", sb.ws, "--detach"]); + const gitDir = g(["-C", sb.ws, "rev-parse", "--absolute-git-dir"]).stdout.trim(); + expect(gitDir).not.toBe(""); + checkoutWorktree(sb.ws); + + rmSync(join(sb.home, ".config", "nono", "profiles"), { recursive: true }); + // The launch grants ~/.bun; create it so nono does not warn on stdout. + mkdirSync(join(sb.home, ".bun"), { recursive: true }); + const env = { ...process.env, HOME: sb.home, XDG_STATE_HOME: join(sb.root, "nono-state") }; + const cfg = fromBase ? join(base, "agent.yaml") : join(sb.home, ".tps", "agents", "probe", "agent.yaml"); + writeFileSync(cfg, agentYaml(sb, sb.ws).replace("provider: ollama", "provider: openai")); + + const approval = agentLaunchOptions({ + configPath: cfg, + cwd: fromBase ? base : sb.run, + workspace: sb.ws, + launchId: "probe", + runtime: "codex", + home: sb.home, + env, + mailDir: join(sb.home, ".tps", "mail"), + tmpDir: sb.tmp, + agentDir: join(sb.home, ".tps", "agents", "probe"), + }); + expect(approval.refusal).toBeNull(); + for (const dir of approval.runtimeDirectories) mkdirSync(dir, { recursive: true, mode: 0o700 }); + + const token = randomBytes(8).toString("hex"); + const deniedMarker = `${NONO_PROBE_DENIED_PREFIX}${token}`; + const probe = spawnSync( + realNono as string, + buildNonoArgs( + runtimeNonoProfile("codex"), + approval.options, + [ + "sh", + "-c", + `cat "$1" || echo CONTROL-UNREADABLE; cat "$2" 2>/dev/null || echo "${NONO_PROBE_DENIED_PREFIX}$3"`, + "sh", + join(sb.ws, "checked-out.txt"), + join(base, "untracked-in-base.txt"), + token, + ], + env, + ), + { cwd: fromBase ? base : sb.run, encoding: "utf-8", env }, + ); + const lines = probe.stdout.trim().split("\n"); + expect(probe.status, `nono run exited ${probe.status}: ${probe.stderr}`).toBe(0); + expect(lines[0]).toBe("from the base repository"); + expect(lines[1]).toBe(deniedMarker); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }, 40_000); + } + + test("an unrelated fixture worktree launched from the base is denied read and write of a base file", () => { + const sb = makeRealNonoSandbox(); + try { + // The trusted base sits beside the workspace, as it does for a launch + // whose current directory is the base. + const base = join(dirname(sb.ws), "tps"); + mkdirSync(base, { recursive: true }); + const g = (args: string[], cwd = base) => spawnSync("git", args, { cwd, encoding: "utf-8" }); + g(["init", "-q"]); + g(["config", "user.email", "agent-a@tps.dev"]); + g(["config", "user.name", "agent-a"]); + writeFileSync(join(base, "base-secret.txt"), "base-secret\n"); + g(["add", "base-secret.txt"]); + g(["commit", "-qm", "init"]); + + // An unrelated repository's worktree: its marker is a regular file, + // but the trusted base's registry does not list it. + const other = join(sb.root, "unrelated"); + mkdirSync(other, { recursive: true }); + const go = (args: string[]) => spawnSync("git", args, { cwd: other, encoding: "utf-8" }); + go(["init", "-q"]); + go(["config", "user.email", "agent-a@tps.dev"]); + go(["config", "user.name", "agent-a"]); + writeFileSync(join(other, "workspace-file.txt"), "workspace-file\n"); + go(["add", "workspace-file.txt"]); + go(["commit", "-qm", "init"]); + const ws = join(sb.root, "unrelated-ws"); + go(["worktree", "add", "--detach", ws]); + + rmSync(join(sb.home, ".config", "nono", "profiles"), { recursive: true }); + mkdirSync(join(sb.home, ".bun"), { recursive: true }); + const env = { ...process.env, HOME: sb.home, XDG_STATE_HOME: join(sb.root, "nono-state") }; + const cfg = join(sb.home, ".tps", "agents", "probe", "agent.yaml"); + writeFileSync(cfg, agentYaml(sb, ws).replace("provider: ollama", "provider: openai")); + const approval = agentLaunchOptions({ + configPath: cfg, + cwd: base, + workspace: ws, + launchId: "probe", + runtime: "codex", + home: sb.home, + env, + mailDir: join(sb.home, ".tps", "mail"), + tmpDir: sb.tmp, + agentDir: join(sb.home, ".tps", "agents", "probe"), + }); + expect(approval.refusal).toBeNull(); + expect(approval.options.cwd).toBe(ws); + for (const dir of approval.runtimeDirectories) mkdirSync(dir, { recursive: true, mode: 0o700 }); + + const token = randomBytes(8).toString("hex"); + const deniedRead = `${NONO_PROBE_DENIED_PREFIX}${token}`; + const deniedWrite = `${NONO_PROBE_DENIED_PREFIX}write-${token}`; + const probe = spawnSync( + realNono as string, + buildNonoArgs( + runtimeNonoProfile("codex"), + approval.options, + [ + "sh", + "-c", + `cat "$1" || echo CONTROL-UNREADABLE; cat "$2" 2>/dev/null || echo "${NONO_PROBE_DENIED_PREFIX}$3"; (echo tampered > "$2") 2>/dev/null && echo WRITE-ALLOWED || echo "${NONO_PROBE_DENIED_PREFIX}write-$3"`, + "sh", + join(ws, "workspace-file.txt"), + join(base, "base-secret.txt"), + token, + ], + env, + ), + { cwd: base, encoding: "utf-8", env }, + ); + const lines = probe.stdout.trim().split("\n"); + expect(probe.status, `nono run exited ${probe.status}: ${probe.stderr}`).toBe(0); + expect(lines[0]).toBe("workspace-file"); + expect(lines[1]).toBe(deniedRead); + expect(lines[2]).toBe(deniedWrite); + expect(readFileSync(join(base, "base-secret.txt"), "utf-8")).toBe("base-secret\n"); + } finally { + rmSync(sb.root, { recursive: true, force: true }); + } + }, 40_000); + + for (const variant of ["regular", "symlink", "symlink-relative"] as const) { + test(`the default re-exec child starts in the custom workspace with config ${variant}`, async () => { + const sb = makeRealNonoSandbox(); + let child: ReturnType | undefined; + let out = ""; + try { + // The default config points at one workspace and the custom config at a + // different one; the runtime writes `.tps-agent.pid` into ITS workspace, + // so the file's location shows which config the child loaded. + const wsDefault = join(sb.root, "ws-default"); + const wsCustom = join(sb.root, "ws-custom"); + const customDir = join(sb.root, "custom"); + for (const d of [wsDefault, wsCustom, customDir]) mkdirSync(d, { recursive: true }); + seedRealNonoProfiles(sb.home); + writeFileSync(join(sb.home, ".tps", "agents", "probe", "agent.yaml"), agentYaml(sb, wsDefault)); + const target = join(customDir, "agent.yaml"); + writeFileSync(target, agentYaml(sb, variant === "symlink-relative" ? "ws-custom" : wsCustom)); + const customConfig = variant === "regular" ? target : join(sb.root, "config-link.yaml"); + if (variant !== "regular") symlinkSync(target, customConfig); + + child = spawn( + NODE, + [TPS_BIN, "agent", "start", "--config", customConfig, SANDBOX_REQUIRED], + { + cwd: sb.run, + detached: true, + env: { + ...process.env, + HOME: sb.home, + [NONO_BIN_ENV]: realNono as string, + XDG_STATE_HOME: join(sb.root, "nono-state"), + TPS_LAUNCH_TIMEOUT_MS: "20000", + }, + }, + ); + child.stdout?.on("data", (d: Buffer) => { + out += d.toString(); + }); + child.stderr?.on("data", (d: Buffer) => { + out += d.toString(); + }); + const customPid = join(wsCustom, ".tps-agent.pid"); + const loaded = await waitWhileRunning( + child, + () => canRead(customPid) && /released under nono session \S+ \(child pid \d+\)/.test(out), + 45_000, + ); + + expect(loaded, `no pid file in the custom workspace; launcher output:\n${out}`).toBe(true); + // The child did NOT fall back to the default config's workspace. + expect(canRead(join(wsDefault, ".tps-agent.pid"))).toBe(false); + } finally { + // Signal and check the release PID and launcher, or the group without a release line. + const stopped = !child || await stopRealLaunch(child, out); + rmSync(sb.root, { recursive: true, force: true }); + expect(stopped, "teardown could not confirm the released child and launcher are gone").toBe(true); + } + }, 60_000); + + } + + for (const relativeWorkspace of [false, true]) { + test(`the sandboxed child checks out a worktree with relative workspace ${relativeWorkspace}`, async () => { + const sb = makeRealNonoSandbox(); + let child: ReturnType | undefined; + let out = ""; + try { + const base = join(sb.home, "ops", "tps"); + mkdirSync(base, { recursive: true }); + const g = (args: string[], cwd = base) => + spawnSync("git", args, { cwd, encoding: "utf-8" }); + g(["init", "-q"]); + g(["config", "user.email", "agent-a@tps.dev"]); + g(["config", "user.name", "agent-a"]); + writeFileSync(join(base, "checked-out.txt"), "from the base repository\n"); + g(["add", "-A"]); + g(["commit", "-qm", "init"]); + rmSync(sb.ws, { recursive: true, force: true }); + + seedRealNonoProfiles(sb.home); + const cfg = join(sb.home, ".tps", "agents", "probe", "agent.yaml"); + writeFileSync(cfg, agentYaml(sb, relativeWorkspace ? relative(dirname(cfg), sb.ws) : sb.ws).replace("provider: ollama", "provider: openai")); + + child = spawn( + NODE, + [TPS_BIN, "agent", "start", "--config", cfg, "--runtime", "codex", SANDBOX_REQUIRED], + { + cwd: sb.run, + detached: true, + env: { + ...process.env, + HOME: sb.home, + [NONO_BIN_ENV]: realNono as string, + XDG_STATE_HOME: join(sb.root, "nono-state"), + TPS_LAUNCH_TIMEOUT_MS: "20000", + }, + }, + ); + child.stdout?.on("data", (d: Buffer) => { + out += d.toString(); + }); + child.stderr?.on("data", (d: Buffer) => { + out += d.toString(); + }); + const checkedOut = await waitWhileRunning( + child, + () => out.includes("worktree checked out") && canRead(join(sb.ws, "checked-out.txt")), + 45_000, + ); + + // Git reported checkout success inside the sandbox. + expect(checkedOut, `no checkout report; launcher output:\n${out}`).toBe(true); + } finally { + // Signal and check the release PID and launcher, or the group without a release line. + const stopped = !child || await stopRealLaunch(child, out); + rmSync(sb.root, { recursive: true, force: true }); + expect(stopped, "teardown could not confirm the released child and launcher are gone").toBe(true); + } + }, 60_000); + } + }, +);