feat(discord): binding reload without a restart, and a per-user channel allowlist (#1509)
`reload` validates the binding file and sends SIGHUP to the live owner; the running connector re-reads it and swaps guildName, channels, users and limits in place. name, seat, guildId, botUserId, tokenFile, engine and context are fixed for the life of the process; a change there, an invalid file or a channel outside the guild refuses the reload and keeps the old binding. Every attempt is one line in reloads.jsonl. The service unit maps `systemctl --user reload` to the same signal. A user entry may carry `channels`, an allowlist of listed channel ids; absent means every listed channel. Outside the list the message is dropped as channel-not-for-user; threads count as their parent. Suite 41/41, 101 node tests. QUEUE rows 19 and 20 opened. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -3,7 +3,7 @@ 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 { validateBinding, loadBinding, readToken, checkPrivateFile, resolveContextFiles, reloadDiff } from "../src/binding.mjs";
|
||||
import { DiscordError } from "../src/errors.mjs";
|
||||
import { makeRoot, makeRepo, makeDeployment, rawBinding } from "./helpers.mjs";
|
||||
|
||||
@@ -43,6 +43,49 @@ test("binding: empty allowlists refuse", () => {
|
||||
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);
|
||||
@@ -129,6 +172,19 @@ test("cli: check refuses a missing context file and a missing binding with exit
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user