diff --git a/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md b/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md index b266299dce..e4daa97024 100644 --- a/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md +++ b/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md @@ -94,3 +94,9 @@ extract the data those reads observed instead: entity types and identity fields, entities with recorded values, documents, and per-tool render plans checked against the recordings. The state comes only from reads before the first related write; types, identity and list membership are inferences. +`rea environment-serve manifest.json --evidence capture.json` derives that +state at startup, answers read tools from it and serves `data/reset`, +`data/add` and `data/get` on `/agentenv`; write tools are not served. 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. diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 5ebaf7f4a3..ac69321454 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -1,11 +1,15 @@ # 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 the records of one retained -web network capture, plus the environment card the agentenv framework reads to -discover it. Nothing is fetched from the recorded application; every answer is -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). +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; `--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). ```sh rea inspect-web-network-capture /absolute/session.har har --format json > capture.json @@ -27,9 +31,10 @@ it, where `view.json` wraps the capture Evidence as the inline source: } ``` -The third command serves that manifest. Both served files are complete REA -Evidence JSON as the commands write it with `--format json`; the `evidence` -member of `view.json` is the whole of `capture.json`. +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`. ## From a browser scenario @@ -110,7 +115,9 @@ record's ordinal is its zero-based position; a transaction whose URL carried a redacted secret is left unrepresented rather than templated, and one the capture cannot attribute to a single request and response is left unrepresented too. Tools are chosen by response media type, so a scenario that -selected neither response bodies nor header values is refused. The browser +selected neither response bodies nor header values is refused; one that +selected header values but no response bodies yields a manifest but no state, +so it is served by replay. The browser scenario redacts credential headers and the secrets it declares; values the application returns in response bodies, such as session tokens, are kept, and the replayed body is the bytes the browser exposes for each response, with any @@ -144,10 +151,16 @@ This was calibrated with 1500 query fields and eight concurrent SDK discovery requests (183,798-byte card, 507.27 MiB sampled process-family RSS). Over-capacity catalogs return `resource_constraint`, retain complete input Evidence, and require a smaller capture or manifest. Fields and tools are never silently omitted. +Served from the state, each description is longer and the card declares the +data plane, so a catalog replay admits can exceed the reservation; the state +is then unavailable and the server serves replay instead (see +[Serve from the state](#serve-from-the-state)). ## What replay means and does not mean -Each MCP tool call selects one of the records its manifest tool cites and +Replay answers every tool with `--replay-only` or when the state is unavailable, +and, served from the state, each read tool whose view the state does not back. +Each such MCP tool call selects one of the records its manifest tool cites and returns that record's recorded response. Selection compares the supplied path parameters against the record's path segments at the parameter indexes, then the supplied query values against the record's query string, and keeps the @@ -202,7 +215,8 @@ degrades into an omitted one. Replay is not emulation. A served environment does not establish the application's authorization model, response semantics or state transitions, which the manifest lists as unknowns; it does not validate supplied values; and -it does not answer requests the capture never recorded. Replayed content is +it answers only from the capture's recordings and the state they observed or a +caller loaded through `data/add`. Replayed content is target content: every tool result, including the argument errors the MCP server returns before a replay runs, carries `_meta.provenance.untrusted_content: true` with `source: "analysis_target"`. The server advertises REA's untrusted-content @@ -222,15 +236,20 @@ instead the data the capture's reads observed before the first related write: entity types with an identity field, entities with their recorded values, documents for written paths, and a render plan per read tool, kept only where it reproduces that tool's recordings from the state. It is the starting state -an environment can hold, load and report; serving from it is a later increment -and does not change replay. +an environment holds, loads and reports: `environment-serve` derives it from +the capture at startup; see [Serve from the state](#serve-from-the-state). +With `--replay-only`, replay is unchanged. ## Serve a manifest ```sh -rea environment-serve manifest.json --evidence capture.json [--host 127.0.0.1] [--port 0] +rea environment-serve manifest.json --evidence capture.json [--replay-only] [--host 127.0.0.1] [--port 0] ``` +The server derives the state from the capture before it binds and serves from +it, or serves replay with `--replay-only` or when the state is unavailable; +see [Serve from the state](#serve-from-the-state). + `--host` and `--port` default to `MCP_HOST` and `MCP_PORT` from the environment, then to `127.0.0.1` and an ephemeral port, exactly as [`rea mcp --http`](cli.md#serve-mcp-over-streamable-http) resolves them. The @@ -242,7 +261,8 @@ manifest's `name` is used, and a set but blank value is refused as listener serves one MCP client at a time with the same session, `Host` header and shutdown contract as [transports](mcp-contracts.md#transports) describes. It runs until SIGINT or SIGTERM, then closes the listener, prints one -`{"status":"stopped", …}` document and exits `130` or `143`. +`{"status":"stopped", …}` document whose `state` member says whether it served +from the state, and exits `130` or `143`. Signals during input loading or DNS/bind cancel the owning reader/listener before a listening announcement; that path returns a structured `cancelled` result instead of a stopped-listener @@ -250,19 +270,19 @@ document, with the same signal exit status. Three routes are served: -| Route | Answer | -| --------------------------------- | ---------------------------------------------------------------------- | -| `POST /mcp` | The MCP server: one tool per manifest tool, replaying retained records | -| `GET /.well-known/agent-env.json` | The environment card | -| `POST /agentenv` | JSON-RPC `-32601 method not found: ` for every method | +| Route | Answer | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST /mcp` | The MCP server: from the state, one tool per read tool, answering from the state where it backs them; in replay, one tool per manifest tool, replaying records | +| `GET /.well-known/agent-env.json` | The environment card | +| `POST /agentenv` | From the state, `data/reset`, `data/add` and `data/get`; in replay, JSON-RPC `-32601 method not found: ` for every method | Numeric JSON-RPC IDs outside JavaScript's safe range or whose precision would be lost are refused as invalid requests with a null ID. They are never echoed as a different number. -The data plane at `/agentenv` is advertised by the card but carries no -operations in this release; the error names the requested method so a caller -can see that the method, not the transport, is unsupported. Its request body is +In replay the data plane at `/agentenv` is advertised by the card but carries +no operations; the error names the requested method so a caller can see that +the method, not the transport, is unsupported. Its request body is read within the same 4 MiB bound the MCP endpoint applies: a larger body is answered `413` with a JSON-RPC `-32000` error before any of it is parsed, and a body that is not JSON is answered `400` with `-32700`. A notification (no `id`) @@ -274,7 +294,7 @@ that are not an object or array) gets `-32600 invalid request`. ## The environment card The card is the discovery document the agentenv framework reads before -connecting to `/mcp`: +connecting to `/mcp`. A replay environment's card reads: ```json { @@ -320,9 +340,205 @@ advertised as `query_`, a query name longer than 64 characters as and the write body as `request_body` when a query parameter is named `body`. Every schema closes with `additionalProperties: false`, a key the echo example's schema does not carry. -`extensions`, `operations` and `children_environments` are fixed to `null`, -`[]` and `null`: a replay environment declares no extensions, no data-plane -operations and no children. +In replay, `extensions`, `operations` and `children_environments` are fixed to +`null`, `[]` and `null`: a replay environment declares no extensions, no +data-plane operations and no children. Served +[from the state](#serve-from-the-state), the card declares the three data-plane +operations and one intake extension instead, leaves write tools out and +describes each tool as answering from the state where the state backs it. + +## Serve from the state + +```sh +rea environment-serve manifest.json --evidence capture.json +``` + +Before it binds, the server derives the +[environment state](web-network-captures.md#environment-state) the capture +observed: it runs the `environment-state` view on the capture in-process with +the manifest's `template_rules` in the same order, exactly as +`inspect-analysis-view` projects it from the same capture, so the state +Evidence id it reports is the one that projection produces. The view's own +budget and refusals apply, and the result is checked like the view's Evidence +for this manifest: extracted from the served capture, with the manifest's +rules, naming only manifest tools. The server keeps one live state, shared by +every MCP session and the data plane, starting from the derived state. + +A state that is unavailable does not stop the server: it logs a warning naming +the error `code` and message before it binds, then serves replay. The state is +unavailable when it cannot be derived, such as from a +`capture_browser_scenario` capture that selected header values but no response +bodies or over the view's budget, and when its catalog under the served name +exceeds the [discovery reservation](#what-a-manifest-is). That check runs +first, so no state is derived for it, and replay is then admitted exactly as +with `--replay-only`. `--replay-only` +serves replay without deriving anything; the card, tools and `/agentenv` +answers are then exactly those of replay. The stopped document's `state` +member reports which applied: + +- `{"served": true, "evidence_id": "ev_…"}`: served from the derived state. +- `{"served": false, "reason": "replay-only"}`: `--replay-only` was given. +- `{"served": false, "reason": "unavailable", "code": "unsupported_target", "message": "…"}`: + the state is unavailable; `code` and `message` are those of the refusal + (`resource_constraint` for a catalog over the reservation), as the warning + logged them. + +Each read tool answers according to its view in the state: + +| View | Answer | +| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `item` | The entity the path arguments name, rendered into the recorded envelope of `envelope_ordinal`. Arguments match as replay matches them: each collection segment equals the held collection's segment as written or after percent-decoding, and the identity is the last argument percent-decoded, then as written; values are never re-split on `/`. Any entity the state holds answers, including one `data/add` loaded; query arguments only decide `replay.exact_match`. An entity the state does not hold, or one lacking a field every recording carried, is a tool error; a body is never invented. | +| `list` | When the arguments name one of the lines the view checked exactly (its latest recordings before the first related write; every path and query value equal to the recording's, and every recorded query parameter supplied), that recorded envelope with its array rendered from the state: every entity of the collection in state order for `membership: "all"`, or the recorded members still held. A member lacking a field every recording carried makes the call a tool error rather than a partial list. | +| `document` | When the arguments name the document's checked line exactly, the state's value for it; a tool error when the state holds none. | +| `recorded` | The recording, as replay answers it. | + +A list or document call that names no checked line, including a line recorded +after a write or with other query values, answers from the recording. Every +answer has the replay shape plus `state`: `{"source": "state", "view": …}` +naming the entity, collection or document path, or `{"source": "recording", +"reason": …}` with the view's reason, `no-view` or `unrecorded-request`. A +state answer carries the base record's status, content type and `replay` +provenance with `truncated: false`; one whose JSON would exceed 256 KiB is +refused as a `resource_constraint` tool error rather than cut. Serving is +read-only: `POST`, `PUT`, `PATCH` and `DELETE` tools, other than query `POST`s, +are not served, so nothing a call does changes the state. Query `POST`s stay +served and answer from their recordings with the reason `query-body`. + +The data plane serves three JSON-RPC methods: + +| Method | Result | +| ------------ | --------------------------------------------------------------------------------------------------------- | +| `data/reset` | `{}` after restoring the state the Evidence holds | +| `data/add` | `{}` after replacing the whole state with one valid `rea-state/v1` document | +| `data/get` | `{"parts": [{"kind": "data", "data": }]}` with `state_evidence_id` and the `types` | + +Calls run one at a time in arrival order across requests. Every message of a +batch is queued when the batch arrives, so another request's call never lands +between them and each message sees the changes of those before it; a reset +sent while a download is loading applies after that load. `data/add` +takes exactly one part: a `data` part whose `data` is the document, or a +`file` part whose `file` carries `bytes` (base64 of UTF-8 JSON) or a `uri`, +either a `file://` URL of a local regular file, whose host is empty or +`localhost`, or an `https://` URL fetched without following redirects and +within 30 seconds, including reading the body; `mimeType`, when given, is +`application/json`. A `file://` URL naming another host, or a UNC path such as +`file:////server/share/state.json`, is refused before anything is opened, so a +load never reaches a network share. A loaded document is at most 32 +MiB, and so is the state as `data/get` writes it back with the members a +document may omit (`types`, `state_evidence_id`, record citations), so a +reported state always loads again; an inline part also stays within the 4 MiB +request body bound, so larger states arrive by `uri`. Anything else, including `{"parts": []}`, is `-32602 +invalid params`; a download that fails or answers a non-2xx status is +`-32000`. Errors never echo the uri or the content; a file or download failure +names its category in `error.data.reason`: `not-found`, `permission`, +`not-regular-file`, `changed` or `read-failed` for files, `timeout`, +`http-status` or `network` (connection, TLS or a refused redirect) for +downloads, and `cancelled` for a download the server's stop or its caller's +departure ended. + +Stopping the server cancels every pending call, and a caller that leaves before +its reply cancels its own: a download in progress ends, and calls not yet run +are answered `-32000` with `error.data.reason` `cancelled` without running. A +cancelled call leaves the state unchanged, and shutdown never waits for a +download. + +A `file://` state file whose close fails stays owned by the server, and +the `data/add` gets `-32000`. The next `file://` load retries that close first +and, while it still fails, answers `-32000` without reading another file; +inline and `https://` documents still load. Closing the server retries it once +more and fails with the unfinished close rather than dropping the descriptor. + +One `/agentenv` response carries at most 64 MiB of replies, twice the 32 MiB +a loaded document may hold: room for a `data/get` of a loaded state, while a +batch of reads cannot multiply a large state into one body. Replies are +measured in order as they are produced, in bounded parts and before they are +assembled, and room is kept for every later reply's budget error and the +batch's brackets and commas, so one that would take the response past the +budget is never built whole and is answered instead with `-32000` whose `error.data` gives +`max_response_bytes` and the `reply_bytes` it would have taken. That call still +ran, so repeat such a read in another request. A reply no larger than that +error, such as a write's `{}`, is always kept. + +```json +{ + "rea_state": { + "version": 1, + "entities": [ + { + "type": "/api/users", + "collection": "/api/users", + "id": "2", + "fields": { "id": 2, "login": "probe" } + } + ], + "documents": [] + } +} +``` + +Each entity's `type` is a state type's `collection_template`, its `collection` +matches that template segment by segment (a template parameter matches any one +segment), its identity field's value is its `id`, and its type, collection and +id are unique; `view_fields` is optional and names item or list views of its +type. Views render their values over the entity's fields, so a view's values +must not set the type's identity field to a value whose text differs from the +entity's `id`. Spell a collection as requests spell it, as `data/get` reports it: list +calls find entities under the checked line's recorded path. Each document +names a `document` view once, at its initial document's `path`. The `version`, +and the `state_evidence_id` and `types` that `data/get` reports, must match +the served state, so a `data/get` result loads back unchanged. A number without +an exact native value, or whose serialized spelling would not be read as exact +(an integer from 2^53 below 1e21, such as `1e20`, in any spelling), or a +`__proto__` member is refused rather than altered. +An invalid document is `-32602` naming the first problem by JSON pointer, as in +`/rea_state/entities/3/collection` (also in `error.data.path`); a problem +inside caller data (`fields`, `value`, `types`) is located at that member, so +the pointer never names the caller's keys. A refused document changes nothing; +a valid one replaces the whole state at once. The order of `entities` is the +state order lists use. + +Record citations stay Evidence provenance. An entity's `record_ordinals` may +be omitted or empty and a document's `record_ordinal` omitted; otherwise they +must be the served Evidence's citations for that entity or document, or the +document is refused. After loading, an entity or document whose values equal the Evidence's cites +the Evidence's records, and one whose values were edited or added cites none +(`record_ordinals: []`, no `record_ordinal`), so `data/get` never presents a +loaded value as recorded. + +The card declares the operations and what `data/add` accepts: + +```json +{ + "extensions": [ + { + "uri": "urn:agentenv:intake/v1", + "description": "data/add replaces the whole environment state with one rea-state/v1 document; data/get returns the current state in the same format.", + "params": { + "add": [ + { "part": "data", "format": "rea-state/v1", "load": "replace" }, + { + "part": "file", + "format": "rea-state/v1", + "mimeTypes": ["application/json"], + "load": "replace" + } + ], + "get": [{ "part": "data", "format": "rea-state/v1" }] + } + } + ], + "operations": ["data/reset", "data/add", "data/get"] +} +``` + +The state is a projection of recorded reads. List and document calls answer +from the state only for the lines their views checked, and every recorded view +answers from recordings; item calls answer for any held entity but render only +the envelope one recording carried. Values copied outside entity objects, +derived values and counters other than those that count a list stay as +recorded; no write has an effect. An exported bundle carries no state file: +its server derives the state at startup, as the export does to predict what it +serves. ## Export a bundle @@ -336,12 +552,12 @@ agent-env run ./grafana-example ```text grafana-example/ - README.md provenance, replay limits, run command, rights note + README.md provenance, what it serves, run command, rights note envs/grafana-example/Dockerfile pinned image: node:24.18.0-bookworm-slim + rea-agents@ envs/grafana-example/env.toml environment_name = "grafana-example" envs/grafana-example/manifest.json byte-exact copy of manifest.json envs/grafana-example/evidence.json byte-exact copy of capture.json - artifacts/check_grafana-example/verify.py the replay verifier + artifacts/check_grafana-example/verify.py the verifier tasks/smoke.json deploy_env (local sandbox), then env_outcome_verifier ``` @@ -352,8 +568,12 @@ pair as `environment-serve` does, so a manifest projected from another capture is refused with the same `invalid_request` before anything is written, and the bytes it copies are the bytes it authenticated. The bundle directory must be absent or empty; an occupied directory is refused and nothing in it changes. -The complete tool catalog must also fit the server's advertised catalog budget -before any files are written. Privately parsed JSON is sealed once so authentication +After checking the bundle name, the export prepares the state from the capture +exactly as the bundle's server will at startup under that name, to predict what +the bundle serves; an unavailable state, including one whose catalog exceeds +the discovery reservation, does not stop the export, and the bundle then +serves replay. The complete tool catalog the bundle serves must also fit the +server's advertised catalog budget before any files are written. Privately parsed JSON is sealed once so authentication and replay share immutable response trees rather than copying the capture repeatedly. `--name` replaces the manifest's portable kebab-case name as the env folder and env id (Windows device names such as `con` and `lpt1` are refused); @@ -361,7 +581,9 @@ and replay share immutable response trees rather than copying the capture repeat folder and makes the Dockerfile install it instead of the registry package and remove it in the same layer, which is how a checkout rather than a release is run. The command prints the -written files with their sizes and the pinned package source. +written files with their sizes, the pinned package source, the number of tools +the bundle serves as `tool_count`, and in `state` whether it serves from the +state, in the shape of the serve command's stopped document. Export uses the shared output-tree owner to check directory identities around exclusive creates, verify written bytes, and reject unowned entries before @@ -383,21 +605,31 @@ user, so a private host umask cannot leave them unreadable, runs as that user an name from Python sources the image does not have. `verify.py` is the framework's `async def verify(mcp_url)` shape over its -Python MCP client: it lists the tools and checks the count equals the -manifest's, calls the first `GET` tool without path parameters with no -arguments and checks the call did not fail and its replayed `status` equals the -recorded status of that tool's first cited record (an unknown recorded status -stays unknown; it is not borrowed from another record), and returns one -`replay` result whose description -reports both checks. A manifest with no such tool yields a verifier that checks -the count only and says so. `tasks/smoke.json` deploys the env on the local +Python MCP client: it lists the tools and checks the count equals the number +the bundle serves (the manifest's read tools from the state, every manifest +tool in replay), calls one tool with no arguments and checks the call did not +fail and its `status` is the expected one, and returns one `serve` result +whose description reports both checks. The tool is the first served `GET` tool +without path parameters whose answer to no arguments is not an error, and the +expected status is the status of that answer: the export calls the same answer +function the server uses, from the derived state or by replay, so a state +answer carries its base record's status and a replayed one its record's (an +unknown recorded status stays unknown; it is not borrowed from another +record). A bundle with no such tool yields a verifier that checks the count +only and says so. `tasks/smoke.json` deploys the env on the local sandbox and runs that verifier against the env's MCP endpoint; the first run also builds the framework's own infrastructure, and the run tears everything down when it ends. The README written into the bundle records the capture artifact's SHA-256, the -capture and manifest Evidence ids, the manifest view digest, the tool count and -the package source, and states that the recorded content belongs to the +capture and manifest Evidence ids, the manifest view digest, the served tool +count, the derived state's Evidence id or that no state is served, and the +package source. It says what the environment serves: read tools from the state +the capture observed, the `/agentenv` data plane on it and no write tools yet, +replay when the state is unavailable, and `--replay-only` for pure replay; it +says which applies to this bundle, with the error code and message when the +state is unavailable, and that neither is emulation and served +content is untrusted data. It states that the recorded content belongs to the captured application and must not be published without the rights to it. ## Compatibility verification @@ -414,13 +646,21 @@ framework versions. ## Resource and lifecycle boundaries -Both Evidence files are read once at startup through the bounded JSON input +The Evidence files are read once at startup through the bounded JSON input reader and authenticated as Evidence before the listener binds; a file that is not Evidence, a manifest of another view kind, a capture of another format, or a manifest projected from a different capture is refused before any port is -opened. Because authentication covers the capture's records, a manifest whose +opened. The state is derived once, in-process and before binding, from the +already authenticated capture: it is projected as retained Evidence, so the +capture's strings are not charged again as inline view input, and the view's +96 MiB budget bounds the derivation. Because authentication covers the capture's records, a manifest whose parent check passes cites only records the capture retains. The server keeps -only the cited records' request lines and response text in memory. No outbound -network request is made, no process is launched and nothing is written to -disk. A listener that fails after binding is logged and ends the command with +only the cited records' request lines and response text in memory, plus the +live state when it serves from one: the derived initial state, or one +validated document whose JSON was at most 32 MiB. Data-plane calls run one at a time, so +at most one loaded document is being read and parsed at once; `data/get` +serializes the held state whole. No process is launched and nothing is +written to disk. No outbound network request is made unless a `data/add` call names an +`https://` uri, which is fetched once within the limits above; a `file://` uri +is read once. A listener that fails after binding is logged and ends the command with a `resource_constraint` error and exit `1` instead of serving nothing. diff --git a/docs/cli.md b/docs/cli.md index 1242e92422..e291589bb1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -390,14 +390,25 @@ entry keep stdio. ## Serve an environment manifest -`rea environment-serve --evidence [--host H] -[--port N]` serves an environment manifest projected by `inspect-analysis-view` -as an agent environment: an MCP server at `/mcp` with one tool per manifest -tool, each replaying the retained capture records it cites, the environment -card at `/.well-known/agent-env.json`, and a `/agentenv` data plane that -answers every method with JSON-RPC `-32601`. The manifest's -`parent_evidence_id` must equal the evidence file's `evidence_id`; otherwise -the command refuses with `invalid_request` before binding. +`rea environment-serve --evidence +[--replay-only] [--host H] [--port N]` serves an environment manifest +projected by `inspect-analysis-view` as an agent environment: an MCP server at +`/mcp`, the environment card at `/.well-known/agent-env.json`, and a +`/agentenv` data plane. The manifest's `parent_evidence_id` must equal the +evidence file's `evidence_id`; otherwise the command refuses with +`invalid_request` before binding. Before binding it derives the +`environment-state` view of the capture with the manifest's `template_rules`, +as `inspect-analysis-view` would, and serves from it: read tools whose views +are state-backed answer from a live state, write tools are not served, and +`/agentenv` serves `data/reset`, `data/add` and `data/get`. When the state +cannot be derived, or its catalog exceeds the discovery reservation, it logs a +warning with the error code and message and serves replay; `--replay-only` serves replay without deriving. In replay every +manifest tool replays the retained capture records it cites and `/agentenv` +answers every method with JSON-RPC `-32601`. The stopped document's `state` +member is `{"served": true, "evidence_id": …}`, +`{"served": false, "reason": "replay-only"}` or +`{"served": false, "reason": "unavailable", "code": …, "message": …}`; see +[serve from the state](agent-environments.md#serve-from-the-state). ```sh rea environment-serve manifest.json --evidence capture.json --port 8765 @@ -408,8 +419,8 @@ rea environment-serve manifest.json --evidence capture.json --port 8765 the served environment; set but blank, it is refused as `configuration_invalid` like a blank `MCP_HOST`. The bound URL is written to stderr as one JSON log line (`"msg":"MCP transport listening"`). See -[agent environments](agent-environments.md) for what replay returns and does -not establish. +[agent environments](agent-environments.md) for what the state and replay +return and do not establish. ## Export an environment bundle @@ -417,9 +428,11 @@ not establish. [--package-tarball ] [--name ]` writes the same manifest and capture pair as a bundle folder the agentenv framework runs with `agent-env run `. It reads each file once, authenticates and cross-checks -the pair with the same checks as `environment-serve`, copies the bytes it -authenticated, refuses to write into a directory that exists and is not empty, -and never overwrites a file. +the pair with the same checks as `environment-serve`, checks the bundle name, +then prepares the state under that name as the bundle's server will at startup +to predict what the bundle serves (replay when the state is unavailable), +copies the bytes it authenticated, refuses to write into a directory that +exists and is not empty, and never overwrites a file. ```sh rea environment-export manifest.json --evidence capture.json ./grafana-example --json @@ -429,14 +442,16 @@ The folder holds a `README.md` with the bundle's provenance, an env folder `envs//` with a `Dockerfile` that installs this exact `rea-agents` version and serves the copied `manifest.json` and `evidence.json` (written with mode `0600` on POSIX; Windows retains inherited ACLs) with -`environment-serve`, a Python verifier under `artifacts/check_/` and the -smoke task `tasks/smoke.json`. `--package-tarball` copies a packed `rea-agents` +`environment-serve` (from the state by default), a Python verifier under +`artifacts/check_/` that checks the served tool count and one served +answer, and the smoke task `tasks/smoke.json`. `--package-tarball` copies a packed `rea-agents` tarball into the env folder and installs it instead of the registry package, which is how the hosted lane proves a checkout rather than a release. `--name` replaces the manifest's kebab-case name as the env folder and env id; a device name Windows reserves (`con`, `nul`, `com1` and the like) is refused. The -command prints one JSON document listing the written files with their sizes -and the pinned package source. See +command prints one JSON document listing the written files with their sizes, +the pinned package source, the served `tool_count`, and `state` in the shape +of the serve command's stopped document. See [agent environments](agent-environments.md#export-a-bundle) for the folder layout and what the smoke task checks. @@ -488,8 +503,8 @@ SIGTERM, closes the listener, then prints one structured `{"status":"stopped", …}` document naming the URL, environment and tool count and exits `130` or `143` through the shared cancellation handler below. Refusals before binding (unreadable files, non-Evidence JSON, capture Evidence -in a format other than HAR, a manifest whose parent is not the supplied -evidence, an unbindable port) print a structured +in a format other than HAR, a manifest or state whose parent is not the +supplied evidence, an unbindable port) print a structured error and exit `1`, as does a listener that fails after binding. `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 diff --git a/docs/specs/agent-environments/state-serve/plan.md b/docs/specs/agent-environments/state-serve/plan.md new file mode 100644 index 0000000000..05f457e403 --- /dev/null +++ b/docs/specs/agent-environments/state-serve/plan.md @@ -0,0 +1,44 @@ +# Implementation plan + +1. Export the Evidence file authenticator from `environmentSource.ts` with a + `state` role, and validate a HAR capture there with the owned borrowing + parse so later views reuse it. Add `environmentState.ts` to check state + Evidence against a loaded source, build the served catalog (the tools a + state serves and the data plane its card declares), derive the state from + the capture through the analysis view service with the capture as retained + Evidence, and prepare it: unavailable when its catalog exceeds the + discovery budget under the served name or it cannot be derived. +2. Add `environmentStateDocument.ts` to validate `rea-state/v1` documents + against the served types and views, and `environmentStateStore.ts` for the + live store with reset, atomic replace and the `data/get` snapshot. +3. Export `selectsRecordedLine`, the body-taking `replayAnswer` and the path + rule `pathValueMatches` from `environmentReplay.ts`, export one recorded-body + predicate `stateBodyJson` from the state accumulator and `changesState` from + the state rules, and widen `collectionPathOf` to any value with a path, so + state answers reuse selection, matching, body and write rules. +4. Add `environmentStateAnswer.ts` for item, list and document answers, the + recorded fallback and the answer function serve and export share. +5. Declare the state data plane on the card in `environmentTools.ts`, taking + the data plane in an options object so the discovery budget reserves the + card and descriptions actually served. +6. Add `stateIntake.ts` for parts, files on this host and downloads + (injectable fetcher and timeout, cancellation, coarse failure categories) + and `environmentDataPlane.ts` for the three methods, run one at a time in + arrival order and cancelled by the server's stop or the caller's + departure; let `serveEnvironment.ts` serve the shared catalog and answer + function, queue every message of a batch at arrival, bound the replies of + one response and stop the data plane before closing the listener. +7. Make `environment-serve` prepare the state under its served name after + loading the source, add `--replay-only`, log an unavailable state as a + warning before binding, and report `state` in the stopped document. +8. Let `environment-export` prepare the state the same way under the bundle + name, only after the name checks, and plan the served + tool count, the verifier check from the shared answer function, the `serve` + verifier and the README's serving section from it; report `state` in the + export output and expect the served count in the hosted-lane script. +9. Cover services, adapters, cli-boundary, mcp-boundary and process-boundary + behaviour; document default serving, `--replay-only`, the fallback, the + answers, the data plane, the card and the export. + +No dependency, provider or REA tool contract changes. The only new network +activity is an `https://` download that a `data/add` call names. diff --git a/docs/specs/agent-environments/state-serve/spec.md b/docs/specs/agent-environments/state-serve/spec.md new file mode 100644 index 0000000000..53f830ee69 --- /dev/null +++ b/docs/specs/agent-environments/state-serve/spec.md @@ -0,0 +1,196 @@ +# Serve the environment from its state by default + +Issue: https://github.com/morluto/rea/issues/4933 + +## Outcome + +`rea environment-serve --evidence ` serves the environment +from the initial state the capture observed, with nothing else to supply: at +startup it derives the `environment-state` view of the capture in-process with +the manifest's `template_rules`. Read tools whose views are state-backed answer +from one live state, the `/agentenv` data plane implements `data/reset`, +`data/add` and `data/get` against it, and the card advertises those operations +and an intake declaration. `--replay-only` serves exactly the stateless replay +main serves: every manifest tool replays its recordings, `/agentenv` answers every method as not found and the card is +byte-identical. A state that cannot be derived, or whose catalog exceeds the +discovery budget, starts the server in replay mode, logs why and reports it. Exported bundles serve the state by default, +and their verifier and README match what they serve. Serving is read-only in +this increment: write tools are not served from the state. + +## Requirements + +- Derive the state in the application layer, shared by serve and export: + `deriveEnvironmentState(source, capture, operation)` authenticates the + capture value the command already read (an owned snapshot authenticated by + the source load is reused, not parsed again), projects the + `environment-state` view with the manifest's `template_rules` through + `inspectAnalysisViewValidated` with the capture as retained Evidence (the + Evidence id equals that of a projection by hand, and the capture's strings + are not charged again as inline input, which refuses captures of a few MiB), + then applies `loadEnvironmentState`: `inspect_analysis_view` Evidence of kind + `environment-state`, its `parent_evidence_id` equal to the capture's, its + `template_rules` equal to the manifest's in the same order, and every view + naming a manifest tool, refusing otherwise with `invalid_request` whose issue + path starts with `state`. The source validates a HAR capture with the owned + borrowing parse, so the view reuses that validation. +- `prepareServedState(source, capture, {name, operation})` first builds the + state catalog under the served name, then derives the state, and reports + `{served: true, evidence_id}` or + `{served: false, reason: "unavailable", code, message}` with the refusal's + projected code and message: a state catalog over the discovery budget is + unavailable with `resource_constraint`, and no state is derived for it. A + derived state whose catalog fits always serves from the state. The CLI + reports `{served: false, reason: "replay-only"}` itself for `--replay-only` + and derives nothing. +- Keep one state store per server process, shared by every MCP session and the + data plane: entities keyed by type, collection and id with per-collection + order taken from the entities array, documents keyed by tool. `reset` + restores the Evidence state, `replace` installs a validated document whole, + and mutations are synchronous. +- Answer item views by rendering the entity the path arguments name into the + recorded envelope of `envelope_ordinal`. Match arguments as replay does: a + held collection segment equal to the argument as written or after + percent-decoding, preferring verbatim spellings, and the identity as the last + argument percent-decoded, then as written, never re-split on `/`. Any held + entity answers, and query arguments only set `replay.exact_match`. Refuse an + entity the state does not hold, or one lacking a required field, as a tool + error and never invent a body. +- Answer list views from the state only when the arguments name one of the + view's `checked_record_ordinals` exactly (every path and query value equal and + no recorded query parameter unsupplied), choosing among them as replay + chooses: every collection entity in state order for `membership: "all"`, + otherwise the recorded members the state still holds. A member lacking one + of the view's `required_fields` makes the call a tool error stating that the + state holds no complete member, never a partial body, as for items. Lines + recorded after a write or with other filters were never checked and answer + from recordings. +- Answer document views with the state's value when the arguments name the + document's checked line exactly. +- Answer recorded views, tools without a view and unrecorded requests from the + recording, labelled `state: {source: "recording", reason}`; label state + answers `state: {source: "state", view, …}` with the base record's replay + provenance and `truncated: false`; refuse a state answer over 256 KiB as a + tool error rather than cut it. +- Serve `data/reset` (`{}`), `data/add` with exactly one `data` or `file` part + (`bytes`, a `file://` regular file or an `https://` download with a 30 s + timeout covering the body, no redirects and a streamed 32 MiB cap; + `mimeType` when given is `application/json`) and `data/get` (`{parts: + [{kind: "data", data: rea_state}]}`). A `file://` uri whose host is neither + empty nor `localhost`, or whose path is a UNC path, is `-32602` before + anything is opened. A document that `data/get` would write back past 32 MiB, + once it adds the members a document may omit, is `-32602`, so a reported + state always loads again. Anything else is `-32602`, including empty `parts`; a + failed download is `-32000`; errors never echo a uri or content and name a + coarse failure category in `error.data.reason`. Calls run one at a time in + arrival order across requests; every message of a batch is queued when the + batch arrives, so no other request's call lands between them, and + notifications run without a reply. +- Stopping the server (`close` or `prepareShutdown`) aborts the data plane's + stop signal, and a request's own signal aborts when its client leaves: the + download in progress ends with reason `cancelled`, queued calls are answered + `-32000` with reason `cancelled` without running, and a `data/add` whose + file read finished after the cancellation does not replace the state. +- A `file://` read whose descriptor close fails answers `-32000` and keeps the + descriptor in a server-lifetime owner. The next file read retries the close + first and, while it still fails, answers `-32000` without opening the file. + `close` retries it again and rejects while it is still open. +- Bound one `/agentenv` response to 64 MiB of replies (twice the intake + limit). Each reply is measured in bounded JSON parts as it is produced, in + message order, and assembled only when it fits, so a reply past the budget is + counted but never serialized whole. Room is kept for every later reply's + budget error and for a batch's brackets and commas, so the body never + passes the budget; a reply that does not fit is replaced by `-32000` with + `data: {max_response_bytes, reply_bytes}`, unless that error is no smaller + than the reply, so the body is never built past the budget and small write + replies are kept. +- Validate `rea-state/v1` documents against the served types and views: known + types, collections that instantiate their template, ids equal to the identity + field, unique keys, `view_fields` naming item or list views of the type + without setting the type's identity field to a value whose scalar text + differs from the entity's id (refused at that view's `fields`), + documents naming document views once at the initial path, and the optional + `state_evidence_id` and `types` that `data/get` reports equal to the served + ones. Refuse numbers without an exact native value and `__proto__` members. + Name the first problem by JSON pointer, never deeper than a caller-data + member (`fields`, `value`, `types`), and leave the state unchanged. +- Keep record citations Evidence provenance: refuse `record_ordinals` or a + `record_ordinal` other than the Evidence's for that entity or document, and + hold the Evidence's citations only for loaded values equal to the Evidence's; + edited or added values cite none. +- In state mode, declare `operations: ["data/reset", "data/add", "data/get"]` + and the `urn:agentenv:intake/v1` extension, describe tools as answering from + the state where it backs them, and leave out `POST`, `PUT`, `PATCH` and + `DELETE` tools other than query `POST`s. The discovery budget reserves the + card and descriptions actually served (`servedCatalog` returns the tools and + the data plane the card declares). A state catalog can exceed the budget a + replay catalog fits, so such a state is unavailable and the environment and + its export serve replay: everything main served and exported is still + served and exported. +- The CLI has no `--state`; `--replay-only` (default false) serves replay + without deriving. Otherwise it prepares the state under the name it serves + (`ENVIRONMENT_NAME` or the manifest's). When the state is unavailable the + command logs a warning with the code and message through its listener logger + before binding, and serves replay. The stopped document's `state` member is + the report. `serveEnvironment` keeps taking an optional prepared state and + serves replay without one; the served catalog (`servedCatalog`) and the + answer function (`environmentToolAnswer`) are shared with export. +- `environment-export` keeps the bundle command + `rea environment-serve /env/manifest.json --evidence /env/evidence.json` and, + after its name checks, prepares the state under the bundle name as that + server will, to predict what it serves, so a refused `--name` derives + nothing; its report is served or unavailable, never replay-only. The + verifier's `TOOL_COUNT` and the command's `tool_count` are the served tool + count, and its check tool and expected status come from calling the server's + answer function with `{}` on the first served `GET` tool without path + parameters whose answer is not an error. The verifier and smoke task use the + id `serve`. The README's "What the environment serves" section describes + serving from the state, the replay fallback and `--replay-only`, says which + applies to the bundle (with the code and message when the state is + unavailable), and keeps the not-emulation and untrusted-content caveats. The + export output adds `state`. The hosted-lane verifier script expects the + served count. + +## Acceptance and limits + +Services tests cover derivation (the by-hand Evidence id and state, a capture +the manifest was not projected from, a capture inline admission refuses), +the prepared reports (served, unavailable for a `capture_browser_scenario` +capture with header values but no response bodies, unavailable with +`resource_constraint` for a 350-tool read-only manifest whose replay catalog +fits), bundle plans served from the state and in replay (tool count, check tool +and status, a check whose state answer differs from replay's in record and +status, a check that skips a tool the server cannot answer, the replay fallback +of that 350-tool manifest, name refusals before any preparation, a manifest +neither mode admits, verifier, smoke task and README wording), the loader +refusals, store reset, order, round trip and +atomic replace, the first-problem pointers, citation provenance after a load and +every answer path: item (including percent-encoded collection segments and +ids), list `all`, list `recorded`, lines recorded after a write or with other +filters, a line replay's tie-break would not pick, document, recorded view, tool +without a view, unrecorded request, missing entity, missing required field and +the body limit, a view value that changes an entity's identity, and a list +member missing a required field. Adapters tests cover the data-plane shapes, +`-32602` for empty and malformed parts and invalid documents, inexact numbers, +bytes, `file://` and its failure categories, `file://` uris naming another +host or a UNC path, injected https downloads (status, declared and streamed +size, the timeout whether or not the fetcher honours its signal), the default +fetch's refused redirects, arrival-order application across requests, a batch +another request's reset cannot split, cancellation by the server's stop and by +a departed caller, a server that closes while a download is stalled, a +retained state-file close that the next file read and `close` retry, the +response budget, batches and notifications, the card declaration, absent write +tools, a served query `POST`, the per-mode discovery reservation and a +`data/add` that changes the next tool answer over MCP. The mcp-boundary tests serve the state the +built CLI derives from only a manifest and a capture, and `--replay-only` with +every manifest tool, `-32601` data-plane answers and a card whose SHA-256 +equals that of the card main serves for the fixture; the process-boundary test checks the stopped document's +`state` for a derived state, `--replay-only` and an unavailable state, and the +warning logged before binding; the cli-boundary export test checks the served +tool count, the verifier and the README of an exported bundle. No test reaches +an external network. Write effects, list filters beyond recorded lines and +`tables` in the intake declaration are out of scope. + +## Deferred issue criteria + +Write effects are a later increment of #4933. This PR must reference rather +than close the issue. diff --git a/docs/specs/agent-environments/state-serve/tasks.md b/docs/specs/agent-environments/state-serve/tasks.md new file mode 100644 index 0000000000..52a01b0774 --- /dev/null +++ b/docs/specs/agent-environments/state-serve/tasks.md @@ -0,0 +1,32 @@ +# Tasks + +- [x] Record the serving contract, requirements and deferred increments. +- [x] Load and cross-check state Evidence for a served source. +- [x] Add the store, the document validator and the snapshot. +- [x] Answer item, list and document views from the state with recorded + fallbacks. +- [x] Serve the data plane and declare it on the card; leave write tools out. +- [x] Address review: answer lists and documents only from checked lines, + match item arguments as replay does, reserve the served card only, keep + record citations Evidence provenance, and run data-plane calls in arrival + order with a tested download timeout. +- [x] Derive the state from the capture at startup, as retained Evidence, with + the loader's checks; reuse the capture's validation. +- [x] Replace `--state` with `--replay-only`, fall back to replay with a logged + warning when no state can be derived, and report `state` when stopped. +- [x] Derive the state in `environment-export` to plan the served tool count, + the verifier check and the README; report `state` in its output. +- [x] Add services, adapters and boundary coverage; remove the `--state` flag + tests. +- [x] Document default serving, `--replay-only`, the fallback, answers, data + plane, document format, card and export. +- [x] Address review: serve and export replay when the state's catalog + exceeds the discovery budget, check the bundle name before preparing the + state, keep replay-only out of export plans, test a check the state + answers differently from replay, and pin main's replay card digest. +- [x] Address review: bound one `/agentenv` response's replies, queue a + batch's messages together at arrival, cancel the running download and + queued calls when the server stops or the caller leaves, refuse + `file://` uris naming another host or a UNC path, refuse view values + that change an entity's identity, and apply list views' required fields. +- [x] Run static, formatting, duplication, documentation and test gates. diff --git a/docs/specs/agent-environments/state-serve/verification.md b/docs/specs/agent-environments/state-serve/verification.md new file mode 100644 index 0000000000..7fff3cf9b1 --- /dev/null +++ b/docs/specs/agent-environments/state-serve/verification.md @@ -0,0 +1,79 @@ +# Verification + +Verified base: 6676582675e08574146a812ae58cff67e2e53282 (this branch's head), +with the review fixes of this pass uncommitted. + +Executed on the implementation worktree on macOS with Node v22.23.1 (the +repository pins 24.18.0 in `.nvmrc`, which was not installed). Every check +below ran as written after the last source change, in this order. + +- `npm run format` then `npm run format:check`: passed (4330 files). +- `npm run check:fast`: typecheck and lint passed. The lint report holds 0 + errors and no diagnostic in any file this pass touches. +- `npx knip`: passed (exit 0, no findings). +- `npm run jscpd`: 3.37% duplicated lines over 3373 files (2800 clones), under + the 3.5% gate; no clone involves a file this pass changed. +- `npm run scan:todos`: passed, no markers. +- `npm run docs:check`: the built documentation checks (6 tasks) and the README + consistency conformance test (5 tests) passed. +- `npx vitest run --project domain --project services`: 347 files, 5948 tests + passed, 5 pre-existing skips. +- `npm run build:cached` (the built output already included this pass's + sources), then `npm run test:focused --` the serve CLI, MCP and process + boundaries, the export CLI boundary, the CLI startup imports, the agentenv + verifier identity boundary, the bundle package pin conformance test, the CI + change scope boundary, the analysis-view CLI and MCP boundaries, and the + `serveEnvironment`, `serveEnvironmentState`, `environmentCapacity` and + `stateIntake` adapters tests: 14 files, 335 tests passed. +- After this file was written: `npm run format`, `npm run format:check`, + `npm run scan:todos` and `npm run docs:check` passed again. + +The review fixes are covered by these tests, all passing in the runs above: + +- Response budget: a batch of four `data/get`s over a loaded state of about a + third of the budget keeps two results, answers the other two with `-32000` + and `max_response_bytes`, keeps the trailing reset's `{}`, and its body stays + within the budget. +- Batch order: a batch `[data/add (gated download), data/get]` with a + `data/reset` from another request sent while the download is held reports + the loaded state from its `data/get`, and the reset applies last. +- Cancellation: aborting the data plane's stop signal ends a stalled download + (its fetcher's signal is aborted) and answers the queued reset as + `cancelled` without running it; a call made after the stop does not run; a + caller whose request signal aborts gets `cancelled` and the state is + unchanged. Over loopback HTTP, `close()` of a served state returned while a + download that never answers was in progress, and that download's signal was + aborted. +- File closes: with an injected close failure, `data/add` of a `file://` uri + answered `-32000` without echoing the uri. The descriptor stayed open, and + the next file load closed it before loading. While the close kept failing, + over loopback HTTP the next file load answered `-32000` ("still open") and + an inline `data` part still loaded. `close()` rejected with the unfinished + close, then resolved and closed the descriptor once the close succeeded. + Both tests fail on the previous head, which dropped the descriptor. +- File hosts: `file://remote.example.test/...` and + `file:////remote.example.test/...` are refused with the network-share + message and no failure category; a `file://localhost/...` uri loads. +- Loaded identity: `view_fields` setting `id` to `3`, `"3"` or `null` on user 2 + is refused at `/rea_state/entities/7/view_fields/0/fields`; `"2"` loads. +- List required fields: a folder without `title` makes `get_folders` a tool + error naming the incomplete entity. + +That each test fails without its fix is source-derived; the fixes were not +reverted to show it. + +Not covered here: a Windows host (the UNC refusal ran on macOS, where +`file:////host/share` converts to `//host/share`; the host check runs before +any path conversion on every platform), a real `https://` download (tests +inject the fetcher), the memory and time of a budgeted batch (not measured), +agent token usage, a real AgentEnv framework run, a real-world capture, and +write effects, which are a later increment. + +After refusing bodies too deep for the state's own nesting (a recorded body +must nest at most the JSON depth limit minus 16 levels to enter the state): +`npm run check:fast`, `npm run format:check`, `npx knip`, `npm run scan:todos` +passed; `npm run jscpd`: 3.37% over 3373 files; `npx vitest run --project +domain --project services`: 347 files, 5950 tests passed, 5 pre-existing +skips; `npm run build:cached`, then `npm run test:focused --` the serve CLI, +MCP and process boundaries, the export CLI boundary and the two serve adapter +tests: 6 files, 48 tests passed. diff --git a/docs/testing.md b/docs/testing.md index 9376578c4a..554bf9fcc1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1804,9 +1804,11 @@ 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, that -the runner exits `0` with `tasks/smoke.json` reported as passed and the -verifier's description naming the tool count, and that no container exists +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 +manifest because write tools 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 after the run that did not exist before; the container inventory runs even when the run fails or times out, so a failed receipt still lists what it left. The one exception is the framework's own image-store helper, `agentenv-registry` (or `agentenv-registry-`), diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 8b81e3f5b2..eeabd4d4eb 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -611,7 +611,10 @@ computes responses or derived values, and no effects of writes. The state accumulator charges the manifest's admission and grouping, every record line, each body as temporary bytes before it is decoded and parsed, and every value it keeps, within the same 96 MiB budget. The view performs no network activity -and serves nothing. +and serves nothing; `rea environment-serve` derives it from the capture at +startup, serves read tools from it and loads replacement states through the +AgentEnv data plane, as +[serve from the state](agent-environments.md#serve-from-the-state) describes. ## Resource and lifecycle boundaries diff --git a/scripts/verify-agentenv-environment.mjs b/scripts/verify-agentenv-environment.mjs index 4ab0e67f9b..d5d4b31954 100644 --- a/scripts/verify-agentenv-environment.mjs +++ b/scripts/verify-agentenv-environment.mjs @@ -142,7 +142,11 @@ try { ); assert.equal(exported.status, "exported"); assert.equal(exported.name, "grafana-example"); - assert.equal(exported.tool_count, toolCount); + // 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; + assert.ok(servedCount > 0 && servedCount < toolCount); assert.equal(exported.package_source.kind, "tarball"); assert.deepEqual(exported.files.map((file) => file.path).sort(), [ "README.md", @@ -219,7 +223,7 @@ try { assert.match( snapshot.stdout.text, new RegExp( - `replay: listed ${String(toolCount)} tools, expected ${String(toolCount)};`, + `serve: listed ${String(servedCount)} tools, expected ${String(servedCount)};`, "u", ), ); diff --git a/src/application/environment/environmentBundle.test.ts b/src/application/environment/environmentBundle.test.ts index da80f08ad6..5f2727b6cd 100644 --- a/src/application/environment/environmentBundle.test.ts +++ b/src/application/environment/environmentBundle.test.ts @@ -1,25 +1,81 @@ import { describe, expect, it } from "vitest"; -import { environmentServeSource } from "../../../tests/fixtures/environmentServe.js"; +import { timedCaptureEvidence } from "../../../tests/fixtures/analysisView.js"; +import { + scenarioHeaderOnlyCaptureEvidence, + scenarioServeFixture, +} from "../../../tests/fixtures/browserScenarioCapture.js"; +import { + environmentServeFixture, + environmentServeServed, + environmentStateServeFixture, +} from "../../../tests/fixtures/environmentServe.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js"; import { BUNDLE_BASE_IMAGE, bundleDockerfile, + ENVIRONMENT_EXPORT_OPERATION, planEnvironmentBundle, registryPackageSource, + type EnvironmentBundleOptions, } from "./environmentBundle.js"; +import type { EnvironmentSource } from "./environmentSource.js"; +import { prepareServedState } from "./environmentState.js"; +import { environmentToolAnswer } from "./environmentStateAnswer.js"; +import { EnvironmentStateStore } from "./environmentStateStore.js"; -const plan = (options: { name?: string; tarball?: string } = {}) => { - const planned = planEnvironmentBundle(environmentServeSource(), { - name: options.name, - packageSource: - options.tarball === undefined - ? registryPackageSource() - : { kind: "tarball", path: options.tarball }, +const fixture = environmentServeFixture(); +const { source: servedSource, served } = environmentServeServed(fixture); + +interface PlanOptions { + readonly name?: string; + readonly tarball?: string; + /** Replaces the fixture's retained exchanges. */ + readonly exchanges?: EnvironmentSource["exchanges"]; + readonly served?: EnvironmentBundleOptions["served"]; +} + +/** Plan the fixture bundle served from its derived state. */ +const planBundle = (options: PlanOptions = {}) => + planEnvironmentBundle( + options.exchanges === undefined + ? servedSource + : { ...servedSource, exchanges: options.exchanges }, + { + name: options.name, + packageSource: + options.tarball === undefined + ? registryPackageSource() + : { kind: "tarball", path: options.tarball }, + served: options.served ?? (() => served), + }, + ); + +/** Prepare the state as the export does, under the checked bundle name. */ +const exportPreparation = + (source: EnvironmentSource, capture: unknown) => (name: string) => + prepareServedState(source, capture, { + name, + operation: ENVIRONMENT_EXPORT_OPERATION, + }); + +/** Plan under a name expected to be refused before the state is prepared, and project the refusal. */ +const nameRefusal = (name: string) => { + const refused = planBundle({ + name, + served: () => { + throw new Error("The state was prepared before the name checks"); + }, }); - if (!planned.ok) throw planned.error; - return planned.value; + if (refused.ok) throw new Error(`Expected ${name} to be refused`); + return projectAnalysisError(refused.error); +}; + +const plan = (options: PlanOptions = {}) => { + const result = planBundle(options); + if (!result.ok) throw result.error; + return result.value; }; const file = (planned: ReturnType, path: string): string => { @@ -32,7 +88,12 @@ describe("planEnvironmentBundle", () => { it("plans the echo-shaped file set under the manifest name with byte-exact copies beside the Dockerfile", () => { const planned = plan(); expect(planned.name).toBe("grafana-example"); - expect(planned.toolCount).toBe(19); + // Served from the derived state: the 19 manifest tools less the 5 write tools. + expect(planned.toolCount).toBe(14); + expect(planned.state).toEqual({ + served: true, + evidence_id: served.state?.evidenceId, + }); expect(planned.files.map((entry) => entry.path)).toEqual([ "README.md", "envs/grafana-example/env.toml", @@ -87,34 +148,35 @@ describe("planEnvironmentBundle", () => { expect(bundleDockerfile(registryPackageSource())).not.toBe(dockerfile); }); - it("writes a verifier that checks the tool count and the first parameterless GET tool's recorded status", () => { + it("writes a verifier that checks the served tool count and the status the server answers the first parameterless GET tool with", () => { const planned = plan(); - // Manifest tools are name-ordered, so the first parameterless GET is the annotations list. + // Served tools are name-ordered, so the first parameterless GET is the annotations list; the state answers it from its recording. expect(planned.check).toEqual({ tool: "get_annotations", recordOrdinal: 25, status: 200, }); const verifier = file(planned, "artifacts/check_grafana-example/verify.py"); + expect(verifier).toContain("served from the state the capture observed"); expect(verifier).toContain("from mcp import ClientSession"); expect(verifier).toContain( "from mcp.client.streamable_http import streamable_http_client", ); expect(verifier).toContain("async def verify(mcp_url):"); - expect(verifier).toContain("TOOL_COUNT = 19"); + expect(verifier).toContain("TOOL_COUNT = 14"); expect(verifier).toContain('TOOL = "get_annotations"'); - expect(verifier).toContain("RECORDED_STATUS = 200"); + expect(verifier).toContain("EXPECTED_STATUS = 200"); expect(verifier).toContain( - "passed = passed and not result.isError and status == RECORDED_STATUS", + "passed = passed and not result.isError and status == EXPECTED_STATUS", ); expect(verifier).toContain("await session.list_tools()"); expect(verifier).toContain("await session.call_tool(TOOL, {})"); expect(verifier).toContain( - 'return [{"id": "replay", "result": passed, "description": description}]', + 'return [{"id": "serve", "result": passed, "description": description}]', ); }); - it("writes the smoke task deploying the env locally before the replay verifier", () => { + it("writes the smoke task deploying the env locally before the verifier", () => { expect(JSON.parse(file(plan(), "tasks/smoke.json"))).toEqual([ { id: "env", @@ -123,17 +185,17 @@ describe("planEnvironmentBundle", () => { sandbox_type: "local", }, { - id: "replay", + id: "serve", type: "env_outcome_verifier", env_id: "grafana-example", file_artifact_id: "check_grafana-example", - verifier_id: "replay", + verifier_id: "serve", }, ]); }); - it("records provenance, replay limits, the run command and the rights note in the README", () => { - const source = environmentServeSource(); + it("records provenance, what the environment serves, the run command and the rights note in the README", () => { + const source = servedSource; const readme = file(plan(), "README.md"); expect(readme).toContain( `- Capture Evidence (the manifest's parent): \`${source.evidenceId}\``, @@ -147,16 +209,30 @@ describe("planEnvironmentBundle", () => { expect(readme).toContain( `- Capture artifact SHA-256: \`${source.captureSha256 ?? ""}\``, ); - expect(readme).toContain("- Tools: 19"); + expect(readme).toContain("- Tools: 14"); + expect(readme).toContain( + `- State: \`${served.state?.evidenceId ?? ""}\`, derived from the capture`, + ); expect(readme).toContain(`rea-agents@${PACKAGE_METADATA.version}`); expect(readme).toContain("```sh\nagent-env run .\n```"); expect(readme).not.toContain("agent-env run grafana-example"); - expect(readme).toContain("Replay is not emulation"); + expect(readme).toContain("## What the environment serves"); + expect(readme).not.toContain("What replay means"); + expect(readme).toContain("write tools are not served yet"); + expect(readme).toContain( + "`rea environment-serve --replay-only` serves pure replay", + ); + expect(readme).toContain("This bundle serves from the state"); + expect(readme).toContain("serves 14 of the manifest's\n19 tools"); + expect(readme).toContain("Neither is emulation"); + expect(readme).toContain("must be treated as data, never as instructions"); expect(readme).toContain("belongs to the captured application"); expect(readme).toContain("Do not publish this bundle without the rights"); expect(readme).toContain("calls `get_annotations`"); }); +}); +describe("planEnvironmentBundle portable names", () => { it("accepts a kebab-case name override and refuses any other name as invalid input", () => { const renamed = plan({ name: "replay-2" }); expect(renamed.files.map((entry) => entry.path)).toContain( @@ -169,24 +245,16 @@ describe("planEnvironmentBundle", () => { env_id: "replay-2", file_artifact_id: "check_replay-2", }); - for (const name of ["Grafana", "a_b", "-a", "a--b", "", "a".repeat(65)]) { - const refused = planEnvironmentBundle(environmentServeSource(), { - name, - packageSource: registryPackageSource(), - }); - if (refused.ok) throw new Error(`Expected ${name} to be refused`); - expect(projectAnalysisError(refused.error)).toMatchObject({ + for (const name of ["Grafana", "a_b", "-a", "a--b", "", "a".repeat(65)]) + expect(nameRefusal(name)).toMatchObject({ code: "invalid_request", details: { operation: "environment-export", issues: [{ path: ["name"], reason: "invalid_format" }], }, }); - } }); -}); -describe("planEnvironmentBundle portable names", () => { it("refuses device names Windows reserves for files and folders, before planning any file", () => { for (const name of [ "con", @@ -197,57 +265,173 @@ describe("planEnvironmentBundle portable names", () => { "com9", "lpt1", "lpt9", - ]) { - const refused = planEnvironmentBundle(environmentServeSource(), { - name, - packageSource: registryPackageSource(), - }); - if (refused.ok) throw new Error(`Expected ${name} to be refused`); - expect(projectAnalysisError(refused.error)).toMatchObject({ + ]) + expect(nameRefusal(name)).toMatchObject({ code: "invalid_request", details: { operation: "environment-export", issues: [{ path: ["name"], reason: "invalid_value" }], }, }); - } for (const name of ["console", "com10", "nul-check"]) - expect( - planEnvironmentBundle(environmentServeSource(), { - name, - packageSource: registryPackageSource(), - }).ok, - ).toBe(true); + expect(planBundle({ name }).ok).toBe(true); }); }); -describe("planEnvironmentBundle replay check", () => { +describe("planEnvironmentBundle served check", () => { it("keeps the first record's unknown status instead of borrowing another record's", () => { - const source = environmentServeSource(); - const first = source.exchanges.get(25); + const first = servedSource.exchanges.get(25); if (first === undefined) throw new Error("Fixture lacks record 25"); - const exchanges = new Map(source.exchanges); + const exchanges = new Map(servedSource.exchanges); exchanges.set(25, { ...first, status: null }); - const planned = planEnvironmentBundle( - { ...source, exchanges }, - { packageSource: registryPackageSource() }, - ); - if (!planned.ok) throw planned.error; - expect(planned.value.check).toMatchObject({ + const unknown = plan({ exchanges }); + expect(unknown.check).toMatchObject({ + tool: "get_annotations", recordOrdinal: 25, status: null, }); expect( - file(planned.value, "artifacts/check_grafana-example/verify.py"), - ).toContain("RECORDED_STATUS = None"); + file(unknown, "artifacts/check_grafana-example/verify.py"), + ).toContain("EXPECTED_STATUS = None"); + }); + + it("checks the next parameterless GET tool when the server cannot answer one without arguments", () => { + const exchanges = new Map(servedSource.exchanges); + exchanges.delete(25); + expect(plan({ exchanges }).check).toMatchObject({ + tool: "get_folders", + status: 200, + }); + }); + + it("expects the status of the state's answer where it differs from replay's", () => { + // Without arguments replay keeps the first line; the state answers the line {} names exactly. + const items = environmentStateServeFixture( + timedCaptureEvidence([ + ["GET", "/api/items?a=1", [{ id: 1 }], 206], + ["GET", "/api/items", [{ id: 1 }, { id: 2 }]], + ]), + ); + const { source, served: prepared } = environmentServeServed(items); + if (prepared.state === undefined) throw new Error("Expected a state"); + const tool = source.manifest.tools.find(({ name }) => name === "get_items"); + if (tool === undefined) throw new Error("Fixture manifest lacks get_items"); + const fromState = environmentToolAnswer( + source, + new EnvironmentStateStore(prepared.state), + )(tool, {}); + const replayed = environmentToolAnswer(source, undefined)(tool, {}); + if (!fromState.ok) throw fromState.error; + if (!replayed.ok) throw replayed.error; + expect(fromState.value).toMatchObject({ + status: 200, + replay: { record_ordinal: 1 }, + state: { source: "state", view: "list" }, + }); + expect(replayed.value).toMatchObject({ + status: 206, + replay: { record_ordinal: 0 }, + }); + const planned = planEnvironmentBundle(source, { + packageSource: registryPackageSource(), + served: exportPreparation(source, items.capture), + }); + if (!planned.ok) throw planned.error; + expect(planned.value.state.served).toBe(true); + expect(planned.value.check).toEqual({ + tool: "get_items", + recordOrdinal: 1, + status: 200, + }); }); }); -it("refuses a bundle that the replay server cannot admit, using export operation context", () => { - const source = environmentServeSource(); - const tool = source.manifest.tools[0]!; +describe("planEnvironmentBundle without a state", () => { + const fallback = environmentServeServed( + scenarioServeFixture(scenarioHeaderOnlyCaptureEvidence()), + ); + + it("predicts replay, with every manifest tool and a replayed status, when the export cannot derive the state", () => { + expect(fallback.served.report).toMatchObject({ + served: false, + reason: "unavailable", + code: "unsupported_target", + }); + const planned = planEnvironmentBundle(fallback.source, { + packageSource: registryPackageSource(), + served: () => fallback.served, + }); + if (!planned.ok) throw planned.error; + expect(planned.value.toolCount).toBe(fallback.source.manifest.tools.length); + expect(planned.value.check).toEqual({ + tool: "get_search", + recordOrdinal: 3, + status: 200, + }); + const name = planned.value.name; + const verifier = file(planned.value, `artifacts/check_${name}/verify.py`); + expect(verifier).toContain( + "served by replay because the state is unavailable", + ); + expect(verifier).toContain( + `TOOL_COUNT = ${String(fallback.source.manifest.tools.length)}`, + ); + const readme = file(planned.value, "README.md"); + expect(readme).toContain("- State: not served"); + expect(readme).toContain( + `This bundle serves replay: the server serves all ${String(fallback.source.manifest.tools.length)} manifest tools\nfrom their recordings because the state is unavailable\n(\`unsupported_target\`:`, + ); + expect(readme).toContain("response_body selected"); + expect(readme).toContain("Neither is emulation"); + }); + + it("serves and predicts replay when the state's catalog exceeds the discovery budget that replay's fits", () => { + // Read tools only, so leaving out write tools saves nothing while each state description is longer. + const reads = servedSource.manifest.tools.filter( + ({ method }) => method === "GET", + ); + const tools = Array.from({ length: 350 }, (_, index) => { + const tool = reads[index % reads.length]!; + return index < reads.length + ? tool + : { ...tool, name: `${tool.name}_${String(index)}` }; + }); + const source = { + ...servedSource, + manifest: { ...servedSource.manifest, tools }, + }; + const prepared = prepareServedState(source, fixture.capture); + expect(prepared.state).toBeUndefined(); + expect(prepared.report).toMatchObject({ + served: false, + reason: "unavailable", + code: "resource_constraint", + }); + const planned = planEnvironmentBundle(source, { + packageSource: registryPackageSource(), + served: exportPreparation(source, fixture.capture), + }); + if (!planned.ok) throw planned.error; + expect(planned.value.state).toMatchObject({ + served: false, + code: "resource_constraint", + }); + expect(planned.value.toolCount).toBe(350); + expect(planned.value.check).toEqual({ + tool: "get_annotations", + recordOrdinal: 25, + status: 200, + }); + expect(file(planned.value, "README.md")).toContain( + "because the state is unavailable\n(`resource_constraint`:", + ); + }); +}); + +it("refuses a bundle that the server cannot admit in either mode, using export operation context", () => { + const tool = servedSource.manifest.tools[0]!; const manifest = { - ...source.manifest, + ...servedSource.manifest, tools: [ { ...tool, @@ -258,10 +442,11 @@ it("refuses a bundle that the replay server cannot admit, using export operation }, ], }; - const result = planEnvironmentBundle( - { ...source, manifest }, - { packageSource: registryPackageSource() }, - ); + const source = { ...servedSource, manifest }; + const result = planEnvironmentBundle(source, { + packageSource: registryPackageSource(), + served: exportPreparation(source, fixture.capture), + }); expect(result).toMatchObject({ ok: false, error: { diff --git a/src/application/environment/environmentBundle.ts b/src/application/environment/environmentBundle.ts index 211dd54018..c5fce6f373 100644 --- a/src/application/environment/environmentBundle.ts +++ b/src/application/environment/environmentBundle.ts @@ -2,11 +2,14 @@ import { PRODUCT_IDENTITY } from "../../identity.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../../domain/result.js"; -import type { - EnvironmentManifestTool, - EnvironmentSource, -} from "./environmentSource.js"; -import { environmentToolDefinitions } from "./environmentTools.js"; +import type { EnvironmentSource } from "./environmentSource.js"; +import { servedCatalog, type PreparedState } from "./environmentState.js"; +import { + environmentToolAnswer, + type EnvironmentToolAnswer, +} from "./environmentStateAnswer.js"; +import { EnvironmentStateStore } from "./environmentStateStore.js"; +import type { EnvironmentToolDefinition } from "./environmentTools.js"; /** Operation name every refusal of the export command reports. */ export const ENVIRONMENT_EXPORT_OPERATION = "environment-export"; @@ -37,19 +40,23 @@ export interface EnvironmentBundleFile { readonly content: string; } -/** The replay check the verifier performs against the served environment. */ -export interface EnvironmentReplayCheck { +/** The answer the verifier checks: one tool called without arguments, as the bundle's server answers it. */ +export interface EnvironmentServedCheck { readonly tool: string; + /** The record whose status and provenance the answer carries. */ readonly recordOrdinal: number; readonly status: number | null; } -/** Everything the export writes, derived from the source alone. */ +/** Everything the export writes, derived from the source and the state its server derives. */ export interface EnvironmentBundlePlan { readonly name: string; readonly packageSource: EnvironmentPackageSource; + /** Tools the bundle serves: every manifest tool for replay, the read tools from a state. */ readonly toolCount: number; - readonly check: EnvironmentReplayCheck | null; + readonly check: EnvironmentServedCheck | null; + /** Whether the bundle serves from the state, as `environment-serve` reports it. */ + readonly state: PreparedState["report"]; /** Text files the export writes; manifest and evidence are copied byte-exact beside them. */ readonly files: readonly EnvironmentBundleFile[]; /** Relative paths of the byte-exact copies in write order. */ @@ -64,6 +71,8 @@ export interface EnvironmentBundleOptions { /** Folder and env id; defaults to the manifest's kebab-case name. */ readonly name?: string | undefined; readonly packageSource: EnvironmentPackageSource; + /** Prepares the state the bundle's server serves under the checked bundle name, as that server prepares it. */ + readonly served: (name: string) => PreparedState; } /** The registry source pinning the exact version of this build. */ @@ -129,26 +138,22 @@ export const bundleDockerfile = (source: EnvironmentPackageSource): string => "", ].join("\n"); -/** The first GET tool without path parameters, in manifest order, and its first record's status. */ -const replayCheck = ( - source: EnvironmentSource, -): EnvironmentReplayCheck | null => { - const tool: EnvironmentManifestTool | undefined = source.manifest.tools.find( - (candidate) => - candidate.method === "GET" && - candidate.params.length === 0 && - candidate.record_ordinals.length > 0, - ); - if (tool === undefined) return null; - const ordinal = tool.record_ordinals[0] ?? -1; - const exchange = source.exchanges.get(ordinal); - return { - tool: tool.name, - recordOrdinal: ordinal, - // An exchange's own unknown status is what replay returns; fall back only when the record is absent. - status: - exchange === undefined ? (tool.statuses[0] ?? null) : exchange.status, - }; +/** The first served GET tool without path parameters whose answer to no arguments is not an error, answered as the server answers it. */ +const servedCheck = ( + tools: readonly EnvironmentToolDefinition[], + answer: EnvironmentToolAnswer, +): EnvironmentServedCheck | null => { + for (const { tool } of tools) { + if (tool.method !== "GET" || tool.params.length > 0) continue; + const answered = answer(tool, {}); + if (answered.ok) + return { + tool: tool.name, + recordOrdinal: answered.value.replay.record_ordinal, + status: answered.value.status, + }; + } + return null; }; const pythonLiteral = (value: string | number | null): string => @@ -156,23 +161,22 @@ const pythonLiteral = (value: string | number | null): string => /** The verifier script in the shape of the agentenv echo example's `verify(mcp_url)`. */ export const bundleVerifier = ( - toolCount: number, - check: EnvironmentReplayCheck | null, -): string => { - const header = [ - '"""Checks the replay environment through its MCP endpoint: the tool count and one replayed status."""', + plan: Pick, +): string => + [ + `"""Checks the environment, ${plan.state.served ? "served from the state the capture observed" : "served by replay because the state is unavailable"}, through its MCP endpoint: the tool count and one answered status."""`, "import json", "", "from mcp import ClientSession", "from mcp.client.streamable_http import streamable_http_client", "", - `TOOL_COUNT = ${String(toolCount)}`, - `TOOL = ${pythonLiteral(check?.tool ?? null)}`, - `RECORDED_STATUS = ${pythonLiteral(check?.status ?? null)}`, + `TOOL_COUNT = ${String(plan.toolCount)}`, + `TOOL = ${pythonLiteral(plan.check?.tool ?? null)}`, + `EXPECTED_STATUS = ${pythonLiteral(plan.check?.status ?? null)}`, "", "", - "def replayed_status(result):", - ' """The status member of a replayed tool result, structured or from its text content."""', + "def answered_status(result):", + ' """The status member of a tool result, structured or from its text content."""', " data = result.structuredContent", " if data is None:", ' text = "".join(getattr(part, "text", "") for part in result.content)', @@ -190,29 +194,27 @@ export const bundleVerifier = ( " passed = count == TOOL_COUNT", ' description = f"listed {count} tools, expected {TOOL_COUNT}"', " if result is not None:", - " status = None if result.isError else replayed_status(result)", - " passed = passed and not result.isError and status == RECORDED_STATUS", - ' description += f"; {TOOL} replayed status {status!r}, recorded {RECORDED_STATUS!r}"', + " status = None if result.isError else answered_status(result)", + " passed = passed and not result.isError and status == EXPECTED_STATUS", + ' description += f"; {TOOL} answered status {status!r}, expected {EXPECTED_STATUS!r}"', " else:", - ' description += "; no GET tool without path parameters to replay"', - ' print(f"replay: {description}")', - ' return [{"id": "replay", "result": passed, "description": description}]', + ' description += "; no GET tool without path parameters answers without arguments"', + ' print(f"serve: {description}")', + ' return [{"id": "serve", "result": passed, "description": description}]', "", - ]; - return header.join("\n"); -}; + ].join("\n"); -/** The smoke task: deploy the env locally, then run the replay verifier against it. */ +/** The smoke task: deploy the env locally, then run the verifier against it. */ export const bundleSmokeTask = (name: string): string => `${JSON.stringify( [ { id: "env", type: "deploy_env", env_id: name, sandbox_type: "local" }, { - id: "replay", + id: "serve", type: "env_outcome_verifier", env_id: name, file_artifact_id: `check_${name}`, - verifier_id: "replay", + verifier_id: "serve", }, ], null, @@ -224,24 +226,38 @@ const packageSourceText = (source: EnvironmentPackageSource): string => ? `\`${source.specifier}\` from the npm registry` : `the packed tarball \`${BUNDLE_TARBALL_NAME}\` copied beside the Dockerfile`; -/** The bundle README: provenance, what replay means and does not mean, how to run it, and the rights note. */ +type ReadmePlan = Pick< + EnvironmentBundlePlan, + "name" | "packageSource" | "toolCount" | "check" | "state" +>; + +/** What this bundle serves: the state the export derived, or replay and why. */ +const servedHere = (source: EnvironmentSource, plan: ReadmePlan): string => { + const { state } = plan; + return state.served + ? `This bundle serves from the state: the export derived it as Evidence +\`${state.evidence_id}\`, and the server serves ${String(plan.toolCount)} of the manifest's +${String(source.manifest.tools.length)} tools.` + : `This bundle serves replay: the server serves all ${String(plan.toolCount)} manifest tools +from their recordings because the state is unavailable +(\`${state.code}\`: ${state.message}).`; +}; + +/** The bundle README: provenance, what the environment serves and does not establish, how to run it, and the rights note. */ export const bundleReadme = ( source: EnvironmentSource, - plan: Pick< - EnvironmentBundlePlan, - "name" | "packageSource" | "toolCount" | "check" - >, + plan: ReadmePlan, ): string => { const { manifest } = source; const check = plan.check === null - ? "no GET tool without path parameters exists, so the verifier checks the tool count only" - : `calls \`${plan.check.tool}\` and checks the replayed \`status\` equals the recorded ${String(plan.check.status)} of record ${String(plan.check.recordOrdinal)}`; + ? "no served GET tool without path parameters answers without arguments, so the verifier checks the tool count only" + : `calls \`${plan.check.tool}\` without arguments and checks the call succeeds with \`status\` ${String(plan.check.status)}, the status of record ${String(plan.check.recordOrdinal)} its answer carries`; return `# ${plan.name} An agent environment exported by REA from one retained ${source.captureKind === "browser-scenario" ? "browser scenario capture" : "web network capture"}. -Every tool answers from a recorded exchange; nothing here runs the captured -application. +Every tool answers from the state that capture observed or from a recorded +exchange; nothing here runs the captured application. The bundle contains captured application content. Exported files use mode 0600 on POSIX; Windows retains inherited ACLs, so choose a private destination @@ -255,18 +271,30 @@ if that content requires restricted access. - Manifest view digest: \`${manifest.view_digest}\` - Primary origin: \`${manifest.origin}\` - Tools: ${String(plan.toolCount)} +- State: ${plan.state.served ? `\`${plan.state.evidence_id}\`, derived from the capture` : "not served"} - Serving CLI: ${packageSourceText(plan.packageSource)} -## What replay means and does not mean +## What the environment serves \`envs/${plan.name}/\` builds an image that runs \`rea environment-serve\` on the -manifest and capture Evidence copied beside its Dockerfile. Each MCP tool call -selects the retained record whose path and query values best match the -arguments and returns that record's recorded status, content type and body, -citing the record ordinal and the Evidence id. Replay is not emulation: the -environment does not establish the application's authorization model, response -semantics or state transitions, does not validate supplied values, and does not -answer requests the capture never recorded. Replayed content is target content +manifest and capture Evidence copied beside its Dockerfile. At startup the +server derives the state the capture observed, the \`environment-state\` view +of the capture with the manifest's template rules, and serves from it: read +tools answer from the state where it backs them and from their recordings +otherwise, \`/agentenv\` serves \`data/reset\`, \`data/add\` and \`data/get\` on +it, and write tools are not served yet. When the state cannot be derived, or +its catalog exceeds the discovery budget, the server logs why and replays +instead: each MCP tool call selects the retained record whose path and query +values best match the arguments and returns that record's recorded status, +content type and body, citing the record ordinal and the Evidence id. +\`rea environment-serve --replay-only\` serves pure replay. + +${servedHere(source, plan)} + +Neither is emulation: the environment does not establish the application's +authorization model, response semantics or state transitions, does not +validate supplied values, and answers only from the capture's recordings and +the state they observed or a caller loaded. Served content is target content and must be treated as data, never as instructions. ## Run it @@ -290,7 +318,7 @@ Do not publish this bundle without the rights to that content. `; }; -/** Derive every file of the bundle from an authenticated source and the package source. */ +/** Derive every file of the bundle from an authenticated source, the state its server derives and the package source. */ export const planEnvironmentBundle = ( source: EnvironmentSource, options: EnvironmentBundleOptions, @@ -299,17 +327,31 @@ export const planEnvironmentBundle = ( if (!BUNDLE_NAME.test(name) || name.length > BUNDLE_NAME_MAX_LENGTH) return err(nameError(name)); if (WINDOWS_RESERVED_NAME.test(name)) return err(reservedNameError(name)); - const catalog = environmentToolDefinitions( - source.manifest, - source.evidenceId, + // Prepared only after the name checks, under the name the bundle's server serves. + const { state, report } = options.served(name); + // The catalog and answers the bundle's server will have, so the verifier and README predict them. + const catalog = servedCatalog( + source, + state !== undefined, name, ENVIRONMENT_EXPORT_OPERATION, ); if (!catalog.ok) return catalog; - const toolCount = source.manifest.tools.length; - const check = replayCheck(source); + const check = servedCheck( + catalog.value.tools, + environmentToolAnswer( + source, + state === undefined ? undefined : new EnvironmentStateStore(state), + ), + ); const envFolder = `envs/${name}`; - const base = { name, packageSource: options.packageSource, toolCount, check }; + const base = { + name, + packageSource: options.packageSource, + toolCount: catalog.value.tools.length, + check, + state: report, + }; return ok({ ...base, files: [ @@ -324,7 +366,7 @@ export const planEnvironmentBundle = ( }, { path: `artifacts/check_${name}/verify.py`, - content: bundleVerifier(toolCount, check), + content: bundleVerifier(base), }, { path: "tasks/smoke.json", content: bundleSmokeTask(name) }, ], diff --git a/src/application/environment/environmentExport.test.ts b/src/application/environment/environmentExport.test.ts index a6701e3bbb..e55a847c02 100644 --- a/src/application/environment/environmentExport.test.ts +++ b/src/application/environment/environmentExport.test.ts @@ -118,9 +118,11 @@ describe("exportEnvironmentBundle", () => { status: "exported", directory, name: "grafana-example", - tool_count: 19, + // The bundle serves the state the export derived: the manifest's read tools. + tool_count: 14, evidence_id: input.source.evidenceId, manifest_evidence_id: input.source.manifestEvidenceId, + state: { served: true, evidence_id: expect.stringMatching(/^ev_/u) }, package_source: { kind: "registry", specifier: `rea-agents@${PACKAGE_METADATA.version}`, diff --git a/src/application/environment/environmentExport.ts b/src/application/environment/environmentExport.ts index 14f5696b62..336011db9d 100644 --- a/src/application/environment/environmentExport.ts +++ b/src/application/environment/environmentExport.ts @@ -40,6 +40,7 @@ import { type EnvironmentPackageSource, } from "./environmentBundle.js"; import type { EnvironmentSource } from "./environmentSource.js"; +import { prepareServedState, type PreparedState } from "./environmentState.js"; /** One input file read once: the bytes the bundle copies and the value they parse to. */ export interface EnvironmentExportFile { @@ -55,7 +56,7 @@ export interface EnvironmentExportInputs { } export interface EnvironmentExportInput extends EnvironmentExportInputs { - /** The authenticated pair the bundle replays, loaded from the parsed values of the two files. */ + /** The authenticated pair the bundle serves, loaded from the parsed values of the two files. */ readonly source: EnvironmentSource; /** Absolute bundle root; absent or empty. */ readonly directory: string; @@ -74,9 +75,12 @@ export interface EnvironmentExportResult { readonly status: "exported"; readonly directory: string; readonly name: string; + /** Tools the bundle serves. */ readonly tool_count: number; readonly evidence_id: string; readonly manifest_evidence_id: string; + /** Whether the bundle serves from the state the export derived, as `environment-serve` reports it. */ + readonly state: PreparedState["report"]; readonly package_source: JsonValue; readonly files: readonly WrittenBundleFile[]; } @@ -328,9 +332,15 @@ export const exportEnvironmentBundle = async ( input.packageTarball === undefined ? registryPackageSource() : { kind: "tarball", path: input.packageTarball }; + // The bundle's server prepares the state at startup; preparing it here, once the name is checked, predicts what it serves. const plan = planEnvironmentBundle(input.source, { name: input.name, packageSource, + served: (name) => + prepareServedState(input.source, input.evidence.value, { + name, + operation: ENVIRONMENT_EXPORT_OPERATION, + }), }); if (!plan.ok) return plan; let files: readonly WrittenBundleFile[]; @@ -398,6 +408,7 @@ export const exportEnvironmentBundle = async ( tool_count: plan.value.toolCount, evidence_id: input.source.evidenceId, manifest_evidence_id: input.source.manifestEvidenceId, + state: plan.value.state, package_source: packageSourceValue(plan.value.packageSource), files, }); diff --git a/src/application/environment/environmentReplay.ts b/src/application/environment/environmentReplay.ts index 1be9c8fe11..7da4391072 100644 --- a/src/application/environment/environmentReplay.ts +++ b/src/application/environment/environmentReplay.ts @@ -51,7 +51,8 @@ interface MatchScore { readonly exact: boolean; } -const decoded = (segment: string): string => { +/** A path segment percent-decoded, or as written when it is not valid percent-encoding. */ +export const decodedPathSegment = (segment: string): string => { try { return decodeURIComponent(segment); } catch { @@ -59,6 +60,10 @@ const decoded = (segment: string): string => { } }; +/** Replay's path rule: a supplied value matches a recorded segment equal to it verbatim or after percent-decoding. */ +export const pathValueMatches = (segment: string, value: string): boolean => + segment === value || decodedPathSegment(segment) === value; + /** Count how many supplied path and query values the recorded request line satisfies. */ const scoreExchange = ( tool: EnvironmentManifestTool, @@ -86,10 +91,7 @@ const scoreExchange = ( for (const { index, value } of providedPaths) { const segment = segments.get(index); if (segment === value) verbatimMatches += 1; - if ( - segment !== undefined && - (segment === value || decoded(segment) === value) - ) + if (segment !== undefined && pathValueMatches(segment, value)) pathMatches += 1; } const supplied = new Map(); @@ -150,6 +152,30 @@ export const selectReplayExchange = ( : { exchange: best.exchange, exact: best.score.exact }; }; +/** Whether the arguments name one recorded request line exactly: every path value supplied and matching, every supplied query value matching, and every recorded query parameter supplied with its recorded value. */ +export const selectsRecordedLine = ( + tool: EnvironmentManifestTool, + exchange: RecordedExchange, + args: ReplayArguments, +): boolean => { + const argumentsMap = environmentToolArguments(tool); + if ( + !tool.params.every( + (param) => + typeof args[argumentsMap.path.get(param.name) ?? param.name] === + "string", + ) || + !scoreExchange(tool, argumentsMap.path, argumentsMap.query, exchange, args) + .exact + ) + return false; + for (const [name, value] of iterateUrlQueryParameters(exchange.query ?? "")) { + const argument = argumentsMap.query.get(name); + if (argument === undefined || args[argument] !== value) return false; + } + return true; +}; + /** The longest prefix of text whose UTF-8 encoding fits the byte limit, encoding only that prefix. */ const utf8Prefix = (text: string, maxBytes: number): string => { // encodeInto stops before the first character that does not fit, so `read` ends on a boundary. @@ -165,19 +191,19 @@ const decodedBase64Bytes = (encoded: string): number => /** Base64 characters, in whole groups, whose bytes fit the replay limit. */ const BASE64_LIMIT_CHARS = Math.floor(REPLAY_BODY_LIMIT_BYTES / 3) * 4; +interface ReplayBody { + readonly body: JsonValue; + readonly truncated: boolean; +} + /** The limit applies to the recorded bytes, not to their base64 spelling. */ -const replayBase64Body = ( - encoded: string, - cut: boolean, -): { readonly body: JsonValue; readonly truncated: boolean } => +const replayBase64Body = (encoded: string, cut: boolean): ReplayBody => !cut && decodedBase64Bytes(encoded) <= REPLAY_BODY_LIMIT_BYTES ? { body: encoded, truncated: false } : { body: encoded.slice(0, BASE64_LIMIT_CHARS), truncated: true }; /** Parse a retained body as JSON when its content type is JSON-like; otherwise return the text. */ -const replayBody = ( - exchange: RecordedExchange, -): { readonly body: JsonValue; readonly truncated: boolean } => { +const replayBody = (exchange: RecordedExchange): ReplayBody => { const text = exchange.bodyText; if (text === null) return { body: null, truncated: false }; if (exchange.bodyEncoding === "base64") @@ -215,8 +241,16 @@ export const replayEnvironmentTool = ( [{ path: ["tool"], reason: "invalid_value", expected: tool.name }], ), ); - const { exchange, exact } = selected; - const { body, truncated } = replayBody(exchange); + return ok(replayAnswer(source, selected.exchange, selected.exact)); +}; + +/** A tool answer with one recorded exchange's status, content type and provenance; the body defaults to the recorded one. */ +export const replayAnswer = ( + source: EnvironmentSource, + exchange: RecordedExchange, + exact: boolean, + { body, truncated }: ReplayBody = replayBody(exchange), +): ReplayResult => { const recordedPath = utf8Prefix(exchange.path, REPLAY_METADATA_LIMIT_BYTES); const recordedQuery = exchange.query === null @@ -226,7 +260,7 @@ export const replayEnvironmentTool = ( exchange.contentType === null ? null : utf8Prefix(exchange.contentType, REPLAY_METADATA_LIMIT_BYTES); - return ok({ + return { status: exchange.status, content_type: contentType, body, @@ -244,5 +278,5 @@ export const replayEnvironmentTool = ( body_encoding: exchange.bodyEncoding ?? "utf-8", truncated, }, - }); + }; }; diff --git a/src/application/environment/environmentReplayNumbers.test.ts b/src/application/environment/environmentReplayNumbers.test.ts index 4e21219b4a..11b34ca391 100644 --- a/src/application/environment/environmentReplayNumbers.test.ts +++ b/src/application/environment/environmentReplayNumbers.test.ts @@ -18,6 +18,7 @@ describe("replay JSON value fidelity", () => { '{"id":9007199254740993}', '{"id":9007199254740993.0}', '{"id":9.007199254740993e15}', + '{"id":1e20}', '[{"id":18446744073709551615}]', '{"value":0.100000000000000005}', '{"value":1e-1000}', diff --git a/src/application/environment/environmentScenario.test.ts b/src/application/environment/environmentScenario.test.ts index 594240ef82..c326b5c0ca 100644 --- a/src/application/environment/environmentScenario.test.ts +++ b/src/application/environment/environmentScenario.test.ts @@ -13,6 +13,7 @@ import { } from "./environmentBundle.js"; import { replayEnvironmentTool } from "./environmentReplay.js"; import { loadEnvironmentSource } from "./environmentSource.js"; +import { prepareServedState } from "./environmentState.js"; const fixture = scenarioServeFixture(); @@ -114,6 +115,8 @@ describe("environment manifests from capture_browser_scenario parents", () => { }); const planned = planEnvironmentBundle(source.value, { packageSource: registryPackageSource(), + served: (name) => + prepareServedState(source.value, fixture.capture, { name }), }); if (!planned.ok) throw planned.error; const readme = diff --git a/src/application/environment/environmentSource.ts b/src/application/environment/environmentSource.ts index 175c3f18d2..c412dbc4a3 100644 --- a/src/application/environment/environmentSource.ts +++ b/src/application/environment/environmentSource.ts @@ -21,7 +21,7 @@ import { projectInputIssues } from "../../domain/inputIssueProjection.js"; import { immutableJsonSnapshotMember } from "../../domain/immutableJson.js"; import { err, ok, type Result } from "../../domain/result.js"; import { - webNetworkCaptureSchema, + safeParseOwnedWebNetworkCapture, type WebNetworkCaptureRecord, } from "../../domain/webNetworkCapture.js"; @@ -66,7 +66,8 @@ export interface EnvironmentSource { readonly exchanges: ReadonlyMap; } -const inputError = ( +/** An input refusal whose issue paths name the offending file. */ +export const inputError = ( operation: string, issues: readonly AnalysisInputIssue[], userMessage?: string, @@ -78,9 +79,9 @@ const inputError = ( ); /** Authenticate one Evidence file; schema failures name the file role in their paths. */ -const parseEvidenceFile = ( +export const parseEvidenceFile = ( operation: string, - role: "manifest" | "evidence", + role: "manifest" | "evidence" | "state", value: unknown, ): Result => { try { @@ -117,15 +118,20 @@ const recordedExchange = ( }; }; -/** Authenticate the manifest file and require the environment-manifest view kind. */ -const parseManifestEvidence = ( +/** Authenticate a view Evidence file and require one view kind; a refusal names the file role. */ +export const parseViewEvidence = ( operation: string, + role: "manifest" | "state", value: unknown, + kind: Kind, ): Result< - { readonly manifest: EnvironmentManifest; readonly evidenceId: string }, + { + readonly view: Extract; + readonly evidenceId: string; + }, AnalysisError > => { - const evidence = parseEvidenceFile(operation, "manifest", value); + const evidence = parseEvidenceFile(operation, role, value); if (!evidence.ok) return evidence; const view = analysisViewResultSchema.safeParse( evidence.value.normalized_result, @@ -133,24 +139,26 @@ const parseManifestEvidence = ( if ( evidence.value.operation !== "inspect_analysis_view" || !view.success || - view.data.kind !== "environment-manifest" + view.data.kind !== kind ) return err( inputError( operation, [ { - path: ["manifest", "normalized_result", "kind"], + path: [role, "normalized_result", "kind"], reason: "invalid_value", - expected: "environment-manifest", - message: - "The manifest file must be inspect_analysis_view Evidence of kind environment-manifest.", + expected: kind, + message: `The ${role} file must be inspect_analysis_view Evidence of kind ${kind}.`, }, ], - "The manifest file is not environment-manifest view Evidence.", + `The ${role} file is not ${kind} view Evidence.`, ), ); - return ok({ manifest: view.data, evidenceId: evidence.value.evidence_id }); + return ok({ + view: view.data as Extract, + evidenceId: evidence.value.evidence_id, + }); }; /** Authenticate the evidence file and require complete capture Evidence of either kind. */ @@ -194,7 +202,8 @@ const parseCaptureEvidence = ( const record = immutableJsonSnapshotMember(records, String(index)); immutableJsonSnapshotMember(record, "reported"); } - const capture = webNetworkCaptureSchema.safeParse(normalized); + // An owned snapshot is validated once and borrowed, so deriving the state reuses this validation. + const capture = safeParseOwnedWebNetworkCapture(normalized); if (capture.success) { // The manifest view projects HAR captures only, and replay reads HAR's response fields. if (capture.data.format !== "har") @@ -269,11 +278,16 @@ export const loadEnvironmentSource = ( evidenceValue: unknown, operation: string = ENVIRONMENT_SERVE_OPERATION, ): Result => { - const manifest = parseManifestEvidence(operation, manifestValue); + const manifest = parseViewEvidence( + operation, + "manifest", + manifestValue, + "environment-manifest", + ); if (!manifest.ok) return manifest; const capture = parseCaptureEvidence(operation, evidenceValue); if (!capture.ok) return capture; - const parentId = manifest.value.manifest.parent_evidence_id; + const parentId = manifest.value.view.parent_evidence_id; if (parentId !== capture.value.evidenceId) return err( inputError( @@ -290,11 +304,11 @@ export const loadEnvironmentSource = ( ), ); return ok({ - manifest: manifest.value.manifest, + manifest: manifest.value.view, manifestEvidenceId: manifest.value.evidenceId, evidenceId: capture.value.evidenceId, captureKind: capture.value.captureKind, captureSha256: capture.value.captureSha256, - exchanges: indexExchanges(manifest.value.manifest, capture.value.records), + exchanges: indexExchanges(manifest.value.view, capture.value.records), }); }; diff --git a/src/application/environment/environmentState.test.ts b/src/application/environment/environmentState.test.ts new file mode 100644 index 0000000000..bdb66a88b9 --- /dev/null +++ b/src/application/environment/environmentState.test.ts @@ -0,0 +1,572 @@ +import { describe, expect, it } from "vitest"; + +import { + analysisViewCaptureEvidence, + environmentManifestCaptureFixture, + timedCaptureEvidence, +} from "../../../tests/fixtures/analysisView.js"; +import { + scenarioHeaderOnlyCaptureEvidence, + scenarioServeFixture, +} from "../../../tests/fixtures/browserScenarioCapture.js"; +import { + environmentServeFixture, + environmentServeSource, + environmentStateServeFixture, + environmentStateServeInputs, +} from "../../../tests/fixtures/environmentServe.js"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import type { JsonValue } from "../../domain/jsonValue.js"; +import { + inspectAnalysisView, + inspectAnalysisViewValidated, +} from "../analysisView/AnalysisViewService.js"; +import { loadEnvironmentSource } from "./environmentSource.js"; +import { + deriveEnvironmentState, + loadEnvironmentState, + prepareServedState, +} from "./environmentState.js"; +import { STATE_INTAKE_LIMIT_BYTES } from "./environmentStateDocument.js"; +import { EnvironmentStateStore } from "./environmentStateStore.js"; + +const fixture = environmentStateServeFixture(); +const { source, state } = environmentStateServeInputs(fixture); + +const refusal = (value: unknown, from = source) => { + const loaded = loadEnvironmentState(value, from); + if (loaded.ok) throw new Error("Expected the state to be refused"); + return projectAnalysisError(loaded.error); +}; + +interface Editable { + rea_state: Record & { + entities: Record[]; + documents: unknown[]; + }; +} + +/** The served state as a rea-state/v1 document, changed by `edit`. */ +const documentWith = (edit: (document: Editable) => void): Editable => { + const document = structuredClone( + new EnvironmentStateStore(state).snapshot(), + ) as unknown as Editable; + edit(document); + return document; +}; + +/** The pointer and message of the first problem the store reports. */ +const firstProblem = (document: unknown) => { + const store = new EnvironmentStateStore(state); + const replaced = store.replace(document); + if (replaced.ok) throw new Error("Expected the document to be refused"); + const [issue] = replaced.error.issues; + return { path: issue?.path.join("/"), message: issue?.message }; +}; + +describe("loadEnvironmentState", () => { + it("loads the state extracted from the served capture with the manifest's rules", () => { + expect(state.evidenceId).toBe(fixture.state.evidence_id); + expect(state.views.get("get_users_by_id")).toMatchObject({ + shape: "item", + envelope_ordinal: 5, + }); + expect(state.types.map((type) => type.collection_template)).toContain( + "/api/folders", + ); + expect(state.entities.length).toBeGreaterThan(0); + }); + + it("refuses non-Evidence JSON, another view kind, another capture's state and other template rules", () => { + const notEvidence = loadEnvironmentState({}, source); + if (notEvidence.ok || !(notEvidence.error instanceof AnalysisInputError)) + throw new Error("Expected an input refusal"); + expect( + notEvidence.error.issues.every((issue) => issue.path[0] === "state"), + ).toBe(true); + expect(refusal(fixture.manifest)).toMatchObject({ + details: { + issues: [ + { + path: ["state", "normalized_result", "kind"], + expected: "environment-state", + }, + ], + }, + }); + const otherCapture = analysisViewCaptureEvidence( + environmentManifestCaptureFixture(), + ); + const other = environmentStateServeFixture(otherCapture); + expect(refusal(other.state)).toMatchObject({ + details: { + issues: [ + { + path: ["state", "normalized_result", "parent_evidence_id"], + expected: source.evidenceId, + }, + ], + }, + }); + const ruleless = inspectAnalysisView({ + source: { kind: "inline", evidence: fixture.capture }, + view: { kind: "environment-state", template_rules: [] }, + }); + if (!ruleless.ok) throw ruleless.error; + const refused = refusal(ruleless.value); + expect(refused).toMatchObject({ + details: { + issues: [ + { path: ["state", "normalized_result", "view", "template_rules"] }, + ], + }, + }); + expect(refused.message).toContain("different template rules"); + }); + + it("refuses a state whose views name a tool the manifest lacks", () => { + const narrowed = { + ...source, + manifest: { + ...source.manifest, + tools: source.manifest.tools.filter( + (tool) => tool.name !== "get_users_by_id", + ), + }, + }; + const index = [...state.views.keys()].indexOf("get_users_by_id"); + expect(refusal(fixture.state, narrowed)).toMatchObject({ + details: { + issues: [ + { path: ["state", "normalized_result", "views", index, "tool"] }, + ], + }, + }); + // A manifest of another capture is refused by the parent check before any tool is compared. + expect( + loadEnvironmentState(fixture.state, environmentServeSource()).ok, + ).toBe(false); + }); +}); + +describe("deriveEnvironmentState", () => { + it("derives the state inspect-analysis-view projects from the capture with the manifest's rules, under the same Evidence id", () => { + const derived = deriveEnvironmentState(source, fixture.capture); + if (!derived.ok) throw derived.error; + expect(derived.value.evidenceId).toBe(fixture.state.evidence_id); + expect(derived.value).toEqual(state); + }); + + it("checks the derived state as a supplied one, refusing a capture the manifest was not projected from", () => { + const other = environmentServeFixture(); + const derived = deriveEnvironmentState(source, other.capture); + if (derived.ok) + throw new Error("Expected the other capture's state to be refused"); + expect(projectAnalysisError(derived.error)).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["state", "normalized_result", "parent_evidence_id"], + expected: source.evidenceId, + }, + ], + }, + }); + }); + + it("projects the authenticated capture as retained Evidence, so a capture inline admission refuses still yields its state", () => { + const capture = timedCaptureEvidence( + Array.from({ length: 20 }, (_, index) => [ + "GET", + `/api/items/${String(index)}`, + { id: index, text: "x".repeat(200_000) }, + ]), + ); + const inline = inspectAnalysisView({ + source: { kind: "inline", evidence: capture }, + view: { kind: "environment-state", template_rules: [] }, + }); + if (inline.ok) throw new Error("Expected inline admission to refuse"); + expect(projectAnalysisError(inline.error).code).toBe("resource_constraint"); + const retained = (kind: "environment-manifest" | "environment-state") => + inspectAnalysisViewValidated( + { + source: { + kind: "retained-evidence", + evidence_id: capture.evidence_id, + }, + view: + kind === "environment-state" + ? { kind, template_rules: [] } + : { + kind, + template_rules: [], + include_query_parameter_names: true, + }, + }, + (id) => (id === capture.evidence_id ? capture : undefined), + ); + const manifest = retained("environment-manifest"); + if (!manifest.ok) throw manifest.error; + const large = loadEnvironmentSource(manifest.value, capture); + if (!large.ok) throw large.error; + const derived = deriveEnvironmentState(large.value, capture); + if (!derived.ok) throw derived.error; + const byHand = retained("environment-state"); + if (!byHand.ok) throw byHand.error; + expect(derived.value.evidenceId).toBe(byHand.value.evidence_id); + }); +}); + +describe("prepareServedState", () => { + it("reports the served state Evidence", () => { + expect(prepareServedState(source, fixture.capture)).toEqual({ + state, + report: { served: true, evidence_id: fixture.state.evidence_id }, + }); + }); + + it("derives a state when a body leaves no room for the state's own nesting", () => { + let deep: JsonValue = { theme: "dark" }; + for (let level = 0; level < 995; level += 1) deep = { nested: deep }; + const deepFixture = environmentStateServeFixture( + timedCaptureEvidence([ + ["GET", "/api/settings", deep], + ["PUT", "/api/settings", { theme: "light" }], + ]), + ); + const deepSource = loadEnvironmentSource( + deepFixture.manifest, + deepFixture.capture, + ); + if (!deepSource.ok) throw deepSource.error; + expect( + prepareServedState(deepSource.value, deepFixture.capture).report, + ).toEqual({ + served: true, + evidence_id: deepFixture.state.evidence_id, + }); + }); + + it("serves replay and reports the code and message when the capture yields a manifest but no state", () => { + const headerOnly = scenarioServeFixture( + scenarioHeaderOnlyCaptureEvidence(), + ); + const scenario = loadEnvironmentSource( + headerOnly.manifest, + headerOnly.capture, + ); + if (!scenario.ok) throw scenario.error; + const served = prepareServedState(scenario.value, headerOnly.capture); + expect(served.state).toBeUndefined(); + expect(served.report).toEqual({ + served: false, + reason: "unavailable", + code: "unsupported_target", + message: expect.stringContaining( + "environment-state needs a capture_browser_scenario capture with capture.network.response_body selected", + ), + }); + }); +}); + +describe("EnvironmentStateStore", () => { + it("reports its state as rea-state/v1, accepts that report back and resets to the initial state", () => { + const store = new EnvironmentStateStore(state); + const initial = store.snapshot(); + expect(initial.rea_state).toMatchObject({ + version: 1, + state_evidence_id: state.evidenceId, + types: state.types, + entities: state.entities, + documents: state.documents, + }); + expect(store.replace(structuredClone(initial)).ok).toBe(true); + const emptied = store.replace({ + rea_state: { version: 1, entities: [], documents: [] }, + }); + expect(emptied.ok).toBe(true); + expect(store.entity("/api/users", "/api/users", "2")).toBeUndefined(); + expect(store.snapshot().rea_state.entities).toEqual([]); + store.reset(); + expect(store.snapshot()).toEqual(initial); + expect(store.entity("/api/users", "/api/users", "2")).toMatchObject({ + fields: { login: "probe" }, + }); + }); + + it("keeps collection order as the entities array orders it", () => { + const store = new EnvironmentStateStore(state); + const folders = state.entities.filter( + (entity) => entity.type === "/api/folders", + ); + expect( + store.replace({ + rea_state: { + version: 1, + entities: [...folders].reverse(), + documents: [], + }, + }).ok, + ).toBe(true); + expect( + store.inCollection("/api/folders", "/api/folders").map(({ id }) => id), + ).toEqual(folders.map(({ id }) => id).reverse()); + }); + + it("cites the Evidence's records only for entities whose values are the Evidence's", () => { + const store = new EnvironmentStateStore(state); + const user = ( + entities: readonly Entity[], + ) => + entities.find( + (entity) => entity.type === "/api/users" && entity.id === "2", + ); + const recorded = user(state.entities)!; + expect(recorded.record_ordinals.length).toBeGreaterThan(0); + expect( + store.replace( + documentWith((document) => { + for (const entity of document.rea_state.entities) { + if (entity.type !== "/api/users") delete entity.record_ordinals; + else if (entity.id === "2") + entity.fields = { ...(entity.fields as object), login: "edited" }; + } + }), + ).ok, + ).toBe(true); + const { entities } = store.snapshot().rea_state; + expect(user(entities)).toMatchObject({ + fields: { login: "edited" }, + record_ordinals: [], + }); + const folder = entities.find((entity) => entity.type === "/api/folders")!; + expect(folder.record_ordinals).toEqual( + state.entities.find( + (entity) => entity.type === "/api/folders" && entity.id === folder.id, + )?.record_ordinals, + ); + const index = state.entities.indexOf(recorded); + expect( + firstProblem( + documentWith((document) => { + document.rea_state.entities[index]!.record_ordinals = [424242]; + }), + ), + ).toEqual({ + path: `rea_state/entities/${String(index)}/record_ordinals`, + message: + "cites records the served Evidence does not cite for this entity", + }); + }); + + it("leaves the state unchanged when a document is refused", () => { + const store = new EnvironmentStateStore(state); + const before = store.snapshot(); + const refused = store.replace( + documentWith((document) => { + document.rea_state.entities = []; + document.rea_state.documents.push({ tool: "get_users_by_id" }); + }), + ); + expect(refused.ok).toBe(false); + expect(store.snapshot()).toEqual(before); + }); + + it("refuses a document that data/get would write back past what data/add loads", () => { + const store = new EnvironmentStateStore(state); + const { rea_state: held } = store.snapshot(); + // Without the members data/get adds back, the document fits only until they return. + const document = { + rea_state: { + version: held.version, + entities: held.entities.map(({ type, collection, id, fields }) => ({ + type, + collection, + id, + fields: { ...fields }, + })), + documents: held.documents, + }, + }; + const entity = document.rea_state.entities[0]!; + const { fields } = entity; + const idField = state.types.find( + (type) => type.collection_template === entity.type, + )?.id_field; + const text = Object.keys(fields).find( + (field) => field !== idField && typeof fields[field] === "string", + )!; + fields[text] = `${String(fields[text])}${"a".repeat( + STATE_INTAKE_LIMIT_BYTES - + Buffer.byteLength(JSON.stringify(document), "utf8"), + )}`; + expect(Buffer.byteLength(JSON.stringify(document), "utf8")).toBe( + STATE_INTAKE_LIMIT_BYTES, + ); + const before = store.snapshot(); + const replaced = store.replace(document); + expect(!replaced.ok && replaced.error.issues).toEqual([ + expect.objectContaining({ + reason: "out_of_range", + maximum: STATE_INTAKE_LIMIT_BYTES, + }), + ]); + expect(store.snapshot()).toEqual(before); + }); +}); + +describe("rea-state/v1 validation", () => { + it.each([ + [ + "an unknown type", + (document: Editable) => { + document.rea_state.entities[0]!.type = "/api/unknown"; + }, + "rea_state/entities/0/type", + ], + [ + "a collection outside its template", + (document: Editable) => { + document.rea_state.entities[3]!.collection = "/api/folders/extra"; + }, + "rea_state/entities/3/collection", + ], + [ + "an id its identity field does not carry", + (document: Editable) => { + document.rea_state.entities[2]!.id = "other"; + }, + "rea_state/entities/2/id", + ], + [ + "a repeated key", + (document: Editable) => { + document.rea_state.entities.push(document.rea_state.entities[0]!); + }, + `rea_state/entities/${String(state.entities.length)}/id`, + ], + [ + "view values for a view of another type", + (document: Editable) => { + document.rea_state.entities[2]!.view_fields = [ + { tool: "get_users_by_id", fields: {} }, + ]; + }, + "rea_state/entities/2/view_fields/0/tool", + ], + [ + "a document for a tool that is not a document view", + (document: Editable) => { + document.rea_state.documents.push({ + tool: "get_org", + path: "/api/org", + value: {}, + }); + }, + "rea_state/documents/0/tool", + ], + [ + "a member the format does not define", + (document: Editable) => { + document.rea_state.entities[1]!.secret = "never echoed"; + }, + "rea_state/entities/1", + ], + [ + "another state's Evidence id", + (document: Editable) => { + document.rea_state.state_evidence_id = "ev_other"; + }, + "rea_state/state_evidence_id", + ], + ])("names the first problem of %s by its path", (_, edit, path) => { + const problem = firstProblem(documentWith(edit)); + expect(problem.path).toBe(path); + expect(problem.message).not.toContain("never echoed"); + }); + + it("refuses view values that give an entity another identity, and accepts its own id spelled as either scalar", () => { + const index = state.entities.findIndex( + (entity) => entity.type === "/api/users" && entity.id === "2", + ); + const withView = (fields: Record) => + documentWith((document) => { + document.rea_state.entities[index]!.view_fields = [ + { tool: "get_users_by_id", fields }, + ]; + }); + for (const id of [3, "3", null]) + expect(firstProblem(withView({ id, login: "other" }))).toEqual({ + path: `rea_state/entities/${String(index)}/view_fields/0/fields`, + message: + "sets the type's id field to a value other than the entity's id", + }); + const store = new EnvironmentStateStore(state); + expect(store.replace(withView({ id: "2", login: "other" })).ok).toBe(true); + expect(store.entity("/api/users", "/api/users", "2")?.view_fields).toEqual([ + { tool: "get_users_by_id", fields: { id: "2", login: "other" } }, + ]); + }); + + it("refuses values parsing would change at the member holding them, without naming caller data", () => { + const marker = Symbol("not exact JSON"); + const problem = firstProblem( + documentWith((document) => { + document.rea_state.entities[0]!.fields = { + ...(document.rea_state.entities[0]!.fields as object), + "caller-named": marker, + }; + }), + ); + expect(problem).toEqual({ + path: "rea_state/entities/0/fields", + message: "holds a value that is not exactly representable JSON", + }); + for (const types of [[marker], [{ "caller-secret-key": marker }]]) + expect( + firstProblem( + documentWith((document) => { + document.rea_state.types = types; + }), + ), + ).toEqual({ + path: "rea_state/types", + message: "holds a value that is not exactly representable JSON", + }); + expect( + firstProblem( + documentWith((document) => { + document.rea_state.types = []; + }), + ), + ).toEqual({ + path: "rea_state/types", + message: "differs from the served types", + }); + const prototypeMember = (at: "entity" | "fields") => + firstProblem( + JSON.parse( + JSON.stringify(documentWith(() => undefined)).replace( + at === "entity" ? '"type":' : '"fields":{', + at === "entity" + ? '"__proto__":{"x":1},"type":' + : '"fields":{"__proto__":{"x":1},', + ), + ), + ); + expect(prototypeMember("entity")).toEqual({ + path: "rea_state/entities/0", + message: "has a member rea-state/v1 does not define", + }); + expect(prototypeMember("fields")).toMatchObject({ + path: "rea_state/entities/0/fields", + }); + expect(firstProblem([])).toMatchObject({ path: "" }); + expect( + firstProblem({ rea_state: { version: 2, entities: [], documents: [] } }), + ).toMatchObject({ path: "rea_state/version" }); + }); +}); diff --git a/src/application/environment/environmentState.ts b/src/application/environment/environmentState.ts new file mode 100644 index 0000000000..6d6b97e381 --- /dev/null +++ b/src/application/environment/environmentState.ts @@ -0,0 +1,248 @@ +import type { AnalysisViewResult } from "../../domain/analysisView/analysisView.js"; +import { + changesState, + type StateDocument, + type StateEntity, + type StateType, + type StateView, +} from "../../domain/analysisView/environmentStateRules.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + AnalysisInputIssue, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewService.js"; +import { + ENVIRONMENT_SERVE_OPERATION, + inputError, + parseEvidenceFile, + parseViewEvidence, + type EnvironmentManifestTool, + type EnvironmentSource, +} from "./environmentSource.js"; +import { + environmentToolDefinitions, + type EnvironmentDataPlane, + type EnvironmentToolDefinition, +} from "./environmentTools.js"; + +type EnvironmentStateView = Extract< + AnalysisViewResult, + { kind: "environment-state" } +>; + +/** The initial state an environment-state view extracted for the served manifest, authenticated and cross-checked. */ +export interface EnvironmentState { + readonly evidenceId: string; + readonly types: readonly StateType[]; + /** Render plans keyed by tool name. */ + readonly views: ReadonlyMap; + readonly entities: readonly StateEntity[]; + readonly documents: readonly StateDocument[]; +} + +/** Serving from the state is read-only: tools whose requests may change state are not served. */ +const servedFromState = (tool: EnvironmentManifestTool): boolean => + !changesState(tool.method, tool.path_template); + +/** The tools an environment serves and the data plane its card declares. */ +export interface ServedCatalog { + readonly tools: readonly EnvironmentToolDefinition[]; + readonly dataPlane: EnvironmentDataPlane; +} + +/** The catalog an environment serves: every manifest tool for replay, the read tools and the data plane from a state. */ +export const servedCatalog = ( + source: EnvironmentSource, + fromState: boolean, + name: string, + operation: string = ENVIRONMENT_SERVE_OPERATION, +): Result => { + const dataPlane: EnvironmentDataPlane = fromState ? "state" : "none"; + const tools = environmentToolDefinitions( + fromState + ? { + ...source.manifest, + tools: source.manifest.tools.filter(servedFromState), + } + : source.manifest, + source.evidenceId, + name, + { operation, dataPlane }, + ); + return tools.ok ? ok({ tools: tools.value, dataPlane }) : tools; +}; + +const refusal = ( + operation: string, + issue: AnalysisInputIssue, + userMessage: string, +): AnalysisError => + inputError( + operation, + [{ ...issue, path: ["state", "normalized_result", ...issue.path] }], + userMessage, + ); + +/** Why the state does not belong to the served manifest and capture, if it does not. */ +const stateMismatch = ( + operation: string, + view: EnvironmentStateView, + source: EnvironmentSource, +): AnalysisError | undefined => { + if (view.parent_evidence_id !== source.evidenceId) + return refusal( + operation, + { + path: ["parent_evidence_id"], + reason: "invalid_value", + expected: source.evidenceId, + message: `The state cites parent Evidence ${view.parent_evidence_id}; the evidence file is ${source.evidenceId}.`, + }, + "The state was extracted from a different capture than the supplied evidence file.", + ); + const rules = source.manifest.view.template_rules; + if ( + view.view.template_rules.length !== rules.length || + view.view.template_rules.some((rule, index) => rule !== rules[index]) + ) + return refusal( + operation, + { + path: ["view", "template_rules"], + reason: "invalid_value", + expected: [...rules], + message: + "The state must be extracted with the manifest's template_rules, in the same order, so its tool names are the manifest's.", + }, + "The state was extracted with different template rules than the manifest.", + ); + const tools = new Set(source.manifest.tools.map((tool) => tool.name)); + const index = view.views.findIndex((entry) => !tools.has(entry.tool)); + return index < 0 + ? undefined + : refusal( + operation, + { + path: ["views", index, "tool"], + reason: "invalid_value", + message: "Every state view must name a tool of the served manifest.", + }, + "The state names a tool the manifest does not have.", + ); +}; + +/** + * Load the environment-state Evidence for an already loaded manifest and + * capture, refusing a state extracted from another capture or with other + * template rules; refusals name the command that loaded it. + */ +export const loadEnvironmentState = ( + stateValue: unknown, + source: EnvironmentSource, + operation: string = ENVIRONMENT_SERVE_OPERATION, +): Result => { + const parsed = parseViewEvidence( + operation, + "state", + stateValue, + "environment-state", + ); + if (!parsed.ok) return parsed; + const { view, evidenceId } = parsed.value; + const mismatch = stateMismatch(operation, view, source); + if (mismatch !== undefined) return err(mismatch); + return ok({ + evidenceId, + types: view.types, + views: new Map(view.views.map((entry) => [entry.tool, entry])), + entities: view.entities, + documents: view.documents, + }); +}; + +/** Derive the state the capture observed with the manifest's rules, projected as retained Evidence, and check it as a supplied state. */ +export const deriveEnvironmentState = ( + source: EnvironmentSource, + captureValue: unknown, + operation: string = ENVIRONMENT_SERVE_OPERATION, +): Result => { + const capture = parseEvidenceFile(operation, "evidence", captureValue); + if (!capture.ok) return capture; + const captureId = capture.value.evidence_id; + const projected = inspectAnalysisViewValidated( + { + source: { kind: "retained-evidence", evidence_id: captureId }, + view: { + kind: "environment-state", + template_rules: [...source.manifest.view.template_rules], + }, + }, + (evidenceId) => (evidenceId === captureId ? capture.value : undefined), + ); + return projected.ok + ? loadEnvironmentState(projected.value, source, operation) + : projected; +}; + +/** What a command reports about the state: the state Evidence it serves, or why it serves replay. */ +export type StateServingReport = + | { readonly served: true; readonly evidence_id: string } + | { readonly served: false; readonly reason: "replay-only" } + | { + readonly served: false; + readonly reason: "unavailable"; + readonly code: string; + readonly message: string; + }; + +/** The state an environment serves, if any, and how its command reports it. */ +export interface ServedState { + readonly state: EnvironmentState | undefined; + readonly report: StateServingReport; +} + +/** What preparing the state yields: served or unavailable, never replay-only, which only the serve command chooses. */ +export interface PreparedState extends ServedState { + readonly report: Exclude< + StateServingReport, + { readonly reason: "replay-only" } + >; +} + +const unavailable = (error: AnalysisError): PreparedState => { + const { code, message } = projectAnalysisError(error); + return { + state: undefined, + report: { served: false, reason: "unavailable", code, message }, + }; +}; + +/** Serve the derived state when its catalog fits under the served name; otherwise serve replay and report why. */ +export const prepareServedState = ( + source: EnvironmentSource, + captureValue: unknown, + options: { + readonly name?: string | undefined; + readonly operation?: string; + } = {}, +): PreparedState => { + const operation = options.operation ?? ENVIRONMENT_SERVE_OPERATION; + // The state catalog depends only on the manifest and name, so checking it first spares deriving a state that cannot be served. + const catalog = servedCatalog( + source, + true, + options.name ?? source.manifest.name, + operation, + ); + if (!catalog.ok) return unavailable(catalog.error); + const derived = deriveEnvironmentState(source, captureValue, operation); + return derived.ok + ? { + state: derived.value, + report: { served: true, evidence_id: derived.value.evidenceId }, + } + : unavailable(derived.error); +}; diff --git a/src/application/environment/environmentStateAnswer.test.ts b/src/application/environment/environmentStateAnswer.test.ts new file mode 100644 index 0000000000..cc4d9e588f --- /dev/null +++ b/src/application/environment/environmentStateAnswer.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, it } from "vitest"; + +import { + timedCaptureEvidence, + type TimedCaptureRow, +} from "../../../tests/fixtures/analysisView.js"; +import { + environmentStateServeFixture, + environmentStateServeInputs, +} from "../../../tests/fixtures/environmentServe.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { REPLAY_BODY_LIMIT_BYTES } from "./environmentReplay.js"; +import type { EnvironmentSource } from "./environmentSource.js"; +import { + answerFromState, + type StateServing, +} from "./environmentStateAnswer.js"; +import { EnvironmentStateStore } from "./environmentStateStore.js"; + +const grafana = environmentStateServeInputs(); + +const servingOf = ( + inputs: typeof grafana = grafana, +): StateServing & { readonly store: EnvironmentStateStore } => ({ + source: inputs.source, + store: new EnvironmentStateStore(inputs.state), +}); + +/** Serve the state the view extracts from a synthetic timed capture. */ +const servingFrom = (rows: readonly TimedCaptureRow[]) => + servingOf( + environmentStateServeInputs( + environmentStateServeFixture(timedCaptureEvidence(rows)), + ), + ); + +const toolOf = (source: EnvironmentSource, name: string) => { + const found = source.manifest.tools.find((tool) => tool.name === name); + if (found === undefined) throw new Error(`Fixture manifest lacks ${name}`); + return found; +}; + +const answer = ( + serving: StateServing, + name: string, + args: Readonly>, +) => { + const result = answerFromState(serving, toolOf(serving.source, name), args); + if (!result.ok) throw result.error; + return result.value; +}; + +const refusal = ( + serving: StateServing, + name: string, + args: Readonly>, +) => { + const result = answerFromState(serving, toolOf(serving.source, name), args); + if (result.ok) throw new Error(`Expected ${name} to be refused`); + return projectAnalysisError(result.error); +}; + +/** Replace the served state with the initial one changed by `edit`. */ +const load = ( + serving: ReturnType, + edit: (entities: Record[]) => Record[], + documents: unknown[] = [], +): void => { + const initial = structuredClone(serving.store.snapshot().rea_state); + const replaced = serving.store.replace({ + rea_state: { + version: 1, + entities: edit(initial.entities as unknown as Record[]), + documents, + }, + }); + if (!replaced.ok) throw replaced.error; +}; + +describe("item answers", () => { + it("renders the named entity into the recorded envelope with the base record's provenance", () => { + const serving = servingOf(); + const probe = answer(serving, "get_users_by_id", { id: "2" }); + expect(probe).toMatchObject({ + status: 200, + content_type: "application/json", + body: { id: 2, login: "probe", email: "probe@example.test" }, + replay: { record_ordinal: 5, exact_match: true, truncated: false }, + state: { + source: "state", + view: "item", + type: "/api/users", + collection: "/api/users", + id: "2", + }, + }); + // The envelope comes from one record; another entity is still answered from the state. + expect(answer(serving, "get_users_by_id", { id: "3" })).toMatchObject({ + body: { login: "viewer" }, + replay: { record_ordinal: 5, exact_match: false }, + }); + expect( + answer(serving, "get_dashboards_by_uid", { uid: "acb4v6" }), + ).toMatchObject({ + body: { + meta: { type: "db", folderUid: "fg1jo91e57e2ob" }, + dashboard: { uid: "acb4v6", title: "probe dashboard", panels: [] }, + }, + }); + load(serving, (entities) => + entities.map((entity) => + entity.type === "/api/users" && entity.id === "3" + ? { + ...entity, + fields: { ...(entity.fields as object), login: "renamed" }, + } + : entity, + ), + ); + expect(answer(serving, "get_users_by_id", { id: "3" })).toMatchObject({ + body: { login: "renamed" }, + }); + }); + + it("refuses an entity the state does not hold or one missing a field the view requires", () => { + const serving = servingOf(); + expect(refusal(serving, "get_users_by_id", { id: "999" })).toMatchObject({ + code: "invalid_request", + message: expect.stringContaining("holds no /api/users entity 999"), + }); + load(serving, (entities) => + entities.map((entity) => { + if (entity.type !== "/api/users" || entity.id !== "2") return entity; + const { email: _email, ...fields } = entity.fields as Record< + string, + unknown + >; + return { ...entity, fields }; + }), + ); + expect(refusal(serving, "get_users_by_id", { id: "2" })).toMatchObject({ + message: expect.stringContaining("lacks fields"), + }); + expect(refusal(serving, "get_users_by_id", {})).toMatchObject({ + message: expect.stringContaining("needs every path argument"), + }); + }); + + it("matches path arguments as replay does, verbatim or percent-decoded, without re-splitting them", () => { + const files = servingFrom([ + ["GET", "/api/files/a%2Fb1", { id: "a/b1", name: "x" }], + ["GET", "/api/files/c2", { id: "c2", name: "y" }], + ["GET", "/api/files/d3", { id: "d3", name: "z" }], + ]); + for (const file_id of ["a/b1", "a%2Fb1"]) + expect(answer(files, "get_files_by_file_id", { file_id })).toMatchObject({ + body: { id: "a/b1", name: "x" }, + state: { source: "state", collection: "/api/files", id: "a/b1" }, + }); + const orgs = servingFrom([ + ["GET", "/api/orgs/my%20org1/users/1", { id: 1, name: "x" }], + ["GET", "/api/orgs/org2/users/2", { id: 2, name: "y" }], + ["GET", "/api/orgs/org3/users/3", { id: 3, name: "z" }], + ]); + for (const org_id of ["my org1", "my%20org1"]) + expect( + answer(orgs, "get_orgs_users_by_org_id_id", { org_id, id: "1" }), + ).toMatchObject({ + body: { id: 1, name: "x" }, + replay: { exact_match: true }, + state: { collection: "/api/orgs/my%20org1/users", id: "1" }, + }); + expect( + refusal(orgs, "get_orgs_users_by_org_id_id", { org_id: "org2", id: "1" }), + ).toMatchObject({ + message: expect.stringContaining( + "holds no /api/orgs/{org_id}/users entity 1 in /api/orgs/org2/users", + ), + }); + }); +}); + +describe("list answers", () => { + it("lists every entity of the collection in state order when membership is all", () => { + const serving = servingOf(); + const args = { uid: "acb4v6" }; + expect( + answer(serving, "get_dashboards_versions_by_uid", args), + ).toMatchObject({ + body: { versions: [{ id: 1, version: 1 }] }, + replay: { record_ordinal: 11, exact_match: true }, + state: { + source: "state", + view: "list", + collection: "/api/dashboards/uid/acb4v6/versions", + membership: "all", + listed: 1, + }, + }); + load(serving, (entities) => [ + ...entities, + { + type: "/api/dashboards/uid/{uid}/versions", + collection: "/api/dashboards/uid/acb4v6/versions", + id: "2", + fields: { id: 2, version: 2 }, + }, + ]); + expect( + answer(serving, "get_dashboards_versions_by_uid", args).body, + ).toEqual({ + versions: [ + { id: 1, version: 1 }, + { id: 2, version: 2 }, + ], + }); + }); + + it("lists the recorded members that remain when membership is recorded", () => { + const serving = servingOf(); + const args = { limit: "100", page: "1" }; + expect(answer(serving, "get_folders", args)).toMatchObject({ + body: [{ id: 5, uid: "fg1jo91e57e2ob", title: "probe folder" }], + state: { view: "list", membership: "recorded", listed: 1 }, + }); + load(serving, (entities) => + entities.filter((entity) => entity.id !== "fg1jo91e57e2ob"), + ); + expect(answer(serving, "get_folders", args)).toMatchObject({ + body: [], + state: { listed: 0 }, + }); + }); + + it("renders only the lines its view checked, never a later or filtered recording", () => { + const teams = servingFrom([ + ["GET", "/api/teams", [{ id: 1 }, { id: 2 }]], + ["POST", "/api/teams", { id: 3 }], + ["GET", "/api/teams?perpage=1", [{ id: 1 }]], + ]); + expect(answer(teams, "get_teams", {})).toMatchObject({ + body: [{ id: 1 }, { id: 2 }], + replay: { record_ordinal: 0 }, + state: { source: "state", membership: "all", listed: 2 }, + }); + expect(answer(teams, "get_teams", { perpage: "1" })).toMatchObject({ + body: [{ id: 1 }], + replay: { record_ordinal: 2 }, + state: { source: "recording", reason: "unrecorded-request" }, + }); + // Replay's tie-break picks the first line here, but the arguments name the second exactly. + const items = servingFrom([ + ["GET", "/api/items?a=1&b=2", [{ id: 1 }]], + ["GET", "/api/items?a=1", [{ id: 1 }, { id: 2 }]], + ]); + expect(answer(items, "get_items", { a: "1" })).toMatchObject({ + body: [{ id: 1 }, { id: 2 }], + replay: { record_ordinal: 1, recorded_query: "?a=1" }, + state: { source: "state", membership: "recorded", listed: 2 }, + }); + expect(answer(items, "get_items", { a: "1", b: "2" })).toMatchObject({ + body: [{ id: 1 }], + replay: { record_ordinal: 0 }, + state: { source: "state", listed: 1 }, + }); + }); + + it("refuses a list whose member lacks a field every recording carried, never answering a partial body", () => { + const serving = servingOf(); + load(serving, (entities) => + entities.map((entity) => { + if (entity.type !== "/api/folders" || entity.id !== "fg1jo91e57e2ob") + return entity; + const { title: _title, ...fields } = entity.fields as Record< + string, + unknown + >; + return { ...entity, fields }; + }), + ); + expect( + refusal(serving, "get_folders", { limit: "100", page: "1" }), + ).toMatchObject({ + code: "invalid_request", + message: expect.stringContaining( + "holds no complete /api/folders entity fg1jo91e57e2ob in /api/folders: it lacks fields this tool's responses always carried: title", + ), + }); + }); + + it("answers an unrecorded request line from its recording", () => { + const serving = servingOf(); + load(serving, () => []); + for (const args of [{ limit: "50", page: "1" }, { limit: "100" }, {}]) + expect(answer(serving, "get_folders", args)).toMatchObject({ + body: [{ uid: "fg1jo91e57e2ob" }], + state: { source: "recording", reason: "unrecorded-request" }, + }); + }); +}); + +describe("recorded and document answers", () => { + it("replays recorded views and tools without a view, saying why", () => { + const serving = servingOf(); + expect(answer(serving, "get_annotations", {})).toMatchObject({ + replay: { record_ordinal: 25 }, + state: { source: "recording", reason: "no-init-recording" }, + }); + expect(answer(serving, "post_folders", {})).toMatchObject({ + state: { source: "recording", reason: "no-view" }, + }); + }); + + it("answers a document's recorded line with the state's value for it", () => { + const capture = timedCaptureEvidence([ + ["GET", "/api/settings", { theme: "dark", density: "compact" }], + ["PUT", "/api/settings", { theme: "light", density: "compact" }], + ["GET", "/api/settings", { theme: "light", density: "compact" }], + ]); + const serving = servingOf( + environmentStateServeInputs(environmentStateServeFixture(capture)), + ); + expect(answer(serving, "get_settings", {})).toMatchObject({ + body: { theme: "dark", density: "compact" }, + replay: { record_ordinal: 0, exact_match: true }, + state: { source: "state", view: "document", path: "/api/settings" }, + }); + load(serving, (entities) => entities, [ + { tool: "get_settings", path: "/api/settings", value: { theme: "blue" } }, + ]); + expect(answer(serving, "get_settings", {}).body).toEqual({ theme: "blue" }); + // Only the Evidence's own value cites its record; an edited one cites none and an invented citation is refused. + expect(serving.store.snapshot().rea_state.documents).toEqual([ + { tool: "get_settings", path: "/api/settings", value: { theme: "blue" } }, + ]); + load(serving, (entities) => entities, [ + { + tool: "get_settings", + path: "/api/settings", + value: { theme: "dark", density: "compact" }, + }, + ]); + expect(serving.store.snapshot().rea_state.documents).toEqual([ + { + tool: "get_settings", + path: "/api/settings", + value: { theme: "dark", density: "compact" }, + record_ordinal: 0, + }, + ]); + const invented = serving.store.replace({ + rea_state: { + version: 1, + entities: [], + documents: [ + { + tool: "get_settings", + path: "/api/settings", + value: { theme: "blue" }, + record_ordinal: 2, + }, + ], + }, + }); + expect(invented.ok || invented.error.issues[0]?.path).toEqual([ + "rea_state", + "documents", + 0, + "record_ordinal", + ]); + load(serving, (entities) => entities, [ + { + tool: "get_settings", + path: "/api/settings", + value: "x".repeat(REPLAY_BODY_LIMIT_BYTES), + }, + ]); + expect(refusal(serving, "get_settings", {})).toMatchObject({ + code: "resource_constraint", + details: { + reported_limits: { max_body_bytes: REPLAY_BODY_LIMIT_BYTES }, + }, + }); + load(serving, (entities) => entities); + expect(refusal(serving, "get_settings", {})).toMatchObject({ + message: expect.stringContaining("holds no document"), + }); + }); +}); diff --git a/src/application/environment/environmentStateAnswer.ts b/src/application/environment/environmentStateAnswer.ts new file mode 100644 index 0000000000..dbd6b45535 --- /dev/null +++ b/src/application/environment/environmentStateAnswer.ts @@ -0,0 +1,425 @@ +import { stateBodyJson } from "../../domain/analysisView/EnvironmentStateAccumulator.js"; +import { + collectionPathOf, + listItems, + renderStateEntity, + renderStateList, + replaceAt, + scalarText, + segmentsOf, + type JsonObject, + type RecordedViewReason, + type StateEntity, + type StateItemView, + type StateListView, + type StateView, +} from "../../domain/analysisView/environmentStateRules.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import type { JsonValue } from "../../domain/jsonValue.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { + decodedPathSegment, + pathValueMatches, + REPLAY_BODY_LIMIT_BYTES, + replayAnswer, + replayEnvironmentTool, + selectReplayExchange, + selectsRecordedLine, + type ReplayArguments, + type ReplayResult, +} from "./environmentReplay.js"; +import { + ENVIRONMENT_SERVE_OPERATION, + type EnvironmentManifestTool, + type EnvironmentSource, + type RecordedExchange, +} from "./environmentSource.js"; +import type { EnvironmentStateStore } from "./environmentStateStore.js"; +import { environmentToolArguments } from "./environmentTools.js"; + +/** Where a served answer came from: the live state, or a recording and why. */ +export type StateProvenance = + | { + readonly source: "recording"; + readonly reason: RecordedViewReason | "no-view" | "unrecorded-request"; + } + | { + readonly source: "state"; + readonly view: "item"; + readonly type: string; + readonly collection: string; + readonly id: string; + } + | { + readonly source: "state"; + readonly view: "list"; + readonly type: string; + readonly collection: string; + readonly membership: "all" | "recorded"; + readonly listed: number; + } + | { + readonly source: "state"; + readonly view: "document"; + readonly path: string; + }; + +/** A replay answer that also says whether the state or a recording produced it. */ +export interface StateAnswer extends ReplayResult { + readonly state: StateProvenance; +} + +/** The manifest and capture a state answer cites, and the live state it reads. */ +export interface StateServing { + readonly source: EnvironmentSource; + readonly store: EnvironmentStateStore; +} + +const refused = ( + tool: EnvironmentManifestTool, + userMessage: string, +): AnalysisError => + new AnalysisInputError(ENVIRONMENT_SERVE_OPERATION, { userMessage }, [ + { path: ["tool"], reason: "invalid_value", expected: tool.name }, + ]); + +/** One entity as an item or list view shows it, or a refusal when it lacks a field the view's every recording carried; a partial body is never answered. */ +const shownEntity = ( + tool: EnvironmentManifestTool, + view: StateItemView | StateListView, + entity: StateEntity, + volatile: ReadonlySet, +): Result => { + const shown = renderStateEntity(entity, view, volatile); + const missing = view.required_fields.filter( + (name) => !Object.hasOwn(shown, name), + ); + return missing.length === 0 + ? ok(shown) + : err( + refused( + tool, + `The state holds no complete ${view.type} entity ${entity.id} in ${entity.collection}: it lacks fields this tool's responses always carried: ${missing.join(", ")}.`, + ), + ); +}; + +/** The recorded answer, labelled with why the state did not produce it. */ +const fromRecording = ( + source: EnvironmentSource, + tool: EnvironmentManifestTool, + args: ReplayArguments, + reason: Extract["reason"], +): Result => { + const replayed = replayEnvironmentTool(source, tool, args); + return replayed.ok + ? ok({ ...replayed.value, state: { source: "recording", reason } }) + : replayed; +}; + +/** A recorded body the state renders into, usable exactly when extraction could use it. */ +const recordedJson = (exchange: RecordedExchange): JsonValue | undefined => + stateBodyJson(exchange.contentType, exchange); + +/** The rendered body on the base record's status, content type and provenance; never cut. */ +const stateAnswer = ( + serving: StateServing, + base: { readonly exchange: RecordedExchange; readonly exact: boolean }, + body: JsonValue, + state: StateProvenance, +): Result => { + const bytes = Buffer.byteLength(JSON.stringify(body), "utf8"); + if (bytes > REPLAY_BODY_LIMIT_BYTES) + return err( + new AnalysisResourceConstraintError( + ENVIRONMENT_SERVE_OPERATION, + "capacity", + "The answer rendered from the state exceeds the reply body limit", + { max_body_bytes: REPLAY_BODY_LIMIT_BYTES, body_bytes: bytes }, + { + retryable: false, + remediationAction: + "Load a smaller state with data/add; the state is not cut to fit.", + }, + ), + ); + return ok({ + ...replayAnswer(serving.source, base.exchange, base.exact, { + body, + truncated: false, + }), + state, + }); +}; + +/** One collection segment an item call names: a template literal, or a supplied path value. */ +interface CallSegment { + readonly text: string; + readonly supplied: boolean; +} + +/** An item call's collection segments and identity value, kept as supplied rather than joined and re-split. */ +interface ItemCall { + readonly segments: readonly CallSegment[]; + readonly id: string; +} + +/** What an item call names, or undefined when a path argument is missing. */ +const itemCall = ( + tool: EnvironmentManifestTool, + args: ReplayArguments, +): ItemCall | undefined => { + const names = environmentToolArguments(tool).path; + const params = new Map(tool.params.map((param) => [param.index, param])); + const segments: CallSegment[] = []; + const template = tool.path_template + .slice(tool.path_template.indexOf("/") + 1) + .split("/"); + for (const [index, text] of template.entries()) { + const param = params.get(index); + if (param === undefined) { + if (text !== "") segments.push({ text, supplied: false }); + continue; + } + const value = args[names.get(param.name) ?? param.name]; + if (typeof value !== "string") return undefined; + segments.push({ text: value, supplied: true }); + } + const last = segments.pop(); + return last?.supplied === true ? { segments, id: last.text } : undefined; +}; + +/** How many supplied segments a held collection matches verbatim, when it matches the call under replay's path rule. */ +const collectionMatch = ( + collection: string, + segments: readonly CallSegment[], +): number | undefined => { + const held = segmentsOf(collection); + if (held.length !== segments.length) return undefined; + let verbatim = 0; + for (const [index, { text, supplied }] of segments.entries()) { + const segment = held[index]!; + if (supplied ? !pathValueMatches(segment, text) : segment !== text) + return undefined; + if (supplied && segment === text) verbatim += 1; + } + return verbatim; +}; + +/** The entity an item call names, preferring verbatim collection spellings as replay does; the id is tried percent-decoded, then as written. */ +const namedEntity = ( + store: EnvironmentStateStore, + type: string, + call: ItemCall, +): StateEntity | undefined => { + const ids = [...new Set([decodedPathSegment(call.id), call.id])]; + const candidates = store + .collectionsOf(type) + .flatMap((collection) => { + const verbatim = collectionMatch(collection, call.segments); + return verbatim === undefined ? [] : [{ collection, verbatim }]; + }) + .sort((left, right) => right.verbatim - left.verbatim); + for (const { collection } of candidates) + for (const id of ids) { + const entity = store.entity(type, collection, id); + if (entity !== undefined) return entity; + } + return undefined; +}; + +/** An item call renders the named entity into the recorded envelope; it never invents an entity. */ +const answerItem = ( + serving: StateServing, + tool: EnvironmentManifestTool, + view: StateItemView, + args: ReplayArguments, +): Result => { + const { source, store } = serving; + const call = itemCall(tool, args); + if (call === undefined) + return err(refused(tool, `Tool ${tool.name} needs every path argument.`)); + const entity = namedEntity(store, view.type, call); + if (entity === undefined) + return err( + refused( + tool, + `The state holds no ${view.type} entity ${call.id} in /${call.segments.map(({ text }) => text).join("/")}.`, + ), + ); + const shown = shownEntity( + tool, + view, + entity, + new Set(store.type(view.type)?.volatile_fields), + ); + if (!shown.ok) return shown; + const base = source.exchanges.get(view.envelope_ordinal); + const envelope = base === undefined ? undefined : recordedJson(base); + if (base === undefined || envelope === undefined) + return err( + refused(tool, `Tool ${tool.name} has no usable recorded envelope.`), + ); + const exact = + selectReplayExchange(tool, new Map([[base.ordinal, base]]), args)?.exact === + true; + return stateAnswer( + serving, + { exchange: base, exact }, + replaceAt(envelope, view.pointer, shown.value), + { + source: "state", + view: "item", + type: view.type, + collection: entity.collection, + id: entity.id, + }, + ); +}; + +/** The checked recorded line a list or document call names exactly, chosen among them as replay chooses; later or filtered recordings never qualify. */ +const checkedLine = ( + source: EnvironmentSource, + tool: EnvironmentManifestTool, + checked: readonly number[], + args: ReplayArguments, +): RecordedExchange | undefined => { + const lines = new Map(); + for (const ordinal of checked) { + const exchange = source.exchanges.get(ordinal); + if (exchange !== undefined && selectsRecordedLine(tool, exchange, args)) + lines.set(ordinal, exchange); + } + return selectReplayExchange(tool, lines, args)?.exchange; +}; + +type StateDocumentView = Extract; + +/** One tool call: the tool and the arguments it was called with. */ +interface ToolCall { + readonly tool: EnvironmentManifestTool; + readonly args: ReplayArguments; +} + +/** A list call renders the members of the checked line it names from the state. */ +const answerList = ( + serving: StateServing, + { tool, args }: ToolCall, + view: StateListView, + base: RecordedExchange, +): Result => { + const { source, store } = serving; + const envelope = recordedJson(base); + const recorded = + envelope === undefined ? undefined : listItems(envelope, view.pointer); + if (envelope === undefined || recorded === undefined) + return fromRecording(source, tool, args, "body-unusable"); + const collection = collectionPathOf(base, false); + const type = store.type(view.type); + const members: readonly StateEntity[] = + view.membership === "all" + ? store.inCollection(view.type, collection) + : recorded.flatMap((item) => { + const id = scalarText(item[type?.id_field ?? ""]); + const entity = + id === undefined + ? undefined + : store.entity(view.type, collection, id); + return entity === undefined ? [] : [entity]; + }); + const volatile = new Set(type?.volatile_fields); + const items: JsonObject[] = []; + for (const entity of members) { + const shown = shownEntity(tool, view, entity, volatile); + if (!shown.ok) return shown; + items.push(shown.value); + } + return stateAnswer( + serving, + { exchange: base, exact: true }, + renderStateList(view, envelope, items), + { + source: "state", + view: "list", + type: view.type, + collection, + membership: view.membership, + listed: items.length, + }, + ); +}; + +/** A document call answers the state's value for the document on its checked line's status and provenance. */ +const answerDocument = ( + serving: StateServing, + tool: EnvironmentManifestTool, + base: RecordedExchange, +): Result => { + const current = serving.store.document(tool.name); + if (current === undefined) + return err(refused(tool, `The state holds no document for ${tool.name}.`)); + return stateAnswer(serving, { exchange: base, exact: true }, current.value, { + source: "state", + view: "document", + path: current.path, + }); +}; + +/** A list or document call that names a checked line answers from the state; one that names none answers from its recording. */ +const answerCheckedLine = ( + serving: StateServing, + tool: EnvironmentManifestTool, + view: StateListView | StateDocumentView, + args: ReplayArguments, +): Result => { + const base = checkedLine( + serving.source, + tool, + view.checked_record_ordinals, + args, + ); + if (base === undefined) + return fromRecording(serving.source, tool, args, "unrecorded-request"); + return view.shape === "list" + ? answerList(serving, { tool, args }, view, base) + : answerDocument(serving, tool, base); +}; + +/** Answer one read tool call from the live state where its view is state-backed, else from its recordings. */ +export const answerFromState = ( + serving: StateServing, + tool: EnvironmentManifestTool, + args: ReplayArguments, +): Result => { + const view = serving.store.state.views.get(tool.name); + if (view === undefined) + return fromRecording(serving.source, tool, args, "no-view"); + switch (view.shape) { + case "recorded": + return fromRecording(serving.source, tool, args, view.reason); + case "item": + return answerItem(serving, tool, view, args); + case "list": + case "document": + return answerCheckedLine(serving, tool, view, args); + } +}; + +/** How a served environment answers one tool call. */ +export type EnvironmentToolAnswer = ( + tool: EnvironmentManifestTool, + args: ReplayArguments, +) => Result; + +/** The answer a served environment gives: replay without a state store, the live state with one. */ +export const environmentToolAnswer = ( + source: EnvironmentSource, + store: EnvironmentStateStore | undefined, +): EnvironmentToolAnswer => + store === undefined + ? (tool, args) => replayEnvironmentTool(source, tool, args) + : (tool, args) => answerFromState({ source, store }, tool, args); diff --git a/src/application/environment/environmentStateDocument.ts b/src/application/environment/environmentStateDocument.ts new file mode 100644 index 0000000000..d4d21603a3 --- /dev/null +++ b/src/application/environment/environmentStateDocument.ts @@ -0,0 +1,391 @@ +import { z } from "zod"; + +import { + isParam, + normalizedPath, + same, + scalarText, + segmentsOf, + type StateDocument, + type StateEntity, + type StateType, +} from "../../domain/analysisView/environmentStateRules.js"; +import { + AnalysisInputError, + type AnalysisInputIssue, +} from "../../domain/analysisErrorCore.js"; +import { canonicalJson } from "../../domain/comparisonSemantics.js"; +import { projectInputIssues } from "../../domain/inputIssueProjection.js"; +import { jsonValueSchema } from "../../domain/jsonValue.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; +import type { EnvironmentState } from "./environmentState.js"; +import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js"; +import { REA_STATE_FORMAT } from "./environmentTools.js"; + +/** A loaded state document is at most this large, whether it arrives as bytes, a file or a download. */ +export const STATE_INTAKE_LIMIT_BYTES = 32 * 1024 * 1024; + +/** A document as the state holds it; only a value equal to the Evidence's cites the record it came from. */ +export type HeldDocument = Omit & { + readonly record_ordinal?: number; +}; + +/** A validated whole state, ready to replace the current one. */ +export interface StateContent { + readonly entities: readonly StateEntity[]; + readonly documents: readonly HeldDocument[]; +} + +const fieldsSchema = z.record(z.string(), jsonValueSchema); +const ordinalSchema = z.number().int().nonnegative(); + +const stateDocumentSchema = z.strictObject({ + rea_state: z.strictObject({ + version: z.literal(1), + // `data/get` reports these; accepting them back lets its output be loaded unchanged. + state_evidence_id: z.string().optional(), + types: z.array(jsonValueSchema).optional(), + entities: z.array( + z.strictObject({ + type: z.string(), + collection: z.string(), + id: z.string(), + fields: fieldsSchema, + view_fields: z + .array(z.strictObject({ tool: z.string(), fields: fieldsSchema })) + .optional(), + record_ordinals: z.array(ordinalSchema).optional(), + }), + ), + documents: z.array( + z.strictObject({ + tool: z.string(), + path: z.string(), + value: jsonValueSchema, + record_ordinal: ordinalSchema.optional(), + }), + ), + }), +}); + +type ParsedDocument = z.output["rea_state"]; +type ParsedEntity = ParsedDocument["entities"][number]; +type Path = readonly (string | number)[]; + +const problem = (path: Path, message: string): AnalysisInputIssue => ({ + path, + reason: "invalid_value", + message, +}); + +/** Members whose values are the caller's data; a problem inside one is reported at the member, never deeper. */ +const CONTENT_MEMBERS: ReadonlySet = new Set([ + "fields", + "value", + "types", +]); + +const contentIndex = (path: Path): number => + path.findIndex((segment) => CONTENT_MEMBERS.has(segment)); + +/** A problem inside the caller's data is reported at the content member holding it; one in a structural object, at that object. */ +const memberProblem = (path: Path): AnalysisInputIssue => { + const content = contentIndex(path); + return content >= 0 && content < path.length - 1 + ? problem( + path.slice(0, content + 1), + "holds a value that is not exactly representable JSON", + ) + : problem( + path.slice(0, -1), + `has a member ${REA_STATE_FORMAT} does not define`, + ); +}; + +/** The path of the first `__proto__` member in document order; schema parsing would drop such a member rather than refuse it. */ +const prototypeMemberPath = (root: unknown): Path | undefined => { + interface Node { + readonly value: unknown; + readonly parent: Node | undefined; + readonly key: string | number; + } + const pathOf = (node: Node): (string | number)[] => { + const path: (string | number)[] = []; + for (let at: Node = node; at.parent !== undefined; at = at.parent) + path.push(at.key); + return path.reverse(); + }; + const pending: Node[] = [{ value: root, parent: undefined, key: "" }]; + for (let node = pending.pop(); node !== undefined; node = pending.pop()) { + const { value } = node; + if (typeof value !== "object" || value === null) continue; + const array = Array.isArray(value); + if (!array && Object.hasOwn(value, "__proto__")) + return [...pathOf(node), "__proto__"]; + const entries = Object.entries(value); + for (let index = entries.length - 1; index >= 0; index -= 1) + pending.push({ + value: entries[index]![1], + parent: node, + key: array ? index : entries[index]![0], + }); + } + return undefined; +}; + +/** The first schema problem, located without naming the caller's data. */ +const schemaProblem = ( + issues: readonly z.core.$ZodIssue[], + value: unknown, +): AnalysisInputIssue => { + const first = projectInputIssues(issues, value)[0]; + if (first === undefined) + return problem([], `is not a ${REA_STATE_FORMAT} document`); + const content = contentIndex(first.path); + if ( + (content >= 0 && content < first.path.length - 1) || + first.reason === "unknown_argument" + ) + return memberProblem(first.path); + if (first.reason === "missing_argument") + return { ...first, message: "is required" }; + return { + ...first, + message: + content >= 0 && first.reason !== "invalid_type" + ? "is not exactly representable JSON" + : `is not a valid ${REA_STATE_FORMAT} value for this member`, + }; +}; + +/** Whether a concrete collection path instantiates a template; parameters match any one segment. */ +const instantiates = (collection: string, template: string): boolean => { + const concrete = segmentsOf(collection); + const pattern = segmentsOf(template); + return ( + normalizedPath(collection) === collection && + concrete.length === pattern.length && + pattern.every( + (segment, index) => isParam(segment) || segment === concrete[index], + ) + ); +}; + +/** What validating one entity reads: the served state, its Evidence entities by key and the keys already seen. */ +interface EntityCheck { + readonly state: EnvironmentState; + readonly initial: ReadonlyMap; + readonly seen: Set; +} + +const entityKey = (entity: Pick) => + compositeKey([entity.type, entity.collection, entity.id]); + +/** The first problem with an entity's view values: each names an item or list view of its type once and keeps its identity. */ +const viewFieldsProblem = ( + state: EnvironmentState, + type: StateType, + entity: ParsedEntity, + path: Path, +): AnalysisInputIssue | undefined => { + const tools = new Set(); + for (const [index, view] of (entity.view_fields ?? []).entries()) { + const plan = state.views.get(view.tool); + if ( + (plan?.shape !== "item" && plan?.shape !== "list") || + plan.type !== entity.type || + tools.has(view.tool) + ) + return problem( + [...path, "view_fields", index, "tool"], + "is not an item or list view of the entity's type, or repeats one", + ); + tools.add(view.tool); + // Views render these values over the entity's fields, so one carrying another id would answer under this entity's key with another entity's identity. + if ( + Object.hasOwn(view.fields, type.id_field) && + scalarText(view.fields[type.id_field]) !== entity.id + ) + return problem( + [...path, "view_fields", index, "fields"], + `sets the type's ${type.id_field} field to a value other than the entity's id`, + ); + } + return undefined; +}; + +/** The first problem with one entity, given the keys already seen. */ +const entityProblem = ( + { state, initial, seen }: EntityCheck, + entity: ParsedEntity, + path: Path, +): AnalysisInputIssue | undefined => { + const type = state.types.find( + (candidate) => candidate.collection_template === entity.type, + ); + if (type === undefined) + return problem([...path, "type"], "is not a type of the served state"); + if (!instantiates(entity.collection, type.collection_template)) + return problem( + [...path, "collection"], + `does not match its type's collection_template ${type.collection_template}`, + ); + if (scalarText(entity.fields[type.id_field]) !== entity.id) + return problem( + [...path, "id"], + `does not equal the entity's ${type.id_field} field`, + ); + const key = entityKey(entity); + if (seen.has(key)) + return problem([...path, "id"], "repeats an earlier entity's key"); + seen.add(key); + const cited = entity.record_ordinals ?? []; + if (cited.length > 0 && !same(cited, initial.get(key)?.record_ordinals ?? [])) + return problem( + [...path, "record_ordinals"], + "cites records the served Evidence does not cite for this entity", + ); + return viewFieldsProblem(state, type, entity, path); +}; + +/** The first problem with the documents: each names a document view once, at the initial document's path. */ +const documentsProblem = ( + state: EnvironmentState, + documents: ParsedDocument["documents"], +): AnalysisInputIssue | undefined => { + const tools = new Set(); + for (const [index, document] of documents.entries()) { + const initial = state.documents.find( + (candidate) => candidate.tool === document.tool, + ); + if ( + state.views.get(document.tool)?.shape !== "document" || + initial === undefined || + tools.has(document.tool) + ) + return problem( + ["rea_state", "documents", index, "tool"], + "is not a document view of the served state, or repeats one", + ); + tools.add(document.tool); + if (document.path !== initial.path) + return problem( + ["rea_state", "documents", index, "path"], + `differs from the initial document's path ${initial.path}`, + ); + if ( + document.record_ordinal !== undefined && + document.record_ordinal !== initial.record_ordinal + ) + return problem( + ["rea_state", "documents", index, "record_ordinal"], + `is not the initial document's record ${String(initial.record_ordinal)}`, + ); + } + return undefined; +}; + +const sameTypes = ( + supplied: readonly unknown[], + types: readonly StateType[], +): boolean => + canonicalJson(supplied, "State types") === + canonicalJson(types, "State types"); + +/** The first problem in document order with a structurally valid document. */ +const contentProblem = ( + state: EnvironmentState, + document: ParsedDocument, + initial: ReadonlyMap, +): AnalysisInputIssue | undefined => { + if ( + document.state_evidence_id !== undefined && + document.state_evidence_id !== state.evidenceId + ) + return problem( + ["rea_state", "state_evidence_id"], + `is not the served state's Evidence ${state.evidenceId}`, + ); + if (document.types !== undefined && !sameTypes(document.types, state.types)) + return problem(["rea_state", "types"], "differs from the served types"); + const check: EntityCheck = { state, initial, seen: new Set() }; + for (const [index, entity] of document.entities.entries()) { + const found = entityProblem(check, entity, [ + "rea_state", + "entities", + index, + ]); + if (found !== undefined) return found; + } + return documentsProblem(state, document.documents); +}; + +/** Loaded content citing the Evidence's records only where its values are the Evidence's; edited values cite none. */ +const heldContent = ( + state: EnvironmentState, + document: ParsedDocument, + initial: ReadonlyMap, +): StateContent => ({ + entities: document.entities.map((entity) => { + const view_fields = entity.view_fields ?? []; + const recorded = initial.get(entityKey(entity)); + const unchanged = + recorded !== undefined && + same(entity.fields, recorded.fields) && + same(view_fields, recorded.view_fields); + return { + type: entity.type, + collection: entity.collection, + id: entity.id, + fields: entity.fields, + view_fields, + record_ordinals: unchanged ? recorded.record_ordinals : [], + }; + }), + documents: document.documents.map(({ tool, path, value }) => { + const recorded = state.documents.find( + (candidate) => candidate.tool === tool, + ); + return { + tool, + path, + value, + ...(recorded !== undefined && same(value, recorded.value) + ? { record_ordinal: recorded.record_ordinal } + : {}), + }; + }), +}); + +/** + * Validate a `rea-state/v1` document against the served state's types and + * views; a refusal carries one issue naming the first problem by path. + */ +export const parseStateDocument = ( + state: EnvironmentState, + value: unknown, +): Result => { + const prototype = prototypeMemberPath(value); + const parsed = stateDocumentSchema.safeParse(value); + const initial = new Map( + state.entities.map((entity) => [entityKey(entity), entity]), + ); + const issue = + prototype !== undefined + ? memberProblem(prototype) + : parsed.success + ? contentProblem(state, parsed.data.rea_state, initial) + : schemaProblem(parsed.error.issues, value); + if (issue !== undefined || !parsed.success) + return err( + new AnalysisInputError( + ENVIRONMENT_SERVE_OPERATION, + { + userMessage: `The document is not a valid ${REA_STATE_FORMAT} state.`, + }, + issue === undefined ? [] : [issue], + ), + ); + return ok(heldContent(state, parsed.data.rea_state, initial)); +}; diff --git a/src/application/environment/environmentStateStore.ts b/src/application/environment/environmentStateStore.ts new file mode 100644 index 0000000000..66d13eaa70 --- /dev/null +++ b/src/application/environment/environmentStateStore.ts @@ -0,0 +1,159 @@ +import type { + StateEntity, + StateType, +} from "../../domain/analysisView/environmentStateRules.js"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; +import { jsonParts } from "../../domain/jsonSerialization.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; +import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js"; +import type { EnvironmentState } from "./environmentState.js"; +import { + parseStateDocument, + STATE_INTAKE_LIMIT_BYTES, + type HeldDocument, + type StateContent, +} from "./environmentStateDocument.js"; + +/** The UTF-8 bytes a value's JSON takes, counted part by part so it is never one string. */ +const jsonBytes = (value: unknown): number => { + let bytes = 0; + for (const part of jsonParts(value)) bytes += Buffer.byteLength(part, "utf8"); + return bytes; +}; + +/** What `data/get` reports: the current state as a `rea-state/v1` document. */ +export interface StateSnapshot { + readonly rea_state: { + readonly version: 1; + readonly state_evidence_id: string; + readonly types: readonly StateType[]; + readonly entities: readonly StateEntity[]; + readonly documents: readonly HeldDocument[]; + }; +} + +/** + * The live state one served environment answers from, shared by every MCP + * session and the data plane. Mutations are synchronous and swap the whole + * state, so every answer reads one consistent state. + */ +export class EnvironmentStateStore { + #entities: ReadonlyMap = new Map(); + #collections: ReadonlyMap = new Map(); + #typeCollections: ReadonlyMap = new Map(); + #documents: ReadonlyMap = new Map(); + #content: StateContent = { entities: [], documents: [] }; + readonly #types: ReadonlyMap; + + constructor(readonly state: EnvironmentState) { + this.#types = new Map( + state.types.map((type) => [type.collection_template, type]), + ); + this.reset(); + } + + /** Restore the initial state the Evidence holds. */ + reset(): void { + this.#install(this.state); + } + + /** Validate a `rea-state/v1` document and, only when it is valid, make it the whole state. */ + replace(document: unknown): Result { + const content = parseStateDocument(this.state, document); + if (!content.ok) return content; + // data/get writes members a document may omit, so the state it reports must still be one data/add loads. + if (jsonBytes(this.#snapshotOf(content.value)) > STATE_INTAKE_LIMIT_BYTES) + return err( + new AnalysisInputError( + ENVIRONMENT_SERVE_OPERATION, + { + userMessage: `The state would exceed ${String(STATE_INTAKE_LIMIT_BYTES)} bytes as data/get writes it.`, + }, + [ + { + path: [], + reason: "out_of_range", + message: `would exceed ${String(STATE_INTAKE_LIMIT_BYTES)} bytes as data/get writes it`, + maximum: STATE_INTAKE_LIMIT_BYTES, + }, + ], + ), + ); + this.#install(content.value); + return ok(undefined); + } + + type(collectionTemplate: string): StateType | undefined { + return this.#types.get(collectionTemplate); + } + + entity( + type: string, + collection: string, + id: string, + ): StateEntity | undefined { + return this.#entities.get(compositeKey([type, collection, id])); + } + + /** One collection's entities in state order. */ + inCollection(type: string, collection: string): readonly StateEntity[] { + return this.#collections.get(compositeKey([type, collection])) ?? []; + } + + /** The collections holding entities of one type, in state order of their first entity. */ + collectionsOf(type: string): readonly string[] { + return this.#typeCollections.get(type) ?? []; + } + + document(tool: string): HeldDocument | undefined { + return this.#documents.get(tool); + } + + snapshot(): StateSnapshot { + return this.#snapshotOf(this.#content); + } + + #snapshotOf(content: StateContent): StateSnapshot { + return { + rea_state: { + version: 1, + state_evidence_id: this.state.evidenceId, + types: this.state.types, + entities: content.entities, + documents: content.documents, + }, + }; + } + + /** Build every index before assigning any, so a reader never sees half a state. */ + #install(content: StateContent): void { + const entities = new Map(); + const collections = new Map(); + const typeCollections = new Map(); + for (const entity of content.entities) { + entities.set( + compositeKey([entity.type, entity.collection, entity.id]), + entity, + ); + const key = compositeKey([entity.type, entity.collection]); + const members = collections.get(key); + if (members !== undefined) { + members.push(entity); + continue; + } + collections.set(key, [entity]); + const named = typeCollections.get(entity.type); + if (named === undefined) + typeCollections.set(entity.type, [entity.collection]); + else named.push(entity.collection); + } + this.#entities = entities; + this.#collections = collections; + this.#typeCollections = typeCollections; + this.#documents = new Map( + content.documents.map((document) => [document.tool, document]), + ); + this.#content = content; + } +} diff --git a/src/application/environment/environmentTools.ts b/src/application/environment/environmentTools.ts index d21414c278..26fc41bf03 100644 --- a/src/application/environment/environmentTools.ts +++ b/src/application/environment/environmentTools.ts @@ -197,12 +197,24 @@ const EMPTY_TOOL_BYTES = JSON.stringify({ }, }).length; +/** What a served environment answers beyond replay: nothing, or a live state with its data plane. */ +export type EnvironmentDataPlane = "none" | "state"; + +/** How one catalog is served: the operation a refusal names and the data plane its card declares. */ +export interface EnvironmentCatalogOptions { + readonly operation?: string; + readonly dataPlane?: EnvironmentDataPlane; +} + /** Admit complete discovery before binding; six bytes per UTF-16 unit covers JSON escaping and UTF-8 expansion. */ export const environmentToolDefinitions = ( manifest: EnvironmentManifest, evidenceId: string, name: string = manifest.name, - operation: string = ENVIRONMENT_SERVE_OPERATION, + { + operation = ENVIRONMENT_SERVE_OPERATION, + dataPlane = "none", + }: EnvironmentCatalogOptions = {}, ): Result< readonly EnvironmentToolDefinition[], AnalysisResourceConstraintError @@ -232,8 +244,15 @@ export const environmentToolDefinitions = ( }, ), ); - if (!text(name) || !reserve(JSON.stringify(environmentCard("", [])).length)) + if ( + !text(name) || + !reserve(JSON.stringify(environmentCard("", [], dataPlane)).length) + ) return refused(); + const answers = + dataPlane === "state" + ? "answers from the served environment state where the state backs this tool, otherwise replays retained records" + : "replays retained records"; const definitions: EnvironmentToolDefinition[] = []; for (const tool of manifest.tools) { if (!reserve(EMPTY_TOOL_BYTES) || !text(tool.name)) return refused(); @@ -243,7 +262,7 @@ export const environmentToolDefinitions = ( (WRITE_METHODS.has(tool.method) ? 1 : 0); // A lower bound rejects enormous argument lists before constructing their alias map. if (!reserve(count * EMPTY_PROPERTY_BYTES)) return refused(); - const description = `${REPLAY_CATALOG_INSTRUCTIONS} ${describeText(tool.method, "method")} ${describeText(tool.path_template, "path")}; replays retained records [${describeList(tool.record_ordinals, tool.record_ordinals.length)}]; evidence ${evidenceId}`; + const description = `${REPLAY_CATALOG_INSTRUCTIONS} ${describeText(tool.method, "method")} ${describeText(tool.path_template, "path")}; ${answers} [${describeList(tool.record_ordinals, tool.record_ordinals.length)}]; evidence ${evidenceId}`; if (!text(description)) return refused(); const args = environmentToolArguments(tool); const properties = new Map(); @@ -276,6 +295,30 @@ export const environmentToolDefinitions = ( return ok(definitions); }; +/** The document format a state-served data plane loads and reports. */ +export const REA_STATE_FORMAT = "rea-state/v1"; + +/** The data-plane methods an environment served from a state answers. */ +export const STATE_OPERATIONS = ["data/reset", "data/add", "data/get"] as const; + +/** The intake declaration a state-served card carries: what `data/add` loads and `data/get` returns. */ +export const STATE_INTAKE_EXTENSION = { + uri: "urn:agentenv:intake/v1", + description: `data/add replaces the whole environment state with one ${REA_STATE_FORMAT} document; data/get returns the current state in the same format.`, + params: { + add: [ + { part: "data", format: REA_STATE_FORMAT, load: "replace" }, + { + part: "file", + format: REA_STATE_FORMAT, + mimeTypes: ["application/json"], + load: "replace", + }, + ], + get: [{ part: "data", format: REA_STATE_FORMAT }], + }, +} as const; + /** The environment card served at `/.well-known/agent-env.json`; key order is part of the contract. */ export interface EnvironmentCard { readonly name: string; @@ -284,20 +327,22 @@ export interface EnvironmentCard { readonly preferredTransport: "JSONRPC"; readonly additionalInterfaces: readonly [{ url: "/mcp"; transport: "mcp" }]; readonly capabilities: { - readonly extensions: null; + /** Null for a replay environment; the intake declaration when served from a state. */ + readonly extensions: null | readonly [typeof STATE_INTAKE_EXTENSION]; readonly tools: readonly Pick< EnvironmentToolDefinition, "name" | "description" | "inputSchema" >[]; - readonly operations: readonly []; + readonly operations: readonly [] | typeof STATE_OPERATIONS; }; readonly children_environments: null; } -/** Build the card from the served name and the tool definitions. */ +/** Build the card from the served name and the tool definitions; a state-served card declares its data plane. */ export const environmentCard = ( name: string, tools: readonly EnvironmentToolDefinition[], + dataPlane: EnvironmentDataPlane = "none", ): EnvironmentCard => ({ name, protocolVersion: "1.0", @@ -305,13 +350,13 @@ export const environmentCard = ( preferredTransport: "JSONRPC", additionalInterfaces: [{ url: "/mcp", transport: "mcp" }], capabilities: { - extensions: null, + extensions: dataPlane === "state" ? [STATE_INTAKE_EXTENSION] : null, tools: tools.map(({ name: toolName, description, inputSchema }) => ({ name: toolName, description, inputSchema, })), - operations: [], + operations: dataPlane === "state" ? STATE_OPERATIONS : [], }, children_environments: null, }); diff --git a/src/cli/environmentCommands.ts b/src/cli/environmentCommands.ts index 7a95e0b835..229d4deb27 100644 --- a/src/cli/environmentCommands.ts +++ b/src/cli/environmentCommands.ts @@ -1,5 +1,7 @@ import { absoluteCallerPath } from "../filesystem/callerPath.js"; import type { 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"; import type { Logger } from "pino"; @@ -19,6 +21,7 @@ import { } 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 { withCommandCancellation } from "./commandCancellation.js"; import type { CliInstance } from "./types.js"; @@ -99,20 +102,65 @@ const listenerRemediation = ( interface ServeArguments { readonly manifest: string; readonly evidence: string; + readonly replayOnly: boolean; readonly host?: string | undefined; readonly port?: number | undefined; } -/** Load both Evidence files, bind the listener and run until a signal ends it. */ +interface ServeInputs { + readonly source: EnvironmentSource; + readonly served: ServedState; +} + +const cancelledServe = (): JsonValue => + cliError(new AnalysisCancelledError(CLI_COMMANDS.environmentServe)); + +/** Pure replay, as `--replay-only` asks for: no state is derived. */ +const REPLAY_ONLY: ServedState = { + state: undefined, + report: { served: false, reason: "replay-only" }, +}; + +/** Read and authenticate the manifest and capture, then prepare the state under the served name unless pure replay is asked for; a refusal is the command's output. */ +const loadServeInputs = async ( + input: ServeArguments, + environmentName: string | undefined, + signal: AbortSignal, +): Promise> => { + const read = (path: string) => + parseCliJsonInput(path, CLI_COMMANDS.environmentServe, signal, { + snapshot: true, + }); + const manifest = await read(input.manifest); + if (!manifest.ok) return manifest; + const evidence = await read(input.evidence); + if (!evidence.ok) return evidence; + const { loadEnvironmentSource } = + await import("../application/environment/environmentSource.js"); + if (signal.aborted) return err(cancelledServe()); + const source = loadEnvironmentSource(manifest.value, evidence.value); + if (!source.ok) return err(cliError(source.error)); + if (input.replayOnly) + return ok({ source: source.value, served: REPLAY_ONLY }); + const { prepareServedState } = + await import("../application/environment/environmentState.js"); + if (signal.aborted) return err(cancelledServe()); + return ok({ + source: source.value, + served: prepareServedState(source.value, evidence.value, { + name: environmentName, + }), + }); +}; + +/** Load the Evidence files, bind the listener and run until a signal ends it. */ const serveEnvironmentCommand = async ( input: ServeArguments, environment: Readonly, logger: Logger, signal: AbortSignal, ): Promise => { - const cancelled = (): JsonValue => - cliError(new AnalysisCancelledError(CLI_COMMANDS.environmentServe)); - if (signal.aborted) return cancelled(); + if (signal.aborted) return cancelledServe(); const endpoint = parseMcpHttpEndpoint(environment, { ...(input.host === undefined ? {} : { host: input.host }), ...(input.port === undefined ? {} : { port: input.port }), @@ -120,32 +168,25 @@ const serveEnvironmentCommand = async ( if (!endpoint.ok) return cliError(endpoint.error); const environmentName = parseEnvironmentName(environment); if (!environmentName.ok) return cliError(environmentName.error); - const manifest = await parseCliJsonInput( - input.manifest, - CLI_COMMANDS.environmentServe, - signal, - { snapshot: true }, - ); - if (!manifest.ok) return manifest.error; - const evidence = await parseCliJsonInput( - input.evidence, - CLI_COMMANDS.environmentServe, - signal, - { snapshot: true }, - ); - if (!evidence.ok) return evidence.error; - const { loadEnvironmentSource } = - await import("../application/environment/environmentSource.js"); - if (signal.aborted) return cancelled(); - const source = loadEnvironmentSource(manifest.value, evidence.value); - if (!source.ok) return cliError(source.error); - if (signal.aborted) return cancelled(); + const inputs = await loadServeInputs(input, environmentName.value, signal); + if (!inputs.ok) return inputs.error; + const { + source, + served: { state, report }, + } = inputs.value; + const commandLogger = listenerLogger(environment, logger); + if (!report.served && report.reason === "unavailable") + commandLogger.warn( + { code: report.code, error: report.message }, + "Environment state unavailable; serving recorded replay", + ); + if (signal.aborted) return cancelledServe(); // Loaded here so registering the command catalog never loads the HTTP listener. const { serveEnvironment } = await import("../server/environment/serveEnvironment.js"); const { StreamableHttpListenError } = await import("../server/http/streamableHttpListenError.js"); - if (signal.aborted) return cancelled(); + if (signal.aborted) return cancelledServe(); // A listener that fails after binding ends the command instead of serving nothing. let failListener: (error: StreamableHttpListenError) => void = () => undefined; @@ -155,19 +196,20 @@ const serveEnvironmentCommand = async ( let served: import("../server/environment/serveEnvironment.js").ServedEnvironment; try { const started = await serveEnvironment({ - source: source.value, + source, + ...(state === undefined ? {} : { state }), signal, ...endpoint.value, ...(environmentName.value === undefined ? {} : { environmentName: environmentName.value }), - logger: listenerLogger(environment, logger), + logger: commandLogger, onerror: failListener, }); if (!started.ok) return cliError(started.error); served = started.value; } catch (cause: unknown) { - if (signal.aborted && cause === signal.reason) return cancelled(); + if (signal.aborted && cause === signal.reason) return cancelledServe(); if (!(cause instanceof StreamableHttpListenError)) throw cause; return cliError(transportError(endpoint.value, cause)); } @@ -184,8 +226,9 @@ const serveEnvironmentCommand = async ( url: served.url, environment: served.card.name, tools: served.card.capabilities.tools.length, - evidence_id: source.value.evidenceId, - manifest_evidence_id: source.value.manifestEvidenceId, + evidence_id: source.evidenceId, + manifest_evidence_id: source.manifestEvidenceId, + state: report, }; }; @@ -197,7 +240,7 @@ interface ExportArguments { readonly name?: string | undefined; } -/** The written files with sizes and the pinned package source, as the command prints them. */ +/** The written files with sizes, whether the bundle serves the state, and the pinned package source, as the command prints them. */ const exportDocument = (result: EnvironmentExportResult): JsonValue => ({ status: result.status, directory: result.directory, @@ -205,6 +248,7 @@ const exportDocument = (result: EnvironmentExportResult): JsonValue => ({ tool_count: result.tool_count, evidence_id: result.evidence_id, manifest_evidence_id: result.manifest_evidence_id, + state: result.state, package_source: result.package_source, files: result.files.map(({ path, bytes }) => ({ path, bytes })), }); @@ -305,10 +349,16 @@ export const registerEnvironmentCommands = ( registerExportCommand(cli, logger); cli.command(CLI_COMMANDS.environmentServe, { description: - "Serve an environment manifest as an agent environment that replays retained capture records over streamable HTTP until SIGINT or SIGTERM", + "Serve an environment manifest as an agent environment over streamable HTTP until SIGINT or SIGTERM: read tools answer from the state the capture observed, derived at startup, or every tool replays retained capture records with --replay-only or when the state is unavailable", args: z.object({ manifest: manifestArgument }), options: z.object({ evidence: evidenceOption, + replayOnly: z + .boolean() + .default(false) + .describe( + "Serve pure replay: derive no state, serve every manifest tool from its retained records and answer every /agentenv method as not found", + ), host: z .string() .min(1) @@ -331,6 +381,7 @@ export const registerEnvironmentCommands = ( { manifest: args.manifest, evidence: options.evidence, + replayOnly: options.replayOnly, host: options.host, port: options.port, }, diff --git a/src/domain/analysisView/EnvironmentStateAccumulator.ts b/src/domain/analysisView/EnvironmentStateAccumulator.ts index 93c115e1c1..d1c3711fd0 100644 --- a/src/domain/analysisView/EnvironmentStateAccumulator.ts +++ b/src/domain/analysisView/EnvironmentStateAccumulator.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { parseExactJson } from "../exactJsonNumber.js"; +import { MAX_JSON_DEPTH, type JsonValue } from "../jsonValue.js"; import { ok, type Result } from "../result.js"; import { WEB_NETWORK_CAPTURE_LIMITS, @@ -14,6 +15,8 @@ import { import { EnvironmentStateExtraction } from "./EnvironmentStateExtraction.js"; import type { TemplateRule } from "./environmentManifestRules.js"; import { + deeperThan, + hasPrototypeKey, isStateWrite, type ParsedBody, type StateRecordFacts, @@ -22,6 +25,7 @@ import { import { base64PrefixChars, readRecordedResponseBody, + type RecordedResponseBody, } from "./recordedResponseBody.js"; import { ENTRY_BYTES, @@ -49,6 +53,35 @@ type StateFields = Pick< /** Bodies beyond this many UTF-8 bytes are not parsed into state; their views stay recorded. */ export const STATE_BODY_LIMIT_BYTES = 256 * 1024; +/** + * Levels the state's own nesting adds above a recorded value: Evidence, a `data/get` reply and a + * JSON-RPC response hold a body's members about ten levels down, so a body must leave this much + * room under the JSON depth limit to be sealed and served. + */ +const STATE_NESTING_DEPTH = 16; + +/** A recorded response body as state JSON: a JSON-like media type, complete UTF-8 text within the state body limit, exact numbers and no `__proto__` member; otherwise undefined. */ +export const stateBodyJson = ( + contentType: string | null, + body: RecordedResponseBody, +): JsonValue | undefined => { + if ( + contentType === null || + !isJsonLike(mediaTypeEssence(contentType)) || + body.bodyText === null || + body.bodyEncoding === "base64" || + body.bodyCut === true || + Buffer.byteLength(body.bodyText, "utf8") > STATE_BODY_LIMIT_BYTES + ) + return undefined; + const value = parseExactJson(body.bodyText); + return value === undefined || + hasPrototypeKey(value) || + deeperThan(value, MAX_JSON_DEPTH - STATE_NESTING_DEPTH) + ? undefined + : value; +}; + const timingSchema = z.object({ startedDateTime: z.string(), time: z.number().nonnegative(), @@ -189,9 +222,10 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator return ok(undefined); } - /** Parse one retained response body as exact JSON, charging its decoding and tree as temporary work. */ + /** Parse one retained response body as state JSON, charging its decoding and tree as temporary work. */ #body(ordinal: number): ParsedBody | null { const entry = this.#entries.get(ordinal); + // A body that is not JSON-like is refused before its decoding is charged. if (entry === undefined || !isJsonLike(mediaTypeEssence(entry.contentType))) return { kind: "unusable" }; const content = contentSchema.safeParse(entry.record.reported); @@ -202,15 +236,10 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator return { kind: "unusable" }; if (!this.temporary(RECORD_SCRATCH_BYTES + textBytes(length) * 4)) return null; - const body = readRecordedResponseBody(entry.record, STATE_BODY_LIMIT_BYTES); - if ( - body.bodyText === null || - body.bodyEncoding === "base64" || - body.bodyCut === true || - Buffer.byteLength(body.bodyText, "utf8") > STATE_BODY_LIMIT_BYTES - ) - return { kind: "unusable" }; - const value = parseExactJson(body.bodyText); + const value = stateBodyJson( + entry.contentType, + readRecordedResponseBody(entry.record, STATE_BODY_LIMIT_BYTES), + ); return value === undefined ? { kind: "unusable" } : { kind: "json", value }; } diff --git a/src/domain/analysisView/EnvironmentStateExtraction.ts b/src/domain/analysisView/EnvironmentStateExtraction.ts index 11f457edd4..10ec4424f8 100644 --- a/src/domain/analysisView/EnvironmentStateExtraction.ts +++ b/src/domain/analysisView/EnvironmentStateExtraction.ts @@ -13,7 +13,6 @@ import { isQueryPost, isStateWrite, jsonBytes, - hasPrototypeKey, lastSegmentValue, listItems, normalizedPath, @@ -162,11 +161,7 @@ export class EnvironmentStateExtraction { const parsed = this.#budget.body(ordinal); if (parsed === null) throw new StateBudgetExhausted(); try { - return use( - parsed.kind === "json" && !hasPrototypeKey(parsed.value) - ? parsed.value - : undefined, - ); + return use(parsed.kind === "json" ? parsed.value : undefined); } finally { this.#budget.release(); } diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index ab0fb9aedd..e287f3f0c2 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -25,7 +25,7 @@ export interface StateToolFacts { readonly record_ordinals: readonly number[]; } -/** A record's response body as JSON, or unusable when absent, cut, not JSON or not exactly representable. */ +/** A record's response body as JSON, or unusable when absent, cut, not JSON, not exactly representable or carrying a `__proto__` member. */ export type ParsedBody = | { readonly kind: "json"; readonly value: JsonValue } | { readonly kind: "unusable" }; @@ -189,7 +189,7 @@ export const normalizedPath = (path: string): string => /** An item record's collection is its path without the identity segment; a list record's is its path. */ export const collectionPathOf = ( - record: StateRecordFacts, + record: Pick, item: boolean, ): string => item @@ -201,10 +201,13 @@ export const isQueryPost = (method: string, path: string): boolean => method === "POST" && segmentsOf(path).some((segment) => QUERY_SEGMENTS.has(segment.toLowerCase())); -/** Whether a record may change state: a successful or unanswered write that is not a query POST. */ +/** Whether a request with this method and path may change state: a write method that is not a query POST. */ +export const changesState = (method: string, path: string): boolean => + WRITE_METHODS.has(method) && !isQueryPost(method, path); + +/** Whether a record may change state: a successful or unanswered request that `changesState` names. */ export const isStateWrite = (record: StateRecordFacts): boolean => - WRITE_METHODS.has(record.method) && - !isQueryPost(record.method, record.path) && + changesState(record.method, record.path) && (record.status === null || (record.status >= 200 && record.status < 400)); /** Whether any write overlaps the span from one record's start to a later record's end; writes come in timeline order. */ @@ -282,6 +285,24 @@ export const jsonBytes = (value: JsonValue): number => { return ENTRY_BYTES; }; +/** Whether a JSON value nests more than `limit` levels below its root. */ +export const deeperThan = (value: JsonValue, limit: number): boolean => { + const pending: { readonly value: JsonValue; readonly depth: number }[] = [ + { value, depth: 0 }, + ]; + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + if (next.depth > limit) return true; + const children = Array.isArray(next.value) + ? next.value + : isObject(next.value) + ? Object.values(next.value) + : []; + for (const child of children) + pending.push({ value: child, depth: next.depth + 1 }); + } + return false; +}; + /** JSON parsers keep a `__proto__` member that plain object construction would turn into a prototype. */ export const hasPrototypeKey = (value: JsonValue): boolean => Array.isArray(value) diff --git a/src/domain/analysisView/environmentStateView.test.ts b/src/domain/analysisView/environmentStateView.test.ts index 872aba189e..736a6a3349 100644 --- a/src/domain/analysisView/environmentStateView.test.ts +++ b/src/domain/analysisView/environmentStateView.test.ts @@ -3,6 +3,8 @@ import { analysisViewCaptureEvidence, environmentManifestCaptureFixture, environmentStateCaptureFixture, + timedCaptureEvidence, + type TimedCaptureRow, } from "../../../tests/fixtures/analysisView.js"; import { SCENARIO_EXCHANGES, @@ -48,35 +50,8 @@ const stateOf = (parent: AnalysisViewParent = parentOf()): State => { return result.value; }; -type Row = readonly [ - method: string, - path: string, - body?: JsonValue, - status?: number, -]; - -/** HAR entries one second apart on one origin; a string body is recorded as written. */ -const captureOf = (rows: readonly Row[]): AnalysisViewParent => - parentOf( - analysisViewCaptureEvidence( - environmentManifestCaptureFixture( - rows.map(([method, path, body = null, status = 200], index) => ({ - startedDateTime: new Date( - Date.parse("2026-10-17T18:00:00.000Z") + index * 1000, - ).toISOString(), - time: 5, - request: { method, url: `http://app.example${path}` }, - response: { - status, - content: { - mimeType: "application/json", - text: typeof body === "string" ? body : JSON.stringify(body), - }, - }, - })), - ), - ), - ); +const captureOf = (rows: readonly TimedCaptureRow[]): AnalysisViewParent => + parentOf(timedCaptureEvidence(rows)); const viewOf = (state: State, tool: string) => state.views.find((view) => view.tool === tool); @@ -278,7 +253,7 @@ it("does not list the whole collection when a counter exceeds the number listed" }); it("lists the whole collection with its counter only when every recording did", () => { - const rows: Row[] = [ + const rows: TimedCaptureRow[] = [ [ "GET", "/api/items", @@ -352,6 +327,23 @@ it("leaves recorded the views it cannot reproduce, with the reason", () => { expect(state.entities).toEqual([]); }); +it("leaves a body too deep for the state's own nesting recorded instead of failing to seal", () => { + let deep: JsonValue = { theme: "dark" }; + for (let level = 0; level < 995; level += 1) deep = { nested: deep }; + const state = stateOf( + captureOf([ + ["GET", "/api/settings", deep], + ["PUT", "/api/settings", { theme: "light" }], + ]), + ); + expect(state.documents).toEqual([]); + expect(viewOf(state, "get_settings")).toEqual({ + tool: "get_settings", + shape: "recorded", + reason: "body-unusable", + }); +}); + it("holds a written path's non-entity read as a document", () => { const state = stateOf( captureOf([ diff --git a/src/domain/exactJsonNumber.test.ts b/src/domain/exactJsonNumber.test.ts new file mode 100644 index 0000000000..d41b1dd564 --- /dev/null +++ b/src/domain/exactJsonNumber.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from "vitest"; + +import { isExactJsonNumber } from "./exactJsonNumber.js"; + +const spellings = [ + "0", + "42", + "-7", + "1.5", + "0.1", + "1e3", + "1e-7", + "9007199254740991", + "9007199254740992", + "9.007199254740992e15", + "1e20", + "100000000000000000000", + "1e21", + "1.7976931348623157e308", +]; + +it("judges a number alike in every spelling, so an accepted number is accepted again once written back", () => { + for (const spelling of spellings) { + const value = Number(spelling); + if (isExactJsonNumber(value, spelling)) + expect(isExactJsonNumber(value, String(value)), spelling).toBe(true); + } + expect(isExactJsonNumber(1e20, "1e20")).toBe(false); + expect(isExactJsonNumber(1e20, "100000000000000000000")).toBe(false); + expect(isExactJsonNumber(1e21, "1e21")).toBe(true); + expect(isExactJsonNumber(9007199254740991, "9007199254740991")).toBe(true); + expect(isExactJsonNumber(-0, "-0")).toBe(false); +}); diff --git a/src/domain/exactJsonNumber.ts b/src/domain/exactJsonNumber.ts index 19f828023e..77191f1e25 100644 --- a/src/domain/exactJsonNumber.ts +++ b/src/domain/exactJsonNumber.ts @@ -2,12 +2,19 @@ import { isSafeNumber } from "lossless-json"; import { jsonValueSchema, type JsonValue } from "./jsonValue.js"; import { safeParseJson } from "./safeJson.js"; -/** Whether native JSON conversion and serialization preserve the recorded number. */ +/** + * Whether native JSON conversion and serialization preserve the recorded number. Both the recorded + * spelling and the one serialization writes must be safe, so `1e20` and `100000000000000000000` + * are judged alike and anything accepted is accepted again once written back. + */ export const isExactJsonNumber = ( value: number, source: string | undefined, ): boolean => - source !== undefined && isSafeNumber(source) && !Object.is(value, -0); + source !== undefined && + isSafeNumber(source) && + isSafeNumber(String(value)) && + !Object.is(value, -0); /** Parse JSON text whose every number survives native conversion exactly; undefined otherwise. */ export const parseExactJson = (text: string): JsonValue | undefined => { diff --git a/src/server/environment/environmentCapacity.test.ts b/src/server/environment/environmentCapacity.test.ts index 146e839b7b..70e887e2cf 100644 --- a/src/server/environment/environmentCapacity.test.ts +++ b/src/server/environment/environmentCapacity.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "vitest"; import { environmentServeSource } from "../../../tests/fixtures/environmentServe.js"; import { + environmentCard, environmentToolDefinitions, ENVIRONMENT_CATALOG_LIMIT_BYTES, } from "../../application/environment/environmentTools.js"; @@ -68,3 +69,30 @@ it("charges environment identity, aggregate tools and escaped text before produc ), ).toBeLessThan(ENVIRONMENT_CATALOG_LIMIT_BYTES); }); + +it("reserves the state card only when the state data plane is served", () => { + const source = environmentServeSource(); + const manifest = { ...source.manifest, tools: [] }; + const admits = (length: number, dataPlane: "none" | "state") => + environmentToolDefinitions( + manifest, + source.evidenceId, + "x".repeat(length), + { dataPlane }, + ).ok; + // With no tools, the name and the card are the whole reservation: six bytes per name unit plus quotes. + const longest = (dataPlane: "none" | "state") => + Math.floor( + (ENVIRONMENT_CATALOG_LIMIT_BYTES - + 2 - + JSON.stringify(environmentCard("", [], dataPlane)).length) / + 6, + ); + const stateless = longest("none"); + expect(admits(stateless, "none")).toBe(true); + expect(admits(stateless + 1, "none")).toBe(false); + expect(longest("state")).toBeLessThan(stateless); + expect(admits(stateless, "state")).toBe(false); + expect(admits(longest("state"), "state")).toBe(true); + expect(admits(longest("state") + 1, "state")).toBe(false); +}); diff --git a/src/server/environment/environmentDataPlane.ts b/src/server/environment/environmentDataPlane.ts new file mode 100644 index 0000000000..b1363cb361 --- /dev/null +++ b/src/server/environment/environmentDataPlane.ts @@ -0,0 +1,119 @@ +import type { EnvironmentStateStore } from "../../application/environment/environmentStateStore.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import type { AnalysisInputError } from "../../domain/analysisErrorCore.js"; +import { + CANCELLED, + DEFAULT_STATE_INTAKE, + invalidParams, + partDocument, + type RpcError, + type StateIntake, +} from "./stateIntake.js"; + +/** One data-plane method's outcome; undefined from a method table means the method is not served. */ +export type RpcOutcome = + | { readonly result: unknown } + | { readonly error: RpcError }; + +/** The data-plane methods one environment serves, by JSON-RPC method name; a call is queued when made, and `signal` cancels it. */ +export type RpcMethods = ( + method: string, + params: unknown, + signal?: AbortSignal, +) => Promise; + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const pointerSegment = (segment: string | number): string => + String(segment).replaceAll("~", "~0").replaceAll("/", "~1"); + +/** The first document problem as `-32602`, located by a JSON pointer into the document. */ +const documentError = (error: AnalysisInputError): RpcError => { + const issue = error.issues[0]; + const pointer = (issue?.path ?? []) + .map((segment) => `/${pointerSegment(segment)}`) + .join(""); + return invalidParams( + `${pointer === "" ? "the document" : pointer} ${issue?.message ?? "is not a rea-state/v1 document"}`, + pointer, + ); +}; + +/** What one queued call runs with: the live state, how documents arrive, what cancels it and who owns file descriptors whose close failed. */ +interface CallContext { + readonly store: EnvironmentStateStore; + readonly intake: StateIntake; + readonly cancel: AbortSignal; + readonly files: ArtifactResourceScope; +} + +/** `data/add`: exactly one part, whose document replaces the whole state only when it is valid and the call was not cancelled. */ +const addState = async ( + { store, intake, cancel, files }: CallContext, + params: unknown, +): Promise => { + const parts = isRecord(params) ? params.parts : undefined; + if (!Array.isArray(parts) || parts.length !== 1) + return { error: invalidParams("data/add takes exactly one part") }; + const document = await partDocument(parts[0], intake, cancel, files); + if (!document.ok) return { error: document.error }; + // A file read is not interruptible, so a cancellation during it is honoured here, before the state changes. + if (cancel.aborted) return { error: CANCELLED }; + const replaced = store.replace(document.value); + return replaced.ok + ? { result: {} } + : { error: documentError(replaced.error) }; +}; + +type Method = ( + context: CallContext, + params: unknown, +) => RpcOutcome | Promise; + +const METHODS: ReadonlyMap = new Map([ + [ + "data/reset", + ({ store }) => { + store.reset(); + return { result: {} }; + }, + ], + ["data/add", addState], + [ + "data/get", + ({ store }) => ({ + result: { parts: [{ kind: "data", data: store.snapshot() }] }, + }), + ], +]); + +/** + * `data/reset`, `data/add` and `data/get` over one live state. Calls run one + * at a time in the order they are made, across requests, so a slow load never + * overwrites a later reset and at most one loaded document is parsed at once. + * Once `stopped` or a call's own signal aborts, its download ends and a call + * not yet run is answered as cancelled. `files` keeps a state file whose close + * failed and retries it before the next file read; its owner closes it at shutdown. + */ +export const stateDataPlane = ( + store: EnvironmentStateStore, + intake: StateIntake = DEFAULT_STATE_INTAKE, + stopped: AbortSignal = new AbortController().signal, + files: ArtifactResourceScope = new ArtifactResourceScope(), +): RpcMethods => { + let previous: Promise = Promise.resolve(); + return (method, params, signal) => { + const run = METHODS.get(method); + if (run === undefined) return Promise.resolve(undefined); + const cancel = + signal === undefined ? stopped : AbortSignal.any([stopped, signal]); + const next = previous.then(() => + cancel.aborted + ? { error: CANCELLED } + : run({ store, intake, cancel, files }, params), + ); + previous = next.catch(() => undefined); + return next; + }; +}; diff --git a/src/server/environment/serveEnvironment.ts b/src/server/environment/serveEnvironment.ts index 46de92dae0..2415c8deb0 100644 --- a/src/server/environment/serveEnvironment.ts +++ b/src/server/environment/serveEnvironment.ts @@ -7,20 +7,25 @@ import { } from "@modelcontextprotocol/server"; import type { Logger } from "pino"; -import { - replayEnvironmentTool, - type ReplayArguments, -} from "../../application/environment/environmentReplay.js"; +import type { ReplayArguments } from "../../application/environment/environmentReplay.js"; import type { EnvironmentSource } from "../../application/environment/environmentSource.js"; +import { + servedCatalog, + type EnvironmentState, +} from "../../application/environment/environmentState.js"; +import { + environmentToolAnswer, + type EnvironmentToolAnswer, +} from "../../application/environment/environmentStateAnswer.js"; +import { EnvironmentStateStore } from "../../application/environment/environmentStateStore.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { environmentCard, - environmentToolDefinitions, REPLAY_CATALOG_INSTRUCTIONS, type EnvironmentCard, type EnvironmentToolDefinition, } from "../../application/environment/environmentTools.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; -import { isExactJsonNumber } from "../../domain/exactJsonNumber.js"; import { safeParseJson } from "../../domain/safeJson.js"; import type { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; import type { StreamableHttpListenError } from "../http/streamableHttpListenError.js"; @@ -37,11 +42,24 @@ import { UNTRUSTED_CONTENT_INSTRUCTIONS, } from "../toolResultEncoding.js"; import { RequestTrackingMcpServer } from "../RequestTrackingMcpServer.js"; +import { stateDataPlane, type RpcMethods } from "./environmentDataPlane.js"; +import { + DEFAULT_STATE_INTAKE, + exactJsonReviver, + SERVER_ERROR, + STATE_INTAKE_LIMIT_BYTES, + type RpcError, + type StateFetcher, +} from "./stateIntake.js"; import { TargetContentTransport } from "./targetContentTransport.js"; +import { + bufferedJsonParts, + jsonParts, +} from "../../domain/jsonSerialization.js"; /** Where the environment card is served. */ export const ENVIRONMENT_CARD_PATH = "/.well-known/agent-env.json"; -/** The JSON-RPC data plane the card advertises; this increment answers every method as unknown. */ +/** The JSON-RPC data plane the card advertises; it serves methods only when serving from a state. */ export const ENVIRONMENT_RPC_PATH = "/agentenv"; const METHOD_NOT_FOUND = -32601; @@ -77,11 +95,11 @@ const isRpcMessage = ( (!("params" in message) || (typeof message.params === "object" && message.params !== null)); -/** Build the MCP server whose tools replay retained records; every result is target content. */ +/** Build the MCP server whose tools answer from retained records or the live state; every result is target content. */ export const createEnvironmentMcpServer = ( name: string, - source: EnvironmentSource, tools: readonly EnvironmentToolDefinition[], + answer: EnvironmentToolAnswer, ): RequestTrackingMcpServer => { // Tool names, descriptions and replies all come from the captured application, so the // server carries REA's untrusted-content instructions plus a warning about its own catalog. @@ -99,7 +117,7 @@ export const createEnvironmentMcpServer = ( inputSchema: fromJsonSchema(definition.inputSchema), }, (args): CallToolResult => { - const replayed = replayEnvironmentTool(source, definition.tool, args); + const replayed = answer(definition.tool, args); if (!replayed.ok) return { isError: true, @@ -120,38 +138,164 @@ export const createEnvironmentMcpServer = ( return server; }; -interface RpcErrorReply { +type RpcReply = { readonly jsonrpc: "2.0"; readonly id: string | number | null; - readonly error: { readonly code: number; readonly message: string }; -} +} & ({ readonly result: unknown } | { readonly error: RpcError }); -/** The reply to one JSON-RPC message: invalid request, method not found, or none for a notification. */ -const rpcReply = (message: unknown): RpcErrorReply | undefined => { - const id = - isRecord(message) && - (typeof message.id === "string" || typeof message.id === "number") - ? message.id - : null; +/** At most this many UTF-8 bytes of replies in one `/agentenv` response: twice the largest document `data/add` loads, so a `data/get` of a loaded state fits but a batch cannot multiply it. */ +export const MAX_RPC_RESPONSE_BYTES = 2 * STATE_INTAKE_LIMIT_BYTES; + +/** The id a message's reply carries: its own string or number id, otherwise null. */ +const replyIdOf = (message: unknown): string | number | null => + isRecord(message) && + (typeof message.id === "string" || typeof message.id === "number") + ? message.id + : null; + +/** Every invalid message is answered; a valid one is unless it is a notification. */ +const expectsReply = (message: unknown): boolean => + !isRpcMessage(message) || "id" in message; + +/** Start one JSON-RPC message now, so its call is queued at arrival; resolves to its reply, or none for a notification. */ +const startRpc = ( + message: unknown, + methods: RpcMethods | undefined, + signal: AbortSignal, +): Promise => { + const id = replyIdOf(message); if (!isRpcMessage(message)) - return { + return Promise.resolve({ jsonrpc: "2.0", id, error: { code: INVALID_REQUEST, message: "invalid request" }, - }; - if (!("id" in message)) return undefined; - return { - jsonrpc: "2.0", - id, - error: { - code: METHOD_NOT_FOUND, - message: `method not found: ${message.method}`, - }, + }); + const method = String(message.method); + // A notification still runs its method; it only gets no reply. + const outcome = + methods === undefined + ? Promise.resolve(undefined) + : methods(method, message.params, signal); + const replies = expectsReply(message); + return outcome.then((settled): RpcReply | undefined => + replies + ? { + jsonrpc: "2.0", + id, + ...(settled ?? { + error: { + code: METHOD_NOT_FOUND, + message: `method not found: ${method}`, + }, + }), + } + : undefined, + ); +}; + +const overBudget = (replyBytes: number): RpcError => ({ + code: SERVER_ERROR, + message: `the reply would take this response past its ${String(MAX_RPC_RESPONSE_BYTES)}-byte budget; the call ran, so repeat a read in another request`, + data: { max_response_bytes: MAX_RPC_RESPONSE_BYTES, reply_bytes: replyBytes }, +}); + +/** The largest budget error a message's reply can be replaced by. */ +const refusalBytes = (message: unknown): number => + Buffer.byteLength( + JSON.stringify({ + jsonrpc: "2.0", + id: replyIdOf(message), + error: overBudget(Number.MAX_SAFE_INTEGER), + }), + "utf8", + ); + +/** + * Serialize a response's replies in order within its budget. Every reply still to come keeps room + * for its budget error, and a batch keeps room for its brackets and commas, so a refusal always + * fits and the body never passes the budget. Each reply is measured in bounded parts before it is + * assembled, and parts past the budget are counted but not kept, so a reply that would pass it is + * never built; it becomes an error stating it, unless that error is no smaller. + */ +const replyEncoder = ( + messages: readonly unknown[], + framed: boolean, +): ((reply: RpcReply) => string) => { + const refusals = messages.filter(expectsReply).map(refusalBytes); + let reserved = refusals.reduce((sum, bytes) => sum + bytes, 0); + let used = framed ? 2 + Math.max(0, refusals.length - 1) : 0; + let index = 0; + return (reply) => { + reserved -= refusals[index] ?? 0; + index += 1; + const room = MAX_RPC_RESPONSE_BYTES - used - reserved; + const parts: string[] = []; + let bytes = 0; + for (const part of bufferedJsonParts(jsonParts(reply))) { + bytes += Buffer.byteLength(part, "utf8"); + if (bytes <= room) parts.push(part); + } + if (bytes <= room) { + used += bytes; + return parts.join(""); + } + const refused = JSON.stringify({ + jsonrpc: "2.0", + id: reply.id, + error: overBudget(bytes), + }); + const refusedBytes = Buffer.byteLength(refused, "utf8"); + if (refusedBytes < bytes) { + used += refusedBytes; + return refused; + } + used += bytes; + return JSON.stringify(reply); }; }; -/** Answer the data plane with a JSON-RPC method-not-found error for every request within the body bound. */ -const answerRpc = async (request: Request): Promise => { +const jsonResponse = (text: string, status: number): Response => + new Response(text, { + status, + headers: { "content-type": "application/json" }, + }); + +/** Answer a batch: every message is queued at arrival, and replies are encoded in order as each completes. */ +const answerBatch = async ( + messages: readonly unknown[], + methods: RpcMethods | undefined, + signal: AbortSignal, +): Promise => { + if (messages.length === 0) + return jsonRpcErrorResponse(400, INVALID_REQUEST, "invalid request"); + if (messages.length > MAX_RPC_BATCH_MESSAGES) + return jsonRpcErrorResponse( + 400, + INVALID_REQUEST, + `batch exceeds ${String(MAX_RPC_BATCH_MESSAGES)} messages`, + ); + // Starting every message now keeps the batch consecutive in the arrival-order + // queue, so no other request's call lands between its messages. + const pending = messages.map((message) => startRpc(message, methods, signal)); + // The loop below reports the first failure; later ones are not left unhandled. + for (const reply of pending) void reply.catch(() => undefined); + const encode = replyEncoder(messages, true); + const texts: string[] = []; + // Taking each settled reply off the queue releases it, and any state it holds, once encoded. + for (let next = pending.shift(); next !== undefined; next = pending.shift()) { + const reply = await next; + if (reply !== undefined) texts.push(encode(reply)); + } + return texts.length === 0 + ? new Response(null, { status: 202 }) + : jsonResponse(`[${texts.join(",")}]`, 200); +}; + +/** Answer the data plane within the body and response bounds; without methods every request is not found. */ +const answerRpc = async ( + request: Request, + methods: RpcMethods | undefined, +): Promise => { const read = await readRequestBody(request, DEFAULT_MAX_REQUEST_BODY_SIZE); if (read.tooLarge) return jsonRpcErrorResponse( @@ -159,49 +303,36 @@ const answerRpc = async (request: Request): Promise => { PAYLOAD_TOO_LARGE, `Request body must not exceed ${String(DEFAULT_MAX_REQUEST_BODY_SIZE)} bytes`, ); - const parsed = safeParseJson(read.text, (key, value, context) => - key === "id" && - typeof value === "number" && - !isExactJsonNumber(value, context?.source) - ? Symbol("unrepresentable RPC id") - : value, - ); + // Values parsing would alter become markers: such an id is refused rather than echoed as another number, and a state document carrying one is refused. + const parsed = safeParseJson(read.text, exactJsonReviver); if (!parsed.ok) return jsonRpcErrorResponse(400, PARSE_ERROR, "parse error"); - // A batch is answered message by message; notifications get no reply at all. - if (Array.isArray(parsed.value)) { - if (parsed.value.length === 0) - return jsonRpcErrorResponse(400, INVALID_REQUEST, "invalid request"); - if (parsed.value.length > MAX_RPC_BATCH_MESSAGES) - return jsonRpcErrorResponse( - 400, - INVALID_REQUEST, - `batch exceeds ${String(MAX_RPC_BATCH_MESSAGES)} messages`, - ); - const replies = parsed.value - .map(rpcReply) - .filter((reply) => reply !== undefined); - return replies.length === 0 - ? new Response(null, { status: 202 }) - : Response.json(replies); - } - const reply = rpcReply(parsed.value); + // The request's signal cancels its calls when the client leaves. + if (Array.isArray(parsed.value)) + return answerBatch(parsed.value, methods, request.signal); + const reply = await startRpc(parsed.value, methods, request.signal); return reply === undefined ? new Response(null, { status: 202 }) - : Response.json(reply, { - status: reply.error.code === INVALID_REQUEST ? 400 : 200, - }); + : jsonResponse( + replyEncoder([parsed.value], false)(reply), + "error" in reply && reply.error.code === INVALID_REQUEST ? 400 : 200, + ); }; -/** The plain routes an agent environment serves beside `/mcp`. */ +/** The plain routes an agent environment serves beside `/mcp`; data-plane methods only when given. */ export const environmentRoutes = ( card: EnvironmentCard, + methods?: RpcMethods, ): readonly StreamableHttpRoute[] => [ { method: "GET", path: ENVIRONMENT_CARD_PATH, handle: () => Response.json(card), }, - { method: "POST", path: ENVIRONMENT_RPC_PATH, handle: answerRpc }, + { + method: "POST", + path: ENVIRONMENT_RPC_PATH, + handle: (request) => answerRpc(request, methods), + }, ]; export interface ServeEnvironmentOptions { @@ -214,6 +345,10 @@ export interface ServeEnvironmentOptions { readonly logger?: Logger; /** Reports a listener failure after binding; the failure is logged either way. */ readonly onerror?: (error: StreamableHttpListenError) => void; + /** The initial state read tools answer from; without it every tool replays its recordings. */ + readonly state?: EnvironmentState; + /** Downloads https state documents for `data/add`; defaults to a redirect-refusing fetch. */ + readonly fetchState?: StateFetcher; } /** A listening agent environment and the card it serves. */ @@ -227,20 +362,44 @@ export const serveEnvironment = async ( ): Promise> => { const { source, host, port, logger } = options; const name = options.environmentName ?? source.manifest.name; - const prepared = environmentToolDefinitions( - source.manifest, - source.evidenceId, - name, - ); - if (!prepared.ok) return prepared; - const tools = prepared.value; - const card = environmentCard(name, tools); + // One store per process: every MCP session and the data plane share it. + const store = + options.state === undefined + ? undefined + : new EnvironmentStateStore(options.state); + const catalog = servedCatalog(source, store !== undefined, name); + if (!catalog.ok) return catalog; + const { tools, dataPlane } = catalog.value; + const card = environmentCard(name, tools, dataPlane); + const answer = environmentToolAnswer(source, store); + // Stopping ends the running download and skips queued data-plane calls, so shutdown never waits on them. + const stopping = new AbortController(); + const stop = (): void => { + stopping.abort(new Error("The agent environment is stopping")); + }; + // A state file whose close failed stays owned here: later file reads retry it, and close reports it if it is still open. + const stateFiles = new ArtifactResourceScope(); const served = await serveStreamableHttp({ host, port, ...(options.signal === undefined ? {} : { signal: options.signal }), - createMcpServer: () => createEnvironmentMcpServer(name, source, tools), - routes: environmentRoutes(card), + createMcpServer: () => createEnvironmentMcpServer(name, tools, answer), + routes: environmentRoutes( + card, + store === undefined + ? undefined + : stateDataPlane( + store, + { + ...DEFAULT_STATE_INTAKE, + ...(options.fetchState === undefined + ? {} + : { fetchState: options.fetchState }), + }, + stopping.signal, + stateFiles, + ), + ), ...(logger === undefined ? {} : { logger }), onerror: (error) => { logger?.error( @@ -250,5 +409,17 @@ export const serveEnvironment = async ( options.onerror?.(error); }, }); - return ok({ ...served, card }); + return ok({ + ...served, + card, + prepareShutdown: () => { + stop(); + return served.prepareShutdown(); + }, + close: async () => { + stop(); + await served.close(); + await stateFiles.close(); + }, + }); }; diff --git a/src/server/environment/serveEnvironmentState.test.ts b/src/server/environment/serveEnvironmentState.test.ts new file mode 100644 index 0000000000..ddc9773fc5 --- /dev/null +++ b/src/server/environment/serveEnvironmentState.test.ts @@ -0,0 +1,933 @@ +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; + +import { timedCaptureEvidence } from "../../../tests/fixtures/analysisView.js"; +import { + environmentStateServeFixture, + environmentStateServeInputs, +} from "../../../tests/fixtures/environmentServe.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { EnvironmentStateStore } from "../../application/environment/environmentStateStore.js"; +import { ArtifactResourceRetryFailure } from "../../artifacts/ArtifactResourceScope.js"; +import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js"; +import { + environmentCard, + STATE_INTAKE_EXTENSION, + STATE_OPERATIONS, +} from "../../application/environment/environmentTools.js"; +import { stateDataPlane } from "./environmentDataPlane.js"; +import { + ENVIRONMENT_CARD_PATH, + ENVIRONMENT_RPC_PATH, + environmentRoutes, + MAX_RPC_RESPONSE_BYTES, + serveEnvironment, + type ServedEnvironment, + type ServeEnvironmentOptions, +} from "./serveEnvironment.js"; +import { + DEFAULT_STATE_INTAKE, + STATE_INTAKE_LIMIT_BYTES, + type StateIntake, +} from "./stateIntake.js"; + +const { source, state } = environmentStateServeInputs(); + +/** The initial state as a rea-state/v1 document with user 2 renamed. */ +const renamedDocument = () => { + const document = structuredClone(new EnvironmentStateStore(state).snapshot()); + return { + rea_state: { + ...document.rea_state, + entities: document.rea_state.entities.map((entity) => + entity.type === "/api/users" && entity.id === "2" + ? { ...entity, fields: { ...entity.fields, login: "renamed" } } + : entity, + ), + }, + }; +}; + +/** A data plane over a fresh store, posting raw bodies to its route; `stopped` is the server's stop signal. */ +const dataPlane = ( + intake: Partial = {}, + stopped?: AbortSignal, +) => { + const store = new EnvironmentStateStore(state); + const route = environmentRoutes( + environmentCard("grafana-example", [], "state"), + stateDataPlane(store, { ...DEFAULT_STATE_INTAKE, ...intake }, stopped), + ).find((entry) => entry.path === ENVIRONMENT_RPC_PATH); + if (route === undefined) throw new Error("No data-plane route"); + const post = async (body: unknown, signal?: AbortSignal) => { + const response = await route.handle( + new Request(`http://127.0.0.1${ENVIRONMENT_RPC_PATH}`, { + method: "POST", + body: typeof body === "string" ? body : JSON.stringify(body), + ...(signal === undefined ? {} : { signal }), + }), + ); + return { + status: response.status, + json: response.status === 202 ? undefined : await response.json(), + }; + }; + const call = async (method: string, params?: unknown) => + ( + await post({ + jsonrpc: "2.0", + id: 1, + method, + ...(params === undefined ? {} : { params }), + }) + ).json; + const add = (part: unknown) => call("data/add", { parts: [part] }); + return { store, post, call, add }; +}; + +const loginOf = (store: EnvironmentStateStore) => + store.entity("/api/users", "/api/users", "2")?.fields.login; + +/** The `file://` uri of a fresh file holding the renamed document. */ +const renamedStateUri = async (): Promise => { + const root = await createTestTempDirectory("rea-environment-state-close-"); + const path = join(root, "state.json"); + await writeFile(path, JSON.stringify(renamedDocument())); + return pathToFileURL(path).href; +}; + +/** Fail every close of the first state file opened while `failing()` holds, recording its owner; restored when the test ends. */ +const failFirstFileClose = (failing: () => boolean = () => true) => { + const owners: OwnedFileHandle[] = []; + const close = OwnedFileHandle.prototype.close; + const spy = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + if (owners.length === 0) owners.push(this); + if (this === owners[0] && failing()) + throw new Error("injected state file close failure"); + await close.call(this); + }); + onTestFinished(async () => { + spy.mockRestore(); + for (const owner of owners) if (owner.handle.fd >= 0) await owner.close(); + }); + return owners; +}; + +/** A `data/add` message loading the document an https uri names. */ +const addByUri = ( + id: number, + uri = "https://state.example.test/state.json", +) => ({ + jsonrpc: "2.0", + id, + method: "data/add", + params: { parts: [{ kind: "file", file: { uri } }] }, +}); + +/** A fetcher that reports each call and the signal it was given, then answers only when `answer` settles. */ +const heldFetcher = (answer: () => Promise) => { + const signals: AbortSignal[] = []; + let started: () => void = () => undefined; + const fetching = new Promise((resolve) => { + started = resolve; + }); + const fetchState = (_: URL, signal: AbortSignal) => { + signals.push(signal); + started(); + return answer(); + }; + return { signals, fetching, fetchState }; +}; + +describe("state data plane", () => { + it("answers data/get, data/add and data/reset in the AgentEnv shapes", async () => { + const plane = dataPlane(); + await expect(plane.call("data/get")).resolves.toEqual({ + jsonrpc: "2.0", + id: 1, + result: { + parts: [ + { + kind: "data", + data: JSON.parse(JSON.stringify(plane.store.snapshot())), + }, + ], + }, + }); + await expect( + plane.add({ kind: "data", data: renamedDocument() }), + ).resolves.toEqual({ jsonrpc: "2.0", id: 1, result: {} }); + expect(loginOf(plane.store)).toBe("renamed"); + await expect(plane.call("data/reset")).resolves.toEqual({ + jsonrpc: "2.0", + id: 1, + result: {}, + }); + expect(loginOf(plane.store)).toBe("probe"); + await expect(plane.call("data/remove")).resolves.toMatchObject({ + error: { code: -32601, message: "method not found: data/remove" }, + }); + }); + + it("refuses anything but exactly one data or file part as invalid params", async () => { + const plane = dataPlane(); + for (const params of [ + { parts: [] }, + undefined, + { + parts: [ + { kind: "data", data: {} }, + { kind: "data", data: {} }, + ], + }, + { parts: [{ kind: "text", text: "{}" }] }, + { parts: [{ kind: "file", file: { bytes: "e30=", uri: "file:///x" } }] }, + { parts: [{ kind: "file", file: { bytes: "not base64!" } }] }, + { + parts: [ + { kind: "file", file: { bytes: "e30=", mimeType: "text/plain" } }, + ], + }, + { parts: [{ kind: "file", file: { uri: "http://example.test/x" } }] }, + ]) + await expect(plane.call("data/add", params)).resolves.toMatchObject({ + error: { code: -32602 }, + }); + expect(loginOf(plane.store)).toBe("probe"); + }); + + it("names the first invalid member by JSON pointer and changes nothing", async () => { + const plane = dataPlane(); + const document = renamedDocument(); + const entities = document.rea_state.entities.map((entity, index) => + index === 3 ? { ...entity, collection: "/api/elsewhere" } : entity, + ); + const reply = await plane.add({ + kind: "data", + data: { rea_state: { ...document.rea_state, entities } }, + }); + expect(reply).toMatchObject({ + error: { + code: -32602, + message: expect.stringContaining("/rea_state/entities/3/collection"), + data: { path: "/rea_state/entities/3/collection" }, + }, + }); + expect(loginOf(plane.store)).toBe("probe"); + // Values parsing would alter are refused where they sit, never rounded or turned into a prototype. + const body = JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "data/add", + params: { parts: [{ kind: "data", data: renamedDocument() }] }, + }); + for (const altered of [ + '"login":9007199254740993', + // Exact in this spelling, but data/get would write it as digits it could not load again. + '"login":1e20', + '"__proto__":{"polluted":true},"login":"renamed"', + ]) { + const reply = await plane.post( + body.replace('"login":"renamed"', altered), + ); + expect(reply.json).toMatchObject({ + error: { + code: -32602, + data: { path: expect.stringMatching(/\/fields$/u) }, + }, + }); + expect(JSON.stringify(reply.json)).not.toContain("polluted"); + } + expect(loginOf(plane.store)).toBe("probe"); + }); +}); + +describe("state data plane intake", () => { + it("loads file parts from bytes and from a file uri", async () => { + const plane = dataPlane(); + const text = JSON.stringify(renamedDocument()); + await expect( + plane.add({ + kind: "file", + file: { + bytes: Buffer.from(text).toString("base64"), + mimeType: "application/json; charset=utf-8", + }, + }), + ).resolves.toMatchObject({ result: {} }); + expect(loginOf(plane.store)).toBe("renamed"); + plane.store.reset(); + const root = await createTestTempDirectory("rea-environment-state-file-"); + const path = join(root, "state.json"); + await writeFile(path, text); + await expect( + plane.add({ kind: "file", file: { uri: pathToFileURL(path).href } }), + ).resolves.toMatchObject({ result: {} }); + expect(loginOf(plane.store)).toBe("renamed"); + await mkdir(join(root, "directory")); + for (const [name, reason] of [ + ["missing.json", "not-found"], + ["directory", "not-regular-file"], + ] as const) { + const uri = pathToFileURL(join(root, name)).href; + const reply = await plane.add({ kind: "file", file: { uri } }); + expect(reply).toMatchObject({ + error: { code: -32602, data: { reason } }, + }); + expect(JSON.stringify(reply)).not.toContain(root); + } + }); + + it("downloads https uris through the injected fetcher within its limits, never echoing the uri", async () => { + const requested: string[] = []; + let answer = (): Response => + new Response(JSON.stringify(renamedDocument())); + const plane = dataPlane({ + fetchState: (url, signal) => { + requested.push(url.href); + expect(signal).toBeInstanceOf(AbortSignal); + return Promise.resolve(answer()); + }, + }); + const uri = "https://state.example.test/state.json?token=secret-token"; + const add = () => plane.add({ kind: "file", file: { uri } }); + await expect(add()).resolves.toMatchObject({ result: {} }); + expect(requested).toEqual([uri]); + expect(loginOf(plane.store)).toBe("renamed"); + answer = () => new Response("missing", { status: 404 }); + const missing = await add(); + expect(missing).toMatchObject({ + error: { code: -32000, data: { reason: "http-status" } }, + }); + expect(JSON.stringify(missing)).not.toContain("secret-token"); + answer = () => + new Response("{}", { + headers: { "content-length": String(STATE_INTAKE_LIMIT_BYTES + 1) }, + }); + await expect(add()).resolves.toMatchObject({ error: { code: -32602 } }); + const chunk = new Uint8Array(1024 * 1024); + answer = () => { + let sent = 0; + return new Response( + new ReadableStream({ + pull(controller) { + sent += chunk.length; + controller.enqueue(chunk); + if (sent > STATE_INTAKE_LIMIT_BYTES + chunk.length) + controller.close(); + }, + }), + ); + }; + await expect(add()).resolves.toMatchObject({ + error: { code: -32602, message: expect.stringContaining("exceeds") }, + }); + answer = () => new Response("not json"); + await expect(add()).resolves.toMatchObject({ error: { code: -32602 } }); + }); + + it("opens file uris only on this host, refusing another host or a network share before opening anything", async () => { + const plane = dataPlane(); + for (const uri of [ + "file://remote.example.test/share/state.json", + "file:////remote.example.test/share/state.json", + ]) { + const reply = await plane.add({ kind: "file", file: { uri } }); + expect(reply).toEqual({ + jsonrpc: "2.0", + id: 1, + error: { + code: -32602, + message: + "invalid params: a file:// uri names a path on this host, not a network share", + }, + }); + } + const root = await createTestTempDirectory("rea-environment-state-host-"); + const path = join(root, "state.json"); + await writeFile(path, JSON.stringify(renamedDocument())); + await expect( + plane.add({ + kind: "file", + file: { uri: `file://localhost${pathToFileURL(path).pathname}` }, + }), + ).resolves.toMatchObject({ result: {} }); + expect(loginOf(plane.store)).toBe("renamed"); + }); + + it("runs batches in order and runs notifications without replying", async () => { + const plane = dataPlane(); + const batch = await plane.post([ + { + jsonrpc: "2.0", + id: 1, + method: "data/add", + params: { parts: [{ kind: "data", data: renamedDocument() }] }, + }, + { jsonrpc: "2.0", id: 2, method: "data/get" }, + ]); + expect(batch.json).toMatchObject([ + { id: 1, result: {} }, + { id: 2, result: { parts: [{ kind: "data" }] } }, + ]); + expect(JSON.stringify(batch.json)).toContain('"login":"renamed"'); + const notified = await plane.post({ jsonrpc: "2.0", method: "data/reset" }); + expect(notified.status).toBe(202); + expect(loginOf(plane.store)).toBe("probe"); + }); +}); + +describe("state data plane file closes", () => { + it("keeps a state file whose close failed and closes it before the next file read", async () => { + const plane = dataPlane(); + const uri = await renamedStateUri(); + let failing = true; + const owners = failFirstFileClose(() => failing); + const failed = await plane.add({ kind: "file", file: { uri } }); + expect(failed).toMatchObject({ + error: { + code: -32000, + message: expect.stringContaining("could not be closed"), + }, + }); + expect(JSON.stringify(failed)).not.toContain(uri); + expect(loginOf(plane.store)).toBe("probe"); + expect(owners[0]?.handle.fd).toBeGreaterThanOrEqual(0); + failing = false; + await expect( + plane.add({ kind: "file", file: { uri } }), + ).resolves.toMatchObject({ result: {} }); + expect(owners[0]?.handle.fd).toBe(-1); + expect(loginOf(plane.store)).toBe("renamed"); + }); +}); + +describe("state data plane timing and order", () => { + it("ends a download that outlives its time limit, whether or not the fetcher honours the signal", async () => { + const uri = "https://state.example.test/slow.json?token=secret-token"; + const waitForAbort = (signal: AbortSignal) => + new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(signal.reason)); + }); + const stalledBody = () => + Promise.resolve( + new Response(new ReadableStream({ pull: () => undefined })), + ); + for (const fetchState of [ + (_: URL, signal: AbortSignal) => waitForAbort(signal), + stalledBody, + () => new Promise(() => undefined), + ]) { + const plane = dataPlane({ fetchState, timeoutMs: 20 }); + const reply = await plane.add({ kind: "file", file: { uri } }); + expect(reply).toMatchObject({ + error: { code: -32000, data: { reason: "timeout" } }, + }); + expect(JSON.stringify(reply)).not.toContain("secret-token"); + expect(loginOf(plane.store)).toBe("probe"); + } + const failing = dataPlane({ + fetchState: () => Promise.reject(new TypeError("fetch failed")), + }); + await expect( + failing.add({ kind: "file", file: { uri } }), + ).resolves.toMatchObject({ + error: { code: -32000, data: { reason: "network" } }, + }); + }); + + it("applies data-plane calls from separate requests in arrival order", async () => { + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const plane = dataPlane({ + fetchState: async () => { + await gate; + return new Response(JSON.stringify(renamedDocument())); + }, + }); + const slowAdd = plane.add({ + kind: "file", + file: { uri: "https://state.example.test/state.json" }, + }); + const reset = plane.call("data/reset"); + release(); + await expect(slowAdd).resolves.toMatchObject({ result: {} }); + await expect(reset).resolves.toMatchObject({ result: {} }); + // The reset arrived after the add, so it wins even though the add finished loading later. + expect(loginOf(plane.store)).toBe("probe"); + }); + + it("queues every message of a batch at arrival, so a later request's reset never lands between them", async () => { + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const held = heldFetcher(async () => { + await gate; + return new Response(JSON.stringify(renamedDocument())); + }); + const plane = dataPlane({ fetchState: held.fetchState }); + const batch = plane.post([ + addByUri(1), + { jsonrpc: "2.0", id: 2, method: "data/get" }, + ]); + await held.fetching; + const reset = plane.call("data/reset"); + release(); + const replies = (await batch).json; + expect(replies).toMatchObject([ + { id: 1, result: {} }, + { id: 2, result: { parts: [{ kind: "data" }] } }, + ]); + // The get arrived with the add, before the reset, so it reports the loaded state. + expect(JSON.stringify(replies)).toContain('"login":"renamed"'); + await expect(reset).resolves.toMatchObject({ result: {} }); + expect(loginOf(plane.store)).toBe("probe"); + }); +}); + +describe("state data plane cancellation", () => { + /** A download that never answers on its own and outlives any test. */ + const stalled = () => + heldFetcher(() => new Promise(() => undefined)); + + it("ends the running download and skips queued calls once the server stops", async () => { + const stopping = new AbortController(); + const held = stalled(); + const plane = dataPlane( + { fetchState: held.fetchState, timeoutMs: 600_000 }, + stopping.signal, + ); + await plane.add({ kind: "data", data: renamedDocument() }); + const batch = plane.post([ + addByUri(1), + { jsonrpc: "2.0", id: 2, method: "data/reset" }, + ]); + await held.fetching; + stopping.abort(); + expect((await batch).json).toEqual([ + { + jsonrpc: "2.0", + id: 1, + error: { + code: -32000, + message: "the state document could not be downloaded", + data: { reason: "cancelled" }, + }, + }, + { + jsonrpc: "2.0", + id: 2, + error: { + code: -32000, + message: "the call was cancelled and left the state unchanged", + data: { reason: "cancelled" }, + }, + }, + ]); + expect(held.signals.map((signal) => signal.aborted)).toEqual([true]); + // The queued reset never ran, and nothing runs after the stop. + expect(loginOf(plane.store)).toBe("renamed"); + await expect(plane.call("data/reset")).resolves.toMatchObject({ + error: { data: { reason: "cancelled" } }, + }); + expect(loginOf(plane.store)).toBe("renamed"); + }); + + it("ends a download whose caller left, leaving the state unchanged", async () => { + const held = stalled(); + const plane = dataPlane({ + fetchState: held.fetchState, + timeoutMs: 600_000, + }); + const leaving = new AbortController(); + const add = plane.post(addByUri(1), leaving.signal); + await held.fetching; + leaving.abort(); + expect((await add).json).toMatchObject({ + error: { code: -32000, data: { reason: "cancelled" } }, + }); + expect(held.signals[0]?.aborted).toBe(true); + expect(loginOf(plane.store)).toBe("probe"); + // Other callers' calls still run. + await expect( + plane.add({ kind: "data", data: renamedDocument() }), + ).resolves.toMatchObject({ result: {} }); + }); +}); + +describe("state data plane response budget", () => { + it("answers replies past the response budget with an error stating it instead of building the body", async () => { + const document = renamedDocument(); + const large = { + rea_state: { + ...document.rea_state, + entities: document.rea_state.entities.map((entity) => + entity.type === "/api/users" && entity.id === "2" + ? { + ...entity, + fields: { + ...entity.fields, + login: "x".repeat(Math.ceil(MAX_RPC_RESPONSE_BYTES / 3)), + }, + } + : entity, + ), + }, + }; + const root = await createTestTempDirectory("rea-environment-state-budget-"); + const path = join(root, "large.json"); + await writeFile(path, JSON.stringify(large)); + const plane = dataPlane(); + await expect( + plane.add({ kind: "file", file: { uri: pathToFileURL(path).href } }), + ).resolves.toMatchObject({ result: {} }); + const get = (id: number) => ({ jsonrpc: "2.0", id, method: "data/get" }); + const batch = await plane.post([ + get(1), + get(2), + get(3), + get(4), + { jsonrpc: "2.0", id: 5, method: "data/reset" }, + ]); + // Two reads fit; the third and fourth would pass the budget, and the reset's small reply is kept. + expect(batch.json).toMatchObject([ + { id: 1, result: { parts: [{ kind: "data" }] } }, + { id: 2, result: { parts: [{ kind: "data" }] } }, + { + id: 3, + error: { + code: -32000, + data: { + max_response_bytes: MAX_RPC_RESPONSE_BYTES, + reply_bytes: expect.any(Number), + }, + }, + }, + { id: 4, error: { code: -32000 } }, + { id: 5, result: {} }, + ]); + expect(Buffer.byteLength(JSON.stringify(batch.json))).toBeLessThanOrEqual( + MAX_RPC_RESPONSE_BYTES, + ); + expect(loginOf(plane.store)).toBe("probe"); + }); + + it("measures each reply before building it, so one past the budget is never serialized whole", async () => { + const document = renamedDocument(); + const login = "x".repeat(Math.ceil(MAX_RPC_RESPONSE_BYTES / 3)); + const large = { + rea_state: { + ...document.rea_state, + entities: document.rea_state.entities.map((entity) => + entity.type === "/api/users" && entity.id === "2" + ? { ...entity, fields: { ...entity.fields, login } } + : entity, + ), + }, + }; + const root = await createTestTempDirectory( + "rea-environment-state-measure-", + ); + const path = join(root, "large.json"); + await writeFile(path, JSON.stringify(large)); + const plane = dataPlane(); + await plane.add({ kind: "file", file: { uri: pathToFileURL(path).href } }); + const reply = (id: number) => ({ + jsonrpc: "2.0", + id, + result: { parts: [{ kind: "data", data: plane.store.snapshot() }] }, + }); + const replyBytes = Buffer.byteLength(JSON.stringify(reply(3))); + const stringify = vi.spyOn(JSON, "stringify"); + try { + const batch = await plane.post( + [1, 2, 3].map((id) => ({ jsonrpc: "2.0", id, method: "data/get" })), + ); + // A refused read is measured exactly, from bounded parts. + expect(batch.json).toMatchObject([ + { id: 1 }, + { id: 2 }, + { + id: 3, + error: { + data: { reply_bytes: replyBytes }, + }, + }, + ]); + // No whole reply was handed to JSON.stringify by the server. + expect( + stringify.mock.calls.some( + ([value]) => + typeof value === "object" && + value !== null && + "result" in value && + (value as { id?: unknown }).id === 3, + ), + ).toBe(false); + } finally { + stringify.mockRestore(); + } + }); +}); + +describe("state data plane response budget reserve", () => { + it("keeps room for every later refusal and the batch's framing, so the body never passes the budget", async () => { + const document = renamedDocument(); + const withLogin = (login: string) => ({ + rea_state: { + ...document.rea_state, + entities: document.rea_state.entities.map((entity) => + entity.type === "/api/users" && entity.id === "2" + ? { ...entity, fields: { ...entity.fields, login } } + : entity, + ), + }, + }); + const plane = dataPlane(); + await plane.add({ kind: "data", data: withLogin("") }); + const base = Buffer.byteLength( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { parts: [{ kind: "data", data: plane.store.snapshot() }] }, + }), + ); + // Three reads that alone fall 100 bytes short of the budget. + const login = "x".repeat( + Math.floor((MAX_RPC_RESPONSE_BYTES - 100) / 3) - base, + ); + const root = await createTestTempDirectory( + "rea-environment-state-reserve-", + ); + const path = join(root, "large.json"); + await writeFile(path, JSON.stringify(withLogin(login))); + await plane.add({ kind: "file", file: { uri: pathToFileURL(path).href } }); + const ids = Array.from({ length: 23 }, (_, index) => index + 1); + const batch = await plane.post( + ids.map((id) => ({ jsonrpc: "2.0", id, method: "data/get" })), + ); + // The third read would leave no room for the twenty refusals after it. + expect((batch.json as unknown[]).slice(0, 3)).toMatchObject([ + { id: 1, result: {} }, + { id: 2, result: {} }, + { id: 3, error: { code: -32000 } }, + ]); + expect((batch.json as { id: number }[]).map((reply) => reply.id)).toEqual( + ids, + ); + expect(Buffer.byteLength(JSON.stringify(batch.json))).toBeLessThanOrEqual( + MAX_RPC_RESPONSE_BYTES, + ); + }); +}); + +const served: ServedEnvironment[] = []; +afterEach(async () => { + await Promise.all(served.splice(0).map((entry) => entry.close())); +}); + +/** Serve a state on a loopback port until the test ends. */ +const serveState = async ( + inputs: ReturnType, + options: Pick = {}, +): Promise => { + const entry = await serveEnvironment({ + ...inputs, + ...options, + host: "127.0.0.1", + port: 0, + }); + if (!entry.ok) throw entry.error; + served.push(entry.value); + return entry.value; +}; + +/** Run `use` with an MCP client, after checking it lists the card's tools, then end its session. */ +const withClient = async ( + environment: ServedEnvironment, + use: (client: Client) => Promise, +): Promise => { + const transport = new StreamableHTTPClientTransport(new URL(environment.url)); + const client = new Client({ name: "environment-state", version: "1" }); + await client.connect(transport); + try { + const { tools } = await client.listTools(); + expect(tools.map(({ name }) => name)).toEqual( + environment.card.capabilities.tools.map(({ name }) => name), + ); + await use(client); + } finally { + await transport.terminateSession(); + await client.close(); + } +}; + +describe("serveEnvironment with a state", () => { + it("declares the data plane, serves read tools from the state and reflects data/add in the next answer", async () => { + const environment = await serveState({ source, state }); + const url = new URL(environment.url); + const card: unknown = await ( + await fetch(new URL(ENVIRONMENT_CARD_PATH, url)) + ).json(); + expect(card).toEqual(JSON.parse(JSON.stringify(environment.card))); + const { capabilities } = environment.card; + expect(capabilities.extensions).toEqual([STATE_INTAKE_EXTENSION]); + expect(capabilities.operations).toEqual(STATE_OPERATIONS); + const names = capabilities.tools.map((tool) => tool.name); + expect(names).toContain("get_users_by_id"); + for (const write of [ + "post_annotations", + "delete_dashboards_by_uid", + "post_folders", + "put_folders_by_uid", + "delete_teams_by_id", + ]) + expect(names).not.toContain(write); + await withClient(environment, async (client) => { + const before = await client.callTool({ + name: "get_users_by_id", + arguments: { id: "2" }, + }); + expect(before.structuredContent).toMatchObject({ + body: { login: "probe" }, + state: { source: "state", view: "item", id: "2" }, + }); + const added = await fetch(new URL(ENVIRONMENT_RPC_PATH, url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "data/add", + params: { parts: [{ kind: "data", data: renamedDocument() }] }, + }), + }); + await expect(added.json()).resolves.toMatchObject({ result: {} }); + const after = await client.callTool({ + name: "get_users_by_id", + arguments: { id: "2" }, + }); + expect(after.structuredContent).toMatchObject({ + body: { login: "renamed" }, + }); + const missing = await client.callTool({ + name: "get_users_by_id", + arguments: { id: "999" }, + }); + expect(missing.isError).toBe(true); + }); + }); + + it("stops without waiting for a stalled download", async () => { + const held = heldFetcher(() => new Promise(() => undefined)); + const environment = await serveState( + { source, state }, + { fetchState: held.fetchState }, + ); + const answered = fetch(new URL(ENVIRONMENT_RPC_PATH, environment.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(addByUri(1)), + }).then( + (response) => response.status, + () => "closed", + ); + await held.fetching; + // The download's own limit is 30 s; stopping must not wait for it. + const started = performance.now(); + await environment.close(); + expect(performance.now() - started).toBeLessThan(10_000); + expect(held.signals[0]?.aborted).toBe(true); + await answered; + }); + + it("refuses file reads while an earlier state file stays open, and reports it on close until it closes", async () => { + const environment = await serveState({ source, state }); + const uri = await renamedStateUri(); + let failing = true; + const owners = failFirstFileClose(() => failing); + const add = async (part: unknown) => + ( + await fetch(new URL(ENVIRONMENT_RPC_PATH, environment.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "data/add", + params: { parts: [part] }, + }), + }) + ).json(); + await expect(add({ kind: "file", file: { uri } })).resolves.toMatchObject({ + error: { + code: -32000, + message: expect.stringContaining("could not be closed"), + }, + }); + await expect(add({ kind: "file", file: { uri } })).resolves.toMatchObject({ + error: { code: -32000, message: expect.stringContaining("still open") }, + }); + // Documents that need no file still load. + await expect( + add({ kind: "data", data: renamedDocument() }), + ).resolves.toMatchObject({ result: {} }); + await expect(environment.close()).rejects.toBeInstanceOf( + ArtifactResourceRetryFailure, + ); + expect(owners[0]?.handle.fd).toBeGreaterThanOrEqual(0); + failing = false; + await expect(environment.close()).resolves.toBeUndefined(); + expect(owners[0]?.handle.fd).toBe(-1); + }); + + it("serves query POSTs from their recordings and leaves state-changing writes out", async () => { + const environment = await serveState( + environmentStateServeInputs( + environmentStateServeFixture( + timedCaptureEvidence([ + ["GET", "/api/items", [{ id: 1, name: "a" }]], + ["POST", "/api/search", [{ id: 1, name: "a" }]], + ["POST", "/api/items", { id: 2, name: "b" }], + ]), + ), + ), + ); + const { tools } = environment.card.capabilities; + expect(tools.map(({ name }) => name)).toEqual(["get_items", "post_search"]); + expect( + tools.find(({ name }) => name === "post_search")?.description, + ).toContain( + "answers from the served environment state where the state backs this tool", + ); + await withClient(environment, async (client) => { + const searched = await client.callTool({ + name: "post_search", + arguments: {}, + }); + expect(searched.structuredContent).toMatchObject({ + body: [{ id: 1, name: "a" }], + state: { source: "recording", reason: "query-body" }, + }); + }); + }); +}); + +it("keeps the stateless card's data plane empty", () => { + expect(environmentCard("x", []).capabilities).toEqual({ + extensions: null, + tools: [], + operations: [], + }); +}); diff --git a/src/server/environment/stateIntake.test.ts b/src/server/environment/stateIntake.test.ts new file mode 100644 index 0000000000..e72923e83e --- /dev/null +++ b/src/server/environment/stateIntake.test.ts @@ -0,0 +1,28 @@ +import { expect, it, vi } from "vitest"; + +import { + DEFAULT_STATE_INTAKE, + fetchStateDocument, + STATE_FETCH_TIMEOUT_MS, +} from "./stateIntake.js"; + +it("downloads with redirects refused and the 30 s timeout by default", async () => { + const fetched = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("{}")); + try { + const url = new URL("https://state.example.test/state.json"); + const signal = AbortSignal.timeout(STATE_FETCH_TIMEOUT_MS); + await fetchStateDocument(url, signal); + expect(fetched).toHaveBeenCalledExactlyOnceWith(url, { + signal, + redirect: "error", + }); + expect(DEFAULT_STATE_INTAKE).toEqual({ + fetchState: fetchStateDocument, + timeoutMs: 30_000, + }); + } finally { + fetched.mockRestore(); + } +}); diff --git a/src/server/environment/stateIntake.ts b/src/server/environment/stateIntake.ts new file mode 100644 index 0000000000..33af692c86 --- /dev/null +++ b/src/server/environment/stateIntake.ts @@ -0,0 +1,365 @@ +import { fileURLToPath } from "node:url"; + +import { + ArtifactResourceRetryFailure, + type ArtifactResourceScope, +} from "../../artifacts/ArtifactResourceScope.js"; +import { STATE_INTAKE_LIMIT_BYTES } from "../../application/environment/environmentStateDocument.js"; +import { mediaTypeEssence } from "../../domain/analysisView/webNetworkRouteTemplates.js"; +import { isExactJsonNumber } from "../../domain/exactJsonNumber.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { safeParseJson } from "../../domain/safeJson.js"; +import { + RegularFileCapacityError, + readStableFileBytes, +} from "../../filesystem/readStableFileBytes.js"; +import { + RegularFileCleanupFailure, + withOwnedRegularFile, +} from "../../filesystem/OwnedRegularFile.js"; + +/** Why a named file or download could not be read, or a call was cancelled: a category naming neither the uri nor the content. */ +export type IntakeFailure = + | "not-found" + | "permission" + | "not-regular-file" + | "changed" + | "read-failed" + | "timeout" + | "http-status" + | "network" + | "cancelled"; + +/** A JSON-RPC error the data plane answers with; `data` locates a document problem, names an intake failure or states the response budget. */ +export interface RpcError { + readonly code: number; + readonly message: string; + readonly data?: + | { readonly path: string } + | { readonly reason: IntakeFailure } + | { readonly max_response_bytes: number; readonly reply_bytes: number }; +} + +const INVALID_PARAMS = -32602; +/** The data plane could not complete an operation whose params were valid. */ +export const SERVER_ERROR = -32000; + +/** A call the server's stop or its caller's departure ended before it could change the state. */ +export const CANCELLED: RpcError = { + code: SERVER_ERROR, + message: "the call was cancelled and left the state unchanged", + data: { reason: "cancelled" }, +}; + +export { STATE_INTAKE_LIMIT_BYTES }; +/** An https state download must finish within this time. */ +export const STATE_FETCH_TIMEOUT_MS = 30_000; + +/** Fetches one https state document; injectable so tests make no external requests. */ +export type StateFetcher = (url: URL, signal: AbortSignal) => Promise; + +/** Redirects are refused so a download never leaves the https origin the caller named. */ +export const fetchStateDocument: StateFetcher = (url, signal) => + fetch(url, { signal, redirect: "error" }); + +/** How `data/add` reaches documents a uri names; injectable so tests make no external requests and need not wait. */ +export interface StateIntake { + readonly fetchState: StateFetcher; + readonly timeoutMs: number; +} + +/** The intake a served environment uses. */ +export const DEFAULT_STATE_INTAKE: StateIntake = { + fetchState: fetchStateDocument, + timeoutMs: STATE_FETCH_TIMEOUT_MS, +}; + +/** Stands in for a value that JSON parsing would change: a number without an exact native value, or a `__proto__` member. */ +const NOT_EXACT_JSON = Symbol("not exact JSON"); + +/** Mark values native parsing would change, so validation refuses them rather than storing an altered value. */ +export const exactJsonReviver = ( + key: string, + value: unknown, + context?: { readonly source?: string }, +): unknown => + key === "__proto__" || + (typeof value === "number" && !isExactJsonNumber(value, context?.source)) + ? NOT_EXACT_JSON + : value; + +export const invalidParams = (detail: string, path?: string): RpcError => ({ + code: INVALID_PARAMS, + message: `invalid params: ${detail}`, + ...(path === undefined ? {} : { data: { path } }), +}); + +const TOO_LARGE = invalidParams( + `the state document exceeds ${String(STATE_INTAKE_LIMIT_BYTES)} bytes`, +); + +const unreadable = (reason: IntakeFailure): RpcError => ({ + ...invalidParams("the file uri does not name a readable regular file"), + data: { reason }, +}); + +const fetchFailed = (reason: IntakeFailure): RpcError => ({ + code: SERVER_ERROR, + message: "the state document could not be downloaded", + data: { reason }, +}); + +const FILE_FAILURES: ReadonlyMap = new Map([ + ["ENOENT", "not-found"], + ["ENOTDIR", "not-found"], + ["EACCES", "permission"], + ["EPERM", "permission"], + ["EISDIR", "not-regular-file"], + ["ENOTFILE", "not-regular-file"], + ["ESTALE", "changed"], +]); + +/** The coarse category of a file open or read failure, from its error code. */ +const fileFailure = (cause: unknown): IntakeFailure => + (cause instanceof Error && "code" in cause + ? FILE_FAILURES.get(String(cause.code)) + : undefined) ?? "read-failed"; + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const BASE64 = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; + +/** Parse loaded bytes as UTF-8 JSON, marking values parsing would change; the content is never echoed. */ +const parseDocumentBytes = (bytes: Uint8Array): Result => { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return err(invalidParams("the file is not UTF-8 text")); + } + const parsed = safeParseJson(text, exactJsonReviver); + return parsed.ok + ? ok(parsed.value) + : err(invalidParams("the file is not JSON")); +}; + +const CLOSE_FAILED: RpcError = { + code: SERVER_ERROR, + message: + "the state file could not be closed; the server retries the close before the next file read and at shutdown", +}; + +const STILL_OPEN: RpcError = { + code: SERVER_ERROR, + message: + "an earlier state file is still open after its close failed, so no file was read", +}; + +/** Read one regular file within the intake limit; a descriptor whose close fails stays with `files`, which retries it before each later read. */ +const readStateFile = async ( + path: string, + files: ArtifactResourceScope, +): Promise> => { + try { + return ok( + await files.run(() => + withOwnedRegularFile( + path, + { + symlinks: "follow", + retainFailedClose: (owner) => + files.retain({ + kind: "file-handle", + resource: path, + handle: owner, + }), + }, + (handle) => + readStableFileBytes(handle, path, STATE_INTAKE_LIMIT_BYTES, { + symlinks: "follow", + }), + ), + ), + ); + } catch (cause: unknown) { + if (cause instanceof RegularFileCleanupFailure) return err(CLOSE_FAILED); + if (cause instanceof ArtifactResourceRetryFailure) return err(STILL_OPEN); + return err( + cause instanceof RegularFileCapacityError + ? TOO_LARGE + : unreadable(fileFailure(cause)), + ); + } +}; + +/** Settles only by rejecting when the signal aborts; the rejection is always handled. */ +const abortion = (signal: AbortSignal): Promise => { + const aborted = new Promise((_, reject) => { + if (signal.aborted) reject(signal.reason); + else + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + aborted.catch(() => undefined); + return aborted; +}; + +/** What ends a download: its time limit or the caller's cancellation; `failure` names which, or a network failure when neither did. */ +interface DownloadBounds { + readonly signal: AbortSignal; + readonly failure: () => IntakeFailure; +} + +const downloadBounds = ( + timeoutMs: number, + cancel: AbortSignal, +): DownloadBounds => { + const timeout = AbortSignal.timeout(timeoutMs); + return { + signal: AbortSignal.any([timeout, cancel]), + failure: () => + cancel.aborted ? "cancelled" : timeout.aborted ? "timeout" : "network", + }; +}; + +/** Read a response body within the intake limit and the download's bounds, whether or not the fetcher ties the body to the signal. */ +const readCapped = async ( + body: ReadableStream | null, + bounds: DownloadBounds, +): Promise> => { + if (body === null) return ok(Buffer.alloc(0)); + const reader = body.getReader(); + const aborted = abortion(bounds.signal); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await Promise.race([reader.read(), aborted]); + if (done) return ok(Buffer.concat(chunks, total)); + total += value.byteLength; + if (total > STATE_INTAKE_LIMIT_BYTES) { + await reader.cancel().catch(() => undefined); + return err(TOO_LARGE); + } + chunks.push(value); + } + } catch { + void reader.cancel().catch(() => undefined); + return err(fetchFailed(bounds.failure())); + } +}; + +/** Download one https document within the time and size limits, ending it when `cancel` aborts. */ +const downloadState = async ( + url: URL, + intake: StateIntake, + cancel: AbortSignal, +): Promise> => { + const bounds = downloadBounds(intake.timeoutMs, cancel); + const fetching = Promise.resolve().then(() => + intake.fetchState(url, bounds.signal), + ); + let response: Response; + try { + response = await Promise.race([fetching, abortion(bounds.signal)]); + } catch { + // A response that arrives after the download ended is released unread. + void fetching + .then( + (late) => late.body?.cancel(), + () => undefined, + ) + .catch(() => undefined); + return err(fetchFailed(bounds.failure())); + } + const declared = Number(response.headers.get("content-length") ?? "0"); + if (!response.ok || declared > STATE_INTAKE_LIMIT_BYTES) { + await response.body?.cancel().catch(() => undefined); + return err( + response.ok + ? TOO_LARGE + : { + ...fetchFailed("http-status"), + message: `the state document download answered HTTP ${String(response.status)}`, + }, + ); + } + return readCapped(response.body, bounds); +}; + +const NETWORK_SHARE = invalidParams( + "a file:// uri names a path on this host, not a network share", +); + +/** A path that names a host before its first segment, as `\\server\share` or `//server/share` does. */ +const UNC_PATH = /^[\\/]{2}/u; + +/** The local path a `file://` uri names; another host or a UNC path would open a network share, so neither is opened. */ +const localFilePath = (url: URL): Result => { + if (url.host !== "" && url.host !== "localhost") return err(NETWORK_SHARE); + let path: string; + try { + path = fileURLToPath(url); + } catch { + return err(invalidParams("a file:// uri names a local absolute path")); + } + return UNC_PATH.test(path) ? err(NETWORK_SHARE) : ok(path); +}; + +/** The bytes a file part carries inline or names by a `file://` or `https://` uri; `cancel` ends a download. */ +const fileBytes = async ( + file: Readonly>, + intake: StateIntake, + cancel: AbortSignal, + files: ArtifactResourceScope, +): Promise> => { + if (typeof file.bytes === "string" && file.uri === undefined) { + if (!BASE64.test(file.bytes)) + return err(invalidParams("the file part's bytes are not base64")); + const bytes = Buffer.from(file.bytes, "base64"); + return bytes.length > STATE_INTAKE_LIMIT_BYTES ? err(TOO_LARGE) : ok(bytes); + } + if ( + typeof file.uri !== "string" || + file.bytes !== undefined || + !URL.canParse(file.uri) + ) + return err( + invalidParams("a file part carries exactly one of bytes or a uri"), + ); + const url = new URL(file.uri); + if (url.protocol === "https:") return downloadState(url, intake, cancel); + if (url.protocol !== "file:") + return err(invalidParams("a file uri is a file:// or https:// URL")); + const path = localFilePath(url); + return path.ok ? readStateFile(path.value, files) : path; +}; + +/** The state document one `data/add` part carries, parsed but not yet validated; `cancel` ends a download and `files` owns file descriptors whose close failed. */ +export const partDocument = async ( + part: unknown, + intake: StateIntake, + cancel: AbortSignal, + files: ArtifactResourceScope, +): Promise> => { + if (isRecord(part) && part.kind === "data" && isRecord(part.data)) + return ok(part.data); + if (!isRecord(part) || part.kind !== "file" || !isRecord(part.file)) + return err( + invalidParams( + "a data/add part is a data part holding the document or a file part carrying it", + ), + ); + const { mimeType } = part.file; + if ( + mimeType !== undefined && + (typeof mimeType !== "string" || + mediaTypeEssence(mimeType) !== "application/json") + ) + return err(invalidParams("a file part's mimeType is application/json")); + const bytes = await fileBytes(part.file, intake, cancel, files); + return bytes.ok ? parseDocumentBytes(bytes.value) : bytes; +}; diff --git a/tests/boundary/cli/environmentExportCli.test.ts b/tests/boundary/cli/environmentExportCli.test.ts index 9fdb69339f..434100782c 100644 --- a/tests/boundary/cli/environmentExportCli.test.ts +++ b/tests/boundary/cli/environmentExportCli.test.ts @@ -93,7 +93,9 @@ describe("rea environment-export", () => { status: "exported", directory, name: "grafana-example", - tool_count: 19, + // The served read tools of the state the export derived, as the bundle's server serves them. + tool_count: 14, + state: { served: true, evidence_id: expect.stringMatching(/^ev_/u) }, package_source: { kind: "registry" }, }); const document = result.json as { @@ -125,7 +127,19 @@ describe("rea environment-export", () => { "utf8", ); expect(dockerfile).toMatch(/^FROM node:24\.18\.0-bookworm-slim\n/u); - expect(dockerfile).toContain('CMD ["rea","environment-serve"'); + expect(dockerfile).toContain( + 'CMD ["rea","environment-serve","/env/manifest.json","--evidence","/env/evidence.json"]', + ); + const verifier = await readFile( + join(directory, "artifacts/check_grafana-example/verify.py"), + "utf8", + ); + expect(verifier).toContain("TOOL_COUNT = 14\n"); + expect(verifier).toContain('TOOL = "get_annotations"\n'); + expect(verifier).toContain("EXPECTED_STATUS = 200\n"); + expect(await readFile(join(directory, "README.md"), "utf8")).toContain( + "This bundle serves from the state", + ); }, ); diff --git a/tests/boundary/mcp/environmentServeMcp.test.ts b/tests/boundary/mcp/environmentServeMcp.test.ts index 0f7d42af92..8fa7b44b4e 100644 --- a/tests/boundary/mcp/environmentServeMcp.test.ts +++ b/tests/boundary/mcp/environmentServeMcp.test.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import { Client, StreamableHTTPClientTransport, @@ -7,6 +9,7 @@ import { describe, expect } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { environmentServeSource, + environmentStateServeFixture, writeEnvironmentServeFiles, } from "../../fixtures/environmentServe.js"; import { startListeningProcess } from "../../support/mcp/streamableHttpProcess.js"; @@ -17,9 +20,12 @@ import { const HTTP_BOUNDARY_TIMEOUT_MS = 90_000; const CARD_PATH = "/.well-known/agent-env.json"; +/** SHA-256 of the 11,131-byte card main serves for the fixture named "renamed"; pure replay must serve the same bytes. */ +const MAIN_REPLAY_CARD_SHA256 = + "f12f16011feb5f675b2bbf6a04a8b28a2ed8041074e4fe188cf74e7a66b15207"; -/** Serve the fixture pair through the built CLI and connect an SDK client to it. */ -const serveFixture = async ( +/** Serve the fixture pair as pure replay through the built CLI. */ +const serveReplayOnly = async ( processes: TestProcesses, environment: Readonly> = {}, ) => { @@ -32,6 +38,7 @@ const serveFixture = async ( files.manifestPath, "--evidence", files.capturePath, + "--replay-only", "--port", "0", ], @@ -47,12 +54,12 @@ const connect = async (url: URL) => { return client; }; -describe("rea environment-serve", () => { +describe("rea environment-serve --replay-only", () => { processTest( "lists one tool per manifest tool and replays exact and best-effort records as untrusted target content", async ({ processes }) => { const source = environmentServeSource(); - const started = await serveFixture(processes); + const started = await serveReplayOnly(processes); expect(started.url.hostname).toBe("127.0.0.1"); expect(started.url.pathname).toBe("/mcp"); const client = await connect(started.url); @@ -104,16 +111,20 @@ describe("rea environment-serve", () => { ); processTest( - "serves the environment card named by ENVIRONMENT_NAME and refuses every data-plane method", + "serves the stateless environment card named by ENVIRONMENT_NAME and refuses every data-plane method", async ({ processes }) => { const source = environmentServeSource(); - const started = await serveFixture(processes, { + const started = await serveReplayOnly(processes, { ENVIRONMENT_NAME: "renamed", }); const origin = new URL(started.url).origin; const card = await fetch(new URL(CARD_PATH, origin)); expect(card.status).toBe(200); - const body: unknown = await card.json(); + const text = await card.text(); + expect(createHash("sha256").update(text).digest("hex")).toBe( + MAIN_REPLAY_CARD_SHA256, + ); + const body: unknown = JSON.parse(text); expect(body).toMatchObject({ name: "renamed", protocolVersion: "1.0", @@ -141,3 +152,122 @@ describe("rea environment-serve", () => { HTTP_BOUNDARY_TIMEOUT_MS, ); }); + +describe("rea environment-serve", () => { + processTest( + "serves read tools from the state it derives from the capture and loads a replacement state through the data plane", + async ({ processes }) => { + const root = await createTestTempDirectory("rea-environment-state-mcp-"); + const fixture = environmentStateServeFixture(); + // Only the manifest and the capture: the server derives the state itself. + const files = await writeEnvironmentServeFiles(root, fixture); + const started = await startListeningProcess(processes, [ + "environment-serve", + files.manifestPath, + "--evidence", + files.capturePath, + "--port", + "0", + ]); + expect(started.stderr()).not.toContain("Environment state unavailable"); + const origin = new URL(started.url).origin; + const card = (await (await fetch(new URL(CARD_PATH, origin))).json()) as { + capabilities: { + operations: string[]; + extensions: { uri: string }[] | null; + tools: { name: string }[]; + }; + }; + expect(card.capabilities.operations).toEqual([ + "data/reset", + "data/add", + "data/get", + ]); + expect(card.capabilities.extensions).toMatchObject([ + { uri: "urn:agentenv:intake/v1" }, + ]); + const names = card.capabilities.tools.map(({ name }) => name); + expect(names).toContain("get_users_by_id"); + for (const write of [ + "post_annotations", + "delete_dashboards_by_uid", + "post_folders", + "put_folders_by_uid", + "delete_teams_by_id", + ]) + expect(names).not.toContain(write); + const rpc = async (method: string, params?: unknown) => + ( + await fetch(new URL("/agentenv", origin), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }) + ).json() as Promise<{ + result?: { + parts: [ + { + data: { + rea_state: { + state_evidence_id: string; + entities: unknown[]; + }; + }; + }, + ]; + }; + error?: { code: number }; + }>; + await expect(rpc("data/add", { parts: [] })).resolves.toMatchObject({ + error: { code: -32602 }, + }); + const current = await rpc("data/get"); + const document = current.result?.parts[0].data; + // The derived state is the one inspect-analysis-view projects from the capture. + expect(document?.rea_state.state_evidence_id).toBe( + fixture.state.evidence_id, + ); + expect(document?.rea_state.entities.length).toBeGreaterThan(0); + const client = await connect(started.url); + try { + const before = await client.callTool({ + name: "get_users_by_id", + arguments: { id: "3" }, + }); + expect(before.structuredContent).toMatchObject({ + body: { login: "viewer" }, + state: { source: "state", view: "item", id: "3" }, + }); + await expect( + rpc("data/add", { + parts: [ + { + kind: "data", + data: { rea_state: { ...document?.rea_state, entities: [] } }, + }, + ], + }), + ).resolves.toMatchObject({ result: {} }); + const emptied = await client.callTool({ + name: "get_users_by_id", + arguments: { id: "3" }, + }); + expect(emptied.isError).toBe(true); + expect(emptied._meta).toMatchObject({ + provenance: { untrusted_content: true }, + }); + await expect(rpc("data/reset")).resolves.toMatchObject({ result: {} }); + const restored = await client.callTool({ + name: "get_users_by_id", + arguments: { id: "3" }, + }); + expect(restored.structuredContent).toMatchObject({ + body: { login: "viewer" }, + }); + } finally { + await client.close(); + } + }, + HTTP_BOUNDARY_TIMEOUT_MS, + ); +}); diff --git a/tests/boundary/process/environmentServeShutdown.test.ts b/tests/boundary/process/environmentServeShutdown.test.ts index 93a53461e3..fd06617d84 100644 --- a/tests/boundary/process/environmentServeShutdown.test.ts +++ b/tests/boundary/process/environmentServeShutdown.test.ts @@ -4,7 +4,14 @@ import { join } from "node:path"; import { describe, expect } from "vitest"; -import { writeEnvironmentServeFiles } from "../../fixtures/environmentServe.js"; +import { + scenarioHeaderOnlyCaptureEvidence, + scenarioServeFixture, +} from "../../fixtures/browserScenarioCapture.js"; +import { + environmentServeFixture, + writeEnvironmentServeFiles, +} from "../../fixtures/environmentServe.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { rawHttpRequest } from "../../support/http/rawRequest.js"; import { startListeningProcess } from "../../support/mcp/streamableHttpProcess.js"; @@ -12,46 +19,103 @@ import { processTest } from "../../support/process/processFixture.js"; const SHUTDOWN_TIMEOUT_MS = 90_000; +/** How each case is served and what its stopped document reports about the state. */ +const MODES = [ + { + mode: "from the state it derives", + fixture: environmentServeFixture, + flags: [], + environment: "grafana-example", + // The manifest's 19 tools less its 5 write tools. + tools: 14, + state: { served: true, evidence_id: expect.stringMatching(/^ev_/u) }, + }, + { + mode: "as pure replay with --replay-only", + fixture: environmentServeFixture, + flags: ["--replay-only"], + environment: "grafana-example", + tools: 19, + state: { served: false, reason: "replay-only" }, + }, + { + mode: "as replay when the capture yields no state", + fixture: () => scenarioServeFixture(scenarioHeaderOnlyCaptureEvidence()), + flags: [], + environment: expect.any(String), + tools: 5, + state: { + served: false, + reason: "unavailable", + code: "unsupported_target", + message: expect.stringContaining("response_body"), + }, + }, +] as const; + describe("rea environment-serve shutdown", () => { // On Windows, Node's child.kill() terminates the child outright for SIGTERM and SIGINT alike, // so no signal handler runs and there is no graceful shutdown to observe. - processTest.skipIf(process.platform === "win32")( - "closes the listener on SIGTERM, prints one stopped document and exits 143", - async ({ processes }) => { - const root = await createTestTempDirectory("rea-environment-serve-stop-"); - const files = await writeEnvironmentServeFiles(root); - const started = await startListeningProcess(processes, [ - "environment-serve", - files.manifestPath, - "--evidence", - files.capturePath, - "--port", - "0", - "--json", - ]); - let stdout = ""; - started.child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString("utf8"); - }); - const card = await rawHttpRequest( - new URL("/.well-known/agent-env.json", started.url.origin), - ); - expect(card.status).toBe(200); - started.child.kill("SIGTERM"); - const [code, signalCode] = await once(started.child, "close"); - expect(code).toBe(143); - expect(signalCode).toBeNull(); - expect(JSON.parse(stdout)).toMatchObject({ - status: "stopped", - url: started.url.href, - environment: "grafana-example", - }); - await expect(rawHttpRequest(started.url)).rejects.toMatchObject({ - code: "ECONNREFUSED", - }); - }, - SHUTDOWN_TIMEOUT_MS, - ); + for (const { mode, fixture, flags, environment, tools, state } of MODES) + processTest.skipIf(process.platform === "win32")( + `closes the listener on SIGTERM, prints one stopped document and exits 143 when serving ${mode}`, + async ({ processes }) => { + const root = await createTestTempDirectory( + "rea-environment-serve-stop-", + ); + const files = await writeEnvironmentServeFiles(root, fixture()); + const started = await startListeningProcess(processes, [ + "environment-serve", + files.manifestPath, + "--evidence", + files.capturePath, + ...flags, + "--port", + "0", + "--json", + ]); + let stdout = ""; + started.child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + const card = await rawHttpRequest( + new URL("/.well-known/agent-env.json", started.url.origin), + ); + expect(card.status).toBe(200); + started.child.kill("SIGTERM"); + const [code, signalCode] = await once(started.child, "close"); + expect(code).toBe(143); + expect(signalCode).toBeNull(); + expect(JSON.parse(stdout)).toEqual({ + status: "stopped", + url: started.url.href, + environment, + tools, + evidence_id: expect.stringMatching(/^ev_/u), + manifest_evidence_id: expect.stringMatching(/^ev_/u), + state, + }); + // A state that cannot be derived is logged as a warning naming its code, before the listener binds. + const warning = started + .stderr() + .split("\n") + .find((line) => line.includes("Environment state unavailable")); + if ("code" in state) { + expect(JSON.parse(warning ?? "{}")).toMatchObject({ + level: 40, + code: state.code, + error: state.message, + }); + expect(started.stderr().indexOf(warning ?? "")).toBeLessThan( + started.stderr().indexOf("MCP transport listening"), + ); + } else expect(warning).toBeUndefined(); + await expect(rawHttpRequest(started.url)).rejects.toMatchObject({ + code: "ECONNREFUSED", + }); + }, + SHUTDOWN_TIMEOUT_MS, + ); }); describe.skipIf(process.platform === "win32")( diff --git a/tests/fixtures/analysisView.ts b/tests/fixtures/analysisView.ts index 9a88e33fe9..30ac1fe239 100644 --- a/tests/fixtures/analysisView.ts +++ b/tests/fixtures/analysisView.ts @@ -313,3 +313,34 @@ export const environmentManifestCaptureFixture = ( /** The same capture with distinct start times, as the environment-state view orders it. */ export const environmentStateCaptureFixture = () => environmentManifestCaptureFixture(environmentStateHar().log.entries); + +/** One synthetic exchange: method, path, JSON response body (a string is recorded as written) and status. */ +export type TimedCaptureRow = readonly [ + method: string, + path: string, + body?: JsonValue, + status?: number, +]; + +/** Capture Evidence of HAR entries one second apart on one origin, as the environment-state view orders them. */ +export const timedCaptureEvidence = ( + rows: readonly TimedCaptureRow[], +): Evidence => + analysisViewCaptureEvidence( + environmentManifestCaptureFixture( + rows.map(([method, path, body = null, status = 200], index) => ({ + startedDateTime: new Date( + Date.parse("2026-10-17T18:00:00.000Z") + index * 1000, + ).toISOString(), + time: 5, + request: { method, url: `http://app.example${path}` }, + response: { + status, + content: { + mimeType: "application/json", + text: typeof body === "string" ? body : JSON.stringify(body), + }, + }, + })), + ), + ); diff --git a/tests/fixtures/browserScenarioCapture.ts b/tests/fixtures/browserScenarioCapture.ts index 22c5f92b5d..09bc1ad687 100644 --- a/tests/fixtures/browserScenarioCapture.ts +++ b/tests/fixtures/browserScenarioCapture.ts @@ -314,3 +314,13 @@ export const scenarioServeFixture = ( if (!manifest.ok) throw manifest.error; return { manifest: manifest.value, capture }; }; + +/** A scenario capture that kept header values but no bodies: it yields a manifest but no environment state. */ +export const scenarioHeaderOnlyCaptureEvidence = (): Evidence => + scenarioCaptureEvidence( + scenarioCaptureFixture(undefined, { + request_body: false, + response_body: false, + header_values: true, + }), + ); diff --git a/tests/fixtures/environmentServe.ts b/tests/fixtures/environmentServe.ts index 8d95b1ec24..3021cbcd22 100644 --- a/tests/fixtures/environmentServe.ts +++ b/tests/fixtures/environmentServe.ts @@ -6,10 +6,17 @@ import { loadEnvironmentSource, type EnvironmentSource, } from "../../src/application/environment/environmentSource.js"; +import { + loadEnvironmentState, + prepareServedState, + type EnvironmentState, + type PreparedState, +} from "../../src/application/environment/environmentState.js"; import type { Evidence } from "../../src/domain/evidence.js"; import { analysisViewCaptureEvidence, environmentManifestCaptureFixture, + environmentStateCaptureFixture, } from "./analysisView.js"; /** Every opt-in rule, so the fixture yields `uid` and `plugin_id` parameters. */ @@ -49,6 +56,14 @@ export const environmentServeSource = ( return source.value; }; +/** The fixture source and the state its server prepares at startup, as serving and export prepare it. */ +export const environmentServeServed = ( + fixture: EnvironmentServeFixture = environmentServeFixture(), +): { readonly source: EnvironmentSource; readonly served: PreparedState } => { + const source = environmentServeSource(fixture); + return { source, served: prepareServedState(source, fixture.capture) }; +}; + /** Paths of the two Evidence files the serve command reads. */ export interface EnvironmentServeFiles { readonly manifestPath: string; @@ -90,3 +105,39 @@ export const environmentServeSourceWithBody = ( }; return { ...source, exchanges: new Map([[ordinal, exchange]]) }; }; + +/** Manifest, capture and environment-state Evidence projected from one capture with the same rules. */ +export interface EnvironmentStateServeFixture extends EnvironmentServeFixture { + readonly state: Evidence; +} + +/** Project a capture's manifest and state through the real view service; defaults to the timed Grafana-shaped capture. */ +export const environmentStateServeFixture = ( + capture: Evidence = analysisViewCaptureEvidence( + environmentStateCaptureFixture(), + ), +): EnvironmentStateServeFixture => { + const project = (kind: "environment-manifest" | "environment-state") => { + const projected = inspectAnalysisView({ + source: { kind: "inline", evidence: capture }, + view: { kind, template_rules: [...ALL_TEMPLATE_RULES] }, + }); + if (!projected.ok) throw projected.error; + return projected.value; + }; + return { + manifest: project("environment-manifest"), + capture, + state: project("environment-state"), + }; +}; + +/** The served source and the state Evidence loaded for it, as derivation checks it. */ +export const environmentStateServeInputs = ( + fixture: EnvironmentStateServeFixture = environmentStateServeFixture(), +): { readonly source: EnvironmentSource; readonly state: EnvironmentState } => { + const source = environmentServeSource(fixture); + const state = loadEnvironmentState(fixture.state, source); + if (!state.ok) throw state.error; + return { source, state: state.value }; +};