mirror of
https://github.com/morluto/rea.git
synced 2026-10-31 20:06:14 +00:00
* 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>
444 lines
14 KiB
TypeScript
444 lines
14 KiB
TypeScript
import type { BrowserObservationPort } from "../application/BrowserObservationPort.js";
|
|
import type { ProviderIdentity } from "../application/AnalysisProvider.js";
|
|
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
|
|
import {
|
|
browserTargetListSchema,
|
|
sanitizeBrowserUrl,
|
|
webPageInspectionSchema,
|
|
type BrowserTargetList,
|
|
type InspectWebPageInput,
|
|
type ListBrowserTargetsInput,
|
|
type WebPageInspection,
|
|
} from "../domain/browserObservation.js";
|
|
import { analyzeCapturedWebBundle } from "../domain/webBundleAnalyzer.js";
|
|
import {
|
|
webBundleAnalysisSchema,
|
|
type AnalyzeWebBundleInput,
|
|
type WebBundleAnalysis,
|
|
} from "../domain/webBundleAnalysis.js";
|
|
import {
|
|
webObservationSessionSchema,
|
|
type ObserveWebSessionInput,
|
|
type WebObservationSession,
|
|
} from "../domain/browserSession.js";
|
|
import {
|
|
webMcpDiscoverySchema,
|
|
type DiscoverWebMcpToolsInput,
|
|
type WebMcpDiscovery,
|
|
} from "../domain/webMcpDiscovery.js";
|
|
import {
|
|
browserCaptureComparisonSchema,
|
|
compareBrowserCaptures,
|
|
type BrowserCaptureComparison,
|
|
type BrowserCaptureComparisonInput,
|
|
} from "../domain/browserCaptureComparison.js";
|
|
import {
|
|
webScreenshotDiffSchema,
|
|
webScreenshotSchema,
|
|
type CaptureWebScreenshotInput,
|
|
type CompareWebScreenshotsInput,
|
|
type WebScreenshot,
|
|
type WebScreenshotDiff,
|
|
} from "../domain/webScreenshot.js";
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
|
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 {
|
|
closeCdpTargetSession,
|
|
openCdpTargetSession,
|
|
type CdpTargetSession,
|
|
} from "./CdpTargetSession.js";
|
|
import { capturePage, type CapturedPage } from "./CdpPageCapture.js";
|
|
import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js";
|
|
import { authorizeCdpTarget } from "./CdpAuthorizedTarget.js";
|
|
import { observeCdpSession } from "./CdpObservationSession.js";
|
|
import { discoverWebMcp } from "./CdpWebMcpDiscovery.js";
|
|
import { captureCdpScreenshot } from "./CdpScreenshot.js";
|
|
import { comparePngScreenshots } from "./PngVisualDiff.js";
|
|
|
|
/** Public identity committed by passive browser observations. */
|
|
export { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
|
import { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
|
const IDENTITY = CDP_BROWSER_PROVIDER_IDENTITY;
|
|
const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const;
|
|
|
|
/** Passive browser observation through a selected user-owned CDP endpoint. */
|
|
export class CdpBrowserProvider implements BrowserObservationPort {
|
|
identity(): ProviderIdentity {
|
|
return IDENTITY;
|
|
}
|
|
|
|
async listTargets(
|
|
input: ListBrowserTargetsInput,
|
|
options: ExecutionOptions = {},
|
|
): Promise<Result<BrowserTargetList, AnalysisError>> {
|
|
try {
|
|
const discovery = await discoverCdpEndpoint(
|
|
input.cdp_endpoint,
|
|
"list_browser_targets",
|
|
options.signal,
|
|
);
|
|
const selected = selectTargets(
|
|
discovery,
|
|
input.allowed_origins.length === 0
|
|
? undefined
|
|
: new Set(input.allowed_origins),
|
|
);
|
|
return ok(
|
|
browserTargetListSchema.parse({
|
|
browser: discovery.version,
|
|
targets: selected.allowed,
|
|
excluded: selected.excluded,
|
|
limitations: [
|
|
input.allowed_origins.length === 0
|
|
? "All page targets with supported HTTP(S) URLs are listed."
|
|
: "Only page targets whose current URL matches an explicitly requested exact origin are listed.",
|
|
...(selected.unconnectable === 0
|
|
? []
|
|
: [
|
|
`${String(selected.unconnectable)} otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.`,
|
|
]),
|
|
],
|
|
}),
|
|
);
|
|
} catch (cause: unknown) {
|
|
return err(providerError(cause, "list_browser_targets"));
|
|
}
|
|
}
|
|
|
|
async inspectPage(
|
|
input: InspectWebPageInput,
|
|
options: ExecutionOptions = {},
|
|
): Promise<Result<WebPageInspection, AnalysisError>> {
|
|
try {
|
|
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"));
|
|
}
|
|
}
|
|
|
|
async analyzeBundle(
|
|
input: AnalyzeWebBundleInput,
|
|
options: ExecutionOptions = {},
|
|
): Promise<Result<WebBundleAnalysis, AnalysisError>> {
|
|
try {
|
|
const captured = await this.#capture(
|
|
input,
|
|
options,
|
|
"analyze_web_bundle",
|
|
);
|
|
const sourceMaps = input.fetch_source_maps
|
|
? await fetchWebSourceMaps(
|
|
captured.sourceMapRequests,
|
|
{
|
|
...input,
|
|
allowed_origins:
|
|
input.allowed_origins.length > 0
|
|
? input.allowed_origins
|
|
: [captured.inspection.target.origin],
|
|
},
|
|
options.signal,
|
|
)
|
|
: undefined;
|
|
return ok(
|
|
webBundleAnalysisSchema.parse(
|
|
analyzeCapturedWebBundle(captured.inspection, sourceMaps),
|
|
),
|
|
);
|
|
} catch (cause: unknown) {
|
|
return err(providerError(cause, "analyze_web_bundle"));
|
|
}
|
|
}
|
|
|
|
async observeSession(
|
|
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 }),
|
|
}),
|
|
),
|
|
);
|
|
} catch (cause: unknown) {
|
|
return err(providerError(cause, "observe_web_session"));
|
|
} finally {
|
|
if (targetSession !== undefined)
|
|
await closeCdpTargetSession(
|
|
targetSession,
|
|
CLEANUP_DOMAINS,
|
|
options.signal,
|
|
);
|
|
}
|
|
}
|
|
|
|
async discoverWebMcpTools(
|
|
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 }),
|
|
}),
|
|
),
|
|
);
|
|
} catch (cause: unknown) {
|
|
return err(providerError(cause, "discover_webmcp_tools"));
|
|
} finally {
|
|
if (targetSession !== undefined)
|
|
await closeCdpTargetSession(
|
|
targetSession,
|
|
CLEANUP_DOMAINS,
|
|
options.signal,
|
|
);
|
|
}
|
|
}
|
|
|
|
async compareCaptures(
|
|
input: BrowserCaptureComparisonInput,
|
|
): Promise<Result<BrowserCaptureComparison, AnalysisError>> {
|
|
try {
|
|
return ok(
|
|
browserCaptureComparisonSchema.parse(compareBrowserCaptures(input)),
|
|
);
|
|
} catch (cause: unknown) {
|
|
return err(providerError(cause, "compare_web_captures"));
|
|
}
|
|
}
|
|
|
|
async captureScreenshot(
|
|
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 }),
|
|
}),
|
|
),
|
|
);
|
|
} catch (cause: unknown) {
|
|
return err(providerError(cause, "capture_web_screenshot"));
|
|
} finally {
|
|
if (targetSession !== undefined)
|
|
await closeCdpTargetSession(
|
|
targetSession,
|
|
CLEANUP_DOMAINS,
|
|
options.signal,
|
|
);
|
|
}
|
|
}
|
|
|
|
async compareScreenshots(
|
|
input: CompareWebScreenshotsInput,
|
|
): Promise<Result<WebScreenshotDiff, AnalysisError>> {
|
|
try {
|
|
return ok(webScreenshotDiffSchema.parse(comparePngScreenshots(input)));
|
|
} catch (cause: unknown) {
|
|
return err(providerError(cause, "compare_web_screenshots"));
|
|
}
|
|
}
|
|
|
|
async #capture(
|
|
input: InspectWebPageInput,
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const selectTargets = (
|
|
discovery: CdpEndpointDiscovery,
|
|
allowedOrigins: ReadonlySet<string> | undefined,
|
|
): {
|
|
readonly allowed: readonly BrowserTargetList["targets"][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;
|
|
continue;
|
|
}
|
|
const url = sanitizeBrowserUrl(target.url);
|
|
if (url.origin === null) {
|
|
unsupportedUrl += 1;
|
|
continue;
|
|
}
|
|
if (allowedOrigins !== undefined && !allowedOrigins.has(url.origin)) {
|
|
disallowedOrigin += 1;
|
|
continue;
|
|
}
|
|
if (!hasCdpTargetWebSocket(discovery, target)) {
|
|
unconnectable += 1;
|
|
continue;
|
|
}
|
|
allowed.push({
|
|
target_id: target.id,
|
|
type: target.type,
|
|
title: target.title,
|
|
url: url.url,
|
|
origin: url.origin,
|
|
attached: target.attached,
|
|
});
|
|
}
|
|
allowed.sort((left, right) => left.target_id.localeCompare(right.target_id));
|
|
return {
|
|
allowed,
|
|
excluded: {
|
|
disallowed_origin: disallowedOrigin,
|
|
unsupported_url: unsupportedUrl,
|
|
non_page: nonPage,
|
|
},
|
|
unconnectable,
|
|
};
|
|
};
|
|
|
|
const authorizeTarget = (
|
|
discovery: CdpEndpointDiscovery,
|
|
input: Pick<InspectWebPageInput, "target_id" | "allowed_origins">,
|
|
): CdpEndpointTarget => {
|
|
return authorizeCdpTarget(discovery, input, "inspect_web_page").target;
|
|
};
|
|
|
|
const scopeToTargetOrigin = <T extends { allowed_origins: readonly string[] }>(
|
|
input: T,
|
|
target: CdpEndpointTarget,
|
|
): T => {
|
|
if (input.allowed_origins.length > 0) return input;
|
|
const origin = sanitizeBrowserUrl(target.url).origin;
|
|
if (origin === null)
|
|
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
|
return { ...input, allowed_origins: [origin] };
|
|
};
|
|
|
|
const providerError = (
|
|
cause: unknown,
|
|
operation: BrowserObservationOperation,
|
|
): AnalysisError =>
|
|
cause instanceof BrowserObservationError && cause.operation !== operation
|
|
? new BrowserObservationError(operation, cause.reason, { cause })
|
|
: cause instanceof AnalysisError
|
|
? cause
|
|
: new ProviderAdapterError(IDENTITY.id, operation, { cause });
|