process.test, proof-cli.test and sso.test set the too-open mode with chmod instead of taking it from the umask. stack-instances.test bootstraps under an explicit umask and asserts the 0700 config dir under umask 077. Candidate morgana/row2436581f750e(code137bda3af), Webby APPROVE rev 2939.
483 lines
26 KiB
JavaScript
483 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 { businessDoc, rolesCopy, writeJson } from "../../business/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);
|
|
assert.match(r.err, new RegExp(`instance-root-unclaimed: ${join(home, `.mosaic-${name}`)} exists, is not empty and holds no instance.json naming ${name}; nothing was written`));
|
|
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: .*\.mosaic-alpha holds no instance\.json naming alpha/);
|
|
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, new RegExp(`instance-config-mismatch: --instance alpha names ${cfgOf(home, "alpha")}, but MOSAIC_CONFIG names ${given}`));
|
|
}
|
|
}
|
|
});
|
|
|
|
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);
|
|
assert.match(r.err, /configuration not found: .*mosaic-gamma\/config\.json \(run scripts\/bootstrap\.sh --instance gamma to create it\)/);
|
|
});
|
|
|
|
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, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-beta")} is claimed by instance beta, not alpha`));
|
|
}
|
|
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, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-alpha")} is claimed by instance alpha; select it with --instance alpha`));
|
|
}
|
|
});
|
|
|
|
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.
|
|
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 });
|
|
notifyConfig(dataRoot, "acme", null);
|
|
return { name, cfg, dataRoot, 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 = ["alpha", "beta"].map((name) => hostInstance(home, root, name));
|
|
for (const inst of both) {
|
|
const sock = join(inst.dataRoot, "bus", "broker.sock");
|
|
// The kernel's limit for a Unix socket path is 107 bytes (the row 177 lesson).
|
|
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(() => inst.child.exitCode === null && inst.child.kill("SIGKILL"));
|
|
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(() => /bus host up: business acme.*\n/.test(inst.out) || inst.child.exitCode !== null, 30000);
|
|
for (const inst of both) {
|
|
assert.match(inst.out, new RegExp(`bus host up: business acme, socket ${inst.sock}, notifier off, launches disabled\n`), inst.out);
|
|
assert.ok(lstatSync(inst.sock).isSocket());
|
|
}
|
|
for (const inst of both) {
|
|
const r = run(MOSAIC, ["bus", "status", "--json"], inst.env);
|
|
assert.equal(r.code, 0, r.err);
|
|
const s = JSON.parse(r.out);
|
|
assert.equal(s.host.business, "acme");
|
|
assert.equal(s.host.pid, inst.child.pid);
|
|
assert.equal(s.host.live, true);
|
|
assert.equal(s.socket, true);
|
|
// The broker process under the host holds the writer lock.
|
|
assert.equal(s.writerLock.live, true);
|
|
inst.lockPid = s.writerLock.pid;
|
|
}
|
|
assert.notEqual(both[0].child.pid, both[1].child.pid);
|
|
assert.notEqual(both[0].lockPid, both[1].lockPid);
|
|
// Stopping alpha leaves beta up.
|
|
const [alpha, beta] = both;
|
|
const exitA = once(alpha.child, "exit");
|
|
const stopA = run(MOSAIC, ["bus", "stop"], alpha.env);
|
|
assert.equal(stopA.code, 0, stopA.err);
|
|
assert.match(stopA.out, new RegExp(`stopped bus host for acme \\(pid ${alpha.child.pid}\\)`));
|
|
assert.equal((await exitA)[0], 0, alpha.out);
|
|
assert.equal(beta.child.exitCode, null);
|
|
const still = JSON.parse(run(MOSAIC, ["bus", "status", "--json"], beta.env).out);
|
|
assert.equal(still.host.pid, beta.child.pid);
|
|
assert.equal(still.writerLock.pid, beta.lockPid);
|
|
assert.equal(still.writerLock.live, true);
|
|
assert.equal(existsSync(join(alpha.dataRoot, "bus", "writer.lock")), false);
|
|
const exitB = once(beta.child, "exit");
|
|
assert.equal(run(MOSAIC, ["bus", "stop"], 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);
|
|
});
|