diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 93e15de901..08a6809bd5 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -31,6 +31,75 @@ The third command serves that manifest. Both served files are complete REA Evidence JSON as the commands write it with `--format json`; the `evidence` member of `view.json` is the whole of `capture.json`. +## From a browser scenario + +The capture can also come from REA itself: a +[browser scenario](browser-scenario-contract.md) records a scripted session +in a browser REA launches, and its Evidence is a manifest parent like a HAR +inspection. Select the `network` event family and response bodies, and raise +the body budget to the 256 KiB a replayed reply can carry: + +```json +{ + "browser": { "mode": "launch", "executable_path": "/usr/bin/chromium" }, + "start_url": { "url": "http://127.0.0.1:5230/auth", "query": [] }, + "secrets": [ + { "secret_id": "admin_password", "environment_variable": "APP_PASSWORD" } + ], + "actions": [ + { + "step_id": "user", + "action": "fill", + "locator": { "kind": "css", "selector": "input[name=username]" }, + "value": { "source": "literal", "value": "admin" } + }, + { + "step_id": "password", + "action": "fill", + "locator": { "kind": "css", "selector": "input[type=password]" }, + "value": { "source": "secret", "secret_id": "admin_password" } + }, + { + "step_id": "sign-in", + "action": "press", + "locator": { "kind": "css", "selector": "input[type=password]" }, + "key": "Enter" + }, + { + "step_id": "first-memo", + "action": "goto", + "destination": { "url": "http://127.0.0.1:5230/m/1", "query": [] }, + "wait_until": "load" + } + ], + "capture": { + "events": ["network"], + "network": { + "request_body": true, + "response_body": true, + "header_values": false + }, + "limits": { "max_network_body_bytes": 262144 } + } +} +``` + +```sh +APP_PASSWORD=… rea capture-browser-scenario scenario.json --format json > capture.json +rea inspect-analysis-view view.json --format json > manifest.json # view.json wraps capture.json as before +rea environment-serve manifest.json --evidence capture.json --port 8765 +``` + +Each network transaction becomes one record, in request order, and the +record's ordinal is its zero-based position; a transaction whose URL carried a +redacted secret is left unrepresented rather than templated, and one the +capture cannot attribute to a single request and response is left +unrepresented too. Tools are chosen by response media type, so a scenario that +selected neither response bodies nor header values is refused. The browser +scenario redacts credential headers and the secrets it declares; values the +application returns in response bodies, such as session tokens, are kept, and +the replayed body is the bytes the browser exposes for each response. + ## What a manifest is An environment manifest is a projection of one application's recorded request diff --git a/docs/specs/agent-environments/scenario-capture/plan.md b/docs/specs/agent-environments/scenario-capture/plan.md new file mode 100644 index 0000000000..50c582d70c --- /dev/null +++ b/docs/specs/agent-environments/scenario-capture/plan.md @@ -0,0 +1,20 @@ +# Implementation plan + +1. Add `src/domain/analysisView/scenarioCaptureRecords.ts`: index scenario + events by transaction, project each request event as a HAR-shaped + `WebNetworkCaptureRecord`, and admit a scenario parent for the manifest + view (clone budget, schema, network-content selection). +2. Route `projectEnvironmentManifestView` through one capture-parent gate that + selects the HAR or scenario admission by parent operation, and widen the + environment-manifest result's `parent_operation`. +3. Branch `parseCaptureEvidence` in `environmentSource.ts` on the Evidence + operation, carry the capture kind and digest on the source, and word the + bundle README and CLI help for both kinds. +4. Add a schema-valid scenario capture fixture and domain and services tests: + the record projection, the manifest from a scenario parent, the refusal, + replay of UTF-8 and binary bodies, and the bundle README. +5. Document scenario parents in `docs/agent-environments.md` and + `docs/web-network-captures.md`, extend the tool contract text, and record + the spec. + +No new dependency is introduced and no command is added. diff --git a/docs/specs/agent-environments/scenario-capture/spec.md b/docs/specs/agent-environments/scenario-capture/spec.md new file mode 100644 index 0000000000..843fccd77f --- /dev/null +++ b/docs/specs/agent-environments/scenario-capture/spec.md @@ -0,0 +1,51 @@ +# Build environments from browser scenario captures + +Issue: https://github.com/morluto/rea/issues/4933 + +## Outcome + +The `environment-manifest` view, `environment-serve` and `environment-export` +accept `capture_browser_scenario` Evidence as the manifest's parent, in +addition to `inspect_web_network_capture` HAR Evidence. A session REA records +itself, in a browser it launches, with credential headers and declared secrets +redacted, becomes an agent environment without an external capture script or +HAR scrubbing step. The replay, the tool surface and the bundle layout do not +change. + +## Requirements + +- A scenario parent is admitted only when its scenario selected + `capture.network.response_body` or `header_values`, because tools are chosen + by response media type; otherwise the view refuses with + `unsupported_target`. +- Each network transaction becomes one record in request order; its ordinal + is the zero-based position of its `request` event, and its location is the + JSON pointer of that event in the capture. The record is shaped like a HAR + entry: request method and URL, the request body as `postData` when it was + captured and decodes as UTF-8, the response status, and the response media + type with its retained bytes as base64 content and a retained binary field. +- A transaction whose URL the scenario redacted keeps a null URL, so no path + template is formed from a redaction marker. A transaction that does not + resolve to exactly one request event, at most one response event and at most + one content event per phase keeps no request line. Both are counted as + unrepresented records, as HAR records without a usable request line are. +- The response media type is the captured body's `media_type`, or the + `content-type` header value when only headers were captured. +- The manifest result names `parent_operation: "capture_browser_scenario"`, + carries an empty `artifact` (`path: ""`, `sha256: null`) and states, as + limitations, the ordinal rule, any dropped capture events and how many + transactions retained no response body. +- `environment-serve` and `environment-export` authenticate either parent + kind through the same loader, project scenario transactions through the same + adapter, and keep the cross-check that the manifest's parent is the supplied + Evidence. The bundle README names the capture kind and, for a scenario, the + SHA-256 of the scenario definition in place of a capture artifact digest. + +## Non-goals + +- No new browser action: scenarios still cover what the UI requests plus GET + reads through `goto`. A fetch or script action would be a new effect and + needs its own scope decision. +- No change to what the scenario redacts. Values the application returns in + response bodies are retained, as they are in HAR captures. +- The api-surface view keeps HAR parents only. diff --git a/docs/specs/agent-environments/scenario-capture/tasks.md b/docs/specs/agent-environments/scenario-capture/tasks.md new file mode 100644 index 0000000000..cd5bb99c08 --- /dev/null +++ b/docs/specs/agent-environments/scenario-capture/tasks.md @@ -0,0 +1,10 @@ +# Tasks + +- [x] Record the scenario-parent contract, requirements and non-goals. +- [x] Project scenario transactions as HAR-shaped records and admit scenario parents. +- [x] Route the manifest view through one capture-parent gate and widen its result schema. +- [x] Load scenario Evidence in serve and export; name the capture kind in the README and help. +- [x] Add the scenario capture fixture and domain and services tests. +- [x] Document scenario parents and extend the tool contract text. +- [ ] Run static, formatting, duplication, documentation and test gates. +- [ ] Build environments from real browser scenario captures and record the results. diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index f397b59346..632823db03 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -328,6 +328,13 @@ whose content types are JSON-like. Every other group is listed under `excluded` with reason `other-origin`, `ui-page-or-asset` or `non-json`, and retained records without a usable request line are counted in a limitation. +The parent may instead be `capture_browser_scenario` Evidence whose scenario +selected response bodies or header values: each network transaction is then +one record, in request order, and the result names +`parent_operation: "capture_browser_scenario"` with an empty `artifact`, since +a scenario records no file. See +[agent environments](agent-environments.md#from-a-browser-scenario). + ```json { "name": "inspect_analysis_view", diff --git a/src/application/environment/environmentBundle.ts b/src/application/environment/environmentBundle.ts index 45cf72af95..4fcdcdb376 100644 --- a/src/application/environment/environmentBundle.ts +++ b/src/application/environment/environmentBundle.ts @@ -220,13 +220,13 @@ export const bundleReadme = ( : `calls \`${plan.check.tool}\` and checks the replayed \`status\` equals the recorded ${String(plan.check.status)} of record ${String(plan.check.recordOrdinal)}`; return `# ${plan.name} -An agent environment exported by REA from one retained web network capture. +An agent environment exported by REA from one retained ${source.captureKind === "browser-scenario" ? "browser scenario capture" : "web network capture"}. Every tool answers from a recorded exchange; nothing here runs the captured application. ## Provenance -- Capture artifact SHA-256: \`${source.captureSha256 ?? "excluded from Evidence"}\` +- ${source.captureKind === "browser-scenario" ? "Scenario definition" : "Capture artifact"} SHA-256: \`${source.captureSha256 ?? "excluded from Evidence"}\` - Capture Evidence (the manifest's parent): \`${source.evidenceId}\` - Manifest Evidence: \`${source.manifestEvidenceId}\` - Manifest view digest: \`${manifest.view_digest}\` diff --git a/src/application/environment/environmentScenario.test.ts b/src/application/environment/environmentScenario.test.ts new file mode 100644 index 0000000000..58713fcadd --- /dev/null +++ b/src/application/environment/environmentScenario.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { + scenarioCaptureEvidence, + scenarioCaptureFixture, + scenarioServeFixture, +} from "../../../tests/fixtures/browserScenarioCapture.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js"; +import { + planEnvironmentBundle, + registryPackageSource, +} from "./environmentBundle.js"; +import { replayEnvironmentTool } from "./environmentReplay.js"; +import { loadEnvironmentSource } from "./environmentSource.js"; + +const fixture = scenarioServeFixture(); + +describe("environment manifests from capture_browser_scenario parents", () => { + it("projects the scenario's transactions into tools, citing them by request order", () => { + const manifest = fixture.manifest.normalized_result as { + parent_operation: string; + artifact: unknown; + tools: { + name: string; + record_ordinals: number[]; + query_parameter_names: string[]; + body_shape: unknown; + }[]; + limitations: string[]; + }; + expect(manifest.parent_operation).toBe("capture_browser_scenario"); + expect(manifest.artifact).toEqual({ path: "", sha256: null }); + const tool = (name: string) => + manifest.tools.find((entry) => entry.name === name); + expect(tool("get_users_by_id")?.record_ordinals).toEqual([1, 2]); + expect(tool("get_search")?.query_parameter_names).toEqual(["query"]); + expect(tool("post_folders")?.body_shape).toEqual({ + type: "object", + properties: { title: "string" }, + }); + // The redacted URL never becomes a template, so no tool is named from its marker. + expect( + manifest.tools.some((entry) => entry.name.includes("redacted")), + ).toBe(false); + expect(manifest.limitations).toContain( + "Scenario records are the capture's network transactions in request order; each record ordinal is the transaction's zero-based position.", + ); + }); + + it("refuses a scenario capture that selected neither response bodies nor header values", () => { + const refused = inspectAnalysisView({ + source: { + kind: "inline", + evidence: scenarioCaptureEvidence( + scenarioCaptureFixture(undefined, { + request_body: false, + response_body: false, + header_values: false, + }), + ), + }, + view: { kind: "environment-manifest", template_rules: [] }, + }); + expect(refused.ok).toBe(false); + if (refused.ok) return; + expect(projectAnalysisError(refused.error)).toMatchObject({ + code: "unsupported_target", + }); + }); + + it("serves and exports a scenario-built manifest from the same capture", () => { + const source = loadEnvironmentSource(fixture.manifest, fixture.capture); + if (!source.ok) throw source.error; + expect(source.value.captureKind).toBe("browser-scenario"); + expect(source.value.captureSha256).toMatch(/^[0-9a-f]{64}$/u); + const users = source.value.manifest.tools.find( + (entry) => entry.name === "get_users_by_id", + ); + if (users === undefined) throw new Error("Manifest lacks get_users_by_id"); + const replayed = replayEnvironmentTool(source.value, users, { id: "3" }); + if (!replayed.ok) throw replayed.error; + expect(replayed.value).toMatchObject({ + status: 200, + body: { id: 3, login: "viewer" }, + replay: { record_ordinal: 2, exact_match: true, body_encoding: "utf-8" }, + }); + const blobs = source.value.manifest.tools.find( + (entry) => entry.name === "get_blobs_by_id", + ); + if (blobs === undefined) throw new Error("Manifest lacks get_blobs_by_id"); + const binary = replayEnvironmentTool(source.value, blobs, { id: "1" }); + if (!binary.ok) throw binary.error; + expect(binary.value).toMatchObject({ + body: Buffer.from([0xff, 0xfe, 0x00, 0x41]).toString("base64"), + replay: { body_encoding: "base64" }, + }); + const planned = planEnvironmentBundle(source.value, { + packageSource: registryPackageSource(), + }); + if (!planned.ok) throw planned.error; + const readme = + planned.value.files.find((entry) => entry.path === "README.md") + ?.content ?? ""; + expect(readme).toContain( + "exported by REA from one retained browser scenario capture", + ); + expect(readme).toContain( + `Scenario definition SHA-256: \`${source.value.captureSha256 ?? ""}\``, + ); + }); +}); diff --git a/src/application/environment/environmentSource.test.ts b/src/application/environment/environmentSource.test.ts index bd91337e59..c0851099d8 100644 --- a/src/application/environment/environmentSource.test.ts +++ b/src/application/environment/environmentSource.test.ts @@ -94,7 +94,7 @@ describe("loadEnvironmentSource", () => { issues: [ { path: ["evidence", "operation"], - expected: "inspect_web_network_capture", + expected: "inspect_web_network_capture or capture_browser_scenario", }, ], }, diff --git a/src/application/environment/environmentSource.ts b/src/application/environment/environmentSource.ts index fb434fc0b2..aa8b3871fe 100644 --- a/src/application/environment/environmentSource.ts +++ b/src/application/environment/environmentSource.ts @@ -4,7 +4,9 @@ import { analysisViewResultSchema, type AnalysisViewResult, } from "../../domain/analysisView/analysisView.js"; +import { scenarioCaptureRecords } from "../../domain/analysisView/scenarioCaptureRecords.js"; import { readRecordedRequestLine } from "../../domain/analysisView/webNetworkRouteTemplates.js"; +import { browserScenarioCaptureSchema } from "../../domain/browserScenarioCapture.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisInputError, @@ -49,12 +51,20 @@ export const REPLAY_BODY_LIMIT_BYTES = 256 * 1024; /** Base64 characters that decode to just over the replay limit, so a cut prefix still fills it. */ const BASE64_PREFIX_CHARS = Math.ceil((REPLAY_BODY_LIMIT_BYTES + 4) / 3) * 4; +/** A HAR inspected by inspect_web_network_capture, or a session recorded by capture_browser_scenario. */ +export type EnvironmentCaptureKind = "web-network-capture" | "browser-scenario"; + /** A manifest and the parent capture it cites, authenticated and cross-checked. */ export interface EnvironmentSource { readonly manifest: EnvironmentManifest; readonly manifestEvidenceId: string; readonly evidenceId: string; - /** SHA-256 of the captured artifact, or null when the capture excluded it from Evidence. */ + /** Which REA operation recorded the parent capture. */ + readonly captureKind: EnvironmentCaptureKind; + /** + * SHA-256 of the captured artifact for a web network capture, or of the scenario definition for a + * browser scenario; null when the capture excluded it from Evidence. + */ readonly captureSha256: string | null; /** Parent records cited by at least one tool, keyed by producer ordinal. */ readonly exchanges: ReadonlyMap; @@ -191,7 +201,7 @@ const parseManifestEvidence = ( return ok({ manifest: view.data, evidenceId: evidence.value.evidence_id }); }; -/** Authenticate the evidence file and require complete capture Evidence. */ +/** Authenticate the evidence file and require complete capture Evidence of either kind. */ const parseCaptureEvidence = ( operation: string, value: unknown, @@ -199,39 +209,53 @@ const parseCaptureEvidence = ( { readonly records: readonly WebNetworkCaptureRecord[]; readonly evidenceId: string; + readonly captureKind: EnvironmentCaptureKind; readonly captureSha256: string | null; }, AnalysisError > => { const evidence = parseEvidenceFile(operation, "evidence", value); if (!evidence.ok) return evidence; - const capture = webNetworkCaptureSchema.safeParse( - evidence.value.normalized_result, + const { evidence_id: evidenceId, normalized_result: result } = evidence.value; + if (evidence.value.operation === "capture_browser_scenario") { + const scenario = browserScenarioCaptureSchema.safeParse(result); + const definition = z + .object({ scenario_sha256: z.string() }) + .safeParse(evidence.value.parameters); + if (scenario.success) + return ok({ + records: scenarioCaptureRecords(scenario.data).records, + evidenceId, + captureKind: "browser-scenario", + captureSha256: definition.success + ? definition.data.scenario_sha256 + : null, + }); + } else if (evidence.value.operation === "inspect_web_network_capture") { + const capture = webNetworkCaptureSchema.safeParse(result); + if (capture.success) + return ok({ + records: capture.data.records, + evidenceId, + captureKind: "web-network-capture", + captureSha256: capture.data.artifact.sha256, + }); + } + return err( + inputError( + operation, + [ + { + path: ["evidence", "operation"], + reason: "invalid_value", + expected: "inspect_web_network_capture or capture_browser_scenario", + message: + "The evidence file must be complete inspect_web_network_capture or capture_browser_scenario Evidence.", + }, + ], + "The evidence file is not web network capture or browser scenario Evidence.", + ), ); - if ( - evidence.value.operation !== "inspect_web_network_capture" || - !capture.success - ) - return err( - inputError( - operation, - [ - { - path: ["evidence", "operation"], - reason: "invalid_value", - expected: "inspect_web_network_capture", - message: - "The evidence file must be complete inspect_web_network_capture Evidence.", - }, - ], - "The evidence file is not web network capture Evidence.", - ), - ); - return ok({ - records: capture.data.records, - evidenceId: evidence.value.evidence_id, - captureSha256: capture.data.artifact.sha256, - }); }; /** @@ -290,6 +314,7 @@ export const loadEnvironmentSource = ( manifest: manifest.value.manifest, manifestEvidenceId: manifest.value.evidenceId, evidenceId: capture.value.evidenceId, + captureKind: capture.value.captureKind, captureSha256: capture.value.captureSha256, exchanges: indexExchanges(manifest.value.manifest, capture.value.records), }); diff --git a/src/cli/environmentCommands.ts b/src/cli/environmentCommands.ts index afd6fa491b..fb2a57520d 100644 --- a/src/cli/environmentCommands.ts +++ b/src/cli/environmentCommands.ts @@ -206,7 +206,7 @@ const manifestArgument = z const evidenceOption = z .string() .describe( - "Parent capture Evidence JSON path (inspect-web-network-capture output) the manifest cites", + "Parent capture Evidence JSON path (inspect-web-network-capture or capture-browser-scenario output) the manifest cites", ); /** Register the one-shot bundle export command. */ diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index c2700c0cab..69f19ea076 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; a transaction whose URL was redacted is left unrepresented. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/domain/analysisView/WebNetworkProjectionAccumulator.ts b/src/domain/analysisView/WebNetworkProjectionAccumulator.ts index 99845dee15..153832d5f8 100644 --- a/src/domain/analysisView/WebNetworkProjectionAccumulator.ts +++ b/src/domain/analysisView/WebNetworkProjectionAccumulator.ts @@ -107,7 +107,7 @@ export const admitHarCaptureParent = ( ); if (parent.operation !== "inspect_web_network_capture") return unsupported( - `View ${viewKind} is unsupported for parent operation ${parent.operation}; ${viewKind} requires inspect_web_network_capture.`, + `View ${viewKind} is unsupported for parent operation ${parent.operation}; ${viewKind} requires ${viewKind === "environment-manifest" ? "inspect_web_network_capture or capture_browser_scenario" : "inspect_web_network_capture"}.`, ); const admitted = accumulator.admitCaptureClone(parent.normalizedResult); if (!admitted.ok) return admitted; diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 38002281e1..096a69e104 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -507,7 +507,10 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ ...viewResultBase, ...environmentManifestFields, kind: z.literal("environment-manifest"), - parent_operation: z.literal("inspect_web_network_capture"), + parent_operation: z.enum([ + "inspect_web_network_capture", + "capture_browser_scenario", + ]), view: environmentManifestViewRequestSchema, }), z.strictObject({ diff --git a/src/domain/analysisView/environmentManifestView.ts b/src/domain/analysisView/environmentManifestView.ts index 226c72fb3a..8d367b4ca3 100644 --- a/src/domain/analysisView/environmentManifestView.ts +++ b/src/domain/analysisView/environmentManifestView.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { WebNetworkCaptureRecord } from "../webNetworkCapture.js"; import { AnalysisUnsupportedTargetError } from "../analysisErrorCore.js"; import type { AnalysisError } from "../analysisErrorBase.js"; import { uniqueSorted } from "../canonicalOrdering.js"; @@ -11,6 +12,7 @@ import type { import { EnvironmentManifestAccumulator } from "./EnvironmentManifestAccumulator.js"; import { TEMPLATE_RULES } from "./environmentManifestRules.js"; import { admitHarCaptureParent } from "./WebNetworkProjectionAccumulator.js"; +import { admitScenarioCaptureParent } from "./scenarioCaptureRecords.js"; import { ROUTE_RULES } from "./webNetworkRouteTemplates.js"; export const environmentManifestViewRequestSchema = z.strictObject({ @@ -119,7 +121,45 @@ export const environmentManifestFields = { ), }; -/** Project retained HAR request lines into a replayable tool surface; templates are inferences. */ +/** The records, coverage and identity a manifest projects from, for either parent kind. */ +interface CaptureParent { + readonly operation: + | "inspect_web_network_capture" + | "capture_browser_scenario"; + readonly records: readonly WebNetworkCaptureRecord[]; + readonly total_records: number; + readonly limitations: readonly string[]; + readonly artifact: { readonly path: string; readonly sha256: string | null }; +} + +/** HAR captures keep their own gate; a browser scenario has no captured file, so its artifact is empty. */ +const admitCaptureParent = ( + parent: AnalysisViewParent, + accumulator: EnvironmentManifestAccumulator, +): Result => { + if (parent.operation === "capture_browser_scenario") { + const scenario = admitScenarioCaptureParent(parent, accumulator); + return scenario.ok + ? ok({ + operation: "capture_browser_scenario", + records: scenario.value.records, + total_records: scenario.value.totalRecords, + limitations: scenario.value.limitations, + artifact: { path: "", sha256: null }, + }) + : scenario; + } + const har = admitHarCaptureParent( + parent, + "environment-manifest", + accumulator, + ); + return har.ok + ? ok({ operation: "inspect_web_network_capture", ...har.value }) + : har; +}; + +/** Project retained request lines, from a HAR or a browser scenario, into a replayable tool surface; templates are inferences. */ export const projectEnvironmentManifestView = ( parent: AnalysisViewParent, view: AnalysisViewRequest, @@ -136,14 +176,10 @@ export const projectEnvironmentManifestView = ( new AnalysisUnsupportedTargetError( "inspect_analysis_view", parent.evidenceId, - `View ${view.kind} is unsupported for parent operation ${parent.operation}; environment-manifest requires inspect_web_network_capture.`, + `View ${view.kind} is unsupported for parent operation ${parent.operation}; environment-manifest requires inspect_web_network_capture or capture_browser_scenario.`, ), ); - const admitted = admitHarCaptureParent( - parent, - "environment-manifest", - accumulator, - ); + const admitted = admitCaptureParent(parent, accumulator); if (!admitted.ok) return admitted; const capture = admitted.value; const limitations = accumulator.addLimitations([ @@ -177,7 +213,7 @@ export const projectEnvironmentManifestView = ( kind: "environment-manifest", view, parent_evidence_id: parent.evidenceId, - parent_operation: "inspect_web_network_capture", + parent_operation: capture.operation, parent_digest: parent.evidenceId.slice(3), artifact: { path: capture.artifact.path, sha256: capture.artifact.sha256 }, ...manifest.value, diff --git a/src/domain/analysisView/scenarioCaptureRecords.test.ts b/src/domain/analysisView/scenarioCaptureRecords.test.ts new file mode 100644 index 0000000000..769937d03c --- /dev/null +++ b/src/domain/analysisView/scenarioCaptureRecords.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { + SCENARIO_EXCHANGES, + scenarioCaptureFixture, +} from "../../../tests/fixtures/browserScenarioCapture.js"; +import { scenarioCaptureRecords } from "./scenarioCaptureRecords.js"; + +describe("scenarioCaptureRecords", () => { + it("projects each transaction, in request order, as a HAR-shaped record with its retained bytes", () => { + const projected = scenarioCaptureRecords(scenarioCaptureFixture()); + expect(projected.totalRecords).toBe(SCENARIO_EXCHANGES.length); + expect(projected.records.map((record) => record.ordinal)).toEqual([ + 0, 1, 2, 3, 4, 5, 6, 7, + ]); + const user = projected.records[1]; + expect(user?.reported).toEqual({ + request: { method: "GET", url: "http://127.0.0.1:3101/api/users/2" }, + response: { + status: 200, + content: { + mimeType: "application/json", + text: Buffer.from('{"id":2,"login":"probe"}').toString("base64"), + encoding: "base64", + }, + }, + }); + expect(user?.binary_fields).toMatchObject([ + { pointer: "/response/content/text", state: "retained", bytes: 24 }, + ]); + expect(user?.location).toEqual({ + kind: "json-pointer", + pointer: "/events/items/3", + }); + // A JSON request body becomes postData, so the manifest can read its key types. + expect(projected.records[4]?.reported).toMatchObject({ + request: { + postData: { + mimeType: "application/json", + text: '{"title":"Lighthouse Rota"}', + }, + }, + }); + // A redacted URL is withheld rather than templated; a failed request has no response. + expect(projected.records[5]?.reported).toMatchObject({ + request: { url: null }, + }); + expect(projected.records[7]?.reported).toEqual({ + request: { method: "GET", url: "http://127.0.0.1:3101/api/slow" }, + }); + expect(projected.limitations).toContain( + "1 of 8 scenario transactions retained no response body; their tools replay status and content type only.", + ); + }); + + it("leaves a transaction that cannot be attributed to one request and response unrepresented", () => { + const capture = scenarioCaptureFixture(SCENARIO_EXCHANGES.slice(1, 2)); + const response = capture.events.items.find( + (event) => event.kind === "response", + ); + if (response === undefined) throw new Error("Fixture lacks a response"); + const duplicated = { + ...capture, + events: { + ...capture.events, + items: [...capture.events.items, { ...response, sequence: 99 }], + }, + }; + expect(scenarioCaptureRecords(duplicated).records[0]?.reported).toEqual({}); + }); +}); diff --git a/src/domain/analysisView/scenarioCaptureRecords.ts b/src/domain/analysisView/scenarioCaptureRecords.ts new file mode 100644 index 0000000000..91c604e123 --- /dev/null +++ b/src/domain/analysisView/scenarioCaptureRecords.ts @@ -0,0 +1,268 @@ +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; +type ResponseEvent = Extract; +type ContentEvent = Extract; +type CapturedBody = Extract; + +/** 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; + readonly responses: Map; + /** Content events keyed by the sequence of the request or response event they belong to. */ + readonly contents: Map; +} + +const append = ( + map: Map, + 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; + +/** 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, +): { readonly [key: string]: JsonValue } => { + const body = captured(content); + 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, + item: number, + request: RequestEvent, + index: TransactionIndex, +): { readonly record: WebNetworkCaptureRecord; readonly body: boolean } => { + 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 }, + body: false, + }; + const requestContent = onlyContent(index, request.sequence); + const responseContent = onlyContent(index, response?.sequence); + const body = captured(responseContent); + const mimeType = mediaType(responseContent) ?? ""; + return { + record: { + ordinal, + location, + reported: { + request: reportedRequest(request, requestContent), + ...(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, + }, + ], + }, + body: body !== undefined, + }; +}; + +/** Project each network transaction, in request order, as one record; the ordinal is its zero-based position. */ +export const scenarioCaptureRecords = ( + capture: BrowserScenarioCapture, +): ScenarioCaptureRecords => { + const index = indexTransactions(capture.events.items); + const projected = index.requests.map(({ event, item }, ordinal) => + record(ordinal, item, event, index), + ); + const withoutBody = projected.filter((entry) => !entry.body).length; + return { + records: projected.map((entry) => entry.record), + totalRecords: projected.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(projected.length)} scenario transactions retained no response body; their tools replay status and content type only.`, + ] + : []), + ], + }; +}; + +/** A scenario capture parent in the shape the environment manifest projects from. */ +export interface ScenarioCaptureParent extends ScenarioCaptureRecords { + readonly capture: BrowserScenarioCapture; +} + +/** Admit a capture_browser_scenario parent whose network content identifies response media types. */ +export const admitScenarioCaptureParent = ( + parent: AnalysisViewParent, + accumulator: WebNetworkProjectionAccumulator, +): Result => { + const admitted = accumulator.admitCaptureClone(parent.normalizedResult); + 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. + if (!content.response_body && !content.header_values) + return err( + new AnalysisUnsupportedTargetError( + "inspect_analysis_view", + parent.evidenceId, + "environment-manifest needs a capture_browser_scenario capture with capture.network.response_body or header_values selected.", + ), + ); + return ok({ capture: parsed.data, ...scenarioCaptureRecords(parsed.data) }); +}; diff --git a/tests/fixtures/browserScenarioCapture.ts b/tests/fixtures/browserScenarioCapture.ts new file mode 100644 index 0000000000..22c5f92b5d --- /dev/null +++ b/tests/fixtures/browserScenarioCapture.ts @@ -0,0 +1,316 @@ +import { createHash } from "node:crypto"; + +import { inspectAnalysisView } from "../../src/application/analysisView/AnalysisViewService.js"; +import { createBrowserScenarioEvidence } from "../../src/application/BrowserScenarioEvidence.js"; +import { sanitizeBrowserUrl } from "../../src/domain/browserObservation.js"; +import { browserScenarioSchema } from "../../src/domain/browserScenario.js"; +import { + browserScenarioCaptureSchema, + type BrowserScenarioCapture, +} from "../../src/domain/browserScenarioCapture.js"; +import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../../src/domain/browserScenarioCaptureLimits.js"; +import type { Evidence } from "../../src/domain/evidence.js"; +import { + ALL_TEMPLATE_RULES, + type EnvironmentServeFixture, +} from "./environmentServe.js"; + +/** One recorded transaction; omit `status` for a request that never got a response. */ +export interface ScenarioExchange { + readonly method: string; + readonly url: string; + readonly resourceType?: string; + readonly redactedUrl?: boolean; + readonly status?: number; + readonly mediaType?: string | null; + readonly body?: string | Buffer; + readonly requestMediaType?: string; + readonly requestBody?: string; +} + +export const SCENARIO_ORIGIN = "http://127.0.0.1:3101"; + +/** A small app session: a page load, JSON reads, a search, a JSON write, a redacted URL, binary bytes and a failure. */ +export const SCENARIO_EXCHANGES: readonly ScenarioExchange[] = [ + { + method: "GET", + url: "/", + resourceType: "document", + status: 200, + mediaType: "text/html", + body: "Rota", + }, + { + method: "GET", + url: "/api/users/2", + status: 200, + mediaType: "application/json", + body: '{"id":2,"login":"probe"}', + }, + { + method: "GET", + url: "/api/users/3", + status: 200, + mediaType: "application/json", + body: '{"id":3,"login":"viewer"}', + }, + { + method: "GET", + url: "/api/search?query=lighthouse", + status: 200, + mediaType: "application/json", + body: '[{"title":"Lighthouse Rota"}]', + }, + { + method: "POST", + url: "/api/folders", + status: 200, + mediaType: "application/json", + body: '{"uid":"f1","title":"Lighthouse Rota"}', + requestMediaType: "application/json", + requestBody: '{"title":"Lighthouse Rota"}', + }, + { + method: "GET", + url: "/api/sessions/[REDACTED:session]", + redactedUrl: true, + status: 200, + mediaType: "application/json", + body: "{}", + }, + { + method: "GET", + url: "/api/blobs/1", + status: 200, + mediaType: "application/json", + body: Buffer.from([0xff, 0xfe, 0x00, 0x41]), + }, + { method: "GET", url: "/api/slow" }, +]; + +const capturedBody = ( + content: string | Buffer, + representation: + | "browser-exposed-request-bytes" + | "playwright-response-body-bytes", + mediaType: string | null, +) => { + const bytes = + typeof content === "string" ? Buffer.from(content, "utf8") : content; + return { + state: "captured" as const, + representation, + encoding: "base64" as const, + content: bytes.toString("base64"), + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + media_type: mediaType, + redacted: false, + }; +}; + +const exchangeEvents = ( + exchange: ScenarioExchange, + index: number, + first: number, +) => { + const transaction = `request-${String(index + 1)}`; + const url = sanitizeBrowserUrl(`${SCENARIO_ORIGIN}${exchange.url}`); + const network = { + step_index: 1, + transaction_id: transaction, + redirected_from_transaction_id: null, + redirected_from_transaction_state: "root" as const, + method: exchange.method, + url: exchange.redactedUrl === true ? { ...url, redacted: true } : url, + resource_type: exchange.resourceType ?? "fetch", + header_names: [], + }; + const content = ( + sequence: number, + source: number, + phase: "request" | "response", + body: ReturnType, + ) => ({ + sequence, + step_index: 1, + kind: "network-content" as const, + transaction_id: transaction, + source_event_sequence: source, + phase, + headers: { state: "not_requested" as const }, + body, + }); + const events: unknown[] = [ + { + ...network, + sequence: first, + kind: "request", + status: null, + failure: null, + }, + ]; + let next = first + 1; + if (exchange.requestBody !== undefined) { + events.push( + content( + next, + first, + "request", + capturedBody( + exchange.requestBody, + "browser-exposed-request-bytes", + exchange.requestMediaType ?? null, + ), + ), + ); + next += 1; + } + if (exchange.status === undefined) { + events.push({ + ...network, + sequence: next, + kind: "request-failed", + status: null, + failure: "net::ERR_CONNECTION_RESET", + }); + return { events, next: next + 1 }; + } + const response = next; + events.push({ + ...network, + sequence: response, + kind: "response", + status: exchange.status, + failure: null, + }); + next += 1; + if (exchange.body !== undefined) { + events.push( + content( + next, + response, + "response", + capturedBody( + exchange.body, + "playwright-response-body-bytes", + exchange.mediaType ?? null, + ), + ), + ); + next += 1; + } + return { events, next }; +}; + +const step = (index: number) => ({ + step_index: index, + step_id: index === 0 ? "initial" : "browse", + action: index === 0 ? "initial" : "goto", + elapsed_ms: 0, + before_url: sanitizeBrowserUrl(`${SCENARIO_ORIGIN}/`), + after_url: sanitizeBrowserUrl(`${SCENARIO_ORIGIN}/`), + event_sequence_start: 1, + event_sequence_end: 0, + artifacts: { + screenshot: { state: "not_requested" }, + dom: { state: "not_requested" }, + accessibility: { state: "not_requested" }, + url: { state: "not_requested" }, + history: { state: "not_requested" }, + storage: { state: "not_requested" }, + }, + completeness: { + status: "complete", + equality_eligible: true, + missing_sections: [], + truncated_sections: [], + }, + status: "completed", + error: null, +}); + +/** A complete, schema-valid scenario capture of the given transactions. */ +export const scenarioCaptureFixture = ( + exchanges: readonly ScenarioExchange[] = SCENARIO_EXCHANGES, + networkContent = { + request_body: true, + response_body: true, + header_values: false, + }, +): BrowserScenarioCapture => { + const items: unknown[] = []; + let sequence = 1; + for (const [index, exchange] of exchanges.entries()) { + const built = exchangeEvents(exchange, index, sequence); + items.push(...built.events); + sequence = built.next; + } + return browserScenarioCaptureSchema.parse({ + browser: { + mode: "launch", + process_ownership: "provider-owned", + cleanup: "terminated-owned-process", + product: "Chromium", + version: "fixture", + }, + scenario: { + start_origin: SCENARIO_ORIGIN, + action_count: 1, + secret_references: [], + network_content: networkContent, + event_families: ["network"], + limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS, + }, + duration_ms: 1, + steps: [step(0), step(1)], + events: { + retained: items.length, + dropped: 0, + items, + omissions: { + by_kind: [], + network_transactions: [], + network_transaction_range: null, + }, + }, + completeness: { + status: "complete", + equality_eligible: true, + missing_sections: [], + truncated_sections: [], + }, + limitations: [], + }); +}; + +/** The capture as Evidence, the way capture_browser_scenario records it. */ +export const scenarioCaptureEvidence = ( + capture: BrowserScenarioCapture = scenarioCaptureFixture(), +): Evidence => + createBrowserScenarioEvidence( + browserScenarioSchema.parse({ + browser: { mode: "launch", executable_path: process.execPath }, + start_url: { url: `${SCENARIO_ORIGIN}/`, query: [] }, + actions: [ + { step_id: "browse", action: "wait_for_timeout", duration_ms: 1 }, + ], + }), + capture, + { id: "fixture", name: "fixture", version: "1" }, + ); + +/** Project the scenario capture through the real view service, with every opt-in rule. */ +export const scenarioServeFixture = ( + capture: Evidence = scenarioCaptureEvidence(), +): EnvironmentServeFixture => { + const manifest = inspectAnalysisView({ + source: { kind: "inline", evidence: capture }, + view: { + kind: "environment-manifest", + template_rules: [...ALL_TEMPLATE_RULES], + }, + }); + if (!manifest.ok) throw manifest.error; + return { manifest: manifest.value, capture }; +};