From c71aded3eec4d9d6f7b677f674f53699e5f4b3ed Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:44:08 +0800 Subject: [PATCH] fix(browser): bind passive captures to selected CDP documents (#10031) * fix(browser): bind passive captures to selected CDP documents * fix(browser): report WebMCP completion only after success * fix(electron): retain unknown document identity on missing loader * fix(browser): retain proven document identity across unknown reads * fix(browser): disclose unknown initial document identity * fix(electron): preserve unknown initial document authority --------- Co-authored-by: Cursor Agent --- src/browser/CdpAgentHints.integration.test.ts | 195 +++++++++++++++++ src/browser/CdpElectronInspection.test.ts | 196 ++++++++++++++++++ src/browser/CdpElectronInspection.ts | 128 ++++++++---- src/browser/CdpScreenshot.ts | 48 ++++- src/browser/CdpWebMcpDiscovery.ts | 92 ++++++-- 5 files changed, 600 insertions(+), 59 deletions(-) diff --git a/src/browser/CdpAgentHints.integration.test.ts b/src/browser/CdpAgentHints.integration.test.ts index 4bb953188f..f6da762eee 100644 --- a/src/browser/CdpAgentHints.integration.test.ts +++ b/src/browser/CdpAgentHints.integration.test.ts @@ -4,6 +4,10 @@ import { createBrowserObservationProvider } from "../composition/browserObservat import { inspectWebPage } from "../application/BrowserObservationService.js"; import { inspectWebPageInputSchema } from "../domain/browserObservation.js"; import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js"; +import { discoverWebMcpToolsInputSchema } from "../domain/webMcpDiscovery.js"; +import { discoverWebMcp } from "./CdpWebMcpDiscovery.js"; +import type { CdpConnection, CdpEvent } from "./CdpConnection.js"; +import { captureWebScreenshotInputSchema } from "../domain/webScreenshot.js"; it.each(["", "h:"])( "retains agent hints from XHTML links with prefix %j", @@ -88,3 +92,194 @@ it.each(["", "h:"])( }, 30_000, ); + +it.each(["progress", "enable", "terminal", "complete"] as const)( + "releases WebMCP subscriptions after %s settlement", + async (stage) => { + const listeners = new Set<(event: CdpEvent) => void>(); + const failure = new Error("controlled discovery interruption"); + const unexpectedTerminal = new Error("terminal progress after failure"); + let interrupted = true; + const progressUpdates: number[] = []; + const connection = { + onEvent: (listener: (event: CdpEvent) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + send: async (method: string) => { + if (method === "Page.getFrameTree") + return { + frameTree: { frame: { id: "main", url: "https://app.test/" } }, + }; + if (method === "WebMCP.enable" && stage === "enable" && interrupted) + throw failure; + return {}; + }, + } as unknown as CdpConnection; + const context: Parameters[0] = { + connection, + sessionId: undefined, + discovery: { + version: { + product: "fixture", + protocol_version: "1", + revision: "", + user_agent: "fixture", + js_version: "", + }, + targets: [], + webSocket: { scope: "browser", url: "ws://127.0.0.1:9222" }, + }, + target: { + id: "selected", + type: "page", + title: "fixture", + url: "https://app.test/", + attached: false, + }, + input: discoverWebMcpToolsInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "selected", + allowed_origins: ["https://app.test"], + observation_ms: 0, + }), + progress: { + report: async (update) => { + progressUpdates.push(update.completed); + if ( + interrupted && + update.completed === 2 && + (stage === "progress" || stage === "enable") + ) + throw unexpectedTerminal; + if ( + interrupted && + ((stage === "progress" && update.completed === 1) || + (stage === "terminal" && update.completed === 2)) + ) + throw failure; + }, + }, + }; + const result = discoverWebMcp(context); + if (stage === "complete") + await expect(result).resolves.toMatchObject({ status: "available" }); + else await expect(result).rejects.toBe(failure); + expect(listeners.size).toBe(0); + expect(progressUpdates).toEqual( + stage === "progress" || stage === "enable" ? [1] : [1, 2], + ); + interrupted = false; + await expect(discoverWebMcp(context)).resolves.toMatchObject({ + status: "available", + }); + expect(listeners.size).toBe(0); + }, +); + +it.each( + ["screenshot", "webmcp"].flatMap((operation) => + [ + "unchanged", + "unknown-loader", + "unknown-initial-loader", + "missing-final-loader", + "empty-final-loader", + "frame", + "loader", + ].map((change) => ({ + operation, + change, + })), + ), +)( + "verifies final $operation document identity from tree snapshots: $change", + async ({ operation, change }) => { + let trees = 0; + const browser = await startFakeCdpBrowser({ + commandResult: (command, origin) => { + if (command.method !== "Page.getFrameTree") return undefined; + trees += 1; + return { + frameTree: { + frame: { + id: + trees > 1 && change === "frame" + ? "replacement-main" + : "frame-main", + loaderId: + trees > 1 && change === "missing-final-loader" + ? undefined + : trees > 1 && change === "empty-final-loader" + ? "" + : change === "unknown-initial-loader" && trees === 1 + ? undefined + : change === "unknown-loader" + ? trees > 1 + ? undefined + : "" + : trees > 1 && change === "loader" + ? "replacement-loader" + : "loader-main", + url: `${origin}/app`, + }, + }, + }; + }, + }); + const provider = createBrowserObservationProvider(); + try { + const selection = { + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + allowed_origins: [browser.allowedOrigin], + }; + const result = + operation === "screenshot" + ? await provider.captureScreenshot( + captureWebScreenshotInputSchema.parse(selection), + ) + : await provider.discoverWebMcpTools( + discoverWebMcpToolsInputSchema.parse({ + ...selection, + observation_ms: 0, + }), + ); + if ( + change === "unchanged" || + change === "unknown-loader" || + change === "unknown-initial-loader" + ) { + if (!result.ok) throw result.error; + const unknown = change !== "unchanged"; + expect( + result.value.limitations.some((value) => + value.includes("initial document loader identity was unavailable"), + ), + ).toBe(unknown); + expect(result.value.completeness.attach_limited_sections).toEqual( + unknown + ? expect.arrayContaining([ + operation === "screenshot" ? "screenshot" : "webmcp_tools", + ]) + : expect.not.arrayContaining([ + operation === "screenshot" ? "screenshot" : "webmcp_tools", + ]), + ); + } else + expect(result).toMatchObject({ + ok: false, + error: { + reason: + change === "missing-final-loader" || + change === "empty-final-loader" + ? "protocol_error" + : "target_changed", + }, + }); + } finally { + await provider.close?.(); + await browser.close(); + } + }, +); diff --git a/src/browser/CdpElectronInspection.test.ts b/src/browser/CdpElectronInspection.test.ts index cdc4ed7f11..2ab4ae3fbf 100644 --- a/src/browser/CdpElectronInspection.test.ts +++ b/src/browser/CdpElectronInspection.test.ts @@ -837,3 +837,199 @@ it.each([ } }, ); + +it.each([2, 3])( + "refuses a tree-first main frame identity replacement at read %s", + async (stage) => { + const root = await mkdtemp(join(tmpdir(), "rea-electron-root-identity-")); + temporary.push(root); + const main = join(root, "main.html"); + await writeFile(main, "main"); + const mainUrl = pathToFileURL(main).href; + let reads = 0; + const connection = { + onEvent: () => () => {}, + send: async (method: string) => { + if (method === "Page.getFrameTree") { + reads += 1; + return { + frameTree: { + frame: { + id: reads >= stage ? "replacement-main" : "initial-main", + url: mainUrl, + loaderId: "same-url-document", + }, + }, + }; + } + if (method === "Page.getResourceTree") + return { frameTree: { resources: [] } }; + if (method === "DOMSnapshot.captureSnapshot") + return { strings: [], documents: [] }; + if (method === "Target.getTargets") return { targetInfos: [] }; + return {}; + }, + } as unknown as CdpConnection; + await expect( + inspectRaceConnection(connection, mainUrl), + ).rejects.toMatchObject({ + reason: "target_changed", + }); + }, +); + +it.each([ + { stage: 1, loader: undefined }, + { stage: 1, loader: "" }, + { stage: 2, loader: undefined }, + { stage: 2, loader: "" }, + { stage: 3, loader: undefined }, + { stage: 3, loader: "" }, + { stage: 0, loader: undefined }, +])( + "retains uncertainty when the main loader is missing at read $stage", + async ({ stage, loader }) => { + const root = await mkdtemp(join(tmpdir(), "rea-electron-missing-loader-")); + temporary.push(root); + const main = join(root, "main.html"); + const child = join(root, "child.html"); + await Promise.all([writeFile(main, "main"), writeFile(child, "child")]); + const mainUrl = pathToFileURL(main).href; + const childUrl = pathToFileURL(child).href; + let reads = 0; + let listener: (event: CdpEvent) => void = () => {}; + const emitScript = (hash: string, id: number, frameId: string) => { + listener({ + method: "Runtime.executionContextCreated", + params: { context: { id, auxData: { frameId } } }, + }); + listener({ + method: "Debugger.scriptParsed", + params: { scriptId: hash, url: mainUrl, executionContextId: id, hash }, + }); + }; + const connection = { + onEvent: (value: typeof listener) => { + listener = value; + return () => {}; + }, + send: async (method: string) => { + if (method === "Page.getFrameTree") { + reads++; + return { + frameTree: { + childFrames: [ + { + frame: { + id: "child", + parentId: "main", + url: childUrl, + loaderId: "loader-child", + }, + }, + ], + frame: { + id: "main", + url: mainUrl, + ...(reads === stage + ? loader === undefined + ? {} + : { loaderId: loader } + : { loaderId: "loader-main" }), + }, + }, + }; + } + if (method === "Debugger.enable") { + emitScript("before", 7, "main"); + emitScript("child-before", 9, "child"); + } + if (method === "Page.getResourceTree") { + emitScript("after", 8, "main"); + emitScript("child-after", 10, "child"); + return { frameTree: { resources: [] } }; + } + if (method === "DOMSnapshot.captureSnapshot") + return { strings: [], documents: [] }; + if (method === "Target.getTargets") return { targetInfos: [] }; + return {}; + }, + } as unknown as CdpConnection; + const result = await inspectRaceConnection(connection, mainUrl); + for (const section of ["frames", "scripts", "workers"] as const) + expect( + result.completeness.attach_limited_sections.includes(section), + ).toBe(stage !== 0); + expect(result.scripts.items).toContainEqual( + expect.objectContaining({ + cdp_hash: "before", + frame_scope: stage === 1 ? "unknown" : "authorized", + }), + ); + expect(result.scripts.items).toContainEqual( + expect.objectContaining({ + cdp_hash: "after", + frame_scope: stage === 1 || stage === 2 ? "unknown" : "authorized", + }), + ); + expect(result.scripts.items).toContainEqual( + expect.objectContaining({ + cdp_hash: "child-before", + frame_scope: stage === 1 ? "unknown" : "authorized", + }), + ); + expect(result.scripts.items).toContainEqual( + expect.objectContaining({ + cdp_hash: "child-after", + frame_scope: stage === 1 || stage === 2 ? "unknown" : "authorized", + }), + ); + }, +); + +it.each([ + { loaders: ["loader-a", undefined, "loader-b"], changed: true }, + { loaders: ["loader-a", undefined, "loader-a"], changed: false }, + { loaders: [undefined, undefined, "loader-b"], changed: false }, +])( + "compares later root identity with the last proven loader: $loaders", + async ({ loaders, changed }) => { + const root = await mkdtemp(join(tmpdir(), "rea-electron-loader-history-")); + temporary.push(root); + const main = join(root, "main.html"); + await writeFile(main, "main"); + const mainUrl = pathToFileURL(main).href; + let reads = 0; + const connection = { + onEvent: () => () => {}, + send: async (method: string) => { + if (method === "Page.getFrameTree") { + const loaderId = loaders[reads++]; + return { + frameTree: { + frame: { + id: "main", + url: mainUrl, + ...(loaderId === undefined ? {} : { loaderId }), + }, + }, + }; + } + if (method === "Page.getResourceTree") + return { frameTree: { resources: [] } }; + if (method === "DOMSnapshot.captureSnapshot") + return { strings: [], documents: [] }; + if (method === "Target.getTargets") return { targetInfos: [] }; + return {}; + }, + } as unknown as CdpConnection; + const result = inspectRaceConnection(connection, mainUrl); + if (changed) + await expect(result).rejects.toMatchObject({ reason: "target_changed" }); + else { + const value = await result; + if (loaders[0] !== undefined) + expect(value.completeness.attach_limited_sections).toContain("frames"); + } + }, +); diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index ae6d30e114..d4e3bb7223 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -66,11 +66,13 @@ interface ElectronInspectionState { readonly frameGenerations: Map; readonly frameParents: Map; readonly frameDocuments: Map; + readonly unknownDocumentFrames: Set; readonly authorizedFrameGenerations: Set; readonly excludedFrameGenerations: Set; readonly scriptGenerations: WeakMap; readonly limitations: string[]; mainFrameId: string | undefined; + lastProvenMainDocument: ElectronFrameDocument | undefined; navigationDuringCapture: boolean; } @@ -81,6 +83,7 @@ interface ElectronFrameInventory { } interface ElectronFrameRead extends ElectronFrameInventory { + readonly unprovenDocuments: ReadonlySet; readonly generations: ReadonlyMap; } @@ -102,6 +105,7 @@ export const inspectCdpElectronPage = async ( frameGenerations: new Map(), frameParents: new Map(), frameDocuments: new Map(), + unknownDocumentFrames: new Set(), authorizedFrameGenerations: new Set(), excludedFrameGenerations: new Set(), scriptGenerations: new WeakMap(), @@ -119,6 +123,7 @@ export const inspectCdpElectronPage = async ( : []), ], mainFrameId: undefined, + lastProvenMainDocument: undefined, navigationDuringCapture: false, }; const removeListener = context.connection.onEvent((event) => { @@ -160,9 +165,13 @@ const runElectronInspection = async ( const main = initialFrames.at(0); if (main === undefined) throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); - if (unprovenFrameIds(initialRead, state).has(main.frame_id)) + if ( + !initialRead.unprovenDocuments.has(main.frame_id) && + unprovenFrameIds(initialRead, state).has(main.frame_id) + ) throw new BrowserObservationError("inspect_web_page", "target_changed"); state.mainFrameId = main.frame_id; + state.lastProvenMainDocument = state.frameDocuments.get(main.frame_id); authorizeFrameGenerations(initialRead, state); await enableElectronCapture(context); await delayWithCancellation( @@ -240,6 +249,13 @@ const authorizeFrameGenerations = ( state: ElectronInspectionState, ): void => { const unproven = markUnstableFrameRead(read, state); + // Never let a later authorization retroactively bless facts from an + // unproven generation, including descendants of an unknown document. + for (const id of unproven) { + const generation = state.frameGenerations.get(id) ?? 0; + if ((read.generations.get(id) ?? 0) === generation) + state.frameGenerations.set(id, generation + 1); + } for (const [id, parent] of read.parents) if (!unproven.has(id)) state.frameParents.set(id, parent); for (const frame of read.frames) { @@ -262,7 +278,7 @@ const unprovenFrameIds = ( state: ElectronInspectionState, ): Set => { const children = new Map(); - const unproven = new Set(); + const unproven = new Set(read.unprovenDocuments); for (const [id, parent] of read.parents) { if ( (read.generations.get(id) ?? 0) !== (state.frameGenerations.get(id) ?? 0) @@ -348,13 +364,17 @@ const retainFrameDocument = ( document: ElectronFrameDocument, state: ElectronInspectionState, ): void => { - const previous = state.frameDocuments.get(id); + const previous = + state.frameDocuments.get(id) ?? + (id === state.mainFrameId ? state.lastProvenMainDocument : undefined); if ( previous !== undefined && sameFrameDocument(previous, document) && previous.url === document.url - ) + ) { + state.frameDocuments.set(id, document); return; + } if ( id === state.mainFrameId && previous !== undefined && @@ -362,12 +382,14 @@ const retainFrameDocument = ( ) state.navigationDuringCapture = true; state.frameDocuments.set(id, document); + if (id === state.mainFrameId) state.lastProvenMainDocument = document; }; /** Tree and event observations invalidate the same document generation. */ const invalidateFrameGenerations = ( id: string, state: ElectronInspectionState, + identity: "changed" | "unproven" = "changed", ): void => { const pending = [id]; const changed = new Set(); @@ -375,7 +397,8 @@ const invalidateFrameGenerations = ( const current = pending.pop(); if (current === undefined || changed.has(current)) continue; changed.add(current); - if (current === state.mainFrameId) state.navigationDuringCapture = true; + if (identity === "changed" && current === state.mainFrameId) + state.navigationDuringCapture = true; state.frameGenerations.set( current, (state.frameGenerations.get(current) ?? 0) + 1, @@ -409,13 +432,41 @@ const seedFrameDocuments = ( generations: ReadonlyMap, documents: ReadonlyMap, state: ElectronInspectionState, -): void => { +): Set => { + const unproven = new Set(); const tree = recordValue(recordValue(result)?.frameTree); - if (tree === undefined) return; + if (tree === undefined) return unproven; for (const entry of walkFrameTrees(tree)) { const facts = cdpFrameRecordFacts(recordValue(entry.frame)); const id = facts.frameId; const document = frameDocument(facts); + if ( + id !== null && + document === undefined && + state.frameDocuments.get(id) === documents.get(id) && + (generations.get(id) ?? 0) === (state.frameGenerations.get(id) ?? 0) + ) { + unproven.add(id); + state.unknownDocumentFrames.add(id); + if (documents.has(id)) invalidateFrameGenerations(id, state, "unproven"); + } + if ( + id !== null && + document !== undefined && + state.unknownDocumentFrames.has(id) && + state.frameDocuments.get(id) === documents.get(id) && + (generations.get(id) ?? 0) === (state.frameGenerations.get(id) ?? 0) + ) { + state.unknownDocumentFrames.delete(id); + const previous = + id === state.mainFrameId ? state.lastProvenMainDocument : undefined; + const changed = + previous !== undefined && !sameFrameDocument(previous, document); + invalidateFrameGenerations(id, state, changed ? "changed" : "unproven"); + unproven.add(id); + retainFrameDocument(id, document, state); + continue; + } if ( id !== null && document !== undefined && @@ -424,12 +475,15 @@ const seedFrameDocuments = ( state.frameDocuments.get(id) === documents.get(id) && (generations.get(id) ?? 0) === (state.frameGenerations.get(id) ?? 0) ) { - const previous = documents.get(id); + const previous = + documents.get(id) ?? + (id === state.mainFrameId ? state.lastProvenMainDocument : undefined); if (previous !== undefined && !sameFrameDocument(previous, document)) invalidateFrameGenerations(id, state); retainFrameDocument(id, document, state); } } + return unproven; }; const authorizeInitialFrames = async ( @@ -462,17 +516,20 @@ const readCurrentFrames = async ( context.sessionId, context.signal, ); - seedFrameDocuments(result, generations, documents, state); + const unprovenDocuments = seedFrameDocuments( + result, + generations, + documents, + state, + ); const inventory = await captureFrames( result, completeness, context.asarHeaderAdmission, context.signal, ); - const main = inventory.frames[0]; - if (main === undefined || inventory.parents.get(main.frame_id) !== null) - throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); - return { ...inventory, generations }; + selectedMainFrame(inventory, state.mainFrameId); + return { ...inventory, generations, unprovenDocuments }; }; const markUnstableFrameRead = ( @@ -563,17 +620,12 @@ const assertStableMainFrame = async ( context.sessionId, context.signal, ); - seedFrameDocuments(result, generations, documents, state); - const afterPath = await mainFilePath( + const unprovenDocuments = seedFrameDocuments( result, - context.asarHeaderAdmission, - context.signal, + generations, + documents, + state, ); - - if (afterPath === undefined) - throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); - if (state.navigationDuringCapture || afterPath !== expectedPath) - throw new BrowserObservationError("inspect_web_page", "target_changed"); const completeness = new CdpCaptureCompleteness(); const inventory = await captureFrames( result, @@ -582,14 +634,20 @@ const assertStableMainFrame = async ( context.signal, ); const { frames } = inventory; + const main = selectedMainFrame(inventory, state.mainFrameId); if ( + main.file_path !== expectedPath || state.navigationDuringCapture || (state.mainFrameId !== undefined && + !unprovenDocuments.has(state.mainFrameId) && (generations.get(state.mainFrameId) ?? 0) !== (state.frameGenerations.get(state.mainFrameId) ?? 0)) ) throw new BrowserObservationError("inspect_web_page", "target_changed"); - markUnstableFrameRead({ ...inventory, generations }, state); + markUnstableFrameRead( + { ...inventory, generations, unprovenDocuments }, + state, + ); if ( completeness .snapshot() @@ -791,19 +849,17 @@ const captureDom = async ( }; }; -const mainFilePath = async ( - result: unknown, - asarHeaderAdmission: AsarHeaderAdmission, - signal?: AbortSignal, -): Promise => { - const frameTree = recordValue(requiredRecord(result).frameTree); - const frame = recordValue(frameTree?.frame); - return await authorizedElectronFile( - cdpStringValue(frame?.url) ?? "", - signal, - "inspect_electron_page", - asarHeaderAdmission, - ); +/** Every tree read must retain the selected root independently of its URL. */ +const selectedMainFrame = ( + inventory: ElectronFrameInventory, + selectedId: string | undefined, +): ElectronPageInspection["frames"][number] => { + const main = inventory.frames[0]; + if (main === undefined || inventory.parents.get(main.frame_id) !== null) + throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); + if (selectedId !== undefined && main.frame_id !== selectedId) + throw new BrowserObservationError("inspect_web_page", "target_changed"); + return main; }; const throwIfElectronInspectionCancelled = (signal?: AbortSignal): void => { diff --git a/src/browser/CdpScreenshot.ts b/src/browser/CdpScreenshot.ts index 245f71942b..3b637e0738 100644 --- a/src/browser/CdpScreenshot.ts +++ b/src/browser/CdpScreenshot.ts @@ -82,6 +82,7 @@ export const captureCdpScreenshot = async ( beforeFacts.origin === null || mainFrame.frameId === null || mainFrame.identityIssue !== null || + mainFrame.loaderIssue !== null || mainFrame.parentRelation !== "root" ) throw new BrowserObservationError( @@ -97,6 +98,9 @@ export const captureCdpScreenshot = async ( loaderId: mainFrame.loaderId, completeness: new CdpCaptureCompleteness(), }; + const unknownInitialLoader = + mainFrame.loaderId === undefined || mainFrame.loaderId === ""; + if (unknownInitialLoader) navigation.completeness.attachLimited("screenshot"); const removeListener = context.connection.onEvent((event) => { if (event.sessionId !== context.sessionId) return; observeScreenshotNavigation(event, origins, navigation); @@ -159,8 +163,45 @@ export const captureCdpScreenshot = async ( if (navigation.invalidIdentity || afterObservation.kind !== "observed") throw new BrowserObservationError("inspect_web_page", "protocol_error"); const afterUrl = afterFacts.url; - if (navigation.changed || afterUrl !== beforeUrl || afterUrl === undefined) + const afterFrame = cdpFrameRecordFacts( + recordValue(recordValue(recordValue(after)?.frameTree)?.frame), + ); + if ( + afterFrame.identityIssue !== null || + afterFrame.parentRelation !== "root" || + afterFrame.loaderIssue !== null + ) + throw new BrowserObservationError( + "capture_web_screenshot", + "protocol_error", + ); + if ( + navigation.changed || + afterUrl !== beforeUrl || + afterUrl === undefined || + afterFrame.frameId !== mainFrame.frameId || + (mainFrame.loaderId !== undefined && + mainFrame.loaderId !== "" && + afterFrame.loaderId !== undefined && + afterFrame.loaderId !== "" && + afterFrame.loaderId !== mainFrame.loaderId) + ) throw new BrowserObservationError("inspect_web_page", "target_changed"); + if ( + mainFrame.loaderId !== undefined && + mainFrame.loaderId !== "" && + (afterFrame.loaderId === undefined || afterFrame.loaderId === "") + ) + throw new BrowserObservationError( + "capture_web_screenshot", + "protocol_error", + { + detail: + "CDP omitted the final document loader identity, so screenshot stability could not be verified.", + remediationAction: + "Retry after the selected page has finished loading.", + }, + ); await context.progress?.report({ phase: "browser_observation", completed: 2, @@ -180,6 +221,11 @@ export const captureCdpScreenshot = async ( artifact: createWebScreenshotArtifact(screenshot.bytes), completeness: navigation.completeness.snapshot(), limitations: [ + ...(unknownInitialLoader + ? [ + "The initial document loader identity was unavailable, so document stability could not be verified. Retry after the page finishes loading.", + ] + : []), "The screenshot contains the visible viewport, including on-screen content.", "REA does not scroll, evaluate JavaScript, or capture beyond the current viewport.", ], diff --git a/src/browser/CdpWebMcpDiscovery.ts b/src/browser/CdpWebMcpDiscovery.ts index a9e0782c96..c853a0e5ad 100644 --- a/src/browser/CdpWebMcpDiscovery.ts +++ b/src/browser/CdpWebMcpDiscovery.ts @@ -74,7 +74,13 @@ export const discoverWebMcp = async ( context.sessionId, context.signal, ); - const initialUrl = requiredDiscoveryFrameUrl(frameTree, origins); + const initialFrame = requiredDiscoveryFrame(frameTree, origins); + if (initialFrame.loaderId === undefined || initialFrame.loaderId === "") { + completeness.attachLimited("webmcp_tools"); + limitations.push( + "The initial document loader identity was unavailable, so document stability could not be verified. Retry after the page finishes loading.", + ); + } const frameUrls = new Map(); const tools = new Map(); const frameScope = { @@ -109,13 +115,14 @@ export const discoverWebMcp = async ( resolvedTransientFrames: frameScope.resolvedTransientFrames, }); }); - await context.progress?.report({ - phase: "browser_observation", - completed: 1, - total: 2, - message: "Enabling passive WebMCP discovery", - }); + let result: ReturnType; try { + await context.progress?.report({ + phase: "browser_observation", + completed: 1, + total: 2, + message: "Enabling passive WebMCP discovery", + }); const enabled = await optionalCdpCommand( context, "WebMCP.enable", @@ -132,10 +139,10 @@ export const discoverWebMcp = async ( "discover_webmcp_tools", context.signal, ); - await assertStableAuthorizedFrame(context, initialUrl, origins); + await assertStableAuthorizedFrame(context, initialFrame, origins); if (frameScope.transientFrames.size > 0) completeness.attachLimited("webmcp_tools"); - return buildWebMcpResult({ + result = buildWebMcpResult({ context, frameTree, tools, @@ -145,14 +152,15 @@ export const discoverWebMcp = async ( }); } finally { removeListener(); - await context.progress?.report({ - phase: "browser_observation", - completed: 2, - total: 2, - message: "WebMCP discovery complete", - terminal: true, - }); } + await context.progress?.report({ + phase: "browser_observation", + completed: 2, + total: 2, + message: "WebMCP discovery complete", + terminal: true, + }); + return result; }; interface FrameScopeState { @@ -367,7 +375,7 @@ const removeFrame = ( const assertStableAuthorizedFrame = async ( context: DiscoveryContext, - initialUrl: string | undefined, + initial: ReturnType, origins: ReadonlySet, ): Promise => { const finalTree = await context.connection.send( @@ -376,15 +384,38 @@ const assertStableAuthorizedFrame = async ( context.sessionId, context.signal, ); - const finalUrl = requiredDiscoveryFrameUrl(finalTree, origins); - if (finalUrl !== initialUrl) + const final = requiredDiscoveryFrame(finalTree, origins); + if ( + final.url !== initial.url || + final.frameId !== initial.frameId || + (initial.loaderId !== undefined && + initial.loaderId !== "" && + final.loaderId !== undefined && + final.loaderId !== "" && + final.loaderId !== initial.loaderId) + ) throw new BrowserObservationError("inspect_web_page", "target_changed"); + if ( + initial.loaderId !== undefined && + initial.loaderId !== "" && + (final.loaderId === undefined || final.loaderId === "") + ) + throw new BrowserObservationError( + "discover_webmcp_tools", + "protocol_error", + { + detail: + "CDP omitted the final document loader identity, so discovery stability could not be verified.", + remediationAction: + "Retry after the selected page has finished loading.", + }, + ); }; -const requiredDiscoveryFrameUrl = ( +const requiredDiscoveryFrame = ( result: unknown, origins: ReadonlySet, -): string => { +) => { const observed = observeMainFrameUrl(result); const facts = classifyCdpFrameUrl(observed, origins); if (facts.scope === "outside_policy" || facts.scope === "unsupported") @@ -393,7 +424,24 @@ const requiredDiscoveryFrameUrl = ( throw new BrowserObservationError("inspect_web_page", "protocol_error"); if (facts.url === undefined) throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); - return observed.url; + const frame = cdpFrameRecordFacts( + recordValue(recordValue(recordValue(result)?.frameTree)?.frame), + ); + if ( + frame.frameId === null || + frame.identityIssue !== null || + frame.parentRelation !== "root" || + frame.loaderIssue !== null + ) + throw new BrowserObservationError( + "discover_webmcp_tools", + "protocol_error", + ); + return { + url: observed.url, + frameId: frame.frameId, + loaderId: frame.loaderId, + }; }; interface WebMcpIngestOptions {