From 062723e44e937c1e1df865e9ae16184b41e70ae8 Mon Sep 17 00:00:00 2001 From: clyde Date: Sun, 11 Oct 2026 13:05:43 +0530 Subject: [PATCH] fix(browser): retain effective source-map response urls Preserve source-map response identity and legacy omission, admit identities before body reading, and retain completed results and cleanup causes within the existing output budget. Price fixed envelopes once. Closes #1729. Closes #1738. Co-authored-by: allisaid --- docs/browser-observation.md | 11 + src/browser/WebSourceMapFetcher.ts | 182 ++++++++++---- src/domain/webBundleAnalysis.ts | 2 + src/domain/webBundleAnalyzer.test.ts | 1 + .../browser/webSourceMapFetcher.test.ts | 238 ++++++++++++++---- .../browser/webSourceMapHttpStatus.test.ts | 186 +++++++++++++- tests/boundary/mcp/browserMcp.test.ts | 82 ++++++ tests/fixtures/fakeCdpBrowser.ts | 33 ++- tests/fixtures/fakeCdpBrowserTypes.ts | 1 + 9 files changed, 636 insertions(+), 100 deletions(-) diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 2294e117c..6cdf2942a 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -199,6 +199,17 @@ Network and console coverage starts only after REA attaches and enables the rele Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation has a 30-second deadline and a 64 MiB aggregate streamed-response budget across redirects. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding also bounds raw resolved identities to 64 MiB and aggregate inline evidence to 32 MiB. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`. +source-map items retain the script's `declared_url` separately from the observed +`effective_url`. successful maps use the latter as their source-resolution base. +failed fetches retain the last response URL observed within the approved origins; +`effective_url` is `null` when no response was observed. query parameters are +preserved, and both URLs count toward the inline evidence budget. +Current fetches emit a string or null; historical observations may omit +`effective_url` without changing their stored content or Evidence identity. +An observed URL must fit before its response body is read. If it cannot fit, +remaining map results are explicitly unknown; failed cancellation retains its +cleanup context and resource constraint. + ## Non-goals and threat model The browser provider deliberately does not expose generic CDP commands, `Runtime.evaluate`, WebMCP invocation, navigation, input, downloads, page closure, or browser closure. Screenshot capture is the sole pixel surface and returns an image only when requested. REA disables the domains it enabled, detaches the target session, and closes only its own WebSocket. diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 96e959527..6c74cff6c 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -6,6 +6,8 @@ import { } from "../javascript/sourceMaps/DecodedSourceMap.js"; import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; +import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; +import type { AnalysisErrorOptions } from "../domain/analysisErrorBase.js"; import { isUrlLikeModuleSpecifier } from "../domain/webBundleAnalyzerAst.js"; import { analyzeParsedJavaScriptReferences } from "../domain/javascript/javascriptSemanticAnalysis.js"; import { traverseJavaScriptAst } from "../domain/javascript/javascriptSemanticTraversal.js"; @@ -103,8 +105,7 @@ export const fetchWebSourceMaps = async ( reservation > SOURCE_MAP_OUTPUT_BYTES - budget.decodedRecords.expandedBytes ) { - const unprocessed = requests.length - items.length; - limitation = `Source-map output budget was exhausted before processing ${String(unprocessed)} requested map${unprocessed === 1 ? "" : "s"}. Their results are unknown.`; + limitation = sourceMapOutputLimitation(requests.length - items.length); break; } const expandedBytesBeforeItem = budget.decodedRecords.expandedBytes; @@ -126,11 +127,30 @@ export const fetchWebSourceMaps = async ( host, budget, ); - if (item.artifact === null) + if (item.artifact === null) { + const failureBytes = + sourceMapFailureReservation( + request, + item.effective_url ?? null, + item.limitation, + item.status, + ) + 1; + if (failureBytes > SOURCE_MAP_OUTPUT_BYTES - expandedBytesBeforeItem) { + limitation = sourceMapOutputLimitation( + requests.length - items.length, + ); + break; + } budget.decodedRecords.expandedBytes = - expandedBytesBeforeItem + reservation; + expandedBytesBeforeItem + failureBytes; + } items.push(item); } + } catch (cause: unknown) { + if (signal?.aborted === true) throw cause; + if (cause instanceof SourceMapFormatFailure && cause.reason === "limit") + limitation = sourceMapOutputLimitation(requests.length - items.length); + else throw cause; } finally { clearTimeout(timeout); signal?.removeEventListener("abort", abortFromCaller); @@ -145,6 +165,9 @@ export const fetchWebSourceMaps = async ( }); }; +const sourceMapOutputLimitation = (unprocessed: number): string => + `source-map output budget could not retain results for ${String(unprocessed)} requested map${unprocessed === 1 ? "" : "s"}. their results are unknown.`; + const sourceMapsStatus = ( items: readonly SourceMapItem[], omitted: boolean, @@ -211,26 +234,55 @@ const fetchOne = async ( "policy_filtered", "Declared source-map URL is outside the approved exact origins.", ); + let effectiveUrl: string | null = null; try { const fetched = await fetchFollowingApprovedRedirects( request.fetchUrl, input.allowed_origins, signal, host, + (url) => { + effectiveUrl = url; + }, ); if (fetched === undefined) return emptySourceMapItem( request, "policy_filtered", "A source-map redirect left the approved exact origins.", + effectiveUrl, ); const { response, fetchedUrl } = fetched; + try { + // Admit the resolution identity before reading or retaining its body. + consumeExpandedBytes(budget.decodedRecords, jsonBytes(fetchedUrl) - 4); + } catch (cause: unknown) { + try { + await response.body?.cancel(); + } catch (cleanupCause: unknown) { + if (cause instanceof SourceMapFormatFailure && cause.reason === "limit") + throw sourceMapOutputError(cause, { + cause: new AggregateError( + [cause, cleanupCause], + "Source-map response cleanup failed after its output exceeded capacity.", + ), + cleanup: { + reason: + "Source-map response cancellation failed after its output exceeded capacity.", + resources: ["source-map-response"], + }, + }); + throw cause; + } + throw cause; + } if (!response.ok) { await response.body?.cancel(); return emptySourceMapItem( request, "fetch_failed", `Source-map server returned HTTP ${String(response.status)}.`, + fetchedUrl, ); } checkOperation(undefined, signal, budget.deadlineAt); @@ -250,13 +302,21 @@ const fetchOne = async ( }, ); } catch (cause: unknown) { + if (cause instanceof AnalysisResourceConstraintError) throw cause; + if (cause instanceof SourceMapFormatFailure && cause.reason === "limit") + throw cause; if ( signal?.aborted === true && !(signal.reason instanceof SourceMapDeadlineError) ) throw cause; if (cause instanceof SourceMapEncodingError) - return emptySourceMapItem(request, "invalid", cause.message); + return emptySourceMapItem( + request, + "invalid", + cause.message, + effectiveUrl, + ); return emptySourceMapItem( request, "fetch_failed", @@ -265,6 +325,7 @@ const fetchOne = async ( cause instanceof SourceMapDeadlineError ? cause.message : "Source-map fetch or validation failed.", + effectiveUrl, ); } }; @@ -288,6 +349,7 @@ const fetchFollowingApprovedRedirects = async ( allowedOrigins: readonly string[], signal: AbortSignal | undefined, host: SourceMapFetchHost, + observeResponse: (url: string) => void, ): Promise<{ response: Response; fetchedUrl: string } | undefined> => { let current = initialUrl; const visited = new Set(); @@ -309,6 +371,7 @@ const fetchFollowingApprovedRedirects = async ( }), signal, ); + observeResponse(current); if (signal !== undefined && signalIsAborted(signal)) { await response.body?.cancel(signal.reason).catch(() => undefined); throw signal.reason; @@ -464,6 +527,7 @@ const normalizeSourceMap = ( request, "invalid", "Source-map JSON is not a version 3 map.", + fetchedUrl, ); const retainedBytesBeforeMap = context.budget.expandedBytes; try { @@ -476,8 +540,8 @@ const normalizeSourceMap = ( }); context.budget.expandedBytes = retainedBytesBeforeMap - - sourceMapFailureReservation(request) + - sourceMapSuccessReservation(request); + SOURCE_MAP_FAILURE_ENVELOPE_BYTES + + SOURCE_MAP_SUCCESS_ENVELOPE_BYTES; consumeExpandedBytes( context.budget, jsonBytes(text) + @@ -583,7 +647,7 @@ const normalizeSourceMap = ( ) - 4, ); const parsed = { - ...sourceMapContext(request), + ...sourceMapContext(request, fetchedUrl), artifact: createWebTextArtifact(text, "application/source-map+json"), original_sources: originalSources, original_module_edges: modules.edges, @@ -602,7 +666,12 @@ const normalizeSourceMap = ( // reservation while keeping cumulative fetch and decoder-work accounting. context.budget.expandedBytes = retainedBytesBeforeMap; if (cause instanceof SourceMapFormatFailure && cause.reason === "limit") - return emptySourceMapItem(request, "fetch_failed", cause.message); + return emptySourceMapItem( + request, + "fetch_failed", + cause.message, + fetchedUrl, + ); if ( context.signal?.aborted === true || cause instanceof SourceMapDeadlineError @@ -612,6 +681,7 @@ const normalizeSourceMap = ( request, "invalid", "Source-map JSON mappings could not be decoded safely.", + fetchedUrl, ); } }; @@ -718,17 +788,22 @@ const approvedUrl = ( } }; -const sourceMapContext = (request: WebSourceMapRequest) => ({ +const sourceMapContext = ( + request: WebSourceMapRequest, + effectiveUrl: string | null = null, +) => ({ script_key: request.scriptKey, declared_url: request.declaredUrl, + effective_url: effectiveUrl, }); const emptySourceMapItem = ( request: WebSourceMapRequest, status: Extract["status"], limitation: string, + effectiveUrl: string | null = null, ): Extract => ({ - ...sourceMapContext(request), + ...sourceMapContext(request, effectiveUrl), status, artifact: null, original_sources: [], @@ -798,6 +873,18 @@ const expandedOutputFailure = (): SourceMapFormatFailure => `Expanded source-map evidence exceeds the ${WEB_SOURCE_MAP_LIMITS.outputBytes / (1024 * 1024)} MiB public representation budget.`, ); +const sourceMapOutputError = ( + cause: SourceMapFormatFailure, + options?: AnalysisErrorOptions, +): AnalysisResourceConstraintError => + new AnalysisResourceConstraintError( + "analyze_web_bundle", + "transport", + cause.message, + { maximum_source_map_output_bytes: SOURCE_MAP_OUTPUT_BYTES }, + { cause, ...options }, + ); + const resolvedSourceLimitFailure = (): SourceMapFormatFailure => new SourceMapFormatFailure( "limit", @@ -821,42 +908,45 @@ const artifactMetadataBytes = (mediaType: string, text: string): number => }), ) - 2; -const sourceMapFailureReservation = (request: WebSourceMapRequest): number => { - const envelope = emptySourceMapItem( - { ...request, declaredUrl: "", scriptKey: "" }, - "fetch_failed", - "x".repeat(SOURCE_MAP_FAILURE_LIMITATION_RESERVE), - ); - return ( - Buffer.byteLength(JSON.stringify(envelope)) - - 4 + - jsonBytes(request.declaredUrl) + - jsonBytes(request.scriptKey) - ); -}; +// Price invariant envelopes once using the producer's representation. Dynamic +// identities keep their own charges when the map's envelope changes. +const EMPTY_SOURCE_MAP_FAILURE = emptySourceMapItem( + { scriptKey: "", declaredUrl: "", fetchUrl: "" }, + "fetch_failed", + "x".repeat(SOURCE_MAP_FAILURE_LIMITATION_RESERVE), +); +const SOURCE_MAP_FAILURE_ENVELOPE_BYTES = + Buffer.byteLength(JSON.stringify(EMPTY_SOURCE_MAP_FAILURE)) - 4; +const SOURCE_MAP_SUCCESS_ENVELOPE_BYTES = + Buffer.byteLength( + JSON.stringify({ + ...EMPTY_SOURCE_MAP_FAILURE, + status: "included", + limitation: null, + }), + ) - 4; -const sourceMapSuccessReservation = (request: WebSourceMapRequest): number => { - const context = sourceMapContext({ - ...request, - declaredUrl: "", - scriptKey: "", - }); - const envelope = { - ...context, - status: "included", - artifact: null, - original_sources: [], - original_module_edges: [], - mappings: [], - limitation: null, - }; - return ( - Buffer.byteLength(JSON.stringify(envelope)) - - 4 + - jsonBytes(request.declaredUrl) + - jsonBytes(request.scriptKey) - ); -}; +const sourceMapFailureReservation = ( + request: WebSourceMapRequest, + effectiveUrl: string | null = null, + limitation?: string, + status: Extract< + SourceMapItem, + { readonly artifact: null } + >["status"] = "fetch_failed", +): number => + SOURCE_MAP_FAILURE_ENVELOPE_BYTES + + jsonBytes(request.declaredUrl) + + jsonBytes(request.scriptKey) + + (effectiveUrl === null ? 0 : jsonBytes(effectiveUrl) - 4) + + Math.max( + 0, + (limitation === undefined ? 0 : jsonBytes(limitation)) - + SOURCE_MAP_FAILURE_LIMITATION_RESERVE - + 2, + ) + + jsonBytes(status) - + jsonBytes("fetch_failed"); const sourceEntryOverheadBytes = (): number => Buffer.byteLength('{"source":,"artifact":null}'); diff --git a/src/domain/webBundleAnalysis.ts b/src/domain/webBundleAnalysis.ts index c3bbcf352..240016c54 100644 --- a/src/domain/webBundleAnalysis.ts +++ b/src/domain/webBundleAnalysis.ts @@ -52,6 +52,8 @@ const sourceMapMappingSchema = z.object({ const sourceMapContextShape = { script_key: z.string(), declared_url: z.string(), + // Older saved observations omit this field; current fetches emit string/null. + effective_url: z.string().nullable().optional(), }; const parsedSourceMapShape = { artifact: webTextArtifactSchema, diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 816ef5cc0..5913b0f40 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -129,6 +129,7 @@ describe("web bundle analyzer", () => { { script_key: `scr_${"1".repeat(64)}`, declared_url: "https://app.example.test/app.js.map", + effective_url: null, status: "fetch_failed", artifact: null, original_sources: [], diff --git a/tests/boundary/browser/webSourceMapFetcher.test.ts b/tests/boundary/browser/webSourceMapFetcher.test.ts index fd6deb952..f3bb5fada 100644 --- a/tests/boundary/browser/webSourceMapFetcher.test.ts +++ b/tests/boundary/browser/webSourceMapFetcher.test.ts @@ -119,26 +119,27 @@ it("parses ECMA-426 HTTP source-map prefixes while retaining exact response evid }); describe("source-map redirect URL resolution", () => { - it.each(["/maps/current", "/assets/v2/app.js.map"])( + const deliveredPath = + "/assets/v2/app.js.map?revision=2&token=public-selection"; + it.each(["/maps/current", deliveredPath])( "resolves relative sources against the delivered map at %s", async (initialPath) => { const calls: string[] = []; + const text = JSON.stringify({ + version: 3, + names: [], + sources: ["../src/main.ts"], + sourcesContent: ["import './dependency.ts';"], + mappings: "AAAA", + }); const server = createServer((incoming, response) => { calls.push(incoming.url ?? ""); if (incoming.url === "/maps/current") { - response.writeHead(302, { location: "/assets/v2/app.js.map" }).end(); + response.writeHead(302, { location: deliveredPath }).end(); return; } response.setHeader("content-type", "application/json"); - response.end( - JSON.stringify({ - version: 3, - names: [], - sources: ["../src/main.ts"], - sourcesContent: ["import './dependency.ts';"], - mappings: "AAAA", - }), - ); + response.end(text); }); try { await new Promise((resolve, reject) => { @@ -156,7 +157,7 @@ describe("source-map redirect URL resolution", () => { ); expect(calls).toEqual( initialPath === "/maps/current" - ? [initialPath, "/assets/v2/app.js.map"] + ? [initialPath, deliveredPath] : [initialPath], ); expect(result).toMatchObject({ @@ -164,6 +165,12 @@ describe("source-map redirect URL resolution", () => { items: [ { declared_url: declaredUrl, + effective_url: `${localOrigin}${deliveredPath}`, + artifact: { + text, + bytes: Buffer.byteLength(text), + sha256: createHash("sha256").update(text).digest("hex"), + }, original_sources: [ { source: `${localOrigin}/assets/src/main.ts` }, ], @@ -188,6 +195,74 @@ describe("source-map redirect URL resolution", () => { } }, ); + + it.each([ + [404, "unavailable", "fetch_failed"], + [200, "not-json", "invalid"], + ])( + "retains the delivered URL when its HTTP %s response cannot supply a map", + async (responseStatus, body, itemStatus) => { + const effectiveUrl = `${origin}${deliveredPath}`; + const calls: string[] = []; + const result = await fetchWebSourceMaps([request], input(), undefined, { + fetch: (url) => { + calls.push(String(url)); + return Promise.resolve( + calls.length === 1 + ? new Response(null, { + status: 302, + headers: { location: effectiveUrl }, + }) + : new Response(body, { status: responseStatus }), + ); + }, + }); + expect(calls).toEqual([request.fetchUrl, effectiveUrl]); + expect(result.items[0]).toMatchObject({ + status: itemStatus, + declared_url: request.declaredUrl, + effective_url: effectiveUrl, + artifact: null, + }); + }, + ); + + it("retains the last observed response URL when the next redirect fetch rejects", async () => { + const nextUrl = `${origin}${deliveredPath}`; + const calls: string[] = []; + const result = await fetchWebSourceMaps([request], input(), undefined, { + fetch: (url) => { + calls.push(String(url)); + return calls.length === 1 + ? Promise.resolve( + new Response(null, { + status: 302, + headers: { location: nextUrl }, + }), + ) + : Promise.reject(new TypeError("fixture connection failed")); + }, + }); + expect(calls).toEqual([request.fetchUrl, nextUrl]); + expect(result.items[0]).toMatchObject({ + status: "fetch_failed", + declared_url: request.declaredUrl, + effective_url: request.fetchUrl, + artifact: null, + }); + }); + + it("reports an absent effective URL when the first fetch rejects", async () => { + const result = await fetchWebSourceMaps([request], input(), undefined, { + fetch: () => Promise.reject(new TypeError("fixture connection failed")), + }); + expect(result.items[0]).toMatchObject({ + status: "fetch_failed", + declared_url: request.declaredUrl, + effective_url: null, + artifact: null, + }); + }); }); describe("web source-map fetching and validation: fetching maps and following redirects", () => { @@ -275,7 +350,7 @@ describe("web source-map fetching and validation: fetching maps and following re expect(calls).toEqual([request.fetchUrl]); expect(result).toMatchObject({ status: "unavailable", - items: [{ status: "policy_filtered" }], + items: [{ status: "policy_filtered", effective_url: request.fetchUrl }], }); expectInvalidSourceMaps({ ...result, @@ -309,7 +384,7 @@ describe("web source-map fetching and validation: fetching maps and following re expect(calls).toEqual([]); expect(result).toMatchObject({ status: "unavailable", - items: [{ status: "policy_filtered" }], + items: [{ status: "policy_filtered", effective_url: null }], }); }, ); @@ -982,38 +1057,111 @@ describe("web source-map collection", () => { }); describe("source-map output admission", () => { - it("admits a complete maximum-size map set when its real output fits", async () => { - const localOrigin = "http://127.0.0.1:9222"; - const mapUrl = `${localOrigin}/m`; - const requests = Array.from({ length: 50_000 }, (_, index) => ({ - ...request, - scriptKey: `scr_${index.toString(16).padStart(64, "0")}`, - declaredUrl: mapUrl, - fetchUrl: mapUrl, - })); - const map = JSON.stringify({ - version: 3, - names: [], - sources: ["x"], - mappings: "AAAA", - }); - const result = await fetchWebSourceMaps( - requests, - input({ allowed_origins: [localOrigin] }), - undefined, - { - timeoutMs: 120_000, - fetch: () => Promise.resolve(new Response(map, { status: 200 })), - }, - ); + it.each([ + { selectionMiB: 8, responseStatus: "invalid" }, + { selectionMiB: 16, responseStatus: "invalid" }, + { selectionMiB: 16, responseStatus: "included" }, + ])( + "accounts for JSON escaping in a $selectionMiB MiB effective URL for $responseStatus responses", + async ({ selectionMiB, responseStatus }) => { + const effectiveUrl = `${origin}/delivered.map?selection=${"\\".repeat(selectionMiB * 1_024 * 1_024)}`; + const secondRequest = { ...request, scriptKey: `scr_${"2".repeat(64)}` }; + let calls = 0; + const result = await fetchWebSourceMaps( + [request, secondRequest], + input(), + undefined, + { + fetch: () => { + calls += 1; + return Promise.resolve( + calls === 1 + ? validMapResponse() + : calls === 2 + ? new Response(null, { + status: 302, + headers: { location: effectiveUrl }, + }) + : responseStatus === "included" + ? validMapResponse() + : new Response("not-json", { status: 200 }), + ); + }, + }, + ); + expect(calls).toBe(3); + expect(result).toMatchObject({ + status: "partial", + requested: 2, + processed: selectionMiB === 8 ? 2 : 1, + }); + if (selectionMiB === 8) { + expect(result.items[1]).toMatchObject({ + status: "invalid", + declared_url: secondRequest.declaredUrl, + }); + expect(result.items[1]?.effective_url === effectiveUrl).toBe(true); + } else { + expect(result.items).toHaveLength(1); + expect(result.limitation).toContain("1 requested map"); + } + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual( + 32 * 1_024 * 1_024, + ); + }, + ); - expect(result.status).toBe("included"); - expect(result.limitation).toBeUndefined(); - expect(result.items).toHaveLength(requests.length); - expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual( - 32 * 1_024 * 1_024, - ); - }, 120_000); + it.each([ + { localOrigin: "http://[::1]:1", expectedStatus: "included" }, + { localOrigin: "http://127.0.0.1:9222", expectedStatus: "partial" }, + ])( + "admits a maximum-size map set according to its actual output at $localOrigin", + async ({ localOrigin, expectedStatus }) => { + const mapUrl = `${localOrigin}/m`; + const requests = Array.from({ length: 50_000 }, (_, index) => ({ + ...request, + scriptKey: `scr_${index.toString(16).padStart(64, "0")}`, + declaredUrl: mapUrl, + fetchUrl: mapUrl, + })); + const map = JSON.stringify({ + version: 3, + names: [], + sources: ["x"], + mappings: "AAAA", + }); + const result = await fetchWebSourceMaps( + requests, + input({ allowed_origins: [localOrigin] }), + undefined, + { + timeoutMs: 120_000, + fetch: () => Promise.resolve(new Response(map, { status: 200 })), + }, + ); + + expect(result.status).toBe(expectedStatus); + if (expectedStatus === "included") { + expect(result.limitation).toBeUndefined(); + expect(result.items).toHaveLength(requests.length); + } else { + expect(result.items.length).toBeLessThan(requests.length); + expect(result.limitation).toContain( + `${String(requests.length - result.items.length)} requested maps`, + ); + expect( + (Buffer.byteLength(JSON.stringify(result.items[0])) + 1) * + requests.length, + ).toBeGreaterThan(32 * 1_024 * 1_024); + } + expect(result.processed).toBe(result.items.length); + expect(result.requested).toBe(requests.length); + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual( + 32 * 1_024 * 1_024, + ); + }, + 120_000, + ); it("reserves large failure contexts and reports unprocessed requests as unknown", async () => { const largeDeclaredUrl = `${origin}/${"u".repeat(8 * 1_024 * 1_024)}`; diff --git a/tests/boundary/browser/webSourceMapHttpStatus.test.ts b/tests/boundary/browser/webSourceMapHttpStatus.test.ts index f07419968..c5deff2cd 100644 --- a/tests/boundary/browser/webSourceMapHttpStatus.test.ts +++ b/tests/boundary/browser/webSourceMapHttpStatus.test.ts @@ -4,7 +4,11 @@ import { createServer } from "node:http"; import { expect, it } from "vitest"; import { fetchWebSourceMaps } from "../../../src/browser/WebSourceMapFetcher.js"; -import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js"; +import { + analyzeWebBundleInputSchema, + webSourceMapsSchema, +} from "../../../src/domain/webBundleAnalysis.js"; +import { WEB_SOURCE_MAP_LIMITS } from "../../../src/domain/webSourceLocation.js"; it.each([ { status: 300, followsRedirect: false }, @@ -70,6 +74,10 @@ it.each([ expect(result.items[0]?.status).toBe( followsRedirect ? "included" : "fetch_failed", ); + expect(result.items[0]).toMatchObject({ + declared_url: url, + effective_url: followsRedirect ? `${origin}/delivered.map` : url, + }); if (!followsRedirect) expect(result.items[0]?.limitation).toBe( `Source-map server returned HTTP ${String(status)}.`, @@ -118,8 +126,184 @@ it("keeps the declared URL and reports malformed redirect Location evidence", as expect(result.items[0]).toMatchObject({ status: "fetch_failed", declared_url: declaredUrl, + effective_url: declaredUrl, limitation: expect.stringContaining("invalid Location URL"), }); expect(result.items[0]?.limitation).toContain(declaredUrl); expect(result.items[0]?.limitation).toContain("http://[invalid"); }); + +it.each(["invalid JSON", "invalid UTF-8", "HTTP failure", "response limit"])( + "retains a redirected response URL for %s", + async (failure) => { + const origin = "https://app.example.test"; + const declaredUrl = `${origin}/declared.map`; + const fetchedUrl = `${origin}/maps/current.map?token=source-value#fragment`; + const result = await fetchWebSourceMaps( + [{ scriptKey: "script", declaredUrl, fetchUrl: declaredUrl }], + analyzeWebBundleInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "page", + allowed_origins: [origin], + fetch_source_maps: true, + }), + undefined, + { + ...(failure === "response limit" ? { maxResponseBytes: 1 } : {}), + fetch: (url) => + Promise.resolve( + String(url) === declaredUrl + ? new Response(null, { + status: 302, + headers: { location: fetchedUrl }, + }) + : new Response( + failure === "invalid UTF-8" + ? Uint8Array.from([0xff]) + : "not-json", + { status: failure === "HTTP failure" ? 503 : 200 }, + ), + ), + }, + ); + expect(result.items[0]).toMatchObject({ + declared_url: declaredUrl, + effective_url: fetchedUrl, + status: + failure === "invalid JSON" || failure === "invalid UTF-8" + ? "invalid" + : "fetch_failed", + artifact: null, + }); + const item = result.items[0]; + if (item === undefined) throw new Error("Expected a fetched map result"); + const legacyItem = { ...item }; + delete legacyItem.effective_url; + const legacy = webSourceMapsSchema.parse({ + ...result, + items: [legacyItem], + }); + expect(legacy.items[0]).not.toHaveProperty("effective_url"); + }, +); + +it.each([ + { cancellationFails: false, reason: null }, + { cancellationFails: true, reason: new Error("fixture cancel failure") }, + { cancellationFails: true, reason: undefined }, +])( + "reserves a fetched URL before reading an over-cap response; cancellation failure=$cancellationFails, reason=$reason", + async ({ cancellationFails, reason }) => { + const origin = "https://app.example.test"; + const fetchedUrl = `${origin}/${"u".repeat(WEB_SOURCE_MAP_LIMITS.outputBytes)}`; + let cancelled = false; + let read = false; + const body = new ReadableStream( + { + pull(controller) { + read = true; + controller.enqueue( + new TextEncoder().encode( + '{"version":3,"sources":[],"names":[],"mappings":""}', + ), + ); + controller.close(); + }, + cancel() { + cancelled = true; + if (cancellationFails) throw reason; + }, + }, + { highWaterMark: 0 }, + ); + const pending = fetchWebSourceMaps( + [ + { + scriptKey: "script", + declaredUrl: `${origin}/declared.map`, + fetchUrl: fetchedUrl, + }, + ], + analyzeWebBundleInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "page", + allowed_origins: [origin], + fetch_source_maps: true, + }), + undefined, + { fetch: () => Promise.resolve(new Response(body)) }, + ); + if (cancellationFails) { + await expect(pending).rejects.toMatchObject({ + _tag: "AnalysisResourceConstraintError", + message: expect.stringContaining("Expanded source-map evidence"), + cleanupIncomplete: true, + cleanupResources: ["source-map-response"], + cause: expect.objectContaining({ + errors: expect.arrayContaining([reason]), + }), + }); + } else { + await expect(pending).resolves.toMatchObject({ + status: "unavailable", + requested: 1, + processed: 0, + items: [], + limitation: expect.stringContaining("1 requested map"), + }); + } + expect(cancelled).toBe(true); + expect(read).toBe(false); + }, +); + +it("charges complete escaped redirect errors and cancels every discarded response", async () => { + const origin = "https://app.example.test"; + const location = `http://[${'\\"'.repeat(3_000)}`; + const request = { + scriptKey: "script", + declaredUrl: `${origin}/map`, + fetchUrl: `${origin}/map`, + }; + const input = analyzeWebBundleInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "page", + allowed_origins: [origin], + fetch_source_maps: true, + }); + let calls = 0; + let cancelled = 0; + const host = { + fetch: () => { + calls += 1; + return Promise.resolve( + new Response( + new ReadableStream({ + cancel() { + cancelled += 1; + }, + }), + { status: 302, headers: { location } }, + ), + ); + }, + }; + const control = await fetchWebSourceMaps([request], input, undefined, host); + expect(control.items[0]?.limitation).toContain(JSON.stringify(location)); + expect(control.items[0]?.limitation?.length).toBeGreaterThan(512); + const result = await fetchWebSourceMaps( + Array.from({ length: 1_500 }, () => request), + input, + undefined, + host, + ); + expect(result).toMatchObject({ status: "unavailable", requested: 1_500 }); + expect(result.processed).toBe(result.items.length); + expect(result.processed).toBeLessThan(result.requested); + expect(result.limitation).toContain("unknown"); + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual( + WEB_SOURCE_MAP_LIMITS.outputBytes, + ); + expect(calls).toBeLessThan(1_501); + expect(cancelled).toBe(calls); +}); diff --git a/tests/boundary/mcp/browserMcp.test.ts b/tests/boundary/mcp/browserMcp.test.ts index db2ba8980..d97376c55 100644 --- a/tests/boundary/mcp/browserMcp.test.ts +++ b/tests/boundary/mcp/browserMcp.test.ts @@ -13,6 +13,7 @@ import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../../../src/contract import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; import { createServer } from "../../../src/server/createServer.js"; import { observed } from "../../fixtures/analysisExecution.js"; +import { runCliJson } from "../../fixtures/cliJsonProcess.js"; import { startFakeCdpBrowser, type FakeCdpBrowser, @@ -336,6 +337,87 @@ const assertScreenshotPngFailures = async (client: Client): Promise => { }); }; +it("delivers the source-map response URL through authenticated MCP Evidence", async () => { + const terminalPath = "/maps/v1/app.js.map?revision=2&token=source-value"; + const browser = await startFakeCdpBrowser({ + sourceMapRedirect: terminalPath, + sourceMapBody: JSON.stringify({ + version: 3, + names: [], + sources: ["../src/original.js"], + sourcesContent: ["export const value = 1;"], + mappings: "AAAA", + }), + }); + browsers.push(browser); + const connected = await connectBrowser(); + const result = await connected.client.callTool({ + name: "analyze_web_bundle", + arguments: { + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + allowed_origins: [browser.allowedOrigin], + observation_ms: 0, + fetch_source_maps: true, + }, + }); + expect(result.isError).not.toBe(true); + const evidence = parseEvidence(result.structuredContent); + const expectedResult = { + observations: { + source_maps: { + status: "included", + items: [ + { + declared_url: `${browser.allowedOrigin}/app.js.map?token=map-secret`, + effective_url: `${browser.allowedOrigin}${terminalPath}`, + original_sources: [ + { source: `${browser.allowedOrigin}/maps/src/original.js` }, + ], + }, + ], + }, + }, + }; + expect(evidence.normalized_result).toMatchObject(expectedResult); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toMatchObject({ + type: "text", + text: JSON.stringify(evidence), + }); + const cliEvidence = parseEvidence( + await runCliJson([ + "analyze-web-bundle", + browser.endpoint, + "allowed-page", + "--allowed-origins", + browser.allowedOrigin, + "--observation-ms", + "0", + "--fetch-source-maps", + "--json", + ]), + ); + expect(cliEvidence.normalized_result).toMatchObject(expectedResult); + const mapRequests = browser.httpRequests.filter(({ url }) => + url.includes(".map"), + ); + expect(mapRequests.map(({ url }) => url)).toEqual([ + "/app.js.map?token=map-secret", + terminalPath, + "/app.js.map?token=map-secret", + terminalPath, + ]); + expect( + mapRequests.every( + ({ authorization, cookie, referer }) => + authorization === undefined && + cookie === undefined && + referer === undefined, + ), + ).toBe(true); +}); + it("does not attach to a target outside the request's allowed origin scope", async () => { const browser = await startFakeCdpBrowser(); browsers.push(browser); diff --git a/tests/fixtures/fakeCdpBrowser.ts b/tests/fixtures/fakeCdpBrowser.ts index 5c99f1fb0..191b4569c 100644 --- a/tests/fixtures/fakeCdpBrowser.ts +++ b/tests/fixtures/fakeCdpBrowser.ts @@ -1,4 +1,8 @@ -import { createServer, type IncomingMessage } from "node:http"; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; import { WebSocket, WebSocketServer } from "ws"; @@ -34,13 +38,7 @@ export const startFakeCdpBrowser = async ( referer: request.headers.referer, }); response.setHeader("content-type", "application/json"); - if ( - request.url?.startsWith("/app.js.map") === true && - options.sourceMapBody !== undefined - ) { - response.end(options.sourceMapBody); - return; - } + if (serveSourceMap(request, response, options)) return; if (request.url === "/json/version") { response.end( options.oversizedDiscovery === true @@ -222,3 +220,22 @@ const replyToCommand = ( if (options.closeAfterMethod === command.method) setImmediate(() => socket.close()); }; + +const serveSourceMap = ( + request: IncomingMessage, + response: ServerResponse, + options: FakeOptions, +): boolean => { + if (options.sourceMapBody === undefined) return false; + if (request.url?.startsWith("/app.js.map") === true) { + if (options.sourceMapRedirect !== undefined) + response.writeHead(302, { location: options.sourceMapRedirect }).end(); + else response.end(options.sourceMapBody); + return true; + } + if (request.url === options.sourceMapRedirect) { + response.end(options.sourceMapBody); + return true; + } + return false; +}; diff --git a/tests/fixtures/fakeCdpBrowserTypes.ts b/tests/fixtures/fakeCdpBrowserTypes.ts index 4fd45bb12..31b75fc51 100644 --- a/tests/fixtures/fakeCdpBrowserTypes.ts +++ b/tests/fixtures/fakeCdpBrowserTypes.ts @@ -93,6 +93,7 @@ export interface FakeOptions { readonly webSocketWithoutFrames?: boolean; readonly invalidBinaryWebSocketEvent?: boolean; readonly sourceMapBody?: string; + readonly sourceMapRedirect?: string; readonly sessionTimeline?: | "same_origin" | "outside_policy"