Files
stack/packages/cli/tests/helpers.mjs
T
jason.woltje c216f47dbb plugins, cli, bus, business, webui: harness, model and thinking from the instance vars, a fallback route, and a harness plug-in kind (row 65, #1558)
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.
2026-10-19 11:27:19 -05:00

147 lines
6.4 KiB
JavaScript

// Shared fixtures. Every test works in its own temporary directory and
// points MOSAIC_CONFIG there; nothing reads the real ~/.config, a real
// token, a real binding, or the real human transport.
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { pathToFileURL } from "node:url";
import { BusError, Broker } from "../../bus/src/broker.mjs";
import { Store } from "../../bus/src/store.mjs";
import { businessDoc, rolesCopy, systemConfig, writeJson } from "../../business/tests/helpers.mjs";
import { tree } from "../../plugins/tests/helpers.mjs";
import { AGENT_MARKERS } from "../src/transport.mjs";
export function tmp(t, prefix = "mosaic-cli-") {
const root = mkdtempSync(join(tmpdir(), prefix));
t.after(() => rmSync(root, { recursive: true, force: true }));
return root;
}
// The environment with every agent marker removed: the test runner itself
// may run under an agent, and the commands refuse there.
export function humanEnv(extra = {}) {
const env = { ...process.env, ...extra };
for (const k of AGENT_MARKERS) delete env[k];
delete env.NODE_TEST_CONTEXT;
return env;
}
// A config directory with the system config, the roles and business `id`. Row 65: both harnesses
// are switched on, since a launch on one that isn't refuses harness-off, and every instance names a
// harness, model and thinking level, as the live file does, since a launch missing one refuses
// no-route. The instance's own vars win, and edit can change any of it.
export function fixture(root, id = "acme", edit = (doc) => doc) {
const config = systemConfig(root);
const roles = rolesCopy(root);
const base = businessDoc(root, id);
for (const r of Object.values(base.roles)) r.vars = { harness: "pi", model: "glm-5.3-flash", thinking: "high", ...r.vars };
const doc = edit({ ...base, plugins: { ...base.plugins, harness: ["pi", "claude-code"] } });
writeJson(join(root, "config", "businesses", `${id}.json`), doc);
return { config, roles, dataRoot: join(root, "data"), doc, env: humanEnv({ MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: roles }) };
}
export function notifyConfig(dataRoot, business, binding) {
// The journal sits beside notify.json and refuses a directory looser than 0700.
mkdirSync(join(dataRoot, "notify", business), { recursive: true, mode: 0o700 });
return writeJson(join(dataRoot, "notify", business, "notify.json"), { notifyVersion: 1, binding });
}
// An io for main(): captured stdout and stderr, a stdin that is not a TTY.
export function io(env = humanEnv()) {
const out = { text: "" };
const err = { text: "" };
const stdin = new PassThrough();
stdin.isTTY = false;
return {
env,
stdin,
stdout: { write: (s) => ((out.text += s), true) },
stderr: { write: (s) => ((err.text += s), true) },
out,
err,
};
}
export const OPTIONS = [
{ key: "yes", text: "Allow" },
{ key: "no", text: "Decline" },
];
export const BUSINESSES = {
demo: {
id: "demo",
human: "jason",
plugins: { harness: ["pi"] },
arbiters: { technical: "cto", delivery: "pm" },
roles: {
pm: { authority: { withinRole: ["message.send"], crossRole: [] } },
cto: { authority: { withinRole: ["message.send"], crossRole: [] } },
coder: { authority: { withinRole: ["message.send"], crossRole: ["task.scope.change"] } },
},
},
};
// An in-process broker with a claimed coder and the human's capability.
// transport(business) mirrors humanTransport: errors carry the bus code.
export function broker(t, businesses = BUSINESSES) {
const root = tmp(t, "mosaic-cli-bus-");
const store = new Store(root);
t.after(() => store.close());
const b = new Broker({ store, businesses });
const coder = b.bindLaunch({ business: "demo", role: "coder", run: "coder-run", harness: "pi", address: "coder-run" });
b.request(coder, { verb: "role.claim" });
const human = b.bindHuman({ business: "demo", human: "jason", via: "cli", outsideAgent: true });
const reader = b.bindReader({ business: "demo" });
const calls = [];
const transport = (business) => async (verb, args = {}) => {
calls.push({ business, verb, args });
try {
return structuredClone(b.request(human, { verb, args }));
} catch (e) {
if (!(e instanceof BusError)) throw e;
const error = new Error(e.code);
error.code = e.code;
throw error;
}
};
const raise = (action, extra = {}) =>
b.request(coder, { verb: "decision.raise", args: { action, question: "Push the release?", options: OPTIONS, recommendation: "no", blocking: false, ...extra } });
return { b, store, coder, human, reader, transport, calls, raise, read: (verb, args = {}) => structuredClone(b.request(reader, { verb, args })) };
}
// A script standing in for packages/bus/src/human-cli.mjs: it reads one
// request on stdin and answers from the table in its first argument.
export function fakeCli(root, body) {
const file = join(root, "fake-human-cli.mjs");
writeFileSync(file, body);
chmodSync(file, 0o600);
return file;
}
// Row 65: a launcher's makeLoader over a scratch plug-in tree. adapters maps a harness module to the
// adapter script a test runs in its place, or to null for the real module unchanged. A faked module
// re-exports the real one with only adapter() replaced; a faked claude-code also passes a missing
// claude binary, since the fake adapter never runs one (packages/plugins/tests/harness.test.mjs
// covers that check). more(write) adds fixture modules, write(kind, name, body).
export async function fakeHarnesses(t, adapters, more = () => {}) {
const { write, loaderUrl } = tree(t);
for (const [name, adapter] of Object.entries(adapters)) {
const real = JSON.stringify(pathToFileURL(new URL(`../../plugins/harness/${name}/index.mjs`, import.meta.url).pathname).href);
if (adapter === null) {
write("harness", name, `export * from ${real};\n`);
continue;
}
const lines = [`export * from ${real};`, `export const adapter = () => ${JSON.stringify(adapter)};`];
if (name === "claude-code") {
lines.push(`import { preflight as real } from ${real};`,
"export function preflight(input) { const p = real(input); return p?.code === \"harness-binary-missing\" ? null : p; }");
}
write("harness", name, lines.join("\n") + "\n");
}
more(write);
const { createLoader } = await import(loaderUrl);
return createLoader;
}