mirror of
https://github.com/mksglu/context-mode.git
synced 2026-09-19 03:27:16 +08:00
0ce043fe8c
The only emptiness guard on the fetch path was `markdown.length === 0`. excalidraw.com serves 6,862 bytes of bootstrap HTML whose entire text content is its <title>; Turndown converts that to the 21 bytes "Excalidraw Whiteboard". 21 is not 0, so ctx_fetch_and_index answered "fetched successfully", indexed the shell as though it were the page, and the caller had no way to tell that apart from a genuinely short document. Measured on this exact code path (bytes received -> markdown bytes out): excalidraw.com 6,862 -> 21 ( 0.31% yield) <- shell app.diagrams.net 2,759 -> 476 (17.25% yield) nextjs.org 316,151 -> 13,587 ( 4.30% yield) developers.cloudflare.com 178,627 -> 8,389 ( 4.70% yield) The subprocess now reports the pre-conversion byte count on stdout line 2. Line 1 keeps its exact historical `__CM_CT__:<type>` shape, so the two `header === "__CM_CT__:json"` comparisons downstream are untouched, and a missing line 2 (older bundle) reads as "no evidence" rather than as a failure. classifyExtraction() then requires BOTH signals before accusing: almost no text came out (< 200 bytes) AND almost none of what came in survived (< 2% yield). Neither works alone -- a ratio alone condemns a small valid page (`<p>Hello</p>` is 5 bytes of text from 38 of markup and is perfectly fine), a floor alone condemns a genuinely short document. Requiring both isolates the shell case and leaves all three real pages above untouched. Arithmetic only: no pattern matching, no markup sniffing, no word lists. A shell is now `fetch_error` with reason "shell", carrying both byte counts and the yield, and saying why a retry will return the same 21 bytes. Nothing is indexed -- storing a bootstrap shell under the page's name poisons the knowledge base and leaves the agent believing it has the document. Verified live end-to-end after the change: excalidraw.com is refused with the diagnosis, the other three are unaffected.
191 lines
8.2 KiB
TypeScript
191 lines
8.2 KiB
TypeScript
/**
|
|
* Regression: a JavaScript-rendered shell must NOT be reported as a successful
|
|
* fetch.
|
|
*
|
|
* THE DEFECT. The fetch path's only emptiness guard was `markdown.length === 0`.
|
|
* excalidraw.com serves 6,862 bytes of bootstrap HTML whose entire text content
|
|
* is its <title>; Turndown converts that to the 21 bytes "Excalidraw Whiteboard".
|
|
* 21 is not 0, so the tool answered "fetched successfully", indexed the shell,
|
|
* and the caller had no way to distinguish that from a genuinely short page.
|
|
*
|
|
* Numbers below are MEASURED against this exact code path (2026-08), not
|
|
* invented — see the table in classifyExtraction()'s doc comment.
|
|
*
|
|
* Run: npx vitest run tests/core/fetch-shell-detection.test.ts
|
|
*/
|
|
import { describe, test, expect } from "vitest";
|
|
import { spawnSync } from "node:child_process";
|
|
import { writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { buildFetchCode, classifyExtraction } from "../../src/server.js";
|
|
|
|
// ── Layer 1: the verdict itself, against real measured byte pairs ──────────
|
|
|
|
describe("classifyExtraction — measured pages", () => {
|
|
// (label, sourceBytes, textBytes, expected)
|
|
const MEASURED: Array<[string, number, number, "ok" | "shell"]> = [
|
|
// The defect case. 0.31% yield, 21 bytes of text.
|
|
["excalidraw.com", 6862, 21, "shell"],
|
|
// A shell too, but one whose HTML genuinely carries marketing prose.
|
|
// 476 bytes is real text that truthfully describes the site: not a lie,
|
|
// so not an error. Guarding this would be a false accusation.
|
|
["app.diagrams.net", 2759, 476, "ok"],
|
|
// Server-rendered controls. Both must stay clean.
|
|
["nextjs.org", 316151, 13587, "ok"],
|
|
["developers.cloudflare.com", 178627, 8389, "ok"],
|
|
];
|
|
|
|
for (const [label, sourceBytes, textBytes, expected] of MEASURED) {
|
|
test(`${label}: ${sourceBytes}B in -> ${textBytes}B out is "${expected}"`, () => {
|
|
expect(classifyExtraction(textBytes, sourceBytes).kind).toBe(expected);
|
|
});
|
|
}
|
|
|
|
test("reports the evidence, so the caller can judge rather than trust", () => {
|
|
const v = classifyExtraction(21, 6862);
|
|
expect(v.kind).toBe("shell");
|
|
if (v.kind !== "shell") throw new Error("unreachable");
|
|
expect(v.textBytes).toBe(21);
|
|
expect(v.sourceBytes).toBe(6862);
|
|
expect(v.yieldPct).toBe("0.31");
|
|
});
|
|
});
|
|
|
|
describe("classifyExtraction — pages that must never be accused", () => {
|
|
test("a small but complete document: low text, but nearly all of it survived", () => {
|
|
// "<html><body><p>Hello</p></body></html>" -> "Hello".
|
|
// Tiny output, but 13% of the bytes were text. Not a shell.
|
|
expect(classifyExtraction(5, 38).kind).toBe("ok");
|
|
});
|
|
|
|
test("plain text passes through 1:1 and is never a shell", () => {
|
|
expect(classifyExtraction(12, 12).kind).toBe("ok");
|
|
});
|
|
|
|
test("pretty-printed JSON grows past its source and is never a shell", () => {
|
|
expect(classifyExtraction(180, 90).kind).toBe("ok");
|
|
});
|
|
|
|
test("a long page with a poor ratio is still a real page", () => {
|
|
// 200 bytes is the floor: at or above it we never accuse, whatever the ratio.
|
|
expect(classifyExtraction(200, 10_000_000).kind).toBe("ok");
|
|
});
|
|
|
|
test("no evidence means no accusation (older bundle emits no byte count)", () => {
|
|
expect(classifyExtraction(21, 0).kind).toBe("ok");
|
|
expect(classifyExtraction(21, Number.NaN).kind).toBe("ok");
|
|
expect(classifyExtraction(21, -1).kind).toBe("ok");
|
|
});
|
|
});
|
|
|
|
// ── Layer 2: the wiring. Run the REAL generated subprocess, no network. ────
|
|
//
|
|
// A verdict function that is never called is not a guard. This drives the
|
|
// actual code buildFetchCode() emits — real Turndown, real remove() list, real
|
|
// emit() — with global fetch stubbed, and asserts the subprocess reports the
|
|
// pre-conversion byte count the parent's guard depends on.
|
|
|
|
function runGeneratedFetch(html: string, contentType: string): { header: string; sourceBytes: number; text: string } {
|
|
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const outPath = join(tmpdir(), `shelltest-out-${stamp}.dat`);
|
|
const codePath = join(tmpdir(), `shelltest-code-${stamp}.cjs`);
|
|
|
|
const stub = `
|
|
globalThis.fetch = async function (u, init) {
|
|
const headers = new Map([['content-type', ${JSON.stringify(contentType)}]]);
|
|
return {
|
|
status: 200,
|
|
ok: true,
|
|
headers: { get: (k) => headers.get(String(k).toLowerCase()) ?? null },
|
|
text: async () => ${JSON.stringify(html)},
|
|
};
|
|
};
|
|
`;
|
|
|
|
try {
|
|
writeFileSync(codePath, stub + buildFetchCode("https://example.invalid/", outPath));
|
|
const proc = spawnSync(process.execPath, [codePath], {
|
|
encoding: "utf-8",
|
|
timeout: 30_000,
|
|
cwd: process.cwd(),
|
|
});
|
|
expect(proc.status, `subprocess stderr: ${proc.stderr}`).toBe(0);
|
|
const lines = (proc.stdout || "").trim().split("\n");
|
|
return {
|
|
header: (lines[0] || "").trim(),
|
|
sourceBytes: Number.parseInt((lines[1] || "").trim(), 10),
|
|
text: existsSync(outPath) ? readFileSync(outPath, "utf-8").trim() : "",
|
|
};
|
|
} finally {
|
|
try { rmSync(codePath); } catch { /* already gone */ }
|
|
try { rmSync(outPath); } catch { /* already gone */ }
|
|
}
|
|
}
|
|
|
|
describe("generated subprocess reports pre-conversion bytes", () => {
|
|
// Shaped like excalidraw.com: a title, a mountpoint, and script/style bulk
|
|
// that Turndown strips to nothing. Padded so the yield lands where the real
|
|
// page's does rather than relying on a handful of bytes.
|
|
const SHELL_HTML =
|
|
"<!doctype html><html><head><title>Excalidraw Whiteboard</title>" +
|
|
`<style>${"a{color:red}".repeat(200)}</style>` +
|
|
`<script>${"var x=1;".repeat(400)}</script>` +
|
|
'</head><body><div id="root"></div>' +
|
|
`<noscript>${"enable javascript ".repeat(20)}</noscript>` +
|
|
"</body></html>";
|
|
|
|
const ARTICLE_HTML =
|
|
"<!doctype html><html><head><title>A Real Page</title></head><body><article>" +
|
|
`<h1>Heading</h1><p>${"This sentence is genuine prose that survives conversion. ".repeat(40)}</p>` +
|
|
"</article></body></html>";
|
|
|
|
test("html: reports raw HTML byte length on stdout line 2", () => {
|
|
const r = runGeneratedFetch(SHELL_HTML, "text/html; charset=utf-8");
|
|
expect(r.header).toBe("__CM_CT__:html");
|
|
expect(r.sourceBytes).toBe(Buffer.byteLength(SHELL_HTML, "utf-8"));
|
|
});
|
|
|
|
test("the shell survives conversion as non-empty text — which is the whole bug", () => {
|
|
const r = runGeneratedFetch(SHELL_HTML, "text/html; charset=utf-8");
|
|
// Non-empty: the OLD guard (`length === 0`) would have called this a success.
|
|
expect(r.text.length).toBeGreaterThan(0);
|
|
// And the NEW guard catches it.
|
|
expect(classifyExtraction(r.text.length, r.sourceBytes).kind).toBe("shell");
|
|
});
|
|
|
|
test("a real article through the same path is reported ok", () => {
|
|
const r = runGeneratedFetch(ARTICLE_HTML, "text/html; charset=utf-8");
|
|
expect(r.header).toBe("__CM_CT__:html");
|
|
expect(r.text.length).toBeGreaterThan(200);
|
|
expect(classifyExtraction(r.text.length, r.sourceBytes).kind).toBe("ok");
|
|
});
|
|
|
|
test("plain text: byte count is reported and the body passes through", () => {
|
|
const r = runGeneratedFetch("just a short note", "text/plain");
|
|
expect(r.header).toBe("__CM_CT__:text");
|
|
expect(r.sourceBytes).toBe(Buffer.byteLength("just a short note", "utf-8"));
|
|
expect(classifyExtraction(r.text.length, r.sourceBytes).kind).toBe("ok");
|
|
});
|
|
|
|
test("json: line 1 keeps its exact historical shape for the downstream ===", () => {
|
|
const r = runGeneratedFetch('{"a":1}', "application/json");
|
|
expect(r.header).toBe("__CM_CT__:json");
|
|
expect(r.sourceBytes).toBe(7);
|
|
});
|
|
});
|
|
|
|
// ── Layer 3: the handler actually consults the verdict ────────────────────
|
|
|
|
describe("the fetch handler wires the verdict into its result", () => {
|
|
const src = readFileSync(new URL("../../src/server.ts", import.meta.url), "utf-8");
|
|
|
|
test("classifyExtraction is called on the success path, not merely defined", () => {
|
|
// Guard against a refactor that keeps the function but drops the call.
|
|
const callSites = src.split("classifyExtraction(").length - 1;
|
|
// one definition signature + at least one call site
|
|
expect(callSites).toBeGreaterThanOrEqual(2);
|
|
expect(src.includes('reason: "shell"')).toBe(true);
|
|
});
|
|
});
|