From df5ed1a4956ef861165ef3311c8f13226166aeb1 Mon Sep 17 00:00:00 2001 From: Ljy-0827 Date: Tue, 8 Sep 2026 19:22:46 +0800 Subject: [PATCH] fix(observation): validate document consistency during capture --- .../__tests__/chromium-cdp.test.ts | 18 +++++ .../__tests__/frame-graph.test.ts | 18 +++++ .../src/browser-driver/chromium-cdp.ts | 10 +++ .../src/browser-driver/frame-graph.ts | 3 + apps/extension/src/tools/shared.ts | 1 + .../src/tools/vom/document-identity.ts | 69 +++++++++++++++++++ apps/extension/src/tools/vom/facts.ts | 9 +++ apps/extension/src/tools/vom/normalize.ts | 7 ++ 8 files changed, 135 insertions(+) create mode 100644 apps/extension/src/tools/vom/document-identity.ts diff --git a/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts b/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts index fccd4e2..41b6719 100644 --- a/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts +++ b/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts @@ -29,6 +29,24 @@ function fakeApi() { } describe("ChromiumCdp", () => { + it("changes attachment identity only across real debugger attachments", async () => { + const { api, onEvent, onDetach } = fakeApi(); + const cdp = new ChromiumCdp(api); + expect(cdp.getAttachmentId(4)).toBeUndefined(); + await cdp.ensureAttached(4); + const first = cdp.getAttachmentId(4); + expect(first).toBeTruthy(); + await cdp.ensureAttached(4); + onEvent.fire({ tabId: 4 }, "Target.attachedToTarget", { sessionId: "child" }); + expect(cdp.getAttachmentId(4)).toBe(first); + onDetach.fire({ tabId: 4 }, "target_closed"); + expect(cdp.getAttachmentId(4)).toBeUndefined(); + await cdp.ensureAttached(4); + expect(cdp.getAttachmentId(4)).not.toBe(first); + await cdp.detach(4); + expect(cdp.getAttachmentId(4)).toBeUndefined(); + }); + it("discovers multiple iframe targets and recursively routes nested OOPIF commands", async () => { const { api, onEvent } = fakeApi(); (api.sendCommand as ReturnType).mockImplementation( diff --git a/apps/extension/src/browser-driver/__tests__/frame-graph.test.ts b/apps/extension/src/browser-driver/__tests__/frame-graph.test.ts index b9d4b40..3838d2b 100644 --- a/apps/extension/src/browser-driver/__tests__/frame-graph.test.ts +++ b/apps/extension/src/browser-driver/__tests__/frame-graph.test.ts @@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest"; import { buildFrameGraph, type CdpFrameTreeNode, type CdpFrameTreeSource } from "../frame-graph"; describe("buildFrameGraph", () => { + it("preserves the owning target's loader identity", () => { + const graph = buildFrameGraph([ + { + target: { tabId: 4 }, + tree: { + frame: { id: "root", loaderId: "root-loader" }, + childFrames: [{ frame: { id: "child", loaderId: "stale-placeholder" } }], + }, + }, + { + target: { tabId: 4, sessionId: "child-session" }, + tree: { frame: { id: "child", loaderId: "child-loader" } }, + }, + ]); + expect(graph?.frames.find((frame) => frame.frameId === "root")?.loaderId).toBe("root-loader"); + expect(graph?.frames.find((frame) => frame.frameId === "child")?.loaderId).toBe("child-loader"); + }); + it("keeps sibling frames distinct and routes nested OOPIFs to their child sessions", () => { const graph = buildFrameGraph([ { diff --git a/apps/extension/src/browser-driver/chromium-cdp.ts b/apps/extension/src/browser-driver/chromium-cdp.ts index 8af2abc..ddbf5ee 100644 --- a/apps/extension/src/browser-driver/chromium-cdp.ts +++ b/apps/extension/src/browser-driver/chromium-cdp.ts @@ -164,6 +164,7 @@ async function settleBeforeDeadline(promises: Promise[], deadline: number) export class ChromiumCdp { private readonly api: CdpDebuggerApi; private readonly attachedTabs = new Set(); + private readonly attachmentIds = new Map(); private readonly attachInFlight = new Map>(); private readonly detachInFlight = new Map>(); private readonly tabOwners = new Map>(); @@ -198,6 +199,11 @@ export class ChromiumCdp { this.bindFrameTargetHandler(); } + /** Identity of this actual debugger attachment, independent of frame topology. */ + getAttachmentId(tabId: number): string | undefined { + return this.attachmentIds.get(tabId); + } + /** Attach to `tabId` if we haven't already in this driver. */ async ensureAttached(tabId: number): Promise { // Returning a tab clears the cache before Chrome finishes detaching. @@ -217,6 +223,7 @@ export class ChromiumCdp { await this.enableConsoleDomains(tabId); await this.enableNetworkDomainBestEffort(tabId); this.attachedTabs.add(tabId); + this.attachmentIds.set(tabId, crypto.randomUUID()); await this.enableFrameDiscovery({ tabId }).catch((err) => { console.debug("[bsk cdp] frame discovery unavailable", { tabId, err }); }); @@ -444,6 +451,7 @@ export class ChromiumCdp { this.attachInFlight.delete(tabId); if (!this.attachedTabs.has(tabId)) return; this.attachedTabs.delete(tabId); + this.attachmentIds.delete(tabId); this.clearDialogState(tabId); this.clearConsoleState(tabId); this.clearNetworkState(tabId); @@ -502,6 +510,7 @@ export class ChromiumCdp { this.attachInFlight.clear(); this.tabOwners.clear(); this.attachedTabs.clear(); + this.attachmentIds.clear(); this.dialogBuffers.clear(); this.dialogSequences.clear(); this.consoleBuffers.clear(); @@ -848,6 +857,7 @@ export class ChromiumCdp { const listener = (source: chrome.debugger.Debuggee, _reason: string) => { if (typeof source.tabId === "number") { this.attachedTabs.delete(source.tabId); + this.attachmentIds.delete(source.tabId); this.attachInFlight.delete(source.tabId); this.tabOwners.delete(source.tabId); this.clearDialogState(source.tabId); diff --git a/apps/extension/src/browser-driver/frame-graph.ts b/apps/extension/src/browser-driver/frame-graph.ts index 1dc2ead..2367a5f 100644 --- a/apps/extension/src/browser-driver/frame-graph.ts +++ b/apps/extension/src/browser-driver/frame-graph.ts @@ -8,6 +8,7 @@ export interface CdpFrame { parentFrameId?: string; ownerBackendNodeId?: number; url?: string; + loaderId?: string; target: CdpTarget; } @@ -21,6 +22,7 @@ export interface CdpFrameTreeNode { id: string; parentId?: string; url?: string; + loaderId?: string; }; childFrames?: CdpFrameTreeNode[]; } @@ -48,6 +50,7 @@ function mergeFrame( frameId: node.frame.id, ...(parentFrameId ? { parentFrameId } : {}), ...(url ? { url } : {}), + ...(node.frame.loaderId ? { loaderId: node.frame.loaderId } : {}), target, }); } diff --git a/apps/extension/src/tools/shared.ts b/apps/extension/src/tools/shared.ts index 82aed79..7ae9fda 100644 --- a/apps/extension/src/tools/shared.ts +++ b/apps/extension/src/tools/shared.ts @@ -55,6 +55,7 @@ export interface CdpRunner { sendToTarget?(target: CdpTarget, method: string, params?: object): Promise; detach?(tabId: number): Promise; getFrameGraph?(tabId: number): Promise; + getAttachmentId?(tabId: number): string | undefined; ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise; trackSessionTab?(sessionId: string, tabId: number): void; releaseSessionTab?(sessionId: string, tabId: number): Promise; diff --git a/apps/extension/src/tools/vom/document-identity.ts b/apps/extension/src/tools/vom/document-identity.ts new file mode 100644 index 0000000..365c2e5 --- /dev/null +++ b/apps/extension/src/tools/vom/document-identity.ts @@ -0,0 +1,69 @@ +import type { CdpFrame } from "@/browser-driver/frame-graph"; +import type { CdpRunner } from "../shared"; +import { sendToCdpTarget } from "../shared"; +import { + isAbortError as isCaptureAbort, + throwIfAborted as throwCaptureAborted, +} from "./capture-abort"; +import type { DocumentIdentity } from "./facts"; + +/** Fresh, frame-scoped identity read. An isolated world supplies the owning + * document without querying each node or installing a page observer. */ +export async function readDocumentIdentity( + cdp: CdpRunner, + frame: CdpFrame, + signal?: AbortSignal, +): Promise { + const attachmentId = cdp.getAttachmentId?.(frame.target.tabId); + if (!attachmentId || !frame.loaderId) return undefined; + let objectId: string | undefined; + const send = (method: string, params: object) => { + throwCaptureAborted(signal); + return sendToCdpTarget(cdp, frame.target, method, params); + }; + try { + const world = await send<{ executionContextId: number }>("Page.createIsolatedWorld", { + frameId: frame.frameId, + worldName: "bsk-document-identity", + }); + const element = await send<{ result?: { objectId?: string } }>("Runtime.evaluate", { + expression: "document.documentElement", + contextId: world.executionContextId, + }); + objectId = element.result?.objectId; + if (!objectId) return undefined; + const reply = await send<{ node?: { backendNodeId?: number } }>("DOM.describeNode", { + objectId, + depth: 0, + }); + const backendNodeId = reply.node?.backendNodeId; + if (!backendNodeId || cdp.getAttachmentId?.(frame.target.tabId) !== attachmentId) + return undefined; + return { + attachmentId, + target: frame.target, + frameId: frame.frameId, + loaderId: frame.loaderId, + documentElementBackendNodeId: backendNodeId, + }; + } catch (error) { + if (isCaptureAbort(error)) throw error; + return undefined; + } finally { + if (objectId) + await sendToCdpTarget(cdp, frame.target, "Runtime.releaseObject", { objectId }).catch( + () => {}, + ); + } +} + +export function sameDocument(a: DocumentIdentity, b: DocumentIdentity): boolean { + return ( + a.attachmentId === b.attachmentId && + a.target.tabId === b.target.tabId && + a.target.sessionId === b.target.sessionId && + a.frameId === b.frameId && + a.loaderId === b.loaderId && + a.documentElementBackendNodeId === b.documentElementBackendNodeId + ); +} diff --git a/apps/extension/src/tools/vom/facts.ts b/apps/extension/src/tools/vom/facts.ts index e257d01..59be289 100644 --- a/apps/extension/src/tools/vom/facts.ts +++ b/apps/extension/src/tools/vom/facts.ts @@ -1,3 +1,4 @@ +import type { CdpTarget } from "@/browser-driver/frame-graph"; import { isOverlayHostNode } from "@/lib/overlay-bridge"; import { createCaptureCheckpoint } from "./capture-abort"; import type { CapturedNode } from "./capture-types"; @@ -92,3 +93,11 @@ export async function buildDocumentIndex( } return { nodes, excludedBackendNodeIds }; } + +export interface DocumentIdentity { + attachmentId: string; + target: CdpTarget; + frameId: string; + loaderId: string; + documentElementBackendNodeId: number; +} diff --git a/apps/extension/src/tools/vom/normalize.ts b/apps/extension/src/tools/vom/normalize.ts index 66bbc4d..d785ee4 100644 --- a/apps/extension/src/tools/vom/normalize.ts +++ b/apps/extension/src/tools/vom/normalize.ts @@ -21,6 +21,7 @@ export interface FrameContext { export interface NormalizedDocument { nodes: NodeFacts[]; index: DocumentIndex; + documentElementBackendNodeId?: number; } /** Interpret one document using a supplied projection. No live reads or frame scheduling. */ @@ -74,5 +75,11 @@ export async function normalizeDocument( (node) => !node.tag.startsWith("#") && !index.excludedBackendNodeIds.has(node.backendNodeId), ), index, + documentElementBackendNodeId: nodes.find( + (node) => + node.nodeType === 1 && + node.parentBackendNodeId !== null && + index.nodes.get(node.parentBackendNodeId)?.nodeType === 9, + )?.backendNodeId, }; }