From 0e2738ffb6aa35bb06fe01d0af864aec968570cf Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:56:49 +0800 Subject: [PATCH] refactor(browser): return complete observation evidence --- AGENTS.md | 2 +- docs/browser-observation.md | 30 +-- src/application/BrowserEvidence.ts | 61 ++--- src/application/BrowserScenarioEvidence.ts | 2 +- src/application/ElectronActiveEvidence.ts | 2 +- .../ElectronBoundaryGraphNative.ts | 1 - .../ElectronBoundaryGraphWindows.ts | 2 - src/application/ElectronEvidence.ts | 4 +- src/browser/CdpBrowserProvider.ts | 4 +- src/browser/CdpCaptureDocuments.test.ts | 12 +- src/browser/CdpCaptureDocuments.ts | 61 +---- src/browser/CdpCaptureEventBodyShapes.ts | 46 +--- src/browser/CdpCaptureEventHandlers.ts | 68 ++---- src/browser/CdpCaptureEvents.ts | 2 +- src/browser/CdpCaptureStorage.ts | 8 +- src/browser/CdpCaptureStorageFingerprints.ts | 27 +-- src/browser/CdpConnection.ts | 6 +- src/browser/CdpElectronInspection.ts | 14 +- src/browser/CdpElectronProvider.ts | 2 +- src/browser/CdpElectronScriptEvents.ts | 33 +-- src/browser/CdpElectronScripts.ts | 15 -- src/browser/CdpElectronWorkers.ts | 13 +- src/browser/CdpEndpoint.ts | 68 ++---- src/browser/CdpObservationSession.ts | 19 +- src/browser/CdpPageCapture.ts | 24 +- src/browser/CdpPageCaptureScripts.ts | 11 +- src/browser/CdpPageCaptureWorkers.ts | 9 +- src/browser/CdpSafeMetadata.ts | 42 ++-- src/browser/CdpScreenshot.ts | 9 +- src/browser/CdpTargetSession.ts | 3 +- src/browser/CdpWebMcpDiscovery.ts | 25 +- src/browser/PngVisualDiff.test.ts | 7 +- src/browser/PngVisualDiff.ts | 19 +- src/browser/SensitiveTextCapture.test.ts | 13 +- src/browser/SensitiveTextCapture.ts | 24 -- src/browser/V8InspectorEndpoint.ts | 35 +-- src/browser/WebSourceMapFetcher.ts | 217 +++++------------ src/cliBrowserAdvancedCommands.ts | 16 -- src/cliBrowserCommands.ts | 19 +- src/cliObservationOptions.ts | 16 +- src/contracts/browserScenarioToolContracts.ts | 2 +- src/contracts/browserToolContracts.ts | 9 +- src/domain/browserObservation.test.ts | 36 +-- src/domain/browserObservation.ts | 61 ++--- src/domain/browserObservationSchemas.ts | 15 +- src/domain/browserScenarioDiff.test.ts | 54 +++++ src/domain/browserScenarioDiffValues.ts | 46 ++-- src/domain/browserSession.ts | 9 +- src/domain/electronObservation.ts | 25 +- src/domain/electronStaticAnalysisBrowser.ts | 27 +-- src/domain/electronStaticAnalysisNative.ts | 13 +- src/domain/electronStaticAnalysisTypes.ts | 3 - src/domain/electronStaticAnalysisValues.ts | 31 +-- src/domain/jsonShape.test.ts | 40 ++-- src/domain/jsonShape.ts | 53 +---- src/domain/webCaptureDiff.ts | 3 +- src/domain/webMcpDiscovery.ts | 4 +- src/domain/webScreenshot.ts | 28 +-- ...cdpBrowserProvider.document_script.test.ts | 73 +++--- ...BrowserProvider.lifecycle_failures.test.ts | 2 +- .../cdpBrowserProvider.sensitive_data.test.ts | 64 ++--- .../cdpBrowserProvider.timelineEvents.test.ts | 53 +++++ .../boundary/browser/cdpSafeMetadata.test.ts | 45 ++++ .../browser/webSourceMapFetcher.test.ts | 221 +++++++----------- tests/boundary/mcp/browserScenarioMcp.test.ts | 2 +- tests/boundary/mcp/electronMcp.test.ts | 2 +- tests/fixtures/fakeCdpBrowserEvents.ts | 28 ++- tests/fixtures/fakeCdpBrowserResults.ts | 10 +- tests/fixtures/fakeCdpBrowserTypes.ts | 3 + 69 files changed, 747 insertions(+), 1206 deletions(-) create mode 100644 tests/boundary/browser/cdpBrowserProvider.timelineEvents.test.ts diff --git a/AGENTS.md b/AGENTS.md index 48a47f43dd..c97ef9cfee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 67aaaaa58b..5f70641bf1 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -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 diff --git a/src/application/BrowserEvidence.ts b/src/application/BrowserEvidence.ts index c59b6f772d..4cbd8df8c7 100644 --- a/src/application/BrowserEvidence.ts +++ b/src/application/BrowserEvidence.ts @@ -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"; } }; diff --git a/src/application/BrowserScenarioEvidence.ts b/src/application/BrowserScenarioEvidence.ts index 733a1f5ddf..752f471b33 100644 --- a/src/application/BrowserScenarioEvidence.ts +++ b/src/application/BrowserScenarioEvidence.ts @@ -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), diff --git a/src/application/ElectronActiveEvidence.ts b/src/application/ElectronActiveEvidence.ts index bcb41a65e8..2b4b78d477 100644 --- a/src/application/ElectronActiveEvidence.ts +++ b/src/application/ElectronActiveEvidence.ts @@ -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), diff --git a/src/application/ElectronBoundaryGraphNative.ts b/src/application/ElectronBoundaryGraphNative.ts index 1b96920e52..6e8101d0f5 100644 --- a/src/application/ElectronBoundaryGraphNative.ts +++ b/src/application/ElectronBoundaryGraphNative.ts @@ -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", }, diff --git a/src/application/ElectronBoundaryGraphWindows.ts b/src/application/ElectronBoundaryGraphWindows.ts index 1a498e6480..d6fe16e1df 100644 --- a/src/application/ElectronBoundaryGraphWindows.ts +++ b/src/application/ElectronBoundaryGraphWindows.ts @@ -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, diff --git a/src/application/ElectronEvidence.ts b/src/application/ElectronEvidence.ts index e313957a32..655b6a71b4 100644 --- a/src/application/ElectronEvidence.ts +++ b/src/application/ElectronEvidence.ts @@ -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), diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index 500cd5e473..a8655962ce 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -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, diff --git a/src/browser/CdpCaptureDocuments.test.ts b/src/browser/CdpCaptureDocuments.test.ts index 452d4440b1..3b90f5177f 100644 --- a/src/browser/CdpCaptureDocuments.test.ts +++ b/src/browser/CdpCaptureDocuments.test.ts @@ -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, }); }); }); diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index e7c056051d..c4cd7b2346 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -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", diff --git a/src/browser/CdpCaptureEventBodyShapes.ts b/src/browser/CdpCaptureEventBodyShapes.ts index c3c0ec34af..6d788dfab4 100644 --- a/src/browser/CdpCaptureEventBodyShapes.ts +++ b/src/browser/CdpCaptureEventBodyShapes.ts @@ -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"); }; diff --git a/src/browser/CdpCaptureEventHandlers.ts b/src/browser/CdpCaptureEventHandlers.ts index dd42c7aba3..23f5731550 100644 --- a/src/browser/CdpCaptureEventHandlers.ts +++ b/src/browser/CdpCaptureEventHandlers.ts @@ -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", diff --git a/src/browser/CdpCaptureEvents.ts b/src/browser/CdpCaptureEvents.ts index 2d8d35e937..df0f7edec1 100644 --- a/src/browser/CdpCaptureEvents.ts +++ b/src/browser/CdpCaptureEvents.ts @@ -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"); } diff --git a/src/browser/CdpCaptureStorage.ts b/src/browser/CdpCaptureStorage.ts index bd6bf0ab74..89cccb4cc9 100644 --- a/src/browser/CdpCaptureStorage.ts +++ b/src/browser/CdpCaptureStorage.ts @@ -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, diff --git a/src/browser/CdpCaptureStorageFingerprints.ts b/src/browser/CdpCaptureStorageFingerprints.ts index cfa0ef79a9..c21d7611c4 100644 --- a/src/browser/CdpCaptureStorageFingerprints.ts +++ b/src/browser/CdpCaptureStorageFingerprints.ts @@ -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 ""; - 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 => { diff --git a/src/browser/CdpConnection.ts b/src/browser/CdpConnection.ts index 667bb6344f..94e3bacc56 100644 --- a/src/browser/CdpConnection.ts +++ b/src/browser/CdpConnection.ts @@ -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; diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index 8aa41ede9d..85db21af37 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -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)), }); } } diff --git a/src/browser/CdpElectronProvider.ts b/src/browser/CdpElectronProvider.ts index b66d1a58f2..b786cf26df 100644 --- a/src/browser/CdpElectronProvider.ts +++ b/src/browser/CdpElectronProvider.ts @@ -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, }); diff --git a/src/browser/CdpElectronScriptEvents.ts b/src/browser/CdpElectronScriptEvents.ts index ac96344720..b81b6b9dbd 100644 --- a/src/browser/CdpElectronScriptEvents.ts +++ b/src/browser/CdpElectronScriptEvents.ts @@ -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, - 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); }; diff --git a/src/browser/CdpElectronScripts.ts b/src/browser/CdpElectronScripts.ts index 8e49e49b8b..5963f9e1b5 100644 --- a/src/browser/CdpElectronScripts.ts +++ b/src/browser/CdpElectronScripts.ts @@ -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"); diff --git a/src/browser/CdpElectronWorkers.ts b/src/browser/CdpElectronWorkers.ts index 3fad0e780d..9c12c9c7e9 100644 --- a/src/browser/CdpElectronWorkers.ts +++ b/src/browser/CdpElectronWorkers.ts @@ -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; }; diff --git a/src/browser/CdpEndpoint.ts b/src/browser/CdpEndpoint.ts index d181d613f8..b5cd0faa70 100644 --- a/src/browser/CdpEndpoint.ts +++ b/src/browser/CdpEndpoint.ts @@ -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 => { - 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 => @@ -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"))); diff --git a/src/browser/CdpObservationSession.ts b/src/browser/CdpObservationSession.ts index 1a072cd537..a1167e4e6c 100644 --- a/src/browser/CdpObservationSession.ts +++ b/src/browser/CdpObservationSession.ts @@ -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, 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(); diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index a32f54fb39..30b230764c 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -319,21 +319,8 @@ const enableObservationDomains = async ( signal?: AbortSignal, ): Promise => { 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, diff --git a/src/browser/CdpPageCaptureScripts.ts b/src/browser/CdpPageCaptureScripts.ts index 85efaec632..6be34b6265 100644 --- a/src/browser/CdpPageCaptureScripts.ts +++ b/src/browser/CdpPageCaptureScripts.ts @@ -106,11 +106,7 @@ const captureScriptSource = async ( script: CapturedScript, state: ScriptDraftState, ): Promise => { - 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); }; diff --git a/src/browser/CdpPageCaptureWorkers.ts b/src/browser/CdpPageCaptureWorkers.ts index 088052d46f..6270f17fcd 100644 --- a/src/browser/CdpPageCaptureWorkers.ts +++ b/src/browser/CdpPageCaptureWorkers.ts @@ -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; diff --git a/src/browser/CdpSafeMetadata.ts b/src/browser/CdpSafeMetadata.ts index 5f9c801073..ba0cc268cd 100644 --- a/src/browser/CdpSafeMetadata.ts +++ b/src/browser/CdpSafeMetadata.ts @@ -68,15 +68,15 @@ const normalizedHeaders = (value: unknown): ReadonlyMap => { if (typeof value !== "object" || value === null || Array.isArray(value)) return new Map(); const headers = new Map(); - 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, ): 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, ): 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 => { const parameters = new Map(); - 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; diff --git a/src/browser/CdpScreenshot.ts b/src/browser/CdpScreenshot.ts index 43f47b569f..7b55dc6386 100644 --- a/src/browser/CdpScreenshot.ts +++ b/src/browser/CdpScreenshot.ts @@ -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 }; }; diff --git a/src/browser/CdpTargetSession.ts b/src/browser/CdpTargetSession.ts index 5567c78906..b8b2e973d4 100644 --- a/src/browser/CdpTargetSession.ts +++ b/src/browser/CdpTargetSession.ts @@ -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; diff --git a/src/browser/CdpWebMcpDiscovery.ts b/src/browser/CdpWebMcpDiscovery.ts index 913f431b00..cb6195b3b8 100644 --- a/src/browser/CdpWebMcpDiscovery.ts +++ b/src/browser/CdpWebMcpDiscovery.ts @@ -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; }; diff --git a/src/browser/PngVisualDiff.test.ts b/src/browser/PngVisualDiff.test.ts index 3485d48ddf..8026935012 100644 --- a/src/browser/PngVisualDiff.test.ts +++ b/src/browser/PngVisualDiff.test.ts @@ -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", () => { diff --git a/src/browser/PngVisualDiff.ts b/src/browser/PngVisualDiff.ts index 8b16891875..81947a5ca7 100644 --- a/src/browser/PngVisualDiff.ts +++ b/src/browser/PngVisualDiff.ts @@ -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 }; }; diff --git a/src/browser/SensitiveTextCapture.test.ts b/src/browser/SensitiveTextCapture.test.ts index 3d6ef688a4..0ba343e09c 100644 --- a/src/browser/SensitiveTextCapture.test.ts +++ b/src/browser/SensitiveTextCapture.test.ts @@ -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, - }); - }); }); diff --git a/src/browser/SensitiveTextCapture.ts b/src/browser/SensitiveTextCapture.ts index 789200ead9..ee1549ba81 100644 --- a/src/browser/SensitiveTextCapture.ts +++ b/src/browser/SensitiveTextCapture.ts @@ -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 }; -}; diff --git a/src/browser/V8InspectorEndpoint.ts b/src/browser/V8InspectorEndpoint.ts index 0fe100e752..375369d354 100644 --- a/src/browser/V8InspectorEndpoint.ts +++ b/src/browser/V8InspectorEndpoint.ts @@ -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 => { 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); diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index f916de88d6..4cde48282a 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -31,33 +31,14 @@ export const fetchWebSourceMaps = async ( host: SourceMapFetchHost = { fetch: globalThis.fetch }, ): Promise => { 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 => { - const { input, maximumBytes, signal, host } = context; + input: AnalyzeWebBundleInput, + signal: AbortSignal | undefined, + host: SourceMapFetchHost, +): Promise => { 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 => { let current = initialUrl; - for (let redirect = 0; redirect <= 5; redirect += 1) { + const visited = new Set(); + 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 => { - 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> => 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, diff --git a/src/cliBrowserAdvancedCommands.ts b/src/cliBrowserAdvancedCommands.ts index 072dba27d0..7679db47e0 100644 --- a/src/cliBrowserAdvancedCommands.ts +++ b/src/cliBrowserAdvancedCommands.ts @@ -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( diff --git a/src/cliBrowserCommands.ts b/src/cliBrowserCommands.ts index d3cc5fa48f..f694ba8c27 100644 --- a/src/cliBrowserCommands.ts +++ b/src/cliBrowserCommands.ts @@ -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")); diff --git a/src/cliObservationOptions.ts b/src/cliObservationOptions.ts index b5fd8b0dc1..b6b86cede8 100644 --- a/src/cliObservationOptions.ts +++ b/src/cliObservationOptions.ts @@ -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) diff --git a/src/contracts/browserScenarioToolContracts.ts b/src/contracts/browserScenarioToolContracts.ts index ca8cbdb28e..d9aca9b0ef 100644 --- a/src/contracts/browserScenarioToolContracts.ts +++ b/src/contracts/browserScenarioToolContracts.ts @@ -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), diff --git a/src/contracts/browserToolContracts.ts b/src/contracts/browserToolContracts.ts index 76262bde41..b619e4eaed 100644 --- a/src/contracts/browserToolContracts.ts +++ b/src/contracts/browserToolContracts.ts @@ -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, }, }, ], diff --git a/src/domain/browserObservation.test.ts b/src/domain/browserObservation.test.ts index 194268068d..f7401ce410 100644 --- a/src/domain/browserObservation.test.ts +++ b/src/domain/browserObservation.test.ts @@ -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); }); }); diff --git a/src/domain/browserObservation.ts b/src/domain/browserObservation.ts index 5b37826e2b..debca875ce 100644 --- a/src/domain/browserObservation.ts +++ b/src/domain/browserObservation.ts @@ -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; 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; @@ -186,9 +161,8 @@ export type SanitizedBrowserUrl = z.infer; /** 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] ?? ""; } }; diff --git a/src/domain/browserObservationSchemas.ts b/src/domain/browserObservationSchemas.ts index a83edb3e0f..0494cf5bda 100644 --- a/src/domain/browserObservationSchemas.ts +++ b/src/domain/browserObservationSchemas.ts @@ -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), }), diff --git a/src/domain/browserScenarioDiff.test.ts b/src/domain/browserScenarioDiff.test.ts index 5d80108a51..81e57385d5 100644 --- a/src/domain/browserScenarioDiff.test.ts +++ b/src/domain/browserScenarioDiff.test.ts @@ -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; diff --git a/src/domain/browserScenarioDiffValues.ts b/src/domain/browserScenarioDiffValues.ts index 8080b23ecf..cfb706e833 100644 --- a/src/domain/browserScenarioDiffValues.ts +++ b/src/domain/browserScenarioDiffValues.ts @@ -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(); @@ -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; diff --git a/src/domain/browserSession.ts b/src/domain/browserSession.ts index 6a72858e3b..cfbb44078d 100644 --- a/src/domain/browserSession.ts +++ b/src/domain/browserSession.ts @@ -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 diff --git a/src/domain/electronObservation.ts b/src/domain/electronObservation.ts index 0d3ac80bc7..6aeb4b86ee 100644 --- a/src/domain/electronObservation.ts +++ b/src/domain/electronObservation.ts @@ -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([]), diff --git a/src/domain/electronStaticAnalysisBrowser.ts b/src/domain/electronStaticAnalysisBrowser.ts index 75bc38ea67..c2d2e06420 100644 --- a/src/domain/electronStaticAnalysisBrowser.ts +++ b/src/domain/electronStaticAnalysisBrowser.ts @@ -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; -}; diff --git a/src/domain/electronStaticAnalysisNative.ts b/src/domain/electronStaticAnalysisNative.ts index e821202a4b..0176e2522a 100644 --- a/src/domain/electronStaticAnalysisNative.ts +++ b/src/domain/electronStaticAnalysisNative.ts @@ -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), }, diff --git a/src/domain/electronStaticAnalysisTypes.ts b/src/domain/electronStaticAnalysisTypes.ts index e1276d229d..15264c3d9e 100644 --- a/src/domain/electronStaticAnalysisTypes.ts +++ b/src/domain/electronStaticAnalysisTypes.ts @@ -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; } diff --git a/src/domain/electronStaticAnalysisValues.ts b/src/domain/electronStaticAnalysisValues.ts index a42dd92bfc..b333d5e969 100644 --- a/src/domain/electronStaticAnalysisValues.ts +++ b/src/domain/electronStaticAnalysisValues.ts @@ -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: "-" }) && diff --git a/src/domain/jsonShape.test.ts b/src/domain/jsonShape.test.ts index 896ca799fd..ba2d4ce622 100644 --- a/src/domain/jsonShape.test.ts +++ b/src/domain/jsonShape.test.ts @@ -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, + }); }); }); diff --git a/src/domain/jsonShape.ts b/src/domain/jsonShape.ts index 1d7271386c..bc6f388b7f 100644 --- a/src/domain/jsonShape.ts +++ b/src/domain/jsonShape.ts @@ -22,22 +22,11 @@ export const jsonShapeSchema = z.object({ observations: z.number().int().min(1), }), ), - truncated: z.boolean(), }); export type JsonShape = z.infer; -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> => 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"); diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index 08ac5af4f0..5c6179bca4 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -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; diff --git a/src/domain/webMcpDiscovery.ts b/src/domain/webMcpDiscovery.ts index b6759d345b..bdd1d7f30d 100644 --- a/src/domain/webMcpDiscovery.ts +++ b/src/domain/webMcpDiscovery.ts @@ -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 diff --git a/src/domain/webScreenshot.ts b/src/domain/webScreenshot.ts index eb26311719..536541bc1f 100644 --- a/src/domain/webScreenshot.ts +++ b/src/domain/webScreenshot.ts @@ -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; /** 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; -/** 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; -/** 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; diff --git a/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts b/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts index dc2a426430..31596ab900 100644 --- a/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts @@ -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", ); }); diff --git a/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts b/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts index 72abd3a2db..7c96769560 100644 --- a/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts @@ -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); diff --git a/tests/boundary/browser/cdpBrowserProvider.sensitive_data.test.ts b/tests/boundary/browser/cdpBrowserProvider.sensitive_data.test.ts index e41df6e6e5..7c52d64825 100644 --- a/tests/boundary/browser/cdpBrowserProvider.sensitive_data.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.sensitive_data.test.ts @@ -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 () => { diff --git a/tests/boundary/browser/cdpBrowserProvider.timelineEvents.test.ts b/tests/boundary/browser/cdpBrowserProvider.timelineEvents.test.ts new file mode 100644 index 0000000000..c77af5985a --- /dev/null +++ b/tests/boundary/browser/cdpBrowserProvider.timelineEvents.test.ts @@ -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", + ); + }); +}); diff --git a/tests/boundary/browser/cdpSafeMetadata.test.ts b/tests/boundary/browser/cdpSafeMetadata.test.ts index 4ad0497c70..f550a6dbaa 100644 --- a/tests/boundary/browser/cdpSafeMetadata.test.ts +++ b/tests/boundary/browser/cdpSafeMetadata.test.ts @@ -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) => `; 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" }), + ); + }); }); diff --git a/tests/boundary/browser/webSourceMapFetcher.test.ts b/tests/boundary/browser/webSourceMapFetcher.test.ts index 273ad4e72a..c0fc490ae5 100644 --- a/tests/boundary/browser/webSourceMapFetcher.test.ts +++ b/tests/boundary/browser/webSourceMapFetcher.test.ts @@ -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 = {}) => { - 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 | undefined), - }; - return { - ...parsed, - analysis_limits: limits, - }; }; diff --git a/tests/boundary/mcp/browserScenarioMcp.test.ts b/tests/boundary/mcp/browserScenarioMcp.test.ts index f9de119f4b..c150abb966 100644 --- a/tests/boundary/mcp/browserScenarioMcp.test.ts +++ b/tests/boundary/mcp/browserScenarioMcp.test.ts @@ -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 ?? {}, diff --git a/tests/boundary/mcp/electronMcp.test.ts b/tests/boundary/mcp/electronMcp.test.ts index 8b9cd43526..f87a80d748 100644 --- a/tests/boundary/mcp/electronMcp.test.ts +++ b/tests/boundary/mcp/electronMcp.test.ts @@ -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", ""], actions: [{ step_id: "submit", kind: "click" }], diff --git a/tests/fixtures/fakeCdpBrowserEvents.ts b/tests/fixtures/fakeCdpBrowserEvents.ts index a2aa3ca719..8f1b8e49a6 100644 --- a/tests/fixtures/fakeCdpBrowserEvents.ts +++ b/tests/fixtures/fakeCdpBrowserEvents.ts @@ -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, diff --git a/tests/fixtures/fakeCdpBrowserResults.ts b/tests/fixtures/fakeCdpBrowserResults.ts index 252f62f019..0045c8f66a 100644 --- a/tests/fixtures/fakeCdpBrowserResults.ts +++ b/tests/fixtures/fakeCdpBrowserResults.ts @@ -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 => diff --git a/tests/fixtures/fakeCdpBrowserTypes.ts b/tests/fixtures/fakeCdpBrowserTypes.ts index 4136ea4ab1..d26d71ea80 100644 --- a/tests/fixtures/fakeCdpBrowserTypes.ts +++ b/tests/fixtures/fakeCdpBrowserTypes.ts @@ -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;