feat(environment): build environments from browser scenario captures (#5182)

Build an agent environment directly from retained capture_browser_scenario Evidence through the existing manifest, serve and export commands. Preserve request ordinals, source pointers, redaction markers, unavailable bodies, provenance and the default stdio MCP transport.

Followups admit copied/transformed text before body validation or decoding, avoid request-text allocation in replay, normalize portable file inventories, select dependent CI lanes, retain the author's redaction/body-budget corrections, and count body coverage over represented requests.

Validation: full pinned-toolchain static gate, source and public CLI/MCP regressions, canonical catalog checks, actual Chromium interactive and live GitHub API captures with exact HTTP replay and stdio parity, the generated non-root Docker image, and its smoke task through real agent-env 0.9.1334. Verification scopes, resource measurements and platform limits are recorded in the PR and feature verification document.

Co-authored-by: gensokyo-dev <gensokyo-dev@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar Arakelyan
2026-10-19 15:07:15 -07:00
committed by GitHub
parent 7a0c47bfa4
commit bf1eeee94f
24 changed files with 1443 additions and 77 deletions
+86
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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.
@@ -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.
@@ -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.
@@ -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 <bundle> --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.
+11 -1
View File
@@ -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
+1 -1
View File
@@ -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",
@@ -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}\`
@@ -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<string[]> =>
(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) => {
@@ -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 ?? ""}\``,
);
});
});
@@ -116,7 +116,7 @@ describe("loadEnvironmentSource", () => {
issues: [
{
path: ["evidence", "operation"],
expected: "inspect_web_network_capture",
expected: "inspect_web_network_capture or capture_browser_scenario",
},
],
},
@@ -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<number, RecordedExchange>;
@@ -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),
});
+1 -1
View File
@@ -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. */
File diff suppressed because one or more lines are too long
@@ -63,9 +63,15 @@ export abstract class WebNetworkProjectionAccumulator {
}
/** Reserve schema output and validation scratch while the borrowed parent stays live. */
admitCaptureClone(value: unknown): Result<void, AnalysisError> {
return admitAnalysisViewData(value, false, (bytes) =>
this.retain(bytes) ? ok(undefined) : this.overBudget(),
admitCaptureClone(
value: unknown,
options?: { readonly copiedStrings: boolean },
): Result<void, AnalysisError> {
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;
+7 -2
View File
@@ -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 => {
@@ -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<void, AnalysisError>,
copiedStrings = false,
): Result<void, AnalysisError> => {
const countStrings = inline || copiedStrings;
const ancestors = new Set<object>();
// 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;
@@ -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<CaptureParent, AnalysisError> => {
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,
@@ -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();
}
});
@@ -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<BrowserScenarioEvent, { kind: "request" }>;
type ResponseEvent = Extract<BrowserScenarioEvent, { kind: "response" }>;
type ContentEvent = Extract<BrowserScenarioEvent, { kind: "network-content" }>;
type CapturedBody = Extract<BrowserNetworkBody, { state: "captured" }>;
/** A scenario capture's network transactions in the record shape HAR captures project from. */
export interface ScenarioCaptureRecords {
readonly records: readonly WebNetworkCaptureRecord[];
readonly totalRecords: number;
readonly limitations: readonly string[];
}
interface TransactionIndex {
readonly requests: { readonly event: RequestEvent; readonly item: number }[];
readonly requestCounts: Map<string, number>;
readonly responses: Map<string, ResponseEvent[]>;
/** Content events keyed by the sequence of the request or response event they belong to. */
readonly contents: Map<number, ContentEvent[]>;
}
const append = <Key, Value>(
map: Map<Key, Value[]>,
key: Key,
value: Value,
): void => {
const existing = map.get(key);
if (existing === undefined) map.set(key, [value]);
else existing.push(value);
};
const indexTransactions = (
events: readonly BrowserScenarioEvent[],
): TransactionIndex => {
const index: TransactionIndex = {
requests: [],
requestCounts: new Map(),
responses: new Map(),
contents: new Map(),
};
for (const [item, event] of events.entries()) {
if (event.kind === "request") {
index.requests.push({ event, item });
index.requestCounts.set(
event.transaction_id,
(index.requestCounts.get(event.transaction_id) ?? 0) + 1,
);
} else if (event.kind === "response")
append(index.responses, event.transaction_id, event);
else if (event.kind === "network-content")
append(index.contents, event.source_event_sequence, event);
}
return index;
};
/** The one content event of an exchange phase, or undefined when absent or ambiguous. */
const onlyContent = (
index: TransactionIndex,
sequence: number | undefined,
): ContentEvent | undefined => {
const found =
sequence === undefined ? undefined : index.contents.get(sequence);
return found?.length === 1 ? found[0] : undefined;
};
const headerValue = (
headers: BrowserNetworkHeaders | undefined,
name: string,
): string | undefined => {
if (headers?.state !== "captured") return undefined;
const item = headers.items.find(
(candidate) => candidate.name.toLowerCase() === name,
);
return item?.value ?? undefined;
};
const captured = (
content: ContentEvent | undefined,
): CapturedBody | undefined =>
content?.body.state === "captured" ? content.body : undefined;
/** A captured response body, unless redaction emptied it because a secret could not be cut out. */
const retainedBody = (
content: ContentEvent | undefined,
): CapturedBody | undefined => {
const body = captured(content);
return body?.redacted === true && body.bytes === 0 ? undefined : body;
};
/** Strictly decoded text, keeping a byte-order mark; undefined for bytes that are not UTF-8. */
const utf8Text = (body: CapturedBody): string | undefined => {
try {
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
Buffer.from(body.content, "base64"),
);
} catch {
return undefined;
}
};
const mediaType = (content: ContentEvent | undefined): string | undefined =>
captured(content)?.media_type ??
headerValue(content?.headers, "content-type");
/** The request object HAR readers expect; a redacted URL is withheld so no path is templated from it. */
const reportedRequest = (
request: RequestEvent,
content: ContentEvent | undefined,
purpose: "manifest" | "replay",
): { readonly [key: string]: JsonValue } => {
const body = purpose === "manifest" ? captured(content) : undefined;
const text = body === undefined ? undefined : utf8Text(body);
const mimeType = mediaType(content);
return {
method: request.method,
url: request.url.redacted ? null : request.url.url,
...(text === undefined || mimeType === undefined
? {}
: { postData: { mimeType, text } }),
};
};
const record = (
ordinal: number,
observed: TransactionIndex["requests"][number],
index: TransactionIndex,
purpose: "manifest" | "replay",
): {
readonly record: WebNetworkCaptureRecord;
/** The record keeps a usable request line, so the manifest may cite it. */
readonly represented: boolean;
readonly body: boolean;
readonly redacted: boolean;
} => {
const { item, event: request } = observed;
const location = {
kind: "json-pointer" as const,
pointer: `/events/items/${String(item)}`,
};
const empty = {
binary_fields: [],
numeric_literals: [],
redactions: [],
limitations: [],
};
const responses = index.responses.get(request.transaction_id) ?? [];
const response = responses.length === 1 ? responses[0] : undefined;
// One request, at most one response and one content event per phase: otherwise the
// transaction cannot be attributed and is left unrepresented.
if (
index.requestCounts.get(request.transaction_id) !== 1 ||
responses.length > 1 ||
(index.contents.get(request.sequence)?.length ?? 0) > 1 ||
(response !== undefined &&
(index.contents.get(response.sequence)?.length ?? 0) > 1)
)
return {
record: { ordinal, location, reported: {}, ...empty },
represented: false,
body: false,
redacted: false,
};
const requestContent = onlyContent(index, request.sequence);
const responseContent = onlyContent(index, response?.sequence);
const body = retainedBody(responseContent);
const mimeType = mediaType(responseContent) ?? "";
return {
record: {
ordinal,
location,
reported: {
request: reportedRequest(request, requestContent, purpose),
...(response === undefined
? {}
: {
response: {
status: response.status,
content:
body === undefined
? { mimeType }
: { mimeType, text: body.content, encoding: "base64" },
},
}),
},
...empty,
binary_fields:
body === undefined
? []
: [
{
pointer: "/response/content/text",
representation: "har-base64-content",
state: "retained",
bytes: body.bytes,
sha256: body.sha256,
content_base64: body.content,
},
],
},
represented: !request.url.redacted,
body: body !== undefined,
redacted: body?.redacted === true,
};
};
/** Project each network transaction, in request order, as one record; the ordinal is its zero-based position. */
export const scenarioCaptureRecords = (
capture: BrowserScenarioCapture,
purpose: "manifest" | "replay",
): ScenarioCaptureRecords => {
const index = indexTransactions(capture.events.items);
const records: WebNetworkCaptureRecord[] = [];
let withoutBody = 0;
let representedCount = 0;
let redactedBodies = 0;
for (const [ordinal, observed] of index.requests.entries()) {
const projected = record(ordinal, observed, index, purpose);
records.push(projected.record);
if (!projected.represented) continue;
representedCount += 1;
if (!projected.body) withoutBody += 1;
if (projected.redacted) redactedBodies += 1;
}
return {
records,
totalRecords: records.length,
limitations: [
"Scenario records are the capture's network transactions in request order; each record ordinal is the transaction's zero-based position.",
...(capture.events.dropped > 0
? [
`The scenario capture omitted ${String(capture.events.dropped)} events; the manifest covers only retained transactions.`,
]
: []),
...(withoutBody > 0
? [
`${String(withoutBody)} of ${String(representedCount)} scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.`,
]
: []),
...(redactedBodies > 0
? [
`${String(redactedBodies)} of ${String(representedCount)} scenario transactions with a usable request line retained a response body with declared secret values replaced by redaction markers; tools citing them replay the redacted bytes.`,
]
: []),
],
};
};
/** Admit a capture_browser_scenario parent whose network content identifies response media types. */
export const admitScenarioCaptureParent = (
parent: AnalysisViewParent,
accumulator: WebNetworkProjectionAccumulator,
): Result<ScenarioCaptureRecords, AnalysisError> => {
// Body validation decodes base64, and projection constructs UTF-8 request text.
// Reserve their expanded strings before either allocation, even for retained parents.
const admitted = accumulator.admitCaptureClone(parent.normalizedResult, {
copiedStrings: true,
});
if (!admitted.ok) return admitted;
const parsed = browserScenarioCaptureSchema.safeParse(
parent.normalizedResult,
);
if (!parsed.success)
return err(
analysisViewInputError([
{
path: ["source"],
reason: "invalid_value",
message:
"Parent Evidence result does not match capture_browser_scenario.",
},
]),
);
const content = parsed.data.scenario.network_content;
// Tools are chosen by response media type, which only bodies or header values carry.
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"));
};
@@ -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);
@@ -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 },
+316
View File
@@ -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: "<!doctype html><title>Rota</title>",
},
{
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<typeof capturedBody>,
) => ({
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 };
};