rea/src/browser/CdpCaptureDocuments.ts

683 lines
22 KiB
TypeScript

import type { InspectWebPageInput } from "../domain/browserObservation.js";
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
import {
allowedSanitizedUrl,
numberValue,
recordValue,
recordsValue,
requiredRecord,
cdpStringValue,
type UnknownRecord,
} from "./CdpCaptureValues.js";
import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js";
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
import {
createDocumentSvgAncestry,
domSvgLink,
type DomSvgNodeQuery,
} from "./CdpCaptureDomSvg.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
export type CapturedResource = Omit<
WebPageInspection["resources"][number],
"resource_key"
> & { readonly rawUrl: string };
/** Normalize every allowed frame, optionally selecting a leading frame. */
export const captureFrames = (
result: unknown,
allowedOrigins: ReadonlySet<string>,
maximum?: number,
completeness?: CdpCaptureCompleteness,
): {
readonly items: WebPageInspection["frames"];
} => {
const root = recordValue(recordValue(result)?.frameTree);
if (root === undefined) {
completeness?.exclude("frames", "invalid_protocol_value");
return { items: [] };
}
const items: WebPageInspection["frames"] = [];
for (const tree of walkFrameTrees(root, () =>
completeness?.exclude("frames", "invalid_protocol_value"),
)) {
const frame = recordValue(tree.frame);
const frameId = cdpStringValue(frame?.id);
const sanitized = allowedSanitizedUrl(frame?.url, allowedOrigins);
if (frame === undefined || frameId === undefined) {
completeness?.exclude("frames", "invalid_protocol_value");
continue;
}
if (sanitized === undefined || sanitized.origin === null) {
completeness?.exclude(
"frames",
exclusionReasonForUrl(cdpStringValue(frame.url)),
);
continue;
}
if (maximum !== undefined && items.length >= maximum) continue;
items.push({
frame_id: frameId,
parent_frame_id: cdpStringValue(frame.parentId) ?? null,
url: sanitized.url,
origin: sanitized.origin,
});
}
return { items };
};
/** Read the current main-frame URL from an untrusted Page.getFrameTree result. */
export const mainFrameUrl = (result: unknown): string | undefined =>
cdpStringValue(
recordValue(recordValue(requiredRecord(result).frameTree)?.frame)?.url,
);
/** Normalize every allowed resource. */
export const captureResources = (
result: unknown,
allowedOrigins: ReadonlySet<string>,
completeness?: CdpCaptureCompleteness,
): {
readonly items: readonly CapturedResource[];
} => {
const root = recordValue(recordValue(result)?.frameTree);
if (root === undefined) {
completeness?.exclude("resources", "invalid_protocol_value");
return { items: [] };
}
const items: CapturedResource[] = [];
for (const tree of walkFrameTrees(root, () =>
completeness?.exclude("resources", "invalid_protocol_value"),
)) {
for (const resource of recordsValue(tree.resources)) {
const url = allowedSanitizedUrl(resource.url, allowedOrigins);
if (url === undefined || url.origin === null) {
completeness?.exclude(
"resources",
exclusionReasonForUrl(cdpStringValue(resource.url)),
);
continue;
}
const contentSize = numberValue(resource.contentSize);
items.push({
rawUrl: cdpStringValue(resource.url) ?? "",
url: url.url,
origin: url.origin,
type: cdpStringValue(resource.type) ?? "Other",
mime_type: cdpStringValue(resource.mimeType) ?? "",
content_size:
contentSize === undefined ? null : Math.max(0, contentSize),
});
}
}
return { items };
};
export const walkFrameTrees = function* (
root: UnknownRecord,
onMalformedChild?: () => void,
): Generator<UnknownRecord> {
const pending = [root];
while (pending.length > 0) {
const tree = pending.pop();
if (tree === undefined) return;
const rawChildren = tree.childFrames;
if (rawChildren !== undefined && !Array.isArray(rawChildren))
onMalformedChild?.();
const children = Array.isArray(rawChildren)
? rawChildren.flatMap((child) => {
const parsed = recordValue(child);
if (parsed === undefined) onMalformedChild?.();
return parsed === undefined ? [] : [parsed];
})
: [];
for (let index = children.length - 1; index >= 0; index -= 1) {
const child = children[index];
if (child !== undefined) pending.push(child);
}
yield tree;
}
};
interface DomMetadataCapture {
readonly urls: WebPageInspection["metadata"]["dom_urls"];
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
readonly excludedUrls: number;
readonly unresolvedSvgNodes: number;
}
export interface DomCapture extends DomMetadataCapture {
readonly total: number;
readonly nodes: WebPageInspection["dom"]["nodes"];
}
// Bound retained projections and outstanding CDP reads independently of page size.
// Eight leaves room for the session's other cancellation listeners.
const DOM_METADATA_BATCH_SIZE = 8;
interface PendingDomMetadata {
readonly options: DomMetadataOptions;
// Undefined means no query is needed; null means the candidate has no valid ID.
readonly svgQuery: DomSvgNodeQuery | null | undefined;
}
/** Project one snapshot, requesting bounded batches of SVG identity facts. */
export function* captureDom(
result: unknown,
allowedOrigins: ReadonlySet<string>,
input: InspectWebPageInput,
completeness?: CdpCaptureCompleteness,
): Generator<
readonly DomSvgNodeQuery[],
DomCapture,
readonly (boolean | undefined)[]
> {
const root = requiredRecord(result);
const strings = Array.isArray(root.strings)
? root.strings.map(cdpStringValue)
: [];
const nodes: WebPageInspection["dom"]["nodes"] = [];
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
const agentHints: WebPageInspection["metadata"]["agent_hints"] = [];
let excludedUrls = 0;
let total = 0;
let unresolvedSvgNodes = 0;
for (const document of recordsValue(root.documents)) {
const documentUrl = indexedString(strings, document.documentURL);
if (documentUrl === undefined) {
completeness?.exclude("dom", "invalid_protocol_value");
continue;
}
if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined) {
completeness?.exclude("dom", exclusionReasonForUrl(documentUrl));
continue;
}
const baseUrlValue =
document.baseURL === undefined
? documentUrl
: indexedString(strings, document.baseURL);
if (baseUrlValue === undefined) {
completeness?.exclude("dom", "invalid_protocol_value");
continue;
}
const baseUrl = baseUrlValue || documentUrl;
const documentNodes = recordValue(document.nodes);
if (documentNodes === undefined) continue;
const rawNodeTypes = numberArray(documentNodes.nodeType);
const rawNodeNames = numberArray(documentNodes.nodeName);
const rawNodeValues = numberArray(documentNodes.nodeValue);
const rawParents = numberArray(documentNodes.parentIndex);
const rawAttributes = Array.isArray(documentNodes.attributes)
? documentNodes.attributes
: undefined;
if (
rawNodeTypes === undefined ||
rawNodeNames === undefined ||
rawNodeValues === undefined ||
rawParents === undefined ||
rawAttributes === undefined ||
rawNodeNames.length !== rawNodeTypes.length ||
rawNodeValues.length !== rawNodeTypes.length ||
rawParents.length !== rawNodeTypes.length
) {
completeness?.exclude("dom", "invalid_protocol_value");
continue;
}
if (rawAttributes.length > rawNodeTypes.length)
completeness?.exclude(
"dom",
"invalid_protocol_value",
rawAttributes.length - rawNodeTypes.length,
);
const backendNodeIds = Array.isArray(documentNodes.backendNodeId)
? documentNodes.backendNodeId
: [];
const baseIndex = total;
total += rawNodeTypes.length;
const ancestry = createDocumentSvgAncestry({
nodeTypes: rawNodeTypes,
nodeNames: rawNodeNames,
parents: rawParents,
strings,
attributes: rawAttributes,
});
const pendingMetadata: PendingDomMetadata[] = [];
for (let index = 0; index < rawNodeTypes.length; index += 1) {
const nodeType = rawNodeTypes[index];
const nodeNameIndex = rawNodeNames[index];
const nodeValueIndex = rawNodeValues[index];
const parent = rawParents[index];
const attributeIndexes = numberArray(rawAttributes[index]);
const nodeName = indexedString(strings, nodeNameIndex);
const nodeValue = indexedString(strings, nodeValueIndex);
if (
nodeType === undefined ||
nodeName === undefined ||
nodeValue === undefined ||
parent === undefined ||
parent < -1 ||
parent === index ||
parent >= rawNodeTypes.length ||
attributeIndexes === undefined ||
attributeIndexes.length % 2 !== 0 ||
attributeIndexes.some(
(value) =>
value === undefined || indexedString(strings, value) === undefined,
)
) {
completeness?.exclude("dom", "invalid_protocol_value");
continue;
}
const attributes = attributeIndexes as readonly number[];
const nodeIndex = baseIndex + index;
const attributeNames = uniqueAttributeNames(attributes, strings);
const svgLink = domSvgLink(ancestry, index);
if (svgLink.xmlDocument === undefined)
completeness?.exclude("metadata", "invalid_protocol_value");
let svgQuery: DomSvgNodeQuery | null | undefined;
if (
svgLink.xmlDocument === false &&
nodeType === 1 &&
attributeNames.includes("xlink:href") &&
!attributeNames.includes("href")
) {
const backendNodeId = numberValue(backendNodeIds[index]);
svgQuery =
backendNodeId !== undefined &&
Number.isSafeInteger(backendNodeId) &&
backendNodeId > 0
? { backendNodeId, nodeName }
: null;
}
nodes.push({
index: nodeIndex,
parent_index: parent < 0 ? -1 : baseIndex + parent,
node_type: nodeType,
node_name: nodeName,
node_value_length: nodeValue.length,
attribute_names: attributeNames,
});
pendingMetadata.push({
svgQuery,
options: {
strings,
attributes,
// The BASE element itself resolves against the document fallback URL.
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
documentUrl,
nodeIndex,
nodeName,
allowedOrigins,
...svgLink,
},
});
if (
pendingMetadata.length === DOM_METADATA_BATCH_SIZE ||
index === rawNodeTypes.length - 1
) {
const metadata = yield* captureDomMetadata(
pendingMetadata,
completeness,
);
urls.push(...metadata.urls);
agentHints.push(...metadata.agentHints);
excludedUrls += metadata.excludedUrls;
unresolvedSvgNodes += metadata.unresolvedSvgNodes;
pendingMetadata.length = 0;
}
}
if (pendingMetadata.length > 0) {
const metadata = yield* captureDomMetadata(pendingMetadata, completeness);
urls.push(...metadata.urls);
agentHints.push(...metadata.agentHints);
excludedUrls += metadata.excludedUrls;
unresolvedSvgNodes += metadata.unresolvedSvgNodes;
pendingMetadata.length = 0;
}
}
return {
total,
nodes,
urls,
agentHints,
excludedUrls,
unresolvedSvgNodes,
};
}
function* captureDomMetadata(
pending: readonly PendingDomMetadata[],
completeness: CdpCaptureCompleteness | undefined,
): Generator<
readonly DomSvgNodeQuery[],
DomMetadataCapture,
readonly (boolean | undefined)[]
> {
const queries = pending.flatMap(({ svgQuery }) =>
svgQuery == null ? [] : [svgQuery],
);
const kinds = queries.length > 0 ? yield queries : [];
const urls: DomMetadataCapture["urls"] = [];
const agentHints: DomMetadataCapture["agentHints"] = [];
let queryIndex = 0;
let unresolvedSvgNodes = 0;
let excludedUrls = 0;
for (const { options, svgQuery } of pending) {
let svgElement = options.svgElement;
if (svgQuery !== undefined) {
const kind = svgQuery === null ? undefined : kinds[queryIndex++];
svgElement = kind === true;
if (kind === undefined) {
unresolvedSvgNodes += 1;
completeness?.exclude("metadata", "provider_unavailable");
}
}
const metadata = domMetadata({ ...options, svgElement });
for (const url of metadata.urls) {
urls.push(url);
if (url.destination_scope !== "approved") {
excludedUrls += 1;
completeness?.exclude(
"metadata",
url.destination_scope === "outside_policy"
? "disallowed_origin"
: "unsupported_url",
);
}
}
agentHints.push(...metadata.agentHints);
}
return { urls, agentHints, unresolvedSvgNodes, excludedUrls };
}
export const captureAccessibility = (
results: readonly unknown[],
options: {
readonly includeText: boolean;
readonly unavailable?: boolean;
},
): {
readonly total: number;
readonly nodes: WebPageInspection["accessibility"]["nodes"];
readonly textCapture: WebPageInspection["accessibility"]["text_capture"];
readonly treeIncomplete: boolean;
} => {
const all = results.flatMap((result) =>
recordsValue(requiredRecord(result).nodes),
);
const nodeIds = new Set(
all.flatMap((node) => {
const nodeId = cdpStringValue(node.nodeId);
return nodeId === undefined ? [] : [nodeId];
}),
);
let excludedFields = 0;
const nodes = all.map((node) => {
const captureText = (value: unknown): string | null => {
const raw = cdpStringValue(recordValue(value)?.value);
if (raw === undefined) return null;
if (!options.includeText) {
excludedFields += 1;
return null;
}
return raw;
};
return {
node_id: cdpStringValue(node.nodeId) ?? "",
parent_id: cdpStringValue(node.parentId) ?? null,
role: axText(node.role),
name: captureText(node.name),
description: captureText(node.description),
ignored: node.ignored === true,
states: accessibilityStates(node.properties),
};
});
return {
total: all.length,
nodes,
treeIncomplete: all.some(
(node) =>
Array.isArray(node.childIds) &&
node.childIds.some((childId) => {
const id = cdpStringValue(childId);
return id !== undefined && !nodeIds.has(id);
}),
),
textCapture: {
status: options.unavailable
? "unavailable"
: !options.includeText
? "not_approved"
: "included",
retained_bytes: nodes.reduce(
(total, node) =>
total +
Buffer.byteLength(node.name ?? "") +
Buffer.byteLength(node.description ?? ""),
0,
),
excluded_fields: excludedFields,
},
};
};
const ACCESSIBILITY_STATE_NAMES = new Set([
"atomic",
"autocomplete",
"busy",
"checked",
"disabled",
"editable",
"expanded",
"focusable",
"focused",
"hasPopup",
"hidden",
"invalid",
"level",
"live",
"modal",
"multiline",
"multiselectable",
"orientation",
"pressed",
"readonly",
"required",
"selected",
"valuemax",
"valuemin",
]);
const accessibilityStates = (
value: unknown,
): WebPageInspection["accessibility"]["nodes"][number]["states"] => {
const states = new Map<string, boolean | number | string>();
for (const property of recordsValue(value)) {
const name = cdpStringValue(property.name);
if (name === undefined || !ACCESSIBILITY_STATE_NAMES.has(name)) continue;
const raw = recordValue(property.value)?.value;
const state =
typeof raw === "boolean"
? raw
: typeof raw === "number"
? numberValue(raw)
: typeof raw === "string"
? raw
: undefined;
if (state !== undefined) states.set(name, state);
}
return [...states]
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
.map(([name, state]) => ({ name, value: state }));
};
const axText = (value: unknown): string | null =>
cdpStringValue(recordValue(value)?.value) ?? null;
const indexedString = (
strings: readonly (string | undefined)[],
index: unknown,
): string | undefined => {
const integer = numberValue(index);
if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0)
return undefined;
return strings[integer];
};
const uniqueAttributeNames = (
attributeIndexes: readonly number[],
strings: readonly (string | undefined)[],
): string[] => {
const names: string[] = [];
const seen = new Set<string>();
for (let index = 0; index < attributeIndexes.length; index += 2) {
const name = indexedString(strings, attributeIndexes[index]);
if (name === undefined) continue;
if (seen.has(name)) continue;
seen.add(name);
names.push(name);
}
return names;
};
const numberArray = (
value: unknown,
): readonly (number | undefined)[] | undefined => {
if (!Array.isArray(value)) return undefined;
return value.map((item) => {
const number = numberValue(item);
return number !== undefined && Number.isSafeInteger(number)
? number
: undefined;
});
};
interface DomMetadataOptions {
readonly strings: readonly (string | undefined)[];
readonly attributes: readonly number[];
readonly baseUrl: string;
readonly documentUrl: string;
readonly nodeIndex: number;
readonly nodeName: string;
readonly allowedOrigins: ReadonlySet<string>;
readonly svgElement: boolean;
readonly xmlDocument: boolean | undefined;
readonly xlinkHref: string | undefined;
}
// Chrome reads an SVG XLink href only when href itself is absent, and an
// empty href still wins. HTML keeps the literal attribute name `xlink:href`.
// XML uses any prefix bound to the XLink namespace. Both are stored as href.
const hrefAttributeValue = (
pairs: ReadonlyMap<string, string>,
attribute: string,
options: DomMetadataOptions,
): string | undefined => {
const declared = pairs.get(attribute);
if (declared !== undefined) return declared;
if (attribute !== "href" || !options.svgElement) return undefined;
return options.xmlDocument ? options.xlinkHref : pairs.get("xlink:href");
};
const domMetadata = (
options: DomMetadataOptions,
): {
readonly urls: WebPageInspection["metadata"]["dom_urls"];
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
} => {
const {
strings,
attributes,
baseUrl,
documentUrl,
nodeIndex,
nodeName,
allowedOrigins,
} = options;
const pairs = new Map<string, string>();
for (let index = 0; index + 1 < attributes.length; index += 2) {
// Chrome already folds HTML names; SVG/XML names remain case-sensitive.
const name = indexedString(strings, attributes[index]);
const value = indexedString(strings, attributes[index + 1]);
if (name === undefined || value === undefined) continue;
// HTML keeps the first declaration. A later repeat must not move dom_urls.
if (!pairs.has(name)) pairs.set(name, value);
}
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
for (const attribute of domUrlAttributes) {
const value = hrefAttributeValue(pairs, attribute, options);
if (value === undefined) continue;
const tagName = nodeName.toLowerCase();
// Chrome strips HTML whitespace for FORM.action, while submit controls
// use the document URL only for an exactly empty formaction attribute.
const usesDocumentUrl =
(tagName === "form" &&
attribute === "action" &&
/^[\t\n\f\r ]*$/u.test(value)) ||
((tagName === "button" || tagName === "input") &&
attribute === "formaction" &&
value === "");
const destination = domDestination(
usesDocumentUrl ? documentUrl : value,
baseUrl,
allowedOrigins,
);
urls.push({
node_index: nodeIndex,
attribute,
url: destination.url,
destination_scope: destination.scope,
});
}
const rel = (pairs.get("rel") ?? "")
.toLowerCase()
// HTML link type tokens are separated by ASCII whitespace only.
.split(/[\t\n\f\r ]+/u)
.filter((value) => agentRelValues.includes(value));
const href = urls.find(({ attribute }) => attribute === "href")?.url ?? null;
const agentHints =
nodeName.toLowerCase() === "link" && rel.length > 0
? rel.map((declaration) => ({
mechanism: "dom_link_rel" as const,
declaration,
url: href,
trust: "page-declared-untrusted" as const,
}))
: [];
return { urls, agentHints };
};
const domDestination = (
value: string,
baseUrl: string,
allowedOrigins: ReadonlySet<string>,
): {
readonly url: string | null;
readonly scope: "approved" | "outside_policy" | "unsupported";
} => {
try {
const parsed = new URL(value, baseUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
return { url: null, scope: "unsupported" };
if (!allowedOrigins.has(parsed.origin))
return { url: null, scope: "outside_policy" };
return {
url: allowedSanitizedUrl(parsed.href, allowedOrigins)?.url ?? null,
scope: "approved",
};
} catch (cause: unknown) {
// Unparseable URLs are out of scope, not failures.
void cause;
return { url: null, scope: "unsupported" };
}
};
const domUrlAttributes = [
"href",
"src",
"action",
"formaction",
"poster",
] as const;
const agentRelValues = ["mcp", "model-context", "ai-plugin", "service-desc"];