From bf9619267b45f2383ebdd648dea05a7d2e3945a6 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 20:22:12 +0000 Subject: [PATCH] fix(browser): support page-scoped CDP transports --- src/application/BrowserObservationService.ts | 9 +- src/browser/CdpBrowserProvider.ts | 176 +++++++------------ src/browser/CdpCaptureStorage.ts | 2 +- src/browser/CdpConnection.ts | 2 +- src/browser/CdpElectronInspection.ts | 2 +- src/browser/CdpElectronProvider.ts | 79 ++++----- src/browser/CdpEndpoint.ts | 121 ++++++++++++- src/browser/CdpObservationSession.ts | 2 +- src/browser/CdpPageCapture.ts | 4 +- src/browser/CdpScreenshot.ts | 2 +- src/browser/CdpTargetSession.ts | 77 ++++++++ src/browser/CdpWebMcpDiscovery.ts | 2 +- src/config.ts | 3 +- src/domain/browserObservation.ts | 5 +- tests/browserObservation.test.ts | 50 ++++++ tests/cdpBrowserProvider.test.ts | 77 ++++++++ tests/cdpElectronProvider.test.ts | 28 +++ tests/config.test.ts | 15 ++ tests/fixtures/fakeCdpBrowser.ts | 72 +++++++- 19 files changed, 540 insertions(+), 188 deletions(-) create mode 100644 src/browser/CdpTargetSession.ts diff --git a/src/application/BrowserObservationService.ts b/src/application/BrowserObservationService.ts index 6ad7c1f58..79b923849 100644 --- a/src/application/BrowserObservationService.ts +++ b/src/application/BrowserObservationService.ts @@ -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"; diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index b49202f0c..de3bd4aab 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -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> { 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> { 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> { - 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> { - 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> { - 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 { - 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 => { - 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, diff --git a/src/browser/CdpCaptureStorage.ts b/src/browser/CdpCaptureStorage.ts index 21f6b32f5..00463d07d 100644 --- a/src/browser/CdpCaptureStorage.ts +++ b/src/browser/CdpCaptureStorage.ts @@ -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; }, diff --git a/src/browser/CdpConnection.ts b/src/browser/CdpConnection.ts index fc4d069b8..e74a279a0 100644 --- a/src/browser/CdpConnection.ts +++ b/src/browser/CdpConnection.ts @@ -106,7 +106,7 @@ export class CdpConnection { id, method, params, - ...(sessionId ? { sessionId } : {}), + ...(sessionId === undefined ? {} : { sessionId }), }), (error) => { if (error === undefined || error === null) return; diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index 9aa0e7172..78d92d2c2 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -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; diff --git a/src/browser/CdpElectronProvider.ts b/src/browser/CdpElectronProvider.ts index d779ca5b5..04789f2a2 100644 --- a/src/browser/CdpElectronProvider.ts +++ b/src/browser/CdpElectronProvider.ts @@ -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> { - 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 => { - 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:"; diff --git a/src/browser/CdpEndpoint.ts b/src/browser/CdpEndpoint.ts index b09c9d1ca..74146213d 100644 --- a/src/browser/CdpEndpoint.ts +++ b/src/browser/CdpEndpoint.ts @@ -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; +/** 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 = ( schema: z.ZodType, input: unknown, @@ -104,11 +160,11 @@ const parseEndpointValue = ( 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, + 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 ( diff --git a/src/browser/CdpObservationSession.ts b/src/browser/CdpObservationSession.ts index 2016f651d..9f7a51542 100644 --- a/src/browser/CdpObservationSession.ts +++ b/src/browser/CdpObservationSession.ts @@ -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; diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index 45418e704..69c1ee287 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -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 => { await connection.send("Runtime.enable", {}, sessionId, signal); diff --git a/src/browser/CdpScreenshot.ts b/src/browser/CdpScreenshot.ts index 6c27518e9..f2cdb9c9f 100644 --- a/src/browser/CdpScreenshot.ts +++ b/src/browser/CdpScreenshot.ts @@ -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; diff --git a/src/browser/CdpTargetSession.ts b/src/browser/CdpTargetSession.ts new file mode 100644 index 000000000..aea683f4a --- /dev/null +++ b/src/browser/CdpTargetSession.ts @@ -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 => { + 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 => { + 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; +}; diff --git a/src/browser/CdpWebMcpDiscovery.ts b/src/browser/CdpWebMcpDiscovery.ts index 8297a8a85..04b8116e9 100644 --- a/src/browser/CdpWebMcpDiscovery.ts +++ b/src/browser/CdpWebMcpDiscovery.ts @@ -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; diff --git a/src/config.ts b/src/config.ts index ad81f50ed..413fb9c21 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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"; diff --git a/src/domain/browserObservation.ts b/src/domain/browserObservation.ts index c94f43f92..dadd9773d 100644 --- a/src/domain/browserObservation.ts +++ b/src/domain/browserObservation.ts @@ -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 !== "" || diff --git a/tests/browserObservation.test.ts b/tests/browserObservation.test.ts index 29cb36c20..5294e0ba1 100644 --- a/tests/browserObservation.test.ts +++ b/tests/browserObservation.test.ts @@ -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({ diff --git a/tests/cdpBrowserProvider.test.ts b/tests/cdpBrowserProvider.test.ts index 1a3d06c88..2df517490 100644 --- a/tests/cdpBrowserProvider.test.ts +++ b/tests/cdpBrowserProvider.test.ts @@ -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, diff --git a/tests/cdpElectronProvider.test.ts b/tests/cdpElectronProvider.test.ts index 98cac969b..77b50bb42 100644 --- a/tests/cdpElectronProvider.test.ts +++ b/tests/cdpElectronProvider.test.ts @@ -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({ diff --git a/tests/config.test.ts b/tests/config.test.ts index 542113700..6e89671d9 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -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"]', diff --git a/tests/fixtures/fakeCdpBrowser.ts b/tests/fixtures/fakeCdpBrowser.ts index 38fc98d4a..321d62d5a 100644 --- a/tests/fixtures/fakeCdpBrowser.ts +++ b/tests/fixtures/fakeCdpBrowser.ts @@ -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> => { 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>, ): Readonly> => ({