Adds the harness plug-in kind (interface 1). packages/plugins/harness/pi and claude-code hold what core branched on: the adapter, thinking levels, preflight, version probe, sandbox mounts, builtins line, typed tool names, gate timeout, bundle files, adapter env, turn end and session-files path. Core reaches a module only through the loader. A session's route is the instance's harness, model and thinking vars, or the three fallback. vars; a missing one refuses no-route, and the system file's execution keys are never read for a session. The launcher checks the primary route, then the fallback: harness-off, harness-refused, thinking-unsupported, then preflight. A failed preflight falls back when a fallback is set, and any refusal of the fallback names the primary's failure too. Account checks run only when the route's module counts an account. The manifest records route, routeReason and harnessVersion, replacing piVersion and claudeVersion. pi gets --thinking, Claude Code --effort, both from MOSAIC_THINKING. The business file refuses a partial fallback. routes and routes.set are human-only bus verbs outside ORGANIZATION_KEYS; checkRoutes is shared by routes.set, instances.set and limits.set and checks only changed routes. The chief of staff changes an instance's model, and the assistant's whole vars (Q169), through limits.set, so its route write runs the same checks. A model outside launch.models refuses unknown-model, primary or fallback, and routes.set passes that code through. A new run binds only on a switched-on harness module name and records its transcript path, which the usage reader uses. Settings gains a Routing table with six selects per instance and loses the Role models fieldset; it posts changed rows to /api/routes. webui/src/bus.mjs is unchanged. Until Jason's `mosaic plugins set` adds "harness": ["claude-code"], a new launch refuses harness-off.
275 lines
15 KiB
JavaScript
275 lines
15 KiB
JavaScript
// Managed role sessions on disk and as processes (src/session.mjs). The
|
|
// runner is the real one, in the real bwrap sandbox, 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 { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, 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,
|
|
hasSessionCredential,
|
|
SESSION_CREDENTIALS,
|
|
isRunner,
|
|
launchLogFile,
|
|
newRun,
|
|
readSessions,
|
|
Registry,
|
|
runnerOf,
|
|
sessionEnv,
|
|
sessionsFile,
|
|
spawnSession,
|
|
sessionExit,
|
|
stopSession,
|
|
threadStore,
|
|
writeSessionFile,
|
|
} from "../src/session.mjs";
|
|
import { BWRAP, sandboxProblem } from "../src/sandbox.mjs";
|
|
|
|
const FAKE = new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url).pathname;
|
|
const skip = sandboxProblem() ?? false;
|
|
|
|
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("REQ-LIMIT-1: family substring matching is no longer exported",async()=>{const m=await import('../src/session.mjs');assert.equal(Object.hasOwn(m,'family'),false);});
|
|
|
|
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));
|
|
// Row 68: the host's pinned Claude Code binary reaches the adapter.
|
|
assert.equal(sessionEnv({ MOSAIC_CLAUDE_BIN: "/opt/claude" }, "r0123456789ab").MOSAIC_CLAUDE_BIN, "/opt/claude");
|
|
});
|
|
|
|
test("sessionExit gives a runner's signal death back as the signal; a session credential is one whole set of nonempty variables", () => {
|
|
assert.deepEqual(sessionExit(0, null), { code: 0, signal: null });
|
|
assert.deepEqual(sessionExit(23, null), { code: 23, signal: null });
|
|
assert.deepEqual(sessionExit(128, null), { code: 128, signal: null });
|
|
assert.deepEqual(sessionExit(137, null), { code: null, signal: "SIGKILL" });
|
|
assert.deepEqual(sessionExit(143, null), { code: null, signal: "SIGTERM" });
|
|
assert.deepEqual(sessionExit(null, "SIGTERM"), { code: null, signal: "SIGTERM" }, "bwrap itself killed");
|
|
assert.equal(endReason(...Object.values(sessionExit(137, null))), "killed");
|
|
assert.equal(hasSessionCredential({ CLAUDE_CODE_OAUTH_TOKEN: "t" }), true);
|
|
assert.equal(hasSessionCredential({ ANTHROPIC_API_KEY: "k" }), true);
|
|
assert.equal(hasSessionCredential({ ANTHROPIC_AUTH_TOKEN: "t", ANTHROPIC_BASE_URL: "http://127.0.0.1:1" }), true, "a proxy's key");
|
|
assert.equal(hasSessionCredential({ ANTHROPIC_AUTH_TOKEN: "t" }), false, "a key with no proxy to send it to");
|
|
assert.equal(hasSessionCredential({ ANTHROPIC_BASE_URL: "http://127.0.0.1:1" }), false);
|
|
assert.equal(hasSessionCredential({ CLAUDE_CODE_OAUTH_TOKEN: "", ANTHROPIC_API_KEY: "" }), false);
|
|
assert.equal(hasSessionCredential({}), false);
|
|
for (const k of SESSION_CREDENTIALS.flat()) assert.ok(ENV_ALLOW.includes(k), 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", provider:"zai",model:"claude-sonnet-5-5", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) };
|
|
r.add(me);
|
|
r.add({ ...me, instance: "pm", run: "r2", provider:"zai",model:"claude-opus-5-5", runnerStartTime: "1" });
|
|
assert.equal(r.count("acme", "zai","claude-sonnet-5-5"), 1);
|
|
assert.equal(r.count("other", "zai","claude-sonnet-5-5"), 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));
|
|
});
|
|
|
|
// Row 121 (#1615): a thread instance runs once per thread, so the registry
|
|
// keys a thread run by its thread too.
|
|
test("REQ-THREAD-1 Registry.running keys a thread run by business, instance and thread", (t) => {
|
|
const r = new Registry(scratch(t));
|
|
const base = { business: "acme", instance: "assistant", provider: "zai", model: "m", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) };
|
|
r.add({ ...base, run: "r1", thread: "th-a" });
|
|
assert.equal(r.running("acme", "assistant", "th-a").run, "r1");
|
|
assert.equal(r.running("acme", "assistant", "th-b"), null);
|
|
assert.equal(r.running("acme", "assistant"), null, "a thread run isn't the instance's non-thread run");
|
|
r.add({ ...base, instance: "coder", run: "r2" });
|
|
assert.equal(r.running("acme", "coder").run, "r2");
|
|
assert.equal(r.running("acme", "coder", "th-a"), null);
|
|
});
|
|
|
|
// Row 121 (#1615, sage-0308 change 3): <business> and <thread> are checked
|
|
// against the id pattern before the path join, then every directory is lstat-checked.
|
|
test("REQ-THREAD-2 REQ-LAUNCH-1 threadStore checks both segments before the join and refuses an unsafe directory", (t) => {
|
|
const root = scratch(t);
|
|
for (const [business, thread] of [["acme", ".."], ["acme", "a/b"], ["acme", ".x"], ["acme", "a..b"], ["..", "th"], ["acme", ""], ["acme", "Th"], ["acme", 7], [undefined, "th"]]) {
|
|
assert.throws(() => threadStore(root, business, thread), (e) => e instanceof SeatError && /^thread store: (business|thread) .* isn't an id$/.test(e.message), `${business}/${thread}`);
|
|
}
|
|
assert.equal(existsSync(join(root, "threads")), false, "a refused segment makes nothing");
|
|
|
|
const s = threadStore(root, "acme", "th-1");
|
|
assert.deepEqual(s, { dir: join(root, "threads", "acme", "th-1"), session: join(root, "threads", "acme", "th-1", "session"), projects: join(root, "threads", "acme", "th-1", "claude-projects") });
|
|
for (const d of [join(root, "threads"), join(root, "threads", "acme"), s.dir, s.session, s.projects]) assert.equal(mode(d), 0o700, d);
|
|
writeFileSync(join(s.session, "history"), "kept\n");
|
|
assert.deepEqual(threadStore(root, "acme", "th-1"), s, "a second call reuses the store");
|
|
assert.equal(readFileSync(join(s.session, "history"), "utf8"), "kept\n");
|
|
|
|
chmodSync(s.session, 0o755);
|
|
assert.throws(() => threadStore(root, "acme", "th-1"), (e) => e instanceof SeatError && /is unsafe$/.test(e.message));
|
|
chmodSync(s.session, 0o700);
|
|
const elsewhere = join(root, "elsewhere");
|
|
mkdirSync(elsewhere, { mode: 0o700 });
|
|
symlinkSync(elsewhere, join(root, "threads", "acme", "th-2"));
|
|
assert.throws(() => threadStore(root, "acme", "th-2"), (e) => e instanceof SeatError && /th-2 is unsafe$/.test(e.message));
|
|
assert.equal(existsSync(join(elsewhere, "session")), false, "nothing is made through the link");
|
|
assert.ok(lstatSync(join(root, "threads", "acme", "th-2")).isSymbolicLink());
|
|
});
|
|
|
|
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 in its sandbox 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"), turnEnd: "marker", harnessEnv: {}, pollInterval: 50,
|
|
});
|
|
|
|
const paths = { workspace: ws, adapter, brokerSocket, readOnly: [FAKE] };
|
|
const child = spawnSession({ runDir, env: sessionEnv({ HOME: process.env.HOME, PATH: process.env.PATH, GITEA_TOKEN: "x" }, run), paths, business: "acme", role: "coder" });
|
|
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.equal(cmdlineOf(child.pid)[0], BWRAP);
|
|
assert.ok(isRunner(runner.pid));
|
|
assert.equal(isRunner(child.pid), false, "bwrap'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=")));
|
|
assert.ok(environ.includes(`HOME=${join(runDir, "home")}`), "HOME is the run's own");
|
|
|
|
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");
|
|
// Row 109 (#1603): the turn opens with the state block, then the header with the time it was sent.
|
|
const sentAt = store.get("SELECT at FROM messages WHERE id=?", sent.id).at;
|
|
assert.match(reply.body, new RegExp(`^echo: Current state, read at \\S+:\nLaunches: allowed \\(never switched\\)\\.\nOpen tasks: 0\\.\n\nMessage ${sent.id} from pm, sent ${sentAt}, 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 }, "bwrap 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);
|
|
});
|