fix(browser): support page-scoped CDP transports
This commit is contained in:
parent
528c05e2fa
commit
bf9619267b
|
|
@ -1,7 +1,8 @@
|
|||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type {
|
||||
InspectWebPageInput,
|
||||
ListBrowserTargetsInput,
|
||||
import {
|
||||
isLiteralLoopbackHostname,
|
||||
type InspectWebPageInput,
|
||||
type ListBrowserTargetsInput,
|
||||
} from "../domain/browserObservation.js";
|
||||
import type { AnalyzeWebBundleInput } from "../domain/webBundleAnalysis.js";
|
||||
import type { ObserveWebSessionInput } from "../domain/browserSession.js";
|
||||
|
|
@ -285,7 +286,7 @@ const browserNetworkScope = (
|
|||
): "loopback" | "external" =>
|
||||
allowedOrigins.every((origin) => {
|
||||
const hostname = new URL(origin).hostname;
|
||||
return hostname === "127.0.0.1" || hostname === "[::1]";
|
||||
return isLiteralLoopbackHostname(hostname);
|
||||
})
|
||||
? "loopback"
|
||||
: "external";
|
||||
|
|
|
|||
|
|
@ -47,12 +47,17 @@ import {
|
|||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { CdpConnection } from "./CdpConnection.js";
|
||||
import {
|
||||
discoverCdpEndpoint,
|
||||
hasCdpTargetWebSocket,
|
||||
type CdpEndpointDiscovery,
|
||||
type CdpEndpointTarget,
|
||||
} from "./CdpEndpoint.js";
|
||||
import {
|
||||
closeCdpTargetSession,
|
||||
openCdpTargetSession,
|
||||
type CdpTargetSession,
|
||||
} from "./CdpTargetSession.js";
|
||||
import { capturePage, type CapturedPage } from "./CdpPageCapture.js";
|
||||
import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js";
|
||||
import { observeCdpSession } from "./CdpObservationSession.js";
|
||||
|
|
@ -65,6 +70,7 @@ const IDENTITY: ProviderIdentity = {
|
|||
name: "REA Chrome DevTools Protocol observation provider",
|
||||
version: "2",
|
||||
};
|
||||
const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const;
|
||||
|
||||
/** Passive, origin-scoped browser observation through a user-owned CDP endpoint. */
|
||||
export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
|
|
@ -105,6 +111,11 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
excluded: selected.excluded,
|
||||
limitations: [
|
||||
"Only page targets whose current URL matches an approved exact origin are listed.",
|
||||
...(selected.unconnectable === 0
|
||||
? []
|
||||
: [
|
||||
`${String(selected.unconnectable)} otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.`,
|
||||
]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
|
@ -118,7 +129,7 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebPageInspection, AnalysisError>> {
|
||||
try {
|
||||
const captured = await this.#capture(input, options);
|
||||
const captured = await this.#capture(input, options, "inspect_web_page");
|
||||
return ok(webPageInspectionSchema.parse(captured.inspection));
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "inspect_web_page"));
|
||||
|
|
@ -130,7 +141,11 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebBundleAnalysis, AnalysisError>> {
|
||||
try {
|
||||
const captured = await this.#capture(input, options);
|
||||
const captured = await this.#capture(
|
||||
input,
|
||||
options,
|
||||
"analyze_web_bundle",
|
||||
);
|
||||
const sourceMaps = input.fetch_source_maps
|
||||
? await fetchWebSourceMaps(
|
||||
captured.sourceMapRequests,
|
||||
|
|
@ -152,31 +167,25 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
input: ObserveWebSessionInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebObservationSession, AnalysisError>> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
"observe_web_session",
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
targetSession = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
"observe_web_session",
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
return ok(
|
||||
webObservationSessionSchema.parse(
|
||||
await observeCdpSession({
|
||||
connection,
|
||||
sessionId,
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
|
|
@ -190,11 +199,8 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "observe_web_session"));
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined)
|
||||
await bestEffortCleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,31 +208,25 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
input: DiscoverWebMcpToolsInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebMcpDiscovery, AnalysisError>> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
"discover_webmcp_tools",
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
targetSession = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
"discover_webmcp_tools",
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
return ok(
|
||||
webMcpDiscoverySchema.parse(
|
||||
await discoverWebMcp({
|
||||
connection,
|
||||
sessionId,
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
|
|
@ -240,11 +240,8 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "discover_webmcp_tools"));
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined)
|
||||
await bestEffortCleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -262,31 +259,25 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
input: CaptureWebScreenshotInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebScreenshot, AnalysisError>> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
"capture_web_screenshot",
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
targetSession = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
"capture_web_screenshot",
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
return ok(
|
||||
webScreenshotSchema.parse(
|
||||
await captureCdpScreenshot({
|
||||
connection,
|
||||
sessionId,
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
|
|
@ -300,11 +291,8 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "capture_web_screenshot"));
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined)
|
||||
await bestEffortCleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -321,30 +309,25 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
async #capture(
|
||||
input: InspectWebPageInput,
|
||||
options: ExecutionOptions,
|
||||
operation: "inspect_web_page" | "analyze_web_bundle",
|
||||
): Promise<CapturedPage> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
operation,
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
targetSession = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
operation,
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
return await capturePage({
|
||||
connection,
|
||||
sessionId,
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
|
|
@ -354,11 +337,8 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
|||
: { progress: options.progress }),
|
||||
});
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined)
|
||||
await bestEffortCleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -369,11 +349,13 @@ const selectTargets = (
|
|||
): {
|
||||
readonly allowed: readonly BrowserTargetList["targets"]["items"][number][];
|
||||
readonly excluded: BrowserTargetList["excluded"];
|
||||
readonly unconnectable: number;
|
||||
} => {
|
||||
const allowed = [];
|
||||
let disallowedOrigin = 0;
|
||||
let unsupportedUrl = 0;
|
||||
let nonPage = 0;
|
||||
let unconnectable = 0;
|
||||
for (const target of discovery.targets) {
|
||||
if (target.type !== "page") {
|
||||
nonPage += 1;
|
||||
|
|
@ -388,6 +370,10 @@ const selectTargets = (
|
|||
disallowedOrigin += 1;
|
||||
continue;
|
||||
}
|
||||
if (!hasCdpTargetWebSocket(discovery, target)) {
|
||||
unconnectable += 1;
|
||||
continue;
|
||||
}
|
||||
allowed.push({
|
||||
target_id: target.id,
|
||||
type: target.type,
|
||||
|
|
@ -405,6 +391,7 @@ const selectTargets = (
|
|||
unsupported_url: unsupportedUrl,
|
||||
non_page: nonPage,
|
||||
},
|
||||
unconnectable,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -427,37 +414,6 @@ const authorizeTarget = (
|
|||
return target;
|
||||
};
|
||||
|
||||
const attachedSessionId = (value: unknown): string => {
|
||||
if (typeof value !== "object" || value === null || !("sessionId" in value))
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
if (typeof value.sessionId !== "string" || value.sessionId.length > 256)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
return value.sessionId;
|
||||
};
|
||||
|
||||
const bestEffortCleanup = async (
|
||||
connection: CdpConnection,
|
||||
sessionId: string,
|
||||
): Promise<void> => {
|
||||
for (const method of [
|
||||
"Network.disable",
|
||||
"Debugger.disable",
|
||||
"Runtime.disable",
|
||||
"Page.disable",
|
||||
]) {
|
||||
try {
|
||||
await connection.send(method, {}, sessionId);
|
||||
} catch {
|
||||
// Cleanup continues so detach is still attempted.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await connection.send("Target.detachFromTarget", { sessionId });
|
||||
} catch {
|
||||
// Closing REA's socket is the final non-destructive cleanup boundary.
|
||||
}
|
||||
};
|
||||
|
||||
const providerError = (
|
||||
cause: unknown,
|
||||
operation: BrowserObservationOperation,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
export const captureStorage = async (
|
||||
context: {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly sessionId: string | undefined;
|
||||
readonly input: InspectWebPageInput;
|
||||
readonly signal?: AbortSignal;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export class CdpConnection {
|
|||
id,
|
||||
method,
|
||||
params,
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
...(sessionId === undefined ? {} : { sessionId }),
|
||||
}),
|
||||
(error) => {
|
||||
if (error === undefined || error === null) return;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import {
|
|||
|
||||
interface ElectronContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly sessionId: string | undefined;
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: InspectElectronPageInput;
|
||||
|
|
|
|||
|
|
@ -18,8 +18,16 @@ import {
|
|||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { CdpConnection } from "./CdpConnection.js";
|
||||
import { discoverCdpEndpoint, type CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import {
|
||||
discoverCdpEndpoint,
|
||||
hasCdpTargetWebSocket,
|
||||
type CdpEndpointTarget,
|
||||
} from "./CdpEndpoint.js";
|
||||
import {
|
||||
closeCdpTargetSession,
|
||||
openCdpTargetSession,
|
||||
type CdpTargetSession,
|
||||
} from "./CdpTargetSession.js";
|
||||
import { inspectCdpElectronPage } from "./CdpElectronInspection.js";
|
||||
import {
|
||||
authorizedElectronFile,
|
||||
|
|
@ -53,6 +61,7 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
|||
let outsideRoot = 0;
|
||||
let unsupportedUrl = 0;
|
||||
let nonPage = 0;
|
||||
let unconnectable = 0;
|
||||
for (const target of discovery.targets) {
|
||||
if (target.type !== "page") {
|
||||
nonPage += 1;
|
||||
|
|
@ -64,6 +73,10 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
|||
else unsupportedUrl += 1;
|
||||
continue;
|
||||
}
|
||||
if (!hasCdpTargetWebSocket(discovery, target)) {
|
||||
unconnectable += 1;
|
||||
continue;
|
||||
}
|
||||
allowed.push({
|
||||
target_id: target.id,
|
||||
type: target.type,
|
||||
|
|
@ -96,6 +109,11 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
|||
},
|
||||
limitations: [
|
||||
"Only page targets whose canonical file path is contained by an approved root are listed.",
|
||||
...(unconnectable === 0
|
||||
? []
|
||||
: [
|
||||
`${String(unconnectable)} otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.`,
|
||||
]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
|
@ -108,13 +126,12 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
|||
input: InspectElectronPageInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<ElectronPageInspection, AnalysisError>> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const roots = await canonicalElectronRoots(input.allowed_file_roots);
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
"inspect_electron_page",
|
||||
options.signal,
|
||||
);
|
||||
const target = await authorizeTarget(
|
||||
|
|
@ -122,22 +139,17 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
|||
input.target_id,
|
||||
roots,
|
||||
);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
targetSession = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
"inspect_electron_page",
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
return ok(
|
||||
electronPageInspectionSchema.parse(
|
||||
await inspectCdpElectronPage({
|
||||
connection,
|
||||
sessionId,
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
|
|
@ -151,10 +163,8 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
|||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "inspect_electron_page"));
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined) await cleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(targetSession, ["Debugger", "Page"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -175,35 +185,6 @@ const authorizeTarget = async (
|
|||
return target;
|
||||
};
|
||||
|
||||
const attachedSessionId = (value: unknown): string => {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("sessionId" in value) ||
|
||||
typeof value.sessionId !== "string" ||
|
||||
value.sessionId.length > 256
|
||||
)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
return value.sessionId;
|
||||
};
|
||||
|
||||
const cleanup = async (
|
||||
connection: CdpConnection,
|
||||
sessionId: string,
|
||||
): Promise<void> => {
|
||||
for (const method of ["Debugger.disable", "Page.disable"])
|
||||
try {
|
||||
await connection.send(method, {}, sessionId);
|
||||
} catch {
|
||||
// Detach remains the final non-destructive cleanup boundary.
|
||||
}
|
||||
try {
|
||||
await connection.send("Target.detachFromTarget", { sessionId });
|
||||
} catch {
|
||||
// Closing REA's socket never closes the Electron target.
|
||||
}
|
||||
};
|
||||
|
||||
const isFileUrl = (value: string): boolean => {
|
||||
try {
|
||||
return new URL(value).protocol === "file:";
|
||||
|
|
|
|||
|
|
@ -29,13 +29,34 @@ const endpointTargetSchema = z.object({
|
|||
title: z.string().max(16_384).default(""),
|
||||
url: z.string().max(65_536),
|
||||
attached: z.boolean().default(false),
|
||||
webSocketDebuggerUrl: z.string().min(1).max(2_048).optional(),
|
||||
});
|
||||
const endpointTargetsSchema = z.array(endpointTargetSchema).max(1_000);
|
||||
|
||||
export type CdpEndpointTarget = z.infer<typeof endpointTargetSchema>;
|
||||
/** Validated direct CDP WebSocket bound to one discovered page target. */
|
||||
export interface CdpPageWebSocketEndpoint {
|
||||
readonly scope: "page";
|
||||
readonly targetId: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
/** Validated CDP discovery socket and its command-routing scope. */
|
||||
export type CdpWebSocketEndpoint =
|
||||
| { readonly scope: "browser"; readonly url: string }
|
||||
| CdpPageWebSocketEndpoint;
|
||||
|
||||
/** Bounded discovery target with an optional validated direct transport. */
|
||||
export interface CdpEndpointTarget {
|
||||
readonly id: string;
|
||||
readonly type: string;
|
||||
readonly title: string;
|
||||
readonly url: string;
|
||||
readonly attached: boolean;
|
||||
readonly webSocket?: CdpPageWebSocketEndpoint;
|
||||
}
|
||||
|
||||
export interface CdpEndpointDiscovery {
|
||||
readonly browserWebSocketUrl: string;
|
||||
readonly webSocket: CdpWebSocketEndpoint;
|
||||
readonly version: {
|
||||
readonly product: string;
|
||||
readonly protocol_version: string;
|
||||
|
|
@ -75,7 +96,7 @@ export const discoverCdpEndpoint = async (
|
|||
operation,
|
||||
);
|
||||
return {
|
||||
browserWebSocketUrl: safeBrowserWebSocketUrl(
|
||||
webSocket: safeCdpWebSocketEndpoint(
|
||||
endpoint,
|
||||
version.webSocketDebuggerUrl,
|
||||
operation,
|
||||
|
|
@ -87,10 +108,45 @@ export const discoverCdpEndpoint = async (
|
|||
user_agent: version["User-Agent"],
|
||||
js_version: version["V8-Version"],
|
||||
},
|
||||
targets,
|
||||
targets: targets.map((target) => ({
|
||||
id: target.id,
|
||||
type: target.type,
|
||||
title: target.title,
|
||||
url: target.url,
|
||||
attached: target.attached,
|
||||
...targetWebSocket(endpoint, target, operation),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
/** Select a browser attachment socket or a direct socket for one page target. */
|
||||
export const cdpTargetWebSocket = (
|
||||
discovery: CdpEndpointDiscovery,
|
||||
target: CdpEndpointTarget,
|
||||
operation: BrowserObservationOperation,
|
||||
): CdpWebSocketEndpoint => {
|
||||
const webSocket = availableCdpTargetWebSocket(discovery, target);
|
||||
if (webSocket !== undefined) return webSocket;
|
||||
throw new BrowserObservationError(operation, "invalid_endpoint_response");
|
||||
};
|
||||
|
||||
/** Report whether discovery contains a validated transport for one target. */
|
||||
export const hasCdpTargetWebSocket = (
|
||||
discovery: CdpEndpointDiscovery,
|
||||
target: CdpEndpointTarget,
|
||||
): boolean => availableCdpTargetWebSocket(discovery, target) !== undefined;
|
||||
|
||||
const availableCdpTargetWebSocket = (
|
||||
discovery: CdpEndpointDiscovery,
|
||||
target: CdpEndpointTarget,
|
||||
): CdpWebSocketEndpoint | undefined => {
|
||||
if (discovery.webSocket.scope === "browser") return discovery.webSocket;
|
||||
if (target.webSocket !== undefined) return target.webSocket;
|
||||
return discovery.webSocket.targetId === target.id
|
||||
? discovery.webSocket
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const parseEndpointValue = <Output>(
|
||||
schema: z.ZodType<Output>,
|
||||
input: unknown,
|
||||
|
|
@ -104,11 +160,11 @@ const parseEndpointValue = <Output>(
|
|||
return parsed.data;
|
||||
};
|
||||
|
||||
const safeBrowserWebSocketUrl = (
|
||||
const safeCdpWebSocketEndpoint = (
|
||||
endpoint: string,
|
||||
reported: string,
|
||||
operation: BrowserObservationOperation,
|
||||
): string => {
|
||||
): CdpWebSocketEndpoint => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(reported);
|
||||
|
|
@ -118,6 +174,7 @@ const safeBrowserWebSocketUrl = (
|
|||
});
|
||||
}
|
||||
const trustedEndpoint = new URL(endpoint);
|
||||
const path = cdpWebSocketPath(parsed.pathname);
|
||||
if (
|
||||
parsed.protocol !== "ws:" ||
|
||||
parsed.port !== trustedEndpoint.port ||
|
||||
|
|
@ -125,11 +182,59 @@ const safeBrowserWebSocketUrl = (
|
|||
parsed.password !== "" ||
|
||||
parsed.search !== "" ||
|
||||
parsed.hash !== "" ||
|
||||
!parsed.pathname.startsWith("/devtools/browser/")
|
||||
path === undefined
|
||||
)
|
||||
throw new BrowserObservationError(operation, "invalid_endpoint_response");
|
||||
parsed.hostname = trustedEndpoint.hostname;
|
||||
return parsed.href;
|
||||
return path.scope === "browser"
|
||||
? { scope: "browser", url: parsed.href }
|
||||
: { scope: "page", targetId: path.targetId, url: parsed.href };
|
||||
};
|
||||
|
||||
const cdpWebSocketPath = (
|
||||
pathname: string,
|
||||
):
|
||||
| { readonly scope: "browser" }
|
||||
| { readonly scope: "page"; targetId: string }
|
||||
| undefined => {
|
||||
const browserId = pathIdentifier(pathname, "/devtools/browser/", 1_024);
|
||||
if (browserId !== undefined) return { scope: "browser" };
|
||||
const targetId = pathIdentifier(pathname, "/devtools/page/", 256);
|
||||
return targetId === undefined ? undefined : { scope: "page", targetId };
|
||||
};
|
||||
|
||||
const pathIdentifier = (
|
||||
pathname: string,
|
||||
prefix: string,
|
||||
maximumLength: number,
|
||||
): string | undefined => {
|
||||
if (!pathname.startsWith(prefix)) return undefined;
|
||||
const identifier = pathname.slice(prefix.length);
|
||||
return identifier.length > 0 &&
|
||||
identifier.length <= maximumLength &&
|
||||
!identifier.includes("/")
|
||||
? identifier
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const targetWebSocket = (
|
||||
endpoint: string,
|
||||
target: z.infer<typeof endpointTargetSchema>,
|
||||
operation: BrowserObservationOperation,
|
||||
): { readonly webSocket?: CdpPageWebSocketEndpoint } => {
|
||||
if (target.webSocketDebuggerUrl === undefined) return {};
|
||||
try {
|
||||
const webSocket = safeCdpWebSocketEndpoint(
|
||||
endpoint,
|
||||
target.webSocketDebuggerUrl,
|
||||
operation,
|
||||
);
|
||||
return webSocket.scope === "page" && webSocket.targetId === target.id
|
||||
? { webSocket }
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const readJson = async (
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { mainFrameUrl } from "./CdpCaptureDocuments.js";
|
|||
|
||||
interface ObservationContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly sessionId: string | undefined;
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: ObserveWebSessionInput;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
|
|||
|
||||
interface CaptureContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly sessionId: string | undefined;
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: InspectWebPageInput;
|
||||
|
|
@ -330,7 +330,7 @@ const authorizedFrameTree = async (
|
|||
|
||||
const enableObservationDomains = async (
|
||||
connection: CdpConnection,
|
||||
sessionId: string,
|
||||
sessionId: string | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
await connection.send("Runtime.enable", {}, sessionId, signal);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { decodeCanonicalBase64 } from "../domain/webScreenshot.js";
|
|||
|
||||
interface ScreenshotContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly sessionId: string | undefined;
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: CaptureWebScreenshotInput;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import {
|
||||
BrowserObservationError,
|
||||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
import { CdpConnection } from "./CdpConnection.js";
|
||||
import {
|
||||
cdpTargetWebSocket,
|
||||
type CdpEndpointDiscovery,
|
||||
type CdpEndpointTarget,
|
||||
} from "./CdpEndpoint.js";
|
||||
|
||||
/** One CDP connection scoped either by a flat browser session or page socket. */
|
||||
export interface CdpTargetSession {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string | undefined;
|
||||
}
|
||||
|
||||
/** Open one authorized target through its supported browser or page transport. */
|
||||
export const openCdpTargetSession = async (
|
||||
discovery: CdpEndpointDiscovery,
|
||||
target: CdpEndpointTarget,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CdpTargetSession> => {
|
||||
const webSocket = cdpTargetWebSocket(discovery, target, operation);
|
||||
const connection = await CdpConnection.connect(webSocket.url, signal);
|
||||
if (webSocket.scope === "page") return { connection, sessionId: undefined };
|
||||
try {
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
return { connection, sessionId: attachedSessionId(attached, operation) };
|
||||
} catch (cause: unknown) {
|
||||
await connection.close();
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
/** Disable enabled domains, detach browser sessions, and close REA's socket. */
|
||||
export const closeCdpTargetSession = async (
|
||||
targetSession: CdpTargetSession,
|
||||
enabledDomains: readonly string[],
|
||||
): Promise<void> => {
|
||||
const { connection, sessionId } = targetSession;
|
||||
for (const domain of enabledDomains)
|
||||
try {
|
||||
await connection.send(`${domain}.disable`, {}, sessionId);
|
||||
} catch {
|
||||
// Cleanup continues to the detach or direct-socket close boundary.
|
||||
}
|
||||
if (sessionId !== undefined)
|
||||
try {
|
||||
await connection.send("Target.detachFromTarget", { sessionId });
|
||||
} catch {
|
||||
// Closing REA's socket is the final non-destructive cleanup boundary.
|
||||
}
|
||||
await connection.close();
|
||||
};
|
||||
|
||||
const attachedSessionId = (
|
||||
value: unknown,
|
||||
operation: BrowserObservationOperation,
|
||||
): string => {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("sessionId" in value) ||
|
||||
typeof value.sessionId !== "string" ||
|
||||
value.sessionId.length === 0 ||
|
||||
value.sessionId.length > 256
|
||||
)
|
||||
throw new BrowserObservationError(operation, "protocol_error");
|
||||
return value.sessionId;
|
||||
};
|
||||
|
|
@ -25,7 +25,7 @@ import { boundedSensitiveText } from "./SensitiveTextCapture.js";
|
|||
|
||||
interface DiscoveryContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly sessionId: string | undefined;
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: DiscoverWebMcpToolsInput;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
import {
|
||||
browserEndpointSchema,
|
||||
browserOriginSchema,
|
||||
isLiteralLoopbackHostname,
|
||||
} from "./domain/browserObservation.js";
|
||||
import { electronFileRootsSchema } from "./domain/electronObservation.js";
|
||||
|
||||
|
|
@ -406,7 +407,7 @@ const browserNetworkScope = (
|
|||
): "loopback" | "external" =>
|
||||
origins.every((origin) => {
|
||||
const hostname = new URL(origin).hostname;
|
||||
return hostname === "127.0.0.1" || hostname === "[::1]";
|
||||
return isLiteralLoopbackHostname(hostname);
|
||||
})
|
||||
? "loopback"
|
||||
: "external";
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ export const browserOriginSchema = z
|
|||
return origin;
|
||||
});
|
||||
|
||||
const isLiteralLoopback = (hostname: string): boolean =>
|
||||
/** Recognize the URL API's bracketed IPv6 form and normalized bare literals. */
|
||||
export const isLiteralLoopbackHostname = (hostname: string): boolean =>
|
||||
hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
||||
|
||||
/** Loopback-only HTTP endpoint accepted for a user-owned CDP browser. */
|
||||
|
|
@ -64,7 +65,7 @@ export const browserEndpointSchema = z
|
|||
}
|
||||
if (
|
||||
url.protocol !== "http:" ||
|
||||
!isLiteralLoopback(url.hostname) ||
|
||||
!isLiteralLoopbackHostname(url.hostname) ||
|
||||
url.port === "" ||
|
||||
url.username !== "" ||
|
||||
url.password !== "" ||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { listBrowserTargets } from "../src/application/BrowserObservationService.js";
|
||||
import { createPermissionAuthority } from "../src/application/PermissionAuthority.js";
|
||||
import {
|
||||
browserEndpointSchema,
|
||||
browserOriginSchema,
|
||||
inspectWebPageInputSchema,
|
||||
isLiteralLoopbackHostname,
|
||||
listBrowserTargetsInputSchema,
|
||||
sanitizeBrowserUrl,
|
||||
} from "../src/domain/browserObservation.js";
|
||||
|
|
@ -44,6 +47,53 @@ describe("browser observation contracts", () => {
|
|||
expect(browserEndpointSchema.safeParse(value).success, value).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes bracketed and normalized IPv6 loopback hostnames", () => {
|
||||
for (const hostname of ["127.0.0.1", "[::1]", "::1"])
|
||||
expect(isLiteralLoopbackHostname(hostname), hostname).toBe(true);
|
||||
for (const hostname of ["localhost", "127.0.0.2", "::2"])
|
||||
expect(isLiteralLoopbackHostname(hostname), hostname).toBe(false);
|
||||
});
|
||||
|
||||
it("requests loopback authority for an IPv6-only browser origin", async () => {
|
||||
const endpoint = "http://127.0.0.1:9222";
|
||||
const origin = "http://[::1]:3000";
|
||||
const ceiling = {
|
||||
capability: "browser_observe" as const,
|
||||
roots: [],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
origins: [endpoint, origin],
|
||||
network: "loopback" as const,
|
||||
mount: false,
|
||||
};
|
||||
const authority = await createPermissionAuthority(
|
||||
[ceiling],
|
||||
[
|
||||
{
|
||||
...ceiling,
|
||||
grant_id: "administrator:browser_observe",
|
||||
lifetime: "administrator",
|
||||
operation_identity: null,
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
);
|
||||
if (!authority.ok) throw authority.error;
|
||||
const result = await listBrowserTargets(
|
||||
undefined,
|
||||
authority.value,
|
||||
listBrowserTargetsInputSchema.parse({
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [origin],
|
||||
approved: true,
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||||
});
|
||||
});
|
||||
|
||||
it("applies conservative bounded defaults to public tool input", () => {
|
||||
expect(
|
||||
listBrowserTargetsInputSchema.parse({
|
||||
|
|
|
|||
|
|
@ -53,6 +53,83 @@ describe("CdpBrowserProvider", () => {
|
|||
expect(JSON.stringify(result.value)).not.toContain("page-secret");
|
||||
});
|
||||
|
||||
it("uses page-scoped discovery sockets as direct target transports", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
pageScopedVersionWebSocket: true,
|
||||
omitTargetWebSocket: true,
|
||||
additionalPageWithWebSocket: true,
|
||||
additionalPageWithoutWebSocket: true,
|
||||
});
|
||||
browsers.push(browser);
|
||||
const provider = new CdpBrowserProvider();
|
||||
const listed = await provider.listTargets(
|
||||
listBrowserTargetsInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
approved: true,
|
||||
}),
|
||||
);
|
||||
if (!listed.ok) throw listed.error;
|
||||
expect(
|
||||
listed.value.targets.items.map(({ target_id }) => target_id),
|
||||
).toEqual(["allowed-page", "allowed-page-with-socket"]);
|
||||
expect(listed.value.limitations).toContain(
|
||||
"1 otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.",
|
||||
);
|
||||
|
||||
const inspected = await provider.inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
if (!inspected.ok) throw inspected.error;
|
||||
expect(inspected.value.target.target_id).toBe("allowed-page");
|
||||
expect(inspected.value.network.requests).toHaveLength(1);
|
||||
|
||||
const second = await provider.inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page-with-socket",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
if (!second.ok) throw second.error;
|
||||
expect(second.value.target.target_id).toBe("allowed-page-with-socket");
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).not.toContain("Target.attachToTarget");
|
||||
expect(methods).not.toContain("Target.detachFromTarget");
|
||||
expect(
|
||||
browser.commands.every(({ sessionId }) => sessionId === undefined),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty browser attachment session identifiers", async () => {
|
||||
const browser = await startFakeCdpBrowser({ invalidAttachedSession: true });
|
||||
browsers.push(browser);
|
||||
const result = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
|
||||
});
|
||||
expect(browser.commands.map(({ method }) => method)).toEqual([
|
||||
"Target.attachToTarget",
|
||||
]);
|
||||
});
|
||||
|
||||
it("captures bounded passive evidence without retaining sensitive values", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
binaryWebSocketEvent: true,
|
||||
|
|
|
|||
|
|
@ -114,6 +114,34 @@ describe("CdpElectronProvider", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("inspects a page-scoped Electron endpoint without browser attachment", async () => {
|
||||
const root = await electronFixture();
|
||||
const index = join(root, "index.html");
|
||||
const browser = await startFakeCdpBrowser({
|
||||
electronFileUrl: pathToFileURL(index).href,
|
||||
pageScopedVersionWebSocket: true,
|
||||
omitTargetWebSocket: true,
|
||||
});
|
||||
browsers.push(browser);
|
||||
const result = await new CdpElectronProvider().inspectPage(
|
||||
inspectElectronPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_file_roots: [root],
|
||||
target_id: "electron-page",
|
||||
approved: true,
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.target.file_path).toBe(index);
|
||||
expect(browser.commands.map(({ method }) => method)).not.toContain(
|
||||
"Target.attachToTarget",
|
||||
);
|
||||
expect(
|
||||
browser.commands.every(({ sessionId }) => sessionId === undefined),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("captures Electron script source only after separate approval", async () => {
|
||||
const root = await electronFixture();
|
||||
const browser = await startFakeCdpBrowser({
|
||||
|
|
|
|||
|
|
@ -232,6 +232,21 @@ describe("runtime configuration", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("classifies IPv6 browser origins as loopback network scope", () => {
|
||||
const result = parseConfig({
|
||||
REA_BROWSER_OBSERVE_ENABLED: "true",
|
||||
REA_BROWSER_CDP_ENDPOINTS_JSON: '["http://127.0.0.1:9222"]',
|
||||
REA_BROWSER_ALLOWED_ORIGINS_JSON: '["http://[::1]:3000"]',
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.permissionCeilings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
capability: "browser_observe",
|
||||
network: "loopback",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'["http://localhost:9222"]',
|
||||
'["http://192.168.1.5:9222"]',
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ interface FakeOptions {
|
|||
readonly malformedDiscovery?: boolean;
|
||||
readonly oversizedDiscovery?: boolean;
|
||||
readonly invalidBrowserWebSocket?: boolean;
|
||||
readonly pageScopedVersionWebSocket?: boolean;
|
||||
readonly omitTargetWebSocket?: boolean;
|
||||
readonly additionalPageWithWebSocket?: boolean;
|
||||
readonly additionalPageWithoutWebSocket?: boolean;
|
||||
readonly invalidAttachedSession?: boolean;
|
||||
readonly malformedMessageOnMethod?: string;
|
||||
readonly malformedEventOnMethod?: string;
|
||||
readonly malformedEventShapeOnMethod?: string;
|
||||
|
|
@ -98,8 +103,10 @@ export const startFakeCdpBrowser = async (
|
|||
"WebKit-Version": "fake-revision",
|
||||
webSocketDebuggerUrl:
|
||||
options.invalidBrowserWebSocket === true
|
||||
? `ws://localhost:${String(port)}/devtools/page/not-browser`
|
||||
: `ws://localhost:${String(port)}/devtools/browser/fake`,
|
||||
? `ws://localhost:${String(port)}/devtools/invalid/fake`
|
||||
: options.pageScopedVersionWebSocket === true
|
||||
? `ws://localhost:${String(port)}/devtools/page/${versionTargetId(options)}`
|
||||
: `ws://localhost:${String(port)}/devtools/browser/fake`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
|
|
@ -113,7 +120,12 @@ export const startFakeCdpBrowser = async (
|
|||
});
|
||||
const webSockets = new WebSocketServer({ noServer: true });
|
||||
http.on("upgrade", (request, socket, head) => {
|
||||
if (request.url !== "/devtools/browser/fake") {
|
||||
if (
|
||||
request.url !== "/devtools/browser/fake" &&
|
||||
request.url !== "/devtools/page/allowed-page" &&
|
||||
request.url !== "/devtools/page/allowed-page-with-socket" &&
|
||||
request.url !== "/devtools/page/electron-page"
|
||||
) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
|
@ -122,7 +134,7 @@ export const startFakeCdpBrowser = async (
|
|||
);
|
||||
});
|
||||
webSockets.on("connection", (socket: WebSocket, request: IncomingMessage) => {
|
||||
void request;
|
||||
const directPage = request.url?.startsWith("/devtools/page/") === true;
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
socket.on("message", (raw) => {
|
||||
|
|
@ -146,6 +158,19 @@ export const startFakeCdpBrowser = async (
|
|||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
directPage &&
|
||||
(command.method === "Target.attachToTarget" ||
|
||||
command.sessionId !== undefined)
|
||||
) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: command.id,
|
||||
error: { code: -32_600, message: "Direct page socket expected" },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command.method === "Page.getFrameTree") frameTreeReads += 1;
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
|
|
@ -197,7 +222,11 @@ const targets = (
|
|||
title: "Inspectable application",
|
||||
url: `http://127.0.0.1:${String(port)}/app?token=page-secret#fragment`,
|
||||
attached: false,
|
||||
webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page`,
|
||||
...(options.omitTargetWebSocket === true
|
||||
? {}
|
||||
: {
|
||||
webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "disallowed-page",
|
||||
|
|
@ -206,6 +235,29 @@ const targets = (
|
|||
url: "https://private.example.test/app?token=forbidden",
|
||||
attached: false,
|
||||
},
|
||||
...(options.additionalPageWithWebSocket === true
|
||||
? [
|
||||
{
|
||||
id: "allowed-page-with-socket",
|
||||
type: "page",
|
||||
title: "Second inspectable application page",
|
||||
url: `http://127.0.0.1:${String(port)}/second`,
|
||||
attached: false,
|
||||
webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page-with-socket`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(options.additionalPageWithoutWebSocket === true
|
||||
? [
|
||||
{
|
||||
id: "allowed-page-without-socket",
|
||||
type: "page",
|
||||
title: "Unavailable application page",
|
||||
url: `http://127.0.0.1:${String(port)}/unavailable`,
|
||||
attached: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "unsupported-page",
|
||||
type: "page",
|
||||
|
|
@ -254,6 +306,11 @@ const targets = (
|
|||
title: "Electron application",
|
||||
url: options.electronFileUrl,
|
||||
attached: false,
|
||||
...(options.omitTargetWebSocket === true
|
||||
? {}
|
||||
: {
|
||||
webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/electron-page`,
|
||||
}),
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
|
@ -289,7 +346,7 @@ const resultFor = (
|
|||
): Readonly<Record<string, unknown>> => {
|
||||
switch (command.method) {
|
||||
case "Target.attachToTarget":
|
||||
return { sessionId: "session-1" };
|
||||
return { sessionId: options.invalidAttachedSession ? "" : "session-1" };
|
||||
case "Page.getFrameTree":
|
||||
return frameTree(
|
||||
port,
|
||||
|
|
@ -366,6 +423,9 @@ const resultFor = (
|
|||
}
|
||||
};
|
||||
|
||||
const versionTargetId = (options: FakeOptions): string =>
|
||||
options.electronFileUrl === undefined ? "allowed-page" : "electron-page";
|
||||
|
||||
const endpointTargetToInfo = (
|
||||
target: Readonly<Record<string, unknown>>,
|
||||
): Readonly<Record<string, unknown>> => ({
|
||||
|
|
|
|||
Loading…
Reference in New Issue