Files
stack/packages/cli/tests/rename-b-commands.test.mjs
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

329 lines
20 KiB
JavaScript

// Rename design r2, stage B2: the command map. `mosaic org config validate|resolve` is the primary
// spelling of `mosaic business validate|resolve`, and `mosaic org instances|instance|veto` of
// `mosaic business instances|instance|veto`, with the same arguments. Both spellings call one
// implementation: the installed-config service (packages/organization/src/config-command.mjs) and the
// proof-bearing instances command (src/instances.mjs). These golden comparisons pin status, stdout,
// stderr and authorization outcomes across the wrapper, the direct entry points and in-process run().
// The old spelling stays byte for byte; the new one differs only where the design lets help read
// organization first: the stderr prefix of the config commands and the usage text.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, readFileSync, rmSync } 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 { businessDoc, rolesCopy, systemConfig, writeJson } from "../../organization/tests/helpers.mjs";
import { projectFilePath } from "../../organization/src/index.mjs";
import { canonicalJson, sha256 } from "../../organization/src/util.mjs";
import { templateRender } from "../../organization/src/org-service.mjs";
import { BUSINESS_CONFIG, ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs";
import * as oldConfigCommand from "../../business/src/config-command.mjs";
import { run, USAGE } from "../src/cli.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { INSTANCES_USAGE, ORG_INSTANCES_USAGE } from "../src/instances.mjs";
import { ORG_USAGE } from "../src/org.mjs";
import { fixture, io, tmp } from "./helpers.mjs";
const path = (p) => fileURLToPath(new URL(p, import.meta.url));
const MOSAIC = path("../../../scripts/mosaic");
const CLI = path("../src/cli.mjs");
const ORG_CLI = path("../../organization/src/cli.mjs");
const OLD_CLI = path("../../business/src/cli.mjs");
const CONFIG_COMMAND = path("../../organization/src/config-command.mjs");
const golden = (name) => readFileSync(path(`../../business/tests/fixtures/rename-a/${name}`), "utf8");
// The old spelling's stderr as the new spelling prints it: only a usage line changes. Every other
// line, refusals and warnings included, is byte for byte the same.
const OLD_USAGE_LINE = `mosaic business: ${BUSINESS_CONFIG.usage}`;
const asOrgConfig = (stderr) => stderr.replaceAll(OLD_USAGE_LINE, `mosaic org config: ${ORG_CONFIG.usage}`);
const spawn = (command, args, env, cwd) => {
const p = spawnSync(command, args, { env, cwd, encoding: "utf8" });
assert.equal(p.error, undefined);
return { status: p.status, stdout: p.stdout, stderr: p.stderr };
};
// An installed organization acme with its stack project file, as the stage A contract test builds it.
function installed(t, edit = () => {}) {
const root = tmp(t, "rb-");
const config = systemConfig(root), doc = businessDoc(root);
writeJson(join(root, "config", "businesses", "acme.json"), doc);
writeJson(projectFilePath(doc.projects.stack.root), { projectVersion: 1, id: "stack", vars: { "tracker.project": 3 } });
const env = { PATH: process.env.PATH, HOME: join(root, "home"), TMPDIR: process.env.TMPDIR, LANG: "C.UTF-8", MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) };
edit({ root, doc, env });
return { root, doc, env };
}
// Every entry point for one argument list. Old: the wrapper, the old executable path and the moved
// executable. New: the wrapper, the direct packages/cli executable and in-process run().
async function entries(args, env, cwd) {
const x = io(env);
const status = await run(["org", "config", ...args], x, {});
return {
old: [spawn(MOSAIC, ["business", ...args], env, cwd), spawn(process.execPath, [OLD_CLI, ...args], env, cwd), spawn(process.execPath, [ORG_CLI, ...args], env, cwd)],
now: [spawn(MOSAIC, ["org", "config", ...args], env, cwd), spawn(process.execPath, [CLI, "org", "config", ...args], env, cwd), { status, stdout: x.out.text, stderr: x.err.text }],
};
}
async function sameAcrossSpellings(args, env, cwd, status) {
const { old, now } = await entries(args, env, cwd);
const label = args.join(" ") || "(no arguments)";
for (const r of old.slice(1)) assert.deepEqual(r, old[0], `old entries agree: ${label}`);
for (const r of now.slice(1)) assert.deepEqual(r, now[0], `new entries agree: ${label}`);
assert.equal(old[0].status, status, `${label}: ${old[0].stderr}`);
assert.deepEqual(now[0], { status: old[0].status, stdout: old[0].stdout, stderr: asOrgConfig(old[0].stderr) }, label);
if (status !== 0) assert.equal(now[0].stdout, "", label);
return { old: old[0], now: now[0] };
}
test("rename r2 B2: org config validate|resolve has the status, stdout and stderr of business validate|resolve at every entry point", async (t) => {
const { root, env } = installed(t);
const cases = [
[["validate", "acme"], 0],
[["resolve", "acme", "coder", "--project", "stack"], 0],
[["resolve", "acme", "chief"], 0],
[["validate", "missing"], 4],
[["resolve", "acme", "unknown"], 2],
[["resolve", "acme", "coder", "--project", "nope"], 2],
[[], 4],
[["bogus"], 4],
[["validate"], 4],
[["validate", "acme", "extra"], 4],
[["resolve", "acme"], 4],
[["resolve", "acme", "coder", "--project"], 4],
[["resolve", "acme", "coder", "--project", "stack", "--project", "stack"], 4],
];
for (const [args, status] of cases) {
const { old, now } = await sameAcrossSpellings(args, env, root, status);
if (status === 4 && args[1] !== "missing") {
assert.equal(old.stderr, `mosaic business: ${BUSINESS_CONFIG.usage}\n`);
assert.equal(now.stderr, `mosaic org config: ${ORG_CONFIG.usage}\n`);
}
if (status === 0) assert.equal(old.stderr, "");
}
// The old usage text is byte for byte what `mosaic business` printed before stage B.
assert.equal(BUSINESS_CONFIG.usage, "usage: mosaic business validate <business> | resolve <business> <instance> [--project <id>]");
});
test("rename r2 B2: a credential problem, an expiry warning and a system config problem read the same in both spellings", async (t) => {
const bad = installed(t, ({ root }) => chmodSync(join(root, "secrets", "coder-gitea.token"), 0o644));
for (const args of [["validate", "acme"], ["resolve", "acme", "coder"]]) {
const { old, now } = await sameAcrossSpellings(args, bad.env, bad.root, 2);
assert.equal(now.stderr, old.stderr, "a refusal is byte for byte the same, unnormalized");
assert.match(old.stderr, /^mosaic business: (?:roles\.coder\.credentials\.gitea|coder gitea) file: mode 644/m);
assert.match(old.stderr, /credential reference problem\(s\)\n$/);
}
const soon = new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10);
const warned = installed(t, ({ root, doc }) => {
doc.roles.coder.credentials.vikunja.expires = soon;
writeJson(join(root, "config", "businesses", "acme.json"), doc);
});
const { old, now } = await sameAcrossSpellings(["resolve", "acme", "coder"], warned.env, warned.root, 0);
assert.equal(now.stderr, old.stderr, "a warning is byte for byte the same, unnormalized");
assert.match(old.stderr, new RegExp(`^mosaic business: warning: .*expires on ${soon}\\n$`, "m"));
const missing = installed(t, ({ root, env }) => { env.MOSAIC_CONFIG = join(root, "nowhere", "config.json"); });
const sys = await sameAcrossSpellings(["validate", "acme"], missing.env, missing.root, 3);
assert.match(sys.old.stderr, /^mosaic business: system config problem \(mosaic-config exit 3\): /);
assert.equal(sys.now.stderr, sys.old.stderr, "a system config problem is byte for byte the same, unnormalized");
});
test("rename r2 B2: org config prints the stage A golden v1 bytes, preimage and digest", async (t) => {
const { root, env } = installed(t);
const invoke = (args) => {
const r = spawn(MOSAIC, ["org", "config", ...args], env, root);
assert.deepEqual([r.status, r.stderr], [0, ""], args.join(" "));
return r.stdout;
};
const validate = invoke(["validate", "acme"]);
const resolved = invoke(["resolve", "acme", "coder", "--project", "stack"]);
const { digest, ...preimage } = JSON.parse(resolved);
assert.equal(sha256(canonicalJson(preimage)), digest);
for (const [id, expected] of Object.entries(JSON.parse(validate).instances)) {
const { digest: actual, ...record } = JSON.parse(invoke(["resolve", "acme", id]));
assert.equal(sha256(canonicalJson(record)), actual, id);
assert.equal(actual, expected, id);
}
const normalized = (text) => text.split(root).join("<ROOT>").replace(/[a-f0-9]{64}/g, "<DIGEST>");
const preimageText = `${normalized(canonicalJson(preimage))}\n`;
assert.equal(sha256(preimageText.slice(0, -1)), "0a12b35f55d033b91bd89745859028d252be52b05ca4083964b778a27ec20fd6");
assert.equal(normalized(validate), golden("installed-validate.json"));
assert.equal(normalized(resolved), golden("installed-resolve.json"));
assert.equal(preimageText, golden("installed-preimage.json"));
assert.equal(JSON.parse(validate).business, "acme", "the v1 key stays business");
});
test("rename r2 B2: a draft file named like an installed ID: org validate reads the file, org config validate the installed organization", async (t) => {
const { root, env } = installed(t);
const cwd = join(root, "drafts");
writeJson(join(cwd, "acme"), await templateRender("acme", { fixture: "local" }));
const draft = spawn(MOSAIC, ["org", "validate", "acme"], env, cwd);
assert.deepEqual(draft, { status: 0, stdout: "valid\n", stderr: "" });
const config = spawn(MOSAIC, ["org", "config", "validate", "acme"], env, cwd);
assert.equal(config.status, 0, config.stderr);
assert.equal(JSON.parse(config.stdout).file, join(root, "config", "businesses", "acme.json"));
assert.deepEqual(config, spawn(MOSAIC, ["business", "validate", "acme"], env, cwd));
// Each side reads only its own: no draft, and org validate fails though acme is installed;
// no installed acme, and org config validate fails though the draft is in the working directory.
rmSync(join(cwd, "acme"));
const noDraft = spawn(MOSAIC, ["org", "validate", "acme"], env, cwd);
assert.equal(noDraft.status, 2);
assert.equal(noDraft.stderr, "mosaic: cannot read organization document: ENOENT\n");
writeJson(join(cwd, "acme"), await templateRender("acme", { fixture: "local" }));
rmSync(join(root, "config", "businesses", "acme.json"));
const noInstalled = spawn(MOSAIC, ["org", "config", "validate", "acme"], env, cwd);
assert.equal(noInstalled.status, 4);
assert.equal(noInstalled.stdout, "");
assert.match(noInstalled.stderr, /^mosaic business: .*acme/);
assert.equal(spawn(MOSAIC, ["org", "validate", "acme"], env, cwd).stdout, "valid\n");
});
// A real broker over an installed organization, as instances.test.mjs builds it. Both spellings run
// through run() with the same deps, so the proof-bearing transport and its calls are the same.
async function bus(t) {
const root = tmp(t, "rbi-");
const f = fixture(root, "acme");
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
t.after(() => b.close());
const human = b.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(b.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 log = () => readFileSync(join(root, "config", "businesses", ".history", "acme", "changes.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
const mosaic = async (words, env = f.env) => {
const x = io(env), before = calls.length;
const code = await run(words, x, deps);
return { code, out: x.out.text, err: x.err.text, calls: calls.slice(before) };
};
const revision = async () => JSON.parse((await mosaic(["business", "instances", "acme", "--json"])).out).revision;
return { root, f, mosaic, revision, log };
}
test("rename r2 B2: org instances|instance|veto reads, refuses and writes exactly as business instances|instance|veto", async (t) => {
const s = await bus(t);
for (const args of [["instances", "acme"], ["instances", "acme", "--json"]]) {
const old = await s.mosaic(["business", ...args]), now = await s.mosaic(["org", ...args]);
assert.equal(old.code, 0, old.err);
assert.deepEqual(now, old, args.join(" "));
assert.deepEqual(now.calls, [{ business: "acme", verb: "instances", args: {} }]);
}
// A refusal from the bus: the same code, reason and exit.
for (const args of [
["instance", "add", "acme", "coder", "--definition", "coder", "--revision", await s.revision()],
["veto", "acme", "keep", "coder", "--revision", "0".repeat(64)],
["instance", "set", "acme", "nobody", "--no-launch", "--revision", await s.revision()],
]) {
const old = await s.mosaic(["business", ...args]), now = await s.mosaic(["org", ...args]);
assert.equal(old.code, 2, args.join(" "));
assert.match(old.err, /^mosaic: [a-z-]+: /);
assert.deepEqual(now, old, args.join(" "));
}
// An agent marker refuses before any call, in both spellings.
for (const marker of ["MOSAIC_RUN_ID", "CODEX_THREAD_ID"]) {
const env = { ...s.f.env, [marker]: "fixture-agent" };
const old = await s.mosaic(["business", "instances", "acme"], env), now = await s.mosaic(["org", "instances", "acme"], env);
assert.equal(old.code, 3);
assert.match(old.err, /outside any agent run/);
assert.deepEqual(now, old);
assert.deepEqual(now.calls, []);
}
// A write through the org spelling: the same lines, a log entry naming the human, and the old
// spelling reads it back.
const kept = await s.mosaic(["org", "veto", "acme", "keep", "coder", "--revision", await s.revision()]);
assert.equal(kept.code, 0, kept.err);
const entry = s.log().at(-1);
assert.deepEqual(kept.out.split("\n"), ["business acme: veto keep coder", `revision ${await s.revision()}`, `transaction ${entry.transaction}`,
"no restart: the launcher reads the change at its next launch check", ""]);
assert.equal(entry.actor, "jason");
assert.deepEqual(kept.calls.map((c) => c.verb), ["veto.set"]);
const vars = writeJson(join(s.root, "vars.json"), { harness: "pi", model: "claude-sonnet-5-5" });
const changed = await s.mosaic(["org", "instance", "set", "acme", "coder", "--vars", vars, "--no-launch", "--revision", await s.revision()]);
assert.equal(changed.code, 0, changed.err);
assert.match(changed.out, /^business acme: changed coder \(coder\)$/m);
assert.deepEqual(changed.calls.at(-1).args, { revision: changed.calls.at(-1).args.revision, instance: "coder", op: "change", vars: { harness: "pi", model: "claude-sonnet-5-5" }, launch: false });
const json = JSON.parse((await s.mosaic(["business", "instances", "acme", "--json"])).out);
assert.equal(json.instances.coder.kept, true);
// The same error refusal the old spelling gives after the write: a kept instance refuses delete.
const delOld = await s.mosaic(["business", "instance", "delete", "acme", "coder", "--revision", await s.revision()]);
const delNew = await s.mosaic(["org", "instance", "delete", "acme", "coder", "--revision", await s.revision()]);
assert.equal(delOld.code, 2);
assert.deepEqual(delNew, delOld);
});
test("rename r2 B2: org instances never reaches the dev-owner context, and bad arguments print the org usage", async (t) => {
const s = await bus(t);
// This fixture is not a named instance, so the dev-owner context refuses here; org instances runs.
const show = await s.mosaic(["org", "show", "acme"]);
assert.equal(show.code, 2);
assert.match(show.err, /--dev-local-owner requires a named instance/);
const view = await s.mosaic(["org", "instances", "acme"]);
assert.equal(view.code, 0, view.err);
assert.doesNotMatch(view.err, /dev-local-owner/);
for (const args of [["instance"], ["instances"], ["veto", "acme"], ["instances", "acme", "--bogus"], ["instance", "add", "acme", "x"]]) {
const old = await s.mosaic(["business", ...args]), now = await s.mosaic(["org", ...args]);
assert.deepEqual(old, { code: 4, out: "", err: `mosaic: ${INSTANCES_USAGE}\n`, calls: [] }, args.join(" "));
assert.deepEqual(now, { code: 4, out: "", err: `mosaic: ${ORG_INSTANCES_USAGE}\n`, calls: [] }, args.join(" "));
}
assert.equal(ORG_INSTANCES_USAGE, INSTANCES_USAGE.replace(/^mosaic business /, "mosaic org ").replaceAll(" | business ", " | org ").replaceAll("BUSINESS", "ORG"));
assert.doesNotMatch(ORG_INSTANCES_USAGE, /business|BUSINESS/);
});
test("rename r2 B2: scripts/mosaic routes org instances|instance|veto to the proof-bearing command, not the dev-owner one", (t) => {
const root = tmp(t, "rbw-");
const env = { PATH: process.env.PATH, HOME: join(root, "home"), LANG: "C.UTF-8", MOSAIC_CONFIG: join(root, "none.json") };
for (const sub of ["instance", "instances", "veto"]) {
const old = spawn(MOSAIC, ["business", sub], env, root), now = spawn(MOSAIC, ["org", sub], env, root);
assert.deepEqual(old, { status: 4, stdout: "", stderr: `mosaic: ${INSTANCES_USAGE}\n` }, sub);
assert.deepEqual(now, { status: 4, stdout: "", stderr: `mosaic: ${ORG_INSTANCES_USAGE}\n` }, sub);
}
// A real read with an agent marker refuses with the human-shell check in both spellings.
const agent = { ...env, MOSAIC_AGENT_RUN: "1" };
const old = spawn(MOSAIC, ["business", "instances", "acme"], agent, root), now = spawn(MOSAIC, ["org", "instances", "acme"], agent, root);
assert.equal(old.status, 3);
assert.match(old.stderr, /outside any agent run \(found MOSAIC_AGENT_RUN/);
assert.deepEqual(now, old);
// Without a marker and without a config, both fail the same way, and neither names the dev owner.
const plainOld = spawn(MOSAIC, ["business", "instances", "acme"], env, root), plainNew = spawn(MOSAIC, ["org", "instances", "acme"], env, root);
assert.notEqual(plainOld.status, 0);
assert.deepEqual(plainNew, plainOld);
assert.doesNotMatch(plainNew.stderr, /dev-local-owner/);
});
test("rename r2 B2: importing the config service runs nothing and never exits; it returns the code", async (t) => {
const p = spawnSync(process.execPath, ["--input-type=module", "-e", `const m = await import(${JSON.stringify(CONFIG_COMMAND)}); process.stdout.write(Object.keys(m).sort().join(",") + "\\n");`, "validate", "acme"], { encoding: "utf8", env: { PATH: process.env.PATH, LANG: "C.UTF-8" } });
assert.deepEqual([p.status, p.stdout, p.stderr], [0, "BUSINESS_CONFIG,ORG_CONFIG,runConfigCommand\n", ""]);
assert.equal(oldConfigCommand.runConfigCommand, runConfigCommand);
const root = tmp(t, "rbc-");
const out = { text: "" }, err = { text: "" };
const streams = { stdout: { write: (s) => { out.text += s; return true; } }, stderr: { write: (s) => { err.text += s; return true; } } };
const env = { PATH: process.env.PATH, HOME: root, MOSAIC_CONFIG: join(root, "none.json") };
assert.equal(runConfigCommand(["validate", "acme"], { ...streams, env, spelling: ORG_CONFIG }), 3);
assert.equal(runConfigCommand([], { ...streams, env }), 4);
assert.equal(out.text, "");
assert.match(err.text, /^mosaic business: system config problem /);
assert.match(err.text, /\nmosaic business: usage: mosaic business validate <business> \| resolve /);
});
test("rename r2 B2: help lists the org spellings first and names the business spelling as the old one", () => {
assert.match(ORG_USAGE, / \| validate FILE \| /, "org validate FILE stays the draft check");
assert.match(ORG_USAGE, / \| config validate ID \| config resolve ID INSTANCE \[--project ID\]$/);
assert.ok(USAGE.includes(` ${ORG_USAGE}\n`));
assert.ok(USAGE.includes(` ${ORG_INSTANCES_USAGE}\n`));
assert.ok(!USAGE.includes(INSTANCES_USAGE));
assert.match(USAGE, /^ {2}mosaic business validate\|resolve\|instances\|instance\|veto: the old spelling of mosaic org config validate\|resolve and mosaic org instances\|instance\|veto, same arguments$/m);
});