feat(discord): connector pilot for the Sage seat, reviewed candidate (#1509)

Zero-dependency Discord connector under packages/discord: binding
validation, REST and gateway clients, pi engine adapter, journal with
append-only inbox, outbox, admissions and notices, and a run.lock
ownership record {pid, start, boot} whose identity is checked three ways
and whose cleanup is gated by STOP. CLI check|run|stop|unlock via
scripts/discord.sh; offline suite scripts/test-discord.sh (28 checks,
87 node tests).

Reviewed by rev-code-02 on #1509 over nine rounds; approved exact tree
4e0feb6758c0a7e4a71483912a8e0d3e3ec95aef at comment 26170. Corrections
(1) to (12) recorded in BUILD-LOG. No listener started, no token read,
no Discord write; the live pilot follows this commit per the brief.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-13 01:15:37 -05:00
co-authored by Claude Fable 5.1
parent b023841c8a
commit 786e379c49
34 changed files with 4641 additions and 1 deletions
+229
View File
@@ -0,0 +1,229 @@
// A binding is deployment policy for one seat on one Discord server: which
// guild, which channels in which mode, which people, which engine, which
// limits. It carries Discord IDs of real people, so it lives under the data
// root at <dataRoot>/discord/<name>.json, mode 0600, and is never committed.
// The repository holds this schema and fixtures/binding.example.json.
//
// 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.
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
import { isAbsolute, join, resolve, sep } from "node:path";
import { homedir } from "node:os";
import { DiscordError } from "./errors.mjs";
export const BINDING_VERSION = 1;
export const BINDING_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
export const SNOWFLAKE = /^[0-9]{17,20}$/;
export const CHANNEL_MODES = Object.freeze(["open", "mention"]);
export const THINKING_LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
export const LIMIT_DEFAULTS = Object.freeze({
turnsPerDay: 200,
turnTimeoutSeconds: 180,
replyChunkChars: 1900,
inboundMaxChars: 4000,
});
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 ENGINE_KEYS = ["provider", "model", "thinking"];
const LIMIT_KEYS = Object.keys(LIMIT_DEFAULTS);
const CONTEXT_KEYS = ["files"];
export function defaultConfigPath(env = process.env) {
return env.MOSAIC_CONFIG ? resolve(env.MOSAIC_CONFIG) : join(homedir(), ".config", "mosaic-dev", "config.json");
}
// Only dataRoot is read here; scripts/mosaic-config.mjs owns full validation.
export function loadDataRoot(path = defaultConfigPath()) {
if (!existsSync(path)) throw new DiscordError(`config not found: ${path}`);
let raw;
try {
raw = JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
throw new DiscordError(`config is not valid JSON: ${path} (${err.message})`);
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DiscordError(`config is not an object: ${path}`);
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new DiscordError(`config.dataRoot must be an absolute path: ${path}`);
return raw.dataRoot;
}
export function discordDir(dataRoot) {
return join(dataRoot, "discord");
}
export function bindingPath(dataRoot, name) {
if (!BINDING_NAME.test(String(name))) throw new DiscordError(`invalid binding name: ${JSON.stringify(name)}`, 4);
return join(discordDir(dataRoot), `${name}.json`);
}
export function bindingDataDir(dataRoot, name) {
if (!BINDING_NAME.test(String(name))) throw new DiscordError(`invalid binding name: ${JSON.stringify(name)}`, 4);
return join(discordDir(dataRoot), name);
}
function isObject(v) {
return v !== null && typeof v === "object" && !Array.isArray(v);
}
function onlyKeys(obj, allowed, where) {
for (const key of Object.keys(obj)) {
if (!allowed.includes(key)) throw new DiscordError(`${where}: unknown key ${JSON.stringify(key)}`);
}
}
function requireString(obj, key, where, pattern, what) {
const v = obj[key];
if (typeof v !== "string" || v.length === 0) throw new DiscordError(`${where}: ${key} must be a non-empty string`);
if (pattern && !pattern.test(v)) throw new DiscordError(`${where}: ${key} is not ${what}: ${JSON.stringify(v)}`);
return v;
}
function requireSnowflake(obj, key, where) {
return requireString(obj, key, where, SNOWFLAKE, "a Discord snowflake id");
}
function requireInteger(obj, key, where, { min, max }) {
const v = obj[key];
if (!Number.isInteger(v) || v < min || v > max) throw new DiscordError(`${where}: ${key} must be an integer in ${min}..${max}`);
return v;
}
// Validate an already-parsed object. Returns a frozen normalized binding.
export function validateBinding(raw, where = "binding") {
if (!isObject(raw)) throw new DiscordError(`${where}: not an object`);
onlyKeys(raw, TOP_KEYS, where);
if (raw.bindingVersion !== BINDING_VERSION) throw new DiscordError(`${where}: bindingVersion must be ${BINDING_VERSION}`);
const name = requireString(raw, "name", where, BINDING_NAME, "a binding name");
const seat = requireString(raw, "seat", where, BINDING_NAME, "a seat name");
const guildId = requireSnowflake(raw, "guildId", where);
const guildName = requireString(raw, "guildName", where);
const botUserId = requireSnowflake(raw, "botUserId", where);
const tokenFile = requireString(raw, "tokenFile", where);
if (!isAbsolute(tokenFile)) throw new DiscordError(`${where}: tokenFile must be an absolute path`);
if (!Array.isArray(raw.channels) || raw.channels.length === 0) throw new DiscordError(`${where}: channels must be a non-empty array`);
const channels = raw.channels.map((c, i) => {
const w = `${where}.channels[${i}]`;
if (!isObject(c)) throw new DiscordError(`${w}: not an object`);
onlyKeys(c, CHANNEL_KEYS, w);
const id = requireSnowflake(c, "id", w);
const cname = requireString(c, "name", w);
const mode = requireString(c, "mode", w);
if (!CHANNEL_MODES.includes(mode)) throw new DiscordError(`${w}: mode must be one of ${CHANNEL_MODES.join(", ")}`);
return Object.freeze({ id, name: cname, mode });
});
if (new Set(channels.map((c) => c.id)).size !== channels.length) throw new DiscordError(`${where}: duplicate channel id`);
if (!Array.isArray(raw.users) || raw.users.length === 0) throw new DiscordError(`${where}: users must be a non-empty array`);
const users = raw.users.map((u, i) => {
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) });
});
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 (!isObject(raw.engine)) throw new DiscordError(`${where}: engine must be an object`);
onlyKeys(raw.engine, ENGINE_KEYS, `${where}.engine`);
const provider = requireString(raw.engine, "provider", `${where}.engine`, /^[a-z0-9][a-z0-9._-]*$/, "a provider id");
const model = requireString(raw.engine, "model", `${where}.engine`, /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/, "a model id");
const thinking = requireString(raw.engine, "thinking", `${where}.engine`);
if (!THINKING_LEVELS.includes(thinking)) throw new DiscordError(`${where}.engine: thinking must be one of ${THINKING_LEVELS.join(", ")}`);
const rawLimits = raw.limits === undefined ? {} : raw.limits;
if (!isObject(rawLimits)) throw new DiscordError(`${where}: limits must be an object`);
onlyKeys(rawLimits, LIMIT_KEYS, `${where}.limits`);
const merged = { ...LIMIT_DEFAULTS, ...rawLimits };
const limits = Object.freeze({
turnsPerDay: requireInteger(merged, "turnsPerDay", `${where}.limits`, { min: 0, max: 100000 }),
turnTimeoutSeconds: requireInteger(merged, "turnTimeoutSeconds", `${where}.limits`, { min: 5, max: 3600 }),
replyChunkChars: requireInteger(merged, "replyChunkChars", `${where}.limits`, { min: 100, max: 2000 }),
inboundMaxChars: requireInteger(merged, "inboundMaxChars", `${where}.limits`, { min: 100, max: 4000 }),
});
if (!isObject(raw.context)) throw new DiscordError(`${where}: context must be an object`);
onlyKeys(raw.context, CONTEXT_KEYS, `${where}.context`);
if (!Array.isArray(raw.context.files) || raw.context.files.length === 0) throw new DiscordError(`${where}.context: files must be a non-empty array`);
const files = raw.context.files.map((f, i) => {
if (typeof f !== "string" || f.length === 0) throw new DiscordError(`${where}.context.files[${i}]: must be a non-empty string`);
if (f.includes("\0")) throw new DiscordError(`${where}.context.files[${i}]: invalid path`);
return f;
});
return Object.freeze({
bindingVersion: BINDING_VERSION,
name, seat, guildId, guildName, botUserId, tokenFile,
channels: Object.freeze(channels),
users: Object.freeze(users),
engine: Object.freeze({ provider, model, thinking }),
limits,
context: Object.freeze({ files: Object.freeze(files) }),
});
}
// A private file: regular, not a symlink, owner-only (0600), non-empty.
export function checkPrivateFile(path, what) {
let st;
try {
st = lstatSync(path);
} catch {
throw new DiscordError(`${what} not found: ${path}`);
}
if (st.isSymbolicLink()) throw new DiscordError(`${what} must not be a symlink: ${path}`);
if (!st.isFile()) throw new DiscordError(`${what} is not a regular file: ${path}`);
const mode = st.mode & 0o777;
if (mode !== 0o600) throw new DiscordError(`${what} must be mode 0600, is ${mode.toString(8).padStart(4, "0")}: ${path}`);
if (st.size === 0) throw new DiscordError(`${what} is empty: ${path}`);
return st;
}
export function loadBinding(path) {
checkPrivateFile(path, "binding");
let raw;
try {
raw = JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
throw new DiscordError(`binding is not valid JSON: ${path} (${err.message})`);
}
return validateBinding(raw, `binding ${path}`);
}
// The token is read once into memory and handed to the REST and gateway
// clients. It is never printed, journaled, or put on a command line.
export function readToken(binding) {
checkPrivateFile(binding.tokenFile, "token file");
const token = readFileSync(binding.tokenFile, "utf8").trim();
if (!/^[A-Za-z0-9._-]{20,}$/.test(token)) throw new DiscordError(`token file does not hold a bot token: ${binding.tokenFile}`);
return token;
}
// Context files are repository-relative and stay inside the repository:
// no absolute paths, no `..`, no symlinks, and the real path must sit under
// the repository's real path. The launch snapshot copies their contents into
// the model's prompt, so this is the boundary that keeps host files out of
// Discord Sage (Q14, Q16). Every file must be a regular non-empty file.
export function resolveContextFiles(binding, repo) {
const root = realpathSync(repo);
return binding.context.files.map((f) => {
if (typeof f !== "string" || f.length === 0) throw new DiscordError("context file must be a non-empty string");
if (isAbsolute(f)) throw new DiscordError(`context file must be repository-relative: ${f}`);
if (f.split(/[\\/]/).includes("..")) throw new DiscordError(`context file must not escape the repository: ${f}`);
const path = resolve(root, f);
let st;
try {
st = lstatSync(path);
} catch {
throw new DiscordError(`missing context file: ${path}`);
}
if (st.isSymbolicLink()) throw new DiscordError(`context file must not be a symlink: ${path}`);
if (!st.isFile() || st.size === 0) throw new DiscordError(`context file is not a regular non-empty file: ${path}`);
const real = realpathSync(path);
if (real !== path || !real.startsWith(root + sep)) throw new DiscordError(`context file resolves outside the repository: ${f}`);
return path;
});
}