Files
stack/packages/discord/tests/web.test.mjs
T
jason.woltjeandClaude Fable 5.1 1685deb423 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]>
2026-09-18 07:27:50 -05:00

243 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});