Files
rea/src/domain/browserScenarioCaptureValues.ts
T
gongfppandgongfpp 7aee876d81 fix(browser)!: preserve storage value code units (#6341)
Keep ordinary UTF-8 digests and secret redaction, separate malformed UTF-16 preimages, and record storage fingerprint provenance. Compare legacy hashed snapshots as unknown without treating encoding metadata as a target change. Cover producer boundaries and real owned-browser CLI/MCP workflows.

BREAKING CHANGE: legacy hashed browser storage remains readable but comparisons return unknown until both sides are recaptured.

Co-authored-by: gongfpp <22683417+gongfpp@users.noreply.github.com>
2026-10-23 02:20:16 +08:00

446 lines
13 KiB
TypeScript

import { createHash } from "node:crypto";
import { z } from "zod";
import { emptyArraySchema } from "./emptyArraySchema.js";
import {
browserNetworkBodySchema,
browserNetworkHeadersSchema,
} from "./browserNetworkEvidence.js";
import { sanitizedBrowserUrlSchema } from "./browserObservation.js";
import { webScreenshotArtifactSchema } from "./webScreenshot.js";
import { digestSchema } from "./../domain/digests.js";
const textArtifactSchema = z
.strictObject({
sha256: digestSchema,
bytes: z.int().min(0),
text: z.string(),
})
.superRefine((artifact, context) => {
const bytes = Buffer.from(artifact.text);
const sha256 = createHash("sha256").update(bytes).digest("hex");
if (bytes.byteLength !== artifact.bytes || sha256 !== artifact.sha256)
context.addIssue({
code: "custom",
message: "Text artifact digest or size mismatch",
});
});
export const captureStateSchema = <Schema extends z.ZodType>(value: Schema) =>
z.discriminatedUnion("state", [
z.strictObject({ state: z.literal("captured"), value }),
z.strictObject({
state: z.literal("not_requested"),
}),
z.strictObject({
state: z.literal("missing"),
reason: z.string().min(1),
}),
z.strictObject({
state: z.literal("truncated"),
observed: z.int().min(0),
retained: z.int().min(0),
reason: z.string().min(1),
}),
]);
const historySchema = z.strictObject({
length: z.int().min(0),
current_url: sanitizedBrowserUrlSchema,
navigation_entries: z.array(
z.strictObject({
type: z.enum([
"navigate",
"reload",
"back_forward",
"prerender",
"unknown",
]),
name: sanitizedBrowserUrlSchema,
}),
),
});
const hashedStorageValueShape = {
value_state: z.literal("hashed"),
value_sha256: digestSchema,
};
const redactedStorageValueShape = {
value_state: z.literal("redacted-secret"),
value_sha256: z.null(),
};
const storageValueFingerprintSchema = z.discriminatedUnion("value_state", [
z.strictObject(hashedStorageValueShape),
z.strictObject(redactedStorageValueShape),
]);
export type BrowserStorageValueFingerprint = z.infer<
typeof storageValueFingerprintSchema
>;
const storageValueSchema = z.discriminatedUnion("value_state", [
z.strictObject({
name: z.string(),
...hashedStorageValueShape,
}),
z.strictObject({
name: z.string(),
...redactedStorageValueShape,
}),
]);
const cookieShape = {
name: z.string(),
domain: z.string(),
path: z.string(),
secure: z.boolean(),
http_only: z.boolean(),
same_site: z.enum(["Strict", "Lax", "None"]),
};
/** UTF-8 for well-formed strings; raw 0xff + UTF-16LE otherwise. */
export const BROWSER_STORAGE_FINGERPRINT_VERSION = 2;
const storageSnapshotSchema = z.strictObject({
// Absence retains the legacy, lossy UTF-8 interpretation.
fingerprint_version: z
.literal(BROWSER_STORAGE_FINGERPRINT_VERSION)
.optional(),
cookies: z.array(
z.discriminatedUnion("value_state", [
z.strictObject({ ...cookieShape, ...hashedStorageValueShape }),
z.strictObject({ ...cookieShape, ...redactedStorageValueShape }),
]),
),
local_storage: z.array(storageValueSchema),
session_storage: z.array(storageValueSchema),
});
export const browserStepArtifactsSchema = z.strictObject({
screenshot: captureStateSchema(webScreenshotArtifactSchema),
dom: captureStateSchema(textArtifactSchema),
accessibility: captureStateSchema(textArtifactSchema),
url: captureStateSchema(sanitizedBrowserUrlSchema),
history: captureStateSchema(historySchema),
storage: captureStateSchema(storageSnapshotSchema),
});
export type BrowserStepArtifacts = z.infer<typeof browserStepArtifactsSchema>;
/** Event families a scenario can select for capture. */
export const browserScenarioEventFamilySchema = z.enum([
"console",
"page-errors",
"network",
"websockets",
"frames",
"workers",
"popups",
"downloads",
]);
export type BrowserScenarioEventFamily = z.infer<
typeof browserScenarioEventFamilySchema
>;
const eventBase = {
sequence: z.int().min(1),
step_index: z.int().min(0),
};
const eventUrl = sanitizedBrowserUrlSchema.nullable();
const networkEventShape = {
...eventBase,
transaction_id: z.string().min(1),
redirected_from_transaction_id: z.string().min(1).nullable(),
/** Distinguishes a captured root from an unobserved predecessor for null links. */
redirected_from_transaction_state: z.enum(["root", "unknown"]).optional(),
method: z.string().min(1),
url: sanitizedBrowserUrlSchema,
resource_type: z.string().min(1),
header_names: z.array(z.string()),
};
const webSocketFrameShape = {
...eventBase,
kind: z.enum(["websocket-frame-sent", "websocket-frame-received"]),
url: sanitizedBrowserUrlSchema,
payload_bytes: z.int().min(0),
truncated: z.boolean(),
};
export const browserScenarioEventSchema = z
.union([
z.strictObject({
...eventBase,
kind: z.literal("console"),
level: z.string().min(1),
text: z.string(),
url: eventUrl,
}),
z.strictObject({
...eventBase,
kind: z.literal("page-error"),
message: z.string(),
stack: z.string().nullable(),
}),
z.strictObject({
...networkEventShape,
kind: z.literal("request"),
status: z.null(),
failure: z.null(),
}),
z.strictObject({
...networkEventShape,
kind: z.literal("response"),
status: z.int().min(100).max(599),
failure: z.null(),
}),
z.strictObject({
...networkEventShape,
kind: z.literal("request-failed"),
status: z.null(),
failure: z.string().min(1),
}),
z.strictObject({
...networkEventShape,
kind: z.literal("request-finished"),
status: z.null(),
failure: z.null(),
}),
z.strictObject({
...networkEventShape,
kind: z.literal("request-unfinished"),
status: z.null(),
failure: z.null(),
reason: z.enum(["capture-ended", "cancelled"]),
}),
z.strictObject({
...eventBase,
kind: z.literal("network-content"),
transaction_id: z.string().min(1),
source_event_sequence: z.int().min(1),
phase: z.enum(["request", "response"]),
headers: browserNetworkHeadersSchema,
body: browserNetworkBodySchema,
}),
z.strictObject({
...eventBase,
kind: z.enum(["websocket-opened", "websocket-closed"]),
url: sanitizedBrowserUrlSchema,
}),
z.strictObject({
...webSocketFrameShape,
payload_type: z.literal("text"),
payload_text: z.string(),
}),
z.strictObject({
...webSocketFrameShape,
payload_type: z.literal("binary"),
payload_text: z.null(),
}),
z.strictObject({
...eventBase,
kind: z.enum([
"frame-attached",
"frame-detached",
"frame-navigated",
"worker-created",
"worker-closed",
"popup-opened",
"popup-closed",
]),
url: eventUrl,
name: z.string().nullable(),
}),
z.strictObject({
...eventBase,
kind: z.literal("download-cancelled"),
suggested_filename: z.string(),
url: sanitizedBrowserUrlSchema,
}),
])
.superRefine((event, context) => {
if (
"redirected_from_transaction_id" in event &&
event.redirected_from_transaction_id !== null &&
event.redirected_from_transaction_state !== undefined
)
context.addIssue({
code: "custom",
message:
"Redirect state is valid only when the predecessor transaction ID is null",
path: ["redirected_from_transaction_state"],
});
});
export type BrowserScenarioEvent = z.infer<typeof browserScenarioEventSchema>;
const completenessSectionSchema = z.enum([
"action",
"screenshot",
"dom",
"accessibility",
"url",
"history",
"storage",
"events",
"frames",
"workers",
"popups",
"websockets",
]);
export const browserScenarioEventKindSchema = z.enum([
"console",
"page-error",
"request",
"response",
"request-failed",
"request-finished",
"request-unfinished",
"network-content",
"websocket-opened",
"websocket-closed",
"websocket-frame-sent",
"websocket-frame-received",
"frame-attached",
"frame-detached",
"frame-navigated",
"worker-created",
"worker-closed",
"popup-opened",
"popup-closed",
"download-cancelled",
]);
export const browserScenarioEventBudgetReasonSchema = z.enum([
"max-events",
"max-event-allocation-bytes",
"max-total-event-allocation-bytes",
"max-network-transactions",
"max-network-metadata-allocation-bytes",
"max-listener-owners",
]);
export const browserScenarioEventOmissionsSchema = z.strictObject({
by_kind: z.array(
z.strictObject({
kind: browserScenarioEventKindSchema,
count: z.int().positive(),
reasons: z.array(browserScenarioEventBudgetReasonSchema).min(1),
}),
),
network_transactions: z.array(
z.strictObject({
transaction_id: z.string().min(1),
omitted_kinds: z.array(browserScenarioEventKindSchema).min(1),
reasons: z.array(browserScenarioEventBudgetReasonSchema).min(1),
}),
),
network_transaction_range: z
.strictObject({
first_transaction_id: z.string().min(1),
last_transaction_id: z.string().min(1),
count: z.int().positive(),
reason: z.literal("max-network-transactions"),
})
.nullable(),
});
export type BrowserScenarioEventOmissions = z.infer<
typeof browserScenarioEventOmissionsSchema
>;
export type BrowserScenarioEventBudgetReason = z.infer<
typeof browserScenarioEventBudgetReasonSchema
>;
export type BrowserScenarioEventAdmission =
| { readonly status: "retained"; readonly sequence: number }
| {
readonly status: "omitted";
readonly sequence: number;
readonly reason: BrowserScenarioEventBudgetReason;
};
export type BrowserScenarioCompletenessSection = z.infer<
typeof completenessSectionSchema
>;
export const browserScenarioCompletenessSchema = z.discriminatedUnion(
"status",
[
z.strictObject({
status: z.literal("complete"),
equality_eligible: z.literal(true),
missing_sections: emptyArraySchema,
truncated_sections: emptyArraySchema,
}),
z.strictObject({
status: z.literal("incomplete"),
equality_eligible: z.literal(false),
missing_sections: z.array(completenessSectionSchema).min(1),
truncated_sections: emptyArraySchema,
}),
z.strictObject({
status: z.literal("truncated"),
equality_eligible: z.literal(false),
missing_sections: z.array(completenessSectionSchema),
truncated_sections: z.array(completenessSectionSchema).min(1),
}),
],
);
export type BrowserScenarioCompleteness = z.infer<
typeof browserScenarioCompletenessSchema
>;
/** Classify canonical browser-scenario completeness from section accounting. */
export const classifyBrowserScenarioCompleteness = (
missing: Iterable<BrowserScenarioCompletenessSection>,
truncated: Iterable<BrowserScenarioCompletenessSection>,
): BrowserScenarioCompleteness => {
const missingSections = [...new Set(missing)].sort();
const truncatedSections = [...new Set(truncated)].sort();
if (truncatedSections.length > 0)
return browserScenarioCompletenessSchema.parse({
status: "truncated",
equality_eligible: false,
missing_sections: missingSections,
truncated_sections: truncatedSections,
});
if (missingSections.length > 0)
return browserScenarioCompletenessSchema.parse({
status: "incomplete",
equality_eligible: false,
missing_sections: missingSections,
truncated_sections: [],
});
return {
status: "complete",
equality_eligible: true,
missing_sections: [],
truncated_sections: [],
};
};
const browserScenarioStepShape = {
step_index: z.int().min(0),
step_id: z.string().min(1),
action: z.string().min(1),
elapsed_ms: z.int().min(0),
before_url: sanitizedBrowserUrlSchema,
after_url: sanitizedBrowserUrlSchema,
event_sequence_start: z.int().min(1),
event_sequence_end: z.int().min(0),
artifacts: browserStepArtifactsSchema,
completeness: browserScenarioCompletenessSchema,
};
export const browserScenarioStepSchema = z.union([
z.strictObject({
...browserScenarioStepShape,
status: z.literal("completed"),
error: z.null(),
}),
z.strictObject({
...browserScenarioStepShape,
status: z.enum(["failed", "cancelled"]),
error: z.string(),
}),
]);
export type BrowserScenarioStep = z.infer<typeof browserScenarioStepSchema>;
type StepOutcome<Step> = Step extends BrowserScenarioStep
? Pick<Step, "status" | "error">
: never;
export type BrowserScenarioStepOutcome = StepOutcome<BrowserScenarioStep>;