mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 10:43:30 +00:00
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:
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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.",
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user