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:
2026-09-13 18:59:31 -05:00
co-authored by Claude Fable 5.1
parent d9745a4510
commit caaef941e6
16 changed files with 326 additions and 21 deletions
+49 -2
View File
@@ -7,6 +7,11 @@
// Loading fails closed: unknown key, missing field, wrong type, bad mode,
// symlink, empty allowlist. Nothing is defaulted silently except the limits'
// documented defaults below, which the fixture spells out anyway.
//
// A user entry may carry `channels`, an allowlist of listed channel ids;
// absent means every listed channel. A running connector may re-read the
// file (`reload`): `reloadDiff` says which keys may change in place and
// refuses the rest.
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
import { isAbsolute, join, resolve, sep } from "node:path";
@@ -28,7 +33,7 @@ export const LIMIT_DEFAULTS = Object.freeze({
const TOP_KEYS = ["bindingVersion", "name", "seat", "guildId", "guildName", "botUserId", "tokenFile", "channels", "users", "engine", "limits", "context"];
const CHANNEL_KEYS = ["id", "name", "mode"];
const USER_KEYS = ["id", "name"];
const USER_KEYS = ["id", "name", "channels"];
const ENGINE_KEYS = ["provider", "model", "thinking"];
const LIMIT_KEYS = Object.keys(LIMIT_DEFAULTS);
const CONTEXT_KEYS = ["files"];
@@ -123,7 +128,19 @@ export function validateBinding(raw, where = "binding") {
const w = `${where}.users[${i}]`;
if (!isObject(u)) throw new DiscordError(`${w}: not an object`);
onlyKeys(u, USER_KEYS, w);
return Object.freeze({ id: requireSnowflake(u, "id", w), name: requireString(u, "name", w) });
const id = requireSnowflake(u, "id", w);
const uname = requireString(u, "name", w);
let allowed = null;
if (u.channels !== undefined) {
if (!Array.isArray(u.channels) || u.channels.length === 0) throw new DiscordError(`${w}: channels must be a non-empty array of listed channel ids`);
allowed = u.channels.map((cid, j) => {
if (typeof cid !== "string" || !SNOWFLAKE.test(cid)) throw new DiscordError(`${w}.channels[${j}]: not a Discord snowflake id`);
if (!channels.some((c) => c.id === cid)) throw new DiscordError(`${w}.channels[${j}]: ${cid} is not a listed channel`);
return cid;
});
if (new Set(allowed).size !== allowed.length) throw new DiscordError(`${w}: duplicate channel id`);
}
return Object.freeze({ id, name: uname, channels: allowed === null ? null : Object.freeze(allowed) });
});
if (new Set(users.map((u) => u.id)).size !== users.length) throw new DiscordError(`${where}: duplicate user id`);
if (users.some((u) => u.id === botUserId)) throw new DiscordError(`${where}: the bot cannot be an authorized user`);
@@ -166,6 +183,36 @@ export function validateBinding(raw, where = "binding") {
});
}
// What a running connector may take from a re-read binding, and what it may
// not: the engine and its prompt are launched once, the token is read once,
// and the journal directory is named after the binding. A change to a fixed
// key needs a stop and a start. Returns a summary of the reloadable
// differences or throws with exit 2.
export const RELOADABLE_KEYS = Object.freeze(["guildName", "channels", "users", "limits"]);
export const FIXED_KEYS = Object.freeze(["bindingVersion", "name", "seat", "guildId", "botUserId", "tokenFile", "engine", "context"]);
export function reloadDiff(current, next) {
for (const k of FIXED_KEYS) {
if (JSON.stringify(current[k]) !== JSON.stringify(next[k])) throw new DiscordError(`reload: ${k} cannot change while running; stop and start instead`);
}
const byId = (xs) => new Map(xs.map((x) => [x.id, JSON.stringify(x)]));
const listDiff = (a, b) => {
const A = byId(a);
const B = byId(b);
return Object.freeze({
added: Object.freeze([...B.keys()].filter((id) => !A.has(id))),
removed: Object.freeze([...A.keys()].filter((id) => !B.has(id))),
changed: Object.freeze([...B.keys()].filter((id) => A.has(id) && A.get(id) !== B.get(id))),
});
};
return Object.freeze({
channels: listDiff(current.channels, next.channels),
users: listDiff(current.users, next.users),
limits: Object.freeze(LIMIT_KEYS.filter((k) => current.limits[k] !== next.limits[k])),
guildName: current.guildName !== next.guildName,
});
}
// A private file: regular, not a symlink, owner-only (0600), non-empty.
export function checkPrivateFile(path, what) {
let st;