From 92c386731a38e334cd397282a197ab2b6e8afaba Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:53:32 +0300 Subject: [PATCH] fix(renderer): ship theme scripts as files; render_page names broken surfaces and blocked scripts Astro inlines component `; + const blocked = await render("csp.html", page("content-security-policy")); + expect(blocked("surface-broken")?.examples).toEqual([ + expect.stringMatching( + /section sec_body div\.ch-surface-problem .* — srf_cards\/components\/c1 unknown component$/, + ), + expect.stringMatching(/component c2 .* — link-cards: unknown block type$/), + ]); + expect(blocked("script-blocked")?.examples).toEqual([ + expect.stringMatching( + /^script .* — an inline script .*was blocked by script-src-elem; ship it as a file/, + ), + ]); + // `cloudhal dev` sends the policy report-only, a header only (a meta tag cannot): the same + // script, named as what a deploy blocks. + const server = createServer((_req, res) => { + res.setHeader("content-type", "text/html"); + res.setHeader("content-security-policy-report-only", "script-src 'self'"); + res.end(page("x-none").replace('', "")); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + try { + const { port } = server.address() as AddressInfo; + const r = await renderPage( + chromeRenderer(chrome as string, { idleMs: 10 }), + `http://127.0.0.1:${port}/`, + { widths: [1440], fullPage: false, timeoutMs: 50_000 }, + ); + expect(r.checks.find((c) => c.id === "script-blocked")?.examples).toEqual([ + expect.stringMatching(/an inline script .*would be blocked on the site by script-src-elem/), + ]); + } finally { + server.close(); + } + }, 90_000); }); describe("localRender", () => { diff --git a/packages/mcp-server/src/render-tools.ts b/packages/mcp-server/src/render-tools.ts index f9c0bc7e..e910d1b6 100644 --- a/packages/mcp-server/src/render-tools.ts +++ b/packages/mcp-server/src/render-tools.ts @@ -73,6 +73,8 @@ const WHAT: Record<(typeof RENDER_CHECK_IDS)[number], string> = { "carousel-autoplay": "carousel autoplay", "image-fit": "image crops", "font-fallback": "font swap shift", + "surface-broken": "interactive blocks that cannot be shown", + "script-blocked": "scripts blocked by the CSP", }; function summary(r: RenderPageResult): string { @@ -130,7 +132,7 @@ export function renderTools(previewDoc: PreviewDoc): ToolDefinition[] { name: "render_page", title: "Render a page in a browser", description: - "Look at a page's draft as people will see it: renders it in a real browser at desktop (1440), tablet (768) and phone (390) widths and returns screenshots plus automatic checks (sideways scrolling, alt text, broken images, heading order, text contrast, empty sections, tap targets, line length, motion under reduced motion, layout shift) that name the section and component behind each problem. Use it after building or changing a page and before asking a person to publish; fix what it finds and render again. Read-only. Rate-limited (about 10 a minute); when no browser is available, use preview_page.", + "Look at a page's draft as people will see it: renders it in a real browser at desktop (1440), tablet (768) and phone (390) widths and returns screenshots plus automatic checks (sideways scrolling, alt text, broken images, heading order, text contrast, empty sections, tap targets, line length, motion under reduced motion, layout shift, interactive blocks that cannot be shown, scripts the site's Content-Security-Policy blocks) that name the section and component behind each problem. Use it after building or changing a page and before asking a person to publish; fix what it finds and render again. Read-only. Rate-limited (about 10 a minute); when no browser is available, use preview_page.", inputSchema: obj( { siteId: id, diff --git a/packages/mcp-server/src/render.ts b/packages/mcp-server/src/render.ts index 3938d007..cd944f9f 100644 --- a/packages/mcp-server/src/render.ts +++ b/packages/mcp-server/src/render.ts @@ -1,9 +1,10 @@ /** * Sight for agents (RFC-0021 §4): renders a page in a real browser at a few widths and returns * screenshots plus automatic checks (overflow, alt text, broken images, heading order, contrast, - * empty sections, tap targets, line length). The browser is a DevTools socket: local Chrome under - * Node (`@cloudhal/cli`), Cloudflare Browser Rendering on Workers (`@cloudhal/cloudflare`). One - * small DevTools client serves both, so there is no browser library to bundle. + * empty sections, tap targets, line length, broken surfaces, blocked scripts, …). The browser is a + * DevTools socket: local Chrome under Node (`@cloudhal/cli`), Cloudflare Browser Rendering on + * Workers (`@cloudhal/cloudflare`). One small DevTools client serves both, so there is no browser + * library to bundle. * * Worker-safe: no Node imports. */ diff --git a/packages/mcp-server/src/render/cdp.ts b/packages/mcp-server/src/render/cdp.ts index 30e2d9c1..ee324492 100644 --- a/packages/mcp-server/src/render/cdp.ts +++ b/packages/mcp-server/src/render/cdp.ts @@ -1,4 +1,5 @@ /** A minimal DevTools client over a browser-level socket (flat sessions), and base64 helpers. */ +import { CSP_LISTENER } from "./checks/scripts.ts"; import { type CdpSocket, RenderError, type RenderSession } from "./types.ts"; /** @@ -123,6 +124,8 @@ export async function cdpSession( }, ], }); + // Before the page's own scripts: keep the CSP violations it raises (`script-blocked`). + await s("Page.addScriptToEvaluateOnNewDocument", { source: CSP_LISTENER }); const loaded = cdp.waitFor("Page.loadEventFired", sessionId, options.loadTimeoutMs ?? 12_000); const nav = await s("Page.navigate", { url }); if (typeof nav.errorText === "string" && nav.errorText) diff --git a/packages/mcp-server/src/render/checks/index.ts b/packages/mcp-server/src/render/checks/index.ts index 7f0967b2..d44cbc61 100644 --- a/packages/mcp-server/src/render/checks/index.ts +++ b/packages/mcp-server/src/render/checks/index.ts @@ -15,6 +15,7 @@ import { LAYOUT_CHECKS } from "./layout.ts"; import { MOTION_CHECKS } from "./motion.ts"; import { NAVIGATION_CHECKS } from "./navigation.ts"; import { PRELUDE } from "./prelude.ts"; +import { SCRIPT_CHECKS } from "./scripts.ts"; export const RENDER_CHECK_SCRIPT = [ "(async () => {", @@ -23,6 +24,7 @@ export const RENDER_CHECK_SCRIPT = [ CONTRAST_CHECKS, CONTENT_CHECKS, FONT_CHECKS, + SCRIPT_CHECKS, MOTION_CHECKS, NAVIGATION_CHECKS, COMPONENT_CHECKS, diff --git a/packages/mcp-server/src/render/checks/scripts.ts b/packages/mcp-server/src/render/checks/scripts.ts new file mode 100644 index 00000000..5f1df660 --- /dev/null +++ b/packages/mcp-server/src/render/checks/scripts.ts @@ -0,0 +1,45 @@ +/** + * In-page checks, part of `RENDER_CHECK_SCRIPT`: surface-broken, script-blocked. + * Plain source text, run inside the page; it shares the script's locals (see ./index.ts). + */ + +/** + * Runs in the page before any of its own scripts (`Page.addScriptToEvaluateOnNewDocument`): + * keeps the Content-Security-Policy violations the page raises while it loads, which the + * `script-blocked` check reads. Report-only ones (`cloudhal dev`) are kept too. + */ +export const CSP_LISTENER = `(() => { + const seen = (window.__chCsp = []); + window.addEventListener("securitypolicyviolation", (e) => { + if (seen.length < 50) + seen.push({ directive: e.effectiveDirective || e.violatedDirective || "", blocked: e.blockedURI || "", sample: e.sample || "", line: e.lineNumber || 0, report: e.disposition === "report", el: e.target instanceof Element ? e.target : null }); + }, true); +})();`; + +export const SCRIPT_CHECKS = ` // surface-broken: a body surface the preview could not draw (RFC-0020 §5), or a component in one + // with an unknown block type or props that do not fit it. Theme blocks the preview leaves to the + // site are fine. + for (const el of document.querySelectorAll(".ch-surface-problem")) { + const why = [...el.querySelectorAll("li")].map((li) => (li.textContent || "").trim()).filter(Boolean); + add("surface-broken", el, why.length ? why.slice(0, 3).join("; ") : "this interactive block cannot be shown"); + } + for (const el of document.querySelectorAll(".ch-block--missing")) { + const text = (el.textContent || "").trim(); + if (!/shown by the theme on the site$/.test(text)) add("surface-broken", el, text); + } + + // script-blocked: scripts the page's Content-Security-Policy refused (the Site Worker sends + // script-src 'self', so an inline