fix(browser): support page-scoped CDP transports

This commit is contained in:
morluto 2026-07-14 22:24:02 +00:00
commit c4930f4820
24 changed files with 1050 additions and 236 deletions

33
.github/workflows/real-browser.yml vendored Normal file
View File

@ -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"

View File

@ -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();
}

View File

@ -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 {

View File

@ -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";

View File

@ -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,

View File

@ -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;
},

View File

@ -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();

View File

@ -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);

View File

@ -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);

View File

@ -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:";

View 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 (

View File

@ -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");
};

View File

@ -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);

View File

@ -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;

View File

@ -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;
};

View File

@ -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);

View File

@ -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";

View File

@ -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 !== "" ||

View File

@ -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({

View File

@ -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");

View File

@ -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);

View File

@ -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({

View File

@ -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"]',

View File

@ -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",
});
}
};