Files
stack/packages/discord/src/binding.mjs
T
jason.woltjeandClaude Fable 5.1 43d7574d6a feat(discord): SetSpark record client for the Discord Sage, fixed verbs against setspark-api, connector-verified approvals (#1509)
Row 25, parts 2a and 2b, against the shared-signals contract a5425a2.

Model side: eight fixed verbs in the pi extension (record_list, record_get,
record_create, record_update, resolve_id, open_approval_request,
get_approval_request, create_document), each one HTTP call with arguments
checked before any request. Writes carry an idempotency key
<principal>:<message id>:<call index> and an audit context. The seat key is
read from a 0600 file on every call and never cached, printed or journaled.

Connector side: append-only approval ledger, Approve button and exact
"approve" reply resolved by the connector against the required approvers,
confirmation message posted as button evidence, bind and add_approval through
the service under connector keys, retry of unknown entries on start.

Evidence: node tests 162 pass, scripts/test-discord.sh 63/63. Review by
rev-code-02, round 1 approved (#1509 comment 26467, tree 7872d8c5).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-22 12:59:39 -05:00

365 lines
18 KiB
JavaScript

// 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.
//
// 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.
//
// An optional `tools` key declares the roots for the Discord Sage's file
// tools (see tools.mjs): read-only unless a root says `write: true`.
// Absent means no tools and a launch exactly as before. It is a fixed
// key: the extension reads it at pi start.
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
import { loadWebConfig } from "./web.mjs";
import { loadSetsparkConfig } from "./setspark.mjs";
import { isAbsolute, join, resolve, sep } from "node:path";
import { homedir } from "node:os";
import { DiscordError } from "./errors.mjs";
import { TOOL_DEFAULTS } from "./tools.mjs";
import { loadGitConfig } from "./git.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", "tools"];
const CHANNEL_KEYS = ["id", "name", "mode"];
const USER_KEYS = ["id", "name", "channels"];
const ENGINE_KEYS = ["provider", "model", "thinking"];
const LIMIT_KEYS = Object.keys(LIMIT_DEFAULTS);
const CONTEXT_KEYS = ["files"];
const TOOLS_KEYS = ["roots", "maxFileBytes", "maxCallsPerTurn", "web", "setspark"];
const ROOT_KEYS = ["name", "path", "write", "git"];
const ROOT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
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);
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`);
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;
});
let tools = null;
if (raw.tools !== undefined) {
if (!isObject(raw.tools)) throw new DiscordError(`${where}: tools must be an object`);
onlyKeys(raw.tools, TOOLS_KEYS, `${where}.tools`);
if (!Array.isArray(raw.tools.roots) || raw.tools.roots.length === 0) throw new DiscordError(`${where}.tools: roots must be a non-empty array`);
const roots = raw.tools.roots.map((r, i) => {
const w = `${where}.tools.roots[${i}]`;
if (!isObject(r)) throw new DiscordError(`${w}: not an object`);
onlyKeys(r, ROOT_KEYS, w);
const rname = requireString(r, "name", w, ROOT_NAME, "a root name");
const rpath = requireString(r, "path", w);
if (!isAbsolute(rpath) || rpath.includes("\0")) throw new DiscordError(`${w}: path must be an absolute path`);
if (rpath.split(sep).some((seg) => seg.startsWith(".") && seg.length > 0)) throw new DiscordError(`${w}: path must not have a dot-prefixed segment (${rpath})`);
if (resolve(rpath) === sep || resolve(rpath) === homedir()) throw new DiscordError(`${w}: path must not be the filesystem root or the home directory`);
if (r.write !== undefined && r.write !== true && r.write !== false) throw new DiscordError(`${w}: write must be true or false`);
let git = null;
if (r.git !== undefined) {
if (r.write !== true) throw new DiscordError(`${w}: git needs write: true`);
try {
git = loadGitConfig(r.git, `${w}.git`, null);
} catch (err) {
throw new DiscordError(err.message);
}
}
return Object.freeze({ name: rname, path: rpath, write: r.write === true, git });
});
if (new Set(roots.map((r) => r.name)).size !== roots.length) throw new DiscordError(`${where}.tools: duplicate root name`);
const mergedTools = { ...TOOL_DEFAULTS, ...raw.tools, roots };
tools = Object.freeze({
roots: Object.freeze(roots),
maxFileBytes: requireInteger(mergedTools, "maxFileBytes", `${where}.tools`, { min: 1024, max: 4 * 1024 * 1024 }),
maxCallsPerTurn: requireInteger(mergedTools, "maxCallsPerTurn", `${where}.tools`, { min: 1, max: 64 }),
web: raw.tools.web === undefined ? null : webConfig(raw.tools.web, `${where}.tools.web`),
setspark: raw.tools.setspark === undefined ? null : setsparkConfig(raw.tools.setspark, `${where}.tools.setspark`),
});
}
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) }),
tools,
});
}
// 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", "tools"]);
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;
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;
});
}
// Tool roots must exist as real directories on this host, not symlinks, and
// must not sit inside the data root (bindings, tokens, journals) or contain
// it. Returns the resolved config the engine hands the extension.
function setsparkConfig(raw, where) {
try {
return loadSetsparkConfig(raw, where);
} catch (err) {
throw new DiscordError(err.message);
}
}
function webConfig(raw, where) {
try {
return loadWebConfig(raw, where);
} catch (err) {
throw new DiscordError(err.message);
}
}
export function resolveToolRoots(binding, { dataRoot }) {
if (!binding.tools) return null;
const data = existsSync(dataRoot) ? realpathSync(dataRoot) : resolve(dataRoot);
const roots = binding.tools.roots.map((r) => {
let st;
try {
st = lstatSync(r.path);
} catch {
throw new DiscordError(`tool root ${r.name} does not exist: ${r.path}`);
}
if (st.isSymbolicLink()) throw new DiscordError(`tool root ${r.name} must not be a symlink: ${r.path}`);
if (!st.isDirectory()) throw new DiscordError(`tool root ${r.name} is not a directory: ${r.path}`);
const real = realpathSync(r.path);
if (real === data || real.startsWith(data + sep) || data.startsWith(real + sep)) throw new DiscordError(`tool root ${r.name} overlaps the data root: ${r.path}`);
return { name: r.name, path: real, write: r.write, ...(r.git ? { git: { branch: r.git.branch, identity: r.git.identity, tokenFile: r.git.tokenFile, author: `${r.git.author.name} <${r.git.author.email}>`, ...(r.git.protocol ? { protocol: r.git.protocol } : {}) } } : {}) };
});
return { roots, maxFileBytes: binding.tools.maxFileBytes, maxCallsPerTurn: binding.tools.maxCallsPerTurn, ...(binding.tools.web ? { web: { searxng: binding.tools.web.searxng, maxFetchBytes: binding.tools.web.maxFetchBytes } } : {}) };
}