fix(browser): support page-scoped CDP transports
This commit is contained in:
commit
c4930f4820
|
|
@ -0,0 +1,33 @@
|
|||
name: Real browser verification
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/real-browser.yml"
|
||||
- "scripts/verify-real-browser.mjs"
|
||||
- "scripts/fixtures/browser-verifier-site.mjs"
|
||||
- "scripts/fixtures/page-cdp-proxy.mjs"
|
||||
- "src/browser/**"
|
||||
- "src/domain/browser*.ts"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
real-browser:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run verify:browser
|
||||
env:
|
||||
REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome
|
||||
REA_BROWSER_NO_SANDBOX: "true"
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { createServer } from "node:http";
|
||||
|
||||
import WebSocket, { WebSocketServer } from "ws";
|
||||
|
||||
/**
|
||||
* Expose Chrome discovery through a page-scoped-only CDP endpoint.
|
||||
*
|
||||
* The proxy deliberately reports a `/devtools/page/<id>` socket from
|
||||
* `/json/version`, so the verifier cannot silently fall back to the browser
|
||||
* socket. WebSocket bytes are forwarded without interpreting CDP messages.
|
||||
*/
|
||||
export async function startPageCdpProxy(upstreamEndpoint) {
|
||||
const clients = new Set();
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
if (request.url !== "/json/version" && request.url !== "/json/list") {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
const value = await upstreamJson(upstreamEndpoint, request.url);
|
||||
const pageScoped =
|
||||
request.url === "/json/version"
|
||||
? await pageScopedVersion(upstreamEndpoint, value)
|
||||
: value;
|
||||
const rewritten = rewriteWebSocketEndpoints(pageScoped, server);
|
||||
const body = JSON.stringify(rewritten);
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
});
|
||||
response.end(body);
|
||||
} catch (error) {
|
||||
response.writeHead(502).end(String(error));
|
||||
}
|
||||
});
|
||||
const webSockets = new WebSocketServer({ noServer: true });
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
if (!request.url?.startsWith("/devtools/page/")) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
webSockets.handleUpgrade(request, socket, head, (client) => {
|
||||
const upstream = new WebSocket(
|
||||
new URL(request.url, upstreamEndpoint).href.replace(/^http/, "ws"),
|
||||
);
|
||||
const pending = [];
|
||||
clients.add(client);
|
||||
const close = () => {
|
||||
clients.delete(client);
|
||||
if (client.readyState < WebSocket.CLOSING) client.close();
|
||||
if (upstream.readyState < WebSocket.CLOSING) upstream.close();
|
||||
};
|
||||
client.on("message", (data, binary) => {
|
||||
if (upstream.readyState === WebSocket.OPEN)
|
||||
upstream.send(data, { binary });
|
||||
else pending.push({ data, binary });
|
||||
});
|
||||
upstream.on("message", (data, binary) => {
|
||||
if (client.readyState === WebSocket.OPEN) client.send(data, { binary });
|
||||
});
|
||||
upstream.on("open", () => {
|
||||
for (const { data, binary } of pending) upstream.send(data, { binary });
|
||||
pending.length = 0;
|
||||
webSockets.emit("connection", client, request);
|
||||
});
|
||||
client.on("close", close);
|
||||
client.on("error", close);
|
||||
upstream.on("close", close);
|
||||
upstream.on("error", close);
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Page CDP proxy did not bind a TCP port");
|
||||
const endpoint = `http://127.0.0.1:${String(address.port)}`;
|
||||
return {
|
||||
endpoint,
|
||||
disconnectClients() {
|
||||
for (const client of clients) client.terminate();
|
||||
},
|
||||
async close() {
|
||||
for (const client of clients) client.terminate();
|
||||
webSockets.close();
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((error) =>
|
||||
error === undefined ? resolve() : reject(error),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function pageScopedVersion(upstreamEndpoint, version) {
|
||||
const targets = await upstreamJson(upstreamEndpoint, "/json/list");
|
||||
const page = targets.find(
|
||||
(target) =>
|
||||
target.type === "page" && typeof target.webSocketDebuggerUrl === "string",
|
||||
);
|
||||
if (page === undefined) throw new Error("Chrome exposed no page CDP socket");
|
||||
return { ...version, webSocketDebuggerUrl: page.webSocketDebuggerUrl };
|
||||
}
|
||||
|
||||
function rewriteWebSocketEndpoints(value, server) {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Page CDP proxy has no TCP address");
|
||||
const rewrite = (entry) => {
|
||||
if (typeof entry.webSocketDebuggerUrl !== "string") return entry;
|
||||
const url = new URL(entry.webSocketDebuggerUrl);
|
||||
url.hostname = "127.0.0.1";
|
||||
url.port = String(address.port);
|
||||
return { ...entry, webSocketDebuggerUrl: url.href };
|
||||
};
|
||||
return Array.isArray(value) ? value.map(rewrite) : rewrite(value);
|
||||
}
|
||||
|
||||
async function upstreamJson(endpoint, path) {
|
||||
const response = await fetch(new URL(path, endpoint));
|
||||
if (!response.ok)
|
||||
throw new Error(`Upstream CDP ${path} returned ${response.status}`);
|
||||
return await response.json();
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
compareWebScreenshotsInputSchema,
|
||||
} from "../dist/domain/webScreenshot.js";
|
||||
import { startBrowserVerifierSite } from "./fixtures/browser-verifier-site.mjs";
|
||||
import { startPageCdpProxy } from "./fixtures/page-cdp-proxy.mjs";
|
||||
|
||||
const SECRET_VALUES = [
|
||||
"network-secret-value",
|
||||
|
|
@ -31,11 +32,15 @@ const executable = await browserExecutable();
|
|||
const profile = await mkdtemp(join(tmpdir(), "rea-real-browser-"));
|
||||
const site = await startBrowserVerifierSite();
|
||||
let browser;
|
||||
let pageProxy;
|
||||
try {
|
||||
browser = spawn(
|
||||
executable,
|
||||
[
|
||||
"--headless=new",
|
||||
...(process.env.REA_BROWSER_NO_SANDBOX === "true"
|
||||
? ["--no-sandbox"]
|
||||
: []),
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
"--remote-debugging-port=0",
|
||||
`--user-data-dir=${profile}`,
|
||||
|
|
@ -43,6 +48,7 @@ try {
|
|||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-sync",
|
||||
"--metrics-recording-only",
|
||||
`${site.origin}/app?startup=browser-secret-value`,
|
||||
|
|
@ -191,6 +197,9 @@ try {
|
|||
)
|
||||
throw new Error("Real Chrome same-origin SPA timeline was missing");
|
||||
|
||||
pageProxy = await startPageCdpProxy(endpoint);
|
||||
await verifyPageScopedTransport(provider, pageProxy, site.origin);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
browser: observed.value.browser.product,
|
||||
|
|
@ -205,11 +214,13 @@ try {
|
|||
bundleScripts: bundle.value.capture.scripts_analyzed,
|
||||
sourceMaps: bundle.value.observations.source_maps.processed,
|
||||
sessionEvents: session.value.timeline.length,
|
||||
pageScopedTransport: true,
|
||||
screenshotBytes: screenshot.value.artifact.bytes,
|
||||
verified: true,
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
if (pageProxy !== undefined) await pageProxy.close();
|
||||
if (browser !== undefined) await stopProcess(browser);
|
||||
await site.close();
|
||||
await rm(profile, {
|
||||
|
|
@ -220,6 +231,61 @@ try {
|
|||
});
|
||||
}
|
||||
|
||||
async function verifyPageScopedTransport(provider, proxy, origin) {
|
||||
const target = await pageTarget(provider, proxy.endpoint, origin);
|
||||
const input = inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: proxy.endpoint,
|
||||
allowed_origins: [origin],
|
||||
approved: true,
|
||||
target_id: target,
|
||||
observation_ms: 100,
|
||||
});
|
||||
const observed = await provider.inspectPage(input);
|
||||
if (!observed.ok) throw observed.error;
|
||||
if (observed.value.dom.nodes.length < 1)
|
||||
throw new Error("Page-scoped CDP transport returned no DOM nodes");
|
||||
|
||||
const controller = new AbortController();
|
||||
const cancelledPromise = provider.inspectPage(
|
||||
{ ...input, observation_ms: 5_000 },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await delay(100);
|
||||
controller.abort();
|
||||
const cancelled = await cancelledPromise;
|
||||
if (
|
||||
cancelled.ok ||
|
||||
cancelled.error._tag !== "AnalysisCancelledError" ||
|
||||
cancelled.error.operation !== "inspect_web_page"
|
||||
)
|
||||
throw new Error(
|
||||
"Page-scoped CDP cancellation lost its operation semantics",
|
||||
);
|
||||
|
||||
const disconnectedPromise = provider.observeSession(
|
||||
observeWebSessionInputSchema.parse({
|
||||
cdp_endpoint: proxy.endpoint,
|
||||
allowed_origins: [origin],
|
||||
approved: true,
|
||||
target_id: target,
|
||||
observation_ms: 5_000,
|
||||
}),
|
||||
);
|
||||
await delay(100);
|
||||
proxy.disconnectClients();
|
||||
const disconnected = await disconnectedPromise;
|
||||
if (
|
||||
!disconnected.ok ||
|
||||
disconnected.value.window.end_reason !== "target_terminated" ||
|
||||
!disconnected.value.timeline.some(
|
||||
({ type }) => type === "target_terminated",
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Page-scoped CDP disconnect was not reported as target_terminated",
|
||||
);
|
||||
}
|
||||
|
||||
async function browserExecutable() {
|
||||
const candidates = [
|
||||
process.env.REA_BROWSER_EXECUTABLE,
|
||||
|
|
@ -247,7 +313,7 @@ async function browserExecutable() {
|
|||
|
||||
async function devtoolsPort(profile, child, stderr) {
|
||||
const activePort = join(profile, "DevToolsActivePort");
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
for (let attempt = 0; attempt < 600; attempt += 1) {
|
||||
if (child.exitCode !== null)
|
||||
throw new Error(`Chrome exited before CDP startup: ${stderr()}`);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type {
|
||||
InspectWebPageInput,
|
||||
ListBrowserTargetsInput,
|
||||
import {
|
||||
isLiteralLoopbackHostname,
|
||||
type InspectWebPageInput,
|
||||
type ListBrowserTargetsInput,
|
||||
} from "../domain/browserObservation.js";
|
||||
import type { AnalyzeWebBundleInput } from "../domain/webBundleAnalysis.js";
|
||||
import type { ObserveWebSessionInput } from "../domain/browserSession.js";
|
||||
|
|
@ -285,7 +286,7 @@ const browserNetworkScope = (
|
|||
): "loopback" | "external" =>
|
||||
allowedOrigins.every((origin) => {
|
||||
const hostname = new URL(origin).hostname;
|
||||
return hostname === "127.0.0.1" || hostname === "[::1]";
|
||||
return isLiteralLoopbackHostname(hostname);
|
||||
})
|
||||
? "loopback"
|
||||
: "external";
|
||||
|
|
|
|||
|
|
@ -47,12 +47,17 @@ import {
|
|||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { CdpConnection } from "./CdpConnection.js";
|
||||
import {
|
||||
discoverCdpEndpoint,
|
||||
hasCdpTargetWebSocket,
|
||||
type CdpEndpointDiscovery,
|
||||
type CdpEndpointTarget,
|
||||
} from "./CdpEndpoint.js";
|
||||
import {
|
||||
closeCdpTargetSession,
|
||||
openCdpTargetSession,
|
||||
type CdpTargetSession,
|
||||
} from "./CdpTargetSession.js";
|
||||
import { capturePage, type CapturedPage } from "./CdpPageCapture.js";
|
||||
import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js";
|
||||
import { observeCdpSession } from "./CdpObservationSession.js";
|
||||
|
|
@ -65,6 +70,7 @@ const IDENTITY: ProviderIdentity = {
|
|||
name: "REA Chrome DevTools Protocol observation provider",
|
||||
version: "2",
|
||||
};
|
||||
const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const;
|
||||
|
||||
/** Passive, origin-scoped browser observation through a user-owned CDP endpoint. */
|
||||
export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
|
|
@ -105,6 +111,11 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
excluded: selected.excluded,
|
||||
limitations: [
|
||||
"Only page targets whose current URL matches an approved exact origin are listed.",
|
||||
...(selected.unconnectable === 0
|
||||
? []
|
||||
: [
|
||||
`${String(selected.unconnectable)} otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.`,
|
||||
]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
|
@ -118,7 +129,7 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebPageInspection, AnalysisError>> {
|
||||
try {
|
||||
const captured = await this.#capture(input, options);
|
||||
const captured = await this.#capture(input, options, "inspect_web_page");
|
||||
return ok(webPageInspectionSchema.parse(captured.inspection));
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "inspect_web_page"));
|
||||
|
|
@ -130,7 +141,11 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebBundleAnalysis, AnalysisError>> {
|
||||
try {
|
||||
const captured = await this.#capture(input, options);
|
||||
const captured = await this.#capture(
|
||||
input,
|
||||
options,
|
||||
"analyze_web_bundle",
|
||||
);
|
||||
const sourceMaps = input.fetch_source_maps
|
||||
? await fetchWebSourceMaps(
|
||||
captured.sourceMapRequests,
|
||||
|
|
@ -152,31 +167,25 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
input: ObserveWebSessionInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebObservationSession, AnalysisError>> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
"observe_web_session",
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
targetSession = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
"observe_web_session",
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
return ok(
|
||||
webObservationSessionSchema.parse(
|
||||
await observeCdpSession({
|
||||
connection,
|
||||
sessionId,
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
|
|
@ -190,11 +199,8 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "observe_web_session"));
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined)
|
||||
await bestEffortCleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,31 +208,25 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
input: DiscoverWebMcpToolsInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebMcpDiscovery, AnalysisError>> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
"discover_webmcp_tools",
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
targetSession = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
"discover_webmcp_tools",
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
return ok(
|
||||
webMcpDiscoverySchema.parse(
|
||||
await discoverWebMcp({
|
||||
connection,
|
||||
sessionId,
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
|
|
@ -240,11 +240,8 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "discover_webmcp_tools"));
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined)
|
||||
await bestEffortCleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -262,31 +259,25 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
input: CaptureWebScreenshotInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebScreenshot, AnalysisError>> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
"capture_web_screenshot",
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
targetSession = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
"capture_web_screenshot",
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
return ok(
|
||||
webScreenshotSchema.parse(
|
||||
await captureCdpScreenshot({
|
||||
connection,
|
||||
sessionId,
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
|
|
@ -300,11 +291,8 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "capture_web_screenshot"));
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined)
|
||||
await bestEffortCleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -321,30 +309,26 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
async #capture(
|
||||
input: InspectWebPageInput,
|
||||
options: ExecutionOptions,
|
||||
operation: "inspect_web_page" | "analyze_web_bundle",
|
||||
): Promise<CapturedPage> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
operation,
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
targetSession = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
operation,
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
return await capturePage({
|
||||
connection,
|
||||
sessionId,
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
operation,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
|
|
@ -354,11 +338,8 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
: { progress: options.progress }),
|
||||
});
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined)
|
||||
await bestEffortCleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -369,11 +350,13 @@ const selectTargets = (
|
|||
): {
|
||||
readonly allowed: readonly BrowserTargetList["targets"]["items"][number][];
|
||||
readonly excluded: BrowserTargetList["excluded"];
|
||||
readonly unconnectable: number;
|
||||
} => {
|
||||
const allowed = [];
|
||||
let disallowedOrigin = 0;
|
||||
let unsupportedUrl = 0;
|
||||
let nonPage = 0;
|
||||
let unconnectable = 0;
|
||||
for (const target of discovery.targets) {
|
||||
if (target.type !== "page") {
|
||||
nonPage += 1;
|
||||
|
|
@ -388,6 +371,10 @@ const selectTargets = (
|
|||
disallowedOrigin += 1;
|
||||
continue;
|
||||
}
|
||||
if (!hasCdpTargetWebSocket(discovery, target)) {
|
||||
unconnectable += 1;
|
||||
continue;
|
||||
}
|
||||
allowed.push({
|
||||
target_id: target.id,
|
||||
type: target.type,
|
||||
|
|
@ -405,6 +392,7 @@ const selectTargets = (
|
|||
unsupported_url: unsupportedUrl,
|
||||
non_page: nonPage,
|
||||
},
|
||||
unconnectable,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -427,37 +415,6 @@ const authorizeTarget = (
|
|||
return target;
|
||||
};
|
||||
|
||||
const attachedSessionId = (value: unknown): string => {
|
||||
if (typeof value !== "object" || value === null || !("sessionId" in value))
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
if (typeof value.sessionId !== "string" || value.sessionId.length > 256)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
return value.sessionId;
|
||||
};
|
||||
|
||||
const bestEffortCleanup = async (
|
||||
connection: CdpConnection,
|
||||
sessionId: string,
|
||||
): Promise<void> => {
|
||||
for (const method of [
|
||||
"Network.disable",
|
||||
"Debugger.disable",
|
||||
"Runtime.disable",
|
||||
"Page.disable",
|
||||
]) {
|
||||
try {
|
||||
await connection.send(method, {}, sessionId);
|
||||
} catch {
|
||||
// Cleanup continues so detach is still attempted.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await connection.send("Target.detachFromTarget", { sessionId });
|
||||
} catch {
|
||||
// Closing REA's socket is the final non-destructive cleanup boundary.
|
||||
}
|
||||
};
|
||||
|
||||
const providerError = (
|
||||
cause: unknown,
|
||||
operation: BrowserObservationOperation,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
export const captureStorage = async (
|
||||
context: {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly sessionId: string | undefined;
|
||||
readonly input: InspectWebPageInput;
|
||||
readonly signal?: AbortSignal;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
AnalysisCancelledError,
|
||||
BrowserObservationError,
|
||||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
||||
|
|
@ -74,11 +75,11 @@ export const sourceExcluded = (
|
|||
|
||||
export const delayWithCancellation = async (
|
||||
durationMs: number,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
if (durationMs === 0) return;
|
||||
if (signal?.aborted === true)
|
||||
throw new AnalysisCancelledError("inspect_web_page");
|
||||
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
|
@ -87,7 +88,7 @@ export const delayWithCancellation = async (
|
|||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(new AnalysisCancelledError("inspect_web_page"));
|
||||
reject(new AnalysisCancelledError(operation));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted === true) onAbort();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
AnalysisError,
|
||||
AnalysisTimeoutError,
|
||||
BrowserObservationError,
|
||||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
|
||||
export interface CdpEvent {
|
||||
|
|
@ -25,34 +26,38 @@ const COMMAND_TIMEOUT_MS = 5_000;
|
|||
const MAX_PAYLOAD_BYTES = 16 * 1_024 * 1_024;
|
||||
const MAX_PENDING_COMMANDS = 128;
|
||||
|
||||
/** Correlated, bounded JSON-RPC transport for a browser-level CDP socket. */
|
||||
/** Correlated, bounded JSON-RPC transport for one CDP target operation. */
|
||||
export class CdpConnection {
|
||||
readonly #pending = new Map<number, PendingCommand>();
|
||||
readonly #listeners = new Set<(event: CdpEvent) => void>();
|
||||
readonly #disconnectListeners = new Set<() => void>();
|
||||
#nextId = 1;
|
||||
#closed = false;
|
||||
#protocolFailed = false;
|
||||
|
||||
private constructor(private readonly socket: WebSocket) {
|
||||
private constructor(
|
||||
private readonly socket: WebSocket,
|
||||
private readonly operation: BrowserObservationOperation,
|
||||
) {
|
||||
socket.on("message", (data) => this.#receive(data));
|
||||
socket.on("close", () => this.#failPending("disconnected"));
|
||||
socket.on("error", () => this.#failPending("disconnected"));
|
||||
socket.on("close", () => this.#disconnect());
|
||||
socket.on("error", () => this.#disconnect());
|
||||
}
|
||||
|
||||
/** Connect to one already-validated loopback browser WebSocket. */
|
||||
/** Connect to one already-validated loopback CDP WebSocket. */
|
||||
static async connect(
|
||||
url: string,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CdpConnection> {
|
||||
if (signal?.aborted === true)
|
||||
throw new AnalysisCancelledError("inspect_web_page");
|
||||
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
|
||||
const socket = new WebSocket(url, {
|
||||
handshakeTimeout: CONNECT_TIMEOUT_MS,
|
||||
maxPayload: MAX_PAYLOAD_BYTES,
|
||||
perMessageDeflate: false,
|
||||
});
|
||||
await waitForOpen(socket, signal);
|
||||
return new CdpConnection(socket);
|
||||
await waitForOpen(socket, operation, signal);
|
||||
return new CdpConnection(socket, operation);
|
||||
}
|
||||
|
||||
/** Subscribe to validated CDP event envelopes. */
|
||||
|
|
@ -61,6 +66,16 @@ export class CdpConnection {
|
|||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Subscribe to an unexpected transport loss while an operation is active. */
|
||||
onDisconnect(listener: () => void): () => void {
|
||||
if (this.#closed) {
|
||||
listener();
|
||||
return () => undefined;
|
||||
}
|
||||
this.#disconnectListeners.add(listener);
|
||||
return () => this.#disconnectListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Execute one bounded command, optionally within a flat target session. */
|
||||
async send(
|
||||
method: string,
|
||||
|
|
@ -69,13 +84,13 @@ export class CdpConnection {
|
|||
signal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
if (this.#protocolFailed)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
throw new BrowserObservationError(this.operation, "protocol_error");
|
||||
if (this.#closed || this.socket.readyState !== WebSocket.OPEN)
|
||||
throw new BrowserObservationError("inspect_web_page", "disconnected");
|
||||
throw new BrowserObservationError(this.operation, "disconnected");
|
||||
if (this.#pending.size >= MAX_PENDING_COMMANDS)
|
||||
throw new BrowserObservationError("inspect_web_page", "payload_limit");
|
||||
throw new BrowserObservationError(this.operation, "payload_limit");
|
||||
if (signal?.aborted === true)
|
||||
throw new AnalysisCancelledError("inspect_web_page");
|
||||
throw new AnalysisCancelledError(this.operation);
|
||||
const id = this.#nextId;
|
||||
this.#nextId += 1;
|
||||
return await new Promise((resolve, reject) => {
|
||||
|
|
@ -85,15 +100,13 @@ export class CdpConnection {
|
|||
clearTimeout(pending.timer);
|
||||
pending.removeAbort();
|
||||
this.#pending.delete(id);
|
||||
reject(new AnalysisCancelledError("inspect_web_page"));
|
||||
reject(new AnalysisCancelledError(this.operation));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
this.#pending.delete(id);
|
||||
reject(
|
||||
new AnalysisTimeoutError("inspect_web_page", COMMAND_TIMEOUT_MS),
|
||||
);
|
||||
reject(new AnalysisTimeoutError(this.operation, COMMAND_TIMEOUT_MS));
|
||||
}, COMMAND_TIMEOUT_MS);
|
||||
this.#pending.set(id, {
|
||||
resolve,
|
||||
|
|
@ -106,7 +119,7 @@ export class CdpConnection {
|
|||
id,
|
||||
method,
|
||||
params,
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
...(sessionId === undefined ? {} : { sessionId }),
|
||||
}),
|
||||
(error) => {
|
||||
if (error === undefined || error === null) return;
|
||||
|
|
@ -114,7 +127,7 @@ export class CdpConnection {
|
|||
if (pending === undefined) return;
|
||||
this.#complete(id, pending);
|
||||
reject(
|
||||
new BrowserObservationError("inspect_web_page", "disconnected", {
|
||||
new BrowserObservationError(this.operation, "disconnected", {
|
||||
cause: error,
|
||||
}),
|
||||
);
|
||||
|
|
@ -191,7 +204,7 @@ export class CdpConnection {
|
|||
this.#complete(id, pending);
|
||||
if ("error" in message) {
|
||||
pending.reject(
|
||||
new BrowserObservationError("inspect_web_page", "protocol_error"),
|
||||
new BrowserObservationError(this.operation, "protocol_error"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -209,9 +222,17 @@ export class CdpConnection {
|
|||
else this.#protocolFailed = true;
|
||||
for (const [id, pending] of this.#pending) {
|
||||
this.#complete(id, pending);
|
||||
pending.reject(new BrowserObservationError("inspect_web_page", reason));
|
||||
pending.reject(new BrowserObservationError(this.operation, reason));
|
||||
}
|
||||
}
|
||||
|
||||
#disconnect(): void {
|
||||
const wasClosed = this.#closed;
|
||||
this.#failPending("disconnected");
|
||||
if (wasClosed) return;
|
||||
for (const listener of this.#disconnectListeners) listener();
|
||||
this.#disconnectListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const rawText = (data: RawData): string =>
|
||||
|
|
@ -226,13 +247,14 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|||
|
||||
const waitForOpen = async (
|
||||
socket: WebSocket,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> =>
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
terminateSocket(socket);
|
||||
reject(new AnalysisTimeoutError("inspect_web_page", CONNECT_TIMEOUT_MS));
|
||||
reject(new AnalysisTimeoutError(operation, CONNECT_TIMEOUT_MS));
|
||||
}, CONNECT_TIMEOUT_MS);
|
||||
const onOpen = (): void => {
|
||||
cleanup();
|
||||
|
|
@ -242,17 +264,15 @@ const waitForOpen = async (
|
|||
cleanup();
|
||||
reject(
|
||||
signal?.aborted === true
|
||||
? new AnalysisCancelledError("inspect_web_page")
|
||||
: new BrowserObservationError(
|
||||
"inspect_web_page",
|
||||
"endpoint_unreachable",
|
||||
{ cause },
|
||||
),
|
||||
? new AnalysisCancelledError(operation)
|
||||
: new BrowserObservationError(operation, "endpoint_unreachable", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
terminateSocket(socket);
|
||||
onFailure(new AnalysisCancelledError("inspect_web_page"));
|
||||
onFailure(new AnalysisCancelledError(operation));
|
||||
};
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import {
|
|||
|
||||
interface ElectronContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly sessionId: string | undefined;
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: InspectElectronPageInput;
|
||||
|
|
@ -95,7 +95,11 @@ export const inspectCdpElectronPage = async (
|
|||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
await delayWithCancellation(context.input.observation_ms, context.signal);
|
||||
await delayWithCancellation(
|
||||
context.input.observation_ms,
|
||||
"inspect_electron_page",
|
||||
context.signal,
|
||||
);
|
||||
await report(context.progress, 2, "Capturing Electron structure");
|
||||
const resources = await captureResources(context, roots, completeness);
|
||||
const dom = await captureDom(context, roots, completeness);
|
||||
|
|
|
|||
|
|
@ -18,8 +18,16 @@ import {
|
|||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { CdpConnection } from "./CdpConnection.js";
|
||||
import { discoverCdpEndpoint, type CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import {
|
||||
discoverCdpEndpoint,
|
||||
hasCdpTargetWebSocket,
|
||||
type CdpEndpointTarget,
|
||||
} from "./CdpEndpoint.js";
|
||||
import {
|
||||
closeCdpTargetSession,
|
||||
openCdpTargetSession,
|
||||
type CdpTargetSession,
|
||||
} from "./CdpTargetSession.js";
|
||||
import { inspectCdpElectronPage } from "./CdpElectronInspection.js";
|
||||
import {
|
||||
authorizedElectronFile,
|
||||
|
|
@ -46,13 +54,14 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
|||
const roots = await canonicalElectronRoots(input.allowed_file_roots);
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"list_browser_targets",
|
||||
"list_electron_targets",
|
||||
options.signal,
|
||||
);
|
||||
const allowed = [];
|
||||
let outsideRoot = 0;
|
||||
let unsupportedUrl = 0;
|
||||
let nonPage = 0;
|
||||
let unconnectable = 0;
|
||||
for (const target of discovery.targets) {
|
||||
if (target.type !== "page") {
|
||||
nonPage += 1;
|
||||
|
|
@ -64,6 +73,10 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
|||
else unsupportedUrl += 1;
|
||||
continue;
|
||||
}
|
||||
if (!hasCdpTargetWebSocket(discovery, target)) {
|
||||
unconnectable += 1;
|
||||
continue;
|
||||
}
|
||||
allowed.push({
|
||||
target_id: target.id,
|
||||
type: target.type,
|
||||
|
|
@ -96,6 +109,11 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
|||
},
|
||||
limitations: [
|
||||
"Only page targets whose canonical file path is contained by an approved root are listed.",
|
||||
...(unconnectable === 0
|
||||
? []
|
||||
: [
|
||||
`${String(unconnectable)} otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.`,
|
||||
]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
|
@ -108,13 +126,12 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
|||
input: InspectElectronPageInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<ElectronPageInspection, AnalysisError>> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const roots = await canonicalElectronRoots(input.allowed_file_roots);
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
"inspect_electron_page",
|
||||
options.signal,
|
||||
);
|
||||
const target = await authorizeTarget(
|
||||
|
|
@ -122,22 +139,17 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
|||
input.target_id,
|
||||
roots,
|
||||
);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
targetSession = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
"inspect_electron_page",
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
return ok(
|
||||
electronPageInspectionSchema.parse(
|
||||
await inspectCdpElectronPage({
|
||||
connection,
|
||||
sessionId,
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
|
|
@ -151,10 +163,8 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
|||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "inspect_electron_page"));
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined) await cleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(targetSession, ["Debugger", "Page"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -175,35 +185,6 @@ const authorizeTarget = async (
|
|||
return target;
|
||||
};
|
||||
|
||||
const attachedSessionId = (value: unknown): string => {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("sessionId" in value) ||
|
||||
typeof value.sessionId !== "string" ||
|
||||
value.sessionId.length > 256
|
||||
)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
return value.sessionId;
|
||||
};
|
||||
|
||||
const cleanup = async (
|
||||
connection: CdpConnection,
|
||||
sessionId: string,
|
||||
): Promise<void> => {
|
||||
for (const method of ["Debugger.disable", "Page.disable"])
|
||||
try {
|
||||
await connection.send(method, {}, sessionId);
|
||||
} catch {
|
||||
// Detach remains the final non-destructive cleanup boundary.
|
||||
}
|
||||
try {
|
||||
await connection.send("Target.detachFromTarget", { sessionId });
|
||||
} catch {
|
||||
// Closing REA's socket never closes the Electron target.
|
||||
}
|
||||
};
|
||||
|
||||
const isFileUrl = (value: string): boolean => {
|
||||
try {
|
||||
return new URL(value).protocol === "file:";
|
||||
|
|
|
|||
|
|
@ -29,13 +29,34 @@ const endpointTargetSchema = z.object({
|
|||
title: z.string().max(16_384).default(""),
|
||||
url: z.string().max(65_536),
|
||||
attached: z.boolean().default(false),
|
||||
webSocketDebuggerUrl: z.string().min(1).max(2_048).optional(),
|
||||
});
|
||||
const endpointTargetsSchema = z.array(endpointTargetSchema).max(1_000);
|
||||
|
||||
export type CdpEndpointTarget = z.infer<typeof endpointTargetSchema>;
|
||||
/** Validated direct CDP WebSocket bound to one discovered page target. */
|
||||
interface CdpPageWebSocketEndpoint {
|
||||
readonly scope: "page";
|
||||
readonly targetId: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
/** Validated CDP discovery socket and its command-routing scope. */
|
||||
export type CdpWebSocketEndpoint =
|
||||
| { readonly scope: "browser"; readonly url: string }
|
||||
| CdpPageWebSocketEndpoint;
|
||||
|
||||
/** Bounded discovery target with an optional validated direct transport. */
|
||||
export interface CdpEndpointTarget {
|
||||
readonly id: string;
|
||||
readonly type: string;
|
||||
readonly title: string;
|
||||
readonly url: string;
|
||||
readonly attached: boolean;
|
||||
readonly webSocket?: CdpPageWebSocketEndpoint;
|
||||
}
|
||||
|
||||
export interface CdpEndpointDiscovery {
|
||||
readonly browserWebSocketUrl: string;
|
||||
readonly webSocket: CdpWebSocketEndpoint;
|
||||
readonly version: {
|
||||
readonly product: string;
|
||||
readonly protocol_version: string;
|
||||
|
|
@ -75,7 +96,7 @@ export const discoverCdpEndpoint = async (
|
|||
operation,
|
||||
);
|
||||
return {
|
||||
browserWebSocketUrl: safeBrowserWebSocketUrl(
|
||||
webSocket: safeCdpWebSocketEndpoint(
|
||||
endpoint,
|
||||
version.webSocketDebuggerUrl,
|
||||
operation,
|
||||
|
|
@ -87,10 +108,45 @@ export const discoverCdpEndpoint = async (
|
|||
user_agent: version["User-Agent"],
|
||||
js_version: version["V8-Version"],
|
||||
},
|
||||
targets,
|
||||
targets: targets.map((target) => ({
|
||||
id: target.id,
|
||||
type: target.type,
|
||||
title: target.title,
|
||||
url: target.url,
|
||||
attached: target.attached,
|
||||
...targetWebSocket(endpoint, target, operation),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
/** Select a browser attachment socket or a direct socket for one page target. */
|
||||
export const cdpTargetWebSocket = (
|
||||
discovery: CdpEndpointDiscovery,
|
||||
target: CdpEndpointTarget,
|
||||
operation: BrowserObservationOperation,
|
||||
): CdpWebSocketEndpoint => {
|
||||
const webSocket = availableCdpTargetWebSocket(discovery, target);
|
||||
if (webSocket !== undefined) return webSocket;
|
||||
throw new BrowserObservationError(operation, "invalid_endpoint_response");
|
||||
};
|
||||
|
||||
/** Report whether discovery contains a validated transport for one target. */
|
||||
export const hasCdpTargetWebSocket = (
|
||||
discovery: CdpEndpointDiscovery,
|
||||
target: CdpEndpointTarget,
|
||||
): boolean => availableCdpTargetWebSocket(discovery, target) !== undefined;
|
||||
|
||||
const availableCdpTargetWebSocket = (
|
||||
discovery: CdpEndpointDiscovery,
|
||||
target: CdpEndpointTarget,
|
||||
): CdpWebSocketEndpoint | undefined => {
|
||||
if (discovery.webSocket.scope === "browser") return discovery.webSocket;
|
||||
if (target.webSocket !== undefined) return target.webSocket;
|
||||
return discovery.webSocket.targetId === target.id
|
||||
? discovery.webSocket
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const parseEndpointValue = <Output>(
|
||||
schema: z.ZodType<Output>,
|
||||
input: unknown,
|
||||
|
|
@ -104,11 +160,11 @@ const parseEndpointValue = <Output>(
|
|||
return parsed.data;
|
||||
};
|
||||
|
||||
const safeBrowserWebSocketUrl = (
|
||||
const safeCdpWebSocketEndpoint = (
|
||||
endpoint: string,
|
||||
reported: string,
|
||||
operation: BrowserObservationOperation,
|
||||
): string => {
|
||||
): CdpWebSocketEndpoint => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(reported);
|
||||
|
|
@ -118,6 +174,7 @@ const safeBrowserWebSocketUrl = (
|
|||
});
|
||||
}
|
||||
const trustedEndpoint = new URL(endpoint);
|
||||
const path = cdpWebSocketPath(parsed.pathname);
|
||||
if (
|
||||
parsed.protocol !== "ws:" ||
|
||||
parsed.port !== trustedEndpoint.port ||
|
||||
|
|
@ -125,11 +182,59 @@ const safeBrowserWebSocketUrl = (
|
|||
parsed.password !== "" ||
|
||||
parsed.search !== "" ||
|
||||
parsed.hash !== "" ||
|
||||
!parsed.pathname.startsWith("/devtools/browser/")
|
||||
path === undefined
|
||||
)
|
||||
throw new BrowserObservationError(operation, "invalid_endpoint_response");
|
||||
parsed.hostname = trustedEndpoint.hostname;
|
||||
return parsed.href;
|
||||
return path.scope === "browser"
|
||||
? { scope: "browser", url: parsed.href }
|
||||
: { scope: "page", targetId: path.targetId, url: parsed.href };
|
||||
};
|
||||
|
||||
const cdpWebSocketPath = (
|
||||
pathname: string,
|
||||
):
|
||||
| { readonly scope: "browser" }
|
||||
| { readonly scope: "page"; targetId: string }
|
||||
| undefined => {
|
||||
const browserId = pathIdentifier(pathname, "/devtools/browser/", 1_024);
|
||||
if (browserId !== undefined) return { scope: "browser" };
|
||||
const targetId = pathIdentifier(pathname, "/devtools/page/", 256);
|
||||
return targetId === undefined ? undefined : { scope: "page", targetId };
|
||||
};
|
||||
|
||||
const pathIdentifier = (
|
||||
pathname: string,
|
||||
prefix: string,
|
||||
maximumLength: number,
|
||||
): string | undefined => {
|
||||
if (!pathname.startsWith(prefix)) return undefined;
|
||||
const identifier = pathname.slice(prefix.length);
|
||||
return identifier.length > 0 &&
|
||||
identifier.length <= maximumLength &&
|
||||
!identifier.includes("/")
|
||||
? identifier
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const targetWebSocket = (
|
||||
endpoint: string,
|
||||
target: z.infer<typeof endpointTargetSchema>,
|
||||
operation: BrowserObservationOperation,
|
||||
): { readonly webSocket?: CdpPageWebSocketEndpoint } => {
|
||||
if (target.webSocketDebuggerUrl === undefined) return {};
|
||||
try {
|
||||
const webSocket = safeCdpWebSocketEndpoint(
|
||||
endpoint,
|
||||
target.webSocketDebuggerUrl,
|
||||
operation,
|
||||
);
|
||||
return webSocket.scope === "page" && webSocket.targetId === target.id
|
||||
? { webSocket }
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const readJson = async (
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import type {
|
|||
ObserveWebSessionInput,
|
||||
WebObservationSession,
|
||||
} from "../domain/browserSession.js";
|
||||
import { BrowserObservationError } from "../domain/errors.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
BrowserObservationError,
|
||||
} from "../domain/errors.js";
|
||||
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import { CdpConnection, type CdpEvent } from "./CdpConnection.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
|
|
@ -20,7 +23,7 @@ import { mainFrameUrl } from "./CdpCaptureDocuments.js";
|
|||
|
||||
interface ObservationContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly sessionId: string | undefined;
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: ObserveWebSessionInput;
|
||||
|
|
@ -48,8 +51,12 @@ export const observeCdpSession = async (
|
|||
initialUrl,
|
||||
);
|
||||
const removeListener = context.connection.onEvent((event) => {
|
||||
if (event.sessionId === context.sessionId) capture.ingest(event);
|
||||
if (observationEventMatches(event, context.sessionId))
|
||||
capture.ingest(event);
|
||||
});
|
||||
const removeDisconnectListener = context.connection.onDisconnect(() =>
|
||||
capture.targetTerminated(),
|
||||
);
|
||||
try {
|
||||
await context.connection.send(
|
||||
"Page.enable",
|
||||
|
|
@ -115,6 +122,7 @@ export const observeCdpSession = async (
|
|||
};
|
||||
} finally {
|
||||
removeListener();
|
||||
removeDisconnectListener();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -162,8 +170,7 @@ class TimelineCapture {
|
|||
break;
|
||||
case "Inspector.targetCrashed":
|
||||
case "Target.detachedFromTarget":
|
||||
this.#add("target_terminated", params, null, null, "target_terminated");
|
||||
this.end("target_terminated");
|
||||
this.targetTerminated();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -181,6 +188,12 @@ class TimelineCapture {
|
|||
this.#listeners.clear();
|
||||
}
|
||||
|
||||
targetTerminated(): void {
|
||||
if (this.#endReason !== undefined) return;
|
||||
this.#add("target_terminated", {}, null, null, "target_terminated");
|
||||
this.end("target_terminated");
|
||||
}
|
||||
|
||||
setFinalUrl(rawUrl: string): void {
|
||||
const destination = scopedUrl(rawUrl, this.allowedOrigins);
|
||||
this.finalUrl = destination.scope === "approved" ? destination.url : null;
|
||||
|
|
@ -300,6 +313,18 @@ class TimelineCapture {
|
|||
}
|
||||
}
|
||||
|
||||
const observationEventMatches = (
|
||||
event: CdpEvent,
|
||||
sessionId: string | undefined,
|
||||
): boolean => {
|
||||
if (event.method !== "Target.detachedFromTarget")
|
||||
return event.sessionId === sessionId;
|
||||
return (
|
||||
sessionId !== undefined &&
|
||||
stringValue(recordValue(event.params)?.sessionId) === sessionId
|
||||
);
|
||||
};
|
||||
|
||||
const waitForWindow = async (
|
||||
durationMs: number,
|
||||
capture: TimelineCapture,
|
||||
|
|
@ -321,7 +346,7 @@ const waitForWindow = async (
|
|||
settled = true;
|
||||
clearTimeout(timer);
|
||||
removeEnd();
|
||||
reject(signal?.reason ?? new Error("browser observation cancelled"));
|
||||
reject(new AnalysisCancelledError("observe_web_session"));
|
||||
};
|
||||
const timer = setTimeout(() => finish("window_elapsed"), durationMs);
|
||||
removeEnd = capture.onEnd(finish);
|
||||
|
|
@ -347,7 +372,7 @@ const authorizedFrame = async (
|
|||
"inspect_web_page",
|
||||
"target_not_allowed",
|
||||
);
|
||||
await delayWithCancellation(25, context.signal);
|
||||
await delayWithCancellation(25, "observe_web_session", context.signal);
|
||||
}
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import type {
|
|||
WebPageInspection,
|
||||
} from "../domain/browserObservation.js";
|
||||
import type { ProgressReporter } from "../application/ProgressReporter.js";
|
||||
import { BrowserObservationError } from "../domain/errors.js";
|
||||
import {
|
||||
BrowserObservationError,
|
||||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
import {
|
||||
reconcileCapturedWebScript,
|
||||
stableWebResources,
|
||||
|
|
@ -37,7 +40,11 @@ import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
|
|||
|
||||
interface CaptureContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly sessionId: string | undefined;
|
||||
readonly operation: Extract<
|
||||
BrowserObservationOperation,
|
||||
"inspect_web_page" | "analyze_web_bundle"
|
||||
>;
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: InspectWebPageInput;
|
||||
|
|
@ -225,7 +232,11 @@ const authorizeObservationWindow = async (
|
|||
events.beginAuthorizedFrame(mainFrame.frame_id);
|
||||
await enableObservationDomains(connection, sessionId, signal);
|
||||
await report(context.progress, 2, "Observing page events");
|
||||
await delayWithCancellation(context.input.observation_ms, signal);
|
||||
await delayWithCancellation(
|
||||
context.input.observation_ms,
|
||||
context.operation,
|
||||
signal,
|
||||
);
|
||||
if (events.originViolation)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
};
|
||||
|
|
@ -323,14 +334,14 @@ const authorizedFrameTree = async (
|
|||
"inspect_web_page",
|
||||
"target_not_allowed",
|
||||
);
|
||||
await delayWithCancellation(25, context.signal);
|
||||
await delayWithCancellation(25, context.operation, context.signal);
|
||||
}
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
};
|
||||
|
||||
const enableObservationDomains = async (
|
||||
connection: CdpConnection,
|
||||
sessionId: string,
|
||||
sessionId: string | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
await connection.send("Runtime.enable", {}, sessionId, signal);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { decodeCanonicalBase64 } from "../domain/webScreenshot.js";
|
|||
|
||||
interface ScreenshotContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly sessionId: string | undefined;
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: CaptureWebScreenshotInput;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import {
|
||||
BrowserObservationError,
|
||||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
import { CdpConnection } from "./CdpConnection.js";
|
||||
import {
|
||||
cdpTargetWebSocket,
|
||||
type CdpEndpointDiscovery,
|
||||
type CdpEndpointTarget,
|
||||
} from "./CdpEndpoint.js";
|
||||
|
||||
/** One CDP connection scoped either by a flat browser session or page socket. */
|
||||
export interface CdpTargetSession {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string | undefined;
|
||||
}
|
||||
|
||||
/** Open one authorized target through its supported browser or page transport. */
|
||||
export const openCdpTargetSession = async (
|
||||
discovery: CdpEndpointDiscovery,
|
||||
target: CdpEndpointTarget,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CdpTargetSession> => {
|
||||
const webSocket = cdpTargetWebSocket(discovery, target, operation);
|
||||
const connection = await CdpConnection.connect(
|
||||
webSocket.url,
|
||||
operation,
|
||||
signal,
|
||||
);
|
||||
if (webSocket.scope === "page") return { connection, sessionId: undefined };
|
||||
try {
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
return { connection, sessionId: attachedSessionId(attached, operation) };
|
||||
} catch (cause: unknown) {
|
||||
await connection.close();
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
/** Disable enabled domains, detach browser sessions, and close REA's socket. */
|
||||
export const closeCdpTargetSession = async (
|
||||
targetSession: CdpTargetSession,
|
||||
enabledDomains: readonly string[],
|
||||
): Promise<void> => {
|
||||
const { connection, sessionId } = targetSession;
|
||||
for (const domain of enabledDomains)
|
||||
try {
|
||||
await connection.send(`${domain}.disable`, {}, sessionId);
|
||||
} catch {
|
||||
// Cleanup continues to the detach or direct-socket close boundary.
|
||||
}
|
||||
if (sessionId !== undefined)
|
||||
try {
|
||||
await connection.send("Target.detachFromTarget", { sessionId });
|
||||
} catch {
|
||||
// Closing REA's socket is the final non-destructive cleanup boundary.
|
||||
}
|
||||
await connection.close();
|
||||
};
|
||||
|
||||
const attachedSessionId = (
|
||||
value: unknown,
|
||||
operation: BrowserObservationOperation,
|
||||
): string => {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("sessionId" in value) ||
|
||||
typeof value.sessionId !== "string" ||
|
||||
value.sessionId.length === 0 ||
|
||||
value.sessionId.length > 256
|
||||
)
|
||||
throw new BrowserObservationError(operation, "protocol_error");
|
||||
return value.sessionId;
|
||||
};
|
||||
|
|
@ -25,7 +25,7 @@ import { boundedSensitiveText } from "./SensitiveTextCapture.js";
|
|||
|
||||
interface DiscoveryContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly sessionId: string | undefined;
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: DiscoverWebMcpToolsInput;
|
||||
|
|
@ -96,6 +96,7 @@ export const discoverWebMcp = async (
|
|||
// CDP sends the enable response before the required toolsAdded replay.
|
||||
await delayWithCancellation(
|
||||
Math.max(context.input.observation_ms, 25),
|
||||
"discover_webmcp_tools",
|
||||
context.signal,
|
||||
);
|
||||
await assertStableAuthorizedFrame(context, initialUrl, origins);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
import {
|
||||
browserEndpointSchema,
|
||||
browserOriginSchema,
|
||||
isLiteralLoopbackHostname,
|
||||
} from "./domain/browserObservation.js";
|
||||
import { electronFileRootsSchema } from "./domain/electronObservation.js";
|
||||
|
||||
|
|
@ -406,7 +407,7 @@ const browserNetworkScope = (
|
|||
): "loopback" | "external" =>
|
||||
origins.every((origin) => {
|
||||
const hostname = new URL(origin).hostname;
|
||||
return hostname === "127.0.0.1" || hostname === "[::1]";
|
||||
return isLiteralLoopbackHostname(hostname);
|
||||
})
|
||||
? "loopback"
|
||||
: "external";
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ export const browserOriginSchema = z
|
|||
return origin;
|
||||
});
|
||||
|
||||
const isLiteralLoopback = (hostname: string): boolean =>
|
||||
/** Recognize the URL API's bracketed IPv6 form and normalized bare literals. */
|
||||
export const isLiteralLoopbackHostname = (hostname: string): boolean =>
|
||||
hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
||||
|
||||
/** Loopback-only HTTP endpoint accepted for a user-owned CDP browser. */
|
||||
|
|
@ -64,7 +65,7 @@ export const browserEndpointSchema = z
|
|||
}
|
||||
if (
|
||||
url.protocol !== "http:" ||
|
||||
!isLiteralLoopback(url.hostname) ||
|
||||
!isLiteralLoopbackHostname(url.hostname) ||
|
||||
url.port === "" ||
|
||||
url.username !== "" ||
|
||||
url.password !== "" ||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { listBrowserTargets } from "../src/application/BrowserObservationService.js";
|
||||
import { createPermissionAuthority } from "../src/application/PermissionAuthority.js";
|
||||
import {
|
||||
browserEndpointSchema,
|
||||
browserOriginSchema,
|
||||
inspectWebPageInputSchema,
|
||||
isLiteralLoopbackHostname,
|
||||
listBrowserTargetsInputSchema,
|
||||
sanitizeBrowserUrl,
|
||||
} from "../src/domain/browserObservation.js";
|
||||
|
|
@ -44,6 +47,53 @@ describe("browser observation contracts", () => {
|
|||
expect(browserEndpointSchema.safeParse(value).success, value).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes bracketed and normalized IPv6 loopback hostnames", () => {
|
||||
for (const hostname of ["127.0.0.1", "[::1]", "::1"])
|
||||
expect(isLiteralLoopbackHostname(hostname), hostname).toBe(true);
|
||||
for (const hostname of ["localhost", "127.0.0.2", "::2"])
|
||||
expect(isLiteralLoopbackHostname(hostname), hostname).toBe(false);
|
||||
});
|
||||
|
||||
it("requests loopback authority for an IPv6-only browser origin", async () => {
|
||||
const endpoint = "http://127.0.0.1:9222";
|
||||
const origin = "http://[::1]:3000";
|
||||
const ceiling = {
|
||||
capability: "browser_observe" as const,
|
||||
roots: [],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
origins: [endpoint, origin],
|
||||
network: "loopback" as const,
|
||||
mount: false,
|
||||
};
|
||||
const authority = await createPermissionAuthority(
|
||||
[ceiling],
|
||||
[
|
||||
{
|
||||
...ceiling,
|
||||
grant_id: "administrator:browser_observe",
|
||||
lifetime: "administrator",
|
||||
operation_identity: null,
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
);
|
||||
if (!authority.ok) throw authority.error;
|
||||
const result = await listBrowserTargets(
|
||||
undefined,
|
||||
authority.value,
|
||||
listBrowserTargetsInputSchema.parse({
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [origin],
|
||||
approved: true,
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||||
});
|
||||
});
|
||||
|
||||
it("applies conservative bounded defaults to public tool input", () => {
|
||||
expect(
|
||||
listBrowserTargetsInputSchema.parse({
|
||||
|
|
|
|||
|
|
@ -53,6 +53,159 @@ describe("CdpBrowserProvider", () => {
|
|||
expect(JSON.stringify(result.value)).not.toContain("page-secret");
|
||||
});
|
||||
|
||||
it("uses page-scoped discovery sockets as direct target transports", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
pageScopedVersionWebSocket: true,
|
||||
omitTargetWebSocket: true,
|
||||
additionalPageWithWebSocket: true,
|
||||
additionalPageWithoutWebSocket: true,
|
||||
});
|
||||
browsers.push(browser);
|
||||
const provider = new CdpBrowserProvider();
|
||||
const listed = await provider.listTargets(
|
||||
listBrowserTargetsInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
approved: true,
|
||||
}),
|
||||
);
|
||||
if (!listed.ok) throw listed.error;
|
||||
expect(
|
||||
listed.value.targets.items.map(({ target_id }) => target_id),
|
||||
).toEqual(["allowed-page", "allowed-page-with-socket"]);
|
||||
expect(listed.value.limitations).toContain(
|
||||
"1 otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.",
|
||||
);
|
||||
|
||||
const inspected = await provider.inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
if (!inspected.ok) throw inspected.error;
|
||||
expect(inspected.value.target.target_id).toBe("allowed-page");
|
||||
expect(inspected.value.network.requests).toHaveLength(1);
|
||||
|
||||
const second = await provider.inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page-with-socket",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
if (!second.ok) throw second.error;
|
||||
expect(second.value.target.target_id).toBe("allowed-page-with-socket");
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).not.toContain("Target.attachToTarget");
|
||||
expect(methods).not.toContain("Target.detachFromTarget");
|
||||
expect(
|
||||
browser.commands.every(({ sessionId }) => sessionId === undefined),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("projects direct-session cancellation onto the requested operation", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
pageScopedVersionWebSocket: true,
|
||||
omitTargetWebSocket: true,
|
||||
});
|
||||
browsers.push(browser);
|
||||
const controller = new AbortController();
|
||||
const result = await new CdpBrowserProvider().observeSession(
|
||||
observeWebSessionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 1_000,
|
||||
}),
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
report(update) {
|
||||
if (update.completed === 1) controller.abort();
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "observe_web_session",
|
||||
},
|
||||
});
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).not.toContain("Target.attachToTarget");
|
||||
expect(methods).not.toContain("Target.detachFromTarget");
|
||||
expect(methods).not.toContain("Target.closeTarget");
|
||||
expect(methods).not.toContain("Browser.close");
|
||||
});
|
||||
|
||||
it("projects capture-window cancellation onto bundle analysis", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
pageScopedVersionWebSocket: true,
|
||||
omitTargetWebSocket: true,
|
||||
});
|
||||
browsers.push(browser);
|
||||
const controller = new AbortController();
|
||||
const result = await new CdpBrowserProvider().analyzeBundle(
|
||||
analyzeWebBundleInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
source_capture_approved: true,
|
||||
observation_ms: 1_000,
|
||||
}),
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
report(update) {
|
||||
if (update.completed === 2) controller.abort();
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "analyze_web_bundle",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty browser attachment session identifiers", async () => {
|
||||
const browser = await startFakeCdpBrowser({ invalidAttachedSession: true });
|
||||
browsers.push(browser);
|
||||
const result = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
|
||||
});
|
||||
expect(browser.commands.map(({ method }) => method)).toEqual([
|
||||
"Target.attachToTarget",
|
||||
]);
|
||||
});
|
||||
|
||||
it("captures bounded passive evidence without retaining sensitive values", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
binaryWebSocketEvent: true,
|
||||
|
|
@ -567,6 +720,58 @@ describe("CdpBrowserProvider", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("ends a browser session when its flat target session detaches", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
sessionTimeline: "target_detached",
|
||||
});
|
||||
browsers.push(browser);
|
||||
const result = await new CdpBrowserProvider().observeSession(
|
||||
observeWebSessionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 1_000,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("target_terminated");
|
||||
expect(result.value.timeline.at(-1)).toMatchObject({
|
||||
type: "target_terminated",
|
||||
detail: "target_terminated",
|
||||
});
|
||||
});
|
||||
|
||||
it("ends a direct page session when its transport disconnects", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
pageScopedVersionWebSocket: true,
|
||||
omitTargetWebSocket: true,
|
||||
closeAfterMethod: "Page.setLifecycleEventsEnabled",
|
||||
});
|
||||
browsers.push(browser);
|
||||
const result = await new CdpBrowserProvider().observeSession(
|
||||
observeWebSessionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 1_000,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("target_terminated");
|
||||
expect(result.value.timeline.at(-1)).toMatchObject({
|
||||
type: "target_terminated",
|
||||
detail: "target_terminated",
|
||||
});
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).not.toContain("Target.attachToTarget");
|
||||
expect(methods).not.toContain("Target.detachFromTarget");
|
||||
expect(methods).not.toContain("Target.closeTarget");
|
||||
});
|
||||
|
||||
it("discovers untrusted WebMCP declarations without registering or invoking them", async () => {
|
||||
const browser = await startFakeCdpBrowser({ webMcpTools: true });
|
||||
browsers.push(browser);
|
||||
|
|
@ -1121,7 +1326,10 @@ describe("CdpBrowserProvider", () => {
|
|||
const result = await pending;
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "inspect_web_page",
|
||||
},
|
||||
});
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).toContain("Target.detachFromTarget");
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ describe("CDP connection", () => {
|
|||
it("correlates concurrent command responses over a real WebSocket", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
browsers.push(browser);
|
||||
const connection = await CdpConnection.connect(browser.browserWebSocketUrl);
|
||||
const connection = await CdpConnection.connect(
|
||||
browser.browserWebSocketUrl,
|
||||
"inspect_web_page",
|
||||
);
|
||||
try {
|
||||
const [attached, frames] = await Promise.all([
|
||||
connection.send("Target.attachToTarget"),
|
||||
|
|
@ -37,13 +40,16 @@ describe("CDP connection", () => {
|
|||
it("returns a typed timeout for an unresponsive CDP command", async () => {
|
||||
const browser = await startFakeCdpBrowser({ hangOnMethod: "Page.enable" });
|
||||
browsers.push(browser);
|
||||
const connection = await CdpConnection.connect(browser.browserWebSocketUrl);
|
||||
const connection = await CdpConnection.connect(
|
||||
browser.browserWebSocketUrl,
|
||||
"observe_web_session",
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const pending = connection.send("Page.enable");
|
||||
const assertion = expect(pending).rejects.toMatchObject({
|
||||
_tag: "AnalysisTimeoutError",
|
||||
operation: "inspect_web_page",
|
||||
operation: "observe_web_session",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
|
|
|||
|
|
@ -114,6 +114,56 @@ describe("CdpElectronProvider", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("inspects a page-scoped Electron endpoint without browser attachment", async () => {
|
||||
const root = await electronFixture();
|
||||
const index = join(root, "index.html");
|
||||
const browser = await startFakeCdpBrowser({
|
||||
electronFileUrl: pathToFileURL(index).href,
|
||||
pageScopedVersionWebSocket: true,
|
||||
omitTargetWebSocket: true,
|
||||
});
|
||||
browsers.push(browser);
|
||||
const result = await new CdpElectronProvider().inspectPage(
|
||||
inspectElectronPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_file_roots: [root],
|
||||
target_id: "electron-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.target.file_path).toBe(index);
|
||||
expect(browser.commands.map(({ method }) => method)).not.toContain(
|
||||
"Target.attachToTarget",
|
||||
);
|
||||
expect(
|
||||
browser.commands.every(({ sessionId }) => sessionId === undefined),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("maps cancelled Electron discovery to the Electron operation", async () => {
|
||||
const root = await electronFixture();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await new CdpElectronProvider().listTargets(
|
||||
listElectronTargetsInputSchema.parse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
allowed_file_roots: [root],
|
||||
approved: true,
|
||||
}),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "list_electron_targets",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("captures Electron script source only after separate approval", async () => {
|
||||
const root = await electronFixture();
|
||||
const browser = await startFakeCdpBrowser({
|
||||
|
|
|
|||
|
|
@ -232,6 +232,21 @@ describe("runtime configuration", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("classifies IPv6 browser origins as loopback network scope", () => {
|
||||
const result = parseConfig({
|
||||
REA_BROWSER_OBSERVE_ENABLED: "true",
|
||||
REA_BROWSER_CDP_ENDPOINTS_JSON: '["http://127.0.0.1:9222"]',
|
||||
REA_BROWSER_ALLOWED_ORIGINS_JSON: '["http://[::1]:3000"]',
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.permissionCeilings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
capability: "browser_observe",
|
||||
network: "loopback",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'["http://localhost:9222"]',
|
||||
'["http://192.168.1.5:9222"]',
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ interface FakeOptions {
|
|||
readonly malformedDiscovery?: boolean;
|
||||
readonly oversizedDiscovery?: boolean;
|
||||
readonly invalidBrowserWebSocket?: boolean;
|
||||
readonly pageScopedVersionWebSocket?: boolean;
|
||||
readonly omitTargetWebSocket?: boolean;
|
||||
readonly additionalPageWithWebSocket?: boolean;
|
||||
readonly additionalPageWithoutWebSocket?: boolean;
|
||||
readonly invalidAttachedSession?: boolean;
|
||||
readonly malformedMessageOnMethod?: string;
|
||||
readonly malformedEventOnMethod?: string;
|
||||
readonly malformedEventShapeOnMethod?: string;
|
||||
|
|
@ -46,7 +51,11 @@ interface FakeOptions {
|
|||
readonly binaryWebSocketEvent?: boolean;
|
||||
readonly invalidBinaryWebSocketEvent?: boolean;
|
||||
readonly sourceMapBody?: string;
|
||||
readonly sessionTimeline?: "same_origin" | "outside_policy";
|
||||
readonly sessionTimeline?:
|
||||
| "same_origin"
|
||||
| "outside_policy"
|
||||
| "target_detached";
|
||||
readonly closeAfterMethod?: string;
|
||||
readonly sensitiveShapes?: boolean;
|
||||
readonly invalidResponseBodyBase64?: boolean;
|
||||
readonly webMcpTools?: boolean;
|
||||
|
|
@ -98,8 +107,10 @@ export const startFakeCdpBrowser = async (
|
|||
"WebKit-Version": "fake-revision",
|
||||
webSocketDebuggerUrl:
|
||||
options.invalidBrowserWebSocket === true
|
||||
? `ws://localhost:${String(port)}/devtools/page/not-browser`
|
||||
: `ws://localhost:${String(port)}/devtools/browser/fake`,
|
||||
? `ws://localhost:${String(port)}/devtools/invalid/fake`
|
||||
: options.pageScopedVersionWebSocket === true
|
||||
? `ws://localhost:${String(port)}/devtools/page/${versionTargetId(options)}`
|
||||
: `ws://localhost:${String(port)}/devtools/browser/fake`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
|
|
@ -113,7 +124,12 @@ export const startFakeCdpBrowser = async (
|
|||
});
|
||||
const webSockets = new WebSocketServer({ noServer: true });
|
||||
http.on("upgrade", (request, socket, head) => {
|
||||
if (request.url !== "/devtools/browser/fake") {
|
||||
if (
|
||||
request.url !== "/devtools/browser/fake" &&
|
||||
request.url !== "/devtools/page/allowed-page" &&
|
||||
request.url !== "/devtools/page/allowed-page-with-socket" &&
|
||||
request.url !== "/devtools/page/electron-page"
|
||||
) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
|
@ -122,7 +138,7 @@ export const startFakeCdpBrowser = async (
|
|||
);
|
||||
});
|
||||
webSockets.on("connection", (socket: WebSocket, request: IncomingMessage) => {
|
||||
void request;
|
||||
const directPage = request.url?.startsWith("/devtools/page/") === true;
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
socket.on("message", (raw) => {
|
||||
|
|
@ -146,6 +162,19 @@ export const startFakeCdpBrowser = async (
|
|||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
directPage &&
|
||||
(command.method === "Target.attachToTarget" ||
|
||||
command.sessionId !== undefined)
|
||||
) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: command.id,
|
||||
error: { code: -32_600, message: "Direct page socket expected" },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command.method === "Page.getFrameTree") frameTreeReads += 1;
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
|
|
@ -158,6 +187,8 @@ export const startFakeCdpBrowser = async (
|
|||
if (options.malformedEventShapeOnMethod === command.method)
|
||||
socket.send(JSON.stringify({ method: 42 }));
|
||||
emitEvents(socket, command, port, options);
|
||||
if (options.closeAfterMethod === command.method)
|
||||
setImmediate(() => socket.close());
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
|
@ -197,7 +228,11 @@ const targets = (
|
|||
title: "Inspectable application",
|
||||
url: `http://127.0.0.1:${String(port)}/app?token=page-secret#fragment`,
|
||||
attached: false,
|
||||
webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page`,
|
||||
...(options.omitTargetWebSocket === true
|
||||
? {}
|
||||
: {
|
||||
webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "disallowed-page",
|
||||
|
|
@ -206,6 +241,29 @@ const targets = (
|
|||
url: "https://private.example.test/app?token=forbidden",
|
||||
attached: false,
|
||||
},
|
||||
...(options.additionalPageWithWebSocket === true
|
||||
? [
|
||||
{
|
||||
id: "allowed-page-with-socket",
|
||||
type: "page",
|
||||
title: "Second inspectable application page",
|
||||
url: `http://127.0.0.1:${String(port)}/second`,
|
||||
attached: false,
|
||||
webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page-with-socket`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(options.additionalPageWithoutWebSocket === true
|
||||
? [
|
||||
{
|
||||
id: "allowed-page-without-socket",
|
||||
type: "page",
|
||||
title: "Unavailable application page",
|
||||
url: `http://127.0.0.1:${String(port)}/unavailable`,
|
||||
attached: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "unsupported-page",
|
||||
type: "page",
|
||||
|
|
@ -254,6 +312,11 @@ const targets = (
|
|||
title: "Electron application",
|
||||
url: options.electronFileUrl,
|
||||
attached: false,
|
||||
...(options.omitTargetWebSocket === true
|
||||
? {}
|
||||
: {
|
||||
webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/electron-page`,
|
||||
}),
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
|
@ -289,7 +352,7 @@ const resultFor = (
|
|||
): Readonly<Record<string, unknown>> => {
|
||||
switch (command.method) {
|
||||
case "Target.attachToTarget":
|
||||
return { sessionId: "session-1" };
|
||||
return { sessionId: options.invalidAttachedSession ? "" : "session-1" };
|
||||
case "Page.getFrameTree":
|
||||
return frameTree(
|
||||
port,
|
||||
|
|
@ -366,6 +429,9 @@ const resultFor = (
|
|||
}
|
||||
};
|
||||
|
||||
const versionTargetId = (options: FakeOptions): string =>
|
||||
options.electronFileUrl === undefined ? "allowed-page" : "electron-page";
|
||||
|
||||
const endpointTargetToInfo = (
|
||||
target: Readonly<Record<string, unknown>>,
|
||||
): Readonly<Record<string, unknown>> => ({
|
||||
|
|
@ -903,6 +969,11 @@ const emitEvents = (
|
|||
name: "networkIdle",
|
||||
timestamp: 15,
|
||||
});
|
||||
if (options.sessionTimeline === "target_detached")
|
||||
event(socket, "Target.detachedFromTarget", undefined, {
|
||||
sessionId: command.sessionId,
|
||||
targetId: "allowed-page",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue