// Real pi (the pinned CLI) through adapters/pi with the extension, against // the scripted Messages API. No real model, no network beyond 127.0.0.1. import { test } from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { buildBundle } from "../src/bundle.mjs"; import { adapterEnv } from "../src/runner.mjs"; import { fakeToolSocket, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs"; const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh"); async function session(t, script, place = (dir) => join(dir, "ws")) { const dir = scratch(t); const workspace = place(dir); const agentDir = join(dir, "pi-agent"); mkdirSync(workspace, { recursive: true }); mkdirSync(agentDir); writeFileSync(join(workspace, "notes.txt"), "inside\n"); writeFileSync(join(dir, "secret.txt"), "outside\n"); const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 80)]))}` }); writeFileSync(join(agentDir, "models.json"), JSON.stringify({ providers: { probe: { baseUrl: api.url, api: "anthropic-messages", apiKey: "probe-dummy", models: [{ id: "probe-model" }] } } })); const resolved = resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, model: "probe-model" })); const sock = await fakeToolSocket(t, dir, (tool, args) => { if (tool === "launch") throw new Error("capacity-full"); return { tool, args }; }); const s = { harness: "pi", provider: "probe", model: "probe-model", workspace, sessionDir: join(dir, "session"), toolSocket: sock.path, turnMarker: join(dir, "turn.done"), }; const { files } = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# PM\n", tracker: true, session: { ...s, run: "run-1", launchedBy: "jason" } }); s.bundle = files; const env = adapterEnv(s, { PATH: `${join(REPO, "node_modules", ".bin")}:/usr/bin:/bin`, HOME: join(dir, "home"), PI_CODING_AGENT_DIR: agentDir, }); return { dir, workspace, api, sock, s, env }; } function turn(env, request, cwd) { return new Promise((resolve) => { const child = spawn("/bin/sh", [ADAPTER], { cwd, env: { ...env, MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout.on("data", (b) => (stdout += b)); child.stderr.on("data", (b) => (stderr += b)); const clock = setTimeout(() => child.kill("SIGKILL"), 60_000); child.on("close", (code) => { clearTimeout(clock); resolve({ code, stdout, stderr }); }); }); } test("pi: typed tools reach the socket, the gate blocks, agent_end writes the marker", async (t) => { const { api, sock, s, env } = await session(t, [ { name: "list_agents", input: {} }, { name: "launch", input: { instance: "coder" } }, { name: "read", input: { path: "notes.txt" } }, { name: "read", input: { path: "../secret.txt" } }, { name: "bash", input: { command: "cat ../secret.txt" } }, ]); const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nwho is running?", s.workspace); assert.equal(r.code, 0, r.stderr); assert.ok(existsSync(s.turnMarker), "agent_end marker"); const answer = r.stdout.trim(); assert.match(answer, /^ANSWER /); const results = JSON.parse(answer.slice("ANSWER ".length)); assert.equal(results.length, 5); // list_agents went through the socket; launch was refused by it. assert.equal(results[0][0], false); assert.match(results[0][1], /"tool": "list_agents"/); assert.equal(results[1][0], true); assert.match(results[1][1], /refused: capacity-full/); // read inside passes; outside is gated; bash isn't in the PM's tools. assert.equal(results[2][0], false); assert.match(results[2][1], /inside/); assert.equal(results[3][0], true); assert.match(results[3][1], /outside the workspace/); assert.equal(results[4][0], true); assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]); // The model saw the typed tools and the generated prompt, not pi's own. const first = api.requests[0]; assert.ok(first.tools.includes("list_agents") && first.tools.includes("launch") && !first.tools.includes("bash")); assert.match(JSON.stringify(first.system), /## This session/); // The session persists in the declared directory. assert.ok(readdirSync(s.sessionDir).length > 0); }); test("pi: a write through a dangling symlink is blocked, and nothing appears outside", async (t) => { const { dir, workspace, s, env } = await session(t, [ { name: "write", input: { path: "notes.md", content: "planted\n" } }, { name: "write", input: { path: "gone/x.md", content: "planted\n" } }, { name: "write", input: { path: "fine.md", content: "inside\n" } }, ]); mkdirSync(join(dir, "outside")); symlinkSync(join(dir, "outside", "planted.txt"), join(workspace, "notes.md")); symlinkSync(join(dir, "outside", "made"), join(workspace, "gone")); const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nwrite your notes", workspace); assert.equal(r.code, 0, r.stderr); const results = JSON.parse(r.stdout.trim().slice("ANSWER ".length)); assert.equal(results.length, 3); assert.equal(results[0][0], true); assert.match(results[0][1], /dangling symlink: notes\.md/); assert.equal(results[1][0], true); assert.match(results[1][1], /dangling symlink: gone\/x\.md/); assert.equal(results[2][0], false); assert.ok(existsSync(join(workspace, "fine.md")), "the write inside landed"); assert.deepEqual(readdirSync(join(dir, "outside")), []); assert.ok(!existsSync(join(dir, "outside", "made"))); assert.ok(existsSync(s.turnMarker)); }); test("pi: a read is refused when pi would open another spelling outside", async (t) => { // Asked name -> link on disk; pi's read opens the link when the asked // name doesn't exist (path-utils.js resolveReadPathAsync). const spellings = [ ["notes's.txt", "notes\u2019s.txt"], ["shot 9.41 AM.png", "shot 9.41\u202FAM.png"], ["r\u00E9sum\u00E9.txt", "r\u00E9sum\u00E9.txt".normalize("NFD")], ["d'\u00E9cran.png", "d\u2019\u00E9cran.png".normalize("NFD")], ["l'\u00E9t\u00E9.txt", "l'\u00E9t\u00E9.txt".normalize("NFD")], ["l'\u00E9t\u00E9.md", "l\u2019\u00E9t\u00E9.md"], ]; const { dir, workspace, s, env } = await session(t, [ ...spellings.map(([asked]) => ({ name: "read", input: { path: asked } })), { name: "read", input: { path: "inside's.txt" } }, ]); mkdirSync(join(dir, "outside")); writeFileSync(join(dir, "outside", "secret.txt"), "SECRET-OUTSIDE\n"); for (const [, disk] of spellings) symlinkSync(join(dir, "outside", "secret.txt"), join(workspace, disk)); // The same respelling pointing inside is still read. symlinkSync(join(workspace, "notes.txt"), join(workspace, "inside\u2019s.txt")); const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nread the files", workspace); assert.equal(r.code, 0, r.stderr); assert.doesNotMatch(r.stdout, /SECRET/); const results = JSON.parse(r.stdout.trim().slice("ANSWER ".length)); assert.equal(results.length, spellings.length + 1); spellings.forEach(([asked], i) => { assert.equal(results[i][0], true, asked); assert.match(results[i][1], /outside the workspace under another spelling/, asked); }); assert.deepEqual(results.at(-1), [false, "inside\n"]); assert.ok(existsSync(s.turnMarker)); }); // The adapter cds into the workspace, and pi resolves `..` from that real // path. Each layout: where the workspace is given, and where `escape` lands // for pi (as given, it is the workspace itself). const CLIMBS = { workspace: { place(dir) { mkdirSync(join(dir, "deep", "store", "ws"), { recursive: true }); mkdirSync(join(dir, "a")); symlinkSync(join(dir, "deep", "store", "ws"), join(dir, "a", "ws")); return join(dir, "a", "ws"); }, outside: (dir) => join(dir, "deep", "a", "ws"), escape: "../../a/ws", stay: "../ws", }, dataRoot: { place(dir) { mkdirSync(join(dir, "deep", "store", "workspaces", "b", "i"), { recursive: true }); symlinkSync(join(dir, "deep", "store"), join(dir, "data")); return join(dir, "data", "workspaces", "b", "i"); }, outside: (dir) => join(dir, "deep", "data", "workspaces", "b", "i"), escape: "../../../../data/workspaces/b/i", stay: "../i", }, }; for (const [name, { place, outside, escape, stay }] of Object.entries(CLIMBS)) { test(`pi: a relative path climbs from the real path of a ${name} behind a symlink`, async (t) => { const { dir, workspace, s, env } = await session( t, [ { name: "read", input: { path: `${escape}/secret.txt` } }, { name: "write", input: { path: `${escape}/planted.txt`, content: "planted\n" } }, { name: "read", input: { path: `${stay}/notes.txt` } }, { name: "write", input: { path: `${stay}/fine.md`, content: "inside\n" } }, ], place, ); mkdirSync(outside(dir), { recursive: true }); writeFileSync(join(outside(dir), "secret.txt"), "SECRET-OUTSIDE\n"); const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nread and write", workspace); assert.equal(r.code, 0, r.stderr); assert.doesNotMatch(r.stdout, /SECRET/); const results = JSON.parse(r.stdout.trim().slice("ANSWER ".length)); assert.equal(results.length, 4); for (const i of [0, 1]) { assert.equal(results[i][0], true); assert.match(results[i][1], /outside the workspace/); } assert.deepEqual(readdirSync(outside(dir)), ["secret.txt"]); assert.ok(!existsSync(join(workspace, "planted.txt"))); assert.deepEqual(results[2], [false, "inside\n"]); assert.equal(results[3][0], false); assert.ok(existsSync(join(workspace, "fine.md")), "the write inside landed"); assert.ok(existsSync(s.turnMarker)); }); } test("pi: a missing extension refuses before any model call", async (t) => { const { dir, api, s, env } = await session(t, []); const r = await turn({ ...env, MOSAIC_EXTENSIONS: join(dir, "missing.mjs") }, "x", s.workspace); assert.equal(r.code, 2); assert.match(r.stderr, /extension missing/); assert.equal(api.requests.length, 0); }); test("pi: an extension without its configuration fails pi's start", async (t) => { const { api, s, env } = await session(t, []); const { MOSAIC_POLICY_FILE, ...rest } = env; const r = await turn(rest, "x", s.workspace); assert.notEqual(r.code, 0); assert.ok(!existsSync(s.turnMarker)); assert.equal(api.requests.length, 0); assert.ok(MOSAIC_POLICY_FILE); });