Files
stack/packages/cli/tests/instances.test.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

155 lines
9.1 KiB
JavaScript

// Row 165 (#1662, Q171, REQ-ROLE-3): `mosaic business instances` and `mosaic business instance
// add|set` reach the broker's human-only verbs, which write through the business package's
// instance writer. The bus here is a real startBroker over a temporary organization directory.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { BusError } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
import { main, run } from "../src/cli.mjs";
import { RESTART_NEEDED } from "../src/instances.mjs";
import { fixture, io, tmp } from "./helpers.mjs";
const BUSINESS_CLI = fileURLToPath(new URL("../../business/src/cli.mjs", import.meta.url));
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
async function setup(t) {
const root = tmp(t, "mosaic-cli-instances-");
const f = fixture(root, "acme");
const dir = join(root, "config");
const bus = await startBroker({
dataRoot: f.dataRoot,
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
organization: { dir, rolesDir: f.roles, system: systemFor(root) },
});
t.after(() => bus.close());
const human = bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
const calls = [];
const deps = {
system: { dataRoot: f.dataRoot },
transport: (business) => async (verb, args = {}) => {
calls.push({ business, verb, args });
try {
return structuredClone(bus.broker.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 file = join(dir, "businesses", "acme.json");
const log = () => readFileSync(join(dir, "businesses", ".history", "acme", "changes.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
const validate = () => spawnSync(process.execPath, [BUSINESS_CLI, "validate", "acme"], {
env: { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: f.config, MOSAIC_ROLES_DIR: f.roles }, encoding: "utf8",
});
const mosaic = async (args, env = f.env) => {
const x = io(env);
const code = await run(["business", ...args], x, deps);
return { code, out: x.out.text, err: x.err.text };
};
const revision = async () => JSON.parse((await mosaic(["instances", "acme", "--json"])).out).revision;
return { root, f, file, log, validate, mosaic, revision, calls };
}
test("row 165 CLI: instance add puts an instance in the file, validate exits 0, and the log names the human", async (t) => {
const s = await setup(t);
const view = await s.mosaic(["instances", "acme"]);
assert.equal(view.code, 0, view.err);
assert.match(view.out, /^business acme, revision [a-f0-9]{64}$/m);
// Row 65: the cli fixture gives every instance its route vars, so the view lists them.
assert.match(view.out, /^ {2}chief: chief-of-staff \(launcher\) vars \{"harness":"pi","model":"glm-5\.3-flash","thinking":"high"\}$/m);
assert.match(view.out, /^definitions add and set accept: .*\bmerge-gate\b.*\bsecurity-reviewer\b/m);
assert.match(view.out, /^definitions that declare credentials, a hand edit: .*\breviewer \(gitea, vikunja\)/m);
const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--launch", "--revision", await s.revision()]);
assert.equal(added.code, 0, added.err);
const entry = s.log().at(-1);
const now = await s.revision();
assert.deepEqual(added.out.split("\n"), [
"business acme: added sec-1 (security-reviewer, launched)", `revision ${now}`, `transaction ${entry.transaction}`, RESTART_NEEDED, "",
]);
assert.equal(RESTART_NEEDED, "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it");
assert.doesNotMatch(RESTART_NEEDED, /Mos|Jason/, "product text names no seat (sage-0931)");
assert.doesNotMatch(added.out, /--restart|run `mosaic bus/, "the CLI names the restart and doesn't offer to run it");
const doc = JSON.parse(readFileSync(s.file, "utf8"));
assert.deepEqual(doc.roles["sec-1"], { definition: "security-reviewer" });
assert.ok(doc.launch.instances.includes("sec-1"));
assert.deepEqual([entry.actor, entry.state], ["jason", "committed"]);
const checked = s.validate();
assert.equal(checked.status, 0, checked.stderr);
});
test("row 165 CLI: instance set passes the vars file and --no-launch, and a vars-only change needs no restart", async (t) => {
const s = await setup(t);
const vars = writeJson(join(s.root, "vars.json"), { harness: "pi", model: "claude-sonnet-5-5" });
const changed = await s.mosaic(["instance", "set", "acme", "coder", "--vars", vars, "--no-launch", "--revision", await s.revision()]);
assert.equal(changed.code, 0, changed.err);
assert.deepEqual(s.calls.at(-1).args, { revision: s.calls.at(-1).args.revision, instance: "coder", op: "change", vars: { harness: "pi", model: "claude-sonnet-5-5" }, launch: false });
assert.match(changed.out, /^business acme: changed coder \(coder\)$/m);
assert.match(changed.out, /^no restart: the launcher reads the change at its next launch check$/m);
const doc = JSON.parse(readFileSync(s.file, "utf8"));
assert.equal(doc.launch.instances.includes("coder"), false);
assert.ok(doc.roles.coder.credentials.gitea && doc.roles.coder.tracker, "a vars change keeps the binding");
assert.equal(s.validate().status, 0);
});
test("row 165 CLI: a refusal names its code and why, exits 2 and keeps the bytes", async (t) => {
const s = await setup(t);
const rev = await s.revision();
const before = readFileSync(s.file, "utf8");
const cases = [
[["instance", "add", "acme", "reviewer-2", "--definition", "reviewer", "--revision", rev], /^mosaic: organization-credentials-out-of-scope: this command binds no credentials/],
[["instance", "set", "acme", "coder", "--definition", "security-reviewer", "--revision", rev], /^mosaic: organization-credentials-out-of-scope: /],
[["instance", "add", "acme", "coder", "--definition", "merge-gate", "--revision", rev], /^mosaic: organization-instance-exists: /],
[["instance", "set", "acme", "ghost", "--launch", "--revision", rev], /^mosaic: organization-unknown-instance: /],
[["instance", "add", "acme", "asst-2", "--definition", "assistant", "--revision", rev], /^mosaic: organization-definition-refused: this command neither adds nor changes an assistant, and moves no instance to the assistant definition;/],
[["instance", "add", "acme", "gate-1", "--definition", "merge-gate", "--revision", "b".repeat(64)], /^mosaic: revision-conflict: the business file changed/],
];
for (const [args, message] of cases) {
const r = await s.mosaic(args);
assert.equal(r.code, 2, args.join(" "));
assert.match(r.err, message);
}
assert.equal(readFileSync(s.file, "utf8"), before);
});
test("row 165 CLI: usage errors exit 4 before any bus call, and an agent session is refused", async (t) => {
const s = await setup(t);
const rev = "a".repeat(64);
for (const args of [
["instances"], ["instances", "acme", "extra"], ["instances", "acme", "--launch"],
["instance", "add", "acme", "sec-1", "--definition", "security-reviewer"],
["instance", "add", "acme", "sec-1", "--revision", rev],
["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--no-launch", "--revision", rev],
["instance", "set", "acme", "coder", "--revision", rev],
["instance", "set", "acme", "coder", "--launch", "--no-launch", "--revision", rev],
["instance", "set", "acme", "coder", "--vars", join(s.root, "missing.json"), "--revision", rev],
["instance", "remove", "acme", "coder", "--revision", rev],
["instance", "set", "acme", "coder", "--holder", "mos", "--revision", rev],
]) {
const r = await s.mosaic(args);
assert.equal(r.code, 4, args.join(" "));
}
assert.equal(s.calls.length, 0);
const agent = await s.mosaic(["instances", "acme"], { ...s.f.env, CODEX_THREAD_ID: "fixture-agent" });
assert.equal(agent.code, 3);
assert.match(agent.err, /agent/i);
assert.equal(s.calls.length, 0);
await assert.rejects(main(["business", "validate", "acme"], io(s.f.env), {}), /mosaic business instances/);
});
test("row 165: scripts/mosaic sends business instances and instance to packages/cli, and validate still to packages/business", (t) => {
const root = tmp(t, "mosaic-cli-route-");
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
const routed = spawnSync(MOSAIC, ["business", "instance"], { env, encoding: "utf8" });
assert.equal(routed.status, 4);
assert.match(routed.stderr, /mosaic business instances BUSINESS/);
const business = spawnSync(MOSAIC, ["business", "validate"], { env, encoding: "utf8" });
assert.doesNotMatch(business.stderr, /mosaic business instances BUSINESS/);
});