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 <cursoragent@cursor.com>
This commit is contained in:
morlutoandCursor Agent authored and GitHub committed 2026-11-11 17:44:08 +08:00
1 parent b00138be64
commit c71aded3ee
5 files changed
+600 -59

No files matched your search

@@ -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<typeof discoverWebMcp>[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();
}
},
);
+196
View File
@@ -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");
}
},
);
+92 -36
View File
@@ -66,11 +66,13 @@ interface ElectronInspectionState {
readonly frameGenerations: Map<string, number>;
readonly frameParents: Map<string, string | null>;
readonly frameDocuments: Map<string, ElectronFrameDocument>;
readonly unknownDocumentFrames: Set<string>;
readonly authorizedFrameGenerations: Set<string>;
readonly excludedFrameGenerations: Set<string>;
readonly scriptGenerations: WeakMap<ElectronScriptDraft, number>;
readonly limitations: string[];
mainFrameId: string | undefined;
lastProvenMainDocument: ElectronFrameDocument | undefined;
navigationDuringCapture: boolean;
}
@@ -81,6 +83,7 @@ interface ElectronFrameInventory {
}
interface ElectronFrameRead extends ElectronFrameInventory {
readonly unprovenDocuments: ReadonlySet<string>;
readonly generations: ReadonlyMap<string, number>;
}
@@ -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<string> => {
const children = new Map<string, string[]>();
const unproven = new Set<string>();
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<string>();
@@ -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<string, number>,
documents: ReadonlyMap<string, ElectronFrameDocument>,
state: ElectronInspectionState,
): void => {
): Set<string> => {
const unproven = new Set<string>();
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<string | undefined> => {
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 => {
+47 -1
View File
@@ -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.",
],
+70 -22
View File
@@ -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<string, FrameScopeFrame>();
const tools = new Map<string, WebMcpDiscovery["tools"]["items"][number]>();
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<typeof buildWebMcpResult>;
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<typeof requiredDiscoveryFrame>,
origins: ReadonlySet<string>,
): Promise<void> => {
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>,
): 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 {