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]>
365 lines
18 KiB
JavaScript
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 } } : {}) };
|
|
}
|