Files
rea/src/domain/webScreenshot.ts
T
all-black-493 62c8b3da1b feat(browser): attach passive tools to browsers without HTTP discovery (#2346)
* feat(browser): attach passive tools to browsers without HTTP discovery

Accept a literal loopback browser WebSocket,
ws://127.0.0.1:PORT/devtools/browser/ID, as the cdp_endpoint of the six
passive browser tools. Discovery then reads Browser.getVersion and
Target.getTargets over that socket, and the target session adopts the
same socket, so a browser that asks for consent per connection prompts
once per call.

The socket identifier gates access, so Evidence records the endpoint as
ws://127.0.0.1:PORT/devtools/browser/[REDACTED], and runtime
reconciliation accepts that recorded form. HTTP endpoints and their
Evidence are unchanged. Electron, web runtime, scenario and Inspector
endpoints stay HTTP-only.

Refs #2288

* test(browser): match browser-socket captures against HTTP discovery

Check in the real Chrome lane that DevToolsActivePort names the same
browser socket as /json/version, and that inspecting the selected page
through that socket leaves DOM structure and scripts unchanged compared
with an inspection through HTTP discovery.

Refs #2288

* fix(browser): bound browser-socket handshake and report discovery timeouts

Run the browser-socket WebSocket handshake and both discovery commands
under one discovery deadline, so a port that accepts the connection but
never completes the upgrade times out instead of waiting forever.

Report a reached deadline as a timeout without carrying the internal
abort as its cause, so CLI and MCP project provider_timeout rather than
cancelled. Caller cancellation is still reported as cancelled.

Refs #2288
2026-10-14 00:36:37 +08:00

159 lines
5.4 KiB
TypeScript

import { createHash } from "node:crypto";
import { z } from "zod";
import { browserCompletenessSchema } from "./browserCompleteness.js";
import {
browserAllowedOriginsSchema,
browserObservationEndpointSchema,
} from "./browserObservation.js";
import { browserVersionSchema } from "./browserObservationSchemas.js";
/** Self-verifying inline PNG artifact for CLI/MCP parity. */
export const webScreenshotArtifactSchema = z
.object({
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
bytes: z.number().int().min(1),
media_type: z.literal("image/png"),
data_base64: z.string(),
})
.superRefine((artifact, context) => {
const bytes = decodeCanonicalBase64(artifact.data_base64);
if (bytes === undefined) {
context.addIssue({ code: "custom", message: "Invalid canonical base64" });
return;
}
const sha256 = createHash("sha256").update(bytes).digest("hex");
if (bytes.byteLength !== artifact.bytes || sha256 !== artifact.sha256)
context.addIssue({
code: "custom",
message: "Screenshot artifact digest or size mismatch",
});
});
export type WebScreenshotArtifact = z.infer<typeof webScreenshotArtifactSchema>;
/** Input for one read-only visible-viewport screenshot. */
export const captureWebScreenshotInputSchema = z.strictObject({
cdp_endpoint: browserObservationEndpointSchema,
allowed_origins: browserAllowedOriginsSchema,
target_id: z.string().trim().min(1),
});
export type CaptureWebScreenshotInput = z.infer<
typeof captureWebScreenshotInputSchema
>;
/** Screenshot observation with embedded immutable artifact bytes. */
export const webScreenshotSchema = z.object({
browser: browserVersionSchema,
target: z.object({
target_id: z.string(),
url: z.string(),
origin: z.string(),
}),
captured_at: z.iso.datetime(),
viewport: z.object({
width: z.number().int().min(1),
height: z.number().int().min(1),
}),
artifact: webScreenshotArtifactSchema,
completeness: browserCompletenessSchema,
limitations: z.array(z.string()),
});
export type WebScreenshot = z.infer<typeof webScreenshotSchema>;
/** Input for local pixel comparison of two screenshot artifacts. */
export const compareWebScreenshotsInputSchema = z.strictObject({
before: webScreenshotArtifactSchema,
after: webScreenshotArtifactSchema,
channel_threshold: z.number().int().min(0).max(255).default(0),
});
export type CompareWebScreenshotsInput = z.infer<
typeof compareWebScreenshotsInputSchema
>;
/** Value-only visual difference metrics; no OCR or image mutation. */
const webScreenshotDiffContextShape = {
before: z.object({
width: z.number().int().min(1),
height: z.number().int().min(1),
}),
after: z.object({
width: z.number().int().min(1),
height: z.number().int().min(1),
}),
channel_threshold: z.number().int().min(0).max(255),
limitations: z.array(z.string()),
};
export const webScreenshotDiffSchema = z
.discriminatedUnion("status", [
z.object({
...webScreenshotDiffContextShape,
status: z.literal("identical"),
compared_pixels: z.number().int().positive(),
changed_pixels: z.literal(0),
changed_ratio: z.literal(0),
maximum_channel_delta: z.number().int().min(0).max(255),
mean_absolute_channel_delta: z.number().min(0).max(255),
}),
z.object({
...webScreenshotDiffContextShape,
status: z.literal("different"),
compared_pixels: z.number().int().positive(),
changed_pixels: z.number().int().positive(),
changed_ratio: z.number().positive().max(1),
maximum_channel_delta: z.number().int().positive().max(255),
mean_absolute_channel_delta: z.number().positive().max(255),
}),
z.object({
...webScreenshotDiffContextShape,
status: z.literal("dimension_mismatch"),
compared_pixels: z.literal(0),
changed_pixels: z.null(),
changed_ratio: z.null(),
maximum_channel_delta: z.null(),
mean_absolute_channel_delta: z.null(),
}),
])
.superRefine((diff, context) => {
const sameDimensions =
diff.before.width === diff.after.width &&
diff.before.height === diff.after.height;
if (sameDimensions !== (diff.status !== "dimension_mismatch"))
context.addIssue({
code: "custom",
path: ["status"],
message: "Screenshot comparison status must match viewport dimensions",
});
if (
diff.status !== "dimension_mismatch" &&
diff.compared_pixels !== diff.before.width * diff.before.height
)
context.addIssue({
code: "custom",
path: ["compared_pixels"],
message: "Compared pixel count must match the shared dimensions",
});
});
export type WebScreenshotDiff = z.infer<typeof webScreenshotDiffSchema>;
/** Create a content-addressed PNG artifact from captured bytes. */
export const createWebScreenshotArtifact = (
bytes: Buffer,
): WebScreenshotArtifact => {
const sha256 = createHash("sha256").update(bytes).digest("hex");
return webScreenshotArtifactSchema.parse({
sha256,
bytes: bytes.byteLength,
media_type: "image/png",
data_base64: bytes.toString("base64"),
});
};
/** Strict canonical base64 decoder used before digest validation. */
export const decodeCanonicalBase64 = (value: string): Buffer | undefined => {
if (value.length === 0 || value.length % 4 !== 0) return undefined;
const decoded = Buffer.from(value, "base64");
return decoded.toString("base64") === value ? decoded : undefined;
};