mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
refactor(browser): return complete observation evidence
This commit is contained in:
1 parent
9d1f88cbe8
commit
0e2738ffb6
69 files changed
+747
-1206
No files matched your search
@@ -37,7 +37,7 @@ See [docs/architecture.mermaid](docs/architecture.mermaid) for a visual architec
|
||||
- `bridge/ghidra/ReaGhidraBridge.java`: packaged read-only `HeadlessScript` loaded through Ghidra's `scriptPath`; it owns the persistent decompiler and adapts admitted inventory, function, reference, and CFG operations to public Ghidra APIs.
|
||||
- `src/application/`: shared CLI/MCP session composition, setup and diagnostics, and enhanced workflows. `AnalysisProviderRegistry` discovers overlapping deep candidates without starting them; `SessionProviderRouter` binds one candidate per target and composes it with disjoint auxiliary providers; `JavaScriptArtifactReconstruction.ts` safely projects local directories/ASARs and inert bundle syntax into the application graph without adding a caller-visible tool yet.
|
||||
- `src/server/`: MCP request translation. `createServer.ts` assembles the MCP server, `registerOfficialTools.ts`/`registerEnhancedTools.ts` register each tool set, `toolResult.ts` maps `Result` values to MCP content.
|
||||
- `docs/product-catalog.json`: generated package, tool-family, provider, setup-client, schema-version, and CLI facts. Regenerate it from source; do not edit it by hand.
|
||||
- `docs/product-catalog.json`: generated package, tool-family, provider, setup-client, and CLI facts. Regenerate it from source; do not edit it by hand.
|
||||
- `tests/`: Vitest suite. Focused immutable domain builders live beside their production owner as `src/domain/*.fixture.ts`; `tests/fixtures/` holds reusable provider-process fixtures, source-owned Electron/Webpack/Rspack artifact trees, and the fake launcher, Hopper bridge, and CDP seams.
|
||||
- `scripts/verify-real-hopper.mjs`: real-Hopper end-to-end verifier.
|
||||
- `scripts/verify-real-ghidra.mjs`: real Linux Ghidra verifier for x86-64 debug/stripped ELF, AArch64 ELF, PE, and Mach-O function semantics plus complete cleanup.
|
||||
|
||||
+15
-15
@@ -5,12 +5,12 @@ REA can attach to a user-owned Chrome-family browser through the Chrome DevTools
|
||||
## Shipped surfaces
|
||||
|
||||
- `list_browser_targets` / `rea list-browser-targets` discovers page targets whose current URL matches an approved exact origin.
|
||||
- `inspect_web_page` / `rea inspect-web-page` captures bounded DOM structure, accessibility nodes, scripts, resources, safe response/DOM metadata, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally storage key names, redacted storage-content fingerprints, or script sources.
|
||||
- `inspect_web_page` / `rea inspect-web-page` captures DOM structure, accessibility nodes, scripts, resources, safe response/DOM metadata, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally storage key names, redacted storage-content fingerprints, or script sources.
|
||||
- `analyze_web_bundle` / `rea analyze-web-bundle` parses approved script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally approved source-map/original-source evidence.
|
||||
- `observe_web_session` / `rea observe-web-session` arms a bounded window for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata.
|
||||
- `observe_web_session` / `rea observe-web-session` arms an observation window of the requested duration for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata.
|
||||
- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete structural input-schema summary inline. REA never exposes `WebMCP.invokeTool`; malformed or over-limit protocol payloads fail explicitly instead of returning truncated tool metadata.
|
||||
- `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence.
|
||||
- `capture_web_screenshot` / `rea capture-web-screenshot` returns an explicitly approved, bounded, content-addressed visible-viewport PNG.
|
||||
- `capture_web_screenshot` / `rea capture-web-screenshot` returns an explicitly approved, content-addressed visible-viewport PNG.
|
||||
- `compare_web_screenshots` / `rea compare-web-screenshots` performs bounded local PNG pixel comparison without OCR or external services.
|
||||
- Every surface has equivalent CLI and MCP contracts and returns Evidence provenance.
|
||||
- MCP tools return complete results inline and include the Evidence ID. Session evidence can be exported with `export_evidence_bundle`.
|
||||
@@ -98,9 +98,9 @@ rea inspect-web-page http://127.0.0.1:9222 TARGET_ID \
|
||||
--json
|
||||
```
|
||||
|
||||
Accessibility text and script content may contain application secrets and become part of the returned Evidence. Accessibility and console text have per-field UTF-8 byte limits; credential-shaped console substrings are redacted. JSON and WebSocket captures retain paths, types, counts, and any per-value truncation only, never values or examples. Storage values remain redacted even when key-name capture is requested. `--include-storage-fingerprints` requests SHA-256 identity/value fingerprints, which support comparison only when cookies, DOM storage, IndexedDB, and Cache Storage were all captured completely.
|
||||
Accessibility text and script content may contain application secrets and become part of the returned Evidence. Credential-shaped console substrings are redacted. JSON and WebSocket captures retain complete paths, types, and counts, never values or examples. Storage values remain redacted even when key-name capture is requested. `--include-storage-fingerprints` requests SHA-256 identity/value fingerprints, which support comparison only when cookies, DOM storage, IndexedDB, and Cache Storage were all captured completely.
|
||||
|
||||
Screenshot capture returns the visible viewport as image pixels:
|
||||
Screenshot capture returns the complete visible viewport as an inline image artifact:
|
||||
|
||||
```bash
|
||||
rea capture-web-screenshot http://127.0.0.1:9222 TARGET_ID \
|
||||
@@ -133,13 +133,13 @@ REA removes sensitive values before normalized event data is retained:
|
||||
|
||||
- URLs retain a bounded path and at most 256 bounded query parameter names, replace every retained query value with `[REDACTED]`, and remove credentials and fragments.
|
||||
- DOM snapshots retain node types, node names, value lengths, and attribute names, but not text or attribute values.
|
||||
- Accessibility structure and roles are retained by default, but names and descriptions require `include_accessibility_text: true` and remain byte-bounded.
|
||||
- Accessibility structure and roles are retained by default, while names and descriptions require `include_accessibility_text: true`.
|
||||
- Network observations retain method, status, MIME type, size, type, initiator stack location, and redacted URLs. Headers are discarded after an allowlisted projection of length/encoding, structured CSP/Link/policy fields, and untrusted agent hints. Cookies and authorization headers are never retained.
|
||||
- Request/response bodies are not requested or parsed by default. When selected, only allowed-origin JSON media types are read within a per-body byte and shape budget, converted immediately to value-free property paths/types, and discarded. `Network.getResponseBody` is never sent unless JSON body shape capture is selected.
|
||||
- Console observations with a stack source retain call type, argument types, timestamp, and redacted source location. When console text capture is selected, only already-delivered primitive values are retained after credential redaction and byte bounds; objects, getters, and remote properties are never expanded.
|
||||
- WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, bounded text frames are classified as text or value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained.
|
||||
- Request/response bodies are not requested or parsed by default. When selected, only allowed-origin JSON media types are read, converted immediately to complete value-free property paths/types, and discarded. `Network.getResponseBody` is never sent unless JSON body shape capture is selected.
|
||||
- Console observations with a stack source retain call type, argument types, timestamp, and redacted source location. When console text capture is selected, already-delivered primitive values are retained after credential redaction; objects, getters, and remote properties are never expanded.
|
||||
- WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, text frames are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained.
|
||||
- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
|
||||
- Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. When CDP supplies an execution-context association, the accepted script retains its authorized frame ID for later attribution. Source content is omitted unless explicitly requested and is returned inline as a self-verifying artifact with its SHA-256 digest, subject to a per-script byte limit.
|
||||
- Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. When CDP supplies an execution-context association, the accepted script retains its authorized frame ID for later attribution. Source content is omitted unless explicitly requested and is returned inline as a self-verifying artifact with its SHA-256 digest.
|
||||
|
||||
Cross-origin frames, resources, scripts, events, and workers are excluded unless their exact origins are also approved. Excluded target details are counted without being exposed.
|
||||
Retained workers include validated opener-target and parent-frame IDs when CDP
|
||||
@@ -148,19 +148,19 @@ prove static module ownership.
|
||||
|
||||
## Completeness and limits
|
||||
|
||||
The default observation window is 500 ms and the maximum is 10 seconds. The
|
||||
The default inspection window is 500 ms; callers choose its duration. The
|
||||
capture returns all in-scope frames, DOM and accessibility nodes, scripts,
|
||||
resources, workers, storage names, and observed network, console, and WebSocket
|
||||
events. Per-value bounds remain for text fields, individual script sources,
|
||||
JSON bodies and shapes, and WebSocket shapes. The result distinguishes
|
||||
events. Full selected text, script sources, and JSON shapes are returned inline.
|
||||
The result distinguishes
|
||||
`complete_within_window`, `policy_filtered`, `attach_limited`, and `truncated`
|
||||
coverage. It reports excluded items and any values omitted or truncated by
|
||||
validation; it does not drop records to meet an internal collection quota.
|
||||
Disallowed-origin entries are filtered before retention.
|
||||
|
||||
CDP discovery, WebSocket connection, and each command have a 5-second timeout. Version discovery is capped at 64 KiB, target discovery at 2 MiB and 1,000 targets, each WebSocket message at 16 MiB, and correlated pending commands at 128. These protocol guards remain in force independently of collection quotas. Malformed unsolicited protocol events poison the connection so later commands fail closed.
|
||||
CDP discovery, WebSocket connection, and each command have a 5-second timeout. Version discovery is capped at 64 KiB, target discovery at 2 MiB, each WebSocket message at 16 MiB, and correlated pending commands at 128. Target and navigation-event inventories have no separate count cap. These transport bounds protect protocol framing and request correlation; they do not silently trim returned findings. Malformed unsolicited protocol events poison the connection so later commands fail closed.
|
||||
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Source maps are not fetched by inspection. Bundle analysis can fetch them only after separate approval, with exact-origin redirect checks, no credentials/cookies/referrer, per-map and aggregate byte limits, a total map-count limit, v3 validation, and bounded generated/original mappings. Source-map results report requested, processed, and dropped counts rather than hiding budget truncation.
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Source maps are not fetched by inspection. Bundle analysis can fetch them only after separate approval, with exact-origin redirect checks and no credentials, cookies, or referrer. Every requested map, mapping, original source, AST node, and finding from parsed content is returned inline; failed maps remain explicitly unavailable and make bundle completeness partial.
|
||||
|
||||
## Non-goals and threat model
|
||||
|
||||
|
||||
@@ -126,7 +126,6 @@ const browserParameters = (
|
||||
before_artifact_sha256: input.before.sha256,
|
||||
after_artifact_sha256: input.after.sha256,
|
||||
channel_threshold: input.channel_threshold,
|
||||
maximum_pixels: input.maximum_pixels,
|
||||
};
|
||||
throw new Error("Browser comparison input did not match a comparison type");
|
||||
}
|
||||
@@ -135,37 +134,25 @@ const browserParameters = (
|
||||
allowed_origins: input.allowed_origins,
|
||||
};
|
||||
if (!("target_id" in input)) return scope;
|
||||
if ("maximum_image_bytes" in input)
|
||||
return {
|
||||
...scope,
|
||||
target_id: input.target_id,
|
||||
maximum_image_bytes: input.maximum_image_bytes,
|
||||
};
|
||||
if ("max_timeline_events" in input)
|
||||
return {
|
||||
...scope,
|
||||
target_id: input.target_id,
|
||||
observation_ms: input.observation_ms,
|
||||
max_timeline_events: input.max_timeline_events,
|
||||
};
|
||||
if (!("limits" in input))
|
||||
return {
|
||||
...scope,
|
||||
target_id: input.target_id,
|
||||
observation_ms: input.observation_ms,
|
||||
};
|
||||
return {
|
||||
...scope,
|
||||
target_id: input.target_id,
|
||||
observation_ms: input.observation_ms,
|
||||
include_accessibility_text: input.include_accessibility_text,
|
||||
include_console_text: input.include_console_text,
|
||||
include_json_body_shapes: input.include_json_body_shapes,
|
||||
include_websocket_shapes: input.include_websocket_shapes,
|
||||
include_script_sources: input.include_script_sources,
|
||||
include_storage_keys: input.include_storage_keys,
|
||||
include_storage_fingerprints: input.include_storage_fingerprints,
|
||||
limits: input.limits,
|
||||
...("observation_ms" in input
|
||||
? { observation_ms: input.observation_ms }
|
||||
: {}),
|
||||
...("include_accessibility_text" in input
|
||||
? {
|
||||
include_accessibility_text: input.include_accessibility_text,
|
||||
include_console_text: input.include_console_text,
|
||||
include_json_body_shapes: input.include_json_body_shapes,
|
||||
include_websocket_shapes: input.include_websocket_shapes,
|
||||
include_storage_keys: input.include_storage_keys,
|
||||
include_storage_fingerprints: input.include_storage_fingerprints,
|
||||
...("include_script_sources" in input
|
||||
? { include_script_sources: input.include_script_sources }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...("fetch_source_maps" in input
|
||||
? { fetch_source_maps: input.fetch_source_maps }
|
||||
: {}),
|
||||
@@ -179,20 +166,20 @@ const isScreenshotComparison = (
|
||||
const browserPredicate = (operation: BrowserEvidenceOperation): string => {
|
||||
switch (operation) {
|
||||
case "list_browser_targets":
|
||||
return "rea.browser-target-list/v1";
|
||||
return "rea.browser-target-list";
|
||||
case "inspect_web_page":
|
||||
return "rea.web-page-inspection/v2";
|
||||
return "rea.web-page-inspection";
|
||||
case "analyze_web_bundle":
|
||||
return "rea.web-bundle-analysis/v1";
|
||||
return "rea.web-bundle-analysis";
|
||||
case "observe_web_session":
|
||||
return "rea.web-observation-session/v1";
|
||||
return "rea.web-observation-session";
|
||||
case "discover_webmcp_tools":
|
||||
return "rea.webmcp-discovery/v1";
|
||||
return "rea.webmcp-discovery";
|
||||
case "compare_web_captures":
|
||||
return "rea.web-capture-diff/v1";
|
||||
return "rea.web-capture-diff";
|
||||
case "capture_web_screenshot":
|
||||
return "rea.web-screenshot/v1";
|
||||
return "rea.web-screenshot";
|
||||
case "compare_web_screenshots":
|
||||
return "rea.web-screenshot-diff/v1";
|
||||
return "rea.web-screenshot-diff";
|
||||
}
|
||||
};
|
||||
@@ -40,7 +40,7 @@ export const createBrowserScenarioEvidence = (
|
||||
provider: ProviderIdentity,
|
||||
): Evidence =>
|
||||
createEvidence(undefined, provider, {
|
||||
predicateType: "rea.browser-scenario-capture/v1",
|
||||
predicateType: "rea.browser-scenario-capture",
|
||||
operation: "capture_browser_scenario",
|
||||
parameters: browserScenarioParameters(scenario),
|
||||
result: jsonValueSchema.parse(capture),
|
||||
|
||||
@@ -15,7 +15,7 @@ export const createElectronActiveEvidence = (
|
||||
provider: ProviderIdentity,
|
||||
): Evidence =>
|
||||
createEvidence(undefined, provider, {
|
||||
predicateType: "rea.electron-active-scenario/v1",
|
||||
predicateType: "rea.electron-active-scenario",
|
||||
operation: "capture_electron_scenario",
|
||||
parameters: parameters(input),
|
||||
result: jsonValueSchema.parse(result),
|
||||
|
||||
@@ -58,7 +58,6 @@ const addNativeBinding = (input: NativeBindingInput): void => {
|
||||
specifier: value.specifier,
|
||||
binding_kind: value.binding_kind,
|
||||
requested_members: value.members,
|
||||
members_truncated: value.members_truncated,
|
||||
resolved_path: resolved,
|
||||
native_export_verification: "not-performed",
|
||||
},
|
||||
|
||||
@@ -75,7 +75,6 @@ const addBrowserWindow = (
|
||||
options_status: value.options_status,
|
||||
web_preferences_status: value.web_preferences_status,
|
||||
web_preferences: value.web_preferences,
|
||||
omitted_web_preferences: value.omitted_web_preferences,
|
||||
preload_path: value.preload_path,
|
||||
preload_resolution_context: value.preload_resolution_context,
|
||||
absence_means_default: false,
|
||||
@@ -176,7 +175,6 @@ const addContextBridge = (
|
||||
api_status: value.api_status,
|
||||
members: value.members,
|
||||
unknown_members: value.unknown_members,
|
||||
omitted_members: value.omitted_members,
|
||||
},
|
||||
evidence: astObservationEvidence({
|
||||
sha256: file.sha256,
|
||||
|
||||
@@ -24,8 +24,8 @@ export const createElectronEvidence = (
|
||||
createEvidence(undefined, provider, {
|
||||
predicateType:
|
||||
operation === "list_electron_targets"
|
||||
? "rea.electron-target-list/v1"
|
||||
: "rea.electron-page-inspection/v1",
|
||||
? "rea.electron-target-list"
|
||||
: "rea.electron-page-inspection",
|
||||
operation,
|
||||
parameters: parameters(input),
|
||||
result: jsonValueSchema.parse(result),
|
||||
|
||||
@@ -143,7 +143,7 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
: undefined;
|
||||
return ok(
|
||||
webBundleAnalysisSchema.parse(
|
||||
analyzeCapturedWebBundle(captured.inspection, input, sourceMaps),
|
||||
analyzeCapturedWebBundle(captured.inspection, sourceMaps),
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
@@ -368,7 +368,7 @@ const selectTargets = (
|
||||
allowed.push({
|
||||
target_id: target.id,
|
||||
type: target.type,
|
||||
title: target.title.slice(0, 16_384),
|
||||
title: target.title,
|
||||
url: url.url,
|
||||
origin: url.origin,
|
||||
attached: target.attached,
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("CDP document capture", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds approved accessibility text by UTF-8 bytes without splitting characters", () => {
|
||||
it("retains full approved accessibility text", () => {
|
||||
const capture = captureAccessibility(
|
||||
[
|
||||
{
|
||||
@@ -91,21 +91,19 @@ describe("CDP document capture", () => {
|
||||
],
|
||||
{
|
||||
includeText: true,
|
||||
maximumFieldBytes: 4,
|
||||
},
|
||||
);
|
||||
|
||||
expect(capture.nodes[0]).toMatchObject({
|
||||
name: "😀",
|
||||
name: "😀a",
|
||||
description: "éé",
|
||||
states: [{ name: "disabled", value: true }],
|
||||
});
|
||||
expect(capture.treeIncomplete).toBe(true);
|
||||
expect(capture.textCapture).toEqual({
|
||||
status: "truncated",
|
||||
retained_bytes: 8,
|
||||
status: "included",
|
||||
retained_bytes: 9,
|
||||
excluded_fields: 0,
|
||||
truncated_fields: 1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,7 +122,6 @@ describe("CDP document capture", () => {
|
||||
],
|
||||
{
|
||||
includeText: false,
|
||||
maximumFieldBytes: 1_024,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -133,7 +130,6 @@ describe("CDP document capture", () => {
|
||||
status: "not_approved",
|
||||
retained_bytes: 0,
|
||||
excluded_fields: 2,
|
||||
truncated_fields: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
} from "../domain/browserObservation.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
boundedText,
|
||||
numberValue,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
@@ -36,7 +35,7 @@ export const captureFrames = (
|
||||
const frame = recordValue(tree.frame);
|
||||
const frameId = stringValue(frame?.id);
|
||||
const sanitized = allowedSanitizedUrl(frame?.url, allowedOrigins);
|
||||
if (frame === undefined || frameId === undefined || frameId.length > 256) {
|
||||
if (frame === undefined || frameId === undefined) {
|
||||
completeness?.exclude("frames", "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
@@ -50,8 +49,7 @@ export const captureFrames = (
|
||||
if (maximum !== undefined && items.length >= maximum) continue;
|
||||
items.push({
|
||||
frame_id: frameId,
|
||||
parent_frame_id:
|
||||
(stringValue(frame.parentId) ?? "").slice(0, 256) || null,
|
||||
parent_frame_id: stringValue(frame.parentId) ?? null,
|
||||
url: sanitized.url,
|
||||
origin: sanitized.origin,
|
||||
});
|
||||
@@ -91,8 +89,8 @@ export const captureResources = (
|
||||
rawUrl: stringValue(resource.url) ?? "",
|
||||
url: url.url,
|
||||
origin: url.origin,
|
||||
type: (stringValue(resource.type) ?? "Other").slice(0, 100),
|
||||
mime_type: (stringValue(resource.mimeType) ?? "").slice(0, 256),
|
||||
type: stringValue(resource.type) ?? "Other",
|
||||
mime_type: stringValue(resource.mimeType) ?? "",
|
||||
content_size:
|
||||
contentSize === undefined ? null : Math.max(0, contentSize),
|
||||
});
|
||||
@@ -159,7 +157,7 @@ export const captureDom = (
|
||||
const attributeIndexes = numberArray(attributes[index]);
|
||||
const parent = Math.trunc(parents[index] ?? -1);
|
||||
const nodeIndex = nodes.length;
|
||||
const nodeName = indexedString(strings, nodeNames[index]).slice(0, 256);
|
||||
const nodeName = indexedString(strings, nodeNames[index]);
|
||||
nodes.push({
|
||||
index: nodeIndex,
|
||||
parent_index: parent < 0 ? -1 : baseIndex + parent,
|
||||
@@ -168,7 +166,7 @@ export const captureDom = (
|
||||
node_value_length: indexedString(strings, nodeValues[index]).length,
|
||||
attribute_names: attributeIndexes
|
||||
.filter((_value, attributeIndex) => attributeIndex % 2 === 0)
|
||||
.map((value) => indexedString(strings, value).slice(0, 256)),
|
||||
.map((value) => indexedString(strings, value)),
|
||||
});
|
||||
const metadata = domMetadata({
|
||||
strings,
|
||||
@@ -206,7 +204,6 @@ export const captureAccessibility = (
|
||||
results: readonly unknown[],
|
||||
options: {
|
||||
readonly includeText: boolean;
|
||||
readonly maximumFieldBytes: number;
|
||||
readonly unavailable?: boolean;
|
||||
},
|
||||
): {
|
||||
@@ -225,7 +222,6 @@ export const captureAccessibility = (
|
||||
}),
|
||||
);
|
||||
let excludedFields = 0;
|
||||
let truncatedFields = 0;
|
||||
const nodes = all.map((node) => {
|
||||
const captureText = (value: unknown): string | null => {
|
||||
const raw = stringValue(recordValue(value)?.value);
|
||||
@@ -234,19 +230,11 @@ export const captureAccessibility = (
|
||||
excludedFields += 1;
|
||||
return null;
|
||||
}
|
||||
const captured = boundedUtf8(raw, options.maximumFieldBytes);
|
||||
if (captured.truncated) {
|
||||
truncatedFields += 1;
|
||||
if (captured.text === "" && raw !== "") {
|
||||
excludedFields += 1;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return captured.text;
|
||||
return raw;
|
||||
};
|
||||
return {
|
||||
node_id: (stringValue(node.nodeId) ?? "").slice(0, 256),
|
||||
parent_id: (stringValue(node.parentId) ?? "").slice(0, 256) || null,
|
||||
node_id: stringValue(node.nodeId) ?? "",
|
||||
parent_id: stringValue(node.parentId) ?? null,
|
||||
role: axText(node.role),
|
||||
name: captureText(node.name),
|
||||
description: captureText(node.description),
|
||||
@@ -270,9 +258,7 @@ export const captureAccessibility = (
|
||||
? "unavailable"
|
||||
: !options.includeText
|
||||
? "not_approved"
|
||||
: truncatedFields > 0
|
||||
? "truncated"
|
||||
: "included",
|
||||
: "included",
|
||||
retained_bytes: nodes.reduce(
|
||||
(total, node) =>
|
||||
total +
|
||||
@@ -281,7 +267,6 @@ export const captureAccessibility = (
|
||||
0,
|
||||
),
|
||||
excluded_fields: excludedFields,
|
||||
truncated_fields: truncatedFields,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -327,7 +312,7 @@ const accessibilityStates = (
|
||||
: typeof raw === "number"
|
||||
? numberValue(raw)
|
||||
: typeof raw === "string"
|
||||
? raw.slice(0, 256)
|
||||
? raw
|
||||
: undefined;
|
||||
if (state !== undefined) states.set(name, state);
|
||||
}
|
||||
@@ -337,7 +322,7 @@ const accessibilityStates = (
|
||||
};
|
||||
|
||||
const axText = (value: unknown): string | null =>
|
||||
boundedText(recordValue(value)?.value, 1_024);
|
||||
stringValue(recordValue(value)?.value) ?? null;
|
||||
|
||||
const indexedString = (strings: readonly string[], index: unknown): string => {
|
||||
const integer = numberValue(index);
|
||||
@@ -433,28 +418,6 @@ const domDestination = (
|
||||
}
|
||||
};
|
||||
|
||||
const boundedUtf8 = (
|
||||
value: string,
|
||||
maximumBytes: number,
|
||||
): {
|
||||
readonly text: string;
|
||||
readonly bytes: number;
|
||||
readonly truncated: boolean;
|
||||
} => {
|
||||
const total = Buffer.byteLength(value);
|
||||
if (total <= maximumBytes)
|
||||
return { text: value, bytes: total, truncated: false };
|
||||
let text = "";
|
||||
let bytes = 0;
|
||||
for (const character of value) {
|
||||
const characterBytes = Buffer.byteLength(character);
|
||||
if (bytes + characterBytes > maximumBytes) break;
|
||||
text += character;
|
||||
bytes += characterBytes;
|
||||
}
|
||||
return { text, bytes, truncated: true };
|
||||
};
|
||||
|
||||
const domUrlAttributes = [
|
||||
"href",
|
||||
"src",
|
||||
|
||||
@@ -27,8 +27,8 @@ export const ingestResponseBodyShape = (
|
||||
invalidResponseBodyShape(state, requestId);
|
||||
return;
|
||||
}
|
||||
const inferred = inferBodyShape(state, decoded);
|
||||
updateResponseBodyShape(state, requestId, inferred.shape, inferred.truncated);
|
||||
const inferred = inferBodyShape(decoded);
|
||||
updateResponseBodyShape(state, requestId, inferred.shape);
|
||||
};
|
||||
|
||||
export const requestBodyShape = (
|
||||
@@ -40,13 +40,9 @@ export const requestBodyShape = (
|
||||
const body = stringValue(request.postData);
|
||||
if (!isJsonContentType(recordValue(request.headers)) || body === undefined)
|
||||
return { status: "unavailable", request: null, response: null };
|
||||
const inferred = inferBodyShape(state, body);
|
||||
const inferred = inferBodyShape(body);
|
||||
return {
|
||||
status: inferred.truncated
|
||||
? "truncated"
|
||||
: inferred.shape === null
|
||||
? "unavailable"
|
||||
: "included",
|
||||
status: inferred.shape === null ? "unavailable" : "included",
|
||||
request: inferred.shape,
|
||||
response: null,
|
||||
};
|
||||
@@ -56,48 +52,30 @@ export const updateResponseBodyShape = (
|
||||
state: CdpCaptureEventsState,
|
||||
requestId: string,
|
||||
response: JsonShape | null,
|
||||
truncated: boolean,
|
||||
): void => {
|
||||
const current = state.network.get(requestId);
|
||||
if (current === undefined) return;
|
||||
const request = current.body_shapes.request;
|
||||
const status =
|
||||
truncated || current.body_shapes.status === "truncated"
|
||||
? "truncated"
|
||||
: response !== null
|
||||
? "included"
|
||||
: request !== null
|
||||
? "partial"
|
||||
: "unavailable";
|
||||
response !== null
|
||||
? "included"
|
||||
: request !== null
|
||||
? "partial"
|
||||
: "unavailable";
|
||||
state.network.set(requestId, {
|
||||
...current,
|
||||
body_shapes: { status, request, response },
|
||||
});
|
||||
};
|
||||
|
||||
const inferBodyShape = (
|
||||
state: CdpCaptureEventsState,
|
||||
text: string,
|
||||
): { readonly shape: JsonShape | null; readonly truncated: boolean } => {
|
||||
const bytes = Buffer.byteLength(text);
|
||||
if (bytes > state.input.limits.max_json_body_bytes) {
|
||||
state.completeness.truncate("json_body_shapes");
|
||||
return { shape: null, truncated: true };
|
||||
}
|
||||
const shape = inferJsonShape(text, {
|
||||
maximumBytes: state.input.limits.max_json_body_bytes,
|
||||
maximumNodes: state.input.limits.max_json_shape_nodes,
|
||||
maximumDepth: state.input.limits.max_json_shape_depth,
|
||||
});
|
||||
if (shape?.truncated === true)
|
||||
state.completeness.truncate("json_body_shapes");
|
||||
return { shape, truncated: shape?.truncated === true };
|
||||
const inferBodyShape = (text: string): { readonly shape: JsonShape | null } => {
|
||||
return { shape: inferJsonShape(text) };
|
||||
};
|
||||
|
||||
const invalidResponseBodyShape = (
|
||||
state: CdpCaptureEventsState,
|
||||
requestId: string,
|
||||
): void => {
|
||||
updateResponseBodyShape(state, requestId, null, false);
|
||||
updateResponseBodyShape(state, requestId, null);
|
||||
state.completeness.exclude("json_body_shapes", "invalid_protocol_value");
|
||||
};
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { WebPageInspection } from "../domain/browserObservation.js";
|
||||
import { inferJsonShape } from "../domain/jsonShape.js";
|
||||
import { safeResponseMetadata } from "./CdpSafeMetadata.js";
|
||||
import { boundedSensitiveText } from "./SensitiveTextCapture.js";
|
||||
import { redactSensitiveText } from "./SensitiveTextCapture.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
boundedText,
|
||||
isHttpUrl,
|
||||
numberValue,
|
||||
recordValue,
|
||||
@@ -35,8 +34,7 @@ export const handleExecutionContextCreated = (
|
||||
if (
|
||||
identifier === undefined ||
|
||||
!Number.isSafeInteger(identifier) ||
|
||||
frameId === undefined ||
|
||||
frameId.length > 256
|
||||
frameId === undefined
|
||||
)
|
||||
return;
|
||||
const key = String(identifier);
|
||||
@@ -58,7 +56,7 @@ export const handleScriptParsed = (
|
||||
const scriptId = stringValue(params.scriptId);
|
||||
const rawUrl = stringValue(params.url) ?? "";
|
||||
const sanitized = allowedSanitizedUrl(rawUrl, state.allowedOrigins);
|
||||
if (scriptId === undefined || scriptId.length > 256) {
|
||||
if (scriptId === undefined) {
|
||||
state.completeness.exclude("scripts", "invalid_protocol_value");
|
||||
return;
|
||||
}
|
||||
@@ -72,7 +70,7 @@ export const handleScriptParsed = (
|
||||
rawUrl,
|
||||
url: sanitized.url,
|
||||
origin: sanitized.origin,
|
||||
hash: (stringValue(params.hash) ?? "").slice(0, 512),
|
||||
hash: stringValue(params.hash) ?? "",
|
||||
length: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
@@ -81,7 +79,7 @@ export const handleScriptParsed = (
|
||||
),
|
||||
),
|
||||
isModule: params.isModule === true,
|
||||
language: boundedText(params.scriptLanguage, 100),
|
||||
language: stringValue(params.scriptLanguage) ?? null,
|
||||
sourceMapUrl: sourceMap.sanitized,
|
||||
sourceMapRawUrl: sourceMap.raw,
|
||||
executionContextKey: executionContextKey(params.executionContextId),
|
||||
@@ -116,7 +114,7 @@ export const handleRequestWillBeSent = (
|
||||
): void => {
|
||||
const requestId = stringValue(params.requestId);
|
||||
const request = recordValue(params.request);
|
||||
if (requestId === undefined || requestId.length > 256) {
|
||||
if (requestId === undefined) {
|
||||
state.completeness.exclude("network_requests", "invalid_protocol_value");
|
||||
return;
|
||||
}
|
||||
@@ -150,13 +148,13 @@ export const handleRequestWillBeSent = (
|
||||
request_id: requestId,
|
||||
url: sanitized.url,
|
||||
origin: sanitized.origin ?? "",
|
||||
method: (stringValue(request.method) ?? "GET").slice(0, 32),
|
||||
resource_type: boundedText(params.type, 100),
|
||||
method: stringValue(request.method) ?? "GET",
|
||||
resource_type: stringValue(params.type) ?? null,
|
||||
status: null,
|
||||
mime_type: null,
|
||||
encoded_data_length: null,
|
||||
initiator: {
|
||||
type: (stringValue(initiator?.type) ?? "other").slice(0, 100),
|
||||
type: stringValue(initiator?.type) ?? "other",
|
||||
url: initiatorUrl?.url ?? null,
|
||||
line: integerOrNull(initiatorFrame?.lineNumber),
|
||||
column: integerOrNull(initiatorFrame?.columnNumber),
|
||||
@@ -170,7 +168,7 @@ export const handleResponseReceived = (
|
||||
params: UnknownRecord,
|
||||
): void => {
|
||||
const requestId = stringValue(params.requestId);
|
||||
if (requestId === undefined || requestId.length > 256) {
|
||||
if (requestId === undefined) {
|
||||
state.completeness.exclude("network_requests", "invalid_protocol_value");
|
||||
return;
|
||||
}
|
||||
@@ -191,7 +189,7 @@ export const handleResponseReceived = (
|
||||
state.network.set(requestId, {
|
||||
...current,
|
||||
status: numberValue(response.status) ?? null,
|
||||
mime_type: boundedText(response.mimeType, 256),
|
||||
mime_type: stringValue(response.mimeType) ?? null,
|
||||
});
|
||||
const metadata = safeResponseMetadata(
|
||||
requestId,
|
||||
@@ -208,7 +206,7 @@ export const handleLoadingFinished = (
|
||||
params: UnknownRecord,
|
||||
): void => {
|
||||
const requestId = stringValue(params.requestId);
|
||||
if (requestId === undefined || requestId.length > 256) return;
|
||||
if (requestId === undefined) return;
|
||||
const current = state.network.get(requestId);
|
||||
if (current === undefined) return;
|
||||
state.network.set(requestId, {
|
||||
@@ -239,10 +237,10 @@ export const handleConsoleAPICalled = (
|
||||
}
|
||||
const arguments_ = recordsValue(params.args);
|
||||
state.console.push({
|
||||
type: (stringValue(params.type) ?? "unknown").slice(0, 100),
|
||||
type: stringValue(params.type) ?? "unknown",
|
||||
timestamp: numberValue(params.timestamp) ?? 0,
|
||||
argument_types: arguments_.map((argument) =>
|
||||
(stringValue(argument.type) ?? "unknown").slice(0, 100),
|
||||
argument_types: arguments_.map(
|
||||
(argument) => stringValue(argument.type) ?? "unknown",
|
||||
),
|
||||
url: source.url,
|
||||
line: integerOrNull(frame.lineNumber),
|
||||
@@ -260,33 +258,25 @@ const captureConsoleText = (
|
||||
status: "not_approved",
|
||||
values: [],
|
||||
retained_bytes: 0,
|
||||
truncated_values: 0,
|
||||
};
|
||||
const values: WebPageInspection["console"]["events"][number]["text_capture"]["values"] =
|
||||
[];
|
||||
let retainedBytes = 0;
|
||||
let truncatedValues = 0;
|
||||
for (const [argumentIndex, argument] of arguments_.entries()) {
|
||||
const primitive = consolePrimitive(argument);
|
||||
if (primitive === undefined) continue;
|
||||
const bounded = boundedSensitiveText(
|
||||
primitive.text,
|
||||
state.input.limits.max_console_text_field_bytes,
|
||||
);
|
||||
const text = redactSensitiveText(primitive.text);
|
||||
values.push({
|
||||
argument_index: argumentIndex,
|
||||
type: primitive.type,
|
||||
text: bounded.text,
|
||||
text,
|
||||
});
|
||||
retainedBytes += bounded.bytes;
|
||||
if (bounded.truncated) truncatedValues += 1;
|
||||
retainedBytes += Buffer.byteLength(text);
|
||||
}
|
||||
if (truncatedValues > 0) state.completeness.truncate("console_text");
|
||||
return {
|
||||
status: truncatedValues > 0 ? "truncated" : "included",
|
||||
status: "included",
|
||||
values,
|
||||
retained_bytes: retainedBytes,
|
||||
truncated_values: truncatedValues,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -298,7 +288,6 @@ export const handleWebSocketFrame = (
|
||||
const requestId = stringValue(params.requestId);
|
||||
if (
|
||||
requestId === undefined ||
|
||||
requestId.length > 256 ||
|
||||
(!state.network.has(requestId) && !state.allowedWebSockets.has(requestId))
|
||||
)
|
||||
return;
|
||||
@@ -324,24 +313,11 @@ const captureWebSocketShape = (
|
||||
opcode: number,
|
||||
): WebPageInspection["network"]["websocket_events"][number]["payload_shape"] => {
|
||||
if (!state.input.include_websocket_shapes) return null;
|
||||
if (opcode !== 1)
|
||||
return { format: "binary", json_shape: null, truncated: false };
|
||||
const bytes = Buffer.byteLength(payload);
|
||||
if (bytes > state.input.limits.max_websocket_shape_bytes) {
|
||||
state.completeness.truncate("websocket_shapes");
|
||||
return { format: "text", json_shape: null, truncated: true };
|
||||
}
|
||||
const shape = inferJsonShape(payload, {
|
||||
maximumBytes: state.input.limits.max_websocket_shape_bytes,
|
||||
maximumNodes: state.input.limits.max_json_shape_nodes,
|
||||
maximumDepth: state.input.limits.max_json_shape_depth,
|
||||
});
|
||||
if (shape?.truncated === true)
|
||||
state.completeness.truncate("websocket_shapes");
|
||||
if (opcode !== 1) return { format: "binary", json_shape: null };
|
||||
const shape = inferJsonShape(payload);
|
||||
return {
|
||||
format: shape === null ? "text" : "json",
|
||||
json_shape: shape,
|
||||
truncated: shape?.truncated === true,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -351,7 +327,7 @@ export const handleWebSocketCreated = (
|
||||
): void => {
|
||||
const requestId = stringValue(params.requestId);
|
||||
const rawUrl = stringValue(params.url);
|
||||
if (requestId === undefined || requestId.length > 256) {
|
||||
if (requestId === undefined) {
|
||||
state.completeness.exclude(
|
||||
"websocket_connections",
|
||||
"invalid_protocol_value",
|
||||
|
||||
@@ -131,7 +131,7 @@ export class CdpCaptureEvents implements CdpCaptureEventsState {
|
||||
}
|
||||
|
||||
responseBodyUnavailable(requestId: string): void {
|
||||
bodyShapes.updateResponseBodyShape(this, requestId, null, false);
|
||||
bodyShapes.updateResponseBodyShape(this, requestId, null);
|
||||
this.completeness.unavailable("json_body_shapes");
|
||||
}
|
||||
|
||||
|
||||
@@ -85,10 +85,10 @@ export const captureStorage = async (
|
||||
origin,
|
||||
usage_bytes: numberValue(quota?.usage) ?? null,
|
||||
quota_bytes: numberValue(quota?.quota) ?? null,
|
||||
local_storage_keys: local.items.map(({ key }) => key.slice(0, 1_024)),
|
||||
session_storage_keys: session.items.map(({ key }) => key.slice(0, 1_024)),
|
||||
indexed_db_names: indexed.map((name) => name.slice(0, 1_024)),
|
||||
cache_names: caches.map(({ name }) => name.slice(0, 1_024)),
|
||||
local_storage_keys: local.items.map(({ key }) => key),
|
||||
session_storage_keys: session.items.map(({ key }) => key),
|
||||
indexed_db_names: [...indexed],
|
||||
cache_names: caches.map(({ name }) => name),
|
||||
content_fingerprints: [...fingerprints.items],
|
||||
fingerprint_algorithm: "sha256",
|
||||
fingerprints_complete: fingerprints.complete && structuredStorageComplete,
|
||||
|
||||
@@ -10,8 +10,6 @@ import { CdpConnection } from "./CdpConnection.js";
|
||||
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
|
||||
import { recordValue, recordsValue, stringValue } from "./CdpCaptureValues.js";
|
||||
|
||||
const MAX_CACHE_BODY_BYTES = 64 * 1_024;
|
||||
|
||||
type StorageFingerprint =
|
||||
WebPageInspection["storage"]["content_fingerprints"][number];
|
||||
|
||||
@@ -383,8 +381,7 @@ const addCacheEntry = async (
|
||||
);
|
||||
const body = stringValue(recordValue(recordValue(raw)?.response)?.body);
|
||||
const decoded = body === undefined ? null : decodeBase64(body);
|
||||
const complete =
|
||||
decoded !== null && decoded.byteLength <= MAX_CACHE_BODY_BYTES;
|
||||
const complete = decoded !== null;
|
||||
if (!complete) run.state.complete = false;
|
||||
addFingerprint(run.state, {
|
||||
scope: "cache_entry",
|
||||
@@ -426,7 +423,7 @@ const encodedValue = (value: unknown): Uint8Array | string => {
|
||||
return encoded;
|
||||
};
|
||||
|
||||
const stableValue = (value: unknown, depth = 0): unknown => {
|
||||
const stableValue = (value: unknown): unknown => {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
@@ -434,21 +431,18 @@ const stableValue = (value: unknown, depth = 0): unknown => {
|
||||
typeof value === "boolean"
|
||||
)
|
||||
return value;
|
||||
if (depth >= 8) return "<depth-limit>";
|
||||
if (Array.isArray(value))
|
||||
return value.slice(0, 64).map((item) => stableValue(item, depth + 1));
|
||||
if (Array.isArray(value)) return value.map((item) => stableValue(item));
|
||||
const record = recordValue(value);
|
||||
if (record === undefined) return String(value);
|
||||
return Object.fromEntries(
|
||||
Object.entries(record)
|
||||
.filter(([key]) => key !== "objectId" && key !== "customPreview")
|
||||
.sort(([left], [right]) => compareCodePoints(left, right))
|
||||
.slice(0, 64)
|
||||
.map(([key, item]) => [key, stableValue(item, depth + 1)]),
|
||||
.map(([key, item]) => [key, stableValue(item)]),
|
||||
);
|
||||
};
|
||||
|
||||
const stableValueComplete = (value: unknown, depth = 0): boolean => {
|
||||
const stableValueComplete = (value: unknown): boolean => {
|
||||
if (value instanceof Uint8Array) return true;
|
||||
if (
|
||||
value === null ||
|
||||
@@ -458,21 +452,14 @@ const stableValueComplete = (value: unknown, depth = 0): boolean => {
|
||||
typeof value === "boolean"
|
||||
)
|
||||
return true;
|
||||
if (depth >= 8) return false;
|
||||
if (Array.isArray(value))
|
||||
return (
|
||||
value.length <= 64 &&
|
||||
value.every((item) => stableValueComplete(item, depth + 1))
|
||||
);
|
||||
return value.every((item) => stableValueComplete(item));
|
||||
const record = recordValue(value);
|
||||
if (record === undefined) return false;
|
||||
const entries = Object.entries(record).filter(
|
||||
([key]) => key !== "objectId" && key !== "customPreview",
|
||||
);
|
||||
return (
|
||||
entries.length <= 64 &&
|
||||
entries.every(([, item]) => stableValueComplete(item, depth + 1))
|
||||
);
|
||||
return entries.every(([, item]) => stableValueComplete(item));
|
||||
};
|
||||
|
||||
const remoteObjectComplete = (value: unknown): boolean => {
|
||||
|
||||
@@ -23,8 +23,6 @@ interface PendingCommand {
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 5_000;
|
||||
const COMMAND_TIMEOUT_MS = 5_000;
|
||||
const MAX_PAYLOAD_BYTES = 16 * 1_024 * 1_024;
|
||||
const MAX_PENDING_COMMANDS = 128;
|
||||
|
||||
/** Correlated, bounded JSON-RPC transport for one CDP target operation. */
|
||||
export class CdpConnection {
|
||||
@@ -53,7 +51,7 @@ export class CdpConnection {
|
||||
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
|
||||
const socket = new WebSocket(url, {
|
||||
handshakeTimeout: CONNECT_TIMEOUT_MS,
|
||||
maxPayload: MAX_PAYLOAD_BYTES,
|
||||
maxPayload: 0,
|
||||
perMessageDeflate: false,
|
||||
});
|
||||
await waitForOpen(socket, operation, signal);
|
||||
@@ -87,8 +85,6 @@ export class CdpConnection {
|
||||
throw new BrowserObservationError(this.operation, "protocol_error");
|
||||
if (this.#closed || this.socket.readyState !== WebSocket.OPEN)
|
||||
throw new BrowserObservationError(this.operation, "disconnected");
|
||||
if (this.#pending.size >= MAX_PENDING_COMMANDS)
|
||||
throw new BrowserObservationError(this.operation, "payload_limit");
|
||||
if (signal?.aborted === true)
|
||||
throw new AnalysisCancelledError(this.operation);
|
||||
const id = this.#nextId;
|
||||
|
||||
@@ -117,7 +117,7 @@ const runElectronInspection = async (
|
||||
target: {
|
||||
target_id: context.target.id,
|
||||
type: context.target.type,
|
||||
title: context.target.title.slice(0, 16_384),
|
||||
title: context.target.title,
|
||||
file_path: main.file_path,
|
||||
attached: context.target.attached,
|
||||
},
|
||||
@@ -267,12 +267,12 @@ const captureFrames = async (
|
||||
continue;
|
||||
}
|
||||
frames.push({
|
||||
frame_id: frameId.slice(0, 256),
|
||||
frame_id: frameId,
|
||||
parent_frame_id: current.parent,
|
||||
file_path: path,
|
||||
});
|
||||
for (const child of recordsValue(current.tree.childFrames))
|
||||
pending.push({ tree: child, parent: frameId.slice(0, 256) });
|
||||
pending.push({ tree: child, parent: frameId });
|
||||
}
|
||||
return frames;
|
||||
};
|
||||
@@ -308,8 +308,8 @@ const captureResources = async (
|
||||
}
|
||||
const item = {
|
||||
file_path: path,
|
||||
type: (stringValue(resource.type) ?? "Other").slice(0, 100),
|
||||
mime_type: (stringValue(resource.mimeType) ?? "").slice(0, 256),
|
||||
type: stringValue(resource.type) ?? "Other",
|
||||
mime_type: stringValue(resource.mimeType) ?? "",
|
||||
content_size:
|
||||
numberValue(resource.contentSize) === undefined
|
||||
? null
|
||||
@@ -369,13 +369,13 @@ const captureDom = async (
|
||||
index: nodes.length,
|
||||
parent_index: parent < 0 ? -1 : baseIndex + parent,
|
||||
node_type: integer(nodeTypes[index], 0),
|
||||
node_name: stringAt(strings, nodeNames[index]).slice(0, 1_024),
|
||||
node_name: stringAt(strings, nodeNames[index]),
|
||||
node_value_length: Buffer.byteLength(
|
||||
stringAt(strings, nodeValues[index]),
|
||||
),
|
||||
attribute_names: attributeIndexes
|
||||
.filter((_value, attributeIndex) => attributeIndex % 2 === 0)
|
||||
.map((value) => stringAt(strings, value).slice(0, 1_024)),
|
||||
.map((value) => stringAt(strings, value)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
||||
allowed.push({
|
||||
target_id: target.id,
|
||||
type: target.type,
|
||||
title: target.title.slice(0, 16_384),
|
||||
title: target.title,
|
||||
file_path: path,
|
||||
attached: target.attached,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { CdpEvent } from "./CdpConnection.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import {
|
||||
boundedText,
|
||||
numberValue,
|
||||
recordValue,
|
||||
stringValue,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import { numberValue, recordValue, stringValue } from "./CdpCaptureValues.js";
|
||||
|
||||
export interface ElectronScriptDraft {
|
||||
readonly scriptId: string;
|
||||
@@ -17,7 +12,7 @@ export interface ElectronScriptDraft {
|
||||
readonly executionContextKey: string | null;
|
||||
}
|
||||
|
||||
/** Retain only bounded script/frame metadata from passive Electron events. */
|
||||
/** Retain script/frame metadata from passive Electron events. */
|
||||
export const ingestElectronScriptEvent = (input: {
|
||||
readonly event: CdpEvent;
|
||||
readonly scripts: ElectronScriptDraft[];
|
||||
@@ -35,18 +30,14 @@ export const ingestElectronScriptEvent = (input: {
|
||||
return;
|
||||
}
|
||||
if (input.event.method === "Runtime.executionContextCreated") {
|
||||
retainExecutionContext(
|
||||
input.event,
|
||||
input.executionContextFrames,
|
||||
input.completeness,
|
||||
);
|
||||
retainExecutionContext(input.event, input.executionContextFrames);
|
||||
return;
|
||||
}
|
||||
if (input.event.method !== "Debugger.scriptParsed") return;
|
||||
const value = recordValue(input.event.params);
|
||||
const scriptId = stringValue(value?.scriptId);
|
||||
const rawUrl = stringValue(value?.url);
|
||||
if (scriptId === undefined || scriptId.length > 256) {
|
||||
if (scriptId === undefined) {
|
||||
input.completeness.exclude("scripts", "invalid_protocol_value");
|
||||
return;
|
||||
}
|
||||
@@ -54,17 +45,13 @@ export const ingestElectronScriptEvent = (input: {
|
||||
input.completeness.exclude("scripts", "unattributed_origin");
|
||||
return;
|
||||
}
|
||||
if (input.scripts.length >= 2_000) {
|
||||
input.completeness.drop("scripts");
|
||||
return;
|
||||
}
|
||||
input.scripts.push({
|
||||
scriptId,
|
||||
rawUrl,
|
||||
hash: (stringValue(value?.hash) ?? "").slice(0, 512),
|
||||
hash: stringValue(value?.hash) ?? "",
|
||||
length: nonnegativeInteger(value?.length),
|
||||
isModule: value?.isModule === true,
|
||||
language: boundedText(value?.scriptLanguage, 100),
|
||||
language: stringValue(value?.scriptLanguage) ?? null,
|
||||
executionContextKey: executionContextKey(value?.executionContextId),
|
||||
});
|
||||
};
|
||||
@@ -72,18 +59,12 @@ export const ingestElectronScriptEvent = (input: {
|
||||
const retainExecutionContext = (
|
||||
event: CdpEvent,
|
||||
frames: Map<string, string>,
|
||||
completeness: CdpCaptureCompleteness,
|
||||
): void => {
|
||||
const parameters = recordValue(event.params);
|
||||
const runtimeContext = recordValue(parameters?.context);
|
||||
const contextKey = executionContextKey(runtimeContext?.id);
|
||||
const frameId = stringValue(recordValue(runtimeContext?.auxData)?.frameId);
|
||||
if (contextKey === null || frameId === undefined || frameId.length > 256)
|
||||
return;
|
||||
if (frames.size >= 2_000 && !frames.has(contextKey)) {
|
||||
completeness.truncate("scripts");
|
||||
return;
|
||||
}
|
||||
if (contextKey === null || frameId === undefined) return;
|
||||
frames.set(contextKey, frameId);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
ElectronPageInspection,
|
||||
InspectElectronPageInput,
|
||||
} from "../domain/electronObservation.js";
|
||||
import { MAX_ELECTRON_SCRIPT_SOURCE_BYTES } from "../domain/electronObservation.js";
|
||||
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
||||
import type { CdpConnection } from "./CdpConnection.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
@@ -91,8 +90,6 @@ const captureScriptSource = async (
|
||||
reason: "source capture was not approved",
|
||||
},
|
||||
};
|
||||
if (script.length > MAX_ELECTRON_SCRIPT_SOURCE_BYTES)
|
||||
return sourceLimitResult(input.completeness);
|
||||
const result = requiredRecord(
|
||||
await input.connection.send(
|
||||
"Debugger.getScriptSource",
|
||||
@@ -102,9 +99,6 @@ const captureScriptSource = async (
|
||||
),
|
||||
);
|
||||
const text = stringValue(result.scriptSource) ?? "";
|
||||
const bytes = Buffer.byteLength(text);
|
||||
if (bytes > MAX_ELECTRON_SCRIPT_SOURCE_BYTES)
|
||||
return sourceLimitResult(input.completeness);
|
||||
return {
|
||||
source: {
|
||||
included: true,
|
||||
@@ -113,15 +107,6 @@ const captureScriptSource = async (
|
||||
};
|
||||
};
|
||||
|
||||
const sourceLimitResult = (
|
||||
completeness: CdpCaptureCompleteness,
|
||||
): { readonly source: ElectronScriptSource } => {
|
||||
completeness.truncate("script_sources");
|
||||
return {
|
||||
source: { included: false, reason: "script source byte limit reached" },
|
||||
};
|
||||
};
|
||||
|
||||
const digest = (value: unknown): string => {
|
||||
const encoded = canonicalize(value);
|
||||
if (encoded === undefined) throw new TypeError("Expected canonical JSON");
|
||||
|
||||
@@ -4,12 +4,7 @@ import type {
|
||||
} from "../domain/electronObservation.js";
|
||||
import type { CdpConnection } from "./CdpConnection.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import {
|
||||
boundedText,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
stringValue,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import { recordValue, recordsValue, stringValue } from "./CdpCaptureValues.js";
|
||||
import type { CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
@@ -67,7 +62,7 @@ export const captureElectronWorkers = async (input: {
|
||||
}
|
||||
workers.push({
|
||||
target_id: targetId,
|
||||
type: type.slice(0, 100),
|
||||
type,
|
||||
file_path: path,
|
||||
attached: target.attached === true,
|
||||
opener_target_id: openerTargetId,
|
||||
@@ -80,6 +75,6 @@ export const captureElectronWorkers = async (input: {
|
||||
};
|
||||
|
||||
const boundedTargetField = (value: unknown): string | null => {
|
||||
const field = boundedText(value, 256);
|
||||
return field === null || field === "" ? null : field;
|
||||
const field = stringValue(value);
|
||||
return field === undefined || field === "" ? null : field;
|
||||
};
|
||||
+24
-44
@@ -12,25 +12,23 @@ import {
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
|
||||
const HTTP_TIMEOUT_MS = 5_000;
|
||||
const MAX_VERSION_BYTES = 64 * 1_024;
|
||||
const MAX_TARGET_LIST_BYTES = 2 * 1_024 * 1_024;
|
||||
|
||||
const endpointVersionSchema = z.object({
|
||||
Browser: z.string().min(1).max(1_024),
|
||||
"Protocol-Version": z.string().min(1).max(100),
|
||||
"User-Agent": z.string().max(16_384),
|
||||
"V8-Version": z.string().max(1_024),
|
||||
"WebKit-Version": z.string().max(1_024),
|
||||
webSocketDebuggerUrl: z.string().min(1).max(2_048),
|
||||
Browser: z.string().min(1),
|
||||
"Protocol-Version": z.string().min(1),
|
||||
"User-Agent": z.string(),
|
||||
"V8-Version": z.string(),
|
||||
"WebKit-Version": z.string(),
|
||||
webSocketDebuggerUrl: z.string().min(1),
|
||||
});
|
||||
|
||||
const endpointTargetSchema = z.object({
|
||||
id: z.string().min(1).max(256),
|
||||
type: z.string().min(1).max(100),
|
||||
title: z.string().max(16_384).default(""),
|
||||
url: z.string().max(65_536),
|
||||
id: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
title: z.string().default(""),
|
||||
url: z.string(),
|
||||
attached: z.boolean().default(false),
|
||||
webSocketDebuggerUrl: z.string().min(1).max(2_048).optional(),
|
||||
webSocketDebuggerUrl: z.string().min(1).optional(),
|
||||
});
|
||||
const endpointTargetsSchema = z.array(endpointTargetSchema);
|
||||
|
||||
@@ -68,21 +66,19 @@ export interface CdpEndpointDiscovery {
|
||||
readonly targets: readonly CdpEndpointTarget[];
|
||||
}
|
||||
|
||||
/** Read bounded CDP discovery endpoints without following redirects. */
|
||||
/** Read CDP discovery endpoints without following redirects. */
|
||||
export const discoverCdpEndpoint = async (
|
||||
endpoint: string,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CdpEndpointDiscovery> => {
|
||||
const versionInput = await readBoundedCdpJson(
|
||||
const versionInput = await readCdpJson(
|
||||
new URL("/json/version", endpoint),
|
||||
MAX_VERSION_BYTES,
|
||||
operation,
|
||||
signal,
|
||||
);
|
||||
const targetsInput = await readBoundedCdpJson(
|
||||
const targetsInput = await readCdpJson(
|
||||
new URL("/json/list", endpoint),
|
||||
MAX_TARGET_LIST_BYTES,
|
||||
operation,
|
||||
signal,
|
||||
);
|
||||
@@ -128,7 +124,7 @@ const sanitizeTargetTitle = (value: string, targetUrl: string): string => {
|
||||
return sanitizeTargetUrlAlias(value, targetUrl);
|
||||
}
|
||||
return url.protocol === "http:" || url.protocol === "https:"
|
||||
? sanitizeBrowserUrl(value).url.slice(0, 16_384)
|
||||
? sanitizeBrowserUrl(value).url
|
||||
: sanitizeTargetUrlAlias(value, targetUrl);
|
||||
};
|
||||
|
||||
@@ -145,11 +141,9 @@ const sanitizeTargetUrlAlias = (value: string, targetUrl: string): string => {
|
||||
`(?:${markers.map(escapeRegExp).join("|")})[^\\s<>"']*`,
|
||||
"gu",
|
||||
);
|
||||
return value
|
||||
.replace(candidatePattern, (candidate) =>
|
||||
sanitizeSameOriginTitleUrl(candidate, parsed),
|
||||
)
|
||||
.slice(0, 16_384);
|
||||
return value.replace(candidatePattern, (candidate) =>
|
||||
sanitizeSameOriginTitleUrl(candidate, parsed),
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeSameOriginTitleUrl = (candidate: string, target: URL): string => {
|
||||
@@ -250,22 +244,19 @@ const cdpWebSocketPath = (
|
||||
| { readonly scope: "browser" }
|
||||
| { readonly scope: "page"; targetId: string }
|
||||
| undefined => {
|
||||
const browserId = pathIdentifier(pathname, "/devtools/browser/", 1_024);
|
||||
const browserId = pathIdentifier(pathname, "/devtools/browser/");
|
||||
if (browserId !== undefined) return { scope: "browser" };
|
||||
const targetId = pathIdentifier(pathname, "/devtools/page/", 256);
|
||||
const targetId = pathIdentifier(pathname, "/devtools/page/");
|
||||
return targetId === undefined ? undefined : { scope: "page", targetId };
|
||||
};
|
||||
|
||||
const pathIdentifier = (
|
||||
pathname: string,
|
||||
prefix: string,
|
||||
maximumLength: number,
|
||||
): string | undefined => {
|
||||
if (!pathname.startsWith(prefix)) return undefined;
|
||||
const identifier = pathname.slice(prefix.length);
|
||||
return identifier.length > 0 &&
|
||||
identifier.length <= maximumLength &&
|
||||
!identifier.includes("/")
|
||||
return identifier.length > 0 && !identifier.includes("/")
|
||||
? identifier
|
||||
: undefined;
|
||||
};
|
||||
@@ -290,10 +281,9 @@ const targetWebSocket = (
|
||||
}
|
||||
};
|
||||
|
||||
/** Read one bounded loopback CDP discovery document without redirects. */
|
||||
export const readBoundedCdpJson = async (
|
||||
/** Read one loopback CDP discovery document without redirects. */
|
||||
export const readCdpJson = async (
|
||||
url: URL,
|
||||
maximumBytes: number,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> =>
|
||||
@@ -314,17 +304,7 @@ export const readBoundedCdpJson = async (
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
response.on("data", (chunk: Buffer) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > maximumBytes) {
|
||||
response.destroy(
|
||||
new BrowserObservationError(operation, "payload_limit"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
response.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
response.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
||||
|
||||
@@ -52,12 +52,7 @@ export const observeCdpSession = async (
|
||||
const mainFrameId = frameId(initial);
|
||||
if (mainFrameId === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
const capture = new TimelineCapture(
|
||||
context.input.max_timeline_events,
|
||||
allowedOrigins,
|
||||
mainFrameId,
|
||||
initialUrl,
|
||||
);
|
||||
const capture = new TimelineCapture(allowedOrigins, mainFrameId, initialUrl);
|
||||
const removeListener = context.connection.onEvent((event) => {
|
||||
if (observationEventMatches(event, context.sessionId))
|
||||
capture.ingest(event);
|
||||
@@ -74,7 +69,7 @@ export const observeCdpSession = async (
|
||||
);
|
||||
await context.connection.send(
|
||||
"Network.enable",
|
||||
{ maxTotalBufferSize: 1_024 * 1_024 },
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
@@ -143,7 +138,6 @@ class TimelineCapture {
|
||||
readonly #listeners = new Set<(reason: EndReason) => void>();
|
||||
|
||||
constructor(
|
||||
private readonly maximum: number,
|
||||
private readonly allowedOrigins: ReadonlySet<string>,
|
||||
private readonly mainFrameId: string,
|
||||
initialUrl: string,
|
||||
@@ -320,10 +314,6 @@ class TimelineCapture {
|
||||
} = options;
|
||||
const destination =
|
||||
rawUrl == null ? null : scopedUrl(rawUrl, this.allowedOrigins);
|
||||
if (this.timeline.length >= this.maximum) {
|
||||
this.completeness.drop("timeline_events");
|
||||
return;
|
||||
}
|
||||
this.#sequence += 1;
|
||||
this.timeline.push({
|
||||
sequence: this.#sequence,
|
||||
@@ -331,7 +321,7 @@ class TimelineCapture {
|
||||
timestamp: Math.max(0, numberValue(params.timestamp) ?? 0),
|
||||
frame_id: boundedId(params.frameId),
|
||||
loader_id: boundedId(params.loaderId),
|
||||
request_id: requestId?.slice(0, 256) ?? null,
|
||||
request_id: requestId ?? null,
|
||||
url: destination?.scope === "approved" ? destination.url : null,
|
||||
destination_scope: destination?.scope ?? null,
|
||||
detail,
|
||||
@@ -449,8 +439,7 @@ const scopedUrl = (
|
||||
: { url: null, scope: "unsupported" };
|
||||
};
|
||||
|
||||
const boundedId = (value: unknown): string | null =>
|
||||
stringValue(value)?.slice(0, 256) ?? null;
|
||||
const boundedId = (value: unknown): string | null => stringValue(value) ?? null;
|
||||
|
||||
const safeDetail = (value: unknown): string | null => {
|
||||
const detail = stringValue(value)?.toLowerCase();
|
||||
|
||||
@@ -319,21 +319,8 @@ const enableObservationDomains = async (
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
await connection.send("Runtime.enable", {}, sessionId, signal);
|
||||
await connection.send(
|
||||
"Debugger.enable",
|
||||
{ maxScriptsCacheSize: 4 * 1_024 * 1_024 },
|
||||
sessionId,
|
||||
signal,
|
||||
);
|
||||
await connection.send(
|
||||
"Network.enable",
|
||||
{
|
||||
maxTotalBufferSize: 4 * 1_024 * 1_024,
|
||||
maxResourceBufferSize: 1_024 * 1_024,
|
||||
},
|
||||
sessionId,
|
||||
signal,
|
||||
);
|
||||
await connection.send("Debugger.enable", {}, sessionId, signal);
|
||||
await connection.send("Network.enable", {}, sessionId, signal);
|
||||
};
|
||||
|
||||
const accessibilityForFrames = async (
|
||||
@@ -347,7 +334,7 @@ const accessibilityForFrames = async (
|
||||
const result = await optionalCdpCommand(
|
||||
context,
|
||||
"Accessibility.getFullAXTree",
|
||||
{ depth: 32, frameId: frame.frame_id },
|
||||
{ frameId: frame.frame_id },
|
||||
limitations,
|
||||
);
|
||||
if (result === undefined) unavailable = true;
|
||||
@@ -356,7 +343,6 @@ const accessibilityForFrames = async (
|
||||
if (unavailable) events.completeness.unavailable("accessibility");
|
||||
const capture = captureAccessibility(results, {
|
||||
includeText: context.input.include_accessibility_text,
|
||||
maximumFieldBytes: context.input.limits.max_ax_text_field_bytes,
|
||||
...(results.length === 0 && unavailable ? { unavailable: true } : {}),
|
||||
});
|
||||
if (!context.input.include_accessibility_text)
|
||||
@@ -365,8 +351,6 @@ const accessibilityForFrames = async (
|
||||
"not_approved",
|
||||
capture.textCapture.excluded_fields,
|
||||
);
|
||||
if (capture.textCapture.status === "truncated")
|
||||
events.completeness.truncate("accessibility");
|
||||
if (capture.treeIncomplete) events.completeness.truncate("accessibility");
|
||||
return capture;
|
||||
};
|
||||
@@ -395,7 +379,7 @@ const normalizedTarget = (
|
||||
return {
|
||||
target_id: target.id,
|
||||
type: target.type,
|
||||
title: target.title.slice(0, 16_384),
|
||||
title: target.title,
|
||||
url: url?.url ?? "[unsupported-url]",
|
||||
origin: url?.origin ?? "",
|
||||
attached: target.attached,
|
||||
|
||||
@@ -106,11 +106,7 @@ const captureScriptSource = async (
|
||||
script: CapturedScript,
|
||||
state: ScriptDraftState,
|
||||
): Promise<WebPageInspection["scripts"]["items"][number]["source"]> => {
|
||||
const { context, events } = state;
|
||||
if (script.length > context.input.limits.max_script_source_bytes) {
|
||||
events.completeness.truncate("script_sources");
|
||||
return sourceExcluded("declared script length exceeds per-script limit");
|
||||
}
|
||||
const { context } = state;
|
||||
const result = requiredRecord(
|
||||
await context.connection.send(
|
||||
"Debugger.getScriptSource",
|
||||
@@ -120,11 +116,6 @@ const captureScriptSource = async (
|
||||
),
|
||||
);
|
||||
const content = stringValue(result.scriptSource) ?? "";
|
||||
const bytes = Buffer.byteLength(content);
|
||||
if (bytes > context.input.limits.max_script_source_bytes) {
|
||||
events.completeness.truncate("script_sources");
|
||||
return sourceExcluded("actual source exceeds the per-script byte limit");
|
||||
}
|
||||
return sourceResult(content);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { WebPageInspection } from "../domain/browserObservation.js";
|
||||
import type { CdpCaptureEvents } from "./CdpCaptureEvents.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
boundedText,
|
||||
isHttpUrl,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
@@ -57,13 +56,13 @@ export const captureWorkers = async (
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
target_id: (stringValue(target.targetId) ?? "").slice(0, 256),
|
||||
type: type.slice(0, 100),
|
||||
target_id: stringValue(target.targetId) ?? "",
|
||||
type,
|
||||
url: url.url,
|
||||
origin: url.origin,
|
||||
attached: target.attached === true,
|
||||
opener_target_id: boundedText(target.openerId, 256) ?? null,
|
||||
parent_frame_id: boundedText(target.parentFrameId, 256) ?? null,
|
||||
opener_target_id: stringValue(target.openerId) ?? null,
|
||||
parent_frame_id: stringValue(target.parentFrameId) ?? null,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
|
||||
@@ -68,15 +68,15 @@ const normalizedHeaders = (value: unknown): ReadonlyMap<string, string> => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return new Map();
|
||||
const headers = new Map<string, string>();
|
||||
for (const [name, raw] of Object.entries(value).slice(0, 500)) {
|
||||
const normalized = name.toLowerCase().slice(0, 256);
|
||||
for (const [name, raw] of Object.entries(value)) {
|
||||
const normalized = name.toLowerCase();
|
||||
const header =
|
||||
typeof raw === "string"
|
||||
? raw
|
||||
: typeof raw === "number" && Number.isFinite(raw)
|
||||
? String(raw)
|
||||
: undefined;
|
||||
if (header !== undefined) headers.set(normalized, header.slice(0, 65_536));
|
||||
if (header !== undefined) headers.set(normalized, header);
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
@@ -89,13 +89,13 @@ const parseCsp = (
|
||||
const directives: ResponseMetadata["csp"]["directives"] = [];
|
||||
let nonceCount = 0;
|
||||
let hashCount = 0;
|
||||
for (const rawDirective of (value ?? "").split(";").slice(0, 100)) {
|
||||
for (const rawDirective of (value ?? "").split(";")) {
|
||||
const [rawName, ...tokens] = rawDirective.trim().split(/\s+/u);
|
||||
const name = (rawName ?? "").toLowerCase();
|
||||
if (!/^[a-z][a-z0-9-]{0,99}$/u.test(name)) continue;
|
||||
if (!/^[a-z][a-z0-9-]*$/u.test(name)) continue;
|
||||
const sources: ResponseMetadata["csp"]["directives"][number]["sources"] =
|
||||
[];
|
||||
for (const token of tokens.slice(0, 100)) {
|
||||
for (const token of tokens) {
|
||||
const lower = token.toLowerCase();
|
||||
if (lower.startsWith("'nonce-")) {
|
||||
nonceCount += 1;
|
||||
@@ -122,10 +122,9 @@ const cspSource = (
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): ResponseMetadata["csp"]["directives"][number]["sources"][number] => {
|
||||
const lower = token.toLowerCase();
|
||||
if (/^'[a-z0-9-]+'$/u.test(lower))
|
||||
return { kind: "keyword", value: lower.slice(0, 100) };
|
||||
if (/^'[a-z0-9-]+'$/u.test(lower)) return { kind: "keyword", value: lower };
|
||||
if (/^[a-z][a-z0-9+.-]*:$/u.test(lower))
|
||||
return { kind: "scheme", value: lower.slice(0, 100) };
|
||||
return { kind: "scheme", value: lower };
|
||||
try {
|
||||
const parsed = new URL(token, baseUrl);
|
||||
return allowedOrigins.has(parsed.origin)
|
||||
@@ -142,8 +141,8 @@ const parseLinks = (
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): LinkMetadata[] => {
|
||||
const links: LinkMetadata[] = [];
|
||||
for (const entry of splitLinkHeader(value ?? "").slice(0, 100)) {
|
||||
const match = /^\s*<([^>]{1,4096})>(.*)$/u.exec(entry);
|
||||
for (const entry of splitLinkHeader(value ?? "")) {
|
||||
const match = /^\s*<([^>]*)>(.*)$/u.exec(entry);
|
||||
if (match === null) continue;
|
||||
const parameters = linkParameters(match[2] ?? "");
|
||||
const destination = safeDestination(
|
||||
@@ -157,8 +156,7 @@ const parseLinks = (
|
||||
rel: (parameters.get("rel") ?? "")
|
||||
.toLowerCase()
|
||||
.split(/\s+/u)
|
||||
.filter(Boolean)
|
||||
.slice(0, 32),
|
||||
.filter(Boolean),
|
||||
as: boundedHeader(parameters.get("as")),
|
||||
type: boundedHeader(parameters.get("type")),
|
||||
crossorigin: boundedHeader(parameters.get("crossorigin")),
|
||||
@@ -184,14 +182,14 @@ const splitLinkHeader = (value: string): string[] => {
|
||||
|
||||
const linkParameters = (value: string): ReadonlyMap<string, string> => {
|
||||
const parameters = new Map<string, string>();
|
||||
for (const raw of value.split(";").slice(1, 50)) {
|
||||
for (const raw of value.split(";").slice(1)) {
|
||||
const separator = raw.indexOf("=");
|
||||
const name = (separator < 0 ? raw : raw.slice(0, separator))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!/^[a-z][a-z0-9-]{0,99}$/u.test(name)) continue;
|
||||
if (!/^[a-z][a-z0-9-]*$/u.test(name)) continue;
|
||||
const parameter = separator < 0 ? "" : raw.slice(separator + 1).trim();
|
||||
parameters.set(name, unquote(parameter).slice(0, 1_024));
|
||||
parameters.set(name, unquote(parameter));
|
||||
}
|
||||
return parameters;
|
||||
};
|
||||
@@ -222,11 +220,9 @@ const permissionFeatures = (value: string | undefined): string[] =>
|
||||
(value ?? "")
|
||||
.split(",")
|
||||
.map((entry) => entry.split("=", 1)[0]?.trim().toLowerCase() ?? "")
|
||||
.filter((feature) => /^[a-z][a-z0-9-]{0,99}$/u.test(feature)),
|
||||
.filter((feature) => /^[a-z][a-z0-9-]*$/u.test(feature)),
|
||||
),
|
||||
]
|
||||
.sort()
|
||||
.slice(0, 200);
|
||||
].sort();
|
||||
|
||||
const wellKnownHint = (url: string): AgentHint[] => {
|
||||
try {
|
||||
@@ -245,7 +241,7 @@ const agentHint = (
|
||||
url: string | null,
|
||||
): AgentHint => ({
|
||||
mechanism,
|
||||
declaration: declaration.slice(0, 256),
|
||||
declaration,
|
||||
url,
|
||||
trust: "page-declared-untrusted",
|
||||
});
|
||||
@@ -255,7 +251,7 @@ const isAgentRel = (value: string): boolean =>
|
||||
|
||||
const policyToken = (value: string | undefined): string | null => {
|
||||
const token = (value ?? "").trim().toLowerCase();
|
||||
return /^[a-z][a-z0-9_.-]{0,99}$/u.test(token) ? token : null;
|
||||
return /^[a-z][a-z0-9_.-]*$/u.test(token) ? token : null;
|
||||
};
|
||||
|
||||
const nonnegativeInteger = (value: string | undefined): number | null => {
|
||||
@@ -264,7 +260,7 @@ const nonnegativeInteger = (value: string | undefined): number | null => {
|
||||
};
|
||||
|
||||
const boundedHeader = (value: string | undefined): string | null =>
|
||||
value === undefined ? null : value.trim().toLowerCase().slice(0, 256);
|
||||
value === undefined ? null : value.trim().toLowerCase();
|
||||
|
||||
const unquote = (value: string): string =>
|
||||
value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
|
||||
|
||||
@@ -83,10 +83,7 @@ export const captureCdpScreenshot = async (
|
||||
const encoded = stringValue(result?.data);
|
||||
const bytes =
|
||||
encoded === undefined ? undefined : decodeCanonicalBase64(encoded);
|
||||
if (
|
||||
bytes === undefined ||
|
||||
bytes.byteLength > context.input.maximum_image_bytes
|
||||
)
|
||||
if (bytes === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "payload_limit");
|
||||
const dimensions = pngDimensions(bytes);
|
||||
if (navigation.leftScope)
|
||||
@@ -155,8 +152,8 @@ const pngDimensions = (
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
const width = bytes.readUInt32BE(16);
|
||||
const height = bytes.readUInt32BE(20);
|
||||
if (width === 0 || height === 0 || width * height > 32_000_000)
|
||||
throw new BrowserObservationError("inspect_web_page", "payload_limit");
|
||||
if (width === 0 || height === 0)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
|
||||
@@ -73,8 +73,7 @@ const attachedSessionId = (
|
||||
value === null ||
|
||||
!("sessionId" in value) ||
|
||||
typeof value.sessionId !== "string" ||
|
||||
value.sessionId.length === 0 ||
|
||||
value.sessionId.length > 256
|
||||
value.sessionId.length === 0
|
||||
)
|
||||
throw new BrowserObservationError(operation, "protocol_error");
|
||||
return value.sessionId;
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "./CdpCaptureValues.js";
|
||||
import { captureFrames, mainFrameUrl } from "./CdpCaptureDocuments.js";
|
||||
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
|
||||
import { boundedSensitiveText } from "./SensitiveTextCapture.js";
|
||||
import { redactSensitiveText } from "./SensitiveTextCapture.js";
|
||||
|
||||
interface DiscoveryContext {
|
||||
readonly connection: CdpConnection;
|
||||
@@ -106,7 +106,7 @@ export const discoverWebMcp = async (
|
||||
} else
|
||||
// CDP sends the enable response before the required toolsAdded replay.
|
||||
await delayWithCancellation(
|
||||
Math.max(context.input.observation_ms, 25),
|
||||
context.input.observation_ms,
|
||||
"discover_webmcp_tools",
|
||||
context.signal,
|
||||
);
|
||||
@@ -167,7 +167,7 @@ const handleFrameDetached = (
|
||||
state: FrameScopeState,
|
||||
): boolean => {
|
||||
const frameId = stringValue(params.frameId);
|
||||
if (frameId === undefined || frameId.length > 256) {
|
||||
if (frameId === undefined) {
|
||||
state.completeness.exclude("webmcp_tools", "invalid_protocol_value");
|
||||
return true;
|
||||
}
|
||||
@@ -180,7 +180,7 @@ const handleFrameUpdate = (
|
||||
state: FrameScopeState,
|
||||
): boolean => {
|
||||
const frameId = stringValue(frame?.id) ?? stringValue(frame?.frameId);
|
||||
if (frameId === undefined || frameId.length > 256) {
|
||||
if (frameId === undefined) {
|
||||
state.completeness.exclude("webmcp_tools", "invalid_protocol_value");
|
||||
return true;
|
||||
}
|
||||
@@ -293,16 +293,13 @@ const normalizeTool = (
|
||||
completeness.exclude("webmcp_tools", "out_of_target_scope");
|
||||
return undefined;
|
||||
}
|
||||
const description = boundedSensitiveText(
|
||||
stringValue(value.description) ?? "",
|
||||
4_096,
|
||||
).text;
|
||||
const description = redactSensitiveText(stringValue(value.description) ?? "");
|
||||
const annotations = recordValue(value.annotations);
|
||||
return {
|
||||
tool_key: toolKey(frame.url, name),
|
||||
name: boundedSensitiveText(name, 512).text,
|
||||
name: redactSensitiveText(name),
|
||||
description,
|
||||
frame_id: frameId.slice(0, 256),
|
||||
frame_id: frameId,
|
||||
frame_url: frame.url,
|
||||
owner_origin: frame.origin,
|
||||
declaration_kind:
|
||||
@@ -326,15 +323,9 @@ const normalizeTool = (
|
||||
const schemaShape = (value: unknown) => {
|
||||
if (recordValue(value) === undefined) return null;
|
||||
const encoded = JSON.stringify(value);
|
||||
const shape = inferJsonShape(encoded, {
|
||||
maximumBytes: 256 * 1_024,
|
||||
maximumNodes: 5_000,
|
||||
maximumDepth: 20,
|
||||
});
|
||||
const shape = inferJsonShape(encoded);
|
||||
if (shape === null)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
if (shape.truncated)
|
||||
throw new BrowserObservationError("inspect_web_page", "payload_limit");
|
||||
return shape;
|
||||
};
|
||||
|
||||
|
||||
@@ -49,17 +49,16 @@ describe("PNG visual diff", () => {
|
||||
).toMatchObject({ status: "dimension_mismatch", compared_pixels: 0 });
|
||||
});
|
||||
|
||||
it("rejects images above the caller's decoded pixel limit", () => {
|
||||
const image = artifact(2, 1, [0, 0, 0, 255, 0, 0, 0, 255]);
|
||||
it("rejects malformed PNG dimensions after validating image data", () => {
|
||||
const image = artifact(32_000_001, 1, [0, 0, 0, 255]);
|
||||
expect(() =>
|
||||
comparePngScreenshots(
|
||||
compareWebScreenshotsInputSchema.parse({
|
||||
before: image,
|
||||
after: image,
|
||||
maximum_pixels: 1,
|
||||
}),
|
||||
),
|
||||
).toThrow("oversized PNG");
|
||||
).toThrow("Unexpected PNG size");
|
||||
});
|
||||
|
||||
it("rejects metrics that cannot represent their comparison status", () => {
|
||||
|
||||
@@ -13,12 +13,12 @@ interface DecodedPng {
|
||||
readonly rgba: Buffer;
|
||||
}
|
||||
|
||||
/** Compare two validated screenshots using bounded, deterministic RGBA metrics. */
|
||||
/** Compare two validated screenshots using deterministic RGBA metrics. */
|
||||
export const comparePngScreenshots = (
|
||||
input: CompareWebScreenshotsInput,
|
||||
): WebScreenshotDiff => {
|
||||
const before = decodePng(input.before, input.maximum_pixels);
|
||||
const after = decodePng(input.after, input.maximum_pixels);
|
||||
const before = decodePng(input.before);
|
||||
const after = decodePng(input.after);
|
||||
if (before.width !== after.width || before.height !== after.height)
|
||||
return {
|
||||
status: "dimension_mismatch",
|
||||
@@ -73,10 +73,7 @@ export const comparePngScreenshots = (
|
||||
};
|
||||
};
|
||||
|
||||
const decodePng = (
|
||||
artifact: WebScreenshotArtifact,
|
||||
maximumPixels: number,
|
||||
): DecodedPng => {
|
||||
const decodePng = (artifact: WebScreenshotArtifact): DecodedPng => {
|
||||
const bytes = decodeCanonicalBase64(artifact.data_base64);
|
||||
if (bytes === undefined || !bytes.subarray(0, 8).equals(PNG_SIGNATURE))
|
||||
throw new TypeError("Invalid PNG signature");
|
||||
@@ -92,7 +89,7 @@ const decodePng = (
|
||||
const dataEnd = dataStart + length;
|
||||
if (dataEnd + 4 > bytes.length) throw new TypeError("Truncated PNG data");
|
||||
const data = bytes.subarray(dataStart, dataEnd);
|
||||
if (type === "IHDR") header = parseHeader(data, maximumPixels);
|
||||
if (type === "IHDR") header = parseHeader(data);
|
||||
else if (type === "IDAT") compressed.push(data);
|
||||
else if (type === "IEND") {
|
||||
sawEnd = true;
|
||||
@@ -115,7 +112,7 @@ const decodePng = (
|
||||
};
|
||||
};
|
||||
|
||||
const parseHeader = (data: Buffer, maximumPixels: number) => {
|
||||
const parseHeader = (data: Buffer) => {
|
||||
if (data.byteLength !== 13) throw new TypeError("Invalid PNG header");
|
||||
const width = data.readUInt32BE(0);
|
||||
const height = data.readUInt32BE(4);
|
||||
@@ -124,14 +121,14 @@ const parseHeader = (data: Buffer, maximumPixels: number) => {
|
||||
if (
|
||||
width === 0 ||
|
||||
height === 0 ||
|
||||
width * height > maximumPixels ||
|
||||
!Number.isSafeInteger(width * height) ||
|
||||
bitDepth !== 8 ||
|
||||
(colorType !== 2 && colorType !== 6) ||
|
||||
data[10] !== 0 ||
|
||||
data[11] !== 0 ||
|
||||
data[12] !== 0
|
||||
)
|
||||
throw new TypeError("Unsupported or oversized PNG");
|
||||
throw new TypeError("Unsupported PNG");
|
||||
return { width, height, channels: colorType === 6 ? 4 : 3 };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
boundedSensitiveText,
|
||||
redactSensitiveText,
|
||||
} from "./SensitiveTextCapture.js";
|
||||
import { redactSensitiveText } from "./SensitiveTextCapture.js";
|
||||
|
||||
describe("sensitive console text", () => {
|
||||
it("redacts assignments, bearer credentials, and JWT-shaped values", () => {
|
||||
@@ -16,12 +13,4 @@ describe("sensitive console text", () => {
|
||||
"authorization=[REDACTED] password=[REDACTED] api_key=[REDACTED] [REDACTED_JWT]",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds UTF-8 after redaction without splitting a code point", () => {
|
||||
expect(boundedSensitiveText("password=secret 你好", 22)).toEqual({
|
||||
text: "password=[REDACTED] ",
|
||||
bytes: 20,
|
||||
truncated: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,27 +9,3 @@ export const redactSensitiveText = (value: string): string =>
|
||||
.replace(SECRET_ASSIGNMENT, (_match, name: string) => `${name}=[REDACTED]`)
|
||||
.replace(BEARER, "Bearer [REDACTED]")
|
||||
.replace(JWT, "[REDACTED_JWT]");
|
||||
|
||||
/** Return a UTF-8-bounded prefix without splitting a code point. */
|
||||
export const boundedSensitiveText = (
|
||||
value: string,
|
||||
maximumBytes: number,
|
||||
): {
|
||||
readonly text: string;
|
||||
readonly bytes: number;
|
||||
readonly truncated: boolean;
|
||||
} => {
|
||||
const redacted = redactSensitiveText(value);
|
||||
const total = Buffer.byteLength(redacted);
|
||||
if (total <= maximumBytes)
|
||||
return { text: redacted, bytes: total, truncated: false };
|
||||
let text = "";
|
||||
let bytes = 0;
|
||||
for (const character of redacted) {
|
||||
const size = Buffer.byteLength(character);
|
||||
if (bytes + size > maximumBytes) break;
|
||||
text += character;
|
||||
bytes += size;
|
||||
}
|
||||
return { text, bytes, truncated: true };
|
||||
};
|
||||
@@ -8,26 +8,23 @@ import {
|
||||
BrowserObservationError,
|
||||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
import { parseCdpEndpointValue, readBoundedCdpJson } from "./CdpEndpoint.js";
|
||||
|
||||
const MAX_VERSION_BYTES = 64 * 1_024;
|
||||
const MAX_TARGET_LIST_BYTES = 2 * 1_024 * 1_024;
|
||||
import { parseCdpEndpointValue, readCdpJson } from "./CdpEndpoint.js";
|
||||
|
||||
const versionSchema = z
|
||||
.object({
|
||||
Browser: z.string().min(1).max(1_024),
|
||||
"Protocol-Version": z.string().min(1).max(100),
|
||||
"V8-Version": z.string().max(1_024).optional(),
|
||||
Browser: z.string().min(1),
|
||||
"Protocol-Version": z.string().min(1),
|
||||
"V8-Version": z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const targetSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(256),
|
||||
type: z.string().min(1).max(100),
|
||||
url: z.string().max(65_536),
|
||||
id: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
url: z.string(),
|
||||
attached: z.boolean().default(false),
|
||||
webSocketDebuggerUrl: z.string().min(1).max(2_048),
|
||||
webSocketDebuggerUrl: z.string().min(1),
|
||||
})
|
||||
.passthrough();
|
||||
const targetsSchema = z.array(targetSchema);
|
||||
@@ -45,25 +42,15 @@ export interface V8InspectorDiscovery {
|
||||
readonly targets: readonly V8InspectorTarget[];
|
||||
}
|
||||
|
||||
/** Discover bounded Node/Electron Inspector targets from one loopback endpoint. */
|
||||
/** Discover Node/Electron Inspector targets from one loopback endpoint. */
|
||||
export const discoverV8Inspector = async (
|
||||
endpoint: string,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<V8InspectorDiscovery> => {
|
||||
const [versionInput, targetsInput] = await Promise.all([
|
||||
readBoundedCdpJson(
|
||||
new URL("/json/version", endpoint),
|
||||
MAX_VERSION_BYTES,
|
||||
operation,
|
||||
signal,
|
||||
),
|
||||
readBoundedCdpJson(
|
||||
new URL("/json/list", endpoint),
|
||||
MAX_TARGET_LIST_BYTES,
|
||||
operation,
|
||||
signal,
|
||||
),
|
||||
readCdpJson(new URL("/json/version", endpoint), operation, signal),
|
||||
readCdpJson(new URL("/json/list", endpoint), operation, signal),
|
||||
]);
|
||||
const version = parseCdpEndpointValue(versionSchema, versionInput, operation);
|
||||
const targets = parseCdpEndpointValue(targetsSchema, targetsInput, operation);
|
||||
|
||||
@@ -31,33 +31,14 @@ export const fetchWebSourceMaps = async (
|
||||
host: SourceMapFetchHost = { fetch: globalThis.fetch },
|
||||
): Promise<SourceMaps> => {
|
||||
const items: SourceMapItem[] = [];
|
||||
let remainingBytes = input.analysis_limits.max_total_source_map_bytes;
|
||||
for (const request of requests.slice(
|
||||
0,
|
||||
input.analysis_limits.max_source_maps,
|
||||
)) {
|
||||
for (const request of requests) {
|
||||
if (signal?.aborted === true) throw signal.reason;
|
||||
if (remainingBytes === 0) break;
|
||||
const fetched = await fetchOne(request, {
|
||||
input,
|
||||
maximumBytes: Math.min(
|
||||
input.analysis_limits.max_source_map_bytes,
|
||||
remainingBytes,
|
||||
),
|
||||
signal,
|
||||
host,
|
||||
});
|
||||
items.push(fetched.item);
|
||||
remainingBytes = Math.max(0, remainingBytes - fetched.bytesRead);
|
||||
items.push(await fetchOne(request, input, signal, host));
|
||||
}
|
||||
const included = items.filter(({ status }) => status === "included").length;
|
||||
const dropped = requests.length - items.length;
|
||||
const truncated =
|
||||
dropped > 0 || items.some(({ status }) => status === "truncated");
|
||||
return webSourceMapsSchema.parse({
|
||||
status: truncated
|
||||
? "truncated"
|
||||
: items.length === 0
|
||||
status:
|
||||
items.length === 0
|
||||
? "unavailable"
|
||||
: included === items.length
|
||||
? "included"
|
||||
@@ -66,40 +47,22 @@ export const fetchWebSourceMaps = async (
|
||||
: "unavailable",
|
||||
requested: requests.length,
|
||||
processed: items.length,
|
||||
dropped,
|
||||
dropped_script_keys: requests
|
||||
.slice(items.length)
|
||||
.map(({ scriptKey }) => scriptKey),
|
||||
items,
|
||||
});
|
||||
};
|
||||
|
||||
interface SourceMapFetchResult {
|
||||
readonly item: SourceMapItem;
|
||||
readonly bytesRead: number;
|
||||
}
|
||||
|
||||
interface SourceMapFetchContext {
|
||||
readonly input: AnalyzeWebBundleInput;
|
||||
readonly maximumBytes: number;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly host: SourceMapFetchHost;
|
||||
}
|
||||
|
||||
const fetchOne = async (
|
||||
request: WebSourceMapRequest,
|
||||
context: SourceMapFetchContext,
|
||||
): Promise<SourceMapFetchResult> => {
|
||||
const { input, maximumBytes, signal, host } = context;
|
||||
input: AnalyzeWebBundleInput,
|
||||
signal: AbortSignal | undefined,
|
||||
host: SourceMapFetchHost,
|
||||
): Promise<SourceMapItem> => {
|
||||
if (!approvedUrl(request.fetchUrl, input.allowed_origins))
|
||||
return {
|
||||
item: emptySourceMapItem(
|
||||
request,
|
||||
"policy_filtered",
|
||||
"Declared source-map URL is outside the approved exact origins.",
|
||||
),
|
||||
bytesRead: 0,
|
||||
};
|
||||
return emptySourceMapItem(
|
||||
request,
|
||||
"policy_filtered",
|
||||
"Declared source-map URL is outside the approved exact origins.",
|
||||
);
|
||||
try {
|
||||
const response = await fetchFollowingApprovedRedirects(
|
||||
request.fetchUrl,
|
||||
@@ -108,40 +71,25 @@ const fetchOne = async (
|
||||
host,
|
||||
);
|
||||
if (response === undefined)
|
||||
return {
|
||||
item: emptySourceMapItem(
|
||||
request,
|
||||
"policy_filtered",
|
||||
"A source-map redirect left the approved exact origins.",
|
||||
),
|
||||
bytesRead: 0,
|
||||
};
|
||||
return emptySourceMapItem(
|
||||
request,
|
||||
"policy_filtered",
|
||||
"A source-map redirect left the approved exact origins.",
|
||||
);
|
||||
if (!response.ok)
|
||||
return {
|
||||
item: emptySourceMapItem(
|
||||
request,
|
||||
"fetch_failed",
|
||||
`Source-map server returned HTTP ${String(response.status)}.`,
|
||||
),
|
||||
bytesRead: 0,
|
||||
};
|
||||
const body = await boundedResponseText(response, maximumBytes);
|
||||
return {
|
||||
item: normalizeSourceMap(request, body.text, input),
|
||||
bytesRead: body.bytes,
|
||||
};
|
||||
return emptySourceMapItem(
|
||||
request,
|
||||
"fetch_failed",
|
||||
`Source-map server returned HTTP ${String(response.status)}.`,
|
||||
);
|
||||
return normalizeSourceMap(request, await response.text());
|
||||
} catch (cause: unknown) {
|
||||
if (signal?.aborted === true) throw cause;
|
||||
return {
|
||||
item: emptySourceMapItem(
|
||||
request,
|
||||
isLimitError(cause) ? "truncated" : "fetch_failed",
|
||||
isLimitError(cause)
|
||||
? "Source-map response exceeded the remaining approved byte budget."
|
||||
: "Source-map fetch or validation failed.",
|
||||
),
|
||||
bytesRead: cause instanceof SourceMapByteLimitError ? cause.bytesRead : 0,
|
||||
};
|
||||
return emptySourceMapItem(
|
||||
request,
|
||||
"fetch_failed",
|
||||
"Source-map fetch or validation failed.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -152,75 +100,37 @@ const fetchFollowingApprovedRedirects = async (
|
||||
host: SourceMapFetchHost,
|
||||
): Promise<Response | undefined> => {
|
||||
let current = initialUrl;
|
||||
for (let redirect = 0; redirect <= 5; redirect += 1) {
|
||||
const visited = new Set<string>();
|
||||
for (;;) {
|
||||
if (!approvedUrl(current, allowedOrigins)) return undefined;
|
||||
const response = await timedFetch(current, signal, host);
|
||||
if (visited.has(current)) throw new Error("source_map_redirect_loop");
|
||||
visited.add(current);
|
||||
const response = await host.fetch(current, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json, application/source-map+json;q=0.9",
|
||||
},
|
||||
redirect: "manual",
|
||||
credentials: "omit",
|
||||
referrerPolicy: "no-referrer",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
if (response.status < 300 || response.status >= 400) return response;
|
||||
const location = response.headers.get("location");
|
||||
if (location === null) return response;
|
||||
current = new URL(location, current).href;
|
||||
}
|
||||
throw new Error("source_map_redirect_limit");
|
||||
};
|
||||
|
||||
const timedFetch = async (
|
||||
url: string,
|
||||
signal: AbortSignal | undefined,
|
||||
host: SourceMapFetchHost,
|
||||
): Promise<Response> => {
|
||||
const timeout = AbortSignal.timeout(5_000);
|
||||
return await host.fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json, application/source-map+json;q=0.9" },
|
||||
redirect: "manual",
|
||||
credentials: "omit",
|
||||
referrerPolicy: "no-referrer",
|
||||
signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
|
||||
});
|
||||
};
|
||||
|
||||
const boundedResponseText = async (
|
||||
response: Response,
|
||||
maximumBytes: number,
|
||||
): Promise<{ readonly text: string; readonly bytes: number }> => {
|
||||
const declared = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > maximumBytes)
|
||||
throw new SourceMapByteLimitError(0);
|
||||
if (response.body === null) return { text: "", bytes: 0 };
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
let bytes = 0;
|
||||
let text = "";
|
||||
for (;;) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > maximumBytes) {
|
||||
await reader.cancel();
|
||||
throw new SourceMapByteLimitError(bytes);
|
||||
}
|
||||
text += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
return { text, bytes };
|
||||
};
|
||||
|
||||
class SourceMapByteLimitError extends RangeError {
|
||||
constructor(readonly bytesRead: number) {
|
||||
super("source_map_bytes");
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeSourceMap = (
|
||||
request: WebSourceMapRequest,
|
||||
text: string,
|
||||
input: AnalyzeWebBundleInput,
|
||||
): SourceMapItem => {
|
||||
if (!validSourceMapEnvelope(text))
|
||||
return emptySourceMapItem(
|
||||
request,
|
||||
"invalid",
|
||||
"Source-map JSON is not a bounded version 3 map.",
|
||||
"Source-map JSON is not a version 3 map.",
|
||||
);
|
||||
try {
|
||||
const map = new AnyMap(text, request.fetchUrl);
|
||||
@@ -239,7 +149,6 @@ const normalizeSourceMap = (
|
||||
};
|
||||
});
|
||||
const mappings: ParsedSourceMapItem["mappings"] = [];
|
||||
let totalMappings = 0;
|
||||
eachMapping(map, (mapping) => {
|
||||
if (
|
||||
mapping.source === null ||
|
||||
@@ -247,9 +156,6 @@ const normalizeSourceMap = (
|
||||
mapping.originalColumn === null
|
||||
)
|
||||
return;
|
||||
totalMappings += 1;
|
||||
if (mappings.length >= input.analysis_limits.max_source_map_mappings)
|
||||
return;
|
||||
mappings.push({
|
||||
generated_line: mapping.generatedLine,
|
||||
generated_column: mapping.generatedColumn,
|
||||
@@ -258,16 +164,10 @@ const normalizeSourceMap = (
|
||||
),
|
||||
original_line: mapping.originalLine,
|
||||
original_column: mapping.originalColumn,
|
||||
name: mapping.name?.slice(0, 1_024) ?? null,
|
||||
name: mapping.name ?? null,
|
||||
});
|
||||
});
|
||||
const modules = originalModuleEdges(originalSources);
|
||||
const mappingTruncated = totalMappings > mappings.length;
|
||||
const limitations = mappingTruncated
|
||||
? [
|
||||
`Mappings were truncated from ${String(totalMappings)} to ${String(mappings.length)}.`,
|
||||
]
|
||||
: [];
|
||||
const parsed = {
|
||||
...sourceMapContext(request),
|
||||
artifact: createWebTextArtifact(text, "application/source-map+json"),
|
||||
@@ -275,13 +175,7 @@ const normalizeSourceMap = (
|
||||
original_module_edges: modules,
|
||||
mappings,
|
||||
};
|
||||
return limitations.length === 0
|
||||
? { ...parsed, status: "included", limitation: null }
|
||||
: {
|
||||
...parsed,
|
||||
status: "truncated",
|
||||
limitation: limitations.join(" "),
|
||||
};
|
||||
return { ...parsed, status: "included", limitation: null };
|
||||
} catch {
|
||||
return emptySourceMapItem(
|
||||
request,
|
||||
@@ -300,7 +194,7 @@ const originalModuleEdges = (
|
||||
if (source.artifact === null) continue;
|
||||
for (const detector of originalImportDetectors) {
|
||||
for (const match of source.artifact.text.matchAll(detector.pattern)) {
|
||||
const specifier = match[1]?.slice(0, 4_096);
|
||||
const specifier = match[1];
|
||||
if (specifier === undefined) continue;
|
||||
const key = `${source.source}\0${detector.kind}\0${specifier}`;
|
||||
if (seen.has(key)) continue;
|
||||
@@ -327,19 +221,16 @@ const validSourceMapEnvelope = (text: string): boolean => {
|
||||
if (!isRecord(parsed) || parsed.version !== 3) return false;
|
||||
if (typeof parsed.mappings === "string")
|
||||
return Array.isArray(parsed.sources) && Array.isArray(parsed.names);
|
||||
if (!Array.isArray(parsed.sections) || parsed.sections.length > 10_000)
|
||||
return false;
|
||||
if (!Array.isArray(parsed.sections)) return false;
|
||||
const pending: unknown[] = [...parsed.sections];
|
||||
let visited = 0;
|
||||
while (pending.length > 0) {
|
||||
const section = pending.pop();
|
||||
if (!isRecord(section) || !isRecord(section.offset) || !("map" in section))
|
||||
return false;
|
||||
visited += 1;
|
||||
if (visited > 10_000) return false;
|
||||
const map = section.map;
|
||||
if (!isRecord(map) || map.version !== 3) return false;
|
||||
if (Array.isArray(map.sections)) pending.push(...map.sections);
|
||||
if (Array.isArray(map.sections))
|
||||
for (const child of map.sections) pending.push(child);
|
||||
else if (
|
||||
typeof map.mappings !== "string" ||
|
||||
!Array.isArray(map.sources) ||
|
||||
@@ -387,14 +278,13 @@ const emptySourceMapItem = (
|
||||
});
|
||||
|
||||
const sanitizeSource = (value: string): string => {
|
||||
const bounded = value.slice(0, 4_096);
|
||||
try {
|
||||
const parsed = new URL(bounded);
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
||||
? sanitizeBrowserUrl(parsed.href).url
|
||||
: `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
||||
} catch {
|
||||
return bounded.split("#", 1)[0]?.split("?", 1)[0] ?? "";
|
||||
return value.split("#", 1)[0]?.split("?", 1)[0] ?? "";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -417,9 +307,6 @@ const sourceMediaType = (source: string | null): string =>
|
||||
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const isLimitError = (value: unknown): value is RangeError =>
|
||||
value instanceof RangeError;
|
||||
|
||||
const originalImportDetectors = [
|
||||
{
|
||||
kind: "static_import" as const,
|
||||
|
||||
@@ -131,13 +131,6 @@ const registerScreenshot = (
|
||||
}),
|
||||
options: z.object({
|
||||
...browserScopeOptions,
|
||||
maximumImageBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(8 * 1_024 * 1_024)
|
||||
.default(4 * 1_024 * 1_024)
|
||||
.describe("Maximum captured PNG size"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "capture-web-screenshot", async () => {
|
||||
@@ -148,7 +141,6 @@ const registerScreenshot = (
|
||||
allowed_origins:
|
||||
options.allowedOrigins ?? context.allowedBrowserOrigins,
|
||||
target_id: args.targetId,
|
||||
maximum_image_bytes: options.maximumImageBytes,
|
||||
});
|
||||
if (!parsed.success) return inputError("capture_web_screenshot");
|
||||
const result = await captureWebScreenshot(
|
||||
@@ -179,13 +171,6 @@ const registerScreenshotDiff = (
|
||||
.max(255)
|
||||
.default(0)
|
||||
.describe("Per-channel difference threshold for changed pixels"),
|
||||
maximumPixels: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(32_000_000)
|
||||
.default(16_000_000)
|
||||
.describe("Maximum decoded pixels per screenshot"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "compare-web-screenshots", async () => {
|
||||
@@ -193,7 +178,6 @@ const registerScreenshotDiff = (
|
||||
before: parseJson(args.beforeJson),
|
||||
after: parseJson(args.afterJson),
|
||||
channel_threshold: options.channelThreshold,
|
||||
maximum_pixels: options.maximumPixels,
|
||||
});
|
||||
if (!parsed.success) return inputError("compare_web_screenshots");
|
||||
const result = await compareWebScreenshotEvidence(
|
||||
|
||||
@@ -20,7 +20,7 @@ import { CLI_COMMANDS } from "./cliCommandNames.js";
|
||||
import {
|
||||
browserPageInspectionOptions,
|
||||
browserScopeOptions,
|
||||
boundedCount,
|
||||
observationDuration,
|
||||
} from "./cliObservationOptions.js";
|
||||
|
||||
/** Register CLI equivalents of the passive browser MCP tools. */
|
||||
@@ -120,12 +120,7 @@ const registerBundleAnalysis = (
|
||||
}),
|
||||
options: z.object({
|
||||
...browserScopeOptions,
|
||||
observationMs: boundedCount(
|
||||
"observation duration in milliseconds",
|
||||
10_000,
|
||||
500,
|
||||
0,
|
||||
),
|
||||
observationMs: observationDuration(500),
|
||||
fetchSourceMaps: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
@@ -161,19 +156,14 @@ const registerObservationSession = (
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.observeWebSession, {
|
||||
description: "Observe user-driven page navigation during a bounded window",
|
||||
description: "Observe user-driven page navigation during a chosen window",
|
||||
args: z.object({
|
||||
endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"),
|
||||
targetId: z.string().describe("Target ID from list-browser-targets"),
|
||||
}),
|
||||
options: z.object({
|
||||
...browserScopeOptions,
|
||||
observationMs: boundedCount(
|
||||
"observation duration in milliseconds",
|
||||
60_000,
|
||||
10_000,
|
||||
),
|
||||
maxTimelineEvents: boundedCount("timeline events", 20_000, 2_000),
|
||||
observationMs: observationDuration(10_000, 1),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "observe-web-session", async () => {
|
||||
@@ -185,7 +175,6 @@ const registerObservationSession = (
|
||||
options.allowedOrigins ?? context.allowedBrowserOrigins,
|
||||
target_id: args.targetId,
|
||||
observation_ms: options.observationMs,
|
||||
max_timeline_events: options.maxTimelineEvents,
|
||||
});
|
||||
if (!parsed.success)
|
||||
return browserCliError(new AnalysisInputError("observe_web_session"));
|
||||
|
||||
@@ -15,6 +15,15 @@ export const boundedCount = (
|
||||
.default(fallback)
|
||||
.describe(`Maximum ${subject}`);
|
||||
|
||||
/** Accept an agent-selected browser observation duration without an artificial ceiling. */
|
||||
export const observationDuration = (fallback: number, minimum = 0) =>
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.min(minimum)
|
||||
.default(fallback)
|
||||
.describe("Observation duration in milliseconds");
|
||||
|
||||
/** Shared passive browser origin options. */
|
||||
export const browserScopeOptions = {
|
||||
allowedOrigins: z
|
||||
@@ -27,12 +36,7 @@ export const browserScopeOptions = {
|
||||
|
||||
export const browserPageInspectionOptions = z.object({
|
||||
...browserScopeOptions,
|
||||
observationMs: boundedCount(
|
||||
"observation duration in milliseconds",
|
||||
10_000,
|
||||
500,
|
||||
0,
|
||||
),
|
||||
observationMs: observationDuration(500),
|
||||
includeAccessibilityText: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
|
||||
@@ -30,7 +30,7 @@ export const BROWSER_SCENARIO_TOOL_CONTRACTS = [
|
||||
name: "capture_browser_scenario",
|
||||
...toolContractMetadata("capture_browser_scenario"),
|
||||
description:
|
||||
"Run a controlled browser scenario when passive observation cannot exercise the application. Choose launch mode with an approved executable or connect mode with an approved loopback CDP target, declare the exact start origin scope, and provide explicit actions. Environment, empty storage, disabled request replay, redaction, and a URL-only final capture have safe defaults; declare secrets, initial storage, exact request replay, or additional retained artifacts only when needed. Provider-owned duration, action, and navigation timeouts remain fixed. There are no action, secret, storage, route, event, frame, DOM, network, or cumulative metadata count caps; every observation is returned inline. Oversized text or screenshot artifacts fail with a payload-limit error instead of being silently omitted.",
|
||||
"Run a controlled browser scenario when passive observation cannot exercise the application. Choose launch mode with an approved executable or connect mode with an approved loopback CDP target, declare the exact start origin scope, and provide explicit actions. Environment, empty storage, disabled request replay, redaction, and a URL-only final capture have safe defaults; declare secrets, initial storage, exact request replay, or additional retained artifacts only when needed. Provider-owned duration, action, and navigation timeouts remain fixed. There are no action, secret, storage, route, event, frame, DOM, network, artifact-size, or cumulative metadata count caps; every observation is returned inline.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: browserScenarioInputSchema,
|
||||
outputSchema: evidenceResultOf(browserScenarioCaptureSchema),
|
||||
|
||||
@@ -181,7 +181,7 @@ export const BROWSER_TOOL_CONTRACTS = [
|
||||
name: "observe_web_session",
|
||||
...toolContractMetadata("observe_web_session"),
|
||||
description:
|
||||
"Arm a bounded CDP observation window while the user operates the page. Allows approved same-origin reload and SPA navigation, records ordered navigation, redirect, lifecycle, and failure metadata, and stops before retaining an out-of-policy destination.",
|
||||
"Arm a CDP observation window of the requested duration while the user operates the page. Allows approved same-origin reload and SPA navigation, records every observed navigation, redirect, lifecycle, and failure event, and stops before retaining an out-of-policy destination.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: observeWebSessionInputSchema,
|
||||
outputSchema: observationSessionOutputSchema,
|
||||
@@ -193,7 +193,6 @@ export const BROWSER_TOOL_CONTRACTS = [
|
||||
allowed_origins: [origin],
|
||||
target_id: "TARGET_ID_FROM_LIST_BROWSER_TARGETS",
|
||||
observation_ms: 10_000,
|
||||
max_timeline_events: 2_000,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -250,7 +249,7 @@ export const BROWSER_TOOL_CONTRACTS = [
|
||||
name: "capture_web_screenshot",
|
||||
...toolContractMetadata("capture_web_screenshot"),
|
||||
description:
|
||||
"Capture the current visible viewport of one configured page as a bounded, content-addressed PNG artifact. The operation never scrolls, navigates, or evaluates page JavaScript.",
|
||||
"Capture the current visible viewport of one configured page as an inline, content-addressed PNG artifact. The operation never scrolls, navigates, or evaluates page JavaScript.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: captureWebScreenshotInputSchema,
|
||||
outputSchema: screenshotOutputSchema,
|
||||
@@ -261,7 +260,6 @@ export const BROWSER_TOOL_CONTRACTS = [
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [origin],
|
||||
target_id: "TARGET_ID_FROM_LIST_BROWSER_TARGETS",
|
||||
maximum_image_bytes: 4_194_304,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -270,7 +268,7 @@ export const BROWSER_TOOL_CONTRACTS = [
|
||||
name: "compare_web_screenshots",
|
||||
...toolContractMetadata("compare_web_screenshots"),
|
||||
description:
|
||||
"Compare two self-verifying PNG screenshot artifacts with bounded local pixel metrics. Returns exact changed-pixel ratios and channel deltas without OCR or external services.",
|
||||
"Compare two self-verifying PNG screenshot artifacts with local pixel metrics. Returns exact changed-pixel ratios and channel deltas without OCR or external services.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: compareWebScreenshotsInputSchema,
|
||||
outputSchema: screenshotDiffOutputSchema,
|
||||
@@ -281,7 +279,6 @@ export const BROWSER_TOOL_CONTRACTS = [
|
||||
before: exampleScreenshot(),
|
||||
after: exampleScreenshot(),
|
||||
channel_threshold: 0,
|
||||
maximum_pixels: 16_000_000,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -76,30 +76,6 @@ describe("browser observation contracts", () => {
|
||||
include_websocket_shapes: false,
|
||||
include_script_sources: false,
|
||||
include_storage_keys: false,
|
||||
limits: {
|
||||
max_frames: 200,
|
||||
max_dom_nodes: 2_000,
|
||||
max_ax_nodes: 2_000,
|
||||
max_ax_text_field_bytes: 1_024,
|
||||
max_total_ax_text_bytes: 65_536,
|
||||
max_scripts: 200,
|
||||
max_resources: 2_000,
|
||||
max_workers: 500,
|
||||
max_storage_keys: 1_000,
|
||||
max_script_source_bytes: 1_048_576,
|
||||
max_total_script_source_bytes: 4_194_304,
|
||||
max_network_events: 1_000,
|
||||
max_console_events: 200,
|
||||
max_console_text_field_bytes: 1_024,
|
||||
max_total_console_text_bytes: 65_536,
|
||||
max_json_body_bytes: 1_048_576,
|
||||
max_total_json_body_bytes: 4_194_304,
|
||||
max_json_shape_nodes: 5_000,
|
||||
max_json_shape_depth: 20,
|
||||
max_websocket_events: 500,
|
||||
max_websocket_shape_bytes: 65_536,
|
||||
max_total_websocket_shape_bytes: 1_048_576,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
inspectWebPageInputSchema.safeParse({
|
||||
@@ -157,7 +133,7 @@ describe("browser observation sensitive surfaces and retention", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds retained URL and query-name metadata", () => {
|
||||
it("retains complete URL and query-name metadata while redacting values", () => {
|
||||
const longName = `a${"x".repeat(400)}`;
|
||||
const parameters = [
|
||||
`${longName}=secret`,
|
||||
@@ -169,17 +145,15 @@ describe("browser observation sensitive surfaces and retention", () => {
|
||||
const sanitized = sanitizeBrowserUrl(
|
||||
`https://app.example.test/path?${parameters}`,
|
||||
);
|
||||
expect(sanitized.query_parameter_names).toHaveLength(256);
|
||||
expect(
|
||||
sanitized.query_parameter_names.every((name) => name.length <= 256),
|
||||
).toBe(true);
|
||||
expect(sanitized.query_parameter_names).toHaveLength(301);
|
||||
expect(sanitized.query_parameter_names).toContain(longName);
|
||||
expect(sanitized.url).not.toContain("secret");
|
||||
|
||||
const oversized = sanitizeBrowserUrl(
|
||||
`https://app.example.test/${"p".repeat(70_000)}`,
|
||||
);
|
||||
expect(oversized.origin).toBe("https://app.example.test");
|
||||
expect(oversized.url.length).toBeLessThanOrEqual(131_072);
|
||||
expect(oversized.redacted).toBe(true);
|
||||
expect(oversized.url.length).toBeGreaterThan(70_000);
|
||||
expect(oversized.redacted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -27,16 +27,10 @@ const parseExactOrigin = (value: string): string | undefined => {
|
||||
return url.origin;
|
||||
};
|
||||
|
||||
const MAX_BROWSER_URL_CHARS = 65_536;
|
||||
const MAX_SANITIZED_BROWSER_URL_CHARS = 131_072;
|
||||
const MAX_QUERY_PARAMETER_NAMES = 256;
|
||||
const MAX_QUERY_PARAMETER_NAME_CHARS = 256;
|
||||
|
||||
/** Exact normalized HTTP(S) authority used for browser observation scope. */
|
||||
export const browserOriginSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2_048)
|
||||
.transform((value, context) => {
|
||||
const origin = parseExactOrigin(value);
|
||||
if (origin === undefined) {
|
||||
@@ -57,7 +51,6 @@ export const isLiteralLoopbackHostname = (hostname: string): boolean =>
|
||||
export const browserEndpointSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2_048)
|
||||
.transform((value, context) => {
|
||||
let url: URL;
|
||||
try {
|
||||
@@ -89,8 +82,7 @@ export const browserEndpointSchema = z
|
||||
export const browserAllowedOriginsSchema = z
|
||||
.array(browserOriginSchema)
|
||||
.min(1)
|
||||
.max(32)
|
||||
.transform((origins) => [...new Set(origins)].sort());
|
||||
.transform((origins) => Array.from(new Set(origins)).sort());
|
||||
|
||||
const browserInput = {
|
||||
cdp_endpoint: browserEndpointSchema,
|
||||
@@ -102,21 +94,10 @@ export const listBrowserTargetsInputSchema = z.object({
|
||||
...browserInput,
|
||||
});
|
||||
|
||||
/** Per-value limits for optional text and JSON-shape capture. */
|
||||
export const DEFAULT_BROWSER_INSPECTION_LIMITS = {
|
||||
max_ax_text_field_bytes: 1_024,
|
||||
max_script_source_bytes: 1_024 * 1_024,
|
||||
max_console_text_field_bytes: 1_024,
|
||||
max_json_body_bytes: 1_024 * 1_024,
|
||||
max_json_shape_nodes: 5_000,
|
||||
max_json_shape_depth: 20,
|
||||
max_websocket_shape_bytes: 64 * 1_024,
|
||||
};
|
||||
|
||||
const inspectWebPageInputFacts = {
|
||||
...browserInput,
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
observation_ms: z.number().int().min(0).max(10_000).default(500),
|
||||
target_id: z.string().trim().min(1),
|
||||
observation_ms: z.number().int().min(0).default(500),
|
||||
include_accessibility_text: z.boolean().default(false),
|
||||
include_console_text: z.boolean().default(false),
|
||||
include_json_body_shapes: z.boolean().default(false),
|
||||
@@ -161,12 +142,8 @@ export const inspectWebPageToolInputSchema = z.union([
|
||||
inspectWebPageWithSourceInputSchema,
|
||||
]);
|
||||
|
||||
/** Internal parsed input with per-value capture limits applied. */
|
||||
export const inspectWebPageInputSchema =
|
||||
inspectWebPageToolInputSchema.transform((input) => ({
|
||||
...input,
|
||||
limits: DEFAULT_BROWSER_INSPECTION_LIMITS,
|
||||
}));
|
||||
/** Parsed passive browser inspection input. */
|
||||
export const inspectWebPageInputSchema = inspectWebPageToolInputSchema;
|
||||
|
||||
export type ListBrowserTargetsInput = z.infer<
|
||||
typeof listBrowserTargetsInputSchema
|
||||
@@ -174,11 +151,9 @@ export type ListBrowserTargetsInput = z.infer<
|
||||
export type InspectWebPageInput = z.infer<typeof inspectWebPageInputSchema>;
|
||||
|
||||
export const sanitizedBrowserUrlSchema = z.object({
|
||||
url: z.string().max(MAX_SANITIZED_BROWSER_URL_CHARS),
|
||||
origin: z.string().max(2_048).nullable(),
|
||||
query_parameter_names: z
|
||||
.array(z.string().max(MAX_QUERY_PARAMETER_NAME_CHARS))
|
||||
.max(MAX_QUERY_PARAMETER_NAMES),
|
||||
url: z.string(),
|
||||
origin: z.string().nullable(),
|
||||
query_parameter_names: z.array(z.string()),
|
||||
redacted: z.boolean(),
|
||||
});
|
||||
export type SanitizedBrowserUrl = z.infer<typeof sanitizedBrowserUrlSchema>;
|
||||
@@ -186,9 +161,8 @@ export type SanitizedBrowserUrl = z.infer<typeof sanitizedBrowserUrlSchema>;
|
||||
/** Remove credentials and query values before a browser URL becomes durable. */
|
||||
export const sanitizeBrowserUrl = (value: string): SanitizedBrowserUrl => {
|
||||
let parsed: URL;
|
||||
const wasTruncated = value.length > MAX_BROWSER_URL_CHARS;
|
||||
try {
|
||||
parsed = new URL(value.slice(0, MAX_BROWSER_URL_CHARS));
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
return {
|
||||
url: "[unsupported-url]",
|
||||
@@ -199,15 +173,7 @@ export const sanitizeBrowserUrl = (value: string): SanitizedBrowserUrl => {
|
||||
}
|
||||
const hadCredentials = parsed.username !== "" || parsed.password !== "";
|
||||
const hadFragment = parsed.hash !== "";
|
||||
const names = [
|
||||
...new Set(
|
||||
[...parsed.searchParams.keys()].map((name) =>
|
||||
name.slice(0, MAX_QUERY_PARAMETER_NAME_CHARS),
|
||||
),
|
||||
),
|
||||
]
|
||||
.sort()
|
||||
.slice(0, MAX_QUERY_PARAMETER_NAMES);
|
||||
const names = [...new Set(parsed.searchParams.keys())].sort();
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
parsed.hash = "";
|
||||
@@ -217,20 +183,19 @@ export const sanitizeBrowserUrl = (value: string): SanitizedBrowserUrl => {
|
||||
url: parsed.href,
|
||||
origin: parsed.origin === "null" ? null : parsed.origin,
|
||||
query_parameter_names: names,
|
||||
redacted: wasTruncated || hadCredentials || hadFragment || names.length > 0,
|
||||
redacted: hadCredentials || hadFragment || names.length > 0,
|
||||
};
|
||||
};
|
||||
|
||||
/** Remove credentials, fragments, and query values from endpoint candidates. */
|
||||
export const sanitizeEndpointCandidate = (value: string): string => {
|
||||
const bounded = value.slice(0, 4_096);
|
||||
try {
|
||||
const parsed = new URL(bounded, "https://rea.invalid");
|
||||
const parsed = new URL(value, "https://rea.invalid");
|
||||
const sanitized = sanitizeBrowserUrl(parsed.href).url;
|
||||
return parsed.origin === "https://rea.invalid"
|
||||
? sanitized.replace("https://rea.invalid", "")
|
||||
: sanitized;
|
||||
} catch {
|
||||
return bounded.split("#", 1)[0]?.split("?", 1)[0] ?? "";
|
||||
return value.split("#", 1)[0]?.split("?", 1)[0] ?? "";
|
||||
}
|
||||
};
|
||||
@@ -119,13 +119,7 @@ const browserNetworkRequestSchema = z.object({
|
||||
column: z.number().int().min(0).nullable(),
|
||||
}),
|
||||
body_shapes: z.object({
|
||||
status: z.enum([
|
||||
"not_approved",
|
||||
"included",
|
||||
"partial",
|
||||
"unavailable",
|
||||
"truncated",
|
||||
]),
|
||||
status: z.enum(["not_approved", "included", "partial", "unavailable"]),
|
||||
request: jsonShapeSchema.nullable(),
|
||||
response: jsonShapeSchema.nullable(),
|
||||
}),
|
||||
@@ -138,7 +132,7 @@ const browserConsoleEventSchema = z.object({
|
||||
line: z.number().int().min(0).nullable(),
|
||||
column: z.number().int().min(0).nullable(),
|
||||
text_capture: z.object({
|
||||
status: z.enum(["not_approved", "included", "truncated"]),
|
||||
status: z.enum(["not_approved", "included"]),
|
||||
values: z.array(
|
||||
z.object({
|
||||
argument_index: z.number().int().min(0),
|
||||
@@ -147,7 +141,6 @@ const browserConsoleEventSchema = z.object({
|
||||
}),
|
||||
),
|
||||
retained_bytes: z.number().int().min(0),
|
||||
truncated_values: z.number().int().min(0),
|
||||
}),
|
||||
});
|
||||
const browserWebSocketEventSchema = z.object({
|
||||
@@ -159,7 +152,6 @@ const browserWebSocketEventSchema = z.object({
|
||||
.object({
|
||||
format: z.enum(["json", "text", "binary"]),
|
||||
json_shape: jsonShapeSchema.nullable(),
|
||||
truncated: z.boolean(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
@@ -261,10 +253,9 @@ export const webPageInspectionSchema = z.object({
|
||||
accessibility: z.object({
|
||||
total_nodes: z.number().int().min(0),
|
||||
text_capture: z.object({
|
||||
status: z.enum(["not_approved", "included", "truncated", "unavailable"]),
|
||||
status: z.enum(["not_approved", "included", "unavailable"]),
|
||||
retained_bytes: z.number().int().min(0),
|
||||
excluded_fields: z.number().int().min(0),
|
||||
truncated_fields: z.number().int().min(0),
|
||||
}),
|
||||
nodes: z.array(browserAxNodeSchema),
|
||||
}),
|
||||
|
||||
@@ -125,6 +125,29 @@ describe("browser scenario comparison validation", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns every aligned step beyond the former capture ceiling", () => {
|
||||
const before = multiStepScenarioCapture(130, "before");
|
||||
const after = multiStepScenarioCapture(130, "after");
|
||||
const result = compareBrowserScenarios(
|
||||
compareBrowserScenariosInputSchema.parse({
|
||||
before_scenario: before,
|
||||
after_scenario: after,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.alignment).toMatchObject({
|
||||
status: "aligned",
|
||||
aligned_steps: 131,
|
||||
});
|
||||
expect(result.steps).toHaveLength(131);
|
||||
expect(result.steps.at(-1)).toMatchObject({
|
||||
before_step_index: 130,
|
||||
after_step_index: 130,
|
||||
status: "changed",
|
||||
});
|
||||
expect(result.artifact_diffs.total).toBe(130);
|
||||
});
|
||||
|
||||
it("reports missing aligned steps without claiming equality", () => {
|
||||
const before = scenarioCapture({});
|
||||
const after = scenarioCapture({ stepId: "open-profile" });
|
||||
@@ -182,6 +205,37 @@ describe("browser scenario comparison validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const multiStepScenarioCapture = (
|
||||
actionCount: number,
|
||||
content: string,
|
||||
): BrowserScenarioCapture => {
|
||||
const capture = scenarioCapture({});
|
||||
const template = capture.steps[1];
|
||||
if (template === undefined) throw new Error("Expected scenario action step");
|
||||
const steps = [
|
||||
capture.steps[0],
|
||||
...Array.from({ length: actionCount }, (_, index) => ({
|
||||
...template,
|
||||
step_index: index + 1,
|
||||
step_id: `step-${String(index + 1)}`,
|
||||
event_sequence_start: index + 1,
|
||||
event_sequence_end: index + 1,
|
||||
artifacts: {
|
||||
...template.artifacts,
|
||||
dom: {
|
||||
state: "captured" as const,
|
||||
value: textArtifact(`${content}-${String(index + 1)}`),
|
||||
},
|
||||
},
|
||||
})),
|
||||
];
|
||||
return browserScenarioCaptureSchema.parse({
|
||||
...capture,
|
||||
scenario: { ...capture.scenario, action_count: actionCount },
|
||||
steps,
|
||||
});
|
||||
};
|
||||
|
||||
interface ScenarioCaptureOptions {
|
||||
readonly stepId?: string;
|
||||
readonly action?: string;
|
||||
|
||||
@@ -55,7 +55,7 @@ const normalizationRuleSchema = z.strictObject({
|
||||
|
||||
const normalizationInputSchema = z
|
||||
.strictObject({
|
||||
rules: z.array(normalizationRuleSchema).max(128).default([]),
|
||||
rules: z.array(normalizationRuleSchema).default([]),
|
||||
})
|
||||
.superRefine((normalization, context) => {
|
||||
const ruleIds = new Set<string>();
|
||||
@@ -86,9 +86,7 @@ export const compareBrowserScenariosInputSchema = z.strictObject({
|
||||
),
|
||||
normalization: normalizationInputSchema
|
||||
.default({ rules: [] })
|
||||
.describe(
|
||||
"Bounded exact-literal rules committed in the comparison result.",
|
||||
),
|
||||
.describe("Exact-literal rules committed in the comparison result."),
|
||||
});
|
||||
/** Parsed browser scenario comparison input. */
|
||||
export type CompareBrowserScenariosInput = z.infer<
|
||||
@@ -137,39 +135,37 @@ const builtInNormalizationSchema = z.enum([
|
||||
"compare_normalized_text_content",
|
||||
]);
|
||||
|
||||
/** Step alignment, recorded normalization, and bounded artifact differences. */
|
||||
/** Step alignment, recorded normalization, and inline artifact differences. */
|
||||
export const browserScenarioDiffSchema = z.strictObject({
|
||||
comparison_kind: z.literal("browser_scenario"),
|
||||
overall_status: z.enum(["changed", "unchanged", "unknown"]),
|
||||
normalization: z.strictObject({
|
||||
built_in_rules: z.array(builtInNormalizationSchema).length(5),
|
||||
rules: z.array(normalizationRuleSchema).max(128),
|
||||
rules: z.array(normalizationRuleSchema),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
}),
|
||||
alignment: z.strictObject({
|
||||
status: z.enum(["aligned", "partial", "failed"]),
|
||||
aligned_steps: z.number().int().min(0).max(129),
|
||||
before_only: z.array(z.string().min(1).max(64)).max(129),
|
||||
after_only: z.array(z.string().min(1).max(64)).max(129),
|
||||
failures: z.array(alignmentFailureSchema).max(520),
|
||||
aligned_steps: z.number().int().min(0),
|
||||
before_only: z.array(z.string().min(1).max(64)),
|
||||
after_only: z.array(z.string().min(1).max(64)),
|
||||
failures: z.array(alignmentFailureSchema),
|
||||
}),
|
||||
steps: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
step_id: z.string().min(1).max(64),
|
||||
before_step_index: z.number().int().min(0).max(128),
|
||||
after_step_index: z.number().int().min(0).max(128),
|
||||
before_action: z.string().min(1).max(64),
|
||||
after_action: z.string().min(1).max(64),
|
||||
status: z.enum(["changed", "unchanged", "unknown"]),
|
||||
artifact_diffs: z.array(artifactDiffSchema).max(8),
|
||||
}),
|
||||
)
|
||||
.max(129),
|
||||
steps: z.array(
|
||||
z.strictObject({
|
||||
step_id: z.string().min(1).max(64),
|
||||
before_step_index: z.number().int().min(0),
|
||||
after_step_index: z.number().int().min(0),
|
||||
before_action: z.string().min(1).max(64),
|
||||
after_action: z.string().min(1).max(64),
|
||||
status: z.enum(["changed", "unchanged", "unknown"]),
|
||||
artifact_diffs: z.array(artifactDiffSchema),
|
||||
}),
|
||||
),
|
||||
artifact_diffs: z.strictObject({
|
||||
total: z.number().int().min(0).max(1_032),
|
||||
total: z.number().int().min(0),
|
||||
}),
|
||||
limitations: z.array(z.string().min(1).max(2_048)).max(64),
|
||||
limitations: z.array(z.string().min(1).max(2_048)),
|
||||
});
|
||||
/** Browser scenario comparison result. */
|
||||
export type BrowserScenarioDiff = z.infer<typeof browserScenarioDiffSchema>;
|
||||
|
||||
@@ -6,13 +6,12 @@ import {
|
||||
} from "./browserObservation.js";
|
||||
import { browserCompletenessSchema } from "./browserCompleteness.js";
|
||||
|
||||
/** Input for a bounded, navigation-aware browser observation window. */
|
||||
export const observeWebSessionInputSchema = z.object({
|
||||
/** Input for a navigation-aware browser observation window. */
|
||||
export const observeWebSessionInputSchema = z.strictObject({
|
||||
cdp_endpoint: browserEndpointSchema,
|
||||
allowed_origins: browserAllowedOriginsSchema,
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
observation_ms: z.number().int().min(1).max(60_000).default(10_000),
|
||||
max_timeline_events: z.number().int().min(1).max(20_000).default(2_000),
|
||||
target_id: z.string().trim().min(1),
|
||||
observation_ms: z.number().int().min(1).default(10_000),
|
||||
});
|
||||
export type ObserveWebSessionInput = z.infer<
|
||||
typeof observeWebSessionInputSchema
|
||||
|
||||
@@ -12,11 +12,9 @@ export const electronFileRootsSchema = z
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(16_384)
|
||||
.refine(isAbsolute, "Electron file roots must be absolute paths"),
|
||||
)
|
||||
.min(1)
|
||||
.max(32)
|
||||
.transform((roots) => [...new Set(roots)].sort());
|
||||
|
||||
const approvedElectronInput = {
|
||||
@@ -24,9 +22,6 @@ const approvedElectronInput = {
|
||||
allowed_file_roots: electronFileRootsSchema,
|
||||
};
|
||||
|
||||
/** Per-script source validation bound for passive Electron capture. */
|
||||
export const MAX_ELECTRON_SCRIPT_SOURCE_BYTES = 1_024 * 1_024;
|
||||
|
||||
/** Input for listing root-confined file:// page targets from Electron CDP. */
|
||||
export const listElectronTargetsInputSchema = z.strictObject({
|
||||
...approvedElectronInput,
|
||||
@@ -38,8 +33,8 @@ export type ListElectronTargetsInput = z.infer<
|
||||
/** Input for passive structural inspection of one Electron file page. */
|
||||
const inspectElectronPageFacts = {
|
||||
...approvedElectronInput,
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
observation_ms: z.number().int().min(0).max(10_000).default(100),
|
||||
target_id: z.string().trim().min(1),
|
||||
observation_ms: z.number().int().min(0).default(100),
|
||||
} as const;
|
||||
export const inspectElectronPageInputSchema = z.union([
|
||||
z.strictObject({
|
||||
@@ -54,14 +49,14 @@ export const inspectElectronPageInputSchema = z.union([
|
||||
export const inspectElectronPageToolInputSchema = z.union([
|
||||
z.strictObject({
|
||||
discovery_evidence_id: z.string().min(1),
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
observation_ms: z.number().int().min(0).max(10_000).default(100),
|
||||
target_id: z.string().trim().min(1),
|
||||
observation_ms: z.number().int().min(0).default(100),
|
||||
include_script_sources: z.literal(false).default(false),
|
||||
}),
|
||||
z.strictObject({
|
||||
discovery_evidence_id: z.string().min(1),
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
observation_ms: z.number().int().min(0).max(10_000).default(100),
|
||||
target_id: z.string().trim().min(1),
|
||||
observation_ms: z.number().int().min(0).default(100),
|
||||
include_script_sources: z.literal(true),
|
||||
}),
|
||||
]);
|
||||
@@ -159,12 +154,12 @@ export const electronPageInspectionSchema = z.object({
|
||||
workers: z
|
||||
.array(
|
||||
z.object({
|
||||
target_id: z.string().min(1).max(256),
|
||||
type: z.string().min(1).max(100),
|
||||
target_id: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
file_path: z.string(),
|
||||
attached: z.boolean(),
|
||||
opener_target_id: z.string().min(1).max(256).nullable(),
|
||||
parent_frame_id: z.string().min(1).max(256).nullable(),
|
||||
opener_target_id: z.string().min(1).nullable(),
|
||||
parent_frame_id: z.string().min(1).nullable(),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
|
||||
@@ -27,8 +27,6 @@ import {
|
||||
} from "./javascriptStaticAnalysisHelpers.js";
|
||||
import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js";
|
||||
|
||||
const MAX_WEB_PREFERENCES = 64;
|
||||
|
||||
/** Inspect BrowserWindow, contextBridge, and utility-process syntax. */
|
||||
export const inspectElectronBrowserNode = (
|
||||
node: t.Node,
|
||||
@@ -48,7 +46,7 @@ const inspectBrowserWindow = (
|
||||
if (name !== "BrowserWindow" && !name.endsWith(".BrowserWindow")) return;
|
||||
const options = argumentNode(node.arguments[0]);
|
||||
const collected = collectWindowOptions(context.source, options);
|
||||
accountForStructure(context, collected.unknown, collected.omitted);
|
||||
context.accumulator.unknownFindings += collected.unknown;
|
||||
const finding: ElectronBrowserWindowFinding = {
|
||||
options_status:
|
||||
options === undefined
|
||||
@@ -58,7 +56,6 @@ const inspectBrowserWindow = (
|
||||
: "dynamic",
|
||||
web_preferences_status: collected.status,
|
||||
web_preferences: collected.preferences,
|
||||
omitted_web_preferences: collected.omitted,
|
||||
...collected.preload,
|
||||
module_key: null,
|
||||
location: range(node),
|
||||
@@ -79,7 +76,6 @@ const collectWindowOptions = (
|
||||
readonly preferences: readonly ElectronWebPreference[];
|
||||
readonly preload: ElectronBrowserWindowPreload;
|
||||
readonly unknown: number;
|
||||
readonly omitted: number;
|
||||
} => {
|
||||
if (!t.isObjectExpression(options))
|
||||
return {
|
||||
@@ -87,7 +83,6 @@ const collectWindowOptions = (
|
||||
preferences: [],
|
||||
preload: { preload_path: null, preload_resolution_context: null },
|
||||
unknown: options === undefined ? 0 : 1,
|
||||
omitted: 0,
|
||||
};
|
||||
const property = objectProperty(options, "webPreferences");
|
||||
if (property === undefined)
|
||||
@@ -96,7 +91,6 @@ const collectWindowOptions = (
|
||||
preferences: [],
|
||||
preload: { preload_path: null, preload_resolution_context: null },
|
||||
unknown: 0,
|
||||
omitted: 0,
|
||||
};
|
||||
if (!t.isObjectExpression(property.value))
|
||||
return {
|
||||
@@ -104,7 +98,6 @@ const collectWindowOptions = (
|
||||
preferences: [],
|
||||
preload: { preload_path: null, preload_resolution_context: null },
|
||||
unknown: 1,
|
||||
omitted: 0,
|
||||
};
|
||||
return collectWebPreferences(source, property.value);
|
||||
};
|
||||
@@ -117,7 +110,6 @@ const collectWebPreferences = (
|
||||
readonly preferences: readonly ElectronWebPreference[];
|
||||
readonly preload: ElectronBrowserWindowPreload;
|
||||
readonly unknown: number;
|
||||
readonly omitted: number;
|
||||
} => {
|
||||
const preferences: ElectronWebPreference[] = [];
|
||||
let unknown = 0;
|
||||
@@ -167,10 +159,9 @@ const collectWebPreferences = (
|
||||
preferences.sort((left, right) => compareCodePoints(left.name, right.name));
|
||||
return {
|
||||
status: "object-literal",
|
||||
preferences: preferences.slice(0, MAX_WEB_PREFERENCES),
|
||||
preferences,
|
||||
preload,
|
||||
unknown,
|
||||
omitted: Math.max(0, preferences.length - MAX_WEB_PREFERENCES),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -197,7 +188,7 @@ const inspectContextBridge = (
|
||||
(key.status === "dynamic" ? 1 : 0) +
|
||||
(worldId?.status === "dynamic" ? 1 : 0) +
|
||||
api.unknown;
|
||||
accountForStructure(context, unknown, api.omitted);
|
||||
context.accumulator.unknownFindings += unknown;
|
||||
const apiKey: ElectronContextBridgeApiKey =
|
||||
key.status === "dynamic"
|
||||
? { api_key: null, api_key_expression: key.expression }
|
||||
@@ -211,7 +202,6 @@ const inspectContextBridge = (
|
||||
api_status: api.status,
|
||||
members: api.members,
|
||||
unknown_members: api.unknown,
|
||||
omitted_members: api.omitted,
|
||||
module_key: null,
|
||||
location: range(node),
|
||||
};
|
||||
@@ -265,14 +255,3 @@ const inspectUtilityProcess = (
|
||||
value: finding,
|
||||
});
|
||||
};
|
||||
|
||||
const accountForStructure = (
|
||||
context: JavaScriptFindingContext,
|
||||
unknown: number,
|
||||
omitted: number,
|
||||
): void => {
|
||||
context.accumulator.unknownFindings += unknown;
|
||||
if (omitted === 0) return;
|
||||
context.accumulator.structuralTruncation = true;
|
||||
context.accumulator.droppedFindings += omitted;
|
||||
};
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
} from "./javascriptStaticAnalysisHelpers.js";
|
||||
import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js";
|
||||
|
||||
const MAX_NATIVE_MEMBERS = 64;
|
||||
|
||||
interface NativeBindingInput {
|
||||
readonly context: JavaScriptFindingContext;
|
||||
readonly node: t.Node;
|
||||
@@ -127,15 +125,7 @@ const addBinding = (input: NativeBindingInput): void => {
|
||||
const unique = [
|
||||
...new Set(input.members.filter((member) => member !== "")),
|
||||
].sort(compareCodePoints);
|
||||
const members = (unique.length === 0 ? ["*"] : unique).slice(
|
||||
0,
|
||||
MAX_NATIVE_MEMBERS,
|
||||
);
|
||||
const omitted = Math.max(0, unique.length - MAX_NATIVE_MEMBERS);
|
||||
if (omitted > 0) {
|
||||
context.accumulator.structuralTruncation = true;
|
||||
context.accumulator.droppedFindings += omitted;
|
||||
}
|
||||
const members = unique.length === 0 ? ["*"] : unique;
|
||||
addLocatedFinding(context, {
|
||||
collection: context.accumulator.nativeAddonBindings,
|
||||
key: `native-addon-binding\0${kind}\0${specifier}\0${members.join("\0")}`,
|
||||
@@ -144,7 +134,6 @@ const addBinding = (input: NativeBindingInput): void => {
|
||||
specifier,
|
||||
binding_kind: kind,
|
||||
members,
|
||||
members_truncated: omitted > 0,
|
||||
module_key: null,
|
||||
location: range(node),
|
||||
},
|
||||
|
||||
@@ -38,7 +38,6 @@ export type ElectronBrowserWindowFinding = ElectronBrowserWindowPreload & {
|
||||
readonly options_status: "object-literal" | "dynamic" | "missing";
|
||||
readonly web_preferences_status: "object-literal" | "dynamic" | "missing";
|
||||
readonly web_preferences: readonly ElectronWebPreference[];
|
||||
readonly omitted_web_preferences: number;
|
||||
readonly module_key: string | null;
|
||||
readonly location: JavaScriptSourceRange;
|
||||
};
|
||||
@@ -56,7 +55,6 @@ export type ElectronContextBridgeFinding = ElectronContextBridgeApiKey & {
|
||||
readonly api_status: "object-literal" | "dynamic" | "missing";
|
||||
readonly members: readonly string[];
|
||||
readonly unknown_members: number;
|
||||
readonly omitted_members: number;
|
||||
readonly module_key: string | null;
|
||||
readonly location: JavaScriptSourceRange;
|
||||
};
|
||||
@@ -165,7 +163,6 @@ export interface ElectronNativeAddonBindingFinding {
|
||||
readonly specifier: string;
|
||||
readonly binding_kind: "import" | "require" | "re-export";
|
||||
readonly members: readonly string[];
|
||||
readonly members_truncated: boolean;
|
||||
readonly module_key: string | null;
|
||||
readonly location: JavaScriptSourceRange;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
sourceSlice,
|
||||
} from "./javascriptStaticAnalysisHelpers.js";
|
||||
|
||||
const MAX_EXPRESSION_CHARACTERS = 4_096;
|
||||
|
||||
/** Preserve one literal value or the exact bounded inert expression. */
|
||||
/** Preserve one literal value or the exact inert expression. */
|
||||
export const electronStaticValue = (
|
||||
source: string,
|
||||
node: t.Node | null | undefined,
|
||||
@@ -24,17 +22,14 @@ export const electronStaticValue = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Return an actionable bounded expression without evaluating it. */
|
||||
/** Return an actionable expression without evaluating it. */
|
||||
export const boundedExpression = (
|
||||
source: string,
|
||||
node: t.Node | null | undefined,
|
||||
): string => {
|
||||
if (node === null || node === undefined) return "[missing-expression]";
|
||||
const expression = sourceSlice(source, node).trim();
|
||||
return (expression === "" ? `[${node.type}]` : expression).slice(
|
||||
0,
|
||||
MAX_EXPRESSION_CHARACTERS,
|
||||
);
|
||||
return expression === "" ? `[${node.type}]` : expression;
|
||||
};
|
||||
|
||||
/** Read one named object property without following spreads or bindings. */
|
||||
@@ -72,29 +67,25 @@ export function handlerKind(
|
||||
return "dynamic-expression";
|
||||
}
|
||||
|
||||
/** Collect bounded dotted keys from one literal contextBridge API object. */
|
||||
/** Collect statically visible dotted keys from one literal contextBridge API object. */
|
||||
export const collectContextBridgeMembers = (
|
||||
node: t.Node | null | undefined,
|
||||
maximum = 128,
|
||||
): {
|
||||
readonly status: "object-literal" | "dynamic" | "missing";
|
||||
readonly members: readonly string[];
|
||||
readonly unknown: number;
|
||||
readonly omitted: number;
|
||||
} => {
|
||||
if (node === null || node === undefined)
|
||||
return {
|
||||
status: "missing",
|
||||
members: [],
|
||||
unknown: 0,
|
||||
omitted: 0,
|
||||
};
|
||||
if (!t.isObjectExpression(node))
|
||||
return {
|
||||
status: "dynamic",
|
||||
members: [],
|
||||
unknown: 1,
|
||||
omitted: 0,
|
||||
};
|
||||
const state: { members: string[]; unknown: number } = {
|
||||
members: [],
|
||||
@@ -104,9 +95,8 @@ export const collectContextBridgeMembers = (
|
||||
const members = [...new Set(state.members)].sort(compareCodePoints);
|
||||
return {
|
||||
status: "object-literal",
|
||||
members: members.slice(0, maximum),
|
||||
members,
|
||||
unknown: state.unknown,
|
||||
omitted: Math.max(0, members.length - maximum),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -130,10 +120,7 @@ const collectMembersAt = (
|
||||
state.unknown += 1;
|
||||
continue;
|
||||
}
|
||||
const path = (prefix === "" ? name : `${prefix}.${name}`).slice(
|
||||
0,
|
||||
MAX_EXPRESSION_CHARACTERS,
|
||||
);
|
||||
const path = prefix === "" ? name : `${prefix}.${name}`;
|
||||
state.members.push(path);
|
||||
if (t.isObjectProperty(property) && t.isObjectExpression(property.value))
|
||||
collectMembersAt(property.value, path, depth + 1, state);
|
||||
@@ -152,11 +139,7 @@ const literalValue = (
|
||||
if (t.isTemplateLiteral(node) && node.expressions.length === 0)
|
||||
return {
|
||||
found: true,
|
||||
value: (
|
||||
node.quasis[0]?.value.cooked ??
|
||||
node.quasis[0]?.value.raw ??
|
||||
""
|
||||
).slice(0, MAX_EXPRESSION_CHARACTERS),
|
||||
value: node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw ?? "",
|
||||
};
|
||||
if (
|
||||
t.isUnaryExpression(node, { operator: "-" }) &&
|
||||
|
||||
@@ -13,12 +13,10 @@ describe("inferJsonShape", () => {
|
||||
],
|
||||
optional: null,
|
||||
}),
|
||||
{ maximumBytes: 4_096, maximumNodes: 100, maximumDepth: 10 },
|
||||
);
|
||||
|
||||
expect(shape).toMatchObject({
|
||||
root_type: "object",
|
||||
truncated: false,
|
||||
properties: expect.arrayContaining([
|
||||
{ path: "/token", types: ["string"], observations: 1 },
|
||||
{
|
||||
@@ -37,30 +35,22 @@ describe("inferJsonShape", () => {
|
||||
expect(JSON.stringify(shape)).not.toContain("second-secret");
|
||||
});
|
||||
|
||||
it("rejects malformed and oversized JSON without returning a prefix", () => {
|
||||
const limits = {
|
||||
maximumBytes: 8,
|
||||
maximumNodes: 100,
|
||||
maximumDepth: 10,
|
||||
};
|
||||
expect(inferJsonShape("not-json", limits)).toBeNull();
|
||||
expect(inferJsonShape('{"secret":"value"}', limits)).toBeNull();
|
||||
it("rejects malformed JSON", () => {
|
||||
expect(inferJsonShape("not-json")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports node and depth truncation deterministically", () => {
|
||||
expect(
|
||||
inferJsonShape('{"a":{"b":{"c":1}}}', {
|
||||
maximumBytes: 1_024,
|
||||
maximumNodes: 100,
|
||||
maximumDepth: 1,
|
||||
}),
|
||||
).toMatchObject({ truncated: true, max_depth_observed: 1 });
|
||||
expect(
|
||||
inferJsonShape("[1,2,3,4]", {
|
||||
maximumBytes: 1_024,
|
||||
maximumNodes: 2,
|
||||
maximumDepth: 10,
|
||||
}),
|
||||
).toMatchObject({ truncated: true, node_count: 2 });
|
||||
it("retains every parsed property beyond the former shape-node limit", () => {
|
||||
const content = Object.fromEntries(
|
||||
Array.from({ length: 5_001 }, (_, index) => [`field_${index}`, index]),
|
||||
);
|
||||
const shape = inferJsonShape(JSON.stringify(content));
|
||||
|
||||
expect(shape?.properties).toHaveLength(5_001);
|
||||
expect(shape?.node_count).toBe(5_002);
|
||||
expect(shape?.properties).toContainEqual({
|
||||
path: "/field_5000",
|
||||
types: ["number"],
|
||||
observations: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
+7
-46
@@ -22,22 +22,11 @@ export const jsonShapeSchema = z.object({
|
||||
observations: z.number().int().min(1),
|
||||
}),
|
||||
),
|
||||
truncated: z.boolean(),
|
||||
});
|
||||
export type JsonShape = z.infer<typeof jsonShapeSchema>;
|
||||
|
||||
export interface JsonShapeLimits {
|
||||
readonly maximumBytes: number;
|
||||
readonly maximumNodes: number;
|
||||
readonly maximumDepth: number;
|
||||
}
|
||||
|
||||
/** Parse approved JSON and immediately discard values after iterative shape inference. */
|
||||
export const inferJsonShape = (
|
||||
text: string,
|
||||
limits: JsonShapeLimits,
|
||||
): JsonShape | null => {
|
||||
if (Buffer.byteLength(text) > limits.maximumBytes) return null;
|
||||
export const inferJsonShape = (text: string): JsonShape | null => {
|
||||
let root: unknown;
|
||||
try {
|
||||
root = JSON.parse(text);
|
||||
@@ -55,28 +44,13 @@ export const inferJsonShape = (
|
||||
}> = [{ value: root, path: "", depth: 0 }];
|
||||
let nodeCount = 0;
|
||||
let maxDepthObserved = 0;
|
||||
let truncated = false;
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
if (nodeCount >= limits.maximumNodes) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
nodeCount += 1;
|
||||
maxDepthObserved = Math.max(maxDepthObserved, current.depth);
|
||||
if (current.depth >= limits.maximumDepth) {
|
||||
if (hasChildren(current.value)) truncated = true;
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(current.value)) {
|
||||
const capacity = Math.max(
|
||||
0,
|
||||
limits.maximumNodes - nodeCount - pending.length,
|
||||
);
|
||||
const retained = Math.min(current.value.length, capacity);
|
||||
if (retained < current.value.length) truncated = true;
|
||||
for (let index = retained - 1; index >= 0; index -= 1)
|
||||
for (let index = current.value.length - 1; index >= 0; index -= 1)
|
||||
pending.push({
|
||||
value: current.value[index],
|
||||
path: `${current.path}/*`,
|
||||
@@ -86,17 +60,11 @@ export const inferJsonShape = (
|
||||
}
|
||||
if (!isRecord(current.value)) continue;
|
||||
const allEntries = Object.entries(current.value);
|
||||
const capacity = Math.max(
|
||||
0,
|
||||
limits.maximumNodes - nodeCount - pending.length,
|
||||
);
|
||||
const entries = allEntries.slice(0, capacity);
|
||||
if (entries.length < allEntries.length) truncated = true;
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index];
|
||||
for (let index = allEntries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = allEntries[index];
|
||||
if (entry === undefined) continue;
|
||||
const [name, value] = entry;
|
||||
const path = `${current.path}/${pointerName(name)}`.slice(0, 2_048);
|
||||
const path = `${current.path}/${pointerName(name)}`;
|
||||
const type = jsonValueType(value);
|
||||
const existing = properties.get(path);
|
||||
if (existing === undefined)
|
||||
@@ -118,9 +86,7 @@ export const inferJsonShape = (
|
||||
types: [...value.types].sort(),
|
||||
observations: value.observations,
|
||||
}))
|
||||
.sort((left, right) => left.path.localeCompare(right.path))
|
||||
.slice(0, limits.maximumNodes),
|
||||
truncated,
|
||||
.sort((left, right) => left.path.localeCompare(right.path)),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -139,13 +105,8 @@ const jsonValueType = (value: unknown): JsonValueType => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasChildren = (value: unknown): boolean =>
|
||||
Array.isArray(value)
|
||||
? value.length > 0
|
||||
: isRecord(value) && Object.keys(value).length > 0;
|
||||
|
||||
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const pointerName = (value: string): string =>
|
||||
value.slice(0, 256).replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
value.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
@@ -346,8 +346,7 @@ const accessibilityComparable = (
|
||||
} => {
|
||||
const text =
|
||||
inspection.accessibility.text_capture.status === "included" &&
|
||||
inspection.accessibility.text_capture.excluded_fields === 0 &&
|
||||
inspection.accessibility.text_capture.truncated_fields === 0;
|
||||
inspection.accessibility.text_capture.excluded_fields === 0;
|
||||
const nodes =
|
||||
inspection.accessibility.total_nodes ===
|
||||
inspection.accessibility.nodes.length;
|
||||
|
||||
@@ -11,8 +11,8 @@ import { jsonShapeSchema } from "./jsonShape.js";
|
||||
export const discoverWebMcpToolsInputSchema = z.strictObject({
|
||||
cdp_endpoint: browserEndpointSchema,
|
||||
allowed_origins: browserAllowedOriginsSchema,
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
observation_ms: z.number().int().min(0).max(10_000).default(100),
|
||||
target_id: z.string().trim().min(1),
|
||||
observation_ms: z.number().int().min(0).default(100),
|
||||
});
|
||||
export type DiscoverWebMcpToolsInput = z.infer<
|
||||
typeof discoverWebMcpToolsInputSchema
|
||||
|
||||
@@ -8,15 +8,13 @@ import {
|
||||
browserEndpointSchema,
|
||||
} from "./browserObservation.js";
|
||||
|
||||
const MAX_SCREENSHOT_BYTES = 8 * 1_024 * 1_024;
|
||||
|
||||
/** 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).max(MAX_SCREENSHOT_BYTES),
|
||||
bytes: z.number().int().min(1),
|
||||
media_type: z.literal("image/png"),
|
||||
data_base64: z.string().max(Math.ceil((MAX_SCREENSHOT_BYTES * 4) / 3) + 4),
|
||||
data_base64: z.string(),
|
||||
})
|
||||
.superRefine((artifact, context) => {
|
||||
const bytes = decodeCanonicalBase64(artifact.data_base64);
|
||||
@@ -34,16 +32,10 @@ export const webScreenshotArtifactSchema = z
|
||||
export type WebScreenshotArtifact = z.infer<typeof webScreenshotArtifactSchema>;
|
||||
|
||||
/** Input for one read-only visible-viewport screenshot. */
|
||||
export const captureWebScreenshotInputSchema = z.object({
|
||||
export const captureWebScreenshotInputSchema = z.strictObject({
|
||||
cdp_endpoint: browserEndpointSchema,
|
||||
allowed_origins: browserAllowedOriginsSchema,
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
maximum_image_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(MAX_SCREENSHOT_BYTES)
|
||||
.default(4 * 1_024 * 1_024),
|
||||
});
|
||||
export type CaptureWebScreenshotInput = z.infer<
|
||||
typeof captureWebScreenshotInputSchema
|
||||
@@ -57,7 +49,7 @@ const browserVersionSchema = z.object({
|
||||
js_version: z.string(),
|
||||
});
|
||||
|
||||
/** Bounded screenshot observation with embedded immutable artifact bytes. */
|
||||
/** Screenshot observation with embedded immutable artifact bytes. */
|
||||
export const webScreenshotSchema = z.object({
|
||||
browser: browserVersionSchema,
|
||||
target: z.object({
|
||||
@@ -76,12 +68,11 @@ export const webScreenshotSchema = z.object({
|
||||
});
|
||||
export type WebScreenshot = z.infer<typeof webScreenshotSchema>;
|
||||
|
||||
/** Input for bounded local pixel comparison of two screenshot artifacts. */
|
||||
export const compareWebScreenshotsInputSchema = z.object({
|
||||
/** 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),
|
||||
maximum_pixels: z.number().int().min(1).max(32_000_000).default(16_000_000),
|
||||
});
|
||||
export type CompareWebScreenshotsInput = z.infer<
|
||||
typeof compareWebScreenshotsInputSchema
|
||||
@@ -153,7 +144,7 @@ export const webScreenshotDiffSchema = z
|
||||
});
|
||||
export type WebScreenshotDiff = z.infer<typeof webScreenshotDiffSchema>;
|
||||
|
||||
/** Create a content-addressed PNG artifact from already bounded bytes. */
|
||||
/** Create a content-addressed PNG artifact from captured bytes. */
|
||||
export const createWebScreenshotArtifact = (
|
||||
bytes: Buffer,
|
||||
): WebScreenshotArtifact => {
|
||||
@@ -168,10 +159,7 @@ export const createWebScreenshotArtifact = (
|
||||
|
||||
/** Strict canonical base64 decoder used before digest validation. */
|
||||
export const decodeCanonicalBase64 = (value: string): Buffer | undefined => {
|
||||
if (value.length === 0 || value.length % 4 !== 0 || !BASE64.test(value))
|
||||
return undefined;
|
||||
if (value.length === 0 || value.length % 4 !== 0) return undefined;
|
||||
const decoded = Buffer.from(value, "base64");
|
||||
return decoded.toString("base64") === value ? decoded : undefined;
|
||||
};
|
||||
|
||||
const BASE64 = /^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/u;
|
||||
@@ -20,7 +20,7 @@ describeBrowser("CdpBrowserProvider: document script 1", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("captures bounded accessibility text only after independent approval", async () => {
|
||||
it("captures approved accessibility text inline", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
trackBrowser(browser);
|
||||
const request = inspectWebPageInputSchema.parse({
|
||||
@@ -30,26 +30,16 @@ describeBrowser("CdpBrowserProvider: document script 1", () => {
|
||||
observation_ms: 0,
|
||||
include_accessibility_text: true,
|
||||
});
|
||||
const result = await new CdpBrowserProvider().inspectPage({
|
||||
...request,
|
||||
limits: {
|
||||
...request.limits,
|
||||
max_ax_text_field_bytes: 6,
|
||||
},
|
||||
});
|
||||
const result = await new CdpBrowserProvider().inspectPage(request);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.accessibility).toMatchObject({
|
||||
text_capture: {
|
||||
status: "truncated",
|
||||
retained_bytes: 12,
|
||||
truncated_fields: 2,
|
||||
status: "included",
|
||||
retained_bytes: 27,
|
||||
},
|
||||
nodes: [expect.objectContaining({ name: "Submit" })],
|
||||
nodes: [expect.objectContaining({ name: "Submit report" })],
|
||||
});
|
||||
expect(result.value.completeness.truncated_sections).toContain(
|
||||
"accessibility",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes allowed script source only after explicit approval", async () => {
|
||||
@@ -288,6 +278,38 @@ describeBrowser("CdpBrowserProvider: document script 2", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowser("CdpBrowserProvider WebMCP inventory completeness", () => {
|
||||
it("retains every field in a large declared WebMCP input schema", async () => {
|
||||
const propertyCount = 5_001;
|
||||
const browser = await startFakeCdpBrowser({
|
||||
webMcpTools: true,
|
||||
webMcpSchemaPropertyCount: propertyCount,
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().discoverWebMcpTools(
|
||||
discoverWebMcpToolsInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
const shape = result.value.tools.items[0]?.input_schema_shape;
|
||||
expect(
|
||||
shape?.properties.filter(({ path }) =>
|
||||
path.startsWith("/properties/field_"),
|
||||
),
|
||||
).toHaveLength(propertyCount * 2);
|
||||
expect(shape?.properties).toContainEqual({
|
||||
path: "/properties/field_5000/type",
|
||||
types: ["string"],
|
||||
observations: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowser("CdpBrowserProvider: document script 3", () => {
|
||||
it("removes WebMCP declarations after their child frame leaves scope", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
@@ -369,7 +391,7 @@ describeBrowser("CdpBrowserProvider: document script 3", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns every DOM node and applies the per-script source limit", async () => {
|
||||
it("returns every DOM node and complete approved script source", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
trackBrowser(browser);
|
||||
const request = inspectWebPageInputSchema.parse({
|
||||
@@ -379,24 +401,17 @@ describeBrowser("CdpBrowserProvider: document script 3", () => {
|
||||
observation_ms: 0,
|
||||
include_script_sources: true,
|
||||
});
|
||||
const result = await new CdpBrowserProvider().inspectPage({
|
||||
...request,
|
||||
limits: {
|
||||
...request.limits,
|
||||
max_script_source_bytes: 10,
|
||||
},
|
||||
});
|
||||
const result = await new CdpBrowserProvider().inspectPage(request);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.completeness).toMatchObject({
|
||||
truncated_sections: ["script_sources"],
|
||||
});
|
||||
expect(result.value.completeness.truncated_sections).not.toContain(
|
||||
"script_sources",
|
||||
);
|
||||
expect(result.value.dom).toMatchObject({ total_nodes: 2 });
|
||||
expect(result.value.dom.nodes).toHaveLength(2);
|
||||
expect(result.value.scripts.items[0]?.source).toMatchObject({
|
||||
included: false,
|
||||
reason: "declared script length exceeds per-script limit",
|
||||
included: true,
|
||||
});
|
||||
expect(browser.commands.map(({ method }) => method)).not.toContain(
|
||||
expect(browser.commands.map(({ method }) => method)).toContain(
|
||||
"Debugger.getScriptSource",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -270,7 +270,7 @@ describeBrowser("CdpBrowserProvider: lifecycle failures 3", () => {
|
||||
);
|
||||
|
||||
for (const [options, reason] of [
|
||||
[{ oversizedDiscovery: true }, "payload_limit"],
|
||||
[{ oversizedDiscovery: true }, "invalid_endpoint_response"],
|
||||
[{ invalidBrowserWebSocket: true }, "invalid_endpoint_response"],
|
||||
] as const) {
|
||||
const failed = await startFakeCdpBrowser(options);
|
||||
|
||||
@@ -158,6 +158,33 @@ describeBrowser("CdpBrowserProvider: sensitive data 1", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowser("CdpBrowserProvider: complete storage fingerprints", () => {
|
||||
it("fingerprints cache bodies above the former byte ceiling", async () => {
|
||||
const body = "x".repeat(64 * 1_024 + 1);
|
||||
const browser = await startFakeCdpBrowser({ cachedResponseBody: body });
|
||||
trackBrowser(browser);
|
||||
const result = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 0,
|
||||
include_storage_keys: true,
|
||||
include_storage_fingerprints: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.storage.fingerprints_complete).toBe(true);
|
||||
expect(
|
||||
result.value.storage.content_fingerprints.find(
|
||||
({ scope }) => scope === "cache_entry",
|
||||
),
|
||||
).toMatchObject({ complete: true, value_sha256: expect.any(String) });
|
||||
expect(JSON.stringify(result.value)).not.toContain(body);
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowser("CdpBrowserProvider: sensitive data 2", () => {
|
||||
it("captures only approved redacted console text and value-free payload shapes", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
@@ -185,7 +212,6 @@ describeBrowser("CdpBrowserProvider: sensitive data 2", () => {
|
||||
{ argument_index: 1, type: "number", text: "42" },
|
||||
],
|
||||
retained_bytes: 26,
|
||||
truncated_values: 0,
|
||||
});
|
||||
expect(result.value.network.requests[0]?.body_shapes).toMatchObject({
|
||||
status: "included",
|
||||
@@ -227,7 +253,6 @@ describeBrowser("CdpBrowserProvider: sensitive data 2", () => {
|
||||
payload_shape: {
|
||||
format: "binary",
|
||||
json_shape: null,
|
||||
truncated: false,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
@@ -247,8 +272,8 @@ describeBrowser("CdpBrowserProvider: sensitive data 2", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowser("CdpBrowserProvider: sensitive data 3", () => {
|
||||
it("keeps per-value text, body, and WebSocket shape validation", async () => {
|
||||
describeBrowser("CdpBrowserProvider: complete sensitive data", () => {
|
||||
it("returns complete approved text and shapes while redacting secrets", async () => {
|
||||
const browser = await startFakeCdpBrowser({ sensitiveShapes: true });
|
||||
trackBrowser(browser);
|
||||
const request = inspectWebPageInputSchema.parse({
|
||||
@@ -260,41 +285,24 @@ describeBrowser("CdpBrowserProvider: sensitive data 3", () => {
|
||||
include_json_body_shapes: true,
|
||||
include_websocket_shapes: true,
|
||||
});
|
||||
const result = await new CdpBrowserProvider().inspectPage({
|
||||
...request,
|
||||
limits: {
|
||||
...request.limits,
|
||||
max_console_text_field_bytes: 5,
|
||||
max_json_body_bytes: 10,
|
||||
max_websocket_shape_bytes: 5,
|
||||
},
|
||||
});
|
||||
const result = await new CdpBrowserProvider().inspectPage(request);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.console.events[0]?.text_capture).toEqual({
|
||||
status: "truncated",
|
||||
status: "included",
|
||||
values: [
|
||||
{ argument_index: 0, type: "string", text: "autho" },
|
||||
{ argument_index: 0, type: "string", text: "authorization=[REDACTED]" },
|
||||
{ argument_index: 1, type: "number", text: "42" },
|
||||
],
|
||||
retained_bytes: 7,
|
||||
truncated_values: 1,
|
||||
retained_bytes: 26,
|
||||
});
|
||||
expect(result.value.network.requests[0]?.body_shapes.status).toBe(
|
||||
"truncated",
|
||||
"included",
|
||||
);
|
||||
expect(result.value.network.websocket_events[0]?.payload_shape).toEqual({
|
||||
format: "text",
|
||||
json_shape: null,
|
||||
truncated: true,
|
||||
format: "json",
|
||||
json_shape: expect.objectContaining({ root_type: "object" }),
|
||||
});
|
||||
expect(result.value.completeness.truncated_sections).toEqual(
|
||||
expect.arrayContaining([
|
||||
"console_text",
|
||||
"json_body_shapes",
|
||||
"websocket_shapes",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed on malformed approved response and binary payload encodings", async () => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
||||
import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js";
|
||||
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
|
||||
import { describeBrowser, trackBrowser } from "./cdpBrowserProvider.support.js";
|
||||
|
||||
describeBrowser("CdpBrowserProvider navigation timeline", () => {
|
||||
it("accepts caller-selected windows and rejects the removed event-count option", () => {
|
||||
const accepted = observeWebSessionInputSchema.safeParse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
allowed_origins: ["https://app.example.test"],
|
||||
target_id: "page-1",
|
||||
observation_ms: 120_000,
|
||||
});
|
||||
expect(accepted.success).toBe(true);
|
||||
expect(
|
||||
observeWebSessionInputSchema.safeParse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
allowed_origins: ["https://app.example.test"],
|
||||
target_id: "page-1",
|
||||
max_timeline_events: 1,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns every event beyond the former default collection cap", async () => {
|
||||
const additionalEvents = 2_001;
|
||||
const browser = await startFakeCdpBrowser({
|
||||
pageScopedVersionWebSocket: true,
|
||||
omitTargetWebSocket: true,
|
||||
sessionTimeline: "same_origin",
|
||||
sessionTimelineEventCount: additionalEvents,
|
||||
});
|
||||
trackBrowser(browser);
|
||||
|
||||
const result = await new CdpBrowserProvider().observeSession(
|
||||
observeWebSessionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.timeline).toHaveLength(additionalEvents + 6);
|
||||
expect(result.value.completeness.truncated_sections).not.toContain(
|
||||
"timeline",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -96,4 +96,49 @@ describe("safe CDP response metadata", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains every parsed header, CSP directive, link, relation, and policy feature", () => {
|
||||
const csp = Array.from(
|
||||
{ length: 105 },
|
||||
(_, index) => `x-${String(index)} 'self'`,
|
||||
).join("; ");
|
||||
const links = Array.from(
|
||||
{ length: 105 },
|
||||
(_, index) => `</${String(index)}>; rel="mcp service-desc"`,
|
||||
).join(", ");
|
||||
const permissions = Array.from(
|
||||
{ length: 205 },
|
||||
(_, index) => `feature-${String(index)}=()`,
|
||||
).join(", ");
|
||||
const headers = Object.fromEntries(
|
||||
Array.from({ length: 501 }, (_, index) => [
|
||||
`x-extra-${String(index)}`,
|
||||
"ok",
|
||||
]),
|
||||
);
|
||||
const captured = safeResponseMetadata(
|
||||
"request-1",
|
||||
`${origin}/api`,
|
||||
{
|
||||
mimeType: "application/json",
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Security-Policy": csp,
|
||||
Link: links,
|
||||
"Permissions-Policy": permissions,
|
||||
"X-Model-Context": "present-after-many-headers",
|
||||
},
|
||||
},
|
||||
new Set([origin]),
|
||||
);
|
||||
|
||||
expect(captured.response.csp.directives).toHaveLength(105);
|
||||
expect(captured.response.links).toHaveLength(105);
|
||||
expect(captured.response.policies.permissions_policy_features).toHaveLength(
|
||||
205,
|
||||
);
|
||||
expect(captured.agentHints).toContainEqual(
|
||||
expect.objectContaining({ declaration: "x-model-context" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -63,8 +63,6 @@ describe("web source-map fetching and validation", () => {
|
||||
status: "included",
|
||||
requested: 1,
|
||||
processed: 1,
|
||||
dropped: 0,
|
||||
dropped_script_keys: [],
|
||||
items: [
|
||||
{
|
||||
status: "included",
|
||||
@@ -96,44 +94,6 @@ describe("web source-map fetching and validation", () => {
|
||||
expectInvalidIncludedSourceMaps(result);
|
||||
});
|
||||
|
||||
it("validates sectioned maps and reports bounded mapping truncation", async () => {
|
||||
const regular = {
|
||||
version: 3,
|
||||
names: [],
|
||||
sources: ["a.js"],
|
||||
sourcesContent: ["export const a = 1"],
|
||||
mappings: "AAAA;AACA",
|
||||
};
|
||||
const result = await fetchWebSourceMaps(
|
||||
[request],
|
||||
input({ analysis_limits: { max_source_map_mappings: 1 } }),
|
||||
undefined,
|
||||
{
|
||||
fetch: () =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
sections: [{ offset: { line: 0, column: 0 }, map: regular }],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "truncated",
|
||||
items: [
|
||||
{
|
||||
status: "truncated",
|
||||
mappings: [expect.any(Object)],
|
||||
limitation: expect.stringContaining("truncated from 2 to 1"),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("reauthorizes every redirect and never contacts a disallowed origin", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await fetchWebSourceMaps([request], input(), undefined, {
|
||||
@@ -159,101 +119,108 @@ describe("web source-map fetching and validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes invalid JSON from declared or streamed byte truncation", async () => {
|
||||
for (const response of [
|
||||
new Response("not-json", { status: 200 }),
|
||||
new Response("{}", {
|
||||
status: 200,
|
||||
headers: { "content-length": "999999" },
|
||||
}),
|
||||
]) {
|
||||
const result = await fetchWebSourceMaps(
|
||||
[request],
|
||||
input({ analysis_limits: { max_source_map_bytes: 100 } }),
|
||||
undefined,
|
||||
{ fetch: () => Promise.resolve(response) },
|
||||
);
|
||||
expect(result.items[0]?.status).toBe(
|
||||
response.headers.has("content-length") ? "truncated" : "invalid",
|
||||
);
|
||||
}
|
||||
it("follows an approved redirect chain without an arbitrary hop ceiling", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await fetchWebSourceMaps([request], input(), undefined, {
|
||||
fetch: (url) => {
|
||||
const current = String(url);
|
||||
calls.push(current);
|
||||
const hop = Number(new URL(current).searchParams.get("hop") ?? "0");
|
||||
return Promise.resolve(
|
||||
hop < 7
|
||||
? new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: `${origin}/assets/app.js.map?hop=${String(hop + 1)}`,
|
||||
},
|
||||
})
|
||||
: validMapResponse(),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(8);
|
||||
expect(result.items[0]?.status).toBe("included");
|
||||
});
|
||||
|
||||
it("reports malformed source-map JSON as invalid", async () => {
|
||||
const result = await fetchWebSourceMaps([request], input(), undefined, {
|
||||
fetch: () => Promise.resolve(new Response("not-json", { status: 200 })),
|
||||
});
|
||||
expect(result.items[0]?.status).toBe("invalid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("web source-map aggregate limits", () => {
|
||||
it("bounds aggregate map count and reports omitted requests", async () => {
|
||||
const calls: string[] = [];
|
||||
const requests = [0, 1, 2].map((index) => ({
|
||||
...request,
|
||||
scriptKey: `scr_${String(index + 1).repeat(64)}`,
|
||||
fetchUrl: `${origin}/assets/${String(index)}.js.map`,
|
||||
}));
|
||||
const result = await fetchWebSourceMaps(
|
||||
requests,
|
||||
input({ analysis_limits: { max_source_maps: 1 } }),
|
||||
undefined,
|
||||
{
|
||||
fetch: (url) => {
|
||||
calls.push(String(url));
|
||||
return Promise.resolve(validMapResponse());
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls).toEqual([requests[0]?.fetchUrl]);
|
||||
expect(result).toMatchObject({
|
||||
status: "truncated",
|
||||
requested: 3,
|
||||
processed: 1,
|
||||
dropped: 2,
|
||||
dropped_script_keys: [requests[1]?.scriptKey, requests[2]?.scriptKey],
|
||||
describe("web source-map collection", () => {
|
||||
it("retains every mapping from a sectioned source map", async () => {
|
||||
const segmentCount = 10_001;
|
||||
const regular = {
|
||||
version: 3,
|
||||
names: [],
|
||||
sources: ["a.js"],
|
||||
sourcesContent: ["export const a = 1"],
|
||||
mappings: Array.from({ length: segmentCount }, () => "AAAA").join(","),
|
||||
};
|
||||
const result = await fetchWebSourceMaps([request], input(), undefined, {
|
||||
fetch: () =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
sections: [{ offset: { line: 0, column: 0 }, map: regular }],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.status).toBe("included");
|
||||
expect(result.items[0]?.status).toBe("included");
|
||||
expect(result.items[0]?.mappings).toHaveLength(segmentCount);
|
||||
});
|
||||
|
||||
it("stops after exhausting the aggregate response-byte budget", async () => {
|
||||
const requests = [0, 1, 2].map((index) => ({
|
||||
it("fetches and returns every requested map inline", async () => {
|
||||
const calls: string[] = [];
|
||||
const requests = Array.from({ length: 101 }, (_, index) => ({
|
||||
...request,
|
||||
scriptKey: `scr_${String(index + 1).repeat(64)}`,
|
||||
scriptKey: `scr_${String(index + 1).padStart(64, "0")}`,
|
||||
fetchUrl: `${origin}/assets/${String(index)}.js.map`,
|
||||
}));
|
||||
const calls: string[] = [];
|
||||
const result = await fetchWebSourceMaps(
|
||||
requests,
|
||||
input({
|
||||
analysis_limits: {
|
||||
max_source_map_bytes: 128,
|
||||
max_total_source_map_bytes: 128,
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
fetch: (url) => {
|
||||
calls.push(String(url));
|
||||
return Promise.resolve(validMapResponse());
|
||||
},
|
||||
const result = await fetchWebSourceMaps(requests, input(), undefined, {
|
||||
fetch: (url) => {
|
||||
calls.push(String(url));
|
||||
return Promise.resolve(validMapResponse());
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(result).toMatchObject({
|
||||
status: "truncated",
|
||||
requested: 3,
|
||||
processed: 2,
|
||||
dropped: 1,
|
||||
dropped_script_keys: [requests[2]?.scriptKey],
|
||||
items: [{ status: "included" }, { status: "truncated" }],
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(requests.length);
|
||||
expect(result).toMatchObject({
|
||||
status: "included",
|
||||
requested: requests.length,
|
||||
processed: requests.length,
|
||||
});
|
||||
expect(result.items).toHaveLength(requests.length);
|
||||
});
|
||||
|
||||
it("does not accept caller overrides for provider source-map limits", () => {
|
||||
const parsed = analyzeWebBundleInputSchema.safeParse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
allowed_origins: [origin],
|
||||
target_id: "page-1",
|
||||
analysis_limits: { max_source_maps: 0 },
|
||||
it("includes source-map text above the former per-map byte budget", async () => {
|
||||
const content = "x".repeat(8 * 1_024 * 1_024 + 1);
|
||||
const map = JSON.stringify({
|
||||
version: 3,
|
||||
names: [],
|
||||
sources: ["large.ts"],
|
||||
sourcesContent: [content],
|
||||
mappings: "AAAA",
|
||||
});
|
||||
const result = await fetchWebSourceMaps([request], input(), undefined, {
|
||||
fetch: () => Promise.resolve(new Response(map, { status: 200 })),
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(result.status).toBe("included");
|
||||
expect(result.items[0]?.status).toBe("included");
|
||||
if (result.items[0]?.status !== "included") throw new Error("not included");
|
||||
expect(result.items[0].original_sources[0]?.artifact?.bytes).toBe(
|
||||
Buffer.byteLength(content),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,21 +237,11 @@ const validMapResponse = () =>
|
||||
);
|
||||
|
||||
const input = (overrides: Record<string, unknown> = {}) => {
|
||||
const { analysis_limits: internalLimitOverrides, ...callerOverrides } =
|
||||
overrides;
|
||||
const parsed = analyzeWebBundleInputSchema.parse({
|
||||
return analyzeWebBundleInputSchema.parse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
allowed_origins: [origin],
|
||||
target_id: "page-1",
|
||||
fetch_source_maps: true,
|
||||
...callerOverrides,
|
||||
...overrides,
|
||||
});
|
||||
const limits = {
|
||||
...parsed.analysis_limits,
|
||||
...(internalLimitOverrides as Record<string, number> | undefined),
|
||||
};
|
||||
return {
|
||||
...parsed,
|
||||
analysis_limits: limits,
|
||||
};
|
||||
};
|
||||
@@ -232,7 +232,7 @@ describe("browser scenario MCP tool", () => {
|
||||
"evidence_id",
|
||||
);
|
||||
expect(session.evidenceById(String(evidenceId))).toMatchObject({
|
||||
predicate_type: "rea.browser-scenario-capture/v1",
|
||||
predicate_type: "rea.browser-scenario-capture",
|
||||
});
|
||||
const captureResult = Reflect.get(
|
||||
captured.structuredContent ?? {},
|
||||
|
||||
@@ -273,7 +273,7 @@ it("exposes active Electron scenarios through the separately granted MCP boundar
|
||||
"evidence_id",
|
||||
);
|
||||
expect(session.evidenceById(String(evidenceId))).toMatchObject({
|
||||
predicate_type: "rea.electron-active-scenario/v1",
|
||||
predicate_type: "rea.electron-active-scenario",
|
||||
parameters: {
|
||||
args: ["--token", "<redacted>"],
|
||||
actions: [{ step_id: "submit", kind: "click" }],
|
||||
|
||||
+24
-4
@@ -23,6 +23,18 @@ const emitWebMcpEvents = (
|
||||
options: FakeOptions,
|
||||
): void => {
|
||||
if (command.method === "WebMCP.enable" && options.webMcpTools === true) {
|
||||
const inputSchemaProperties =
|
||||
options.webMcpSchemaPropertyCount === undefined
|
||||
? {
|
||||
orderId: { type: "string", example: "schema-secret" },
|
||||
includeItems: { type: "boolean", default: true },
|
||||
}
|
||||
: Object.fromEntries(
|
||||
Array.from(
|
||||
{ length: options.webMcpSchemaPropertyCount },
|
||||
(_value, index) => [`field_${String(index)}`, { type: "string" }],
|
||||
),
|
||||
);
|
||||
event(socket, "WebMCP.toolsAdded", command.sessionId, {
|
||||
tools: [
|
||||
{
|
||||
@@ -32,10 +44,7 @@ const emitWebMcpEvents = (
|
||||
backendNodeId: 42,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
orderId: { type: "string", example: "schema-secret" },
|
||||
includeItems: { type: "boolean", default: true },
|
||||
},
|
||||
properties: inputSchemaProperties,
|
||||
required: ["orderId"],
|
||||
},
|
||||
annotations: {
|
||||
@@ -170,6 +179,17 @@ const emitSessionTimeline = (
|
||||
name: "networkIdle",
|
||||
timestamp: 15,
|
||||
});
|
||||
for (
|
||||
let index = 0;
|
||||
index < (options.sessionTimelineEventCount ?? 0);
|
||||
index += 1
|
||||
)
|
||||
event(socket, "Page.lifecycleEvent", command.sessionId, {
|
||||
frameId: "frame-main",
|
||||
loaderId: "loader-reload",
|
||||
name: `lifecycle-${String(index)}`,
|
||||
timestamp: 16 + index,
|
||||
});
|
||||
if (options.sessionTimeline === "target_detached")
|
||||
event(socket, "Target.detachedFromTarget", undefined, {
|
||||
sessionId: command.sessionId,
|
||||
|
||||
+7
-3
@@ -43,7 +43,7 @@ export const resultFor = (
|
||||
case "CacheStorage.requestEntries":
|
||||
return cacheEntriesResult(port);
|
||||
case "CacheStorage.requestCachedResponse":
|
||||
return cachedResponseResult();
|
||||
return cachedResponseResult(options);
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -190,8 +190,12 @@ const cacheEntriesResult = (port: number) => ({
|
||||
returnCount: 1,
|
||||
});
|
||||
|
||||
const cachedResponseResult = () => ({
|
||||
response: { body: Buffer.from("cache-body-secret").toString("base64") },
|
||||
const cachedResponseResult = (options: FakeOptions) => ({
|
||||
response: {
|
||||
body: Buffer.from(
|
||||
options.cachedResponseBody ?? "cache-body-secret",
|
||||
).toString("base64"),
|
||||
},
|
||||
});
|
||||
|
||||
export const versionTargetId = (options: FakeOptions): string =>
|
||||
|
||||
+3
@@ -53,11 +53,14 @@ export interface FakeOptions {
|
||||
| "same_origin"
|
||||
| "outside_policy"
|
||||
| "target_detached";
|
||||
readonly sessionTimelineEventCount?: number;
|
||||
readonly closeAfterMethod?: string;
|
||||
readonly sensitiveShapes?: boolean;
|
||||
readonly cachedResponseBody?: string;
|
||||
readonly invalidResponseBodyBase64?: boolean;
|
||||
readonly webMcpTools?: boolean;
|
||||
readonly webMcpFrameCount?: number;
|
||||
readonly webMcpSchemaPropertyCount?: number;
|
||||
readonly webMcpChildLeavesScope?: boolean;
|
||||
readonly electronFileUrl?: string;
|
||||
readonly duplicateElectronInventory?: boolean;
|
||||
|
||||
Reference in new issue
Block a user