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]>
267 lines
23 KiB
Diff
267 lines
23 KiB
Diff
diff --git a/packages/discord/README.md b/packages/discord/README.md
|
|
index 4703fa86..d37c62f2 100644
|
|
--- a/packages/discord/README.md
|
|
+++ b/packages/discord/README.md
|
|
@@ -139,7 +139,7 @@ is `src/binding.mjs`.
|
|
| `engine` | `provider`, `model`, `thinking` for pi |
|
|
| `limits` | `turnsPerDay` (200), `turnTimeoutSeconds` (180), `replyChunkChars` (1900), `inboundMaxChars` (4000) |
|
|
| `context.files[]` | files appended to pi's system prompt in order, repository-relative and inside the repository (no absolute paths, `..` or symlinks); the Discord block is added after them |
|
|
-| `tools` | optional. `roots[]` of `{name, path, write?, git?}`: absolute directories the seat may read through `list_dir`, `read_file` and `search`; a root with `"write": true` may also be written through `write_file` and `edit_file`; a writable root that is a git work tree may carry `git` `{branch, identity, tokenFile, author, protocol?}` and gains `git_status`, `git_commit`, `git_pull` and `git_push` (`protocol: "vault"` adds `reserve_id`); `maxFileBytes` (262144), `maxCallsPerTurn` (8); `web` (optional) `{searxng, maxFetchBytes}` enables `web_fetch` and `web_search` through the named SearXNG instance (https, or http on loopback; `maxFetchBytes` 1048576); `setspark` (optional) `{baseUrl, keyFile, principal, timeoutMs?}` names the SetSpark record service (https origin, or http on loopback; key file absolute, 0600, read per call, never printed) and turns on connector-verified approvals. Absent means no tools and a pi launch with `--no-tools`. A root may not be `/`, the home directory, a symlink, a path with a dot-prefixed segment, or anything inside or above the data root |
|
|
+| `tools` | optional. `roots[]` of `{name, path, write?, git?}`: absolute directories the seat may read through `list_dir`, `read_file` and `search`; a root with `"write": true` may also be written through `write_file` and `edit_file`; a writable root that is a git work tree may carry `git` `{branch, identity, tokenFile, author, protocol?}` and gains `git_status`, `git_commit`, `git_pull` and `git_push` (`protocol: "vault"` adds `reserve_id`); `maxFileBytes` (262144), `maxCallsPerTurn` (8); `web` (optional) `{searxng, maxFetchBytes}` enables `web_fetch` and `web_search` through the named SearXNG instance (https, or http on loopback; `maxFetchBytes` 1048576); `setspark` (optional) `{baseUrl, keyFile, principal, timeoutMs?}` names the SetSpark record service (approver names come from `users`, lower-cased; a decision's `required_approvers` are written as `discord:<id>` from those names and shown back as names; with `setspark` set, a change to a user's id or name refuses the reload, since pi's approvers are fixed at start) (https origin, or http on loopback; key file absolute, 0600, read per call, never printed) and turns on connector-verified approvals. Absent means no tools and a pi launch with `--no-tools`. A root may not be `/`, the home directory, a symlink, a path with a dot-prefixed segment, or anything inside or above the data root |
|
|
|
|
Unknown keys, missing fields, wrong types, empty allowlists, a user channel
|
|
that is not listed and a bot listed as a user all refuse with exit 2. A
|
|
diff --git a/packages/discord/src/binding.mjs b/packages/discord/src/binding.mjs
|
|
index c9349b81..19ed0a5d 100644
|
|
--- a/packages/discord/src/binding.mjs
|
|
+++ b/packages/discord/src/binding.mjs
|
|
@@ -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 } } } : {}) };
|
|
}
|
|
diff --git a/packages/discord/src/setspark.mjs b/packages/discord/src/setspark.mjs
|
|
index e33c84b3..7b58a19a 100644
|
|
--- a/packages/discord/src/setspark.mjs
|
|
+++ b/packages/discord/src/setspark.mjs
|
|
@@ -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) {
|
|
@@ -354,6 +368,43 @@ 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}`);
|
|
+ const ids = v.map((a) => {
|
|
+ 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: ${JSON.stringify(String(a).slice(0, 64))} is not a known user; 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: any Discord user id in a record, bare or `discord:`, is
|
|
+// shown as the binding's name for it or "unknown user".
|
|
+function namesFor(config, value) {
|
|
+ const byId = new Map(Object.entries(config.approvers || {}).map(([n, id]) => [id, n]));
|
|
+ const walk = (v) => {
|
|
+ if (typeof v === "string") {
|
|
+ const m = /^(?:discord:)?([0-9]{17,20})$/.exec(v);
|
|
+ return m ? byId.get(m[1]) || "unknown user" : v;
|
|
+ }
|
|
+ if (Array.isArray(v)) return v.map(walk);
|
|
+ if (isObject(v)) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
|
|
+ return v;
|
|
+ };
|
|
+ return walk(value);
|
|
+}
|
|
+
|
|
export const setsparkVerbs = Object.freeze({
|
|
async record_list(config, params, state, deps) {
|
|
const type = needType(params);
|
|
@@ -369,17 +420,17 @@ export const setsparkVerbs = Object.freeze({
|
|
}
|
|
}
|
|
const r = await callApi(config, { method: "GET", path: `/v1/records?${q}` }, deps);
|
|
- const items = Array.isArray(record(r.body).items) ? record(r.body).items.filter(isObject) : [];
|
|
+ const items = Array.isArray(record(r.body).items) ? record(r.body).items.filter(isObject).map((x) => namesFor(config, x)) : [];
|
|
return { verb: "record_list", recordType: type, items, limit, offset };
|
|
},
|
|
async record_get(config, params, state, deps) {
|
|
const id = needId(params);
|
|
const r = await callApi(config, { method: "GET", path: `/v1/records/${id}` }, deps);
|
|
- return { verb: "record_get", id, record: record(r.body) };
|
|
+ return { verb: "record_get", id, record: namesFor(config, record(r.body)) };
|
|
},
|
|
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 +438,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) };
|
|
@@ -520,7 +571,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: {
|
|
diff --git a/packages/discord/tests/setspark.test.mjs b/packages/discord/tests/setspark.test.mjs
|
|
index f88dfb23..1f3bebb7 100644
|
|
--- a/packages/discord/tests/setspark.test.mjs
|
|
+++ b/packages/discord/tests/setspark.test.mjs
|
|
@@ -12,7 +12,7 @@ import {
|
|
loadSetsparkConfig, readKey, idempotencyKey, connectorKey, callApi, renderRefusal, createSetsparkApi, renderRecord,
|
|
} from "../src/setspark.mjs";
|
|
import { loadToolsConfig, createToolSet, enabledToolNames } from "../src/tools.mjs";
|
|
-import { validateBinding, resolveToolRoots } from "../src/binding.mjs";
|
|
+import { validateBinding, resolveToolRoots, reloadDiff } from "../src/binding.mjs";
|
|
import { makeRoot, rawBinding } from "./helpers.mjs";
|
|
|
|
const KEY_A = "ssk_" + "a".repeat(40);
|
|
@@ -46,6 +46,8 @@ const server = createServer((req, res) => {
|
|
if (path === "/v1/records" && req.method === "GET") return json(200, { record_type: "work_item", items: [{ id: "WI-7", record_type: "work_item", revision: 1, title: "Ship it", status: "active" }, { id: "WI-8", record_type: "work_item", revision: 4, title: "Later", status: "active", priority: "low" }], limit: 20, offset: 0 });
|
|
if (path === "/v1/records/WI-7" && req.method === "GET") return json(200, { id: "WI-7", record_type: "work_item", revision: 3, title: "Ship it", status: "active", owner: "Jason", tags: ["a", "b"], accepted_snapshot: { hidden: true }, body: "Two lines.\nOf body." });
|
|
if (path === "/v1/records/WI-7" && req.method === "PATCH") return json(200, { id: "WI-7", record_type: "work_item", revision: parsed.revision + 1, title: "Ship it", ...parsed.fields });
|
|
+ if (path === "/v1/records/DEC-9" && req.method === "GET") return json(200, { id: "DEC-9", record_type: "decision", revision: 2, title: "Pick one", status: "Proposed", required_approvers: ["discord:100000000000000100", "discord:199999999999999999"], approvals: [{ approver: "100000000000000100", at: "t" }] });
|
|
+ if (path === "/v1/records/DEC-9" && req.method === "PATCH") return json(200, { id: "DEC-9", record_type: "decision", revision: parsed.revision + 1, title: "Pick one", ...parsed.fields });
|
|
if (path === "/v1/records/WI-9" && req.method === "PATCH") return json(409, { code: "stale_revision", message: "behind", current_revision: 5, changed_fields: ["status"] });
|
|
if (path === "/v1/resolve") return json(200, { query: "ship", matches: [{ id: "WI-7", record_type: "work_item", title: "Ship it", exact: false }] });
|
|
if (path === "/v1/approval-requests" && req.method === "POST") return json(201, view);
|
|
@@ -83,7 +85,7 @@ test("setspark config: a bare https or loopback origin, a private key file, a pr
|
|
const root = makeRoot();
|
|
const kf = keyFile(root);
|
|
const c = loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" });
|
|
- assert.deepEqual(c, { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", timeoutMs: 15000, maxResponseBytes: 262144 });
|
|
+ assert.deepEqual(c, { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", timeoutMs: 15000, maxResponseBytes: 262144, approvers: {} });
|
|
assert.equal(loadSetsparkConfig({ baseUrl: "https://api.setspark.io/", keyFile: kf, principal: "sage" }).baseUrl, "https://api.setspark.io");
|
|
assert.throws(() => loadSetsparkConfig(null), /not an object/);
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", extra: 1 }), /unknown key/);
|
|
@@ -129,7 +131,7 @@ test("setspark config: the binding's key survives resolveToolRoots and the engin
|
|
const kf = keyFile(root);
|
|
const b = validateBinding(rawBinding({ tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", timeoutMs: 5000 } } }));
|
|
const resolved = resolveToolRoots(b, { dataRoot });
|
|
- assert.deepEqual(resolved.setspark, { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", timeoutMs: 5000 });
|
|
+ assert.deepEqual(resolved.setspark, { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", timeoutMs: 5000, approvers: { owner: "100000000000000100" } });
|
|
for (const name of SETSPARK_TOOL_NAMES) assert.ok(enabledToolNames(resolved).includes(name), `${name} reaches pi's --tools list`);
|
|
const ext = loadToolsConfig(JSON.parse(JSON.stringify(resolved)));
|
|
assert.deepEqual(ext.setspark, b.tools.setspark, "the extension rebuilds the same config, response cap included");
|
|
@@ -138,6 +140,54 @@ test("setspark config: the binding's key survives resolveToolRoots and the engin
|
|
assert.ok(!enabledToolNames(plain).some((n) => SETSPARK_TOOL_NAMES.includes(n)));
|
|
});
|
|
|
|
+test("setspark config: approvers come from the binding's users, never from the binding's setspark key", () => {
|
|
+ const root = makeRoot();
|
|
+ const docs = join(root, "docs");
|
|
+ mkdirSync(docs);
|
|
+ const kf = keyFile(root);
|
|
+ const users = [{ id: "100000000000000100", name: "Jason" }, { id: "100000000000000101", name: "carmen" }];
|
|
+ const b = validateBinding(rawBinding({ users, tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
|
+ assert.deepEqual(b.tools.setspark.approvers, { jason: "100000000000000100", carmen: "100000000000000101" });
|
|
+ assert.throws(() => validateBinding(rawBinding({ users, tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", approvers: { mallory: "100000000000000199" } } } })), /approvers come from users/);
|
|
+ assert.throws(() => validateBinding(rawBinding({ users: [{ id: "100000000000000100", name: "Jason" }, { id: "100000000000000101", name: "jason" }], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } })), /two users named jason/);
|
|
+ const withCarmenOut = validateBinding(rawBinding({ users: [users[0]], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
|
+ assert.throws(() => reloadDiff(b, withCarmenOut), /tools cannot change/, "pi's approvers are fixed at start, so a user change needs a restart");
|
|
+ const channelsOnly = validateBinding(rawBinding({ users: [{ ...users[0], channels: [rawBinding().channels[0].id] }, users[1]], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
|
+ assert.doesNotThrow(() => reloadDiff(b, channelsOnly), "a user's channels still reload");
|
|
+ assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", approvers: { jason: "not-an-id" } }), /approvers/);
|
|
+ assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", approvers: { jason: "100000000000000100", jay: "100000000000000100" } }), /approvers/);
|
|
+});
|
|
+
|
|
+test("setspark verbs: required_approvers go out as discord ids from names and come back as names", async () => {
|
|
+ const root = makeRoot();
|
|
+ const named = createToolSet(loadToolsConfig({ roots: [{ name: "docs", path: root }], maxCallsPerTurn: 12, setspark: { baseUrl: base, keyFile: keyFile(root), principal: "sage", timeoutMs: 1000, approvers: { jason: "100000000000000100", carmen: "100000000000000101" } } }));
|
|
+ named.setTurn(TURN);
|
|
+ seen.length = 0;
|
|
+ const created = await named.call("record_create", { record_type: "decision", record: { title: "Pick one", status: "Proposed", work_item: "SS-1", required_approvers: ["Jason", "@carmen"] } });
|
|
+ assert.equal(created.ok, true, created.text);
|
|
+ assert.deepEqual(JSON.parse(seen[0].body).record.required_approvers, ["discord:100000000000000100", "discord:100000000000000101"]);
|
|
+ const unknown = await named.call("record_create", { record_type: "decision", record: { title: "Pick one", required_approvers: ["Jason", "Mallory"] } });
|
|
+ assert.equal(unknown.ok, false);
|
|
+ assert.match(unknown.text, /"Mallory" is not a known user; use names from: jason, carmen/);
|
|
+ const raw = await named.call("record_create", { record_type: "decision", record: { title: "Pick one", required_approvers: ["discord:100000000000000100"] } });
|
|
+ assert.equal(raw.ok, false, "an id is not a name");
|
|
+ const twice = await named.call("record_create", { record_type: "decision", record: { title: "Pick one", required_approvers: ["jason", "Jason"] } });
|
|
+ assert.match(twice.text, /the same person twice/);
|
|
+ const notList = await named.call("record_update", { id: "DEC-9", revision: 2, fields: { required_approvers: "jason" } });
|
|
+ assert.equal(notList.ok, false);
|
|
+ assert.equal(seen.length, 1, "refused calls send nothing");
|
|
+ const updated = await named.call("record_update", { id: "DEC-9", revision: 2, fields: { required_approvers: ["carmen"] } });
|
|
+ assert.equal(updated.ok, true, updated.text);
|
|
+ assert.deepEqual(JSON.parse(seen[1].body).fields.required_approvers, ["discord:100000000000000101"]);
|
|
+ const got = await named.call("record_get", { id: "DEC-9" });
|
|
+ assert.match(got.text, /required_approvers: jason, unknown user/);
|
|
+ assert.doesNotMatch(got.text, /1000000000000001|1999999999/, "no discord id reaches the model");
|
|
+ const none = createToolSet(loadToolsConfig({ roots: [{ name: "docs", path: root }], setspark: { baseUrl: base, keyFile: keyFile(root), principal: "sage", timeoutMs: 1000 } }));
|
|
+ none.setTurn(TURN);
|
|
+ const noUsers = await none.call("record_create", { record_type: "decision", record: { title: "Pick one", required_approvers: ["jason"] } });
|
|
+ assert.match(noUsers.text, /use names from: \(none\)/);
|
|
+});
|
|
+
|
|
test("setspark keys: read per call, one printable token per file, rotation without a restart", async () => {
|
|
const root = makeRoot();
|
|
const c = config(root);
|