Files
reverse-engineering-anything/src/domain/analysisView/scenarioCaptureRecords.ts
T
Edgar ArakelyanandClaude Opus 5.5 ad439bd2f2 feat(environment): extract the initial state a capture's reads observed
Add the environment-state analysis view. From the parents the environment
manifest accepts, it extracts the data the capture's reads observed before
the first related write: entity types with an identity field (proved by a
path parameter or inferred from an identity name), entities with their
recorded values and provenance, documents for written non-entity paths, and
a render plan per read tool that is kept only when it reproduces the tool's
latest initial recordings from the state; every other read tool is a
recorded view with the reason. The Evidence is always inferred.

Response-body decoding moves to a domain owner shared with replay, exact
JSON parsing moves to the owner of the exact-number policy, the manifest and
state views share parent admission and record grouping, and
projectAnalysisView takes its accumulators as one object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:29:37 -07:00

309 lines
11 KiB
TypeScript

import { AnalysisUnsupportedTargetError } from "../analysisErrorCore.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import type {
BrowserNetworkBody,
BrowserNetworkHeaders,
} from "../browserNetworkEvidence.js";
import {
browserScenarioCaptureSchema,
type BrowserScenarioCapture,
} from "../browserScenarioCapture.js";
import type { BrowserScenarioEvent } from "../browserScenarioCaptureValues.js";
import type { JsonValue } from "../jsonValue.js";
import { err, ok, type Result } from "../result.js";
import type { WebNetworkCaptureRecord } from "../webNetworkCapture.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import type { AnalysisViewParent } from "./analysisView.js";
import type { WebNetworkProjectionAccumulator } from "./WebNetworkProjectionAccumulator.js";
type RequestEvent = Extract<BrowserScenarioEvent, { kind: "request" }>;
type ResponseEvent = Extract<BrowserScenarioEvent, { kind: "response" }>;
type ContentEvent = Extract<BrowserScenarioEvent, { kind: "network-content" }>;
type CapturedBody = Extract<BrowserNetworkBody, { state: "captured" }>;
/** A scenario capture's network transactions in the record shape HAR captures project from. */
export interface ScenarioCaptureRecords {
readonly records: readonly WebNetworkCaptureRecord[];
readonly totalRecords: number;
readonly limitations: readonly string[];
}
interface TransactionIndex {
readonly requests: { readonly event: RequestEvent; readonly item: number }[];
readonly requestCounts: Map<string, number>;
readonly responses: Map<string, ResponseEvent[]>;
/** Content events keyed by the sequence of the request or response event they belong to. */
readonly contents: Map<number, ContentEvent[]>;
}
const append = <Key, Value>(
map: Map<Key, Value[]>,
key: Key,
value: Value,
): void => {
const existing = map.get(key);
if (existing === undefined) map.set(key, [value]);
else existing.push(value);
};
const indexTransactions = (
events: readonly BrowserScenarioEvent[],
): TransactionIndex => {
const index: TransactionIndex = {
requests: [],
requestCounts: new Map(),
responses: new Map(),
contents: new Map(),
};
for (const [item, event] of events.entries()) {
if (event.kind === "request") {
index.requests.push({ event, item });
index.requestCounts.set(
event.transaction_id,
(index.requestCounts.get(event.transaction_id) ?? 0) + 1,
);
} else if (event.kind === "response")
append(index.responses, event.transaction_id, event);
else if (event.kind === "network-content")
append(index.contents, event.source_event_sequence, event);
}
return index;
};
/** The one content event of an exchange phase, or undefined when absent or ambiguous. */
const onlyContent = (
index: TransactionIndex,
sequence: number | undefined,
): ContentEvent | undefined => {
const found =
sequence === undefined ? undefined : index.contents.get(sequence);
return found?.length === 1 ? found[0] : undefined;
};
const headerValue = (
headers: BrowserNetworkHeaders | undefined,
name: string,
): string | undefined => {
if (headers?.state !== "captured") return undefined;
const item = headers.items.find(
(candidate) => candidate.name.toLowerCase() === name,
);
return item?.value ?? undefined;
};
const captured = (
content: ContentEvent | undefined,
): CapturedBody | undefined =>
content?.body.state === "captured" ? content.body : undefined;
/** A captured response body, unless redaction emptied it because a secret could not be cut out. */
const retainedBody = (
content: ContentEvent | undefined,
): CapturedBody | undefined => {
const body = captured(content);
return body?.redacted === true && body.bytes === 0 ? undefined : body;
};
/** Strictly decoded text, keeping a byte-order mark; undefined for bytes that are not UTF-8. */
const utf8Text = (body: CapturedBody): string | undefined => {
try {
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
Buffer.from(body.content, "base64"),
);
} catch {
return undefined;
}
};
const mediaType = (content: ContentEvent | undefined): string | undefined =>
captured(content)?.media_type ??
headerValue(content?.headers, "content-type");
/** The request object HAR readers expect; a redacted URL is withheld so no path is templated from it. */
const reportedRequest = (
request: RequestEvent,
content: ContentEvent | undefined,
purpose: "manifest" | "replay",
): { readonly [key: string]: JsonValue } => {
const body = purpose === "manifest" ? captured(content) : undefined;
const text = body === undefined ? undefined : utf8Text(body);
const mimeType = mediaType(content);
return {
method: request.method,
url: request.url.redacted ? null : request.url.url,
...(text === undefined || mimeType === undefined
? {}
: { postData: { mimeType, text } }),
};
};
const record = (
ordinal: number,
observed: TransactionIndex["requests"][number],
index: TransactionIndex,
purpose: "manifest" | "replay",
): {
readonly record: WebNetworkCaptureRecord;
/** The record keeps a usable request line, so the manifest may cite it. */
readonly represented: boolean;
readonly body: boolean;
readonly redacted: boolean;
} => {
const { item, event: request } = observed;
const location = {
kind: "json-pointer" as const,
pointer: `/events/items/${String(item)}`,
};
const empty = {
binary_fields: [],
numeric_literals: [],
redactions: [],
limitations: [],
};
const responses = index.responses.get(request.transaction_id) ?? [];
const response = responses.length === 1 ? responses[0] : undefined;
// One request, at most one response and one content event per phase: otherwise the
// transaction cannot be attributed and is left unrepresented.
if (
index.requestCounts.get(request.transaction_id) !== 1 ||
responses.length > 1 ||
(index.contents.get(request.sequence)?.length ?? 0) > 1 ||
(response !== undefined &&
(index.contents.get(response.sequence)?.length ?? 0) > 1)
)
return {
record: { ordinal, location, reported: {}, ...empty },
represented: false,
body: false,
redacted: false,
};
const requestContent = onlyContent(index, request.sequence);
const responseContent = onlyContent(index, response?.sequence);
const body = retainedBody(responseContent);
const mimeType = mediaType(responseContent) ?? "";
return {
record: {
ordinal,
location,
reported: {
request: reportedRequest(request, requestContent, purpose),
...(response === undefined
? {}
: {
response: {
status: response.status,
content:
body === undefined
? { mimeType }
: { mimeType, text: body.content, encoding: "base64" },
},
}),
},
...empty,
binary_fields:
body === undefined
? []
: [
{
pointer: "/response/content/text",
representation: "har-base64-content",
state: "retained",
bytes: body.bytes,
sha256: body.sha256,
content_base64: body.content,
},
],
},
represented: !request.url.redacted,
body: body !== undefined,
redacted: body?.redacted === true,
};
};
/** Project each network transaction, in request order, as one record; the ordinal is its zero-based position. */
export const scenarioCaptureRecords = (
capture: BrowserScenarioCapture,
purpose: "manifest" | "replay",
): ScenarioCaptureRecords => {
const index = indexTransactions(capture.events.items);
const records: WebNetworkCaptureRecord[] = [];
let withoutBody = 0;
let representedCount = 0;
let redactedBodies = 0;
for (const [ordinal, observed] of index.requests.entries()) {
const projected = record(ordinal, observed, index, purpose);
records.push(projected.record);
if (!projected.represented) continue;
representedCount += 1;
if (!projected.body) withoutBody += 1;
if (projected.redacted) redactedBodies += 1;
}
return {
records,
totalRecords: records.length,
limitations: [
"Scenario records are the capture's network transactions in request order; each record ordinal is the transaction's zero-based position.",
...(capture.events.dropped > 0
? [
`The scenario capture omitted ${String(capture.events.dropped)} events; the manifest covers only retained transactions.`,
]
: []),
...(withoutBody > 0
? [
`${String(withoutBody)} of ${String(representedCount)} scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.`,
]
: []),
...(redactedBodies > 0
? [
`${String(redactedBodies)} of ${String(representedCount)} scenario transactions with a usable request line retained a response body with declared secret values replaced by redaction markers; tools citing them replay the redacted bytes.`,
]
: []),
],
};
};
/** Admit a capture_browser_scenario parent whose network content identifies response media types; state also needs bodies. */
export const admitScenarioCaptureParent = (
parent: AnalysisViewParent,
viewKind: "environment-manifest" | "environment-state",
accumulator: WebNetworkProjectionAccumulator,
): Result<ScenarioCaptureRecords, AnalysisError> => {
// Body validation decodes base64, and projection constructs UTF-8 request text.
// Reserve their expanded strings before either allocation, even for retained parents.
const admitted = accumulator.admitCaptureClone(parent.normalizedResult, {
copiedStrings: true,
});
if (!admitted.ok) return admitted;
const parsed = browserScenarioCaptureSchema.safeParse(
parent.normalizedResult,
);
if (!parsed.success)
return err(
analysisViewInputError([
{
path: ["source"],
reason: "invalid_value",
message:
"Parent Evidence result does not match capture_browser_scenario.",
},
]),
);
const content = parsed.data.scenario.network_content;
// Tools are chosen by response media type, which only bodies or header values carry; state is read from bodies.
if (
viewKind === "environment-state"
? !content.response_body
: !content.response_body && !content.header_values
)
return err(
new AnalysisUnsupportedTargetError(
"inspect_analysis_view",
parent.evidenceId,
viewKind === "environment-state"
? "environment-state needs a capture_browser_scenario capture with capture.network.response_body selected."
: "environment-manifest needs a capture_browser_scenario capture with capture.network.response_body or header_values selected.",
),
);
return ok(scenarioCaptureRecords(parsed.data, "manifest"));
};