diff --git a/docs/electron-observation.md b/docs/electron-observation.md index 2ebfcc5025..c6469b15d0 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -84,6 +84,11 @@ Scripts from document generations whose frames were explicitly excluded by local-file policy are omitted before script-source reads. Frame identity can come from the script event's own auxiliary data or its observed execution context; contradictory identities are not aligned. +`frame_claims.script_event_frame_id` and +`frame_claims.execution_context_frame_id` preserve the actual observed claims +from those two sources. Both remain available when their disagreement leaves +the resolved `frame_id` null and `frame_scope` unknown. An omitted claim is +unobserved, not proof that a frame was absent. Frames describe the current inventory; script observations can retain earlier authorized frames that have since detached. @@ -97,6 +102,13 @@ The capture also inventories worker, service-worker, and shared-worker targets with validated opener-target and parent-frame IDs. Worker discovery uses passive target metadata; REA does not attach to or execute code in those targets. +Script metadata uses the shared 8 MiB JSON-byte allowance for each retained +representation: incoming drafts and normalized capture metadata. Admission +counts escaped strings, frame claims, and provenance fields before retaining +them or serializing metadata for a digest. Exhaustion preserves earlier records +and reports counted `resource_budget_exhausted` script omissions; a later +smaller record may still fit. This allowance adds no script-count cap and is +separate from script-source bytes. Script-source collection retains at most 32 MiB of UTF-8 text across scripts and shares a 20-second collection deadline. Omitted sources retain their script metadata and an explicit reason; later smaller sources may still fit after an diff --git a/src/browser/CdpCaptureContextRetention.test.ts b/src/browser/CdpCaptureContextRetention.test.ts index 67abbf646b..423e16d0cd 100644 --- a/src/browser/CdpCaptureContextRetention.test.ts +++ b/src/browser/CdpCaptureContextRetention.test.ts @@ -39,12 +39,17 @@ describe("CDP execution-context retention", () => { }, }, scripts, + retention: { bytes: 2 }, executionContextFrames: new Map([["7", "frame-main"]]), completeness, }); expect(scripts[0]).toMatchObject({ frameId: expectedFrame, hasSourceUrl: expectedLabel, + frameClaims: { + script_event_frame_id: frameId, + execution_context_frame_id: "frame-main", + }, }); expect(completeness.snapshot().excluded).toContainEqual({ section: "scripts", @@ -85,6 +90,7 @@ describe("CDP execution-context retention", () => { expect(browser.frameForScript(script, new Set(["frame-old"]))).toBeNull(); const electronFrames = new Map(); + const retention = { bytes: 2 }; const completeness = new CdpCaptureCompleteness(); ingestElectronScriptEvent({ event: { @@ -92,12 +98,14 @@ describe("CDP execution-context retention", () => { params: { context: { id: 7, auxData: { frameId: "frame-old" } } }, }, scripts: [], + retention, executionContextFrames: electronFrames, completeness, }); ingestElectronScriptEvent({ event: { method: "Runtime.executionContextsCleared", params: {} }, scripts: [], + retention, executionContextFrames: electronFrames, completeness, }); diff --git a/src/browser/CdpElectronInspection.test.ts b/src/browser/CdpElectronInspection.test.ts index f00c351bac..41422f9c92 100644 --- a/src/browser/CdpElectronInspection.test.ts +++ b/src/browser/CdpElectronInspection.test.ts @@ -469,12 +469,14 @@ describe("Electron frame capture", () => { await writeFile(scriptPath, "export const app = true;"); const scripts: ElectronScriptDraft[] = []; const executionContextFrames = new Map(); + const retention = { bytes: 2 }; const completeness = new CdpCaptureCompleteness(); const ingest = (method: string, params: unknown): void => ingestElectronScriptEvent({ event: { method, params }, scripts, executionContextFrames, + retention, completeness, }); const context = (frameId: string) => ({ @@ -585,6 +587,7 @@ it("admits a prefix and fitting suffix around an oversized Electron source", asy language: "JavaScript", frameId: null, hasSourceUrl: null, + frameClaims: {}, }; }); const completeness = new CdpCaptureCompleteness(); diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index d671178800..5197363685 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -30,6 +30,7 @@ import { cdpSnapshotString } from "./CdpSnapshotStrings.js"; import { ingestElectronScriptEvent, type ElectronScriptDraft, + type ElectronScriptMetadataRetention, } from "./CdpElectronScriptEvents.js"; import { captureElectronScripts } from "./CdpElectronScripts.js"; import { captureElectronWorkers } from "./CdpElectronWorkers.js"; @@ -54,6 +55,7 @@ interface ElectronContext { interface ElectronInspectionState { readonly completeness: CdpCaptureCompleteness; readonly scripts: ElectronScriptDraft[]; + readonly scriptMetadataRetention: ElectronScriptMetadataRetention; readonly executionContextFrames: Map; readonly frameGenerations: Map; readonly frameParents: Map; @@ -89,6 +91,7 @@ export const inspectCdpElectronPage = async ( const state: ElectronInspectionState = { completeness: new CdpCaptureCompleteness(), scripts: [], + scriptMetadataRetention: { bytes: 2 }, executionContextFrames: new Map(), frameGenerations: new Map(), frameParents: new Map(), @@ -121,6 +124,7 @@ export const inspectCdpElectronPage = async ( scripts: state.scripts, executionContextFrames: state.executionContextFrames, completeness: state.completeness, + retention: state.scriptMetadataRetention, }); const script = state.scripts[priorScripts]; if (script?.frameId != null) diff --git a/src/browser/CdpElectronScriptEvents.ts b/src/browser/CdpElectronScriptEvents.ts index e2b16b15b5..8461bed8bc 100644 --- a/src/browser/CdpElectronScriptEvents.ts +++ b/src/browser/CdpElectronScriptEvents.ts @@ -1,4 +1,7 @@ import type { CdpEvent } from "./CdpConnection.js"; +import type { ElectronScriptFrameClaims } from "../domain/javascript/electronObservation.js"; +import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; +import { CDP_CAPTURE_SCRIPT_METADATA_LIMITS } from "./CdpCaptureEventTypes.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; import { numberValue, @@ -16,6 +19,7 @@ export interface ElectronScriptDraft { readonly language: string | null; readonly frameId: string | null; readonly hasSourceUrl: boolean | null; + readonly frameClaims: ElectronScriptFrameClaims; } /** Frame identity is retained separately from the document generation's admission. */ @@ -24,12 +28,18 @@ export type ElectronScriptFrameScope = | { readonly state: "excluded" } | { readonly state: "unknown"; readonly frameId: string | null }; +/** Cumulative JSON bytes for this capture's retained draft array, starting at `[]`. */ +export interface ElectronScriptMetadataRetention { + bytes: number; +} + /** Retain script/frame metadata from passive Electron events. */ export const ingestElectronScriptEvent = (input: { readonly event: CdpEvent; readonly scripts: ElectronScriptDraft[]; readonly executionContextFrames: Map; readonly completeness: CdpCaptureCompleteness; + readonly retention: ElectronScriptMetadataRetention; }): void => { if (input.event.method === "Runtime.executionContextsCleared") { input.executionContextFrames.clear(); @@ -79,14 +89,33 @@ export const ingestElectronScriptEvent = (input: { typeof value.hasSourceURL !== "boolean") ) input.completeness.exclude("scripts", "invalid_protocol_value"); - input.scripts.push({ + const script: ElectronScriptDraft = { scriptId, rawUrl, ...scriptMetadataValues(value), frameId: conflictingFrame ? null : (declaredFrame ?? contextFrame ?? null), + frameClaims: { + ...(reportedFrame === undefined + ? {} + : { script_event_frame_id: reportedFrame }), + ...(contextFrame === undefined + ? {} + : { execution_context_frame_id: contextFrame }), + }, hasSourceUrl: typeof value?.hasSourceURL === "boolean" ? value.hasSourceURL : null, - }); + }; + const remaining = + CDP_CAPTURE_SCRIPT_METADATA_LIMITS.retainedBytes - input.retention.bytes; + const bytes = + jsonValueByteLength({ ...script }, remaining) + + (input.scripts.length > 0 ? 1 : 0); + if (bytes > remaining) { + input.completeness.exclude("scripts", "resource_budget_exhausted"); + return; + } + input.retention.bytes += bytes; + input.scripts.push(script); }; const retainExecutionContext = ( diff --git a/src/browser/CdpElectronScriptMetadata.test.ts b/src/browser/CdpElectronScriptMetadata.test.ts new file mode 100644 index 0000000000..bb92e56429 --- /dev/null +++ b/src/browser/CdpElectronScriptMetadata.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from "vitest"; + +import { DEFAULT_ASAR_HEADER_ADMISSION } from "../config/asarHeaderAdmission.js"; +import { inspectElectronPageInputSchema } from "../domain/javascript/electronObservation.js"; +import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; +import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; +import type { CdpConnection } from "./CdpConnection.js"; +import { + ingestElectronScriptEvent, + type ElectronScriptDraft, +} from "./CdpElectronScriptEvents.js"; +import { captureElectronScripts } from "./CdpElectronScripts.js"; + +// Exercise the existing byte-admission boundary without allocating an 8 MiB fixture. +vi.mock("./CdpCaptureEventTypes.js", () => ({ + CDP_CAPTURE_SCRIPT_METADATA_LIMITS: { retainedBytes: 2048, scripts: 50_000 }, +})); + +const oversizedClaim = "\0".repeat(400); + +describe("Electron script metadata byte admission", () => { + it("retains actual frame claims and later fitting observations after an escaped claim exceeds the allowance", () => { + const scripts: ElectronScriptDraft[] = []; + const completeness = new CdpCaptureCompleteness(); + const retention = { bytes: 2 }; + for (const [scriptId, frameId] of [ + ["prefix", "reported-frame"], + ["oversized", oversizedClaim], + ["suffix", ""], + ]) { + ingestElectronScriptEvent({ + event: { + method: "Debugger.scriptParsed", + params: { + scriptId, + url: "file:///fixture/app.js", + executionContextId: 7, + executionContextAuxData: { frameId }, + }, + }, + scripts, + executionContextFrames: new Map([["7", "context-frame"]]), + completeness, + retention, + }); + } + + expect(oversizedClaim.length).toBeLessThan(2048); + expect(scripts.map((script) => script.scriptId)).toEqual([ + "prefix", + "suffix", + ]); + expect(scripts[0]).toMatchObject({ + frameId: null, + frameClaims: { + script_event_frame_id: "reported-frame", + execution_context_frame_id: "context-frame", + }, + }); + expect(scripts[1]?.frameClaims.script_event_frame_id).toBe(""); + expect(retention.bytes).toBe( + jsonValueByteLength(scripts.map((script) => ({ ...script }))), + ); + expect(retention.bytes).toBeLessThanOrEqual(2048); + expect(completeness.snapshot().excluded).toContainEqual({ + section: "scripts", + reason: "resource_budget_exhausted", + count: 1, + }); + }); + + it("accounts for frame claims in normalized metadata before hashing and retains fitting siblings", async () => { + const completeness = new CdpCaptureCompleteness(); + const scripts: ElectronScriptDraft[] = [ + "prefix", + "oversized", + "suffix", + ].map((scriptId) => ({ + scriptId, + rawUrl: import.meta.url, + hash: scriptId, + length: null, + isModule: null, + language: null, + frameId: null, + hasSourceUrl: null, + frameClaims: { + script_event_frame_id: + scriptId === "oversized" ? oversizedClaim : "reported-frame", + execution_context_frame_id: "context-frame", + }, + })); + const capture = await captureElectronScripts({ + connection: { + send: async () => { + throw new Error("Source capture was not selected"); + }, + } as unknown as CdpConnection, + sessionId: undefined, + request: inspectElectronPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "page", + include_script_sources: false, + }), + scripts, + frameScopeForScript: () => ({ state: "unknown", frameId: null }), + completeness, + asarHeaderAdmission: DEFAULT_ASAR_HEADER_ADMISSION, + }); + + expect(capture.total).toBe(2); + expect(capture.items.map((item) => item.cdp_hash).sort()).toEqual([ + "prefix", + "suffix", + ]); + expect(capture.items[0]).toMatchObject({ + frame_id: null, + frame_scope: "unknown", + frame_claims: { + script_event_frame_id: "reported-frame", + execution_context_frame_id: "context-frame", + }, + }); + expect( + jsonValueByteLength( + capture.items.map(({ source: _source, ...metadata }) => metadata), + ), + ).toBeLessThanOrEqual(2048); + expect(completeness.snapshot().excluded).toContainEqual({ + section: "scripts", + reason: "resource_budget_exhausted", + count: 1, + }); + }); +}); diff --git a/src/browser/CdpElectronScripts.ts b/src/browser/CdpElectronScripts.ts index 52f2c600e3..5ed027cf62 100644 --- a/src/browser/CdpElectronScripts.ts +++ b/src/browser/CdpElectronScripts.ts @@ -8,6 +8,8 @@ import { createWebTextArtifact } from "../domain/webContentArtifact.js"; import type { CdpConnection } from "./CdpConnection.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; import { requiredScriptSource } from "./CdpCaptureValues.js"; +import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; +import { CDP_CAPTURE_SCRIPT_METADATA_LIMITS } from "./CdpCaptureEventTypes.js"; import type { ElectronScriptDraft, ElectronScriptFrameScope, @@ -42,6 +44,7 @@ export const captureElectronScripts = async ( let total = 0; const items: ElectronPageInspection["scripts"]["items"] = []; const seen = new Set(); + let metadataBytes = 2; const commandTimeoutMs = selectedCdpCommandTimeoutMs( input.request.command_timeout_ms, ); @@ -74,6 +77,7 @@ export const captureElectronScripts = async ( const identity = { frame_id: frame.frameId, frame_scope: frame.state, + frame_claims: script.frameClaims, file_path: path, reported_url: script.rawUrl, has_source_url: script.hasSourceUrl, @@ -82,9 +86,23 @@ export const captureElectronScripts = async ( is_module: script.isModule, language: script.language, }; + // Digest text has fixed ASCII width; count the final metadata shape + // before canonical hashing or retaining the expanded public projection. + const remaining = + CDP_CAPTURE_SCRIPT_METADATA_LIMITS.retainedBytes - metadataBytes; + const retainedBytes = + jsonValueByteLength( + { script_key: `electron_script_${"0".repeat(64)}`, ...identity }, + remaining, + ) + (items.length > 0 ? 1 : 0); + if (retainedBytes > remaining) { + input.completeness.exclude("scripts", "resource_budget_exhausted"); + continue; + } const scriptKey = `electron_script_${digestCanonicalValue(identity, "CDP capture")}`; if (seen.has(scriptKey)) continue; seen.add(scriptKey); + metadataBytes += retainedBytes; total += 1; let source: ElectronScriptSource = input.request.include_script_sources ? { diff --git a/src/domain/javascript/electronObservation.ts b/src/domain/javascript/electronObservation.ts index 7423af9d4d..9f2e9f3bfe 100644 --- a/src/domain/javascript/electronObservation.ts +++ b/src/domain/javascript/electronObservation.ts @@ -69,6 +69,15 @@ export const electronTargetListSchema = z.object({ }); export type ElectronTargetList = z.infer; +const electronScriptFrameClaimsSchema = z.object({ + script_event_frame_id: z.string().exactOptional(), + execution_context_frame_id: z.string().exactOptional(), +}); +/** Actual reported claims remain available when their resolved association is unknown. */ +export type ElectronScriptFrameClaims = z.infer< + typeof electronScriptFrameClaimsSchema +>; + /** Provider-neutral passive Electron file-page structure and script inventory. */ export const electronPageInspectionSchema = z.object({ browser: browserVersionSchema, @@ -97,6 +106,7 @@ export const electronPageInspectionSchema = z.object({ script_key: prefixedDigestSchema("electron_script"), frame_id: z.string().nullable().default(null), frame_scope: z.enum(["authorized", "unknown"]).exactOptional(), + frame_claims: electronScriptFrameClaimsSchema.exactOptional(), file_path: z.string(), has_source_url: z.boolean().nullable().exactOptional(), reported_url: z.string().exactOptional(), diff --git a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts index 7b79b7a8ae..b0be2de5bd 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts @@ -188,6 +188,9 @@ const scriptEntities = ( ...("has_source_url" in script && script.has_source_url !== undefined ? { has_source_url: script.has_source_url } : {}), + ...("frame_claims" in script && script.frame_claims !== undefined + ? { frame_claims: script.frame_claims } + : {}), cdp_hash: script.cdp_hash, length: script.length, is_module: script.is_module, diff --git a/tests/boundary/browser/cdpElectronProvider.test.ts b/tests/boundary/browser/cdpElectronProvider.test.ts index 747e936040..1c447e6a2e 100644 --- a/tests/boundary/browser/cdpElectronProvider.test.ts +++ b/tests/boundary/browser/cdpElectronProvider.test.ts @@ -422,6 +422,7 @@ it("excludes srcdoc scripts before source reads while retaining declared labels frame_scope: "authorized", has_source_url: true, reported_url: label, + frame_claims: { script_event_frame_id: "frame-main" }, file_path: join(root, "app.js"), source: { included: true }, }); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts index 20f0008ff1..619e104803 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts @@ -291,6 +291,10 @@ it("preserves unknown Electron frame provenance without linking it to an authori frame_scope: "unknown", has_source_url: true, reported_url: "file:///reported-label.js", + frame_claims: { + script_event_frame_id: "declared-frame", + execution_context_frame_id: "context-frame", + }, })), }, }, @@ -310,6 +314,10 @@ it("preserves unknown Electron frame provenance without linking it to an authori reported_frame_id: inspection.scripts.items[0]?.frame_id, has_source_url: true, reported_url: "file:///reported-label.js", + frame_claims: { + script_event_frame_id: "declared-frame", + execution_context_frame_id: "context-frame", + }, }), }), ]);