From caaef941e6035850e892cea26b06016cb365482b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 13 Sep 2026 18:59:31 -0500 Subject: [PATCH] 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 --- docs/TOOLS.md | 10 ++- .../2026-09-13_discord-connector-pilot.md | 25 ++++++++ docs/plans/QUEUE.md | 3 + packages/discord/README.md | 26 +++++++- .../discord/fixtures/binding.example.json | 3 +- packages/discord/src/authorize.mjs | 7 +- packages/discord/src/binding.mjs | 51 ++++++++++++++- packages/discord/src/cli.mjs | 64 +++++++++++++++++-- packages/discord/src/connector.mjs | 21 +++++- packages/discord/src/journal.mjs | 14 ++++ .../systemd/mosaic-discord@.service.in | 4 ++ packages/discord/tests/authorize.test.mjs | 12 ++++ packages/discord/tests/binding.test.mjs | 58 ++++++++++++++++- packages/discord/tests/connector.test.mjs | 41 ++++++++++++ scripts/discord.sh | 2 +- scripts/test-discord.sh | 6 +- 16 files changed, 326 insertions(+), 21 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 11d93b18..26705798 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -155,6 +155,7 @@ scripts/discord.sh run [--supervised] scripts/discord.sh stop scripts/discord.sh unlock scripts/discord.sh recover +scripts/discord.sh reload scripts/discord-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status ``` @@ -172,10 +173,15 @@ parsed. `run` refuses a stale lock rather than reclaiming it, and releases a claim that meets `STOP`. `recover` is the supervised pre-start, and `run --supervised` does it first in the same process: it refuses (exit 3) while `STOP` is present or the binding is held, clears a lock whose owner -is gone and removes only the `STOP` it wrote for that. +is gone and removes only the `STOP` it wrote for that. `reload` validates +the binding file and sends SIGHUP to the running connector, which applies +`guildName`, `channels`, `users` and `limits` in place and refuses anything +else, keeping the old binding; attempts are journaled in `reloads.jsonl`. A +user entry may carry `channels`, an allowlist of listed channel ids. `scripts/discord-service.sh install` renders and writes the systemd user unit `mosaic-discord@.service` (one instance per binding, restart on -failure, exit 3 never retried, SIGTERM on `systemctl --user stop`). Records +failure, exit 3 never retried, SIGTERM on `systemctl --user stop`, SIGHUP on +`systemctl --user reload`). Records under `/discord//`: `inbox.jsonl`, `outbox.jsonl`, `drops.jsonl`, `admissions.jsonl`, `notices.jsonl`, write-once `turns/.json`. Suite: `scripts/test-discord.sh`. diff --git a/docs/plans/2026-09-13_discord-connector-pilot.md b/docs/plans/2026-09-13_discord-connector-pilot.md index 7c2c71de..59644597 100644 --- a/docs/plans/2026-09-13_discord-connector-pilot.md +++ b/docs/plans/2026-09-13_discord-connector-pilot.md @@ -436,6 +436,31 @@ Recorded here because they refine a ruling or fill a gap the rulings left. still needs stop and start). Next: the control board row. - Live check 2026-09-13 19:35 to 19:40 UTC: Sage seat migrated to the unit; SIGKILL recovered in 16 s with the dead lock cleared; `discord.sh stop` held (start exits 3, no restart); released and READY. Iteration 2 closed. - Operator check 2026-09-13 21:29 UTC: Jason ran the four steps (traffic under the unit, SIGKILL recovery, brake holds with exit 3 and no restart, release to READY) and reported all verified. Receipt `mvp2-operator-verify-*.json` in the private evidence dir. Unit active afterwards with a fresh main PID and zero auto-restarts, as expected after a brake and release. +- Binding reload (iteration 4, Jason: "proceed in order", 2026-09-13). + Today the binding is read once at start, so a ceiling or channel change + needs brake, STOP removal, reset and start. New: `scripts/discord.sh + reload ` validates the file, then sends SIGHUP to the live + owner in `run.lock`; the unit gets `ExecReload` so `systemctl --user + reload` does the same. The running process re-reads the file and applies + `channels`, `users`, `limits` and `guildName` in place; a new channel is + read over REST first and must sit in the bound guild. `name`, `seat`, + `guildId`, `botUserId`, `tokenFile`, `engine` and `context` are fixed for + the life of the process (the engine and its prompt are launched once); + a change there, an invalid file or a failed channel lookup refuses the + reload, keeps the old binding and journals the refusal in + `reloads.jsonl`. Turns in flight finish under the limits they started + with. Nothing is sent to Discord on a reload. +- Per-user channels and Carmen (iteration 5, same ruling). Jason gave + Carmen's id and the rule "all rooms except #sage-admin". A user entry + may carry `channels: [ids]`, an allowlist of listed channel ids; absent + means every listed channel. A listed user posting in a channel outside + their list is dropped with `channel-not-for-user`, silent like every + drop; threads use the parent. Choice recorded: allowlist over a deny + list, because the connector's policy is explicit lists (Q3) and a new + channel must not widen anyone's reach by default. Cost: when a channel + is added later, Carmen's list needs the id too. Enrollment is a binding + edit plus `reload`, which doubles as the live check for iteration 4. + Carmen's id lives only in the binding file, never in the repository. - Control board row (iteration 3, briefed, not started). Blocked on ownership, not on design: `packages/control-board` is darkwing's (#1503, #1505, brief `2026-09-12_control-board-mvp.md`), and the row cannot be diff --git a/docs/plans/QUEUE.md b/docs/plans/QUEUE.md index a55c6bb7..c69ff4d3 100644 --- a/docs/plans/QUEUE.md +++ b/docs/plans/QUEUE.md @@ -43,6 +43,8 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here. | 15 | Discord connector: eyes reaction on every admitted message as a read receipt (MVP iteration 1) | coordinator | #1509 | done: committed 93d6b624, live check passed 19:21 UTC (turn record receipt ok, Jason: test is successful), receipt `mvp1-read-receipt-20260913T192158Z.json` in the private evidence dir; `rest.react` best effort, reaction placed at admission before the engine runs, outcome in the turn record, no reaction on drops or refusals; `scripts/test-discord.sh` 28/28 (90 node tests) | Jason sees the reaction on a live message | `2026-09-13_discord-connector-pilot.md` section 11 | | 17 | Discord connector: systemd user service with a supervised pre-start (`recover`, exit 3 never retried) (MVP iteration 2) | coordinator | #1509 | done, operator-verified by Jason 2026-09-13 (all four steps): `scripts/test-discord.sh` 40/40 (95 node tests); Sage seat migrated 19:35 UTC, SIGKILL recovered in 16 s with the dead lock cleared, brake held (exit 3, no restart), released and READY; receipt `mvp2-service-unit-*.json` in the private evidence dir. First cut (ExecStartPre) looped and was replaced by `run --supervised` before any traffic | the Sage connector runs under `mosaic-discord@shared-signals`, survives a kill with a clean restart, and stays down behind `discord.sh stop` | `2026-09-13_discord-connector-pilot.md` section 11 | | 18 | Control board row for the Discord connector (MVP iteration 3): discovery from binding files, liveness from run.lock, reply refused | darkwing (Jason's ruling 2026-09-13); coordinator answers connector-side questions | #1509 | briefed; assigned to darkwing, not started | a Sage (discord) row on the board shows live, offline and braked correctly, and reply from the board is refused | `2026-09-13_discord-connector-pilot.md` section 11 | +| 19 | Discord connector: binding reload without a restart (`reload` verb, SIGHUP, `systemctl --user reload`); channels, users, limits and guildName apply in place, identity, engine and context stay fixed, an invalid file is refused and the old binding kept (MVP iteration 4) | coordinator | #1509 | in progress | edit the binding, run `scripts/discord.sh reload shared-signals`, the change applies with no restart, a broken edit is refused and journaled | `2026-09-13_discord-connector-pilot.md` section 11 | +| 20 | Discord connector: per-user channel allowlist in the binding and Carmen enrolled (all listed rooms except #sage-admin) (MVP iteration 5) | coordinator | #1509 | briefed, after row 19 | Carmen gets a reply in #general and silence in #sage-admin; Jason unchanged | `2026-09-13_discord-connector-pilot.md` section 11 | Start message for row 6, sent from the board to darkwing: "Read docs/plans/QUEUE.md, then the plan page section "Piece 5: darkwing on @@ -88,3 +90,4 @@ Gate F or when blocked." - 2026-09-13 — coordinator: rows 17–18 pushed (dc5902aa..90cb31f5 to origin/refactor) under the jarvis git identity on Jason's authorization. No other row changed. - 2026-09-13 — coordinator: row 18 assigned to darkwing by Jason ("darkwing should build the board row"). Coordinator no longer holds a Discord row in progress. No other row changed. - 2026-09-13 — coordinator: row 17 operator check passed (Jason: all tests successfully verified, 21:29 UTC). No other row changed. +- 2026-09-13 — coordinator: rows 19 (binding reload) and 20 (per-user channels, Carmen) added on Jason's "proceed in order"; row 19 in progress. No other row changed. diff --git a/packages/discord/README.md b/packages/discord/README.md index 746f76a9..50e75e0c 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -18,6 +18,7 @@ scripts/discord.sh run [--supervised] scripts/discord.sh stop scripts/discord.sh unlock scripts/discord.sh recover +scripts/discord.sh reload scripts/discord-service.sh render | install | uninstall | status ``` @@ -63,6 +64,22 @@ scripts/discord-service.sh render | install | uninstall | status line it wrote itself; a brake an operator wrote at any point, even during the recovery, stays and the start is refused. Nothing automatic ever removes an operator's `STOP`. +- `reload` applies an edited binding to the running connector without a + restart. It validates the file first (an invalid file exits 2 and nothing + is signaled), then sends SIGHUP to the live owner in `run.lock` (no live + owner exits 1; the file applies at the next start). The process re-reads + the file and swaps `guildName`, `channels`, `users` and `limits` in place; + a channel that is new to the binding is read over REST and must be in the + bound guild. `name`, `seat`, `guildId`, `botUserId`, `tokenFile`, `engine` + and `context` are fixed for the life of the process, because the engine + and its prompt are launched once and the token is read once; a change + there, an invalid file or a failed channel lookup refuses the reload and + keeps the old binding. The turn in flight finishes under the limits it + started with; the next admission uses the new binding. Every attempt is + one line in `reloads.jsonl`, `applied` with the differences by id or + `refused` with the reason, and one line in the log. Nothing is sent to + Discord. Under the service unit, `systemctl --user reload + mosaic-discord@` sends the same signal. Exit codes: 0 ok, 1 operation failed, 2 invalid data or configuration, 3 refused by a brake (`STOP` present or the binding held; a supervisor must @@ -114,13 +131,15 @@ is `src/binding.mjs`. | `guildId`, `guildName`, `botUserId` | the one server and the bot identity `check` confirms | | `tokenFile` | absolute path to the bot token, 0600, read into memory at start, never printed or journaled | | `channels[]` | `{id, name, mode}`; `open` answers every message, `mention` only when the bot is mentioned; threads inherit the parent's mode | -| `users[]` | `{id, name}`; the only authors that get a turn | +| `users[]` | `{id, name, channels?}`; the only authors that get a turn. `channels` is an optional allowlist of listed channel ids; absent means every listed channel, present means those and their threads only, everything else is dropped as `channel-not-for-user` | | `engine` | `provider`, `model`, `thinking` for pi | | `limits` | `turnsPerDay` (200), `turnTimeoutSeconds` (180), `replyChunkChars` (1900), `inboundMaxChars` (4000) | | `context.files[]` | files appended to pi's system prompt in order, repository-relative and inside the repository (no absolute paths, `..` or symlinks); the Discord block is added after them | -Unknown keys, missing fields, wrong types, empty allowlists and a bot listed -as a user all refuse with exit 2. +Unknown keys, missing fields, wrong types, empty allowlists, a user channel +that is not listed and a bot listed as a user all refuse with exit 2. A +running connector picks up an edit through `reload`; the fields it will not +take in place are listed under that command. ## What happens to a message @@ -172,6 +191,7 @@ start and names the nonces. /discord//launches/ context snapshot and sha256 per run /discord//STOP stop switch /discord//notices.jsonl once-per-day fixed lines already attempted +/discord//reloads.jsonl one line per reload attempt, applied or refused /discord//run.lock/ ownership directory (atomic mkdir) with owner.json {pid, start, boot}; stale ones need `unlock` /sessions/discord-/ the pi session, continued across runs ``` diff --git a/packages/discord/fixtures/binding.example.json b/packages/discord/fixtures/binding.example.json index b7330952..7e95870d 100644 --- a/packages/discord/fixtures/binding.example.json +++ b/packages/discord/fixtures/binding.example.json @@ -11,7 +11,8 @@ { "id": "100000000000000011", "name": "general", "mode": "mention" } ], "users": [ - { "id": "100000000000000100", "name": "owner" } + { "id": "100000000000000100", "name": "owner" }, + { "id": "100000000000000101", "name": "guest", "channels": ["100000000000000011"] } ], "engine": { "provider": "zai", "model": "glm-5.3", "thinking": "high" }, "limits": { diff --git a/packages/discord/src/authorize.mjs b/packages/discord/src/authorize.mjs index a7325aa5..c09bfab5 100644 --- a/packages/discord/src/authorize.mjs +++ b/packages/discord/src/authorize.mjs @@ -28,6 +28,7 @@ export const DROP = Object.freeze({ SELF: "author-is-self", USER: "user-unlisted", CHANNEL: "channel-unlisted", + USER_CHANNEL: "channel-not-for-user", THREAD_PARENT: "thread-parent-unlisted", MENTION: "no-mention", }); @@ -47,7 +48,8 @@ export function authorize(binding, message, channelInfo = () => undefined) { if (author.id === binding.botUserId) return { ok: false, reason: DROP.SELF }; if (message.webhook_id !== undefined && message.webhook_id !== null) return { ok: false, reason: DROP.WEBHOOK }; if (author.bot === true || author.system === true) return { ok: false, reason: DROP.BOT }; - if (!binding.users.some((u) => u.id === author.id)) return { ok: false, reason: DROP.USER }; + const user = binding.users.find((u) => u.id === author.id); + if (!user) return { ok: false, reason: DROP.USER }; let channel = binding.channels.find((c) => c.id === message.channel_id); let thread = null; @@ -59,6 +61,9 @@ export function authorize(binding, message, channelInfo = () => undefined) { if (!channel) return { ok: false, reason: DROP.THREAD_PARENT }; thread = { id: message.channel_id, name: typeof info.name === "string" ? info.name : null }; } + // A user's channel allowlist, when present, is checked against the listed + // channel, so a thread counts as its parent. + if (user.channels && !user.channels.includes(channel.id)) return { ok: false, reason: DROP.USER_CHANNEL }; // A thread inherits the parent's mode (Q4). @everyone is not a mention of // the bot; only an entry in `mentions` with the bot's id counts. if (channel.mode === "mention" && !mentionsBot(message, binding.botUserId)) return { ok: false, reason: DROP.MENTION }; diff --git a/packages/discord/src/binding.mjs b/packages/discord/src/binding.mjs index a6917be7..9a435ab5 100644 --- a/packages/discord/src/binding.mjs +++ b/packages/discord/src/binding.mjs @@ -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; diff --git a/packages/discord/src/cli.mjs b/packages/discord/src/cli.mjs index 705e6fa6..3b469f1c 100755 --- a/packages/discord/src/cli.mjs +++ b/packages/discord/src/cli.mjs @@ -5,6 +5,7 @@ // mosaic-discord stop [--config PATH] // mosaic-discord unlock [--config PATH] // mosaic-discord recover [--config PATH] +// mosaic-discord reload [--config PATH] // // names /discord/.json. The repository wrapper // is scripts/discord.sh. @@ -33,6 +34,14 @@ // honours a never-retry exit status from the main process, not from a // pre-start command. // +// reload: validates the binding file, then sends SIGHUP to the live owner in +// run.lock. The running process re-reads the file and applies guildName, +// channels, users and limits in place; a new channel is read over REST and +// must be in the bound guild. Any fixed key changed, an invalid file or a +// failed lookup refuses the reload and keeps the old binding. Each attempt is +// one line in reloads.jsonl. The service unit maps `systemctl --user reload` +// to the same signal. +// // Exit codes: 0 ok; 1 operation failed; 2 invalid data or configuration; // 3 refused by a brake (STOP present or the binding held; a supervisor must // not retry); 4 usage. @@ -41,13 +50,13 @@ import { existsSync, mkdirSync, mkdtempSync, writeFileSync, statSync, readdirSyn import { join, resolve } from "node:path"; import { createHash } from "node:crypto"; import { DiscordError } from "./errors.mjs"; -import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles } from "./binding.mjs"; +import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, reloadDiff } from "./binding.mjs"; import { createRest } from "./rest.mjs"; import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs"; import { createEngine, buildPiArgs } from "./engine-pi.mjs"; import { assembleContext } from "./context.mjs"; import { createConnector } from "./connector.mjs"; -import { ensureJournal, requestStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, BRAKE_EXIT } from "./journal.mjs"; +import { ensureJournal, requestStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, BRAKE_EXIT } from "./journal.mjs"; const USAGE = [ "usage: mosaic-discord check [--config PATH] [--repo PATH]", @@ -55,6 +64,7 @@ const USAGE = [ " mosaic-discord stop [--config PATH]", " mosaic-discord unlock [--config PATH]", " mosaic-discord recover [--config PATH]", + " mosaic-discord reload [--config PATH]", ].join("\n"); function parse(argv) { @@ -74,7 +84,7 @@ function parse(argv) { else throw new DiscordError(`unexpected argument: ${a}\n${USAGE}`, 4); } if (opts.command === "help") return opts; - if (!["check", "run", "stop", "unlock", "recover"].includes(opts.command)) throw new DiscordError(USAGE, 4); + if (!["check", "run", "stop", "unlock", "recover", "reload"].includes(opts.command)) throw new DiscordError(USAGE, 4); if (opts.binding === null) throw new DiscordError(`${opts.command} needs a binding name\n${USAGE}`, 4); if (opts.supervised && opts.command !== "run") throw new DiscordError(`--supervised applies to run only\n${USAGE}`, 4); return opts; @@ -90,14 +100,15 @@ function warn(msg) { // Everything that can be checked without the network, shared by check and run. function prepare(opts) { const dataRoot = loadDataRoot(opts.config); - const binding = loadBinding(bindingPath(dataRoot, opts.binding)); + const bindingFile = bindingPath(dataRoot, opts.binding); + const binding = loadBinding(bindingFile); if (binding.name !== opts.binding) throw new DiscordError(`binding name ${JSON.stringify(binding.name)} does not match file name ${opts.binding}`); const contextFiles = resolveContextFiles(binding, opts.repo); const pi = join(opts.repo, "node_modules", ".bin", "pi"); if (!existsSync(pi)) throw new DiscordError(`pi not found at ${pi}; run npm ci in the repository`); const journalDir = bindingDataDir(dataRoot, binding.name); const sessionDir = join(dataRoot, "sessions", `discord-${binding.name}`); - return { dataRoot, binding, contextFiles, pi, journalDir, sessionDir }; + return { dataRoot, bindingFile, binding, contextFiles, pi, journalDir, sessionDir }; } async function check(opts) { @@ -148,7 +159,7 @@ async function check(opts) { } async function run(opts) { - const { binding, contextFiles, pi, journalDir, sessionDir } = prepare(opts); + const { bindingFile, binding, contextFiles, pi, journalDir, sessionDir } = prepare(opts); const token = readToken(binding); ensureJournal(journalDir); if (opts.supervised) { @@ -216,6 +227,34 @@ async function run(opts) { process.on("SIGTERM", () => shutdown(0)); process.on("SIGINT", () => shutdown(0)); + // Reload on SIGHUP: re-read the file, refuse anything that is not a + // reloadable difference, verify new channels over REST, then swap. + // Attempts are serialized; the outcome is journaled and logged, and a + // refusal leaves the binding as it was. + const reloadNow = async () => { + const at = new Date().toISOString(); + try { + const next = loadBinding(bindingFile); + if (next.name !== binding.name) throw new DiscordError(`reload: name ${JSON.stringify(next.name)} does not match ${binding.name}`); + const diff = reloadDiff(connector.binding, next); + for (const id of diff.channels.added) { + const ch = await rest.getChannel(id); + if (ch.guild_id !== next.guildId) throw new DiscordError(`reload: channel ${id} is in guild ${ch.guild_id}, not ${next.guildId}`); + } + connector.reload(next); + appendReload(journalDir, { at, outcome: "applied", ...diff }); + const n = (d) => `+${d.added.length} -${d.removed.length} ~${d.changed.length}`; + warn(`reload applied: channels ${n(diff.channels)}, users ${n(diff.users)}, limits ${diff.limits.length ? diff.limits.join(",") : "unchanged"}${diff.guildName ? ", guildName" : ""}`); + } catch (err) { + appendReload(journalDir, { at, outcome: "refused", error: err.message }); + warn(`reload refused, binding unchanged: ${err.message}`); + } + }; + let reloadChain = Promise.resolve(); + process.on("SIGHUP", () => { + reloadChain = reloadChain.then(reloadNow, reloadNow); + }); + const started = await connector.start(); say(`run ${binding.name}: pid ${process.pid}, ${started.inbox} inbox id(s), ${started.reconciled.length} reconciled, context sha256 ${snapshot.sha256}, session ${continueSession ? "continued" : "new"}`); say(`stop with: scripts/discord.sh stop ${binding.name}`); @@ -261,6 +300,18 @@ function unlockCommand(opts) { say("remove STOP to run again"); } +function reloadCommand(opts) { + const dataRoot = loadDataRoot(opts.config); + const binding = loadBinding(bindingPath(dataRoot, opts.binding)); + if (binding.name !== opts.binding) throw new DiscordError(`binding name ${JSON.stringify(binding.name)} does not match file name ${opts.binding}`); + const journalDir = bindingDataDir(dataRoot, binding.name); + ensureJournal(journalDir); + const pid = stopTarget(journalDir); + if (pid === null) throw new DiscordError("no running connector for this binding; the file is valid and applies at the next start", 1); + process.kill(pid, "SIGHUP"); + say(`SIGHUP sent to pid ${pid}; the outcome is the last line of ${join(journalDir, "reloads.jsonl")} and in its log`); +} + function recoverCommand(opts) { const dataRoot = loadDataRoot(opts.config); const binding = loadBinding(bindingPath(dataRoot, opts.binding)); @@ -281,6 +332,7 @@ async function main() { else if (opts.command === "run") await run(opts); else if (opts.command === "unlock") unlockCommand(opts); else if (opts.command === "recover") recoverCommand(opts); + else if (opts.command === "reload") reloadCommand(opts); else stop(opts); return opts.command === "run" ? null : 0; } diff --git a/packages/discord/src/connector.mjs b/packages/discord/src/connector.mjs index 95f443c7..8784e476 100644 --- a/packages/discord/src/connector.mjs +++ b/packages/discord/src/connector.mjs @@ -11,7 +11,10 @@ // window is marked refused, not re-sent, because a re-send could post a // second reply. If any intent is still unknown after that, start refuses. // 4. A STOP file refuses new turns; the current one finishes or times out. -// 5. The daily ceiling counts admissions (admissions.jsonl) on the current +// 5. A reload swaps the binding between turns: authorization and limits +// read the current binding at admission; a turn in flight keeps the +// values it started with. Fixed keys (see binding.mjs) are refused. +// 6. The daily ceiling counts admissions (admissions.jsonl) on the current // UTC date; an admission is written before the engine runs, so turns in // flight and turns cut short by a crash count too. // Over the ceiling, inbound messages are journaled as drops, one fixed @@ -21,6 +24,7 @@ // timers. The offline suite drives this with fakes. import { authorize, isThreadType } from "./authorize.mjs"; +import { reloadDiff } from "./binding.mjs"; import { envelope, splitReply } from "./context.mjs"; import { ensureJournal, appendInbox, readInboxIds, appendOutbox, unresolvedOutbox, appendDrop, @@ -41,17 +45,18 @@ export const FIXED_LINES = Object.freeze({ export const RECONCILE_WINDOW_MS = 5 * 60 * 1000; export function createConnector({ - binding, journalDir, rest, gateway, engine, + binding: initialBinding, journalDir, rest, gateway, engine, now = () => Date.now(), setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout, typingIntervalMs = 8000, readReceipt = READ_RECEIPT, log = () => {}, } = {}) { - for (const [k, v] of Object.entries({ binding, journalDir, rest, gateway, engine })) { + for (const [k, v] of Object.entries({ binding: initialBinding, journalDir, rest, gateway, engine })) { if (!v) throw new DiscordError(`connector: ${k} required`, 1); } ensureJournal(journalDir); + let binding = initialBinding; const state = { inbox: new Set(), channels: new Map(), inFlight: 0, typingTimer: null, typingChannel: null, @@ -308,6 +313,16 @@ export function createConnector({ onDispatch, reconcile, rememberChannel, + get binding() { + return binding; + }, + // Swap the binding in place. Throws (exit 2) and changes nothing when a + // fixed key differs. Returns the summary of what changed. + reload(next) { + const diff = reloadDiff(binding, next); + binding = next; + return diff; + }, get inFlight() { return state.inFlight; }, diff --git a/packages/discord/src/journal.mjs b/packages/discord/src/journal.mjs index 4a361c85..09f3cc07 100644 --- a/packages/discord/src/journal.mjs +++ b/packages/discord/src/journal.mjs @@ -10,6 +10,9 @@ // ({at, reason}), appended, so the last line names who // braked; `recover` removes only a STOP it wrote itself // notices.jsonl once-per-day fixed lines already attempted (ceiling) +// reloads.jsonl one line per binding reload attempt: applied (with the +// differences) or refused (with the reason); the binding +// in memory only changes on an applied line // run.lock/ ownership directory (mkdir is atomic) holding owner.json // {pid, start, boot}; `stop` signals only a live pid whose // start time and boot id match; a stale lock refuses `run` @@ -435,6 +438,17 @@ export function clearPid(dir, pid) { if (rec !== null && !rec.invalid && rec.pid === pid) rmSync(lockPath(dir), { recursive: true, force: true }); } +// --- reloads --- + +export function appendReload(dir, entry) { + if (!["applied", "refused"].includes(entry.outcome)) throw new DiscordError("reload entry needs an outcome", 1); + appendLine(join(dir, "reloads.jsonl"), entry); +} + +export function readReloads(dir) { + return readLines(join(dir, "reloads.jsonl")); +} + // --- notices --- // Fixed lines that must go out at most once per UTC day (the ceiling // notice). The line is appended before the delivery attempt, so a crash diff --git a/packages/discord/systemd/mosaic-discord@.service.in b/packages/discord/systemd/mosaic-discord@.service.in index ec8c6fed..f8d41e49 100644 --- a/packages/discord/systemd/mosaic-discord@.service.in +++ b/packages/discord/systemd/mosaic-discord@.service.in @@ -20,6 +20,10 @@ Environment=PATH=@PATH@ # the main process on purpose: systemd honours RestartPreventExitStatus only # for the main process, so a refusing ExecStartPre would loop. ExecStart=@REPO@/scripts/discord.sh run %i --supervised +# `systemctl --user reload` re-reads the binding in place (channels, users, +# limits); the connector refuses and keeps the old binding when the file is +# invalid or a fixed key changed. See reloads.jsonl under the journal. +ExecReload=/bin/kill -HUP $MAINPID Restart=on-failure RestartSec=15 RestartPreventExitStatus=3 diff --git a/packages/discord/tests/authorize.test.mjs b/packages/discord/tests/authorize.test.mjs index b1c05e43..9b9413a3 100644 --- a/packages/discord/tests/authorize.test.mjs +++ b/packages/discord/tests/authorize.test.mjs @@ -55,6 +55,18 @@ for (const [name, msg, expected] of table) { }); } +test("authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected", () => { + const guest = { id: IDS.stranger, name: "guest", channels: [IDS.general] }; + const scoped = binding({ users: [{ id: IDS.owner, name: "owner" }, guest] }); + const asGuest = (o) => authorize(scoped, message({ author: { id: IDS.stranger }, ...o }), info); + assert.equal(asGuest({ channel_id: IDS.admin }).reason, DROP.USER_CHANNEL); + assert.equal(asGuest({ channel_id: IDS.threadOfAdmin }).reason, DROP.USER_CHANNEL); + assert.equal(asGuest({ channel_id: IDS.general, mentions: botMention }).ok, true); + assert.equal(asGuest({ channel_id: IDS.general }).reason, DROP.MENTION); + assert.equal(asGuest({ channel_id: "100000000000000030", mentions: botMention }).channel.id, IDS.general); + assert.equal(authorize(scoped, message({ channel_id: IDS.admin }), info).ok, true); +}); + test("authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers)", () => { let looked = 0; const spy = (id) => { diff --git a/packages/discord/tests/binding.test.mjs b/packages/discord/tests/binding.test.mjs index e3bb0ba9..be2b2e0a 100644 --- a/packages/discord/tests/binding.test.mjs +++ b/packages/discord/tests/binding.test.mjs @@ -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); diff --git a/packages/discord/tests/connector.test.mjs b/packages/discord/tests/connector.test.mjs index 26406814..b4053260 100644 --- a/packages/discord/tests/connector.test.mjs +++ b/packages/discord/tests/connector.test.mjs @@ -5,6 +5,7 @@ import { join } from "node:path"; import { createConnector, FIXED_LINES, RECONCILE_WINDOW_MS, READ_RECEIPT } from "../src/connector.mjs"; import { readOutbox, readDrops, listTurns, readInboxIds, appendOutbox, appendInbox, ensureJournal, requestStop, writeTurn, countAdmissionsOn, noticeOn } from "../src/journal.mjs"; import { makeRoot, binding, message, IDS, fakeRest, fakeGateway, fakeEngine } from "./helpers.mjs"; +import { DiscordError } from "../src/errors.mjs"; function setup({ bindingOverrides = {}, replies = [], outcomes = [], now, engineOverrides = {} } = {}) { const root = makeRoot(); @@ -435,3 +436,43 @@ test("receipt: Discord refusing the reaction leaves the turn intact and records assert.deepEqual(turns[0].receipt, { emoji: READ_RECEIPT, ok: false }); await connector.stop(); }); + +test("reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once", async () => { + const { journalDir, engine, connector } = setup(); + await connector.start(); + const r0 = await connector.handleMessage(message({ id: "300000000000000090", author: { id: IDS.stranger } })); + assert.equal(r0.reason, "user-unlisted"); + const diff = connector.reload(binding({ + channels: [{ id: IDS.admin, name: "seat-admin", mode: "open" }], + users: [{ id: IDS.owner, name: "owner" }, { id: IDS.stranger, name: "guest", channels: [IDS.admin] }], + limits: { turnsPerDay: 2, turnTimeoutSeconds: 180, replyChunkChars: 1900, inboundMaxChars: 4000 }, + })); + assert.deepEqual(diff.users.added, [IDS.stranger]); + assert.deepEqual(diff.channels.removed, [IDS.general]); + assert.deepEqual(diff.limits, ["turnsPerDay"]); + const r1 = await connector.handleMessage(message({ id: "300000000000000091", author: { id: IDS.stranger } })); + assert.equal(r1.accepted, true); + assert.equal(await r1.turn, "ok"); + const r2 = await connector.handleMessage(message({ id: "300000000000000092", channel_id: IDS.general, mentions: [{ id: IDS.bot }] })); + assert.equal(r2.reason, "channel-unlisted"); + const r3 = await connector.handleMessage(message({ id: "300000000000000093" })); + assert.equal(await r3.turn, "ok"); + const r4 = await connector.handleMessage(message({ id: "300000000000000094" })); + assert.equal(r4.reason, "ceiling"); + assert.equal(engine.prompts.length, 2); + assert.equal(readDrops(journalDir).map((d) => d.reason).join(","), "user-unlisted,channel-unlisted,ceiling"); + await connector.stop(); +}); + +test("reload: a fixed key refuses with exit 2 and the old binding stays in force", async () => { + const { connector } = setup(); + await connector.start(); + const before = connector.binding; + assert.throws(() => connector.reload(binding({ seat: "other" })), (err) => err instanceof DiscordError && err.exitCode === 2 && /seat cannot change/.test(err.message)); + assert.throws(() => connector.reload(binding({ engine: { provider: "zai", model: "glm-5.3", thinking: "low" } })), /engine cannot change/); + assert.equal(connector.binding, before); + const r = await connector.handleMessage(message({ id: "300000000000000095" })); + assert.equal(r.accepted, true); + assert.equal(await r.turn, "ok"); + await connector.stop(); +}); diff --git a/scripts/discord.sh b/scripts/discord.sh index 127ca5ac..4fac8bf4 100755 --- a/scripts/discord.sh +++ b/scripts/discord.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# `scripts/discord.sh check|run|stop|unlock `: the Discord connector for +# `scripts/discord.sh check|run|stop|unlock|recover|reload `: the Discord connector for # one seat on one server. See packages/discord/README.md. The binding lives # at /discord/.json (0600, never committed); the token # stays in the seat's private secrets directory and is read at runtime only. diff --git a/scripts/test-discord.sh b/scripts/test-discord.sh index 3a7c3b6c..8904ad41 100755 --- a/scripts/test-discord.sh +++ b/scripts/test-discord.sh @@ -74,6 +74,9 @@ check "scripts/discord.sh check without a binding exits 4" $? scripts/discord.sh recover >/dev/null 2>&1 [ $? -eq 4 ] check "scripts/discord.sh recover without a binding exits 4" $? +scripts/discord.sh reload >/dev/null 2>&1 +[ $? -eq 4 ] +check "scripts/discord.sh reload without a binding exits 4" $? # --- the service unit: rendered from the template, never touching systemd here --- UNITS="$SANDBOX/units" @@ -87,8 +90,9 @@ scripts/discord-service.sh render >"$SANDBOX/unit.rendered" 2>/dev/null \ && grep -qx 'RestartPreventExitStatus=3' "$SANDBOX/unit.rendered" \ && grep -qx 'Restart=on-failure' "$SANDBOX/unit.rendered" \ && grep -qx 'KillSignal=SIGTERM' "$SANDBOX/unit.rendered" \ + && grep -qx 'ExecReload=/bin/kill -HUP $MAINPID' "$SANDBOX/unit.rendered" \ && ! grep -q '@REPO@\|@PATH@' "$SANDBOX/unit.rendered" -check "service unit renders with the repository path, a supervised run as the main process, and exit 3 never retried" $? +check "service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP" $? scripts/discord-service.sh install --dir "$UNITS" --no-reload >"$SANDBOX/install.1" 2>&1 \ && [ -f "$UNITS/mosaic-discord@.service" ] \ && grep -q '^written:' "$SANDBOX/install.1" \