feat(browser): attach passive tools to browsers without HTTP discovery (#2346)

* feat(browser): attach passive tools to browsers without HTTP discovery

Accept a literal loopback browser WebSocket,
ws://127.0.0.1:PORT/devtools/browser/ID, as the cdp_endpoint of the six
passive browser tools. Discovery then reads Browser.getVersion and
Target.getTargets over that socket, and the target session adopts the
same socket, so a browser that asks for consent per connection prompts
once per call.

The socket identifier gates access, so Evidence records the endpoint as
ws://127.0.0.1:PORT/devtools/browser/[REDACTED], and runtime
reconciliation accepts that recorded form. HTTP endpoints and their
Evidence are unchanged. Electron, web runtime, scenario and Inspector
endpoints stay HTTP-only.

Refs #2288

* test(browser): match browser-socket captures against HTTP discovery

Check in the real Chrome lane that DevToolsActivePort names the same
browser socket as /json/version, and that inspecting the selected page
through that socket leaves DOM structure and scripts unchanged compared
with an inspection through HTTP discovery.

Refs #2288

* fix(browser): bound browser-socket handshake and report discovery timeouts

Run the browser-socket WebSocket handshake and both discovery commands
under one discovery deadline, so a port that accepts the connection but
never completes the upgrade times out instead of waiting forever.

Report a reached deadline as a timeout without carrying the internal
abort as its cause, so CLI and MCP project provider_timeout rather than
cancelled. Caller cancellation is still reported as cancelled.

Refs #2288
This commit is contained in:
all-black-493 2026-10-13 19:36:37 +03:00 committed by GitHub
parent 0c9ee56c2f
commit 62c8b3da1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 961 additions and 180 deletions

View File

@ -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/<id>
```
Chrome writes the port and the `/devtools/browser/<id>` 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.

View File

@ -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,
};
}

View File

@ -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,

View File

@ -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

View File

@ -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<Result<BrowserTargetList, AnalysisError>> {
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<Result<WebObservationSession, AnalysisError>> {
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<Result<WebMcpDiscovery, AnalysisError>> {
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<Result<WebScreenshot, AnalysisError>> {
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<CapturedPage> {
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<Input> {
readonly connection: CdpTargetSession["connection"];
readonly sessionId: CdpTargetSession["sessionId"];
readonly discovery: CdpEndpointDiscovery;
readonly target: CdpEndpointTarget;
readonly input: Input;
readonly signal?: AbortSignal;
readonly progress?: NonNullable<ExecutionOptions["progress"]>;
}
/** 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<Input>) => Promise<Output>,
): Promise<Output> => {
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<string> | undefined,

View File

@ -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 <T>(
operation: BrowserObservationOperation,
signal: AbortSignal | undefined,
work: (deadlineSignal: AbortSignal) => Promise<T>,
timeoutMs: number = CDP_DISCOVERY_RESPONSE_LIMITS.timeoutMs,
): Promise<T> => {
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<CdpEndpointDiscovery> => {
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 {

View File

@ -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<CdpOpenedEndpoint> => {
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<CdpTargetSession> => {
try {
const attached = await connection.send(
"Target.attachToTarget",

View File

@ -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({

View File

@ -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({

View File

@ -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", () => {

View File

@ -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,
};

View File

@ -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),

View File

@ -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"],
);

View File

@ -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),

View File

@ -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<typeof webScreenshotArtifactSchema>;
/** 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),
});

View File

@ -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<void> => {
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<Socket>();
// 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<void>((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<void>((resolve) => server.close(() => resolve()));
}
});
});

View File

@ -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);

View File

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

View File

@ -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);

View File

@ -28,6 +28,7 @@ export const startFakeCdpBrowser = async (
referer: string | undefined;
}[] = [];
const sockets = new Set<WebSocket>();
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);
},

View File

@ -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",
});

View File

@ -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: {