// 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 /discord/.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`, users), }); } 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, users) { if (raw !== null && typeof raw === "object" && Object.hasOwn(raw, "approvers")) throw new DiscordError(`${where}.approvers: not allowed; approvers come from users`); const approvers = {}; for (const u of users) { const name = u.name.trim().toLowerCase(); if (Object.hasOwn(approvers, name)) throw new DiscordError(`${where}: two users named ${name}; approver names must be distinct`); approvers[name] = u.id; } try { return loadSetsparkConfig(raw !== null && typeof raw === "object" && !Array.isArray(raw) ? { ...raw, approvers } : 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 } : {}) } } : {}) }; }); // setspark carries only the keys loadSetsparkConfig accepts; the extension // re-validates it and adds the response cap itself. const ss = binding.tools.setspark; return { roots, maxFileBytes: binding.tools.maxFileBytes, maxCallsPerTurn: binding.tools.maxCallsPerTurn, ...(binding.tools.web ? { web: { searxng: binding.tools.web.searxng, maxFetchBytes: binding.tools.web.maxFetchBytes } } : {}), ...(ss ? { setspark: { baseUrl: ss.baseUrl, keyFile: ss.keyFile, principal: ss.principal, timeoutMs: ss.timeoutMs, approvers: { ...ss.approvers } } } : {}) }; }