mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
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 <[email protected]>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<string>();
|
||||
@@ -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<SourceMapItem, { readonly artifact: null }>["status"],
|
||||
limitation: string,
|
||||
effectiveUrl: string | null = null,
|
||||
): Extract<SourceMapItem, { readonly artifact: null }> => ({
|
||||
...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}');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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<void>((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)}`;
|
||||
|
||||
@@ -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<Uint8Array>(
|
||||
{
|
||||
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<Uint8Array>({
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
Vendored
+25
-8
@@ -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;
|
||||
};
|
||||
|
||||
+1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user