mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user