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.
212 lines
12 KiB
JavaScript
212 lines
12 KiB
JavaScript
// Row 65 (REQ-PLUG-2, Q156): the two harness modules load through the loader with every export
|
|
// the kind names, answer the questions core used to answer by name, and no core source names a
|
|
// harness outside the exceptions the brief lists.
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, relative, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { spawnSync } from "node:child_process";
|
|
import { createLoader } from "../src/loader.mjs";
|
|
import { KINDS } from "../src/kinds.mjs";
|
|
|
|
const REPO = resolve(fileURLToPath(new URL("../../../", import.meta.url)));
|
|
|
|
async function load(name) {
|
|
const logged = [];
|
|
const loader = createLoader({ log: entry => logged.push(entry) });
|
|
const result = await loader.load({ harness: [name] });
|
|
assert.deepEqual(result.refused, [], name);
|
|
assert.deepEqual(logged, [], name);
|
|
const [module] = loader.modules("harness");
|
|
return module;
|
|
}
|
|
|
|
function scratch(t) {
|
|
const root = mkdtempSync(join(tmpdir(), "mosaic-harness-plugin-"));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
return root;
|
|
}
|
|
|
|
test("row 65 both harness modules load through the loader with every export the kind names", async () => {
|
|
for (const name of ["pi", "claude-code"]) {
|
|
const module = await load(name);
|
|
for (const key of KINDS.harness.exports) assert.equal(typeof module[key], "function", `${name} ${key}`);
|
|
assert.equal(module.interfaceVersion, 1);
|
|
}
|
|
});
|
|
|
|
test("row 65 each module answers for its own harness: adapter, levels, account, web, turn end and session files", async () => {
|
|
const pi = await load("pi"), claude = await load("claude-code");
|
|
assert.equal(pi.adapter(), join(REPO, "adapters", "pi", "adapter.sh"));
|
|
assert.equal(claude.adapter(), join(REPO, "adapters", "claude", "adapter.sh"));
|
|
// pi 0.85.1 --thinking and claude 2.1.302 --effort, as probed (BUILD.md); claude has no off or minimal.
|
|
assert.deepEqual([...pi.levels()], ["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
assert.deepEqual([...claude.levels()], ["low", "medium", "high", "xhigh", "max"]);
|
|
assert.deepEqual([pi.countsAccount(), claude.countsAccount()], [false, true]);
|
|
assert.deepEqual([pi.builtinWeb(), claude.builtinWeb()], [false, true]);
|
|
assert.deepEqual([pi.turnEnd(), claude.turnEnd()], ["marker", "exit"]);
|
|
assert.deepEqual([pi.typedName("list_tasks"), claude.typedName("list_tasks")], ["list_tasks", "mcp__mosaic__list_tasks"]);
|
|
assert.equal(pi.gateTimeout(), null);
|
|
assert.deepEqual(claude.gateTimeout(), { n: 10, k: 2, hook: 20 });
|
|
assert.equal(pi.transcript({ runDir: "/r" }), "/r/session");
|
|
assert.equal(claude.transcript({ runDir: "/r" }), "/r/home/.claude/projects");
|
|
assert.equal(pi.transcript({ runDir: "/r", store: { session: "/s", projects: "/p" } }), "/s");
|
|
assert.equal(claude.transcript({ runDir: "/r", store: { session: "/s", projects: "/p" } }), "/p");
|
|
assert.equal(pi.reliesOn().length, 5);
|
|
assert.equal(claude.reliesOn().length, 5);
|
|
});
|
|
|
|
test("row 65 claude-code preflight: a missing credential, then a missing or relative binary, each refuse with their code", async t => {
|
|
const claude = await load("claude-code");
|
|
const root = scratch(t);
|
|
const bin = join(root, "claude");
|
|
writeFileSync(bin, "#!/bin/sh\necho 2.1.302\n");
|
|
chmodSync(bin, 0o755);
|
|
const token = { CLAUDE_CODE_OAUTH_TOKEN: "fixture" };
|
|
assert.equal(claude.preflight({ env: { MOSAIC_CLAUDE_BIN: bin } }).code, "no-session-credential");
|
|
assert.equal(claude.preflight({ env: { ...token, PATH: join(root, "none") } }).code, "harness-binary-missing");
|
|
assert.equal(claude.preflight({ env: { ...token, MOSAIC_CLAUDE_BIN: "claude" } }).code, "harness-binary-missing");
|
|
assert.equal(claude.preflight({ env: { ...token, MOSAIC_CLAUDE_BIN: bin } }), null);
|
|
assert.equal(claude.preflight({ env: { ...token, PATH: root } }), null);
|
|
assert.equal(claude.version({ env: { MOSAIC_CLAUDE_BIN: bin, PATH: process.env.PATH } }), "2.1.302");
|
|
// The pinned binary outside /usr is mounted where the adapter looks for it.
|
|
assert.deepEqual(claude.mounts({ env: { MOSAIC_CLAUDE_BIN: bin } }).map(m => [m.src, m.dest]), [[bin, bin]]);
|
|
assert.deepEqual(claude.mounts({ env: {} }), []);
|
|
});
|
|
|
|
// Row 68's claudeVersion precedence, moved here with the version (row 65): the manifest records
|
|
// what the binary sessions run answers.
|
|
test("row 65 claude-code version asks MOSAIC_CLAUDE_BIN when set, else claude on PATH, else null", async t => {
|
|
const claude = await load("claude-code");
|
|
const dir = scratch(t);
|
|
mkdirSync(join(dir, "bin"));
|
|
const stand = (p, v) => {
|
|
writeFileSync(p, `#!/bin/sh\necho "${v} (Claude Code)"\n`);
|
|
chmodSync(p, 0o755);
|
|
return p;
|
|
};
|
|
stand(join(dir, "bin", "claude"), "1.0.0");
|
|
const pinned = stand(join(dir, "pinned"), "9.9.9");
|
|
const PATH = join(dir, "bin");
|
|
assert.equal(claude.version({ env: { PATH, MOSAIC_CLAUDE_BIN: pinned } }), "9.9.9 (Claude Code)");
|
|
assert.equal(claude.version({ env: { PATH } }), "1.0.0 (Claude Code)");
|
|
assert.equal(claude.version({ env: { PATH: join(dir, "none") } }), null);
|
|
});
|
|
|
|
test("row 65 pi preflight passes with the repository's pi, and its version is the package pin", async () => {
|
|
const pi = await load("pi");
|
|
assert.equal(pi.preflight({ env: {} }), null);
|
|
const pkg = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8"));
|
|
assert.equal(pi.version({ env: {} }), pkg.dependencies?.["@earendil-works/pi-coding-agent"] ?? pkg.devDependencies?.["@earendil-works/pi-coding-agent"]);
|
|
assert.deepEqual(pi.mounts({ env: {} }).map(m => m.dest), [join(REPO, "node_modules")]);
|
|
});
|
|
|
|
test("row 65 env passes the thinking level to both adapters, and claude-code's bundle writes its hook and MCP files", async t => {
|
|
const pi = await load("pi"), claude = await load("claude-code");
|
|
const session = { provider: "fixture", model: "m", thinking: "high", toolSocket: "/run/tools.sock" };
|
|
const piEnv = pi.env({ policy: { tools: ["read"] }, tools: [{ name: "list_tasks" }], session });
|
|
assert.deepEqual([piEnv.MOSAIC_THINKING, piEnv.PI_MODEL, piEnv.MOSAIC_TOOLS], ["high", "m", "read,list_tasks"]);
|
|
const dir = scratch(t);
|
|
const written = [];
|
|
const write = (name, text) => { writeFileSync(join(dir, name), text); written.push(name); return [name, "0".repeat(64)]; };
|
|
const quote = path => { assert.ok(!path.includes("'")); return `'${path}'`; };
|
|
const out = claude.bundle({ dir, files: { policy: join(dir, "policy.json"), tools: join(dir, "tools.json") }, session, write, quote });
|
|
assert.deepEqual(written, ["settings.json", "mcp.json"]);
|
|
const settings = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8"));
|
|
assert.match(settings.hooks.PreToolUse[0].hooks[0].command, /^timeout -k 2 10 .* \|\| exit 2$/);
|
|
assert.equal(settings.hooks.PreToolUse[0].hooks[0].timeout, 20);
|
|
assert.equal(JSON.parse(readFileSync(join(dir, "mcp.json"), "utf8")).mcpServers.mosaic.args.at(-1), "/run/tools.sock");
|
|
const claudeEnv = claude.env({ files: out.files, policy: { tools: ["read"], typed: [] }, session });
|
|
assert.deepEqual([claudeEnv.MOSAIC_THINKING, claudeEnv.MOSAIC_CLAUDE_SETTINGS, claudeEnv.MOSAIC_CLAUDE_MCP_CONFIG], ["high", out.files.settings, out.files.mcp]);
|
|
});
|
|
|
|
// The level a route names reaches the harness binary as its flag: each module's env() feeds the
|
|
// real adapter, and a stand-in pi or claude records the argv it was given (dewey B3).
|
|
test("row 65 a route's thinking reaches pi as --thinking and claude as --effort, through each module's env and adapter", async t => {
|
|
const pi = await load("pi"), claude = await load("claude-code");
|
|
const dir = scratch(t);
|
|
mkdirSync(join(dir, "bin"));
|
|
for (const name of ["pi", "claude"]) {
|
|
writeFileSync(join(dir, "bin", name), '#!/bin/sh\nprintf "%s\\n" "$@" > "$ARGV_OUT"\n');
|
|
chmodSync(join(dir, "bin", name), 0o755);
|
|
}
|
|
for (const f of ["prompt.md", "settings.json", "mcp.json"]) writeFileSync(join(dir, f), "{}");
|
|
const run = (adapter, moduleEnv) => {
|
|
const out = join(dir, "argv");
|
|
rmSync(out, { force: true });
|
|
const r = spawnSync("/bin/sh", [join(REPO, "adapters", adapter, "adapter.sh")], {
|
|
encoding: "utf8",
|
|
env: {
|
|
PATH: `${join(dir, "bin")}:/usr/bin:/bin`, ARGV_OUT: out, MOSAIC_SYSTEM_PROMPT_FILE: join(dir, "prompt.md"),
|
|
MOSAIC_REQUEST: "x", MOSAIC_WORKSPACE: join(dir, "ws"), MOSAIC_SESSION_DIR: join(dir, "session"), MOSAIC_MODEL: "m",
|
|
...moduleEnv,
|
|
},
|
|
});
|
|
assert.equal(r.status, 0, r.stderr);
|
|
return readFileSync(out, "utf8").split("\n");
|
|
};
|
|
const flag = (argv, name) => {
|
|
assert.equal(argv.filter(a => a === name).length, 1, argv.join(" "));
|
|
return argv[argv.indexOf(name) + 1];
|
|
};
|
|
for (const thinking of ["xhigh", "low"]) {
|
|
const session = { provider: "fixture", model: "m", thinking, toolSocket: "/run/tools.sock" };
|
|
assert.equal(flag(run("pi", pi.env({ policy: { tools: ["read"] }, tools: [], session })), "--thinking"), thinking);
|
|
const files = { settings: join(dir, "settings.json"), mcp: join(dir, "mcp.json") };
|
|
assert.equal(flag(run("claude", claude.env({ files, policy: { tools: ["read"], typed: [] }, session })), "--effort"), thinking);
|
|
}
|
|
});
|
|
|
|
// A harness name in core: a quoted "pi" or "claude-code", or either inside a regex alternation.
|
|
const NAMED = /["'`](?:pi|claude-code)["'`]|[(|](?:pi|claude-code)[|)]/;
|
|
// The brief's Out of scope list: the tool-policy gate, the human-proof check, the engines that
|
|
// start pi directly, and the process scan. Each must still name a harness, so the list stays true.
|
|
const EXCEPT = [
|
|
"packages/harness/src/gate.mjs",
|
|
"packages/harness/src/claude-gate.mjs",
|
|
"packages/harness/src/pi-extension.mjs",
|
|
"packages/bus/src/human.mjs",
|
|
"packages/conversation/src/",
|
|
"packages/discord/src/",
|
|
"packages/control-board/src/scan.mjs",
|
|
];
|
|
function sources(dir, out = []) {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const path = join(dir, entry.name);
|
|
if (entry.isDirectory()) { if (entry.name !== "node_modules" && entry.name !== "third_party") sources(path, out); }
|
|
else if (/\.(?:mjs|js)$/.test(entry.name)) out.push(path);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
test("row 65 no core source outside packages/plugins names a harness, apart from the listed exceptions", () => {
|
|
const named = [];
|
|
const hit = new Set();
|
|
for (const pkg of readdirSync(join(REPO, "packages"))) {
|
|
if (pkg === "plugins") continue;
|
|
let files;
|
|
try { files = sources(join(REPO, "packages", pkg, "src")); } catch (e) { if (e.code === "ENOENT") continue; throw e; }
|
|
for (const file of files) {
|
|
const rel = relative(REPO, file);
|
|
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
|
|
if (!NAMED.test(line)) return;
|
|
const exception = EXCEPT.find(e => (e.endsWith("/") ? rel.startsWith(e) : rel === e));
|
|
if (exception) hit.add(exception);
|
|
else named.push(`${rel}:${i + 1}: ${line.trim()}`);
|
|
});
|
|
}
|
|
}
|
|
assert.deepEqual(named, []);
|
|
assert.deepEqual([...hit].sort(), [...EXCEPT].sort(), "every exception still names a harness");
|
|
// human.mjs keeps one: the human-proof regex that refuses any agent harness binary.
|
|
const human = readFileSync(join(REPO, "packages", "bus", "src", "human.mjs"), "utf8").split("\n").filter(line => NAMED.test(line));
|
|
assert.deepEqual(human.map(line => line.trim()), ["/^(pi|claude|claude-code|codex)(?:\\.js)?$/.test(command) ||"]);
|
|
});
|
|
|
|
test("row 65 the grep's pattern catches a quoted name and a regex alternation, and not a word in prose", () => {
|
|
for (const line of ['if (harness === "pi")', "x = 'claude-code';", "/^(pi|codex)$/", "/^(codex|claude-code)$/"]) assert.ok(NAMED.test(line), line);
|
|
for (const line of ["// pi runs the bundle", "const piModel = 1;", "pipeline"]) assert.ok(!NAMED.test(line), line);
|
|
});
|