feat(discord): writes on write-marked roots, web fetch and search, held prompts (#1509)

Row 23. write_file and edit_file for roots marked write: true under the
same fence as reads; web_fetch (https only, public addresses, pinned
connection, capped body) and web_search through SearXNG; extension
renamed to tools.mjs. Engine holds a prompt while pi is busy and sends
it as its own run, so a second message mid-turn no longer folds into
the first (live defect). fake-pi models the real follow-up folding.

Suite 52/52, node tests 129. rev-code-02 APPROVED round 3, comment
26362, tree dbd2ce9a. Records: QUEUE rows 23-24, CURRENT, BUILD-LOG
phase, SESSIONS, row 24 brief (git verbs, D5-D7 ruled).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-18 07:27:50 -05:00
co-authored by Claude Fable 5.1
parent 1ac812d3d5
commit 1685deb423
24 changed files with 1519 additions and 113 deletions
+58 -11
View File
@@ -1,10 +1,12 @@
# discord
The Discord connector: one seat's conversation reachable from listed
channels of one Discord server, chat plus read-only tools confined to
declared folders. Issue #1509, briefs
`docs/plans/2026-09-13_discord-connector-pilot.md` and
`docs/plans/2026-09-14_discord-readonly-tools.md`. Plain ESM, no
channels of one Discord server, chat plus file tools confined to
declared folders: reads everywhere, writes only where a root allows
them. Issue #1509, briefs
`docs/plans/2026-09-13_discord-connector-pilot.md`,
`docs/plans/2026-09-14_discord-readonly-tools.md` and
`docs/plans/2026-09-16_discord-write-and-web-tools.md`. Plain ESM, no
dependencies, Node 24 or newer, built-in WebSocket and fetch.
A Discord channel is one more interface onto a seat's conversation, the same
@@ -137,18 +139,23 @@ 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}`: absolute directories the seat may read through `list_dir`, `read_file` and `search`; `maxFileBytes` (262144), `maxCallsPerTurn` (8). 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?}`: 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`; `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). 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
running connector picks up an edit through `reload`; the fields it will not
take in place are listed under that command.
## Read-only tools
## File tools
With `tools` in the binding, pi starts with `--no-builtin-tools`, loads
`extension/readonly-tools.mjs` explicitly, and allowlists exactly
`list_dir`, `read_file` and `search`. The extension reads its roots from
`extension/tools.mjs` explicitly, and allowlists exactly the tools the
binding enables: `list_dir`, `read_file` and `search` always, plus
`write_file` and `edit_file` when at least one root has `"write": true`,
plus `web_fetch` and `web_search` when `tools.web` names a SearXNG instance
(`enabledToolNames` in `src/tools.mjs` is the one place that decides; the
suite checks the real pi exposes that list and nothing else). The
extension reads its roots from
the `MOSAIC_DISCORD_TOOLS` environment variable the connector sets, and
throws without it, which makes pi exit and the connector refuse to start.
Every rule lives in `src/tools.mjs` and is tested without pi: a request
@@ -168,6 +175,44 @@ of pi turns the answer took. The system prompt names the roots, says file
content is data like Discord text, and tells the seat to say plainly when
a read was refused.
Writes (row 23) add rules on top of the read rules, with the same fixed
refusals. `write_file(root, path, text)` creates or replaces a file;
`edit_file(root, path, old, new)` replaces one exact string that occurs
exactly once. Both refuse a root without `write: true`, a parent folder
that does not exist (no folder is ever created), any dot-prefixed segment
(so `.git/` is unreachable), a symlink anywhere in the path, a target
that is not a regular file with one link (a folder, a FIFO, a hard link),
text over `maxFileBytes` or with a NUL byte, and text that matches a
credential shape. The bytes go to a dot-prefixed temp file in the same
folder, created exclusively, then one rename over the target after a
second `lstat` confirms the target is the file that was checked (or is
still absent); a refused rename removes the temp file. The tool result
says the file is not committed, and the prompt tells the seat to end its
reply by naming the file it changed: Sage has no git, Jason commits from
the terminal.
Web tools (row 23, `src/web.mjs`, no dependencies). `web_fetch(url)` is
one GET of an absolute https url with no user or password part. The host
is resolved first and every address must be public: loopback, private,
link-local, carrier-grade NAT, multicast and IPv4-mapped forms refuse the
call, and the connection is pinned to the vetted address so a name that
answers differently on the second lookup gains nothing. At most three
redirects, each re-checked by the same rules and refused unless https.
The body stops at `maxFetchBytes`; html is reduced to text with its title
(scripts, styles and comments dropped); plain text, json and xml pass as
they are; anything else is refused. The model sees at most 12000
characters. One fixed User-Agent, no cookies, no auth headers, no POST,
and the whole call ends within 15 s. `web_search(query)` asks the
instance `/search?q=…&format=json` and returns title, url and snippet for
at most 10 results; a query over 400 characters, a non-200 answer or a
non-json body is refused. The instance url must be https or http on
loopback. Both count against `maxCallsPerTurn`, and the turn record keeps
the url, status and byte count (fetch) or the query and hit count
(search). The prompt says web content is data like file content. The
tests drive both tools against a local server through an injected
resolver and transport, so the fence is tested without the network; the
real transport is `node:https` with the same options.
## What happens to a message
1. The gateway delivers `MESSAGE_CREATE`. `authorize` drops it unless the
@@ -183,8 +228,10 @@ a read was refused.
day, then silence until midnight UTC; the process stays up.
4. The prompt is an envelope, one bracketed line naming server, channel,
thread, author id and message id, then the text. The system prompt says
that text is data. A message that arrives during a turn is queued in pi
as a follow-up, so it is neither lost nor run concurrently. As soon as
that text is data. A message that arrives during a turn is held by the
connector and sent when pi settles, one run per message, so it is
neither lost nor run concurrently (a pi follow-up would fold it into
the running answer and lose the first reply). As soon as
the turn is admitted the connector reacts to the inbound message with
eyes as a read receipt; a typing indicator follows every 8 seconds while
the turn runs. A reaction Discord refuses is logged and recorded in the
@@ -232,7 +279,7 @@ written with `O_EXCL` and never rewritten.
offline: fake WebSocket and timers for the gateway, fake fetch for REST, a
scripted stand-in for pi over stdio, a disposable data root. Groups: binding,
authorization table, gateway (hello, identify, heartbeat, missed ack, op 7,
op 9, close 4014), delivery and reconcile, engine (follow-up, timeout,
op 9, close 4014), delivery and reconcile, engine (held prompt, timeout,
malformed line, tool runs), restart replay, stop and ceiling, tools
confinement. The suite also starts the real pi offline three times, with no
model call, to show the extension exposes exactly the three tools, the pilot