Files
stack/packages/cli/tests/stack-instances.test.mjs
T
jason.woltje 87db40e0cd fix(cli,seat,discord,organization,control-board,harness,plugins,webui): review round: launcher reasons, session, discord reads, mosaic-config and keys by label (#1873)
Review "changes 8" fix round for org-redact6.

- launcher: a harness module's throw names its system code or class,
  never its message; preflight, loader, bundle, checkout, sandbox and
  route text is relayed with host paths replaced (relay()).
- claude-code and pi preflights name MOSAIC_CLAUDE_BIN and the
  repository's node_modules, never a path; persona and skill bundle
  errors and sandboxProblem name their file or the sandbox by label.
- seat session: thread store, legacy store, overlap, busy lock and
  newRun refusals name the place under the data root.
- discord: unknown keys, binding names and turn ids are described when
  not plain; approvals, journal and repository read errors name the file
  and code; git drops stderr lines that carry a host path and a spawn
  failure names its code only.
- seat, control-board: registration fields and seen keys are described
  when not plain; invalid seat name and layout state the constraint.
- organization: the registry names the config directory and registry
  file by label; --project is quoted only when it is a plain name;
  systemConfigProblem moves to its own module.
- scripts/mosaic-config.mjs: the config by where it came from, the data
  root and its marker by role, keys and arguments only when plain, and
  error codes in place of raw messages. Exit codes unchanged.
- webui sso-config: an unknown console key is named only when plain.

Sentinel tests in each package's path-redaction-labels file fail on
BASE; pins that quoted a path now assert the label and its absence.
2026-11-11 08:53:31 -06:00

488 lines
26 KiB
JavaScript

// Row 190 (#1688): several stack instances on one host. The config and bootstrap cases (T-I1, T-I2,
// the config part of T-I7), two real bus hosts side by side (T-I5) and the guard (T-I10). Design and
// T table: agents/darkwing/work/queue-190/DESIGN.md D7. The unit, port and release cases are T56 in
// stack-release.test.mjs. HOME is a scratch directory in every test, so ~/.config/mosaic-* and
// ~/.mosaic-* resolve inside it; nothing here reads or writes ~/.mosaic-dev or ~/.config/mosaic-dev.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { once } from "node:events";
import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { LiveSessionGuard } from "../../conversation/src/guard.mjs";
import { reserveOrg, activateOrg } from "../../organization/src/org-registry.mjs";
import { hostStatus } from "../src/host.mjs";
import { businessDoc, rolesCopy, writeJson } from "../../organization/tests/helpers.mjs";
import { REPO_SKILLS } from "../../harness/tests/helpers.mjs";
import { humanEnv, notifyConfig, tmp } from "./helpers.mjs";
const SCRIPTS = fileURLToPath(new URL("../../../scripts/", import.meta.url));
const CONFIG = join(SCRIPTS, "mosaic-config.mjs");
const BOOTSTRAP = join(SCRIPTS, "bootstrap.sh");
const BUS_SERVICE = join(SCRIPTS, "bus-service.sh");
const WEBUI_SERVICE = join(SCRIPTS, "webui-service.sh");
const STACK_COPY = join(SCRIPTS, "stack-copy.sh");
const MOSAIC = join(SCRIPTS, "mosaic");
// The environment for a scratch home: no MOSAIC_CONFIG unless a test sets one.
function homeEnv(home, extra = {}) {
const env = humanEnv({ HOME: home, ...extra });
if (!("MOSAIC_CONFIG" in extra)) delete env.MOSAIC_CONFIG;
delete env.XDG_CONFIG_HOME;
return env;
}
function run(cmd, args, env) {
const r = spawnSync(cmd, args, { env, encoding: "utf8", timeout: 30000 });
return { code: r.status, out: r.stdout, err: r.stderr };
}
// The child runs under an explicit umask, so the mode assertions hold under any gate umask (row 243,
// sage-1918). mosaic-config.mjs creates with 0644 and lets the umask narrow it.
const underUmask = (mask, home, args) => run("/bin/bash", ["-c", `umask ${mask}; exec "$@"`, "bash", BOOTSTRAP, ...args], homeEnv(home));
const bootstrap = (home, ...args) => underUmask("022", home, args);
const config = (home, extra, ...args) => run(process.execPath, [CONFIG, ...args], homeEnv(home, extra));
const cfgOf = (home, name) => join(home, ".config", `mosaic-${name}`, "config.json");
// The instance op's exports as an object.
function exportsOf(out) {
return Object.fromEntries(out.trim().split("\n").map((line) => {
const m = line.match(/^(\w+)='(.*)'$/);
assert.ok(m, `not a shell-safe export: ${line}`);
return [m[1], m[2]];
}));
}
// Every entry under `dir`: relative path, type, mode, size, mtime and content hash.
function snapshot(dir) {
const out = {};
const walk = (d) => {
for (const name of readdirSync(d).sort()) {
const p = join(d, name);
const s = lstatSync(p);
const key = relative(dir, p);
if (s.isDirectory()) {
out[key] = { dir: true, mode: s.mode, mtime: s.mtimeMs };
walk(p);
} else {
out[key] = { mode: s.mode, size: s.size, mtime: s.mtimeMs, sha: createHash("sha256").update(readFileSync(p)).digest("hex") };
}
}
};
if (existsSync(dir)) walk(dir);
return out;
}
const mode = (p) => statSync(p).mode & 0o777;
test("T-I1 bootstrap --instance creates each instance's config, root and marker once, and refuses what it must not claim", async (t) => {
await t.test("alpha and beta get a 0700 config dir, a config naming ~/.mosaic-NAME, a 0700 root and a 0644 marker", (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 0, r.err);
const cfg = cfgOf(home, name), root = join(home, `.mosaic-${name}`);
assert.equal(r.err, `mosaic-config: created configuration for instance ${name}: ${cfg}, data root ${root}\n`);
assert.equal(mode(join(home, ".config", `mosaic-${name}`)), 0o700);
assert.equal(mode(cfg), 0o644);
assert.equal(JSON.parse(readFileSync(cfg, "utf8")).dataRoot, root);
assert.equal(mode(root), 0o700);
const marker = join(root, "instance.json");
assert.equal(readFileSync(marker, "utf8"), `{"instanceVersion":1,"name":"${name}"}\n`);
assert.equal(mode(marker), 0o644);
assert.deepEqual(readdirSync(root), ["instance.json"]);
assert.deepEqual(readdirSync(join(home, ".config", `mosaic-${name}`)), ["config.json"]);
}
assert.equal(existsSync(join(home, ".config", "mosaic-dev")), false);
assert.equal(existsSync(join(home, ".mosaic-dev")), false);
});
await t.test("under umask 077 the config and marker are 0600: the source honors the operator's umask", (t) => {
const home = tmp(t, "si-");
const r = underUmask("077", home, ["--instance", "alpha"]);
assert.equal(r.code, 0, r.err);
assert.equal(mode(join(home, ".config", "mosaic-alpha")), 0o700);
assert.equal(mode(cfgOf(home, "alpha")), 0o600);
assert.equal(mode(join(home, ".mosaic-alpha")), 0o700);
assert.equal(mode(join(home, ".mosaic-alpha", "instance.json")), 0o600);
});
await t.test("a second bootstrap of each changes no byte, mode or mtime", (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const before = snapshot(home);
for (const name of ["alpha", "beta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 0, r.err);
assert.equal(r.err, `mosaic-config: configuration already present, validated without changes: ${cfgOf(home, name)}\n`);
}
assert.deepEqual(snapshot(home), before);
});
await t.test("a non-empty root without its own marker refuses before anything is written, worktrees included", (t) => {
const home = tmp(t, "si-");
mkdirSync(join(home, ".mosaic-gamma"));
writeFileSync(join(home, ".mosaic-gamma", "notes.txt"), "someone's data\n");
mkdirSync(join(home, ".mosaic-worktrees", "row190"), { recursive: true });
mkdirSync(join(home, ".mosaic-delta"));
writeFileSync(join(home, ".mosaic-delta", "instance.json"), `{"instanceVersion":1,"name":"alpha"}\n`);
writeFileSync(join(home, ".mosaic-delta", "other"), "x");
const before = snapshot(home);
for (const name of ["gamma", "worktrees", "delta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 3, r.err);
// #1873: the data root by its role, never its path.
assert.match(r.err, new RegExp(`instance-root-unclaimed: the instance's data root exists, is not empty and holds no instance.json naming ${name}; nothing was written`));
assert.ok(!r.err.includes(home), r.err);
assert.equal(existsSync(join(home, ".config", `mosaic-${name}`)), false);
}
assert.deepEqual(snapshot(home), before);
// A root that is a file refuses the same way.
writeFileSync(join(home, ".mosaic-file"), "x");
const r = bootstrap(home, "--instance", "file");
assert.equal(r.code, 3);
assert.match(r.err, /instance-root-unclaimed: .* exists and is not a directory; nothing was written/);
assert.equal(existsSync(join(home, ".config", "mosaic-file")), false);
});
await t.test("an empty existing root is claimed, and an existing config whose root lost its marker refuses, not repairs", (t) => {
const home = tmp(t, "si-");
mkdirSync(join(home, ".mosaic-alpha"), { mode: 0o700 });
assert.equal(bootstrap(home, "--instance", "alpha").code, 0);
assert.equal(readFileSync(join(home, ".mosaic-alpha", "instance.json"), "utf8"), `{"instanceVersion":1,"name":"alpha"}\n`);
rmSync(join(home, ".mosaic-alpha", "instance.json"));
const before = snapshot(home);
const r = bootstrap(home, "--instance", "alpha");
assert.equal(r.code, 3);
assert.match(r.err, /instance-root-unclaimed: the data root holds no instance\.json naming alpha/);
assert.ok(!r.err.includes(home), r.err);
assert.deepEqual(snapshot(home), before);
});
await t.test("a dangling symlink at the config path is never written through, named or default", (t) => {
const home = tmp(t, "si-");
for (const [name, args] of [["alpha", ["--instance", "alpha"]], ["dev", []]]) {
const dir = join(home, ".config", `mosaic-${name}`), target = join(home, `elsewhere-${name}.json`);
mkdirSync(dir, { recursive: true, mode: 0o700 });
symlinkSync(target, join(dir, "config.json"));
const r = bootstrap(home, ...args);
assert.equal(r.code, 2, `${name}: ${r.err}`);
assert.match(r.err, /configuration path must be a regular, non-symbolic-link file/);
assert.equal(existsSync(target), false, `${name}: the link's target was created`);
assert.deepEqual(readdirSync(dir), ["config.json"]);
assert.ok(lstatSync(join(dir, "config.json")).isSymbolicLink());
}
assert.deepEqual(readdirSync(home).sort(), [".config"]);
});
await t.test("bad names and stray arguments exit 4 and write nothing", (t) => {
const home = tmp(t, "si-");
for (const name of ["A", "a-b", "x", "a", "abcdefghijklmnopq", "../x", "1ab", "a.b", ""]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 4, `${name}: ${r.err}`);
assert.match(r.err, /instance-name-invalid/);
}
assert.equal(bootstrap(home, "--instance", "abcdefghijklmnop").code, 0, "16 characters pass");
rmSync(join(home, ".config"), { recursive: true });
rmSync(join(home, ".mosaic-abcdefghijklmnop"), { recursive: true });
for (const args of [["--instance"], ["--instance", "alpha", "extra"], ["extra"], ["--name", "alpha"]]) {
const r = bootstrap(home, ...args);
assert.equal(r.code, 4, `${args.join(" ")}: ${r.err}`);
assert.match(r.err, /usage: mosaic-config\.mjs bootstrap \[--instance NAME\]/);
}
assert.deepEqual(readdirSync(home), []);
});
await t.test("--instance dev writes exactly what no flag writes", (t) => {
const plain = tmp(t, "si-"), dev = tmp(t, "si-");
const a = bootstrap(plain), b = bootstrap(dev, "--instance", "dev");
assert.equal(a.code, 0, a.err);
assert.equal(b.code, 0, b.err);
assert.equal(b.err.replaceAll(dev, "HOME"), a.err.replaceAll(plain, "HOME"));
const shape = (home) => Object.fromEntries(Object.entries(snapshot(home)).map(([k, v]) => [k, { ...v, mtime: 0, sha: v.sha && createHash("sha256").update(readFileSync(join(home, k), "utf8").replaceAll(home, "HOME")).digest("hex") }]));
assert.deepEqual(shape(dev), shape(plain));
assert.deepEqual(Object.keys(snapshot(plain)), [".config", ".config/mosaic-dev", ".config/mosaic-dev/config.json"]);
});
});
test("T-I2 selection: the four rows of D1 through the instance op and through an entry point", async (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const scratch = join(home, "scratch-config.json");
writeJson(scratch, { configVersion: 1, environment: "development", dataRoot: join(home, "scratch-data"), execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash" } });
await t.test("row 1: no flag and no MOSAIC_CONFIG is the default; a missing default config leaves the data root empty", () => {
const r = config(home, {}, "instance");
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: "dev",
MOSAIC_INSTANCE_CONFIG: cfgOf(home, "dev"),
MOSAIC_INSTANCE_CONFIG_DIR: join(home, ".config", "mosaic-dev"),
MOSAIC_INSTANCE_DATA_ROOT: "",
MOSAIC_UNIT_SUFFIX: "",
});
assert.deepEqual(exportsOf(config(home, {}, "instance", "--instance", "dev").out), exportsOf(r.out));
});
await t.test("row 2: MOSAIC_CONFIG alone is that config with the default names", () => {
const r = config(home, { MOSAIC_CONFIG: scratch }, "instance");
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: "dev",
MOSAIC_INSTANCE_CONFIG: scratch,
MOSAIC_INSTANCE_CONFIG_DIR: home,
MOSAIC_INSTANCE_DATA_ROOT: join(home, "scratch-data"),
MOSAIC_UNIT_SUFFIX: "",
});
});
await t.test("row 3: --instance NAME alone is NAME's config, root and suffix", () => {
for (const name of ["alpha", "beta"]) {
const r = config(home, {}, "instance", "--instance", name);
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: name,
MOSAIC_INSTANCE_CONFIG: cfgOf(home, name),
MOSAIC_INSTANCE_CONFIG_DIR: join(home, ".config", `mosaic-${name}`),
MOSAIC_INSTANCE_DATA_ROOT: join(home, `.mosaic-${name}`),
MOSAIC_UNIT_SUFFIX: `-${name}`,
});
}
});
await t.test("row 4: both must name one file; a symlink to it agrees, any other file exits 4", () => {
const link = join(home, "alpha-link.json");
symlinkSync(cfgOf(home, "alpha"), link);
for (const given of [cfgOf(home, "alpha"), link]) {
const r = config(home, { MOSAIC_CONFIG: given }, "instance", "--instance", "alpha");
assert.equal(r.code, 0, r.err);
assert.equal(exportsOf(r.out).MOSAIC_INSTANCE_CONFIG, cfgOf(home, "alpha"));
}
for (const given of [cfgOf(home, "beta"), scratch, join(home, "missing.json")]) {
for (const op of ["instance", "validate", "env", "bootstrap"]) {
const r = config(home, { MOSAIC_CONFIG: given }, op, "--instance", "alpha");
assert.equal(r.code, 4, `${op} ${given}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, /instance-config-mismatch: --instance alpha names its own config, but MOSAIC_CONFIG names another file/);
assert.ok(!r.err.includes(home) && !r.err.includes(given), r.err);
}
}
});
await t.test("a named instance with no config exits 3 with its own bootstrap hint", () => {
const r = config(home, {}, "instance", "--instance", "gamma");
assert.equal(r.code, 3);
// #1873: the refusal names the config by its instance, never by host path.
assert.match(r.err, /configuration not found: the config of instance gamma \(run scripts\/bootstrap\.sh --instance gamma to create it\)/);
assert.ok(!r.err.includes(cfgOf(home, "gamma")) && !r.err.includes(home), r.err);
});
await t.test("an entry point takes --instance in any position, once, and refuses a mismatch before it prints", () => {
const first = run(BUS_SERVICE, ["--instance", "alpha", "render"], homeEnv(home));
assert.equal(first.code, 0, first.err);
assert.match(first.out, new RegExp(`^Environment=MOSAIC_CONFIG=${cfgOf(home, "alpha")}$`, "m"));
assert.equal(run(BUS_SERVICE, ["render", "--instance", "alpha"], homeEnv(home)).out, first.out);
assert.equal(run(BUS_SERVICE, ["render", "--instance", "alpha"], homeEnv(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") })).out, first.out);
const mismatch = run(BUS_SERVICE, ["--instance", "alpha", "render"], homeEnv(home, { MOSAIC_CONFIG: cfgOf(home, "beta") }));
assert.equal(mismatch.code, 4);
assert.equal(mismatch.out, "");
assert.match(mismatch.err, /instance-config-mismatch/);
for (const args of [["--instance", "alpha", "render", "--instance", "alpha"], ["render", "--instance"]]) {
const r = run(BUS_SERVICE, args, homeEnv(home));
assert.equal(r.code, 4, args.join(" "));
assert.equal(r.out, "");
assert.match(r.err, /instance: usage: --instance NAME, given once/);
}
const bad = run(BUS_SERVICE, ["--instance", "a-b", "render"], homeEnv(home));
assert.equal(bad.code, 4);
assert.match(bad.err, /instance-name-invalid/);
});
await t.test("copy takes no --instance in either script: every instance shares the copies", () => {
const copies = join(home, "copies-probe");
const before = snapshot(home);
for (const [cmd, args, err] of [
[STACK_COPY, ["--instance", "alpha", "copy", "HEAD", "--copies", copies], /^usage: stack-copy\.sh copy COMMIT/],
[STACK_COPY, ["copy", "HEAD", "--copies", copies, "--instance", "alpha"], /^usage: stack-copy\.sh copy COMMIT/],
[WEBUI_SERVICE, ["--instance", "alpha", "copy", "HEAD", "--copies", copies], /^webui-service: copy takes no --instance; every instance shares the copies\n$/],
[WEBUI_SERVICE, ["copy", "HEAD", "--copies", copies, "--instance", "alpha"], /^webui-service: copy takes no --instance; every instance shares the copies\n$/],
]) {
const r = run(cmd, args, homeEnv(home));
assert.equal(r.code, 4, `${args.join(" ")}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, err, args.join(" "));
}
assert.deepEqual(snapshot(home), before);
});
});
test("T-I7 claims: the instance op refuses a root another instance claims, and a named root with no marker", async (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const edit = (name, dataRoot) => writeJson(cfgOf(home, name), { ...JSON.parse(readFileSync(cfgOf(home, name), "utf8")), dataRoot });
const scratch = join(home, "scratch-config.json");
const scratchRoot = join(home, "scratch-data");
writeJson(scratch, { configVersion: 1, environment: "development", dataRoot: scratchRoot, execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash" } });
await t.test("alpha's config pointing at beta's root refuses instance-root-mismatch in every op", () => {
edit("alpha", join(home, ".mosaic-beta"));
for (const op of ["instance", "validate", "env", "bootstrap"]) {
const r = config(home, {}, op, "--instance", "alpha");
assert.equal(r.code, 3, `${op}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, /instance-root-mismatch: the data root is claimed by instance beta, not alpha/);
assert.ok(!r.err.includes(home), r.err);
}
edit("alpha", join(home, ".mosaic-alpha"));
assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0);
});
await t.test("MOSAIC_CONFIG naming alpha's config with no flag refuses: the root is alpha's", () => {
for (const op of ["instance"]) {
const r = config(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") }, op);
assert.equal(r.code, 3, r.err);
assert.match(r.err, /instance-root-mismatch: the data root is claimed by instance alpha; select it with --instance alpha/);
assert.ok(!r.err.includes(home), r.err);
}
});
await t.test("a default config whose root carries alpha's marker refuses; one naming dev, or none, passes", () => {
mkdirSync(scratchRoot, { recursive: true });
assert.equal(config(home, { MOSAIC_CONFIG: scratch }, "instance").code, 0, "no marker passes as today");
writeFileSync(join(scratchRoot, "instance.json"), `{"instanceVersion":1,"name":"alpha"}\n`);
const r = config(home, { MOSAIC_CONFIG: scratch }, "instance");
assert.equal(r.code, 3);
assert.match(r.err, /claimed by instance alpha; select it with --instance alpha/);
writeFileSync(join(scratchRoot, "instance.json"), `{"instanceVersion":1,"name":"dev"}\n`);
assert.equal(config(home, { MOSAIC_CONFIG: scratch }, "instance").code, 0);
rmSync(join(scratchRoot, "instance.json"));
});
await t.test("a named root whose marker is missing, a symlink, unparsable or the wrong shape refuses instance-root-unclaimed", () => {
const marker = join(home, ".mosaic-alpha", "instance.json");
const good = readFileSync(marker, "utf8");
const cases = [
["missing", () => {}, /holds no instance\.json naming alpha/],
["a symlink", () => { writeFileSync(join(home, "real-marker.json"), good); symlinkSync(join(home, "real-marker.json"), marker); }, /must be a regular, non-symbolic-link file/],
["a directory", () => mkdirSync(marker), /must be a regular, non-symbolic-link file/],
["unparsable", () => writeFileSync(marker, "{"), /is not a valid marker/],
["an extra key", () => writeFileSync(marker, `{"instanceVersion":1,"name":"alpha","x":1}\n`), /is not a valid marker/],
["version 2", () => writeFileSync(marker, `{"instanceVersion":2,"name":"alpha"}\n`), /is not a valid marker/],
["a bad name", () => writeFileSync(marker, `{"instanceVersion":1,"name":"Alpha"}\n`), /is not a valid marker/],
];
for (const [name, change, why] of cases) {
rmSync(marker, { recursive: true, force: true });
change();
const r = config(home, {}, "instance", "--instance", "alpha");
assert.equal(r.code, 3, `${name}: ${r.err}`);
assert.match(r.err, /instance-root-unclaimed/, name);
assert.match(r.err, why, name);
rmSync(marker, { recursive: true, force: true });
writeFileSync(marker, good);
}
assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0);
});
});
async function until(check, ms) {
const end = Date.now() + ms;
while (!check()) {
if (Date.now() > end) throw new Error("timed out");
await new Promise((r) => setTimeout(r, 50));
}
}
// One named instance in `home`, ready for `bus start acme --no-launch`: bootstrap, a business file in
// its config dir, its own roles copy, the skills its roles name, and a notifier with no binding.
async function hostInstance(home, root, name) {
assert.equal(bootstrap(home, "--instance", name).code, 0);
const cfg = cfgOf(home, name), dataRoot = join(home, `.mosaic-${name}`);
const files = join(root, `${name}-files`);
const roles = rolesCopy(files);
const doc = businessDoc(files, "acme");
for (const r of Object.values(doc.roles)) r.vars = { harness: "pi", model: "glm-5.3-flash", thinking: "high", ...r.vars };
writeJson(join(home, ".config", `mosaic-${name}`, "businesses", "acme.json"), { ...doc, plugins: { ...doc.plugins, harness: ["pi", "claude-code"] } });
cpSync(REPO_SKILLS, join(dataRoot, "skills-enabled"), { recursive: true });
const ctx = { configDir: join(home, ".config", `mosaic-${name}`), dataRoot, instance: name };
const org = await reserveOrg(ctx, "acme"); await activateOrg(ctx, "acme");
notifyConfig(org.root, "acme", null);
return { name, cfg, dataRoot, orgRoot: org.root, env: homeEnv(home, { MOSAIC_CONFIG: cfg, MOSAIC_ROLES_DIR: roles }) };
}
test("T-I5 two real bus hosts, one per instance, run at once and each answers from its own socket under its own root", async (t) => {
const root = tmp(t, "si-");
const home = join(root, "h");
mkdirSync(home);
const both = await Promise.all(["alpha", "beta"].map((name) => hostInstance(home, root, name)));
for (const inst of both) {
const sock = join(inst.orgRoot, "bus", "broker.sock");
assert.ok(Buffer.byteLength(sock) <= 107, `socket path is ${Buffer.byteLength(sock)} bytes: ${sock}`);
inst.sock = sock;
inst.child = spawn(MOSAIC, ["bus", "start", "acme", "--no-launch"], { env: inst.env, stdio: ["ignore", "pipe", "pipe"] });
t.after(() => { run(MOSAIC, ["bus", "stop", "acme"], inst.env); if (inst.child.exitCode === null) inst.child.kill("SIGTERM"); });
inst.out = "";
inst.child.stdout.on("data", (b) => (inst.out += b));
inst.child.stderr.on("data", (b) => (inst.out += b));
}
for (const inst of both) await until(() => inst.out.includes('"org":"acme"') || inst.child.exitCode !== null, 30000);
for (const inst of both) {
const receipt = JSON.parse(inst.out);
assert.equal(receipt.org, "acme"); assert.equal(receipt.socket, inst.sock);
inst.hostPid = receipt.pid;
assert.ok(lstatSync(inst.sock).isSocket());
const r = run(MOSAIC, ["bus", "status", "acme", "--json"], inst.env);
assert.equal(r.code, 0, r.err);
const status = JSON.parse(r.out);
assert.equal(status.org, "acme"); assert.equal(status.instance, inst.name);
assert.equal(status.pid, inst.hostPid); assert.equal(status.healthy, true);
const state = hostStatus(inst.orgRoot);
assert.equal(state.writerLock.live, true); inst.lockPid = state.writerLock.pid;
}
assert.notEqual(both[0].hostPid, both[1].hostPid);
assert.notEqual(both[0].lockPid, both[1].lockPid);
const [alpha, beta] = both;
const exitA = once(alpha.child, "exit");
const stopA = run(MOSAIC, ["bus", "stop", "acme"], alpha.env);
assert.equal(stopA.code, 0, stopA.err); assert.equal(JSON.parse(stopA.out).stopped, true);
assert.equal((await exitA)[0], 0, alpha.out);
assert.equal(beta.child.exitCode, null);
const still = JSON.parse(run(MOSAIC, ["bus", "status", "acme", "--json"], beta.env).out);
assert.equal(still.pid, beta.hostPid); assert.equal(still.healthy, true);
assert.equal(hostStatus(beta.orgRoot).writerLock.pid, beta.lockPid);
assert.equal(existsSync(join(alpha.orgRoot, "bus", "writer.lock")), false);
const exitB = once(beta.child, "exit");
assert.equal(run(MOSAIC, ["bus", "stop", "acme"], beta.env).code, 0);
assert.equal((await exitB)[0], 0, beta.out);
});
test("T-I10 the guard protects every instance's config dir and data roots, even under a corrupt config", (t) => {
const root = tmp(t, "si-");
const home = join(root, "h");
mkdirSync(home);
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
// alpha's root moved by its operator; beta's config is broken.
const moved = join(root, "alpha-data");
mkdirSync(moved);
writeJson(cfgOf(home, "alpha"), { ...JSON.parse(readFileSync(cfgOf(home, "alpha"), "utf8")), dataRoot: moved });
writeFileSync(cfgOf(home, "beta"), "{ not json");
for (const dir of ["mosaic.bak", "mosaic", "mosaic-a-b", "mosaic-Alpha", "mosaic-x"]) mkdirSync(join(home, ".config", dir));
const guard = new LiveSessionGuard({ fixtureRoot: root, homes: [home] });
const refuses = [
[join(home, ".config", "mosaic-alpha", "x"), "stack instance alpha's config dir"],
[join(home, ".mosaic-alpha", "x"), "stack instance alpha's default data root"],
[join(moved, "sessions", "x"), "stack instance alpha's configured data root"],
[join(home, ".config", "mosaic-beta", "x"), "stack instance beta's config dir"],
[join(home, ".mosaic-beta", "x"), "stack instance beta's default data root"],
[join(home, ".config"), "stack instance alpha's config dir"],
];
for (const [p, why] of refuses) {
assert.throws(() => guard.check({ workspace: p }, "bind"), (e) => e.message === `workspace overlaps ${why} (bind)`, p);
}
for (const dir of ["mosaic.bak", "mosaic", "mosaic-a-b", "mosaic-Alpha", "mosaic-x"]) {
assert.equal(guard.check({ workspace: join(home, ".config", dir, "x") }, "bind"), true, dir);
}
assert.equal(guard.check({ workspace: join(root, "work") }, "bind"), true);
});