diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index b23248073e..28f04e9cd0 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -1,8 +1,8 @@ --- name: reverse-engineer-anything -description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis. +description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA, and build agent environments (AgentEnv bundles) of web applications from a live instance or a capture. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis. metadata: - version: "46" + version: "48" --- # REA @@ -133,6 +133,9 @@ explicit path or endpoint and do not need it. `capture_browser_scenario` with declared actions. See [runtime observation](references/runtime-observation.md) for connect/launch and the focused follow-up browser tools. +- Agent environment (AgentEnv bundle) of a web application: read + [environments](references/environments.md) before recording; it records with + `capture_browser_scenario` and writes the bundle with `export_environment_bundle`. - Fiddler Everywhere traffic or a native `.saz` capture: read the [Fiddler workflow](references/runtime-observation.md#fiddler-everywhere-traffic-debugging). Inspect saved SAZ with `inspect_web_network_capture` and `format: "saz"`; @@ -471,6 +474,9 @@ as complete while required questions remain open. [references/runtime-observation.md](references/runtime-observation.md) - Evidence paging, comparisons, residual unknowns, and verification: [references/evidence-workflows.md](references/evidence-workflows.md) +- Agent environments (AgentEnv bundles) of a web application, recorded from + a live instance or a HAR: + [references/environments.md](references/environments.md) ## Finish the task diff --git a/.agents/skills/reverse-engineer-anything/references/environments.md b/.agents/skills/reverse-engineer-anything/references/environments.md new file mode 100644 index 0000000000..a07d768471 --- /dev/null +++ b/.agents/skills/reverse-engineer-anything/references/environments.md @@ -0,0 +1,215 @@ +# Agent environments from a live application + +Use this when the user asks for an agent environment (an AgentEnv bundle) of a +web application: a folder `agent-env` deploys, whose MCP tools answer from what +one recording of the application observed and serve the writes that recording +confirmed. The workflow records the application once with +`capture_browser_scenario`, checks what the environment would serve, records +again if a hint names a gap, then writes the bundle with +`export_environment_bundle`. For a HAR the user already has, skip to +[export](#export-and-hand-off) through `inspect_web_network_capture`, or use +`rea environment-build `. + +## Before recording + +- Record only against an instance the user says is disposable or seeded for + this purpose: every write step creates, edits or deletes its data. If the + user has not said so, ask before any scenario that writes. REA does not + start, seed or reset applications. +- Credentials come from environment variables the user names. Declare each as a + scenario secret, `{secret_id, environment_variable}`; the variable must be set + in the environment REA's MCP server started with. Never ask for a password in + chat. Declared secrets are redacted as the capture records. +- Map the application first with `inspect_web_page` (and `capture_web_screenshot` + when layout matters): the login form's locators, and the API calls behind the + pages that list and edit the entities the user asked about. + +## Record a session an environment can learn writes from + +One `capture_browser_scenario` records the whole session. Start from this +shape, with the application's own locators, paths and headers: + +```json +{ + "browser": { "mode": "launch", "executable_path": "/usr/bin/chromium" }, + "start_url": { "url": "http://127.0.0.1:3000/login" }, + "secrets": [ + { "secret_id": "admin_password", "environment_variable": "APP_PASSWORD" } + ], + "capture": { + "network": { + "request_body": true, + "response_body": true, + "header_values": true + } + }, + "actions": [ + { + "step_id": "user", + "action": "fill", + "timeout_ms": 15000, + "locator": { "kind": "css", "selector": "input[name=username]" }, + "value": { "source": "literal", "value": "admin" } + }, + { + "step_id": "password", + "action": "fill", + "timeout_ms": 15000, + "locator": { "kind": "css", "selector": "input[name=password]" }, + "value": { "source": "secret", "secret_id": "admin_password" } + }, + { + "step_id": "sign-in", + "action": "press", + "timeout_ms": 15000, + "locator": { "kind": "css", "selector": "input[name=password]" }, + "key": "Enter" + }, + { + "step_id": "signed-in", + "action": "wait_for", + "timeout_ms": 15000, + "locator": { "kind": "css", "selector": "nav a[href='/settings']" }, + "state": "visible" + }, + { + "step_id": "folders-1", + "action": "request", + "timeout_ms": 15000, + "method": "GET", + "path": "/api/folders", + "expected_status": [200] + }, + { + "step_id": "create-folder-1", + "action": "request", + "timeout_ms": 15000, + "method": "POST", + "path": "/api/folders", + "json": { "title": "Scratch folder 1" }, + "headers": [ + { + "name": "X-CSRFToken", + "value": { "source": "cookie", "name": "csrftoken" } + } + ], + "expected_status": [200, 201] + } + ] +} +``` + +Give every step a `timeout_ms`. A step without one waits until the scenario +is cancelled, so a locator that never matches hangs the capture, and the +client with it. Match `wait_for` locators to exactly one element. + +1. Sign in through the UI: `fill` the user and password fields (the password + as a secret reference), submit, then `wait_for` an element only a signed-in + user sees. +2. Read with `request` steps: each collection the environment needs, twice + before any write, so fields that change on their own show up; one item of + each. +3. Use `request` steps only when the API lets the caller choose each scratch + object's ID or a prior read already established it. Request paths are fixed + strings; no action substitutes a value from an earlier response. For + server-assigned IDs, create through the UI and open the new object's detail + view to record its read, edit and delete through UI actions. If that journey + cannot be scripted, leave the write unserved and report the limitation; + never guess an ID or create objects with no supported cleanup path. + Record two numbered scratch objects per write type, + with varied free text. After each write, and before the next one, read the + object back by id and then its list: create 1, read 1, list, create 2, + read 2, list; never several writes before their reads. +4. Leave more than a second between a create and its first edit + (`wait_for_timeout` with `duration_ms: 1100`). +5. Update both objects and read each back; delete both and read each back + (expect 404) and the list. + +About `request` steps: + +- `path` is on the start URL's origin; the page must be on that origin. +- Header values are literals, declared secrets, a cookie the browser holds + (`{source: "cookie", name}`), or a local storage entry + (`{source: "local_storage", key, json_pointer}`), with an optional + `prefix` such as `"Bearer "`. Copy the headers the application's own + requests send (a CSRF header, a bearer token). Values read from the page + are redacted from records made after they are read, not from the login + response that returned them, and values shorter than 8 characters are not + redacted at all. +- Reading a page value makes later bodies costlier to keep: after a CSRF + token, bodies over about 47 KiB are kept as `unavailable`. When the API + returns larger responses, raise `capture.limits` + (`max_network_body_allocation_bytes`, `max_total_event_allocation_bytes`). +- Request steps follow no redirects: a redirect fails the step, and + `expected_status` cannot name a 3xx status. Use the path the redirect names, + such as the one with the trailing slash. +- Set `expected_status` on every step, so a refused call fails its step and + the later steps report not having run, instead of recording read-backs of + nothing. + +Keep it to about 60 to 160 requests. A scenario with no `request` step is +recorded exactly as before. + +The capture returns its whole Evidence, every recorded body included, which is +often more than a client shows inline. Before checking coverage, confirm that +every entry of `normalized_result.steps` has `status: "completed"`; if the +client saved the result to a file, search it for `"status":"failed"` and read +that step's `error`. A failed sign-in or write cancels every step after it. +Use the result's `evidence_id` for coverage and export; do not paste the +Evidence back into another tool. + +## Check coverage + +`inspect_analysis_view` with `source: {kind: "retained-evidence", +evidence_id}` (the capture's id) and `view: {kind: "environment-coverage", +template_rules: ["after-uid-literal", "opaque-token", "sibling-variance", +"prefix-variance"]}`. It reports read tools +answered from the state or replayed (`reads.recorded_reasons`), the served write +tools, and each withheld write with its `reason` and `hint`. It does not apply +the serve-time catalog and state size checks; the export reports those. + +Re-record a withheld write at most twice, and only for a recording gap. Stop at +once when the reason is application behaviour, and report the write as +withheld with its hint: + +| Reason | What to do | +| --------------------- | --------------------------------------------------------------------------------------------- | +| `no-state-target` | Re-record: read the collection or item the write changes. | +| `no-observation` | Re-record: read the entity back by id after the write; read it first for an update or delete. | +| `unconfirmed` | Re-record: read the entity and its list after the write. | +| `incomplete-entities` | Re-record: read the entity through every view that lists it. | +| `unexplained-change` | Re-record once with a second, different call of the same write. | +| `no-call` | Re-record once with a write that succeeds. | +| `side-effect` | Stop: the application changes other entities. | +| `contradicted` | Stop: a later read disagrees with the modelled effect. | +| `body-unusable` | Stop: the body is not a JSON object. | + +Use new scratch names on each attempt; the first attempt's objects may still +exist. +A browser scenario carries no request times, so the coverage limitations say +the state learns no clock: an update the application stamps with a +modification time stays `unexplained-change` however it is recorded. Report +it rather than re-recording for it. + +## Export and hand off + +`export_environment_bundle` with `source: {kind: "retained-evidence", +evidence_id}` (or `{kind: "evidence-file", path}`), an absolute +`output_directory` that does not exist yet and whose parent does, and a `name` +(required when the application runs on `localhost` or an IP address). It returns +the coverage again, `tool_count`, the written files and warnings; `dry_run: +true` reports without writing. The bundle installs `rea-agents` of this REA's +version from the registry; when the user runs an unreleased REA build, pass the +`package_tarball` they give you (a packed `rea-agents` tarball) instead. + +Tell the user how many tools answer from the state, which writes are served and +which are withheld and why, and the run command (`agent-env run `). Say +that the bundle ships the recorded responses and the capture's local paths +(for a browser scenario, the browser executable's path), and +list any `credential-named-members` warning pointers for review. Never publish +or share the bundle yourself. + +See +[agent environments](https://github.com/morluto/rea/blob/main/docs/agent-environments.md) +for what the state, replay and the bundle establish, and +[evidence workflows](evidence-workflows.md) for the manifest and state views. diff --git a/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md b/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md index a59d2b2acd..4cff968233 100644 --- a/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md +++ b/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md @@ -106,6 +106,13 @@ each plan states why in its `reason`. It replays the capture instead with `--replay-only` or when the state is unavailable (not derivable, or a catalog over the discovery budget), and its stopped document's `state` says which applied. +Use `view: {kind: "environment-coverage"}` with the same rules to see what an +environment built from a capture would serve: read tools answered from the +state or replayed with their reason, and each write tool served or withheld +with a hint about what to record next; it does not apply the serve-time +catalog and state size checks. `rea environment-build ` +inspects, projects and exports in one step and prints the same coverage, or +`null` when the bundle serves replay. For a PE/ELF export question, `inspect_native_exports` accepts `detail: "summary"` and retains the complete table. Query `inspect_analysis_view` with diff --git a/docs/agent-environments.md b/docs/agent-environments.md index e541b34591..6103ebec53 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -1,16 +1,20 @@ # Agent environments from recorded captures -Use `rea environment-serve` to serve an environment manifest as an agent -environment: an MCP server whose tools answer from what one retained web -network capture observed, plus the environment card the agentenv framework -reads to discover it. By default the server derives the -[state the capture observed](#the-state-a-capture-observed) at startup and -answers read tools from it, and the write tools whose effect the capture -confirmed by changing it; `--replay-only` replays the capture's records +Use `rea environment-build` to turn one recorded capture into an agent +environment bundle in one offline step: a folder `agent-env run` builds and +runs, whose MCP server answers from what the capture observed. See +[Build a bundle in one step](#build-a-bundle-in-one-step). + +The steps it composes are separate commands too. `rea environment-serve` serves +an environment manifest as an agent environment: an MCP server whose tools +answer from what one retained web network capture observed, plus the +environment card the agentenv framework reads to discover it. By default the +server derives the [state the capture observed](#the-state-a-capture-observed) +at startup and answers read tools from it, and the write tools whose effect the +capture confirmed by changing it; `--replay-only` replays the capture's records instead. Nothing is fetched from the recorded application; every answer comes from retained Evidence. `rea environment-export` writes the same pair as a -bundle folder `agent-env run` builds and runs; see -[Export a bundle](#export-a-bundle). +bundle folder; see [Export a bundle](#export-a-bundle). ```sh rea inspect-web-network-capture /absolute/session.har har --format json > capture.json @@ -36,6 +40,185 @@ The third command serves that manifest from the state it derives from the capture; see [Serve from the state](#serve-from-the-state). 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`. +A `view.json` can also name the capture file as +`{"kind": "evidence-file", "path": "capture.json"}`, which projects it as +retained Evidence instead of charging the whole capture as inline input. + +## Build a bundle in one step + +```sh +rea environment-build grafana.har ./grafana --json +agent-env run ./grafana +``` + +`rea environment-build [directory]` takes a `.har` file, or capture +Evidence JSON from `inspect-web-network-capture` (HAR) or +`capture-browser-scenario`, and writes the bundle +[Export a bundle](#export-a-bundle) describes. It inspects a HAR as +`inspect-web-network-capture` does (Evidence input skips this), projects the +environment manifest from that Evidence as retained Evidence, authenticates +the pair, prepares the state the bundle's server derives at startup, and +writes the bundle with the export. For the same capture path, every file it +writes is byte-identical to what `inspect-web-network-capture`, +`inspect-analysis-view` and `environment-export` write, Evidence ids included, +when `view.json` lists the same rules in the views' order and the export is +given the build's name. It reads one file and writes one folder: it fetches +nothing and launches no browser (HAR inspection runs REA's own decoder +process), so record a browser scenario with `capture-browser-scenario` first +and pass its Evidence. Other capture formats are refused, as the manifest view +refuses them, and a HAR document saved as `.json` is refused with a hint to +name it `.har`. + +- `--template-rule `, repeated, replaces the default opt-in rules + `after-uid-literal`, `opaque-token`, `sibling-variance` and + `prefix-variance`, the rules the published capture-built environments use; + `--format-rules-only` applies none, the views' own default. Rules apply in + the views' fixed order, the report lists them, and a repeated rule is + refused as the views refuse it. +- `--name` names the env folder and env id. Without it the name is the + manifest's, unless the capture's origin is a loopback name or an IP literal: + the capture file then names it (`grafana.har` and `grafana.capture.json` + become `grafana`) and a `name-from-file` warning says so. A file name that + yields no portable label (kebab-case, at most 64 characters, not a Windows + device name) is refused; pass `--name`. +- `--package-tarball` works as for the export, and `--capture-timeout-ms` + as for `inspect-web-network-capture`. +- `--dry-run` runs every stage and prints the report with `status: "planned"` + and the files a build would write, but writes nothing and does not check the + directory, which may then be omitted. It reads and hashes a + `--package-tarball` as a build does before copying it, so it refuses the + tarballs a build refuses, including one whose contents cannot be read. + +The directory must be absent or empty, as for the export; the build never +replaces a folder. A refusal before the write leaves nothing behind. A capture +Evidence file, an inspected HAR's Evidence and the projected manifest must each +fit the live heap headroom for parsing and sealing, checked before the file is +read or the Evidence serialized; one that does not is refused as +`resource_constraint`. Each +refusal names its stage: `environment-build` for the build's own checks, +`inspect_web_network_capture` and `inspect_analysis_view` for inspection and +projection, `environment-export` for the bundle. SIGINT cancels the build +(`cancelled`); before the bundle stage nothing is written, and a cancellation +during the write names the bundle it wrote. Stage progress is logged to stderr +at info level; the report is the only output on stdout: + +```json +{ + "status": "built", + "directory": "/work/grafana", + "name": "grafana", + "capture": { + "evidence_id": "ev_…", + "operation": "inspect_web_network_capture", + "records": 105, + "input": "har" + }, + "manifest": { + "evidence_id": "ev_…", + "tools": 48, + "excluded_groups": 0, + "template_rules": [ + "after-uid-literal", + "opaque-token", + "sibling-variance", + "prefix-variance" + ] + }, + "state": { "served": true, "evidence_id": "ev_…" }, + "coverage": { + "reads": { + "state": 36, + "recorded": 6, + "recorded_reasons": { "query-varies": 3, "…": 1 } + }, + "writes": { + "served": [{ "tool": "delete_folders_by_uid", "effect": "delete" }], + "withheld": [ + { + "tool": "post_folders", + "effect": "create", + "reason": "unconfirmed", + "hint": "No later read showed the effect. Read the entity and its list after the write.", + "exemplar_ordinal": 94, + "contradicted_by": null + } + ] + } + }, + "tool_count": 37, + "package_source": { "kind": "registry", "specifier": "rea-agents@…" }, + "files": [{ "path": "README.md", "bytes": 4210 }], + "warnings": [{ "code": "name-from-file", "message": "…" }], + "next": ["agent-env run /work/grafana"] +} +``` + +`coverage` is the state's read views and write plans summarized, and `null` +when the state is unavailable and the bundle serves replay. The +[environment coverage](web-network-captures.md#environment-coverage) view +reports the same summary for any capture without building, so an agent can +check it after each recording. The view summarizes the state as derived; it +does not apply the serve-time admission checks, so for an environment whose +served catalog exceeds the discovery budget, or whose state exceeds what +`data/add` loads, it still reports the state while the build reports `coverage: +null` and the bundle serves replay. Each withheld write carries a hint about +what to record next; hints never change what is served: + +| Reason | Hint | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `no-state-target` | The write's path names no collection or item the capture reads. Read the collection or item it changes. | +| `no-call` | No recorded call of this tool was modelled, for example because every call failed. Record a successful call. | +| `body-unusable` | The request body is not a usable JSON object. | +| `no-observation` | Nothing showed the entity or document this write changed. Read a created entity back by id right after the write; for an update or delete the state must already hold the entity, so read it before the write or record the create that made it. | +| `unexplained-change` | The write changed fields it did not send, in a way no rule explains. A second, different call of the same tool can show the pattern. | +| `contradicted` | A later read disagreed with the modelled effect. Check the read at contradicted_by. | +| `side-effect` | The write changed something besides its own entity, which the environment does not model. | +| `unconfirmed` | No later read showed the effect. Read the entity and its list after the write. | +| `incomplete-entities` | The write is confirmed, but the views that show its entity cannot render it in full. Read the written entity through every view that lists it. | + +The bundle ships `evidence.json`, the whole capture Evidence, response bodies +included. Inspection excludes the values of the standard authentication headers +(`Authorization`, `Cookie` and `Set-Cookie` among them) and URL userinfo; a +custom key header such as `X-Api-Key`, a token in the query string and tokens +an application returns in bodies remain. The environment views refuse captures +inspected with `--sensitive-value`, so scrub a capture before building it. The +report's `credential-named-members` warning counts recorded header names, query +parameter names and JSON or form body members whose names look like +credentials (whole name segments such as `token`, `secret`, `password`, +`api_key`, `authorization` or `session`) and hold a value, and lists the first +20 by record and pointer, never by value. A body is read as a form or as JSON by +its recorded media type, or by its shape when it has none or one naming +neither. A browser scenario is +scanned by event (`event 4 response /token` names +`normalized_result.events.items[4]`): every retained request and response +content event, including a response whose request began before the browser was +attached and a transaction no record represents, and each transaction URL's +query once. Bodies larger than 4 MiB of UTF-8 text are not +scanned; `credential-scan-incomplete` counts them and lists up to 20 record or +event pointers, including base64 bodies whose decoded scan was cut. An empty name +warning does not establish that these bodies were checked. Names do not establish sensitivity, +so the build never refuses on them. Capture + +Evidence also records the capture file's absolute path (`subject.local_path`, +`parameters.capture_path`, `locations`, `artifact.path`), and the bundle +carries it: build from a neutral folder when that path should not ship. + +Over MCP, `export_environment_bundle` builds the same bundle and returns the +same report. Its `source` is capture Evidence the session retained +(`{"kind": "retained-evidence", "evidence_id"}`), such as a +`capture_browser_scenario` or `inspect_web_network_capture` result, or a +capture Evidence file (`{"kind": "evidence-file", "path"}`); inspect a HAR with +`inspect_web_network_capture` first. Retained Evidence is written into the +bundle as `--format json` writes it, so the bundle is the one +`environment-build` writes from that file. `output_directory` must be an +absolute path that does not exist yet, whose parent does; the tool creates it +and refuses an existing directory, empty or not. `name`, `template_rules`, +`package_tarball` and `dry_run` work as `--name`, `--template-rule`, +`--package-tarball` and `--dry-run` do, and retained Evidence has no file to +name the environment after, so a loopback or IP origin needs `name`. Unlike +`--dry-run`, `dry_run` still checks `output_directory`. Destination refusals +name `export_environment_bundle`; the others name their stage as +`environment-build`'s do. ## From a browser scenario @@ -125,6 +308,46 @@ 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). +To record writes, sign in through the UI and then send the application's API +calls with [request steps](browser-scenario-contract.md#request-steps): each +sends one request to the start origin through the page's own `fetch`, with the +session's cookies, and a header can carry the CSRF cookie or stored token the +application's own requests send. A write is served only when the capture shows +its effect, so read each collection before writing, read every written entity +back by id and in its list right after the write, and leave more than a second +between a create and its first edit. A request step's path is fixed when the +scenario is written and cannot use a value an earlier response returned, so an +id must be known before the run: one the caller chooses, such as a `uid` in the +create body, or one an earlier scenario created. When the server assigns ids, +read the list after each create (the create's response or the list shows the +new entity), and record updates and deletes in a second scenario that reads +the first one's objects by id before editing them, or make them through the +UI. The environment coverage view names each +write it withholds and what to record next. A scenario records no request +times, so the state learns no clock: an update whose response changes a +timestamp the server sets, such as a modification time, is withheld as +`unexplained-change` however it is recorded, and the coverage view says so; a +HAR of the same session times its entries and can serve it. A scenario with write requests +changes the application's data: record against an instance whose data may +change. + +```json +{ + "step_id": "create-folder-1", + "action": "request", + "method": "POST", + "path": "/api/folders", + "headers": [ + { + "name": "X-CSRFToken", + "value": { "source": "cookie", "name": "csrftoken" } + } + ], + "json": { "title": "Scratch folder 1" }, + "expected_status": [200, 201] +} +``` + ## What a manifest is An environment manifest is a projection of one application's recorded request diff --git a/docs/browser-scenario-contract.md b/docs/browser-scenario-contract.md index 751c133d04..2bbdc08b6d 100644 --- a/docs/browser-scenario-contract.md +++ b/docs/browser-scenario-contract.md @@ -10,8 +10,10 @@ liveness deadlines. `INPUT_JSON` may be inline JSON or a JSON file path. The minimal request contains only browser launch/connect selection, `start_url`, and at least one `actions` entry. Launch `headless` defaults to `true` and can be set to `false` where the host supports a visible browser. Navigations follow -the destinations named by the scenario and the page's ordinary browser requests; -there is no separate origin allowlist or request-interception layer. Environment +the destinations named by the scenario and the page's ordinary browser requests, +and [request steps](#request-steps) send only to the start URL's origin; +there is no separate origin allowlist. Request steps use a temporary dispatch +gate for cancellation; ordinary page requests retain their browser behavior. Environment settings (including service workers blocked by default), empty storage, and a final sanitized URL capture are supplied by default. Set `environment.service_workers` to `allow` when the application requires them. @@ -204,7 +206,8 @@ language environments. HTTP(S) URLs may include ordinary query values and fragments directly. Use ordered structured query entries when a value needs to reference a declared environment-backed secret. Form, storage, and cookie values are either literal -strings or declared secret references. Raw URL userinfo credentials are rejected. +strings or declared secret references; request step headers can also read a +cookie or local storage value from the page. Raw URL userinfo credentials are rejected. Captured credential-header values are not retained, and declared secret values are redacted automatically. A secret may be declared solely to @@ -315,6 +318,113 @@ external browser without closing it. The request itself defines the operation; there is no additional REA grant step. The browser and host still enforce their own access and process rules. +## Request steps + +A `request` step sends one HTTP request from the page with the page's own +`fetch`, so the browser attaches the session's cookies and the request is +recorded in the step's network events like any request the application makes. +Use it to record an application's API, including writes, when driving the UI +for each call would be slower or less exact than calling the endpoint the UI +calls. It is the only action that changes application state without a user +gesture, so run scenarios with write requests only against an instance whose +data you may change. This scenario creates one item: + +```json +{ + "browser": { + "mode": "launch", + "executable_path": "/absolute/path/to/chromium" + }, + "start_url": { "url": "http://127.0.0.1:3000" }, + "actions": [ + { + "step_id": "create-item", + "action": "request", + "method": "POST", + "path": "/api/items", + "query": [ + { "name": "notify", "value": { "source": "literal", "value": "false" } } + ], + "headers": [ + { + "name": "X-CSRFToken", + "value": { "source": "cookie", "name": "csrftoken" } + }, + { + "name": "Authorization", + "value": { + "source": "local_storage", + "key": "auth", + "json_pointer": "/token" + }, + "prefix": "Bearer " + } + ], + "json": { "title": "Scratch item 1" }, + "expected_status": [201], + "timeout_ms": 10000 + } + ] +} +``` + +- `method` is `GET`, `HEAD`, `POST`, `PUT`, `PATCH` or `DELETE`. `json` is the + body, sent with `Content-Type: application/json` unless a header sets one; a + `GET` or `HEAD` step cannot carry it. +- `path` starts with one `/` and must resolve to the start URL's origin, which + validation checks before the browser starts. When the step runs, the page must + be on that origin, checked again in the page as the request is sent; add a + `goto` step first if an earlier action left it. A query in `path` is sent as + written (a fragment is not sent), and `query` entries are appended after it + in order and may reference declared secrets. +- Request steps follow no redirects: a redirect answer fails the step, and + nothing is sent to the location it names, though a write the redirect + answered may already have been applied. `expected_status` cannot name a 3xx + status. Give the path the redirect names; with header values selected, the + capture records its `Location`. +- Header values are literals, declared secrets, a cookie the browser holds for + the request URL (`{"source": "cookie", "name"}`, HttpOnly cookies included, + whose value the page's scripts can then see while the request runs), or a + local storage entry on the start origin (`{"source": "local_storage", + "key"}`, with an optional `json_pointer` to a string or number in a stored + JSON value). `prefix` is sent before the value. A value read from the page is + redacted like a declared secret, as `[REDACTED:page:.
]`, + from records made after the step reads it; a response recorded earlier, such + as the login response that returned a token, keeps the value. A value read + again keeps its first marker, and a value shorter than 8 characters is not + redacted, since redacting it would rewrite ids, ports and hosts throughout + the capture. A value is redacted as stored and as `fetch` sends it, without + surrounding spaces and tabs. A missing cookie, key or pointer fails the step. + Reading a page value raises the estimated cost of redacting every later + body, so the bodies kept within `max_network_body_allocation_bytes` (1 MiB + by default) get smaller: after a 32-character `X-CSRFToken` value, a body over + about 47 KiB is kept as `unavailable`. Raise `capture.limits` when the + application's responses are larger. +- Validation refuses a header name given twice in a step, header names a + page's `fetch` cannot set, such as `Cookie`, `Origin`, `Host` or `Sec-*`, and + `User-Agent`, which Chrome drops; it refuses line breaks and other ASCII + control characters in literal values and prefixes, and the step fails before sending + when a secret or page value holds them. +- The step completes when the whole response has been read. With + `expected_status`, any other status fails the step and the later actions are + recorded as `cancelled`; without it, any response completes the step. A + network error fails the step. `timeout_ms` covers reading the header values + and the request: a step whose time runs out, or that is cancelled, before + its request is sent sends nothing, but a request already sent can still reach + the application after the step fails. + An absolute deadline also covers browser evaluation queued behind page work. + A temporary route checks cancellation again at network dispatch and is removed + when the request evaluation settles. A page controlled by a service worker + is refused because its fetch can bypass that dispatch gate; use the default + blocked service-worker policy for request steps. + +The request runs in the page's main world, so an application that wraps +`fetch` sees it, and it goes through the same network observation as other +page requests: select network content (bodies or header values) to record it. +Captures with request steps state in `limitations` that the steps sent their +declared requests through the page's fetch. A scenario without request steps +is unchanged. + ## Network evidence `capture.events: ["network"]` retains request, response, completion, failure, diff --git a/docs/cli.md b/docs/cli.md index 412df3e979..8a5d1535f8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -746,6 +746,31 @@ like a blank `MCP_HOST`. The bound URL is written to stderr as one JSON log line [agent environments](agent-environments.md) for what the state and replay return and do not establish. +## Build an environment bundle + +`rea environment-build [dir] [--name ] [--template-rule ...] +[--format-rules-only] [--package-tarball ] [--capture-timeout-ms ] [--dry-run]` builds the bundle +`environment-export` writes from one recorded capture: a `.har` file, which it +inspects as `inspect-web-network-capture` does, or capture Evidence JSON from +`inspect-web-network-capture` (HAR) or `capture-browser-scenario`. It projects +the environment manifest from the in-memory Evidence with the opt-in rules +`after-uid-literal`, `opaque-token`, `sibling-variance` and `prefix-variance` +unless `--template-rule` or `--format-rules-only` selects others, prepares the +state, and writes the bundle; every file is byte-identical to the three-step +path's when that path lists the same rules in the views' order and exports +under the build's name. A loopback or IP-literal capture is named after its file unless `--name` +is given. It prints one JSON report: the capture and manifest identities, the +state, a `coverage` summary of which tools answer from the state, which replay +and why each write is withheld with a hint, the files, warnings and the next +command. `--dry-run` writes nothing and may omit the directory. + +```sh +rea environment-build grafana.har ./grafana --json +``` + +See [agent environments](agent-environments.md#build-a-bundle-in-one-step) +for the report, the hints and what the bundle ships. + ## Export an environment bundle `rea environment-export --evidence @@ -921,6 +946,10 @@ server resources. It preserves a primary listener diagnostic, or a tool count and Evidence identifiers. The successful `stopped` document is returned only after cleanup is confirmed. The same in-process server owner retains failed stages for retry; the CLI exposes no separate retry command. +`rea environment-build` is one-shot too: it prints its report and exits `0`, +or prints the refusing stage's structured error and exits `1`, writing nothing +when the refusal comes before the bundle is written. SIGINT cancels it with a +`cancelled` error; a cancellation before the bundle stage writes nothing. `rea environment-export` is one-shot: it prints the written files and exits `0`, or prints a structured `invalid_request` error and exits `1` for the same pair refusals, a non-empty or file-occupied directory, a missing package diff --git a/docs/specs/agent-environments/environment-build/plan.md b/docs/specs/agent-environments/environment-build/plan.md new file mode 100644 index 0000000000..65b156bbda --- /dev/null +++ b/docs/specs/agent-environments/environment-build/plan.md @@ -0,0 +1,25 @@ +# Plan + +1. Add `environmentCoverageView.ts`: the request schema, the result fields, the + hint per write reason (`satisfies Record`), a + summarizer over state views and write plans, and the projector, which + projects the environment-state view and summarizes it. Register the kind in + the view unions, the grouping dispatch and the view service (state + accumulator, inferred confidence). +2. Split the export's input read into `readEnvironmentExportFile` and + `environmentExportFile` (parse and seal bytes), both naming the calling + operation, and let the export take an already prepared state. +3. Add `environmentBuild.ts`: classify the capture, project the manifest from + the in-memory Evidence, serialize it as `--format json` writes it to a file, + authenticate the pair from those bytes, name the environment, prepare the + state as the export does, summarize coverage, scan credential names, then + plan (dry run) or export. `environmentBuildReport.ts` holds the scan and the + naming helpers. +4. Register `environment-build` in the CLI: inspect a HAR through the capture + service as `inspect-web-network-capture` does, or read Evidence once as the + export does, and log stages at info. +5. Switch `scripts/verify-agentenv-environment.mjs` to the build command. +6. Document the command, the report, the hints, what ships, and the view. +7. Tests: coverage summary and hint completeness, the view against the state + view, build equivalence with the three-step path in process and through the + CLI, dry run, naming, refusals, the credential scan, and the view over MCP. diff --git a/docs/specs/agent-environments/environment-build/spec.md b/docs/specs/agent-environments/environment-build/spec.md new file mode 100644 index 0000000000..282cf16408 --- /dev/null +++ b/docs/specs/agent-environments/environment-build/spec.md @@ -0,0 +1,96 @@ +# Build an environment in one step + +Issue: https://github.com/morluto/rea/issues/4933 + +## Outcome + +Building an agent environment bundle takes three commands and a hand-written +view request that embeds the whole capture inline, so large captures hit inline +input accounting. The views apply no opt-in template rules by default, while the +published capture-built environments all use the same four, and a local capture +is named after its origin host (`127-0-0-1-3105`). Nothing reports which tools +an environment serves from its state, or why a write tool is withheld. + +`rea environment-build [directory]` builds the bundle in one offline +step and reports what it serves. A new `environment-coverage` analysis view +reports the same summary for any capture, so an agent can check a recording +before building. + +## Requirements + +- The capture is a `.har` file, inspected as `inspect-web-network-capture` + inspects it, or capture Evidence JSON from `inspect_web_network_capture` or + `capture_browser_scenario`; any other extension is refused before it is + read, and a HAR document saved as `.json` is refused with a hint. +- The manifest is projected from the in-memory capture as retained Evidence, + with the default rules `after-uid-literal`, `opaque-token`, + `sibling-variance` and `prefix-variance` unless `--template-rule` or + `--format-rules-only` selects others; rules apply in the views' order and a + repeated rule is refused, as the views refuse it. +- For the same capture path, every bundle file is byte-identical to what + `inspect-web-network-capture --format json`, `inspect-analysis-view --format + json` (the same rules in the views' order) and `environment-export` (the + build's name) write, Evidence ids included. The Evidence is serialized once + into one exactly sized buffer, whose bytes are both authenticated and shipped. +- The name is `--name`, else the manifest's, unless the origin is a loopback + name or IP literal: then the capture file's name, with a warning; a file name + that yields no portable bundle name is refused, asking for `--name`. +- The report carries the export's tool count, state, package source and files, + plus the capture and manifest identities, `coverage`, warnings and the next + command, shell-quoted. `coverage` + counts read tools answered from the state and replayed (per view reason), and + lists write tools as served or withheld with their reason, plan record, the + contradicting read and a hint; it is null when the state is unavailable. +- Every write reason has a hint; a reason without one fails to compile. +- Credential-named header names, query parameter names and JSON or form body + members that hold a value are reported as a count and the first 20 record + pointers, never values, and never refuse a build; the scan cannot overflow the + stack on deeply nested bodies. A body is read as a form + (`application/x-www-form-urlencoded`, by `URLSearchParams`) or as JSON (the + media types replay parses as JSON) by its recorded media type; a body with + none, or one naming neither, is JSON when it starts with `{` or `[` and a form + when it contains `=` and no whitespace, so plain text and empty bodies yield + no pointers. A browser scenario is scanned by event rather than by record: + every retained request and response network-content event's header values and + body, including a response whose request began before the browser was attached + and a transaction that cannot be attributed to one request and response, and + each transaction URL's query parameters once; its pointers name the event's + index in `events.items` (`event 4 response /token`). Each occurrence of a + repeated form field counts, as each repeated header or query parameter does. A + HAR request recorded with structured form parameters and no text, or the null + text mitmproxy's SaveHar records, is scanned by those parameters. Text bodies + over the 4 MiB scan limit, measured in UTF-8 bytes, including a response body + decoded only as a prefix, are not scanned; they are reported as a + `credential-scan-incomplete` warning with a count and the first 20 record or + event and side pointers (`record 3 response`, `event 4 response`), never + contents, and never refuse a build. Binary bodies hold no JSON or form text + and are neither scanned nor reported. +- The inspected HAR's Evidence and the projected manifest are admitted against + the live heap headroom for parsing and sealing before either is serialized, as + a capture Evidence file is before it is read; one that does not fit is refused + as `resource_constraint` before anything is written. +- An empty or NUL-containing capture, directory or package tarball argument is + refused as invalid input; it is never resolved to the working directory. +- Nothing is written before the bundle stage; `--dry-run` writes nothing, + reports the files a build would write and refuses the package tarballs a build + refuses, admitting, reading and hashing, and releasing the tarball as a build + does before copying it, so a tarball whose contents cannot be read is refused: + a close it cannot confirm is reported as incomplete cleanup that keeps its + owner. The directory must be absent or empty. +- SIGINT is checked between stages and after a dry run's last step: a build + cancelled before the bundle stage writes nothing, and one cancelled during the + write names the written bundle. +- The `environment-coverage` view summarizes the environment-state view with + the same rules, refuses what it refuses and is inferred. It does not apply the + serve-time catalog and state admission checks, and says so in a limitation. +- The `real-agentenv` lane builds its bundle with `environment-build`. + +## Non-goals + +- No change to the three existing commands' contracts, or to what any view + derives or any environment serves. +- No browser launch or network access: scenarios are recorded with + `capture-browser-scenario` first. +- No `--replace`, no refusal on credential names, and no rewriting of the + capture's absolute path, which Evidence identity includes. +- No MCP export tool; that is a separate change. diff --git a/docs/specs/agent-environments/environment-build/tasks.md b/docs/specs/agent-environments/environment-build/tasks.md new file mode 100644 index 0000000000..1ee2e8380b --- /dev/null +++ b/docs/specs/agent-environments/environment-build/tasks.md @@ -0,0 +1,13 @@ +# Tasks + +- [x] Record the outcome, requirements and non-goals. +- [x] Add the environment-coverage view and register it. +- [x] Split the export's input read and accept a prepared state. +- [x] Add the build module, its report helpers and the CLI command. +- [x] Switch the real-agentenv lane to the build command. +- [x] Document the command, the report, the hints and the view. +- [x] Add domain, service, CLI and MCP tests. +- [x] Run static, formatting, duplication, documentation and test gates. +- [x] Build the 50 published captures and compare them with the three-step path. +- [x] Build environments end to end on Linux VMs and run them with agents. +- [x] Address the independent review. diff --git a/docs/specs/agent-environments/environment-build/verification.md b/docs/specs/agent-environments/environment-build/verification.md new file mode 100644 index 0000000000..b478deecaf --- /dev/null +++ b/docs/specs/agent-environments/environment-build/verification.md @@ -0,0 +1,102 @@ +# Verification + +Executed on macOS with Node v24.22.1 (the checkout's `.nvmrc`) on main `3d4f5565`, and on Ubuntu 24.04 VMs for the +end-to-end runs. + +## Gates + +- `npm run -s check:fast`, `npm run -s format:check`, `npx knip`, `npm run -s scan:todos`, `npm run -s docs:check`, + `npm run -s verify:test-discovery` and `npm run -s mcp-catalog:check`: passed. +- `npm run jscpd`: 3.52% duplicated lines over 4348 files, as on main (3.52% over 4341). +- `npx vitest run --project domain --project services`: 9537 passed, 6 skipped and 151 failed. The same 151 fail on main + `3d4f5565` in this environment (9513 passed there); none is new. +- `npm run -s test:prepare`, then `npm run -s test:focused --` the environment build, export, export-inputs and serve CLI + boundaries, the analysis-view CLI and MCP boundaries and the environment serve MCP boundary: 7 files, 67 passed and 1 + failed, "projects portable artifact inventory after the original ZIP is removed", which fails the same way on main. +- `npm run verify:agentenv:environment` (the `real-agentenv` lane) passed on Ubuntu 24.04 with `agent-env 0.9.1334`. + +## Equivalence and coverage over the published captures + +Each of the 50 published capture-built environments' captures (capture Evidence JSON) and four scoped HAR recordings +(EspoCRM, Ghost, NocoDB, Sonarr) was built twice: once with `rea environment-build --json`, and once +with the three CLI steps, `inspect-web-network-capture --format json` (HAR only), `inspect-analysis-view` with an +`evidence-file` source and the four default rules, and `environment-export --name `. Every file of +every bundle was compared by SHA-256. + +| Inputs | Bundles identical | State served | Read tools from state / replayed | Write tools served / withheld | Build / three steps | +| ------------------------- | ----------------- | ------------ | -------------------------------- | ----------------------------- | ------------------- | +| 50 capture Evidence files | 50 of 50 | 50 | 1,100 / 290 | 50 / 143 | 32.9 s / 59.2 s | +| 4 scoped HARs | 4 of 4 | 4 | 166 / 21 | 5 / 12 | 8.3 s / 12.4 s | + +- The coverage of every capture equals an independent census of the environment-state view with the same rules (read + views by shape, write plans by verdict and reason). +- 43 of the 50 captures have a loopback origin and are named after their file (`grafana.capture.json` becomes `grafana`) + with a `name-from-file` warning. 20 carry a `credential-named-members` warning; the published captures were scrubbed by + their pipeline, whose placeholder strings still count. +- The withheld writes' reasons: no-state-target 86, contradicted 16, unconfirmed 14, no-observation 13, + unexplained-change 8, side-effect 4, body-unusable 1, incomplete-entities 1. + +## End to end + +Five environments last rebuilt on 10-23 were built again with this branch's packed CLI on disposable Ubuntu VMs, four +from their scoped HAR recordings and Metabase from its capture Evidence, with `rea environment-build +--name --package-tarball `. Each bundle then ran its smoke task, a retrieval eval generated from the +bundle's own manifest and capture, and the write eval from its recorded write spec, with two models through a LiteLLM +proxy (`fireworks_ai/gpt-oss-120b`, `fireworks_ai/glm-5p3-flash`), one run per task. + +| App | Input | Served tools of manifest | Served writes (build report) | Smoke | Retrieval gpt-oss / GLM | Writes gpt-oss / GLM | +| -------- | -------- | ------------------------ | -------------------------------------------------- | ------ | ----------------------- | -------------------- | +| Ghost | HAR | 35 of 38 | 0 (3 no-state-target) | passed | 12/12 / 8/12 | 0/2 / 0/2 | +| Metabase | Evidence | 40 of 42 | 1 (create contradicted, update unexplained-change) | passed | 12/12 / 12/12 | 1/2 / 1/2 | +| EspoCRM | HAR | 85 of 88 | 0 (2 contradicted, 1 unexplained-change) | passed | 12/12 / 10/12 | 0/2 / 0/2 | +| NocoDB | HAR | 23 of 28 | 0 (4 no-state-target, 1 unconfirmed) | passed | 6/6 / 5/6 | 0/3 / 0/3 | +| Sonarr | HAR | 49 of 50 | 5 (1 side-effect) | passed | 12/12 / 12/12 | 3/3 / 3/3 | + +- Every write task passed with both models when the build report listed its tool as served (Metabase's card delete and + Sonarr's release-profile create, update and delete), and failed when it listed it as withheld: 8 of 24 write episodes + passed, exactly the served ones. On main these apps' other writes are withheld; the published v0.2.0 versions were + built with the state-target and write-rule changes, which serve more of them. +- The retrieval misses are GLM answers on bundles byte-identical to the three-step path's; gpt-oss passed all 54. +- The `real-agentenv` lane, switched to `environment-build`, passed from a fresh checkout on Ubuntu with + `agent-env 0.9.1334` and the host's Docker: five public cases, the smoke task passed in 26.6 s, no new containers. + +## Review + +An independent review rebuilt the branch and tried to refute it. It confirmed byte-equality for pretty-printed, +CRLF and newline-free Evidence, Unicode HARs across output part boundaries, inline and file view sources, browser +scenario Evidence and relative or symlinked paths, and that the refactored export answers 16 success and refusal cases +exactly as main does. Its findings, all addressed: + +- The credential scan recursed and a 4 KB body nested 5,000 deep crashed the build; it now walks with its own stack, and + also scans header names, query names and form bodies. +- SIGINT after the capture was read was ignored; the build now checks the signal between stages and writes nothing when + cancelled before the bundle stage. +- The coverage view does not apply the serve-time catalog and state admission checks; a limitation and the docs now say + so. +- A dry run accepted a tarball the build refuses; it now opens it for reading. +- Repeated `--template-rule` values were deduplicated; they are refused as the views refuse them. A HAR saved as `.json` + gets a hint, file-derived names follow the bundle name rules, `localhost.` is anonymous, and the next command is + shell-quoted. +- The `no-observation` hint now covers updates and deletes of entities the state never held; the docs no longer claim + the build launches no process, state the conditions of byte-equality, and name the headers inspection excludes; the + MCP tool description names the view. + +## Review round on main `584120e8` + +The branch merged main `584120e8`: the export's file read now returns permission refusals through `Result`, as main +does, under the build's operation name, and the `environment-coverage` sentence sits in main's `inspect_analysis_view` +description. Four automated review findings were fixed, each with a test that fails on the merge: + +- A browser scenario's retained header values and the query parameters of its recorded URLs are scanned for credential + names as a HAR's are; the manifest and replay record projections are unchanged. +- `--dry-run --package-tarball` admits and releases the tarball through the export's own regular-file scope, so a close + it cannot confirm is retried and then reported as incomplete cleanup that keeps its owner. +- A dry run checks for cancellation after reading the tarball, before it reports a plan. +- Evidence is serialized into one exactly sized buffer instead of chunks joined by a copy. Those bytes are still parsed + and sealed before authentication, so authentication reads exactly what ships. + +On macOS with Node v22.23.1: the static gates passed (`npm run jscpd`: 3.53%), `npx vitest run --project domain +--project services` passed (9753, 6 skipped), the environment boundary projects passed (102), and the focused build, +export, export-inputs, serve and analysis-view boundaries with tool schema validity passed (9 files, 96). Rebuilt over +the same 50 captures and four HARs, all 54 bundles are byte-identical to the three-step path's, with the same coverage, +served writes and warnings as before the round. diff --git a/docs/specs/agent-environments/environment-prompt/plan.md b/docs/specs/agent-environments/environment-prompt/plan.md new file mode 100644 index 0000000000..e25f19d838 --- /dev/null +++ b/docs/specs/agent-environments/environment-prompt/plan.md @@ -0,0 +1,21 @@ +# Plan + +1. Add the `request` action to the scenario schema: methods, path, query, + headers with page value sources, JSON body and expected statuses; validate + the origin and the body against the start URL; include query and header + secrets in the declared-secret check. +2. Add `PlaywrightScenarioRequest.ts`: check the page's origin, build the URL, + resolve header values (admitting values read from the page to the scenario's + redaction), fetch in the page with a timeout and read the whole response, + then check the status. Dispatch it from the action runner with the start + origin, and add the limitation when a scenario has a request step. +3. Add a real-browser lane, `verify:browser:request`: a fixture item API that + requires a CSRF cookie header and a stored bearer token for writes, a write + session recorded through the CLI, the build's served writes, and a failing + expected status. +4. Add the `export_environment_bundle` contract, effects, CLI alias, catalog + family and handler: check the destination as other MCP output tools do, + resolve the capture bytes, and call the build with an exclusive destination. +5. Write the skill reference, route it from SKILL.md and install it with setup. +6. Document the request step, the MCP tool and recording writes. +7. Tests: request step unit tests, the MCP tool boundary, setup's skill files. diff --git a/docs/specs/agent-environments/environment-prompt/spec.md b/docs/specs/agent-environments/environment-prompt/spec.md new file mode 100644 index 0000000000..e55061042c --- /dev/null +++ b/docs/specs/agent-environments/environment-prompt/spec.md @@ -0,0 +1,83 @@ +# Build an environment from a prompt + +## Outcome + +`rea environment-build` builds a bundle from one capture, but an agent asked to +"make an environment of this app" still cannot finish the job through REA's MCP +server. A browser scenario can only reach an application's API through the UI or +a `goto` GET, so recording the writes an environment serves means scripting every +form, and there is no MCP tool that writes a bundle. Nothing tells the agent how +to record a session the state can learn writes from, or when to stop recording. + +A `request` scenario action sends one same-origin request through the page's own +`fetch`, so a scenario can sign in through the UI and then call the application's +API, writes included, with the session's cookies and the CSRF or bearer values +the application's own requests send. An `export_environment_bundle` MCP tool +builds the bundle from capture Evidence, as `environment-build` does. A skill +reference walks an agent from the prompt to a bundle: map, record, check +coverage, re-record by reason, export and hand off. + +## Requirements + +- A `request` step names a method (`GET`, `HEAD`, `POST`, `PUT`, `PATCH`, + `DELETE`), a path on the start URL's origin, ordered query entries, headers, + an optional JSON body and optional expected statuses. Validation refuses a + path that resolves to another origin, a body on `GET` or `HEAD`, a 3xx + expected status, header names a page's `fetch` cannot set or Chrome drops + (`User-Agent`), a header name given twice, line breaks or control characters + in literal values and prefixes, and a path the URL parser rejects, as a field + issue at the step's path. +- Header values are literals, declared secrets, a cookie the browser holds for + the request URL, or a local storage value on the start origin, optionally read + at a JSON pointer, with an optional prefix; local storage values are read only + while the page is on the start origin. Values read from the page are redacted + like declared secrets from records made after they are read, as stored and as + sent; a value read again keeps its first marker, a colliding label never + unredacts an earlier value, and values shorter than 8 characters are not + redacted. +- The request runs through the page's `fetch` with same-origin credentials, from + a page on the start origin (checked again in the page as it is sent), follows + no redirects, and completes when the response has been read to its end, + without keeping its body. A redirect, a status outside `expected_status`, a + network error or the step timeout fails the step; later steps are cancelled as + for other actions. The page sends only before the step's deadline, which it + receives as an absolute time, and within one second of being handed the + request; a busy page that starts later sends nothing, and the step hands the + request over again while it is neither cancelled nor out of time. A step + cancelled or out of time before the page starts its request sends nothing, + except a request the page starts within that second of being handed it. A + browser whose clock runs ahead of the host's fails the step. Its network + events are attributed to the step. +- Captures with request steps say so in a limitation. A scenario without one + validates, runs and records exactly as before. +- `export_environment_bundle` takes retained capture Evidence or a capture + Evidence file, an absolute output directory that does not exist and whose + parent does, and the build's name, template rules, package tarball and dry + run. It writes the bundle `environment-build` writes from the same Evidence + bytes (retained Evidence serialized as `--format json` writes it) and returns + the same report, validated by its output schema. It refuses an existing + directory, empty or not, a parentless or relative one, unknown Evidence, an + unnamed loopback capture and repeated rules, and writes nothing when it + refuses. Retained Evidence is refused for heap headroom before it is + serialized, as an Evidence file is before it is read. `environment-build` is + its CLI command. +- The skill reference `references/environments.md` is installed with the skill + and routed from SKILL.md. It covers disposable instances only, credentials + from environment variables, the recording rules the state needs, coverage, the + stop rule, the hand-off, and how to reach objects by id when the server + assigns ids: ids the caller chooses, a second scenario, or the UI. +- Coverage of a capture whose records are not all timed, which includes every + browser scenario capture, says the state learns no write clock. +- Stop rule: re-record a withheld write at most twice for a recording gap + (`no-state-target`, `no-observation`, `unconfirmed`, `incomplete-entities`), + once for `unexplained-change` and `no-call`, and stop at once for application + behaviour (`side-effect`, `contradicted`, `body-unusable`). + +## Non-goals + +- No cross-origin requests, form or multipart bodies, or arbitrary script + actions. +- No retroactive redaction of values a request step reads: a login response + recorded before the step keeps the token it returned, as captures do today. +- No application start, seed or reset, and no publishing of bundles. +- No change to what any view derives or any environment serves. diff --git a/docs/specs/agent-environments/environment-prompt/tasks.md b/docs/specs/agent-environments/environment-prompt/tasks.md new file mode 100644 index 0000000000..a0487aaa16 --- /dev/null +++ b/docs/specs/agent-environments/environment-prompt/tasks.md @@ -0,0 +1,11 @@ +# Tasks + +- [x] Record the outcome, requirements and non-goals. +- [x] Add the request action, its validation and its Playwright runner. +- [x] Add the real-browser request lane. +- [x] Add the export_environment_bundle tool. +- [x] Write and install the skill reference. +- [x] Document the request step, the tool and recording writes. +- [x] Run static, formatting, documentation and test gates, and the existing browser lanes. +- [x] Build environments from prompts with an agent against live applications on Linux VMs. +- [x] Address the two independent reviews. diff --git a/docs/specs/agent-environments/environment-prompt/verification.md b/docs/specs/agent-environments/environment-prompt/verification.md new file mode 100644 index 0000000000..cf8f86c8b3 --- /dev/null +++ b/docs/specs/agent-environments/environment-prompt/verification.md @@ -0,0 +1,82 @@ +# Verification + +Executed on macOS with Node v24.22.1 at `feat/environment-prompt` 8dfd5a4d (stacked on the environment-build branch at +eeab0bda), and on Ubuntu 24.04 VMs for the end-to-end runs. + +## Gates + +- `npm run -s typecheck`, `lint`, `format:check`, `docs:check`, `mcp-catalog:check`, `verify:test-discovery` and + `npx knip`: passed. +- `npx vitest run --project domain --project services --project adapters`: 16,347 passed and 1 failed, + "stops Inspector target authorization after the current stat settles", which fails the same way on the base fce1aba8 + in this environment (16,326 passed, 2 failed there); none is new. +- Boundaries: the bundle tool, analysis view, tool schema validity, environment-build CLI, setup skill and client + lifecycle suites (123 passed), and every browser and scenario MCP and CLI boundary suite (68 passed). +- Real Chrome: `verify:browser:request` (new: 22 request steps, the CSRF cookie and stored token authenticate seven + writes and never reach the Evidence, two queued writes are refused, the build serves the three write tools, an unexpected status fails the step and + cancels the rest, and a redirect to another origin is not followed, the other origin receiving nothing) and + `verify:browser:network` passed. `verify:browser` stops at "Real Chrome stable capture identities did not + reconcile", an `inspect_web_page` comparison this change does not touch, and fails the same way on the base. +- `verify:browser:request` runs each CLI call under the shared package verifier command owner, which stops the CLI and + the browser it launched on timeout, output overflow or failure before the lane's private root is removed, and reports + a failed stop with the root retained; `browserRequestVerifierOwnership.test.ts` proves a still-running CLI's + separately grouped browser is stopped before the root goes. + +Scenarios without request steps are unchanged: the action union gains a member, the secrets refactor compiles the same +patterns, the request-step limitation appears only with request steps, and the browser suites and the network lane pass. + +## End to end: environments from a prompt + +Five self-hosted applications were brought up fresh and seeded on four disposable VMs, loopback only. Claude Code 2.1.310 +(`claude-sonnet-5-5` through a LiteLLM proxy) got this branch's packed CLI, registered with `rea setup --client +claude_code` (the MCP server and the skill), and one prompt per application: build an agent environment of the app at +this URL, cover these entities, the instance is disposable but leave the seeded data as found, sign in as this user, the +password is in `APP_PASSWORD`, Chromium is here, write the bundle there. The prompt did not mention the skill. The agent +could use the REA tools, the skill and file tools; Bash and web tools were disabled. + +Each bundle was then rebuilt with `environment-build` from its own `evidence.json` and this branch's tarball (the +registry's `rea-agents@6.7.0` has no `environment-serve`); `manifest.json` and `evidence.json` were byte-identical to +the agent's in all five. It ran its smoke task, a retrieval eval generated from its manifest and capture, and the +application's recorded write eval with `fireworks_ai/gpt-oss-120b` and `fireworks_ai/glm-5p3-flash`, one run per task. + +| App | Agent run | Recordings | Records | Tools (state / replayed reads) | Served writes | Smoke | Retrieval gpt-oss / GLM | Writes gpt-oss / GLM | +| -------- | --------- | ---------- | ------- | ------------------------------ | ------------- | ------ | ----------------------- | -------------------- | +| Linkding | 182 s | 9 | 66 | 8 (7 / 1) | 0 | passed | 9/9 / 8/9 | 0/3 / 0/3 | +| Grocy | 253 s | 7 | 154 | 22 (16 / 0) | 6 | passed | 10/11 / 10/11 | 3/3 / 3/3 | +| Homebox | 391 s | 9 | 268 | 13 (11 / 2) | 0 | passed | 9/9 / 9/9 | 0/2 / 0/2 | +| Directus | 202 s | 7 | 100 | 25 (16 / 4) | 5 | passed | 12/12 / 11/12 | 2/2 / 2/2 \* | +| Grafana | 143 s | 4 | 100 | 15 (12 / 2) | 1 | passed | 5/5 / 5/5 | 2/2 / 2/2 | + +\* The Directus write eval checks that the state holds exactly the provisions the scripted recording read (2); the +agent's recording read all 5, so its count checks fail although every field check passes (0/2 with both models). With +the counts relative to the state's own provisions it passes 2/2 with both. + +- Write tasks passed exactly when the build served their tool, with both models. +- The skill loaded unprompted in every run. All five agents signed in through the form with the declared secret, + recorded the API with request steps, checked coverage, stopped by the withheld reasons and exported with + `export_environment_bundle`; each reported the credential-named warning pointers, and none published anything. +- The scripted recordings the published environments were built from, built with this branch, read more (11, 39, 21, + 31 and 35 state-backed reads) and serve 7, 3, 2, 2 and 0 writes; the prompted recordings serve 0, 6, 0, 5 and 1. +- Linkding's and Homebox's updates are withheld because a browser scenario carries no request times: the state learns + no clock, so an update the application stamps with a modification time is `unexplained-change`. Linkding's scripted + HAR serves the same PATCH with a clock rule. The coverage view now says so; recording request times on scenario + network events would lift it. Linkding derives each bookmark's archive URL from its creation time, so its create is + `contradicted` in both recordings. + +Earlier rounds: in the first, two agents hung on steps without `timeout_ms` and most guessed the scenario schema, so the +reference now starts from a whole scenario with a timeout on every step; the second (same five apps, before the review +fixes) served 1, 6, 0, 5 and 0 writes with the same eval pattern. + +## Review + +Two independent reviews tried to refute the change. + +- The first found a 302 that took a request step to another origin, page values left unredacted under a repeated or + colliding header label, short page values that rewrote ids and hosts across the capture, `User-Agent` accepted but + dropped by Chrome, a request that could still be sent after its step timed out, an origin check separated from the + send, a literal query re-encoded, line breaks accepted in header values, and CLI wording in MCP refusals. All are + fixed and tested; redirects are now checked in real Chrome. +- The second confirmed those fixes and found query entries mishandled after a path ending in `?` or `#`, 3xx expected + statuses that can never match, and padded stored values escaping redaction (fixed), plus the body cost of reading a + page value, short values kept in plain text and the MCP dry run checking its directory (documented). In the + prompted captures, only static assets lost bodies to the size limits. diff --git a/docs/specs/agent-environments/scenario-capture/spec.md b/docs/specs/agent-environments/scenario-capture/spec.md index 3956c4d9bc..9a8ce2f93d 100644 --- a/docs/specs/agent-environments/scenario-capture/spec.md +++ b/docs/specs/agent-environments/scenario-capture/spec.md @@ -61,7 +61,8 @@ change. - 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. + needs its own scope decision. (The `request` action was later scoped and + added by [environment-prompt](../environment-prompt/spec.md).) - No change to what the scenario redacts. Values the application returns in response bodies are retained, as they are in HAR captures. - The api-surface view keeps HAR parents only. diff --git a/docs/testing.md b/docs/testing.md index 4060d1959f..fff59c9512 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1935,13 +1935,15 @@ would remain after the pause, the verifier waits 90 seconds and runs once more, recording `attempts: 2` and `retried_for` in the receipt. Any other failure ends the lane at once. -The verifier writes the Grafana-shaped HAR the view tests own, inspects it and -projects the `environment-manifest` view with all three template rules through -the built CLI, packs the checkout with `npm pack`, exports the bundle with -`--package-tarball` so the image installs this checkout rather than the -registry package, and runs `agent-env run` on the bundle. It checks that the -export reports the planned file set with the inputs copied byte-exact and a -bundle served from the derived state, which serves fewer tools than the +The verifier writes the Grafana-shaped HAR the view tests own, packs the +checkout with `npm pack`, builds the bundle from the HAR with +`rea environment-build --package-tarball` through the built CLI, the command +users run, so the image installs this checkout rather than the registry +package, and runs `agent-env run` on the bundle. It checks that the build +reports the 28-record capture, the default template rules, the planned file set +with the shipped manifest naming the reported capture, a coverage summary whose +read and served write tools add up to the served tool count, and a bundle +served from the derived state, which serves fewer tools than the manifest because write tools the capture did not confirm are not served, that the runner exits `0` with `tasks/smoke.json` reported as passed and the verifier's description naming that served tool count, and that no container exists diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index ef40072501..866494acb7 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -802,6 +802,28 @@ derives it from the capture at startup, serves read tools and the served write plans from it and loads replacement states through the AgentEnv data plane, as [serve from the state](agent-environments.md#serve-from-the-state) describes. +## Environment coverage + +`view: {"kind":"environment-coverage"}` with the same `template_rules` reports +what the state an environment derives from the parent would serve, without the +state's entities: `reads.state` counts read tools answered from the state, +`reads.recorded` those that replay their recordings with +`reads.recorded_reasons` per view reason, `writes.served` lists the write tools +whose effect later reads confirmed, and `writes.withheld` lists every other +write tool with its reason, the record its plan was built from +(`exemplar_ordinal`), the read that disagreed (`contradicted_by`) and a `hint` +about what to record next. Hints are advice; they never change what is served. +The view projects the environment-state view and summarizes it, so it refuses +every parent that view refuses, with the same errors, and it is always +inferred. Call it after each recording to see which writes an environment +built from it would serve. It does not apply the serve-time admission checks: +an environment whose served catalog exceeds the discovery budget, or whose +state exceeds what `data/add` loads, serves replay, and `rea environment-build` +then reports `coverage: null`; otherwise the build prints this summary as its +`coverage`. See +[Build a bundle in one step](agent-environments.md#build-a-bundle-in-one-step) +for the hint per reason. + ## Resource and lifecycle boundaries Complete evidence has a 32 MiB input, 96 MiB decoder reply and 64-level nesting diff --git a/package.json b/package.json index e5da92fbdc..f34bc22899 100644 --- a/package.json +++ b/package.json @@ -220,6 +220,7 @@ "verify:frida:bindings": "node scripts/run-typescript.mjs --noEmit -p tsconfig.frida-verification.json", "verify:browser:dom": "node scripts/verify-browser-dom-destinations.mjs", "verify:browser:network": "npm run build:cached && node scripts/verify-browser-network.mjs", + "verify:browser:request": "npm run build:cached && node scripts/verify-browser-request.mjs", "verify:electron": "npm run build:cached && node scripts/verify-real-electron.mjs", "verify:inspector": "npm run build:cached && node scripts/verify/inspector/runtime-observation.mjs", "verify:javascript-runtime": "npm run build:cached && node scripts/verify/javascript/runtime-observation.mjs", diff --git a/scripts/fixtures/browser-request-site.mjs b/scripts/fixtures/browser-request-site.mjs new file mode 100644 index 0000000000..6695e5d2bd --- /dev/null +++ b/scripts/fixtures/browser-request-site.mjs @@ -0,0 +1,130 @@ +import { once } from "node:events"; +import { createServer } from "node:http"; + +/** The CSRF cookie and the stored bearer token the page holds; request steps must send both and the capture must not keep them. */ +export const requestFixtureCsrf = "csrf-fixture-7c1d2e"; +export const requestFixtureToken = "bearer-fixture-9f3a6b"; + +const html = `Items
Items
+`; + +const json = (response, status, value) => { + response.writeHead(status, { "Content-Type": "application/json" }); + response.end(value === undefined ? undefined : JSON.stringify(value)); +}; + +/** The item API's answer to one authorized request, as a status and body. */ +const answerItems = (store, method, id, body) => { + const { items } = store; + if (id === undefined) { + if (method === "GET") + return [200, { count: items.size, results: [...items.values()] }]; + if (method === "POST") { + const created = { id: store.next++, title: body.title, done: false }; + items.set(created.id, created); + return [201, created]; + } + return [405, { error: "method not allowed" }]; + } + const item = items.get(id); + if (item === undefined) return [404, { error: "not found" }]; + if (method === "GET") return [200, item]; + if (method === "PUT") { + const updated = { ...item, title: body.title, done: body.done }; + items.set(id, updated); + return [200, updated]; + } + if (method === "DELETE") { + items.delete(id); + return [204, undefined]; + } + return [405, { error: "method not allowed" }]; +}; + +/** Start a small item API that, like a cookie-session app with a CSRF header and a stored bearer token, refuses writes without both. */ +export async function startBrowserRequestSite() { + const store = { + items: new Map([ + [1, { id: 1, title: "Seeded item", done: false }], + [2, { id: 2, title: "Second seeded item", done: true }], + ]), + next: 3, + }; + const writes = []; + const hosts = []; + const server = createServer(async (request, response) => { + const url = new URL(request.url, "http://fixture.test"); + hosts.push(`${request.headers.host} ${url.pathname}`); + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const body = + chunks.length === 0 ? undefined : JSON.parse(Buffer.concat(chunks)); + if (url.pathname === "/") { + response.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Set-Cookie": `csrftoken=${requestFixtureCsrf}; Path=/; SameSite=Lax`, + }); + response.end(html); + return; + } + if (url.pathname === "/favicon.ico") { + response.writeHead(204); + response.end(); + return; + } + if (url.pathname === "/api/elsewhere") { + const port = new URL(`http://${request.headers.host}`).port; + response.writeHead(302, { + Location: `http://localhost:${port}/api/items`, + }); + response.end(); + return; + } + if (url.pathname === "/api/session") { + json(response, 200, { token: requestFixtureToken }); + return; + } + const match = /^\/api\/items(?:\/(\d+))?$/u.exec(url.pathname); + if (match === null) { + json(response, 404, { error: "not found" }); + return; + } + const id = match[1] === undefined ? undefined : Number(match[1]); + if (request.method !== "GET") { + writes.push({ + method: request.method, + path: url.pathname, + csrf: request.headers["x-csrftoken"] === requestFixtureCsrf, + bearer: + request.headers.authorization === `Bearer ${requestFixtureToken}`, + }); + if ( + request.headers["x-csrftoken"] !== requestFixtureCsrf || + request.headers.authorization !== `Bearer ${requestFixtureToken}` + ) { + json(response, 403, { error: "forbidden" }); + return; + } + } + const [status, value] = answerItems(store, request.method, id, body); + json(response, status, value); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (typeof address !== "object" || address === null) + throw new Error("Request fixture has no TCP address"); + return { + origin: `http://127.0.0.1:${address.port}`, + writes, + /** Host header and path of every request the server received. */ + hosts, + items: store.items, + async close() { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }, + }; +} diff --git a/scripts/lib/browser-request-e2e.mjs b/scripts/lib/browser-request-e2e.mjs new file mode 100644 index 0000000000..64b80c9b09 --- /dev/null +++ b/scripts/lib/browser-request-e2e.mjs @@ -0,0 +1,376 @@ +import assert from "node:assert/strict"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright-core"; + +import { browserScenarioSchema } from "../../dist/domain/browserScenario.js"; +import { browserScenarioCaptureSchema } from "../../dist/domain/browserScenarioCapture.js"; +import { parseEvidence } from "../../dist/domain/evidence.js"; +import { performScenarioRequest } from "../../dist/browser/PlaywrightScenarioRequest.js"; +import { BrowserScenarioSecrets } from "../../dist/browser/BrowserScenarioSecrets.js"; +import { PrivateRuntimeRoot } from "../../dist/process/PrivateRuntimeRoot.js"; +import { + requestFixtureCsrf, + requestFixtureToken, + startBrowserRequestSite, +} from "../fixtures/browser-request-site.mjs"; +import { withVerifierCleanup } from "./verifier-cleanup.mjs"; +import { createPackageVerifierCommands } from "./verify-package-commands.mjs"; + +const authenticated = [ + { name: "X-CSRFToken", value: { source: "cookie", name: "csrftoken" } }, + { + name: "Authorization", + value: { source: "local_storage", key: "auth", json_pointer: "/token" }, + prefix: "Bearer ", + }, +]; + +const read = (step_id, path) => ({ + step_id, + action: "request", + method: "GET", + path, + expected_status: [200], +}); + +const write = (step_id, method, path, { json, status }) => ({ + step_id, + action: "request", + method, + path, + headers: authenticated, + ...(json === undefined ? {} : { json }), + expected_status: [status], +}); + +const pause = (step_id) => ({ + step_id, + action: "wait_for_timeout", + duration_ms: 1100, +}); + +/** A recording that writes two scratch items and reads each write back at once, as an environment needs. */ +const writeSession = (executable, origin) => + browserScenarioSchema.parse({ + browser: { mode: "launch", executable_path: executable }, + start_url: { url: `${origin}/` }, + actions: [ + { + step_id: "ready", + action: "wait_for", + locator: { kind: "css", selector: "#ready" }, + state: "visible", + timeout_ms: 10_000, + }, + read("list-1", "/api/items"), + read("list-2", "/api/items"), + read("item-1", "/api/items/1"), + write("create-1", "POST", "/api/items", { + json: { title: "Scratch item 1" }, + status: 201, + }), + read("created-1", "/api/items/3"), + read("list-created-1", "/api/items"), + write("create-2", "POST", "/api/items", { + json: { title: "Scratch item 2" }, + status: 201, + }), + read("created-2", "/api/items/4"), + read("list-created-2", "/api/items"), + pause("settle-creates"), + write("update-1", "PUT", "/api/items/3", { + json: { title: "Scratch item 1 renamed", done: true }, + status: 200, + }), + read("updated-1", "/api/items/3"), + read("list-updated-1", "/api/items"), + write("update-2", "PUT", "/api/items/4", { + json: { title: "Scratch item 2 renamed", done: false }, + status: 200, + }), + read("updated-2", "/api/items/4"), + read("list-updated-2", "/api/items"), + write("delete-1", "DELETE", "/api/items/3", { status: 204 }), + { ...read("deleted-1", "/api/items/3"), expected_status: [404] }, + read("list-deleted-1", "/api/items"), + write("delete-2", "DELETE", "/api/items/4", { status: 204 }), + { ...read("deleted-2", "/api/items/4"), expected_status: [404] }, + read("list-deleted-2", "/api/items"), + read("session", "/api/session"), + ], + capture: { + at_end: ["url"], + network: { request_body: true, response_body: true, header_values: true }, + }, + }); + +/** Run the CLI under the owner, which stops it and every browser it launched on timeout or failure. */ +const cliOf = (commands, entrypoint) => async (args, maxBuffer) => + ( + await commands.exec(process.execPath, [entrypoint, ...args, "--json"], { + env: { + ...process.env, + REA_LOG_LEVEL: "silent", + HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper", + }, + timeout: 120_000, + maxBuffer, + }) + ).stdout; + +const captureOf = async (cli, scenario, maxBuffer) => { + const stdout = await cli( + ["capture-browser-scenario", JSON.stringify(scenario)], + maxBuffer, + ); + const evidence = parseEvidence(JSON.parse(stdout)); + return { + stdout, + evidence, + capture: browserScenarioCaptureSchema.parse(evidence.normalized_result), + }; +}; + +/** Every request step completed and sent exactly its own request. */ +const assertRequestsAttributed = (scenario, capture) => { + const steps = capture.steps.filter( + (step) => step.step_id !== "scenario_start", + ); + assert.equal(steps.length, scenario.actions.length); + for (const step of steps) + assert.equal(step.status, "completed", JSON.stringify(step)); + const requests = capture.events.items.filter( + (event) => event.kind === "request", + ); + scenario.actions.forEach((action, index) => { + if (action.action !== "request") return; + const stepIndex = capture.steps.findIndex( + (step) => step.step_id === action.step_id, + ); + assert.equal(capture.steps[stepIndex].action, "request"); + const sent = requests.filter( + (event) => + event.step_index === stepIndex && + event.method === action.method && + new URL(event.url.url).pathname === action.path, + ); + assert.equal(sent.length, 1, `step ${String(index)} ${action.step_id}`); + }); +}; + +/** The CSRF cookie and stored token reached the server but no record keeps them. */ +const assertPageValuesRedacted = (evidence, capture) => { + // Bodies are base64 in the Evidence, so decode them before looking for the values. + const contents = capture.events.items.filter( + (event) => event.kind === "network-content", + ); + const bodies = contents + .filter((event) => event.body.state === "captured") + .map((event) => Buffer.from(event.body.content, "base64").toString()); + const headerValues = contents.flatMap((event) => + event.headers.state === "captured" + ? event.headers.items.map((item) => item.value ?? "") + : [], + ); + const retained = [JSON.stringify(evidence), ...bodies, ...headerValues]; + for (const value of [requestFixtureCsrf, requestFixtureToken]) + assert.ok( + retained.every((text) => !text.includes(value)), + `${value} was retained`, + ); + assert.ok( + headerValues.some((value) => + value.includes("[REDACTED:page:create-1.X-CSRFToken]"), + ), + "the CSRF header was not recorded redacted", + ); + assert.ok( + bodies.some( + (body) => + body.includes('"token"') && body.includes("[REDACTED:page:create-1."), + ), + "the session response was not recorded redacted", + ); + assert.ok( + capture.limitations.some((line) => line.startsWith("Request steps sent")), + ); +}; + +/** A status outside expected_status fails the step and cancels the rest; a redirect to another origin is not followed. */ +const assertUnexpectedStatusFails = async (cli, scenario, site) => { + const failing = browserScenarioSchema.parse({ + ...scenario, + actions: [ + write("delete-missing", "DELETE", "/api/items/999", { status: 204 }), + read("after", "/api/items"), + ], + }); + const { capture } = await captureOf(cli, failing, 16 * 1024 * 1024); + const missing = capture.steps.find( + ({ step_id }) => step_id === "delete-missing", + ); + assert.equal(missing.status, "failed"); + assert.match(missing.error ?? "", /answered 404; expected 204/u); + assert.equal( + capture.steps.find(({ step_id }) => step_id === "after").status, + "cancelled", + ); + const redirected = browserScenarioSchema.parse({ + ...scenario, + actions: [read("elsewhere", "/api/elsewhere")], + }); + const followed = await captureOf(cli, redirected, 16 * 1024 * 1024); + const step = followed.capture.steps.find( + ({ step_id }) => step_id === "elsewhere", + ); + assert.equal(step.status, "failed"); + assert.match(step.error ?? "", /request steps do not follow/u); + // Chrome reports the redirected request and aborts it; the other origin must never receive it. + assert.ok( + site.hosts.every((line) => !line.startsWith("localhost")), + "a request step followed a redirect to another origin", + ); +}; + +/** A genuinely busy page must not dispatch a write after its deadline or host cancellation. */ +const assertQueuedWritesStopped = async (executable, site) => { + const browser = await chromium.launch({ + executablePath: executable, + headless: true, + }); + try { + const page = await browser.newPage({ serviceWorkers: "block" }); + await page.goto(site.origin); + for (const cancelled of [false, true]) { + const scenario = browserScenarioSchema.parse({ + ...writeSession(executable, site.origin), + actions: [ + { + ...write("queued", "POST", "/api/items", { + json: { title: "must not be created" }, + status: 201, + }), + headers: [ + { + name: "X-CSRFToken", + value: { source: "literal", value: requestFixtureCsrf }, + }, + { + name: "Authorization", + value: { + source: "literal", + value: `Bearer ${requestFixtureToken}`, + }, + }, + ], + timeout_ms: cancelled ? 5000 : 50, + }, + ], + }); + const secrets = BrowserScenarioSecrets.resolve(scenario, {}); + if (!secrets.ok) throw secrets.error; + // A preceding evaluation monopolizes the page thread while Node can + // still cancel. Its protocol request is queued before the request step. + const busy = page.evaluate(() => { + const until = Date.now() + 300; + while (Date.now() < until) {} + }); + const controller = new AbortController(); + const before = site.writes.length; + const request = performScenarioRequest({ + page, + action: scenario.actions[0], + secrets: secrets.value, + startOrigin: site.origin, + signal: controller.signal, + }); + const outcome = request.then( + () => ({ ok: true }), + (error) => ({ ok: false, error }), + ); + const timer = cancelled + ? setTimeout(() => controller.abort("queued request cancelled"), 30) + : undefined; + try { + const result = await outcome; + assert.equal(result.ok, false, "queued write unexpectedly completed"); + assert.equal( + site.writes.length, + before, + "the server received a late write", + ); + await busy; + } finally { + if (timer !== undefined) clearTimeout(timer); + } + } + } finally { + await browser.close(); + } +}; + +/** + * Record a write session through request steps in a real browser, then build + * an environment from it: every request lands in its own step, the page's + * CSRF cookie and stored token authenticate the writes and never reach the + * Evidence, and the build serves the recorded writes. + */ +export async function verifyBrowserRequestSteps( + executable, + entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)), +) { + const site = await startBrowserRequestSite(); + let root; + let commands; + const proof = async () => { + root = await PrivateRuntimeRoot.create({ prefix: "rea-request-e2e-" }); + commands = await createPackageVerifierCommands({ workspace: root }); + const cli = cliOf(commands, entrypoint); + const scenario = writeSession(executable, site.origin); + const { stdout, evidence, capture } = await captureOf( + cli, + scenario, + 64 * 1024 * 1024, + ); + assertRequestsAttributed(scenario, capture); + assert.equal(site.writes.length, 6); + assert.ok(site.writes.every(({ csrf, bearer }) => csrf && bearer)); + assertPageValuesRedacted(evidence, capture); + + const capturePath = join(root.path, "items.capture.json"); + await writeFile(capturePath, stdout); + const built = JSON.parse( + await cli( + ["environment-build", capturePath, join(root.path, "env")], + 16 * 1024 * 1024, + ), + ); + assert.equal(built.status, "built"); + assert.equal(built.name, "items"); + const served = built.coverage.writes.served.map(({ tool }) => tool).sort(); + assert.deepEqual( + served, + ["delete_items_by_id", "post_items", "put_items_by_id"], + JSON.stringify(built.coverage), + ); + + await assertUnexpectedStatusFails(cli, scenario, site); + await assertQueuedWritesStopped(executable, site); + return { + request_steps: scenario.actions.filter( + ({ action }) => action === "request", + ).length, + authenticated_writes: site.writes.length, + served_writes: served, + tool_count: built.tool_count, + queued_writes_refused: 2, + }; + }; + // The owner stops its commands' processes before removing the root; a failed stop keeps the root and is reported. + return withVerifierCleanup(proof, [ + () => (commands === undefined ? root?.close() : commands.close()), + () => site.close(), + ]); +} diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 066b11ae0d..d9eedb0292 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -127,6 +127,7 @@ export const toolFamilyCatalog = (sources) => { ...sources.webScriptContracts.WEB_SCRIPT_TOOL_CONTRACTS, ...sources.javascriptRecoveryContracts .JAVASCRIPT_RECOVERY_TOOL_CONTRACTS, + ...sources.environmentContracts.ENVIRONMENT_TOOL_CONTRACTS, ], }, { diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 446d2d5474..ba002dba70 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -116,6 +116,7 @@ const SOURCE_PATHS = { applicationContracts: "dist/contracts/applicationToolContracts.js", analysisViewContracts: "dist/contracts/analysisViewToolContracts.js", webScriptContracts: "dist/contracts/webScriptToolContracts.js", + environmentContracts: "dist/contracts/environmentToolContracts.js", javascriptRecoveryContracts: "dist/contracts/javascript/javascriptRecoveryToolContracts.js", javascriptRecoveryProvider: "dist/javascript/recovery/WakaruRelease.js", diff --git a/scripts/verify-agentenv-environment.mjs b/scripts/verify-agentenv-environment.mjs index bd7f0bf009..49e786ee7e 100644 --- a/scripts/verify-agentenv-environment.mjs +++ b/scripts/verify-agentenv-environment.mjs @@ -22,11 +22,6 @@ const QUOTA_RETRY_PAUSE_MS = 90_000; const RUN_BUDGET_MS = 22 * 60_000; const FIRST_ATTEMPT_MS = 15 * 60_000; const MIN_RETRY_MS = 5 * 60_000; -const TEMPLATE_RULES = [ - "after-uid-literal", - "opaque-token", - "sibling-variance", -]; // Both tools are caller-supplied absolute paths, as every real-* lane names its upstream. for (const key of ["REA_AGENT_ENV_COMMAND", "REA_DOCKER_COMMAND"]) @@ -80,43 +75,6 @@ try { ); const harPath = join(runtime.path, "capture.har"); await writeFile(harPath, JSON.stringify(environmentManifestHar())); - const capture = await cli( - "inspect-web-network-capture", - harPath, - "har", - "--format", - "json", - ); - assert.equal(capture.operation, "inspect_web_network_capture"); - assert.equal(capture.normalized_result.total_records, 28); - cases++; - const viewPath = join(runtime.path, "view.json"); - await writeFile( - viewPath, - JSON.stringify({ - source: { kind: "inline", evidence: capture }, - view: { kind: "environment-manifest", template_rules: TEMPLATE_RULES }, - }), - ); - const manifest = await cli( - "inspect-analysis-view", - viewPath, - "--format", - "json", - ); - assert.equal(manifest.normalized_result.kind, "environment-manifest"); - assert.equal( - manifest.normalized_result.parent_evidence_id, - capture.evidence_id, - ); - assert.equal(manifest.normalized_result.name, "grafana-example"); - const toolCount = manifest.normalized_result.tools.length; - assert.ok(toolCount > 0); - cases++; - const capturePath = join(runtime.path, "capture.json"); - const manifestPath = join(runtime.path, "manifest.json"); - await writeFile(capturePath, JSON.stringify(capture)); - await writeFile(manifestPath, JSON.stringify(manifest)); // The lane proves this checkout, not the registry package: pack it and install the tarball in the image. const { stdout: packed } = await commands.execNpm( ["pack", "--ignore-scripts", "--json", "--pack-destination", runtime.path], @@ -124,25 +82,43 @@ try { ); const tarball = join(runtime.path, packedFilename(JSON.parse(packed))); await access(tarball); + // The one-step build users run: it inspects the HAR, projects the manifest and writes the bundle. const bundle = join(runtime.path, "grafana-example"); - const exported = await cli( - "environment-export", - manifestPath, - "--evidence", - capturePath, + const built = await cli( + "environment-build", + harPath, bundle, "--package-tarball", tarball, ); - assert.equal(exported.status, "exported"); - assert.equal(exported.name, "grafana-example"); + assert.equal(built.status, "built"); + assert.equal(built.capture.operation, "inspect_web_network_capture"); + assert.equal(built.capture.records, 28); + cases++; + assert.equal(built.name, "grafana-example"); + const toolCount = built.manifest.tools; + assert.ok(toolCount > 0); + assert.deepEqual(built.manifest.template_rules, [ + "after-uid-literal", + "opaque-token", + "sibling-variance", + "prefix-variance", + ]); + cases++; // The bundle serves the state its server derives from the capture, which leaves the - // manifest's write tools unserved; the export reports the count the bundle serves. - assert.equal(exported.state.served, true); - const servedCount = exported.tool_count; + // manifest's write tools unserved; the build reports the count the bundle serves. + assert.equal(built.state.served, true); + const servedCount = built.tool_count; assert.ok(servedCount > 0 && servedCount < toolCount); - assert.equal(exported.package_source.kind, "tarball"); - assert.deepEqual(exported.files.map((file) => file.path).sort(), [ + // Every read tool and every served write tool is in the served catalog. + assert.equal( + built.coverage.reads.state + + built.coverage.reads.recorded + + built.coverage.writes.served.length, + servedCount, + ); + assert.equal(built.package_source.kind, "tarball"); + assert.deepEqual(built.files.map((file) => file.path).sort(), [ "README.md", "artifacts/check_grafana-example/verify.py", "envs/grafana-example/Dockerfile", @@ -152,9 +128,13 @@ try { "envs/grafana-example/rea-agents.tgz", "tasks/smoke.json", ]); - assert.equal( + const shipped = JSON.parse( await readFile(join(bundle, "envs/grafana-example/manifest.json"), "utf8"), - await readFile(manifestPath, "utf8"), + ); + assert.equal(shipped.evidence_id, built.manifest.evidence_id); + assert.equal( + shipped.normalized_result.parent_evidence_id, + built.capture.evidence_id, ); const dockerfile = await readFile( join(bundle, "envs/grafana-example/Dockerfile"), diff --git a/scripts/verify-browser-request.mjs b/scripts/verify-browser-request.mjs new file mode 100644 index 0000000000..6ed6741806 --- /dev/null +++ b/scripts/verify-browser-request.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import { access } from "node:fs/promises"; +import { verifyBrowserRequestSteps } from "./lib/browser-request-e2e.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { requireOrdinaryWindowsVerifierCaller } from "./lib/windows-ordinary-user-verifier.mjs"; + +const executable = process.env.REA_BROWSER_EXECUTABLE; +if (executable === undefined || executable === "") + throw new Error( + "verify:browser:request requires REA_BROWSER_EXECUTABLE pointing to an installed Chrome-family browser", + ); +await requireOrdinaryWindowsVerifierCaller(); +await access(executable); +const run = createVerifierRun(); +const proof = await verifyBrowserRequestSteps(executable, process.argv[2]); +process.stdout.write( + `${JSON.stringify({ ...proof, verifier_run: await completeVerifierRun(run), verified: true })}\n`, +); diff --git a/src/application/SetupSkill.ts b/src/application/SetupSkill.ts index f7f2723b80..2d94e6c83b 100644 --- a/src/application/SetupSkill.ts +++ b/src/application/SetupSkill.ts @@ -30,6 +30,7 @@ export const MANAGED_SKILL_FILES = [ "references/android-applications.md", "references/runtime-observation.md", "references/evidence-workflows.md", + "references/environments.md", ] as const; export const isManagedSkillManifest = (content: string): boolean => diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index 5b386e14a5..6d5309e4e2 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -193,7 +193,8 @@ export const inspectAnalysisViewValidated = ( ) : undefined; const stateAccumulator = - input.view.kind === "environment-state" + input.view.kind === "environment-state" || + input.view.kind === "environment-coverage" ? new EnvironmentStateAccumulator(undefined, input.view.template_rules) : undefined; const accumulator = @@ -387,8 +388,9 @@ const createAnalysisViewEvidence = ( rawResult: null, confidence: // Grouping is whole-capture, so any templated group or parameter makes the result inferred; - // entity types and identity in an extracted state always are. + // entity types and identity in an extracted state, and the coverage summarizing it, always are. result.kind === "environment-state" || + result.kind === "environment-coverage" || ((result.kind === "api-surface" || result.kind === "environment-manifest") && result.templated_groups > 0) diff --git a/src/application/environment/environmentBuild.test.ts b/src/application/environment/environmentBuild.test.ts new file mode 100644 index 0000000000..275071433f --- /dev/null +++ b/src/application/environment/environmentBuild.test.ts @@ -0,0 +1,881 @@ +import { evidenceFileBytes } from "./environmentEvidenceBytes.js"; +import { + chmod, + mkdir, + open, + readdir, + readFile, + writeFile, + type FileHandle, +} from "node:fs/promises"; +import { join, sep } from "node:path"; +import { Writable } from "node:stream"; + +import { describe, expect, it, vi } from "vitest"; + +import { + analysisViewCaptureEvidence, + environmentManifestCaptureFixture, +} from "../../../tests/fixtures/analysisView.js"; +import { + scenarioCaptureEvidence, + scenarioCaptureFixture, +} from "../../../tests/fixtures/browserScenarioCapture.js"; +import { environmentManifestHar } from "../../../tests/fixtures/historicalHar.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { ArtifactFileCleanupFailure } from "../../artifacts/ArtifactFileOperation.js"; +import { writeJsonOutput } from "../../cli/jsonOutput.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { analysisViewRequestSchema } from "../../domain/analysisView/analysisView.js"; +import { summarizeEnvironmentCoverage } from "../../domain/analysisView/environmentCoverageView.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewService.js"; +import { + DEFAULT_BUILD_TEMPLATE_RULES, + buildEnvironment, + canonicalTemplateRules, + classifyBuildCapture, + type EnvironmentBuildInput, +} from "./environmentBuild.js"; +import { + exportEnvironmentBundle, + readEnvironmentExportFile, + readEnvironmentExportInputs, +} from "./environmentExport.js"; +import { loadEnvironmentSource } from "./environmentSource.js"; +import { prepareServedState } from "./environmentState.js"; + +const listFiles = async (root: string): Promise => + (await readdir(root, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => + join(entry.parentPath, entry.name) + .slice(root.length + 1) + .split(sep) + .join("/"), + ) + .sort(); + +/** The Grafana-shaped capture with every recorded URL moved to a loopback origin. */ +const loopbackCapture = (): Evidence => + analysisViewCaptureEvidence( + JSON.parse( + JSON.stringify(environmentManifestCaptureFixture()).replaceAll( + /https?:\/\/grafana\.example/gu, + "http://127.0.0.1:3000", + ), + ) as ReturnType, + ); + +/** Write capture Evidence as `--format json` writes it, then read it as the build command does. */ +const captureFile = async ( + root: string, + evidence: Evidence, + name = "grafana.capture.json", +) => { + const path = join(root, name); + await writeFile(path, evidenceFileBytes(evidence)); + const read = await readEnvironmentExportFile( + "evidence", + path, + "environment-build", + ); + if (!read.ok) throw read.error; + return read.value; +}; + +const buildInput = async ( + root: string, + evidence: Evidence = analysisViewCaptureEvidence( + environmentManifestCaptureFixture(), + ), + overrides: Partial = {}, +): Promise => { + const capture = await captureFile(root, evidence); + return { + capture, + captureInput: "evidence", + capturePath: capture.path, + templateRules: DEFAULT_BUILD_TEMPLATE_RULES, + directory: join(root, "built"), + dryRun: false, + ...overrides, + }; +}; + +/** The three-step path: project the manifest, write both files, export them. */ +const threeStep = async ( + root: string, + evidence: Evidence, + name: string | undefined, +) => { + const manifest = inspectAnalysisViewValidated({ + source: { kind: "inline", evidence }, + view: analysisViewRequestSchema.parse({ + kind: "environment-manifest", + template_rules: [...DEFAULT_BUILD_TEMPLATE_RULES], + }), + }); + if (!manifest.ok) throw manifest.error; + const manifestPath = join(root, "manifest.json"); + const capturePath = join(root, "capture.json"); + await writeFile(manifestPath, evidenceFileBytes(manifest.value)); + await writeFile(capturePath, evidenceFileBytes(evidence)); + const inputs = await readEnvironmentExportInputs(manifestPath, capturePath); + if (!inputs.ok) throw inputs.error; + const source = loadEnvironmentSource( + inputs.value.manifest.value, + inputs.value.evidence.value, + ); + if (!source.ok) throw source.error; + const directory = join(root, "three-step"); + const exported = await exportEnvironmentBundle({ + ...inputs.value, + source: source.value, + directory, + name, + }); + if (!exported.ok) throw exported.error; + return { directory, exported: exported.value }; +}; + +/** The prototype every host file handle shares, so a test can fail one handle's host call. */ +const fileHandlePrototype = async (path: string): Promise => { + const handle = await open(path, "r"); + await handle.close(); + return Object.getPrototypeOf(handle) as FileHandle; +}; + +const sameTree = async (left: string, right: string): Promise => { + const files = await listFiles(left); + expect(await listFiles(right)).toEqual(files); + for (const file of files) + expect(await readFile(join(left, file)), file).toEqual( + await readFile(join(right, file)), + ); + return files.length; +}; + +describe("buildEnvironment", () => { + it("writes the bundle the three-step path writes, byte for byte", async () => { + const root = await createTestTempDirectory("rea-environment-build-same-"); + const evidence = analysisViewCaptureEvidence( + environmentManifestCaptureFixture(), + ); + const input = await buildInput(root, evidence); + const built = await buildEnvironment(input); + if (!built.ok) throw built.error; + const reference = await threeStep( + await createTestTempDirectory("rea-environment-build-ref-"), + evidence, + undefined, + ); + expect(await sameTree(input.directory!, reference.directory)).toBe(7); + expect(built.value).toMatchObject({ + status: "built", + name: "grafana-example", + capture: { + evidence_id: evidence.evidence_id, + operation: "inspect_web_network_capture", + input: "evidence", + }, + manifest: { + evidence_id: reference.exported.manifest_evidence_id, + template_rules: [...DEFAULT_BUILD_TEMPLATE_RULES], + }, + state: reference.exported.state, + tool_count: reference.exported.tool_count, + package_source: reference.exported.package_source, + files: reference.exported.files, + warnings: [], + next: [`agent-env run ${input.directory!}`], + }); + }); + + it("reports the coverage of the state the bundle serves", async () => { + const root = await createTestTempDirectory("rea-environment-build-cov-"); + const evidence = analysisViewCaptureEvidence( + environmentManifestCaptureFixture(), + ); + const built = await buildEnvironment( + await buildInput(root, evidence, { dryRun: true, directory: undefined }), + ); + if (!built.ok) throw built.error; + const manifest = inspectAnalysisViewValidated({ + source: { kind: "inline", evidence }, + view: analysisViewRequestSchema.parse({ + kind: "environment-manifest", + template_rules: [...DEFAULT_BUILD_TEMPLATE_RULES], + }), + }); + if (!manifest.ok) throw manifest.error; + const source = loadEnvironmentSource(manifest.value, evidence); + if (!source.ok) throw source.error; + const { state } = prepareServedState(source.value, evidence); + if (state === undefined) throw new Error("Expected a served state"); + expect(built.value.coverage).toEqual( + summarizeEnvironmentCoverage({ + views: state.views.values(), + writes: state.writes.values(), + }), + ); + expect( + built.value.coverage!.reads.state + built.value.coverage!.reads.recorded, + ).toBe(state.views.size); + }); + + it("plans the same files without writing anything on a dry run", async () => { + const root = await createTestTempDirectory("rea-environment-build-dry-"); + const planned = await buildEnvironment( + await buildInput(root, undefined, { dryRun: true }), + ); + if (!planned.ok) throw planned.error; + await expect(readdir(join(root, "built"))).rejects.toMatchObject({ + code: "ENOENT", + }); + const built = await buildEnvironment(await buildInput(root)); + if (!built.ok) throw built.error; + const { status, next, ...plannedRest } = planned.value; + const { status: builtStatus, next: builtNext, ...builtRest } = built.value; + expect([status, builtStatus]).toEqual(["planned", "built"]); + expect(next).toEqual([]); + expect(builtNext).toHaveLength(1); + expect(plannedRest).toEqual(builtRest); + }); +}); + +describe("buildEnvironment naming and refusals", () => { + it("names a loopback capture after its file and says so", async () => { + const root = await createTestTempDirectory("rea-environment-build-name-"); + const evidence = loopbackCapture(); + const input = await buildInput(root, evidence); + const built = await buildEnvironment(input); + if (!built.ok) throw built.error; + expect(built.value.name).toBe("grafana"); + expect(built.value.warnings).toEqual([ + expect.objectContaining({ + code: "name-from-file", + message: expect.stringContaining("http://127.0.0.1:3000"), + }), + ]); + const reference = await threeStep( + await createTestTempDirectory("rea-environment-build-name-ref-"), + evidence, + "grafana", + ); + await sameTree(input.directory!, reference.directory); + const named = await buildEnvironment({ + ...input, + name: "my-grafana", + directory: join(root, "named"), + }); + if (!named.ok) throw named.error; + expect(named.value.name).toBe("my-grafana"); + expect(named.value.warnings).toEqual([]); + }); + + it("refuses a loopback capture whose file name yields no name", async () => { + const root = await createTestTempDirectory("rea-environment-build-noname-"); + const capture = await captureFile(root, loopbackCapture(), "--.json"); + const built = await buildEnvironment({ + capture, + captureInput: "evidence", + capturePath: capture.path, + templateRules: DEFAULT_BUILD_TEMPLATE_RULES, + directory: join(root, "built"), + dryRun: false, + }); + if (built.ok) throw new Error("Expected a refusal"); + expect(projectAnalysisError(built.error)).toMatchObject({ + code: "invalid_request", + details: { + operation: "environment-build", + issues: [{ path: ["name"] }], + }, + }); + await expect(readdir(join(root, "built"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("refuses evidence that is not a capture, and a build without a directory", async () => { + const root = await createTestTempDirectory("rea-environment-build-refuse-"); + const evidence = analysisViewCaptureEvidence( + environmentManifestCaptureFixture(), + ); + const manifest = inspectAnalysisViewValidated({ + source: { kind: "inline", evidence }, + view: analysisViewRequestSchema.parse({ kind: "environment-manifest" }), + }); + if (!manifest.ok) throw manifest.error; + const notCapture = await buildEnvironment( + await buildInput(root, manifest.value), + ); + if (notCapture.ok) throw new Error("Expected a refusal"); + expect(projectAnalysisError(notCapture.error)).toMatchObject({ + code: "invalid_request", + details: { issues: [{ path: ["capture", "operation"] }] }, + }); + const noDirectory = await buildEnvironment( + await buildInput(root, evidence, { directory: undefined }), + ); + if (noDirectory.ok) throw new Error("Expected a refusal"); + expect(projectAnalysisError(noDirectory.error)).toMatchObject({ + code: "invalid_request", + details: { issues: [{ path: ["directory"] }] }, + }); + await expect(readdir(join(root, "built"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("refuses an occupied directory and leaves it as it was", async () => { + const root = await createTestTempDirectory("rea-environment-build-busy-"); + const input = await buildInput(root); + await mkdir(input.directory!); + await writeFile(join(input.directory!, "notes.md"), "keep"); + const built = await buildEnvironment(input); + if (built.ok) throw new Error("Expected a refusal"); + expect(projectAnalysisError(built.error).code).toBe("invalid_request"); + expect(await listFiles(input.directory!)).toEqual(["notes.md"]); + }); +}); + +describe("buildEnvironment cancellation and input checks", () => { + it("writes nothing when cancelled before the bundle stage", async () => { + const root = await createTestTempDirectory("rea-environment-build-stop-"); + const controller = new AbortController(); + const input = await buildInput(root, undefined, { + signal: controller.signal, + progress: (stage) => { + if (stage === "state") controller.abort(); + }, + }); + const built = await buildEnvironment(input); + if (built.ok) throw new Error("Expected a cancellation"); + expect(projectAnalysisError(built.error)).toMatchObject({ + code: "cancelled", + details: { operation: "environment-build" }, + }); + await expect(readdir(input.directory!)).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("names the written bundle when cancelled during the write", async () => { + const root = await createTestTempDirectory("rea-environment-build-late-"); + const controller = new AbortController(); + const input = await buildInput(root, undefined, { + signal: controller.signal, + progress: (stage) => { + if (stage === "bundle") controller.abort(); + }, + }); + const built = await buildEnvironment(input); + if (built.ok) throw new Error("Expected a cancellation"); + const projected = projectAnalysisError(built.error); + expect(projected.code).toBe("cancelled"); + expect(JSON.stringify(projected)).toContain( + `"output_directory":${JSON.stringify(input.directory)}`, + ); + expect(await listFiles(input.directory!)).toHaveLength(7); + }); + + it("refuses repeated rules and a HAR document saved as JSON", async () => { + const root = await createTestTempDirectory("rea-environment-build-input-"); + const repeated = await buildEnvironment( + await buildInput(root, undefined, { + templateRules: ["opaque-token", "opaque-token"], + }), + ); + if (repeated.ok) throw new Error("Expected a refusal"); + expect(projectAnalysisError(repeated.error)).toMatchObject({ + code: "invalid_request", + details: { issues: [{ path: ["template_rules"] }] }, + }); + const harPath = join(root, "grafana.json"); + await writeFile(harPath, JSON.stringify({ log: { entries: [] } })); + const har = await readEnvironmentExportFile("evidence", harPath); + if (!har.ok) throw har.error; + const asJson = await buildEnvironment({ + capture: har.value, + captureInput: "evidence", + capturePath: harPath, + templateRules: DEFAULT_BUILD_TEMPLATE_RULES, + dryRun: true, + }); + if (asJson.ok) throw new Error("Expected a refusal"); + expect(projectAnalysisError(asJson.error)).toMatchObject({ + code: "invalid_request", + message: expect.stringContaining(".har"), + details: { issues: [{ path: ["capture"] }] }, + }); + }); + + it("refuses an unreadable package tarball on a dry run as a build does", async () => { + if (process.getuid?.() === 0) return; + const root = await createTestTempDirectory("rea-environment-build-tgz-"); + const tarball = join(root, "rea-agents.tgz"); + await writeFile(tarball, "packed"); + await chmod(tarball, 0o000); + try { + const planned = await buildEnvironment( + await buildInput(root, undefined, { + dryRun: true, + packageTarball: tarball, + }), + ); + const built = await buildEnvironment( + await buildInput(root, undefined, { packageTarball: tarball }), + ); + if (planned.ok || built.ok) throw new Error("Expected both refusals"); + expect(projectAnalysisError(planned.error).code).toBe("access_denied"); + expect(projectAnalysisError(built.error).code).toBe("access_denied"); + } finally { + await chmod(tarball, 0o600); + } + }); + + it("refuses a loopback capture whose file name is too long, and quotes the next command", async () => { + const root = await createTestTempDirectory("rea-environment-build-long-"); + const long = await captureFile( + root, + loopbackCapture(), + `${"a".repeat(70)}.capture.json`, + ); + const refused = await buildEnvironment({ + capture: long, + captureInput: "evidence", + capturePath: long.path, + templateRules: DEFAULT_BUILD_TEMPLATE_RULES, + dryRun: true, + }); + if (refused.ok) throw new Error("Expected a refusal"); + expect(projectAnalysisError(refused.error)).toMatchObject({ + details: { operation: "environment-build", issues: [{ path: ["name"] }] }, + }); + const built = await buildEnvironment( + await buildInput(root, undefined, { directory: join(root, "my bundle") }), + ); + if (!built.ok) throw built.error; + expect(built.value.next).toEqual([ + `agent-env run '${join(root, "my bundle")}'`, + ]); + }); +}); + +describe("classifyBuildCapture", () => { + it("inspects .har files, reads .json Evidence and refuses anything else", () => { + expect(classifyBuildCapture("/x/grafana.HAR")).toEqual({ + ok: true, + value: "har", + }); + expect(classifyBuildCapture("/x/grafana.capture.json")).toEqual({ + ok: true, + value: "evidence", + }); + const refused = classifyBuildCapture("/x/grafana.saz"); + if (refused.ok) throw new Error("Expected a refusal"); + expect(projectAnalysisError(refused.error)).toMatchObject({ + code: "invalid_request", + details: { + operation: "environment-build", + issues: [{ path: ["capture"] }], + }, + }); + }); +}); + +describe("canonicalTemplateRules", () => { + it("orders rules as the views apply them", () => { + expect( + canonicalTemplateRules(["prefix-variance", "after-uid-literal"]), + ).toEqual(["after-uid-literal", "prefix-variance"]); + }); +}); + +describe("buildEnvironment credential names", () => { + it("flags the credential-named headers and query parameters a scenario capture retains, as a HAR's", async () => { + const root = await createTestTempDirectory("rea-environment-build-scan-"); + const evidence = scenarioCaptureEvidence( + scenarioCaptureFixture( + [ + { + method: "GET", + url: "/api/users/2?api_key=k-1&page=1", + status: 200, + mediaType: "application/json", + body: '{"id":2,"login":"probe"}', + requestHeaders: [ + { name: "X-Api-Key", value: "k-123" }, + { name: "Accept", value: "application/json" }, + { name: "Authorization", value: null }, + ], + responseHeaders: [{ name: "X-Auth-Token", value: "t-9" }], + }, + ], + { request_body: true, response_body: true, header_values: true }, + ), + ); + const planned = await buildEnvironment( + await buildInput(root, evidence, { + dryRun: true, + directory: undefined, + name: "rota", + }), + ); + if (!planned.ok) throw planned.error; + expect(planned.value.capture.records).toBe(1); + expect(planned.value.warnings).toEqual([ + expect.objectContaining({ + code: "credential-named-members", + pointers: [ + "event 0 request query api_key", + "event 1 request header X-Api-Key", + "event 3 response header X-Auth-Token", + ], + }), + ]); + const report = JSON.stringify(planned.value); + for (const value of ["k-123", "t-9"]) expect(report).not.toContain(value); + }); +}); + +/** A scenario capture whose event list `edit` rewrote, with its retained count kept consistent. */ +const editedScenarioEvidence = ( + capture: ReturnType, + edit: ( + items: ReturnType["events"]["items"], + ) => ReturnType["events"]["items"], +): Evidence => { + const items = edit(capture.events.items); + return scenarioCaptureEvidence({ + ...capture, + events: { ...capture.events, retained: items.length, items }, + }); +}; + +/** Drop a transaction's request event, as a capture attached after the request began records it. */ +const attachedMidFlight = + (transaction: string) => + (items: ReturnType["events"]["items"]) => + items.filter( + (event) => + event.kind !== "request" || event.transaction_id !== transaction, + ); + +const plannedScan = async (prefix: string, evidence: Evidence) => { + const root = await createTestTempDirectory(prefix); + const planned = await buildEnvironment( + await buildInput(root, evidence, { + dryRun: true, + directory: undefined, + name: "rota", + }), + ); + if (!planned.ok) throw planned.error; + return planned.value; +}; + +const USERS_EXCHANGE = { + method: "GET", + url: "/api/users/2", + status: 200, + mediaType: "application/json", + body: '{"id":2,"login":"probe"}', +} as const; + +describe("buildEnvironment credential scan of scenario events", () => { + it("scans retained content that no record represents: a response attached mid-flight and an ambiguous transaction", async () => { + const capture = scenarioCaptureFixture( + [ + USERS_EXCHANGE, + { + method: "GET", + url: "/api/session?api_key=k-mid", + status: 200, + mediaType: "application/json", + body: '{"token":"t-mid"}', + responseHeaders: [{ name: "X-Auth-Token", value: "t-header" }], + }, + { + method: "GET", + url: "/api/me", + status: 200, + mediaType: "application/json", + body: '{"sessionId":"s-ambiguous"}', + }, + ], + { request_body: true, response_body: true, header_values: true }, + ); + const duplicate = capture.events.items.find( + (event) => + event.kind === "response" && event.transaction_id === "request-3", + ); + if (duplicate === undefined) throw new Error("Fixture lacks a response"); + // The session's request began before attachment; the third transaction recorded two responses. + const planned = await plannedScan( + "rea-environment-build-events-", + editedScenarioEvidence(capture, (items) => [ + ...attachedMidFlight("request-2")(items), + { ...duplicate, sequence: 99 }, + ]), + ); + expect(planned.capture.records).toBe(2); + expect(planned.warnings).toEqual([ + expect.objectContaining({ + code: "credential-named-members", + pointers: [ + "event 3 response query api_key", + "event 4 response header X-Auth-Token", + "event 4 response /token", + "event 7 response /sessionId", + ], + }), + ]); + const report = JSON.stringify(planned); + for (const value of ["k-mid", "t-mid", "t-header", "s-ambiguous"]) + expect(report).not.toContain(value); + }); + + it("reports a response attached mid-flight that is too large to scan", async () => { + const capture = scenarioCaptureFixture([ + USERS_EXCHANGE, + { + method: "GET", + url: "/api/export", + status: 200, + mediaType: "application/json", + body: JSON.stringify({ + token: "t-large", + pad: "a".repeat(4 * 1024 * 1024), + }), + }, + ]); + const planned = await plannedScan( + "rea-environment-build-events-large-", + editedScenarioEvidence(capture, attachedMidFlight("request-2")), + ); + expect(planned.warnings).toEqual([ + expect.objectContaining({ + code: "credential-scan-incomplete", + pointers: ["event 4 response"], + }), + ]); + }); +}); + +describe("buildEnvironment credential scan coverage", () => { + it("warns that a body too large to scan was not scanned", async () => { + const root = await createTestTempDirectory("rea-environment-build-large-"); + const [entry] = environmentManifestHar().log.entries; + const text = JSON.stringify({ + token: "t-large", + pad: "a".repeat(4 * 1024 * 1024), + }); + const evidence = analysisViewCaptureEvidence( + environmentManifestCaptureFixture([ + { + ...entry!, + response: { + ...entry!.response, + content: { ...entry!.response.content, text }, + }, + }, + ]), + ); + const planned = await buildEnvironment( + await buildInput(root, evidence, { + dryRun: true, + directory: undefined, + name: "large", + }), + ); + if (!planned.ok) throw planned.error; + expect(planned.value.warnings).toEqual([ + expect.objectContaining({ + code: "credential-scan-incomplete", + pointers: ["record 0 response"], + }), + ]); + expect(JSON.stringify(planned.value.warnings)).not.toContain("t-large"); + }); +}); + +describe("buildEnvironment dry-run package tarball", () => { + it("refuses a tarball whose descriptor stats successfully but fails to read", async () => { + const root = await createTestTempDirectory("rea-environment-build-read-"); + const tarball = join(root, "rea-agents.tgz"); + await writeFile(tarball, "packed"); + const input = await buildInput(root, undefined, { + dryRun: true, + packageTarball: tarball, + }); + const prototype = await fileHandlePrototype(tarball); + const failure = Object.assign( + new Error("controlled tarball read failure"), + { code: "EIO" }, + ); + const read = vi.spyOn(prototype, "read").mockRejectedValue(failure); + try { + const planned = await buildEnvironment(input); + expect(read).toHaveBeenCalled(); + if (planned.ok) throw new Error("Expected a tarball read refusal"); + expect(projectAnalysisError(planned.error)).toMatchObject({ + code: "invalid_request", + details: { issues: [{ path: ["package_tarball"] }] }, + }); + expect(await readdir(root)).not.toContain("bundle"); + } finally { + read.mockRestore(); + } + }); + + it.each([false, true])( + "retries a failed tarball close and retains its owner when the failure persists: %s", + async (persistent) => { + const root = await createTestTempDirectory( + "rea-environment-build-close-", + ); + const tarball = join(root, "rea-agents.tgz"); + await writeFile(tarball, "packed"); + const input = await buildInput(root, undefined, { + dryRun: true, + packageTarball: tarball, + }); + const prototype = await fileHandlePrototype(tarball); + const stat = prototype.stat; + let owner: FileHandle | undefined; + let close: (() => Promise) | undefined; + let attempts = 0; + let released = false; + const failClose = (handle: FileHandle): void => { + const original = handle.close.bind(handle); + owner = handle; + close = original; + handle.close = () => { + attempts += 1; + return !released && (persistent || attempts === 1) + ? Promise.reject( + Object.assign(new Error("controlled tarball close failure"), { + code: "EIO", + }), + ) + : original(); + }; + }; + // The tarball is the only file a dry run opens; fail its handle's close. + const spy = vi.spyOn(prototype, "stat").mockImplementation(function ( + this: FileHandle, + ...args: unknown[] + ) { + if (owner === undefined) failClose(this); + return Reflect.apply(stat, this, args) as ReturnType< + FileHandle["stat"] + >; + } as FileHandle["stat"]); + try { + const planned = await buildEnvironment(input); + if (!persistent) { + if (!planned.ok) throw planned.error; + expect(attempts).toBe(2); + expect(planned.value.files.at(-1)).toEqual({ + path: expect.stringMatching(/\.tgz$/u), + bytes: 6, + }); + return; + } + if (planned.ok) throw new Error("Expected a cleanup refusal"); + expect(planned.error).toMatchObject({ + _tag: "AnalysisOutputError", + operation: "environment-export", + cleanupIncomplete: true, + }); + const failure = planned.error.cause; + if (!(failure instanceof ArtifactFileCleanupFailure)) + throw new Error("Missing exact cleanup owner"); + released = true; + await failure.cleanupLease.release(); + expect(planned.error.cleanupIncomplete).toBe(false); + expect(owner?.fd).toBe(-1); + } finally { + released = true; + spy.mockRestore(); + if (owner !== undefined && owner.fd >= 0) await close?.(); + } + }, + ); + + it("is cancelled by an interrupt that arrives while it reads the tarball", async () => { + const root = await createTestTempDirectory( + "rea-environment-build-tgz-stop-", + ); + const tarball = join(root, "rea-agents.tgz"); + await writeFile(tarball, "packed"); + const controller = new AbortController(); + const input = await buildInput(root, undefined, { + dryRun: true, + packageTarball: tarball, + signal: controller.signal, + }); + const prototype = await fileHandlePrototype(tarball); + const original = prototype.stat; + const stat = vi.spyOn(prototype, "stat").mockImplementation(function ( + this: FileHandle, + ...args: unknown[] + ) { + controller.abort(); + return Reflect.apply(original, this, args) as ReturnType< + FileHandle["stat"] + >; + } as FileHandle["stat"]); + try { + const planned = await buildEnvironment(input); + if (planned.ok) throw new Error("Expected a cancellation"); + expect(projectAnalysisError(planned.error)).toMatchObject({ + code: "cancelled", + details: { operation: "environment-build" }, + }); + } finally { + stat.mockRestore(); + } + }); +}); +describe("evidenceFileBytes", () => { + it("writes the bytes --format json writes, into one buffer", async () => { + // A long multibyte body with a surrogate pair across the serializer's first part boundary. + const [entry] = environmentManifestHar().log.entries; + const text = `${"a".repeat(8191)}😀${"é😀".repeat(40_000)}`; + const evidence = analysisViewCaptureEvidence( + environmentManifestCaptureFixture([ + { + ...entry!, + response: { + ...entry!.response, + content: { ...entry!.response.content, text }, + }, + }, + ]), + ); + const written: Buffer[] = []; + await writeJsonOutput( + evidence, + new Writable({ + write(chunk: Buffer, _encoding, done) { + written.push(chunk); + done(); + }, + }), + ); + const expected = Buffer.concat(written); + const concat = vi.spyOn(Buffer, "concat"); + try { + const bytes = evidenceFileBytes(evidence); + expect(concat).not.toHaveBeenCalled(); + expect(bytes.length).toBeGreaterThan(200_000); + expect(bytes.equals(expected)).toBe(true); + } finally { + concat.mockRestore(); + } + }); +}); diff --git a/src/application/environment/environmentBuild.ts b/src/application/environment/environmentBuild.ts new file mode 100644 index 0000000000..8390c7f8ba --- /dev/null +++ b/src/application/environment/environmentBuild.ts @@ -0,0 +1,409 @@ +import { setImmediate } from "node:timers/promises"; + +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, +} from "../../domain/analysisErrorCore.js"; +import { summarizeEnvironmentCoverage } from "../../domain/analysisView/environmentCoverageView.js"; +import { + TEMPLATE_RULES, + type TemplateRule, +} from "../../domain/analysisView/environmentManifestRules.js"; +import type { Evidence } from "../../domain/evidence.js"; +import type { EnvironmentBuildReport } from "../../domain/environmentBundleExport.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js"; +import { + ENVIRONMENT_EXPORT_OPERATION, + isPortableBundleName, + planEnvironmentBundle, + registryPackageSource, + type EnvironmentPackageSource, +} from "./environmentBundle.js"; +import { + anonymousOrigin, + credentialScanWarnings, + nameFromCaptureFile, + type EnvironmentBuildWarning, +} from "./environmentBuildReport.js"; +import { + environmentExportEvidenceFile, + exportEnvironmentBundle, + packageTarballBytes, + type EnvironmentExportFile, + type WrittenBundleFile, +} from "./environmentExport.js"; +import { + loadEnvironmentSource, + parseCaptureEvidence, + parseEvidenceFile, +} from "./environmentSource.js"; +import { prepareServedState } from "./environmentState.js"; + +/** Operation name the build command's own refusals report; each stage keeps its own. */ +export const ENVIRONMENT_BUILD_OPERATION = "environment-build"; + +/** The opt-in rules the rebuild pipeline selects for every published capture-built environment. */ +export const DEFAULT_BUILD_TEMPLATE_RULES: readonly TemplateRule[] = [ + "after-uid-literal", + "opaque-token", + "sibling-variance", + "prefix-variance", +]; + +/** What the capture argument is, decided from its file name before anything is read. */ +export type EnvironmentBuildCaptureInput = "har" | "evidence"; + +/** A HAR file is inspected; a JSON file must already be capture Evidence. */ +export const classifyBuildCapture = ( + path: string, +): Result => { + if (/\.har$/iu.test(path)) return ok("har"); + if (/\.json$/iu.test(path)) return ok("evidence"); + return err( + new AnalysisInputError( + ENVIRONMENT_BUILD_OPERATION, + { + userMessage: `${path} is neither a .har capture nor a .json capture Evidence file.`, + }, + [ + { + path: ["capture"], + reason: "invalid_value", + expected: ".har or .json", + message: + "Pass a HAR file, or capture Evidence JSON from inspect-web-network-capture (HAR) or capture-browser-scenario.", + }, + ], + ), + ); +}; + +/** Rules in the views' fixed order, so the report and manifest list them canonically. */ +export const canonicalTemplateRules = ( + rules: readonly TemplateRule[], +): TemplateRule[] => TEMPLATE_RULES.filter((rule) => rules.includes(rule)); + +export interface EnvironmentBuildInput { + /** The capture Evidence the bundle ships, read or serialized once. */ + readonly capture: EnvironmentExportFile; + readonly captureInput: EnvironmentBuildCaptureInput; + /** The capture argument, absolute; names a loopback capture's environment. */ + readonly capturePath: string; + readonly templateRules: readonly TemplateRule[]; + /** Absolute bundle root, absent or empty; may be absent only for a dry run. */ + readonly directory?: string | undefined; + readonly name?: string | undefined; + readonly packageTarball?: string | undefined; + readonly dryRun: boolean; + /** Called as each stage starts. */ + readonly progress?: ((stage: string) => void) | undefined; + /** Checked between stages; a build cancelled before the write writes nothing. */ + readonly signal?: AbortSignal | undefined; + /** Refuse an existing directory and never create its parent, as MCP output directories require. */ + readonly exclusiveDestination?: boolean | undefined; +} + +/** The report environment-build prints and export_environment_bundle returns. */ +export type { EnvironmentBuildReport }; + +const captureOperations = new Set([ + "inspect_web_network_capture", + "capture_browser_scenario", +]); + +const harDocument = (value: unknown): boolean => { + if (typeof value !== "object" || value === null) return false; + const log: unknown = Reflect.get(value, "log"); + return ( + typeof log === "object" && + log !== null && + Array.isArray(Reflect.get(log, "entries")) + ); +}; + +const harAsJson = (path: string): AnalysisInputError => + new AnalysisInputError( + ENVIRONMENT_BUILD_OPERATION, + { + userMessage: `${path} is a HAR document, not capture Evidence; save it as a .har file to build from it, or inspect it as a HAR first (inspect_web_network_capture) and build from that Evidence.`, + }, + [ + { + path: ["capture"], + reason: "invalid_value", + expected: "capture Evidence JSON, or a .har file", + message: + "A .json capture must be capture Evidence; a HAR document is inspected only from a .har file.", + }, + ], + ); + +const repeatedRules = (): AnalysisInputError => + new AnalysisInputError(ENVIRONMENT_BUILD_OPERATION, undefined, [ + { + path: ["template_rules"], + reason: "invalid_value", + message: "template_rules must not repeat a rule", + }, + ]); + +/** A cancellation, naming the bundle when it arrived after the bundle was written. */ +const cancelled = async ( + signal: AbortSignal | undefined, + written?: string, +): Promise => { + // The stages are synchronous; yield so a pending interrupt is dispatched before checking. + if (signal !== undefined) await setImmediate(); + return signal?.aborted === true + ? new AnalysisCancelledError(ENVIRONMENT_BUILD_OPERATION, { + cause: signal.reason, + ...(written === undefined + ? {} + : { + userMessage: `The build was cancelled after it wrote the bundle at ${written}.`, + diagnostics: { output_directory: written, bundle_written: true }, + }), + }) + : undefined; +}; + +const notCaptureEvidence = (operation: string): AnalysisInputError => + new AnalysisInputError( + ENVIRONMENT_BUILD_OPERATION, + { + userMessage: `The capture file is ${operation} Evidence, not capture Evidence.`, + }, + [ + { + path: ["capture", "operation"], + reason: "invalid_value", + expected: "inspect_web_network_capture or capture_browser_scenario", + message: + "Pass a HAR file, or capture Evidence JSON from inspect-web-network-capture (HAR) or capture-browser-scenario.", + }, + ], + ); + +const unnamed = (path: string): AnalysisInputError => + new AnalysisInputError( + ENVIRONMENT_BUILD_OPERATION, + { + userMessage: `The capture's origin is a loopback or IP address, and ${path === "" ? "retained Evidence has no file name to take one from" : `${path} yields no portable environment name (a kebab-case label of at most 64 characters that is not a Windows device name)`}; name the environment.`, + }, + [ + { + path: ["name"], + reason: "invalid_value", + message: + "Name the environment: a kebab-case label of lowercase letters, digits and single hyphens.", + }, + ], + ); + +const missingDirectory = (): AnalysisInputError => + new AnalysisInputError( + ENVIRONMENT_BUILD_OPERATION, + { userMessage: "Name the bundle directory, or plan a dry run." }, + [ + { + path: ["directory"], + reason: "invalid_value", + message: "The bundle directory may be omitted only with --dry-run.", + }, + ], + ); + +/** The given name; else the manifest's, unless its origin is anonymous, when the capture file names it. */ +const environmentName = ( + input: EnvironmentBuildInput, + manifest: { readonly name: string; readonly origin: string }, + warnings: EnvironmentBuildWarning[], +): Result => { + if (input.name !== undefined) return ok(input.name); + if (!anonymousOrigin(manifest.origin)) return ok(undefined); + const name = nameFromCaptureFile(input.capturePath); + if (!isPortableBundleName(name)) return err(unnamed(input.capturePath)); + warnings.push({ + code: "name-from-file", + pointers: [], + message: `The capture's origin ${manifest.origin} names no application, so the environment is named ${name} after the capture file; name it explicitly to choose another.`, + }); + return ok(name); +}; + +/** A path as one POSIX shell word. */ +const shellWord = (path: string): string => + /^[\w./@%+=:,-]+$/u.test(path) ? path : `'${path.replaceAll("'", "'\\''")}'`; + +const packageSourceValue = ( + source: EnvironmentPackageSource, +): EnvironmentBuildReport["package_source"] => + source.kind === "registry" + ? { kind: "registry", specifier: source.specifier } + : { kind: "tarball", path: source.path }; + +/** + * Build an agent environment bundle from capture Evidence: project the + * manifest from the in-memory capture, authenticate the pair, prepare the + * state the bundle's server derives and summarize what it serves, then write + * the bundle with the export unless this is a dry run. Every file equals what + * inspect-analysis-view and environment-export produce from the same capture. + */ +export const buildEnvironment = async ( + input: EnvironmentBuildInput, +): Promise> => { + if (input.directory === undefined && !input.dryRun) + return err(missingDirectory()); + if (new Set(input.templateRules).size !== input.templateRules.length) + return err(repeatedRules()); + if (input.captureInput === "evidence" && harDocument(input.capture.value)) + return err(harAsJson(input.capturePath)); + const evidence = parseEvidenceFile( + ENVIRONMENT_BUILD_OPERATION, + "evidence", + input.capture.value, + ); + if (!evidence.ok) return evidence; + if (!captureOperations.has(evidence.value.operation)) + return err(notCaptureEvidence(evidence.value.operation)); + const capture = evidence.value; + const templateRules = canonicalTemplateRules(input.templateRules); + input.progress?.("manifest"); + const projected = await inspectAnalysisView( + { + source: { kind: "retained-evidence", evidence_id: capture.evidence_id }, + view: { kind: "environment-manifest", template_rules: templateRules }, + }, + (id) => (id === capture.evidence_id ? capture : undefined), + ); + if (!projected.ok) return projected; + const stopped = await cancelled(input.signal); + if (stopped !== undefined) return err(stopped); + const manifest = await environmentExportEvidenceFile( + "manifest", + "manifest.json", + projected.value, + ENVIRONMENT_BUILD_OPERATION, + ); + if (!manifest.ok) return manifest; + const source = loadEnvironmentSource( + manifest.value.value, + input.capture.value, + ENVIRONMENT_BUILD_OPERATION, + ); + if (!source.ok) return source; + const warnings: EnvironmentBuildWarning[] = []; + const named = environmentName(input, source.value.manifest, warnings); + if (!named.ok) return named; + const name = named.value ?? source.value.manifest.name; + const beforeState = await cancelled(input.signal); + if (beforeState !== undefined) return err(beforeState); + input.progress?.("state"); + // Prepared as the export prepares it, so its report and the bundle's README match the three-step path. + const served = prepareServedState(source.value, input.capture.value, { + name, + operation: ENVIRONMENT_EXPORT_OPERATION, + }); + const recorded = parseCaptureEvidence( + ENVIRONMENT_BUILD_OPERATION, + input.capture.value, + ); + if (!recorded.ok) return recorded; + warnings.push(...credentialScanWarnings(recorded.value)); + + const packageSource: EnvironmentPackageSource = + input.packageTarball === undefined + ? registryPackageSource() + : { kind: "tarball", path: input.packageTarball }; + const base = { + name, + capture: { + evidence_id: capture.evidence_id, + operation: capture.operation, + records: recorded.value.records.length, + input: input.captureInput, + }, + manifest: { + evidence_id: source.value.manifestEvidenceId, + tools: source.value.manifest.tools.length, + excluded_groups: source.value.manifest.excluded.length, + template_rules: templateRules, + }, + state: served.report, + coverage: + served.state === undefined + ? null + : summarizeEnvironmentCoverage({ + views: served.state.views.values(), + writes: served.state.writes.values(), + }), + warnings, + }; + if (input.dryRun) { + const beforePlan = await cancelled(input.signal); + if (beforePlan !== undefined) return err(beforePlan); + const plan = planEnvironmentBundle(source.value, { + name, + packageSource, + served: () => served, + }); + if (!plan.ok) return plan; + const files: WrittenBundleFile[] = [ + ...plan.value.files.map((file) => ({ + path: file.path, + bytes: Buffer.byteLength(file.content), + })), + { path: plan.value.copies.manifest, bytes: manifest.value.bytes.length }, + { path: plan.value.copies.evidence, bytes: input.capture.bytes.length }, + ]; + if ( + plan.value.copies.tarball !== null && + input.packageTarball !== undefined + ) { + const bytes = await packageTarballBytes(input.packageTarball); + if (!bytes.ok) return bytes; + files.push({ path: plan.value.copies.tarball, bytes: bytes.value }); + } + const afterPlan = await cancelled(input.signal); + if (afterPlan !== undefined) return err(afterPlan); + return ok({ + status: "planned", + directory: input.directory ?? null, + ...base, + tool_count: plan.value.toolCount, + package_source: packageSourceValue(packageSource), + files, + next: [], + }); + } + const directory = input.directory; + if (directory === undefined) return err(missingDirectory()); + const beforeWrite = await cancelled(input.signal); + if (beforeWrite !== undefined) return err(beforeWrite); + input.progress?.("bundle"); + const exported = await exportEnvironmentBundle({ + manifest: manifest.value, + evidence: input.capture, + source: source.value, + served, + directory, + name, + packageTarball: input.packageTarball, + exclusiveDestination: input.exclusiveDestination, + }); + if (!exported.ok) return exported; + const report: EnvironmentBuildReport = { + status: "built", + directory, + ...base, + tool_count: exported.value.tool_count, + package_source: packageSourceValue(packageSource), + files: [...exported.value.files], + next: [`agent-env run ${shellWord(directory)}`], + }; + // A cancellation that arrives during the write still names the bundle it wrote. + const afterWrite = await cancelled(input.signal, directory); + return afterWrite === undefined ? ok(report) : err(afterWrite); +}; diff --git a/src/application/environment/environmentBuildReport.test.ts b/src/application/environment/environmentBuildReport.test.ts new file mode 100644 index 0000000000..bf583b3ac9 --- /dev/null +++ b/src/application/environment/environmentBuildReport.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it } from "vitest"; + +import { scenarioCaptureFixture } from "../../../tests/fixtures/browserScenarioCapture.js"; +import type { WebNetworkCaptureRecord } from "../../domain/webNetworkCapture.js"; +import { + anonymousOrigin, + credentialNamed, + nameFromCaptureFile, + scanCredentialNames, + scanScenarioCredentialNames, +} from "./environmentBuildReport.js"; + +const record = ( + ordinal: number, + request: unknown, + response: unknown, +): WebNetworkCaptureRecord => + ({ + ordinal, + reported: { + request: { + method: "POST", + url: "http://127.0.0.1/api", + ...(request === undefined + ? {} + : { + postData: { + mimeType: "application/json", + text: JSON.stringify(request), + }, + }), + }, + response: { + status: 200, + content: { + mimeType: "application/json", + text: JSON.stringify(response), + }, + }, + }, + redactions: [], + binary_fields: [], + }) as unknown as WebNetworkCaptureRecord; + +/** Every body of these records was small enough to scan. */ +const NONE_SKIPPED = { count: 0, pointers: [] }; + +describe("credentialNamed", () => { + it("matches whole name segments, not substrings", () => { + for (const name of [ + "password", + "authToken", + "api_key", + "apiKey", + "sessionId", + "client_secret", + "X-Session", + "refresh.token", + "Authorization", + "APIToken", + "accesstoken", + "passphrase", + "api_keys", + "ssh_key", + ]) + expect(credentialNamed(name), name).toBe(true); + for (const name of ["author", "authority_level", "keyboard", "tokenizer"]) + expect(credentialNamed(name), name).toBe(false); + }); +}); + +describe("scanCredentialNames", () => { + it("counts string values under credential names in request and response bodies, by pointer and never by value", () => { + const scanned = scanCredentialNames([ + record( + 3, + { name: "Folder", secret: "s3cr3t-value" }, + { data: [{ id: 1, sessionId: "abc123def" }], hasPassword: true }, + ), + record(4, undefined, { token: "", "a/b": { apiKey: "k-1" } }), + ]); + expect(scanned).toEqual({ + count: 3, + pointers: [ + "record 3 request /secret", + "record 3 response /data/0/sessionId", + "record 4 response /a~1b/apiKey", + ], + skipped: NONE_SKIPPED, + }); + expect(JSON.stringify(scanned)).not.toContain("s3cr3t"); + }); + + it("scans header and query names, form bodies and string arrays", () => { + const withHeaders = { + ordinal: 7, + reported: { + request: { + method: "POST", + url: "http://127.0.0.1/api?api_key=k&page=1", + headers: [ + { name: "X-Api-Key", value: "k-123" }, + { name: "Accept", value: "application/json" }, + { name: "Authorization", value: null }, + ], + queryString: [ + { name: "api_key", value: "k" }, + { name: "page", value: "1" }, + ], + postData: { + mimeType: "application/x-www-form-urlencoded", + text: "user=a&password=hunter22", + }, + }, + response: { + status: 200, + headers: [{ name: "X-Auth-Token", value: "t-9" }], + content: { + mimeType: "application/json", + text: JSON.stringify({ tokens: ["a", "b"], token_count: 2 }), + }, + }, + }, + redactions: [], + binary_fields: [], + } as unknown as WebNetworkCaptureRecord; + expect(scanCredentialNames([withHeaders])).toEqual({ + count: 5, + pointers: [ + "record 7 request header X-Api-Key", + "record 7 response header X-Auth-Token", + "record 7 request query api_key", + "record 7 request /password", + "record 7 response /tokens", + ], + skipped: NONE_SKIPPED, + }); + }); + + it("counts every occurrence of a repeated form field that holds a value", () => { + const form = (text: string): WebNetworkCaptureRecord => { + const scanned = record(3, undefined, undefined); + ( + scanned.reported as { request: { postData?: unknown } } + ).request.postData = { + mimeType: "application/x-www-form-urlencoded", + text, + }; + return scanned; + }; + // A later empty occurrence does not hide the earlier value the Evidence ships. + expect(scanCredentialNames([form("api_key=secret&api_key=")])).toEqual({ + count: 1, + pointers: ["record 3 request /api_key"], + skipped: NONE_SKIPPED, + }); + expect(scanCredentialNames([form("token=a&page=1&token=b")])).toEqual({ + count: 2, + pointers: ["record 3 request /token", "record 3 request /token"], + skipped: NONE_SKIPPED, + }); + }); + + it("walks bodies nested deeper than the call stack allows", () => { + const depth = 20_000; + const deep = record(1, undefined, undefined); + ( + deep.reported as { response: { content: { text: string } } } + ).response.content.text = + `${"[".repeat(depth)}{"token":"x"}${"]".repeat(depth)}`; + expect(scanCredentialNames([deep]).count).toBe(1); + }); +}); + +describe("scanCredentialNames body formats", () => { + const posted = (mimeType: string, text: string): WebNetworkCaptureRecord => { + const scanned = record(3, undefined, undefined); + (scanned.reported as { request: { postData?: unknown } }).request.postData = + { mimeType, text }; + return scanned; + }; + const FORM = "application/x-www-form-urlencoded; charset=UTF-8"; + + it("reads a form body as the form parser does, by its media type or, without one, by its shape", () => { + for (const mimeType of [FORM, ""]) { + expect( + scanCredentialNames([posted(mimeType, "password=example&")]), + mimeType, + ).toEqual({ + count: 1, + pointers: ["record 3 request /password"], + skipped: NONE_SKIPPED, + }); + expect( + scanCredentialNames([posted(mimeType, "user=a&&flag&token=example")]), + mimeType, + ).toEqual({ + count: 1, + pointers: ["record 3 request /token"], + skipped: NONE_SKIPPED, + }); + } + }); + + it("finds nothing in plain text that is not a form", () => { + for (const mimeType of ["text/plain", ""]) + for (const text of ["token=abc to sign in", "Use token=abc", ""]) + expect( + scanCredentialNames([posted(mimeType, text)]), + `${mimeType} ${text}`, + ).toEqual({ count: 0, pointers: [], skipped: NONE_SKIPPED }); + }); +}); + +describe("scanCredentialNames over structured form parameters", () => { + it.each([undefined, null])( + "scans each structured form parameter with absent text: %s", + (text) => { + const structured = record(4, undefined, undefined); + ( + structured.reported as { request: { postData?: unknown } } + ).request.postData = { + mimeType: "multipart/form-data", + text, + params: [ + { name: "api_key", value: "secret" }, + { name: "api_key", value: "" }, + { name: "user", value: "a" }, + { name: "token", value: "t" }, + ], + }; + expect(scanCredentialNames([structured])).toEqual({ + count: 2, + pointers: ["record 4 request /api_key", "record 4 request /token"], + skipped: NONE_SKIPPED, + }); + }, + ); +}); + +describe("scanScenarioCredentialNames", () => { + it("scans each content event, and each transaction URL once, by event index", () => { + const capture = scenarioCaptureFixture( + [ + { + method: "POST", + url: "/api/session?api_key=k-1", + status: 200, + mediaType: "application/json", + body: "{}", + // Recorded without a media type, so its shape decides how it is read. + requestBody: '{"password":"x"}', + requestHeaders: [{ name: "X-Api-Key", value: "k-2" }], + }, + ], + { request_body: true, response_body: true, header_values: true }, + ); + const response = capture.events.items.find( + (event) => event.kind === "response", + ); + if (response === undefined) throw new Error("Fixture lacks a response"); + // A second response leaves the transaction unattributable; its events still ship. + const scanned = scanScenarioCredentialNames([ + ...capture.events.items, + { ...response, sequence: 99 }, + ]); + expect(scanned).toEqual({ + count: 3, + pointers: [ + "event 0 request query api_key", + "event 1 request header X-Api-Key", + "event 1 request /password", + ], + skipped: NONE_SKIPPED, + }); + expect(JSON.stringify(scanned)).not.toContain("k-2"); + }); +}); + +describe("scanCredentialNames coverage", () => { + it("measures the scan limit in UTF-8 bytes for multibyte text", () => { + const multibyte = record( + 9, + { token: "界".repeat(2 * 1024 * 1024) }, + undefined, + ); + expect(scanCredentialNames([multibyte])).toMatchObject({ + count: 0, + skipped: { count: 1, pointers: ["record 9 request"] }, + }); + }); + it("reports text bodies too large to scan by record and side, never by content", () => { + const large = JSON.stringify({ + token: "t-large", + pad: "a".repeat(4 * 1024 * 1024), + }); + const oversized = record(5, undefined, undefined); + const reported = oversized.reported as { + request: { postData?: unknown }; + response: { content: { text: string } }; + }; + reported.request.postData = { mimeType: "application/json", text: large }; + reported.response.content.text = large; + const base64 = (ordinal: number, bytes: Buffer): WebNetworkCaptureRecord => + ({ + ...record(ordinal, undefined, undefined), + reported: { + request: { method: "GET", url: "http://127.0.0.1/api" }, + response: { + status: 200, + content: { + mimeType: "application/json", + text: "", + encoding: "base64", + }, + }, + }, + binary_fields: [ + { + pointer: "/response/content/text", + state: "retained", + content_base64: bytes.toString("base64"), + }, + ], + }) as unknown as WebNetworkCaptureRecord; + const scanned = scanCredentialNames([ + oversized, + // Decoded only as a prefix, so its end is never read. + base64(6, Buffer.from(large)), + // Binary bytes hold no JSON or form text, so nothing was left unscanned. + base64(7, Buffer.alloc(5 * 1024 * 1024, 0xff)), + record(8, { token: "t-small" }, {}), + ]); + expect(scanned).toEqual({ + count: 1, + pointers: ["record 8 request /token"], + skipped: { + count: 3, + pointers: [ + "record 5 request", + "record 5 response", + "record 6 response", + ], + }, + }); + expect(JSON.stringify(scanned)).not.toContain("t-large"); + }); +}); + +describe("anonymousOrigin", () => { + it("is true for loopback names and IP literals", () => { + for (const origin of [ + "http://127.0.0.1:3101", + "http://localhost:3000", + "http://app.localhost", + "http://[::1]:8080", + "http://10.0.0.5", + ]) + expect(anonymousOrigin(origin), origin).toBe(true); + expect(anonymousOrigin("http://localhost.:3000")).toBe(true); + for (const origin of ["https://grafana.example", "not a url"]) + expect(anonymousOrigin(origin), origin).toBe(false); + }); +}); + +describe("nameFromCaptureFile", () => { + it("drops capture suffixes and keeps a kebab-case label", () => { + expect(nameFromCaptureFile("/w/grafana.har")).toBe("grafana"); + expect(nameFromCaptureFile("/w/grafana.capture.json")).toBe("grafana"); + expect(nameFromCaptureFile("/w/My App.scoped.HAR")).toBe("my-app-scoped"); + expect(nameFromCaptureFile("/w/--.json")).toBe(""); + }); +}); diff --git a/src/application/environment/environmentBuildReport.ts b/src/application/environment/environmentBuildReport.ts new file mode 100644 index 0000000000..0ab133ce21 --- /dev/null +++ b/src/application/environment/environmentBuildReport.ts @@ -0,0 +1,358 @@ +import { basename } from "node:path"; +import { isIP } from "node:net"; + +import { isJsonLike } from "../../domain/analysisView/EnvironmentManifestAccumulator.js"; +import { + decodeRetainedBase64, + readRecordedResponseBody, + type RecordedResponseBody, +} from "../../domain/analysisView/recordedResponseBody.js"; +import { scenarioContentMediaType } from "../../domain/analysisView/scenarioCaptureRecords.js"; +import { mediaTypeEssence } from "../../domain/analysisView/webNetworkRouteTemplates.js"; +import type { BrowserScenarioEvent } from "../../domain/browserScenarioCaptureValues.js"; +import type { EnvironmentBuildReport } from "../../domain/environmentBundleExport.js"; +import { + iterateUrlQueryParameters, + parseRecordedTransportUrl, +} from "../../domain/recordedTransportUrl.js"; +import type { WebNetworkCaptureRecord } from "../../domain/webNetworkCapture.js"; + +/** A note the build report carries; warnings never change what is built. */ +export type EnvironmentBuildWarning = + EnvironmentBuildReport["warnings"][number]; + +/** Whole name segments that name credentials; `author` is not `auth`. */ +const CREDENTIAL_SEGMENTS = new Set([ + "accesstoken", + "apikey", + "apikeys", + "apitoken", + "auth", + "authorization", + "bearer", + "cookie", + "credential", + "credentials", + "jwt", + "passphrase", + "passwd", + "password", + "privatekey", + "pwd", + "secret", + "secrets", + "session", + "sessionid", + "token", + "tokens", +]); +const CREDENTIAL_PAIRS = new Set([ + "access key", + "api key", + "api keys", + "client secret", + "private key", + "session id", + "ssh key", +]); + +/** Split a member name into lowercase segments at case changes and separators. */ +const nameSegments = (name: string): string[] => + name + .replaceAll(/([a-z0-9])([A-Z])/gu, "$1 $2") + .replaceAll(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/u) + .filter((segment) => segment.length > 0); + +/** Whether a member, header or query name looks like it holds a credential. */ +export const credentialNamed = (name: string): boolean => { + const segments = nameSegments(name); + return segments.some( + (segment, index) => + CREDENTIAL_SEGMENTS.has(segment) || + (index > 0 && CREDENTIAL_PAIRS.has(`${segments[index - 1]} ${segment}`)), + ); +}; + +/** Bodies larger than this are not scanned. */ +const SCAN_BODY_LIMIT_BYTES = 4 * 1024 * 1024; +/** Pointers the report lists; the count covers every match. */ +const REPORTED_POINTERS = 20; + +const pointerToken = (name: string): string => + name.replaceAll("~", "~0").replaceAll("/", "~1"); + +/** A held value: a non-empty string, or an array holding one. */ +const holdsValue = (value: unknown): boolean => + (typeof value === "string" && value !== "") || + (Array.isArray(value) && + value.some((item) => typeof item === "string" && item !== "")); + +const member = (value: unknown, name: string): unknown => + typeof value === "object" && value !== null + ? Reflect.get(value, name) + : undefined; + +/** A recorded body: a JSON value to walk, form fields in order with every occurrence kept, as headers and query strings are, or text too large to scan. */ +type ScannedBody = + | { readonly kind: "json"; readonly value: unknown } + | { readonly kind: "form"; readonly fields: readonly [string, string][] } + | { readonly kind: "skipped" }; + +const FORM_MEDIA_TYPE = "application/x-www-form-urlencoded"; + +/** The parser a recorded media type names; otherwise JSON by its first character, or a form when it has `=` and no whitespace, which form encoding never leaves raw. */ +const bodyFormat = ( + text: string, + mediaType: unknown, +): "json" | "form" | undefined => { + const essence = + typeof mediaType === "string" ? mediaTypeEssence(mediaType) : null; + if (essence === FORM_MEDIA_TYPE) return "form"; + if (isJsonLike(essence)) return "json"; + const start = text.trimStart()[0]; + if (start === "{" || start === "[") return "json"; + return text.includes("=") && !/\s/u.test(text) ? "form" : undefined; +}; + +/** A recorded body as JSON or form fields, skipped when it is cut or over the limit, or undefined when it is neither. */ +const parsedBody = ( + text: unknown, + mediaType: unknown, + cut = false, +): ScannedBody | undefined => { + if (typeof text !== "string") return undefined; + if (cut || Buffer.byteLength(text, "utf8") > SCAN_BODY_LIMIT_BYTES) + return { kind: "skipped" }; + const format = bodyFormat(text, mediaType); + if (format === "form") + return { kind: "form", fields: [...new URLSearchParams(text)] }; + if (format === undefined) return undefined; + try { + return { kind: "json", value: JSON.parse(text) }; + } catch { + return undefined; + } +}; + +/** Decoded body text as the scan reads it; binary bytes hold no JSON or form text. */ +const decodedBody = ( + body: RecordedResponseBody, + mediaType: unknown, +): ScannedBody | undefined => + body.bodyEncoding === "base64" + ? undefined + : parsedBody(body.bodyText, mediaType, body.bodyCut === true); + +/** Name/value pairs such as HAR headers and query strings. */ +const namedValues = (value: unknown): (readonly [string, unknown])[] => + Array.isArray(value) + ? value.flatMap((entry: unknown) => { + const name = member(entry, "name"); + return typeof name === "string" + ? [[name, member(entry, "value")] as const] + : []; + }) + : []; + +/** A count of matches and the first of their pointers. */ +interface ScanTally { + count: number; + readonly pointers: string[]; +} + +type CredentialScan = Readonly & { + readonly skipped: Readonly; +}; + +const tallied = (tally: ScanTally, pointer: string): void => { + tally.count++; + if (tally.pointers.length < REPORTED_POINTERS) tally.pointers.push(pointer); +}; + +/** + * The tallies both capture readers fill: a named value counts when its name + * looks like a credential and it holds a value. The body walk keeps its own + * stack, so nesting depth cannot overflow it. + */ +const credentialTally = () => { + const matches: ScanTally = { count: 0, pointers: [] }; + const skipped: ScanTally = { count: 0, pointers: [] }; + const field = (pointer: string, name: string, value: unknown): void => { + if (credentialNamed(name) && holdsValue(value)) tallied(matches, pointer); + }; + const walk = (root: unknown, label: string): void => { + const stack: [unknown, string][] = [[root, ""]]; + while (stack.length > 0) { + const [value, pointer] = stack.pop()!; + if (typeof value !== "object" || value === null) continue; + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index--) + stack.push([value[index], `${pointer}/${String(index)}`]); + continue; + } + const entries = Object.entries(value); + for (let index = entries.length - 1; index >= 0; index--) { + const [name, child] = entries[index]!; + const path = `${pointer}/${pointerToken(name)}`; + field(`${label} ${path}`, name, child); + stack.push([child, path]); + } + } + }; + const body = (scanned: ScannedBody | undefined, label: string): void => { + if (scanned?.kind === "skipped") tallied(skipped, label); + else if (scanned?.kind === "json") walk(scanned.value, label); + else if (scanned?.kind === "form") + for (const [name, value] of scanned.fields) + field(`${label} /${pointerToken(name)}`, name, value); + }; + return { + field, + body, + result: (): CredentialScan => ({ ...matches, skipped }), + }; +}; + +/** + * Credential-named members that hold a value in recorded JSON and form + * bodies, headers and query strings: a count and the first pointers, never + * values, and the bodies too large to scan by record and side. + */ +export const scanCredentialNames = ( + records: readonly WebNetworkCaptureRecord[], +): CredentialScan => { + const scan = credentialTally(); + for (const record of records) { + const ordinal = String(record.ordinal); + const request = member(record.reported, "request"); + const response = member(record.reported, "response"); + for (const [side, message] of [ + ["request", request], + ["response", response], + ] as const) + for (const [name, value] of namedValues(member(message, "headers"))) + scan.field(`record ${ordinal} ${side} header ${name}`, name, value); + for (const [name, value] of namedValues(member(request, "queryString"))) + scan.field(`record ${ordinal} request query ${name}`, name, value); + const postData = member(request, "postData"); + const text = member(postData, "text"); + // A HAR may record form fields only as structured params; each occurrence counts, as in a form body. + scan.body( + typeof text !== "string" + ? { + kind: "form", + fields: namedValues(member(postData, "params")).flatMap( + ([name, value]) => + typeof value === "string" + ? [[name, value] as [string, string]] + : [], + ), + } + : parsedBody(text, member(postData, "mimeType")), + `record ${ordinal} request`, + ); + scan.body( + decodedBody( + readRecordedResponseBody(record, SCAN_BODY_LIMIT_BYTES), + member(member(response, "content"), "mimeType"), + ), + `record ${ordinal} response`, + ); + } + return scan.result(); +}; + +/** + * The same scan over a browser scenario's retained events, by index in + * `events.items`: every network-content event's headers and body, whether or + * not a record represents its transaction, and each transaction URL's query + * once. + */ +export const scanScenarioCredentialNames = ( + events: readonly BrowserScenarioEvent[], +): CredentialScan => { + const scan = credentialTally(); + const urls = new Set(); + for (const [index, event] of events.entries()) { + const label = `event ${String(index)}`; + if (event.kind === "network-content") { + const side = `${label} ${event.phase}`; + if (event.headers.state === "captured") + for (const { name, value } of event.headers.items) + scan.field(`${side} header ${name}`, name, value); + if (event.body.state === "captured") + scan.body( + decodedBody( + decodeRetainedBase64(event.body.content, SCAN_BODY_LIMIT_BYTES), + scenarioContentMediaType(event), + ), + side, + ); + } else if ("transaction_id" in event) { + // Every event of a transaction repeats its URL; a response attached mid-flight may be the only one. + const key = `${event.transaction_id} ${event.url.url}`; + if (urls.has(key)) continue; + urls.add(key); + const { query } = parseRecordedTransportUrl(event.url.url, { + includeQuery: true, + }); + for (const [name, value] of iterateUrlQueryParameters(query ?? "")) + scan.field(`${label} ${event.kind} query ${name}`, name, value); + } + } + return scan.result(); +}; + +/** The credential-name scan's warnings: what it found, and the bodies it could not scan. */ +export const credentialScanWarnings = (capture: { + readonly records: readonly WebNetworkCaptureRecord[]; + readonly scenarioEvents: readonly BrowserScenarioEvent[] | null; +}): EnvironmentBuildWarning[] => { + const scanned = + capture.scenarioEvents === null + ? scanCredentialNames(capture.records) + : scanScenarioCredentialNames(capture.scenarioEvents); + const warnings: EnvironmentBuildWarning[] = []; + if (scanned.count > 0) + warnings.push({ + code: "credential-named-members", + message: `${String(scanned.count)} recorded body members have credential-like names and a value; evidence.json ships them, so review it before sharing.`, + pointers: scanned.pointers, + }); + if (scanned.skipped.count > 0) + warnings.push({ + code: "credential-scan-incomplete", + message: `${String(scanned.skipped.count)} recorded bodies exceed the credential-name scan's 4 MiB limit and were not scanned; evidence.json ships them, so review them before sharing.`, + pointers: scanned.skipped.pointers, + }); + return warnings; +}; + +/** Whether an origin's host is a loopback name or an IP literal, which says nothing about the application. */ +export const anonymousOrigin = (origin: string): boolean => { + let host: string; + try { + host = new URL(origin).hostname.toLowerCase(); + } catch { + return false; + } + const bracketless = host.startsWith("[") ? host.slice(1, -1) : host; + const bare = bracketless.endsWith(".") + ? bracketless.slice(0, -1) + : bracketless; + return ( + bare === "localhost" || bare.endsWith(".localhost") || isIP(bare) !== 0 + ); +}; + +/** A kebab-case name from a capture file's name: `grafana.har` and `grafana.capture.json` become `grafana`. */ +export const nameFromCaptureFile = (path: string): string => + basename(path) + .replace(/\.(?:har|json)$/iu, "") + .replace(/\.capture$/iu, "") + .toLowerCase() + .replaceAll(/[^a-z0-9]+/gu, "-") + .replace(/^-+/u, "") + .replace(/-+$/u, ""); diff --git a/src/application/environment/environmentBundle.ts b/src/application/environment/environmentBundle.ts index 685f1653b6..6ec75c8143 100644 --- a/src/application/environment/environmentBundle.ts +++ b/src/application/environment/environmentBundle.ts @@ -30,6 +30,12 @@ const BUNDLE_NAME_MAX_LENGTH = 64; /** Win32 reserves these device names for files and folders, so the env folder can never take one. */ const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/u; +/** Whether a name can be the env folder and env id: the checks the export applies. */ +export const isPortableBundleName = (name: string): boolean => + BUNDLE_NAME.test(name) && + name.length <= BUNDLE_NAME_MAX_LENGTH && + !WINDOWS_RESERVED_NAME.test(name); + /** Where the Dockerfile installs the CLI from. */ export type EnvironmentPackageSource = | { readonly kind: "registry"; readonly specifier: string } @@ -103,7 +109,7 @@ const reservedNameError = (name: string): AnalysisInputError => new AnalysisInputError( ENVIRONMENT_EXPORT_OPERATION, { - userMessage: `The bundle name ${JSON.stringify(name)} is a device name Windows reserves, so it cannot name the env folder; pass --name with another label.`, + userMessage: `The bundle name ${JSON.stringify(name)} is a device name Windows reserves, so it cannot name the env folder; choose another name.`, }, [ { diff --git a/src/application/environment/environmentBundleTool.ts b/src/application/environment/environmentBundleTool.ts new file mode 100644 index 0000000000..87b4352e16 --- /dev/null +++ b/src/application/environment/environmentBundleTool.ts @@ -0,0 +1,82 @@ +import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js"; +import { outputDirectoryAdmissionRefusal } from "../../artifacts/SafeOutputTreeAdmission.js"; +import type { + EnvironmentBuildReport, + ExportEnvironmentBundleInput, +} from "../../domain/environmentBundleExport.js"; +import { err, type Result } from "../../domain/result.js"; +import { + resolveEvidenceInput, + type EvidenceLookup, +} from "../EvidenceInputResolver.js"; +import { + DEFAULT_BUILD_TEMPLATE_RULES, + buildEnvironment, +} from "./environmentBuild.js"; +import { + environmentExportEvidenceFile, + readEnvironmentExportFile, + type EnvironmentExportFile, +} from "./environmentExport.js"; + +/** MCP tool and operation name of the bundle export. */ +export const EXPORT_ENVIRONMENT_BUNDLE_OPERATION = "export_environment_bundle"; + +/** The capture's bytes as the bundle ships them: a file's own bytes, or retained Evidence as `--format json` writes it. */ +const captureFile = async ( + source: ExportEnvironmentBundleInput["source"], + lookup: EvidenceLookup | undefined, +): Promise> => { + if (source.kind === "evidence-file") + return readEnvironmentExportFile( + "evidence", + source.path, + EXPORT_ENVIRONMENT_BUNDLE_OPERATION, + ); + const retained = resolveEvidenceInput(source, lookup); + if (!retained.ok) return retained; + return environmentExportEvidenceFile( + "evidence", + source.evidence_id, + retained.value, + EXPORT_ENVIRONMENT_BUNDLE_OPERATION, + ); +}; + +/** + * Build an environment bundle from capture Evidence into an absent output + * directory whose parent exists, as environment-build does, and return the + * same report. A dry run checks the directory and writes nothing. + */ +export const exportEnvironmentBundleTool = async ( + input: ExportEnvironmentBundleInput, + lookup: EvidenceLookup | undefined, + options: { readonly signal?: AbortSignal | undefined } = {}, +): Promise> => { + try { + await SafeOutputTree.checkDestination(input.output_directory); + } catch (cause: unknown) { + const refusal = outputDirectoryAdmissionRefusal( + EXPORT_ENVIRONMENT_BUNDLE_OPERATION, + "output_directory", + cause, + ); + if (refusal instanceof AnalysisError) return err(refusal); + throw refusal; + } + const capture = await captureFile(input.source, lookup); + if (!capture.ok) return capture; + return buildEnvironment({ + capture: capture.value, + captureInput: "evidence", + capturePath: input.source.kind === "evidence-file" ? input.source.path : "", + templateRules: input.template_rules ?? DEFAULT_BUILD_TEMPLATE_RULES, + directory: input.output_directory, + name: input.name, + packageTarball: input.package_tarball, + dryRun: input.dry_run, + signal: options.signal, + exclusiveDestination: true, + }); +}; diff --git a/src/application/environment/environmentBundleWrite.ts b/src/application/environment/environmentBundleWrite.ts index 0f5c6cc2ed..bd85fb82a2 100644 --- a/src/application/environment/environmentBundleWrite.ts +++ b/src/application/environment/environmentBundleWrite.ts @@ -94,19 +94,17 @@ const writeBytes = async ( return { path: written.relativePath, bytes: written.bytesWritten }; }; -interface BundleTarball { +/** The package tarball instance admitted once and retained until its owner closes it. */ +export interface BundleTarball { readonly path: string; readonly handle: FileHandle; readonly size: number; } -/** Hash and copy only the instance retained before destination admission. */ -const writeTarball = async ( - tree: SafeOutputTree, - directory: string, - relative: string, +/** Read and hash the admitted instance, checking its complete length without retaining its contents. */ +export const bundleTarballDigest = async ( source: BundleTarball, -): Promise => { +): Promise => { const hash = createHash("sha256"); let observed = 0; for await (const chunk of tarballChunks(source)) { @@ -116,7 +114,17 @@ const writeTarball = async ( hash.update(bytes); } if (observed !== source.size) throw new RegularFileChangedError(source.path); - const sha256 = hash.digest("hex"); + return hash.digest("hex"); +}; + +/** Hash and copy only the instance retained before destination admission. */ +const writeTarball = async ( + tree: SafeOutputTree, + directory: string, + relative: string, + source: BundleTarball, +): Promise => { + const sha256 = await bundleTarballDigest(source); const written = await writingBundleFile(directory, relative, () => tree.write(relative, Readable.from(tarballChunks(source)), { bytes: source.size, @@ -142,10 +150,11 @@ const writeBundleFiles = async ( } | { readonly kind: "failed"; readonly cause: unknown }; try { - await mkdir(dirname(input.directory), { recursive: true, mode: 0o700 }); + if (input.exclusiveDestination !== true) + await mkdir(dirname(input.directory), { recursive: true, mode: 0o700 }); try { tree = await SafeOutputTree.create(input.directory, process.platform, { - acceptExistingEmpty: true, + acceptExistingEmpty: input.exclusiveDestination !== true, }); } catch (cause: unknown) { if (cause instanceof SafeOutputTreeCreationFailure) tree = cause.tree; @@ -208,14 +217,31 @@ const writeBundleFiles = async ( return outcome.files; }; -/** Retain one tarball instance across planning output writes and final stability verification. */ -export const writeEnvironmentBundle = async ( - input: EnvironmentExportInput, - plan: EnvironmentBundlePlan, -): Promise => { - if (plan.packageSource.kind !== "tarball") - return writeBundleFiles(input, plan); - const path = plan.packageSource.path; +/** A package tarball path that names no readable regular file. */ +export const packageTarballRefusal = ( + path: string, + cause: unknown, +): AnalysisInputError => + new AnalysisInputError( + ENVIRONMENT_EXPORT_OPERATION, + { + cause, + userMessage: `${path} must name a readable regular package tarball.`, + }, + [ + { + path: ["package_tarball"], + reason: "invalid_value", + message: "The package tarball must be an existing regular file.", + }, + ], + ); + +/** Admit the package tarball once and retain it through `use`; a close it cannot confirm stays bound to its owner. */ +export const withBundleTarball = async ( + path: string, + use: (tarball: BundleTarball) => Promise, +): Promise => { const resources = new ArtifactResourceScope(); let admitted = false; try { @@ -223,11 +249,7 @@ export const writeEnvironmentBundle = async ( path, async (handle, state) => { admitted = true; - return writeBundleFiles(input, plan, { - path, - handle, - size: state.size, - }); + return use({ path, handle, size: state.size }); }, { resources }, ); @@ -250,21 +272,19 @@ export const writeEnvironmentBundle = async ( String(cause.code), ) ) - throw new AnalysisInputError( - ENVIRONMENT_EXPORT_OPERATION, - { - cause, - userMessage: `${path} must name a readable regular package tarball.`, - }, - [ - { - path: ["package_tarball"], - reason: "invalid_value", - message: "The package tarball must be an existing regular file.", - }, - ], - ); + throw packageTarballRefusal(path, cause); } throw cause; } }; + +/** Retain one tarball instance across planning output writes and final stability verification. */ +export const writeEnvironmentBundle = ( + input: EnvironmentExportInput, + plan: EnvironmentBundlePlan, +): Promise => + plan.packageSource.kind === "tarball" + ? withBundleTarball(plan.packageSource.path, (tarball) => + writeBundleFiles(input, plan, tarball), + ) + : writeBundleFiles(input, plan); diff --git a/src/application/environment/environmentEvidenceBytes.ts b/src/application/environment/environmentEvidenceBytes.ts new file mode 100644 index 0000000000..1a4b5654b9 --- /dev/null +++ b/src/application/environment/environmentEvidenceBytes.ts @@ -0,0 +1,19 @@ +import type { Evidence } from "../../domain/evidence.js"; +import { jsonByteLength, jsonParts } from "../../domain/jsonSerialization.js"; + +/** An Evidence file's bytes as `--format json` writes the Evidence to a file: compact JSON and a newline. */ +export const evidenceFileBytes = ( + evidence: Evidence, + admit?: (bytes: number) => void, +): Buffer => { + // Measured first, so the document is written into one exactly sized buffer. + const size = jsonByteLength(evidence) + 1; + admit?.(size); + const bytes = Buffer.allocUnsafe(size); + let offset = 0; + for (const part of jsonParts(evidence)) offset += bytes.write(part, offset); + if (offset !== bytes.length - 1) + throw new Error("Evidence serialized to a different length than measured"); + bytes[offset] = 0x0a; + return bytes; +}; diff --git a/src/application/environment/environmentExport.ts b/src/application/environment/environmentExport.ts index d559d80a51..738b50a6a0 100644 --- a/src/application/environment/environmentExport.ts +++ b/src/application/environment/environmentExport.ts @@ -12,6 +12,8 @@ import { type AnalysisInputIssue, } from "../../domain/analysisErrorCore.js"; import type { JsonValue } from "../../domain/jsonValue.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { evidenceFileBytes } from "./environmentEvidenceBytes.js"; import { err, ok, type Result } from "../../domain/result.js"; import { RegularFileChangedError } from "../../filesystem/RegularFile.js"; import { @@ -24,6 +26,9 @@ import { RegularFileCleanupFailure } from "../../filesystem/OwnedRegularFile.js" import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { BundleFileHostFailure, + bundleTarballDigest, + packageTarballRefusal, + withBundleTarball, writeEnvironmentBundle, } from "./environmentBundleWrite.js"; import { sealOwnedJsonInput } from "../OwnedJsonInput.js"; @@ -58,6 +63,10 @@ export interface EnvironmentExportInputs { export interface EnvironmentExportInput extends EnvironmentExportInputs { /** The authenticated pair the bundle serves, loaded from the parsed values of the two files. */ readonly source: EnvironmentSource; + /** The state already prepared under `name` from the evidence value; prepared here when absent. */ + readonly served?: PreparedState | undefined; + /** Refuse an existing directory and never create its parent; otherwise an empty directory is accepted. */ + readonly exclusiveDestination?: boolean | undefined; /** Absolute bundle root; absent or empty. */ readonly directory: string; readonly name?: string | undefined; @@ -86,12 +95,11 @@ export interface EnvironmentExportResult { } const inputError = ( + operation: string, issue: AnalysisInputIssue, userMessage: string, ): AnalysisInputError => - new AnalysisInputError(ENVIRONMENT_EXPORT_OPERATION, { userMessage }, [ - issue, - ]); + new AnalysisInputError(operation, { userMessage }, [issue]); const systemCode = (cause: unknown): string | undefined => cause instanceof Error && "code" in cause && typeof cause.code === "string" @@ -129,6 +137,7 @@ const accessDeniedError = ( path: string, diagnostics?: Readonly>, access: "read" | "write" = "read", + operation: string = ENVIRONMENT_EXPORT_OPERATION, ): AnalysisAccessDeniedError | AnalysisOutputError | undefined => { const seen = new Set(); let current = cause; @@ -148,14 +157,9 @@ const accessDeniedError = ( }; const error = access === "read" - ? new AnalysisAccessDeniedError( - ENVIRONMENT_EXPORT_OPERATION, - path, - code, - options, - ) + ? new AnalysisAccessDeniedError(operation, path, code, options) : new AnalysisOutputError( - ENVIRONMENT_EXPORT_OPERATION, + operation, `Host filesystem permissions denied write access: ${path}`, { ...options, @@ -204,6 +208,7 @@ const NOT_A_FILE_CODES = new Set([ /** Refuse a whole read, native parse and owned snapshot traversal that cannot fit live heap headroom. */ const headroomRefusal = ( + operation: string, path: string, inputBytes: number, ): AnalysisResourceConstraintError | undefined => { @@ -212,7 +217,7 @@ const headroomRefusal = ( return requiredBytes <= heap.total_available_size ? undefined : new AnalysisResourceConstraintError( - ENVIRONMENT_EXPORT_OPERATION, + operation, "memory", "Insufficient heap headroom to read, parse and seal the complete JSON file", { @@ -226,10 +231,64 @@ const headroomRefusal = ( ); }; -/** Read one input once: the same bytes are parsed for authentication and copied into the bundle. */ -const readInput = async ( +/** Measure and admit every serialized copy before allocating its buffer. */ +export const environmentExportEvidenceFile = async ( role: "manifest" | "evidence", path: string, + evidence: Evidence, + operation: string = ENVIRONMENT_EXPORT_OPERATION, +): Promise> => { + try { + const bytes = evidenceFileBytes(evidence, (size) => { + const refused = headroomRefusal(operation, path, size); + if (refused !== undefined) throw refused; + }); + return environmentExportFile(role, path, bytes, operation); + } catch (cause: unknown) { + if (cause instanceof AnalysisResourceConstraintError) return err(cause); + throw cause; + } +}; + +/** Parse and seal bytes the bundle will copy, so authentication reads exactly what ships. */ +export const environmentExportFile = async ( + role: "manifest" | "evidence", + path: string, + bytes: Buffer, + operation: string = ENVIRONMENT_EXPORT_OPERATION, +): Promise> => { + const refusal = headroomRefusal(operation, path, bytes.length); + if (refusal !== undefined) return err(refusal); + let parsed: ReturnType; + try { + parsed = parseUtf8Json(bytes, operation, path); + } catch (cause: unknown) { + if (cause instanceof AnalysisResourceConstraintError) return err(cause); + throw cause; + } + if (!parsed.ok) + return err( + inputError( + operation, + { + path: [role], + reason: "invalid_format", + expected: "JSON", + message: parsed.error, + }, + `${path} is not valid JSON: ${parsed.error}`, + ), + ); + const snapshot = await sealOwnedJsonInput(parsed.value, operation); + if (!snapshot.ok) return snapshot; + return ok({ path, bytes, value: snapshot.value }); +}; + +/** Read one input once: the same bytes are parsed for authentication and copied into the bundle. */ +export const readEnvironmentExportFile = async ( + role: "manifest" | "evidence", + path: string, + operation: string = ENVIRONMENT_EXPORT_OPERATION, ): Promise> => { const resources = new ArtifactResourceScope(); let bytes: Buffer; @@ -239,7 +298,7 @@ const readInput = async ( bytes = await readRegularFile(path, { resources, admit: (state) => { - const refused = headroomRefusal(path, state.size); + const refused = headroomRefusal(operation, path, state.size); if (refused !== undefined) throw refused; }, }); @@ -250,7 +309,7 @@ const readInput = async ( AnalysisCleanupLease.forCloseOwner(cause.owner, path), ); const cleanup = new AnalysisOutputError( - ENVIRONMENT_EXPORT_OPERATION, + operation, `Could not confirm closing the ${role} input file.`, { cause: failure, @@ -266,7 +325,7 @@ const readInput = async ( ? analysisErrorWithCleanupFailure( primary, cleanup, - ENVIRONMENT_EXPORT_OPERATION, + operation, ).bindCleanupDependencies([primary, cleanup]) : cleanup, ); @@ -275,6 +334,7 @@ const readInput = async ( if (cause instanceof RegularFileChangedError) return err( inputError( + operation, { path: [role], reason: "invalid_value", @@ -287,6 +347,7 @@ const readInput = async ( if (NOT_A_FILE_CODES.has(systemCode(cause) ?? "")) return err( inputError( + operation, { path: [role], reason: "invalid_value", @@ -295,35 +356,11 @@ const readInput = async ( `${path} could not be read as the ${role} file.`, ), ); - const denied = accessDeniedError(cause, path); + const denied = accessDeniedError(cause, path, undefined, "read", operation); if (denied !== undefined) return err(denied); throw cause; } - let parsed: ReturnType; - try { - parsed = parseUtf8Json(bytes, ENVIRONMENT_EXPORT_OPERATION, path); - } catch (cause: unknown) { - if (cause instanceof AnalysisResourceConstraintError) return err(cause); - throw cause; - } - if (!parsed.ok) - return err( - inputError( - { - path: [role], - reason: "invalid_format", - expected: "JSON", - message: parsed.error, - }, - `${path} is not valid JSON: ${parsed.error}`, - ), - ); - const snapshot = await sealOwnedJsonInput( - parsed.value, - ENVIRONMENT_EXPORT_OPERATION, - ); - if (!snapshot.ok) return snapshot; - return ok({ path, bytes, value: snapshot.value }); + return environmentExportFile(role, path, bytes, operation); }; /** Read both files once; the caller authenticates the parsed values and the export copies the same bytes. */ @@ -331,9 +368,9 @@ export const readEnvironmentExportInputs = async ( manifestPath: string, evidencePath: string, ): Promise> => { - const manifest = await readInput("manifest", manifestPath); + const manifest = await readEnvironmentExportFile("manifest", manifestPath); if (!manifest.ok) return manifest; - const evidence = await readInput("evidence", evidencePath); + const evidence = await readEnvironmentExportFile("evidence", evidencePath); if (!evidence.ok) return evidence; return ok({ manifest: manifest.value, evidence: evidence.value }); }; @@ -343,6 +380,67 @@ const packageSourceValue = (source: EnvironmentPackageSource): JsonValue => ? { kind: "registry", specifier: source.specifier } : { kind: "tarball", path: source.path }; +const changedArtifact = ( + cause: RegularFileChangedError, +): AnalysisArtifactChangedError => + new AnalysisArtifactChangedError( + ENVIRONMENT_EXPORT_OPERATION, + cause.path, + cause.message, + { cause }, + ); + +/** A reader failure as an output refusal that keeps an unconfirmed cleanup bound to its owner. */ +const readerFailureError = ( + cause: ArtifactReaderFailure, + directory: string | undefined, +): AnalysisOutputError => { + const host = hostSystemError(cause); + const error = new AnalysisOutputError( + ENVIRONMENT_EXPORT_OPERATION, + host === undefined ? cause.message : `${cause.message}: ${host.message}`, + { + cause, + // Only a host failure gets host recovery; an integrity or other + // refusal keeps its own message without disk-space advice. + ...(cause.cleanup !== undefined + ? { cleanup: cause.cleanup } + : directory === undefined + ? {} + : host === undefined + ? { diagnostics: { output_directory: directory } } + : hostWriteFailure(directory)), + }, + ); + bindArtifactFileCleanup(cause, error); + return error; +}; + +/** The package tarball's size after the same complete read and integrity check export uses. */ +export const packageTarballBytes = async ( + path: string, +): Promise> => { + try { + return ok( + await withBundleTarball(path, async (tarball) => { + await bundleTarballDigest(tarball); + return tarball.size; + }), + ); + } catch (cause: unknown) { + if (cause instanceof AnalysisError) return err(cause); + if (cause instanceof RegularFileChangedError) + return err(changedArtifact(cause)); + if (cause instanceof ArtifactReaderFailure) + return err(readerFailureError(cause, undefined)); + if (cause instanceof BundleFileHostFailure) + return err(packageTarballRefusal(path, cause)); + if (systemCode(cause) !== undefined) + return err(packageTarballRefusal(path, cause)); + throw cause; + } +}; + /** Export the authenticated pair as an agent environment bundle folder. */ export const exportEnvironmentBundle = async ( input: EnvironmentExportInput, @@ -356,6 +454,7 @@ export const exportEnvironmentBundle = async ( name: input.name, packageSource, served: (name) => + input.served ?? prepareServedState(input.source, input.evidence.value, { name, operation: ENVIRONMENT_EXPORT_OPERATION, @@ -379,35 +478,9 @@ export const exportEnvironmentBundle = async ( ); if (denied !== undefined) return err(denied); if (cause instanceof RegularFileChangedError) - return err( - new AnalysisArtifactChangedError( - ENVIRONMENT_EXPORT_OPERATION, - cause.path, - cause.message, - { cause }, - ), - ); - if (cause instanceof ArtifactReaderFailure) { - const host = hostSystemError(cause); - const error = new AnalysisOutputError( - ENVIRONMENT_EXPORT_OPERATION, - host === undefined - ? cause.message - : `${cause.message}: ${host.message}`, - { - cause, - // Only a host failure gets host recovery; an integrity or other - // refusal keeps its own message without disk-space advice. - ...(cause.cleanup !== undefined - ? { cleanup: cause.cleanup } - : host === undefined - ? { diagnostics: { output_directory: input.directory } } - : hostWriteFailure(input.directory)), - }, - ); - bindArtifactFileCleanup(cause, error); - return err(error); - } + return err(changedArtifact(cause)); + if (cause instanceof ArtifactReaderFailure) + return err(readerFailureError(cause, input.directory)); if ( cause instanceof BundleFileHostFailure || (cause instanceof Error && systemCode(cause) !== undefined) diff --git a/src/application/environment/environmentScenario.test.ts b/src/application/environment/environmentScenario.test.ts index 7995f5d291..2f0d542c14 100644 --- a/src/application/environment/environmentScenario.test.ts +++ b/src/application/environment/environmentScenario.test.ts @@ -82,7 +82,7 @@ describe("environment manifests from capture_browser_scenario parents", () => { expect(projectAnalysisError(refused.error)).toMatchObject({ code: "unsupported_target", message: expect.stringContaining( - "supports only the environment-manifest and environment-state views", + "supports only the environment-manifest, environment-state and environment-coverage views", ), }); }); diff --git a/src/application/environment/environmentSource.ts b/src/application/environment/environmentSource.ts index df5db267b8..95b832ac65 100644 --- a/src/application/environment/environmentSource.ts +++ b/src/application/environment/environmentSource.ts @@ -16,6 +16,7 @@ import { type RecordedRequestLine, } from "../../domain/analysisView/webNetworkRouteTemplates.js"; import { retainedBrowserScenarioCaptureSchema } from "../../domain/browserScenarioCapture.js"; +import type { BrowserScenarioEvent } from "../../domain/browserScenarioCaptureValues.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisInputError, @@ -263,6 +264,8 @@ interface CaptureFacts { readonly responseBodies: boolean; /** Whether the capture recorded request bodies, likewise. */ readonly requestBodies: boolean; + /** A browser scenario's retained events, including those no record represents; null for a HAR. */ + readonly scenarioEvents: readonly BrowserScenarioEvent[] | null; } /** Issue paths of a capture payload refusal start here. */ @@ -297,6 +300,7 @@ const parseScenarioCapture = ( captureSha256: definition.success ? definition.data.scenario_sha256 : null, responseBodies: scenario.data.scenario.network_content.response_body, requestBodies: scenario.data.scenario.network_content.request_body, + scenarioEvents: scenario.data.events.items, }); }; @@ -347,6 +351,7 @@ const parseHarCapture = ( captureSha256: capture.data.artifact.sha256, responseBodies: true, requestBodies: true, + scenarioEvents: null, }); }; diff --git a/src/browser/BrowserScenarioPartialObservation.ts b/src/browser/BrowserScenarioPartialObservation.ts index 858d2c524d..74d01d1e11 100644 --- a/src/browser/BrowserScenarioPartialObservation.ts +++ b/src/browser/BrowserScenarioPartialObservation.ts @@ -43,6 +43,11 @@ const browserScenarioLimitations = ( : []), "Playwright scenario events do not expose request initiator stacks; receipt order does not prove causality.", "Storage values are hashed only after declared-secret redaction.", + ...(scenario.actions.some(({ action }) => action === "request") + ? [ + "Request steps sent their declared requests through the page's own fetch with its cookies and session, following no redirects; they are REA-initiated, not user-interface activity, and header values of at least 8 characters they read from page cookies or local storage are redacted like declared secrets from records made after they were read.", + ] + : []), ...(scenario.storage.local_storage.length === 0 && scenario.storage.session_storage.length === 0 ? [] diff --git a/src/browser/BrowserScenarioSecrets.ts b/src/browser/BrowserScenarioSecrets.ts index 19a097a911..18c238342c 100644 --- a/src/browser/BrowserScenarioSecrets.ts +++ b/src/browser/BrowserScenarioSecrets.ts @@ -15,6 +15,9 @@ import type { BrowserStorageValueFingerprint } from "../domain/browserScenarioCa const REDACTION_PREFIX = "[REDACTED:"; +/** Page values shorter than this are not redacted: redacting them would rewrite ids, ports and hosts throughout the capture. */ +export const MIN_REDACTED_PAGE_VALUE_LENGTH = 8; + const encodedSecretValues = (secret: string): ReadonlySet => new Set([ encodeURIComponent(secret), @@ -227,14 +230,19 @@ const secretNeedles = ( /** Resolved secret values kept only for one in-memory scenario session. */ export class BrowserScenarioSecrets { - private readonly urlPatterns: readonly SecretPattern[]; - private readonly textPatterns: readonly SecretPattern[]; - private readonly urlReplacements: readonly SecretReplacement[]; - private readonly textReplacements: readonly SecretReplacement[]; - private readonly textNeedles: readonly SecretNeedle[]; - private readonly redactionCostPerCharacter: number; + private urlPatterns: readonly SecretPattern[] = []; + private textPatterns: readonly SecretPattern[] = []; + private urlReplacements: readonly SecretReplacement[] = []; + private textReplacements: readonly SecretReplacement[] = []; + private textNeedles: readonly SecretNeedle[] = []; + private redactionCostPerCharacter = 8; - private constructor(private readonly values: ReadonlyMap) { + private constructor(private readonly values: Map) { + this.compile(); + } + + private compile(): void { + const values = this.values; this.urlPatterns = declaredPatterns(values, "url"); this.textPatterns = declaredPatterns(values, "text"); this.urlReplacements = secretReplacements(values, "url"); @@ -255,6 +263,26 @@ export class BrowserScenarioSecrets { ); } + /** + * Redact a value a request step read from the page, such as a CSRF cookie + * or a stored bearer token, from everything recorded after it, as a + * declared secret is. The label names it in redaction markers; a value + * already redacted keeps its first marker, and a value shorter than + * {@link MIN_REDACTED_PAGE_VALUE_LENGTH} is not redacted. + */ + admitPageValue(label: string, value: string): void { + // fetch sends a header value without surrounding spaces and tabs, so that is the spelling records hold. + const sent = value.replace(/^[\t ]+|[\t ]+$/gu, ""); + if (sent !== value) this.admitPageValue(label, sent); + if (value.length < MIN_REDACTED_PAGE_VALUE_LENGTH) return; + for (const held of this.values.values()) if (held === value) return; + let id = `page:${label}`; + for (let suffix = 2; this.values.has(id); suffix += 1) + id = `page:${label}~${String(suffix)}`; + this.values.set(id, value); + this.compile(); + } + static resolve( scenario: BrowserScenario, environment: Readonly>, diff --git a/src/browser/PlaywrightScenarioActions.ts b/src/browser/PlaywrightScenarioActions.ts index b3064dca1d..ad6f3f2532 100644 --- a/src/browser/PlaywrightScenarioActions.ts +++ b/src/browser/PlaywrightScenarioActions.ts @@ -5,6 +5,7 @@ import type { BrowserScenarioValue, } from "../domain/browserScenarioValues.js"; import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; +import { performScenarioRequest } from "./PlaywrightScenarioRequest.js"; type ScenarioLocator = Extract< BrowserScenarioAction, @@ -34,13 +35,17 @@ interface PerformActionInput { readonly page: Page; readonly action: BrowserScenarioAction; readonly secrets: BrowserScenarioSecrets; + /** Origin of the scenario's start URL, the only origin a request step may address. */ + readonly startOrigin: string; + /** Cancellation of the step; a request step that sees it before sending sends nothing. */ + readonly signal?: AbortSignal | undefined; } -/** Execute one admitted declarative action with no caller-supplied code. */ +/** Execute one admitted declarative action; a request step sends one declared request through the page's own fetch, and no action runs caller-supplied code. */ export const performPlaywrightScenarioAction = async ( input: PerformActionInput, ): Promise => { - const { page, action, secrets } = input; + const { page, action, secrets, startOrigin, signal } = input; const timeout = "timeout_ms" in action ? action.timeout_ms : undefined; const options = timeout === undefined ? {} : { timeout }; switch (action.action) { @@ -86,5 +91,19 @@ export const performPlaywrightScenarioAction = async ( return; case "wait_for_timeout": await page.waitForTimeout(action.duration_ms); + return; + case "request": + await performScenarioRequest({ + page, + action, + secrets, + startOrigin, + signal, + }); + return; + default: { + const unhandled: never = action; + throw new Error(`Unhandled browser scenario action ${String(unhandled)}`); + } } }; diff --git a/src/browser/PlaywrightScenarioRequest.test.ts b/src/browser/PlaywrightScenarioRequest.test.ts new file mode 100644 index 0000000000..c31c7a2222 --- /dev/null +++ b/src/browser/PlaywrightScenarioRequest.test.ts @@ -0,0 +1,591 @@ +import type { Page, Route } from "playwright-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { browserScenarioSchema } from "../domain/browserScenario.js"; +import type { BrowserScenarioAction } from "../domain/browserScenarioValues.js"; +import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; +import { performPlaywrightScenarioAction } from "./PlaywrightScenarioActions.js"; + +const ORIGIN = "https://app.example.test"; + +const scenario = (actions: readonly unknown[], secrets: unknown[] = []) => + browserScenarioSchema.parse({ + browser: { mode: "launch", executable_path: "/opt/chromium/chrome" }, + start_url: { url: `${ORIGIN}/` }, + actions, + storage: { cookies: [], local_storage: [], session_storage: [] }, + secrets, + capture: {}, + }); + +const requestStep = (fields: Record) => ({ + step_id: "create", + action: "request", + method: "POST", + path: "/api/folders", + ...fields, +}); + +const issues = (actions: readonly unknown[], secrets: unknown[] = []) => { + const parsed = browserScenarioSchema.safeParse({ + browser: { mode: "launch", executable_path: "/opt/chromium/chrome" }, + start_url: { url: `${ORIGIN}/` }, + actions, + storage: { cookies: [], local_storage: [], session_storage: [] }, + secrets, + capture: {}, + }); + return parsed.success + ? [] + : parsed.error.issues.map((issue) => issue.message); +}; + +describe("request step schema", () => { + it("refuses repeated header names, User-Agent and line breaks in literal values or prefixes", () => { + const literal = (value: string) => ({ source: "literal", value }); + expect( + issues([ + requestStep({ + headers: [ + { name: "X-A", value: literal("one") }, + { name: "x-a", value: literal("two") }, + ], + }), + ]), + ).toContainEqual(expect.stringContaining("more than once")); + expect( + issues([ + requestStep({ + headers: [{ name: "User-Agent", value: literal("agent") }], + }), + ]), + ).toContainEqual(expect.stringContaining("cannot set")); + expect( + issues([requestStep({ expected_status: [200, 303] })]), + ).toContainEqual(expect.stringContaining("3xx")); + for (const header of [ + { name: "X-A", value: literal("a\r\nX-B: b") }, + { name: "X-A", value: literal("a"), prefix: "Bearer\n" }, + ]) + expect(issues([requestStep({ headers: [header] })])).toContainEqual( + expect.stringContaining("line breaks"), + ); + }); + + it("accepts a same-origin request with query, headers and a JSON body", () => { + const parsed = scenario( + [ + requestStep({ + query: [{ name: "org", value: { source: "literal", value: "1" } }], + headers: [ + { + name: "X-CSRFToken", + value: { source: "cookie", name: "csrftoken" }, + }, + { + name: "Authorization", + value: { source: "secret", secret_id: "token" }, + prefix: "Bearer ", + }, + ], + json: { title: "Scratch folder 1" }, + expected_status: [200, 201], + }), + ], + [{ secret_id: "token", environment_variable: "APP_TOKEN" }], + ); + expect(parsed.actions[0]).toMatchObject({ + action: "request", + method: "POST", + path: "/api/folders", + }); + }); + + it("refuses paths that leave the start origin", () => { + for (const path of [ + "//evil.test/x", + "/\\evil.test/x", + "https://evil.test/", + "/\n/[bad]", + ]) + expect(issues([requestStep({ path })]).length, path).toBeGreaterThan(0); + }); + + it("refuses a body on GET, a header fetch cannot set and an undeclared secret", () => { + expect( + issues([requestStep({ method: "GET", json: { a: 1 } })]), + ).toContainEqual(expect.stringContaining("cannot send a body")); + expect( + issues([ + requestStep({ + headers: [ + { name: "Cookie", value: { source: "literal", value: "a=b" } }, + ], + }), + ]), + ).toContainEqual(expect.stringContaining("cannot set this header")); + expect( + issues([ + requestStep({ + headers: [ + { name: "X-Key", value: { source: "secret", secret_id: "key" } }, + ], + }), + ]), + ).toContainEqual(expect.stringContaining("not declared")); + }); +}); + +interface FakeFetch { + readonly url: string; + readonly init: RequestInit; +} + +const fakePage = (options: { + readonly url?: string; + /** The origin the page reports when the request is sent, if it moved after the check. */ + readonly sendingOrigin?: string; + readonly cookies?: readonly { name: string; value: string }[]; + readonly cookieDelayMs?: number; + readonly evaluateDelayMs?: number; + readonly storage?: Record; +}) => { + const url = options.url ?? `${ORIGIN}/dashboard`; + vi.stubGlobal("localStorage", { + getItem: (key: string) => options.storage?.[key] ?? null, + }); + vi.stubGlobal("location", { + origin: options.sendingOrigin ?? new URL(url).origin, + }); + return { + route: async () => {}, + unroute: async () => {}, + url: () => url, + context: () => ({ + cookies: async () => { + if (options.cookieDelayMs !== undefined) + await new Promise((resolve) => + setTimeout(resolve, options.cookieDelayMs), + ); + return options.cookies ?? []; + }, + }), + evaluate: async ( + fn: (arg: Arg) => Result | Promise, + arg: Arg, + ): Promise => { + if (options.evaluateDelayMs !== undefined) + await new Promise((resolve) => + setTimeout(resolve, options.evaluateDelayMs), + ); + return fn(arg); + }, + } as unknown as Page; +}; + +const stubFetch = (status: number) => { + const calls: FakeFetch[] = []; + vi.stubGlobal("fetch", async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return new Response("{}", { status }); + }); + return calls; +}; + +const perform = async ( + page: Page, + action: BrowserScenarioAction, + secrets: BrowserScenarioSecrets, +) => + performPlaywrightScenarioAction({ + page, + action, + secrets, + startOrigin: ORIGIN, + }); + +const resolved = (parsed: ReturnType, environment = {}) => { + const secrets = BrowserScenarioSecrets.resolve(parsed, environment); + if (!secrets.ok) throw secrets.error; + return secrets.value; +}; + +describe("request step execution", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("sends the declared request through the page's fetch with its cookies", async () => { + const calls = stubFetch(201); + const parsed = scenario([ + requestStep({ + query: [{ name: "org", value: { source: "literal", value: "1" } }], + json: { title: "Scratch folder 1" }, + }), + ]); + await perform(fakePage({}), parsed.actions[0]!, resolved(parsed)); + expect(calls).toEqual([ + { + url: `${ORIGIN}/api/folders?org=1`, + init: expect.objectContaining({ + method: "POST", + credentials: "same-origin", + headers: [["content-type", "application/json"]], + body: '{"title":"Scratch folder 1"}', + }), + }, + ]); + }); + + it("reads header values from a cookie and from stored JSON, and redacts them afterwards", async () => { + const calls = stubFetch(200); + const parsed = scenario([ + requestStep({ + headers: [ + { + name: "X-CSRFToken", + value: { source: "cookie", name: "csrftoken" }, + }, + { + name: "Authorization", + value: { + source: "local_storage", + key: "auth", + json_pointer: "/token", + }, + prefix: "Bearer ", + }, + ], + }), + ]); + const secrets = resolved(parsed); + await perform( + fakePage({ + cookies: [{ name: "csrftoken", value: "csrf-value-123" }], + storage: { auth: JSON.stringify({ token: "jwt-value-456" }) }, + }), + parsed.actions[0]!, + secrets, + ); + expect(calls[0]!.init.headers).toEqual([ + ["X-CSRFToken", "csrf-value-123"], + ["Authorization", "Bearer jwt-value-456"], + ]); + const redacted = secrets.redact("csrf-value-123 and jwt-value-456"); + expect(redacted).not.toContain("csrf-value-123"); + expect(redacted).not.toContain("jwt-value-456"); + expect(redacted).toContain("[REDACTED:page:create.X-CSRFToken]"); + }); + + it("refuses to run off the start origin, and fails on an unexpected status or a missing cookie", async () => { + stubFetch(403); + const parsed = scenario([requestStep({ expected_status: [200, 201] })]); + const action = parsed.actions[0]!; + await expect( + perform( + fakePage({ url: "https://other.test/" }), + action, + resolved(parsed), + ), + ).rejects.toThrow("start origin"); + await expect( + perform(fakePage({}), action, resolved(parsed)), + ).rejects.toThrow("answered 403; expected 200 or 201"); + const withCookie = scenario([ + requestStep({ + headers: [{ name: "X-T", value: { source: "cookie", name: "absent" } }], + }), + ]); + await expect( + perform(fakePage({}), withCookie.actions[0]!, resolved(withCookie)), + ).rejects.toThrow("cookie absent is not set"); + }); + + it("completes on any status when none is expected and reports fetch errors", async () => { + stubFetch(500); + const parsed = scenario([requestStep({})]); + await expect( + perform(fakePage({}), parsed.actions[0]!, resolved(parsed)), + ).resolves.toBeUndefined(); + vi.stubGlobal("fetch", async () => { + throw new TypeError("Failed to fetch"); + }); + await expect( + perform(fakePage({}), parsed.actions[0]!, resolved(parsed)), + ).rejects.toThrow("Failed to fetch"); + }); +}); + +describe("request dispatch boundary", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("sends nothing when browser evaluation starts after the absolute deadline", async () => { + const calls = stubFetch(200); + const parsed = scenario([requestStep({ timeout_ms: 5 })]); + await expect( + perform( + fakePage({ evaluateDelayMs: 30 }), + parsed.actions[0]!, + resolved(parsed), + ), + ).rejects.toThrow("ran out of time"); + expect(calls).toEqual([]); + }); + + it("guards dispatch after cancellation while evaluation was queued, and removes its route", async () => { + const parsed = scenario([requestStep({})]); + const controller = new AbortController(); + const page = fakePage({}); + let gate: ((route: Route) => Promise) | undefined; + vi.spyOn(page, "route").mockImplementation(async (_match, handler) => { + gate = handler as typeof gate; + }); + const unroute = vi.spyOn(page, "unroute"); + const sent = vi.fn(); + vi.spyOn(page, "evaluate").mockImplementation(async (fn, arg) => { + controller.abort("abandoned while queued"); + return (fn as (arg: unknown) => unknown)(arg); + }); + vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => { + if (gate === undefined) throw new Error("Missing dispatch owner"); + await gate({ + request: () => ({ + method: () => init.method, + postData: () => init.body ?? null, + }), + fallback: sent, + abort: async () => { + throw new Error("aborted at dispatch"); + }, + } as unknown as Route); + return new Response("{}", { status: 200 }); + }); + await expect( + performPlaywrightScenarioAction({ + page, + action: parsed.actions[0]!, + secrets: resolved(parsed), + startOrigin: ORIGIN, + signal: controller.signal, + }), + ).rejects.toThrow("aborted at dispatch"); + expect(sent).not.toHaveBeenCalled(); + expect(unroute).toHaveBeenCalledOnce(); + }); + + it("refuses foreign-origin storage before reading its value", async () => { + const calls = stubFetch(200); + const read = vi.fn(); + const parsed = scenario([ + requestStep({ + headers: [ + { + name: "Authorization", + value: { source: "local_storage", key: "token" }, + }, + ], + }), + ]); + const page = fakePage({ sendingOrigin: "https://other.test" }); + vi.stubGlobal("localStorage", { getItem: read }); + await expect( + perform(page, parsed.actions[0]!, resolved(parsed)), + ).rejects.toThrow("local storage"); + expect(read).not.toHaveBeenCalled(); + expect(calls).toEqual([]); + }); + + it("drains response chunks and releases the reader without materializing the body", async () => { + const read = vi + .fn() + .mockResolvedValueOnce({ done: false, value: new Uint8Array(8) }) + .mockResolvedValueOnce({ done: true }); + const releaseLock = vi.fn(); + const arrayBuffer = vi.fn(); + vi.stubGlobal("fetch", async () => ({ + status: 200, + type: "basic", + body: { getReader: () => ({ read, releaseLock }) }, + arrayBuffer, + })); + const parsed = scenario([requestStep({})]); + await perform(fakePage({}), parsed.actions[0]!, resolved(parsed)); + expect(read).toHaveBeenCalledTimes(2); + expect(releaseLock).toHaveBeenCalledOnce(); + expect(arrayBuffer).not.toHaveBeenCalled(); + }); +}); + +describe("request step confinement", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("follows no redirect and sends nothing once the page has left the start origin", async () => { + const calls: FakeFetch[] = []; + vi.stubGlobal("fetch", async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return { + type: "opaqueredirect", + status: 0, + arrayBuffer: async () => new ArrayBuffer(0), + }; + }); + const parsed = scenario([requestStep({})]); + await expect( + perform(fakePage({}), parsed.actions[0]!, resolved(parsed)), + ).rejects.toThrow("request steps do not follow"); + expect(calls[0]!.init.redirect).toBe("manual"); + await expect( + perform( + fakePage({ sendingOrigin: "https://other.test" }), + parsed.actions[0]!, + resolved(parsed), + ), + ).rejects.toThrow("the page moved"); + expect(calls).toHaveLength(1); + }); + + it("sends nothing when its time runs out while header values are read, or the step is cancelled", async () => { + const calls = stubFetch(200); + const parsed = scenario([ + requestStep({ + timeout_ms: 5, + headers: [{ name: "X-T", value: { source: "cookie", name: "t" } }], + }), + ]); + const cookies = [{ name: "t", value: "token-value-1" }]; + await expect( + perform( + fakePage({ cookies, cookieDelayMs: 30 }), + parsed.actions[0]!, + resolved(parsed), + ), + ).rejects.toThrow("ran out of time"); + const cancelled = new AbortController(); + cancelled.abort(); + await expect( + performPlaywrightScenarioAction({ + page: fakePage({ cookies }), + action: scenario([requestStep({})]).actions[0]!, + secrets: resolved(parsed), + startOrigin: ORIGIN, + signal: cancelled.signal, + }), + ).rejects.toThrow("ran out of time"); + expect(calls).toEqual([]); + }); + + it("appends query entries after the path's literal query without re-encoding it", async () => { + const calls = stubFetch(200); + const parsed = scenario([ + requestStep({ + path: "/api/items?flag&a=b%20c&s=x;y", + query: [{ name: "q", value: { source: "literal", value: "a b" } }], + }), + ]); + await perform(fakePage({}), parsed.actions[0]!, resolved(parsed)); + expect(calls[0]!.url).toBe( + `${ORIGIN}/api/items?flag&a=b%20c&s=x;y&q=a%20b`, + ); + for (const [path, sent] of [ + ["/api/items?", "/api/items?q=1"], + ["/api/items#", "/api/items?q=1"], + ["/api/items?a=1&", "/api/items?a=1&q=1"], + ["/api/items?a=1#top", "/api/items?a=1&q=1"], + ] as const) { + const edge = scenario([ + requestStep({ + path, + query: [{ name: "q", value: { source: "literal", value: "1" } }], + }), + ]); + await perform(fakePage({}), edge.actions[0]!, resolved(edge)); + expect(calls.at(-1)!.url, path).toBe(`${ORIGIN}${sent}`); + } + }); + + it("refuses page values a header cannot carry, and stored values it cannot read", async () => { + const calls = stubFetch(200); + const header = (value: Record) => + scenario([requestStep({ headers: [{ name: "X-T", value }] })]); + const cookie = header({ source: "cookie", name: "t" }); + await expect( + perform( + fakePage({ cookies: [{ name: "t", value: "a\r\nX-B: b" }] }), + cookie.actions[0]!, + resolved(cookie), + ), + ).rejects.toThrow("cannot carry"); + for (const [storage, pointer, message] of [ + [{}, undefined, "local storage has no auth"], + [{ auth: "not json" }, "/token", "is not JSON"], + [{ auth: '{"other":1}' }, "/token", "no string or number at /token"], + ] as const) { + const stored = header({ + source: "local_storage", + key: "auth", + ...(pointer === undefined ? {} : { json_pointer: pointer }), + }); + await expect( + perform(fakePage({ storage }), stored.actions[0]!, resolved(stored)), + ).rejects.toThrow(message); + } + expect(calls).toEqual([]); + const numeric = header({ + source: "local_storage", + key: "auth", + json_pointer: "/org", + }); + await perform( + fakePage({ storage: { auth: '{"org":42}' } }), + numeric.actions[0]!, + resolved(numeric), + ); + expect(calls[0]!.init.headers).toEqual([["X-T", "42"]]); + }); +}); + +describe("page value redaction", () => { + const secrets = () => resolved(scenario([requestStep({})])); + + it("keeps every admitted value redacted, even under a repeated or colliding label", () => { + const held = secrets(); + held.admitPageValue("s.x-a", "first-secret-AAAA"); + held.admitPageValue("s.x-a", "second-secret-BBBB"); + held.admitPageValue("a.b.c", "third-secret-CCCC"); + held.admitPageValue("a.b.c", "fourth-secret-DDDD"); + const text = held.redact( + "first-secret-AAAA second-secret-BBBB third-secret-CCCC fourth-secret-DDDD", + ); + for (const value of ["AAAA", "BBBB", "CCCC", "DDDD"]) + expect(text).not.toContain(value); + }); + + it("keeps a value's first marker when later steps read it again", () => { + const held = secrets(); + held.admitPageValue("create-1.X-CSRFToken", "csrf-token-value"); + held.admitPageValue("create-2.X-CSRFToken", "csrf-token-value"); + expect(held.redact("csrf-token-value")).toBe( + "[REDACTED:page:create-1.X-CSRFToken]", + ); + }); + + it("redacts the value fetch sends when the stored one has surrounding spaces", () => { + const held = secrets(); + held.admitPageValue("s.X-Tok", " padded-token-4f9e2a\t"); + expect(held.redact("X-Tok: padded-token-4f9e2a")).not.toContain( + "padded-token-4f9e2a", + ); + }); + + it("does not redact values too short to tell from ids and ports", () => { + const held = secrets(); + held.admitPageValue("s.X-Org-Id", "1"); + held.admitPageValue("s.X-Port", "3000"); + const text = "http://127.0.0.1:3000/api/items/12?page=1"; + expect(held.redact(text)).toBe(text); + }); +}); diff --git a/src/browser/PlaywrightScenarioRequest.ts b/src/browser/PlaywrightScenarioRequest.ts new file mode 100644 index 0000000000..5d3f291260 --- /dev/null +++ b/src/browser/PlaywrightScenarioRequest.ts @@ -0,0 +1,323 @@ +import type { Page, Route } from "playwright-core"; + +import { + HEADER_FIELD_VALUE, + type BrowserScenarioAction, + type BrowserScenarioRequestHeader, +} from "../domain/browserScenarioValues.js"; +import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; + +type RequestAction = Extract; + +/** What the page's fetch returned; a redirect is not followed, so its status is unknown. */ +type PageResponse = + | { readonly status: number } + | { readonly redirected: true } + | { readonly left: string } + | { readonly error: string }; + +/** A request step that could not be sent or did not answer as expected. */ +export class ScenarioRequestError extends Error { + override readonly name = "ScenarioRequestError"; +} + +const originOf = (url: string): string | undefined => { + try { + return new URL(url).origin; + } catch { + return undefined; + } +}; + +/** The string or number at a JSON pointer, as text. */ +const pointerText = (value: unknown, pointer: string): string | undefined => { + let current = value; + if (pointer !== "") + for (const token of pointer.slice(1).split("/")) { + const key = token.replaceAll("~1", "/").replaceAll("~0", "~"); + if (Array.isArray(current) && /^(?:0|[1-9][0-9]*)$/u.test(key)) + current = current[Number(key)]; + else if ( + typeof current === "object" && + current !== null && + Object.hasOwn(current, key) + ) + current = (current as Record)[key]; + else return undefined; + } + return typeof current === "string" || typeof current === "number" + ? String(current) + : undefined; +}; + +/** Resolve one header value; values read from the page are redacted from later records. */ +const headerValue = async ( + step: { + readonly page: Page; + readonly url: URL; + readonly stepId: string; + readonly secrets: BrowserScenarioSecrets; + }, + header: BrowserScenarioRequestHeader, +): Promise => { + const { page, url, stepId, secrets } = step; + const { value } = header; + if (value.source === "literal" || value.source === "secret") + return secrets.value(value); + let read: string | undefined; + if (value.source === "cookie") { + const cookies = await page.context().cookies(url.href); + read = cookies.find((cookie) => cookie.name === value.name)?.value; + if (read === undefined) + throw new ScenarioRequestError( + `Request step ${stepId}: cookie ${value.name} is not set for ${url.origin}.`, + ); + } else { + const stored = await page.evaluate( + ({ key, origin }) => { + if ( + (globalThis as { readonly location?: { origin: string } }).location + ?.origin !== origin + ) + throw new Error( + "The page moved off the start origin before its local storage was read.", + ); + return ( + ( + globalThis as { + readonly localStorage?: { getItem(key: string): string | null }; + } + ).localStorage?.getItem(key) ?? null + ); + }, + { key: value.key, origin: url.origin }, + ); + if (stored === null) + throw new ScenarioRequestError( + `Request step ${stepId}: local storage has no ${value.key} on ${url.origin}.`, + ); + if (value.json_pointer === undefined) read = stored; + else { + let parsed: unknown; + try { + parsed = JSON.parse(stored); + } catch { + throw new ScenarioRequestError( + `Request step ${stepId}: local storage ${value.key} is not JSON.`, + ); + } + read = pointerText(parsed, value.json_pointer); + if (read === undefined) + throw new ScenarioRequestError( + `Request step ${stepId}: local storage ${value.key} has no string or number at ${value.json_pointer}.`, + ); + } + } + secrets.admitPageValue(`${stepId}.${header.name}`, read); + return read; +}; + +/** Append declared query entries after any literal query the path carries, leaving that query as written. */ +const withQuery = ( + url: URL, + entries: readonly (readonly [string, string])[], +): string => { + if (entries.length === 0) return url.href; + const added = entries + .map( + ([name, value]) => + `${encodeURIComponent(name)}=${encodeURIComponent(value)}`, + ) + .join("&"); + // fetch drops the fragment; cut it at its "#" even when nothing follows it. + const hash = url.href.indexOf("#"); + const base = hash < 0 ? url.href : url.href.slice(0, hash); + const separator = !base.includes("?") + ? "?" + : base.endsWith("?") || base.endsWith("&") + ? "" + : "&"; + return `${base}${separator}${added}`; +}; + +/** What a request step sends: the URL, the origin it must be sent from, and the call. */ +interface PageRequest { + readonly url: string; + readonly origin: string; + readonly method: string; + readonly headers: [string, string][]; + readonly body: string | undefined; + readonly deadline: number | undefined; +} + +/** Send the request with the page's own fetch, from the page's main world, and read the whole answer. */ +const sendFromPage = ( + page: Page, + request: PageRequest, +): Promise => + page.evaluate(async (request): Promise => { + // The page may have navigated since the origin check; never send from another origin. + const here = (globalThis as { readonly location?: { origin: string } }) + .location?.origin; + if (here !== request.origin) return { left: String(here) }; + if ( + ( + globalThis as { + readonly navigator?: { serviceWorker?: { controller?: unknown } }; + } + ).navigator?.serviceWorker?.controller + ) + return { + error: + "Request steps require a page without a controlling service worker so cancellation can guard dispatch.", + }; + const remaining = + request.deadline === undefined + ? undefined + : request.deadline - Date.now(); + if (remaining !== undefined && remaining <= 0) + return { error: "The request ran out of time before it was sent." }; + const controller = new AbortController(); + const timer = + remaining === undefined + ? undefined + : setTimeout(() => controller.abort(), remaining); + try { + const answer = await fetch(request.url, { + method: request.method, + headers: request.headers, + credentials: "same-origin", + redirect: "manual", + signal: controller.signal, + ...(request.body === undefined ? {} : { body: request.body }), + }); + const reader = answer.body?.getReader(); + if (reader !== undefined) + try { + while (!(await reader.read()).done) { + // Wait for completion without retaining a second response body. + } + } finally { + reader.releaseLock(); + } + return answer.type === "opaqueredirect" + ? { redirected: true } + : { status: answer.status }; + } catch (cause: unknown) { + return { + error: cause instanceof Error ? cause.message : String(cause), + }; + } finally { + if (timer !== undefined) clearTimeout(timer); + } + }, request); + +/** + * Send one request from the page with its own fetch, cookies and session, as + * the application's frontend would, and read the whole response so the + * capture can record it. The page must be on the scenario's start origin. + */ +export const performScenarioRequest = async (input: { + readonly page: Page; + readonly action: RequestAction; + readonly secrets: BrowserScenarioSecrets; + readonly startOrigin: string; + readonly signal?: AbortSignal | undefined; +}): Promise => { + const { page, action, secrets, startOrigin, signal } = input; + const deadline = + action.timeout_ms === undefined + ? undefined + : Date.now() + action.timeout_ms; + const pageOrigin = originOf(page.url()); + if (pageOrigin !== startOrigin) + throw new ScenarioRequestError( + `Request step ${action.step_id} runs from a page on the start origin ${startOrigin}; navigate back to it with a goto step first.`, + ); + const url = new URL(action.path, startOrigin); + if (url.origin !== startOrigin) + throw new ScenarioRequestError( + `Request step ${action.step_id} must stay on the start origin ${startOrigin}.`, + ); + const target = withQuery( + url, + action.query.map(({ name, value }) => [name, secrets.value(value)]), + ); + const step = { page, url, stepId: action.step_id, secrets }; + const headers: [string, string][] = []; + for (const header of action.headers) { + const value = `${header.prefix ?? ""}${await headerValue(step, header)}`; + if (!HEADER_FIELD_VALUE.test(value)) + throw new ScenarioRequestError( + `Request step ${action.step_id}: the ${header.name} value holds characters a header cannot carry, such as a line break.`, + ); + headers.push([header.name, value]); + } + const hasContentType = headers.some( + ([name]) => name.toLowerCase() === "content-type", + ); + if (action.json !== undefined && !hasContentType) + headers.push(["content-type", "application/json"]); + // A step the boundary abandoned, or whose time ran out while values were read, sends nothing. + const remaining = deadline === undefined ? undefined : deadline - Date.now(); + if (signal?.aborted === true || (remaining !== undefined && remaining <= 0)) + throw new ScenarioRequestError( + `Request step ${action.step_id} ran out of time before its request was sent.`, + ); + const request = { + url: target, + origin: startOrigin, + method: action.method, + headers, + body: action.json === undefined ? undefined : JSON.stringify(action.json), + deadline, + }; + // The browser can queue evaluate behind a busy page. Keep a host-side gate + // until that evaluation settles, including after the step boundary stops + // waiting. A queued fetch then observes live cancellation at dispatch. + const dispatchUrl = new URL(target); + dispatchUrl.hash = ""; + const matches = (url: URL): boolean => url.href === dispatchUrl.href; + const gate = async (route: Route): Promise => { + const sent = route.request(); + if ( + sent.method() !== request.method || + sent.postData() !== (request.body ?? null) + ) { + await route.fallback(); + return; + } + if ( + signal?.aborted === true || + (deadline !== undefined && Date.now() >= deadline) + ) + await route.abort("aborted"); + else await route.fallback(); + }; + await page.route(matches, gate); + let response: PageResponse; + try { + response = await sendFromPage(page, request); + } finally { + await page.unroute(matches, gate); + } + if ("error" in response) + throw new ScenarioRequestError( + `Request step ${action.step_id} failed in the page: ${response.error}`, + ); + if ("left" in response) + throw new ScenarioRequestError( + `Request step ${action.step_id} runs from a page on the start origin ${startOrigin}; the page moved before the request was sent.`, + ); + if ("redirected" in response) + throw new ScenarioRequestError( + `Request step ${action.step_id} was answered with a redirect, which request steps do not follow; a write may already have been applied. The capture records the redirect and, with header values selected, its Location.`, + ); + if ( + action.expected_status !== undefined && + !action.expected_status.includes(response.status) + ) + throw new ScenarioRequestError( + `Request step ${action.step_id} answered ${String(response.status)}; expected ${action.expected_status.join(" or ")}.`, + ); +}; diff --git a/src/browser/PlaywrightScenarioSession.ts b/src/browser/PlaywrightScenarioSession.ts index 31cb3ae90a..7463c69888 100644 --- a/src/browser/PlaywrightScenarioSession.ts +++ b/src/browser/PlaywrightScenarioSession.ts @@ -82,6 +82,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { readonly version: string; readonly initialUrl: string; private readonly secrets: BrowserScenarioSecrets; + private readonly startOrigin: string; private readonly eventCapture: PlaywrightScenarioEvents; private readonly storage: PlaywrightScenarioStorage | undefined; private readonly serviceWorkers: PlaywrightScenarioServiceWorkers | undefined; @@ -101,6 +102,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { options: { readonly mode: BrowserScenario["browser"]["mode"]; readonly secrets: BrowserScenarioSecrets; + readonly startOrigin: string; readonly eventCapture: PlaywrightScenarioEvents; readonly storage: PlaywrightScenarioStorage | undefined; readonly serviceWorkers: PlaywrightScenarioServiceWorkers | undefined; @@ -111,6 +113,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { this.processOwnership = options.mode === "launch" ? "provider-owned" : "external"; this.secrets = options.secrets; + this.startOrigin = options.startOrigin; this.eventCapture = options.eventCapture; this.storage = options.storage; this.serviceWorkers = options.serviceWorkers; @@ -327,6 +330,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { return new PlaywrightScenarioSession(opened, { mode: scenario.browser.mode, secrets, + startOrigin: new URL(scenario.start_url.url).origin, eventCapture: events, storage, serviceWorkers, @@ -439,6 +443,8 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { page: this.opened.page, action, secrets: this.secrets, + startOrigin: this.startOrigin, + signal, }), "timeout_ms" in action ? action.timeout_ms : undefined, signal, diff --git a/src/cli/environmentCommands.ts b/src/cli/environmentCommands.ts index 7b29c9c87b..6445523de5 100644 --- a/src/cli/environmentCommands.ts +++ b/src/cli/environmentCommands.ts @@ -1,7 +1,11 @@ import { basename } from "node:path"; import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; -import type { EnvironmentExportResult } from "../application/environment/environmentExport.js"; +import type { EnvironmentBuildCaptureInput } from "../application/environment/environmentBuild.js"; +import type { + EnvironmentExportFile, + EnvironmentExportResult, +} from "../application/environment/environmentExport.js"; import type { EnvironmentSource } from "../application/environment/environmentSource.js"; import type { ServedState } from "../application/environment/environmentState.js"; import { z } from "incur"; @@ -21,13 +25,17 @@ import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup. import type { StreamableHttpListenError } from "../server/http/streamableHttpListenError.js"; import { AnalysisCancelledError, + AnalysisInputError, AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import { createLogger } from "../logger.js"; +import { TEMPLATE_RULES } from "../domain/analysisView/environmentManifestRules.js"; +import { inspectWebNetworkCaptureInputSchema } from "../domain/webNetworkCapture.js"; import { withCommandCancellation } from "./commandCancellation.js"; +import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; import type { CliInstance } from "./types.js"; /** Same envelope label as the other JSON-input commands and `parseCliJsonInput`. */ @@ -36,8 +44,8 @@ const cliError = (error: AnalysisError): JsonValue => ({ ...projectAnalysisError(error), }); -/** The listener announces its URL at info like `rea mcp --http`; an explicit REA_LOG_LEVEL still wins. */ -const listenerLogger = ( +/** The listener announces its URL, and a build its stages, at info like `rea mcp --http`; an explicit REA_LOG_LEVEL still wins. */ +const infoLogger = ( environment: Readonly, logger: Logger, ): Logger => @@ -178,7 +186,7 @@ const serveEnvironmentCommand = async ( source, served: { state, report }, } = inputs.value; - const commandLogger = listenerLogger(environment, logger); + const commandLogger = infoLogger(environment, logger); if (!report.served && report.reason === "unavailable") commandLogger.warn( { code: report.code, error: report.message }, @@ -305,6 +313,240 @@ const exportEnvironmentCommand = async ( : cliError(exported.error); }; +interface BuildArguments { + readonly capture: string; + readonly directory?: string | undefined; + readonly name?: string | undefined; + readonly templateRule?: + | readonly (typeof TEMPLATE_RULES)[number][] + | undefined; + readonly formatRulesOnly: boolean; + readonly packageTarball?: string | undefined; + readonly dryRun: boolean; + readonly captureTimeoutMs?: number | undefined; +} + +const conflictingRules = (): JsonValue => + cliError( + new AnalysisInputError(CLI_COMMANDS.environmentBuild, undefined, [ + { + path: ["format_rules_only"], + reason: "invalid_value", + message: + "--format-rules-only applies no opt-in template rules; it cannot be combined with --template-rule.", + }, + ]), + ); + +const isEvidenceValue = (value: unknown): boolean => + typeof value === "object" && + value !== null && + "evidence_id" in value && + "normalized_result" in value; + +/** Inspect a HAR as inspect-web-network-capture does and serialize the Evidence as it prints it; a refusal is the command's output. */ +const inspectBuildHar = async ( + capturePath: string, + captureTimeoutMs: number | undefined, + environment: Readonly, + signal: AbortSignal, +): Promise> => { + const { createWebNetworkCaptureService } = + await import("../composition/webNetworkCaptures.js"); + const { parseEvidence } = await import("../domain/evidence.js"); + const { environmentExportEvidenceFile } = + await import("../application/environment/environmentExport.js"); + const { ENVIRONMENT_BUILD_OPERATION } = + await import("../application/environment/environmentBuild.js"); + const service = createWebNetworkCaptureService(environment); + const inspected = await finishAnalysisCommand( + () => + service.inspect( + { + capture_path: capturePath, + format: "har", + ...(captureTimeoutMs === undefined + ? {} + : { capture_timeout_ms: captureTimeoutMs }), + }, + { signal }, + ), + service, + "web-network-capture", + "inspect_web_network_capture", + signal, + ); + if (!isEvidenceValue(inspected)) + return err({ + error: "Application workflow failed", + ...(inspected as Record), + }); + const file = await environmentExportEvidenceFile( + "evidence", + capturePath, + parseEvidence(inspected), + ENVIRONMENT_BUILD_OPERATION, + ); + return file.ok ? ok(file.value) : err(cliError(file.error)); +}; + +/** Read the capture once, then build or plan the bundle from it. */ +const buildEnvironmentCommand = async ( + input: BuildArguments, + environment: Readonly, + logger: Logger, + signal: AbortSignal, +): Promise => { + if (input.formatRulesOnly && input.templateRule !== undefined) + return conflictingRules(); + const { + buildEnvironment, + classifyBuildCapture, + DEFAULT_BUILD_TEMPLATE_RULES, + ENVIRONMENT_BUILD_OPERATION, + } = await import("../application/environment/environmentBuild.js"); + const capturePath = absoluteCallerPath(input.capture); + const kind = classifyBuildCapture(capturePath); + if (!kind.ok) return cliError(kind.error); + if (input.directory === undefined && !input.dryRun) + return cliError( + new AnalysisInputError( + CLI_COMMANDS.environmentBuild, + { userMessage: "Name the bundle directory, or pass --dry-run." }, + [ + { + path: ["directory"], + reason: "invalid_value", + message: "The bundle directory may be omitted only with --dry-run.", + }, + ], + ), + ); + const commandLogger = infoLogger(environment, logger); + const progress = (stage: string): void => { + commandLogger.info({ stage }, "Building environment"); + }; + let capture: EnvironmentExportFile; + if (kind.value === "har") { + progress("inspect"); + const inspected = await inspectBuildHar( + capturePath, + input.captureTimeoutMs, + environment, + signal, + ); + if (!inspected.ok) return inspected.error; + capture = inspected.value; + } else { + const { readEnvironmentExportFile } = + await import("../application/environment/environmentExport.js"); + const read = await readEnvironmentExportFile( + "evidence", + capturePath, + ENVIRONMENT_BUILD_OPERATION, + ); + if (!read.ok) return cliError(read.error); + capture = read.value; + } + if (signal.aborted) + return cliError(new AnalysisCancelledError(CLI_COMMANDS.environmentBuild)); + const captureInput: EnvironmentBuildCaptureInput = kind.value; + const built = await buildEnvironment({ + capture, + captureInput, + capturePath, + templateRules: input.formatRulesOnly + ? [] + : (input.templateRule ?? DEFAULT_BUILD_TEMPLATE_RULES), + directory: + input.directory === undefined + ? undefined + : absoluteCallerPath(input.directory), + name: input.name, + packageTarball: + input.packageTarball === undefined + ? undefined + : absoluteCallerPath(input.packageTarball), + dryRun: input.dryRun, + progress, + signal, + }); + return built.ok + ? (built.value as unknown as JsonValue) + : cliError(built.error); +}; + +/** Register the one-step build from a recorded capture to a bundle folder. */ +const registerBuildCommand = ( + cli: CliInstance, + logger: Logger, + environment: Readonly, +): void => { + cli.command(CLI_COMMANDS.environmentBuild, { + description: + "Build an agent environment bundle from a recorded capture in one offline step: inspect a HAR (or read capture Evidence), project the environment manifest, derive the state, report which tools the environment answers from state, which replay and why each write is withheld, then write the bundle environment-export writes", + args: z.object({ + capture: localPathStringSchema.describe( + "A .har file, or capture Evidence JSON from inspect-web-network-capture (HAR) or capture-browser-scenario", + ), + directory: localPathStringSchema + .optional() + .describe( + "Bundle directory to create; must be absent or empty, nothing is overwritten; omit only with --dry-run", + ), + }), + options: z.object({ + name: z + .string() + .optional() + .describe( + "Env folder and env id (kebab-case); default the manifest's name, or the capture file's name when the origin is a loopback or IP address", + ), + templateRule: z + .array(z.enum(TEMPLATE_RULES)) + .optional() + .describe( + "Opt-in template rule; repeat to select several. Replaces the default after-uid-literal, opaque-token, sibling-variance and prefix-variance", + ), + formatRulesOnly: z + .boolean() + .default(false) + .describe("Apply only the four format rules, the views' own default"), + packageTarball: localPathStringSchema + .optional() + .describe( + "Packed rea-agents tarball (npm pack) copied into the env folder and installed by the Dockerfile instead of the registry package pinned to this version", + ), + dryRun: z + .boolean() + .default(false) + .describe("Run every stage and report, but write nothing"), + captureTimeoutMs: + inspectWebNetworkCaptureInputSchema.shape.capture_timeout_ms, + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.environmentBuild, () => + buildEnvironmentCommand( + { + capture: args.capture, + directory: args.directory, + name: options.name, + templateRule: options.templateRule, + formatRulesOnly: options.formatRulesOnly, + packageTarball: options.packageTarball, + dryRun: options.dryRun, + captureTimeoutMs: options.captureTimeoutMs, + }, + environment, + logger, + signal, + ), + ), + ), + }); +}; + const manifestArgument = z .string() .describe( @@ -431,6 +673,7 @@ export const registerEnvironmentCommands = ( logger: Logger, environment: Readonly, ): void => { + registerBuildCommand(cli, logger, environment); registerExportCommand(cli, logger); registerCompareCommand(cli, logger); cli.command(CLI_COMMANDS.environmentServe, { diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index c898e4f6ad..d8a2b36d2c 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -140,6 +140,7 @@ export const CLI_COMMANDS = Object.freeze({ environmentServe: "environment-serve", environmentExport: "environment-export", environmentCompare: "environment-compare", + environmentBuild: "environment-build", compare: "compare", captureProcess: "capture-process", compareProcessCaptures: "compare-process-captures", @@ -179,6 +180,7 @@ export const CLI_COMMAND_NAMES = Object.freeze(Object.values(CLI_COMMANDS)); /** Canonical MCP operations reached by CLI commands with different names. */ export const CLI_COMMAND_TOOL_ALIASES = Object.freeze({ + "environment-build": ["export_environment_bundle"], analyze: ["binary_overview"], inspect: ["binary_overview"], decompile: ["procedure_pseudo_code"], diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index d03b343dcd..8218722f25 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. The file may also be an Evidence bundle written by CLI --evidence_output or export_evidence_bundle; select evidence_id when it contains more than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances accept exact names (native type names match retained rendered values, preserving same-name rows; null names never match), types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses and generic_method_instances accept addresses/states (generic_method_instances also method_indices), preserving every folded alternative; generic_method_issues accept rows. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence accepts kind summary for identity, availability and facet counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. The environment-coverage view, view {kind: "environment-coverage", template_rules: [...]}, summarizes that state without its entities: reads.state and reads.recorded count read tools answered from the state and replayed (recorded_reasons per view reason), writes.served lists the served write tools, and writes.withheld lists each other write tool with its reason, plan record, contradicting read and a hint about what to record next; it refuses what the environment-state view refuses. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. The file may also be an Evidence bundle written by CLI --evidence_output or export_evidence_bundle; select evidence_id when it contains more than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances accept exact names (native type names match retained rendered values, preserving same-name rows; null names never match), types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses and generic_method_instances accept addresses/states (generic_method_instances also method_indices), preserving every folded alternative; generic_method_issues accept rows. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence accepts kind summary for identity, availability and facet counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/browserScenarioToolContracts.ts b/src/contracts/browserScenarioToolContracts.ts index 54bc1bc156..4e19ef730d 100644 --- a/src/contracts/browserScenarioToolContracts.ts +++ b/src/contracts/browserScenarioToolContracts.ts @@ -26,7 +26,7 @@ export const BROWSER_SCENARIO_TOOL_CONTRACTS = [ name: "capture_browser_scenario", ...toolContractMetadata("capture_browser_scenario"), description: - "Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, a URL-only final capture, and measured event-retention budgets are defaults. capture.network selects request_body, response_body, and header_values independently; capture.limits lets callers raise or lower the event count, retained-allocation estimates, network transaction count, and network content budgets. Selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Budget omissions identify affected event kinds and network transactions. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Results are returned inline.", + "Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. A request action sends one HTTP request to the start origin through the page's own fetch, with header values from secrets, page cookies or local storage; write methods change application state. Environment, empty storage, a URL-only final capture, and measured event-retention budgets are defaults. capture.network selects request_body, response_body, and header_values independently; capture.limits lets callers raise or lower the event count, retained-allocation estimates, network transaction count, and network content budgets. Selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Budget omissions identify affected event kinds and network transactions. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Results are returned inline.", kind: "browser-provider", inputSchema: browserScenarioInputSchema, outputSchema: evidenceResultOf(browserScenarioCaptureSchema), diff --git a/src/contracts/environmentToolContracts.ts b/src/contracts/environmentToolContracts.ts new file mode 100644 index 0000000000..c152314d71 --- /dev/null +++ b/src/contracts/environmentToolContracts.ts @@ -0,0 +1,41 @@ +import { + environmentBuildReportSchema, + exportEnvironmentBundleInputSchema, +} from "../domain/environmentBundleExport.js"; +import type { ToolContract } from "./toolContractTypes.js"; +import { toolContractMetadata } from "./toolEffects.js"; + +/** Agent environment bundle contracts shared with the environment-build CLI command. */ +export const ENVIRONMENT_TOOL_CONTRACTS = [ + { + name: "export_environment_bundle", + ...toolContractMetadata("export_environment_bundle"), + description: + "Write an agent environment bundle folder from capture Evidence (capture_browser_scenario, or HAR inspect_web_network_capture Evidence) into an absent absolute output_directory whose parent exists, as rea environment-build does. It projects the environment manifest with the default template rules, derives the state the bundle serves, and reports read tools answered from the state or replayed with their reason, and write tools served or withheld with a hint about what to record next; check the same coverage first with inspect_analysis_view view environment-coverage. dry_run reports without writing. The bundle copies the capture Evidence byte for byte, recorded response bodies included, and its README names how the agentenv framework uses it. Reads that Evidence, and package_tarball when given, and writes only that folder; no network access. dry_run still checks output_directory. Destination refusals name this tool; the others name their stage as environment-build's do.", + kind: "application", + inputSchema: exportEnvironmentBundleInputSchema, + outputSchema: environmentBuildReportSchema, + examples: [ + { + title: "Write the bundle for a retained browser scenario capture", + input: { + source: { + kind: "retained-evidence", + evidence_id: + "ev_0000000000000000000000000000000000000000000000000000000000000001", + }, + output_directory: "/tmp/grafana-env", + name: "grafana", + }, + }, + { + title: "Check what a capture file's bundle would serve", + input: { + source: { kind: "evidence-file", path: "/tmp/grafana.capture.json" }, + output_directory: "/tmp/grafana-env", + dry_run: true, + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 3f8f821ea4..174b3b4ec9 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -41,6 +41,7 @@ import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "./reverseEngineeringToolCont import { GDB_TOOL_CONTRACTS } from "./gdbToolContracts.js"; import { RIZIN_DEBUG_TOOL_CONTRACTS } from "./rizinDebugToolContracts.js"; import { FRIDA_TOOL_CONTRACTS } from "./fridaToolContracts.js"; +import { ENVIRONMENT_TOOL_CONTRACTS } from "./environmentToolContracts.js"; /** Complete ordered public inventory used by registration and verification. */ export const TOOL_CONTRACTS = [ @@ -80,6 +81,7 @@ export const TOOL_CONTRACTS = [ ...WEB_SCRIPT_TOOL_CONTRACTS, ...WEB_RUNTIME_TOOL_CONTRACTS, ...WEB_NETWORK_CAPTURE_TOOL_CONTRACTS, + ...ENVIRONMENT_TOOL_CONTRACTS, ...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS, ...SESSION_TOOL_CONTRACTS, ...CUTTER_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index ac0c2a5ac2..b607a3f04e 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -681,6 +681,10 @@ export const TOOL_EFFECTS: Readonly> = { writesFilesystem: true, idempotent: false, }), + export_environment_bundle: effects({ + writesFilesystem: true, + idempotent: false, + }), reconcile_javascript_runtime: evidence, recover_javascript_sources: effects({ mutatesSession: true, diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 4b7f2a854e..4f8d983671 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -235,6 +235,11 @@ import { environmentStateViewRequestSchema, projectEnvironmentStateView, } from "./environmentStateView.js"; +import { + environmentCoverageFields, + environmentCoverageViewRequestSchema, + projectEnvironmentCoverageView, +} from "./environmentCoverageView.js"; import { pcapViewRequestSchema, pcapViewPageSchema, @@ -374,6 +379,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ apiSurfaceViewSchema, environmentManifestViewRequestSchema, environmentStateViewRequestSchema, + environmentCoverageViewRequestSchema, captureViewRequestSchema, protobufViewRequestSchema, qlogViewRequestSchema, @@ -966,6 +972,16 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ ]), view: environmentStateViewRequestSchema, }), + z.strictObject({ + ...viewResultBase, + ...environmentCoverageFields, + kind: z.literal("environment-coverage"), + parent_operation: z.enum([ + "inspect_web_network_capture", + "capture_browser_scenario", + ]), + view: environmentCoverageViewRequestSchema, + }), z.discriminatedUnion("parent_operation", [ erlangBeamSummaryViewResultSchema, pythonPycSummaryViewResultSchema, @@ -1241,7 +1257,7 @@ const unsupportedParent = ( INSPECT_ANALYSIS_VIEW_OPERATION, parentArtifactPath(parent), parent.operation === "capture_browser_scenario" - ? `Parent Evidence operation "capture_browser_scenario" supports only the environment-manifest and environment-state views.` + ? `Parent Evidence operation "capture_browser_scenario" supports only the environment-manifest, environment-state and environment-coverage views.` : `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed Evidence from a supported parent operation with a compatible view. For a summary receipt, use its normalized_result.parent_evidence_id rather than the receipt's own evidence_id.`, ); @@ -1305,9 +1321,16 @@ export interface AnalysisViewAccumulators { readonly state?: EnvironmentStateAccumulator | undefined; } +/** Views that group a capture's records into tools. */ +export type GroupingViewKind = + | "api-surface" + | "environment-manifest" + | "environment-state" + | "environment-coverage"; + type GroupingViewRequest = Extract< AnalysisViewRequest, - { kind: "api-surface" | "environment-manifest" | "environment-state" } + { kind: GroupingViewKind } >; const isGroupingView = ( @@ -1315,7 +1338,8 @@ const isGroupingView = ( ): view is GroupingViewRequest => view.kind === "api-surface" || view.kind === "environment-manifest" || - view.kind === "environment-state"; + view.kind === "environment-state" || + view.kind === "environment-coverage"; /** Views that group a capture's records, each under the budget its caller created. */ const projectGroupingView = ( @@ -1331,6 +1355,8 @@ const projectGroupingView = ( ); if (view.kind === "environment-manifest") return projectEnvironmentManifestView(parent, view, accumulators.manifest); + if (view.kind === "environment-coverage") + return projectEnvironmentCoverageView(parent, view, accumulators.state); return projectEnvironmentStateView(parent, view, accumulators.state); }; diff --git a/src/domain/analysisView/binaryLayoutView.ts b/src/domain/analysisView/binaryLayoutView.ts index b441524ddd..09128066e6 100644 --- a/src/domain/analysisView/binaryLayoutView.ts +++ b/src/domain/analysisView/binaryLayoutView.ts @@ -9,6 +9,7 @@ import type { AnalysisViewParent, AnalysisViewRequest, UnsignedAnalysisView, + GroupingViewKind, } from "./analysisView.js"; import { boundedViewMessages, @@ -157,10 +158,7 @@ const layoutSummary = (layout: BinaryLayout) => ({ export const projectBinaryLayoutView = ( parent: AnalysisViewParent, layout: BinaryLayout, - view: Exclude< - AnalysisViewRequest, - { kind: "api-surface" | "environment-manifest" | "environment-state" } - >, + view: Exclude, ): Result => { const shared = parentFields(parent, layout); if (view.kind === "native") diff --git a/src/domain/analysisView/environmentCoverageView.test.ts b/src/domain/analysisView/environmentCoverageView.test.ts new file mode 100644 index 0000000000..50bdc6b205 --- /dev/null +++ b/src/domain/analysisView/environmentCoverageView.test.ts @@ -0,0 +1,221 @@ +import { expect, it } from "vitest"; +import { + analysisViewCaptureEvidence, + environmentStateCaptureFixture, +} from "../../../tests/fixtures/analysisView.js"; +import { + SCENARIO_EXCHANGES, + scenarioCaptureEvidence, + scenarioCaptureFixture, +} from "../../../tests/fixtures/browserScenarioCapture.js"; +import type { Evidence } from "../evidence.js"; +import { + analysisViewRequestSchema, + projectAnalysisView, + type AnalysisViewParent, + type AnalysisViewResult, +} from "./analysisView.js"; +import { + WITHHELD_WRITE_HINTS, + summarizeEnvironmentCoverage, +} from "./environmentCoverageView.js"; +import type { StateView } from "./environmentStateRules.js"; +import { environmentStateFields } from "./environmentStateView.js"; +import type { StateWritePlan } from "./environmentStateWriteRules.js"; + +const RULES = [ + "after-uid-literal", + "opaque-token", + "sibling-variance", +] as const; + +const parentOf = ( + evidence: Evidence = analysisViewCaptureEvidence( + environmentStateCaptureFixture(), + ), +): AnalysisViewParent => ({ + evidenceId: evidence.evidence_id, + operation: evidence.operation, + normalizedResult: evidence.normalized_result, + subject: evidence.subject, + limitations: [], + parameters: evidence.parameters, +}); + +const project = ( + kind: "environment-state" | "environment-coverage", + parent: AnalysisViewParent = parentOf(), +): AnalysisViewResult => { + const projected = projectAnalysisView( + parent, + analysisViewRequestSchema.parse({ kind, template_rules: [...RULES] }), + ); + if (!projected.ok) throw projected.error; + return projected.value; +}; + +const plan = ( + tool: string, + reason: StateWritePlan["reason"], + extra: Partial = {}, +): StateWritePlan => + ({ + tool, + effect: "create", + target: "/api/folders", + served: reason === null, + reason, + exemplar_ordinal: 4, + observation: null, + response: null, + body: null, + rules: [], + collection: null, + insert: null, + merge: null, + checked_record_ordinals: [], + contradicted_by: null, + ...extra, + }) as StateWritePlan; + +it("has a hint for every reason the state view reports for a withheld write", () => { + const reasons = environmentStateFields.writes.element.shape.reason + .unwrap() + .options.slice() + .sort(); + expect(Object.keys(WITHHELD_WRITE_HINTS).sort()).toEqual(reasons); + for (const hint of Object.values(WITHHELD_WRITE_HINTS)) + expect(hint.length).toBeGreaterThan(0); +}); + +it("counts read tools by how they answer and lists writes as served or withheld with hints", () => { + const views: StateView[] = [ + { tool: "get_folders", shape: "document", checked_record_ordinals: [] }, + { tool: "get_users", shape: "recorded", reason: "query-varies" }, + { tool: "get_teams", shape: "recorded", reason: "envelope-varies" }, + { tool: "get_orgs", shape: "recorded", reason: "query-varies" }, + ]; + const writes = [ + plan("post_folders", null), + plan("put_folders_by_uid", "contradicted", { + effect: "update", + contradicted_by: 9, + }), + plan("delete_folders_by_uid", "no-state-target", { + effect: "delete", + exemplar_ordinal: null, + }), + ]; + expect(summarizeEnvironmentCoverage({ views, writes })).toEqual({ + reads: { + state: 1, + recorded: 3, + recorded_reasons: { "envelope-varies": 1, "query-varies": 2 }, + }, + writes: { + served: [{ tool: "post_folders", effect: "create" }], + withheld: [ + { + tool: "put_folders_by_uid", + effect: "update", + reason: "contradicted", + hint: WITHHELD_WRITE_HINTS.contradicted, + exemplar_ordinal: 4, + contradicted_by: 9, + }, + { + tool: "delete_folders_by_uid", + effect: "delete", + reason: "no-state-target", + hint: WITHHELD_WRITE_HINTS["no-state-target"], + exemplar_ordinal: null, + contradicted_by: null, + }, + ], + }, + }); +}); + +it("summarizes the state the environment-state view derives with the same rules", () => { + const state = project("environment-state"); + const coverage = project("environment-coverage"); + if ( + state.kind !== "environment-state" || + coverage.kind !== "environment-coverage" + ) + throw new Error("Expected state and coverage views"); + const summary = summarizeEnvironmentCoverage(state); + expect(coverage.reads).toEqual(summary.reads); + expect(coverage.writes).toEqual(summary.writes); + expect(coverage.reads.state + coverage.reads.recorded).toBe( + state.views.length, + ); + expect(coverage.writes.served.length + coverage.writes.withheld.length).toBe( + state.writes.length, + ); + expect(coverage).toMatchObject({ + view: { kind: "environment-coverage", template_rules: [...RULES] }, + parent_evidence_id: state.parent_evidence_id, + origin: state.origin, + name: state.name, + unknowns: state.unknowns, + coverage: state.coverage, + }); + expect(coverage.limitations).toEqual( + expect.arrayContaining([ + ...state.limitations, + expect.stringContaining("does not run the environment"), + ]), + ); +}); + +it("says an untimed capture learns no clock, and only for an untimed capture", () => { + const untimed = /learns no clock/u; + const scenarioParent = parentOf( + scenarioCaptureEvidence( + scenarioCaptureFixture(SCENARIO_EXCHANGES, { + request_body: true, + response_body: true, + header_values: true, + }), + ), + ); + for (const [parent, timeline] of [ + [parentOf(), "recorded-start-time"], + [scenarioParent, "record-order"], + ] as const) { + const state = project("environment-state", parent); + const coverage = project("environment-coverage", parent); + if ( + state.kind !== "environment-state" || + coverage.kind !== "environment-coverage" + ) + throw new Error("Expected state and coverage views"); + expect(state.timeline).toBe(timeline); + expect(coverage.limitations.some((line) => untimed.test(line))).toBe( + timeline === "record-order", + ); + } +}); + +it("refuses a browser scenario without response bodies as the state view does", () => { + const evidence = scenarioCaptureEvidence( + scenarioCaptureFixture(SCENARIO_EXCHANGES, { + request_body: true, + response_body: false, + header_values: true, + }), + ); + const view = (kind: "environment-state" | "environment-coverage") => + projectAnalysisView( + parentOf(evidence), + analysisViewRequestSchema.parse({ kind, template_rules: [] }), + ); + const state = view("environment-state"); + const coverage = view("environment-coverage"); + if (state.ok || coverage.ok) throw new Error("Expected both refusals"); + expect(coverage.error).toMatchObject({ + _tag: "AnalysisUnsupportedTargetError", + message: state.error.message, + }); +}); diff --git a/src/domain/analysisView/environmentCoverageView.ts b/src/domain/analysisView/environmentCoverageView.ts new file mode 100644 index 0000000000..63435e3e7a --- /dev/null +++ b/src/domain/analysisView/environmentCoverageView.ts @@ -0,0 +1,186 @@ +import { z } from "zod"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { ok, type Result } from "../result.js"; +import type { + AnalysisViewParent, + AnalysisViewRequest, + UnsignedAnalysisView, +} from "./analysisView.js"; +import { EnvironmentStateAccumulator } from "./EnvironmentStateAccumulator.js"; +import { templateRulesSchema } from "./environmentManifestView.js"; +import type { StateView } from "./environmentStateRules.js"; +import { + environmentStateFields, + projectEnvironmentStateView, +} from "./environmentStateView.js"; +import type { + StateWritePlan, + WriteEffect, + WriteReason, +} from "./environmentStateWriteRules.js"; + +export const environmentCoverageViewRequestSchema = z.strictObject({ + kind: z.literal("environment-coverage"), + template_rules: templateRulesSchema.describe( + "Opt-in template rules as for environment-manifest, each listed at most once. Use the rules of the manifest the environment serves so tool names match.", + ), +}); + +/** What to record next for each reason a write tool is withheld; advice only, it never changes what is served. */ +export const WITHHELD_WRITE_HINTS = { + "no-state-target": + "The write's path names no collection or item the capture reads. Read the collection or item it changes.", + "no-call": + "No recorded call of this tool was modelled, for example because every call failed. Record a successful call.", + "body-unusable": "The request body is not a usable JSON object.", + "no-observation": + "Nothing showed the entity or document this write changed. Read a created entity back by id right after the write; for an update or delete the state must already hold the entity, so read it before the write or record the create that made it.", + "unexplained-change": + "The write changed fields it did not send, in a way no rule explains. A second, different call of the same tool can show the pattern.", + contradicted: + "A later read disagreed with the modelled effect. Check the read at contradicted_by.", + "side-effect": + "The write changed something besides its own entity, which the environment does not model.", + unconfirmed: + "No later read showed the effect. Read the entity and its list after the write.", + "incomplete-entities": + "The write is confirmed, but the views that show its entity cannot render it in full. Read the written entity through every view that lists it.", +} as const satisfies Record; + +const writeReasons = Object.keys(WITHHELD_WRITE_HINTS) as [ + WriteReason, + ...WriteReason[], +]; +const effectSchema = z.enum(["create", "update", "delete", "document", "none"]); +const ordinalSchema = z.number().int().nonnegative(); + +/** Which tools an environment answers from its state, which replay their recordings, and why each write is withheld. */ +export const environmentCoverageSchema = z.strictObject({ + reads: z.strictObject({ + state: z + .number() + .int() + .nonnegative() + .describe("Read tools answered from the state."), + recorded: z + .number() + .int() + .nonnegative() + .describe("Read tools that replay their recordings."), + recorded_reasons: z + .record(z.string(), z.number().int().positive()) + .describe( + "Replayed read tools per reason, as the environment-state view names the reasons.", + ), + }), + writes: z.strictObject({ + served: z.array(z.strictObject({ tool: z.string(), effect: effectSchema })), + withheld: z.array( + z.strictObject({ + tool: z.string(), + effect: effectSchema, + reason: z.enum(writeReasons), + hint: z + .string() + .describe( + "What to record next; advice that never changes what is served.", + ), + exemplar_ordinal: ordinalSchema + .nullable() + .describe( + "The first successful recorded call the plan was built from.", + ), + contradicted_by: ordinalSchema + .nullable() + .describe("The read that disagreed with the modelled effect."), + }), + ), + }), +}); + +export type EnvironmentCoverage = z.infer; + +export const environmentCoverageFields = { + origin: environmentStateFields.origin, + name: environmentStateFields.name, + ...environmentCoverageSchema.shape, + unknowns: environmentStateFields.unknowns, +}; + +/** Summarize a state's read views and write plans as served, replayed and withheld tools. */ +export const summarizeEnvironmentCoverage = (state: { + readonly views: Iterable; + readonly writes: Iterable; +}): EnvironmentCoverage => { + let answered = 0; + let recorded = 0; + const reasons: Record = {}; + for (const view of state.views) + if (view.shape === "recorded") { + recorded++; + reasons[view.reason] = (reasons[view.reason] ?? 0) + 1; + } else answered++; + const served: { tool: string; effect: WriteEffect }[] = []; + const withheld: EnvironmentCoverage["writes"]["withheld"] = []; + for (const plan of state.writes) + if (plan.served || plan.reason === null) + served.push({ tool: plan.tool, effect: plan.effect }); + else + withheld.push({ + tool: plan.tool, + effect: plan.effect, + reason: plan.reason, + hint: WITHHELD_WRITE_HINTS[plan.reason], + exemplar_ordinal: plan.exemplar_ordinal, + contradicted_by: plan.contradicted_by, + }); + return { + reads: { + state: answered, + recorded, + recorded_reasons: Object.fromEntries( + Object.entries(reasons).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ), + ), + }, + writes: { served, withheld }, + }; +}; + +/** Project the state an environment would serve and summarize which tools it answers from that state. */ +export const projectEnvironmentCoverageView = ( + parent: AnalysisViewParent, + view: Extract, + accumulator = new EnvironmentStateAccumulator(undefined, view.template_rules), +): Result => { + const projected = projectEnvironmentStateView( + parent, + { kind: "environment-state", template_rules: view.template_rules }, + accumulator, + ); + if (!projected.ok) return projected; + const state = projected.value; + return ok({ + kind: "environment-coverage", + view, + parent_evidence_id: state.parent_evidence_id, + parent_operation: state.parent_operation, + parent_digest: state.parent_digest, + artifact: state.artifact, + coverage: state.coverage, + limitations: [ + ...state.limitations, + "Coverage summarizes the environment-state view an environment derives at startup; it does not run the environment. An environment whose served catalog exceeds the discovery budget, or whose state exceeds what data/add loads, serves replay instead, which this view does not check. Hints are advice about what to record next.", + ...(state.timeline === "record-order" + ? [ + "Not every record carries a start time (browser scenario captures carry none), so the state learns no clock: a write whose response changes a timestamp the server sets, such as a modification time, is withheld as unexplained-change however often it is recorded.", + ] + : []), + ], + unknowns: state.unknowns, + origin: state.origin, + name: state.name, + ...summarizeEnvironmentCoverage(state), + }); +}; diff --git a/src/domain/analysisView/environmentStateView.ts b/src/domain/analysisView/environmentStateView.ts index 1a7c998278..f4a78c5c50 100644 --- a/src/domain/analysisView/environmentStateView.ts +++ b/src/domain/analysisView/environmentStateView.ts @@ -466,7 +466,10 @@ export const projectEnvironmentStateView = ( parent: AnalysisViewParent, view: Extract, accumulator = new EnvironmentStateAccumulator(undefined, view.template_rules), -): Result => { +): Result< + Extract, + AnalysisError +> => { const grouped = groupCaptureRecords(parent, view.kind, view.template_rules, { accumulator, noun: "state", diff --git a/src/domain/analysisView/javascriptApplicationView.ts b/src/domain/analysisView/javascriptApplicationView.ts index 912c93c9ae..eb38371aff 100644 --- a/src/domain/analysisView/javascriptApplicationView.ts +++ b/src/domain/analysisView/javascriptApplicationView.ts @@ -23,6 +23,7 @@ import type { AnalysisViewParent, AnalysisViewRequest, UnsignedAnalysisView, + GroupingViewKind, } from "./analysisView.js"; const OPERATION = "inspect_analysis_view"; @@ -340,10 +341,7 @@ const selectModule = ( export const projectJavaScriptApplicationView = ( parent: AnalysisViewParent, analysis: JavaScriptApplicationAnalysisResult, - view: Exclude< - AnalysisViewRequest, - { kind: "api-surface" | "environment-manifest" | "environment-state" } - >, + view: Exclude, ): Result => { if ( view.kind !== "summary" && diff --git a/src/domain/analysisView/recordedResponseBody.ts b/src/domain/analysisView/recordedResponseBody.ts index 07286d1176..77b60ad214 100644 --- a/src/domain/analysisView/recordedResponseBody.ts +++ b/src/domain/analysisView/recordedResponseBody.ts @@ -28,6 +28,27 @@ const strictUtf8 = (): TextDecoder => export const base64PrefixChars = (limitBytes: number): number => Math.ceil((limitBytes + 4) / 3) * 4; +/** Decode retained base64 as strict UTF-8, only as far as `limitBytes`; bytes that are not UTF-8 stay base64. */ +export const decodeRetainedBase64 = ( + encoded: string, + limitBytes: number, +): RecordedResponseBody => { + const prefixChars = base64PrefixChars(limitBytes); + const cut = encoded.length > prefixChars; + const prefix = cut ? encoded.slice(0, prefixChars) : encoded; + const flags = cut ? { bodyCut: true as const } : {}; + try { + // A cut prefix may end inside a character; streaming leaves that tail undecoded. + const text = strictUtf8().decode(Buffer.from(prefix, "base64"), { + stream: cut, + }); + return { bodyText: text, ...flags }; + } catch { + // Binary or non-UTF-8 bytes: keep them exactly, as base64, instead of inventing replacement characters. + return { bodyText: prefix, bodyEncoding: "base64", ...flags }; + } +}; + /** Read producer-decoded text or supported HAR base64; unknown encoding declarations stay opaque. Base64 beyond `limitBytes` is decoded only that far. */ export const readRecordedResponseBody = ( record: WebNetworkCaptureRecord, @@ -53,22 +74,7 @@ export const readRecordedResponseBody = ( ); if (binary === undefined || binary.content_base64 === null) return { bodyText: null }; - const prefixChars = base64PrefixChars(limitBytes); - const cut = binary.content_base64.length > prefixChars; - const encoded = cut - ? binary.content_base64.slice(0, prefixChars) - : binary.content_base64; - const flags = cut ? { bodyCut: true as const } : {}; - try { - // A cut prefix may end inside a character; streaming leaves that tail undecoded. - const text = strictUtf8().decode(Buffer.from(encoded, "base64"), { - stream: cut, - }); - return { bodyText: text, ...flags }; - } catch { - // Binary or non-UTF-8 bytes: keep them exactly, as base64, instead of inventing replacement characters. - return { bodyText: encoded, bodyEncoding: "base64", ...flags }; - } + return decodeRetainedBase64(binary.content_base64, limitBytes); }; /** Prove decoded emptiness with a bounded prefix, preserving encoding and cut uncertainty. */ diff --git a/src/domain/analysisView/scenarioCaptureRecords.ts b/src/domain/analysisView/scenarioCaptureRecords.ts index f8bc23277a..4adcb453d6 100644 --- a/src/domain/analysisView/scenarioCaptureRecords.ts +++ b/src/domain/analysisView/scenarioCaptureRecords.ts @@ -81,7 +81,10 @@ const utf8Text = (body: CapturedBody): string | undefined => { } }; -const mediaType = (content: ContentEvent | undefined): string | undefined => +/** A content event's media type: its captured body's, else its retained Content-Type value. */ +export const scenarioContentMediaType = ( + content: ContentEvent | undefined, +): string | undefined => captured(content)?.media_type ?? headerValue(content?.headers, "content-type"); @@ -93,7 +96,7 @@ const reportedRequest = ( ): { readonly [key: string]: JsonValue } => { const body = purpose === "manifest" ? captured(content) : undefined; const text = body === undefined ? undefined : utf8Text(body); - const mimeType = mediaType(content); + const mimeType = scenarioContentMediaType(content); return { method: request.method, bodySize: body !== undefined && !body.redacted ? body.bytes : -1, @@ -142,7 +145,7 @@ const record = ( const requestContent = onlyContent(index, request.sequence); const responseContent = onlyContent(index, response?.sequence); const body = retainedBody(responseContent); - const mimeType = mediaType(responseContent) ?? ""; + const mimeType = scenarioContentMediaType(responseContent) ?? ""; return { record: { ordinal, diff --git a/src/domain/analysisView/webNetworkCaptureView.ts b/src/domain/analysisView/webNetworkCaptureView.ts index 0439ab3efe..d236af38ab 100644 --- a/src/domain/analysisView/webNetworkCaptureView.ts +++ b/src/domain/analysisView/webNetworkCaptureView.ts @@ -458,7 +458,7 @@ export const projectWebNetworkCaptureView = ( path: ["view"], reason: "invalid_value", message: - "Historical captures support summary, capture record pages, qlog fact pages and admitted api-surface, environment-manifest or environment-state views.", + "Historical captures support summary, capture record pages, qlog fact pages and admitted api-surface, environment-manifest, environment-state or environment-coverage views.", }, ]), ); diff --git a/src/domain/browserScenario.ts b/src/domain/browserScenario.ts index bf66966d76..2e27c445cd 100644 --- a/src/domain/browserScenario.ts +++ b/src/domain/browserScenario.ts @@ -10,6 +10,7 @@ import { browserScenarioSecretSchema, browserScenarioStorageSchema, browserScenarioUrlSchema, + HEADER_FIELD_VALUE, type BrowserScenarioAction, type BrowserScenarioUrl, type BrowserScenarioValue, @@ -72,9 +73,69 @@ const actionSecretReferences = ( return secretReferencesInValue(action.value); if (action.action === "goto") return destinationSecretReferences(action.destination); + if (action.action === "request") + return [ + ...action.query.flatMap(({ value }) => secretReferencesInValue(value)), + ...action.headers.flatMap(({ value }) => + value.source === "secret" ? [value.secret_id] : [], + ), + ]; return []; }; +/** Request steps stay on the start origin and send a body only with methods that carry one. */ +const validateRequestSteps = ( + scenario: ScenarioShape, + context: z.RefinementCtx, +): void => { + const origin = new URL(scenario.start_url.url).origin; + scenario.actions.forEach((action, index) => { + if (action.action !== "request") return; + const resolved = URL.parse(action.path, origin); + if (resolved === null) + addIssue( + context, + ["actions", index, "path"], + "The request step path is not a URL path the browser can resolve", + ); + else if (resolved.origin !== origin) + addIssue( + context, + ["actions", index, "path"], + `Request steps must stay on the start URL's origin ${origin}`, + ); + if ( + action.json !== undefined && + (action.method === "GET" || action.method === "HEAD") + ) + addIssue( + context, + ["actions", index, "json"], + `A ${action.method} request step cannot send a body`, + ); + const names = new Set(); + action.headers.forEach((header, position) => { + const name = header.name.toLowerCase(); + if (names.has(name)) + addIssue( + context, + ["actions", index, "headers", position, "name"], + `Request step headers name ${header.name} more than once`, + ); + names.add(name); + if ( + header.value.source === "literal" && + !HEADER_FIELD_VALUE.test(header.value.value) + ) + addIssue( + context, + ["actions", index, "headers", position, "value", "value"], + "A header value cannot carry line breaks or control characters", + ); + }); + }); +}; + const validateStorage = (scenario: ScenarioShape): readonly string[] => { const references: string[] = []; scenario.storage.cookies.forEach((cookie) => { @@ -136,6 +197,7 @@ const validateBrowserScenario = ( context, ); + validateRequestSteps(scenario, context); const references = scenario.actions.flatMap(actionSecretReferences); references.push(...destinationSecretReferences(scenario.start_url)); references.push(...validateStorage(scenario)); diff --git a/src/domain/browserScenarioValues.ts b/src/domain/browserScenarioValues.ts index 88ba2f4efe..8c748dfcc8 100644 --- a/src/domain/browserScenarioValues.ts +++ b/src/domain/browserScenarioValues.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { IDENTIFIER_PATTERN } from "./stringPatterns.js"; +import { jsonValueSchema } from "./jsonValue.js"; import { absoluteLocalPathStringSchema } from "./localPath.js"; import { browserNetworkContentSelectionSchema } from "./browserNetworkEvidence.js"; @@ -67,6 +68,97 @@ const queryEntrySchema = z.strictObject({ value: browserScenarioValueSchema, }); +/** HTTP methods a request step may send. */ +export const BROWSER_SCENARIO_REQUEST_METHODS = [ + "GET", + "HEAD", + "POST", + "PUT", + "PATCH", + "DELETE", +] as const; + +/** Header names a page's fetch cannot set (and User-Agent, which Chrome drops), so a request step refuses them rather than having them dropped. */ +const FORBIDDEN_REQUEST_HEADERS = new Set([ + "accept-charset", + "accept-encoding", + "access-control-request-headers", + "access-control-request-method", + "connection", + "content-length", + "cookie", + "cookie2", + "date", + "dnt", + "expect", + "host", + "keep-alive", + "origin", + "referer", + "set-cookie", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "user-agent", + "via", +]); + +/** Characters an HTTP field value may carry: visible ASCII, space, tab and obs-text. */ +export const HEADER_FIELD_VALUE = /^[\t\x20-\x7e\x80-\xff]*$/u; + +const settableRequestHeader = (name: string): boolean => { + const lower = name.toLowerCase(); + return ( + !FORBIDDEN_REQUEST_HEADERS.has(lower) && + !lower.startsWith("proxy-") && + !lower.startsWith("sec-") + ); +}; + +/** A header value a request step reads from the page as it runs; it is redacted like a declared secret. */ +const browserScenarioPageValueSchemas = [ + z.strictObject({ + source: z.literal("cookie"), + name: z + .string() + .min(1) + .describe("Cookie the browser holds for the request URL."), + }), + z.strictObject({ + source: z.literal("local_storage"), + key: z.string().min(1).describe("Local storage key on the start origin."), + json_pointer: z + .string() + .regex(/^(?:\/.*)?$/u) + .optional() + .describe( + "Parse the stored value as JSON and use the string or number at this pointer; empty selects the whole value.", + ), + }), +] as const; + +const requestHeaderSchema = z.strictObject({ + name: z + .string() + .regex(/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u) + .refine(settableRequestHeader, { + message: "A page's fetch cannot set this header", + }), + value: z.discriminatedUnion("source", [ + ...browserScenarioValueSchema.options, + ...browserScenarioPageValueSchemas, + ]), + prefix: z + .string() + .regex(HEADER_FIELD_VALUE, { + message: "A header value cannot carry line breaks or control characters", + }) + .optional() + .describe("Text sent before the value, such as `Bearer `."), +}); +export type BrowserScenarioRequestHeader = z.infer; + /** URL with ordinary inline details and optional structured secret query references. */ export const browserScenarioUrlSchema = z.strictObject({ url: browserScenarioBaseUrlSchema, @@ -221,6 +313,40 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [ locator: locatorSchema, state: z.enum(["attached", "detached", "visible", "hidden"]), }), + z.strictObject({ + ...stepBase, + action: z.literal("request"), + method: z.enum(BROWSER_SCENARIO_REQUEST_METHODS), + path: z + .string() + .regex(/^\/(?![/\\])/u) + .describe( + "Path, and optional literal query, on the start URL's origin, such as /api/folders; it must resolve to that origin.", + ), + query: z.array(queryEntrySchema).default([]), + headers: z.array(requestHeaderSchema).default([]), + json: jsonValueSchema + .optional() + .describe( + "JSON request body, sent with Content-Type application/json unless a header sets one; not allowed for GET or HEAD.", + ), + expected_status: z + .array( + z + .int() + .min(100) + .max(599) + .refine((status) => status < 300 || status > 399, { + message: + "Request steps follow no redirects, so a 3xx status cannot complete one", + }), + ) + .min(1) + .optional() + .describe( + "Statuses that complete the step; any other status fails it. Omitted means any response completes it.", + ), + }), z.strictObject({ step_id: scenarioIdentifierSchema, action: z.literal("wait_for_timeout"), diff --git a/src/domain/environmentBundleExport.ts b/src/domain/environmentBundleExport.ts new file mode 100644 index 0000000000..d90ba29818 --- /dev/null +++ b/src/domain/environmentBundleExport.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; + +import { environmentCoverageSchema } from "./analysisView/environmentCoverageView.js"; +import { TEMPLATE_RULES } from "./analysisView/environmentManifestRules.js"; +import { prefixedDigestSchema } from "./digests.js"; +import { retainedEvidenceReferenceSchema } from "./evidenceReference.js"; +import { absoluteLocalPathStringSchema } from "./localPath.js"; + +/** Capture Evidence retained in this session, or one capture Evidence file on this host. */ +const environmentCaptureSourceSchema = z.discriminatedUnion("kind", [ + retainedEvidenceReferenceSchema.describe( + "Capture Evidence this session retained, such as a capture_browser_scenario or inspect_web_network_capture result.", + ), + z.strictObject({ + kind: z.literal("evidence-file"), + path: absoluteLocalPathStringSchema.describe( + "Absolute path of one capture Evidence JSON file, copied byte for byte into the bundle.", + ), + }), +]); + +export const exportEnvironmentBundleInputSchema = z.strictObject({ + source: environmentCaptureSourceSchema.describe( + "The capture the environment is built from: capture_browser_scenario Evidence, or HAR inspect_web_network_capture Evidence.", + ), + output_directory: absoluteLocalPathStringSchema.describe( + "Absolute, absent output directory for the bundle folder; parent must exist.", + ), + name: z + .string() + .min(1) + .optional() + .describe( + "Env folder and env id (kebab-case). Default: the manifest's name; required when the capture's origin is a loopback or IP address.", + ), + template_rules: z + .array(z.enum(TEMPLATE_RULES)) + .optional() + .describe( + "Opt-in template rules, each at most once; [] applies only the format rules. Default: after-uid-literal, opaque-token, sibling-variance and prefix-variance.", + ), + package_tarball: absoluteLocalPathStringSchema + .optional() + .describe( + "Absolute path of a packed rea-agents tarball the bundle installs instead of the registry package of this version.", + ), + dry_run: z + .boolean() + .default(false) + .describe("Report what the bundle would hold without writing anything."), +}); +export type ExportEnvironmentBundleInput = z.infer< + typeof exportEnvironmentBundleInputSchema +>; + +/** What a built or planned bundle serves and holds; environment-build prints the same report. */ +export const environmentBuildReportSchema = z.strictObject({ + status: z.enum(["built", "planned"]), + directory: z.string().nullable(), + name: z.string(), + capture: z.strictObject({ + evidence_id: prefixedDigestSchema("ev"), + operation: z.string(), + records: z.int().nonnegative(), + input: z.enum(["har", "evidence"]), + }), + manifest: z.strictObject({ + evidence_id: prefixedDigestSchema("ev"), + tools: z.int().nonnegative(), + excluded_groups: z.int().nonnegative(), + template_rules: z.array(z.enum(TEMPLATE_RULES)), + }), + state: z + .union([ + z.strictObject({ + served: z.literal(true), + evidence_id: z.string(), + }), + z.strictObject({ + served: z.literal(false), + reason: z.literal("unavailable"), + code: z.string(), + message: z.string(), + }), + ]) + .describe( + "Whether the bundle serves from the derived state, or replays and why.", + ), + coverage: environmentCoverageSchema + .nullable() + .describe( + "Read tools answered from the state or replayed, and write tools served or withheld; null when the bundle serves replay.", + ), + tool_count: z.int().nonnegative(), + package_source: z.union([ + z.strictObject({ kind: z.literal("registry"), specifier: z.string() }), + z.strictObject({ kind: z.literal("tarball"), path: z.string() }), + ]), + files: z.array( + z.strictObject({ path: z.string(), bytes: z.int().nonnegative() }), + ), + warnings: z.array( + z.strictObject({ + code: z.enum([ + "name-from-file", + "credential-named-members", + "credential-scan-incomplete", + ]), + message: z.string(), + pointers: z + .array(z.string()) + .describe( + "Where the warning applies, such as record pointers; never values.", + ), + }), + ), + next: z.array(z.string()), +}); +export type EnvironmentBuildReport = z.infer< + typeof environmentBuildReportSchema +>; diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts index 871c4a7d57..cdb613c4ee 100644 --- a/src/generatedPackageMetadata.ts +++ b/src/generatedPackageMetadata.ts @@ -6,5 +6,5 @@ export const PACKAGE_METADATA = { serverSdkVersion: "2.3.1", clientSdkVersion: "2.3.1", coreSdkVersion: "2.3.1", - skillVersion: "46", + skillVersion: "48", } as const; diff --git a/src/server/registerApplicationTools.ts b/src/server/registerApplicationTools.ts index 61118d5d36..8250acb921 100644 --- a/src/server/registerApplicationTools.ts +++ b/src/server/registerApplicationTools.ts @@ -6,6 +6,7 @@ import { registerCompareApplicationVersionsTool } from "./registerApplicationToo import { registerCompareSourceToBundleTool } from "./registerApplicationTools/compareSourceToBundle.js"; import { registerCompareJavaScriptExportShapesTool } from "./registerApplicationTools/compareExportShapes.js"; import { registerCoverageTools } from "./registerApplicationTools/coverage.js"; +import { registerEnvironmentBundleTool } from "./registerApplicationTools/environmentBundle.js"; import { registerReconstructionObligationLedgerTool } from "./registerApplicationTools/obligationLedger.js"; import { registerProjectMobileApplicationGraphTools } from "./registerApplicationTools/projectMobileApplicationGraph.js"; import type { ApplicationToolRegistration } from "./registerApplicationTools/types.js"; @@ -22,5 +23,6 @@ export const registerApplicationTools = ( registerCompareJavaScriptExportShapesTool(server, options); registerReconstructionObligationLedgerTool(server, options); registerCoverageTools(server, options); + registerEnvironmentBundleTool(server, options); registerProjectMobileApplicationGraphTools(server, options); }; diff --git a/src/server/registerApplicationTools/environmentBundle.ts b/src/server/registerApplicationTools/environmentBundle.ts new file mode 100644 index 0000000000..45de5f847d --- /dev/null +++ b/src/server/registerApplicationTools/environmentBundle.ts @@ -0,0 +1,54 @@ +import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; +import { runAdmittedToolOperation } from "../admittedToolOperation.js"; + +import { exportEnvironmentBundleTool } from "../../application/environment/environmentBundleTool.js"; +import { toolContract } from "../../contracts/toolContracts.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { err } from "../../domain/result.js"; +import { logToolExecution } from "../toolLogging.js"; +import { applicationToolRegistrationOptions } from "../toolRegistrationOptions.js"; +import type { ApplicationToolRegistration } from "./types.js"; + +const contract = toolContract("export_environment_bundle"); + +/** Register the agent environment bundle export; it validates its own input so refusals name the field. */ +export const registerEnvironmentBundleTool = ( + server: EvidenceMcpServer, + options: ApplicationToolRegistration, +): void => { + server.registerTool( + contract.name, + applicationToolRegistrationOptions(contract), + async (rawInput, context) => + runAdmittedToolOperation( + server, + options.withAdmittedAnalysis, + contract.name, + context.mcpReq.signal, + async () => { + const result = await logToolExecution( + options.logger, + contract.name, + async () => { + const parsed = contract.inputSchema.safeParse(rawInput); + if (!parsed.success) + return err( + analysisInputErrorFromIssues( + contract.name, + parsed.error.issues, + rawInput, + { cause: parsed.error }, + ), + ); + return exportEnvironmentBundleTool( + parsed.data, + options.evidenceById, + { signal: context.mcpReq.signal }, + ); + }, + ); + return server.delivery.toCallToolResult(result, contract); + }, + ), + ); +}; diff --git a/tests/boundary/cli/environmentBuildCli.test.ts b/tests/boundary/cli/environmentBuildCli.test.ts new file mode 100644 index 0000000000..206ec83960 --- /dev/null +++ b/tests/boundary/cli/environmentBuildCli.test.ts @@ -0,0 +1,191 @@ +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { join, sep } from "node:path"; + +import { expect } from "vitest"; + +import { environmentManifestHar } from "../../fixtures/historicalHar.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const RULES = [ + "after-uid-literal", + "opaque-token", + "sibling-variance", + "prefix-variance", +]; + +const listFiles = async (root: string): Promise => + (await readdir(root, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => + join(entry.parentPath, entry.name) + .slice(root.length + 1) + .split(sep) + .join("/"), + ) + .sort(); + +cliTest( + "builds the bundle the three CLI steps build, byte for byte", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-environment-build-cli-"); + const har = join(root, "grafana.har"); + await writeFile(har, JSON.stringify(environmentManifestHar())); + const built = await cli.run({ + arguments: ["environment-build", har, join(root, "built"), "--json"], + timeoutMs: 60_000, + }); + expect(built.exitCode, built.stderr).toBe(0); + expect(built.json).toMatchObject({ + status: "built", + name: "grafana-example", + capture: { input: "har", operation: "inspect_web_network_capture" }, + manifest: { template_rules: RULES }, + state: { served: true }, + warnings: [], + }); + + const inspected = await cli.run({ + arguments: [ + "inspect-web-network-capture", + har, + "har", + "--format", + "json", + ], + }); + expect(inspected.exitCode, inspected.stderr).toBe(0); + const capture = join(root, "capture.json"); + await writeFile(capture, inspected.stdout); + const request = join(root, "view.json"); + await writeFile( + request, + JSON.stringify({ + source: { kind: "evidence-file", path: capture }, + view: { kind: "environment-manifest", template_rules: RULES }, + }), + ); + const projected = await cli.run({ + arguments: ["inspect-analysis-view", request, "--format", "json"], + }); + expect(projected.exitCode, projected.stderr).toBe(0); + const manifest = join(root, "manifest.json"); + await writeFile(manifest, projected.stdout); + const exported = await cli.run({ + arguments: [ + "environment-export", + manifest, + "--evidence", + capture, + join(root, "three-step"), + "--json", + ], + timeoutMs: 60_000, + }); + expect(exported.exitCode, exported.stderr).toBe(0); + + const files = await listFiles(join(root, "built")); + expect(files).toEqual(await listFiles(join(root, "three-step"))); + expect(files).toHaveLength(7); + for (const file of files) + expect(await readFile(join(root, "built", file)), file).toEqual( + await readFile(join(root, "three-step", file)), + ); + expect(built.json).toMatchObject({ + tool_count: (exported.json as { tool_count: number }).tool_count, + files: (exported.json as { files: unknown[] }).files, + }); + + const coverageRequest = join(root, "coverage.json"); + await writeFile( + coverageRequest, + JSON.stringify({ + source: { kind: "evidence-file", path: capture }, + view: { kind: "environment-coverage", template_rules: RULES }, + }), + ); + const coverage = await cli.run({ + arguments: ["inspect-analysis-view", coverageRequest, "--format", "json"], + }); + expect(coverage.exitCode, coverage.stderr).toBe(0); + const view = ( + coverage.json as { + normalized_result: { kind: string; reads: unknown; writes: unknown }; + } + ).normalized_result; + const report = ( + built.json as { coverage: { reads: unknown; writes: unknown } } + ).coverage; + expect(view.kind).toBe("environment-coverage"); + expect(view.reads).toEqual(report.reads); + expect(view.writes).toEqual(report.writes); + }, +); + +cliTest( + "plans without writing on a dry run and refuses unusable arguments", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-environment-build-plan-"); + const har = join(root, "grafana.har"); + await writeFile(har, JSON.stringify(environmentManifestHar())); + const planned = await cli.run({ + arguments: ["environment-build", har, "--dry-run", "--json"], + timeoutMs: 60_000, + }); + expect(planned.exitCode, planned.stderr).toBe(0); + expect(planned.json).toMatchObject({ + status: "planned", + directory: null, + next: [], + }); + expect(await readdir(root)).toEqual(["grafana.har"]); + + const other = join(root, "grafana.saz"); + await writeFile(other, "not a capture"); + const unsupported = await cli.run({ + arguments: ["environment-build", other, join(root, "out"), "--json"], + }); + expect(unsupported.exitCode).toBe(1); + expect(unsupported.json).toMatchObject({ + code: "invalid_request", + details: { operation: "environment-build" }, + }); + + const conflicting = await cli.run({ + arguments: [ + "environment-build", + har, + join(root, "out"), + "--format-rules-only", + "--template-rule", + "opaque-token", + "--json", + ], + }); + expect(conflicting.exitCode).toBe(1); + expect(conflicting.json).toMatchObject({ code: "invalid_request" }); + + const noDirectory = await cli.run({ + arguments: ["environment-build", har, "--json"], + timeoutMs: 60_000, + }); + expect(noDirectory.exitCode).toBe(1); + expect(noDirectory.json).toMatchObject({ + code: "invalid_request", + details: { issues: [{ path: ["directory"] }] }, + }); + const emptyWorkingDirectory = join(root, "empty-cwd"); + await mkdir(emptyWorkingDirectory); + const emptyDirectory = await cli.run({ + arguments: ["environment-build", har, "", "--json"], + cwd: emptyWorkingDirectory, + }); + expect(emptyDirectory.exitCode).toBe(1); + expect(await readdir(emptyWorkingDirectory)).toEqual([]); + expect((await readdir(root)).sort()).toEqual([ + "empty-cwd", + "grafana.har", + "grafana.saz", + ]); + }, +); diff --git a/tests/boundary/filesystem/setupSkill.test.ts b/tests/boundary/filesystem/setupSkill.test.ts index 1ca405548f..dbeb443837 100644 --- a/tests/boundary/filesystem/setupSkill.test.ts +++ b/tests/boundary/filesystem/setupSkill.test.ts @@ -638,6 +638,7 @@ describe("canonical skill transaction", () => { "SKILL.md", "references/android-applications.md", "references/connection-and-recovery.md", + "references/environments.md", "references/evidence-workflows.md", "references/javascript-applications.md", "references/native-and-artifacts.md", diff --git a/tests/boundary/filesystem/setupSkillPublication.test.ts b/tests/boundary/filesystem/setupSkillPublication.test.ts index a25797eb39..6923d3cda9 100644 --- a/tests/boundary/filesystem/setupSkillPublication.test.ts +++ b/tests/boundary/filesystem/setupSkillPublication.test.ts @@ -18,6 +18,7 @@ import { SkillFilePublication } from "../../../src/application/SkillFilePublicat import { canonicalSkillNeedsInstall, installCanonicalSkill, + MANAGED_SKILL_FILES, skillDestinations, type SkillInstallFileOperations, } from "../../../src/application/SetupSkill.js"; @@ -164,7 +165,9 @@ describe("skill publication recovery", () => { reason.includes("First-install skill recovery"), ), ).toBe(true); - expect(result.error.rollbackFailures).toHaveLength(6); + expect(result.error.rollbackFailures).toHaveLength( + MANAGED_SKILL_FILES.length - 1, + ); }, ); @@ -185,7 +188,9 @@ describe("skill publication recovery", () => { }, }); if (result.ok) throw new Error("Expected readback failure"); - expect(result.error.rollbackFailures).toHaveLength(7); + expect(result.error.rollbackFailures).toHaveLength( + MANAGED_SKILL_FILES.length, + ); expect( result.error.rollbackFailures.every(({ reason }) => reason.includes("manual removal"), diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 326decd562..f45cb0c045 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -1428,6 +1428,51 @@ it("extracts an environment state identically from retained, portable and file M await client.ping(); }); +it("reports environment coverage identically from retained, portable and file MCP Evidence", async () => { + const { client, session } = await connect(); + const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( + client, + "inspect_analysis_view", + ); + const parent = analysisViewCaptureEvidence(environmentStateCaptureFixture()); + expect(session.recordEvidence(parent).ok).toBe(true); + const evidencePath = join( + await createTestTempDirectory("rea-coverage-file-mcp-"), + "capture.json", + ); + await writeFile(evidencePath, JSON.stringify(parent)); + const rules = ["after-uid-literal", "opaque-token", "sibling-variance"]; + const outputs = []; + for (const source of [ + { kind: "inline", evidence: parent }, + { kind: "retained-evidence", evidence_id: parent.evidence_id }, + { kind: "evidence-file", path: evidencePath }, + ]) { + const view = { kind: "environment-coverage", template_rules: rules }; + expect(ajv.validate(inputSchema, { source, view })).toBe(true); + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: { source, view }, + }); + expect(response.isError).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const evidence = parseEvidence(response.structuredContent); + expect(evidence.confidence).toBe("inferred"); + expect(evidence.normalized_result).toMatchObject({ + kind: "environment-coverage", + reads: { state: expect.any(Number) }, + writes: { served: expect.any(Array), withheld: expect.any(Array) }, + }); + outputs.push(evidence.normalized_result); + } + expect(outputs[1]).toEqual(outputs[0]); + expect(outputs[2]).toEqual(outputs[0]); + await client.ping(); +}); + it("projects an environment manifest identically from retained, portable and file MCP Evidence", async () => { const { client, session } = await connect(); const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( diff --git a/tests/boundary/mcp/environmentBundleMcp.test.ts b/tests/boundary/mcp/environmentBundleMcp.test.ts new file mode 100644 index 0000000000..5f8211e608 --- /dev/null +++ b/tests/boundary/mcp/environmentBundleMcp.test.ts @@ -0,0 +1,271 @@ +import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { join, sep } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it, onTestFinished } from "vitest"; + +import { + DEFAULT_BUILD_TEMPLATE_RULES, + buildEnvironment, +} from "../../../src/application/environment/environmentBuild.js"; +import { evidenceFileBytes } from "../../../src/application/environment/environmentEvidenceBytes.js"; +import { environmentExportFile } from "../../../src/application/environment/environmentExport.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import type { Evidence } from "../../../src/domain/evidence.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; +import { + analysisViewCaptureEvidence, + environmentManifestCaptureFixture, +} from "../../fixtures/analysisView.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const TOOL = "export_environment_bundle"; + +const connect = async () => { + const session = createTestBinarySession(() => { + throw new Error("the bundle export must not start a deep provider"); + }); + const server = createServer( + { kind: "session", session }, + { + environment: { ...process.env, REA_MCP_INPUT_SCHEMA_PROFILE: "full" }, + }, + ); + const client = new Client({ name: "environment-bundle-mcp", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { client, session }; +}; + +const listFiles = async (root: string): Promise => + (await readdir(root, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => + join(entry.parentPath, entry.name) + .slice(root.length + 1) + .split(sep) + .join("/"), + ) + .sort(); + +const loopbackCapture = (): Evidence => + analysisViewCaptureEvidence( + JSON.parse( + JSON.stringify(environmentManifestCaptureFixture()).replaceAll( + /https?:\/\/grafana\.example/gu, + "http://127.0.0.1:3000", + ), + ) as ReturnType, + ); + +it("writes the bundle environment-build writes from retained capture Evidence", async () => { + const { client, session } = await connect(); + const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( + client, + TOOL, + ); + const capture = analysisViewCaptureEvidence( + environmentManifestCaptureFixture(), + ); + expect(session.recordEvidence(capture).ok).toBe(true); + const root = await createTestTempDirectory("rea-bundle-mcp-"); + const input = { + source: { kind: "retained-evidence", evidence_id: capture.evidence_id }, + output_directory: join(root, "grafana"), + }; + expect(ajv.validate(inputSchema, input)).toBe(true); + const response = await client.callTool({ name: TOOL, arguments: input }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const report = toolContract(TOOL).outputSchema.parse( + response.structuredContent, + ); + expect(report).toMatchObject({ + status: "built", + name: "grafana-example", + directory: input.output_directory, + capture: { evidence_id: capture.evidence_id, input: "evidence" }, + manifest: { template_rules: [...DEFAULT_BUILD_TEMPLATE_RULES] }, + }); + const file = await environmentExportFile( + "evidence", + "capture.json", + evidenceFileBytes(capture), + ); + if (!file.ok) throw file.error; + const reference = await buildEnvironment({ + capture: file.value, + captureInput: "evidence", + capturePath: "capture.json", + templateRules: DEFAULT_BUILD_TEMPLATE_RULES, + directory: join(root, "reference"), + dryRun: false, + }); + if (!reference.ok) throw reference.error; + const files = await listFiles(input.output_directory); + expect(files).toEqual(await listFiles(join(root, "reference"))); + for (const path of files) + expect(await readFile(join(input.output_directory, path)), path).toEqual( + await readFile(join(root, "reference", path)), + ); + expect(report.coverage).toEqual(reference.value.coverage); +}); + +it("copies a capture Evidence file byte for byte and plans without writing on a dry run", async () => { + const { client } = await connect(); + const root = await createTestTempDirectory("rea-bundle-mcp-file-"); + const path = join(root, "grafana.capture.json"); + await writeFile( + path, + `${JSON.stringify(analysisViewCaptureEvidence(environmentManifestCaptureFixture()), null, 2)}\n`, + ); + const planned = await client.callTool({ + name: TOOL, + arguments: { + source: { kind: "evidence-file", path }, + output_directory: join(root, "planned"), + dry_run: true, + }, + }); + expect(planned.isError).not.toBe(true); + expect(planned.structuredContent).toMatchObject({ status: "planned" }); + await expect(access(join(root, "planned"))).rejects.toMatchObject({ + code: "ENOENT", + }); + const built = await client.callTool({ + name: TOOL, + arguments: { + source: { kind: "evidence-file", path }, + output_directory: join(root, "built"), + }, + }); + expect(built.isError).not.toBe(true); + expect( + await readFile(join(root, "built/envs/grafana-example/evidence.json")), + ).toEqual(await readFile(path)); +}); + +it("refuses an existing or parentless output directory and leaves it as it was", async () => { + const { client, session } = await connect(); + const capture = analysisViewCaptureEvidence( + environmentManifestCaptureFixture(), + ); + expect(session.recordEvidence(capture).ok).toBe(true); + const root = await createTestTempDirectory("rea-bundle-mcp-dirs-"); + const source = { + kind: "retained-evidence", + evidence_id: capture.evidence_id, + }; + const empty = join(root, "empty"); + const busy = join(root, "busy"); + await mkdir(empty); + await mkdir(busy); + await writeFile(join(busy, "notes.md"), "keep"); + for (const output_directory of [empty, busy]) { + const refused = await client.callTool({ + name: TOOL, + arguments: { source, output_directory }, + }); + expect(refused.isError).toBe(true); + expect(parseMcpToolError(refused).error).toMatchObject({ + code: "invalid_request", + details: { + operation: TOOL, + issues: [{ path: ["output_directory"], reason: "invalid_value" }], + }, + }); + } + expect(await readdir(empty)).toEqual([]); + expect(await readdir(busy)).toEqual(["notes.md"]); + const parentless = await client.callTool({ + name: TOOL, + arguments: { source, output_directory: join(root, "missing", "env") }, + }); + expect(parentless.isError).toBe(true); + expect(parseMcpToolError(parentless).error).toMatchObject({ + details: { issues: [{ path: ["output_directory"] }] }, + }); + await expect(access(join(root, "missing"))).rejects.toMatchObject({ + code: "ENOENT", + }); + const relative = await client.callTool({ + name: TOOL, + arguments: { source, output_directory: "relative/env" }, + }); + expect(parseMcpToolError(relative).error).toMatchObject({ + code: "invalid_request", + details: { issues: [{ path: ["output_directory"] }] }, + }); +}); + +it("refuses unknown, unnamed loopback and non-capture Evidence, and repeated rules", async () => { + const { client, session } = await connect(); + const root = await createTestTempDirectory("rea-bundle-mcp-refuse-"); + const output_directory = join(root, "env"); + const unknown = await client.callTool({ + name: TOOL, + arguments: { + source: { + kind: "retained-evidence", + evidence_id: `ev_${"0".repeat(63)}9`, + }, + output_directory, + }, + }); + expect(unknown.isError).toBe(true); + const loopback = loopbackCapture(); + expect(session.recordEvidence(loopback).ok).toBe(true); + const unnamed = await client.callTool({ + name: TOOL, + arguments: { + source: { kind: "retained-evidence", evidence_id: loopback.evidence_id }, + output_directory, + }, + }); + expect(parseMcpToolError(unnamed).error).toMatchObject({ + code: "invalid_request", + details: { issues: [{ path: ["name"] }] }, + }); + const named = await client.callTool({ + name: TOOL, + arguments: { + source: { kind: "retained-evidence", evidence_id: loopback.evidence_id }, + output_directory, + name: "grafana", + dry_run: true, + }, + }); + expect(named.structuredContent).toMatchObject({ + status: "planned", + name: "grafana", + warnings: [], + }); + const repeated = await client.callTool({ + name: TOOL, + arguments: { + source: { kind: "retained-evidence", evidence_id: loopback.evidence_id }, + output_directory, + name: "grafana", + template_rules: ["opaque-token", "opaque-token"], + }, + }); + expect(parseMcpToolError(repeated).error).toMatchObject({ + code: "invalid_request", + }); + await expect(access(output_directory)).rejects.toMatchObject({ + code: "ENOENT", + }); +}); diff --git a/tests/boundary/process/browserRequestVerifierOwnership.test.ts b/tests/boundary/process/browserRequestVerifierOwnership.test.ts new file mode 100644 index 0000000000..12bf0c9136 --- /dev/null +++ b/tests/boundary/process/browserRequestVerifierOwnership.test.ts @@ -0,0 +1,158 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { liveProcesses } from "../../../src/process/ProcessOwnershipProcessTree.js"; +import { systemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { processTest } from "../../support/process/processFixture.js"; + +// Records once if the lane's root disappears while this stand-in browser still runs. +const browserSource = ` +import { appendFileSync, existsSync, readdirSync } from "node:fs"; +const rootExists = () => readdirSync(process.env.OWNER_TMP).some((name) => name.startsWith("rea-request-e2e-")); +let seen = false; +setInterval(() => { + if (existsSync(process.env.OWNER_RELEASE)) process.exit(0); + if (rootExists()) seen = true; + else if (seen) { + appendFileSync(process.env.OWNER_EVENTS, JSON.stringify({ kind: "browser-outlived-root" }) + "\\n"); + seen = false; + } +}, 10); +setTimeout(() => process.exit(0), 20000); +process.send({ pid: process.pid }); +`; + +// Launches its browser in a new process group, as Playwright does, then overruns the +// lane's output budget while still running. +const cliSource = ` +import { spawn } from "node:child_process"; +import { writeFileSync } from "node:fs"; +const browser = spawn(process.execPath, [process.env.OWNER_BROWSER_ENTRY], { + detached: true, + stdio: ["ignore", "ignore", "ignore", "ipc"], +}); +const ready = await new Promise((resolve, reject) => { + browser.once("message", resolve); + browser.once("error", reject); +}); +browser.disconnect(); +writeFileSync(process.env.OWNER_READY, JSON.stringify({ pids: [ready.pid] })); +const chunk = Buffer.alloc(1024 * 1024, 120); +for (;;) await new Promise((resolve) => process.stdout.write(chunk, resolve)); +`; + +const reportSchema = z.object({ + pids: z.array(z.number().int().positive()).length(1), + live: z.array(z.number().int().positive()), + roots: z.array(z.string()), + failure: z.object({ name: z.string(), message: z.string() }), +}); + +const releaseBrowser = async (release: string, ready: string) => { + await writeFile(release, "release the stand-in browser"); + const pids: readonly number[] = await readFile(ready, "utf8").then( + (text) => reportSchema.shape.pids.parse(JSON.parse(text).pids), + (cause: unknown) => { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + return []; + throw cause; + }, + ); + const deadline = Date.now() + 7_500; + while ( + liveProcesses(await systemProcessOwnershipHost.listProcesses()).some( + ({ pid }) => pids.includes(pid), + ) + ) { + if (Date.now() >= deadline) + throw new Error("The stand-in browser did not consume its release"); + await delay(25); + } +}; + +// The stand-in CLI and browser are authored; the lane, its owner, the private root +// and process cleanup are real. No browser capture is simulated or claimed. +processTest.skipIf(process.platform === "win32")( + "stops a failed capture's CLI and the browser it launched before removing the request lane's root", + { timeout: 40_000 }, + async ({ processes }) => { + const root = await createTestTempDirectory("rea-browser-request-owner-"); + const tmp = join(root, "tmp"); + await mkdir(tmp); + const cli = join(root, "cli.mjs"); + const browser = join(root, "browser.mjs"); + const ready = join(root, "ready.json"); + const release = join(root, "release-browser"); + const events = join(root, "events.jsonl"); + const driver = join(root, "driver.mjs"); + await writeFile(cli, cliSource); + await writeFile(browser, browserSource); + const lane = pathToFileURL( + resolve("scripts/lib/browser-request-e2e.mjs"), + ).href; + const observations = pathToFileURL( + resolve("dist/process/ProcessOwnershipObservation.js"), + ).href; + const tree = pathToFileURL( + resolve("dist/process/ProcessOwnershipProcessTree.js"), + ).href; + await writeFile( + driver, + ` +import { readFile, readdir } from "node:fs/promises"; +import { verifyBrowserRequestSteps } from ${JSON.stringify(lane)}; +import { systemProcessOwnershipHost } from ${JSON.stringify(observations)}; +import { liveProcesses } from ${JSON.stringify(tree)}; +let failure; +try { + await verifyBrowserRequestSteps("/rea-unlaunched-browser", process.env.OWNER_CLI); +} catch (cause) { + failure = { name: cause?.name ?? typeof cause, message: cause?.message ?? String(cause) }; +} +const { pids } = JSON.parse(await readFile(process.env.OWNER_READY, "utf8")); +const live = liveProcesses(await systemProcessOwnershipHost.listProcesses()).filter(({ pid }) => pids.includes(pid)).map(({ pid }) => pid); +const roots = (await readdir(process.env.OWNER_TMP)).filter((name) => name.startsWith("rea-request-e2e-")); +process.stdout.write(JSON.stringify({ pids, live, roots, failure })); +`, + ); + try { + const result = await processes.run(process.execPath, [driver], { + timeoutMs: 30_000, + env: { + ...process.env, + TMPDIR: tmp, + OWNER_TMP: tmp, + OWNER_CLI: cli, + OWNER_BROWSER_ENTRY: browser, + OWNER_READY: ready, + OWNER_RELEASE: release, + OWNER_EVENTS: events, + }, + }); + expect(result.exitCode, result.stderr).toBe(0); + const report = reportSchema.parse(JSON.parse(result.stdout)); + expect(report.live).toEqual([]); + const receipts = await readFile(events, "utf8").catch( + (cause: unknown) => { + if ( + cause instanceof Error && + "code" in cause && + cause.code === "ENOENT" + ) + return ""; + throw cause; + }, + ); + expect(receipts).toBe(""); + expect(report.roots).toEqual([]); + expect(report.failure.message).toMatch(/output exceeded/u); + } finally { + await releaseBrowser(release, ready); + } + }, +); diff --git a/tests/fixtures/browserScenarioCapture.ts b/tests/fixtures/browserScenarioCapture.ts index 3a9067671a..c5dfb24d56 100644 --- a/tests/fixtures/browserScenarioCapture.ts +++ b/tests/fixtures/browserScenarioCapture.ts @@ -27,8 +27,28 @@ export interface ScenarioExchange { readonly body?: string | Buffer; readonly requestMediaType?: string; readonly requestBody?: string; + /** Header values as `header_values` retains them; a null value is one the capture excluded. */ + readonly requestHeaders?: readonly ScenarioHeader[]; + readonly responseHeaders?: readonly ScenarioHeader[]; } +export interface ScenarioHeader { + readonly name: string; + readonly value: string | null; +} + +const retainedHeaders = (headers: readonly ScenarioHeader[] | undefined) => + headers === undefined + ? { state: "not_requested" as const } + : { + state: "captured" as const, + items: headers.map(({ name, value }) => ({ + name, + value, + redacted: value === null, + })), + }; + 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. */ @@ -131,7 +151,7 @@ const exchangeEvents = ( sequence: number, source: number, phase: "request" | "response", - body: ReturnType, + body: ReturnType | { readonly state: "not_requested" }, ) => ({ sequence, step_index: 1, @@ -139,7 +159,9 @@ const exchangeEvents = ( transaction_id: transaction, source_event_sequence: source, phase, - headers: { state: "not_requested" as const }, + headers: retainedHeaders( + phase === "request" ? exchange.requestHeaders : exchange.responseHeaders, + ), body, }); const events: unknown[] = [ @@ -152,17 +174,22 @@ const exchangeEvents = ( }, ]; let next = first + 1; - if (exchange.requestBody !== undefined) { + if ( + exchange.requestBody !== undefined || + exchange.requestHeaders !== undefined + ) { events.push( content( next, first, "request", - capturedBody( - exchange.requestBody, - "browser-exposed-request-bytes", - exchange.requestMediaType ?? null, - ), + exchange.requestBody === undefined + ? { state: "not_requested" } + : capturedBody( + exchange.requestBody, + "browser-exposed-request-bytes", + exchange.requestMediaType ?? null, + ), ), ); next += 1; @@ -186,17 +213,19 @@ const exchangeEvents = ( failure: null, }); next += 1; - if (exchange.body !== undefined) { + if (exchange.body !== undefined || exchange.responseHeaders !== undefined) { events.push( content( next, response, "response", - capturedBody( - exchange.body, - "playwright-response-body-bytes", - exchange.mediaType ?? null, - ), + exchange.body === undefined + ? { state: "not_requested" } + : capturedBody( + exchange.body, + "playwright-response-body-bytes", + exchange.mediaType ?? null, + ), ), ); next += 1; diff --git a/tests/process-global/environmentBuildHeadroom.test.ts b/tests/process-global/environmentBuildHeadroom.test.ts new file mode 100644 index 0000000000..cf6c65dc3c --- /dev/null +++ b/tests/process-global/environmentBuildHeadroom.test.ts @@ -0,0 +1,119 @@ +import { readdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import * as v8 from "node:v8"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + DEFAULT_BUILD_TEMPLATE_RULES, + buildEnvironment, +} from "../../src/application/environment/environmentBuild.js"; +import { evidenceFileBytes } from "../../src/application/environment/environmentEvidenceBytes.js"; +import { readEnvironmentExportFile } from "../../src/application/environment/environmentExport.js"; +import { createCli } from "../../src/cli.js"; +import { + analysisViewCaptureEvidence, + environmentManifestCaptureFixture, +} from "../fixtures/analysisView.js"; +import { environmentManifestHar } from "../fixtures/historicalHar.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +vi.mock("node:v8", { spy: true }); + +// The HAR decoder runs as a compiled child process; inspection returns the capture Evidence it decodes. +vi.mock("../../src/composition/webNetworkCaptures.js", () => ({ + createWebNetworkCaptureService: () => ({ + inspect: async () => ({ + ok: true, + value: analysisViewCaptureEvidence(environmentManifestCaptureFixture()), + }), + close: async () => {}, + }), +})); + +/** Every later heap reading reports no headroom. */ +const starveHeap = (): void => { + const heap = v8.getHeapStatistics(); + vi.mocked(v8.getHeapStatistics).mockImplementation(() => ({ + ...heap, + total_available_size: 0, + })); +}; + +afterEach(() => { + vi.mocked(v8.getHeapStatistics).mockReset(); +}); + +describe("environment build heap headroom", () => { + it("refuses to serialize the manifest the live heap cannot hold, before writing", async () => { + const root = await createTestTempDirectory("rea-environment-build-heap-"); + const path = join(root, "grafana.capture.json"); + await writeFile( + path, + evidenceFileBytes( + analysisViewCaptureEvidence(environmentManifestCaptureFixture()), + ), + ); + const capture = await readEnvironmentExportFile( + "evidence", + path, + "environment-build", + ); + if (!capture.ok) throw capture.error; + starveHeap(); + const built = await buildEnvironment({ + capture: capture.value, + captureInput: "evidence", + capturePath: path, + templateRules: DEFAULT_BUILD_TEMPLATE_RULES, + directory: join(root, "built"), + dryRun: false, + }); + expect(built).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisResourceConstraintError", + operation: "environment-build", + resource: "memory", + reportedLimits: { + input_path: "manifest.json", + input_file_bytes: expect.any(Number), + available_heap_bytes: 0, + }, + }, + }); + expect(await readdir(root)).toEqual(["grafana.capture.json"]); + }); + + it("refuses to serialize an inspected HAR's Evidence the live heap cannot hold, before writing", async () => { + const root = await createTestTempDirectory("rea-environment-build-heap-"); + const har = join(root, "grafana.har"); + await writeFile(har, JSON.stringify(environmentManifestHar())); + const exitCode = process.exitCode; + starveHeap(); + let stdout = ""; + try { + await createCli({ REA_LOG_LEVEL: "silent" }).serve( + ["environment-build", har, join(root, "built"), "--json"], + { + env: {}, + exit: () => {}, + stdout: (text) => { + stdout += text; + }, + }, + ); + } finally { + process.exitCode = exitCode; + } + expect(JSON.parse(stdout)).toMatchObject({ + code: "resource_constraint", + details: { + operation: "environment-build", + resource: "memory", + reported_limits: { input_path: har, available_heap_bytes: 0 }, + }, + }); + expect(await readdir(root)).toEqual(["grafana.har"]); + }); +}); diff --git a/tests/process-global/environmentBundleMemory.test.ts b/tests/process-global/environmentBundleMemory.test.ts new file mode 100644 index 0000000000..5eb2b03f93 --- /dev/null +++ b/tests/process-global/environmentBundleMemory.test.ts @@ -0,0 +1,48 @@ +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { getHeapStatistics } from "node:v8"; + +import { expect, it, vi } from "vitest"; + +import { exportEnvironmentBundleTool } from "../../src/application/environment/environmentBundleTool.js"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { + analysisViewCaptureEvidence, + environmentManifestCaptureFixture, +} from "../fixtures/analysisView.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +vi.mock("node:v8", { spy: true }); + +it("refuses retained export before allocation when its expanded copies cannot fit", async () => { + const capture = analysisViewCaptureEvidence( + environmentManifestCaptureFixture(), + ); + const root = await createTestTempDirectory("rea-retained-export-memory-"); + const statistics = getHeapStatistics(); + vi.mocked(getHeapStatistics).mockReturnValue({ + ...statistics, + total_available_size: 1, + }); + const allocation = vi.spyOn(Buffer, "allocUnsafe"); + try { + const result = await exportEnvironmentBundleTool( + { + source: { kind: "retained-evidence", evidence_id: capture.evidence_id }, + output_directory: join(root, "bundle"), + dry_run: false, + }, + () => capture, + ); + if (result.ok) throw new Error("Expected a memory refusal"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "resource_constraint", + details: { operation: "export_environment_bundle", resource: "memory" }, + }); + expect(allocation).not.toHaveBeenCalled(); + expect(await readdir(root)).toEqual([]); + } finally { + allocation.mockRestore(); + vi.mocked(getHeapStatistics).mockReset(); + } +}); diff --git a/tests/process-global/environmentExportMemory.test.ts b/tests/process-global/environmentExportMemory.test.ts new file mode 100644 index 0000000000..cae4f99a05 --- /dev/null +++ b/tests/process-global/environmentExportMemory.test.ts @@ -0,0 +1,57 @@ +import { getHeapStatistics } from "node:v8"; + +import { expect, it, vi } from "vitest"; + +import { evidenceFileBytes } from "../../src/application/environment/environmentEvidenceBytes.js"; +import { + environmentExportEvidenceFile, + environmentExportFile, +} from "../../src/application/environment/environmentExport.js"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { + analysisViewCaptureEvidence, + environmentManifestCaptureFixture, +} from "../fixtures/analysisView.js"; + +vi.mock("node:v8", { spy: true }); + +it.each(["serialize", "parse"] as const)( + "refuses in-memory export before %s when its copies cannot fit", + async (stage) => { + const capture = analysisViewCaptureEvidence( + environmentManifestCaptureFixture(), + ); + const bytes = evidenceFileBytes(capture); + const statistics = getHeapStatistics(); + const allocate = vi.spyOn(Buffer, "allocUnsafe"); + vi.mocked(getHeapStatistics).mockReturnValue({ + ...statistics, + total_available_size: 1, + }); + try { + const result = + stage === "serialize" + ? await environmentExportEvidenceFile( + "evidence", + "capture.json", + capture, + "environment-build", + ) + : await environmentExportFile( + "evidence", + "capture.json", + bytes, + "environment-build", + ); + if (result.ok) throw new Error("Expected a memory refusal"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "resource_constraint", + details: { operation: "environment-build", resource: "memory" }, + }); + expect(allocate).not.toHaveBeenCalled(); + } finally { + vi.mocked(getHeapStatistics).mockReset(); + allocate.mockRestore(); + } + }, +);