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:` 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:` 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:`; 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);