feat(discord): connector pilot for the Sage seat, reviewed candidate (#1509)
Zero-dependency Discord connector under packages/discord: binding
validation, REST and gateway clients, pi engine adapter, journal with
append-only inbox, outbox, admissions and notices, and a run.lock
ownership record {pid, start, boot} whose identity is checked three ways
and whose cleanup is gated by STOP. CLI check|run|stop|unlock via
scripts/discord.sh; offline suite scripts/test-discord.sh (28 checks,
87 node tests).
Reviewed by rev-code-02 on #1509 over nine rounds; approved exact tree
4e0feb6758c0a7e4a71483912a8e0d3e3ec95aef at comment 26170. Corrections
(1) to (12) recorded in BUILD-LOG. No listener started, no token read,
no Discord write; the live pilot follows this commit per the brief.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
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 } from "../src/binding.mjs";
|
||||
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: 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: 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, 1, r.stderr);
|
||||
assert.match(r.stderr, /STOP is present/);
|
||||
});
|
||||
Reference in New Issue
Block a user