Files
stack/packages/discord/tests/binding.test.mjs
T
jason.woltjeandClaude Fable 5.1 1685deb423 feat(discord): writes on write-marked roots, web fetch and search, held prompts (#1509)
Row 23. write_file and edit_file for roots marked write: true under the
same fence as reads; web_fetch (https only, public addresses, pinned
connection, capped body) and web_search through SearXNG; extension
renamed to tools.mjs. Engine holds a prompt while pi is busy and sends
it as its own run, so a second message mid-turn no longer folds into
the first (live defect). fake-pi models the real follow-up folding.

Suite 52/52, node tests 129. rev-code-02 APPROVED round 3, comment
26362, tree dbd2ce9a. Records: QUEUE rows 23-24, CURRENT, BUILD-LOG
phase, SESSIONS, row 24 brief (git verbs, D5-D7 ruled).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-18 07:27:50 -05:00

242 lines
14 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";
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"] },
};
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: 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 not found/);
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);
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 }], maxFileBytes: 262144, maxCallsPerTurn: 8, web: 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/);
});