fix(discord): row 25 approvers are user names, never Discord ids in tool text (#1509)

Jason's live check after the 20:58Z restart posted no Approve button. The
Discord Sage wrote DEC-009's required_approvers as names; the SetSpark
service stores approvers as discord:<id> and accepted the names, and the
connector correctly refused the approval request ("bad approver id").

- binding.mjs derives setspark.approvers from the binding's users (name to
  id); a binding-set approvers key and duplicate names are refused. With
  setspark set, a user id or name change refuses the reload (pi's approvers
  are fixed at start).
- setspark.mjs: record_create/record_update map required_approvers names to
  discord:<id> and refuse unknown names, ids, duplicates and non-lists
  before any request, without echoing the value. hideIds turns mentions,
  discord: values and standalone 17-20 digit runs into the user's name or
  "unknown user" in every verb's text and refusal, including the service
  message and code before they are cut. The connector's approval request
  keeps the bare ids.
- tests: boundary test over nested, keyed, numeric, mention and cut ids;
  a local contract fixture from create through validateRequest, with the
  old name-stored shape still refused.

Rocko: R1 revise, R2 revise, R3 approve (81379830..., report da75219f...).
Suites on an index export: 24/90/43/17/14/15/63/18; Discord node tests 173/173.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 16:30:11 -05:00
co-authored by Claude Opus 5.5
parent 1c5f6bc3a0
commit 20ea5a0b64
11 changed files with 1651 additions and 26 deletions
+11 -4
View File
@@ -217,7 +217,7 @@ export function validateBinding(raw, where = "binding") {
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`),
setspark: raw.tools.setspark === undefined ? null : setsparkConfig(raw.tools.setspark, `${where}.tools.setspark`, users),
});
}
@@ -328,9 +328,16 @@ export function resolveContextFiles(binding, repo) {
// 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) {
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, where);
return loadSetsparkConfig(raw !== null && typeof raw === "object" && !Array.isArray(raw) ? { ...raw, approvers } : raw, where);
} catch (err) {
throw new DiscordError(err.message);
}
@@ -363,5 +370,5 @@ export function resolveToolRoots(binding, { dataRoot }) {
// 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 } } : {}) };
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 } } } : {}) };
}
+80 -13
View File
@@ -69,7 +69,7 @@ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
export function loadSetsparkConfig(raw, where = "setspark") {
if (!isObject(raw)) throw new Error(`${where}: not an object`);
for (const k of Object.keys(raw)) {
if (!["baseUrl", "keyFile", "principal", "timeoutMs"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
if (!["baseUrl", "keyFile", "principal", "timeoutMs", "approvers"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
}
if (typeof raw.baseUrl !== "string") throw new Error(`${where}.baseUrl: must be a url string`);
let u;
@@ -86,7 +86,21 @@ export function loadSetsparkConfig(raw, where = "setspark") {
if (typeof raw.principal !== "string" || !PRINCIPAL.test(raw.principal)) throw new Error(`${where}.principal: must match ${PRINCIPAL}`);
const timeoutMs = raw.timeoutMs === undefined ? SETSPARK_DEFAULTS.timeoutMs : raw.timeoutMs;
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 60000) throw new Error(`${where}.timeoutMs: must be an integer between 1000 and 60000`);
return Object.freeze({ baseUrl: u.origin, keyFile: raw.keyFile, principal: raw.principal, timeoutMs, maxResponseBytes: SETSPARK_DEFAULTS.maxResponseBytes });
const approvers = loadApprovers(raw.approvers === undefined ? {} : raw.approvers, `${where}.approvers`);
return Object.freeze({ baseUrl: u.origin, keyFile: raw.keyFile, principal: raw.principal, timeoutMs, maxResponseBytes: SETSPARK_DEFAULTS.maxResponseBytes, approvers });
}
// Lower-case user name to Discord user id. The binding derives it from its
// users; the verbs turn a decision's required_approvers from names into
// `discord:<id>` and back, so the model never handles an id.
function loadApprovers(raw, where) {
if (!isObject(raw) || Object.keys(raw).length > 64) throw new Error(`${where}: must be an object of at most 64 names`);
for (const [name, id] of Object.entries(raw)) {
if (name.length === 0 || name.length > 64 || name !== name.trim().toLowerCase() || /[\u0000-\u001f\u007f]/.test(name)) throw new Error(`${where}: ${JSON.stringify(name.slice(0, 64))} must be a trimmed lower-case name`);
if (typeof id !== "string" || !SNOWFLAKE.test(id)) throw new Error(`${where}.${name}: must be a Discord user id`);
}
if (new Set(Object.values(raw)).size !== Object.keys(raw).length) throw new Error(`${where}: one Discord user id under two names`);
return Object.freeze({ ...raw });
}
function checkPrivateFile(path, what) {
@@ -248,8 +262,8 @@ export async function callApi(config, { method, path, body, idempotencyKey: key
return finish(resolve, { status, body: json });
}
const err = isObject(json) ? json : {};
const code = typeof err.code === "string" ? err.code.slice(0, 64) : null;
const extra = { status, code, message: cutMessage(err.message) };
const code = typeof err.code === "string" ? hideIds(config, err.code).slice(0, 64) : null;
const extra = { status, code, message: cutMessage(typeof err.message === "string" ? hideIds(config, err.message) : err.message) };
if (status === 409) {
if (err.current_revision !== undefined) extra.currentRevision = err.current_revision;
if (Array.isArray(err.changed_fields)) extra.changedFields = err.changed_fields.filter((f) => typeof f === "string").slice(0, 32);
@@ -263,14 +277,15 @@ export async function callApi(config, { method, path, body, idempotencyKey: key
}
// How a refusal reads to the model and in the turn record: the fixed
// reason, the code, and on 409 the fields that changed. Never the raw body.
export function renderRefusal(err) {
// reason, the code, and on 409 the fields that changed. Never the raw body,
// and never a Discord user id (hideIds).
export function renderRefusal(err, config = null) {
let s = `refused: ${err.reason}`;
if (err.code) s += ` (code ${err.code})`;
if (err.currentRevision !== undefined) s += `; current revision ${err.currentRevision}`;
if (Array.isArray(err.changedFields) && err.changedFields.length > 0) s += `; changed: ${err.changedFields.join(", ")}`;
if (err.message && err.message !== err.reason) s += `\n${err.message}`;
return s;
return hideIds(config, s);
}
// --- the verbs (contract: shared-signals stack/api/openapi.json at a5425a2) ---
@@ -324,7 +339,7 @@ function needObject(params, name) {
if (!isObject(v)) throw bad(`${name} must be an object`);
const size = Buffer.byteLength(JSON.stringify(v), "utf8");
if (size > RECORD_MAX_BYTES) throw bad(`${name} is over ${RECORD_MAX_BYTES} bytes`);
for (const k of Object.keys(v)) if (!PROP_NAME.test(k)) throw bad(`${name} has a property name that is not allowed: ${k.slice(0, 32)}`);
for (const k of Object.keys(v)) if (!PROP_NAME.test(k)) throw bad(`${name} has a property name that is not allowed: ${hideIds(null, k).slice(0, 32)}`);
return v;
}
@@ -354,6 +369,52 @@ function record(body) {
return isObject(body) ? body : {};
}
// A decision's required_approvers as the model gives them: names of the
// binding's users. Each becomes `discord:<id>`; anything else is refused
// before a request, since the service checks an approval's author against
// these values and a stored name could never be approved.
function approverIds(config, v) {
const map = config.approvers || {};
const names = Object.keys(map).join(", ") || "(none)";
if (!Array.isArray(v) || v.length === 0 || v.length > 16) throw bad(`required_approvers must be a list of 1 to 16 names; use names from: ${names}`);
// The refusal names the entry's position, never its value: a model that
// wrote an id must not get it echoed back.
const ids = v.map((a, i) => {
const k = typeof a === "string" ? a.trim().replace(/^@/, "").toLowerCase() : "";
if (k.length > 0 && Object.hasOwn(map, k)) return `discord:${map[k]}`;
throw bad(`required_approvers: entry ${i + 1} is not a known user name; use names from: ${names}`);
});
if (new Set(ids).size !== ids.length) throw bad("required_approvers names the same person twice");
return ids;
}
function withApprovers(config, props) {
return props.required_approvers === undefined ? props : { ...props, required_approvers: approverIds(config, props.required_approvers) };
}
// The way back, for everything the model reads: a Discord user id never
// appears in SetSpark tool text. A mention (<@id>), a `discord:` value or a
// 17 to 20 digit run standing alone becomes the binding's name for that id,
// or "unknown user". A digit run inside a longer token (a hex digest, SS-027)
// is left alone. The connector's own request keeps the bare ids; only the
// rendered text and refusals go through this.
const DISCORD_ID_TEXT = /<@!?([0-9]{17,20})>|(?<![0-9A-Za-z])(?:discord:)?([0-9]{17,20})(?![0-9A-Za-z])/g;
export function hideIds(config, text) {
const byId = new Map(Object.entries((config && config.approvers) || {}).map(([n, id]) => [id, n]));
return String(text).replace(DISCORD_ID_TEXT, (_, a, b) => byId.get(a || b) || "unknown user");
}
// The same rule over a service value before it is rendered, so a field cut
// at its length limit cannot leave part of an id. Keys and big integers too.
function hideDeep(config, v) {
if (typeof v === "string") return hideIds(config, v);
if (typeof v === "number") return /^[0-9]{17,20}$/.test(String(v)) ? hideIds(config, String(v)) : v;
if (Array.isArray(v)) return v.map((x) => hideDeep(config, x));
if (isObject(v)) return Object.fromEntries(Object.entries(v).map(([k, x]) => [hideIds(config, k), hideDeep(config, x)]));
return v;
}
export const setsparkVerbs = Object.freeze({
async record_list(config, params, state, deps) {
const type = needType(params);
@@ -363,7 +424,7 @@ export const setsparkVerbs = Object.freeze({
if (params.filters !== undefined) {
if (!isObject(params.filters) || Object.keys(params.filters).length > FILTERS_MAX) throw bad(`filters must be an object of at most ${FILTERS_MAX} properties`);
for (const [k, v] of Object.entries(params.filters)) {
if (!PROP_NAME.test(k) || ["record_type", "type", "limit", "offset"].includes(k)) throw bad(`filters: property name not allowed: ${k.slice(0, 32)}`);
if (!PROP_NAME.test(k) || ["record_type", "type", "limit", "offset"].includes(k)) throw bad(`filters: property name not allowed: ${hideIds(null, k).slice(0, 32)}`);
if (typeof v !== "string" || v.length === 0 || v.length > 200) throw bad(`filters.${k} must be a short string`);
q.set(k, v);
}
@@ -379,7 +440,7 @@ export const setsparkVerbs = Object.freeze({
},
async record_create(config, params, state, deps) {
const type = needType(params);
const rec = needObject(params, "record");
const rec = withApprovers(config, needObject(params, "record"));
if (typeof rec.title !== "string" || rec.title.trim().length === 0) throw bad("record.title is required");
const r = await write(config, state, "POST", "/v1/records", { record_type: type, record: rec }, deps);
return { verb: "record_create", key: r.key, recordType: type, record: record(r.body) };
@@ -387,7 +448,7 @@ export const setsparkVerbs = Object.freeze({
async record_update(config, params, state, deps) {
const id = needId(params);
const revision = needInt(params, "revision", 1, 1000000000);
const fields = needObject(params, "fields");
const fields = withApprovers(config, needObject(params, "fields"));
if (Object.keys(fields).length === 0) throw bad("fields must name at least one property");
const r = await write(config, state, "PATCH", `/v1/records/${id}`, { revision, fields }, deps);
return { verb: "record_update", key: r.key, id, from: revision, record: record(r.body) };
@@ -476,7 +537,13 @@ function renderView(v) {
return `request ${scalar(v.request_id)} for ${scalar(v.decision_id)} version ${scalar(v.proposal_version)}: ${scalar(v.state) || "?"}; ${approvals.length} of ${who} approvals recorded${v.message_id ? "; bound to a Discord message" : "; no message bound yet"}`;
}
export function renderSetspark(name, out) {
// Tool text for the model. `out` is hidden (hideDeep) before any field is
// cut, and the whole text once more after.
export function renderSetspark(name, out, config = null) {
return hideIds(config, renderOut(name, hideDeep(config, out)));
}
function renderOut(name, out) {
if (name === "record_list") {
const body = out.items.map(summary).join("\n");
return `${out.items.length} ${out.recordType} record(s) from offset ${out.offset} (limit ${out.limit})\n${body || "(none)"}`;
@@ -520,7 +587,7 @@ export const SETSPARK_TOOL_DESCRIPTIONS = Object.freeze({
},
record_create: {
label: "Create record",
description: "Create one SetSpark record; the service allocates the id. Give record_type and the record's properties (title required). Only when the user asked for a record to be created.",
description: "Create one SetSpark record; the service allocates the id. Give record_type and the record's properties (title required). A decision's required_approvers is a list of user names (such as jason); the connector turns each into that user's Discord identity and refuses a name it does not know. Only when the user asked for a record to be created.",
snippet: "record_create creates one SetSpark record",
},
record_update: {
+4 -4
View File
@@ -567,8 +567,8 @@ const TOOL_FNS = Object.freeze({
});
const SETSPARK_SET = new Set(SETSPARK_TOOL_NAMES);
function render(name, out) {
if (SETSPARK_SET.has(name)) return renderSetspark(name, out);
function render(name, out, config) {
if (SETSPARK_SET.has(name)) return renderSetspark(name, out, config.setspark);
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");
@@ -648,11 +648,11 @@ export function createToolSet(config) {
: name === "git_status" ? { branch: out.branch }
: name === "reserve_id" ? { id: out.id }
: { path: out.path };
return { ok: true, text: render(name, out), details: { ...base, ok: true, ...extra, ...(bytes === undefined ? {} : { bytes }), ms: Date.now() - t0 } };
return { ok: true, text: render(name, out, config), 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 } };
return { ok: false, text: renderSetsparkRefusal(err, config.setspark), 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;