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]>
This commit is contained in:
2026-09-22 12:59:39 -05:00
co-authored by Claude Fable 5.1
parent 1949ed8d31
commit 43d7574d6a
24 changed files with 2178 additions and 32 deletions
+33 -6
View File
@@ -49,13 +49,15 @@ import { isAbsolute, join, sep } from "node:path";
import { randomBytes } from "node:crypto";
import { WEB_TOOL_NAMES, WEB_TOOL_DESCRIPTIONS, FETCH_MAX_TEXT_CHARS, WebRefusal, loadWebConfig, webFetch, webSearch } from "./web.mjs";
import { GIT_TOOL_NAMES, RESERVE_TOOL_NAME, GIT_REFUSAL, GitRefusal, COMMIT_MESSAGE_MAX, COMMIT_PATHS_MAX, VAULT_PREFIXES, VAULT_REGISTRY, loadGitConfig, gitStatus, gitCommit, gitPull, gitPush, reserveId, withVaultLock } from "./git.mjs";
import { SETSPARK_TOOL_NAMES, SETSPARK_TOOL_DESCRIPTIONS, SetsparkRefusal, loadSetsparkConfig, setsparkVerbs, renderSetspark, renderRefusal as renderSetsparkRefusal, setsparkDetails } from "./setspark.mjs";
export const TOOL_NAMES = Object.freeze(["list_dir", "read_file", "search"]);
export const WRITE_TOOL_NAMES = Object.freeze(["write_file", "edit_file"]);
// The tools a config enables, in the order pi's --tools list names them:
// the reads always, the writes with a writable root, the web pair with a
// web key, the git verbs with a root that carries a git key, reserve_id
// with a root whose git key names the vault protocol.
// with a root whose git key names the vault protocol, the SetSpark verbs
// with a setspark key.
export function enabledToolNames(config) {
const names = [...TOOL_NAMES];
const roots = config && Array.isArray(config.roots) ? config.roots : [];
@@ -63,6 +65,7 @@ export function enabledToolNames(config) {
if (config && config.web) names.push(...WEB_TOOL_NAMES);
if (roots.some((r) => r.git)) names.push(...GIT_TOOL_NAMES);
if (roots.some((r) => r.git && r.git.protocol === "vault")) names.push(RESERVE_TOOL_NAME);
if (config && config.setspark) names.push(...SETSPARK_TOOL_NAMES);
return names;
}
export const TOOLS_ENV = "MOSAIC_DISCORD_TOOLS";
@@ -135,7 +138,7 @@ const ROOT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
export function loadToolsConfig(raw, where = TOOLS_ENV) {
if (!isObject(raw)) throw new Error(`${where}: not an object`);
for (const k of Object.keys(raw)) {
if (!["roots", "maxFileBytes", "maxCallsPerTurn", "web"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
if (!["roots", "maxFileBytes", "maxCallsPerTurn", "web", "setspark"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
}
if (!Array.isArray(raw.roots) || raw.roots.length === 0) throw new Error(`${where}: roots must be a non-empty array`);
const roots = raw.roots.map((r, i) => {
@@ -174,6 +177,7 @@ export function loadToolsConfig(raw, where = TOOLS_ENV) {
maxFileBytes: int("maxFileBytes", 1024, 4 * 1024 * 1024),
maxCallsPerTurn: int("maxCallsPerTurn", 1, 64),
web: raw.web === undefined ? null : loadWebConfig(raw.web, `${where}.web`),
setspark: raw.setspark === undefined ? null : loadSetsparkConfig(raw.setspark, `${where}.setspark`),
});
}
@@ -543,7 +547,10 @@ function commitPaths(root, paths) {
// The web tools are asynchronous; call() returns a promise for them and a
// plain result for the file tools, and the extension awaits either. The
// git verbs read `state.requester`, which the extension sets from each
// message's envelope before the run starts.
// message's envelope before the run starts; the SetSpark writes also read
// the turn id and the author id from there, and the call index from the
// budget counter, to form their idempotency keys.
const SETSPARK_FNS = Object.fromEntries(SETSPARK_TOOL_NAMES.map((name) => [name, (config, params, state) => setsparkVerbs[name](config.setspark, params, state)]));
const TOOL_FNS = Object.freeze({
list_dir: listDir, read_file: readFile, search, write_file: writeFile, edit_file: editFile,
web_fetch: (config, params) => webFetch(config.web, params),
@@ -556,9 +563,12 @@ const TOOL_FNS = Object.freeze({
git_pull: (config, { root }) => gitPull(gitRoot(config, root)),
git_push: (config, { root }) => gitPush(gitRoot(config, root)),
reserve_id: (config, { root, prefix, title }) => reserveId(gitRoot(config, root), { prefix, title }),
...SETSPARK_FNS,
});
const SETSPARK_SET = new Set(SETSPARK_TOOL_NAMES);
function render(name, out) {
if (SETSPARK_SET.has(name)) return renderSetspark(name, out);
if (name === "list_dir") {
const head = `${out.root}/${out.path}`.replace(/\/$/, "");
const body = out.entries.map((e) => (e.type === "dir" ? `${e.name}/` : `${e.name} (${e.bytes} bytes)`)).join("\n");
@@ -614,7 +624,7 @@ function render(name, out) {
// is a bug and propagates.
export function createToolSet(config) {
let calls = 0;
const state = { requester: null };
const state = { requester: null, turnId: null, authorId: null, callIndex: 0 };
const enabled = new Set(enabledToolNames(config));
const call = (name, params) => {
const fn = enabled.has(name) ? TOOL_FNS[name] : undefined;
@@ -626,9 +636,11 @@ export function createToolSet(config) {
return { ok: false, text: `refused: ${REFUSAL.BUDGET}`, details: { ...base, ok: false, reason: REFUSAL.BUDGET, ms: 0 } };
}
calls += 1;
state.callIndex = calls;
const done = (out) => {
const bytes = name === "list_dir" || name === "search" || name === "web_search" || name.startsWith("git_") || name === "reserve_id" ? undefined : out.bytes;
const extra = name === "web_fetch" ? { url: out.finalUrl, status: out.status }
const bytes = name === "list_dir" || name === "search" || name === "web_search" || name.startsWith("git_") || name === "reserve_id" || SETSPARK_SET.has(name) ? undefined : out.bytes;
const extra = SETSPARK_SET.has(name) ? setsparkDetails(name, out)
: name === "web_fetch" ? { url: out.finalUrl, status: out.status }
: name === "web_search" ? { hits: out.results.length }
: name === "git_commit" ? { hash: out.hash, pushed: out.pushed, paths: out.paths, requester: out.requester }
: name === "git_push" ? { hash: out.hash, pushed: true }
@@ -639,6 +651,9 @@ export function createToolSet(config) {
return { ok: true, text: render(name, out), details: { ...base, ok: true, ...extra, ...(bytes === undefined ? {} : { bytes }), ms: Date.now() - t0 } };
};
const refused = (err) => {
if (err instanceof SetsparkRefusal) {
return { ok: false, text: renderSetsparkRefusal(err), details: { ...base, ok: false, reason: err.reason, ...(err.code ? { code: err.code } : {}), ...(err.status ? { status: err.status } : {}), ms: Date.now() - t0 } };
}
if (!(err instanceof Refusal) && !(err instanceof WebRefusal) && !(err instanceof GitRefusal)) throw err;
const reason = err instanceof GitRefusal ? err.message : err.reason;
return { ok: false, text: `refused: ${reason}`, details: { ...base, ok: false, reason, ...(err.status ? { status: err.status } : {}), ms: Date.now() - t0 } };
@@ -662,6 +677,17 @@ export function createToolSet(config) {
setRequester(name) {
state.requester = typeof name === "string" && name.length > 0 ? name : null;
},
// The running message's id and author id, from the envelope, for the
// SetSpark write keys and the audit context. Unset between messages, so
// a write outside a message is refused.
setTurn({ requester = null, turnId = null, authorId = null } = {}) {
state.requester = typeof requester === "string" && requester.length > 0 ? requester : null;
state.turnId = typeof turnId === "string" && /^[0-9]{15,20}$/.test(turnId) ? turnId : null;
state.authorId = typeof authorId === "string" && /^[0-9]{15,20}$/.test(authorId) ? authorId : null;
},
get turnId() {
return state.turnId;
},
get requester() {
return state.requester;
},
@@ -693,6 +719,7 @@ export const TOOL_DESCRIPTIONS = Object.freeze({
snippet: "write_file creates or replaces a text file under a writable root",
},
...WEB_TOOL_DESCRIPTIONS,
...SETSPARK_TOOL_DESCRIPTIONS,
edit_file: {
label: "Edit file",
description: "Replace one exact string that occurs exactly once in a text file under a root that allows writes. Read the file first so the old text is exact. The file is not committed: tell the user which file changed.",