fix(browser): preserve body API provenance through script export

Label new captured response bytes as Playwright body API results and retain the possibility of a credentialed follow-up load. Preserve legacy representations and source limitations through export without treating body-origin uncertainty as missing event coverage.

Validated source and filesystem boundary tests plus real Chrome CLI and MCP network workflows covering binary, compressed, empty, unfinished, and same-URL responses and profile cleanup.
This commit is contained in:
morluto 2026-10-12 14:07:58 +08:00
parent 6bae774328
commit 966f7cc84b
15 changed files with 270 additions and 92 deletions

View File

@ -262,8 +262,13 @@ Capturing more data alone does not establish a website change.
states. Captured bodies retain base64 bytes, byte count, media type, and SHA-256
of the retained bytes. Request bytes are those exposed by Playwright; a
`not_exposed` request body does not prove absence or complete multipart file
coverage. Response bytes are decoded by the browser, not the compressed wire
representation. No later fetch is substituted for the original response.
coverage. Response bytes are the result of Playwright's `Response.body()` API.
Playwright may satisfy that API with a credentialed follow-up load of the
request URL when CDP has no response body. Event and transaction references
identify the associated Playwright response object, but do not verify that the
retained bytes came from the original network transaction. Successful selected
response bodies include this limitation in the capture. These bytes are not
the compressed wire representation.
Headers preserve producer order and duplicates. Authorization,
Proxy-Authorization, Cookie, and Set-Cookie values are null with
`redacted: true`; ordinary values remain intact. Declared UTF-8 secret values

View File

@ -48,9 +48,14 @@ its own descriptor. It does not duplicate script bytes.
- Passive sources are the Debugger-exposed text encoded as UTF-8, with the
verified source digest and source-map declaration retained. Unavailable or
explicitly non-JavaScript sources remain unavailable.
- Scenario sources use observed requests classified as `script`, matched to
completed response bytes by transaction and event identity. URLs never select
the response. Binary and empty retained bytes are written exactly.
- Scenario sources use observed requests classified as `script`, associated
with one response event and the bytes returned by Playwright's
`Response.body()` API for its response object. URLs never select the response.
Playwright may load the request URL again with credentials when CDP has no
response body, so transaction and event references do not verify that
exported bytes came from the original network transaction. The capture and
export manifest retain this limitation. Binary and empty retained bytes are
written exactly.
- Ordinary query-free `.js`, `.mjs`, and `.cjs` URL paths are arranged under a
directory for each origin, preserving their relative module layout when it
is unambiguous.
@ -62,8 +67,9 @@ its own descriptor. It does not duplicate script bytes.
## Interpretation and limits
Export does not fetch URLs or execute JavaScript. Retained Debugger sources and
response bytes do not establish execution or a complete website. Response bytes
are browser-decoded bytes; they do not represent transfer encoding. Declared
response bytes do not establish execution or a complete website. Scenario
response bytes preserve Playwright's body API result; see the source-body
identity limitation above. Declared
redaction is preserved and can change JavaScript syntax; passive captures do
not report a byte-redaction flag, so that value remains unknown.

View File

@ -117,7 +117,7 @@ export function assertBrowserNetworkEvidence(
assert.deepEqual(retained(responseFor("/compressed")), networkFixtureDecoded);
assert.equal(
responseFor("/compressed").body.representation,
"browser-decoded-response-bytes",
"playwright-response-body-bytes",
);
const formRequest = requestEvents.find(
(event) => event.url.url === `${origin}/form`,
@ -149,7 +149,7 @@ export function assertBrowserNetworkEvidence(
return {
same_url_markers: markers,
binary_bytes: networkFixtureBinary.length,
compressed_representation: "browser-decoded-response-bytes",
compressed_representation: "playwright-response-body-bytes",
unfinished_stream: "response-unfinished",
empty_response_state: responseFor("/empty").body.state,
};

View File

@ -1,9 +1,13 @@
import type { BrowserScenario } from "../domain/browserScenario.js";
import { PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION } from "../domain/browserNetworkEvidence.js";
import type {
BrowserScenarioCaptureData,
BrowserScenarioPartialObservation,
} from "../domain/browserScenarioCapture.js";
import type { BrowserScenarioStep } from "../domain/browserScenarioCaptureValues.js";
import type {
BrowserScenarioEvent,
BrowserScenarioStep,
} from "../domain/browserScenarioCaptureValues.js";
import type { BrowserScenarioSessionPort } from "./BrowserScenarioSessionPort.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
@ -20,26 +24,42 @@ const OPERATION = "capture_browser_scenario" as const;
const browserScenarioLimitations = (
session: BrowserScenarioSessionPort,
scenario: BrowserScenario,
): string[] => [
"Event sequence records provider receipt order; simultaneous browser causality is not inferred.",
"Network content is retained only when selected; response bytes are browser-decoded, not compressed wire bytes.",
"Request bytes are limited to what Playwright exposes; not_exposed does not establish body absence or multipart file coverage.",
"Network content reads settle within 5 seconds each and never wait for unfinished responses or refetch them.",
"Playwright scenario events do not expose request initiator stacks; receipt order does not prove causality.",
"Storage values are hashed only after declared-secret redaction.",
...(scenario.storage.local_storage.length === 0 &&
scenario.storage.session_storage.length === 0
? []
: [
"Non-cookie storage seeds use CDP pre-script instrumentation; only pauses in the scenario's private seed script are resumed by this observer.",
]),
...(session.eventLimitations?.() ?? []),
...(session.mode === "connect"
? [
"CDP attachment cannot recover pre-attach events or guarantee launch-time context options.",
]
: []),
];
events: readonly BrowserScenarioEvent[],
): string[] => {
const capturedResponseBody =
scenario.capture.network.response_body &&
events.some(
(event) =>
event.kind === "network-content" &&
event.phase === "response" &&
event.body.state === "captured" &&
(event.body.representation === "playwright-response-body-bytes" ||
event.body.representation === "browser-decoded-response-bytes"),
);
return [
"Event sequence records provider receipt order; simultaneous browser causality is not inferred.",
"Network content is retained only when selected; Playwright response-body bytes are API results and are not guaranteed to match wire bytes.",
"Request bytes are limited to what Playwright exposes; not_exposed does not establish body absence or multipart file coverage.",
"Network content reads settle within 5 seconds each and the observer does not request bodies from unfinished responses.",
...(capturedResponseBody
? [PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION]
: []),
"Playwright scenario events do not expose request initiator stacks; receipt order does not prove causality.",
"Storage values are hashed only after declared-secret redaction.",
...(scenario.storage.local_storage.length === 0 &&
scenario.storage.session_storage.length === 0
? []
: [
"Non-cookie storage seeds use CDP pre-script instrumentation; only pauses in the scenario's private seed script are resumed by this observer.",
]),
...(session.eventLimitations?.() ?? []),
...(session.mode === "connect"
? [
"CDP attachment cannot recover pre-attach events or guarantee launch-time context options.",
]
: []),
];
};
/** Project one session into the common capture fields used by success and failure. */
export const browserScenarioCaptureData = (input: {
@ -52,30 +72,37 @@ export const browserScenarioCaptureData = (input: {
| "disconnected-external"
| "incomplete";
readonly limitations?: readonly string[];
}): BrowserScenarioCaptureData => ({
browser: {
mode: input.session.mode,
process_ownership: input.session.processOwnership,
cleanup: input.cleanup,
product: input.session.product,
version: input.session.version,
},
scenario: {
start_origin: new URL(input.scenario.start_url.url).origin,
action_count: input.scenario.actions.length,
secret_references: input.scenario.secrets
.map(({ secret_id: id }) => id)
.sort(),
network_content: input.scenario.capture.network,
},
duration_ms: Date.now() - input.startedAt,
steps: [...input.steps],
events: input.session.events(),
limitations: [
...browserScenarioLimitations(input.session, input.scenario),
...(input.limitations ?? []),
],
});
}): BrowserScenarioCaptureData => {
const events = input.session.events();
return {
browser: {
mode: input.session.mode,
process_ownership: input.session.processOwnership,
cleanup: input.cleanup,
product: input.session.product,
version: input.session.version,
},
scenario: {
start_origin: new URL(input.scenario.start_url.url).origin,
action_count: input.scenario.actions.length,
secret_references: input.scenario.secrets
.map(({ secret_id: id }) => id)
.sort(),
network_content: input.scenario.capture.network,
},
duration_ms: Date.now() - input.startedAt,
steps: [...input.steps],
events,
limitations: [
...browserScenarioLimitations(
input.session,
input.scenario,
events.items,
),
...(input.limitations ?? []),
],
};
};
/** Describe the cleanup failure without discarding its resource evidence. */
export const browserScenarioCleanupObservation = (

View File

@ -16,6 +16,7 @@ import {
type BrowserScenarioEvent,
type BrowserStepArtifacts,
} from "../domain/browserScenarioCaptureValues.js";
import { PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION } from "../domain/browserNetworkEvidence.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
AnalysisCancelledError,
@ -218,6 +219,70 @@ class FakeSession implements BrowserScenarioSessionPort {
}
describe("PlaywrightBrowserScenarioProvider", () => {
it("reports response-body provenance uncertainty without marking event coverage missing", async () => {
const session = new FakeSession("launch");
const selected = scenario();
session.eventItems = [
{
sequence: 1,
step_index: 0,
kind: "response",
transaction_id: "request-1",
redirected_from_transaction_id: null,
method: "GET",
url: sanitizeBrowserUrl("https://app.example.test/main.js"),
resource_type: "script",
header_names: [],
status: 200,
failure: null,
},
{
sequence: 2,
step_index: 0,
kind: "network-content",
transaction_id: "request-1",
source_event_sequence: 1,
phase: "response",
headers: { state: "not_requested" },
body: {
state: "captured",
representation: "playwright-response-body-bytes",
encoding: "base64",
content: "",
bytes: 0,
sha256:
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
media_type: "text/javascript",
redacted: false,
},
},
];
const input = browserScenarioSchema.parse({
...selected,
capture: {
...selected.capture,
network: { ...selected.capture.network, response_body: true },
},
});
const provider = new PlaywrightBrowserScenarioProvider(() =>
Promise.resolve(session),
);
const result = await provider.captureScenario(input);
if (!result.ok) throw result.error;
expect(result.value.limitations).toContain(
PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION,
);
expect(result.value.completeness.missing_sections).not.toContain("events");
expect(result.value.events.items[1]).toMatchObject({
kind: "network-content",
body: {
state: "captured",
representation: "playwright-response-body-bytes",
},
});
});
it("makes reported event gaps ineligible for equality even in launch mode", async () => {
const session = new FakeSession("launch");
session.eventGaps = ["Popup frames before discovery are unavailable"];

View File

@ -7,6 +7,7 @@ import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"
import { DESTINATION_CASE_COLLISION_PREFIX } from "../../artifacts/ArtifactPaths.js";
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
import { SafeOutputTreeCreationFailure } from "../../artifacts/SafeOutputTreeCreationFailure.js";
import { PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION } from "../../domain/browserNetworkEvidence.js";
import {
webScriptExportManifestSchema,
webScriptExportResultSchema,
@ -26,7 +27,7 @@ const LIMITATIONS = [
"Ordinary unambiguous URL paths preserve relative module layouts by origin, including case-distinct spellings. Query variants, exact path collisions, file/directory prefixes, inline sources, and unrepresentable paths are isolated. A case-distinct pair is isolated only when the destination filesystem cannot store both spellings; the record names that pair. Browser module relationships of isolated paths remain unknown.",
"Local JavaScript analysis does not implement browser import maps, root-relative URL resolution, remote URL loading, or browser runtime behavior. It may strip import queries and fragments; isolated variants are not canonical targets.",
"Source-map declarations are retained as metadata. No source maps or missing dependencies are fetched or reconstructed.",
"Captured response bytes are browser-decoded bytes; Debugger sources are the exposed text encoded as UTF-8. Redacted bytes are exported exactly as retained, and may no longer parse as JavaScript.",
"Scenario response bytes preserve the output of Playwright's Response.body() API; any source-body provenance caveat from the capture is retained in this manifest. Debugger sources are the exposed text encoded as UTF-8. Redacted bytes are exported exactly as retained, and may no longer parse as JavaScript.",
];
interface PublishWebScriptsOptions {
@ -43,6 +44,22 @@ export const publishWebScripts = async (
): Promise<WebScriptExportResult> =>
options.resources.run(async () => {
const planned = planWebScriptExport(capture.scripts);
const scenarioResponseBodyCaptured =
capture.kind === "browser-scenario" &&
capture.scripts.some(
(script) =>
script.source.kind === "scenario-response" &&
script.content.state === "captured" &&
(script.content.representation === "playwright-response-body-bytes" ||
script.content.representation === "browser-decoded-response-bytes"),
);
const responseBodyProvenanceLimitations =
scenarioResponseBodyCaptured &&
!capture.limitations.includes(
PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION,
)
? [PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION]
: [];
let tree: SafeOutputTree | undefined;
let result: WebScriptExportResult | undefined;
try {
@ -70,6 +87,7 @@ export const publishWebScripts = async (
scripts: records,
limitations: [
...capture.limitations,
...responseBodyProvenanceLimitations,
...LIMITATIONS,
...(records.some(({ content }) => content.state === "exported")
? []

View File

@ -98,7 +98,10 @@ const projectBody = (
body.state,
"No selected, completed script response bytes were retained for this request.",
);
if (body.representation !== "browser-decoded-response-bytes")
if (
body.representation !== "playwright-response-body-bytes" &&
body.representation !== "browser-decoded-response-bytes"
)
return unavailable(
"unsupported-body-representation",
`Captured representation ${body.representation} is not script response bytes.`,

View File

@ -152,9 +152,11 @@ describe("script capture identity and response projection", () => {
)
throw new Error("Missing response bytes");
content.body.redacted = true;
content.body.representation = "playwright-response-body-bytes";
expect(selectScriptCapture(capture).scripts[0]?.content).toMatchObject({
state: "captured",
redacted: true,
representation: "playwright-response-body-bytes",
});
content.body.representation = "browser-exposed-request-bytes";
expect(selectScriptCapture(capture).scripts[0]?.content).toMatchObject({

View File

@ -72,7 +72,7 @@ export const captureNetworkHeaders = async (
}
};
/** Retain only bytes exposed by the browser; no fetch or wire-byte reconstruction occurs. */
/** Retain bytes returned by Playwright's body API, after declared-secret redaction. */
export const captureNetworkBody = async (
read: () => Promise<Buffer | null>,
phase: "request" | "response",
@ -94,7 +94,7 @@ export const captureNetworkBody = async (
representation:
phase === "request"
? "browser-exposed-request-bytes"
: "browser-decoded-response-bytes",
: "playwright-response-body-bytes",
encoding: "base64",
content: retained.toString("base64"),
bytes: retained.length,

View File

@ -178,6 +178,9 @@ it("associates out-of-order same-URL responses by request identity", async () =>
(event) =>
event.phase === "response" && bodyText(event) === `reply:${marker}`,
);
expect(response).toMatchObject({
body: { representation: "playwright-response-body-bytes" },
});
expect(request?.transaction_id).toBe(response?.transaction_id);
expect(request?.source_event_sequence).not.toBe(
response?.source_event_sequence,

View File

@ -128,7 +128,7 @@ export class PlaywrightScenarioNetwork {
transaction.response = { source: response, sequence };
}
/** Record completion and read the original completed response, never a later refetch. */
/** Record completion and read selected content through the response API. */
finished(request: ScenarioNetworkRequest): void {
if (!this.accepting) return;
const transaction = this.transaction(request);

View File

@ -17,6 +17,12 @@ const retained = {
it("validates the actual retained bytes against canonical base64, count, and digest", () => {
expect(browserNetworkBodySchema.safeParse(retained).success).toBe(true);
expect(
browserNetworkBodySchema.safeParse({
...retained,
representation: "playwright-response-body-bytes",
}).success,
).toBe(true);
for (const mutation of [
{ bytes: 4 },
{ sha256: "0".repeat(64) },

View File

@ -1,6 +1,9 @@
import { createHash } from "node:crypto";
import { z } from "zod";
export const PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION =
"Captured response-body bytes are the result of Playwright's Response.body() API. Playwright may satisfy that API with a credentialed follow-up load of the request URL when CDP has no body, so transaction and event links do not verify that retained bytes came from the original network transaction.";
/** Resolved network content selection recorded by a capture. */
export const browserNetworkContentSelectionValuesSchema = z.strictObject({
request_body: z.boolean(),
@ -43,6 +46,9 @@ export const browserNetworkBodySchema = z.union([
state: z.literal("captured"),
representation: z.enum([
"browser-exposed-request-bytes",
"playwright-response-body-bytes",
// Retained for captures produced before response-body provenance was
// narrowed to the bytes Playwright exposes through Response.body().
"browser-decoded-response-bytes",
]),
encoding: z.literal("base64"),

View File

@ -52,6 +52,9 @@ export const exportedWebScriptSchema = z.strictObject({
redacted: z.boolean().nullable(),
representation: z.enum([
"debugger-source-utf8",
"playwright-response-body-bytes",
// Accept already-saved exports from before the response representation
// was narrowed to the Playwright API result.
"browser-decoded-response-bytes",
]),
}),
@ -116,9 +119,10 @@ export interface CapturedWebScript {
readonly sha256: string;
readonly media_type: string | null;
readonly redacted: boolean | null;
readonly representation:
| "debugger-source-utf8"
| "browser-decoded-response-bytes";
readonly representation: Extract<
ExportedWebScript["content"],
{ readonly state: "exported" }
>["representation"];
}
| Extract<ExportedWebScript["content"], { state: "unavailable" }>;
}

View File

@ -32,7 +32,11 @@ import { SafeOutputTreeCreationFailure } from "../../../src/artifacts/SafeOutput
import { selectScriptCapture } from "../../../src/browser/assets/ScriptCaptureAdapters.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { webScriptExportResultSchema } from "../../../src/domain/webScriptExport.js";
import { PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION } from "../../../src/domain/browserNetworkEvidence.js";
import {
webScriptExportManifestSchema,
webScriptExportResultSchema,
} from "../../../src/domain/webScriptExport.js";
import { readWithoutFifoWriter } from "../../fixtures/fifoInput.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
@ -137,43 +141,72 @@ describe("captured script publication boundary", () => {
);
});
it("links authenticated input Evidence and preserves binary and empty source bytes", async () => {
it("links authenticated input Evidence", async () => {
const { input, resources } = await setup();
const result = await exportWebScripts(input, resources);
if (!result.ok) throw result.error;
expect(result.value.evidence_links).toEqual([
scriptCaptureEvidenceFixture().evidence_id,
]);
const binary = Buffer.from([255, 0, 254, 1]);
const { input: binaryInput, resources: binaryResources } = await setup(
scriptScenarioFixture([
});
it.each([
"playwright-response-body-bytes",
"browser-decoded-response-bytes",
] as const)(
"preserves binary and empty response bytes and provenance for %s",
async (representation) => {
const binary = Buffer.from([255, 0, 254, 1]);
const capture = scriptScenarioFixture([
{ url: "https://fixture.test/binary.js", bytes: binary },
{ url: "https://fixture.test/empty.js", bytes: Buffer.alloc(0) },
]),
);
const binaryExport = await exportWebScripts(binaryInput, binaryResources);
if (!binaryExport.ok) throw binaryExport.error;
const parsed = webScriptExportResultSchema.parse(
binaryExport.value.normalized_result,
);
const [first, second] = parsed.scripts;
if (
first?.content.state !== "exported" ||
second?.content.state !== "exported" ||
parsed.analysis_input === null
)
throw new Error("Missing exported bytes");
expect(
await readFile(
join(parsed.analysis_input.input_path, first.content.relative_path),
),
).toEqual(binary);
expect(
await readFile(
join(parsed.analysis_input.input_path, second.content.relative_path),
),
).toHaveLength(0);
});
]);
for (const event of capture.events.items) {
if (event.kind === "network-content" && event.body.state === "captured")
event.body.representation = representation;
}
const { input: binaryInput, resources: binaryResources } =
await setup(capture);
const binaryExport = await exportWebScripts(binaryInput, binaryResources);
if (!binaryExport.ok) throw binaryExport.error;
const parsed = webScriptExportResultSchema.parse(
binaryExport.value.normalized_result,
);
const [first, second] = parsed.scripts;
if (
first?.content.state !== "exported" ||
second?.content.state !== "exported" ||
parsed.analysis_input === null
)
throw new Error("Missing exported bytes");
expect(first.content.representation).toBe(representation);
expect(second.content.representation).toBe(representation);
expect(parsed.limitations).toContain(
PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION,
);
const persisted = webScriptExportManifestSchema.parse(
JSON.parse(await readFile(parsed.manifest.path, "utf8")),
);
expect(
persisted.scripts.map(({ content }) =>
content.state === "exported" ? content.representation : undefined,
),
).toEqual([representation, representation]);
expect(persisted.limitations).toContain(
PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION,
);
expect(
await readFile(
join(parsed.analysis_input.input_path, first.content.relative_path),
),
).toEqual(binary);
expect(
await readFile(
join(parsed.analysis_input.input_path, second.content.relative_path),
),
).toHaveLength(0);
},
);
it("retains missing sources in a manifest without inventing an analysis directory", async () => {
const capture = scriptScenarioFixture();