diff --git a/.github/workflows/real-browser.yml b/.github/workflows/real-browser.yml new file mode 100644 index 000000000..f57b1a401 --- /dev/null +++ b/.github/workflows/real-browser.yml @@ -0,0 +1,33 @@ +name: Real browser verification + +on: + pull_request: + paths: + - ".github/workflows/real-browser.yml" + - "scripts/verify-real-browser.mjs" + - "scripts/fixtures/browser-verifier-site.mjs" + - "scripts/fixtures/page-cdp-proxy.mjs" + - "src/browser/**" + - "src/domain/browser*.ts" + - "package.json" + - "package-lock.json" + workflow_dispatch: + +permissions: + contents: read + +jobs: + real-browser: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run verify:browser + env: + REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome + REA_BROWSER_NO_SANDBOX: "true" diff --git a/scripts/fixtures/page-cdp-proxy.mjs b/scripts/fixtures/page-cdp-proxy.mjs new file mode 100644 index 000000000..6a002e9d8 --- /dev/null +++ b/scripts/fixtures/page-cdp-proxy.mjs @@ -0,0 +1,126 @@ +import { createServer } from "node:http"; + +import WebSocket, { WebSocketServer } from "ws"; + +/** + * Expose Chrome discovery through a page-scoped-only CDP endpoint. + * + * The proxy deliberately reports a `/devtools/page/` socket from + * `/json/version`, so the verifier cannot silently fall back to the browser + * socket. WebSocket bytes are forwarded without interpreting CDP messages. + */ +export async function startPageCdpProxy(upstreamEndpoint) { + const clients = new Set(); + const server = createServer(async (request, response) => { + try { + if (request.url !== "/json/version" && request.url !== "/json/list") { + response.writeHead(404).end(); + return; + } + const value = await upstreamJson(upstreamEndpoint, request.url); + const pageScoped = + request.url === "/json/version" + ? await pageScopedVersion(upstreamEndpoint, value) + : value; + const rewritten = rewriteWebSocketEndpoints(pageScoped, server); + const body = JSON.stringify(rewritten); + response.writeHead(200, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }); + response.end(body); + } catch (error) { + response.writeHead(502).end(String(error)); + } + }); + const webSockets = new WebSocketServer({ noServer: true }); + server.on("upgrade", (request, socket, head) => { + if (!request.url?.startsWith("/devtools/page/")) { + socket.destroy(); + return; + } + webSockets.handleUpgrade(request, socket, head, (client) => { + const upstream = new WebSocket( + new URL(request.url, upstreamEndpoint).href.replace(/^http/, "ws"), + ); + const pending = []; + clients.add(client); + const close = () => { + clients.delete(client); + if (client.readyState < WebSocket.CLOSING) client.close(); + if (upstream.readyState < WebSocket.CLOSING) upstream.close(); + }; + client.on("message", (data, binary) => { + if (upstream.readyState === WebSocket.OPEN) + upstream.send(data, { binary }); + else pending.push({ data, binary }); + }); + upstream.on("message", (data, binary) => { + if (client.readyState === WebSocket.OPEN) client.send(data, { binary }); + }); + upstream.on("open", () => { + for (const { data, binary } of pending) upstream.send(data, { binary }); + pending.length = 0; + webSockets.emit("connection", client, request); + }); + client.on("close", close); + client.on("error", close); + upstream.on("close", close); + upstream.on("error", close); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Page CDP proxy did not bind a TCP port"); + const endpoint = `http://127.0.0.1:${String(address.port)}`; + return { + endpoint, + disconnectClients() { + for (const client of clients) client.terminate(); + }, + async close() { + for (const client of clients) client.terminate(); + webSockets.close(); + await new Promise((resolve, reject) => + server.close((error) => + error === undefined ? resolve() : reject(error), + ), + ); + }, + }; +} + +async function pageScopedVersion(upstreamEndpoint, version) { + const targets = await upstreamJson(upstreamEndpoint, "/json/list"); + const page = targets.find( + (target) => + target.type === "page" && typeof target.webSocketDebuggerUrl === "string", + ); + if (page === undefined) throw new Error("Chrome exposed no page CDP socket"); + return { ...version, webSocketDebuggerUrl: page.webSocketDebuggerUrl }; +} + +function rewriteWebSocketEndpoints(value, server) { + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Page CDP proxy has no TCP address"); + const rewrite = (entry) => { + if (typeof entry.webSocketDebuggerUrl !== "string") return entry; + const url = new URL(entry.webSocketDebuggerUrl); + url.hostname = "127.0.0.1"; + url.port = String(address.port); + return { ...entry, webSocketDebuggerUrl: url.href }; + }; + return Array.isArray(value) ? value.map(rewrite) : rewrite(value); +} + +async function upstreamJson(endpoint, path) { + const response = await fetch(new URL(path, endpoint)); + if (!response.ok) + throw new Error(`Upstream CDP ${path} returned ${response.status}`); + return await response.json(); +} diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs index ad08e70b6..7bf8fe197 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,11 +32,15 @@ const executable = await browserExecutable(); const profile = await mkdtemp(join(tmpdir(), "rea-real-browser-")); const site = await startBrowserVerifierSite(); let browser; +let pageProxy; try { browser = spawn( executable, [ "--headless=new", + ...(process.env.REA_BROWSER_NO_SANDBOX === "true" + ? ["--no-sandbox"] + : []), "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=0", `--user-data-dir=${profile}`, @@ -43,6 +48,7 @@ try { "--no-default-browser-check", "--disable-background-networking", "--disable-component-update", + "--disable-dev-shm-usage", "--disable-sync", "--metrics-recording-only", `${site.origin}/app?startup=browser-secret-value`, @@ -191,6 +197,9 @@ try { ) throw new Error("Real Chrome same-origin SPA timeline was missing"); + pageProxy = await startPageCdpProxy(endpoint); + await verifyPageScopedTransport(provider, pageProxy, site.origin); + process.stdout.write( `${JSON.stringify({ browser: observed.value.browser.product, @@ -205,11 +214,13 @@ try { bundleScripts: bundle.value.capture.scripts_analyzed, sourceMaps: bundle.value.observations.source_maps.processed, sessionEvents: session.value.timeline.length, + pageScopedTransport: true, screenshotBytes: screenshot.value.artifact.bytes, verified: true, })}\n`, ); } finally { + if (pageProxy !== undefined) await pageProxy.close(); if (browser !== undefined) await stopProcess(browser); await site.close(); await rm(profile, { @@ -220,6 +231,61 @@ try { }); } +async function verifyPageScopedTransport(provider, proxy, origin) { + const target = await pageTarget(provider, proxy.endpoint, origin); + const input = inspectWebPageInputSchema.parse({ + cdp_endpoint: proxy.endpoint, + allowed_origins: [origin], + approved: true, + target_id: target, + observation_ms: 100, + }); + const observed = await provider.inspectPage(input); + if (!observed.ok) throw observed.error; + if (observed.value.dom.nodes.length < 1) + throw new Error("Page-scoped CDP transport returned no DOM nodes"); + + const controller = new AbortController(); + const cancelledPromise = provider.inspectPage( + { ...input, observation_ms: 5_000 }, + { signal: controller.signal }, + ); + await delay(100); + controller.abort(); + const cancelled = await cancelledPromise; + if ( + cancelled.ok || + cancelled.error._tag !== "AnalysisCancelledError" || + cancelled.error.operation !== "inspect_web_page" + ) + throw new Error( + "Page-scoped CDP cancellation lost its operation semantics", + ); + + const disconnectedPromise = provider.observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: proxy.endpoint, + allowed_origins: [origin], + approved: true, + target_id: target, + observation_ms: 5_000, + }), + ); + await delay(100); + proxy.disconnectClients(); + const disconnected = await disconnectedPromise; + if ( + !disconnected.ok || + disconnected.value.window.end_reason !== "target_terminated" || + !disconnected.value.timeline.some( + ({ type }) => type === "target_terminated", + ) + ) + throw new Error( + "Page-scoped CDP disconnect was not reported as target_terminated", + ); +} + async function browserExecutable() { const candidates = [ process.env.REA_BROWSER_EXECUTABLE, @@ -247,7 +313,7 @@ async function browserExecutable() { async function devtoolsPort(profile, child, stderr) { const activePort = join(profile, "DevToolsActivePort"); - for (let attempt = 0; attempt < 200; attempt += 1) { + for (let attempt = 0; attempt < 600; attempt += 1) { if (child.exitCode !== null) throw new Error(`Chrome exited before CDP startup: ${stderr()}`); try { 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..602e9f7a5 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,26 @@ 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, + operation, discovery, target, input, @@ -354,11 +338,8 @@ export class CdpBrowserProvider implements BrowserObservationPort { : { progress: options.progress }), }); } finally { - if (connection !== undefined) { - if (sessionId !== undefined) - await bestEffortCleanup(connection, sessionId); - await connection.close(); - } + if (targetSession !== undefined) + await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS); } } } @@ -369,11 +350,13 @@ const selectTargets = ( ): { readonly allowed: readonly BrowserTargetList["targets"]["items"][number][]; readonly excluded: BrowserTargetList["excluded"]; + readonly unconnectable: number; } => { const allowed = []; let disallowedOrigin = 0; let unsupportedUrl = 0; let nonPage = 0; + let unconnectable = 0; for (const target of discovery.targets) { if (target.type !== "page") { nonPage += 1; @@ -388,6 +371,10 @@ const selectTargets = ( disallowedOrigin += 1; continue; } + if (!hasCdpTargetWebSocket(discovery, target)) { + unconnectable += 1; + continue; + } allowed.push({ target_id: target.id, type: target.type, @@ -405,6 +392,7 @@ const selectTargets = ( unsupported_url: unsupportedUrl, non_page: nonPage, }, + unconnectable, }; }; @@ -427,37 +415,6 @@ const authorizeTarget = ( return target; }; -const attachedSessionId = (value: unknown): string => { - if (typeof value !== "object" || value === null || !("sessionId" in value)) - throw new BrowserObservationError("inspect_web_page", "protocol_error"); - if (typeof value.sessionId !== "string" || value.sessionId.length > 256) - throw new BrowserObservationError("inspect_web_page", "protocol_error"); - return value.sessionId; -}; - -const bestEffortCleanup = async ( - connection: CdpConnection, - sessionId: string, -): Promise => { - 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/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 fc4d069b8..667bb6344 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,34 +26,38 @@ const COMMAND_TIMEOUT_MS = 5_000; const MAX_PAYLOAD_BYTES = 16 * 1_024 * 1_024; const MAX_PENDING_COMMANDS = 128; -/** Correlated, bounded JSON-RPC transport for a browser-level CDP socket. */ +/** Correlated, bounded JSON-RPC transport for one CDP target operation. */ export class CdpConnection { readonly #pending = new Map(); readonly #listeners = new Set<(event: CdpEvent) => void>(); + readonly #disconnectListeners = new Set<() => void>(); #nextId = 1; #closed = false; #protocolFailed = false; - private constructor(private readonly socket: WebSocket) { + private constructor( + private readonly socket: WebSocket, + private readonly operation: BrowserObservationOperation, + ) { socket.on("message", (data) => this.#receive(data)); - socket.on("close", () => this.#failPending("disconnected")); - socket.on("error", () => this.#failPending("disconnected")); + socket.on("close", () => this.#disconnect()); + socket.on("error", () => this.#disconnect()); } - /** Connect to one already-validated loopback browser WebSocket. */ + /** Connect to one already-validated loopback CDP WebSocket. */ static async connect( url: string, + operation: BrowserObservationOperation, signal?: AbortSignal, ): Promise { - if (signal?.aborted === true) - throw new AnalysisCancelledError("inspect_web_page"); + if (signal?.aborted === true) throw new AnalysisCancelledError(operation); const socket = new WebSocket(url, { handshakeTimeout: CONNECT_TIMEOUT_MS, maxPayload: MAX_PAYLOAD_BYTES, perMessageDeflate: false, }); - await waitForOpen(socket, signal); - return new CdpConnection(socket); + await waitForOpen(socket, operation, signal); + return new CdpConnection(socket, operation); } /** Subscribe to validated CDP event envelopes. */ @@ -61,6 +66,16 @@ export class CdpConnection { return () => this.#listeners.delete(listener); } + /** Subscribe to an unexpected transport loss while an operation is active. */ + onDisconnect(listener: () => void): () => void { + if (this.#closed) { + listener(); + return () => undefined; + } + this.#disconnectListeners.add(listener); + return () => this.#disconnectListeners.delete(listener); + } + /** Execute one bounded command, optionally within a flat target session. */ async send( method: string, @@ -69,13 +84,13 @@ export class CdpConnection { signal?: AbortSignal, ): Promise { if (this.#protocolFailed) - throw new BrowserObservationError("inspect_web_page", "protocol_error"); + throw new BrowserObservationError(this.operation, "protocol_error"); if (this.#closed || this.socket.readyState !== WebSocket.OPEN) - throw new BrowserObservationError("inspect_web_page", "disconnected"); + throw new BrowserObservationError(this.operation, "disconnected"); if (this.#pending.size >= MAX_PENDING_COMMANDS) - throw new BrowserObservationError("inspect_web_page", "payload_limit"); + throw new BrowserObservationError(this.operation, "payload_limit"); if (signal?.aborted === true) - throw new AnalysisCancelledError("inspect_web_page"); + throw new AnalysisCancelledError(this.operation); const id = this.#nextId; this.#nextId += 1; return await new Promise((resolve, reject) => { @@ -85,15 +100,13 @@ export class CdpConnection { clearTimeout(pending.timer); pending.removeAbort(); this.#pending.delete(id); - reject(new AnalysisCancelledError("inspect_web_page")); + reject(new AnalysisCancelledError(this.operation)); }; signal?.addEventListener("abort", onAbort, { once: true }); const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); this.#pending.delete(id); - reject( - new AnalysisTimeoutError("inspect_web_page", COMMAND_TIMEOUT_MS), - ); + reject(new AnalysisTimeoutError(this.operation, COMMAND_TIMEOUT_MS)); }, COMMAND_TIMEOUT_MS); this.#pending.set(id, { resolve, @@ -106,7 +119,7 @@ export class CdpConnection { id, method, params, - ...(sessionId ? { sessionId } : {}), + ...(sessionId === undefined ? {} : { sessionId }), }), (error) => { if (error === undefined || error === null) return; @@ -114,7 +127,7 @@ export class CdpConnection { if (pending === undefined) return; this.#complete(id, pending); reject( - new BrowserObservationError("inspect_web_page", "disconnected", { + new BrowserObservationError(this.operation, "disconnected", { cause: error, }), ); @@ -191,7 +204,7 @@ export class CdpConnection { this.#complete(id, pending); if ("error" in message) { pending.reject( - new BrowserObservationError("inspect_web_page", "protocol_error"), + new BrowserObservationError(this.operation, "protocol_error"), ); return; } @@ -209,9 +222,17 @@ export class CdpConnection { else this.#protocolFailed = true; for (const [id, pending] of this.#pending) { this.#complete(id, pending); - pending.reject(new BrowserObservationError("inspect_web_page", reason)); + pending.reject(new BrowserObservationError(this.operation, reason)); } } + + #disconnect(): void { + const wasClosed = this.#closed; + this.#failPending("disconnected"); + if (wasClosed) return; + for (const listener of this.#disconnectListeners) listener(); + this.#disconnectListeners.clear(); + } } const rawText = (data: RawData): string => @@ -226,13 +247,14 @@ const isRecord = (value: unknown): value is Record => 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 +264,15 @@ const waitForOpen = async ( cleanup(); reject( signal?.aborted === true - ? new AnalysisCancelledError("inspect_web_page") - : new BrowserObservationError( - "inspect_web_page", - "endpoint_unreachable", - { cause }, - ), + ? new AnalysisCancelledError(operation) + : new BrowserObservationError(operation, "endpoint_unreachable", { + cause, + }), ); }; const onAbort = (): void => { terminateSocket(socket); - onFailure(new AnalysisCancelledError("inspect_web_page")); + onFailure(new AnalysisCancelledError(operation)); }; const cleanup = (): void => { clearTimeout(timer); diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index 9aa0e7172..adf84e50f 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; @@ -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 d779ca5b5..da400e630 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, @@ -46,13 +54,14 @@ export class CdpElectronProvider implements ElectronObservationPort { const roots = await canonicalElectronRoots(input.allowed_file_roots); const discovery = await discoverCdpEndpoint( input.cdp_endpoint, - "list_browser_targets", + "list_electron_targets", options.signal, ); const allowed = []; let outsideRoot = 0; let unsupportedUrl = 0; let nonPage = 0; + let unconnectable = 0; for (const target of discovery.targets) { if (target.type !== "page") { nonPage += 1; @@ -64,6 +73,10 @@ export class CdpElectronProvider implements ElectronObservationPort { else unsupportedUrl += 1; continue; } + if (!hasCdpTargetWebSocket(discovery, target)) { + unconnectable += 1; + continue; + } allowed.push({ target_id: target.id, type: target.type, @@ -96,6 +109,11 @@ export class CdpElectronProvider implements ElectronObservationPort { }, limitations: [ "Only page targets whose canonical file path is contained by an approved root are listed.", + ...(unconnectable === 0 + ? [] + : [ + `${String(unconnectable)} otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.`, + ]), ], }), ); @@ -108,13 +126,12 @@ export class CdpElectronProvider implements ElectronObservationPort { input: InspectElectronPageInput, options: ExecutionOptions = {}, ): Promise> { - 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..4aa55b8df 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. */ +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..ff5592535 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"; @@ -20,7 +23,7 @@ import { mainFrameUrl } from "./CdpCaptureDocuments.js"; interface ObservationContext { readonly connection: CdpConnection; - readonly sessionId: string; + readonly sessionId: string | undefined; readonly discovery: CdpEndpointDiscovery; readonly target: CdpEndpointTarget; readonly input: ObserveWebSessionInput; @@ -48,8 +51,12 @@ export const observeCdpSession = async ( initialUrl, ); const removeListener = context.connection.onEvent((event) => { - if (event.sessionId === context.sessionId) capture.ingest(event); + if (observationEventMatches(event, context.sessionId)) + capture.ingest(event); }); + const removeDisconnectListener = context.connection.onDisconnect(() => + capture.targetTerminated(), + ); try { await context.connection.send( "Page.enable", @@ -115,6 +122,7 @@ export const observeCdpSession = async ( }; } finally { removeListener(); + removeDisconnectListener(); } }; @@ -162,8 +170,7 @@ class TimelineCapture { break; case "Inspector.targetCrashed": case "Target.detachedFromTarget": - this.#add("target_terminated", params, null, null, "target_terminated"); - this.end("target_terminated"); + this.targetTerminated(); break; } } @@ -181,6 +188,12 @@ class TimelineCapture { this.#listeners.clear(); } + targetTerminated(): void { + if (this.#endReason !== undefined) return; + this.#add("target_terminated", {}, null, null, "target_terminated"); + this.end("target_terminated"); + } + setFinalUrl(rawUrl: string): void { const destination = scopedUrl(rawUrl, this.allowedOrigins); this.finalUrl = destination.scope === "approved" ? destination.url : null; @@ -300,6 +313,18 @@ class TimelineCapture { } } +const observationEventMatches = ( + event: CdpEvent, + sessionId: string | undefined, +): boolean => { + if (event.method !== "Target.detachedFromTarget") + return event.sessionId === sessionId; + return ( + sessionId !== undefined && + stringValue(recordValue(event.params)?.sessionId) === sessionId + ); +}; + const waitForWindow = async ( durationMs: number, capture: TimelineCapture, @@ -321,7 +346,7 @@ const waitForWindow = async ( settled = true; clearTimeout(timer); removeEnd(); - reject(signal?.reason ?? new Error("browser observation cancelled")); + reject(new AnalysisCancelledError("observe_web_session")); }; const timer = setTimeout(() => finish("window_elapsed"), durationMs); removeEnd = capture.onEnd(finish); @@ -347,7 +372,7 @@ const authorizedFrame = async ( "inspect_web_page", "target_not_allowed", ); - await delayWithCancellation(25, context.signal); + await delayWithCancellation(25, "observe_web_session", context.signal); } throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); }; diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index 45418e704..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, @@ -37,7 +40,11 @@ import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js"; interface CaptureContext { readonly connection: CdpConnection; - readonly sessionId: string; + readonly sessionId: string | undefined; + readonly operation: Extract< + BrowserObservationOperation, + "inspect_web_page" | "analyze_web_bundle" + >; readonly discovery: CdpEndpointDiscovery; readonly target: CdpEndpointTarget; readonly input: InspectWebPageInput; @@ -225,7 +232,11 @@ const authorizeObservationWindow = async ( events.beginAuthorizedFrame(mainFrame.frame_id); await enableObservationDomains(connection, sessionId, signal); await report(context.progress, 2, "Observing page events"); - await delayWithCancellation(context.input.observation_ms, signal); + await delayWithCancellation( + context.input.observation_ms, + context.operation, + signal, + ); if (events.originViolation) throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); }; @@ -323,14 +334,14 @@ const authorizedFrameTree = async ( "inspect_web_page", "target_not_allowed", ); - await delayWithCancellation(25, context.signal); + await delayWithCancellation(25, context.operation, context.signal); } throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); }; const enableObservationDomains = async ( connection: CdpConnection, - sessionId: string, + sessionId: string | undefined, signal?: AbortSignal, ): Promise => { 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..5567c7890 --- /dev/null +++ b/src/browser/CdpTargetSession.ts @@ -0,0 +1,81 @@ +import { + BrowserObservationError, + type BrowserObservationOperation, +} from "../domain/errors.js"; +import { CdpConnection } from "./CdpConnection.js"; +import { + cdpTargetWebSocket, + type CdpEndpointDiscovery, + type CdpEndpointTarget, +} from "./CdpEndpoint.js"; + +/** One CDP connection scoped either by a flat browser session or page socket. */ +export interface CdpTargetSession { + readonly connection: CdpConnection; + readonly sessionId: string | undefined; +} + +/** Open one authorized target through its supported browser or page transport. */ +export const openCdpTargetSession = async ( + discovery: CdpEndpointDiscovery, + target: CdpEndpointTarget, + operation: BrowserObservationOperation, + signal?: AbortSignal, +): Promise => { + const webSocket = cdpTargetWebSocket(discovery, target, operation); + const connection = await CdpConnection.connect( + webSocket.url, + operation, + signal, + ); + if (webSocket.scope === "page") return { connection, sessionId: undefined }; + try { + const attached = await connection.send( + "Target.attachToTarget", + { targetId: target.id, flatten: true }, + undefined, + signal, + ); + return { connection, sessionId: attachedSessionId(attached, operation) }; + } catch (cause: unknown) { + await connection.close(); + throw cause; + } +}; + +/** Disable enabled domains, detach browser sessions, and close REA's socket. */ +export const closeCdpTargetSession = async ( + targetSession: CdpTargetSession, + enabledDomains: readonly string[], +): Promise => { + 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..d0bbbdf61 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; @@ -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/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..3a8ce3a5e 100644 --- a/tests/cdpBrowserProvider.test.ts +++ b/tests/cdpBrowserProvider.test.ts @@ -53,6 +53,159 @@ describe("CdpBrowserProvider", () => { expect(JSON.stringify(result.value)).not.toContain("page-secret"); }); + it("uses page-scoped discovery sockets as direct target transports", async () => { + const browser = await startFakeCdpBrowser({ + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + additionalPageWithWebSocket: true, + additionalPageWithoutWebSocket: true, + }); + browsers.push(browser); + const provider = new CdpBrowserProvider(); + const listed = await provider.listTargets( + listBrowserTargetsInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + approved: true, + }), + ); + if (!listed.ok) throw listed.error; + expect( + listed.value.targets.items.map(({ target_id }) => target_id), + ).toEqual(["allowed-page", "allowed-page-with-socket"]); + expect(listed.value.limitations).toContain( + "1 otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.", + ); + + const inspected = await provider.inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + if (!inspected.ok) throw inspected.error; + expect(inspected.value.target.target_id).toBe("allowed-page"); + expect(inspected.value.network.requests).toHaveLength(1); + + const second = await provider.inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page-with-socket", + approved: true, + observation_ms: 0, + }), + ); + if (!second.ok) throw second.error; + expect(second.value.target.target_id).toBe("allowed-page-with-socket"); + const methods = browser.commands.map(({ method }) => method); + expect(methods).not.toContain("Target.attachToTarget"); + expect(methods).not.toContain("Target.detachFromTarget"); + expect( + browser.commands.every(({ sessionId }) => sessionId === undefined), + ).toBe(true); + }); + + it("projects direct-session cancellation onto the requested operation", async () => { + const browser = await startFakeCdpBrowser({ + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + }); + browsers.push(browser); + const controller = new AbortController(); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 1_000, + }), + { + signal: controller.signal, + progress: { + report(update) { + if (update.completed === 1) controller.abort(); + return Promise.resolve(); + }, + }, + }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "observe_web_session", + }, + }); + const methods = browser.commands.map(({ method }) => method); + expect(methods).not.toContain("Target.attachToTarget"); + expect(methods).not.toContain("Target.detachFromTarget"); + expect(methods).not.toContain("Target.closeTarget"); + expect(methods).not.toContain("Browser.close"); + }); + + it("projects capture-window cancellation onto bundle analysis", async () => { + const browser = await startFakeCdpBrowser({ + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + }); + browsers.push(browser); + const controller = new AbortController(); + const result = await new CdpBrowserProvider().analyzeBundle( + analyzeWebBundleInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + source_capture_approved: true, + observation_ms: 1_000, + }), + { + signal: controller.signal, + progress: { + report(update) { + if (update.completed === 2) controller.abort(); + return Promise.resolve(); + }, + }, + }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "analyze_web_bundle", + }, + }); + }); + + it("rejects empty browser attachment session identifiers", async () => { + const browser = await startFakeCdpBrowser({ invalidAttachedSession: true }); + browsers.push(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "protocol_error" }, + }); + expect(browser.commands.map(({ method }) => method)).toEqual([ + "Target.attachToTarget", + ]); + }); + it("captures bounded passive evidence without retaining sensitive values", async () => { const browser = await startFakeCdpBrowser({ binaryWebSocketEvent: true, @@ -567,6 +720,58 @@ describe("CdpBrowserProvider", () => { ); }); + it("ends a browser session when its flat target session detaches", async () => { + const browser = await startFakeCdpBrowser({ + sessionTimeline: "target_detached", + }); + browsers.push(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 1_000, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("target_terminated"); + expect(result.value.timeline.at(-1)).toMatchObject({ + type: "target_terminated", + detail: "target_terminated", + }); + }); + + it("ends a direct page session when its transport disconnects", async () => { + const browser = await startFakeCdpBrowser({ + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + closeAfterMethod: "Page.setLifecycleEventsEnabled", + }); + browsers.push(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 1_000, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("target_terminated"); + expect(result.value.timeline.at(-1)).toMatchObject({ + type: "target_terminated", + detail: "target_terminated", + }); + const methods = browser.commands.map(({ method }) => method); + expect(methods).not.toContain("Target.attachToTarget"); + expect(methods).not.toContain("Target.detachFromTarget"); + expect(methods).not.toContain("Target.closeTarget"); + }); + it("discovers untrusted WebMCP declarations without registering or invoking them", async () => { const browser = await startFakeCdpBrowser({ webMcpTools: true }); browsers.push(browser); @@ -1121,7 +1326,10 @@ describe("CdpBrowserProvider", () => { const result = await pending; expect(result).toMatchObject({ ok: false, - error: { _tag: "AnalysisCancelledError" }, + error: { + _tag: "AnalysisCancelledError", + operation: "inspect_web_page", + }, }); const methods = browser.commands.map(({ method }) => method); expect(methods).toContain("Target.detachFromTarget"); 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 98cac969b..ddeac1c1b 100644 --- a/tests/cdpElectronProvider.test.ts +++ b/tests/cdpElectronProvider.test.ts @@ -114,6 +114,56 @@ describe("CdpElectronProvider", () => { }); }); + it("inspects a page-scoped Electron endpoint without browser attachment", async () => { + const root = await electronFixture(); + const index = join(root, "index.html"); + const browser = await startFakeCdpBrowser({ + electronFileUrl: pathToFileURL(index).href, + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + }); + browsers.push(browser); + const result = await new CdpElectronProvider().inspectPage( + inspectElectronPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_file_roots: [root], + target_id: "electron-page", + approved: true, + observation_ms: 0, + }), + ); + if (!result.ok) throw result.error; + expect(result.value.target.file_path).toBe(index); + expect(browser.commands.map(({ method }) => method)).not.toContain( + "Target.attachToTarget", + ); + expect( + browser.commands.every(({ sessionId }) => sessionId === undefined), + ).toBe(true); + }); + + it("maps cancelled Electron discovery to the Electron operation", async () => { + const root = await electronFixture(); + const controller = new AbortController(); + controller.abort(); + const result = await new CdpElectronProvider().listTargets( + listElectronTargetsInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_file_roots: [root], + approved: true, + }), + { signal: controller.signal }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "list_electron_targets", + }, + }); + }); + it("captures Electron script source only after separate approval", async () => { const root = await electronFixture(); const browser = await startFakeCdpBrowser({ 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..25f6c5612 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; @@ -46,7 +51,11 @@ interface FakeOptions { readonly binaryWebSocketEvent?: boolean; readonly invalidBinaryWebSocketEvent?: boolean; readonly sourceMapBody?: string; - readonly sessionTimeline?: "same_origin" | "outside_policy"; + readonly sessionTimeline?: + | "same_origin" + | "outside_policy" + | "target_detached"; + readonly closeAfterMethod?: string; readonly sensitiveShapes?: boolean; readonly invalidResponseBodyBase64?: boolean; readonly webMcpTools?: boolean; @@ -98,8 +107,10 @@ export const startFakeCdpBrowser = async ( "WebKit-Version": "fake-revision", webSocketDebuggerUrl: options.invalidBrowserWebSocket === true - ? `ws://localhost:${String(port)}/devtools/page/not-browser` - : `ws://localhost:${String(port)}/devtools/browser/fake`, + ? `ws://localhost:${String(port)}/devtools/invalid/fake` + : options.pageScopedVersionWebSocket === true + ? `ws://localhost:${String(port)}/devtools/page/${versionTargetId(options)}` + : `ws://localhost:${String(port)}/devtools/browser/fake`, }), ); return; @@ -113,7 +124,12 @@ export const startFakeCdpBrowser = async ( }); const webSockets = new WebSocketServer({ noServer: true }); http.on("upgrade", (request, socket, head) => { - if (request.url !== "/devtools/browser/fake") { + if ( + request.url !== "/devtools/browser/fake" && + request.url !== "/devtools/page/allowed-page" && + request.url !== "/devtools/page/allowed-page-with-socket" && + request.url !== "/devtools/page/electron-page" + ) { socket.destroy(); return; } @@ -122,7 +138,7 @@ export const startFakeCdpBrowser = async ( ); }); webSockets.on("connection", (socket: WebSocket, request: IncomingMessage) => { - void request; + const directPage = request.url?.startsWith("/devtools/page/") === true; sockets.add(socket); socket.on("close", () => sockets.delete(socket)); socket.on("message", (raw) => { @@ -146,6 +162,19 @@ export const startFakeCdpBrowser = async ( ); return; } + if ( + directPage && + (command.method === "Target.attachToTarget" || + command.sessionId !== undefined) + ) { + socket.send( + JSON.stringify({ + id: command.id, + error: { code: -32_600, message: "Direct page socket expected" }, + }), + ); + return; + } if (command.method === "Page.getFrameTree") frameTreeReads += 1; socket.send( JSON.stringify({ @@ -158,6 +187,8 @@ export const startFakeCdpBrowser = async ( if (options.malformedEventShapeOnMethod === command.method) socket.send(JSON.stringify({ method: 42 })); emitEvents(socket, command, port, options); + if (options.closeAfterMethod === command.method) + setImmediate(() => socket.close()); }); }); await new Promise((resolve, reject) => { @@ -197,7 +228,11 @@ const targets = ( title: "Inspectable application", url: `http://127.0.0.1:${String(port)}/app?token=page-secret#fragment`, attached: false, - webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page`, + ...(options.omitTargetWebSocket === true + ? {} + : { + webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page`, + }), }, { id: "disallowed-page", @@ -206,6 +241,29 @@ const targets = ( url: "https://private.example.test/app?token=forbidden", attached: false, }, + ...(options.additionalPageWithWebSocket === true + ? [ + { + id: "allowed-page-with-socket", + type: "page", + title: "Second inspectable application page", + url: `http://127.0.0.1:${String(port)}/second`, + attached: false, + webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page-with-socket`, + }, + ] + : []), + ...(options.additionalPageWithoutWebSocket === true + ? [ + { + id: "allowed-page-without-socket", + type: "page", + title: "Unavailable application page", + url: `http://127.0.0.1:${String(port)}/unavailable`, + attached: false, + }, + ] + : []), { id: "unsupported-page", type: "page", @@ -254,6 +312,11 @@ const targets = ( title: "Electron application", url: options.electronFileUrl, attached: false, + ...(options.omitTargetWebSocket === true + ? {} + : { + webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/electron-page`, + }), }, ]), ]; @@ -289,7 +352,7 @@ const resultFor = ( ): Readonly> => { switch (command.method) { case "Target.attachToTarget": - return { sessionId: "session-1" }; + return { sessionId: options.invalidAttachedSession ? "" : "session-1" }; case "Page.getFrameTree": return frameTree( port, @@ -366,6 +429,9 @@ const resultFor = ( } }; +const versionTargetId = (options: FakeOptions): string => + options.electronFileUrl === undefined ? "allowed-page" : "electron-page"; + const endpointTargetToInfo = ( target: Readonly>, ): Readonly> => ({ @@ -903,6 +969,11 @@ const emitEvents = ( name: "networkIdle", timestamp: 15, }); + if (options.sessionTimeline === "target_detached") + event(socket, "Target.detachedFromTarget", undefined, { + sessionId: command.sessionId, + targetId: "allowed-page", + }); } };