diff --git a/docs/browser-observation.md b/docs/browser-observation.md index dca1f994b..72dde900b 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -107,6 +107,47 @@ first navigation. For a dedicated investigation profile, add then describe an unloaded page; REA continues to require an allowed, committed URL. See [the startup investigation](https://github.com/morluto/rea/issues/1384#issuecomment-6093781686). +### Browsers without HTTP discovery + +Some browsers expose a loopback browser WebSocket but no `/json/version` or +`/json/list`. Chrome 144 and later can enable remote debugging on a running +browser from `chrome://inspect/#remote-debugging`; this mode is reported to +serve only the browser socket. Since Chrome 136, `--remote-debugging-port` is +ignored for the default profile, so this is the remaining way to inspect a page +in an existing profile. + +For such a browser, pass the browser socket instead of the HTTP endpoint: + +```bash +rea list-browser-targets ws://127.0.0.1:9222/devtools/browser/ +``` + +Chrome writes the port and the `/devtools/browser/` path to the +`DevToolsActivePort` file in its user data directory. REA does not read that +file for you. + +- Only `list_browser_targets`, `inspect_web_page`, `analyze_web_bundle`, + `observe_web_session`, `discover_webmcp_tools`, and `capture_web_screenshot` + accept a browser socket. Website runtime attribution, Electron inspection, + and scenario attach mode still require HTTP discovery. +- Each call opens one WebSocket and uses it for discovery + (`Browser.getVersion`, `Target.getTargets`) and for the selected page. + Discovery must finish within the same 10-second deadline as HTTP discovery. + Chrome may ask you to approve each connection. +- The browser identifier controls access to the socket. Evidence records the + endpoint as `ws://127.0.0.1:9222/devtools/browser/[REDACTED]`. +- `browser.revision` is the socket's `Browser.getVersion` value, such as + `@e533e98…`. HTTP discovery reports `WebKit-Version`, such as + `537.36 (@e533e98…)`. +- `Target.getTargets` reports whether each target is attached. Chrome's + `/json/list` can omit it, so HTTP discovery may report `attached` as `null`. +- An existing profile can hold many unrelated pages. Target listing returns + each eligible page's URL and title, so pass `--allowed-origins` to list only + the origins you are investigating. + +A browser driven only through `--remote-debugging-pipe` or a vendor's private +automation channel has neither endpoint, and REA cannot attach to it. + ## Request boundary CDP connections enforce the shared 64 MiB limit per WebSocket message. A larger @@ -116,9 +157,13 @@ decoded strings or JSON objects. Discovery supplies a literal loopback CDP endpoint and returns all eligible page targets unless an exact HTTP(S) origin filter narrows the result. Follow-up -inspection requests select one target; their default scope is its current origin. `localhost`, private-LAN -addresses, HTTPS CDP endpoints, credentials, paths, queries, fragments, and -implicit ports are not valid CDP endpoints. Origin filters contain only scheme, +inspection requests select one target; their default scope is its current origin. +The endpoint is either `http://127.0.0.1:PORT` or a +[browser socket](#browsers-without-http-discovery), +`ws://127.0.0.1:PORT/devtools/browser/ID`; `[::1]` may replace `127.0.0.1`. +`localhost`, private-LAN addresses, HTTPS and `wss:` endpoints, credentials, +queries, fragments, implicit ports, and any other path are not valid CDP +endpoints. Origin filters contain only scheme, host, and port; paths, credentials, queries, fragments, and wildcards are not valid origins. diff --git a/scripts/lib/browser-socket-e2e.mjs b/scripts/lib/browser-socket-e2e.mjs new file mode 100644 index 000000000..6e54e3d18 --- /dev/null +++ b/scripts/lib/browser-socket-e2e.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { + inspectWebPageInputSchema, + listBrowserTargetsInputSchema, +} from "../../dist/domain/browserObservation.js"; +import { compareWebCapturesInputSchema } from "../../dist/domain/webCaptureDiffSchemas.js"; +import { artifactCliEvidence } from "./artifact-e2e.mjs"; + +/** Verify browser-socket discovery and inspection against HTTP discovery. */ +export async function verifyBrowserSocketTransport({ + provider, + profile, + endpoint, + target, + origin, +}) { + const [port, path] = ( + await readFile(join(profile, "DevToolsActivePort"), "utf8") + ).split("\n"); + const socket = `ws://127.0.0.1:${port}${path}`; + const version = await (await fetch(`${endpoint}/json/version`)).json(); + assert.equal( + new URL(version.webSocketDebuggerUrl).pathname, + path, + "DevToolsActivePort and /json/version named different browser sockets", + ); + const list = async (cdpEndpoint) => { + const listed = await provider.listTargets( + listBrowserTargetsInputSchema.parse({ + cdp_endpoint: cdpEndpoint, + allowed_origins: [origin], + }), + ); + if (!listed.ok) throw listed.error; + return listed.value; + }; + const throughHttp = await list(endpoint); + const throughSocket = await list(socket); + // Target.getTargets reports attachment state that /json/list may omit. + const withoutAttachment = (targets) => + targets.map(({ attached: _attached, ...rest }) => rest); + assert.ok(throughSocket.targets.length > 0, "Browser socket listed no page"); + assert.deepEqual( + withoutAttachment(throughSocket.targets), + withoutAttachment(throughHttp.targets), + ); + assert.ok( + throughSocket.targets.every( + ({ attached }) => typeof attached === "boolean", + ), + "Browser socket omitted target attachment state", + ); + assert.equal(throughSocket.browser.product, throughHttp.browser.product); + + const evidence = await artifactCliEvidence("inspect-web-page", socket, [ + target, + "--allowed-origins", + origin, + "--observation-ms", + "100", + ]); + assert.equal(evidence.normalized_result.target.target_id, target); + assert.ok( + evidence.normalized_result.dom.nodes.length > 0, + "Browser-socket inspection returned no DOM nodes", + ); + assert.equal( + evidence.parameters.cdp_endpoint, + `ws://127.0.0.1:${port}/devtools/browser/[REDACTED]`, + ); + assert.ok( + !JSON.stringify(evidence).includes(path), + "Browser-socket Evidence retained the socket identifier", + ); + const throughHttpInspection = await provider.inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: endpoint, + allowed_origins: [origin], + target_id: target, + observation_ms: 100, + }), + ); + if (!throughHttpInspection.ok) throw throughHttpInspection.error; + const compared = await provider.compareCaptures( + compareWebCapturesInputSchema.parse({ + before: { inspection: throughHttpInspection.value }, + after: { inspection: evidence.normalized_result }, + }), + ); + if (!compared.ok) throw compared.error; + assert.equal(compared.value.dimensions.dom_structure.status, "unchanged"); + assert.equal(compared.value.dimensions.scripts.status, "unchanged"); + return { + targets: throughSocket.targets.length, + revision: throughSocket.browser.revision, + }; +} diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs index 27c0c5e52..158877bca 100644 --- a/scripts/verify-real-browser.mjs +++ b/scripts/verify-real-browser.mjs @@ -45,6 +45,7 @@ import { verifyBrowserNetworkEvidence } from "./lib/browser-network-e2e.mjs"; import { verifyBrowserScriptExport } from "./lib/browser-script-export-e2e.mjs"; import { verifyBrowserModules } from "./lib/browser-module-e2e.mjs"; import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e.mjs"; +import { verifyBrowserSocketTransport } from "./lib/browser-socket-e2e.mjs"; import { verifyBrowserCaptureMetadataBudget } from "./lib/browser-capture-metadata-budget-e2e.mjs"; import { artifactCliEvidence, artifactMcpResult } from "./lib/artifact-e2e.mjs"; import { verifyScenarioEnvironment } from "./lib/browser-scenario-environment-e2e.mjs"; @@ -234,6 +235,13 @@ try { pageProxy = await startPageCdpProxy(endpoint); await verifyPageScopedTransport(provider, pageProxy, site.origin); + const browserSocketTransport = await verifyBrowserSocketTransport({ + provider, + profile, + endpoint, + target, + origin: site.origin, + }); const attachedScenarioInput = browserScenario( { mode: "connect", @@ -377,6 +385,7 @@ try { bundle_cli_and_stdio_mcp: true, sessionEvents: session.value.timeline.length, pageScopedTransport: true, + browserSocketTransport, screenshotBytes: screenshot.value.artifact.bytes, largeScreenshot, popupEvents, diff --git a/src/application/BrowserEvidence.ts b/src/application/BrowserEvidence.ts index d864fac9d..495f1f197 100644 --- a/src/application/BrowserEvidence.ts +++ b/src/application/BrowserEvidence.ts @@ -131,7 +131,7 @@ const browserParameters = ( } const observedOrigin = browserResultTargetOrigin(result); const scope = { - cdp_endpoint: input.cdp_endpoint, + cdp_endpoint: input.cdp_endpoint.recorded, allowed_origins: input.allowed_origins.length > 0 ? input.allowed_origins diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index f4d898a68..744a82983 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -49,13 +49,14 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js"; import { err, ok, type Result } from "../domain/result.js"; import { - discoverCdpEndpoint, hasCdpTargetWebSocket, type CdpEndpointDiscovery, type CdpEndpointTarget, } from "./CdpEndpoint.js"; import { + attachCdpTargetSession, closeCdpTargetSession, + openCdpEndpoint, openCdpTargetSession, type CdpTargetSession, } from "./CdpTargetSession.js"; @@ -82,11 +83,13 @@ export class CdpBrowserProvider implements BrowserObservationPort { options: ExecutionOptions = {}, ): Promise> { try { - const discovery = await discoverCdpEndpoint( + const endpoint = await openCdpEndpoint( input.cdp_endpoint, "list_browser_targets", options.signal, ); + await endpoint.connection?.close(); + const { discovery } = endpoint; const selected = selectTargets( discovery, input.allowed_origins.length === 0 @@ -169,45 +172,19 @@ export class CdpBrowserProvider implements BrowserObservationPort { input: ObserveWebSessionInput, options: ExecutionOptions = {}, ): Promise> { - let targetSession: CdpTargetSession | undefined; try { - const discovery = await discoverCdpEndpoint( - input.cdp_endpoint, - "observe_web_session", - options.signal, - ); - const target = authorizeTarget(discovery, input); - const scopedInput = scopeToTargetOrigin(input, target); - targetSession = await openCdpTargetSession( - discovery, - target, - "observe_web_session", - options.signal, - ); return ok( webObservationSessionSchema.parse( - await observeCdpSession({ - connection: targetSession.connection, - sessionId: targetSession.sessionId, - discovery, - target, - input: scopedInput, - ...(options.signal === undefined ? {} : { signal: options.signal }), - ...(options.progress === undefined - ? {} - : { progress: options.progress }), - }), + await withTargetSession( + input, + "observe_web_session", + options, + observeCdpSession, + ), ), ); } catch (cause: unknown) { return err(providerError(cause, "observe_web_session")); - } finally { - if (targetSession !== undefined) - await closeCdpTargetSession( - targetSession, - CLEANUP_DOMAINS, - options.signal, - ); } } @@ -215,45 +192,19 @@ export class CdpBrowserProvider implements BrowserObservationPort { input: DiscoverWebMcpToolsInput, options: ExecutionOptions = {}, ): Promise> { - let targetSession: CdpTargetSession | undefined; try { - const discovery = await discoverCdpEndpoint( - input.cdp_endpoint, - "discover_webmcp_tools", - options.signal, - ); - const target = authorizeTarget(discovery, input); - const scopedInput = scopeToTargetOrigin(input, target); - targetSession = await openCdpTargetSession( - discovery, - target, - "discover_webmcp_tools", - options.signal, - ); return ok( webMcpDiscoverySchema.parse( - await discoverWebMcp({ - connection: targetSession.connection, - sessionId: targetSession.sessionId, - discovery, - target, - input: scopedInput, - ...(options.signal === undefined ? {} : { signal: options.signal }), - ...(options.progress === undefined - ? {} - : { progress: options.progress }), - }), + await withTargetSession( + input, + "discover_webmcp_tools", + options, + discoverWebMcp, + ), ), ); } catch (cause: unknown) { return err(providerError(cause, "discover_webmcp_tools")); - } finally { - if (targetSession !== undefined) - await closeCdpTargetSession( - targetSession, - CLEANUP_DOMAINS, - options.signal, - ); } } @@ -273,45 +224,19 @@ export class CdpBrowserProvider implements BrowserObservationPort { input: CaptureWebScreenshotInput, options: ExecutionOptions = {}, ): Promise> { - let targetSession: CdpTargetSession | undefined; try { - const discovery = await discoverCdpEndpoint( - input.cdp_endpoint, - "capture_web_screenshot", - options.signal, - ); - const target = authorizeTarget(discovery, input); - const scopedInput = scopeToTargetOrigin(input, target); - targetSession = await openCdpTargetSession( - discovery, - target, - "capture_web_screenshot", - options.signal, - ); return ok( webScreenshotSchema.parse( - await captureCdpScreenshot({ - connection: targetSession.connection, - sessionId: targetSession.sessionId, - discovery, - target, - input: scopedInput, - ...(options.signal === undefined ? {} : { signal: options.signal }), - ...(options.progress === undefined - ? {} - : { progress: options.progress }), - }), + await withTargetSession( + input, + "capture_web_screenshot", + options, + captureCdpScreenshot, + ), ), ); } catch (cause: unknown) { return err(providerError(cause, "capture_web_screenshot")); - } finally { - if (targetSession !== undefined) - await closeCdpTargetSession( - targetSession, - CLEANUP_DOMAINS, - options.signal, - ); } } @@ -336,44 +261,83 @@ export class CdpBrowserProvider implements BrowserObservationPort { options: ExecutionOptions, operation: "inspect_web_page" | "analyze_web_bundle", ): Promise { - let targetSession: CdpTargetSession | undefined; - try { - const discovery = await discoverCdpEndpoint( - input.cdp_endpoint, - operation, - options.signal, - ); - const target = authorizeTarget(discovery, input); - const scopedInput = scopeToTargetOrigin(input, target); - targetSession = await openCdpTargetSession( - discovery, - target, - operation, - options.signal, - ); - return await capturePage({ - connection: targetSession.connection, - sessionId: targetSession.sessionId, - operation, - discovery, - target, - input: scopedInput, - ...(options.signal === undefined ? {} : { signal: options.signal }), - ...(options.progress === undefined - ? {} - : { progress: options.progress }), - }); - } finally { - if (targetSession !== undefined) - await closeCdpTargetSession( - targetSession, - CLEANUP_DOMAINS, - options.signal, - ); - } + return await withTargetSession( + input, + operation, + options, + async (session) => await capturePage({ ...session, operation }), + ); } } +/** Facts and transport handed to one target operation. */ +interface CdpTargetOperationSession { + readonly connection: CdpTargetSession["connection"]; + readonly sessionId: CdpTargetSession["sessionId"]; + readonly discovery: CdpEndpointDiscovery; + readonly target: CdpEndpointTarget; + readonly input: Input; + readonly signal?: AbortSignal; + readonly progress?: NonNullable; +} + +/** Authorize one discovered target, run work in its session, and close REA's socket. */ +const withTargetSession = async < + Input extends Pick< + InspectWebPageInput, + "cdp_endpoint" | "target_id" | "allowed_origins" + >, + Output, +>( + input: Input, + operation: BrowserObservationOperation, + options: ExecutionOptions, + work: (session: CdpTargetOperationSession) => Promise, +): Promise => { + const endpoint = await openCdpEndpoint( + input.cdp_endpoint, + operation, + options.signal, + ); + let targetSession: CdpTargetSession | undefined; + try { + const target = authorizeTarget(endpoint.discovery, input); + const scopedInput = scopeToTargetOrigin(input, target); + targetSession = + endpoint.connection === undefined + ? await openCdpTargetSession( + endpoint.discovery, + target, + operation, + options.signal, + ) + : await attachCdpTargetSession( + endpoint.connection, + target, + operation, + options.signal, + ); + return await work({ + connection: targetSession.connection, + sessionId: targetSession.sessionId, + discovery: endpoint.discovery, + target, + input: scopedInput, + ...(options.signal === undefined ? {} : { signal: options.signal }), + ...(options.progress === undefined ? {} : { progress: options.progress }), + }); + } finally { + if (targetSession !== undefined) + await closeCdpTargetSession( + targetSession, + CLEANUP_DOMAINS, + options.signal, + ); + // An unadopted browser socket is closed here; closing is idempotent. + await endpoint.connection?.close(); + } +}; + const selectTargets = ( discovery: CdpEndpointDiscovery, allowedOrigins: ReadonlySet | undefined, diff --git a/src/browser/CdpEndpoint.ts b/src/browser/CdpEndpoint.ts index cf78df183..3db49d28c 100644 --- a/src/browser/CdpEndpoint.ts +++ b/src/browser/CdpEndpoint.ts @@ -9,6 +9,7 @@ import type { BrowserObservationOperation } from "../domain/browserObservationEr import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js"; import { safeParseJson } from "../domain/safeJson.js"; +import type { CdpConnection } from "./CdpConnection.js"; const endpointVersionSchema = z.object({ Browser: z.string().min(1), @@ -40,6 +41,26 @@ interface CdpDiscoveryResponseLimits { readonly timeoutMs: number; } +const browserSocketVersionSchema = z.object({ + protocolVersion: z.string().min(1), + product: z.string().min(1), + revision: z.string(), + userAgent: z.string(), + jsVersion: z.string(), +}); + +const browserSocketTargetsSchema = z.object({ + targetInfos: z.array( + z.object({ + targetId: z.string().min(1), + type: z.string().min(1), + title: z.string().default(""), + url: z.string(), + attached: z.boolean().optional(), + }), + ), +}); + /** Validated direct CDP WebSocket bound to one discovered page target. */ interface CdpPageWebSocketEndpoint { readonly scope: "page"; @@ -124,6 +145,88 @@ export const discoverCdpEndpoint = async ( }; }; +/** + * Run browser-socket discovery work, including its WebSocket handshake, under + * the HTTP discovery deadline. Reaching the deadline is a timeout, not a + * caller cancellation, so the internal abort is not carried as its cause. + */ +export const withCdpDiscoveryDeadline = async ( + operation: BrowserObservationOperation, + signal: AbortSignal | undefined, + work: (deadlineSignal: AbortSignal) => Promise, + timeoutMs: number = CDP_DISCOVERY_RESPONSE_LIMITS.timeoutMs, +): Promise => { + const deadline = new AbortController(); + const timer = setTimeout(() => deadline.abort(), timeoutMs); + try { + return await work( + signal === undefined + ? deadline.signal + : AbortSignal.any([signal, deadline.signal]), + ); + } catch (cause: unknown) { + if (deadline.signal.aborted && signal?.aborted !== true) + throw new BrowserObservationError(operation, "timeout", { + detail: `CDP browser-socket discovery exceeded its ${String(timeoutMs)} ms deadline.`, + }); + throw cause; + } finally { + clearTimeout(timer); + } +}; + +/** + * Read discovery facts over a browser socket that has no HTTP discovery. + * Each reply is bounded by the connection's protocol message limit; callers + * apply the discovery deadline through `signal`. + */ +export const discoverCdpBrowserSocket = async ( + connection: CdpConnection, + url: string, + operation: BrowserObservationOperation, + signal?: AbortSignal, +): Promise => { + const versionInput = await connection.send( + "Browser.getVersion", + {}, + undefined, + signal, + ); + const targetsInput = await connection.send( + "Target.getTargets", + {}, + undefined, + signal, + ); + const version = parseCdpEndpointValue( + browserSocketVersionSchema, + versionInput, + operation, + ); + const targets = parseCdpEndpointValue( + browserSocketTargetsSchema, + targetsInput, + operation, + ); + return { + webSocket: { scope: "browser", url }, + version: { + product: version.product, + protocol_version: version.protocolVersion, + revision: version.revision, + user_agent: version.userAgent, + js_version: version.jsVersion, + }, + targets: targets.targetInfos.map((target) => ({ + id: target.targetId, + type: target.type, + title: sanitizeTargetTitle(target.title, target.url), + url: target.url, + attached: target.attached ?? null, + })), + }; +}; + const sanitizeTargetTitle = (value: string, targetUrl: string): string => { let url: URL; try { diff --git a/src/browser/CdpTargetSession.ts b/src/browser/CdpTargetSession.ts index 3e1f6eb43..7355e142e 100644 --- a/src/browser/CdpTargetSession.ts +++ b/src/browser/CdpTargetSession.ts @@ -1,8 +1,12 @@ +import type { BrowserObservationEndpoint } from "../domain/browserObservation.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js"; import { CdpConnection } from "./CdpConnection.js"; import { cdpTargetWebSocket, + discoverCdpBrowserSocket, + discoverCdpEndpoint, + withCdpDiscoveryDeadline, type CdpEndpointDiscovery, type CdpEndpointTarget, } from "./CdpEndpoint.js"; @@ -13,6 +17,59 @@ export interface CdpTargetSession { readonly sessionId: string | undefined; } +/** Discovery for one caller-selected endpoint and the browser socket that read it. */ +export interface CdpOpenedEndpoint { + readonly discovery: CdpEndpointDiscovery; + /** + * Browser socket kept for the target session, because a browser may ask + * the user to approve each socket. HTTP discovery opens no socket. + */ + readonly connection: CdpConnection | undefined; +} + +/** + * Discover one passive browser endpoint through its selected transport. A + * browser socket's handshake and discovery share one discovery deadline. + */ +export const openCdpEndpoint = async ( + endpoint: BrowserObservationEndpoint, + operation: BrowserObservationOperation, + signal?: AbortSignal, + timeoutMs?: number, +): Promise => { + if (endpoint.transport === "http_discovery") + return { + discovery: await discoverCdpEndpoint(endpoint.url, operation, signal), + connection: undefined, + }; + return await withCdpDiscoveryDeadline( + operation, + signal, + async (deadlineSignal) => { + const connection = await CdpConnection.connect( + endpoint.url, + operation, + deadlineSignal, + ); + try { + return { + discovery: await discoverCdpBrowserSocket( + connection, + endpoint.url, + operation, + deadlineSignal, + ), + connection, + }; + } catch (cause: unknown) { + await connection.close(); + throw cause; + } + }, + timeoutMs, + ); +}; + /** Open one authorized target through its supported browser or page transport. */ export const openCdpTargetSession = async ( discovery: CdpEndpointDiscovery, @@ -29,6 +86,16 @@ export const openCdpTargetSession = async ( limits, ); if (webSocket.scope === "page") return { connection, sessionId: undefined }; + return await attachCdpTargetSession(connection, target, operation, signal); +}; + +/** Attach one target through an open browser socket, which the session then owns. */ +export const attachCdpTargetSession = async ( + connection: CdpConnection, + target: CdpEndpointTarget, + operation: BrowserObservationOperation, + signal?: AbortSignal, +): Promise => { try { const attached = await connection.send( "Target.attachToTarget", diff --git a/src/cli/browserAdvancedCommands.ts b/src/cli/browserAdvancedCommands.ts index 50c9ce446..dc0c268d1 100644 --- a/src/cli/browserAdvancedCommands.ts +++ b/src/cli/browserAdvancedCommands.ts @@ -46,7 +46,9 @@ const registerWebMcp = ( cli.command(CLI_COMMANDS.discoverWebMcpTools, { description: "Passively discover page-declared WebMCP tools", args: z.object({ - endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"), + endpoint: z + .string() + .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), targetId: z.string().describe("Target ID from list-browser-targets"), }), options: z.object({ @@ -150,7 +152,9 @@ const registerScreenshot = ( description: "Capture one visible page viewport within the supplied origin scope", args: z.object({ - endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"), + endpoint: z + .string() + .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), targetId: z.string().describe("Target ID from list-browser-targets"), }), options: z.object({ diff --git a/src/cli/browserCommands.ts b/src/cli/browserCommands.ts index aae6714d2..e9043ec80 100644 --- a/src/cli/browserCommands.ts +++ b/src/cli/browserCommands.ts @@ -42,7 +42,9 @@ const registerTargetList = ( description: "List page targets from a selected loopback CDP browser, optionally filtered by origin", args: z.object({ - endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"), + endpoint: z + .string() + .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), }), options: z.object({ ...browserScopeOptions }), run: ({ args, options }) => @@ -75,7 +77,9 @@ const registerPageInspection = ( description: "Passively inspect one page through CDP, defaulting to its current origin", args: z.object({ - endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"), + endpoint: z + .string() + .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), targetId: z.string().describe("Target ID from list-browser-targets"), }), options: browserPageInspectionOptions, @@ -118,7 +122,9 @@ const registerBundleAnalysis = ( description: "Capture and statically analyze a page bundle, defaulting to the selected target origin", args: z.object({ - endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"), + endpoint: z + .string() + .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), targetId: z.string().describe("Target ID from list-browser-targets"), }), options: z.object({ @@ -162,7 +168,9 @@ const registerObservationSession = ( cli.command(CLI_COMMANDS.observeWebSession, { description: "Observe user-driven page navigation during a chosen window", args: z.object({ - endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"), + endpoint: z + .string() + .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), targetId: z.string().describe("Target ID from list-browser-targets"), }), options: z.object({ diff --git a/src/domain/browserObservation.test.ts b/src/domain/browserObservation.test.ts index c5eb73287..6bcf09f68 100644 --- a/src/domain/browserObservation.test.ts +++ b/src/domain/browserObservation.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import { browserEndpointSchema, + browserObservationEndpointSchema, browserOriginSchema, + recordedBrowserObservationEndpointSchema, sanitizeEndpointCandidate, sanitizeBrowserUrl, } from "./browserObservation.js"; @@ -45,6 +47,58 @@ describe("browser observation contracts", () => { ]) expect(browserEndpointSchema.safeParse(value).success, value).toBe(false); }); + + it("accepts a loopback browser socket and records it without its identifier", () => { + expect( + browserObservationEndpointSchema.parse("http://127.0.0.1:9222/"), + ).toEqual({ + transport: "http_discovery", + url: "http://127.0.0.1:9222", + recorded: "http://127.0.0.1:9222", + }); + expect( + browserObservationEndpointSchema.parse( + "ws://[::1]:9222/devtools/browser/0b1c-socket-id", + ), + ).toEqual({ + transport: "browser_websocket", + url: "ws://[::1]:9222/devtools/browser/0b1c-socket-id", + recorded: "ws://[::1]:9222/devtools/browser/[REDACTED]", + }); + for (const value of [ + "ws://127.0.0.1/devtools/browser/id", + "ws://localhost:9222/devtools/browser/id", + "wss://127.0.0.1:9222/devtools/browser/id", + "ws://user:pass@127.0.0.1:9222/devtools/browser/id", + "ws://127.0.0.1:9222/devtools/browser/id?token=x", + "ws://127.0.0.1:9222/devtools/browser/id#fragment", + "ws://127.0.0.1:9222/devtools/browser/id?", + "ws://127.0.0.1:9222/devtools/browser/id#", + "ws://127.0.0.1:9222/devtools/browser/", + "ws://127.0.0.1:9222/devtools/browser/id/extra", + "ws://127.0.0.1:9222/devtools/page/id", + ]) + expect( + browserObservationEndpointSchema.safeParse(value).success, + value, + ).toBe(false); + }); + + it("accepts recorded browser sockets only in their redacted form", () => { + for (const value of [ + "http://127.0.0.1:9222", + "ws://127.0.0.1:9222/devtools/browser/[REDACTED]", + ]) + expect( + recordedBrowserObservationEndpointSchema.safeParse(value).success, + value, + ).toBe(true); + expect( + recordedBrowserObservationEndpointSchema.safeParse( + "ws://127.0.0.1:9222/devtools/browser/0b1c-socket-id", + ).success, + ).toBe(false); + }); }); describe("browser observation sensitive surfaces and retention", () => { diff --git a/src/domain/browserObservation.ts b/src/domain/browserObservation.ts index f4b8984b6..e15265305 100644 --- a/src/domain/browserObservation.ts +++ b/src/domain/browserObservation.ts @@ -43,46 +43,125 @@ export const browserOriginSchema = z export const isLiteralLoopbackHostname = (hostname: string): boolean => hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"; +const HTTP_ENDPOINT_MESSAGE = + "CDP endpoint must be an explicit-port HTTP URL on 127.0.0.1 or ::1"; +const BROWSER_SOCKET_PATH = "/devtools/browser/"; +const REDACTED_BROWSER_SOCKET_PATH = `${BROWSER_SOCKET_PATH}[REDACTED]`; + +/** Normalized origin of an accepted loopback CDP HTTP discovery endpoint. */ +const httpDiscoveryOrigin = (url: URL): string | undefined => + url.protocol === "http:" && + isLiteralLoopbackHostname(url.hostname) && + url.port !== "" && + url.username === "" && + url.password === "" && + url.pathname === "/" && + !urlDeclaresQueryOrFragment(url) + ? url.origin + : undefined; + +/** Scheme and authority of an accepted loopback CDP browser WebSocket. */ +const browserSocketAuthority = (url: URL): string | undefined => { + const id = url.pathname.slice(BROWSER_SOCKET_PATH.length); + return url.protocol === "ws:" && + isLiteralLoopbackHostname(url.hostname) && + url.port !== "" && + url.username === "" && + url.password === "" && + !urlDeclaresQueryOrFragment(url) && + url.pathname.startsWith(BROWSER_SOCKET_PATH) && + id.length > 0 && + !id.includes("/") + ? `${url.protocol}//${url.host}` + : undefined; +}; + /** Loopback-only HTTP endpoint accepted for a user-owned CDP browser. */ export const browserEndpointSchema = z .string() .min(1) .transform((value, context) => { - let url: URL; - try { - url = new URL(value); - } catch (cause: unknown) { - // Invalid input is reported through the zod issue. - void cause; + const url = URL.parse(value); + const origin = url === null ? undefined : httpDiscoveryOrigin(url); + if (origin !== undefined) return origin; + context.addIssue({ + code: "custom", + message: + url === null ? "Invalid CDP endpoint URL" : HTTP_ENDPOINT_MESSAGE, + }); + return z.NEVER; + }); + +/** + * Caller-selected endpoint for passive browser observation. A browser socket + * skips HTTP discovery; its identifier gates access, so `recorded` omits it. + */ +export interface BrowserObservationEndpoint { + readonly transport: "http_discovery" | "browser_websocket"; + readonly url: string; + readonly recorded: string; +} + +/** Loopback CDP HTTP discovery origin or browser WebSocket URL. */ +export const browserObservationEndpointSchema = z + .string() + .min(1) + .describe( + "Literal loopback CDP endpoint: http://127.0.0.1:PORT, or ws://127.0.0.1:PORT/devtools/browser/ID.", + ) + .transform((value, context): BrowserObservationEndpoint => { + const url = URL.parse(value); + if (url === null) { context.addIssue({ code: "custom", message: "Invalid CDP endpoint URL" }); return z.NEVER; } - if ( - url.protocol !== "http:" || - !isLiteralLoopbackHostname(url.hostname) || - url.port === "" || - url.username !== "" || - url.password !== "" || - url.pathname !== "/" || - urlDeclaresQueryOrFragment(url) - ) { - context.addIssue({ - code: "custom", - message: - "CDP endpoint must be an explicit-port HTTP URL on 127.0.0.1 or ::1", - }); - return z.NEVER; - } - return url.origin; + const origin = httpDiscoveryOrigin(url); + if (origin !== undefined) + return { transport: "http_discovery", url: origin, recorded: origin }; + const authority = browserSocketAuthority(url); + if (authority !== undefined) + return { + transport: "browser_websocket", + url: url.href, + recorded: `${authority}${REDACTED_BROWSER_SOCKET_PATH}`, + }; + context.addIssue({ + code: "custom", + message: + url.protocol === "ws:" + ? "CDP browser WebSocket must be ws://127.0.0.1:PORT/devtools/browser/ID or ws://[::1]:PORT/devtools/browser/ID" + : HTTP_ENDPOINT_MESSAGE, + }); + return z.NEVER; }); +/** Evidence form of a passive browser endpoint, with any socket identifier redacted. */ +export const recordedBrowserObservationEndpointSchema = z + .string() + .min(1) + .refine( + (value) => { + const url = URL.parse(value); + return ( + url !== null && + (httpDiscoveryOrigin(url) !== undefined || + (browserSocketAuthority(url) !== undefined && + url.pathname === REDACTED_BROWSER_SOCKET_PATH)) + ); + }, + { + message: + "Recorded CDP endpoint must be a loopback HTTP origin or a redacted browser WebSocket URL", + }, + ); + export const browserAllowedOriginsSchema = z .array(browserOriginSchema) .transform((origins) => Array.from(new Set(origins)).sort()) .default([]); const browserInput = { - cdp_endpoint: browserEndpointSchema, + cdp_endpoint: browserObservationEndpointSchema, allowed_origins: browserAllowedOriginsSchema, }; diff --git a/src/domain/browserSession.ts b/src/domain/browserSession.ts index 17ecfa8db..c2f00e9a2 100644 --- a/src/domain/browserSession.ts +++ b/src/domain/browserSession.ts @@ -2,14 +2,14 @@ import { z } from "zod"; import { browserAllowedOriginsSchema, - browserEndpointSchema, + browserObservationEndpointSchema, } from "./browserObservation.js"; import { browserCompletenessSchema } from "./browserCompleteness.js"; import { browserVersionSchema } from "./browserObservationSchemas.js"; /** Input for a navigation-aware browser observation window. */ export const observeWebSessionInputSchema = z.strictObject({ - cdp_endpoint: browserEndpointSchema, + cdp_endpoint: browserObservationEndpointSchema, allowed_origins: browserAllowedOriginsSchema, target_id: z.string().trim().min(1), observation_ms: z.number().int().min(1).default(10_000), diff --git a/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts b/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts index baba3395d..be428b06c 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { browserAllowedOriginsSchema, browserEndpointSchema, + recordedBrowserObservationEndpointSchema, } from "../browserObservation.js"; import { webPageInspectionSchema, @@ -424,14 +425,17 @@ const assertRuntimeParameters = ( ): void => { const common = { target_id: z.string().trim().min(1), - cdp_endpoint: browserEndpointSchema, include_script_sources: z.boolean(), }; if (expected.kind === "browser") { const parameters = parseAtPath( (value) => z - .object({ ...common, allowed_origins: browserAllowedOriginsSchema }) + .object({ + ...common, + cdp_endpoint: recordedBrowserObservationEndpointSchema, + allowed_origins: browserAllowedOriginsSchema, + }) .passthrough() .parse(value), evidence.parameters, @@ -455,7 +459,11 @@ const assertRuntimeParameters = ( return; } const parameters = parseAtPath( - (value) => z.object(common).passthrough().parse(value), + (value) => + z + .object({ ...common, cdp_endpoint: browserEndpointSchema }) + .passthrough() + .parse(value), evidence.parameters, [...path, "parameters"], ); diff --git a/src/domain/webMcpDiscovery.ts b/src/domain/webMcpDiscovery.ts index f1a3f3b12..0924f5b78 100644 --- a/src/domain/webMcpDiscovery.ts +++ b/src/domain/webMcpDiscovery.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { browserCompletenessSchema } from "./browserCompleteness.js"; import { browserAllowedOriginsSchema, - browserEndpointSchema, + browserObservationEndpointSchema, } from "./browserObservation.js"; import { browserVersionSchema } from "./browserObservationSchemas.js"; import { jsonValueSchema } from "./jsonValue.js"; @@ -11,7 +11,7 @@ import { digestSchema, prefixedDigestSchema } from "./../domain/digests.js"; /** Input for passive discovery of page-registered WebMCP tools. */ export const discoverWebMcpToolsInputSchema = z.strictObject({ - cdp_endpoint: browserEndpointSchema, + cdp_endpoint: browserObservationEndpointSchema, allowed_origins: browserAllowedOriginsSchema, target_id: z.string().trim().min(1), observation_ms: z.number().int().min(0).default(100), diff --git a/src/domain/webScreenshot.ts b/src/domain/webScreenshot.ts index 57ee35161..d477bdaeb 100644 --- a/src/domain/webScreenshot.ts +++ b/src/domain/webScreenshot.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { browserCompletenessSchema } from "./browserCompleteness.js"; import { browserAllowedOriginsSchema, - browserEndpointSchema, + browserObservationEndpointSchema, } from "./browserObservation.js"; import { browserVersionSchema } from "./browserObservationSchemas.js"; @@ -34,7 +34,7 @@ export type WebScreenshotArtifact = z.infer; /** Input for one read-only visible-viewport screenshot. */ export const captureWebScreenshotInputSchema = z.strictObject({ - cdp_endpoint: browserEndpointSchema, + cdp_endpoint: browserObservationEndpointSchema, allowed_origins: browserAllowedOriginsSchema, target_id: z.string().trim().min(1), }); diff --git a/tests/boundary/browser/cdpBrowserProvider.browserSocket.test.ts b/tests/boundary/browser/cdpBrowserProvider.browserSocket.test.ts new file mode 100644 index 000000000..f04b8210d --- /dev/null +++ b/tests/boundary/browser/cdpBrowserProvider.browserSocket.test.ts @@ -0,0 +1,232 @@ +import { createServer, type Socket } from "node:net"; + +import { describe, expect, it, vi } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { openCdpEndpoint } from "../../../src/browser/CdpTargetSession.js"; +import { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { + browserObservationEndpointSchema, + inspectWebPageInputSchema, + listBrowserTargetsInputSchema, +} from "../../../src/domain/browserObservation.js"; +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import { trackBrowser } from "./cdpBrowserProvider.support.js"; + +describe("CdpBrowserProvider: browser socket without HTTP discovery", () => { + it("discovers and inspects a page through one browser socket", async () => { + const browser = await startFakeCdpBrowser(); + trackBrowser(browser); + const provider = new CdpBrowserProvider(); + const throughHttp = await provider.listTargets( + listBrowserTargetsInputSchema.parse({ cdp_endpoint: browser.endpoint }), + ); + if (!throughHttp.ok) throw throughHttp.error; + const httpRequests = browser.httpRequests.length; + const openedBefore = browser.openedConnectionCount(); + + const listed = await provider.listTargets( + listBrowserTargetsInputSchema.parse({ + cdp_endpoint: browser.browserWebSocketUrl, + }), + ); + if (!listed.ok) throw listed.error; + expect(listed.value.targets).toEqual(throughHttp.value.targets); + expect(listed.value.excluded).toEqual(throughHttp.value.excluded); + expect(listed.value.browser).toEqual({ + ...throughHttp.value.browser, + revision: "@fake-revision", + }); + + const commandsBefore = browser.commands.length; + const inspected = await provider.inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.browserWebSocketUrl, + target_id: "allowed-page", + observation_ms: 0, + }), + ); + if (!inspected.ok) throw inspected.error; + expect(inspected.value.target.origin).toBe(browser.allowedOrigin); + expect( + browser.commands + .slice(commandsBefore, commandsBefore + 3) + .map(({ method, params }) => ({ method, params })), + ).toEqual([ + { method: "Browser.getVersion", params: {} }, + { method: "Target.getTargets", params: {} }, + { + method: "Target.attachToTarget", + params: { targetId: "allowed-page", flatten: true }, + }, + ]); + expect(browser.httpRequests).toHaveLength(httpRequests); + expect(browser.openedConnectionCount()).toBe(openedBefore + 2); + await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + }); +}); + +describe("CdpBrowserProvider: browser socket failures and cleanup", () => { + it("closes an unadopted browser socket when the target is missing", async () => { + const browser = await startFakeCdpBrowser(); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.browserWebSocketUrl, + target_id: "missing", + observation_ms: 0, + }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "target_not_found" }, + }); + expect(browser.commands.map(({ method }) => method)).toEqual([ + "Browser.getVersion", + "Target.getTargets", + ]); + await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + }); + + it("rejects malformed browser-socket discovery and closes the socket", async () => { + const browser = await startFakeCdpBrowser({ + commandResult: ({ method }) => + method === "Browser.getVersion" + ? { product: "FakeChrome/1.0" } + : undefined, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().listTargets( + listBrowserTargetsInputSchema.parse({ + cdp_endpoint: browser.browserWebSocketUrl, + }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "BrowserObservationError", + operation: "list_browser_targets", + reason: "invalid_endpoint_response", + }, + }); + await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + }); + + it("reports a refused browser socket as unreachable", async () => { + const browser = await startFakeCdpBrowser(); + trackBrowser(browser); + const result = await new CdpBrowserProvider().listTargets( + listBrowserTargetsInputSchema.parse({ + cdp_endpoint: browser.browserWebSocketUrl.replace( + /fake$/u, + "not-this-browser", + ), + }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "BrowserObservationError", + reason: "endpoint_unreachable", + }, + }); + expect(JSON.stringify(result)).not.toContain("not-this-browser"); + }); + + it("cancels browser-socket discovery and releases the socket", async () => { + const browser = await startFakeCdpBrowser({ + hangOnMethod: "Target.getTargets", + }); + trackBrowser(browser); + const controller = new AbortController(); + const pending = new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.browserWebSocketUrl, + target_id: "allowed-page", + observation_ms: 0, + }), + { signal: controller.signal }, + ); + await vi.waitFor(() => + expect(browser.commands.map(({ method }) => method)).toContain( + "Target.getTargets", + ), + ); + controller.abort(); + + expect(await pending).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", operation: "inspect_web_page" }, + }); + expect(browser.commands.map(({ method }) => method)).not.toContain( + "Target.attachToTarget", + ); + await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + }); +}); + +/** Expect a discovery timeout that projects publicly as a provider timeout. */ +const expectDiscoveryTimeout = async (url: string): Promise => { + let failure: unknown; + try { + await openCdpEndpoint( + browserObservationEndpointSchema.parse(url), + "list_browser_targets", + undefined, + 50, + ); + } catch (cause: unknown) { + failure = cause; + } + expect(failure).toMatchObject({ + _tag: "BrowserObservationError", + reason: "timeout", + }); + if (!(failure instanceof AnalysisError)) + throw new Error("Expected a typed discovery failure"); + expect(projectAnalysisError(failure)).toMatchObject({ + code: "provider_timeout", + category: "timeout", + }); +}; + +describe("CdpBrowserProvider: browser socket discovery deadline", () => { + it("times out a discovery reply that never arrives and closes the socket", async () => { + const browser = await startFakeCdpBrowser({ + hangOnMethod: "Target.getTargets", + }); + trackBrowser(browser); + await expectDiscoveryTimeout(browser.browserWebSocketUrl); + await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); + }); + + it("times out a WebSocket handshake that never completes", async () => { + const sockets = new Set(); + // Read the upgrade request but never answer it; reading lets the + // client's close be observed here. + const server = createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + socket.resume(); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Expected a TCP listener address"); + try { + await expectDiscoveryTimeout( + `ws://127.0.0.1:${String(address.port)}/devtools/browser/stalled`, + ); + await vi.waitFor(() => expect(sockets.size).toBe(0)); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); diff --git a/tests/boundary/browser/cdpBrowserProvider.targetInventory.test.ts b/tests/boundary/browser/cdpBrowserProvider.targetInventory.test.ts index 81fe60759..9b2053d39 100644 --- a/tests/boundary/browser/cdpBrowserProvider.targetInventory.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.targetInventory.test.ts @@ -3,6 +3,7 @@ import { createServer } from "node:http"; import { describe, expect, it } from "vitest"; import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { listBrowserTargetsInputSchema } from "../../../src/domain/browserObservation.js"; describe("CDP target inventory", () => { it("returns every allowed target within the bounded discovery payload", async () => { @@ -51,10 +52,12 @@ describe("CDP target inventory", () => { port = address.port; const endpoint = `http://127.0.0.1:${String(port)}`; - const result = await new CdpBrowserProvider().listTargets({ - cdp_endpoint: endpoint, - allowed_origins: ["https://allowed.example"], - }); + const result = await new CdpBrowserProvider().listTargets( + listBrowserTargetsInputSchema.parse({ + cdp_endpoint: endpoint, + allowed_origins: ["https://allowed.example"], + }), + ); if (!result.ok) throw result.error; expect(result.value.targets).toHaveLength(targets.length); diff --git a/tests/boundary/cli/browserCli.test.ts b/tests/boundary/cli/browserCli.test.ts index 5f8193fce..351d07512 100644 --- a/tests/boundary/cli/browserCli.test.ts +++ b/tests/boundary/cli/browserCli.test.ts @@ -183,6 +183,54 @@ it( INTEGRATION_TEST_TIMEOUT_MS, ); +it( + "browser CLI attaches through a browser socket and records it redacted", + async () => { + const browser = await startFakeCdpBrowser(); + browsers.push(browser); + const inspected = await runCli( + [ + "inspect-web-page", + browser.browserWebSocketUrl, + "allowed-page", + "--observation-ms", + "0", + "--json", + ], + process.env, + ); + expect(inspected).toMatchObject({ + operation: "inspect_web_page", + parameters: { + cdp_endpoint: browser.browserWebSocketUrl.replace( + /fake$/u, + "[REDACTED]", + ), + }, + normalized_result: { target: { target_id: "allowed-page" } }, + }); + expect(JSON.stringify(inspected)).not.toContain("/devtools/browser/fake"); + + const refused = await runCli( + [ + "list-browser-targets", + browser.browserWebSocketUrl.replace(/fake$/u, "not-this-browser"), + "--json", + ], + process.env, + ); + expect(refused).toMatchObject({ + code: "provider_unavailable", + details: { + operation: "list_browser_targets", + reason: "endpoint_unreachable", + }, + }); + expect(JSON.stringify(refused)).not.toContain("not-this-browser"); + }, + INTEGRATION_TEST_TIMEOUT_MS, +); + describe("scenario comparison CLI diagnostics", () => { const rule = { rule_id: "domain", diff --git a/tests/boundary/mcp/browserMcp.test.ts b/tests/boundary/mcp/browserMcp.test.ts index a8a780d61..b13d17df5 100644 --- a/tests/boundary/mcp/browserMcp.test.ts +++ b/tests/boundary/mcp/browserMcp.test.ts @@ -424,6 +424,47 @@ it("delivers the source-map response URL through authenticated MCP Evidence", as ).toBe(true); }); +it("records a browser-socket endpoint redacted and reconciles its Evidence", async () => { + const browser = await startFakeCdpBrowser(); + browsers.push(browser); + const connected = await connectBrowser(); + const tool = (await connected.client.listTools()).tools.find( + ({ name }) => name === "inspect_web_page", + ); + expect(tool?.inputSchema.properties?.cdp_endpoint).toMatchObject({ + description: expect.stringContaining("/devtools/browser/ID"), + }); + const inspected = await connected.client.callTool({ + name: "inspect_web_page", + arguments: { + cdp_endpoint: browser.browserWebSocketUrl, + target_id: "allowed-page", + observation_ms: 0, + include_script_sources: true, + }, + }); + expect(inspected.isError).not.toBe(true); + expect(inspected.structuredContent).toMatchObject({ + parameters: { + cdp_endpoint: browser.browserWebSocketUrl.replace(/fake$/u, "[REDACTED]"), + }, + }); + expect(JSON.stringify(inspected.structuredContent)).not.toContain( + "/devtools/browser/fake", + ); + const reconciled = await connected.client.callTool({ + name: "reconcile_javascript_runtime", + arguments: { + static_layers: JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE.static_layers, + runtime_observations: [evidenceFor(inspected.structuredContent)], + }, + }); + expect(reconciled.isError).not.toBe(true); + expect(reconciled.structuredContent).toMatchObject({ + normalized_result: { summary: { runtime_scripts: 1 } }, + }); +}); + it("does not attach to a target outside the request's allowed origin scope", async () => { const browser = await startFakeCdpBrowser(); browsers.push(browser); diff --git a/tests/fixtures/fakeCdpBrowser.ts b/tests/fixtures/fakeCdpBrowser.ts index 191b4569c..aa476a58b 100644 --- a/tests/fixtures/fakeCdpBrowser.ts +++ b/tests/fixtures/fakeCdpBrowser.ts @@ -28,6 +28,7 @@ export const startFakeCdpBrowser = async ( referer: string | undefined; }[] = []; const sockets = new Set(); + let openedConnections = 0; let frameTreeReads = 0; let port = 0; const http = createServer((request, response) => { @@ -85,6 +86,7 @@ export const startFakeCdpBrowser = async ( }); webSockets.on("connection", (socket: WebSocket, request: IncomingMessage) => { const directPage = request.url?.startsWith("/devtools/page/") === true; + openedConnections += 1; sockets.add(socket); socket.on("close", () => sockets.delete(socket)); socket.on("message", (raw) => { @@ -142,6 +144,7 @@ export const startFakeCdpBrowser = async ( allowedOrigin: endpoint, commands, activeConnectionCount: () => sockets.size, + openedConnectionCount: () => openedConnections, emitRawMessage(message) { for (const socket of sockets) socket.send(message); }, diff --git a/tests/fixtures/fakeCdpBrowserResults.ts b/tests/fixtures/fakeCdpBrowserResults.ts index 908db238e..4a489227f 100644 --- a/tests/fixtures/fakeCdpBrowserResults.ts +++ b/tests/fixtures/fakeCdpBrowserResults.ts @@ -24,6 +24,8 @@ export const resultFor = ( return responseBodyResult(options); case "Page.captureScreenshot": return screenshotResult(); + case "Browser.getVersion": + return browserVersionResult(); case "Target.getTargets": return { targetInfos: targets(port, options).map(endpointTargetToInfo) }; case "Storage.getUsageAndQuota": @@ -66,6 +68,15 @@ export const resultFor = ( } }; +/** Chrome's browser-socket reply shape, mirroring the fake's `/json/version`. */ +const browserVersionResult = () => ({ + protocolVersion: "1.3", + product: "FakeChrome/1.0", + revision: "@fake-revision", + userAgent: "FakeChrome", + jsVersion: "13.0", +}); + const attachToTargetResult = (options: FakeOptions) => ({ sessionId: options.invalidAttachedSession ? "" : "session-1", }); diff --git a/tests/fixtures/fakeCdpBrowserTypes.ts b/tests/fixtures/fakeCdpBrowserTypes.ts index 07a882a1e..e75e0dc48 100644 --- a/tests/fixtures/fakeCdpBrowserTypes.ts +++ b/tests/fixtures/fakeCdpBrowserTypes.ts @@ -13,6 +13,8 @@ export interface FakeCdpBrowser { readonly commands: readonly FakeCdpCommand[]; /** Actual WebSocket connections still held by the wire fixture. */ activeConnectionCount(): number; + /** WebSocket upgrades accepted over the fixture's lifetime. */ + openedConnectionCount(): number; /** Deliver actual malformed wire data without bypassing the production transport parser. */ emitRawMessage(message: string): void; emitEvent(event: {