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]>
This commit is contained in:
@@ -150,6 +150,47 @@ Fleet seats under `~/.mosaic` stay on their own launchers (Jason's ruling,
|
||||
2026-09-12); a fleet seat can still be registered by hand with
|
||||
`scripts/mosaic launch <seat-dir>`.
|
||||
|
||||
## Managed role sessions (slice 1 S6, #1523)
|
||||
|
||||
Separate from seats: `src/session.mjs` holds what the bus host's launcher
|
||||
(`packages/cli/src/launcher.mjs`) uses to start and stop a managed role
|
||||
session, and `src/proc.mjs` the `/proc` reads (start time, command line,
|
||||
children) that the host, the launcher and `mosaic stop` share. Nothing here
|
||||
decides authority or capacity; `packages/cli/README.md` ("Sessions") has the
|
||||
launch flow and the on-disk layout.
|
||||
|
||||
- `newRun` picks a run id (`r` and 12 hex digits) and makes its 0700
|
||||
directory, refusing a data root too deep for the run's socket path.
|
||||
- `sessionEnv` builds the session's environment from an allowlist
|
||||
(`ENV_ALLOW`), the repository's `node_modules/.bin` on `PATH`, and
|
||||
`MOSAIC_RUN_ID`. Founder credential variables never pass.
|
||||
- `spawnSession` starts `packages/harness/src/runner.mjs` under
|
||||
`unshare --user --map-current-user --pid --fork --kill-child --mount-proc`,
|
||||
detached, with stdin open for the capability line and output to
|
||||
`runner.log` (0600). `runnerOf` finds the runner inside it.
|
||||
- `Registry` is the host's list of running sessions, mirrored to
|
||||
`bus-host/sessions.json` (0600); `readSessions` reads it and marks each
|
||||
entry live or stale by the runner's start time, and refuses a file it
|
||||
can't read.
|
||||
- `stopSession` is `mosaic stop`: SIGTERM to a live runner, resent every
|
||||
second until it exits.
|
||||
|
||||
Limits of the PID namespace, which are limits and not a sandbox:
|
||||
|
||||
- The runner is pid 1 of its namespace. A detached child is reparented to
|
||||
it, not to the user's init, so it stays under the launch's pid. Pid 1
|
||||
ignores a signal it has no handler for; the runner installs its handlers
|
||||
first, and the senders resend SIGTERM to cover Node's startup.
|
||||
- The namespace isolates pids only. With its own `/proc`, a session sees
|
||||
no process outside it, so it can't signal or `ptrace` another session or
|
||||
the host by pid (checked on this host, Yama `ptrace_scope` 1). It still
|
||||
runs as the host's user, with that user's files, `/tmp`, network and
|
||||
sockets.
|
||||
- A session can still ask a process outside its namespace (a systemd user
|
||||
manager, an existing tmux server) to run a command for it.
|
||||
- The runner doesn't reap zombies other than its own turns; they go when
|
||||
the namespace does.
|
||||
|
||||
## Exit codes
|
||||
|
||||
`launch` exits with the launch script's own code once the script runs.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// What /proc says about one process. Same-UID process identity: a pid plus
|
||||
// its start time names one process, so a recycled pid never matches.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// `/proc/<pid>/stat` field 22, the start time in clock ticks. Null when the
|
||||
// process is gone or has exited and waits to be reaped (state Z).
|
||||
export function startTimeOf(pid) {
|
||||
try {
|
||||
const raw = readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
const fields = raw.slice(raw.lastIndexOf(")") + 2).split(" ");
|
||||
return fields[0] === "Z" ? null : fields[19];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function cmdlineOf(pid) {
|
||||
try {
|
||||
return readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The pids of pid's direct children, or [] when it has none or is gone.
|
||||
export function childrenOf(pid) {
|
||||
try {
|
||||
return readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8").trim().split(/\s+/).filter(Boolean).map(Number);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Managed role sessions (slice 1 S6): where a launch lives on disk, how its
|
||||
// runner starts, and how the human stops it. The bus host
|
||||
// (packages/cli/src/launcher.mjs) is the only caller that starts one; it
|
||||
// decides authority and capacity first, and this module only does what it
|
||||
// is told.
|
||||
//
|
||||
// A session is `node packages/harness/src/runner.mjs <run dir>` inside
|
||||
// `unshare --user --map-current-user --pid --fork --kill-child --mount-proc`.
|
||||
// The runner is pid 1 of its own PID namespace, so a child that detaches
|
||||
// (`setsid -f`) is reparented to the runner and stays under the launch's
|
||||
// pid, which the human CLI's ancestry check refuses. unshare itself ignores
|
||||
// SIGTERM and passes the runner's exit status on; `stop` signals the runner.
|
||||
//
|
||||
// On disk, under the data root:
|
||||
// launches/<business>/<run>/ the run directory (0700): session.json,
|
||||
// bundle/, session/, turns/, runner.log,
|
||||
// tools.sock, turn.done
|
||||
// launches/<business>.jsonl the launch log (0600, append-only):
|
||||
// every launch, refusal and end
|
||||
// workspaces/<business>/<instance>/ the instance's workspace (0700), kept
|
||||
// across launches
|
||||
// bus-host/sessions.json the running sessions (0600), written
|
||||
// only by the host; `mosaic stop` reads it
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { childrenOf, cmdlineOf, startTimeOf } from "./proc.mjs";
|
||||
import { SeatError } from "./seat.mjs";
|
||||
|
||||
export const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
export const RUNNER = join(REPO, "packages", "harness", "src", "runner.mjs");
|
||||
export const ADAPTERS = Object.freeze({
|
||||
pi: join(REPO, "adapters", "pi", "adapter.sh"),
|
||||
"claude-code": join(REPO, "adapters", "claude", "adapter.sh"),
|
||||
});
|
||||
export const UNSHARE = Object.freeze(["unshare", "--user", "--map-current-user", "--pid", "--fork", "--kill-child", "--mount-proc"]);
|
||||
export const SESSIONS_VERSION = 1;
|
||||
|
||||
export const sessionsFile = (dataRoot) => join(dataRoot, "bus-host", "sessions.json");
|
||||
export const launchSocketPath = (dataRoot) => join(dataRoot, "bus-host", "launch.sock");
|
||||
export const runsDir = (dataRoot, business) => join(dataRoot, "launches", business);
|
||||
export const launchLogFile = (dataRoot, business) => join(dataRoot, "launches", `${business}.jsonl`);
|
||||
export const workspaceDir = (dataRoot, business, instance) => join(dataRoot, "workspaces", business, instance);
|
||||
|
||||
// The capacity family of a model: the one key of the business file's
|
||||
// launch.max that the model name contains (`claude-opus-5-5` is `opus`).
|
||||
// None, or more than one, is null, and the launch refuses.
|
||||
export function family(model, max) {
|
||||
const hits = Object.keys(max).filter((f) => model.toLowerCase().includes(f.toLowerCase()));
|
||||
return hits.length === 1 ? hits[0] : null;
|
||||
}
|
||||
|
||||
// The session's environment is an allowlist: nothing else from the host's
|
||||
// environment passes, so a founder credential variable (GITEA_TOKEN,
|
||||
// GH_TOKEN, SSH_AUTH_SOCK, ...) never reaches a session (REQ-CRED-2).
|
||||
// Provider variables pass because the harness needs them to reach a model.
|
||||
export const ENV_ALLOW = Object.freeze([
|
||||
"HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "TZ", "TERM",
|
||||
"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"OPENAI_API_KEY", "OPENROUTER_API_KEY",
|
||||
]);
|
||||
|
||||
export function sessionEnv(env, run) {
|
||||
const out = {};
|
||||
for (const k of ENV_ALLOW) if (env[k] !== undefined) out[k] = env[k];
|
||||
out.PATH = [join(REPO, "node_modules", ".bin"), env.PATH ?? "/usr/bin:/bin"].join(":");
|
||||
out.MOSAIC_RUN_ID = run;
|
||||
return out;
|
||||
}
|
||||
|
||||
// A new run id and its directory. Ids are short because the run directory
|
||||
// holds a Unix socket (tools.sock), and socket paths stop at 107 bytes.
|
||||
export function newRun(dataRoot, business) {
|
||||
const root = runsDir(dataRoot, business);
|
||||
mkdirSync(root, { recursive: true, mode: 0o700 });
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const run = `r${randomBytes(6).toString("hex")}`;
|
||||
const runDir = join(root, run);
|
||||
try {
|
||||
mkdirSync(runDir, { mode: 0o700 });
|
||||
} catch (e) {
|
||||
if (e.code === "EEXIST") continue;
|
||||
throw e;
|
||||
}
|
||||
if (Buffer.byteLength(join(runDir, "tools.sock")) > 107) throw new SeatError(`data root path too long for a socket: ${runDir}`, 2);
|
||||
return { run, runDir };
|
||||
}
|
||||
throw new SeatError("could not pick a free run id", 1);
|
||||
}
|
||||
|
||||
export function writeSessionFile(runDir, session) {
|
||||
writeFileSync(join(runDir, "session.json"), `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
||||
}
|
||||
|
||||
export function appendLaunchLog(dataRoot, business, entry) {
|
||||
const file = launchLogFile(dataRoot, business);
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
// Start the runner. stdin stays open for the capability line the host writes
|
||||
// once the broker has bound the launch; stdout and stderr go to runner.log.
|
||||
// `namespace: false` exists for hosts without unprivileged user namespaces
|
||||
// in the tests only; the host always passes true.
|
||||
export function spawnSession({ runDir, env, namespace = true, node = process.execPath }) {
|
||||
const out = openSync(join(runDir, "runner.log"), "a", 0o600);
|
||||
const argv = [...(namespace ? UNSHARE : []), node, RUNNER, runDir];
|
||||
try {
|
||||
return spawn(argv[0], argv.slice(1), { cwd: runDir, env, stdio: ["pipe", out, out], detached: true });
|
||||
} finally {
|
||||
closeSync(out);
|
||||
}
|
||||
}
|
||||
|
||||
// A runner's command line is `<node> <RUNNER> <run dir>`. Matching argv[1]
|
||||
// rather than any argument skips unshare's forked child before its exec,
|
||||
// whose argv still names the runner.
|
||||
export const isRunner = (pid) => (cmdlineOf(pid) ?? [])[1] === RUNNER;
|
||||
|
||||
// The runner's pid and start time as seen from here. Under unshare the
|
||||
// runner is the outer process's only child; without a namespace it is the
|
||||
// process itself.
|
||||
export async function runnerOf(child, { namespace = true, timeoutMs = 5000 } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) break;
|
||||
const pid = namespace ? childrenOf(child.pid)[0] : child.pid;
|
||||
const start = pid ? startTimeOf(pid) : null;
|
||||
if (start && isRunner(pid)) return { pid, startTime: start };
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
throw new SeatError(`runner for ${child.pid} did not start`, 1);
|
||||
}
|
||||
|
||||
// The launch end reason the broker records for a runner's exit
|
||||
// (packages/harness/README.md, "The runner").
|
||||
const REASONS = { 0: "stopped", 2: "failed", 20: "founder-credentials", 21: "claim-refused", 22: "session-invalid", 23: "broker-unreachable" };
|
||||
export function endReason(code, signal) {
|
||||
if (signal) return "killed";
|
||||
return REASONS[code] ?? "failed";
|
||||
}
|
||||
|
||||
// The host's view of its running sessions, kept in memory and mirrored to
|
||||
// sessions.json for `mosaic stop` and `mosaic launches list`.
|
||||
export class Registry {
|
||||
#dataRoot;
|
||||
#sessions = [];
|
||||
constructor(dataRoot) {
|
||||
this.#dataRoot = dataRoot;
|
||||
this.#write();
|
||||
}
|
||||
get sessions() {
|
||||
return this.#sessions.map((s) => ({ ...s }));
|
||||
}
|
||||
add(entry) {
|
||||
this.#sessions.push({ ...entry });
|
||||
this.#write();
|
||||
}
|
||||
remove(business, run) {
|
||||
this.#sessions = this.#sessions.filter((s) => !(s.business === business && s.run === run));
|
||||
this.#write();
|
||||
}
|
||||
count(business, fam) {
|
||||
return this.#sessions.filter((s) => s.business === business && s.family === fam).length;
|
||||
}
|
||||
running(business, instance) {
|
||||
return this.#sessions.find((s) => s.business === business && s.instance === instance) ?? null;
|
||||
}
|
||||
#write() {
|
||||
const file = sessionsFile(this.#dataRoot);
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, `${JSON.stringify({ sessionsVersion: SESSIONS_VERSION, sessions: this.#sessions }, null, 2)}\n`, { mode: 0o600 });
|
||||
renameSync(tmp, file);
|
||||
}
|
||||
}
|
||||
|
||||
// sessions.json as `mosaic stop` sees it. `live` is true only when the
|
||||
// runner still runs with the recorded start time.
|
||||
export function readSessions(dataRoot) {
|
||||
const file = sessionsFile(dataRoot);
|
||||
if (!existsSync(file)) return [];
|
||||
let doc;
|
||||
try {
|
||||
doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
throw new SeatError(`sessions file is unreadable: ${file}`, 2);
|
||||
}
|
||||
if (doc?.sessionsVersion !== SESSIONS_VERSION || !Array.isArray(doc.sessions)) throw new SeatError(`sessions file is malformed: ${file}`, 2);
|
||||
return doc.sessions.map((s) => ({ ...s, live: startTimeOf(s.runnerPid) === s.runnerStartTime }));
|
||||
}
|
||||
|
||||
// `mosaic stop <run>`: SIGTERM to the run's runner, after checking that the
|
||||
// pid still has the recorded start time and runs the runner. Same-UID
|
||||
// process control, not an authority check. The runner releases its role
|
||||
// and the host records the end.
|
||||
export async function stopSession(dataRoot, run, { timeoutMs = 30000 } = {}) {
|
||||
const hits = readSessions(dataRoot).filter((s) => s.run === run);
|
||||
if (hits.length === 0) throw new SeatError(`no running session has run id ${run}; see mosaic launches list`, 2);
|
||||
const s = hits[0];
|
||||
if (!s.live) return { stopped: false, reason: `run ${run} is not running (stale entry)` };
|
||||
if (!isRunner(s.runnerPid)) throw new SeatError(`pid ${s.runnerPid} is not a session runner; refusing to signal it`, 2);
|
||||
// Sent again every second: a runner that is still starting has no handler
|
||||
// yet, and as pid 1 of its namespace it never sees a signal sent before then.
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let next = 0;
|
||||
while (Date.now() < deadline) {
|
||||
if (startTimeOf(s.runnerPid) !== s.runnerStartTime) return { stopped: true, business: s.business, instance: s.instance, run };
|
||||
if (Date.now() >= next) {
|
||||
try {
|
||||
process.kill(s.runnerPid, "SIGTERM");
|
||||
} catch {}
|
||||
next = Date.now() + 1000;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
throw new SeatError(`run ${run} did not stop within ${Math.round(timeoutMs / 1000)} s`, 1);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Managed role sessions on disk and as processes (src/session.mjs). The
|
||||
// runner is the real one, under the real unshare, against an in-process
|
||||
// broker on a real socket; the harness turn is packages/harness's fake
|
||||
// adapter. The launcher's checks are tested in packages/cli.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Broker } from "../../bus/src/broker.mjs";
|
||||
import { serve } from "../../bus/src/server.mjs";
|
||||
import { Store } from "../../bus/src/store.mjs";
|
||||
import { cmdlineOf, startTimeOf } from "../src/proc.mjs";
|
||||
import { SeatError } from "../src/seat.mjs";
|
||||
import {
|
||||
appendLaunchLog,
|
||||
endReason,
|
||||
ENV_ALLOW,
|
||||
family,
|
||||
isRunner,
|
||||
launchLogFile,
|
||||
newRun,
|
||||
readSessions,
|
||||
Registry,
|
||||
runnerOf,
|
||||
sessionEnv,
|
||||
sessionsFile,
|
||||
spawnSession,
|
||||
stopSession,
|
||||
UNSHARE,
|
||||
writeSessionFile,
|
||||
} from "../src/session.mjs";
|
||||
|
||||
const FAKE = new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url).pathname;
|
||||
const NAMESPACES = spawnSync(UNSHARE[0], [...UNSHARE.slice(1), "true"]).status === 0;
|
||||
const skip = NAMESPACES ? false : "unprivileged user and PID namespaces are unavailable here";
|
||||
|
||||
function scratch(t) {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), "mosaic-session-")));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
const mode = (p) => statSync(p).mode & 0o777;
|
||||
|
||||
test("family: exactly one launch.max key in the model name, else null", () => {
|
||||
const max = { opus: 2, sonnet: 4 };
|
||||
assert.equal(family("claude-opus-5-5", max), "opus");
|
||||
assert.equal(family("Claude-Sonnet-5-5", max), "sonnet");
|
||||
assert.equal(family("glm-5.3-flash", max), null);
|
||||
assert.equal(family("opus-sonnet-mix", max), null);
|
||||
});
|
||||
|
||||
test("sessionEnv passes only the allowlist, the repo's bin on PATH, and the run id", () => {
|
||||
const env = sessionEnv({ HOME: "/h", ANTHROPIC_API_KEY: "k", GITEA_TOKEN: "t", GH_TOKEN: "t", SSH_AUTH_SOCK: "/s", MOSAIC_CONFIG: "/c", PATH: "/usr/bin" }, "r0123456789ab");
|
||||
assert.deepEqual(Object.keys(env).sort(), ["ANTHROPIC_API_KEY", "HOME", "MOSAIC_RUN_ID", "PATH"]);
|
||||
assert.match(env.PATH, /node_modules\/\.bin:\/usr\/bin$/);
|
||||
assert.equal(env.MOSAIC_RUN_ID, "r0123456789ab");
|
||||
for (const k of ["GITEA_TOKEN", "GH_TOKEN", "SSH_AUTH_SOCK"]) assert.ok(!ENV_ALLOW.includes(k));
|
||||
});
|
||||
|
||||
test("newRun: short ids, 0700 directories, and a refusal when the socket path won't fit", (t) => {
|
||||
const root = scratch(t);
|
||||
const a = newRun(join(root, "data"), "acme");
|
||||
const b = newRun(join(root, "data"), "acme");
|
||||
assert.match(a.run, /^r[0-9a-f]{12}$/);
|
||||
assert.notEqual(a.run, b.run);
|
||||
assert.equal(mode(a.runDir), 0o700);
|
||||
assert.equal(mode(join(root, "data", "launches", "acme")), 0o700);
|
||||
const deep = join(root, "x".repeat(100));
|
||||
assert.throws(() => newRun(deep, "acme"), (e) => e instanceof SeatError && /too long for a socket/.test(e.message));
|
||||
});
|
||||
|
||||
test("session file and launch log: 0600, the session file written once", (t) => {
|
||||
const root = scratch(t);
|
||||
const { runDir } = newRun(root, "acme");
|
||||
writeSessionFile(runDir, { run: "x" });
|
||||
assert.equal(mode(join(runDir, "session.json")), 0o600);
|
||||
assert.throws(() => writeSessionFile(runDir, { run: "y" }), /EEXIST/);
|
||||
appendLaunchLog(root, "acme", { event: "launch", run: "x" });
|
||||
appendLaunchLog(root, "acme", { event: "end", run: "x" });
|
||||
const file = launchLogFile(root, "acme");
|
||||
assert.equal(mode(file), 0o600);
|
||||
const lines = readFileSync(file, "utf8").trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.deepEqual(lines.map((l) => l.event), ["launch", "end"]);
|
||||
assert.ok(lines.every((l) => !Number.isNaN(Date.parse(l.at))));
|
||||
});
|
||||
|
||||
test("endReason maps the runner's exit codes; a signal is killed", () => {
|
||||
assert.deepEqual(
|
||||
[0, 2, 20, 21, 22, 23, 7].map((c) => endReason(c, null)),
|
||||
["stopped", "failed", "founder-credentials", "claim-refused", "session-invalid", "broker-unreachable", "failed"],
|
||||
);
|
||||
assert.equal(endReason(null, "SIGKILL"), "killed");
|
||||
});
|
||||
|
||||
test("Registry mirrors to sessions.json; readSessions marks live entries; bad files refuse", (t) => {
|
||||
const root = scratch(t);
|
||||
const r = new Registry(root);
|
||||
assert.equal(mode(sessionsFile(root)), 0o600);
|
||||
assert.deepEqual(readSessions(root), []);
|
||||
const me = { business: "acme", instance: "coder", run: "r1", family: "sonnet", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) };
|
||||
r.add(me);
|
||||
r.add({ ...me, instance: "pm", run: "r2", family: "opus", runnerStartTime: "1" });
|
||||
assert.equal(r.count("acme", "sonnet"), 1);
|
||||
assert.equal(r.count("other", "sonnet"), 0);
|
||||
assert.equal(r.running("acme", "coder").run, "r1");
|
||||
assert.deepEqual(readSessions(root).map((s) => [s.run, s.live]), [["r1", true], ["r2", false]]);
|
||||
r.remove("acme", "r1");
|
||||
assert.equal(r.running("acme", "coder"), null);
|
||||
assert.deepEqual(readSessions(root).map((s) => s.run), ["r2"]);
|
||||
|
||||
writeFileSync(sessionsFile(root), "{");
|
||||
assert.throws(() => readSessions(root), (e) => e instanceof SeatError && /unreadable/.test(e.message));
|
||||
writeFileSync(sessionsFile(root), JSON.stringify({ sessionsVersion: 9, sessions: [] }));
|
||||
assert.throws(() => readSessions(root), (e) => e instanceof SeatError && /malformed/.test(e.message));
|
||||
});
|
||||
|
||||
test("stopSession refuses an unknown run, reports a stale one, and won't signal a pid that isn't a runner", async (t) => {
|
||||
const root = scratch(t);
|
||||
const r = new Registry(root);
|
||||
await assert.rejects(stopSession(root, "nope"), /no running session has run id nope/);
|
||||
r.add({ business: "acme", instance: "coder", run: "stale", runnerPid: process.pid, runnerStartTime: "1" });
|
||||
assert.deepEqual(await stopSession(root, "stale"), { stopped: false, reason: "run stale is not running (stale entry)" });
|
||||
r.add({ business: "acme", instance: "pm", run: "me", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) });
|
||||
await assert.rejects(stopSession(root, "me"), /is not a session runner; refusing to signal it/);
|
||||
});
|
||||
|
||||
test("a session runs under unshare as pid 1 of its namespace, claims, answers, and stops on mosaic stop", { skip }, async (t) => {
|
||||
const root = scratch(t);
|
||||
const store = new Store(root);
|
||||
const broker = new Broker({
|
||||
store,
|
||||
businesses: {
|
||||
acme: {
|
||||
id: "acme",
|
||||
human: "jason",
|
||||
arbiters: { technical: "pm", delivery: "pm" },
|
||||
roles: {
|
||||
pm: { authority: { withinRole: ["message.send"], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ["message.send"], crossRole: [] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const brokerSocket = join(root, "bus.sock");
|
||||
const server = await serve({ broker, path: brokerSocket });
|
||||
t.after(async () => {
|
||||
await server.close().catch(() => {});
|
||||
store.close();
|
||||
});
|
||||
|
||||
const dataRoot = join(root, "data");
|
||||
const { run, runDir } = newRun(dataRoot, "acme");
|
||||
const bundle = join(runDir, "bundle");
|
||||
const ws = join(root, "ws");
|
||||
mkdirSync(bundle, { mode: 0o700 });
|
||||
mkdirSync(ws);
|
||||
writeFileSync(join(bundle, "prompt.md"), "# test\n");
|
||||
writeFileSync(join(bundle, "policy.json"), JSON.stringify({ harness: "pi", workspace: ws, tools: [], typed: [], network: "none", needs: [], credentials: [] }));
|
||||
writeFileSync(join(bundle, "tools.json"), "[]");
|
||||
const adapter = join(root, "adapter.sh");
|
||||
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
|
||||
writeSessionFile(runDir, {
|
||||
business: "acme", instance: "coder", run, launchedBy: "pm", harness: "pi", provider: "fake", model: "fake-model",
|
||||
brokerSocket, launchSocket: join(root, "launch.sock"), workspace: ws, sessionDir: join(runDir, "session"), adapter,
|
||||
bundle: { prompt: join(bundle, "prompt.md"), policy: join(bundle, "policy.json"), tools: join(bundle, "tools.json") },
|
||||
toolSocket: join(runDir, "tools.sock"), turnMarker: join(runDir, "turn.done"), pollInterval: 50,
|
||||
});
|
||||
|
||||
const child = spawnSession({ runDir, env: sessionEnv({ HOME: process.env.HOME, PATH: process.env.PATH, GITEA_TOKEN: "x" }, run) });
|
||||
t.after(() => {
|
||||
try {
|
||||
process.kill(child.pid, "SIGKILL");
|
||||
} catch {}
|
||||
});
|
||||
const exited = new Promise((r) => child.once("close", (code, signal) => r({ code, signal })));
|
||||
const runner = await runnerOf(child);
|
||||
assert.notEqual(runner.pid, child.pid);
|
||||
assert.deepEqual(cmdlineOf(child.pid).slice(0, UNSHARE.length), UNSHARE);
|
||||
assert.ok(isRunner(runner.pid));
|
||||
assert.equal(isRunner(child.pid), false, "unshare's argv names the runner too");
|
||||
// Inside its namespace the runner is pid 1.
|
||||
const nspid = readFileSync(`/proc/${runner.pid}/status`, "utf8").match(/^NSpid:\s*(.*)$/m)[1].trim().split(/\s+/);
|
||||
assert.equal(nspid.at(-1), "1");
|
||||
const environ = readFileSync(`/proc/${runner.pid}/environ`, "utf8").split("\0");
|
||||
assert.ok(environ.includes(`MOSAIC_RUN_ID=${run}`));
|
||||
assert.ok(!environ.some((v) => v.startsWith("GITEA_TOKEN=")));
|
||||
|
||||
const cap = broker.bindLaunch({ business: "acme", role: "coder", run, harness: "pi", pid: child.pid, startTime: startTimeOf(child.pid) });
|
||||
const pm = broker.bindLaunch({ business: "acme", role: "pm", run: "pm-run", harness: "pi" });
|
||||
broker.request(pm, { verb: "role.claim" });
|
||||
child.stdin.end(`${JSON.stringify({ cap })}\n`);
|
||||
const sent = broker.request(pm, { verb: "message.send", args: { to: "coder", body: "hello", class: "REQUEST" } });
|
||||
let reply;
|
||||
const end = Date.now() + 15000;
|
||||
while (!reply && Date.now() < end) {
|
||||
reply = broker.request(pm, { verb: "message.receive" }).find((m) => m.in_reply_to === sent.id);
|
||||
if (!reply) await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
assert.ok(reply, "the session answered");
|
||||
assert.match(reply.body, /^echo: Message .* from pm, class REQUEST:\n\nhello/);
|
||||
|
||||
const registry = new Registry(dataRoot);
|
||||
registry.add({ business: "acme", instance: "coder", run, pid: child.pid, startTime: startTimeOf(child.pid), runnerPid: runner.pid, runnerStartTime: runner.startTime });
|
||||
const stopped = await stopSession(dataRoot, run, { timeoutMs: 10000 });
|
||||
assert.deepEqual(stopped, { stopped: true, business: "acme", instance: "coder", run });
|
||||
assert.deepEqual(await exited, { code: 0, signal: null }, "unshare passes the runner's exit 0 on");
|
||||
assert.equal(store.get("SELECT op FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1").op, "release");
|
||||
assert.equal(existsSync(join(runDir, "tools.sock")), false);
|
||||
assert.equal(mode(join(runDir, "runner.log")), 0o600);
|
||||
});
|
||||
Reference in New Issue
Block a user