Files
stack/packages/harness/tests/pi-session.test.mjs
T
jason.woltjeandClaude Opus 5.5 fe5c68fa03 feat(harness): meta-harness and PM launching off T3 (#1523, row 41)
Slice 1 S6. packages/harness runs a role session under the pi or claude
adapter behind a tool gate, and packages/seat holds the session and its
process. The bus host gains a launch socket: `mosaic bus start
<business> --pm <harness>:<model>` runs the PM without a window, the PM
launches role sessions through the broker within the business file's
`launch.max`, and every launch, refusal and end is a launch-log line and
a broker event. `mosaic talk`, `mosaic launches off|on|list` and
`mosaic stop <run>` are new CLI verbs. packages/bus gains the IPC ops
identity, authorize, refuse, endLaunch and credentialStatus, plus
Broker.endLaunch (decision 77); no socket verb, event kind or schema
changed. The session environment is an allowlist, and the runner exits
before claiming if a founder credential reaches it.

Filbert built it (agents/filbert/work/s6). Darkwing reviewed on #1523:
round 1 changes (26991/26995), round 2 changes (27004/27010), round 3
changes (27012/27032), round 4 approve (27033/27034) on manifest
08a78972, 42 paths.

Sage's gate on 64b0f9f1 (12:32Z), sequential and teed: node suites
business 60, bus 74, cli 83, control-board 124, conversation 182,
discord 178, harness 56, ledger 78, mosaic 69, queue 149, runs 41,
seat 27, tasks 51, webui 39, every one 0 failed; test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98 and 26 without Docker, all 0 failed.
64b0f9f1..ee7f909b touches none of the 42 paths. It landed after the
Q14 hold ended at 2026-10-11T17:19:23.857Z. The recorded PM-to-coder
run on a scratch data root is still owed before row 41 moves to done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 12:22:50 -05:00

232 lines
10 KiB
JavaScript

// 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);
});