mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 13:26:10 +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>
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { z } from "zod";
|
|
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
|
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
|
import { sanitizeBrowserUrl } from "../../domain/browserObservation.js";
|
|
import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
|
import {
|
|
WEB_RUNTIME_LIMITS,
|
|
type WebRuntimeSource,
|
|
type webRuntimeLocationSchema,
|
|
} from "../../domain/webRuntime.js";
|
|
import type { CdpEvent } from "../CdpConnection.js";
|
|
import type { CdpRuntimeSession } from "./CdpRuntimeSession.js";
|
|
import {
|
|
runtimeContextSchema,
|
|
runtimeScriptParsedSchema,
|
|
} from "./CdpRuntimeProtocol.js";
|
|
|
|
const scriptIdentitySchema = runtimeScriptParsedSchema.pick({ scriptId: true });
|
|
const contextIdentitySchema = z.object({
|
|
context: runtimeContextSchema.shape.context.pick({ id: true }),
|
|
});
|
|
|
|
/** Source ownership is proven by execution context/frame metadata, independently of URL aliases. */
|
|
export class CdpRuntimeSources {
|
|
readonly scripts = new Map<
|
|
string,
|
|
z.infer<typeof runtimeScriptParsedSchema>
|
|
>();
|
|
readonly #contexts = new Map<
|
|
number,
|
|
z.infer<typeof runtimeContextSchema>["context"]
|
|
>();
|
|
readonly #changed = new Set<string>();
|
|
readonly #changedContexts = new Set<number>();
|
|
readonly #sources = new Map<string, WebRuntimeSource>();
|
|
#retainedBytes = 0;
|
|
#sourceBytes = 0;
|
|
#failure: AnalysisError | undefined;
|
|
constructor(readonly session: CdpRuntimeSession) {}
|
|
|
|
/** Subscribe before enabling Debugger; existing uncollected scripts are replayed by the producer. */
|
|
ingest(event: CdpEvent, retainNewMetadata = true): void {
|
|
if (
|
|
event.sessionId !== this.session.transport.sessionId ||
|
|
this.#failure !== undefined
|
|
)
|
|
return;
|
|
try {
|
|
if (event.method === "Runtime.executionContextCreated") {
|
|
this.#retainedBytes += Buffer.byteLength(JSON.stringify(event.params));
|
|
if (this.#retainedBytes > WEB_RUNTIME_LIMITS.retainedEventBytes)
|
|
throw new Error(
|
|
"Context/script metadata exceeds the complete 8 MiB event budget.",
|
|
);
|
|
const context = runtimeContextSchema.parse(event.params).context;
|
|
const previous = this.#contexts.get(context.id);
|
|
if (
|
|
previous !== undefined &&
|
|
JSON.stringify(previous) !== JSON.stringify(context)
|
|
) {
|
|
this.#changedContexts.add(context.id);
|
|
for (const item of this.#sources.values())
|
|
if (item.execution_context_id === context.id) this.revalidate(item);
|
|
}
|
|
if (retainNewMetadata) this.#contexts.set(context.id, context);
|
|
} else if (event.method === "Debugger.scriptParsed") {
|
|
this.#retainedBytes += Buffer.byteLength(JSON.stringify(event.params));
|
|
if (this.#retainedBytes > WEB_RUNTIME_LIMITS.retainedEventBytes)
|
|
throw new Error(
|
|
"Script metadata exceeds the complete 8 MiB event budget.",
|
|
);
|
|
const script = runtimeScriptParsedSchema.parse(event.params);
|
|
const previous = this.scripts.get(script.scriptId);
|
|
if (
|
|
previous !== undefined &&
|
|
JSON.stringify(previous) !== JSON.stringify(script)
|
|
) {
|
|
this.#changed.add(script.scriptId);
|
|
const retained = this.#sources.get(script.scriptId);
|
|
if (retained !== undefined) this.revalidate(retained);
|
|
}
|
|
if (retainNewMetadata) this.scripts.set(script.scriptId, script);
|
|
}
|
|
} catch (cause: unknown) {
|
|
this.#failure = new AnalysisOutputError(
|
|
this.session.operation,
|
|
cause instanceof Error ? cause.message : String(cause),
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Producer parse/resource failures are surfaced outside the transport event callback. */
|
|
check(): void {
|
|
if (this.#failure !== undefined) throw this.#failure;
|
|
}
|
|
|
|
/** Frozen inventories still monitor known identities through asynchronous source reads. */
|
|
verifyKnownIdentity(event: CdpEvent): void {
|
|
if (event.method === "Debugger.scriptParsed") {
|
|
const id = scriptIdentitySchema.safeParse(event.params);
|
|
if (id.success && this.scripts.has(id.data.scriptId))
|
|
this.ingest(event, false);
|
|
} else if (event.method === "Runtime.executionContextCreated") {
|
|
const id = contextIdentitySchema.safeParse(event.params);
|
|
if (id.success && this.#contexts.has(id.data.context.id))
|
|
this.ingest(event, false);
|
|
}
|
|
}
|
|
|
|
/** Only proven default worlds of the selected main frame establish website source ownership. */
|
|
belongsToDocument(scriptId: string): boolean {
|
|
const script = this.scripts.get(scriptId);
|
|
if (
|
|
script === undefined ||
|
|
this.#changed.has(scriptId) ||
|
|
this.#changedContexts.has(script.executionContextId)
|
|
)
|
|
return false;
|
|
const scriptContext = script.executionContextAuxData;
|
|
const context = this.#contexts.get(script.executionContextId)?.auxData;
|
|
const worlds = [scriptContext, context];
|
|
if (
|
|
worlds.some(
|
|
(world) =>
|
|
world?.isDefault === false ||
|
|
(world?.type !== undefined && world.type !== "default"),
|
|
) ||
|
|
!worlds.some(
|
|
(world) => world?.isDefault === true || world?.type === "default",
|
|
)
|
|
)
|
|
return false;
|
|
const reported = scriptContext?.frameId;
|
|
const contextFrame = context?.frameId;
|
|
return (
|
|
(reported === undefined ||
|
|
contextFrame === undefined ||
|
|
reported === contextFrame) &&
|
|
(reported ?? contextFrame) === this.session.target.frame_id
|
|
);
|
|
}
|
|
|
|
/** Retain complete script text and compute a digest distinct from the producer hash. */
|
|
async read(scriptIds: Iterable<string>): Promise<WebRuntimeSource[]> {
|
|
this.check();
|
|
for (const scriptId of new Set(scriptIds)) {
|
|
if (this.#sources.has(scriptId)) continue;
|
|
const script = this.scripts.get(scriptId);
|
|
const owned = this.belongsToDocument(scriptId);
|
|
const context =
|
|
script === undefined
|
|
? undefined
|
|
: this.#contexts.get(script.executionContextId);
|
|
const item: WebRuntimeSource = {
|
|
script_id: scriptId,
|
|
url: this.sourceUrl(scriptId, script?.url ?? ""),
|
|
execution_context_id: script?.executionContextId ?? null,
|
|
reported_execution_context:
|
|
context === undefined ? null : jsonObjectSchema.parse(context),
|
|
reported_script_context_aux_data:
|
|
script?.executionContextAuxData === undefined
|
|
? null
|
|
: jsonObjectSchema.parse(script.executionContextAuxData),
|
|
frame_id: owned ? this.session.target.frame_id : null,
|
|
producer_hash: script?.hash ?? null,
|
|
source_map_url: script?.sourceMapURL ?? null,
|
|
has_source_url: script?.hasSourceURL ?? null,
|
|
language: script?.scriptLanguage ?? null,
|
|
resource_start:
|
|
script !== undefined
|
|
? {
|
|
line_number: script.startLine,
|
|
column_number: script.startColumn,
|
|
}
|
|
: null,
|
|
source: {
|
|
state: "excluded",
|
|
reason: this.#changed.has(scriptId)
|
|
? "The script changed identity during observation; source attribution is unknown."
|
|
: "Selected main-document default-world ownership was not established.",
|
|
},
|
|
};
|
|
if (owned && script?.scriptLanguage !== "WebAssembly")
|
|
item.source = await this.capture(scriptId);
|
|
else if (owned)
|
|
item.source = {
|
|
state: "excluded",
|
|
reason:
|
|
"WebAssembly bytecode is outside this JavaScript source operation.",
|
|
};
|
|
this.revalidate(item);
|
|
this.#sources.set(scriptId, item);
|
|
}
|
|
this.check();
|
|
return [...this.#sources.values()];
|
|
}
|
|
|
|
/** Resolve an exact producer script ID; URL equality is never an identity fallback. */
|
|
location(
|
|
scriptId: string,
|
|
url: string | null,
|
|
line: number,
|
|
column: number | null,
|
|
functionName: string | null = null,
|
|
): z.infer<typeof webRuntimeLocationSchema> {
|
|
return {
|
|
script_id: scriptId,
|
|
url: url === null ? null : this.sourceUrl(scriptId, url),
|
|
line_number: line,
|
|
column_number: column,
|
|
function_name: functionName,
|
|
source_association: this.belongsToDocument(scriptId)
|
|
? "script_id"
|
|
: "unknown",
|
|
};
|
|
}
|
|
|
|
/** Declared script names are inert evidence; known resource URLs omit transport userinfo. */
|
|
sourceUrl(scriptId: string, url: string): string {
|
|
return !this.#changed.has(scriptId) &&
|
|
this.scripts.get(scriptId)?.hasSourceURL === false
|
|
? sanitizeBrowserUrl(url).url
|
|
: url;
|
|
}
|
|
|
|
private revalidate(item: WebRuntimeSource): void {
|
|
if (item.frame_id === null || this.belongsToDocument(item.script_id))
|
|
return;
|
|
const previous =
|
|
item.source.state === "unavailable"
|
|
? ` Previous source read failure: ${item.source.reason}`
|
|
: "";
|
|
item.frame_id = null;
|
|
item.source = {
|
|
state: "excluded",
|
|
reason: `The script or context changed identity during source collection; attribution is unknown.${previous}`,
|
|
};
|
|
}
|
|
|
|
private async capture(scriptId: string): Promise<WebRuntimeSource["source"]> {
|
|
let raw: unknown;
|
|
try {
|
|
raw = await this.session.command("Debugger.getScriptSource", {
|
|
scriptId,
|
|
});
|
|
} catch (cause: unknown) {
|
|
if (
|
|
this.session.options.signal?.aborted ||
|
|
!(cause instanceof AnalysisError)
|
|
)
|
|
throw cause;
|
|
return {
|
|
state: "unavailable",
|
|
reason: cause.userMessage ?? cause.message,
|
|
};
|
|
}
|
|
const source = z.object({ scriptSource: z.string() }).safeParse(raw);
|
|
if (!source.success)
|
|
throw new AnalysisOutputError(
|
|
this.session.operation,
|
|
"Debugger.getScriptSource returned malformed source text.",
|
|
);
|
|
const text = source.data.scriptSource;
|
|
const bytes = Buffer.byteLength(text);
|
|
this.#sourceBytes += bytes;
|
|
if (this.#sourceBytes > WEB_RUNTIME_LIMITS.sourceBytes)
|
|
throw new AnalysisOutputError(
|
|
this.session.operation,
|
|
"Complete runtime source evidence exceeds its 32 MiB budget; no partial evidence is returned.",
|
|
);
|
|
return {
|
|
state: "captured",
|
|
text,
|
|
sha256: createHash("sha256").update(text).digest("hex"),
|
|
utf8_bytes: bytes,
|
|
utf16_units: text.length,
|
|
};
|
|
}
|
|
}
|