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 1/7] 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> => ({ From 37439620710d4c47bf48f1df0b099eab0c115446 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 20:37:22 +0000 Subject: [PATCH 2/7] fix(browser): keep page endpoint type internal --- src/browser/CdpEndpoint.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/CdpEndpoint.ts b/src/browser/CdpEndpoint.ts index 74146213d..4aa55b8df 100644 --- a/src/browser/CdpEndpoint.ts +++ b/src/browser/CdpEndpoint.ts @@ -34,7 +34,7 @@ const endpointTargetSchema = z.object({ const endpointTargetsSchema = z.array(endpointTargetSchema).max(1_000); /** Validated direct CDP WebSocket bound to one discovered page target. */ -export interface CdpPageWebSocketEndpoint { +interface CdpPageWebSocketEndpoint { readonly scope: "page"; readonly targetId: string; readonly url: string; From 0aac8aaba5a5097d99277d7501dae6fdd017f2d0 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 20:36:06 +0000 Subject: [PATCH 3/7] fix(browser): preserve operation-aware cancellation errors --- src/browser/CdpBrowserProvider.ts | 1 + src/browser/CdpCaptureValues.ts | 7 +-- src/browser/CdpConnection.ts | 53 +++++++++--------- src/browser/CdpElectronInspection.ts | 6 ++- src/browser/CdpElectronProvider.ts | 2 +- src/browser/CdpObservationSession.ts | 9 ++-- src/browser/CdpPageCapture.ts | 17 ++++-- src/browser/CdpTargetSession.ts | 6 ++- src/browser/CdpWebMcpDiscovery.ts | 1 + tests/cdpBrowserProvider.test.ts | 81 +++++++++++++++++++++++++++- tests/cdpConnection.test.ts | 12 +++-- tests/cdpElectronProvider.test.ts | 22 ++++++++ 12 files changed, 175 insertions(+), 42 deletions(-) diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index de3bd4aab..602e9f7a5 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -328,6 +328,7 @@ export class CdpBrowserProvider implements BrowserObservationPort { return await capturePage({ connection: targetSession.connection, sessionId: targetSession.sessionId, + operation, discovery, target, input, diff --git a/src/browser/CdpCaptureValues.ts b/src/browser/CdpCaptureValues.ts index ffa601fc8..3352de9d0 100644 --- a/src/browser/CdpCaptureValues.ts +++ b/src/browser/CdpCaptureValues.ts @@ -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 => { 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((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(); diff --git a/src/browser/CdpConnection.ts b/src/browser/CdpConnection.ts index e74a279a0..73d05f4a0 100644 --- a/src/browser/CdpConnection.ts +++ b/src/browser/CdpConnection.ts @@ -5,6 +5,7 @@ import { AnalysisError, AnalysisTimeoutError, BrowserObservationError, + type BrowserObservationOperation, } from "../domain/errors.js"; export interface CdpEvent { @@ -25,7 +26,7 @@ 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(); readonly #listeners = new Set<(event: CdpEvent) => void>(); @@ -33,26 +34,29 @@ export class CdpConnection { #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")); } - /** 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 { - 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. */ @@ -69,13 +73,13 @@ export class CdpConnection { signal?: AbortSignal, ): Promise { 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 +89,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, @@ -114,7 +116,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 +193,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,7 +211,7 @@ 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)); } } } @@ -226,13 +228,14 @@ const isRecord = (value: unknown): value is Record => const waitForOpen = async ( socket: WebSocket, + operation: BrowserObservationOperation, signal?: AbortSignal, ): Promise => 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 +245,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); diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index 78d92d2c2..adf84e50f 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -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); diff --git a/src/browser/CdpElectronProvider.ts b/src/browser/CdpElectronProvider.ts index 04789f2a2..da400e630 100644 --- a/src/browser/CdpElectronProvider.ts +++ b/src/browser/CdpElectronProvider.ts @@ -54,7 +54,7 @@ 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 = []; diff --git a/src/browser/CdpObservationSession.ts b/src/browser/CdpObservationSession.ts index 9f7a51542..21171f131 100644 --- a/src/browser/CdpObservationSession.ts +++ b/src/browser/CdpObservationSession.ts @@ -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"; @@ -321,7 +324,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 +350,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"); }; diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index 69c1ee287..9972d86c4 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -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, @@ -38,6 +41,10 @@ import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js"; interface CaptureContext { readonly connection: CdpConnection; 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,7 +334,7 @@ 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"); }; diff --git a/src/browser/CdpTargetSession.ts b/src/browser/CdpTargetSession.ts index aea683f4a..5567c7890 100644 --- a/src/browser/CdpTargetSession.ts +++ b/src/browser/CdpTargetSession.ts @@ -23,7 +23,11 @@ export const openCdpTargetSession = async ( signal?: AbortSignal, ): Promise => { const webSocket = cdpTargetWebSocket(discovery, target, operation); - const connection = await CdpConnection.connect(webSocket.url, signal); + const connection = await CdpConnection.connect( + webSocket.url, + operation, + signal, + ); if (webSocket.scope === "page") return { connection, sessionId: undefined }; try { const attached = await connection.send( diff --git a/src/browser/CdpWebMcpDiscovery.ts b/src/browser/CdpWebMcpDiscovery.ts index 04b8116e9..d0bbbdf61 100644 --- a/src/browser/CdpWebMcpDiscovery.ts +++ b/src/browser/CdpWebMcpDiscovery.ts @@ -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); diff --git a/tests/cdpBrowserProvider.test.ts b/tests/cdpBrowserProvider.test.ts index 2df517490..b705f1324 100644 --- a/tests/cdpBrowserProvider.test.ts +++ b/tests/cdpBrowserProvider.test.ts @@ -109,6 +109,82 @@ describe("CdpBrowserProvider", () => { ).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); @@ -1198,7 +1274,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"); diff --git a/tests/cdpConnection.test.ts b/tests/cdpConnection.test.ts index a957f6bad..dc1c3e051 100644 --- a/tests/cdpConnection.test.ts +++ b/tests/cdpConnection.test.ts @@ -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); diff --git a/tests/cdpElectronProvider.test.ts b/tests/cdpElectronProvider.test.ts index 77b50bb42..ddeac1c1b 100644 --- a/tests/cdpElectronProvider.test.ts +++ b/tests/cdpElectronProvider.test.ts @@ -142,6 +142,28 @@ describe("CdpElectronProvider", () => { ).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({ From 9be24f8de14edc04cdd3649e0ea66d53edb72328 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 20:38:33 +0000 Subject: [PATCH 4/7] fix(browser): observe direct target disconnects --- src/browser/CdpConnection.ts | 23 ++++++++++-- src/browser/CdpObservationSession.ts | 28 +++++++++++++-- tests/cdpBrowserProvider.test.ts | 52 ++++++++++++++++++++++++++++ tests/fixtures/fakeCdpBrowser.ts | 13 ++++++- 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/src/browser/CdpConnection.ts b/src/browser/CdpConnection.ts index 73d05f4a0..667bb6344 100644 --- a/src/browser/CdpConnection.ts +++ b/src/browser/CdpConnection.ts @@ -30,6 +30,7 @@ const MAX_PENDING_COMMANDS = 128; export class CdpConnection { readonly #pending = new Map(); readonly #listeners = new Set<(event: CdpEvent) => void>(); + readonly #disconnectListeners = new Set<() => void>(); #nextId = 1; #closed = false; #protocolFailed = false; @@ -39,8 +40,8 @@ export class CdpConnection { 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 CDP WebSocket. */ @@ -65,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, @@ -214,6 +225,14 @@ export class CdpConnection { 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 => diff --git a/src/browser/CdpObservationSession.ts b/src/browser/CdpObservationSession.ts index 21171f131..ff5592535 100644 --- a/src/browser/CdpObservationSession.ts +++ b/src/browser/CdpObservationSession.ts @@ -51,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", @@ -118,6 +122,7 @@ export const observeCdpSession = async ( }; } finally { removeListener(); + removeDisconnectListener(); } }; @@ -165,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; } } @@ -184,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; @@ -303,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, diff --git a/tests/cdpBrowserProvider.test.ts b/tests/cdpBrowserProvider.test.ts index b705f1324..3a8ce3a5e 100644 --- a/tests/cdpBrowserProvider.test.ts +++ b/tests/cdpBrowserProvider.test.ts @@ -720,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); diff --git a/tests/fixtures/fakeCdpBrowser.ts b/tests/fixtures/fakeCdpBrowser.ts index 321d62d5a..25f6c5612 100644 --- a/tests/fixtures/fakeCdpBrowser.ts +++ b/tests/fixtures/fakeCdpBrowser.ts @@ -51,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; @@ -183,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((resolve, reject) => { @@ -963,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", + }); } }; From c252536494c15f36e69978eaf068eae176e71688 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 21:21:50 +0000 Subject: [PATCH 5/7] test(browser): verify page-scoped Chrome transport --- .github/workflows/real-browser.yml | 32 ++++++++ scripts/fixtures/page-cdp-proxy.mjs | 111 ++++++++++++++++++++++++++++ scripts/verify-real-browser.mjs | 62 ++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 .github/workflows/real-browser.yml create mode 100644 scripts/fixtures/page-cdp-proxy.mjs diff --git a/.github/workflows/real-browser.yml b/.github/workflows/real-browser.yml new file mode 100644 index 000000000..157a0d9fa --- /dev/null +++ b/.github/workflows/real-browser.yml @@ -0,0 +1,32 @@ +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 diff --git a/scripts/fixtures/page-cdp-proxy.mjs b/scripts/fixtures/page-cdp-proxy.mjs new file mode 100644 index 000000000..c6d531f8b --- /dev/null +++ b/scripts/fixtures/page-cdp-proxy.mjs @@ -0,0 +1,111 @@ +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/` 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 rewritten = + request.url === "/json/version" + ? await pageScopedVersion(upstreamEndpoint, value) + : value; + 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 }; +} + +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(); +} diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs index ad08e70b6..2a07dd492 100644 --- a/scripts/verify-real-browser.mjs +++ b/scripts/verify-real-browser.mjs @@ -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,6 +32,7 @@ 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, @@ -191,6 +193,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 +210,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 +227,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, From 74c512130c5bc25d6c119eda72a86e73fcc9f1ff Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 21:35:46 +0000 Subject: [PATCH 6/7] test(browser): harden Chrome startup on CI --- .github/workflows/real-browser.yml | 1 + scripts/verify-real-browser.mjs | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/real-browser.yml b/.github/workflows/real-browser.yml index 157a0d9fa..f57b1a401 100644 --- a/.github/workflows/real-browser.yml +++ b/.github/workflows/real-browser.yml @@ -30,3 +30,4 @@ jobs: - run: npm run verify:browser env: REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome + REA_BROWSER_NO_SANDBOX: "true" diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs index 2a07dd492..7bf8fe197 100644 --- a/scripts/verify-real-browser.mjs +++ b/scripts/verify-real-browser.mjs @@ -38,6 +38,9 @@ try { 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}`, @@ -45,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`, @@ -309,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 { From e363f0eab8325a9f83568295d4669d70e7d47a2d Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 21:37:08 +0000 Subject: [PATCH 7/7] test(browser): route discovery sockets through proxy --- scripts/fixtures/page-cdp-proxy.mjs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/fixtures/page-cdp-proxy.mjs b/scripts/fixtures/page-cdp-proxy.mjs index c6d531f8b..6a002e9d8 100644 --- a/scripts/fixtures/page-cdp-proxy.mjs +++ b/scripts/fixtures/page-cdp-proxy.mjs @@ -18,10 +18,11 @@ export async function startPageCdpProxy(upstreamEndpoint) { return; } const value = await upstreamJson(upstreamEndpoint, request.url); - const rewritten = + 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", @@ -103,6 +104,20 @@ async function pageScopedVersion(upstreamEndpoint, version) { 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)