From d450689a8b3d631bae8e1ac7877f861d0c5d6566 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 7 Oct 2026 22:08:29 +0300 Subject: [PATCH] =?UTF-8?q?fix(web=5Ffetch):=20the=20protocol=20gate=20nev?= =?UTF-8?q?er=20accepted=20a=20URL=20=E2=80=94=20the=20colon=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL.protocol carries a trailing colon ("https:", WHATWG); the colon-less comparison (a direct port from the py harness, where urlparse().scheme has none) rejected EVERY valid URL ever fetched — the tool never fetched a page in its life. Owner-reported via OpenRouter logs: 8 valid URLs in, 8 'invalid URL' results out. The new pin stubs globalThis.fetch so the happy path runs hermetically — a valid https URL must REACH the fetch stage (and ftp:// still rejected). dist rebuilt (desktop:dist) — bundle verified: "http:"!==t.protocol --- src/agent/tools/web.ts | 7 ++++++- tests/web-search.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/agent/tools/web.ts b/src/agent/tools/web.ts index dfb2c64..40aa357 100644 --- a/src/agent/tools/web.ts +++ b/src/agent/tools/web.ts @@ -160,7 +160,12 @@ export function createWebTools(search: () => SearchProvider | null): Record => { try { const parsed = new URL(url); - if (parsed.protocol !== "http" && parsed.protocol !== "https") throw new Error("Only http(s) URLs are supported"); + // URL.protocol carries a TRAILING COLON ("https:", WHATWG) — the + // colon-less comparison born from the py-harness port (urlparse's + // scheme has NO colon) rejected every valid URL ever fetched + // (owner-reported via OpenRouter logs, 2026-10-07). + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") + throw new Error("Only http(s) URLs are supported"); } catch { return `web_fetch failed: invalid URL "${url}"`; } diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 283d3c1..62ba397 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -80,6 +80,34 @@ describe("web_search on the injected port (web.ts)", () => { const out = await web_fetch!.execute!("c1", { url: "not a url" }); assert.match(String(out.details), /web_fetch failed: invalid URL/); }); + + it("web_fetch ACCEPTS a valid http(s) URL — the protocol gate must not reject everything (the colon bug, 2026-10-07)", async () => { + // URL.protocol is \"https:\" WITH the colon; the colon-less comparison + // ported from the py harness (urlparse().scheme has none) made the + // gate throw for every valid URL — the tool never fetched a page in + // its life (owner-reported via OpenRouter logs). This pin stubs fetch + // so the happy path runs hermetically: a VALID url must REACH the + // fetch stage. + const { web_fetch } = createWebTools(() => null); + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response("A plain text page with enough content to matter for the test.", { + headers: { "content-type": "text/plain" }, + })) as typeof fetch; + try { + const ok = await web_fetch!.execute!("c-ok", { url: "https://example.com/page" }); + assert.ok( + !String(ok.details).includes("invalid URL"), + "a valid https URL passes the protocol gate and reaches fetch", + ); + assert.match(String(ok.details), /plain text page/); + + const ftp = await web_fetch!.execute!("c-ftp", { url: "ftp://example.com/file" }); + assert.match(String(ftp.details), /invalid URL/, "non-http(s) schemes still rejected"); + } finally { + globalThis.fetch = realFetch; + } + }); }); /** Minimal in-memory JobStore — the drop tests never touch the fs. */