diff --git a/docs/browser-observation.md b/docs/browser-observation.md index a1bd15a74c..67aaaaa58b 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -98,7 +98,7 @@ 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 independent per-field and aggregate UTF-8 byte limits; credential-shaped console substrings are redacted. JSON and WebSocket captures retain paths, types, counts, and 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. 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. Screenshot capture returns the visible viewport as image pixels: @@ -119,8 +119,9 @@ rea capture-web-screenshot http://127.0.0.1:9222 TARGET_ID \ The omitted observation window and capture options use conservative defaults. Set an option only when the investigation needs that additional content. -Collection budgets are applied internally and cannot be changed per call; -truncated sections and dropped counts make incomplete captures explicit. +Collection counts are not capped. Per-value validation, the selected observation +window, and CDP protocol limits still apply; any incomplete or truncated values +are reported in the result. Call `list_browser_targets` first because target IDs are browser-instance-specific. REA rechecks the selected target's current type and origin immediately before attaching. @@ -134,24 +135,30 @@ REA removes sensitive values before normalized event data is retained: - 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. - 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 per-body and aggregate budgets, converted immediately to value-free property paths/types, and discarded. `Network.getResponseBody` is never sent unless JSON body shape capture is selected. +- 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. - 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 per-script and aggregate byte limits. +- 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. 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 bounded opener-target and parent-frame IDs when CDP +Retained workers include validated opener-target and parent-frame IDs when CDP provides them; these relationships do not expand the exact-origin boundary or prove static module ownership. ## Completeness and limits -The default observation window is 500 ms and the maximum is 10 seconds. Frames (200), DOM nodes (2,000), accessibility nodes (2,000), scripts (200), resources (2,000), workers (500), and each storage key/name inventory (1,000) have internal collection budgets. Script source bytes, network events, console events, and WebSocket events are bounded separately. These budgets are fixed by REA rather than caller-configurable; results identify truncated sections and dropped counts. +The default observation window is 500 ms and the maximum is 10 seconds. 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 +`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. -Every result distinguishes `complete_within_window`, `policy_filtered`, `attach_limited`, and `truncated` coverage. It names affected sections and reports sparse excluded-item and per-event-type dropped counts rather than implying exhaustive capture. Disallowed-origin entries are filtered before collection-limit accounting, so they cannot consume an approved origin's output budget. - -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. 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 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. 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. diff --git a/docs/electron-observation.md b/docs/electron-observation.md index 902e8f98ee..5dcc96af27 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -40,7 +40,7 @@ rea inspect-electron-page http://127.0.0.1:9223 TARGET_ID \ --observation-ms 100 --json ``` -Script content is excluded by default. Requesting it remains subject to per-script and aggregate byte budgets: +Script content is excluded by default. Requesting it includes every authorized script source whose individual source stays within the per-script validation bound: ```bash rea inspect-electron-page http://127.0.0.1:9223 TARGET_ID \ @@ -48,15 +48,14 @@ rea inspect-electron-page http://127.0.0.1:9223 TARGET_ID \ --json ``` -The normalized result contains canonical local paths, bounded frame and DOM +The normalized result contains canonical local paths, frame and DOM structure, resource metadata, stable script/resource identities, explicit completeness, and content-addressed approved source artifacts. Script metadata retains its execution-context frame ID when CDP supplies one. The capture also inventories authorized worker, service-worker, and shared-worker targets with -bounded opener-target and parent-frame IDs. Worker discovery uses passive +validated opener-target and parent-frame IDs. Worker discovery uses passive target metadata; REA does not attach to or execute code in those targets. - -The worker collection budget is 500 and is applied internally. Like every +Collection counts and aggregate script-source bytes are not capped. Like every target, frame, script, and resource, a worker URL must resolve beneath an approved canonical root before it is retained. Relationship IDs improve attribution but do not prove which static module started a worker or that its diff --git a/docs/javascript-runtime-observation.md b/docs/javascript-runtime-observation.md index 3512b4dcd2..a36e971fa1 100644 --- a/docs/javascript-runtime-observation.md +++ b/docs/javascript-runtime-observation.md @@ -1,7 +1,7 @@ # Passive Node and Electron runtime observation REA can attach to an already-running Node.js or Electron V8 Inspector target -and retain bounded script-load and execution-context metadata. The +and retain script-load and execution-context metadata. The `list_javascript_runtime_targets` and `observe_javascript_runtime` MCP tools have equivalent `rea list-javascript-runtime-targets` and `rea observe-javascript-runtime` commands. Successful calls return @@ -83,20 +83,19 @@ Electron main, preload, and renderer behavior can therefore be observed only as separate approved Inspector targets. The provider does not infer that two targets belong to the same Electron application. -## Bounds and determinism +## Capture and determinism -Every observation records the window and effective internal collection bounds -for events, scripts, execution contexts, per-location bytes, and total retained -metadata bytes. These bounds are not caller-configurable. Limit hits increment -dropped-event counts and set `capture.truncated`; they never become completeness claims. Scripts are authorized after capture, -deduplicated by stable metadata, and canonically sorted. Wall-clock timestamps -and protocol script IDs are excluded from the durable result, so identical -inputs and captured metadata produce the same Evidence ID. +Every observation records its time window and returns all valid script and +execution-context events received during it. Per-location protocol validation +remains in force. Scripts are authorized after capture, deduplicated by stable +metadata, and canonically sorted. Wall-clock timestamps and protocol script IDs +are excluded from the durable result, so identical inputs and captured metadata +produce the same Evidence ID. Inspector attach is inherently incomplete. Enabling `Debugger` reports known and uncollected scripts, but scripts collected before attachment may be -missing. Absence from a bounded capture never proves that a script or behavior -does not occur. +missing. Absence from the observation window never proves that a script or +behavior does not occur. ## Static correlation diff --git a/docs/javascript-runtime-reconciliation.md b/docs/javascript-runtime-reconciliation.md index 75b3455ba7..03804600a3 100644 --- a/docs/javascript-runtime-reconciliation.md +++ b/docs/javascript-runtime-reconciliation.md @@ -160,8 +160,8 @@ The workflow does not impose aggregate count caps on static layers, passive captures, runtime entities, reconciliation items, static load states, runtime mapping declarations, or candidate references. These collections remain subject to the structural validation applied to each Evidence record and -application graph; passive capture collection limits are committed by the -observation operation that produced that Evidence. Reconciliation reports zero +application graph; passive observation returns all in-window collection items +that pass per-value validation. Reconciliation reports zero omitted items when it projects the complete supplied inputs. Static JavaScript analysis can still report its own bounded AST coverage as partial or truncated. diff --git a/src/application/ElectronEvidence.ts b/src/application/ElectronEvidence.ts index da247c6670..e313957a32 100644 --- a/src/application/ElectronEvidence.ts +++ b/src/application/ElectronEvidence.ts @@ -53,7 +53,6 @@ const parameters = ( target_id: input.target_id, observation_ms: input.observation_ms, include_script_sources: input.include_script_sources, - limits: input.limits, } : {}), }); diff --git a/src/application/JavaScriptRuntimeObservationEvidence.ts b/src/application/JavaScriptRuntimeObservationEvidence.ts index b3a16fd7b9..fb29842ce3 100644 --- a/src/application/JavaScriptRuntimeObservationEvidence.ts +++ b/src/application/JavaScriptRuntimeObservationEvidence.ts @@ -56,7 +56,6 @@ const parameters = ( ? {} : { runtime_kind: input.runtime_kind }), observation_ms: input.observation_ms, - limits: input.limits, } : {}), }); diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index 266a93b8b3..8aa41ede9d 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -150,12 +150,7 @@ const authorizeInitialFrames = async ( context.sessionId, context.signal, ); - const frames = await captureFrames( - result, - state.roots, - context.input.limits.max_frames, - state.completeness, - ); + const frames = await captureFrames(result, state.roots, state.completeness); if (frames[0] === undefined) throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); return frames; @@ -248,7 +243,6 @@ const navigatedFrameId = (event: CdpEvent): string | undefined => { const captureFrames = async ( result: unknown, roots: readonly string[], - maximum: number, completeness: CdpCaptureCompleteness, ): Promise => { const frameTree = recordValue(requiredRecord(result).frameTree); @@ -272,10 +266,6 @@ const captureFrames = async ( completeness.exclude("frames", "out_of_target_scope"); continue; } - if (frames.length >= maximum) { - completeness.truncate("frames"); - continue; - } frames.push({ frame_id: frameId.slice(0, 256), parent_frame_id: current.parent, @@ -328,10 +318,6 @@ const captureResources = async ( const resourceKey = `electron_resource_${digest(item)}`; if (seen.has(resourceKey)) continue; seen.add(resourceKey); - if (resources.length >= context.input.limits.max_resources) { - completeness.truncate("resources"); - continue; - } resources.push({ resource_key: resourceKey, ...item, @@ -377,7 +363,6 @@ const captureDom = async ( const baseIndex = nodes.length; total += nodeTypes.length; for (let index = 0; index < nodeTypes.length; index += 1) { - if (nodes.length >= context.input.limits.max_dom_nodes) continue; const attributeIndexes = arrayValue(attributes[index]); const parent = integer(parents[index], -1); nodes.push({ @@ -390,12 +375,10 @@ const captureDom = async ( ), attribute_names: attributeIndexes .filter((_value, attributeIndex) => attributeIndex % 2 === 0) - .map((value) => stringAt(strings, value).slice(0, 1_024)) - .slice(0, 256), + .map((value) => stringAt(strings, value).slice(0, 1_024)), }); } } - if (total > nodes.length) completeness.truncate("dom"); return { total_nodes: total, nodes }; }; diff --git a/src/browser/CdpElectronScripts.ts b/src/browser/CdpElectronScripts.ts index dee4e7dfd4..8e49e49b8b 100644 --- a/src/browser/CdpElectronScripts.ts +++ b/src/browser/CdpElectronScripts.ts @@ -6,6 +6,7 @@ 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"; @@ -30,7 +31,6 @@ export const captureElectronScripts = async ( input: ElectronScriptCaptureInput, ): Promise => { let total = 0; - let sourceBytes = 0; const items: ElectronPageInspection["scripts"]["items"] = []; const seen = new Set(); for (const script of input.scripts) { @@ -58,16 +58,7 @@ export const captureElectronScripts = async ( if (seen.has(scriptKey)) continue; seen.add(scriptKey); total += 1; - if (items.length >= input.request.limits.max_scripts) { - input.completeness.drop("scripts"); - continue; - } - const capturedSource = await captureScriptSource( - input, - script, - sourceBytes, - ); - sourceBytes += capturedSource.bytes; + const capturedSource = await captureScriptSource(input, script); items.push({ script_key: scriptKey, ...identity, @@ -90,10 +81,8 @@ type ElectronScriptSource = const captureScriptSource = async ( input: ElectronScriptCaptureInput, script: ElectronScriptDraft, - sourceBytes: number, ): Promise<{ readonly source: ElectronScriptSource; - readonly bytes: number; }> => { if (!input.request.include_script_sources) return { @@ -101,9 +90,8 @@ const captureScriptSource = async ( included: false, reason: "source capture was not approved", }, - bytes: 0, }; - if (sourceLimitReached(input.request.limits, script.length, sourceBytes)) + if (script.length > MAX_ELECTRON_SCRIPT_SOURCE_BYTES) return sourceLimitResult(input.completeness); const result = requiredRecord( await input.connection.send( @@ -115,32 +103,22 @@ const captureScriptSource = async ( ); const text = stringValue(result.scriptSource) ?? ""; const bytes = Buffer.byteLength(text); - if (sourceLimitReached(input.request.limits, bytes, sourceBytes)) + if (bytes > MAX_ELECTRON_SCRIPT_SOURCE_BYTES) return sourceLimitResult(input.completeness); return { source: { included: true, artifact: createWebTextArtifact(text, "text/javascript"), }, - bytes, }; }; -const sourceLimitReached = ( - limits: InspectElectronPageInput["limits"], - bytes: number, - previousBytes: number, -): boolean => - bytes > limits.max_script_source_bytes || - previousBytes + bytes > limits.max_total_script_source_bytes; - const sourceLimitResult = ( completeness: CdpCaptureCompleteness, -): { readonly source: ElectronScriptSource; readonly bytes: 0 } => { +): { readonly source: ElectronScriptSource } => { completeness.truncate("script_sources"); return { source: { included: false, reason: "script source byte limit reached" }, - bytes: 0, }; }; diff --git a/src/browser/CdpElectronWorkers.ts b/src/browser/CdpElectronWorkers.ts index 964fc7359d..3fad0e780d 100644 --- a/src/browser/CdpElectronWorkers.ts +++ b/src/browser/CdpElectronWorkers.ts @@ -60,10 +60,6 @@ export const captureElectronWorkers = async (input: { input.completeness.exclude("workers", "out_of_target_scope"); continue; } - if (workers.length >= input.request.limits.max_workers) { - input.completeness.truncate("workers"); - continue; - } const targetId = boundedTargetField(target.targetId); if (targetId === null) { input.completeness.exclude("workers", "invalid_protocol_value"); diff --git a/src/browser/V8InspectorProvider.ts b/src/browser/V8InspectorProvider.ts index 7e6fe4f61e..0dda4fd2ac 100644 --- a/src/browser/V8InspectorProvider.ts +++ b/src/browser/V8InspectorProvider.ts @@ -4,6 +4,7 @@ import type { } from "../application/AnalysisProvider.js"; import type { JavaScriptRuntimeObservationPort } from "../application/JavaScriptRuntimeObservationPort.js"; import { + MAX_JAVASCRIPT_RUNTIME_LOCATION_BYTES, javascriptRuntimeObservationSchema, javascriptRuntimeTargetListSchema, type JavaScriptRuntimeObservation, @@ -252,17 +253,13 @@ const ingestEvent = ( ) return; state.eventsObserved += 1; - if (state.eventsObserved > input.limits.max_events) { - drop(state, "max_events"); - return; - } if (event.method === "Debugger.scriptParsed") { ingestScript(event, input, state); return; } if (event.method === "Debugger.scriptFailedToParse") { state.invalidScripts += 1; - retainEvent(state, 0, input); + retainEvent(state, 0); return; } ingestContext(event, input, state); @@ -279,14 +276,10 @@ const ingestScript = ( if ( rawUrl === undefined || rawUrl === "" || - Buffer.byteLength(rawUrl) > input.limits.max_location_bytes + Buffer.byteLength(rawUrl) > MAX_JAVASCRIPT_RUNTIME_LOCATION_BYTES ) { state.invalidScripts += 1; - retainEvent(state, 0, input); - return; - } - if (state.scripts.length >= input.limits.max_scripts) { - drop(state, "max_scripts"); + retainEvent(state, 0); return; } const draft: ScriptDraft = { @@ -297,7 +290,7 @@ const ingestScript = ( isModule: value?.isModule === true, }; const bytes = metadataBytes(draft); - if (!retainEvent(state, bytes, input)) return; + retainEvent(state, bytes); state.scripts.push(draft); }; @@ -308,7 +301,7 @@ const ingestContext = ( ): void => { if (event.method === "Runtime.executionContextsCleared") { for (const context of state.contexts.values()) context.state = "cleared"; - retainEvent(state, 0, input); + retainEvent(state, 0); return; } const parameters = recordValue(event.params); @@ -322,14 +315,7 @@ const ingestContext = ( : runtimeContext?.executionContextId, ); if (key === null) { - retainEvent(state, 0, input); - return; - } - if ( - state.contexts.size >= input.limits.max_execution_contexts && - !state.contexts.has(key) - ) { - drop(state, "max_execution_contexts"); + retainEvent(state, 0); return; } const origin = @@ -346,28 +332,13 @@ const ingestContext = ( origin !== null && input.allowed_origins.includes(origin) ? origin : null, }; const bytes = metadataBytes(draft); - if (!retainEvent(state, bytes, input)) return; + retainEvent(state, bytes); state.contexts.set(key, draft); }; -const retainEvent = ( - state: CaptureState, - bytes: number, - input: ObserveJavaScriptRuntimeInput, -): boolean => { - if (state.metadataBytes + bytes > input.limits.max_total_metadata_bytes) { - drop(state, "max_total_metadata_bytes"); - return false; - } +const retainEvent = (state: CaptureState, bytes: number): void => { state.metadataBytes += bytes; state.eventsRetained += 1; - return true; -}; - -const drop = (state: CaptureState, reason: string): void => { - state.eventsDropped += 1; - state.truncated = true; - state.truncationReasons.add(reason); }; const waitForCapture = async ( diff --git a/src/domain/electronActiveObservation.ts b/src/domain/electronActiveObservation.ts index 6d54445948..ae18157fff 100644 --- a/src/domain/electronActiveObservation.ts +++ b/src/domain/electronActiveObservation.ts @@ -58,7 +58,7 @@ export const electronActiveObservationInputSchema = z.strictObject({ executable_path: absolutePathSchema, application_path: absolutePathSchema, application_root: absolutePathSchema, - args: z.array(z.string().max(4_096)).max(32).default([]), + args: z.array(z.string().max(4_096)).default([]), actions: z.array(actionSchema).default([]), }); export type ElectronActiveObservationInput = z.infer< diff --git a/src/domain/electronObservation.ts b/src/domain/electronObservation.ts index 6b810de39f..0d3ac80bc7 100644 --- a/src/domain/electronObservation.ts +++ b/src/domain/electronObservation.ts @@ -24,16 +24,8 @@ const approvedElectronInput = { allowed_file_roots: electronFileRootsSchema, }; -/** Collection bounds for passive Electron capture, applied internally. */ -export const DEFAULT_ELECTRON_INSPECTION_LIMITS = { - max_frames: 200, - max_dom_nodes: 2_000, - max_scripts: 500, - max_resources: 2_000, - max_workers: 500, - max_script_source_bytes: 1_024 * 1_024, - max_total_script_source_bytes: 4 * 1_024 * 1_024, -}; +/** 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({ @@ -49,21 +41,16 @@ const inspectElectronPageFacts = { target_id: z.string().trim().min(1).max(256), observation_ms: z.number().int().min(0).max(10_000).default(100), } as const; -export const inspectElectronPageInputSchema = z - .union([ - z.strictObject({ - ...inspectElectronPageFacts, - include_script_sources: z.literal(false).default(false), - }), - z.strictObject({ - ...inspectElectronPageFacts, - include_script_sources: z.literal(true), - }), - ]) - .transform((input) => ({ - ...input, - limits: DEFAULT_ELECTRON_INSPECTION_LIMITS, - })); +export const inspectElectronPageInputSchema = z.union([ + z.strictObject({ + ...inspectElectronPageFacts, + include_script_sources: z.literal(false).default(false), + }), + z.strictObject({ + ...inspectElectronPageFacts, + include_script_sources: z.literal(true), + }), +]); export const inspectElectronPageToolInputSchema = z.union([ z.strictObject({ discovery_evidence_id: z.string().min(1), diff --git a/src/domain/javascriptRuntimeObservation.ts b/src/domain/javascriptRuntimeObservation.ts index f37ac209a9..107b82d39b 100644 --- a/src/domain/javascriptRuntimeObservation.ts +++ b/src/domain/javascriptRuntimeObservation.ts @@ -72,14 +72,8 @@ const observedJavaScriptRuntimeKindSchema = javascriptRuntimeKindSchema.or( z.literal("unknown"), ); -/** Collection bounds for passive V8 Inspector capture, applied internally. */ -export const DEFAULT_JAVASCRIPT_RUNTIME_OBSERVATION_LIMITS = { - max_events: 10_000, - max_scripts: 2_000, - max_execution_contexts: 1_000, - max_location_bytes: 16_384, - max_total_metadata_bytes: 4 * 1_024 * 1_024, -}; +/** Maximum accepted source location byte length in one inspector event. */ +export const MAX_JAVASCRIPT_RUNTIME_LOCATION_BYTES = 16_384; /** Input for one bounded, attach-only V8 Inspector observation. */ export const observeJavaScriptRuntimeToolInputSchema = z.strictObject({ @@ -97,11 +91,7 @@ export const observeJavaScriptRuntimeInputSchema = z runtime_kind: javascriptRuntimeKindSchema.optional(), observation_ms: z.number().int().min(0).max(10_000).default(100), }) - .superRefine(requireRuntimeScope) - .transform((input) => ({ - ...input, - limits: DEFAULT_JAVASCRIPT_RUNTIME_OBSERVATION_LIMITS, - })); + .superRefine(requireRuntimeScope); export type ObserveJavaScriptRuntimeInput = z.infer< typeof observeJavaScriptRuntimeInputSchema >; diff --git a/tests/boundary/browser/cdpBrowserProvider.targetInventory.test.ts b/tests/boundary/browser/cdpBrowserProvider.targetInventory.test.ts new file mode 100644 index 0000000000..f03a6b0f9c --- /dev/null +++ b/tests/boundary/browser/cdpBrowserProvider.targetInventory.test.ts @@ -0,0 +1,73 @@ +import { createServer } from "node:http"; + +import { describe, expect, it } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; + +describe("CDP target inventory", () => { + it("returns every allowed target within the bounded discovery payload", async () => { + let port = 0; + const targets = Array.from({ length: 1_101 }, (_, index) => { + const id = `target-${String(index).padStart(4, "0")}`; + return { + id, + type: "page", + title: `Page ${String(index)}`, + url: `https://allowed.example/page/${String(index)}`, + attached: false, + webSocketDebuggerUrl: `ws://127.0.0.1:${String(port)}/devtools/page/${id}`, + }; + }); + const server = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + if (request.url === "/json/version") { + response.end( + JSON.stringify({ + Browser: "Chrome/1", + "Protocol-Version": "1.3", + "User-Agent": "Chrome", + "V8-Version": "1", + "WebKit-Version": "1", + webSocketDebuggerUrl: `ws://127.0.0.1:${String(port)}/devtools/browser/browser`, + }), + ); + return; + } + if (request.url === "/json/list") { + response.end(JSON.stringify(targets)); + return; + } + response.writeHead(404).end(); + }); + + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") + throw new TypeError("Expected a TCP listener address"); + port = address.port; + const endpoint = `http://127.0.0.1:${String(port)}`; + + const result = await new CdpBrowserProvider().listTargets({ + cdp_endpoint: endpoint, + allowed_origins: ["https://allowed.example"], + }); + + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.targets).toHaveLength(targets.length); + expect(result.value.targets[0]?.target_id).toBe("target-0000"); + expect(result.value.targets.at(-1)?.target_id).toBe("target-1100"); + } finally { + if (server.listening) + await new Promise((resolve, reject) => { + server.close((error) => + error === undefined ? resolve() : reject(error), + ); + }); + } + }); +}); diff --git a/tests/boundary/browser/cdpElectronProvider.test.ts b/tests/boundary/browser/cdpElectronProvider.test.ts index 5ff5f820e7..97b741b625 100644 --- a/tests/boundary/browser/cdpElectronProvider.test.ts +++ b/tests/boundary/browser/cdpElectronProvider.test.ts @@ -29,6 +29,33 @@ afterEach(async () => { }); describe("CdpElectronProvider target access", () => { + it("returns every in-scope resource beyond the former collection ceiling", async () => { + const root = await electronFixture(); + const index = join(root, "index.html"); + const count = 2_001; + await Promise.all( + Array.from({ length: count }, (_value, item) => + writeFile(join(root, `inventory-${String(item)}.js`), ""), + ), + ); + const browser = await startFakeCdpBrowser({ + electronFileUrl: pathToFileURL(index).href, + electronInventoryCount: count, + }); + browsers.push(browser); + const inspected = await new CdpElectronProvider().inspectPage( + inspectElectronPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_file_roots: [root], + target_id: "electron-page", + observation_ms: 0, + }), + ); + if (!inspected.ok) throw inspected.error; + expect(inspected.value.resources).toHaveLength(count + 1); + expect(inspected.value.completeness.truncated_sections).toEqual([]); + }); + it("returns every approved Electron page target inline", async () => { const root = await electronFixture(); const index = join(root, "index.html"); @@ -48,7 +75,9 @@ describe("CdpElectronProvider target access", () => { expect(listed.value.targets[0]?.target_id).toBe("electron-page"); expect(listed.value.targets.at(-1)?.target_id).toBe("electron-page-0204"); }); +}); +describe("CdpElectronProvider target selection", () => { it("lists and inspects only canonical file targets beneath approved roots", async () => { const root = await electronFixture(); const index = join(root, "index.html"); @@ -180,7 +209,7 @@ describe("CdpElectronProvider target access", () => { }); describe("Electron request schemas", () => { - it("rejects unknown fields and misspelled inspection limits", () => { + it("rejects unknown fields and removed collection limits", () => { expect( listElectronTargetsInputSchema.safeParse({ cdp_endpoint: "http://127.0.0.1:9223", @@ -193,7 +222,8 @@ describe("Electron request schemas", () => { cdp_endpoint: "http://127.0.0.1:9223", allowed_file_roots: ["/tmp/app"], target_id: "page-1", - limits: { max_dom_nodes: 20, max_dom_node: 20 }, + observation_ms: 0, + limits: { max_dom_nodes: 20 }, }).success, ).toBe(false); }); diff --git a/tests/boundary/browser/javascriptRuntimeObservation.test.ts b/tests/boundary/browser/javascriptRuntimeObservation.test.ts index 4e03030c4c..97ede49aa1 100644 --- a/tests/boundary/browser/javascriptRuntimeObservation.test.ts +++ b/tests/boundary/browser/javascriptRuntimeObservation.test.ts @@ -212,16 +212,15 @@ describe("undeclared V8 runtime role", () => { }); describe("passive V8 Inspector evidence", () => { - test("marks capture truncation instead of claiming complete coverage", async () => { + test("returns every authorized script beyond the former collection ceiling", async () => { const fixture = await runtimeFixture(); - const second = join(fixture.root, "second.js"); - await writeFile(second, "export {};\n"); + const scriptUrls = [ + pathToFileURL(fixture.entry).href, + ...Array.from({ length: 2_004 }, () => pathToFileURL(fixture.entry).href), + ]; const fake = await startFakeV8Inspector({ targetUrl: pathToFileURL(fixture.entry).href, - scriptUrls: [ - pathToFileURL(fixture.entry).href, - pathToFileURL(second).href, - ], + scriptUrls, }); try { const input = observeInput( @@ -230,15 +229,13 @@ describe("passive V8 Inspector evidence", () => { fixture.root, "node", ); - const result = await new V8InspectorProvider().observe({ - ...input, - limits: { ...input.limits, max_scripts: 1 }, - }); + const result = await new V8InspectorProvider().observe(input); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.value.capture.truncated).toBe(true); - expect(result.value.capture.truncation_reasons).toEqual(["max_scripts"]); - expect(result.value.capture.events_dropped).toBe(1); + expect(result.value.capture.truncated).toBe(false); + expect(result.value.capture.truncation_reasons).toEqual([]); + expect(result.value.capture.events_dropped).toBe(0); + expect(result.value.scripts.items).toHaveLength(scriptUrls.length); } finally { await fake.close(); } @@ -302,13 +299,6 @@ describe("passive V8 Inspector evidence", () => { target_id: "example-v8-target", runtime_kind: "electron-main", observation_ms: 100, - limits: { - max_events: 10_000, - max_scripts: 2_000, - max_execution_contexts: 1_000, - max_location_bytes: 16_384, - max_total_metadata_bytes: 4_194_304, - }, }, observation, V8_INSPECTOR_PROVIDER_IDENTITY, @@ -354,13 +344,6 @@ const observeInput = ( target_id: targetId, runtime_kind: runtimeKind, observation_ms: 10, - limits: { - max_events: 10_000, - max_scripts: 2_000, - max_execution_contexts: 1_000, - max_location_bytes: 16_384, - max_total_metadata_bytes: 4_194_304, - }, }); const authorityFor = async (endpoint: string, root: string) => { diff --git a/tests/fixtures/fakeCdpBrowserResults.ts b/tests/fixtures/fakeCdpBrowserResults.ts index f536428991..252f62f019 100644 --- a/tests/fixtures/fakeCdpBrowserResults.ts +++ b/tests/fixtures/fakeCdpBrowserResults.ts @@ -70,15 +70,15 @@ const frameTreeResult = ( ); const resourceTreeResult = (port: number, options: FakeOptions) => - resourceTree( - port, - options.extraCollections === true, - options.electronFileUrl, - options.duplicateElectronInventory === true, - ); + resourceTree(port, options); const domSnapshotResult = (port: number, options: FakeOptions) => - domSnapshot(port, options.electronFileUrl, options.extraCollections === true); + domSnapshot( + port, + options.electronFileUrl, + options.extraCollections === true, + options.electronInventoryCount ?? 0, + ); const accessibilityTreeResult = (options: FakeOptions) => accessibilityTree(options.extraCollections === true); @@ -260,40 +260,38 @@ const frameTree = ( const resourceTree = ( port: number, - extraCollections = false, - electronFileUrl?: string, - duplicateElectronInventory = false, + options: FakeOptions = {}, ): Readonly> => ({ frameTree: { frame: { id: "frame-main", loaderId: "loader-main", - url: electronFileUrl ?? `http://127.0.0.1:${String(port)}/app`, + url: options.electronFileUrl ?? `http://127.0.0.1:${String(port)}/app`, }, resources: [ { url: - electronFileUrl === undefined + options.electronFileUrl === undefined ? `http://127.0.0.1:${String(port)}/app.js?token=script-secret` - : new URL("app.js", electronFileUrl).href, + : new URL("app.js", options.electronFileUrl).href, type: "Script", mimeType: "text/javascript", contentSize: 128, }, - ...(duplicateElectronInventory + ...(options.duplicateElectronInventory === true ? [ { url: - electronFileUrl === undefined + options.electronFileUrl === undefined ? `http://127.0.0.1:${String(port)}/app.js?token=script-secret` - : new URL("app.js", electronFileUrl).href, + : new URL("app.js", options.electronFileUrl).href, type: "Script", mimeType: "text/javascript", contentSize: 128, }, ] : []), - ...(extraCollections + ...(options.extraCollections === true ? [ { url: `http://127.0.0.1:${String(port)}/app.css`, @@ -303,6 +301,21 @@ const resourceTree = ( }, ] : []), + ...Array.from( + { length: options.electronInventoryCount ?? 0 }, + (_value, index) => ({ + url: + options.electronFileUrl === undefined + ? `http://127.0.0.1:${String(port)}/inventory-${String(index)}.js` + : new URL( + `inventory-${String(index)}.js`, + options.electronFileUrl, + ).href, + type: "Script", + mimeType: "text/javascript", + contentSize: 128, + }), + ), { url: "https://private.example.test/private.js?secret=forbidden", type: "Script", @@ -316,6 +329,7 @@ const domSnapshot = ( port: number, electronFileUrl?: string, extraCollections = false, + electronInventoryCount = 0, ): Readonly> => { const secondDocumentUrl = electronFileUrl !== undefined && extraCollections @@ -343,11 +357,31 @@ const domSnapshot = ( { documentURL: 0, nodes: { - nodeType: [9, 1], - nodeName: [1, 3], - nodeValue: [2, 2], - parentIndex: [-1, 0], - attributes: [[], [4, 5, 8, 9, 10, 11]], + nodeType: [ + 9, + 1, + ...Array.from({ length: electronInventoryCount }, () => 1), + ], + nodeName: [ + 1, + 3, + ...Array.from({ length: electronInventoryCount }, () => 3), + ], + nodeValue: [ + 2, + 2, + ...Array.from({ length: electronInventoryCount }, () => 2), + ], + parentIndex: [ + -1, + 0, + ...Array.from({ length: electronInventoryCount }, () => 0), + ], + attributes: [ + [], + [4, 5, 8, 9, 10, 11], + ...Array.from({ length: electronInventoryCount }, () => []), + ], }, }, { diff --git a/tests/fixtures/fakeCdpBrowserTypes.ts b/tests/fixtures/fakeCdpBrowserTypes.ts index f01d3e77a6..4136ea4ab1 100644 --- a/tests/fixtures/fakeCdpBrowserTypes.ts +++ b/tests/fixtures/fakeCdpBrowserTypes.ts @@ -61,6 +61,7 @@ export interface FakeOptions { readonly webMcpChildLeavesScope?: boolean; readonly electronFileUrl?: string; readonly duplicateElectronInventory?: boolean; + readonly electronInventoryCount?: number; readonly urlShapedAllowedTitle?: | boolean | "host-path"