diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 2d816fac23..67b9909570 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -31,6 +31,92 @@ 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. A replayed +reply carries up to 256 KiB of a recorded body, but the scenario keeps at most +`max_network_body_bytes` of each body (64 KiB by default), and only within its +allocation budgets: base64 is charged about 11 bytes per body byte, and +redaction more the shorter a declared secret is (about 35 bytes per body byte +for a 12-character `admin_password`). A body over +`max_network_body_allocation_bytes` (1 MiB by default) is kept as `unavailable` +with that reason; one over `max_event_allocation_bytes` (4 MiB) or +`max_total_event_allocation_bytes` (16 MiB) loses its whole content event, +headers included, which the capture lists among its omissions. Either way its +tool replays no body. The example raises all four so that 256 KiB bodies are +kept when the declared secret has at least 12 characters: + +```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, + "max_network_body_allocation_bytes": 12582912, + "max_event_allocation_bytes": 12582912, + "max_total_event_allocation_bytes": 67108864 + } + } +} +``` + +```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, with any +declared secret replaced by its redaction marker (a body that redaction had to +empty replays no body). + ## What a manifest is An environment manifest is a projection of one application's recorded request diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 011dc9826c..9f25a28891 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -218,7 +218,7 @@ no group was templated. See [derived API surfaces](web-network-captures.md#derived-api-surface). `inspect_analysis_view` also accepts `environment-manifest` for retained HAR capture -Evidence, projecting the primary origin's request lines into named tools with path +Evidence and `capture_browser_scenario` Evidence, projecting the primary origin's request lines into named tools with path templates, parameters, observed query names, value-free body shapes and record pointers, plus excluded groups and three fixed unknowns. Templates, parameters and names are inferences; the Evidence is inferred whenever any tool or excluded group 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..5ffb098230 --- /dev/null +++ b/docs/specs/agent-environments/scenario-capture/spec.md @@ -0,0 +1,62 @@ +# 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 existing projection representation budget accounts for copied and + transformed strings before schema body validation or request-body decoding, + including retained Evidence parents. Over-budget captures return + `resource_constraint` before those allocations. Replay projection omits the + request text used only for manifest body-shape inference; retained response + bytes and their integrity checks are unchanged. +- 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, how many + 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) and how many retained response bodies carry redaction + markers. A body that redaction emptied, because a declared secret could not + be cut out, is treated as not retained rather than replayed as an empty + reply. +- `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..bdc8ecf0d6 --- /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. +- [x] Run static, formatting, duplication, documentation and test gates. +- [x] Build environments from real browser scenario captures and record the results. diff --git a/docs/specs/agent-environments/scenario-capture/verification.md b/docs/specs/agent-environments/scenario-capture/verification.md new file mode 100644 index 0000000000..d85376eefe --- /dev/null +++ b/docs/specs/agent-environments/scenario-capture/verification.md @@ -0,0 +1,144 @@ +# Verification + +Verified commit: 3c2a8db2, this increment on top of the environment-export increment (481c9883). + +## Gates + +Run on macOS 26.5.1 with Node 22.23.1 on that tree: + +- `npx tsc --noEmit`: no errors. +- `oxfmt --check`: all files formatted. `oxlint` on the changed files: no new findings. + `npm run verify:module-boundaries`: 2133 files checked. +- `npm run jscpd`: 3.35% duplicated lines, unchanged. `npm run scan:todos`: none. + `npm run mcp-catalog:check`: passed. `npx knip`: one unresolved import that predates + this change (`#javascript-analysis-worker`). +- `npm run test:prepare`, then vitest over `src/domain/analysisView`, `src/application`, + `src/contracts`, `src/server/environment`, `tests/conformance` and the analysis-view, + environment-serve, environment-export and browser-scenario boundary tests: 1946 tests in + 157 files, 1941 passed, 5 skipped, 0 failed. + +## Real browser captures + +`npm pack` of this commit (`rea-agents-6.7.0.tgz`) was installed on Ubuntu 24.04 amd64 hosts +(8 vCPU, 32 GB) with Chromium 155.0.8059.79 at `/snap/bin/chromium`, Node 24, Docker and +`agentenv-framework` 0.9.1334. Every capture below is `rea capture-browser-scenario` output; +no HAR was recorded, scoped or edited. + +### Fixture + +A loopback fixture served a page that lists photos from `/api/photos`, three PNG thumbnails, and +a form that POSTs JSON to `/api/albums`. The scenario waited for the list, opened +`/api/photos/1..3` with `goto`, filled and submitted the form. + +- Capture: 14 transactions, 70 events, 0 dropped, authority `controlled-replay`. +- `environment-manifest`: `parent_operation` `capture_browser_scenario`, origin + `http://127.0.0.1:3199`, confidence `inferred`. Tools `get_photos` (records 1, 9), + `get_photos_by_id` (`{numeric}` id, records 5, 6, 7) and `post_albums` (record 13, body shape + `title: string`). The page and the three PNGs were excluded as `ui-page-or-asset`. +- `environment-serve`: `get_photos_by_id {id: "3"}` returned the recorded JSON with + `record_ordinal` 7 and `exact_match` true. +- The POST's response body was missing: the scenario ended at the action that sent it, the + transaction was still unfinished, and the observer does not read bodies of unfinished + responses. The manifest stated "1 of 14 scenario transactions retained no response body" and + `post_albums` replayed status 201 with no body. Ending the scenario with a 1.5 s + `wait_for_timeout` finished all 14 transactions and retained the body. +- `environment-export` of that capture wrote a README naming the browser scenario capture and the + scenario definition's SHA-256; `agent-env run --task smoke` passed in 30.4 s. + +### Five applications + +Each environment ran manifest, export, the bundle's smoke task and 6 to 12 generated retrieval +tasks, with one run per task for two models through the Hugging Face router. + +| Application | Capture | Actions | Transactions | Tools (rules / none) | Smoke | Retrieval tasks | gpt-oss-120b | GLM-5.3-Flash | +| ---------------------------- | ------------------------------------------------------------------ | ------- | ------------ | -------------------- | ------ | --------------- | ------------ | ------------- | +| PhotoPrism 261007 | self-hosted, no login (public mode) | 55 | 277 | 13 / 28 | passed | 10 | 10/10 | 10/10 | +| Lychee 6.10.4 | self-hosted, session cookie seeded from a declared secret | 152 | 134 | 15 / 27 | passed | 12 | 11/12 | 12/12 | +| Pinry 2.1.13 | self-hosted, session and CSRF cookies seeded from declared secrets | 148 | 568 | 12 / 12 | passed | 6 | 6/6 | 6/6 | +| Art Institute of Chicago API | public, `goto` per URL | 52 | 26 | 11 / 12 | passed | 12 | 11/12 | 12/12 | +| Library of Congress JSON API | public, `goto` per URL | 55 | 29 | 11 / 11 | passed | 12 | 11/12 | 12/12 | + +Every scenario step completed and no capture dropped events. Each self-hosted capture includes +one create, one edit and one delete made through the application's own forms; all three became +tools with their recorded statuses, including a 204 DELETE without a content type. Declared +secrets did not appear in any capture; where the application echoed a declared value in a +response body (PhotoPrism's session reply, Pinry's owner token) the capture held the redaction +marker instead. + +Two other targets were tried and replaced. The Met Collection API projected 3 tools: its data +paths are `/departments`, `/objects/{id}` and a search, and numeric object ids merge into one +tool. The NASA Image and Video Library projected 16 tools, but its Collection+JSON bodies nest +record fields deeper than the task generator reads, so no tasks could be derived. + +## Observed limits + +These were observed during the runs and are outside this increment: + +- A scenario's last action can lose its response body unless the scenario ends with a wait + (above). +- Large asset bodies count against the analysis-view budget. One Pinry capture at + `max_network_body_bytes` 262144 held 12.5 MB of Evidence, mostly CSS and JavaScript reloaded + on 33 page loads, and the manifest view returned `resource_constraint`. A 20,000-byte body + limit kept it within budget. Scenarios have no per-origin or per-media-type body selection. +- Image responses outside an API path are excluded as UI assets, so their bytes are replayable + only when an API path serves them (PhotoPrism's `/api/v1/t/...` tiles became tools). +- With the default `max_total_event_allocation_bytes`, one PhotoPrism capture dropped its last + 68 transactions, including all three writes, while the command exited 0; 64 MiB dropped none. +- Actions have no default deadline: two runs waited on a locator for 277 s and 7.5 min until + cancelled. Every scenario above sets `timeout_ms` on its actions. +- A redirect is its own transaction; Django's trailing-slash 301 produced a second tool + (`get_pins_by_id` and `get_pins_by_id_2`) for one resource. + +## Linux followup verification (2026-10-19) + +The original PR was merged with current main `7a0c47bfa`, after #4955, #4975 +and #4999 merged. The export filesystem ownership, mode, tarball, cleanup and +caller-path fixes from main are retained. Default MCP remains stdio. + +The retained-parent projection previously decoded every request body before +per-record budget checks. It now reserves copied/transformed text through the +shared admission owner before schema validation or decoding, including retained +parents. Replay projection omits request text used only for body-shape inference; +response bytes still undergo the producer schema's integrity validation. The +intermediate projected-array copy and unused capture wrapper were removed. +Windows test inventories normalize filesystem separators to the portable bundle +path representation. CI selects the agentenv lane for the new projection and its +shared admission dependencies. + +Executed on Linux x64, Node 24.18.0 / npm 11.16.0, Chrome for Testing +156.0.8078.4: + +- `turbo run typecheck lint:built format:check docs:check:built knip artifacts:skills artifacts:mcp-catalog --concurrency=1 --summarize`: eleven tasks passed, one cached, 210.023 s elapsed / 209.860 s CPU / 4562.07 MiB peak process-family RSS. This first complete gate preceded the final lint cleanup and CI-selection additions; later checks are reported on the PR. +- Initial source verification passed 111 tests in 13 files, including HAR/manifest regressions, scenario loading/replay/export and new capacity/replay-mode tests. CI-selection verification passed 235 cases. +- Real public CLI capture of the source-owned interactive browser network site: ten transactions, fifty events, two tools, no dropped events. Its POST response replayed twice through the official HTTP SDK with exact body/status/Evidence ordinal and untrusted-content provenance. Declared secrets and transport credentials were excluded; the ordinary selected `password` field value remained in retained body bytes. +- Real public CLI capture of three live GitHub API responses in Chromium, without a HAR: three transactions, fifteen events, three tools, no dropped events. Every response replayed twice through the HTTP SDK with exact body/status/Evidence/provenance. Explicit navigation waits let response-body capture settle. Complete two-scenario capture/projection/serve task: **22.527 s / 16.678 s CPU / 1397.93 MiB** peak process-family RSS, including owned browser processes. Both listeners exited 143 and newly owned browser profiles were gone. +- The same two actual captures produced identical manifest results through CLI and official SDK **stdio** MCP, with HTTP environment variables present: **2.795 s / 2.778 s CPU / 476.67 MiB**. This verifies adapter parity and default transport independently of explicit environment HTTP. + +The first local probe mistakenly searched base64 body content for plaintext; its +assertion was corrected to inspect decoded retained bytes. A later SDK probe +initially imported stdio transport from the SDK root instead of its documented +subpath; the corrected probe passed. An early live navigation also reported one +`body-read-failed` response, which the projection preserved as unavailable. No +browser-provider behavior was changed; the successful run used explicit waits. +These earlier attempts are not counted as passing runs. + +A matched retained-scenario projection comparison bundles the original helper +from `3aae3579e` and the followup helper, supplies the same 72,771-byte capture +with three JSON writes, and completes one cold plus 100 warm full manifest +projections. Both outputs have the same SHA-256 +`990ac431670374ff68c77a20d77985f626e4681bb0e2f491e5af18f45ce79991`. +Before/after process totals: **0.970/0.876 s elapsed, 0.934/0.865 s CPU, +184.89/184.39 MiB** RSS. First task: **17.378/13.073 ms**; 100 warm tasks: +**261.419/236.131 ms**. Single samples do not establish a speed or memory +improvement. Bundle preparation is excluded; process totals include fixture +startup. CLI, provider and transport costs are excluded from this comparison. + +Heavy work is serialized on one CPU with one test worker. RSS sums sampled +process descendants every 50 ms and can double-count shared pages. Agent token +usage is unmeasured. These followups do not rerun the author's five application +reconstruction/model tasks, nor establish Windows or macOS behavior. Historical +response replay does not reconstruct application state. + +A retained-parent counterexample used a 128 KiB projection budget and one 16 KiB JSON request: the original helper admitted it and decoded once; the repaired helper returned `AnalysisResourceConstraintError` with zero UTF-8 decodes. The test intentionally uses a small budget without inducing an OOM. Final targeted verification passed 242 tests (including 235 CI-selection cases): 41.042 s / 40.666 s CPU / 644.37 MiB peak process-family RSS. + +The broader d2416ca54 regression run passed 657 tests in 47 files plus ten product-catalog cases (295.763 s / 293.370 s CPU / 1921.69 MiB family RSS; catalog checked separately). The author then pushed 655e62334 with redaction/body-budget documentation and withheld-empty-body behavior. That commit was retained; the followup counts bodiless/redacted observations against represented transactions, preserving the complete request ordinals. The related source run passed 145 tests in 14 files (9.793 s / 9.618 s CPU / 688.66 MiB). Docker execution was held at this author-update checkpoint, so no earlier bundle is attributed to the new source. Final committed checks and actual scenario-container/framework results are reported on the PR. diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 5a5283c672..c4fda1723e 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -334,6 +334,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", @@ -400,7 +407,10 @@ whenever it is nonzero the Evidence is inferred, otherwise derived. The view performs no network activity and replays nothing. Native mitmproxy, SAZ and qlog captures, and parents inspected with `sensitive_values`, return the same typed unsupported errors as the api-surface view, including the canonical -token `sensitive_values_unretained`. +token `sensitive_values_unretained`. A `capture_browser_scenario` parent is not +refused for declaring secrets: the capture redacted them as it recorded, a +transaction whose URL was redacted is left unrepresented, and a response body +keeps the capture's redaction markers, which a manifest limitation counts. The manifest accumulator shares the api-surface admission model: the capture clone, the inline envelope, record pointers, groups, every route part, observed diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index aeda08504e..8cd4a99533 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -448,7 +448,7 @@ export const SCOPES = { "src/server/http/**", "src/{cli,cliCommandNames,cli/environmentCommands}.ts", "src/config/{environmentName,mcpHttpEndpoint}.ts", - "src/domain/analysisView/{e,E}nvironmentManifest*.ts", + "src/domain/analysisView/{environmentManifest*,EnvironmentManifest*,scenarioCaptureRecords,WebNetworkProjectionAccumulator,analysisViewDataAdmission}.ts", "src/process/{OwnedCommand,ProviderProcess,PrivateRuntimeRoot}.ts", "src/process/ProcessOwnership*.ts", "tests/fixtures/historicalHar.ts", diff --git a/src/application/environment/environmentBundle.ts b/src/application/environment/environmentBundle.ts index d5eda7399d..211dd54018 100644 --- a/src/application/environment/environmentBundle.ts +++ b/src/application/environment/environmentBundle.ts @@ -239,7 +239,7 @@ 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. @@ -249,7 +249,7 @@ if that content requires restricted access. ## 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/environmentExport.test.ts b/src/application/environment/environmentExport.test.ts index c7d15a5076..bb053ef326 100644 --- a/src/application/environment/environmentExport.test.ts +++ b/src/application/environment/environmentExport.test.ts @@ -1,5 +1,5 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, sep } from "node:path"; import * as v8 from "node:v8"; import { describe, expect, it, vi } from "vitest"; @@ -26,7 +26,12 @@ vi.mock("../RegularFileRead.js", { spy: true }); const listFiles = async (root: string): Promise => (await readdir(root, { recursive: true, withFileTypes: true })) .filter((entry) => entry.isFile()) - .map((entry) => join(entry.parentPath, entry.name).slice(root.length + 1)) + .map((entry) => + join(entry.parentPath, entry.name) + .slice(root.length + 1) + .split(sep) + .join("/"), + ) .sort(); const prepare = async (prefix: string) => { diff --git a/src/application/environment/environmentScenario.test.ts b/src/application/environment/environmentScenario.test.ts new file mode 100644 index 0000000000..592db02976 --- /dev/null +++ b/src/application/environment/environmentScenario.test.ts @@ -0,0 +1,129 @@ +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("names the one view a scenario capture supports when another is requested", () => { + const refused = inspectAnalysisView({ + source: { + kind: "inline", + evidence: scenarioCaptureEvidence(scenarioCaptureFixture()), + }, + view: { kind: "summary" }, + }); + if (refused.ok) throw new Error("Expected the unsupported view refusal"); + expect(projectAnalysisError(refused.error)).toMatchObject({ + code: "unsupported_target", + message: expect.stringContaining( + "supports only the environment-manifest view", + ), + }); + }); + + 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 614a822e79..69858623be 100644 --- a/src/application/environment/environmentSource.test.ts +++ b/src/application/environment/environmentSource.test.ts @@ -116,7 +116,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 dba47582b7..569c2299ea 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, @@ -50,12 +52,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; @@ -194,7 +204,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, @@ -202,63 +212,80 @@ 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 { evidence_id: evidenceId } = evidence.value; const normalized = immutableJsonSnapshotMember( evidence.value, "normalized_result", ); - const records = immutableJsonSnapshotMember(normalized, "records"); - if (Array.isArray(records)) - for (let index = 0; index < records.length; index += 1) { - const record = immutableJsonSnapshotMember(records, String(index)); - immutableJsonSnapshotMember(record, "reported"); + if (evidence.value.operation === "capture_browser_scenario") { + const scenario = browserScenarioCaptureSchema.safeParse(normalized); + const definition = z + .object({ scenario_sha256: z.string() }) + .safeParse(evidence.value.parameters); + if (scenario.success) + return ok({ + records: scenarioCaptureRecords(scenario.data, "replay").records, + evidenceId, + captureKind: "browser-scenario", + captureSha256: definition.success + ? definition.data.scenario_sha256 + : null, + }); + } else if (evidence.value.operation === "inspect_web_network_capture") { + const records = immutableJsonSnapshotMember(normalized, "records"); + if (Array.isArray(records)) + for (let index = 0; index < records.length; index += 1) { + const record = immutableJsonSnapshotMember(records, String(index)); + immutableJsonSnapshotMember(record, "reported"); + } + const capture = webNetworkCaptureSchema.safeParse(normalized); + if (capture.success) { + // The manifest view projects HAR captures only, and replay reads HAR's response fields. + if (capture.data.format !== "har") + return err( + inputError( + operation, + [ + { + path: ["evidence", "normalized_result", "format"], + reason: "invalid_value", + expected: "har", + message: `Agent environments replay HAR captures; this capture's format is ${capture.data.format}.`, + }, + ], + "The evidence file is not a HAR capture.", + ), + ); + return ok({ + records: capture.data.records, + evidenceId, + captureKind: "web-network-capture", + captureSha256: capture.data.artifact.sha256, + }); } - const capture = webNetworkCaptureSchema.safeParse(normalized); - 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.", - ), - ); - // The manifest view projects HAR captures only, and replay reads HAR's response fields. - if (capture.data.format !== "har") - return err( - inputError( - operation, - [ - { - path: ["evidence", "normalized_result", "format"], - reason: "invalid_value", - expected: "har", - message: `Agent environments replay HAR captures; this capture's format is ${capture.data.format}.`, - }, - ], - "The evidence file is not a HAR capture.", - ), - ); - return ok({ - records: capture.data.records, - evidenceId: evidence.value.evidence_id, - 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.", + ), + ); }; /** @@ -317,6 +344,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 5e10269f6c..c30d1c11d6 100644 --- a/src/cli/environmentCommands.ts +++ b/src/cli/environmentCommands.ts @@ -220,7 +220,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 6fe55a1e27..a4fc879c5a 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. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. 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; it relies on capture-time redaction of declared secrets instead of that refusal, and 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. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. 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..aa867eb267 100644 --- a/src/domain/analysisView/WebNetworkProjectionAccumulator.ts +++ b/src/domain/analysisView/WebNetworkProjectionAccumulator.ts @@ -63,9 +63,15 @@ export abstract class WebNetworkProjectionAccumulator { } /** Reserve schema output and validation scratch while the borrowed parent stays live. */ - admitCaptureClone(value: unknown): Result { - return admitAnalysisViewData(value, false, (bytes) => - this.retain(bytes) ? ok(undefined) : this.overBudget(), + admitCaptureClone( + value: unknown, + options?: { readonly copiedStrings: boolean }, + ): Result { + return admitAnalysisViewData( + value, + false, + (bytes) => (this.retain(bytes) ? ok(undefined) : this.overBudget()), + options?.copiedStrings, ); } @@ -107,7 +113,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 b8b8842cdb..2da27c8c41 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -522,7 +522,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({ @@ -812,7 +815,9 @@ const unsupportedParent = ( new AnalysisUnsupportedTargetError( INSPECT_ANALYSIS_VIEW_OPERATION, parentArtifactPath(parent), - `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, or web network capture Evidence.`, + parent.operation === "capture_browser_scenario" + ? `Parent Evidence operation "capture_browser_scenario" supports only the environment-manifest view.` + : `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, or web network capture Evidence.`, ); const parentArtifactPath = (parent: AnalysisViewParent): string => { diff --git a/src/domain/analysisView/analysisViewDataAdmission.ts b/src/domain/analysisView/analysisViewDataAdmission.ts index ad39d23418..a4847c0ae2 100644 --- a/src/domain/analysisView/analysisViewDataAdmission.ts +++ b/src/domain/analysisView/analysisViewDataAdmission.ts @@ -10,12 +10,14 @@ const MEMBER_BYTES = 256; const CONTAINER_BYTES = 512; const textBytes = (length: number): number => 32 + length * 14; -/** Inspect borrowed data without invoking accessors; only API-surface reserves bytes. */ +/** Inspect borrowed JSON without accessors and reserve copied or transformed representations. */ export const admitAnalysisViewData = ( value: unknown, inline: boolean, reserve?: (bytes: number) => Result, + copiedStrings = false, ): Result => { + const countStrings = inline || copiedStrings; const ancestors = new Set(); // A single bounded stack preserves diagnostics without per-node path copies. const path: (string | number)[] = inline @@ -43,7 +45,9 @@ export const admitAnalysisViewData = ( return invalid(); const member = reserve?.( MEMBER_BYTES + - (inline && typeof item === "string" ? textBytes(item.length) : 0) + + (countStrings && typeof item === "string" + ? textBytes(item.length) + : 0) + (typeof item === "object" && item !== null ? CONTAINER_BYTES : 0), ); if (member !== undefined && !member.ok) return member; @@ -83,7 +87,7 @@ export const admitAnalysisViewData = ( // Zod reads named fields even when they are non-enumerable. for (const key of Object.getOwnPropertyNames(item)) { // Authentication hashes strings and keys alongside the schema clone. - const keyAdmission = inline + const keyAdmission = countStrings ? reserve?.(textBytes(key.length)) : undefined; if (keyAdmission !== undefined && !keyAdmission.ok) return keyAdmission; 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..53eed91940 --- /dev/null +++ b/src/domain/analysisView/scenarioCaptureRecords.test.ts @@ -0,0 +1,189 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it, vi } from "vitest"; + +import { + SCENARIO_EXCHANGES, + scenarioCaptureFixture, +} from "../../../tests/fixtures/browserScenarioCapture.js"; +import { + admitScenarioCaptureParent, + scenarioCaptureRecords, +} from "./scenarioCaptureRecords.js"; +import { EnvironmentManifestAccumulator } from "./EnvironmentManifestAccumulator.js"; + +describe("scenarioCaptureRecords", () => { + it("projects each transaction, in request order, as a HAR-shaped record with its retained bytes", () => { + const projected = scenarioCaptureRecords( + scenarioCaptureFixture(), + "manifest", + ); + 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 7 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.", + ); + }); + + 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 }], + }, + }; + const projected = scenarioCaptureRecords(duplicated, "manifest"); + expect(projected.records[0]?.reported).toEqual({}); + // No manifest tool can cite it, so it is not counted among bodiless tools. + expect( + projected.limitations.some((limitation) => + limitation.includes("retained no response body"), + ), + ).toBe(false); + }); + + it("replays a redacted body's markers, counts it, and treats a body redaction emptied as not retained", () => { + const capture = scenarioCaptureFixture(SCENARIO_EXCHANGES.slice(1, 3)); + const redacted = + (transaction: string, bytes: Buffer) => + (event: (typeof capture.events.items)[number]) => + event.kind === "network-content" && + event.phase === "response" && + event.transaction_id === transaction && + event.body.state === "captured" + ? { + ...event, + body: { + ...event.body, + content: bytes.toString("base64"), + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + redacted: true, + }, + } + : event; + const marker = Buffer.from('{"token":"[REDACTED:owner_token]"}'); + const projected = scenarioCaptureRecords( + { + ...capture, + events: { + ...capture.events, + items: capture.events.items + .map(redacted("request-1", marker)) + .map(redacted("request-2", Buffer.alloc(0))), + }, + }, + "manifest", + ); + expect(projected.records[0]?.reported).toMatchObject({ + response: { content: { text: marker.toString("base64") } }, + }); + expect(projected.records[1]?.reported).toMatchObject({ + response: { status: 200, content: { mimeType: "application/json" } }, + }); + expect(projected.records[1]?.reported).not.toHaveProperty( + "response.content.text", + ); + expect(projected.records[1]?.binary_fields).toEqual([]); + expect(projected.limitations).toEqual( + expect.arrayContaining([ + "1 of 2 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.", + "1 of 2 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.", + ]), + ); + }); +}); + +it("reserves retained request-body expansion before schema decoding or projection", () => { + const capture = scenarioCaptureFixture([ + { + method: "POST", + url: "/api/items", + status: 201, + mediaType: "application/json", + body: "{}", + requestMediaType: "application/json", + requestBody: JSON.stringify({ value: "x".repeat(16 * 1024) }), + }, + ]); + const decoder = vi.spyOn(TextDecoder.prototype, "decode"); + try { + const projected = admitScenarioCaptureParent( + { + evidenceId: `ev_${"a".repeat(64)}`, + operation: "capture_browser_scenario", + normalizedResult: capture, + limitations: [], + }, + new EnvironmentManifestAccumulator(128 * 1024), + ); + expect(projected).toMatchObject({ + ok: false, + error: { _tag: "AnalysisResourceConstraintError", resource: "memory" }, + }); + expect(decoder).not.toHaveBeenCalled(); + } finally { + decoder.mockRestore(); + } +}); + +it("does not decode request text when projecting replay records", () => { + const capture = scenarioCaptureFixture(); + const decoder = vi.spyOn(TextDecoder.prototype, "decode"); + try { + const projected = scenarioCaptureRecords(capture, "replay"); + expect(projected.records[4]?.reported).toMatchObject({ + request: { method: "POST", url: "http://127.0.0.1:3101/api/folders" }, + }); + expect(projected.records[4]?.reported).not.toHaveProperty( + "request.postData", + ); + expect(decoder).not.toHaveBeenCalled(); + expect(projected.records[4]?.binary_fields).toHaveLength(1); + } finally { + decoder.mockRestore(); + } +}); diff --git a/src/domain/analysisView/scenarioCaptureRecords.ts b/src/domain/analysisView/scenarioCaptureRecords.ts new file mode 100644 index 0000000000..df4378d1d7 --- /dev/null +++ b/src/domain/analysisView/scenarioCaptureRecords.ts @@ -0,0 +1,301 @@ +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; + +/** 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. */ +export const admitScenarioCaptureParent = ( + parent: AnalysisViewParent, + accumulator: WebNetworkProjectionAccumulator, +): Result => { + // 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. + 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(scenarioCaptureRecords(parsed.data, "manifest")); +}; diff --git a/tests/boundary/cli/environmentExportCli.test.ts b/tests/boundary/cli/environmentExportCli.test.ts index 064a8358dd..7f6dae529f 100644 --- a/tests/boundary/cli/environmentExportCli.test.ts +++ b/tests/boundary/cli/environmentExportCli.test.ts @@ -6,7 +6,7 @@ import { symlink, writeFile, } from "node:fs/promises"; -import { join } from "node:path"; +import { join, sep } from "node:path"; import { describe, expect } from "vitest"; @@ -106,7 +106,10 @@ describe("rea environment-export", () => { ) .filter((entry) => entry.isFile()) .map((entry) => - join(entry.parentPath, entry.name).slice(directory.length + 1), + join(entry.parentPath, entry.name) + .slice(directory.length + 1) + .split(sep) + .join("/"), ) .sort(); expect(written).toEqual(BUNDLE_FILES); diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 107eb1449d..874b4ade29 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -806,6 +806,18 @@ it.each([ "scripts/verify-agentenv-environment.mjs", { code: true, agentenv: true, runtime: false, package: false }, ], + [ + "src/domain/analysisView/scenarioCaptureRecords.ts", + { code: true, agentenv: true, runtime: true, package: false }, + ], + [ + "src/domain/analysisView/WebNetworkProjectionAccumulator.ts", + { code: true, agentenv: true, runtime: true, package: false }, + ], + [ + "src/domain/analysisView/analysisViewDataAdmission.ts", + { code: true, agentenv: true, runtime: true, package: false }, + ], [ "src/application/OwnedJsonInput.ts", { code: true, agentenv: true, runtime: true, package: false }, 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 }; +};