fix(electron): separate script frame admission from declared source labels

This commit is contained in:
morluto committed 2026-10-24 04:16:47 +08:00
1 parent aad3d88cc0
commit df00588325
8 files changed
+272 -41

No files matched your search

+17 -2
View File
@@ -76,8 +76,23 @@ completeness diagnostics, so `total_nodes` can exceed the retained node count
and a parent index can refer to an omitted source node.
Script metadata retains its execution-context frame ID when CDP supplies one and
the observed frame generation is authorized. Frames describe the current inventory;
script observations can retain earlier authorized frames that have since detached.
records admission separately in `frame_scope`. An `authorized` scope refers to
the observed document generation; `unknown` retains the known frame ID without
claiming authorization and makes script coverage attach-limited. Historical
captures may omit this field, which leaves that admission fact unknown.
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.
Frames describe the current inventory; script observations can retain earlier
authorized frames that have since detached.
`has_source_url` preserves CDP's sourceURL declaration flag as true, false, or
unknown (`null`, or omitted in historical captures). A script's canonical
`file_path` is the admitted local-file URL or supplied sourceURL label. It does
not verify that the runtime bytes came from that disk file; captured source
bytes have their own digest. `reported_url` retains the original URL or label
before canonical path admission. Labels remain distinct from filesystem provenance.
The capture also
inventories worker, service-worker, and shared-worker targets with
validated opener-target and parent-frame IDs. Worker discovery uses passive
+48 -1
View File
@@ -3,9 +3,56 @@ import { describe, expect, it } from "vitest";
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
import { CdpCaptureEvents } from "./CdpCaptureEvents.js";
import { ingestElectronScriptEvent } from "./CdpElectronScriptEvents.js";
import {
ingestElectronScriptEvent,
type ElectronScriptDraft,
} from "./CdpElectronScriptEvents.js";
describe("CDP execution-context retention", () => {
it.each([
{
frameId: "frame-other",
hasSourceURL: true,
expectedFrame: null,
expectedLabel: true,
},
{
frameId: "frame-main",
hasSourceURL: "invalid",
expectedFrame: "frame-main",
expectedLabel: null,
},
])(
"keeps contradictory Electron ownership and invalid source labels uncertain: $frameId/$hasSourceURL",
({ frameId, hasSourceURL, expectedFrame, expectedLabel }) => {
const scripts: ElectronScriptDraft[] = [];
const completeness = new CdpCaptureCompleteness();
ingestElectronScriptEvent({
event: {
method: "Debugger.scriptParsed",
params: {
scriptId: "script",
url: "file:///fixture/app.js",
executionContextId: 7,
executionContextAuxData: { frameId },
hasSourceURL,
},
},
scripts,
executionContextFrames: new Map([["7", "frame-main"]]),
completeness,
});
expect(scripts[0]).toMatchObject({
frameId: expectedFrame,
hasSourceUrl: expectedLabel,
});
expect(completeness.snapshot().excluded).toContainEqual({
section: "scripts",
reason: "invalid_protocol_value",
count: 1,
});
},
);
it("removes stale frame attribution when execution contexts are destroyed", () => {
const request = inspectWebPageInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
+15 -8
View File
@@ -209,7 +209,11 @@ it.each([
expect(fired).toBe(true);
if (stage !== "final")
expect(result.scripts.items).toContainEqual(
expect.objectContaining({ frame_id: null, cdp_hash: "new-document" }),
expect.objectContaining({
frame_id: "child",
frame_scope: "unknown",
cdp_hash: "new-document",
}),
);
expect(result.completeness.attach_limited_sections).toContain("frames");
}
@@ -369,7 +373,8 @@ it.each([
expect(captured.scripts.items).toContainEqual(
expect.objectContaining({
cdp_hash: "observed",
frame_id: kind === "duplicate-child" ? "child" : null,
frame_id: "child",
frame_scope: kind === "duplicate-child" ? "authorized" : "unknown",
}),
);
}
@@ -518,11 +523,11 @@ describe("Electron frame capture", () => {
include_script_sources: false,
}),
scripts,
frameForScript: (script) =>
frameScopeForScript: (script) =>
script.frameId !== null &&
new Set(["frame-old", "frame-new"]).has(script.frameId)
? script.frameId
: null,
? { state: "authorized", frameId: script.frameId }
: { state: "unknown", frameId: script.frameId },
completeness,
asarHeaderAdmission: DEFAULT_ASAR_HEADER_ADMISSION,
});
@@ -543,7 +548,7 @@ describe("Electron frame capture", () => {
),
).toEqual({
unknown: null,
outside: null,
outside: "frame-outside",
});
expect(completeness.snapshot().excluded).toContainEqual({
section: "scripts",
@@ -579,6 +584,7 @@ it("admits a prefix and fitting suffix around an oversized Electron source", asy
isModule: false,
language: "JavaScript",
frameId: null,
hasSourceUrl: null,
};
});
const completeness = new CdpCaptureCompleteness();
@@ -602,7 +608,7 @@ it("admits a prefix and fitting suffix around an oversized Electron source", asy
include_script_sources: true,
}),
scripts,
frameForScript: () => null,
frameScopeForScript: () => ({ state: "unknown", frameId: null }),
completeness,
asarHeaderAdmission: DEFAULT_ASAR_HEADER_ADMISSION,
});
@@ -788,7 +794,8 @@ it.each([
expect(captured.scripts.items).toContainEqual(
expect.objectContaining({
cdp_hash: `after-${id}`,
frame_id: changed ? null : id,
frame_id: id,
frame_scope: changed ? "unknown" : "authorized",
}),
);
}
+22 -11
View File
@@ -59,6 +59,7 @@ interface ElectronInspectionState {
readonly frameParents: Map<string, string | null>;
readonly frameDocuments: Map<string, ElectronFrameDocument>;
readonly authorizedFrameGenerations: Set<string>;
readonly excludedFrameGenerations: Set<string>;
readonly scriptGenerations: WeakMap<ElectronScriptDraft, number>;
readonly limitations: string[];
mainFrameId: string | undefined;
@@ -68,6 +69,7 @@ interface ElectronInspectionState {
interface ElectronFrameInventory {
readonly frames: ElectronPageInspection["frames"];
readonly parents: ReadonlyMap<string, string | null>;
readonly excludedFrameIds: ReadonlySet<string>;
}
interface ElectronFrameRead extends ElectronFrameInventory {
@@ -92,12 +94,15 @@ export const inspectCdpElectronPage = async (
frameParents: new Map(),
frameDocuments: new Map(),
authorizedFrameGenerations: new Set(),
excludedFrameGenerations: new Set(),
scriptGenerations: new WeakMap(),
limitations: [
...(context.discovery.limitations ?? []),
"Local file paths exposed by the selected CDP target are retained in the observation.",
"REA does not evaluate renderer JavaScript, invoke Electron APIs, navigate, click, or close the page.",
"Select include_script_sources to retain script contents.",
"Script file_path is the admitted local-file URL or sourceURL label, not a verification that disk bytes supplied the runtime code. has_source_url retains CDP's sourceURL declaration flag; null or an omitted historical field is unknown.",
"Known script frame identity is retained independently from frame_scope authorization. Unknown frame scope is attach-limited; scripts from known excluded document generations are omitted before source reads.",
...(context.target.attached === null
? [
"CDP discovery omitted the target attachment state; attached is reported as unknown.",
@@ -234,6 +239,11 @@ const authorizeFrameGenerations = (
compositeKey([frame.frame_id, generation]),
);
}
for (const id of read.excludedFrameIds)
if (!unproven.has(id))
state.excludedFrameGenerations.add(
compositeKey([id, read.generations.get(id) ?? 0]),
);
};
/** A changed ancestor also invalidates descendants first discovered in this tree. */
@@ -512,17 +522,16 @@ const captureElectronContent = async (
...(context.signal === undefined ? {} : { signal: context.signal }),
request: context.input,
scripts: state.scripts,
frameForScript: (script) => {
frameScopeForScript: (script) => {
const generation = state.scriptGenerations.get(script);
const authorized =
script.frameId !== null &&
generation !== undefined &&
state.authorizedFrameGenerations.has(
compositeKey([script.frameId, generation]),
);
if (script.frameId !== null && !authorized)
state.completeness.attachLimited("scripts");
return authorized ? script.frameId : null;
if (script.frameId !== null && generation !== undefined) {
const key = compositeKey([script.frameId, generation]);
if (state.excludedFrameGenerations.has(key))
return { state: "excluded" };
if (state.authorizedFrameGenerations.has(key))
return { state: "authorized", frameId: script.frameId };
}
return { state: "unknown", frameId: script.frameId };
},
completeness: state.completeness,
asarHeaderAdmission: context.asarHeaderAdmission,
@@ -606,6 +615,7 @@ export const captureFrames = async (
const frames: ElectronPageInspection["frames"] = [];
const enclosingParents = new Map<UnknownRecord, string>();
const parents = new Map<string, string | null>();
const excludedFrameIds = new Set<string>();
for (const tree of walkFrameTrees(frameTree, (count) =>
completeness.exclude("frames", "invalid_protocol_value", count),
)) {
@@ -643,6 +653,7 @@ export const captureFrames = async (
);
if (path === undefined) {
completeness.exclude("frames", "out_of_target_scope");
excludedFrameIds.add(frameId);
continue;
}
frames.push({
@@ -651,7 +662,7 @@ export const captureFrames = async (
file_path: path,
});
}
return { frames, parents };
return { frames, parents, excludedFrameIds };
};
const captureResources = async (
+32 -5
View File
@@ -15,8 +15,15 @@ export interface ElectronScriptDraft {
readonly isModule: boolean | null;
readonly language: string | null;
readonly frameId: string | null;
readonly hasSourceUrl: boolean | null;
}
/** Frame identity is retained separately from the document generation's admission. */
export type ElectronScriptFrameScope =
| { readonly state: "authorized"; readonly frameId: string }
| { readonly state: "excluded" }
| { readonly state: "unknown"; readonly frameId: string | null };
/** Retain script/frame metadata from passive Electron events. */
export const ingestElectronScriptEvent = (input: {
readonly event: CdpEvent;
@@ -51,14 +58,34 @@ export const ingestElectronScriptEvent = (input: {
return;
}
const contextKey = executionContextKey(value?.executionContextId);
const contextFrame =
contextKey === null
? undefined
: input.executionContextFrames.get(contextKey);
const auxiliary = recordValue(value?.executionContextAuxData);
const reportedFrame = cdpStringValue(auxiliary?.frameId);
const declaredFrame = reportedFrame === "" ? undefined : reportedFrame;
const conflictingFrame =
declaredFrame !== undefined &&
contextFrame !== undefined &&
declaredFrame !== contextFrame;
if (
conflictingFrame ||
(value?.executionContextAuxData !== undefined && auxiliary === undefined) ||
(auxiliary !== undefined &&
Object.hasOwn(auxiliary, "frameId") &&
declaredFrame === undefined) ||
(value?.hasSourceURL !== undefined &&
typeof value.hasSourceURL !== "boolean")
)
input.completeness.exclude("scripts", "invalid_protocol_value");
input.scripts.push({
scriptId,
rawUrl,
...scriptMetadataValues(value),
frameId:
contextKey === null
? null
: (input.executionContextFrames.get(contextKey) ?? null),
frameId: conflictingFrame ? null : (declaredFrame ?? contextFrame ?? null),
hasSourceUrl:
typeof value?.hasSourceURL === "boolean" ? value.hasSourceURL : null,
});
};
@@ -70,7 +97,7 @@ const retainExecutionContext = (
const runtimeContext = recordValue(parameters?.context);
const contextKey = executionContextKey(runtimeContext?.id);
const frameId = cdpStringValue(recordValue(runtimeContext?.auxData)?.frameId);
if (contextKey === null || frameId === undefined) return;
if (contextKey === null || frameId === undefined || frameId === "") return;
frames.set(contextKey, frameId);
};
+18 -3
View File
@@ -8,7 +8,10 @@ import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import type { CdpConnection } from "./CdpConnection.js";
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
import { requiredScriptSource } from "./CdpCaptureValues.js";
import type { ElectronScriptDraft } from "./CdpElectronScriptEvents.js";
import type {
ElectronScriptDraft,
ElectronScriptFrameScope,
} from "./CdpElectronScriptEvents.js";
import { authorizedElectronFile } from "./ElectronFileScope.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
@@ -25,7 +28,9 @@ interface ElectronScriptCaptureInput {
readonly signal?: AbortSignal;
readonly request: InspectElectronPageInput;
readonly scripts: readonly ElectronScriptDraft[];
readonly frameForScript: (script: ElectronScriptDraft) => string | null;
readonly frameScopeForScript: (
script: ElectronScriptDraft,
) => ElectronScriptFrameScope;
readonly completeness: CdpCaptureCompleteness;
readonly asarHeaderAdmission: AsarHeaderAdmission;
}
@@ -49,6 +54,11 @@ export const captureElectronScripts = async (
try {
for (const script of input.scripts) {
throwIfCallerCancelled(input.signal);
const frame = input.frameScopeForScript(script);
if (frame.state === "excluded") {
input.completeness.exclude("scripts", "out_of_target_scope");
continue;
}
const path = await authorizedElectronFile(
script.rawUrl,
input.signal,
@@ -59,9 +69,14 @@ export const captureElectronScripts = async (
input.completeness.exclude("scripts", "out_of_target_scope");
continue;
}
if (frame.state === "unknown")
input.completeness.attachLimited("scripts");
const identity = {
frame_id: input.frameForScript(script),
frame_id: frame.frameId,
frame_scope: frame.state,
file_path: path,
reported_url: script.rawUrl,
has_source_url: script.hasSourceUrl,
cdp_hash: script.hash,
length: script.length,
is_module: script.isModule,
@@ -96,7 +96,10 @@ export const electronPageInspectionSchema = z.object({
z.object({
script_key: prefixedDigestSchema("electron_script"),
frame_id: z.string().nullable().default(null),
frame_scope: z.enum(["authorized", "unknown"]).optional(),
file_path: z.string(),
has_source_url: z.boolean().nullable().optional(),
reported_url: z.string().optional(),
...cdpScriptMetadataSchema.shape,
source: cdpScriptSourceSchema,
}),
@@ -126,14 +126,22 @@ it.each(["late", "initial", "destroyed", "final-malformed", "out-of-scope"])(
expect(
inspected.value.frames.some(({ frame_id }) => frame_id === "child"),
).toBe(kind !== "out-of-scope");
expect(inspected.value.scripts.items).toContainEqual(
expect.objectContaining({
file_path: join(root, "app.js"),
// Main snapshots historical frame identity at script ingestion, so
// later context teardown does not erase this observed association.
frame_id: kind === "out-of-scope" ? null : "child",
}),
);
if (kind === "out-of-scope") {
expect(inspected.value.scripts.items).toEqual([]);
expect(
browser.commands.some(
({ method }) => method === "Debugger.getScriptSource",
),
).toBe(false);
} else
expect(inspected.value.scripts.items).toContainEqual(
expect.objectContaining({
file_path: join(root, "app.js"),
// Later context teardown does not erase the observed association.
frame_id: "child",
frame_scope: "authorized",
}),
);
if (kind === "final-malformed") {
expect(inspected.value.completeness.attach_limited_sections).toEqual(
expect.arrayContaining(["frames", "scripts", "workers"]),
@@ -339,9 +347,107 @@ it.each([
).toEqual(["first", "second"]);
if (kind !== "detached" && kind !== "same-document")
expect(
result.value.scripts.items.find(({ cdp_hash }) => cdp_hash === "foreign")
?.frame_id,
).toBeNull();
result.value.scripts.items.find(({ cdp_hash }) => cdp_hash === "foreign"),
).toMatchObject({
frame_id: kind === "unvalidated-context" ? "not-authorized" : "first",
frame_scope: "unknown",
});
});
it("excludes srcdoc scripts before source reads while retaining declared labels and unknown frame scope", async () => {
const root = await electronFixture();
const mainUrl = pathToFileURL(join(root, "index.html")).href;
const label = pathToFileURL(join(root, "app.js")).href;
const browser = await startFakeCdpBrowser({
electronFileUrl: mainUrl,
eventsBeforeReply: ["Debugger.enable"],
commandResult: ({ method, params }) =>
method === "Page.getFrameTree"
? {
frameTree: {
frame: { id: "frame-main", url: mainUrl },
childFrames: [
{
frame: {
id: "srcdoc",
parentId: "frame-main",
url: "about:srcdoc",
},
},
],
},
}
: method === "Debugger.getScriptSource"
? { scriptSource: `void ${String(params.scriptId)};` }
: undefined,
commandEvents: ({ method, sessionId }) =>
method === "Runtime.enable"
? []
: method === "Debugger.enable"
? [
["declared", "frame-main", true],
["reported", "frame-main", false],
["excluded", "srcdoc", true],
["unknown", "unproven-frame", true],
].map(([scriptId, frameId, hasSourceURL], index) => ({
method: "Debugger.scriptParsed",
sessionId,
params: {
scriptId,
url: label,
executionContextId: index + 1,
executionContextAuxData: { frameId },
hasSourceURL,
hash: scriptId,
},
}))
: undefined,
});
browsers.push(browser);
const result = await new CdpElectronProvider().inspectPage(
inspectElectronPageInputSchema.parse({
cdp_endpoint: browser.endpoint,
target_id: "electron-page",
observation_ms: 0,
include_script_sources: true,
}),
);
if (!result.ok) throw result.error;
const byHash = new Map(
result.value.scripts.items.map((script) => [script.cdp_hash, script]),
);
expect(byHash.has("excluded")).toBe(false);
expect(byHash.get("declared")).toMatchObject({
frame_id: "frame-main",
frame_scope: "authorized",
has_source_url: true,
reported_url: label,
file_path: join(root, "app.js"),
source: { included: true },
});
expect(byHash.get("reported")).toMatchObject({
has_source_url: false,
frame_scope: "authorized",
});
expect(byHash.get("unknown")).toMatchObject({
frame_id: "unproven-frame",
frame_scope: "unknown",
has_source_url: true,
});
expect(
browser.commands
.filter(({ method }) => method === "Debugger.getScriptSource")
.map(({ params }) => params.scriptId)
.sort(),
).toEqual(["declared", "reported", "unknown"]);
expect(result.value.completeness.excluded).toContainEqual({
section: "scripts",
reason: "out_of_target_scope",
count: 1,
});
expect(result.value.completeness.attach_limited_sections).toContain(
"scripts",
);
});
describe("CdpElectronProvider target access", () => {