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
+8 -2
View File
@@ -155,6 +155,7 @@ scripts/discord.sh run <binding> [--supervised]
scripts/discord.sh stop <binding> scripts/discord.sh stop <binding>
scripts/discord.sh unlock <binding> scripts/discord.sh unlock <binding>
scripts/discord.sh recover <binding> scripts/discord.sh recover <binding>
scripts/discord.sh reload <binding>
scripts/discord-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding> scripts/discord-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>
``` ```
@@ -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 claim that meets `STOP`. `recover` is the supervised pre-start, and
`run --supervised` does it first in the same process: it refuses (exit 3) `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 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 `scripts/discord-service.sh install` renders and writes the systemd user
unit `[email protected]` (one instance per binding, restart on unit `[email protected]` (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 `<dataRoot>/discord/<binding>/`: `inbox.jsonl`, `outbox.jsonl`, under `<dataRoot>/discord/<binding>/`: `inbox.jsonl`, `outbox.jsonl`,
`drops.jsonl`, `admissions.jsonl`, `notices.jsonl`, write-once `turns/<id>.json`. Suite: `drops.jsonl`, `admissions.jsonl`, `notices.jsonl`, write-once `turns/<id>.json`. Suite:
`scripts/test-discord.sh`. `scripts/test-discord.sh`.
@@ -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. 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. - 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. - 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 <binding>` 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 - Control board row (iteration 3, briefed, not started). Blocked on
ownership, not on design: `packages/control-board` is darkwing's (#1503, 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 #1505, brief `2026-09-12_control-board-mvp.md`), and the row cannot be
+3
View File
@@ -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 | | 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 | | 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 | | 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: 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 "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 1718 pushed (dc5902aa..90cb31f5 to origin/refactor) under the jarvis git identity on Jason's authorization. No other row changed. - 2026-09-13 — coordinator: rows 1718 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 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: 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.
+23 -3
View File
@@ -18,6 +18,7 @@ scripts/discord.sh run <binding> [--supervised]
scripts/discord.sh stop <binding> scripts/discord.sh stop <binding>
scripts/discord.sh unlock <binding> scripts/discord.sh unlock <binding>
scripts/discord.sh recover <binding> scripts/discord.sh recover <binding>
scripts/discord.sh reload <binding>
scripts/discord-service.sh render | install | uninstall | status <binding> scripts/discord-service.sh render | install | uninstall | status <binding>
``` ```
@@ -63,6 +64,22 @@ scripts/discord-service.sh render | install | uninstall | status <binding>
line it wrote itself; a brake an operator wrote at any point, even during 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 the recovery, stays and the start is refused. Nothing automatic ever
removes an operator's `STOP`. 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@<binding>` sends the same signal.
Exit codes: 0 ok, 1 operation failed, 2 invalid data or configuration, 3 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 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 | | `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 | | `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 | | `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 | | `engine` | `provider`, `model`, `thinking` for pi |
| `limits` | `turnsPerDay` (200), `turnTimeoutSeconds` (180), `replyChunkChars` (1900), `inboundMaxChars` (4000) | | `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 | | `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 Unknown keys, missing fields, wrong types, empty allowlists, a user channel
as a user all refuse with exit 2. 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 ## What happens to a message
@@ -172,6 +191,7 @@ start and names the nonces.
<dataRoot>/discord/<binding>/launches/ context snapshot and sha256 per run <dataRoot>/discord/<binding>/launches/ context snapshot and sha256 per run
<dataRoot>/discord/<binding>/STOP stop switch <dataRoot>/discord/<binding>/STOP stop switch
<dataRoot>/discord/<binding>/notices.jsonl once-per-day fixed lines already attempted <dataRoot>/discord/<binding>/notices.jsonl once-per-day fixed lines already attempted
<dataRoot>/discord/<binding>/reloads.jsonl one line per reload attempt, applied or refused
<dataRoot>/discord/<binding>/run.lock/ ownership directory (atomic mkdir) with owner.json {pid, start, boot}; stale ones need `unlock` <dataRoot>/discord/<binding>/run.lock/ ownership directory (atomic mkdir) with owner.json {pid, start, boot}; stale ones need `unlock`
<dataRoot>/sessions/discord-<binding>/ the pi session, continued across runs <dataRoot>/sessions/discord-<binding>/ the pi session, continued across runs
``` ```
@@ -11,7 +11,8 @@
{ "id": "100000000000000011", "name": "general", "mode": "mention" } { "id": "100000000000000011", "name": "general", "mode": "mention" }
], ],
"users": [ "users": [
{ "id": "100000000000000100", "name": "owner" } { "id": "100000000000000100", "name": "owner" },
{ "id": "100000000000000101", "name": "guest", "channels": ["100000000000000011"] }
], ],
"engine": { "provider": "zai", "model": "glm-5.3", "thinking": "high" }, "engine": { "provider": "zai", "model": "glm-5.3", "thinking": "high" },
"limits": { "limits": {
+6 -1
View File
@@ -28,6 +28,7 @@ export const DROP = Object.freeze({
SELF: "author-is-self", SELF: "author-is-self",
USER: "user-unlisted", USER: "user-unlisted",
CHANNEL: "channel-unlisted", CHANNEL: "channel-unlisted",
USER_CHANNEL: "channel-not-for-user",
THREAD_PARENT: "thread-parent-unlisted", THREAD_PARENT: "thread-parent-unlisted",
MENTION: "no-mention", 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 (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 (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 (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 channel = binding.channels.find((c) => c.id === message.channel_id);
let thread = null; let thread = null;
@@ -59,6 +61,9 @@ export function authorize(binding, message, channelInfo = () => undefined) {
if (!channel) return { ok: false, reason: DROP.THREAD_PARENT }; if (!channel) return { ok: false, reason: DROP.THREAD_PARENT };
thread = { id: message.channel_id, name: typeof info.name === "string" ? info.name : null }; 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 // 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. // 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 }; if (channel.mode === "mention" && !mentionsBot(message, binding.botUserId)) return { ok: false, reason: DROP.MENTION };
+49 -2
View File
@@ -7,6 +7,11 @@
// Loading fails closed: unknown key, missing field, wrong type, bad mode, // Loading fails closed: unknown key, missing field, wrong type, bad mode,
// symlink, empty allowlist. Nothing is defaulted silently except the limits' // symlink, empty allowlist. Nothing is defaulted silently except the limits'
// documented defaults below, which the fixture spells out anyway. // 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 { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
import { isAbsolute, join, resolve, sep } from "node:path"; 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 TOP_KEYS = ["bindingVersion", "name", "seat", "guildId", "guildName", "botUserId", "tokenFile", "channels", "users", "engine", "limits", "context"];
const CHANNEL_KEYS = ["id", "name", "mode"]; const CHANNEL_KEYS = ["id", "name", "mode"];
const USER_KEYS = ["id", "name"]; const USER_KEYS = ["id", "name", "channels"];
const ENGINE_KEYS = ["provider", "model", "thinking"]; const ENGINE_KEYS = ["provider", "model", "thinking"];
const LIMIT_KEYS = Object.keys(LIMIT_DEFAULTS); const LIMIT_KEYS = Object.keys(LIMIT_DEFAULTS);
const CONTEXT_KEYS = ["files"]; const CONTEXT_KEYS = ["files"];
@@ -123,7 +128,19 @@ export function validateBinding(raw, where = "binding") {
const w = `${where}.users[${i}]`; const w = `${where}.users[${i}]`;
if (!isObject(u)) throw new DiscordError(`${w}: not an object`); if (!isObject(u)) throw new DiscordError(`${w}: not an object`);
onlyKeys(u, USER_KEYS, w); 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 (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`); 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. // A private file: regular, not a symlink, owner-only (0600), non-empty.
export function checkPrivateFile(path, what) { export function checkPrivateFile(path, what) {
let st; let st;
+58 -6
View File
@@ -5,6 +5,7 @@
// mosaic-discord stop <binding> [--config PATH] // mosaic-discord stop <binding> [--config PATH]
// mosaic-discord unlock <binding> [--config PATH] // mosaic-discord unlock <binding> [--config PATH]
// mosaic-discord recover <binding> [--config PATH] // mosaic-discord recover <binding> [--config PATH]
// mosaic-discord reload <binding> [--config PATH]
// //
// <binding> names <dataRoot>/discord/<binding>.json. The repository wrapper // <binding> names <dataRoot>/discord/<binding>.json. The repository wrapper
// is scripts/discord.sh. // is scripts/discord.sh.
@@ -33,6 +34,14 @@
// honours a never-retry exit status from the main process, not from a // honours a never-retry exit status from the main process, not from a
// pre-start command. // 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; // 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 // 3 refused by a brake (STOP present or the binding held; a supervisor must
// not retry); 4 usage. // not retry); 4 usage.
@@ -41,13 +50,13 @@ import { existsSync, mkdirSync, mkdtempSync, writeFileSync, statSync, readdirSyn
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { DiscordError } from "./errors.mjs"; 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 { createRest } from "./rest.mjs";
import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs"; import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
import { createEngine, buildPiArgs } from "./engine-pi.mjs"; import { createEngine, buildPiArgs } from "./engine-pi.mjs";
import { assembleContext } from "./context.mjs"; import { assembleContext } from "./context.mjs";
import { createConnector } from "./connector.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 = [ const USAGE = [
"usage: mosaic-discord check <binding> [--config PATH] [--repo PATH]", "usage: mosaic-discord check <binding> [--config PATH] [--repo PATH]",
@@ -55,6 +64,7 @@ const USAGE = [
" mosaic-discord stop <binding> [--config PATH]", " mosaic-discord stop <binding> [--config PATH]",
" mosaic-discord unlock <binding> [--config PATH]", " mosaic-discord unlock <binding> [--config PATH]",
" mosaic-discord recover <binding> [--config PATH]", " mosaic-discord recover <binding> [--config PATH]",
" mosaic-discord reload <binding> [--config PATH]",
].join("\n"); ].join("\n");
function parse(argv) { function parse(argv) {
@@ -74,7 +84,7 @@ function parse(argv) {
else throw new DiscordError(`unexpected argument: ${a}\n${USAGE}`, 4); else throw new DiscordError(`unexpected argument: ${a}\n${USAGE}`, 4);
} }
if (opts.command === "help") return opts; 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.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); if (opts.supervised && opts.command !== "run") throw new DiscordError(`--supervised applies to run only\n${USAGE}`, 4);
return opts; return opts;
@@ -90,14 +100,15 @@ function warn(msg) {
// Everything that can be checked without the network, shared by check and run. // Everything that can be checked without the network, shared by check and run.
function prepare(opts) { function prepare(opts) {
const dataRoot = loadDataRoot(opts.config); 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}`); 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 contextFiles = resolveContextFiles(binding, opts.repo);
const pi = join(opts.repo, "node_modules", ".bin", "pi"); 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`); if (!existsSync(pi)) throw new DiscordError(`pi not found at ${pi}; run npm ci in the repository`);
const journalDir = bindingDataDir(dataRoot, binding.name); const journalDir = bindingDataDir(dataRoot, binding.name);
const sessionDir = join(dataRoot, "sessions", `discord-${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) { async function check(opts) {
@@ -148,7 +159,7 @@ async function check(opts) {
} }
async function run(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); const token = readToken(binding);
ensureJournal(journalDir); ensureJournal(journalDir);
if (opts.supervised) { if (opts.supervised) {
@@ -216,6 +227,34 @@ async function run(opts) {
process.on("SIGTERM", () => shutdown(0)); process.on("SIGTERM", () => shutdown(0));
process.on("SIGINT", () => 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(); 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(`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}`); say(`stop with: scripts/discord.sh stop ${binding.name}`);
@@ -261,6 +300,18 @@ function unlockCommand(opts) {
say("remove STOP to run again"); 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) { function recoverCommand(opts) {
const dataRoot = loadDataRoot(opts.config); const dataRoot = loadDataRoot(opts.config);
const binding = loadBinding(bindingPath(dataRoot, opts.binding)); 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 === "run") await run(opts);
else if (opts.command === "unlock") unlockCommand(opts); else if (opts.command === "unlock") unlockCommand(opts);
else if (opts.command === "recover") recoverCommand(opts); else if (opts.command === "recover") recoverCommand(opts);
else if (opts.command === "reload") reloadCommand(opts);
else stop(opts); else stop(opts);
return opts.command === "run" ? null : 0; return opts.command === "run" ? null : 0;
} }
+18 -3
View File
@@ -11,7 +11,10 @@
// window is marked refused, not re-sent, because a re-send could post a // 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. // 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. // 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 // UTC date; an admission is written before the engine runs, so turns in
// flight and turns cut short by a crash count too. // flight and turns cut short by a crash count too.
// Over the ceiling, inbound messages are journaled as drops, one fixed // Over the ceiling, inbound messages are journaled as drops, one fixed
@@ -21,6 +24,7 @@
// timers. The offline suite drives this with fakes. // timers. The offline suite drives this with fakes.
import { authorize, isThreadType } from "./authorize.mjs"; import { authorize, isThreadType } from "./authorize.mjs";
import { reloadDiff } from "./binding.mjs";
import { envelope, splitReply } from "./context.mjs"; import { envelope, splitReply } from "./context.mjs";
import { import {
ensureJournal, appendInbox, readInboxIds, appendOutbox, unresolvedOutbox, appendDrop, 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 const RECONCILE_WINDOW_MS = 5 * 60 * 1000;
export function createConnector({ export function createConnector({
binding, journalDir, rest, gateway, engine, binding: initialBinding, journalDir, rest, gateway, engine,
now = () => Date.now(), now = () => Date.now(),
setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout, setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout,
typingIntervalMs = 8000, typingIntervalMs = 8000,
readReceipt = READ_RECEIPT, readReceipt = READ_RECEIPT,
log = () => {}, 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); if (!v) throw new DiscordError(`connector: ${k} required`, 1);
} }
ensureJournal(journalDir); ensureJournal(journalDir);
let binding = initialBinding;
const state = { const state = {
inbox: new Set(), channels: new Map(), inFlight: 0, typingTimer: null, typingChannel: null, inbox: new Set(), channels: new Map(), inFlight: 0, typingTimer: null, typingChannel: null,
@@ -308,6 +313,16 @@ export function createConnector({
onDispatch, onDispatch,
reconcile, reconcile,
rememberChannel, 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() { get inFlight() {
return state.inFlight; return state.inFlight;
}, },
+14
View File
@@ -10,6 +10,9 @@
// ({at, reason}), appended, so the last line names who // ({at, reason}), appended, so the last line names who
// braked; `recover` removes only a STOP it wrote itself // braked; `recover` removes only a STOP it wrote itself
// notices.jsonl once-per-day fixed lines already attempted (ceiling) // 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 // run.lock/ ownership directory (mkdir is atomic) holding owner.json
// {pid, start, boot}; `stop` signals only a live pid whose // {pid, start, boot}; `stop` signals only a live pid whose
// start time and boot id match; a stale lock refuses `run` // 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 }); 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 --- // --- notices ---
// Fixed lines that must go out at most once per UTC day (the ceiling // 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 // notice). The line is appended before the delivery attempt, so a crash
@@ -20,6 +20,10 @@ Environment=PATH=@PATH@
# the main process on purpose: systemd honours RestartPreventExitStatus only # the main process on purpose: systemd honours RestartPreventExitStatus only
# for the main process, so a refusing ExecStartPre would loop. # for the main process, so a refusing ExecStartPre would loop.
ExecStart=@REPO@/scripts/discord.sh run %i --supervised 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 Restart=on-failure
RestartSec=15 RestartSec=15
RestartPreventExitStatus=3 RestartPreventExitStatus=3
+12
View File
@@ -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)", () => { test("authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers)", () => {
let looked = 0; let looked = 0;
const spy = (id) => { const spy = (id) => {
+57 -1
View File
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { chmodSync, mkdirSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { chmodSync, mkdirSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { spawnSync } from "node:child_process"; 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 { DiscordError } from "../src/errors.mjs";
import { makeRoot, makeRepo, makeDeployment, rawBinding } from "./helpers.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/); 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", () => { test("binding: file must be 0600, regular, not a symlink", () => {
const root = makeRoot(); const root = makeRoot();
const dep = makeDeployment(root); 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); 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", () => { test("cli: run refuses when STOP is present, before any network use", () => {
const root = makeRoot(); const root = makeRoot();
const repo = makeRepo(root); const repo = makeRepo(root);
+41
View File
@@ -5,6 +5,7 @@ import { join } from "node:path";
import { createConnector, FIXED_LINES, RECONCILE_WINDOW_MS, READ_RECEIPT } from "../src/connector.mjs"; 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 { 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 { makeRoot, binding, message, IDS, fakeRest, fakeGateway, fakeEngine } from "./helpers.mjs";
import { DiscordError } from "../src/errors.mjs";
function setup({ bindingOverrides = {}, replies = [], outcomes = [], now, engineOverrides = {} } = {}) { function setup({ bindingOverrides = {}, replies = [], outcomes = [], now, engineOverrides = {} } = {}) {
const root = makeRoot(); 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 }); assert.deepEqual(turns[0].receipt, { emoji: READ_RECEIPT, ok: false });
await connector.stop(); 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();
});
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# `scripts/discord.sh check|run|stop|unlock <binding>`: the Discord connector for # `scripts/discord.sh check|run|stop|unlock|recover|reload <binding>`: the Discord connector for
# one seat on one server. See packages/discord/README.md. The binding lives # one seat on one server. See packages/discord/README.md. The binding lives
# at <dataRoot>/discord/<binding>.json (0600, never committed); the token # at <dataRoot>/discord/<binding>.json (0600, never committed); the token
# stays in the seat's private secrets directory and is read at runtime only. # stays in the seat's private secrets directory and is read at runtime only.
+5 -1
View File
@@ -74,6 +74,9 @@ check "scripts/discord.sh check without a binding exits 4" $?
scripts/discord.sh recover >/dev/null 2>&1 scripts/discord.sh recover >/dev/null 2>&1
[ $? -eq 4 ] [ $? -eq 4 ]
check "scripts/discord.sh recover without a binding exits 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 --- # --- the service unit: rendered from the template, never touching systemd here ---
UNITS="$SANDBOX/units" 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 'RestartPreventExitStatus=3' "$SANDBOX/unit.rendered" \
&& grep -qx 'Restart=on-failure' "$SANDBOX/unit.rendered" \ && grep -qx 'Restart=on-failure' "$SANDBOX/unit.rendered" \
&& grep -qx 'KillSignal=SIGTERM' "$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" && ! 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 \ scripts/discord-service.sh install --dir "$UNITS" --no-reload >"$SANDBOX/install.1" 2>&1 \
&& [ -f "$UNITS/[email protected]" ] \ && [ -f "$UNITS/[email protected]" ] \
&& grep -q '^written:' "$SANDBOX/install.1" \ && grep -q '^written:' "$SANDBOX/install.1" \