--- r2wt/packages/cli/tests/host.test.mjs 2026-10-09 22:01:00.286672229 -0500 +++ wt/packages/cli/tests/host.test.mjs 2026-10-09 22:00:50.898562239 -0500 @@ -262,6 +262,29 @@ } }); +test("a broker reply with no request waiting breaks the channel and the host exits 1", { timeout: 30000 }, async (t) => { + // A request sent to the real broker past the host's queue: its reply + // arrives when nothing is waiting (Darkwing round 2, Mr). + const children = []; + const onChild = ({ process: child }) => children.push(child); + subscribe("child_process", onChild); + t.after(() => unsubscribe("child_process", onChild)); + const root = tmp(t); + const f = fixture(root); + makeDeployment(root); + const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env }); + const logs = []; + const host = await startHost({ boot, business: "acme", log: (l) => logs.push(l) }); + t.after(() => host.close(0)); + const record = { business: "acme", role: "coder", run: "coder-run", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) }; + const coder = await host.bindLaunch(record); + children.find((c) => c.pid === host.pids.broker).send({ op: "identity", cap: coder.cap, id: 1_000_000 }); + const late = new Promise((r) => setTimeout(r, 5000, "still running").unref()); + assert.equal(await Promise.race([host.done, late]), 1); + assert.ok(logs.includes("broker channel broken (reply with no request waiting); stopping the host"), logs.join("\n")); + await assert.rejects(host.op({ op: "identity", cap: coder.cap }), (e) => e.code === "broker-channel-broken"); +}); + test("a notifier that refuses stops the broker and the host refuses with exit 3", async (t) => { const root = tmp(t); const f = fixture(root); --- r2wt/packages/cli/tests/launcher.test.mjs 2026-10-09 22:01:00.286672229 -0500 +++ wt/packages/cli/tests/launcher.test.mjs 2026-10-09 22:00:50.898562239 -0500 @@ -103,7 +103,7 @@ const log = (f) => readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); -test("bus start --pm: the PM runs under its own PID namespace, registered, with a manifest", { skip }, async (t) => { +test("bus start --pm: the PM runs under its own PID namespace, registered, with a manifest", { skip, timeout: 30000 }, async (t) => { const { f, launcher, host, cto, close } = await setup(t); const sock = launchSocketPath(f.dataRoot); assert.equal(statSync(sock).mode & 0o777, 0o600); @@ -173,7 +173,7 @@ assert.equal(end.exitCode, 0); }); -test("the PM launches a coder through its launch tool; the coder answers; refusals name their code", { skip }, async (t) => { +test("the PM launches a coder through its launch tool; the coder answers; refusals name their code", { skip, timeout: 30000 }, async (t) => { const { f, launcher, client, cto } = await setup(t); const sock = launchSocketPath(f.dataRoot); const pm = await launcher.launchPm(); @@ -234,7 +234,7 @@ assert.ok(readSessions(f.dataRoot).some((s) => s.run === pm.run)); }); -test("a runner that stops at once ends its launch with the runner's reason", { skip }, async (t) => { +test("a runner that stops at once ends its launch with the runner's reason", { skip, timeout: 30000 }, async (t) => { // pm's tracker token has expired: the runner's founder-credential stop // exits 20 before role.claim, and the host records founder-credentials. const { f, launcher } = await setup(t, (doc) => { @@ -250,7 +250,7 @@ }); for (const exited of [false, true]) { - test(`a host that died hard leaves its sessions to the next host, which ${exited ? "finds one already exited (23)" : "kills them"} and ends their runs so the role can be launched again`, { skip }, async (t) => { + test(`a host that died hard leaves its sessions to the next host, which ${exited ? "finds one already exited (23)" : "kills them"} and ends their runs so the role can be launched again`, { skip, timeout: 30000 }, async (t) => { const given = prepare(t); const { f } = given; const old = spawn(process.execPath, [HOST, given.adapter], { env: f.env, stdio: ["ignore", "pipe", "inherit"] }); @@ -311,7 +311,7 @@ }); } -test("a malformed sessions.json refuses the host start with exit 3 and stays as it was", async (t) => { +test("a malformed sessions.json refuses the host start with exit 3 and stays as it was", { timeout: 30000 }, async (t) => { const given = prepare(t); const file = sessionsFile(given.f.dataRoot); mkdirSync(join(given.f.dataRoot, "bus-host"), { mode: 0o700 }); @@ -323,7 +323,7 @@ const within = (p, ms, what) => Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error(`${what} took over ${ms} ms`)), ms).unref())]); -test("an over-long launch request is refused at once, not at the 10 s idle timeout", async (t) => { +test("an over-long launch request is refused at once, not at the 10 s idle timeout", { timeout: 30000 }, async (t) => { const { f } = await setup(t); const s = connect(launchSocketPath(f.dataRoot)); t.after(() => s.destroy()); --- r2wt/packages/harness/README.md 2026-10-09 22:01:00.286672229 -0500 +++ wt/packages/harness/README.md 2026-10-09 22:00:50.898562239 -0500 @@ -83,6 +83,27 @@ directories first. Pi calls it from the extension, Claude Code from `claude-gate.mjs`. +Pi's `read` doesn't always open the name it is given. When that name +doesn't exist, it tries other spellings of the whole resolved path: a +narrow no-break space (U+202F) before ` AM.` or ` PM.`, the NFD form, a +curly apostrophe (U+2019) for `'`, and NFD with the curly apostrophe. So +for `read`, the gate also checks every one of those spellings that exists +as a name, built from both the workspace as given and its real path (Pi's +working directory). If any of them resolves outside the workspace or goes +through a dangling symlink, the read is refused, whichever one Pi would +pick. A spelling that doesn't exist is ignored. Claude Code's `Read` gets +the same check, though in Darkwing's round 2 probe Claude Code's own +symlink check already stopped the one variant it tries (AM/PM). `write`, +`edit`, `grep`, `find` and `ls` take the name as given in Pi 0.85.1, so +they get no such check. + +The gate copies Pi 0.85.1 code, so recheck these files on a Pi upgrade: +`dist/utils/paths.js` (`normalizePath`), `dist/core/tools/path-utils.js` +(`resolveToCwd`, `resolveReadPathAsync`) and which tools in +`dist/core/tools/` call `resolveReadPath*`. The `pi` binary runs the +bundled copy under `dist/bundle/`; in 0.85.1 it holds the same code, and +only `read` and the CLI's own file arguments call `resolveReadPath*`. + ## The runner `node runner.mjs `, with `{"cap": "..."}` and a newline on stdin. @@ -156,6 +177,11 @@ manifest but not applied to either harness. - **`--restricted`** (Claude Code) is not one of the S0 lines. It is what keeps `CLAUDE.md` files and auto-memory out of the prompt (above). +- **Other spellings.** A read is refused when a spelling Pi might open + points outside the workspace, even if the name as given exists and is + inside, and Pi would open that one. The same holds when a directory + next to the workspace has another spelling of its path, for example + `jo’s/ws` beside a workspace in `jo's/ws`. - **The gate checks a path when the call is made.** A link created or changed between the check and the tool's own open (by `bash`, or by another process of the same user) isn't seen. That is the same reach as --- r2wt/packages/harness/src/gate.mjs 2026-10-09 22:01:00.287853049 -0500 +++ wt/packages/harness/src/gate.mjs 2026-10-09 22:00:50.899987075 -0500 @@ -62,13 +62,60 @@ } } -export function insideWorkspace(workspace, p) { +function within(workspace, path) { const root = realpathSync(workspace); - const s = normalise(p); - const target = real(isAbsolute(s) ? resolve(s) : resolve(workspace, s)); + const target = real(path); return target === root || target.startsWith(root + sep); } +export function insideWorkspace(workspace, p) { + const s = normalise(p); + return within(workspace, isAbsolute(s) ? resolve(s) : resolve(workspace, s)); +} + +// Pi's read (dist/core/tools/path-utils.js resolveReadPathAsync) opens +// another spelling when the resolved path doesn't exist: U+202F before +// AM/PM, then NFD, then U+2019 for ', then both. Each applies to the whole +// resolved path, directory names included, and Pi resolves against its cwd, +// the workspace's real path. So every spelling that exists as a name is +// checked, not only the one Pi would pick, and the check doesn't depend on +// which of them exists when Pi opens it. Claude Code's Read gets the same +// check; its own retries aren't documented. +function spellings(workspace, p) { + const s = normalise(p); + const bases = isAbsolute(s) ? [resolve(s)] : [resolve(workspace, s), resolve(realpathSync(workspace), s)]; + const curly = (v) => v.replace(/'/g, "\u2019"); + const out = new Set(); + for (const r of bases) { + const nfd = r.normalize("NFD"); + for (const v of [r.replace(/ (AM|PM)\./gi, "\u202F$1."), nfd, curly(r), curly(nfd)]) if (v !== r) out.add(v); + } + return out; +} + +// The first spelling that exists and resolves outside, or through a +// dangling link, as a refusal reason; null if there is none. +function otherSpelling(workspace, tool, p) { + for (const v of spellings(workspace, p)) { + let found; + try { + found = lstatSync(v, { throwIfNoEntry: false }); + } catch (error) { + // A file used as a directory: Pi's access() fails too. + if (error.code === "ENOTDIR") continue; + return `${tool} path can't be checked under another spelling: ${error.code ?? error.message}`; + } + if (!found) continue; + try { + if (!within(workspace, v)) return `${tool} path is outside the workspace under another spelling: ${p} -> ${JSON.stringify(v)}`; + } catch (error) { + if (error.code === "dangling-symlink") return `${tool} path goes through a dangling symlink under another spelling: ${p} -> ${JSON.stringify(v)}`; + return `${tool} path can't be checked under another spelling: ${error.code ?? error.message}`; + } + } + return null; +} + // decide(policy, tool, input) -> { allow: true } | { allow: false, reason } // policy { harness: "pi" | "claude-code", workspace, tools: [pi names], typed: [typed tool names] } export function decide(policy, tool, input) { @@ -98,5 +145,14 @@ if (error.code === "dangling-symlink") return no(`${tool} path goes through a dangling symlink: ${value}`); return no(`${tool} path can't be checked: ${error.code ?? error.message}`); } + if (tool === (claude ? "Read" : "read")) { + let other; + try { + other = otherSpelling(policy.workspace, tool, value); + } catch (error) { + return no(`${tool} path can't be checked: ${error.code ?? error.message}`); + } + if (other) return no(other); + } return { allow: true }; } --- r2wt/packages/harness/tests/gate.test.mjs 2026-10-09 22:01:00.288274261 -0500 +++ wt/packages/harness/tests/gate.test.mjs 2026-10-09 22:00:50.900285193 -0500 @@ -102,6 +102,62 @@ } }); +// Pi's read opens another spelling when the name it's given doesn't exist +// (path-utils.js resolveReadPathAsync). Asked name -> name on disk. +const SPELLINGS = [ + ["quote", "notes's.txt", "notes\u2019s.txt"], + ["ampm", "shot 9.41 AM.png", "shot 9.41\u202FAM.png"], + ["nfd", "r\u00E9sum\u00E9.txt", "r\u00E9sum\u00E9.txt".normalize("NFD")], + ["nfd and quote", "d'\u00E9cran.png", "d\u2019\u00E9cran.png".normalize("NFD")], + // Each family alone, on a name with both an apostrophe and an accent. + ["nfd, straight quote", "l'\u00E9t\u00E9.txt", "l'\u00E9t\u00E9.txt".normalize("NFD")], + ["quote, composed", "l'\u00E9t\u00E9.md", "l\u2019\u00E9t\u00E9.md"], +]; + +test("read is checked under every spelling pi's read would open, in both harnesses", (t) => { + for (const [harness, read, write, field] of [["pi", "read", "write", "path"], ["claude-code", "Read", "Write", "file_path"]]) { + for (const [, asked, disk] of SPELLINGS) { + const { dir, workspace, policy } = setup(t, harness, ["read", "write"]); + mkdirSync(join(dir, "outside")); + writeFileSync(join(dir, "outside", "secret.txt"), "s"); + symlinkSync(join(dir, "outside", "secret.txt"), join(workspace, disk)); + const why = new RegExp(`outside the workspace under another spelling: (.*/)?${asked.replace(/[.']/g, "\\$&")}`); + blocked(decide(policy, read, { [field]: asked }), why); + blocked(decide(policy, read, { [field]: join(workspace, asked) }), why); + // Write takes the name as given, so writing the asked name stays inside. + allowed(decide(policy, write, { [field]: asked })); + // The same spelling pointing inside is fine. + const inner = setup(t, harness, ["read"]); + symlinkSync(join(inner.workspace, "a.txt"), join(inner.workspace, disk)); + allowed(decide(inner.policy, read, { [field]: asked })); + } + } +}); + +test("other spellings cover directories, dangling links and pi's cwd", (t) => { + const { dir, workspace, policy } = setup(t, "pi", ["read"]); + mkdirSync(join(dir, "outside")); + writeFileSync(join(dir, "outside", "s.txt"), "s"); + // A directory name is respelled too. + symlinkSync(join(dir, "outside"), join(workspace, "it\u2019s")); + blocked(decide(policy, "read", { path: "it's/s.txt" }), /outside the workspace under another spelling/); + // A dangling link under another spelling is refused, like the name itself. + symlinkSync(join(dir, "nowhere"), join(workspace, "gone\u2019s")); + blocked(decide(policy, "read", { path: "gone's" }), /dangling symlink under another spelling/); + // A spelling that doesn't exist, or uses a file as a directory, isn't opened. + allowed(decide(policy, "read", { path: "nobody's.txt" })); + writeFileSync(join(workspace, "f\u2019s"), "f"); + allowed(decide(policy, "read", { path: "f's/x" })); + // Pi resolves against its cwd, the workspace's real path, and respells + // that too: here the real path has an apostrophe the given path lacks. + mkdirSync(join(dir, "jo's", "ws"), { recursive: true }); + mkdirSync(join(dir, "jo\u2019s", "ws"), { recursive: true }); + writeFileSync(join(dir, "jo\u2019s", "ws", "x.txt"), "x"); + symlinkSync(join(dir, "jo's"), join(dir, "jo")); + const viaLink = { ...policy, workspace: join(dir, "jo", "ws") }; + blocked(decide(viaLink, "read", { path: "x.txt" }), /outside the workspace under another spelling/); +}); + test("claude path fields per tool", (t) => { const { workspace, policy } = setup(t, "claude-code", ["read", "write", "edit", "grep", "find"]); allowed(decide(policy, "Read", { file_path: join(workspace, "a.txt") })); --- r2wt/packages/harness/tests/pi-session.test.mjs 2026-10-09 22:01:00.288274261 -0500 +++ wt/packages/harness/tests/pi-session.test.mjs 2026-10-09 22:00:50.900285193 -0500 @@ -120,6 +120,39 @@ 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)); +}); + 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);