Files
reverse-engineering-anything/src/browser/CdpConnection.ts
T
Ysopwnableandmorluto b5afa5f91e feat(browser): attribute native execution and listener sources (#917)
* feat(browser): attribute native execution and listener sources

Integrate finite native precise-coverage windows and direct DOM listener inspection through shared CLI/MCP workflows, exact session script identities, inline source evidence and owned instrumentation cleanup.

Refs #881, #774

Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis)
Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)

* fix(browser): preserve runtime identity and settle fixture cleanup

Retain complete reported initiator objects, reject changing loader availability, propagate the selected operation through main-frame authorization and complete effect/help inventories. Close only the synthetic owned Chrome via its native endpoint, await process-group settlement and preserve primary verification errors during cleanup.

Refs #881

Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis)
Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)

* fix(browser): settle invalid events and preserve document evidence

Accept same-document URL changes with proven stable loader identity, retain metadata without inventing ownership, terminate malformed event collection immediately, and sanitize known transport URLs without erasing sourceURL declarations.

Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis)
Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)

* fix(browser): end detached target windows and sanitize coverage identities

Reuse the existing flat-session event matcher, retain prearming termination, and apply the source identity policy to coverage URLs. Add real MCP target-close verification and producer regressions for detachment orderings.

Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis)
Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)

* fix(browser): retain final sample metadata and settle fatal protocol failures

Keep source inventory open through the resetting sample, propagate fatal wire failures without outstanding commands, and check listener collection failures after final document validation.

Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis)

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)

* fix(browser): require default execution worlds for website attribution

Preserve native context and world metadata without treating same-frame isolated or unknown contexts as website sources. Verify actual Chrome isolated function execution before excluding its coverage, and cover ambiguous world metadata with producer regressions.

Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis)

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)

* fix(browser): revalidate runtime source joins and selector failures

Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis)
Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-07 21:45:07 +08:00

338 lines
11 KiB
TypeScript

import WebSocket, { type RawData } from "ws";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { CdpCommandRejection } from "./CdpCommandRejection.js";
import { type BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import { safeParseJson } from "../domain/safeJson.js";
export interface CdpEvent {
readonly method: string;
readonly params: unknown;
readonly sessionId?: string;
}
interface PendingCommand {
readonly method: string;
readonly resolve: (value: unknown) => void;
readonly reject: (error: AnalysisError) => void;
readonly removeAbort: () => void;
}
/** Correlated JSON-RPC transport for one CDP target operation. */
export class CdpConnection {
readonly #pending = new Map<number, PendingCommand>();
readonly #listeners = new Set<(event: CdpEvent) => void>();
readonly #disconnectListeners = new Set<() => void>();
readonly #protocolFailureListeners = new Set<
(error: BrowserObservationError) => void
>();
#nextId = 1;
#closed = false;
#protocolFailed = false;
#transportFailure: "disconnected" | "payload_limit" = "disconnected";
private constructor(
private readonly socket: WebSocket,
private readonly operation: BrowserObservationOperation,
private readonly maxPayloadBytes: number,
) {
socket.on("message", (data) => this.#receive(data));
socket.on("close", () => this.#disconnect());
socket.on("error", (cause) =>
this.#disconnect(
isRecord(cause) && cause.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH"
? "payload_limit"
: "disconnected",
),
);
}
/** Connect to one already-validated loopback CDP WebSocket. */
static async connect(
url: string,
operation: BrowserObservationOperation,
signal?: AbortSignal,
limits?: { readonly maxPayloadBytes: number },
): Promise<CdpConnection> {
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
const socket = new WebSocket(url, {
handshakeTimeout: 0,
maxPayload: limits?.maxPayloadBytes ?? 0,
perMessageDeflate: false,
});
await waitForOpen(socket, operation, signal);
return new CdpConnection(socket, operation, limits?.maxPayloadBytes ?? 0);
}
/** Subscribe to validated CDP event envelopes. */
onEvent(listener: (event: CdpEvent) => void): () => void {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
/** Subscribe to an unexpected transport loss while an operation is active. */
onDisconnect(listener: () => void): () => void {
if (this.#closed) {
listener();
return () => undefined;
}
this.#disconnectListeners.add(listener);
return () => this.#disconnectListeners.delete(listener);
}
/** Notify fatal wire parsing failure even when no command is outstanding. */
onProtocolFailure(
listener: (error: BrowserObservationError) => void,
): () => void {
if (this.#protocolFailed) {
listener(this.#transportError("protocol_error"));
return () => undefined;
}
this.#protocolFailureListeners.add(listener);
return () => this.#protocolFailureListeners.delete(listener);
}
/** Execute one command, optionally within a flat target session. */
async send(
method: string,
params: Readonly<Record<string, unknown>> = {},
sessionId?: string,
signal?: AbortSignal,
): Promise<unknown> {
if (this.#protocolFailed)
throw new BrowserObservationError(this.operation, "protocol_error");
if (this.#closed || this.socket.readyState !== WebSocket.OPEN)
throw this.#transportError(this.#transportFailure);
if (signal?.aborted === true)
throw new AnalysisCancelledError(this.operation);
const id = this.#nextId;
this.#nextId += 1;
return await new Promise((resolve, reject) => {
const onAbort = (): void => {
const pending = this.#pending.get(id);
if (pending === undefined) return;
pending.removeAbort();
this.#pending.delete(id);
reject(new AnalysisCancelledError(this.operation));
};
signal?.addEventListener("abort", onAbort, { once: true });
this.#pending.set(id, {
method,
resolve,
reject,
removeAbort: () => signal?.removeEventListener("abort", onAbort),
});
this.socket.send(
JSON.stringify({
id,
method,
params,
...(sessionId === undefined ? {} : { sessionId }),
}),
(error) => {
if (error === undefined || error === null) return;
const pending = this.#pending.get(id);
if (pending === undefined) return;
this.#complete(id, pending);
reject(
new BrowserObservationError(this.operation, "disconnected", {
cause: error,
}),
);
},
);
});
}
/** Close only REA's socket; never close the browser or selected page. */
async close(): Promise<void> {
if (this.#closed) return;
this.#closed = true;
this.#failPending("disconnected");
if (this.socket.readyState === WebSocket.CLOSED) return;
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
this.socket.terminate();
resolve();
}, 1_000);
this.socket.once("close", () => {
clearTimeout(timer);
resolve();
});
this.socket.close();
});
}
#receive(data: RawData): void {
if (this.#closed || this.#protocolFailed) return;
const parsed = safeParseJson(rawText(data));
if (!parsed.ok) {
this.#failPending("protocol_error");
return;
}
const message: unknown = parsed.value;
if (!isRecord(message)) {
this.#failPending("protocol_error");
return;
}
if ("id" in message) {
if (
typeof message.id !== "number" ||
!Number.isSafeInteger(message.id) ||
message.id < 1
) {
this.#failPending("protocol_error");
return;
}
this.#receiveResponse(message, message.id);
return;
}
if (
typeof message.method !== "string" ||
message.method.length === 0 ||
("params" in message && !isRecord(message.params)) ||
("sessionId" in message && typeof message.sessionId !== "string")
) {
this.#failPending("protocol_error");
return;
}
const event: CdpEvent = {
method: message.method,
params: "params" in message ? message.params : {},
...(typeof message.sessionId === "string"
? { sessionId: message.sessionId }
: {}),
};
for (const listener of this.#listeners) listener(event);
}
#receiveResponse(message: Record<string, unknown>, id: number): void {
const pending = this.#pending.get(id);
if (pending === undefined) return;
this.#complete(id, pending);
if ("error" in message) {
const reported = message.error;
pending.reject(
new CdpCommandRejection(
this.operation,
pending.method,
isRecord(reported) && typeof reported.code === "number"
? reported.code
: null,
isRecord(reported) && typeof reported.message === "string"
? reported.message
: null,
),
);
return;
}
pending.resolve("result" in message ? message.result : {});
}
#complete(id: number, pending: PendingCommand): void {
pending.removeAbort();
this.#pending.delete(id);
}
#failPending(
reason: "disconnected" | "protocol_error" | "payload_limit",
): void {
const wasProtocolFailed = this.#protocolFailed;
if (reason !== "protocol_error") this.#closed = true;
else this.#protocolFailed = true;
for (const [id, pending] of this.#pending) {
this.#complete(id, pending);
pending.reject(this.#transportError(reason));
}
if (reason === "protocol_error" && !wasProtocolFailed) {
const error = this.#transportError(reason);
for (const listener of this.#protocolFailureListeners) listener(error);
this.#protocolFailureListeners.clear();
}
}
#disconnect(reason: "disconnected" | "payload_limit" = "disconnected"): void {
const wasClosed = this.#closed;
if (!wasClosed) this.#transportFailure = reason;
this.#failPending(reason);
if (wasClosed) return;
for (const listener of this.#disconnectListeners) listener();
this.#disconnectListeners.clear();
}
#transportError(
reason: "disconnected" | "protocol_error" | "payload_limit",
): BrowserObservationError {
return new BrowserObservationError(
this.operation,
reason,
reason === "payload_limit"
? {
detail: `CDP message exceeded the selected ${String(this.maxPayloadBytes)} byte protocol budget.`,
}
: undefined,
);
}
}
const rawText = (data: RawData): string =>
Array.isArray(data)
? Buffer.concat(data).toString("utf8")
: Buffer.isBuffer(data)
? data.toString("utf8")
: Buffer.from(data).toString("utf8");
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const waitForOpen = async (
socket: WebSocket,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): Promise<void> =>
await new Promise((resolve, reject) => {
const onOpen = (): void => {
cleanup();
resolve();
};
const onFailure = (cause: unknown): void => {
cleanup();
reject(
signal?.aborted === true
? new AnalysisCancelledError(operation)
: new BrowserObservationError(operation, "endpoint_unreachable", {
cause,
}),
);
};
const onClose = (code: number, reason: Buffer): void => {
onFailure(
new Error(
`CDP WebSocket closed before opening (${code}: ${reason.toString("utf8")})`,
),
);
};
const onAbort = (): void => {
terminateSocket(socket);
onFailure(new AnalysisCancelledError(operation));
};
const cleanup = (): void => {
socket.off("open", onOpen);
socket.off("error", onFailure);
socket.off("close", onClose);
signal?.removeEventListener("abort", onAbort);
};
socket.once("open", onOpen);
socket.once("error", onFailure);
socket.once("close", onClose);
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted === true) onAbort();
});
const terminateSocket = (socket: WebSocket): void => {
socket.once("error", () => undefined);
socket.terminate();
};