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
+242
View File
@@ -0,0 +1,242 @@
// The web tools' fence, tested without the network. A local http server
// plays every remote host; an injected resolver decides what each name
// resolves to, and an injected request function sends "https" urls to that
// server over plain http so the redirect, cap, timeout and html logic run
// on real sockets. The address rules themselves are tested directly.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { createServer, request as httpRequest } from "node:http";
import { once } from "node:events";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { loadWebConfig, webFetch, webSearch, isPublicAddress, htmlToText, WEB_REFUSAL, WEB_TOOL_NAMES, FETCH_MAX_TEXT_CHARS, SEARCH_MAX_RESULTS } from "../src/web.mjs";
import { loadToolsConfig, createToolSet, enabledToolNames, REFUSAL } from "../src/tools.mjs";
import { makeRoot } from "./helpers.mjs";
const hits = [];
const server = createServer((req, res) => {
hits.push({ host: req.headers.host, path: req.url, ua: req.headers["user-agent"], cookie: req.headers.cookie, auth: req.headers.authorization, method: req.method });
const u = new URL(req.url, "http://x");
switch (u.pathname) {
case "/page":
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
return res.end("<html><head><title>Names &amp; things</title><script>evil()</script></head><body><h1>Hello</h1><p>one</p><p>two &lt;3</p><!-- c --></body></html>");
case "/plain":
res.writeHead(200, { "content-type": "text/plain" });
return res.end("just text\r\nline 2\n");
case "/json":
res.writeHead(200, { "content-type": "application/json" });
return res.end('{"a":1}');
case "/big": {
res.writeHead(200, { "content-type": "text/plain" });
const chunk = Buffer.alloc(1024, 0x61);
let n = 0;
const push = () => {
while (n < 64) {
n += 1;
if (!res.write(chunk)) return res.once("drain", push);
}
res.end();
};
return push();
}
case "/slow":
return setTimeout(() => { res.writeHead(200, { "content-type": "text/plain" }); res.end("late"); }, 2000).unref();
case "/drip":
res.writeHead(200, { "content-type": "text/plain" });
res.write("start");
return setTimeout(() => res.end("end"), 2000).unref();
case "/binary":
res.writeHead(200, { "content-type": "application/octet-stream" });
return res.end(Buffer.from([0, 1, 2]));
case "/pdf":
res.writeHead(200, { "content-type": "application/pdf" });
return res.end("%PDF");
case "/missing":
res.writeHead(404, { "content-type": "text/html" });
return res.end("<p>gone</p>");
case "/hop":
res.writeHead(302, { location: `/hop${Number(u.searchParams.get("n") || 0) + 1 > 5 ? "" : `?n=${Number(u.searchParams.get("n") || 0) + 1}`}` });
return res.end();
case "/once":
res.writeHead(301, { location: "https://public.example/plain" });
return res.end();
case "/to-private":
res.writeHead(302, { location: "https://internal.example/plain" });
return res.end();
case "/to-http":
res.writeHead(302, { location: "http://public.example/plain" });
return res.end();
case "/to-ip":
res.writeHead(302, { location: "https://127.0.0.1/plain" });
return res.end();
case "/search": {
const q = u.searchParams.get("q");
if (u.searchParams.get("format") !== "json") { res.writeHead(403); return res.end("json off"); }
if (q === "boom") { res.writeHead(500); return res.end("x"); }
if (q === "junk") { res.writeHead(200, { "content-type": "application/json" }); return res.end("not json"); }
res.writeHead(200, { "content-type": "application/json" });
const results = [];
for (let i = 0; i < 14; i += 1) results.push({ title: ` Result ${i} `, url: `https://r.example/${i}`, content: `snippet\n${i}`, engine: "ddg" });
results.unshift({ title: "bad", url: "javascript:alert(1)" }, { title: "nourl" }, "junk");
return res.end(JSON.stringify({ query: q, results }));
}
default:
res.writeHead(404);
return res.end();
}
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const port = server.address().port;
after(() => server.close());
// Names: public.example and r.example are "public"; internal.example is
// private; rebind.example answers with one public and one private address.
const table = {
"public.example": [{ address: "203.0.113.10", family: 4 }],
"r.example": [{ address: "203.0.113.11", family: 4 }],
"internal.example": [{ address: "10.0.0.5", family: 4 }],
"rebind.example": [{ address: "203.0.113.12", family: 4 }, { address: "192.168.1.1", family: 4 }],
"v6.example": [{ address: "::ffff:10.1.1.1", family: 6 }],
};
const deps = {
lookup: async (host) => {
if (!table[host]) { const e = new Error("ENOTFOUND"); e.code = "ENOTFOUND"; throw e; }
return table[host];
},
// "https://host/path" goes to the local server as plain http, with the
// Host header kept, so the server sees which host was asked for. The
// pinned lookup the tool passes is checked: it must be the vetted address.
httpsRequest: (opts) => {
assert.ok(opts.lookup, "the tool pins the vetted address");
opts.lookup(opts.hostname, {}, (err, address) => { assert.equal(err, null); assert.equal(address, table[opts.hostname][0].address); });
return httpRequest({ ...opts, hostname: "127.0.0.1", port, servername: undefined, lookup: undefined });
},
httpRequest,
};
const config = () => loadWebConfig({ searxng: `http://127.0.0.1:${port}`, maxFetchBytes: 16384 });
const fast = () => ({ ...config(), timeoutMs: 500 });
const refuses = (p, reason) => assert.rejects(p, (err) => err.reason === reason, reason);
test("web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap", () => {
assert.deepEqual(config(), { searxng: `http://127.0.0.1:${port}`, maxFetchBytes: 16384, timeoutMs: 15000 });
assert.equal(loadWebConfig({ searxng: "https://search.example/" }).searxng, "https://search.example");
assert.equal(loadWebConfig({ searxng: "http://localhost:8888" }).maxFetchBytes, 1048576);
assert.throws(() => loadWebConfig({ searxng: "http://search.example" }), /https, or http on loopback/);
assert.throws(() => loadWebConfig({ searxng: "http://127.0.0.1:8888/search?q=x" }), /bare base url/);
assert.throws(() => loadWebConfig({ searxng: "https://u:[email protected]" }), /bare base url/);
assert.throws(() => loadWebConfig({ searxng: "nope" }), /not a valid url/);
assert.throws(() => loadWebConfig({ searxng: "https://s.example", maxFetchBytes: 100 }), /maxFetchBytes/);
assert.throws(() => loadWebConfig({ searxng: "https://s.example", key: "x" }), /unknown key/);
assert.throws(() => loadWebConfig({}), /searxng/);
assert.deepEqual(WEB_TOOL_NAMES, ["web_fetch", "web_search"]);
});
test("web: address rules refuse every private, loopback, link-local, mapped and multicast form", () => {
for (const a of ["203.0.113.1", "8.8.8.8", "172.32.0.1", "100.128.0.1", "2606:4700::1111", "::ffff:8.8.8.8"]) assert.equal(isPublicAddress(a), true, a);
for (const a of ["10.1.1.1", "127.0.0.1", "127.9.9.9", "0.0.0.0", "169.254.1.1", "172.16.0.1", "172.31.255.255", "192.168.0.1", "192.0.0.1", "100.64.0.1", "198.18.0.1", "224.0.0.1", "255.255.255.255", "::1", "::", "::ffff:10.0.0.1", "::ffff:127.0.0.1", "fd00::1", "fc00::1", "fe80::1", "fec0::1", "ff02::1", "64:ff9b::a00:1", "not-an-ip", "999.1.1.1"]) assert.equal(isPublicAddress(a), false, a);
});
test("web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out", async () => {
const c = fast();
for (const url of ["http://public.example/page", "ftp://public.example/x", "public.example/page", "https://u:[email protected]/page", "", 5, "https://", "javascript:alert(1)"]) await refuses(webFetch(c, { url }, deps), WEB_REFUSAL.BAD_URL);
await refuses(webFetch(c, { url: "https://internal.example/page" }, deps), WEB_REFUSAL.PRIVATE);
await refuses(webFetch(c, { url: "https://rebind.example/page" }, deps), WEB_REFUSAL.PRIVATE);
await refuses(webFetch(c, { url: "https://v6.example/page" }, deps), WEB_REFUSAL.PRIVATE);
await refuses(webFetch(c, { url: "https://127.0.0.1/page" }, deps), WEB_REFUSAL.PRIVATE);
await refuses(webFetch(c, { url: "https://[::1]/page" }, deps), WEB_REFUSAL.PRIVATE);
await refuses(webFetch(c, { url: "https://10.0.0.1/page" }, deps), WEB_REFUSAL.PRIVATE);
await refuses(webFetch(c, { url: "https://nowhere.example/page" }, deps), WEB_REFUSAL.UNRESOLVED);
const before = hits.length;
await refuses(webFetch(c, { url: "https://public.example/to-private" }, deps), WEB_REFUSAL.PRIVATE);
await refuses(webFetch(c, { url: "https://public.example/to-ip" }, deps), WEB_REFUSAL.PRIVATE);
await refuses(webFetch(c, { url: "https://public.example/to-http" }, deps), WEB_REFUSAL.BAD_REDIRECT);
assert.equal(hits.slice(before).filter((h) => h.host !== "public.example").length, 0, "a refused redirect target is never requested");
await refuses(webFetch(c, { url: "https://public.example/hop" }, deps), WEB_REFUSAL.REDIRECTS);
await refuses(webFetch(c, { url: "https://public.example/missing" }, deps), WEB_REFUSAL.STATUS);
await refuses(webFetch(c, { url: "https://public.example/binary" }, deps), WEB_REFUSAL.NOT_TEXT);
await refuses(webFetch(c, { url: "https://public.example/pdf" }, deps), WEB_REFUSAL.NOT_TEXT);
await refuses(webFetch(c, { url: "https://public.example/slow" }, deps), WEB_REFUSAL.TIMEOUT);
await refuses(webFetch(c, { url: "https://public.example/drip" }, deps), WEB_REFUSAL.TIMEOUT);
for (const h of hits) {
assert.equal(h.method, "GET");
assert.match(h.ua, /^mosaic-discord-sage\//);
assert.equal(h.cookie, undefined);
assert.equal(h.auth, undefined);
}
});
test("web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap", async () => {
const c = fast();
const page = await webFetch(c, { url: "https://public.example/page?x=1" }, deps);
assert.equal(page.status, 200);
assert.equal(page.contentType, "text/html");
assert.equal(page.title, "Names & things");
assert.equal(page.text, "Hello\none\ntwo <3");
assert.equal(page.redirects, 0);
assert.equal(page.truncated, false);
const hopped = await webFetch(c, { url: "https://public.example/once" }, deps);
assert.equal(hopped.finalUrl, "https://public.example/plain");
assert.equal(hopped.redirects, 1);
assert.equal(hopped.text, "just text\nline 2");
const j = await webFetch(c, { url: "https://public.example/json" }, deps);
assert.equal(j.contentType, "application/json");
assert.equal(j.text, '{"a":1}');
const big = await webFetch(c, { url: "https://public.example/big" }, deps);
assert.equal(big.truncated, true);
assert.equal(big.bytes, 16384);
assert.equal(big.textTruncated, true);
assert.equal(big.text.length, FETCH_MAX_TEXT_CHARS);
});
test("web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks", () => {
const r = htmlToText("<html><head><title> A &#8211; B </title><style>p{}</style></head><body><div>x<br>y</div><script>z</script><table><tr><td>1</td><td>2</td></tr></table><p>&#x41;&nbsp;&quot;q&quot;</p></body></html>");
assert.equal(r.title, "A – B");
assert.equal(r.text, "x\ny\n1 2\n\nA \"q\"");
});
test("web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer", async () => {
const c = fast();
const r = await webSearch(c, { query: " content engine name " }, deps);
assert.equal(r.query, "content engine name");
assert.equal(r.results.length, SEARCH_MAX_RESULTS);
assert.equal(r.total, 17);
assert.deepEqual(r.results[0], { title: "Result 0", url: "https://r.example/0", snippet: "snippet 0" });
const last = hits[hits.length - 1];
assert.equal(last.path, "/search?q=content+engine+name&format=json");
await refuses(webSearch(c, { query: "" }, deps), WEB_REFUSAL.BAD_QUERY);
await refuses(webSearch(c, { query: "x".repeat(401) }, deps), WEB_REFUSAL.BAD_QUERY);
await refuses(webSearch(c, { query: 7 }, deps), WEB_REFUSAL.BAD_QUERY);
await refuses(webSearch(c, { query: "boom" }, deps), WEB_REFUSAL.SEARCH_DOWN);
await refuses(webSearch(c, { query: "junk" }, deps), WEB_REFUSAL.SEARCH_BAD);
await refuses(webSearch({ ...c, searxng: "http://127.0.0.1:1" }, { query: "x" }, deps), WEB_REFUSAL.SEARCH_DOWN);
});
test("web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits", async () => {
const base = makeRoot();
const root = join(base, "docs");
mkdirSync(root);
const plain = loadToolsConfig({ roots: [{ name: "docs", path: root }] });
assert.deepEqual(enabledToolNames(plain), ["list_dir", "read_file", "search"]);
assert.throws(() => createToolSet(plain).call("web_fetch", { url: "https://public.example/page" }), /unknown tool/);
assert.throws(() => loadToolsConfig({ roots: [{ name: "docs", path: root }], web: { searxng: "http://evil.example" } }), /loopback/);
const cfg = loadToolsConfig({ roots: [{ name: "docs", path: root, write: true }], maxCallsPerTurn: 2, web: { searxng: `http://127.0.0.1:${port}` } });
assert.deepEqual(enabledToolNames(cfg), ["list_dir", "read_file", "search", "write_file", "edit_file", "web_fetch", "web_search"]);
assert.deepEqual(cfg.web, { searxng: `http://127.0.0.1:${port}`, maxFetchBytes: 1048576, timeoutMs: 15000 });
const set = createToolSet(cfg);
// The real https path would need a real host; the set's call goes through
// the default transport, so only the refusals that happen before any
// socket are exercised here. The transport itself is covered above.
const bad = await set.call("web_fetch", { url: "http://public.example/page" });
assert.equal(bad.ok, false);
assert.equal(bad.text, `refused: ${WEB_REFUSAL.BAD_URL}`);
assert.deepEqual({ ...bad.details, ms: 0 }, { tool: "web_fetch", root: null, path: null, url: "http://public.example/page", ok: false, reason: WEB_REFUSAL.BAD_URL, ms: 0 });
const s = await set.call("web_search", { query: "content engine" });
assert.equal(s.ok, true);
assert.match(s.text, /^10 result\(s\) for "content engine" \(of 17\)\n1\. Result 0\n https:\/\/r\.example\/0\n snippet 0\n/);
assert.deepEqual({ ...s.details, ms: 0 }, { tool: "web_search", root: null, path: null, query: "content engine", ok: true, hits: 10, ms: 0 });
assert.equal(set.calls, 2);
const over = await set.call("web_search", { query: "again" });
assert.equal(over.details.reason, REFUSAL.BUDGET);
});