fix(browser): support page-scoped CDP transports

This commit is contained in:
N0zoM1z0 2026-07-14 20:22:12 +00:00
parent 528c05e2fa
commit bf9619267b
19 changed files with 540 additions and 188 deletions

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,25 @@ 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,
discovery,
target,
input,
@ -354,11 +337,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 +349,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 +370,10 @@ const selectTargets = (
disallowedOrigin += 1;
continue;
}
if (!hasCdpTargetWebSocket(discovery, target)) {
unconnectable += 1;
continue;
}
allowed.push({
target_id: target.id,
type: target.type,
@ -405,6 +391,7 @@ const selectTargets = (
unsupported_url: unsupportedUrl,
non_page: nonPage,
},
unconnectable,
};
};
@ -427,37 +414,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

@ -106,7 +106,7 @@ export class CdpConnection {
id,
method,
params,
...(sessionId ? { sessionId } : {}),
...(sessionId === undefined ? {} : { sessionId }),
}),
(error) => {
if (error === undefined || error === null) return;

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;

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,
@ -53,6 +61,7 @@ export class CdpElectronProvider implements ElectronObservationPort {
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. */
export 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

@ -20,7 +20,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;

View File

@ -37,7 +37,7 @@ import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
interface CaptureContext {
readonly connection: CdpConnection;
readonly sessionId: string;
readonly sessionId: string | undefined;
readonly discovery: CdpEndpointDiscovery;
readonly target: CdpEndpointTarget;
readonly input: InspectWebPageInput;
@ -330,7 +330,7 @@ const authorizedFrameTree = async (
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,77 @@
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, 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;

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,83 @@ 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("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,

View File

@ -114,6 +114,34 @@ 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("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;
@ -98,8 +103,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 +120,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 +134,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 +158,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({
@ -197,7 +222,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 +235,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 +306,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 +346,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 +423,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>> => ({