Files
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

283 lines
17 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { chmodSync, mkdirSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { validateBinding, loadBinding, readToken, checkPrivateFile, resolveContextFiles, resolveToolRoots, reloadDiff, FIXED_KEYS } from "../src/binding.mjs";
import { homedir } from "node:os";
import { DiscordError } from "../src/errors.mjs";
import { makeRoot, makeRepo, makeDeployment, rawBinding } from "./helpers.mjs";
import { initializeControl } from '../../processing-control/src/state.mjs';
const cli = join(import.meta.dirname, "..", "src", "cli.mjs");
function refuses(raw, re) {
assert.throws(() => validateBinding(raw), (err) => err instanceof DiscordError && err.exitCode === 2 && re.test(err.message), `expected refusal matching ${re}`);
}
test("binding: a complete binding validates and is frozen", () => {
const b = validateBinding(rawBinding());
assert.equal(b.name, "test-seat");
assert.equal(b.channels.length, 2);
assert.ok(Object.isFrozen(b) && Object.isFrozen(b.channels));
});
test("binding: unknown key, missing field, wrong type refuse with exit 2", () => {
refuses({ ...rawBinding(), extra: 1 }, /unknown key "extra"/);
refuses({ ...rawBinding(), channels: [{ id: "100000000000000010", name: "x", mode: "open", colour: "red" }] }, /unknown key "colour"/);
const missing = rawBinding();
delete missing.guildId;
refuses(missing, /guildId must be a non-empty string/);
refuses(rawBinding({ guildId: 123 }), /guildId/);
refuses(rawBinding({ guildId: "abc" }), /not a Discord snowflake/);
refuses(rawBinding({ bindingVersion: 2 }), /bindingVersion must be 1/);
refuses(rawBinding({ tokenFile: "relative/path" }), /absolute path/);
refuses(rawBinding({ engine: { provider: "zai", model: "m", thinking: "loud" } }), /thinking must be one of/);
refuses(rawBinding({ limits: { turnsPerDay: -1 } }), /turnsPerDay/);
refuses(rawBinding({ limits: { replyChunkChars: 2001 } }), /replyChunkChars/);
refuses(rawBinding({ channels: [{ id: "100000000000000010", name: "x", mode: "loud" }] }), /mode must be one of/);
});
test("binding: empty allowlists refuse", () => {
refuses(rawBinding({ channels: [] }), /channels must be a non-empty array/);
refuses(rawBinding({ users: [] }), /users must be a non-empty array/);
refuses(rawBinding({ context: { files: [] } }), /files must be a non-empty array/);
refuses(rawBinding({ users: [{ id: "100000000000000002", name: "bot" }] }), /bot cannot be an authorized user/);
});
test("binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel", () => {
const owner = { id: "100000000000000100", name: "owner" };
const guest = { id: "100000000000000101", name: "guest", channels: ["100000000000000011"] };
const b = validateBinding(rawBinding({ users: [owner, guest] }));
assert.equal(b.users[0].channels, null);
assert.deepEqual([...b.users[1].channels], ["100000000000000011"]);
assert.ok(Object.isFrozen(b.users[1].channels));
refuses(rawBinding({ users: [{ ...guest, channels: [] }] }), /channels must be a non-empty array/);
refuses(rawBinding({ users: [{ ...guest, channels: "100000000000000011" }] }), /channels must be a non-empty array/);
refuses(rawBinding({ users: [{ ...guest, channels: ["100000000000000012"] }] }), /not a listed channel/);
refuses(rawBinding({ users: [{ ...guest, channels: ["nope"] }] }), /not a Discord snowflake/);
refuses(rawBinding({ users: [{ ...guest, channels: ["100000000000000011", "100000000000000011"] }] }), /duplicate channel id/);
});
test("reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2", () => {
const cur = validateBinding(rawBinding());
const next = validateBinding(rawBinding({
guildName: "Renamed",
channels: [
{ id: "100000000000000010", name: "seat-admin", mode: "mention" },
{ id: "100000000000000012", name: "other", mode: "open" },
],
users: [{ id: "100000000000000100", name: "owner" }, { id: "100000000000000101", name: "guest" }],
limits: { turnsPerDay: 5, turnTimeoutSeconds: 180, replyChunkChars: 1900, inboundMaxChars: 4000 },
}));
const d = reloadDiff(cur, next);
assert.deepEqual(d.channels, { added: ["100000000000000012"], removed: ["100000000000000011"], changed: ["100000000000000010"] });
assert.deepEqual(d.users, { added: ["100000000000000101"], removed: [], changed: [] });
assert.deepEqual(d.limits, ["turnsPerDay"]);
assert.equal(d.guildName, true);
const same = reloadDiff(cur, validateBinding(rawBinding()));
assert.deepEqual([same.channels.added, same.users.added, same.limits, same.guildName], [[], [], [], false]);
const fixed = {
name: "other-seat", seat: "other", guildId: "100000000000000009", botUserId: "100000000000000003",
tokenFile: "/nonexistent/other", engine: { provider: "zai", model: "glm-5.3", thinking: "low" },
context: { files: ["contracts/STANDARDS.md"] }, dmRecipient: "100000000000000100",
};
for (const [k, v] of Object.entries(fixed)) {
assert.throws(() => reloadDiff(cur, validateBinding(rawBinding({ [k]: v }))),
(err) => err instanceof DiscordError && err.exitCode === 2 && err.message.includes(`${k} cannot change while running`), k);
}
});
test("binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key", () => {
assert.equal(validateBinding(rawBinding()).dmRecipient, null);
assert.equal(validateBinding(rawBinding({ dmRecipient: "100000000000000100" })).dmRecipient, "100000000000000100");
refuses(rawBinding({ dmRecipient: "100000000000000101" }), /dmRecipient must be one of the listed users/);
refuses(rawBinding({ dmRecipient: "owner" }), /dmRecipient is not a Discord snowflake/);
refuses(rawBinding({ dmRecipient: 100000000000000100 }), /dmRecipient must be a non-empty string/);
refuses(rawBinding({ dmRecipient: "100000000000000002" }), /dmRecipient must be one of the listed users/);
assert.ok(FIXED_KEYS.includes("dmRecipient"));
// A reload that drops the recipient from users fails validation before reloadDiff runs.
refuses(rawBinding({ dmRecipient: "100000000000000100", users: [{ id: "100000000000000101", name: "guest" }] }), /dmRecipient must be one of the listed users/);
});
test("binding: file must be 0600, regular, not a symlink", () => {
const root = makeRoot();
const dep = makeDeployment(root);
assert.equal(loadBinding(dep.bindingFile).name, "test-seat");
chmodSync(dep.bindingFile, 0o644);
assert.throws(() => loadBinding(dep.bindingFile), /must be mode 0600, is 0644/);
chmodSync(dep.bindingFile, 0o600);
const link = join(root, "link.json");
symlinkSync(dep.bindingFile, link);
assert.throws(() => loadBinding(link), /must not be a symlink/);
writeFileSync(dep.bindingFile, "{not json", { mode: 0o600 });
assert.throws(() => loadBinding(dep.bindingFile), /not valid JSON/);
});
test("binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors", () => {
const root = makeRoot();
const dep = makeDeployment(root, {}, { tokenMode: 0o644 });
const b = loadBinding(dep.bindingFile);
assert.throws(() => readToken(b), (err) => err.exitCode === 2 && /token file must be mode 0600, is 0644/.test(err.message) && !err.message.includes("MTAw"));
chmodSync(dep.tokenFile, 0o600);
assert.equal(readToken(b), "MTAw.abcdefghijklmnopqrstuvwxyz0123456789");
writeFileSync(dep.tokenFile, "", { mode: 0o600 });
assert.throws(() => readToken(b), /token file is empty/);
writeFileSync(dep.tokenFile, "short\n", { mode: 0o600 });
assert.throws(() => readToken(b), /does not hold a bot token/);
unlinkSync(dep.tokenFile);
symlinkSync(dep.bindingFile, dep.tokenFile);
assert.throws(() => readToken(b), /must not be a symlink/);
assert.throws(() => checkPrivateFile(join(root, "missing"), "thing"), /thing not found/);
});
function runCli(args, env = {}) {
return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", env: { ...process.env, ...env } });
}
test("cli: check refuses a non-0600 token file with exit 2 before any network use", () => {
const root = makeRoot();
const repo = makeRepo(root);
const dep = makeDeployment(root, {}, { tokenMode: 0o644 });
const r = runCli(["check", "test-seat", "--config", dep.config, "--repo", repo]);
assert.equal(r.status, 2, r.stderr);
assert.match(r.stderr, /token file must be mode 0600/);
assert.ok(!r.stderr.includes("MTAw") && !r.stdout.includes("MTAw"));
});
test("context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve", () => {
const root = makeRoot();
const repo = makeRepo(root);
writeFileSync(join(root, "outside.txt"), "family names and a pet\n");
mkdirSync(join(repo, "agents"), { recursive: true });
symlinkSync(join(root, "outside.txt"), join(repo, "agents", "link.md"));
symlinkSync(join(root), join(repo, "agents", "escape"));
const refusesFiles = (files, re) => assert.throws(
() => resolveContextFiles(validateBinding(rawBinding({ context: { files } })), repo),
(err) => err instanceof DiscordError && err.exitCode === 2 && re.test(err.message),
`expected refusal matching ${re} for ${JSON.stringify(files)}`,
);
refusesFiles([join(root, "outside.txt")], /repository-relative/);
refusesFiles(["../outside.txt"], /escape/);
refusesFiles(["contracts/../../outside.txt"], /escape/);
refusesFiles(["agents/link.md"], /symlink/);
refusesFiles(["agents/escape/outside.txt"], /outside the repository/);
refusesFiles(["contracts/NOPE.md"], /missing context file/);
const ok = resolveContextFiles(validateBinding(rawBinding({ context: { files: ["contracts/CONSTITUTION.md"] } })), repo);
assert.equal(ok.length, 1);
assert.ok(ok[0].endsWith("/contracts/CONSTITUTION.md"));
});
test("cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4", () => {
const root = makeRoot();
const repo = makeRepo(root);
const dep = makeDeployment(root, { context: { files: ["contracts/NOPE.md"] } });
let r = runCli(["check", "test-seat", "--config", dep.config, "--repo", repo]);
assert.equal(r.status, 2, r.stderr);
assert.match(r.stderr, /missing context file/);
r = runCli(["check", "other-seat", "--config", dep.config, "--repo", repo]);
assert.equal(r.status, 2);
assert.match(r.stderr, /binding other-seat not found/);
assert.ok(!r.stderr.includes(root), r.stderr);
r = runCli(["check"]);
assert.equal(r.status, 4);
r = runCli(["frobnicate", "x"]);
assert.equal(r.status, 4);
r = runCli(["check", "bad name", "--config", dep.config, "--repo", repo]);
assert.equal(r.status, 4);
});
test("cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4", () => {
const root = makeRoot();
const dep = makeDeployment(root);
assert.equal(runCli(["reload"]).status, 4);
const r1 = runCli(["reload", "test-seat", "--config", dep.config]);
assert.equal(r1.status, 1, r1.stderr);
assert.match(r1.stderr, /no running connector/);
writeFileSync(dep.bindingFile, JSON.stringify({ ...dep.raw, users: [] }), { mode: 0o600 });
const r2 = runCli(["reload", "test-seat", "--config", dep.config]);
assert.equal(r2.status, 2, r2.stderr);
assert.match(r2.stderr, /users must be a non-empty array/);
});
test("cli: run refuses when STOP is present, before any network use", () => {
const root = makeRoot();
const repo = makeRepo(root);
const dep = makeDeployment(root);
initializeControl(dep.dataRoot);
const r0 = runCli(["stop", "test-seat", "--config", dep.config]);
assert.equal(r0.status, 0, r0.stderr);
assert.match(r0.stdout, /STOP written/);
const r = runCli(["run", "test-seat", "--config", dep.config, "--repo", repo]);
assert.equal(r.status, 3, r.stderr);
assert.match(r.stderr, /STOP is present/);
});
test("binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root", () => {
assert.equal(validateBinding(rawBinding()).tools, null);
const root = makeRoot();
const docs = join(root, "docs");
mkdirSync(docs);
const ok = validateBinding(rawBinding({ tools: { roots: [{ name: "docs", path: docs }] } }));
assert.deepEqual(ok.tools, { roots: [{ name: "docs", path: docs, write: false, git: null }], maxFileBytes: 262144, maxCallsPerTurn: 8, web: null, setspark: null });
assert.ok(FIXED_KEYS.includes("tools"));
const bad = [
[{ tools: [] }, /must be an object/],
[{ tools: { roots: [] } }, /non-empty/],
[{ tools: { roots: [{ name: "docs", path: "docs" }] } }, /absolute/],
[{ tools: { roots: [{ name: "docs", path: join(root, ".hidden") }] } }, /dot-prefixed/],
[{ tools: { roots: [{ name: "home", path: homedir() }] } }, /home directory/],
[{ tools: { roots: [{ name: "slash", path: "/" }] } }, /filesystem root/],
[{ tools: { roots: [{ name: "docs", path: docs }, { name: "docs", path: docs }] } }, /duplicate/],
[{ tools: { roots: [{ name: "docs", path: docs }], maxCallsPerTurn: 65 } }, /maxCallsPerTurn/],
[{ tools: { roots: [{ name: "docs", path: docs }], extra: true } }, /unknown key/],
[{ tools: { roots: [{ name: "docs", path: docs, mode: "rw" }] } }, /unknown key/],
[{ tools: { roots: [{ name: "docs", path: docs, write: "yes" }] } }, /write must be true or false/],
];
for (const [o, re] of bad) assert.throws(() => validateBinding(rawBinding(o)), re, JSON.stringify(o));
assert.throws(() => reloadDiff(ok, validateBinding(rawBinding())), (e) => e instanceof DiscordError && e.exitCode === 2 && /tools cannot change/.test(e.message));
const dataRoot = join(root, "data");
mkdirSync(join(dataRoot, "discord"), { recursive: true });
assert.equal(resolveToolRoots(validateBinding(rawBinding()), { dataRoot }), null);
const resolved = resolveToolRoots(ok, { dataRoot });
assert.deepEqual(resolved, { roots: [{ name: "docs", path: docs, write: false }], maxFileBytes: 262144, maxCallsPerTurn: 8 });
const rw = validateBinding(rawBinding({ tools: { roots: [{ name: "docs", path: docs, write: true }] } }));
assert.deepEqual(resolveToolRoots(rw, { dataRoot }).roots, [{ name: "docs", path: docs, write: true }], "write: true reaches the extension's config");
const inData = validateBinding(rawBinding({ tools: { roots: [{ name: "d", path: join(dataRoot, "discord") }] } }));
assert.throws(() => resolveToolRoots(inData, { dataRoot }), /overlaps the data root/);
const above = validateBinding(rawBinding({ tools: { roots: [{ name: "r", path: root }] } }));
assert.throws(() => resolveToolRoots(above, { dataRoot }), /overlaps the data root/);
const missing = validateBinding(rawBinding({ tools: { roots: [{ name: "x", path: join(root, "nope") }] } }));
assert.throws(() => resolveToolRoots(missing, { dataRoot }), /does not exist/);
symlinkSync(docs, join(root, "docs-link"));
const linked = validateBinding(rawBinding({ tools: { roots: [{ name: "l", path: join(root, "docs-link") }] } }));
assert.throws(() => resolveToolRoots(linked, { dataRoot }), /symlink/);
});
test("binding: a git key is validated at load and reaches the extension whole, and only on a writable root", () => {
const root = makeRoot();
const repo = join(root, "repo");
mkdirSync(repo);
const tokenFile = join(root, "token");
writeFileSync(tokenFile, "not_a_real_token_just_a_test_value_x\n", { mode: 0o600 });
const git = { branch: "main", identity: "sage", tokenFile, author: "Sage <[email protected]>" };
const b = validateBinding(rawBinding({ tools: { roots: [{ name: "ss", path: repo, write: true, git }] } }));
assert.deepEqual(b.tools.roots[0].git, { branch: "main", identity: "sage", tokenFile, author: { name: "Sage", email: "[email protected]" }, protocol: null });
const dataRoot = join(root, "data");
mkdirSync(join(dataRoot, "discord"), { recursive: true });
assert.deepEqual(resolveToolRoots(b, { dataRoot }).roots, [{ name: "ss", path: repo, write: true, git: { branch: "main", identity: "sage", tokenFile, author: "Sage <[email protected]>" } }]);
const v = validateBinding(rawBinding({ tools: { roots: [{ name: "ss", path: repo, write: true, git: { ...git, protocol: "vault" } }] } }));
assert.equal(resolveToolRoots(v, { dataRoot }).roots[0].git.protocol, "vault");
const bad = [
[{ git }, /git needs write: true/],
[{ write: true, git: { ...git, branch: "" } }, /branch/],
[{ write: true, git: { ...git, tokenFile: join(root, "nope") } }, /not found/],
[{ write: true, git: { ...git, protocol: "x" } }, /protocol/],
[{ write: true, git: "yes" }, /git: not an object/],
];
for (const [o, re] of bad) assert.throws(() => validateBinding(rawBinding({ tools: { roots: [{ name: "ss", path: repo, ...o }] } })), re, JSON.stringify(o));
chmodSync(tokenFile, 0o640);
assert.throws(() => validateBinding(rawBinding({ tools: { roots: [{ name: "ss", path: repo, write: true, git }] } })), (e) => e instanceof DiscordError && /mode 0600/.test(e.message));
});