Files
stack/packages/cli/tests/helpers.mjs
T
jason.woltje 9f00397110 roles, harness, cli: a managed session gets its role's skills as prompt text on both harnesses (row 64, #1557)
Author and gate owner: Sage (mos-0855).

Role files take an optional skills list. The bundle reads each named skill
from <dataRoot>/skills-enabled, copies it into the bundle and renders every
file into prompt.md between skill-file markers, so the prompt text matches
the bundle copy byte for byte. The role's skills together are capped at
SKILL_BYTES (64 KiB): the claude adapter passes prompt.md as one argument,
and Linux caps one argument at 128 KiB. Symlinks are refused, not followed.
The launcher checks the list before a run exists: skill-not-enabled names
the activate command, anything else refuses skill-refused.

Pi keeps --no-skills; Claude Code keeps --disable-slash-commands and
--restricted. The two harness modules and MOSAIC_SKILLS are unchanged.
First lists: coder, reviewer, cto and pm, ms-unslop on each.
2026-10-19 16:11:36 -05:00

152 lines
6.8 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, cpSync, 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 { REPO_SKILLS } from "../../harness/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. Row 64: the data root's
// skills-enabled holds the repository's skills, since a launch whose role names one that isn't
// there refuses skill-not-enabled. The fixture makes the data root (0700) to hold them.
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);
mkdirSync(join(root, "data"), { recursive: true, mode: 0o700 });
cpSync(REPO_SKILLS, join(root, "data", "skills-enabled"), { recursive: true });
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;
}