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:
parent
0c9ee56c2f
commit
62c8b3da1b
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue