Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7183b654d | ||
|
|
f5847a8ea9 | ||
|
|
41fa1b2f05 | ||
|
|
78c1c03815 | ||
|
|
47789cdaa8 | ||
|
|
d7ca93cc2c | ||
|
|
82e4e032df | ||
|
|
ca2ca16146 | ||
|
|
14c4c2a1d7 | ||
|
|
4842f23765 | ||
|
|
7721d59e1a | ||
|
|
76dbc32f39 | ||
|
|
f23066f0da | ||
|
|
cbbd317fb5 | ||
|
|
d54c4345d3 | ||
|
|
bdf0be87c6 | ||
|
|
0155b89953 |
No files matched your search
@@ -219,6 +219,13 @@ explicit path or endpoint and do not need it.
|
||||
delivery requires `--evidence_output`.
|
||||
See the
|
||||
[Rust binary guide](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md).
|
||||
- PE Authenticode certificate table: `inspect_pe_certificates` with the explicit
|
||||
local PE32/PE32+ file. This built-in target-free inspection requires no
|
||||
Windows SDK. Preserve WIN_CERTIFICATE ranges, types, payload SHA-256, and
|
||||
PKCS#7 signer/X.509 identity fields. A missing security directory is observed
|
||||
absence of that table, not proof the image is unsigned in every catalog sense.
|
||||
Signatures are not verified. See the
|
||||
[PE certificate guide](https://github.com/morluto/rea/blob/main/docs/pe-certificates.md).
|
||||
|
||||
- Explicit local SQLite database snapshot: `inspect_sqlite_database`. Inspect
|
||||
with Node.js 24.x >=24.15 or 26+ providing native SQLite limits. Read schema
|
||||
|
||||
@@ -93,14 +93,10 @@ Use `view: {kind: "environment-state"}` with the manifest's `template_rules` to
|
||||
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. The
|
||||
view also gives each state-changing tool a write plan, served only when a later
|
||||
recorded read confirms its effect and none contradicts it.
|
||||
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, serves the write tools whose plan
|
||||
is served, each applying its effect to that state, and serves `data/reset`,
|
||||
`data/add` and `data/get` on `/agentenv`; other write tools are not served, and
|
||||
each plan states why in its `reason`. It replays the capture instead with
|
||||
`--replay-only` or when the state is unavailable (not derivable, or a catalog
|
||||
over the discovery budget), and its stopped document's `state` says which
|
||||
applied.
|
||||
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.
|
||||
@@ -22,6 +22,11 @@ requires complete application analysis; `source_paths: []` is invalid, not an
|
||||
unfiltered request. `detail: "summary"` reduces delivery
|
||||
and does not by itself reduce analysis work.
|
||||
|
||||
ASAR header/index admission is fixed at 16 MiB / 16777216 bytes for this
|
||||
release and has no runtime override. `source_paths`, `detail`, and
|
||||
`max_heap_mb` do not bypass or raise it. Analyze an extracted directory, or
|
||||
supply an archive whose header fits.
|
||||
|
||||
Exact `#alias` specifiers resolve through the importer's
|
||||
enclosing package `imports` map to package-local files; nested `node_modules`
|
||||
packages use their own map. Wildcard, recursive, and external-package import
|
||||
|
||||
@@ -94,6 +94,19 @@ dependencies. An omitted package kind stays null. A rustc commit hash is
|
||||
inferred from a read-only `/rustc/<40-hex>/` path and does not by itself
|
||||
identify the compiler. See [Rust binaries](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md).
|
||||
|
||||
## PE Authenticode certificate tables
|
||||
|
||||
Use `inspect_pe_certificates` for an explicit local PE32/PE32+ image when the
|
||||
question concerns the Authenticode certificate table, WIN_CERTIFICATE types, or
|
||||
embedded PKCS#7 signer and X.509 identity fields. No `open_binary`, Windows SDK,
|
||||
or target execution is required. The CLI equivalent is
|
||||
`inspect-pe-certificates PATH`. Preserve artifact SHA-256, table file ranges,
|
||||
payload hashes, and original validity-time spellings. A missing
|
||||
`IMAGE_DIRECTORY_ENTRY_SECURITY` directory is observed absence of that table,
|
||||
not proof the image is unsigned in every catalog or overlay sense. Signatures,
|
||||
chains, timestamps, and Authenticode digest coverage remain unobserved. See
|
||||
[PE Authenticode certificate tables](https://github.com/morluto/rea/blob/main/docs/pe-certificates.md).
|
||||
|
||||
## SQLite snapshots
|
||||
|
||||
Use `inspect_sqlite_database` with an explicit local database snapshot for its
|
||||
|
||||
@@ -191,6 +191,7 @@ Additional tools and host support depend on the target:
|
||||
| IL2CPP metadata | Unity global-metadata.dat assembly images, type and method names and index ranges | Built-in static decoding of versions 24, 27, 29, 31 and 39; no Unity required |
|
||||
| Godot PCK | Archive inventory and extraction, original header/directory facts and selected content identities | Built-in static formats 2, 3 and 4; no Godot required |
|
||||
| Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](docs/pe-certificates.md) |
|
||||
| WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) |
|
||||
| SQLite databases | Tables, columns, indexes, views, triggers and selected ordinary-table rows | Local database/WAL snapshot; Node.js 24.x >=24.15 or 26+ with native SQLite limits |
|
||||
|
||||
@@ -32,6 +32,7 @@ export default defineConfig({
|
||||
{ text: "IL2CPP metadata", link: "/il2cpp-metadata" },
|
||||
{ text: "Model artifacts", link: "/model-artifacts" },
|
||||
{ text: "Rust dependencies", link: "/rust-binaries" },
|
||||
{ text: "PE certificates", link: "/pe-certificates" },
|
||||
{
|
||||
text: "JavaScript and Electron",
|
||||
link: "/javascript-artifact-reconstruction",
|
||||
|
||||
+45
-171
@@ -5,8 +5,7 @@ environment: an MCP server whose tools answer from what one retained web
|
||||
network capture observed, plus the environment card the agentenv framework
|
||||
reads to discover it. By default the server derives the
|
||||
[state the capture observed](#the-state-a-capture-observed) at startup and
|
||||
answers read tools from it, and the write tools whose effect the capture
|
||||
confirmed by changing it; `--replay-only` replays the capture's records
|
||||
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
|
||||
@@ -235,12 +234,11 @@ moment of the capture recorded them. The
|
||||
[environment state](web-network-captures.md#environment-state) view extracts
|
||||
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 reads that are not entities, 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 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.
|
||||
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 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
|
||||
|
||||
@@ -272,11 +270,11 @@ document, with the same signal exit status.
|
||||
|
||||
Three routes are served:
|
||||
|
||||
| Route | Answer |
|
||||
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `POST /mcp` | The MCP server: from the state, one tool per read tool, answering from the state where it backs them, and one per write tool whose effect the capture confirmed, applying it to the state; 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: <method>` 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: <method>` 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
|
||||
@@ -310,7 +308,7 @@ connecting to `/mcp`. A replay environment's card reads:
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_users_by_id",
|
||||
"description": "Untrusted captured API catalog: … GET /api/users/{numeric}; returns a JSON object with fields \"id\", \"uid\", \"login\", \"email\"; recorded statuses 200; replays retained records [5, 6]; evidence ev_…",
|
||||
"description": "GET /api/users/{numeric}; replays retained records [5, 6]; evidence ev_…",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -331,38 +329,7 @@ connecting to `/mcp`. A replay environment's card reads:
|
||||
```
|
||||
|
||||
`capabilities.tools` lists the same names, descriptions and input schemas that
|
||||
`tools/list` advertises over MCP. After the catalog warning, each description
|
||||
names the method and path template, then what the tool returns, one level deep:
|
||||
`returns a JSON object with fields …`, `returns a JSON array of objects with
|
||||
fields …` or `returns a JSON object whose <member> lists objects with fields …`,
|
||||
where a list is an array of objects found by the
|
||||
[environment state](web-network-captures.md#environment-state)'s list rule (the
|
||||
root array, or the one array of objects an envelope carries). Field and member
|
||||
names are written as JSON strings, so a captured name cannot read as more of
|
||||
the description; a member name too long to show whole is a quoted prefix marked
|
||||
as truncated. The summary comes
|
||||
from the first cited `2xx` record that replay answers as JSON; only when none
|
||||
did, the first cited JSON record of another status gives it, labelled with that
|
||||
status (`status 401 returns …`). It describes one recorded response, not a
|
||||
contract. Field names are sampled within the same budget as other captured
|
||||
values, and the manifest's `statuses` for the tool follow; a tool with no JSON
|
||||
record states only its statuses. The description then lists the replayed
|
||||
records and the evidence ID, and the whole catalog stays within its
|
||||
serialization reservation, which the summaries and MCP annotations are charged
|
||||
to.
|
||||
|
||||
Over MCP, every tool also carries annotations: `readOnlyHint` is true for the
|
||||
safe methods (`GET`, `HEAD`, `OPTIONS` and `TRACE`) and for query `POST`s (a
|
||||
path segment such as `search` or `query`); `destructiveHint` is true for `DELETE` and left unset for other
|
||||
writes, so the MCP default (destructive) applies to them, and read-only tools
|
||||
omit it because clients ignore it there; `idempotentHint` is true for the safe
|
||||
methods, `PUT` and `DELETE`; and `openWorldHint` is false for every tool,
|
||||
because answers come from the capture rather than the captured application. The
|
||||
hints describe the captured method; replay itself changes nothing. The card
|
||||
keeps the `name`, `description` and `inputSchema` of the echo example's tool
|
||||
shape and carries no annotations.
|
||||
|
||||
Path parameters are required strings whose
|
||||
`tools/list` advertises over MCP. Path parameters are required strings whose
|
||||
description names the segment index, the templating rule and the observed
|
||||
values (sampled within the list budget above; the manifest keeps them all);
|
||||
query parameter names are optional strings; write methods accept an
|
||||
@@ -377,11 +344,8 @@ 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, and the catalog holds the read
|
||||
tools and only the write tools whose effect the capture confirmed: a read tool
|
||||
is described as answering from the state where the state backs it, and a
|
||||
served write tool by the effect it applies to the state and the body members it
|
||||
accepts.
|
||||
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
|
||||
|
||||
@@ -404,8 +368,7 @@ 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, when `data/get` would write it past the 32 MiB
|
||||
`data/add` intake limit, and when its catalog under the served name
|
||||
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`
|
||||
@@ -422,53 +385,24 @@ member reports which applied:
|
||||
|
||||
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 answers the tool's latest recorded 400, 404 or 410 response with `state.view` `missing`, or is a tool error when none was recorded; 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 call with no query argument to a `membership: "all"` view answers from a checked line of the collection its path arguments name that only pages its list, with `replay.exact_match` false. 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. |
|
||||
| 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`, `unrecorded-request` or `page-exceeded` (a call naming a page of a whole list that the collection has outgrown). A
|
||||
"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. Query `POST`s
|
||||
stay served and answer from their recordings with the reason `query-body`.
|
||||
|
||||
`POST`, `PUT`, `PATCH` and `DELETE` tools are served only when the state's
|
||||
[write plan](web-network-captures.md#environment-state) for them is served:
|
||||
the capture's later reads confirmed the effect the plan models. Other write
|
||||
tools are not served, so no call claims a change it does not make; each one's
|
||||
plan states why in its `reason`. A served write changes the live state, which
|
||||
every MCP session and `data/get` then see:
|
||||
|
||||
| Effect | Call |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `create` | Adds an entity to the collection the path names, built from the observed entity with each ruled field taken from the call or generated, at the plan's `insert` position. A plan tied to its exemplar's collection writes only there. |
|
||||
| `update` | Sets the sent members on the entity the path names, constants for members accepted only with their recorded value, and the clock, increment and slug changes the plan models. |
|
||||
| `delete` | Removes the entity the path names. |
|
||||
| `document` | Merges the body into the plan's document object, or replaces it for `PUT`. |
|
||||
|
||||
Its answer has the status and content type of the plan's recorded response,
|
||||
with the written entity or its identity rendered in, no body, or the recorded
|
||||
body. A call whose body is not an object, sends a member the capture did not
|
||||
show stored, sends a fixed member with another value or a member of another
|
||||
JSON type, or lacks a required member is a tool error naming the accepted
|
||||
members; so is a rendered answer missing a field the recorded response
|
||||
carried, and an update or delete of an entity the state does not hold answers
|
||||
the recorded 400, 404 or 410 of the tool or of an item view of the type, if
|
||||
any. A refused call leaves the state unchanged. Values a write generates are
|
||||
deterministic: the clock starts at `write_clock` and advances one second per
|
||||
served write, sequences take one past both the largest held value and the
|
||||
largest they generated, and UUIDs and tokens derive from the state Evidence
|
||||
id, tool, field and write count, skipping held values. `data/reset` and
|
||||
`data/add` restart the clock, counter and sequences, so the same calls after a
|
||||
reset give the same state. Answers carry `state: {"source": "state", "view":
|
||||
"create" | "update" | "delete", …}` or `"document-write"`.
|
||||
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:
|
||||
|
||||
@@ -508,6 +442,12 @@ 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
|
||||
@@ -591,17 +531,14 @@ The card declares the operations and what `data/add` accepts:
|
||||
}
|
||||
```
|
||||
|
||||
The state starts as a projection of recorded reads and changes only through
|
||||
served write tools and the data plane. 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, and a
|
||||
served write changes only the one entity or document it writes: side effects
|
||||
on counters, relations and copies of entities in other collections, reordering
|
||||
after an update and referential rules on delete are not modelled. An exported
|
||||
bundle carries no state file: its server derives the state at startup, as the
|
||||
export does to predict what it serves.
|
||||
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
|
||||
|
||||
@@ -669,8 +606,8 @@ 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 number
|
||||
the bundle serves (from the state, the manifest's read tools and the write
|
||||
tools whose effect the capture confirmed; every manifest tool in replay), calls one tool with no arguments and checks the call did not
|
||||
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
|
||||
@@ -688,76 +625,13 @@ The README written into the bundle records the capture artifact's SHA-256, 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 the write tools
|
||||
whose effect the capture's later reads confirmed, replay when the state is
|
||||
unavailable, and `--replay-only` for pure replay; it
|
||||
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.
|
||||
|
||||
## Measure fidelity against the application
|
||||
|
||||
An environment agrees with its own capture by construction. What matters to an agent is how it answers the calls the
|
||||
capture did not make, what it shows after a write, and how it fails. `rea environment-compare` measures that against the
|
||||
application itself:
|
||||
|
||||
```sh
|
||||
rea environment-compare manifest.json --evidence capture.json \
|
||||
--probe probe-crud.json --probe probe-reads.json --json
|
||||
```
|
||||
|
||||
Each `--probe` is capture Evidence (`inspect-web-network-capture` of a HAR, or `capture-browser-scenario`) of one call
|
||||
sequence made against the live application, seeded as it was for the capture and reset before the sequence. The command
|
||||
makes no network call: whoever records the probes drives the application with its own credentials. For each probe it
|
||||
resets the environment's state, takes the requests in the order they were made (record order when any lacks a start time), matches each to the manifest tool of the
|
||||
same method whose path template fits with the most literal segments (a format-rule parameter must fit its format once
|
||||
percent-decoded),
|
||||
answers it as the served environment would, and compares that answer with the application's.
|
||||
|
||||
A re-seeded application holds the same entities under other ids, so the comparison maps them. Objects in the two answers
|
||||
to one call pair by a natural key, a string field unique on each side that both share, such as a name or a slug; their
|
||||
identifier fields (`id`, `uuid`, `guid`, and names ending in `_id`, `Id`, `ID`, `_uuid` or `Uuid`), and those of two single
|
||||
answers such as a create's, map the live value to the environment's. Ids are learned only from an answer the environment
|
||||
gave that call itself, from its state or a recording of that very call, request line and body, never from another call's
|
||||
recording replayed in its place, and the answer's own `id`, `uuid` or `guid` also maps within the collection the call names, since
|
||||
two collections can reuse a live id. A live id in a later request's path, query or body is translated before the
|
||||
environment answers, a path parameter by the collection it names, except a query value that pages a list, such as `limit`
|
||||
or `page`. Each call is checked against the served tool's input schema first, as the MCP server checks it. After mapping, two clock values compare by format only (ISO-8601 strings, and epoch numbers under
|
||||
time-named fields), and two different values each side generated on its own compare equal: both UUIDs, or one length of
|
||||
eight or more with the same character classes and a digit. The report counts each kind of normalised value.
|
||||
|
||||
A step agrees when the statuses and the normalised bodies are equal. The report gives agreement overall, per probe and
|
||||
per tool, `agreement_ignoring_order` for steps that differ only in item order, and each step's differences, at most 20,
|
||||
with a JSON pointer and both values cut short:
|
||||
|
||||
| Category | Meaning |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `status` | The statuses differ |
|
||||
| `missing-field` | The application's object has a member the environment's lacks |
|
||||
| `extra-field` | The environment's object has a member the application's lacks |
|
||||
| `value` | Values differ, or one is an object or array and the other is not |
|
||||
| `length` | Arrays differ in length, or not every item pairs |
|
||||
| `order` | Paired items come in a different order |
|
||||
| `clock-format` | Both are clocks, in different formats |
|
||||
| `refused` | The environment refused the call |
|
||||
| `not-served` | The request's tool is one the environment does not offer, such as a withheld write |
|
||||
| `no-tool` | No manifest tool matches the request |
|
||||
| `unmapped-id` | The application answered with success and the environment did not, for a path id the comparison never mapped |
|
||||
|
||||
What a probe did not observe is not counted as the application's behaviour. A record without a usable request line,
|
||||
such as a redacted URL or a transaction the capture cannot attribute, is not replayed, nor is a request to another
|
||||
origin than the application's (`other-origin`: the probe's origin with the most JSON-like records, as the manifest
|
||||
chooses its own), and a request with no recorded
|
||||
answer is made, since it may have changed the application, but not compared: each probe lists them under `unobserved` with the reason, and the summary counts them. A live
|
||||
body the probe did not record compares by status only and is counted as `unobserved_bodies`, unless the answer never
|
||||
carries one (204, 205, 304, `HEAD`). A browser probe captured without `capture.network.response_body` is refused, as
|
||||
is one that makes POST, PUT or PATCH calls without `capture.network.request_body`.
|
||||
|
||||
Agreement covers only the calls the probes make, after the mapping the answers allow. Values compared by form, clocks
|
||||
and generated values, can still differ in content, and an equal answer after a write shows the state matched for that
|
||||
read, not that the write had no other effect.
|
||||
|
||||
## Compatibility verification
|
||||
|
||||
`npm run verify:agentenv:environment` is the hosted `real-agentenv` lane
|
||||
|
||||
+40
-19
@@ -385,7 +385,8 @@ section explicitly truncated.
|
||||
Disallowed-origin entries are filtered before retention.
|
||||
|
||||
Selected script-source collection shares a 32 MiB aggregate UTF-8 retention
|
||||
budget and a 20-second deadline across the capture. An oversized source is
|
||||
budget and the `command_timeout_ms` deadline (default 20000 ms) across the
|
||||
capture. The byte budget has no runtime override. An oversized source is
|
||||
omitted while later smaller sources may still fit. When the deadline expires,
|
||||
remaining source reads are skipped. Script metadata, per-item omission reasons,
|
||||
and `script_sources` completeness remain available. A single CDP response can
|
||||
@@ -393,19 +394,25 @@ still be materialized up to the separate 64 MiB transport limit before source
|
||||
admission; the retention budget does not bound that transient allocation.
|
||||
|
||||
Bundle parsing and semantic inspection run sequentially in an owned child,
|
||||
without executing captured code. `max_heap_mb` defaults to 1024 MiB V8
|
||||
old-space; `analysis_timeout_ms` defaults to 30000 per script, including startup
|
||||
and transfer. A failed or timed-out script becomes an explicit unknown while
|
||||
completed findings and later scripts remain available. `total_analysis_timeout_ms`
|
||||
defaults to 300000 across script analysis; expiry returns a typed timeout with
|
||||
partial Evidence and identifies unstarted scripts. These controls are also
|
||||
available as CLI flags with hyphenated names. Capture and optional source-map
|
||||
fetching have separate budgets.
|
||||
without executing captured code. These are separate phases. `max_heap_mb`
|
||||
defaults to 1024 MiB V8 old-space (range 128..16384). `analysis_timeout_ms`
|
||||
defaults to 30000 per script, including startup and transfer.
|
||||
`total_analysis_timeout_ms` defaults to 300000 across script analysis. A
|
||||
per-script deadline above the effective total is rejected. A failed or
|
||||
timed-out script becomes an explicit unknown while completed findings and later
|
||||
scripts remain available. Total-deadline expiry returns a typed timeout with
|
||||
partial Evidence and identifies unstarted scripts. `source_map_fetch_timeout_ms`
|
||||
defaults to 30000 and is an item limitation, not that analysis timeout. These
|
||||
controls are also available as CLI flags with hyphenated names. Worker protocol
|
||||
and result byte budgets have no runtime override.
|
||||
|
||||
One web-bundle capture/analysis is admitted per REA process at a time. Completed
|
||||
script results share an 8 MiB serialized transfer budget, with host heap
|
||||
reservation checked before decoding and validating another result.
|
||||
Exhausted result capacity stops further script launches and returns a typed
|
||||
When the host reservation is what reaches zero, the failure names that heap
|
||||
headroom and the remaining fixed budget. When the 8 MiB allowance itself is
|
||||
exhausted, the failure names that fixed budget and says it has no runtime
|
||||
override. Either stop ends further script launches and returns a typed
|
||||
resource failure with completed facts and explicit unstarted-script unknowns.
|
||||
Worker control frames are limited to 64 KiB. Cleanup must confirm child termination
|
||||
before releasing its private files; failed cleanup retains its exact owner and
|
||||
@@ -415,8 +422,10 @@ facts, including cancellation during final cleanup.
|
||||
The worker heap setting is not a process-family RSS limit. Node enforces V8
|
||||
old-space flags; Bun ignores them. Windows Bun instead uses an owned process
|
||||
commit limit with a 512 MiB runtime reserve; other Bun hosts have no enforced
|
||||
memory limit. These bounds do not establish an aggregate host-memory limit
|
||||
across other REA operations or external processes.
|
||||
memory limit. An allocation failure before that Windows limit is reported as
|
||||
reached still names the configured limit and says it was not reached. These
|
||||
bounds do not establish an aggregate host-memory limit across other REA
|
||||
operations or external processes.
|
||||
|
||||
Resource-tree inventories distinguish a valid empty `resources` array from a
|
||||
missing or malformed collection. Invalid resource entries are omitted with
|
||||
@@ -430,12 +439,24 @@ an invalid supplied size remains unknown with incomplete resource coverage while
|
||||
the resource's other facts are retained. Zero and nonnegative fractional sizes
|
||||
are preserved.
|
||||
|
||||
CDP discovery has a 10-second deadline. Each WebSocket handshake and command
|
||||
response has its own 20-second deadline, separate from the caller-selected
|
||||
observation window. A stalled handshake or command reports `provider_timeout`;
|
||||
caller cancellation still reports cancellation. WebSocket messages remain subject
|
||||
to the [64 MiB limit](#request-boundary). REA does not impose a message-count or
|
||||
pending-command quota. On cancellation, it closes only its own WebSocket,
|
||||
CDP discovery has a 10-second response deadline. HTTP discovery documents
|
||||
use an 8 MiB budget. Browser-socket discovery (`Browser.getVersion`,
|
||||
`Target.getTargets`) uses the 64 MiB protocol message budget. Neither budget
|
||||
nor the discovery deadline has a runtime override. `list_browser_targets` accepts no
|
||||
`command_timeout_ms`: HTTP discovery and browser-socket discovery
|
||||
(`Browser.getVersion`, `Target.getTargets`) stay on that fixed deadline, and
|
||||
the socket closes without a later command. Other CDP tools use
|
||||
`command_timeout_ms` (default 20000) for each WebSocket handshake and command
|
||||
response, separate from the caller-selected observation window, discovery, and
|
||||
the 5000 ms cleanup grace. A browser-level socket's handshake and the following
|
||||
`Target.attachToTarget` each receive that full deadline; they do not share one
|
||||
budget. A stalled handshake or command reports
|
||||
`provider_timeout` and names `command_timeout_ms` only when that operation
|
||||
accepts it; caller cancellation still reports cancellation. WebSocket messages
|
||||
remain subject to the [64 MiB limit](#request-boundary), which has no runtime
|
||||
override. REA does
|
||||
not impose a message-count or pending-command quota. On cancellation, it closes
|
||||
only its own WebSocket,
|
||||
waiting up to one second before terminating it. Malformed unsolicited events
|
||||
and malformed replies to pending commands poison the connection so later
|
||||
commands fail closed. A correlated reply's session ID must match the command's
|
||||
@@ -444,7 +465,7 @@ rejects it as unknown; other command rejections retain their reported failure.
|
||||
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Scripts are different: scripts the page parsed before REA attached are still reported under `scripts`. A page that loaded before attach can therefore list its scripts while `network.requests` lacks the requests that fetched them. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
|
||||
|
||||
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation has a 30-second deadline and a 64 MiB aggregate streamed-response budget across redirects. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding also bounds raw resolved identities to 64 MiB and aggregate inline evidence to 32 MiB. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
|
||||
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation uses `source_map_fetch_timeout_ms` (default 30000) and a 64 MiB aggregate streamed-response budget across redirects. The byte budget has no runtime override. A fetch overrun is an item limitation, not a thrown analysis timeout. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding also bounds raw resolved identities to 64 MiB and aggregate inline evidence to 32 MiB. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
|
||||
|
||||
source-map items retain the script's `declared_url` separately from the observed
|
||||
`effective_url`. successful maps use the latter as their source-resolution base.
|
||||
|
||||
@@ -23,9 +23,21 @@ that rejection does not roll back browser effects. REA removes its owned
|
||||
future-document scripts, restores live frame methods, then disconnects.
|
||||
Add secret declarations, storage seeds, and additional artifact or event
|
||||
capture only when needed. Action, secret, and storage counts are not capped.
|
||||
Duration, action, and navigation timeouts remain fixed provider-owned liveness
|
||||
limits. `capture.limits` controls retained event and network-content budgets;
|
||||
it does not change those liveness deadlines.
|
||||
Duration, action, and navigation timeouts remain separate from CDP command
|
||||
deadlines. `command_timeout_ms` (default 20000; omit it to keep that default
|
||||
and leave it out of Evidence) bounds launch startup, the connect-over-CDP
|
||||
handshake, target lookup, environment commands, and storage CDP commands,
|
||||
including replies from attached child targets. Set it in the scenario JSON.
|
||||
`capture-browser-scenario` has no `--command-timeout-ms` option.
|
||||
It does not change action `timeout_ms`, `wait_for_timeout.duration_ms`, or
|
||||
navigation. Service-worker restoration keeps a fixed 5000 ms grace and does
|
||||
not inherit a raised `command_timeout_ms`. CDP discovery stays on its own
|
||||
fixed deadline. When service workers are blocked, the registration check
|
||||
before discovery has its own fixed 10000 ms deadline; `command_timeout_ms`
|
||||
does not apply because discovery has not started. The discovery deadline
|
||||
covers endpoint discovery only. The following WebSocket handshake and
|
||||
`Target.attachToTarget` each use `command_timeout_ms`. `capture.limits` controls retained event and network-content
|
||||
budgets; it does not change those liveness deadlines.
|
||||
|
||||
## Minimal launch example
|
||||
|
||||
|
||||
+13
-18
@@ -145,6 +145,18 @@ dependency edges, and the section's file range. A missing `.dep-v0` section is
|
||||
null metadata, not proof that the binary has no dependencies. See
|
||||
[Rust binaries](rust-binaries.md).
|
||||
|
||||
Inspect a PE Authenticode certificate table without executing the image or
|
||||
verifying signatures:
|
||||
|
||||
```bash
|
||||
rea inspect-pe-certificates /absolute/path/to/application.exe --json
|
||||
```
|
||||
|
||||
The workflow reports `WIN_CERTIFICATE` entries and, for PKCS#7 signedData,
|
||||
embedded X.509 serial, issuer, subject, and validity fields. A missing security
|
||||
directory is observed absence of that table. See
|
||||
[PE Authenticode certificate tables](pe-certificates.md).
|
||||
|
||||
Configure [Hopper or Ghidra](installation.md#hopper), the
|
||||
[IDA adapter](ida-provider.md), or [Kuna](kuna-provider.md) before analyzing a
|
||||
native target. Substitute
|
||||
@@ -399,8 +411,7 @@ 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 served only where
|
||||
the capture's later reads confirmed their effect, and
|
||||
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
|
||||
@@ -456,22 +467,6 @@ 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.
|
||||
|
||||
## Compare an environment with its application
|
||||
|
||||
`rea environment-compare <manifest.json> --evidence <capture.json> --probe <probe.json> [--probe <probe.json> ...]`
|
||||
compares the environment the pair serves with probe captures of the application it came from. Each probe is capture
|
||||
Evidence of one call sequence recorded against the application after a reset; the environment's state is reset before
|
||||
each probe, every request to the application's origin is answered as the served environment answers it, and the
|
||||
answer is compared with the application's after ids are mapped and clocks and generated values are normalised. The command reads files only and
|
||||
makes no network call. It prints the report and exits `0`, or prints a structured `invalid_request` error and exits `1`
|
||||
for the same pair refusals as `environment-serve`, a probe that is not capture Evidence, or a browser probe captured
|
||||
without response bodies, or without the request bodies its writes need. See
|
||||
[agent environments](agent-environments.md#measure-fidelity-against-the-application) for the report.
|
||||
|
||||
```sh
|
||||
rea environment-compare manifest.json --evidence capture.json --probe probe.json --json
|
||||
```
|
||||
|
||||
## Output and exit status
|
||||
|
||||
`rea mcp doctor --json` checks production MCP startup and reports child logs in
|
||||
|
||||
@@ -245,4 +245,10 @@ application path and capture outcome to the static graph, while frames, scripts,
|
||||
workers, and execution claims remain unavailable until a separate passive runtime
|
||||
capture provides them.
|
||||
|
||||
ASAR observation reads the header through one open file handle, validates its declared lengths against the archive size, and limits encoded JSON headers to 16 MiB before parsing. The header cache also retains at most 16 MiB of encoded headers across eight archives. Oversized headers produce a scope refusal.
|
||||
ASAR observation reads the header through one open file handle and validates its
|
||||
declared lengths against the archive size. Header/index admission is the same
|
||||
fixed 16 MiB / 16777216 byte budget used by static ASAR reads. This release has
|
||||
no runtime override. An oversized header is not parsed; selecting a member path
|
||||
does not bypass admission, and the page stays unauthorized. The header cache
|
||||
separately retains at most 16 MiB of already-admitted encoded headers across
|
||||
eight archives. Evicting that cache does not raise admission.
|
||||
+1
-1
@@ -50,7 +50,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md).
|
||||
| Task | Guides |
|
||||
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) |
|
||||
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
|
||||
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [PE certificates](pe-certificates.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
|
||||
| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) |
|
||||
| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) |
|
||||
| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) |
|
||||
|
||||
@@ -106,6 +106,15 @@ only reduces delivery; source selection reduces text acquisition and analysis.
|
||||
Explicit native provider or snapshot routing rejects `--source-path` rather
|
||||
than ignoring it.
|
||||
|
||||
ASAR header/index admission is a fixed 16 MiB / 16777216 bytes for this
|
||||
release. There is no runtime override. The archive index is read before any
|
||||
member can be selected, so `source_paths`, `--source-path`, `detail:
|
||||
"summary"`, `max_heap_mb`, and available host memory do not bypass or raise
|
||||
it. When the header exceeds that allowance, the failure names this budget.
|
||||
Analyze an extracted application directory, which does not admit an ASAR
|
||||
header, or supply an archive whose header fits. Repeating the unchanged
|
||||
request fails the same way.
|
||||
|
||||
## Read the analysis
|
||||
|
||||
`analyze_javascript_application` returns the complete analysis Evidence by
|
||||
@@ -172,7 +181,8 @@ for the tool-result contract.
|
||||
|
||||
Use MCP `detail: "summary"` for a large application, then read selected views of
|
||||
the retained analysis. Summary changes delivery only. To reduce source parsing,
|
||||
select exact [source paths](#select-sources-before-analysis).
|
||||
select exact [source paths](#select-sources-before-analysis). Source selection
|
||||
does not bypass ASAR header/index admission.
|
||||
|
||||
If a complete response exceeds the transport budget, use its retained Evidence
|
||||
ID for a summary, module page, or item, or export the bundle. See
|
||||
|
||||
@@ -622,6 +622,9 @@ which managed methods executed or how the CLR resolved them.
|
||||
|
||||
Managed member comparisons report a `metadata` dimension when an observed
|
||||
MethodDef flags or implementation flags value differs, including accessibility
|
||||
or synchronization changes. This is separate from the CIL/signature matching
|
||||
tiers: identical instructions do not establish unchanged method metadata. The
|
||||
reported difference is static metadata evidence, not proof of runtime behavior.
|
||||
or synchronization changes. Observed method-body `init_locals` and `max_stack`
|
||||
differences also contribute to `metadata`, even when CIL decoding is incomplete.
|
||||
An unavailable header value keeps body coverage unknown rather than establishing
|
||||
equality or a difference. These comparisons are separate from the CIL/signature
|
||||
matching tiers: identical instructions do not establish unchanged method metadata.
|
||||
The reported difference is static metadata evidence, not proof of runtime behavior.
|
||||
@@ -234,11 +234,14 @@ The destination changes only when a complete export is atomically published.
|
||||
|
||||
Clients that explicitly configure a larger receive buffer can set the REA
|
||||
server's `REA_MCP_MAX_RESPONSE_BYTES` environment variable to the same byte
|
||||
count. This setting must be a safe decimal integer at least 10485760; REA
|
||||
reserves 1024 bytes for the envelope. Raising it restores complete inline
|
||||
delivery for responses that fit that buffer and Node's single-string limit.
|
||||
It does not change the client's buffer, analysis coverage, or retained content.
|
||||
Ordinary responses keep their existing complete result contract.
|
||||
count. This setting must be a safe decimal integer at least 10485760 (10 MiB);
|
||||
REA reserves 1024 bytes for the envelope. The value is read when the REA
|
||||
process starts, so a change requires a restart and a matching client receive
|
||||
buffer. Raising it restores complete inline delivery for responses that fit
|
||||
that buffer. It does not raise REA's reserved single-string limit, which is
|
||||
1024 UTF-16 code units below Node's single-string ceiling, and it does not
|
||||
raise that Node ceiling. It does not change analysis coverage or retained
|
||||
content. Ordinary responses keep their existing complete result contract.
|
||||
|
||||
### Error recovery and deadline ownership
|
||||
|
||||
@@ -329,7 +332,7 @@ See [environment manifests](web-network-captures.md#environment-manifest).
|
||||
|
||||
`inspect_analysis_view` also accepts `environment-state` for the same parents,
|
||||
extracting the initial state the recorded reads observed: entity types with an
|
||||
identity field, entities, documents for non-entity reads, and a render
|
||||
identity field, entities, documents for written non-entity paths, and a render
|
||||
plan per read tool that reproduces its recordings from the state or names why it
|
||||
cannot. Types and identity are inferences, so the Evidence is always inferred.
|
||||
See [environment state](web-network-captures.md#environment-state).
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# PE Authenticode certificate tables
|
||||
|
||||
`inspect_pe_certificates` / `inspect-pe-certificates` answers whether one
|
||||
selected local PE32/PE32+ image declares an `IMAGE_DIRECTORY_ENTRY_SECURITY`
|
||||
table, which `WIN_CERTIFICATE` entries that table stores, and which PKCS#7
|
||||
signer and X.509 fields those entries carry. It reads the file without
|
||||
executing it and does not require a Windows SDK, catalog file, or disassembler.
|
||||
|
||||
```bash
|
||||
rea inspect-pe-certificates /absolute/path/to/application.exe --json
|
||||
```
|
||||
|
||||
The CLI resolves relative paths against the caller's working directory. MCP
|
||||
requires an absolute filesystem path and preserves that exact selection:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "inspect_pe_certificates",
|
||||
"arguments": { "path": "/artifacts/application.exe" }
|
||||
}
|
||||
```
|
||||
|
||||
## Observations
|
||||
|
||||
The inline Evidence result contains the artifact path, SHA-256, and byte length.
|
||||
`table` is the file-offset security directory, or `null` when the directory is
|
||||
absent or zero. Each `entries[]` item reports `WIN_CERTIFICATE` length,
|
||||
revision, type, and the payload SHA-256.
|
||||
|
||||
For `WIN_CERTIFICATE` type 2 (PKCS#7 signedData) `pkcs7` contains digest
|
||||
algorithm OIDs, embedded X.509 serial/issuer/subject/validity fields, and
|
||||
signer identifier facts. Other certificate types keep `pkcs7` null.
|
||||
|
||||
A missing table is observed absence of that directory. It is not proof the
|
||||
image is unsigned in every catalog or overlay sense.
|
||||
|
||||
## Limits and failures
|
||||
|
||||
The selected file is limited to 64 MiB by default (512 MiB maximum). At most
|
||||
eight `WIN_CERTIFICATE` entries are examined by default. Malformed PE headers,
|
||||
truncated certificate entries, and PKCS#7 that is not structurally signedData
|
||||
fail without partial success. A security directory that ends in fewer than
|
||||
eight header bytes, including zeros, is a truncated `WIN_CERTIFICATE` and
|
||||
fails the same way. SignedData must contain at least one `SignerInfo`, and
|
||||
each `SignerInfo` must carry a non-empty signature value. Decoded certificate
|
||||
name text shares a 64 KiB budget per PKCS#7 payload.
|
||||
|
||||
This workflow does not verify signatures, certificate chains, timestamps, or
|
||||
Authenticode digest coverage of image bytes.
|
||||
@@ -143,6 +143,7 @@ npx rea-agents@latest setup
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| الملفات الثنائية للشيفرة الأصلية | شيفرة شبه برمجية، وتعليمات تجميع، وسلاسل نصية، ورموز، واستدعاءات، ومراجع | Hopper أو Ghidra أو IDA؛ [تحليل الشيفرة الأصلية](../native-investigation.md) |
|
||||
| بنية ELF دون تشغيل | الأقسام والمقاطع والرموز ومعلومات إعادة التموضع الأصلية، وآليات الحماية المحتملة المستدلّ عليها بالفحص الثابت | pwntools يوفّره المستدعي على Linux x64؛ [تشخيص الملفات الثنائية](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| شيفرة EVM البايتية | محددات التوجيه، وإزاحات البايتات، والمعاملات المستنتجة، وقابلية تغيير الحالة | مدخل محلي يحمل بايتات خامًا أو تمثيلًا سداسيًا عشريًا على Linux x64؛ [دليل EVM دون تشغيل](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| أعطال Linux المسجّلة | سجلات note الخام، ومسجّلات المعالج/الإشارات لكل خيط مسجّل، وترشيحات اختيارية لخرائط الذاكرة | pwntools يوفّره المستدعي على Linux x64؛ GDB/pwndbg اختياريان؛ [الأعطال المسجّلة](../recorded-crashes.md) |
|
||||
|
||||
@@ -143,6 +143,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz
|
||||
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native Binärdateien | Pseudocode, Assemblercode, Zeichenketten, Symbole, Aufrufe und Verweise | Hopper, Ghidra oder IDA; [native Analyse](../native-investigation.md) |
|
||||
| Offline-ELF-Struktur | Sektionen, Segmente, ursprüngliche Symbole/Relokationen und mögliche Schutzmaßnahmen aus statischer Analyse | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; [Binärdiagnostik](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| EVM-Bytecode | Dispatch-Selektoren, Byte-Offsets, abgeleitete Argumente und Veränderbarkeit des Zustands | Lokale Eingabe mit Rohbytes oder Hexdarstellung unter Linux x64; [Offline-EVM-Leitfaden](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Aufgezeichnete Linux-Abstürze | Unverarbeitete Note-Einträge, Register/Signale aller aufgezeichneten Threads und optionale Zuordnungskandidaten | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; optional GDB/pwndbg; [aufgezeichnete Abstürze](../recorded-crashes.md) |
|
||||
|
||||
@@ -149,6 +149,7 @@ npx rea-agents@latest setup
|
||||
| Δομή ELF χωρίς εκτέλεση | Ενότητες, τμήματα, αρχικά σύμβολα/εγγραφές μετεγκατάστασης και ενδείξεις πιθανών μέτρων προστασίας από στατική ανάλυση | pwntools που παρέχει ο χρήστης σε Linux x64· [διαγνωστικά δυαδικών αρχείων](../binary-diagnostics.md) |
|
||||
| Δυαδικά αρχεία Go | Ενσωματωμένη έκδοση μεταγλωττιστή, modules, αντικαταστάσεις και ρυθμίσεις μεταγλώττισης | Ενσωματωμένη στατική εξέταση ELF/PE/thin Mach-O· δεν απαιτείται εγκατάσταση του Go |
|
||||
| Δυαδικά αρχεία Rust | Ενσωματωμένα ονόματα, εκδόσεις, πηγές και σχέσεις εξάρτησης των crates από το cargo-auditable | Ενσωματωμένη στατική εξέταση ELF/PE/thin Mach-O· [δυαδικά αρχεία Rust](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Αρχείο WASM | Έλεγχο εγκυρότητας, ενότητες, ακριβείς μορφές εισαγωγών/εξαγωγών σε WAT και αποκωδικοποιημένο WAT | WABT 1.0.42 που παρέχει ο χρήστης· `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Selectors επιλογής συνάρτησης, μετατοπίσεις σε bytes, εκτιμώμενα ορίσματα και δυνατότητα μεταβολής της κατάστασης | Τοπική είσοδος σε ακατέργαστα bytes ή δεκαεξαδική μορφή σε Linux x64· [οδηγός EVM χωρίς εκτέλεση](../evm-bytecode.md) |
|
||||
| Βάσεις δεδομένων SQLite | Πίνακες, στήλες, ευρετήρια, όψεις, triggers και επιλεγμένες γραμμές κανονικών πινάκων | Τοπικό στιγμιότυπο βάσης δεδομένων/WAL· Node.js 24.x >=24.15 ή 26+ με εγγενή όρια SQLite |
|
||||
|
||||
@@ -143,6 +143,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Binarios nativos | Pseudocódigo, ensamblador, cadenas, símbolos, llamadas y referencias | Hopper, Ghidra o IDA; [análisis nativo](../native-investigation.md) |
|
||||
| Estructura ELF sin ejecutar | Secciones, segmentos, símbolos/reubicaciones originales y posibles medidas de mitigación detectadas mediante análisis estático | pwntools proporcionado por quien llama, en Linux x64; [diagnóstico de binarios](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Bytecode EVM | Selectores de despacho, desplazamientos en bytes, argumentos inferidos y mutabilidad | Entrada local con bytes sin procesar o representación hexadecimal, en Linux x64; [guía de EVM sin ejecutar](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Fallos de Linux registrados | Registros note sin procesar, registros/señales de cada hilo registrado y posibles correspondencias de memoria opcionales | pwntools proporcionado por quien llama, en Linux x64; GDB/pwndbg opcionales; [fallos registrados](../recorded-crashes.md) |
|
||||
|
||||
@@ -135,6 +135,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، بههمراه np
|
||||
| ---------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| باینریهای بومی | شبهکد، اسمبلی، رشتهها، نمادها، فراخوانیها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](../native-investigation.md) |
|
||||
| ساختار ELF آفلاین | بخشها، سگمنتها، نمادها و relocationهای اصلی و گزینههای احتمالی کاهش آسیبپذیری ایستا | pwntools تأمینشده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| بایتکد EVM | selectorهای dispatch، offsetهای بایتی، آرگومانهای استنباطشده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| کرشهای ثبتشدهٔ لینوکس | یادداشتهای خام، رجیسترها/سیگنالهای تمام threadهای ثبتشده و گزینههای احتمالی نگاشت | pwntools تأمینشده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرشهای ثبتشده](../recorded-crashes.md) |
|
||||
|
||||
@@ -146,6 +146,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les
|
||||
| ---------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Binaires natifs | Pseudo-code, assembleur, chaînes, symboles, appels et références | Hopper, Ghidra ou IDA ; [analyse native](../native-investigation.md) |
|
||||
| Structure ELF hors ligne | Sections, segments, symboles et relocations d'origine, et protections statiques candidates | pwntools fourni par l'utilisateur sous Linux x64 ; [diagnostics binaires](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Artefact WASM | Validation, sections, formes WAT exactes d'import/export et WAT décodé | WABT 1.0.42 fourni par l'utilisateur ; `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Sélecteurs de dispatch, offsets en octets, arguments inférés et mutabilité | Fichier local brut ou hexadécimal sous Linux x64 ; [guide EVM hors ligne](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
|
||||
@@ -149,6 +149,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis:
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Biner native | Pseudocode, assembly, string, simbol, pemanggilan, dan referensi | Hopper, Ghidra, atau IDA; [analisis native](../native-investigation.md) |
|
||||
| Tata letak ELF offline | Bagian, segmen, simbol/relokasi asli, dan kandidat mitigasi statis | pwntools yang disediakan pemanggil pada Linux x64; [diagnostik biner](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Artefak WASM | Validasi, bagian, bentuk WAT import/ekspor yang tepat, dan WAT yang didekode | WABT 1.0.42 yang disediakan pemanggil; `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
|
||||
@@ -146,6 +146,7 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Binari nativi | Pseudocodice, assembly, stringhe, simboli, chiamate e riferimenti | Hopper, Ghidra o IDA; [analisi nativa](../native-investigation.md) |
|
||||
| Layout ELF offline | Sezioni, segmenti, simboli/relocazioni originali e candidati mitigazioni statiche | pwntools fornito dal chiamante su Linux x64; [diagnostica binaria](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Artefatto WASM | Convalida, sezioni, forme WAT di import/export esatte e WAT decodificato | WABT 1.0.42 fornito dal chiamante; `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Selettori di dispatch, offset dei byte, argomenti inferiti e mutabilità | Carrier raw/hex locale; [guida EVM offline](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
|
||||
@@ -143,6 +143,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | Hopper、Ghidra、または IDA;[ネイティブ分析](../native-investigation.md) |
|
||||
| オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](../recorded-crashes.md) |
|
||||
|
||||
@@ -153,6 +153,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
|
||||
| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) |
|
||||
| Go 바이너리 | 내장된 컴파일러 버전, 모듈, `replace` 지시어, 빌드 설정 | REA 내장 ELF/PE/thin Mach-O 정적 검사; Go 설치 불필요 |
|
||||
| Rust 바이너리 | cargo-auditable이 내장한 크레이트 이름, 버전, 소스, 의존성 관계 | REA 내장 ELF/PE/thin Mach-O 정적 검사; [Rust 바이너리](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| WASM 아티팩트 | 유효성 검사, 섹션, import/export의 정확한 WAT 형식, 디코딩된 WAT | 호출자 제공 WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) |
|
||||
| SQLite 데이터베이스 | 테이블, 열, 인덱스, 뷰, 트리거, 선택한 일반 테이블의 행 | 로컬 데이터베이스/WAL 스냅샷; Node.js 24.15 이상의 24.x 또는 26 이상(네이티브 SQLite 한도 적용) |
|
||||
|
||||
@@ -151,6 +151,7 @@ REA-യ്ക്ക് Node.js 22.x (>=22.19), 24.x (>=24.11), അല്ലെ
|
||||
| Offline ELF layout | Sections, segments, യഥാർത്ഥ symbols/relocations, static mitigation candidates | Linux x64-ൽ caller നൽകുന്ന pwntools; [binary diagnostics](../binary-diagnostics.md) |
|
||||
| Go binaries | ഉൾച്ചേർത്ത compiler version, modules, replacements, build settings | Built-in static ELF/PE/thin Mach-O പരിശോധന; Go installation ആവശ്യമില്ല |
|
||||
| Rust binaries | ഉൾച്ചേർത്ത cargo-auditable crate പേരുകൾ, versions, sources, dependency edges | Built-in static ELF/PE/thin Mach-O പരിശോധന; [Rust binaries](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| WASM artifact | Validation, sections, കൃത്യമായ import/export WAT രൂപങ്ങൾ, decoded WAT | Caller നൽകുന്ന WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM bytecode | Dispatch selectors, byte offsets, അനുമാനിച്ച arguments, mutability | Linux x64-ൽ local raw/hex carrier; [offline EVM guide](../evm-bytecode.md) |
|
||||
| SQLite databases | Tables, columns, indexes, views, triggers, തിരഞ്ഞെടുത്ത സാധാരണ table rows | Local database/WAL snapshot; native SQLite പരിമിതികളോടെ Node.js 24.x >=24.15 അല്ലെങ്കിൽ 26+ |
|
||||
|
||||
@@ -143,6 +143,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na
|
||||
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Natywne pliki binarne | Pseudokod, kod asemblera, ciągi znaków, symbole, wywołania i odwołania | Hopper, Ghidra lub IDA; [analiza kodu natywnego](../native-investigation.md) |
|
||||
| Układ pliku ELF w trybie offline | Sekcje, segmenty, oryginalne symbole/relokacje i potencjalne mechanizmy zabezpieczeń wskazane przez analizę statyczną | pwntools dostarczone przez użytkownika w systemie Linux x64; [diagnostyka plików binarnych](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Kod bajtowy EVM | Selektory wywołań, przesunięcia bajtowe, wywnioskowane argumenty i mutowalność | Lokalny plik z surowymi bajtami lub zapisem szesnastkowym w systemie Linux x64; [przewodnik EVM offline](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Zarejestrowane awarie systemu Linux | Surowe rekordy note, rejestry/sygnały każdego zarejestrowanego wątku i opcjonalni kandydaci na mapowania | pwntools dostarczone przez użytkownika w systemie Linux x64; opcjonalnie GDB/pwndbg; [zarejestrowane awarie](../recorded-crashes.md) |
|
||||
|
||||
@@ -143,6 +143,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer
|
||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Binários nativos | Pseudocódigo, assembly, strings, símbolos, chamadas e referências | Hopper, Ghidra ou IDA; [análise nativa](../native-investigation.md) |
|
||||
| Estrutura ELF sem execução | Seções, segmentos, símbolos/relocações originais e possíveis mecanismos de mitigação identificados por análise estática | pwntools fornecido pelo chamador no Linux x64; [diagnóstico de binários](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Bytecode EVM | Seletores de despacho, offsets de bytes, argumentos inferidos e mutabilidade | Entrada local com bytes brutos ou representação hexadecimal no Linux x64; [guia de EVM sem execução](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Falhas do Linux registradas | Registros note brutos, registradores/sinais de cada thread registrada e candidatos opcionais a mapeamentos | pwntools fornecido pelo chamador no Linux x64; GDB/pwndbg opcionais; [falhas registradas](../recorded-crashes.md) |
|
||||
|
||||
@@ -145,6 +145,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так
|
||||
| Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](../binary-diagnostics.md) |
|
||||
| Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) |
|
||||
| Установленные настольные приложения | Возможные среды выполнения, файлы с доказательствами, компоненты и дальнейшие шаги | Ограниченная проверка каталогов и заголовков;[Настольные приложения](../desktop-applications.md) |
|
||||
| JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -143,6 +143,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26
|
||||
| ---------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ไบนารีเนทีฟ | โค้ดเทียม แอสเซมบลี สตริง สัญลักษณ์ การเรียก และการอ้างอิง | Hopper, Ghidra หรือ IDA; [การวิเคราะห์เนทีฟ](../native-investigation.md) |
|
||||
| โครงสร้าง ELF แบบออฟไลน์ | เซกชัน เซกเมนต์ สัญลักษณ์/รีโลเคชันต้นฉบับ และตัวเลือกกลไกป้องกันที่พบจากการวิเคราะห์แบบสถิต | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; [การวินิจฉัยไบนารี](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| ไบต์โค้ด EVM | ตัวเลือกฟังก์ชันสำหรับการเรียก ออฟเซ็ตไบต์ อาร์กิวเมนต์และความสามารถในการเปลี่ยนสถานะที่อนุมานได้ | ไฟล์บนเครื่องที่มีไบต์ดิบหรือข้อมูลเลขฐานสิบหกบน Linux x64; [คู่มือ EVM แบบออฟไลน์](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| ข้อมูลการแครชของ Linux ที่บันทึกไว้ | ระเบียน note ดิบ รีจิสเตอร์/สัญญาณของทุกเธรดที่บันทึกไว้ และตัวเลือกการแมปเพิ่มเติม | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; เลือกใช้ GDB/pwndbg เพิ่มเติมได้; [การแครชที่บันทึกไว้](../recorded-crashes.md) |
|
||||
|
||||
@@ -147,6 +147,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç
|
||||
| Çevrimdışı ELF yapısı | Bölümler, segmentler, özgün semboller/yeniden konumlandırmalar ve statik analizden olası güvenlik önlemleri | Linux x64 üzerinde çağıranın sağladığı pwntools; [ikili dosya tanılama](../binary-diagnostics.md) |
|
||||
| Go ikili dosyaları | Gömülü compiler sürümü, modüller, replacement'lar ve build ayarları | Yerleşik statik ELF/PE/thin Mach-O incelemesi; Go kurulumu gerekmez |
|
||||
| Rust ikili dosyaları | Gömülü cargo-auditable crate adları, sürümleri, kaynakları ve bağımlılık ilişkileri | Yerleşik statik ELF/PE/thin Mach-O incelemesi; [Rust ikili dosyaları](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| WASM artefaktı | Doğrulama, bölümler, tam import/export WAT biçimleri ve decode edilmiş WAT | Çağıranın sağladığı WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM bayt kodu | Yönlendirme seçicileri, bayt ofsetleri, çıkarımla belirlenen argümanlar ve durum değiştirilebilirliği | Linux x64 üzerinde yerel ham bayt/onaltılık girdi; [çevrimdışı EVM rehberi](../evm-bytecode.md) |
|
||||
| SQLite veritabanları | Tablolar, sütunlar, index'ler, view'lar, trigger'lar ve seçilen sıradan tablo satırları | Yerel database/WAL snapshot'ı; native SQLite sınırlarıyla Node.js 24.x >=24.15 veya 26+ |
|
||||
|
||||
@@ -143,6 +143,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Нативні бінарні файли | Псевдокод, асемблерний код, рядки, символи, виклики й посилання | Hopper, Ghidra або IDA; [нативний аналіз](../native-investigation.md) |
|
||||
| Структура ELF без запуску | Секції, сегменти, початкові символи/релокації та можливі захисні механізми за даними статичного аналізу | pwntools, наданий стороною, що викликає інструмент, у Linux x64; [діагностика бінарних файлів](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Байт-код EVM | Селектори диспетчеризації, зміщення байтів, виведені припущення про аргументи та змінюваність стану | Локальні необроблені байти або шістнадцяткове представлення у Linux x64; [посібник EVM без запуску](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Записані аварійні завершення в Linux | Необроблені записи note, регістри/сигнали кожного записаного потоку й необов'язкові кандидати зіставлення пам'яті | pwntools, наданий стороною, що викликає інструмент, у Linux x64; необов'язкові GDB/pwndbg; [записані аварійні завершення](../recorded-crashes.md) |
|
||||
|
||||
@@ -145,6 +145,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np
|
||||
| Bố cục ELF ngoại tuyến | Các section, segment, ký hiệu/thông tin tái định vị gốc và các biện pháp giảm thiểu có thể có từ phân tích tĩnh | pwntools do bên gọi cung cấp trên Linux x64; [chẩn đoán tệp nhị phân](../binary-diagnostics.md) |
|
||||
| Bytecode EVM | Bộ chọn điều phối, độ lệch byte, tham số suy luận và khả năng thay đổi trạng thái | Đầu vào cục bộ chứa byte thô hoặc dạng thập lục phân trên Linux x64; [hướng dẫn EVM ngoại tuyến](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Sự cố Linux đã ghi lại | Bản ghi note thô, thanh ghi/tín hiệu của mọi luồng được ghi lại và các ứng viên ánh xạ tùy chọn | pwntools do bên gọi cung cấp trên Linux x64; GDB/pwndbg tùy chọn; [sự cố đã ghi lại](../recorded-crashes.md) |
|
||||
| Thư mục cài đặt ứng dụng máy tính | Các runtime có khả năng được dùng, tệp bằng chứng, dấu hiệu thành phần và bước tiếp theo | Kiểm tra thư mục và phần đầu tệp có giới hạn;[Ứng dụng máy tính](../desktop-applications.md) |
|
||||
| JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](../javascript-application-workflows.md) |
|
||||
|
||||
+27
-26
@@ -141,32 +141,33 @@ AI 代理通過 MCP 呼叫 REA,檢查目標並追蹤相關程式碼。REA 返
|
||||
|
||||
REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。額外工具和主機支援取決於分析目標:
|
||||
|
||||
| 目標 | REA 返回的內容 | 要求與指南 |
|
||||
| --------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| 原生二進位檔案 | 偽程式碼、組合語言、字串、符號、呼叫和引用 | Hopper、Ghidra 或 IDA;[原生分析](../native-investigation.md) |
|
||||
| 離線 ELF 佈局 | 節、段、原始符號/重定位資訊及靜態防護機制候選項 | Linux x64 上由呼叫方提供的 pwntools;[二進位診斷](../binary-diagnostics.md) |
|
||||
| Go 二進位檔案 | 內嵌編譯器版本、模組、替換項和建置設定 | 內建靜態 ELF/PE/thin Mach-O 檢查;無需安裝 Go |
|
||||
| WASM 工件 | 驗證、節、精確的匯入/匯出 WAT 形式及解碼後的 WAT | 呼叫方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
|
||||
| EVM 位元組碼 | 分派選擇器、位元組偏移、推斷的參數和狀態可變性 | Linux x64 上的本地原始位元組/十六進位輸入載體;[離線 EVM 指南](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) |
|
||||
| 桌面安裝目錄 | 執行階段候選、支持檔案、元件標記與後續操作 | 有界的目錄與檔頭檢查;[桌面應用](../desktop-applications.md) |
|
||||
| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](../javascript-application-workflows.md) |
|
||||
| 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](../browser-observation.md) |
|
||||
| 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](../web-network-captures.md) |
|
||||
| 封包擷取 | 端點、DNS、TLS SNI、通訊協定分佈和 HTTP 請求 | 呼叫端提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) |
|
||||
| .NET 程式集 | 中繼資料、CIL 指令、聲明的原生依賴和建置對比 | 靜態檢查;[托管程式碼指南](../managed-code-analysis.md) |
|
||||
| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
|
||||
| Android APK | 清單聲明、類別、反編譯的方法和引用 | Linux/macOS/Windows x64 上的無界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) |
|
||||
| Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](../adb-device-analysis.md) |
|
||||
| JEB 專案 | 引擎支援的專案單元、成品摘要和型別/方法的虛擬碼 | 由呼叫端啟動、提供 MCP 的 JEB 用戶端,透過 REA_JEB_MCP_URL 選擇;[JEB 指南](../jeb-analysis.md) |
|
||||
| Android 資源 | 解碼後的清單、字串表、語系和版本資訊 | 呼叫端提供的 Apktool 啟動器;[資源指南](../apktool-resource-analysis.md) |
|
||||
| Flutter 應用 | Dart 快照雜湊、依 ABI 的程式庫雜湊及引擎建置來源 | 無(靜態 APK 解析);[Flutter 指南](../flutter-analysis.md) |
|
||||
| 韌體 | 區域、提取結果和轉交原生分析的內容 | Linux 上的 Binwalk / Unblob;[韌體指南](../firmware-analysis.md) |
|
||||
| 封包記錄 | 指定欄位、重複值、篩選條件與解碼規則 | Linux: TShark 4.x + prlimit; [封包分析指南](../packet-captures.md) |
|
||||
| 錄製的邏輯訊號 | 解碼註解、樣本範圍與診斷資訊 | Linux: sigrok-cli 0.7.2 + prlimit; [訊號記錄指南](../signal-recordings.md) |
|
||||
| 軟體包與資源 | 檔案清單、摘要、plist、Apple bundle 結構和提取的資源 | [製品與 JavaScript 指南](../javascript-artifact-reconstruction.md)、[Apple 應用](../apple-application-analysis.md) |
|
||||
| 處理程序行為 | 終端輸出、互動、退出和檔案系統觀察結果,以及執行對比 | 支援原生 PTY 的 Linux/macOS;[處理程序擷取](../process-capture.md) |
|
||||
| 目標 | REA 返回的內容 | 要求與指南 |
|
||||
| --------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| 原生二進位檔案 | 偽程式碼、組合語言、字串、符號、呼叫和引用 | Hopper、Ghidra 或 IDA;[原生分析](../native-investigation.md) |
|
||||
| 離線 ELF 佈局 | 節、段、原始符號/重定位資訊及靜態防護機制候選項 | Linux x64 上由呼叫方提供的 pwntools;[二進位診斷](../binary-diagnostics.md) |
|
||||
| Go 二進位檔案 | 內嵌編譯器版本、模組、替換項和建置設定 | 內建靜態 ELF/PE/thin Mach-O 檢查;無需安裝 Go |
|
||||
| WASM 工件 | 驗證、節、精確的匯入/匯出 WAT 形式及解碼後的 WAT | 呼叫方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
|
||||
| EVM 位元組碼 | 分派選擇器、位元組偏移、推斷的參數和狀態可變性 | Linux x64 上的本地原始位元組/十六進位輸入載體;[離線 EVM 指南](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) |
|
||||
| 桌面安裝目錄 | 執行階段候選、支持檔案、元件標記與後續操作 | 有界的目錄與檔頭檢查;[桌面應用](../desktop-applications.md) |
|
||||
| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](../javascript-application-workflows.md) |
|
||||
| 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](../browser-observation.md) |
|
||||
| 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](../web-network-captures.md) |
|
||||
| 封包擷取 | 端點、DNS、TLS SNI、通訊協定分佈和 HTTP 請求 | 呼叫端提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) |
|
||||
| .NET 程式集 | 中繼資料、CIL 指令、聲明的原生依賴和建置對比 | 靜態檢查;[托管程式碼指南](../managed-code-analysis.md) |
|
||||
| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
|
||||
| Android APK | 清單聲明、類別、反編譯的方法和引用 | Linux/macOS/Windows x64 上的無界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) |
|
||||
| Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](../adb-device-analysis.md) |
|
||||
| JEB 專案 | 引擎支援的專案單元、成品摘要和型別/方法的虛擬碼 | 由呼叫端啟動、提供 MCP 的 JEB 用戶端,透過 REA_JEB_MCP_URL 選擇;[JEB 指南](../jeb-analysis.md) |
|
||||
| Android 資源 | 解碼後的清單、字串表、語系和版本資訊 | 呼叫端提供的 Apktool 啟動器;[資源指南](../apktool-resource-analysis.md) |
|
||||
| Flutter 應用 | Dart 快照雜湊、依 ABI 的程式庫雜湊及引擎建置來源 | 無(靜態 APK 解析);[Flutter 指南](../flutter-analysis.md) |
|
||||
| 韌體 | 區域、提取結果和轉交原生分析的內容 | Linux 上的 Binwalk / Unblob;[韌體指南](../firmware-analysis.md) |
|
||||
| 封包記錄 | 指定欄位、重複值、篩選條件與解碼規則 | Linux: TShark 4.x + prlimit; [封包分析指南](../packet-captures.md) |
|
||||
| 錄製的邏輯訊號 | 解碼註解、樣本範圍與診斷資訊 | Linux: sigrok-cli 0.7.2 + prlimit; [訊號記錄指南](../signal-recordings.md) |
|
||||
| 軟體包與資源 | 檔案清單、摘要、plist、Apple bundle 結構和提取的資源 | [製品與 JavaScript 指南](../javascript-artifact-reconstruction.md)、[Apple 應用](../apple-application-analysis.md) |
|
||||
| 處理程序行為 | 終端輸出、互動、退出和檔案系統觀察結果,以及執行對比 | 支援原生 PTY 的 Linux/macOS;[處理程序擷取](../process-capture.md) |
|
||||
|
||||
靜態 JavaScript 和 .NET 檢查讀取提供的檔案,不執行應用。執行階段擷取會以你的使用者權限執行目標或與之互動;各執行階段指南說明瞭具體影響。
|
||||
|
||||
|
||||
+27
-26
@@ -141,32 +141,33 @@ npx rea-agents@latest setup
|
||||
|
||||
REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。额外工具和主机支持取决于分析目标:
|
||||
|
||||
| 目标 | REA 返回的内容 | 要求与指南 |
|
||||
| --------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| 原生二进制文件 | 伪代码、汇编、字符串、符号、调用和引用 | Hopper、Ghidra 或 IDA;[原生分析](../native-investigation.md) |
|
||||
| 离线 ELF 布局 | 节、段、原始符号/重定位信息及静态防护机制候选项 | Linux x64 上由调用方提供的 pwntools;[二进制诊断](../binary-diagnostics.md) |
|
||||
| Go 二进制文件 | 内嵌编译器版本、模块、替换项和构建设置 | 内置静态 ELF/PE/thin Mach-O 检查;无需安装 Go |
|
||||
| WASM 工件 | 验证、节、精确的导入/导出 WAT 形式及解码后的 WAT | 调用方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
|
||||
| EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | Linux x64 上的本地原始字节/十六进制输入载体;[离线 EVM 指南](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) |
|
||||
| 桌面安装目录 | 运行时候选、支持文件、组件标记和后续操作 | 有界的目录与文件头检查;[桌面应用](../desktop-applications.md) |
|
||||
| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](../javascript-application-workflows.md) |
|
||||
| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](../browser-observation.md) |
|
||||
| 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](../web-network-captures.md) |
|
||||
| 数据包捕获 | 端点、DNS、TLS SNI、协议分布和 HTTP 请求 | 调用方提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) |
|
||||
| .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](../managed-code-analysis.md) |
|
||||
| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
|
||||
| Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS/Windows x64 上的无界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) |
|
||||
| Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](../adb-device-analysis.md) |
|
||||
| JEB 项目 | 引擎支持的项目单元、工件摘要和类型/方法的伪代码 | 由调用方启动、提供 MCP 的 JEB 客户端,通过 REA_JEB_MCP_URL 选择;[JEB 指南](../jeb-analysis.md) |
|
||||
| Android 资源 | 解码后的清单、字符串表、语言区域和版本信息 | 调用方提供的 Apktool 启动器;[资源指南](../apktool-resource-analysis.md) |
|
||||
| Flutter 应用 | Dart 快照哈希、按 ABI 的库摘要及引擎构建溯源 | 无(静态 APK 解析);[Flutter 指南](../flutter-analysis.md) |
|
||||
| 固件 | 区域、提取结果和转交原生分析的内容 | Linux 上的 Binwalk / Unblob;[固件指南](../firmware-analysis.md) |
|
||||
| 抓包文件 | 指定字段、重复值、过滤条件与解码规则 | Linux: TShark 4.x + prlimit; [抓包分析指南](../packet-captures.md) |
|
||||
| 录制的逻辑信号 | 解码注释、样本范围和诊断信息 | Linux: sigrok-cli 0.7.2 + prlimit; [信号记录指南](../signal-recordings.md) |
|
||||
| 软件包与资源 | 文件清单、摘要、plist、Apple bundle 结构和提取的资源 | [制品与 JavaScript 指南](../javascript-artifact-reconstruction.md)、[Apple 应用](../apple-application-analysis.md) |
|
||||
| 进程行为 | 终端输出、交互、退出和文件系统观察结果,以及运行对比 | 支持原生 PTY 的 Linux/macOS;[进程捕获](../process-capture.md) |
|
||||
| 目标 | REA 返回的内容 | 要求与指南 |
|
||||
| --------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| 原生二进制文件 | 伪代码、汇编、字符串、符号、调用和引用 | Hopper、Ghidra 或 IDA;[原生分析](../native-investigation.md) |
|
||||
| 离线 ELF 布局 | 节、段、原始符号/重定位信息及静态防护机制候选项 | Linux x64 上由调用方提供的 pwntools;[二进制诊断](../binary-diagnostics.md) |
|
||||
| Go 二进制文件 | 内嵌编译器版本、模块、替换项和构建设置 | 内置静态 ELF/PE/thin Mach-O 检查;无需安装 Go |
|
||||
| WASM 工件 | 验证、节、精确的导入/导出 WAT 形式及解码后的 WAT | 调用方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
|
||||
| EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | Linux x64 上的本地原始字节/十六进制输入载体;[离线 EVM 指南](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) |
|
||||
| 桌面安装目录 | 运行时候选、支持文件、组件标记和后续操作 | 有界的目录与文件头检查;[桌面应用](../desktop-applications.md) |
|
||||
| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](../javascript-application-workflows.md) |
|
||||
| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](../browser-observation.md) |
|
||||
| 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](../web-network-captures.md) |
|
||||
| 数据包捕获 | 端点、DNS、TLS SNI、协议分布和 HTTP 请求 | 调用方提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) |
|
||||
| .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](../managed-code-analysis.md) |
|
||||
| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
|
||||
| Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS/Windows x64 上的无界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) |
|
||||
| Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](../adb-device-analysis.md) |
|
||||
| JEB 项目 | 引擎支持的项目单元、工件摘要和类型/方法的伪代码 | 由调用方启动、提供 MCP 的 JEB 客户端,通过 REA_JEB_MCP_URL 选择;[JEB 指南](../jeb-analysis.md) |
|
||||
| Android 资源 | 解码后的清单、字符串表、语言区域和版本信息 | 调用方提供的 Apktool 启动器;[资源指南](../apktool-resource-analysis.md) |
|
||||
| Flutter 应用 | Dart 快照哈希、按 ABI 的库摘要及引擎构建溯源 | 无(静态 APK 解析);[Flutter 指南](../flutter-analysis.md) |
|
||||
| 固件 | 区域、提取结果和转交原生分析的内容 | Linux 上的 Binwalk / Unblob;[固件指南](../firmware-analysis.md) |
|
||||
| 抓包文件 | 指定字段、重复值、过滤条件与解码规则 | Linux: TShark 4.x + prlimit; [抓包分析指南](../packet-captures.md) |
|
||||
| 录制的逻辑信号 | 解码注释、样本范围和诊断信息 | Linux: sigrok-cli 0.7.2 + prlimit; [信号记录指南](../signal-recordings.md) |
|
||||
| 软件包与资源 | 文件清单、摘要、plist、Apple bundle 结构和提取的资源 | [制品与 JavaScript 指南](../javascript-artifact-reconstruction.md)、[Apple 应用](../apple-application-analysis.md) |
|
||||
| 进程行为 | 终端输出、交互、退出和文件系统观察结果,以及运行对比 | 支持原生 PTY 的 Linux/macOS;[进程捕获](../process-capture.md) |
|
||||
|
||||
静态 JavaScript 和 .NET 检查读取提供的文件,不运行应用。运行时捕获会以你的用户权限运行目标或与之交互;各运行时指南说明了具体影响。
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# Plan
|
||||
|
||||
1. Export the capture parser from `environmentSource.ts` so a probe loads like a capture.
|
||||
2. `environmentCompareMatch.ts`: a probe request to a tool and its arguments.
|
||||
3. `environmentCompareIds.ts`: the id map, natural-key pairing, learning from two answers, translating arguments.
|
||||
4. `environmentCompareDiff.ts`: normalisation and categorised differences.
|
||||
5. `environmentCompare.ts`: per probe, reset the state, answer each request, compare and learn; the report.
|
||||
6. `rea environment-compare` in `src/cli/environmentCommands.ts`, documented in `docs/agent-environments.md` and
|
||||
`docs/cli.md`.
|
||||
7. Tests: matching, pairing, normalisation and categories as unit tests; the command over a fixture capture and probes.
|
||||
8. Verification on VMs: six applications brought up and seeded as for their capture, reset from a snapshot per sequence,
|
||||
driven by a script that records each sequence as a HAR; baseline reports per environment.
|
||||
@@ -1,75 +0,0 @@
|
||||
# Compare an environment with the application it came from
|
||||
|
||||
Issue: https://github.com/morluto/rea/issues/4933
|
||||
|
||||
## Outcome
|
||||
|
||||
An environment is only as useful as it is faithful: an agent trained or graded on it should meet the behaviour of the
|
||||
application it was captured from. REA checks an environment only against its own capture, so nothing says how far it
|
||||
drifts from the application on calls the capture did not make, after writes, or on errors.
|
||||
|
||||
`rea environment-compare` measures that. It takes an environment (a manifest and its capture Evidence) and one or more
|
||||
**probe captures**, each a recording of one call sequence made against the live application, and reports how many of the
|
||||
probe's calls the environment answers as the application did, which tools disagree and how.
|
||||
|
||||
The command only reads Evidence: it makes no network call. Whoever records the probes drives the application with its own
|
||||
credentials and resets it between sequences.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Inputs: the manifest Evidence, its capture Evidence (`--evidence`), and one or more probe capture Evidence files
|
||||
(`--probe`, repeatable), each `inspect-web-network-capture` or `capture-browser-scenario` Evidence of one sequence.
|
||||
- Each probe is one sequence run against a freshly reset application. Before each probe the environment's state is reset,
|
||||
so sequences are independent on both sides.
|
||||
- Requests are taken in the probe's order (start time, then ordinal; ordinal alone when any record lacks a start
|
||||
time, as state extraction orders them). A request is matched to the manifest tool of the
|
||||
same method whose path template it fits with the most literal segments; format-rule parameters must fit their format
|
||||
once percent-decoded, as replay reads them.
|
||||
Path values, query values the tool takes and a JSON body become the tool's arguments.
|
||||
- A record without a usable request line (a redacted URL, a transaction the capture cannot attribute, an unparseable
|
||||
URL or no method) is not replayed, nor is a request to another origin than the probe's application (`other-origin`),
|
||||
which is the origin with the most JSON-like records, as the manifest chooses its own. A request with no recorded
|
||||
answer is made, since it may have changed the application, but not compared. Each is listed per probe with its
|
||||
reason and left out of agreement.
|
||||
- A request no tool matches is reported `no-tool`; a matched tool the environment does not offer, such as a write it
|
||||
withholds, is reported `not-served`. Neither is answered.
|
||||
- Ids differ between a re-seeded application and the capture. The comparison maps them:
|
||||
- seeded entities pair across the two answers by a natural key: a field present on every element of both arrays whose
|
||||
string values are unique on each side and shared between them;
|
||||
- identifier fields (`id`, `uuid`, `guid`, and names ending in `_id`, `Id`, `ID`, `_uuid` or `Uuid`) of paired objects,
|
||||
and of the two answers to the same call, map the live value to the environment's value; a create's two answers map
|
||||
the new ids. The `id`, `uuid` or `guid` of the answer's own entities (at the read view's entity or list location, a
|
||||
write's response location, or else the root) also maps within the collection the call names,
|
||||
since two collections can reuse a live id for different entities. Outside identifier fields, a string maps when the
|
||||
environment's state holds it as an id, and the keys of two objects keyed by ids map when their values pair (keys
|
||||
that partly overlap only when they are digits, UUIDs or their entries' identities, and every value pairs);
|
||||
- ids are learned only from an answer the environment gave this call, from its state or from a recording of exactly
|
||||
this call, its whole request line and its request body: a recording of another call that replay answers with
|
||||
instead holds other entities;
|
||||
- path, query and body values equal to a mapped live id are translated before the environment answers, a path
|
||||
parameter by the collection it names, except query values that page a list (`limit`, `offset`, `page` and the
|
||||
like), which count items rather than name one.
|
||||
A path value the map does not know, and the environment's state does not hold, is reported `unmapped-id`.
|
||||
- A call is checked against the served tool's input schema first, as the MCP server checks it; a call it refuses is
|
||||
reported `refused`. A browser probe keeps its request bodies; one captured without response bodies is refused, since
|
||||
only statuses would compare, and so is one that makes POST, PUT or PATCH calls without recorded request bodies, since
|
||||
those calls would be made empty.
|
||||
- Answers compare after normalisation: empty response text equals no body (a JSON empty string stays one); mapped ids
|
||||
compare equal, in scalar sets as well, an entity's own identity within the call's collection and any other id by the
|
||||
probe's mapping; two clock values (ISO-8601 strings, or epoch numbers under
|
||||
time-named fields) compare by format only; two different generated values (both UUIDs, or both of one length and the
|
||||
same character classes with a digit) that neither side maps compare equal and are counted. A body over the replay
|
||||
limit, text or binary, compares by status only, as does a live body the probe did not record, unless the answer has
|
||||
none by definition (204, 205, 304, or an answer to `HEAD`). Both are counted.
|
||||
- A step agrees when the statuses are equal and the normalised bodies are. Differences are categorised: `status`,
|
||||
`missing-field` (the application had it), `extra-field`, `value`, `length`, `order`, `clock-format`, `refused` (the
|
||||
environment refused the call), `not-served`, `no-tool` and `unmapped-id`, each with a JSON pointer and short values.
|
||||
- The report gives agreement overall, per probe and per tool, agreement ignoring item order beside it, the counts per
|
||||
category, the ids mapped and the values normalised, the records left uncompared, and each step's differences (at most
|
||||
20 per step).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No network access, no credentials, no reset of the application: probes are recorded outside REA.
|
||||
- No generation of call sequences; a later change generates them where the environment disagrees.
|
||||
- No change to how environments serve.
|
||||
@@ -1,12 +0,0 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Record the outcome, requirements and non-goals.
|
||||
- [x] Load probes as captures.
|
||||
- [x] Match probe requests to tools.
|
||||
- [x] Map ids and translate arguments.
|
||||
- [x] Normalise and categorise differences.
|
||||
- [x] Compare per probe and report.
|
||||
- [x] Add the command and its documentation.
|
||||
- [x] Add unit and command tests.
|
||||
- [x] Run the gates.
|
||||
- [x] Measure six environments against their live applications and record the results.
|
||||
@@ -1,146 +0,0 @@
|
||||
# Verification
|
||||
|
||||
Verified commit: 235b22f2 on the write-plan branch at 7056f5ff. Executed on macOS with Node v22.23.1 (the repository
|
||||
pins 24.18.0 in `.nvmrc`, which was not installed); the live applications ran on six disposable Linux VMs.
|
||||
|
||||
## Gates
|
||||
|
||||
- `npm run build:cached`, `npm run check:fast` (typecheck and lint), `npm run format:check`, `npx knip`,
|
||||
`npm run scan:todos`, `npm run docs:check`, `npm run verify:test-discovery` and `npm run mcp-catalog:check`: passed.
|
||||
- `npm run jscpd`: 3.46% duplicated lines over 3609 files, under the 3.5% gate.
|
||||
- `npx vitest run --project domain --project services`: 7487 passed, 5 pre-existing skips.
|
||||
- `npm run test:prepare`, then `npm run test:focused --` the environment-compare, export and serve CLI boundaries, the
|
||||
serve MCP boundary and the tool schema validity boundary: 5 files, 30 passed.
|
||||
|
||||
New tests: matching, id mapping and translation, pairing guards, normalisation and every category as unit tests
|
||||
(`environmentCompareIds`, `environmentCompareDiff`, `environmentCompare`, 19 tests), and the command through the built
|
||||
CLI (`environmentCompareCli`, 2 tests): a probe of the books application re-seeded under other ids agrees on every step
|
||||
with 2 ids mapped, and a probe that is not capture Evidence is refused with its index.
|
||||
|
||||
## Six environments against their live applications
|
||||
|
||||
Six published environments were compared with the application each was captured from: NetBox 4.7.3, Komga 1.29.1,
|
||||
Mealie 3.28.0, n8n 1.123.85, Shlink 5.1.7 and self-hosted Sentry 26.9.0, the same images, brought up and seeded by the
|
||||
same scripts as for their captures, on loopback. Each was snapshotted after seeding and restored from that snapshot
|
||||
before every sequence (7 to 80 s), so every sequence met the same application.
|
||||
|
||||
A driver outside REA ran three kinds of sequence per application, each starting with the capture's read-only prefix:
|
||||
the capture re-run in order; per served write type, a list, create, read, update, read, delete, read and list, then a
|
||||
second create with changed free text; and a read of every seeded entity through its item tool. It translated the
|
||||
environment's ids to the application's as it learned them, signed in with the application's own credentials, and wrote
|
||||
each sequence as a HAR without headers or cookies, which `inspect-web-network-capture` turned into probe Evidence. The
|
||||
environments' manifests use `prefix-variance`, so the comparison ran on a local build of this branch with that rule's
|
||||
branch merged.
|
||||
|
||||
| Environment | Steps | Agreement | Ignoring order | Against its own capture |
|
||||
| ----------- | ----: | --------: | -------------: | ----------------------: |
|
||||
| NetBox | 370 | 99.2% | 99.2% | 100% |
|
||||
| Komga | 356 | 93.8% | 93.8% | 93.1% |
|
||||
| Mealie | 301 | 96.0% | 97.0% | 97.1% |
|
||||
| Shlink | 133 | 89.5% | 92.5% | 92.4% |
|
||||
| n8n | 202 | 74.8% | 87.6% | 87.5% |
|
||||
| Sentry | 230 | 23.9% | 46.5% | 84.7% |
|
||||
|
||||
The last column compares each environment with its own capture as the probe: what it cannot reproduce even of the
|
||||
session it was built from. The disagreements fall into four groups.
|
||||
|
||||
- Environment gaps, the ones to fix:
|
||||
- write tools the environment withholds, and the reads after them: readlists and collections (Komga), recipes (Mealie), short URLs
|
||||
(Shlink), workflow and tag writes (n8n);
|
||||
- preconditions: n8n refuses to delete a workflow that is not archived and the environment deletes it;
|
||||
- lists: a create does not appear in a list that keeps its recorded members (Komga), a filtered list is answered
|
||||
without the filter (Shlink), a replayed search ignores its body (Komga);
|
||||
- a domain-scoped short URL answered without its `domain` parameter, where Shlink answers 404;
|
||||
- state rendering: a project field two recorded views disagree on is rendered with one value, and an organisation
|
||||
read whose shape depends on a query parameter is answered the same either way (Sentry);
|
||||
- a tag read refused for want of the fields its item view always carried (Mealie);
|
||||
- time: per-hour event counts move with the current time, while the environment's clock is frozen at the capture
|
||||
(Sentry);
|
||||
- a field the capture's scrubbing replaced, answered as `"<scrubbed>"` (Sentry).
|
||||
- The application differing from itself between seedings: Sentry returns permission lists in no fixed order and
|
||||
reseeding changed release first-event times, feature lists and profile flags; n8n's executions and NetBox's running
|
||||
worker count differ from the captured instance.
|
||||
- The probe observing itself: Komga and Sentry report the driver's own sign-in user agent.
|
||||
- Driver gaps: ids the driver never mapped (Mealie units, Sentry events by hash) were sent as the environment's ids.
|
||||
|
||||
## What the runs fixed in the comparison
|
||||
|
||||
Each live run found a way the comparison itself was wrong, fixed with a test before the next run: requests are matched
|
||||
to tools in the environment's terms, so a tool whose path holds a seeded id literally matches; ids are learned outside
|
||||
identifier fields only from strings, after NetBox's worker count of 1 mapped a live 0 to entity 1; items paired only by
|
||||
position teach ids only when no key is shared and the lengths agree, after a filtered Shlink list mapped one short URL to
|
||||
another; objects keyed by ids pair their entries (n8n data tables); reordered scalar sets differ in order only and epoch
|
||||
numbers in arrays are clocks (Sentry); and bodies over the replay limit compare by status.
|
||||
|
||||
## After the review round
|
||||
|
||||
The branch merged the write-plan branch at b6c3b168 (four serving and gate fixes its review found), then changed the
|
||||
comparison in nine places: ids are learned only from answers to the call itself and also per collection, paging values
|
||||
are never translated, calls go through the served tools' input schemas, browser probes keep their request bodies, empty
|
||||
bodies and large binary bodies compare as the server answers them, scalar sets compare after mapping, and id-keyed
|
||||
objects pair when some keys overlap. Each new test fails on the previous head. The gates above passed again (domain and
|
||||
services 7504 passed with 5 pre-existing skips; the focused boundaries 80; conformance; `jscpd` 3.46%). Re-run on the
|
||||
same probes with this branch and the template rule's merged, the six environments compare exactly as in the table above,
|
||||
step for step, with the same ids mapped and the same values normalised.
|
||||
|
||||
A second round, on Greptile's review of those changes, learned ids from a replay only when the whole recorded line and
|
||||
request body match the call, compared an entity's own identity within the call's collection and other ids by the probe's
|
||||
mapping, read overlapping keys as ids only when they evidently are ids, and kept a JSON empty string apart from empty
|
||||
text. Its four tests fail on the previous head; the six environments again compare exactly as in the table.
|
||||
|
||||
A third round located the answer's own entities where the served environment does, at the read view's entity or list
|
||||
pointer or the write's response pointer, so an entity inside an envelope keeps its collection's mapping. Its test fails
|
||||
on the previous head; the gates passed again (domain and services 7509, boundaries 80, conformance, `jscpd` 3.46%) and
|
||||
the six environments again compare exactly as in the table.
|
||||
|
||||
A fourth round, on Greptile's next review, kept what a probe did not observe out of the agreement figures. Records
|
||||
without a usable request line, and requests without a recorded answer, are listed per probe with the reason and left
|
||||
uncompared. A live body the probe did not record compares by status only and is counted. A browser probe captured
|
||||
without response bodies is refused. A format-rule path parameter fits by its decoded value, as replay reads it. The
|
||||
branch also merged the write-plan branch at 1d1c4787, whose gate no longer confirms a write by a read of a state that a
|
||||
later write replaced. Each new test fails on the previous head. The gates passed again (domain and services
|
||||
7647, 5 pre-existing skips; the compare, export, serve and analysis-view boundaries 69; conformance 279; `jscpd` 3.44%).
|
||||
|
||||
On the same probes, the six environments have no unobserved records or bodies, and five compare exactly as before.
|
||||
Komga now withholds its collection create and patch. The only read the gate could check them by is the 404 after the
|
||||
collection is deleted, because the read after the patch is the patch's own observation. Its agreement falls from
|
||||
96.9% to 93.8%: the three creates and two patches the probes make are not served, and the six reads and deletes of
|
||||
those collections that follow fail. Against its own capture it falls from 96.6% to 93.1%. The table shows the new
|
||||
figures.
|
||||
|
||||
A fifth round merged the write-plan branch at dc5e2aad, which weighs an update's derived changes when a later write
|
||||
replaces it and resets collection spellings on reset or load. It also makes a request whose answer the probe lost,
|
||||
still uncompared, so that a later read sees its effect: a lost delete followed by a 404 now agrees. Its test fails on
|
||||
the previous head. The six environments compare as in the table.
|
||||
|
||||
A sixth round merged the write-plan branch at fe8ab584, whose gate erases the fields later writes and recorded
|
||||
outcomes overwrite from earlier write effects. The six environments again compare exactly as in the table.
|
||||
|
||||
A seventh round merged the write-plan branch at 3580d85c and main d8a9673f. That brought the gate's fix for overwritten
|
||||
document writes, which Greptile raised here against the write-plan code, among others. It also fixed two findings of
|
||||
its own:
|
||||
|
||||
- a probe with any undated record keeps its recorded order, as state extraction does;
|
||||
- a browser probe that makes POST, PUT or PATCH calls without recorded request bodies is refused.
|
||||
|
||||
Both tests fail on the previous head. The six environments compare exactly as in the table, on the probes and against
|
||||
their own captures.
|
||||
|
||||
An eighth round merged the write-plan branch at 43d98ea9 and 264ce410. They bring the gate's fixes for three findings
|
||||
Greptile raised here against the write-plan code: a passing read that shows nothing of a write leaves it pending, an
|
||||
update or delete finds its entity as an item read does, and a built document plan writes later calls at its own
|
||||
object. It also fixed one finding of its own: a request to another origin than the probe's application is neither
|
||||
made nor compared, so another server's `DELETE /api/books/2` no longer deletes the environment's book. Its test fails
|
||||
on the previous head. The six environments compare exactly as in the table, on the probes and against their own
|
||||
captures, with no unobserved records.
|
||||
|
||||
It then merged the write-plan branch at 0ae64400 and 6257de93: an update's identity member never takes a rule, a page
|
||||
size of zero bounds nothing, and the paging rules, with this branch's `isPagingParameter`, move to
|
||||
`environmentStatePaging.ts`. The six environments again compare exactly as in the table.
|
||||
|
||||
A ninth round merged the write-plan branch at 466ed8b4, which fixes two findings Greptile raised here against its
|
||||
code (entity fields that look like pointers match by name; a zero page size bounds nothing only where a recording
|
||||
listed items under it) among others. The six environments compare exactly as in the table, on the probes and against
|
||||
their own captures. It then merged 7cef2fb2, whose fixes settle three more findings Greptile raised here against that branch's code:
|
||||
zero page sizes erasing another bound, partial write responses taken for removals, and interference timed by a tool's
|
||||
call elsewhere. The six environments again compare exactly as in the table.
|
||||
@@ -1,32 +0,0 @@
|
||||
# Implementation plan
|
||||
|
||||
1. In `environmentStateRules.ts`, parse pointers into tokens and let `at` and
|
||||
`replaceAt` read and replace element 0 of a one-element array at the root
|
||||
or in one member; extend `locateItem` with those forms after the object
|
||||
forms, requiring an identity name there; add `identityCandidates`, the
|
||||
identity names then suffix-named fields, and let `locateList` prefer arrays
|
||||
identified by name, then arrays of two or more items identified by suffix.
|
||||
2. In `EnvironmentStateExtraction.ts`, plan lists with the shared candidates,
|
||||
treat an empty recording as unconstraining, accept a suffix-named field only
|
||||
once a recording shows it distinct across two or more items, leave reads
|
||||
without a list or identity to documents, drop the written-path requirement
|
||||
and the `not-entity` fallback, record `path-varies` apart from
|
||||
`query-varies`, and keep each entity's earliest sighting;
|
||||
`environmentStateMerge.ts` cites records in timeline order. Remove
|
||||
`not-entity` from the reason type and result schema, add `path-varies`, and
|
||||
describe the new pointer forms and `field-name` basis.
|
||||
3. Move the classification of retained bodies (none, base64, cut, JSON, text)
|
||||
into `replayedBody` in `environmentSource.ts`, which owns recorded
|
||||
exchanges; replay renders from it and tool descriptions read its JSON case.
|
||||
4. Make `environmentToolDefinitions` take the `EnvironmentSource`; describe the
|
||||
first `2xx` JSON record, else the first JSON record labelled with its status,
|
||||
one level deep with the state's list rule and the existing sample budget;
|
||||
list the manifest tool's statuses; and charge the whole description and the
|
||||
tool's MCP annotations to the catalog reservation. Update the serve and
|
||||
bundle callers.
|
||||
5. Let the tool definitions own the MCP annotations derived from the method and
|
||||
the query-`POST` rule; `serveEnvironment.ts` registers them and the card
|
||||
leaves them out.
|
||||
6. Cover domain, services, adapters and the compiled MCP boundary; document the
|
||||
rules in the capture guide, descriptions and hints in the agent environments
|
||||
guide, and the contract text.
|
||||
@@ -1,94 +0,0 @@
|
||||
# Wider environment state coverage and tool descriptions
|
||||
|
||||
Issue: https://github.com/morluto/rea/issues/4933
|
||||
|
||||
## Outcome
|
||||
|
||||
The `environment-state` view backs more read tools with state, and a served
|
||||
environment tells agents more about each tool. Read-only paths whose responses
|
||||
are not entities become documents, listed items may be identified by an
|
||||
`…Id` or `…_id` field, an item wrapped in a one-element array is located
|
||||
inside it, and reads that vary by path get their own recorded reason. Each tool description says what the tool returns and which statuses
|
||||
were recorded, and every MCP tool carries method hints. This increment adds no
|
||||
tool, view kind, provider, network activity or write effect.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A `GET` list tool (its template does not end in a parameter) that yields no
|
||||
item or list plan holds its latest initial value as a `document` when its
|
||||
initial reads have one request line, whether or not the capture writes its
|
||||
path, unless its value changed between two of those reads across any
|
||||
recorded write (even under another root), which leaves it `fields-differ`. More request lines give `path-varies` when they differ by path (a
|
||||
template with a parameter before its end) and `query-varies` when they share
|
||||
one path. With every such read now a document, `path-varies`, `query-varies`
|
||||
or `body-unusable`, the unreachable `not-entity` reason leaves the result
|
||||
schema, and `no-identity` names item tools only. A document holds the value
|
||||
of its one `path`.
|
||||
- When no path parameter proves an identity and no identity name identifies
|
||||
the listed items, the `field-name` identity is the first key of the first
|
||||
listed item, in its order, whose name ends in `Id` or `_id`, that every item
|
||||
carries as a distinct string or number, and that at least one initial
|
||||
recording shows distinct across two or more items. The last condition goes
|
||||
beyond "every item carries it distinctly": one item satisfies that
|
||||
vacuously, and a lone item's suffix-named fields are often foreign keys (a
|
||||
reaction keyed by its user, a comment by its post), so a list that only ever
|
||||
held one item stays a document. Empty recordings constrain no field.
|
||||
Identity names keep precedence, also when choosing among an envelope's
|
||||
arrays, where a suffix-named field identifies only an array of two or more
|
||||
items.
|
||||
- An item response that is a root array of exactly one object, or an object
|
||||
with one member holding such an array, locates its entity at `/0` or
|
||||
`/<member>/0` when one of the identity names in that object repeats the path
|
||||
value. Objects at the root or in one member keep precedence. `at` and
|
||||
`replaceAt` read and replace exactly these forms besides the root and one
|
||||
member, so the gate and renderers run unchanged.
|
||||
- An entity's state order is its earliest sighting on the timeline and it cites
|
||||
its records in timeline order, whichever plan observed it first.
|
||||
- Each tool description reads `METHOD template; <returns>; recorded statuses
|
||||
…; replays retained records […]; evidence ev_…` after the untrusted-catalog
|
||||
warning. `<returns>` describes one level deep the first cited `2xx` record
|
||||
replay answers as JSON: an object's fields, an array of objects' fields, or
|
||||
an envelope whose member lists objects, where a list is an array of objects
|
||||
found by the state's list rule; an empty field set reads `with no fields`.
|
||||
Only when no `2xx` record replays as JSON does the first cited JSON record of
|
||||
another status give it, labelled `status <code> returns …`; with no JSON
|
||||
record it is omitted. The statuses are the manifest tool's `statuses`, the
|
||||
one owner of that set (`no recorded status` when it is empty). Field names
|
||||
and statuses are sampled within the description sample budget and charged
|
||||
with the whole description to the catalog reservation before it is retained.
|
||||
One owner in the environment source classifies each retained body as none,
|
||||
base64, cut, JSON or text; replay answers from that classification and
|
||||
descriptions read only its JSON case.
|
||||
- MCP tools carry `readOnlyHint` (true for the safe methods `GET`, `HEAD`,
|
||||
`OPTIONS` and `TRACE`, and for query `POST`s),
|
||||
`destructiveHint: true` for `DELETE` (unset for other writes, so the MCP
|
||||
default applies, and for reads, where clients ignore it), `idempotentHint`
|
||||
(true for the safe methods, `PUT` and `DELETE`) and `openWorldHint: false`. The
|
||||
tool definitions own the hints and charge them to the catalog reservation;
|
||||
the environment card keeps its `name`, `description`, `inputSchema` tool
|
||||
shape.
|
||||
|
||||
## Acceptance and limits
|
||||
|
||||
Synthetic captures cover read-only documents, `path-varies` and
|
||||
`query-varies`, suffix-named identities with their key order, their
|
||||
two-item proof, empty first recordings and their precedence (including the
|
||||
choice between an envelope's arrays), a Ghost-like `{"posts":[...]}` item under
|
||||
a trailing-slash path merging with its list view, a root one-element array, a
|
||||
one-element filtered list that stays unidentified, and the pointer forms. The
|
||||
Grafana-shaped fixture covers documents for its read-only paths (its one-member
|
||||
team list among them) and object, array and enveloped-list descriptions;
|
||||
synthetic sources cover the `2xx` preference over an earlier error body, the
|
||||
first of several JSON records, the labelled fallback, the manifest's statuses,
|
||||
empty, scalar and fieldless bodies, a truncated member name, non-JSON bodies
|
||||
and the catalog budget; the in-process and compiled MCP servers advertise the
|
||||
hints. A filtered list may still answer with one element whose identity name
|
||||
repeats the requested value; list filters, pagination, lossless big integers
|
||||
and cross-endpoint entity linking remain out of scope. Documents inline whole
|
||||
recorded values and enlarge the state result; agent tokens, CPU time and peak
|
||||
memory of the state and description build were not measured.
|
||||
|
||||
## Deferred issue criteria
|
||||
|
||||
Serving from the state, the AgentEnv data plane and write effects remain later
|
||||
increments of #4933. This PR must reference rather than close the issue.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Record the coverage and description contract and its limits.
|
||||
- [x] Extend pointers, item location and identity candidates.
|
||||
- [x] Require two distinct items before a suffix-named field identifies a list.
|
||||
- [x] Hold read-only non-entity reads as documents, retire `not-entity` and
|
||||
separate `path-varies` from `query-varies`.
|
||||
- [x] Order entities by earliest sighting and cite records in timeline order.
|
||||
- [x] Classify retained bodies in one owner and describe tool responses,
|
||||
preferring `2xx` records, with the manifest's statuses.
|
||||
- [x] Advertise MCP tool annotations, charged to the catalog, without changing
|
||||
the card.
|
||||
- [x] Add domain, services, adapters and compiled-boundary coverage.
|
||||
- [x] Document the rules, descriptions and hints; update the contract text.
|
||||
- [x] Run static, formatting, duplication, documentation and test gates and the
|
||||
real-capture measurement.
|
||||
@@ -1,112 +0,0 @@
|
||||
# Verification
|
||||
|
||||
Verified base: 7a668b73f4783b42eb0e8ce1eeff079f86d53e22 (the environment-state
|
||||
view branch).
|
||||
|
||||
Executed on the implementation worktree with its existing locked installation
|
||||
under Node.js v22.23.1 (the repository pins 24.18.0, which was not available);
|
||||
every check below ran as written in this pass, after the last source change.
|
||||
|
||||
- `npm run format` then `npm run format:check`: passed.
|
||||
- `npm run check:fast`: typecheck and lint passed (3959 files, no errors). The
|
||||
touched files carry two lint warnings, both present on the base: `max-params`
|
||||
in `environmentReplay.ts` and an unused `_` in `environmentCapacity.test.ts`.
|
||||
- `npx knip`: passed (exit 0).
|
||||
- `npm run jscpd`: 3.40% duplicated lines over 3348 files, under the 3.5%
|
||||
gate. The three clones that touch changed test files pair the same blocks as
|
||||
on the base; none involves code this change adds.
|
||||
- `npm run scan:todos`: no markers.
|
||||
- `npm run docs:check`: the built documentation checks and the README
|
||||
consistency conformance test (5 tests) passed.
|
||||
- `npx vitest run --project domain --project services`: 341 files, 5859 tests
|
||||
passed, 5 pre-existing skips.
|
||||
- `npm run build:cached`: passed.
|
||||
- `npm run test:focused --` the environment serve adapters and capacity tests,
|
||||
the environment serve MCP boundary, the environment serve and export CLI
|
||||
boundaries, the environment serve shutdown process boundary, the
|
||||
analysis-view MCP and CLI boundaries, the compact input-schema contract and
|
||||
MCP boundary, the tool-schema validity boundary, the analysis-detail and
|
||||
advertised-input-example contracts and the qlog acceptance test: 14 files,
|
||||
113 tests passed, 1 pre-existing skip.
|
||||
|
||||
Real captures (outside the test suite): the built view ran through the
|
||||
application service on the same 44 recorded captures (39 HAR, 5 browser
|
||||
scenario) with all three template rules, in 7 to 50 ms each. 42 produced a
|
||||
state, and the same two browser-scenario captures are refused with
|
||||
`resource_constraint` as on the base. The base column is the brief's baseline,
|
||||
which the same script reproduced on the base build in an earlier pass; this
|
||||
pass did not rebuild the base.
|
||||
|
||||
| Measure | Base | This change |
|
||||
| ---------------------------------- | ----- | ----------- |
|
||||
| Types | 568 | 576 |
|
||||
| Entities | 2,213 | 2,246 |
|
||||
| Documents | 2 | 299 |
|
||||
| Item views | 211 | 213 |
|
||||
| List views | 497 | 504 |
|
||||
| Document views | 2 | 299 |
|
||||
| State-backed read tools (of 1,258) | 710 | 1,016 |
|
||||
| Recorded: `not-entity` | 306 | (retired) |
|
||||
| Recorded: `no-identity` | 129 | 32 |
|
||||
| Recorded: `path-varies` | (new) | 32 |
|
||||
| Recorded: `query-varies` | 5 | 69 |
|
||||
| Recorded: `body-unusable` | 32 | 32 |
|
||||
| Recorded: `list-differs` | 31 | 31 |
|
||||
| Recorded: `no-init-recording` | 26 | 26 |
|
||||
| Recorded: `query-body` | 12 | 12 |
|
||||
| Recorded: `fields-differ` | 3 | 4 |
|
||||
| Recorded: `envelope-varies` | 4 | 4 |
|
||||
|
||||
Seven list types gained a suffix-named identity (`permissionId`, `product_id`,
|
||||
`ItemId`, `user_id`, `channel_id`, `spl_id` and `eventId`). Seven more had one
|
||||
before the two-item proof and lost it, because every initial response listed a
|
||||
single item: an Airbyte organization, a Mattermost reaction and team
|
||||
membership, and a Redpanda broker and schema reference became documents; a
|
||||
NOAA station read is `query-varies` and a Redpanda consumer read
|
||||
`path-varies`. Two Ghost item tools render from `/members/0` and `/tags/0`. A
|
||||
third Ghost item tool is located at `/posts/0` but stays `fields-differ`: its
|
||||
recordings differ by an `include` query.
|
||||
|
||||
State result size, as the JSON length of each Evidence summed over the 42
|
||||
states: 2,677 KiB on the base and 3,196 KiB now (+19%), measured by the same
|
||||
script. The largest growth per capture is Metabase (133 to 236 KiB), Directus
|
||||
(21 to 93), Redpanda Console (26 to 68) and Vault (9 to 43). Documents take
|
||||
492,516 of the 3,274,243 UTF-8 bytes of the results now; the base held two.
|
||||
Agent tokens, CPU time and peak memory of the state view were not measured.
|
||||
|
||||
Tool descriptions on the 44 captures' manifests: all 44 catalogs were admitted
|
||||
within their reservation. 1,336 of 1,400 tools state what they return: 1,333
|
||||
from a `2xx` record and three labelled with another status (Miniflux and
|
||||
NocoDB 404, Shlink 501), because none of their records succeeded as JSON. The
|
||||
summaries add 261,502 bytes to a 1,185,893-byte catalog of names, descriptions
|
||||
and input schemas (median 136, 95th percentile 554, maximum 1,133 bytes per
|
||||
tool); five reached the field sample bound (one Keycloak, one Lychee and three
|
||||
Sentry tools with 46 to 105 fields). The MCP annotations add 113,182 bytes to
|
||||
`tools/list` and none to the card. Building a catalog took a median 1.6 ms and
|
||||
at most 7.6 ms (Metabase), after the source was loaded. Agent tokens, CPU time
|
||||
and peak memory of the description build were not measured.
|
||||
|
||||
After merging the environment state view's review fixes (its branch at
|
||||
6ad63bcc, which also brings main's evidence-file sources), every gate ran
|
||||
again on the merged tree: `npm run check:fast`, `npm run format:check`,
|
||||
`npx knip`, `npm run scan:todos` and `npm run docs:check` passed; `npm run
|
||||
jscpd`: 3.39% duplicated lines over 3362 files; `npx vitest run --project
|
||||
domain --project services`: 344 files, 5914 tests passed, 5 pre-existing
|
||||
skips; `npm run build:cached`, then `npm run test:focused --` the serve and
|
||||
tool-definition adapters, the serve MCP and CLI boundaries, the export CLI
|
||||
boundary, the analysis-view MCP and CLI boundaries, the compact input-schema
|
||||
budget and the analysis-detail and advertised-input-example contracts: 10
|
||||
files, 98 tests passed.
|
||||
|
||||
The real-capture comparison was repeated on the merged code, with the base
|
||||
measured on the merged state view without this change: all 44 captures now
|
||||
produce a state, and 1,011 of 1,277 read tools are state-backed against 625
|
||||
on the base (216 item, 416 list and 379 document views against 214, 409 and
|
||||
2). Recorded reasons: `no-identity` 216 to 32 and `not-entity` 310 to none;
|
||||
`query-varies` 5 to 76 and `path-varies` 36 are document reads under more
|
||||
than one query or path; `list-differs` 35, `body-unusable` 33,
|
||||
`no-init-recording` 28, `query-body` 12, `envelope-varies` 4 are unchanged,
|
||||
and `fields-differ` is 9 to 10.
|
||||
|
||||
Not covered here: serving from the state, the AgentEnv data plane and write
|
||||
effects, which are later increments.
|
||||
@@ -90,6 +90,10 @@ this increment: write tools are not served from the state.
|
||||
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
|
||||
@@ -172,7 +176,8 @@ 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, the
|
||||
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
|
||||
|
||||
@@ -44,6 +44,13 @@ The review fixes are covered by these tests, all passing in the runs above:
|
||||
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.
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Implementation plan
|
||||
|
||||
The branch builds on the state-serving and coverage branches (merged into it).
|
||||
|
||||
1. **Write plan types and request bodies.** Add the plan, rule and reason types
|
||||
beside the state rules, and have the state accumulator lend extraction
|
||||
parsed request bodies under the same budget, admitted like response bodies.
|
||||
2. **Field rules.** Add a module that explains an observed entity against the
|
||||
call that wrote it (echo, copy, path, slug, clock, sequence, unique,
|
||||
template and constant) and that recognises and formats clock values,
|
||||
UUIDs and tokens.
|
||||
3. **Effects.** Add one apply module that both the gate and the server use. It
|
||||
applies a create, update, delete or document write to a writable state
|
||||
interface from a plan, a call and a value source: the recorded values in
|
||||
the gate, the store's generators when served.
|
||||
4. **Gate.** Add a simulation that:
|
||||
- classifies every write tool;
|
||||
- builds each plan from its exemplar;
|
||||
- walks the timeline over a copy of the initial state;
|
||||
- checks state-answered reads;
|
||||
- attributes passes and mismatches;
|
||||
- learns the insert rule;
|
||||
- records verdicts.
|
||||
|
||||
Extraction runs it after the read gate and returns `writes` and
|
||||
`write_clock`; the view schema and its unknowns state the write model.
|
||||
|
||||
5. **Serving.**
|
||||
- The loaded state carries the plans and the clock.
|
||||
- The store gains atomic mutations, the clock and the counter (all reset by
|
||||
`data/reset` and `data/add`).
|
||||
- A write answer module validates the body, applies the effect and renders
|
||||
the response.
|
||||
- Item reads of missing entities answer recorded 404s.
|
||||
- The served catalog adds served write tools, with descriptions charged to
|
||||
the discovery budget.
|
||||
6. **Export.** Count served write tools in the bundle plan and README.
|
||||
7. **Tests.** Domain, services, adapters and MCP-boundary coverage as the
|
||||
spec's acceptance lists.
|
||||
8. **Documentation.** Document the write model in the environment-state and
|
||||
serving sections, and record the real-capture results, the AgentEnv run
|
||||
and the live comparison in `verification.md`.
|
||||
|
||||
There are no dependency, provider or REA tool contract changes beyond the
|
||||
`environment-state` result members, and no new network activity.
|
||||
@@ -1,406 +0,0 @@
|
||||
# Apply write effects to the served state
|
||||
|
||||
Issue: https://github.com/morluto/rea/issues/4933
|
||||
|
||||
## Outcome
|
||||
|
||||
An environment served from its state also serves the write tools whose effects
|
||||
the capture itself confirms:
|
||||
|
||||
- a create adds an entity;
|
||||
- an update changes the fields the call sends;
|
||||
- a delete removes the entity;
|
||||
- a document write changes a document.
|
||||
|
||||
Later reads, list membership and `data/get` show the change, and `data/reset`
|
||||
undoes it.
|
||||
|
||||
The `environment-state` view decides which write tools are served. It replays
|
||||
the capture's own timeline on the extracted initial state: each recorded write
|
||||
applies its modelled effect, and each later read that the state answers is
|
||||
rendered and compared with the recorded response. A write tool is served only
|
||||
when at least one later read reproduces after its effect and none contradicts
|
||||
it. Every other write tool stays unserved, listed with the reason.
|
||||
|
||||
Values the application generates are deterministic: identities, sequences and
|
||||
clock values follow the formats the capture recorded, so the same calls after
|
||||
a reset give the same state.
|
||||
|
||||
## Write plans
|
||||
|
||||
The view adds two members to its result:
|
||||
|
||||
- `writes`: one plan per manifest tool whose method may change state
|
||||
(`changesState`);
|
||||
- `write_clock`: the instant the served clock starts from.
|
||||
|
||||
**Effects.** Each plan classifies its tool:
|
||||
|
||||
| Effect | Tool | Target |
|
||||
| ---------- | --------------------------------------------------------------------- | ------------------------- |
|
||||
| `create` | `POST` to a state type's `collection_template` | an entity of that type |
|
||||
| `update` | `PUT`, `PATCH` or `POST` to that template plus one trailing parameter | the entity the path names |
|
||||
| `delete` | `DELETE` to that same item template | the entity the path names |
|
||||
| `document` | `PUT`, `PATCH` or `POST` to a document view's path | that document |
|
||||
| `none` | anything else | reason `no-state-target` |
|
||||
|
||||
**Exemplar.** A plan is built from its first successful (2xx) recorded call,
|
||||
the exemplar.
|
||||
|
||||
- **Request body.** The exemplar's request body must be a JSON object that
|
||||
could enter the state: a JSON-like media type, complete UTF-8 text within
|
||||
256 KiB, exact numbers, no `__proto__` member. Otherwise the reason is
|
||||
`body-unusable`. A `DELETE` takes no body.
|
||||
- **The written entity.** The entity is observed in the exemplar's response,
|
||||
located by its identity value at the root, in one member, or in a
|
||||
one-element array, as item views locate it. When the response does not carry
|
||||
it, the entity is observed in the first later read that shows it: an item
|
||||
read of that entity, or for a create, the one entity a collection read newly
|
||||
lists. A create whose response names its id alone (a scalar, the identity
|
||||
field, or one member) is observed only through a read of that id. With
|
||||
neither, the reason is `no-observation`.
|
||||
|
||||
**Field rules for creates.** Each field of the observed entity gets the first
|
||||
rule that explains its value. Rules that read the call come first, so an
|
||||
identity the client sends is an echo:
|
||||
|
||||
1. **Echo.** A body member of the same name with an equal value.
|
||||
2. **Copy.** A body string of 8 or more characters, found at a JSON pointer of
|
||||
the body (the shallowest match).
|
||||
3. **Path.** The value of a path parameter of the call, percent-decoded.
|
||||
Serving decodes it from the collection the call names, whichever spelling
|
||||
the call uses.
|
||||
4. **Slug.** The slug of a body string member: lower case, runs of other
|
||||
characters joined by `-`.
|
||||
5. **Identity.** The type's `id_field` is generated. A sequence when it is an
|
||||
integer (or a string of digits); otherwise a UUID when it has the
|
||||
`8-4-4-4-12` hex shape; otherwise a token of the same length and character
|
||||
classes.
|
||||
6. **Clock.** A timestamp within 10 minutes of the exemplar's start time. The
|
||||
format is kept: ISO-8601 with its separator, fraction digits and zone, or
|
||||
epoch seconds or milliseconds.
|
||||
7. **Sequence.** An integer one greater than the largest value of the field in
|
||||
the collection, else in the type.
|
||||
8. **Unique.** A UUID-shaped string that no other entity of the type shares.
|
||||
9. **Template.** A string containing a generated value as a delimited token
|
||||
(for example a URL that ends in the new id). It is rebuilt around the new
|
||||
value.
|
||||
10. **Constant.** Otherwise the exemplar's value.
|
||||
|
||||
When a constant, or the text a template keeps, contains a path value of the
|
||||
exemplar's call as a delimited token, the create is served only for calls
|
||||
naming the exemplar's collection.
|
||||
|
||||
**Body members of a create.** A member is either accepted or not:
|
||||
|
||||
- **Accepted.** An echoed member, or the identity field's value. Every member
|
||||
the exemplar sent is required.
|
||||
- **Not accepted.** A member the application stored with a different value
|
||||
(transformed), or one it did not show. A call that sends such a member is a
|
||||
tool error naming the accepted members.
|
||||
|
||||
**Updates.** Body members follow the same accepted and not-accepted rules,
|
||||
except that a member the application stored differently is accepted only with
|
||||
the value a recorded call sent it with, and then stores the value that call
|
||||
recorded (its `constant` rule carries it). The type's identity field never
|
||||
takes a rule: sent as the path's id it is the identity member, sent as another
|
||||
value the application ignored it is accepted with that value and stores
|
||||
nothing, and a stored id that changed is `unexplained-change`. A plan whose
|
||||
calls use a member both as the identity and as a fixed member, or fix it at
|
||||
different values, is `unexplained-change`, as serving could not take every
|
||||
recorded call. So is an update a later read shows dropped a field the
|
||||
entity held, since serving keeps every field an update does not write; a write's
|
||||
own response may leave fields out.
|
||||
`PUT` requires every member the exemplar sent; `PATCH` and `POST` require at
|
||||
least one. A field the exemplar's update changed without sending it is
|
||||
explained as one of:
|
||||
|
||||
- clock;
|
||||
- an increment by one;
|
||||
- a slug of an echoed member.
|
||||
|
||||
Any other change is `unexplained-change`. An increment or a slug applies to
|
||||
every call that could take it, so the plan is also `unexplained-change` when
|
||||
such a rule would give a call of the tool another value than the one it
|
||||
recorded, or when a later call treats a member otherwise than the plan: one
|
||||
call echoes it and another stores something else, or the two rule it
|
||||
differently.
|
||||
|
||||
**Deletes.** A delete removes the entity. Its response is the exemplar's
|
||||
response, with the entity re-rendered when the response carries it.
|
||||
|
||||
**Documents.** A document write merges the body's members (replaces them, for
|
||||
`PUT`) into the document object that holds most of them: the root or one
|
||||
member. Later calls write at the exemplar's object. Document reads answer only on their recorded line, so a write is
|
||||
served only at the path the state holds the document at, and the gate treats a
|
||||
recorded write at another path as an unmodelled write. A recorded write whose
|
||||
body serving would refuse (a member of another JSON type than the document
|
||||
holds) is `unexplained-change`.
|
||||
|
||||
**Responses.** A write answers with the status and content type of one
|
||||
recorded response:
|
||||
|
||||
| Response | Body |
|
||||
| ---------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `entity` | the written entity, rendered into the recorded envelope at the plan's pointer through the recorded fields |
|
||||
| `identity` | the recorded body with the new identity at the plan's pointer |
|
||||
| `empty` | no body |
|
||||
| `recorded` | the recorded body |
|
||||
|
||||
A rendered body missing a field the recorded response carried is a tool error,
|
||||
and the state is left unchanged.
|
||||
|
||||
**List position.** A create's `insert` rule places the new entity in its
|
||||
collection's order: sorted by the identity field or a clock field (ascending
|
||||
or descending), `first` or `last`. The rule is the first candidate consistent
|
||||
with both the initial `all`-membership recordings and the position a later
|
||||
recorded list shows. With no evidence, the rule is `last`.
|
||||
|
||||
## The gate
|
||||
|
||||
The view replays every primary-origin record in timeline order on a copy of
|
||||
the initial state.
|
||||
|
||||
**Writes.** Only a 2xx write is modelled. A modelled write applies its effect, using the values it recorded
|
||||
for generated fields and the stored values for fields it transformed. A
|
||||
modelled write whose call the plan does not explain brings its entity to the
|
||||
recorded outcome instead, without attribution. A later create whose generated
|
||||
values take another form than serving gives them (a sequence value out of
|
||||
order, a clock in another format, a non-UUID unique value) makes the plan
|
||||
`unexplained-change`; integer identities are left to the id mapping. An update or
|
||||
delete names its entity as an item read does (below).
|
||||
|
||||
Any other write that may have changed state, including a redirected or
|
||||
unanswered one, is unmodelled, and what it may have changed
|
||||
depends on where its path lies relative to each type's collection paths
|
||||
(segment-prefix related, parameters matching any segment):
|
||||
|
||||
- **Below an item path:** the named entity is unknown until a read shows it
|
||||
again. When the path names no held entity, every entity of that collection
|
||||
is unknown.
|
||||
- **At a collection path:** that collection is not checked again.
|
||||
- **Above collection paths:** everything under the path is not checked again.
|
||||
|
||||
An item read of an unknown entity shows it again: the copy takes its recorded
|
||||
values. Writes that changed it since its last check are checked only on the
|
||||
members they sent.
|
||||
|
||||
**Reads.** A read is checked when the served environment would answer it
|
||||
from the state:
|
||||
|
||||
- an item read of a state-backed item view, of the entity serving would name:
|
||||
in a held collection the read's path matches, each segment verbatim or once
|
||||
percent-decoded and verbatim spellings first, the id percent-decoded once,
|
||||
then as written;
|
||||
- a list or document read on one of its view's checked request lines,
|
||||
chosen as replay chooses the line for the read's call: each path parameter
|
||||
the read's value verbatim or once percent-decoded, preferring verbatim
|
||||
spellings, then recorded order, and the query values decoded, in any order.
|
||||
A read with no query on a whole list falls back, as serving does, on a
|
||||
checked line that only pages it. A line whose query repeats a name with
|
||||
different values is never chosen, since no call's arguments can name it; one
|
||||
repeating a name with one value is, as a call naming it matches both. A list
|
||||
is compared as served from the collection of that line. A read on a page
|
||||
(`limit`, `size`, `per_page` and the like, or a `_start`–`_end` range; a size
|
||||
of zero is no bound on a line whose recording listed items under it, while its
|
||||
other sizes and range still bound the page) of a
|
||||
whole list checks nothing once the collection holds more entities than the
|
||||
page, which serving then answers from its recording.
|
||||
- A read made while a modelled write to its entity or collection was still
|
||||
running, starting before that write ended, checks nothing: it may show the
|
||||
state before or after the write. Nor does such a read show an unknown entity
|
||||
again.
|
||||
- A read a write's effect was learned from checks nothing of that write, but
|
||||
still contradicts the other writes it exercises.
|
||||
|
||||
**Comparison.** The read's answer is the full response serving would give from
|
||||
the copy: an item read's entity rendered into its item view's base recording,
|
||||
or a list read's members as serving would list them (so a member the copy no
|
||||
longer holds fails the check) rendered into its line's base recording. It is
|
||||
compared with the recording, as the read gate compares, with volatile fields taking the
|
||||
observed values. A recorded 400, 404 or 410, or a 2xx with a JSON `null`
|
||||
body, for an entity the copy does not hold agrees with it. A successful read
|
||||
whose usable body no longer holds the entity or the list fails, as does one of
|
||||
an entity lacking a field every recording of its view carried, which serving
|
||||
refuses to answer with.
|
||||
|
||||
**Attribution.**
|
||||
|
||||
- A check exercises the write tools that changed its entity or collection
|
||||
since the previous check of it, less those whose effect later writes erased.
|
||||
An update's effect is the fields it sent or changed, derived ones included.
|
||||
A later update overwrites the fields it sets (an increment adds to its field
|
||||
instead), a recorded outcome every field, and a create or delete the whole
|
||||
entity. A document write's effect is the members it merged at its
|
||||
object, or everything under the object it replaces, the whole document at the
|
||||
root; an entity's fields match by name only, whatever their names look like. A write with nothing left on an entity no
|
||||
longer counts there, nor in a collection where no entity it changed keeps any
|
||||
of its effect. A mismatch contradicts them. A pass confirms those it shows
|
||||
something of: a create's or delete's membership, or a changed field the
|
||||
read's view shows and does not treat as volatile; a read that shows none of
|
||||
an update's changes confirms nothing of it, and leaves it for a later check
|
||||
of that entity or collection.
|
||||
- A mismatch contradicts each exercised write that no unmodelled write came
|
||||
after its latest call on any of the read's entities or collections where its
|
||||
effect survives, whatever earlier write is still pending. An item read checks the writes an unmodelled
|
||||
write did follow only on the members they sent, and a list with such a write
|
||||
is not checked again: the unmodelled write may explain the rest.
|
||||
- A mismatch on an entity or collection that no write changed contradicts,
|
||||
as `side-effect`, the modelled writes since that previous check whose paths
|
||||
are segment-prefix related to the read's path.
|
||||
- After a failed item check, the copy takes the read's recorded values (or
|
||||
drops the entity on a recorded absence). A list whose check failed is not
|
||||
checked again.
|
||||
- A list that fails only for where one create placed its entity passes when
|
||||
another of that create's insert candidates reproduces it, which then
|
||||
becomes the plan's rule, unless an earlier list check relied on the rule
|
||||
(an item read's confirmation does not fix it).
|
||||
- The read that supplied a create's or update's observation does not check
|
||||
it, but checks the other writes it exercises.
|
||||
|
||||
**Verdicts.** A plan is served when at least one check confirms it and none
|
||||
contradicts it. Otherwise the reason, in this precedence, is:
|
||||
|
||||
1. the first build reason;
|
||||
2. `contradicted`, with `contradicted_by` naming the first contradicting read;
|
||||
3. `side-effect`;
|
||||
4. `unconfirmed`;
|
||||
5. `incomplete-entities`: an update or delete that answers with the entity,
|
||||
when no initial entity holds every field its recorded answer shows (for
|
||||
example a delete that answers with an edit view of the entity the reads
|
||||
never showed), so it could answer only for entities the environment itself
|
||||
created.
|
||||
|
||||
`checked_record_ordinals` lists the confirming reads.
|
||||
|
||||
## Serving
|
||||
|
||||
**Catalog.** In state mode, the catalog adds the write tools whose plan is
|
||||
served. Their descriptions say the tool:
|
||||
|
||||
- applies its effect to the served state;
|
||||
- names its target type;
|
||||
- lists its required and accepted body members.
|
||||
|
||||
The discovery budget charges them. `--replay-only` and the replay fallback are
|
||||
unchanged.
|
||||
|
||||
**Calls.** A served write answers synchronously against the live store and
|
||||
changes it atomically, so every MCP session and `data/get` see one state.
|
||||
|
||||
**Refusals.** These are tool errors that leave the state unchanged:
|
||||
|
||||
- a body that is not an object;
|
||||
- a missing required member;
|
||||
- a member that is not accepted;
|
||||
- a value of another JSON type than the exemplar's, for documents as well as
|
||||
entities;
|
||||
- an update or delete of an entity the state does not hold;
|
||||
- a create in another collection when the plan is tied to its own;
|
||||
- a document write at a path other than the one the state holds it at;
|
||||
- a body member or a resulting value `data/add` would refuse (a number not
|
||||
exact as `data/get` writes it, a `__proto__` member, nesting past the
|
||||
limit), increments included, so `data/get` always loads back;
|
||||
- a create, update or document write that would take what `data/get` reports
|
||||
past the 32 MiB `data/add` intake limit, as a `resource_constraint` error;
|
||||
- a rendered response missing a recorded field.
|
||||
|
||||
When the tool, or else an item view of the type, recorded a 400, 404 or 410
|
||||
response, an update or delete of a missing entity answers with the latest such
|
||||
recording instead.
|
||||
|
||||
A create in a collection held under more than one spelling (`acme1` and
|
||||
`%61cme1`) goes to the one the call spells verbatim, as item calls choose their
|
||||
entity; a collection keeps its spelling after its last entity is deleted. A
|
||||
reset or load knows the spellings of the Evidence's state, of the state it
|
||||
installs and of the type's checked list lines (so a collection the capture
|
||||
listed empty keeps its spelling), never those of a state it replaced. When two
|
||||
spellings fit a call equally, the one holding entities wins. `data/add` refuses a document that `data/get` would write back past the
|
||||
32 MiB intake limit once it adds the members a document may omit, such as
|
||||
`types` and the state Evidence id. A derived state `data/get` would write
|
||||
past that limit is not served: the environment serves replay and reports the
|
||||
state `unavailable`.
|
||||
|
||||
**Clock and generators.** The store's clock starts at `write_clock` and
|
||||
advances one second before each served write; clock fields take its value in
|
||||
their recorded format.
|
||||
|
||||
- **Sequences:** one more than the largest current value in their scope, the
|
||||
largest the state started with at its last reset or load, and the largest
|
||||
value they generated, so a deleted entity's value is not reused, as database
|
||||
sequences do not reuse values.
|
||||
- **UUIDs and tokens:** derived from a SHA-256 of the state Evidence id, the
|
||||
tool, the field and a write counter, skipping values the type already holds.
|
||||
|
||||
`data/reset` and `data/add` restart the clock, the counter and the
|
||||
sequences.
|
||||
|
||||
**Missing entities.** An item read of an entity the state does not hold
|
||||
answers with the tool's latest recorded 400, 404 or 410 response, or 2xx JSON
|
||||
`null` response, the absences the gate accepts, when it has one, labelled
|
||||
`state.view: "missing"`. Otherwise it stays a tool error.
|
||||
|
||||
**Lists of a whole collection.** Written entities matter only if lists show
|
||||
them, so the read gate's `membership: "all"` also covers a line that only pages
|
||||
its list (`limit`, `offset`, `page`, `per_page`, `perpage`, `page_size`,
|
||||
`pagesize`, `size`, `start`, `_start`, `_end`) when its recording listed the
|
||||
whole collection. A list call with no query argument answers from such a line
|
||||
of an `all` view of the collection its path arguments name, with
|
||||
`replay.exact_match` false; a collection with no such line answers from its
|
||||
recording. A call that asks for a page
|
||||
still answers from its recording.
|
||||
|
||||
**Provenance.** Write answers carry `state: {source: "state", view: "create" |
|
||||
"update" | "delete", type, collection, id}`, or `{source: "state", view:
|
||||
"document-write", path}`.
|
||||
|
||||
**Data plane and export.**
|
||||
|
||||
- `data/get` reports written entities without record citations; they load
|
||||
back through `data/add` unchanged.
|
||||
- `environment-export` counts served write tools in the bundle's tool count
|
||||
and README. Its check stays a read tool.
|
||||
|
||||
## Not modelled
|
||||
|
||||
These stay unserved, or are labelled in the description:
|
||||
|
||||
- side effects on counters, relations and copies of entities in other
|
||||
collections;
|
||||
- inputs the application transforms;
|
||||
- members the capture never sent;
|
||||
- reordering after an update;
|
||||
- membership of filtered or paged lists, which answer their recorded members;
|
||||
- write paths the manifest left literal;
|
||||
- writes whose request body the capture did not retain;
|
||||
- referential rules on delete: a delete removes the entity alone, so an
|
||||
application that refuses to delete a referenced entity, or deletes its
|
||||
dependents with it, is not modelled;
|
||||
- authorization.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- **Domain tests:** classification, every field rule, request-body admission,
|
||||
observation from a later read, the insert rules, every verdict and reason,
|
||||
attribution and resynchronisation, and taint by an unmodelled write.
|
||||
- **Services tests:**
|
||||
- store mutations and their atomicity;
|
||||
- every refusal;
|
||||
- generated values and their determinism across reset;
|
||||
- `data/get` round trips of written entities;
|
||||
- the recorded 404 for missing entities;
|
||||
- the catalog and descriptions in each mode.
|
||||
- **Boundary test:** a write tool changes the next read over MCP.
|
||||
|
||||
**Real captures.**
|
||||
|
||||
- The view runs over every showcase capture within its budget, and the
|
||||
verification reports, per capture, the write tools served and the reasons
|
||||
for the rest.
|
||||
- One environment, chosen at random among those serving a write tool, is
|
||||
exported, run in the AgentEnv framework, and graded from `data/get` after an
|
||||
agent's create, update and delete.
|
||||
- The served model is compared with that application running live on the same
|
||||
calls.
|
||||
|
||||
No test reaches an external network.
|
||||
@@ -1,22 +0,0 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Record the write model, the gate and the serving contract.
|
||||
- [x] Admit request bodies and add the plan types.
|
||||
- [x] Explain created and updated entities with field rules.
|
||||
- [x] Apply create, update, delete and document effects in one shared module.
|
||||
- [x] Simulate the capture's timeline and decide each plan's verdict.
|
||||
- [x] Leave entities below an unmodelled write unknown until a read shows them
|
||||
again, and check writes such a write follows only on the members they
|
||||
sent.
|
||||
- [x] Serve gated write tools from the live store with deterministic values.
|
||||
- [x] Answer recorded 400, 404 and 410 responses for missing entities.
|
||||
- [x] Let lines that only page a whole collection back `membership: "all"`,
|
||||
and answer argument-less list calls from them.
|
||||
- [x] Count served write tools in exports and describe them in bundle READMEs.
|
||||
- [x] Add domain, services, adapters and boundary coverage.
|
||||
- [x] Document the write model and serving.
|
||||
- [x] Run the view over every showcase capture and report per-capture verdicts.
|
||||
- [x] Export one randomly chosen environment, run write tasks in the AgentEnv
|
||||
framework and grade them from `data/get`.
|
||||
- [x] Compare the served model with the live application on the same calls.
|
||||
- [x] Run static, formatting, duplication, documentation and test gates.
|
||||
@@ -1,360 +0,0 @@
|
||||
# Verification
|
||||
|
||||
Verified base: cbb7b613 (this branch before this file). Executed on macOS with
|
||||
Node v22.23.1 (the repository pins 24.18.0 in `.nvmrc`, which was not
|
||||
installed); the AgentEnv runs and the live comparison ran on a disposable Linux
|
||||
VM. Every check below ran as written after the last source change.
|
||||
|
||||
## Gates
|
||||
|
||||
- `npm run build:cached`, `npm run check:fast` (typecheck and lint),
|
||||
`npm run format:check`, `npx knip`, `npm run scan:todos`, `npm run docs:check`
|
||||
and `npm run verify:test-discovery`: passed.
|
||||
- `npm run jscpd`: 3.43% duplicated lines over 3538 files, under the 3.5% gate.
|
||||
- `npx vitest run --project domain --project services`: 7407 passed, 5
|
||||
pre-existing skips.
|
||||
- `npm run test:prepare`, then `npm run test:focused --` the new write MCP
|
||||
boundary, the serve MCP, CLI and process boundaries, the export CLI
|
||||
boundary, the analysis-view CLI and MCP boundaries, the compact input schema
|
||||
boundary and the contract description tests: 10 files, 72 passed.
|
||||
- `npm run mcp-catalog:check`: passed.
|
||||
|
||||
New tests: `environmentStateWriteValues` (94), `environmentStateWriteExplain`
|
||||
(101), `environmentStateWriteApply` (52), `environmentStateWrites` (16, the gate
|
||||
through the view), `environmentStateWriteAnswer` (14, serving) and
|
||||
`environmentServeWritesMcp` (1, the built CLI over MCP). The replay card pinned
|
||||
by the serve MCP boundary changed with the response summaries tool descriptions
|
||||
carry; its digest now matches the combined branch.
|
||||
|
||||
## Real captures
|
||||
|
||||
The `environment-state` view ran over the 50 showcase captures (45 HAR
|
||||
inspections and 5 `capture_browser_scenario` captures), each within its budget in
|
||||
8–110 ms. It served 33 write tools in 16 captures (11 creates, 12 updates, 10
|
||||
deletes). Of the other 150 state-changing tools, 102 name no state type or
|
||||
document (most write to literal single-entity paths the manifest left
|
||||
untemplated, or to collections whose reads are not state-backed), and 48 are
|
||||
modelled but not served:
|
||||
|
||||
- 15 unconfirmed (no later read checked them);
|
||||
- 13 no-observation;
|
||||
- 8 contradicted, for example a deleted Airbyte source and EspoCRM task still
|
||||
readable, Immich and BookStack item reads showing fields the create
|
||||
response lacked, and Sonarr's tag list ordering;
|
||||
- 7 unexplained-change, such as BookStack's description_html and Paperless's
|
||||
derived text colour;
|
||||
- 4 side-effect, for example a Sonarr tag also listed by `/tag/detail`;
|
||||
- 1 incomplete-entities: WordPress answers a delete with the post in its edit
|
||||
context, which the reads never showed for seeded posts.
|
||||
|
||||
| Capture | Served (create / update / delete) | Not served |
|
||||
| ---------------- | --------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| airbyte | 2 / 2 / 0 | create contradicted, delete contradicted, delete unconfirmed |
|
||||
| baserow | none | no-state-target ×3 |
|
||||
| bookstack | 0 / 0 / 1 | create contradicted, create side-effect, update unexplained-change |
|
||||
| directus | none | no-state-target ×2 |
|
||||
| espocrm | none | create unconfirmed, delete contradicted, update unexplained-change |
|
||||
| ghost | none | no-state-target ×2 |
|
||||
| gitea | 1 / 1 / 0 | no-state-target ×2 |
|
||||
| grafana | none | create unconfirmed, no-state-target ×5 |
|
||||
| grist | none | create no-observation, no-state-target ×5 |
|
||||
| grocy | none | create no-observation, delete no-observation, update no-observation, no-state-target ×2 |
|
||||
| healthchecks | none | create no-observation, delete no-observation, update no-observation, no-state-target ×2 |
|
||||
| homebox | none | document no-observation, no-state-target ×3 |
|
||||
| immich | 0 / 0 / 1 | create contradicted, update contradicted |
|
||||
| jellyfin | none | no-state-target ×2 |
|
||||
| keycloak | none | no-state-target ×1 |
|
||||
| komga | none | create unconfirmed ×2, no-state-target ×4 |
|
||||
| linkding | 2 / 2 / 2 | create unconfirmed, delete unconfirmed, update unconfirmed, no-state-target ×2 |
|
||||
| listmonk | 1 / 1 / 1 | — |
|
||||
| lychee | none | no-state-target ×4 |
|
||||
| mailpit | none | document no-observation, no-state-target ×5 |
|
||||
| mattermost | 1 / 1 / 1 | no-state-target ×3 |
|
||||
| mealie | none | create no-observation, no-state-target ×2 |
|
||||
| memos | none | no-state-target ×3 |
|
||||
| metabase | 0 / 0 / 1 | create side-effect, update unexplained-change |
|
||||
| miniflux | 0 / 1 / 0 | no-state-target ×3 |
|
||||
| n8n | none | create unconfirmed, no-state-target ×6 |
|
||||
| navidrome | none | create no-observation, no-state-target ×2 |
|
||||
| netbox | 1 / 1 / 1 | — |
|
||||
| nocodb | none | create no-observation, no-state-target ×4 |
|
||||
| nomad | none | no-state-target ×4 |
|
||||
| paperless | 0 / 0 / 1 | create unconfirmed, update unexplained-change, no-state-target ×3 |
|
||||
| photoprism | 1 / 1 / 0 | delete unconfirmed |
|
||||
| pinry | none | create unconfirmed, delete unconfirmed, update unexplained-change |
|
||||
| pocketbase | none | no-state-target ×4 |
|
||||
| redpanda-console | none | document no-observation, no-state-target ×5 |
|
||||
| sentry | 1 / 0 / 0 | update unexplained-change, no-state-target ×3 |
|
||||
| shlink | none | create unconfirmed, no-state-target ×1 |
|
||||
| sonarr | 0 / 1 / 0 | create contradicted, create side-effect, update contradicted |
|
||||
| tandoor | none | no-state-target ×4 |
|
||||
| umami | 1 / 1 / 1 | — |
|
||||
| vault | none | no-state-target ×7 |
|
||||
| vikunja | none | no-state-target ×4 |
|
||||
| wordpress | none | create side-effect, delete incomplete-entities, document unconfirmed, update unexplained-change |
|
||||
|
||||
An in-process smoke derived each capture's served state and called every served
|
||||
write tool once with a varied body: all 33 applied (status equal to the
|
||||
recording), and every created or updated entity read back from the state;
|
||||
deleted entities answered the recorded 400 or 404 where one was recorded. Eight
|
||||
older published manifests no longer match the tool names the current view
|
||||
derives, so their state is unavailable and they serve replay, as designed;
|
||||
none of them has a served write tool.
|
||||
|
||||
## End to end on a published environment
|
||||
|
||||
Five published environments serve a create, an update and a delete (Linkding,
|
||||
Listmonk, Mattermost, NetBox, Umami); `random.SystemRandom().choice` picked
|
||||
NetBox (NetBoxEnv-REA, NetBox 4.7.3).
|
||||
|
||||
- `rea environment-export` (packed from this branch) wrote the bundle; its
|
||||
smoke task passed in AgentEnv framework 0.9.1334 with 42 served tools (39
|
||||
reads and `post_dcim_sites`, `patch_dcim_sites_by_id`,
|
||||
`delete_dcim_sites_by_id`).
|
||||
- Four write tasks, each graded by an `env_outcome_verifier` that reads the
|
||||
environment's state through `/agentenv` `data/get` (not the agent's reply),
|
||||
with the replay-qa agent over the Hugging Face router, one run per task and
|
||||
model:
|
||||
|
||||
| Task | gpt-oss-120b | GLM-5.3-Flash | Tool calls (gpt-oss) |
|
||||
| ----------------------------------------------- | ------------ | ------------- | ------------------------------------------------------------ |
|
||||
| Create a site in the Copper Valley region | 1.0 | 1.0 | get_dcim_regions, post_dcim_sites |
|
||||
| Update a seeded site's facility and description | 1.0 | 1.0 | get_dcim_sites, patch_dcim_sites_by_id |
|
||||
| Delete one seeded site and only it | 1.0 | 1.0 | get_dcim_sites, delete_dcim_sites_by_id |
|
||||
| Create a site, then set its facility | 1.0 | 1.0 | get_dcim_regions, post_dcim_sites, patch_dcim_sites_by_id ×2 |
|
||||
|
||||
- The bundle's 12 retrieval tasks (one per seeded object) passed 12/12 for both
|
||||
models, as they did on the published replay bundle.
|
||||
|
||||
## The served model against the live application
|
||||
|
||||
The same NetBox 4.7.3 image ran on the VM, seeded by the capture's own seed
|
||||
script (the same ids), and one scenario ran against both the live API and the
|
||||
environment served from the capture. Only `created` and `last_updated` were
|
||||
normalised; every other member had to be equal:
|
||||
|
||||
| Step | Live | Environment | Equal |
|
||||
| ----------------------------------------- | ---- | ----------- | ---------------------------- |
|
||||
| List sites | 200 | 200 | yes |
|
||||
| Create Tide Gauge Annex (id 5) | 201 | 201 | yes |
|
||||
| Read it | 200 | 200 | yes |
|
||||
| List after the create (sorted by name) | 200 | 200 | yes |
|
||||
| Patch its description and facility | 200 | 200 | yes |
|
||||
| Read after the patch | 200 | 200 | yes |
|
||||
| Create Granite Spur (id 6) | 201 | 201 | yes |
|
||||
| Patch a seeded site | 200 | 200 | yes |
|
||||
| Delete Tide Gauge Annex | 204 | 204 | yes |
|
||||
| Read the deleted site | 404 | 404 | yes |
|
||||
| Create Ledger Point (id 7, not reusing 5) | 201 | 201 | yes |
|
||||
| Delete a seeded site with devices | 409 | 204 | no |
|
||||
| Final list | 200 | 200 | no (follows from the delete) |
|
||||
|
||||
11 of 13 steps were equal. The difference is NetBox's referential protection:
|
||||
it refuses to delete a site that devices reference, while the environment's
|
||||
delete removes the entity alone. The capture only deleted a scratch site with
|
||||
no dependents, so the gate could not see the rule; it is listed under what is
|
||||
not modelled.
|
||||
|
||||
## Not covered
|
||||
|
||||
- A run of the hosted `real-agentenv` lane in CI (the AgentEnv runs here used
|
||||
the published framework release on a VM).
|
||||
- Document writes on a real capture: the four modelled ones are unserved.
|
||||
- Collections tied to a create's own collection on a real capture, and UUID
|
||||
or token identities through an AgentEnv run (both are covered by tests).
|
||||
- Agent token usage and cost.
|
||||
|
||||
## After merging the base branches and main
|
||||
|
||||
The branch then merged the serving branch at 5a61853a, the coverage branch at
|
||||
b4b7ebc7 (their review fixes) and main at edbd291a, then main at 17f581ff
|
||||
(the analysis-view tool description string, which main changes often). The gates above ran again
|
||||
and passed: `npm run jscpd` 3.44% over 3579 files; domain and services 7442
|
||||
passed with 5 pre-existing skips; the focused boundary set 72 passed. The
|
||||
write verdicts over the 50 captures were unchanged plan by plan, and the
|
||||
in-process smoke again applied all 33 served write tools. The AgentEnv runs
|
||||
and the live comparison above ran on cbb7b613, before these merges.
|
||||
After the second main merge: `npm run check:fast`, `npm run format:check`,
|
||||
`npm run docs:check` and `npm run mcp-catalog:check` passed; the contracts,
|
||||
analysis-view and environment tests 857 passed with 3 pre-existing skips; the
|
||||
analysis-view and environment MCP and CLI boundaries 53 passed.
|
||||
|
||||
## After the review round
|
||||
|
||||
The branch then merged the serving branch at c00fde41, the coverage branch at
|
||||
f6c756aa and main at 1629266e, and took the review fixes (82556cd5, 138ea4b1,
|
||||
6d21b62e). The coverage branch quotes field names in response summaries, so the
|
||||
replay card pinned by the serve MCP boundary is now 12,818 bytes; removing
|
||||
those quotes gives back the previous 12,618-byte digest exactly. The gates ran
|
||||
again and passed: `npm run check:fast`, `npm run format:check`, `npx knip`,
|
||||
`npm run scan:todos`, `npm run docs:check`, `npm run verify:test-discovery`,
|
||||
`npm run verify:module-boundaries` and `npm run mcp-catalog:check`;
|
||||
`npm run jscpd` 3.47% over 3601 files; domain and services 7463 passed with 5
|
||||
pre-existing skips; the focused boundary set 75 passed; conformance 279
|
||||
passed. The gate is stricter, but the write verdicts over the 50 captures were
|
||||
unchanged plan by plan, and the in-process smoke again applied all 33 served
|
||||
write tools. The AgentEnv runs and the live comparison above ran on cbb7b613,
|
||||
before these merges and fixes.
|
||||
|
||||
A second review round then made the gate and serving agree in five more places
|
||||
(reads matched to checked lines as serving matches calls, increments and slugs
|
||||
held for every call, insert rules still learned after an item read, the
|
||||
document body check in the gate, and decoded parent path values). Each new test
|
||||
fails on the previous head and passes here. The same gates passed again
|
||||
(domain and services 7468 passed with 5 pre-existing skips, the focused
|
||||
boundary set 75, conformance 279, `jscpd` 3.47%), the write verdicts over the 50
|
||||
captures were again unchanged plan by plan, and the smoke applied all 33 served
|
||||
write tools.
|
||||
|
||||
A third round kept a checked line's path spelling (replay prefers a verbatim
|
||||
one, so two spellings of one parent stay separate lines) and compared a later
|
||||
slug rule with the values earlier calls recorded rather than only their member
|
||||
names; both new tests fail on the previous head. A fourth round chose a read's
|
||||
line as replay does, so a read spelled plainly is checked against the encoded
|
||||
line that answers it, and its list against that line's collection; its test
|
||||
fails on the previous head.
|
||||
|
||||
A review of the fidelity branch built on this one found four more places: a
|
||||
document write at another path is now unmodelled in the gate, a create picks
|
||||
the collection the call spells verbatim, a missing entity is answered with a
|
||||
recorded 2xx `null` the gate accepted as an absence, and `data/add` refuses a
|
||||
document whose `data/get` form would exceed the intake limit. Each new test
|
||||
fails on the previous head. The gates passed again (domain and services 7475
|
||||
with 5 pre-existing skips, boundaries 75, conformance, `jscpd` 3.46%), the
|
||||
verdicts over the 50 captures were unchanged plan by plan, and the smoke
|
||||
applied all 33 served write tools.
|
||||
|
||||
A sixth round, after merging the serving and coverage branches and main
|
||||
335c042d, settled five more findings: a read whose usable body no longer holds
|
||||
the entity or list fails its check, an entity lacking a field its view always
|
||||
carried confirms nothing, a write a later write replaced is not confirmed by
|
||||
reads of the replacement, sequences never reuse a value the state started
|
||||
with, and a collection keeps its spelling after it empties. Each new test fails
|
||||
on the previous head. Over the 50 captures one capture's verdicts change:
|
||||
Linkding's bundle create and patch, confirmed only by a list read after the
|
||||
same bundle was deleted, are now unconfirmed, so 31 write tools are served (10
|
||||
creates, 11 updates, 10 deletes) and the in-process smoke applies all 31. The
|
||||
gates passed again (domain and services, boundaries, conformance and `jscpd`).
|
||||
|
||||
A seventh round settled two more findings. An update keeps its confirmation
|
||||
when a later write overwrites only part of what it changed: a derived change,
|
||||
such as an incremented version, counts as part of the update's effect, and a
|
||||
later increment adds to the field rather than replacing it. A reset or load
|
||||
forgets the collection spellings of the state it replaced. Both new tests fail
|
||||
on the previous head. The verdicts over the 50 captures are unchanged plan by
|
||||
plan, the in-process smoke applies all 31 served write tools, and the gates
|
||||
passed again.
|
||||
|
||||
An eighth round settled three more. A later write erases the fields of an
|
||||
earlier effect it overwrites, so two writes that together overwrite all of an
|
||||
update's changes leave it nothing to be confirmed by. A recorded outcome erases
|
||||
what earlier writes changed on its entity. A load keeps the Evidence's own
|
||||
collection spellings, so a create in a collection the loaded state does not
|
||||
hold keeps its recorded spelling. Each new test fails on the previous head. The
|
||||
verdicts over the 50 captures are unchanged plan by plan, the smoke applies all
|
||||
31 served write tools, and the gates passed again.
|
||||
|
||||
A ninth round made a create prefer a collection the state holds over an equally
|
||||
fitting spelling known only from the Evidence, so a create after a load lands in
|
||||
the loaded collection and reads back. Its test fails on the previous head; the
|
||||
verdicts and the smoke are unchanged and the gates passed again.
|
||||
|
||||
A tenth round settled six findings Greptile raised on the list-lines branch
|
||||
against this branch's code:
|
||||
|
||||
- a read with no query on a whole list is checked on the paging line serving
|
||||
answers it from, and a line whose query repeats a name is never checked;
|
||||
- a read made while a write to its targets was still running checks nothing;
|
||||
- a later call that stores a member otherwise than the plan refuses the plan;
|
||||
- a create keeps the spelling of a collection the capture listed empty;
|
||||
- a wide array no longer overflows the stack while checking path-dependent
|
||||
values;
|
||||
- the guide counts served write tools among the catalog's.
|
||||
|
||||
Each new test fails on the previous head. The verdicts over the 50 captures
|
||||
are unchanged plan by plan, the smoke applies all 31 served write tools, and
|
||||
the gates passed again.
|
||||
|
||||
An eleventh round settled six more findings, five raised on this branch and one on the fidelity branch:
|
||||
|
||||
- a passing read confirms only the writes it shows something of;
|
||||
- an item read names its entity as serving does, percent-decoded spellings included;
|
||||
- a read a write's effect was learned from still contradicts the other writes;
|
||||
- a line repeating a query name with one value stays checkable;
|
||||
- document writes join the effect tracking, so a replacement overwrites an earlier merge;
|
||||
- a call naming a page that the collection has outgrown answers from its recording (`page-exceeded`), and the gate does not check such reads.
|
||||
|
||||
Each new test fails on the previous head. The verdicts over the 50 captures are unchanged plan by plan, the smoke
|
||||
applies all 31 served write tools, and the gates passed again.
|
||||
|
||||
A twelfth round settled four findings Greptile raised on the list-lines branch against this branch's code:
|
||||
|
||||
- a create that answers with its id alone is observed only through a read of that id;
|
||||
- a later call that is the first to send the path's id in its body adds the identity member to the plan;
|
||||
- the copy search walks a request body with an index, so a wide body no longer takes quadratic time (500,000 zeros: 63 s
|
||||
before, 74 ms after);
|
||||
- the sent values of fixed members a plan keeps are counted against the view's allowance.
|
||||
|
||||
Each new test fails on the previous head. The verdicts over the 50 captures are unchanged plan by plan, the smoke
|
||||
applies all 31 served write tools, and the gates passed again.
|
||||
|
||||
A thirteenth round settled seven findings Greptile raised on this branch, the fidelity branch and the list-lines branch:
|
||||
|
||||
- a passing read that shows nothing of a write leaves it for a later check, instead of clearing it;
|
||||
- a read begun while a write to an unknown entity still ran neither checks nor shows the entity again;
|
||||
- a later create whose sequence fields do not follow the learned sequence refuses the plan;
|
||||
- a document replacement below the root overwrites only what it replaced;
|
||||
- a built document plan writes later calls at its own object, even one sharing no member with the document;
|
||||
- an update or delete finds its entity as an item read does, percent-decoded spellings included;
|
||||
- a `_start`–`_end` range bounds a page as `limit` does, in serving and in the gate.
|
||||
|
||||
Each new test fails on the previous head. The verdicts over the 50 captures are unchanged plan by plan, the smoke
|
||||
applies all 31 served write tools, and the gates passed again.
|
||||
|
||||
A fourteenth round settled two findings Greptile raised on the list-queries branch against this branch's code:
|
||||
|
||||
- the type's identity field never becomes a constant: an update whose id member the application ignored no longer
|
||||
writes the exemplar's id into the entity another call names;
|
||||
- a page size of zero bounds nothing, so a whole line recorded with `limit=0` answers from the state.
|
||||
|
||||
Both tests fail on the previous head. The verdicts over the 50 captures are unchanged plan by plan, the smoke applies
|
||||
all 31 served write tools, and the gates passed again.
|
||||
|
||||
A fifteenth round settled nine findings Greptile raised on this branch, the fidelity branch and the list-lines branch,
|
||||
five of them on the previous rounds' own changes:
|
||||
|
||||
- an entity field whose name looks like a pointer is matched by name; only a document's pointers cover the members
|
||||
under them;
|
||||
- a failed read contradicts each exercised write no unmodelled write came after, rather than skipping all of them
|
||||
when the oldest pending write came before one;
|
||||
- an item read, update or delete names its entity as serving does, decoding the id once, so `book%2531` no longer
|
||||
reaches `book1`;
|
||||
- a plan whose calls use a member both as the identity and as a fixed member, or fix it at different values, is
|
||||
refused;
|
||||
- a size of zero bounds nothing only on a line whose recording listed items under it;
|
||||
- an update whose observation drops a field the entity held is refused;
|
||||
- a later create whose clock takes another format, or whose unique value is not a UUID, is refused;
|
||||
- a derived state `data/get` would write past 32 MiB is not served.
|
||||
|
||||
Each new test fails on the previous head. The verdicts over the 50 captures are unchanged plan by plan, the smoke
|
||||
applies all 31 served write tools, and the gates passed again.
|
||||
|
||||
A sixteenth round settled four findings Greptile raised on the fifteenth round's changes, here and on the fidelity,
|
||||
list-lines and list-queries branches:
|
||||
|
||||
- a size of zero is no bound only by itself: a range or another size on the same line still bounds the page;
|
||||
- an update is refused for a dropped field only when a later read shows it dropped, since a write's own response may
|
||||
leave fields out;
|
||||
- interference is weighed against a tool's latest call on the read's entity or collection, not its latest call
|
||||
anywhere.
|
||||
|
||||
Each new test fails on the previous head. The verdicts over the 50 captures are unchanged plan by plan, the smoke
|
||||
applies all 31 served write tools, and the gates passed again.
|
||||
|
||||
A seventeenth round settled two more findings Greptile raised on the list-queries branch against this branch's
|
||||
interference rule: an unmodelled write counts against a tool when it followed the tool's call on any target the read
|
||||
covers, so a later call on another entity does not hide it; and a failed item read contradicts the writes no
|
||||
unmodelled write followed, checking only the others on their members. Both tests fail on the previous head. The
|
||||
verdicts over the 50 captures are unchanged plan by plan, the smoke applies all 31 served write tools, and the gates
|
||||
passed again. A follow-up weighs interference only on targets where the tool's effect survives, so a call
|
||||
whose effect a later write overwrote no longer excuses a later mismatch; its test fails on the previous head.
|
||||
+1
-1
@@ -1806,7 +1806,7 @@ the built CLI, packs the checkout with `npm pack`, exports the bundle with
|
||||
registry package, and runs `agent-env run` on the bundle. It checks that the
|
||||
export reports the planned file set with the inputs copied byte-exact and a
|
||||
bundle served from the derived state, which serves fewer tools than the
|
||||
manifest because write tools the capture did not confirm are not served, that the runner exits `0` with
|
||||
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
|
||||
|
||||
@@ -120,6 +120,8 @@ HTTP fields and ordered duplicate headers are retained, with stored body bytes
|
||||
in binary sidecars. Bodies are not dechunked or decompressed. Metadata is an
|
||||
XML node tree; optional `_w.txt` and `_g.txt` files retain opaque WebSocket and
|
||||
gRPC message bytes. Missing members remain null, distinct from empty bodies.
|
||||
A zero-byte `_c.txt` or `_s.txt` member, which Fiddler writes for a session
|
||||
without that message, is also null and adds a record limitation.
|
||||
See Telerik's [SAZ format reference](https://www.telerik.com/fiddler/fiddler-everywhere/documentation/knowledge-base/fiddler-archives).
|
||||
|
||||
## Protobuf and gRPC bodies
|
||||
@@ -520,9 +522,9 @@ Pass the same parent to `inspect_analysis_view` with
|
||||
`view: {"kind":"environment-state"}` to extract the initial state the
|
||||
capture's reads observed, and a render plan per read tool that reproduces those
|
||||
reads from it. Where the environment manifest names an application's tools, the
|
||||
state holds its data: entities by type and identity, plus documents for reads
|
||||
that are not entities. Use the `template_rules` of the manifest the state
|
||||
accompanies so tool names match; the view groups records the same way.
|
||||
state holds its data: entities by type and identity, plus documents for written
|
||||
paths whose reads are not entities. Use the `template_rules` of the manifest the
|
||||
state accompanies so tool names match; the view groups records the same way.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -558,27 +560,13 @@ refused for them, as the manifest is not.
|
||||
|
||||
Each `GET` tool whose template ends in a parameter is an item tool, and its
|
||||
type's `collection_template` is the template without that parameter; any other
|
||||
`GET` tool is a list tool of its own template. Empty segments are ignored, so a
|
||||
trailing slash names the same collection. An item tool proves its type's
|
||||
`GET` tool is a list tool of its own template. An item tool proves its type's
|
||||
`id_field` when every initial response carries the requested path value in the
|
||||
same field of one entity object (`id_basis: "path-parameter"`). The object is
|
||||
the response itself or one wrapper member; failing both, it is the only element
|
||||
of a one-element array at the root (pointer `/0`) or in one member
|
||||
(`/<member>/0`), as in `{"posts":[{...}]}` or `{"data":[{...}]}`, provided one
|
||||
of the identity names below repeats the value, since a filtered list may also
|
||||
answer with one element that repeats its parent's value. Otherwise the field is
|
||||
the first of `id`, `uid`, `uuid`, `_id`, `ID`, `UID`, `Id`, `guid`, `key`,
|
||||
`slug` and `name` that every listed item carries as a distinct string or
|
||||
number; when none does, it is the first key of the first listed item, in its
|
||||
order, whose name ends in `Id` or `_id` (such as `orgId` or `user_id`), that
|
||||
every item carries as a distinct string or number, and that at least one
|
||||
initial response shows distinct across two or more items
|
||||
(`id_basis: "field-name"`). A single listed item is distinct from nothing, and
|
||||
its suffix-named fields are often foreign keys, so a list that only ever held
|
||||
one item is left to a document. Empty initial lists constrain no field. A list
|
||||
is the response array, or the one array of objects in an envelope that is not
|
||||
itself an entity, preferring an array whose items an identity name identifies,
|
||||
then an array of two or more items a suffix-named field identifies.
|
||||
same field, at the root or in one wrapper member (`id_basis: "path-parameter"`).
|
||||
Otherwise the field is the first of `id`, `uid`, `uuid`, `_id`, `ID`, `UID`,
|
||||
`Id`, `guid`, `key`, `slug` and `name` that every listed item carries as a
|
||||
distinct string or number (`id_basis: "field-name"`). A list is the response
|
||||
array, or the one array of objects in an envelope that is not itself an entity.
|
||||
|
||||
`entities` merge every initial observation of one identity within one
|
||||
collection path: an item view's latest values win over a list view's, a view
|
||||
@@ -593,12 +581,12 @@ Every entity cites its `record_ordinals`.
|
||||
|
||||
`views` lists one plan per read tool in tool order:
|
||||
|
||||
| Shape | Plan |
|
||||
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `item` | The entity object at `pointer` (the root, one member, or `/0` or `/<member>/0`), the ordered `fields` the view shows and the `required_fields` every recording had, and `envelope_ordinal`: the recording whose response, with the entity replaced, every item response shares |
|
||||
| `list` | The array at `pointer`, its `fields`, `membership` (`all`: every entity of the collection in state order, when each recording listed exactly that, had no query or only paging parameters (`limit`, `offset`, `page`, `per_page`, `perpage`, `page_size`, `pagesize`, `size`, `start`, `_start`, `_end`), no item-total member (`total`, `count`, `totalCount`, `item_count`, `totalcount`, `itemcount`, but not `total_pages` or `per_page`; a number or a string of digits) differing from the number listed, and no `has_more`, `next` or next-page token member set; `recorded`: the entities each recording listed) and `counters` that equal the number listed |
|
||||
| `document` | The latest initial value of a list tool whose responses hold no identified list, when its initial reads share one request line; held in `documents` with that `path`, whether or not the capture writes it, unless its value changed across any recorded write between those reads (`fields-differ`) |
|
||||
| `recorded` | A `reason` the tool replays its recordings instead |
|
||||
| Shape | Plan |
|
||||
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `item` | The entity object at `pointer` (the root or one member), the ordered `fields` the view shows and the `required_fields` every recording had, and `envelope_ordinal`: the recording whose response, with the entity replaced, every item response shares |
|
||||
| `list` | The array at `pointer`, its `fields`, `membership` (`all`: every entity of the collection in state order, when each recording listed exactly that, had no query, no item-total member (`total`, `count`, `totalCount`, `item_count`, `totalcount`, `itemcount`, but not `total_pages` or `per_page`; a number or a string of digits) differing from the number listed, and no `has_more`, `next` or next-page token member set; `recorded`: the entities each recording listed) and `counters` that equal the number listed |
|
||||
| `document` | The path's latest initial value, held in `documents` |
|
||||
| `recorded` | A `reason` the tool replays its recordings instead |
|
||||
|
||||
An item or list plan is kept only when it reproduces the latest initial
|
||||
recording of each request line from the state, comparing JSON values without
|
||||
@@ -606,86 +594,39 @@ regard to member order; a volatile field compares its recorded value. Each
|
||||
plan lists the `checked_record_ordinals`. Otherwise the tool is a recorded view
|
||||
with reason `envelope-varies`, `fields-differ` or `list-differs`; the other
|
||||
reasons are `query-body` (a query `POST`), `no-init-recording`,
|
||||
`body-unusable`, `no-identity` (an item read without a proved identity),
|
||||
`path-varies` (another read under more than one path, as a template with a
|
||||
parameter before its end) and `query-varies` (another read of one path under
|
||||
more than one query). A document holds the value of its one `path`, so for such
|
||||
a template it says nothing about the template's other paths. A body is usable
|
||||
when its content type is
|
||||
`body-unusable`, `not-entity`, `no-identity` and `query-varies` (a document
|
||||
read under several queries). Documents are held only for list-shaped paths
|
||||
that a `POST`, `PUT` or `PATCH` tool writes: a non-entity read of an item path
|
||||
such as `/api/files/{id}`, or of a path that is only deleted, stays recorded
|
||||
with `no-identity` or `not-entity`. A body is usable when its content type is
|
||||
JSON-like, it is UTF-8 text of at most 256 KiB, every number survives native
|
||||
conversion exactly, and no member is named `__proto__`.
|
||||
|
||||
`writes` lists one plan per tool whose request may change state, in tool
|
||||
order. Its `effect` is `create` for a `POST` to a type's
|
||||
`collection_template`, `update` for a `PUT`, `PATCH` or `POST` to that template
|
||||
plus one trailing parameter, `delete` for a `DELETE` to it, `document` for a
|
||||
`PUT`, `PATCH` or `POST` to a document's path, and otherwise `none` with reason
|
||||
`no-state-target`. A plan is built from the tool's first successful call: its
|
||||
request body must be a usable JSON object (as response bodies are usable), and
|
||||
the written entity must show in that call's response or in a later read before
|
||||
the next write. Each field of a created entity gets the first rule that
|
||||
explains it: generated `identity` (a sequence, UUID or token, by the recorded
|
||||
value's shape), `echo` of a body member, `copy` of a body string of 8 or more
|
||||
characters, a `path` value, a `slug` of a body member, a `clock` value within 10
|
||||
minutes of the request in its recorded format, a `sequence` one past the
|
||||
largest value of its collection or type, a `unique` UUID, or a `template` around
|
||||
a generated value; other fields keep the observed value. An update's members
|
||||
echo when stored as sent; a member stored differently is accepted only with its
|
||||
recorded value (`body.fixed`) and stores the value the call that showed it
|
||||
recorded (the `value` of its `constant` rule), and a field it changed without
|
||||
sending must be a clock, an increment or a slug, or the plan is
|
||||
`unexplained-change`; an increment or slug must hold for every call of the
|
||||
tool. Every member the first call sent is required, except by `PATCH` and
|
||||
`POST` updates.
|
||||
|
||||
The view then replays the timeline on a copy of the initial state. Modelled
|
||||
writes apply their effect; only a `2xx` write is modelled or an example, and
|
||||
any other write (a redirect, an unanswered or unmodelled one) leaves the entity
|
||||
below whose item path it wrote unknown until a read shows it again (a whole
|
||||
collection when the path names no held entity), and taints what it wrote at or
|
||||
above. A read the served environment would answer from the state is compared
|
||||
with its recording as the read plans are, as the whole response it would serve:
|
||||
the entity or list rendered into the envelope of the item view's or the line's
|
||||
base recording, with the members the state still holds. A 400, 404 or 410 (or a
|
||||
`null` body) agrees with an entity the copy does not hold. A match confirms
|
||||
the writes that changed its entity or collection since it was last read; a
|
||||
mismatch contradicts them, or, when an unmodelled write came after them, is
|
||||
checked only on the members they sent; a mismatch no write explains marks the related writes since as
|
||||
`side-effect`. A plan is `served` when a read confirmed it and none
|
||||
contradicted it; otherwise its `reason` is the first of its build reason
|
||||
(`no-call`, `body-unusable`, `no-observation`, `unexplained-change`),
|
||||
`contradicted` (with `contradicted_by`), `side-effect`, `unconfirmed` and `incomplete-entities` (an update or delete that answers with the entity, when no initial entity holds every field its recorded answer shows, so it could answer only for entities the environment created).
|
||||
`checked_record_ordinals` lists the confirming reads, `insert` places created
|
||||
entities (sorted by a field the collection's recorded order shows, else last),
|
||||
and `write_clock` is the end of the last timed record, to the next second.
|
||||
|
||||
Values in the state are recorded data, so the view refuses the same parents as
|
||||
the manifest, including those inspected with `sensitive_values`, and a
|
||||
`capture_browser_scenario` parent must have selected response bodies. Types and
|
||||
identity are inferences, so the Evidence is always inferred. `unknowns` states
|
||||
that the state establishes no authorization model, no way the application
|
||||
computes responses or derived values, and write effects only as modelled from
|
||||
recorded calls. The state
|
||||
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. Documents hold whole recorded response
|
||||
values of up to 256 KiB each, so a capture with many read-only paths yields a
|
||||
correspondingly larger state result. The view performs no network activity and
|
||||
serves nothing; `rea environment-serve` derives it from the capture at startup,
|
||||
serves read tools and the served write plans from it and loads replacement
|
||||
states through the AgentEnv data plane, as
|
||||
it keeps, within the same 96 MiB budget. The view performs no network activity
|
||||
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
|
||||
|
||||
Complete evidence has a 32 MiB input, 96 MiB decoder reply and 64-level nesting
|
||||
budget. Exceeding a budget returns no partial success.
|
||||
budget. Those budgets have no runtime override. `capture_timeout_ms` does not
|
||||
raise them. Exceeding a budget returns no partial success.
|
||||
Oversized selected captures return an input error identifying `capture_path`
|
||||
and the byte budget; owned reply/output failures remain output or provider errors.
|
||||
HAR, SAZ and qlog run in an owned
|
||||
192 MiB Node old-generation heap with one V8 worker. Mitmproxy decoding applies
|
||||
Linux limits of 768 MiB address space, 30 seconds CPU and 96 MiB file output;
|
||||
both commands have a 30-second wall deadline and independently supervised
|
||||
both commands use `capture_timeout_ms` (default 30000) and independently supervised
|
||||
process cleanup. Owned decoder diagnostics retain at most 1 MiB across stdout
|
||||
and stderr; the collector drops overflow as it arrives, reports the limit
|
||||
failure and verifies cleanup. Heap limits alone do not establish aggregate RSS limits.
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ The result contains directly registered listener types/flags, callback coordinat
|
||||
|
||||
## Bounds and verification
|
||||
|
||||
Each protocol message has a 64 MiB budget; script/context metadata and request metadata each have an 8 MiB retained budget. Complete retained source text has a 32 MiB budget. Oversized or malformed evidence returns no partial successful result. Each command and the complete inspection/source-join phase have a 20-second deadline; cleanup has a separate 5-second command deadline plus transport closure. The selected observation duration must fit the host timer's positive 32-bit millisecond range. REA does not control the externally owned browser's CPU or memory.
|
||||
Each protocol message has a 64 MiB budget; script/context metadata and request metadata each have an 8 MiB retained budget. Complete retained source text has a 32 MiB budget. Oversized or malformed evidence returns no partial successful result. Each command and the complete inspection/source-join phase use `command_timeout_ms` (default 20000). Cleanup uses a separate 5000 ms grace; `command_timeout_ms` does not raise or shorten it, and this release has no runtime override for that grace. Transport closure follows the grace. The selected observation duration must fit the host timer's positive 32-bit millisecond range. REA does not control the externally owned browser's CPU or memory.
|
||||
|
||||
`npm run verify:browser:runtime` uses caller-supplied `REA_BROWSER_EXECUTABLE` and the native `ps` command on Linux/macOS against an owned synthetic fixture, through real public CLI and stdio MCP. It checks exact Unicode/CRLF text/digests, same-URL identities, same-document navigation, inert declared names, executed and zero-count branches, function-only unknowns on repeated observation, listener selection, initiator association, actual arming and preservation of the external page. A separate fixture page closes during an armed MCP window to verify prompt termination and inline cleanup diagnostics. The optional positional entrypoint to `scripts/verify-browser-runtime.mjs` permits the same checks against an isolated installed tarball.
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ implement every current source-map extension.
|
||||
One owned Node subprocess decodes the map. Its private request directory is
|
||||
removed before success is returned. Only this child's inherited `NODE_OPTIONS`
|
||||
is removed; explicit V8 flags set a 192 MiB old-generation and 8 MiB semi-space
|
||||
budget, one V8 worker and a 20-second deadline. Reported actual V8 heap limits
|
||||
budget, one V8 worker and `decode_timeout_ms` (default 20000). The codec heap, map budget, and reply budget have no runtime override. Reported actual V8 heap limits
|
||||
are checked. These are heap limits, not an aggregate process RSS limit. When
|
||||
REA runs on Bun, the subprocess is Bun, which ignores these V8 flags: `runtime`
|
||||
records `id: "bun"`, the Bun version, its Node.js compatibility version and a
|
||||
@@ -86,7 +86,8 @@ cleanup overrides success with `cleanup_incomplete` and the owning
|
||||
operation/resources.
|
||||
|
||||
Complete evidence is bounded by a 4 MiB map, 32 MiB codec reply, 262144 decoded
|
||||
rows/segments and 64 nested section levels. Exceeding a budget returns an explicit
|
||||
rows/segments and 64 nested section levels. Those structural budgets and the
|
||||
192 MiB codec heap have no runtime override. Exceeding a budget returns an explicit
|
||||
failure, without partial results. The existing 32 MiB manifest and 16 MiB selected
|
||||
script boundaries also apply. No global toolchain or browser configuration changes
|
||||
are made.
|
||||
|
||||
@@ -238,7 +238,9 @@ export const providerCatalog = (sources) => {
|
||||
identity: sources.pwntoolsProvider.PWNTOOLS_PROVIDER_IDENTITY,
|
||||
contracts:
|
||||
sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter(
|
||||
({ name }) => name !== "inspect_pe_resources",
|
||||
({ name }) =>
|
||||
name !== "inspect_pe_resources" &&
|
||||
name !== "inspect_pe_certificates",
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -248,6 +250,13 @@ export const providerCatalog = (sources) => {
|
||||
({ name }) => name === "inspect_pe_resources",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.artifactProviders.PE_CERTIFICATES_PROVIDER,
|
||||
contracts:
|
||||
sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter(
|
||||
({ name }) => name === "inspect_pe_certificates",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.captureProvider.TSHARK_PROVIDER_IDENTITY,
|
||||
contracts: sources.captureContracts.CAPTURE_TOOL_CONTRACTS,
|
||||
|
||||
@@ -47,6 +47,13 @@ export function profileMcpModuleLoading(
|
||||
/** Parse Linux procfs VmRSS text into bytes. */
|
||||
export function parseLinuxVmRssBytes(status: string): number | null;
|
||||
|
||||
/** Name a fixed MCP startup phase budget. Mirrors `fixedMcpPhaseBudgetRecovery`. */
|
||||
export function fixedMcpStartupPhaseBudgetRecovery(
|
||||
phase: string,
|
||||
value: number,
|
||||
unit: "ms" | "bytes",
|
||||
): string;
|
||||
|
||||
/** Collect unique file-backed ESM module URLs from Node diagnostics. */
|
||||
export function createEsmModuleProfileCollector(packageName?: string): {
|
||||
append(chunk: string): void;
|
||||
|
||||
@@ -35,11 +35,22 @@ export async function measureMcpStartup({
|
||||
const startedAt = performance.now();
|
||||
rss.start();
|
||||
try {
|
||||
await client.connect(transport, { timeout: policy.initializeBudgetMs });
|
||||
await withFixedPhaseBudget(
|
||||
"MCP initialize phase budget",
|
||||
policy.initializeBudgetMs,
|
||||
"ms",
|
||||
() => client.connect(transport, { timeout: policy.initializeBudgetMs }),
|
||||
);
|
||||
const initializedAt = performance.now();
|
||||
const tools = await client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
});
|
||||
const tools = await withFixedPhaseBudget(
|
||||
"MCP tools/list phase budget",
|
||||
policy.toolsListBudgetMs,
|
||||
"ms",
|
||||
() =>
|
||||
client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
}),
|
||||
);
|
||||
const listedAt = performance.now();
|
||||
rss.stop();
|
||||
const measurement = {
|
||||
@@ -52,7 +63,11 @@ export async function measureMcpStartup({
|
||||
assertStartupBudgets(measurement, policy);
|
||||
return measurement;
|
||||
} catch (cause) {
|
||||
throw new Error(`packaged MCP startup probe failed: ${stderr}`, { cause });
|
||||
const detail = cause instanceof Error ? cause.message : String(cause);
|
||||
throw new Error(
|
||||
`packaged MCP startup probe failed: ${detail}${stderr.length === 0 ? "" : `\n${stderr}`}`,
|
||||
{ cause },
|
||||
);
|
||||
} finally {
|
||||
rss.stop();
|
||||
await Promise.allSettled([client.close(), transport.close()]);
|
||||
@@ -82,12 +97,24 @@ export async function profileMcpModuleLoading({
|
||||
version: "1.0.0",
|
||||
});
|
||||
try {
|
||||
await client.connect(transport, {
|
||||
timeout: policy.doctorDeadlineMs,
|
||||
});
|
||||
await client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
});
|
||||
await withFixedPhaseBudget(
|
||||
"MCP doctor startup deadline",
|
||||
policy.doctorDeadlineMs,
|
||||
"ms",
|
||||
() =>
|
||||
client.connect(transport, {
|
||||
timeout: policy.doctorDeadlineMs,
|
||||
}),
|
||||
);
|
||||
await withFixedPhaseBudget(
|
||||
"MCP tools/list phase budget",
|
||||
policy.toolsListBudgetMs,
|
||||
"ms",
|
||||
() =>
|
||||
client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
}),
|
||||
);
|
||||
return profile.result();
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), transport.close()]);
|
||||
@@ -197,16 +224,46 @@ const createLinuxRssSampler = (pid) => {
|
||||
};
|
||||
};
|
||||
|
||||
/** Same sentence as `fixedMcpPhaseBudgetRecovery`; this script cannot import that TypeScript module. */
|
||||
export const fixedMcpStartupPhaseBudgetRecovery = (phase, value, unit) =>
|
||||
`REA's fixed ${phase} is ${String(value)} ${unit} and this release has no runtime override.`;
|
||||
|
||||
const phaseTimeout = (cause) => {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return (
|
||||
message.includes("Request timed out") ||
|
||||
message.includes("Maximum total timeout exceeded") ||
|
||||
message.includes("timed out")
|
||||
);
|
||||
};
|
||||
|
||||
const withFixedPhaseBudget = async (phase, value, unit, operation) => {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (cause) {
|
||||
if (!phaseTimeout(cause)) throw cause;
|
||||
const detail = cause instanceof Error ? cause.message : String(cause);
|
||||
throw new Error(
|
||||
`${detail} ${fixedMcpStartupPhaseBudgetRecovery(phase, value, unit)}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const assertStartupBudgets = (measurement, policy) => {
|
||||
if (measurement.process_started_to_initialized_ms > policy.initializeBudgetMs)
|
||||
throw new Error("packaged MCP exceeded the initialize regression budget");
|
||||
throw new Error(
|
||||
`packaged MCP exceeded the initialize phase budget. ${fixedMcpStartupPhaseBudgetRecovery("MCP initialize phase budget", policy.initializeBudgetMs, "ms")}`,
|
||||
);
|
||||
if (measurement.initialized_to_tools_list_ms > policy.toolsListBudgetMs)
|
||||
throw new Error("packaged MCP exceeded the tools/list regression budget");
|
||||
throw new Error(
|
||||
`packaged MCP exceeded the tools/list phase budget. ${fixedMcpStartupPhaseBudgetRecovery("MCP tools/list phase budget", policy.toolsListBudgetMs, "ms")}`,
|
||||
);
|
||||
if (
|
||||
measurement.process_started_to_tools_list_ms > policy.firstCatalogBudgetMs
|
||||
)
|
||||
throw new Error(
|
||||
"packaged MCP exceeded the first-catalog regression budget",
|
||||
`packaged MCP exceeded the first-catalog phase budget. ${fixedMcpStartupPhaseBudgetRecovery("MCP first-catalog phase budget", policy.firstCatalogBudgetMs, "ms")}`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -784,6 +784,33 @@ it("projects package details from dumpsys package output", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Package Manager prints an absent installer as the text "null"; the
|
||||
// normalized result must report the absence, not a package named "null"
|
||||
// (morluto/rea#3943).
|
||||
it("reports a null installer declaration as an absent installer", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
"dumpsys:package": `Packages:
|
||||
Package [com.example] (abcdef):
|
||||
versionName=1.2.3
|
||||
installerPackageName=null
|
||||
requested permissions:
|
||||
android.permission.INTERNET
|
||||
`,
|
||||
}),
|
||||
);
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "inspect_adb_package",
|
||||
input: { serial: "emulator-5554", package: "com.example" },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
version_name: "1.2.3",
|
||||
installer_package_name: null,
|
||||
requested_permissions_count: 1,
|
||||
coverage: "complete",
|
||||
});
|
||||
});
|
||||
|
||||
// PackageInfo.getLongVersionCode() combines versionCodeMajor and versionCode
|
||||
// into a signed 64-bit long, which JSON numbers cannot hold exactly.
|
||||
it.each(["9007199254740993", "-4294967296"])(
|
||||
|
||||
@@ -7,6 +7,52 @@ import {
|
||||
parseWindowFocusOutput,
|
||||
} from "./AdbShellOutput.js";
|
||||
|
||||
const packageDump = (installerLine: string | undefined): string =>
|
||||
[
|
||||
"Packages:",
|
||||
" Package [com.example] (abcdef):",
|
||||
" versionName=1.2.3",
|
||||
...(installerLine === undefined ? [] : [` ${installerLine}`]),
|
||||
" pkgFlags=[ HAS_CODE ]",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
describe("parseDumpsysPackageOutput", () => {
|
||||
it("keeps a reported installer package name", () => {
|
||||
expect(
|
||||
parseDumpsysPackageOutput(
|
||||
packageDump("installerPackageName=com.android.vending"),
|
||||
).installerPackageName,
|
||||
).toBe("com.android.vending");
|
||||
});
|
||||
|
||||
// Package Manager prints an absent String field as the text "null"; a
|
||||
// missing or empty declaration means the same absence (morluto/rea#3943).
|
||||
it.each([
|
||||
["the literal null sentinel", "installerPackageName=null"],
|
||||
["an empty value", "installerPackageName="],
|
||||
["a missing line", undefined],
|
||||
])("reports an absent installer as null for %s", (_case, line) => {
|
||||
expect(parseDumpsysPackageOutput(packageDump(line))).toMatchObject({
|
||||
versionName: "1.2.3",
|
||||
installerPackageName: null,
|
||||
});
|
||||
});
|
||||
|
||||
// A manifest may declare the literal string "null" as its version name and
|
||||
// dumpsys prints it the same way as an absent one, so the text is kept.
|
||||
it("keeps a version name printed as the text null", () => {
|
||||
expect(
|
||||
parseDumpsysPackageOutput(
|
||||
packageDump("installerPackageName=null").replace(
|
||||
"versionName=1.2.3",
|
||||
"versionName=null",
|
||||
),
|
||||
).versionName,
|
||||
).toBe("null");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWindowFocusOutput display contexts", () => {
|
||||
it.each([false, true])(
|
||||
"retains both display owners independent of block order: %s",
|
||||
|
||||
@@ -275,6 +275,17 @@ export interface AdbPackageDetails {
|
||||
|
||||
const DETAIL_LINE = /^\s+([A-Za-z][A-Za-z0-9_]*)=(.*)$/u;
|
||||
|
||||
/**
|
||||
* Package Manager prints its `String` fields by concatenation, so an absent
|
||||
* `installerPackageName` appears as the literal text `null`
|
||||
* (morluto/rea#3943). No installer can be named "null", so an empty value or
|
||||
* that sentinel is the absence of the fact and becomes the nullable result's
|
||||
* null. `versionName` keeps the printed text: a manifest may declare the
|
||||
* string "null" on purpose, and the parser cannot tell the two apart.
|
||||
*/
|
||||
const absentInstallerOrName = (value: string): string | null =>
|
||||
value === "" || value === "null" ? null : value;
|
||||
|
||||
/**
|
||||
* Project `dumpsys package PACKAGE` output. Only the listed fields are
|
||||
* extracted; the input is version-dependent and everything else is ignored
|
||||
@@ -340,7 +351,7 @@ export const parseDumpsysPackageOutput = (
|
||||
key === "installerPackageName" &&
|
||||
details.installerPackageName === null
|
||||
)
|
||||
details.installerPackageName = value === "" ? null : value;
|
||||
details.installerPackageName = absentInstallerOrName(value);
|
||||
else if (key === "userId" && details.userId === null) {
|
||||
const parsed = /^(\d+)/u.exec(value);
|
||||
if (parsed !== null) details.userId = Number(parsed[1]!);
|
||||
|
||||
@@ -138,6 +138,9 @@ const browserParameters = (
|
||||
: "target_id" in input && observedOrigin !== undefined
|
||||
? [observedOrigin]
|
||||
: [],
|
||||
...("command_timeout_ms" in input && input.command_timeout_ms !== undefined
|
||||
? { command_timeout_ms: input.command_timeout_ms }
|
||||
: {}),
|
||||
};
|
||||
if (!("target_id" in input)) return scope;
|
||||
return {
|
||||
@@ -171,6 +174,11 @@ const browserParameters = (
|
||||
...(input.total_analysis_timeout_ms === undefined
|
||||
? {}
|
||||
: { total_analysis_timeout_ms: input.total_analysis_timeout_ms }),
|
||||
...(input.source_map_fetch_timeout_ms === undefined
|
||||
? {}
|
||||
: {
|
||||
source_map_fetch_timeout_ms: input.source_map_fetch_timeout_ms,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -499,6 +499,8 @@ const workflowAvailabilityFor = ({
|
||||
};
|
||||
if (name === "inspect_pe_resources")
|
||||
return { reason: "available", remediation: null };
|
||||
if (name === "inspect_pe_certificates")
|
||||
return { reason: "available", remediation: null };
|
||||
if (name === "inspect_evm_interface") {
|
||||
if (policy.evmInterfaceEnabled !== true)
|
||||
return {
|
||||
|
||||
@@ -49,6 +49,13 @@ export const PE_RESOURCES_PROVIDER = Object.freeze({
|
||||
version: "1",
|
||||
} as const);
|
||||
|
||||
/** Provider identity for portable, execution-free PE Authenticode table decoding. */
|
||||
export const PE_CERTIFICATES_PROVIDER = Object.freeze({
|
||||
id: "rea-pe-certificates",
|
||||
name: "REA PE certificate parser",
|
||||
version: "1",
|
||||
} as const);
|
||||
|
||||
/** Provider identity for deterministic managed-code comparison workflows. */
|
||||
export const MANAGED_WORKFLOW_PROVIDER = {
|
||||
id: "rea-dotnet-workflows",
|
||||
|
||||
@@ -35,7 +35,13 @@ import { MANAGED_WORKFLOW_PROVIDER } from "./InvestigationProviders.js";
|
||||
import { createProcessCaptureEvidence } from "./process/ProcessEvidence.js";
|
||||
import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js";
|
||||
|
||||
const applicationWithUnknowns = (keys: readonly string[]) => {
|
||||
const applicationWithUnknowns = (
|
||||
keys: readonly string[],
|
||||
{
|
||||
kind = "unknown",
|
||||
label = "Unclassified application boundary",
|
||||
}: { kind?: "unknown" | "package"; label?: string } = {},
|
||||
) => {
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture-source", name: "Fixture source", version: "1" },
|
||||
@@ -50,7 +56,7 @@ const applicationWithUnknowns = (keys: readonly string[]) => {
|
||||
const base = buildSyntheticJavaScriptApplicationGraph();
|
||||
const unknownNodes = keys.map((key) =>
|
||||
createJavaScriptApplicationNode({
|
||||
kind: "unknown",
|
||||
kind,
|
||||
identity: {
|
||||
strategy: "artifact-local-key",
|
||||
stability: "artifact-version",
|
||||
@@ -60,7 +66,7 @@ const applicationWithUnknowns = (keys: readonly string[]) => {
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label: "Unclassified application boundary",
|
||||
label,
|
||||
properties: {},
|
||||
evidence: artifactEvidence(
|
||||
APPLICATION_GRAPH_DIGESTS.asar,
|
||||
@@ -144,6 +150,22 @@ describe("reconstruction obligation candidates", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to a kind-based title when an application node label is blank", () => {
|
||||
const {
|
||||
unknownNodes: [node],
|
||||
generated,
|
||||
} = applicationWithUnknowns(["blank-label"], {
|
||||
kind: "package",
|
||||
label: " ",
|
||||
});
|
||||
|
||||
expect(
|
||||
generated.candidates.find(
|
||||
({ target }) => target.application_node_id === node?.node_id,
|
||||
)?.title,
|
||||
).toBe("package boundary");
|
||||
});
|
||||
|
||||
it("does not bind an unknown process image to a packaged artifact obligation", () => {
|
||||
const scenario = parseProcessScenario({
|
||||
executable: "/path/whose-launch-identity-is-unknown",
|
||||
@@ -327,3 +349,87 @@ it("keeps each semantic candidate bound to its own file and authority context",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to a kind-based title when a semantic node label is blank", () => {
|
||||
const contexts = new JavaScriptSemanticEvidenceContextRegistry();
|
||||
const base = javascriptApplicationAnalysisResultSchema.parse(
|
||||
JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.normalized_result,
|
||||
);
|
||||
const source = proofEvidence("blank-label-file");
|
||||
const module = createJavaScriptSemanticGraphNode(
|
||||
{
|
||||
kind: "module",
|
||||
identity: {
|
||||
artifact_sha256: base.root_artifact_sha256,
|
||||
module_path: "app.js",
|
||||
source_range: null,
|
||||
role_key: "module",
|
||||
},
|
||||
function_node_id: null,
|
||||
application_node_ids: [],
|
||||
label: "app.js",
|
||||
properties: {},
|
||||
evidence: artifactEvidence(base.root_artifact_sha256, "app.js"),
|
||||
},
|
||||
contexts,
|
||||
);
|
||||
const sha256 = "4".repeat(64);
|
||||
const request = createJavaScriptSemanticGraphNode(
|
||||
{
|
||||
kind: "request",
|
||||
identity: {
|
||||
artifact_sha256: sha256,
|
||||
module_path: "file.js",
|
||||
source_range: {
|
||||
start: { line: 1, column: 0 },
|
||||
end: { line: 1, column: 8 },
|
||||
},
|
||||
role_key: "request-1",
|
||||
},
|
||||
function_node_id: null,
|
||||
application_node_ids: [],
|
||||
label: " ",
|
||||
properties: {},
|
||||
evidence: {
|
||||
...artifactEvidence(sha256, "file.js", "ast-static-analysis"),
|
||||
evidence_ids: [source.evidence_id],
|
||||
},
|
||||
},
|
||||
contexts,
|
||||
);
|
||||
const { graph_id: _graphId, ...semantic } = base.semantic_graph;
|
||||
const semanticGraph = createJavaScriptSemanticGraph({
|
||||
...semantic,
|
||||
root_node_ids: [module.node_id],
|
||||
nodes: [module, request],
|
||||
evidence_contexts: contexts.contexts,
|
||||
});
|
||||
const example = JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE;
|
||||
const application = createEvidence(
|
||||
{
|
||||
path: base.input_path,
|
||||
sha256: base.root_artifact_sha256,
|
||||
format: base.format,
|
||||
},
|
||||
example.provider,
|
||||
{
|
||||
predicateType: example.predicate_type,
|
||||
operation: example.operation,
|
||||
parameters: example.parameters,
|
||||
result: jsonValueSchema.parse({ ...base, semantic_graph: semanticGraph }),
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
evidenceLinks: [source.evidence_id],
|
||||
},
|
||||
);
|
||||
const generated = deriveReconstructionObligationCandidates(
|
||||
createEvidenceBundle([source, application]),
|
||||
[],
|
||||
);
|
||||
|
||||
expect(
|
||||
generated.candidates.find(
|
||||
({ target }) => target.semantic_node_id === request.node_id,
|
||||
)?.title,
|
||||
).toBe("request semantic boundary");
|
||||
});
|
||||
@@ -114,7 +114,7 @@ const deriveApplicationCandidates = (
|
||||
candidates,
|
||||
generatedCandidate({
|
||||
key: `application:${node.node_id}:${candidatePolicy.family}`,
|
||||
title: node.observations[0]?.label ?? `${node.kind} boundary`,
|
||||
title: node.observations[0]?.label?.trim() || `${node.kind} boundary`,
|
||||
policy: candidatePolicy,
|
||||
artifactSha256: source.rootArtifactSha256,
|
||||
applicationNodeId: node.node_id,
|
||||
@@ -143,7 +143,7 @@ const deriveApplicationCandidates = (
|
||||
candidates,
|
||||
generatedCandidate({
|
||||
key: `semantic:${node.node_id}:${candidatePolicy.family}`,
|
||||
title: node.label ?? `${node.kind} semantic boundary`,
|
||||
title: node.label?.trim() || `${node.kind} semantic boundary`,
|
||||
policy: candidatePolicy,
|
||||
artifactSha256: node.identity.artifact_sha256,
|
||||
applicationNodeId: node.application_node_ids[0] ?? null,
|
||||
|
||||
@@ -43,6 +43,30 @@ it.each(["REDACTED", "[", "]", "/capture.har"])(
|
||||
},
|
||||
);
|
||||
|
||||
it("records an explicit capture deadline and omits the default", async () => {
|
||||
const seen: Array<number | undefined> = [];
|
||||
const service = new WebNetworkCaptureService({
|
||||
inspect: (input) => {
|
||||
seen.push(input.capture_timeout_ms);
|
||||
return Promise.resolve(ok(fixture()));
|
||||
},
|
||||
});
|
||||
const explicit = await service.inspect({
|
||||
capture_path: "/capture.har",
|
||||
format: "har",
|
||||
capture_timeout_ms: 250,
|
||||
});
|
||||
if (!explicit.ok) throw explicit.error;
|
||||
expect(explicit.value.parameters.capture_timeout_ms).toBe(250);
|
||||
const omitted = await service.inspect({
|
||||
capture_path: "/capture.har",
|
||||
format: "har",
|
||||
});
|
||||
if (!omitted.ok) throw omitted.error;
|
||||
expect(omitted.value.parameters).not.toHaveProperty("capture_timeout_ms");
|
||||
expect(seen).toEqual([250, undefined]);
|
||||
});
|
||||
|
||||
it("keeps observed artifact identity when the entire sensitive path is excluded", async () => {
|
||||
const result = await new WebNetworkCaptureService({
|
||||
inspect: () => Promise.resolve(ok(fixture())),
|
||||
|
||||
@@ -250,6 +250,9 @@ export class WebNetworkCaptureService {
|
||||
},
|
||||
}),
|
||||
sensitive_value_count: input.sensitive_values.length,
|
||||
...(input.capture_timeout_ms === undefined
|
||||
? {}
|
||||
: { capture_timeout_ms: input.capture_timeout_ms }),
|
||||
});
|
||||
return ok(
|
||||
createEvidence(
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface WebSourceMapPort {
|
||||
readonly path: string;
|
||||
readonly url: string;
|
||||
readonly position: WebSourceLocationInput["generated_position"];
|
||||
readonly decodeTimeoutMs?: number;
|
||||
},
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebSourceMapReport, AnalysisError>>;
|
||||
|
||||
@@ -11,12 +11,55 @@ import {
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { webSourceLocationResultSchema } from "../domain/webSourceLocation.js";
|
||||
import {
|
||||
DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS,
|
||||
webSourceLocationResultSchema,
|
||||
} from "../domain/webSourceLocation.js";
|
||||
import { WebSourceLocationService } from "./WebSourceLocationService.js";
|
||||
import type { WebSourceMapPort } from "./WebSourceLocationPorts.js";
|
||||
|
||||
const service = (data = webSourceLocationFixture(), port = decoder) =>
|
||||
new WebSourceLocationService({ load: () => Promise.resolve(ok(data)) }, port);
|
||||
const expectCodecDeadline = (
|
||||
evidence: {
|
||||
readonly limitations: readonly string[];
|
||||
readonly normalized_result: unknown;
|
||||
},
|
||||
timeoutMs: number,
|
||||
): void => {
|
||||
const phrase = `${String(timeoutMs)} ms decode_timeout_ms deadline`;
|
||||
const result = webSourceLocationResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(
|
||||
evidence.limitations.filter((text) => text.includes(phrase)),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
result.limitations.filter((text) => text.includes(phrase)),
|
||||
).toHaveLength(1);
|
||||
};
|
||||
it("passes an explicit decode deadline and omits the default from Evidence", async () => {
|
||||
const seen: Array<number | undefined> = [];
|
||||
const port: WebSourceMapPort = {
|
||||
trace: (input) => {
|
||||
seen.push(input.decodeTimeoutMs);
|
||||
return decoder.trace(input);
|
||||
},
|
||||
};
|
||||
const explicit = await service(undefined, port).trace({
|
||||
...args,
|
||||
decode_timeout_ms: 250,
|
||||
});
|
||||
if (!explicit.ok) throw explicit.error;
|
||||
expect(explicit.value.parameters.decode_timeout_ms).toBe(250);
|
||||
expectCodecDeadline(explicit.value, 250);
|
||||
const omitted = await service(undefined, port).trace(args);
|
||||
if (!omitted.ok) throw omitted.error;
|
||||
expect(omitted.value.parameters).not.toHaveProperty("decode_timeout_ms");
|
||||
expectCodecDeadline(omitted.value, DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS);
|
||||
expect(seen).toEqual([250, undefined]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...args, manifest_path: "relative.json" },
|
||||
{ ...args, script_index: -1 },
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
webArtifactFileSchema,
|
||||
} from "../domain/webScriptArtifacts.js";
|
||||
import {
|
||||
DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS,
|
||||
WEB_SOURCE_MAP_LIMITS,
|
||||
webSourceLocationInputSchema,
|
||||
webSourceLocationResultSchema,
|
||||
@@ -52,17 +53,19 @@ const LIMITATIONS = [
|
||||
"Lookup returns all segments at the greatest generated position not greater than the selected point, including earlier lines and generated-only segments. A mapped point does not establish an executed range or UI causality.",
|
||||
"Regular maps and inline indexed sections are supported. External section URLs are unsupported; unknown extensions are retained in raw map text and are not interpreted.",
|
||||
];
|
||||
const CODEC_BUDGET =
|
||||
"Complete evidence has a 4 MiB map, 32 MiB reply, 262144 decoded rows/segments and 64-level section budget. The owned codec has a 20-second deadline";
|
||||
const codecBudget = (timeoutMs: number): string =>
|
||||
`Complete evidence has a 4 MiB map, 32 MiB reply, 262144 decoded rows/segments and 64-level section budget. The owned codec has a ${String(timeoutMs)} ms decode_timeout_ms deadline`;
|
||||
/** Bun accepts but ignores the codec's V8 heap flags; Windows enforces a commit limit instead. */
|
||||
const codecBudgetLimitation = (
|
||||
runtime: WebSourceMapReport["runtime"],
|
||||
timeoutMs: number,
|
||||
): string => {
|
||||
const budget = codecBudget(timeoutMs);
|
||||
if (runtime.id === "node-v8")
|
||||
return `${CODEC_BUDGET}, ${String(WEB_SOURCE_MAP_LIMITS.codecHeapMiB)} MiB old-generation heap and one V8 worker; cleanup may extend the deadline. V8 heap limits do not establish aggregate RSS limits.`;
|
||||
return `${budget}, ${String(WEB_SOURCE_MAP_LIMITS.codecHeapMiB)} MiB old-generation heap and one V8 worker; cleanup may extend the deadline. V8 heap limits do not establish aggregate RSS limits.`;
|
||||
return runtime.process_memory_limit_bytes === null
|
||||
? `${CODEC_BUDGET}; cleanup may extend the deadline. The codec ran on Bun, which does not enforce REA's V8 heap flags, so no codec heap limit is established.`
|
||||
: `${CODEC_BUDGET}; cleanup may extend the deadline. The codec ran on Bun, which does not enforce REA's V8 heap flags; a Windows Job Object limited its committed memory to ${String(runtime.process_memory_limit_bytes)} bytes, which is not a heap or RSS limit.`;
|
||||
? `${budget}; cleanup may extend the deadline. The codec ran on Bun, which does not enforce REA's V8 heap flags, so no codec heap limit is established.`
|
||||
: `${budget}; cleanup may extend the deadline. The codec ran on Bun, which does not enforce REA's V8 heap flags; a Windows Job Object limited its committed memory to ${String(runtime.process_memory_limit_bytes)} bytes, which is not a heap or RSS limit.`;
|
||||
};
|
||||
|
||||
/** Join verified captured bytes with an explicitly selected source-map point. */
|
||||
@@ -125,15 +128,20 @@ export class WebSourceLocationService {
|
||||
path: map.file.path,
|
||||
url: map.url,
|
||||
position: input.generated_position,
|
||||
...(input.decode_timeout_ms === undefined
|
||||
? {}
|
||||
: { decodeTimeoutMs: input.decode_timeout_ms }),
|
||||
},
|
||||
options,
|
||||
);
|
||||
if (!traced.ok) return traced;
|
||||
const report = verifyReport(input, map.file.sha256, traced.value);
|
||||
if (!report.ok) return report;
|
||||
const decodeTimeoutMs =
|
||||
input.decode_timeout_ms ?? DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS;
|
||||
const limitations = [
|
||||
...LIMITATIONS,
|
||||
codecBudgetLimitation(report.value.runtime),
|
||||
codecBudgetLimitation(report.value.runtime, decodeTimeoutMs),
|
||||
];
|
||||
const normalized = webSourceLocationResultSchema.safeParse({
|
||||
...report.value,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type {
|
||||
InspectPeCertificatesInput,
|
||||
PeCertificates,
|
||||
} from "../../domain/native/peCertificates.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
|
||||
/** Stable read and PE certificate-table decoding, independent of a disassembler session. */
|
||||
export interface PeCertificatesPort {
|
||||
readonly identity: ProviderIdentity;
|
||||
close?(): Promise<void>;
|
||||
inspect(
|
||||
input: InspectPeCertificatesInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<PeCertificates, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { PeCertificatesService } from "./PeCertificatesService.js";
|
||||
import { PE_CERTIFICATES_PROVIDER } from "../InvestigationProviders.js";
|
||||
import type { PeCertificates } from "../../domain/native/peCertificates.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const identity = PE_CERTIFICATES_PROVIDER;
|
||||
const report: PeCertificates = {
|
||||
artifact: {
|
||||
path: "/artifacts/app.exe",
|
||||
sha256: "a".repeat(64),
|
||||
bytes: 512,
|
||||
},
|
||||
format: "pe32",
|
||||
machine: 0x14c,
|
||||
table: null,
|
||||
entries: [],
|
||||
coverage: { status: "complete", examined_entries: 0, certificates: 0 },
|
||||
limitations: ["IMAGE_DIRECTORY_ENTRY_SECURITY is absent."],
|
||||
};
|
||||
|
||||
it("rejects a relative path and extra arguments before reading", async () => {
|
||||
let calls = 0;
|
||||
const service = new PeCertificatesService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
calls += 1;
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
});
|
||||
const relative = await service.inspect({ path: "app.exe" });
|
||||
const extra = await service.inspect({
|
||||
path: report.artifact.path,
|
||||
extra: true,
|
||||
});
|
||||
expect(relative.ok).toBe(false);
|
||||
expect(extra.ok).toBe(false);
|
||||
if (!relative.ok) expect(relative.error).toBeInstanceOf(AnalysisInputError);
|
||||
if (!extra.ok) expect(extra.error).toBeInstanceOf(AnalysisInputError);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("records the selected PE and an absent certificate table", async () => {
|
||||
const result = await new PeCertificatesService({
|
||||
identity,
|
||||
inspect: () => Promise.resolve(ok(report)),
|
||||
}).inspect({ path: report.artifact.path });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value).toMatchObject({
|
||||
confidence: "observed",
|
||||
subject: { format: "pe", architecture: "x86" },
|
||||
normalized_result: report,
|
||||
});
|
||||
});
|
||||
|
||||
it("discards an observation if the caller cancels while the provider completes", async () => {
|
||||
const controller = new AbortController();
|
||||
const service = new PeCertificatesService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
controller.abort();
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
});
|
||||
const result = await service.inspect(
|
||||
{ path: report.artifact.path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { PeCertificatesPort } from "./PeCertificatesPort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
inspectPeCertificatesInputSchema,
|
||||
peCertificatesSchema,
|
||||
type PeCertificates,
|
||||
} from "../../domain/native/peCertificates.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import {
|
||||
decodePeMachine,
|
||||
peOptionalHeaderMagic,
|
||||
type PeArchitecture,
|
||||
} from "../../domain/peMachine.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "inspect_pe_certificates";
|
||||
|
||||
/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */
|
||||
export class PeCertificatesService {
|
||||
constructor(readonly provider: PeCertificatesPort) {}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.provider.close?.() ?? Promise.resolve();
|
||||
}
|
||||
|
||||
async inspect(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const input = inspectPeCertificatesInputSchema.safeParse(rawInput);
|
||||
if (!input.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, {
|
||||
cause: input.error,
|
||||
}),
|
||||
);
|
||||
if (!isAbsolute(input.data.path))
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute filesystem path on this host.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const result = await this.provider.inspect(input.data, options);
|
||||
if (!result.ok) return result;
|
||||
const report = peCertificatesSchema.safeParse(result.value);
|
||||
if (
|
||||
!report.success ||
|
||||
report.data.artifact.path !== input.data.path ||
|
||||
!validReport(
|
||||
report.data,
|
||||
input.data.max_entries,
|
||||
input.data.max_file_bytes,
|
||||
)
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"PE certificate provider returned malformed output or changed artifact identity.",
|
||||
),
|
||||
);
|
||||
const value = report.data;
|
||||
const architecture = subjectArchitecture(value);
|
||||
const locations = [
|
||||
{ kind: "artifact-path" as const, path: value.artifact.path },
|
||||
...(value.table === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
kind: "file-offset-range" as const,
|
||||
start: value.table.location.offset,
|
||||
end: value.table.location.offset + value.table.location.bytes,
|
||||
},
|
||||
]),
|
||||
];
|
||||
return completeProviderEvidence(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
format: "pe",
|
||||
sha256: value.artifact.sha256,
|
||||
...(architecture === undefined ? {} : { architecture }),
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: value,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
locations,
|
||||
},
|
||||
),
|
||||
options?.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const subjectArchitecture = (
|
||||
report: PeCertificates,
|
||||
): PeArchitecture | undefined => {
|
||||
const machine = decodePeMachine(report.machine);
|
||||
if (machine === undefined || machine.nativeOsOverride) return undefined;
|
||||
const magic = report.format === "pe32-plus" ? 0x20b : 0x10b;
|
||||
return magic === peOptionalHeaderMagic(machine.architecture)
|
||||
? machine.architecture
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const validReport = (
|
||||
report: PeCertificates,
|
||||
maxEntries: number,
|
||||
maxBytes: number,
|
||||
): boolean => {
|
||||
const inside = ({ offset, bytes }: { offset: number; bytes: number }) =>
|
||||
offset <= report.artifact.bytes - bytes;
|
||||
if (
|
||||
report.artifact.bytes > maxBytes ||
|
||||
report.coverage.examined_entries > maxEntries ||
|
||||
report.coverage.certificates !==
|
||||
report.entries.reduce(
|
||||
(count, entry) => count + (entry.pkcs7?.certificates.length ?? 0),
|
||||
0,
|
||||
) ||
|
||||
report.entries.length > maxEntries
|
||||
)
|
||||
return false;
|
||||
if (report.table !== null && !inside(report.table.location)) return false;
|
||||
return report.entries.every(
|
||||
(entry) => inside(entry.location) && inside(entry.payload.location),
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "./environmentBundle.js";
|
||||
import type { EnvironmentSource } from "./environmentSource.js";
|
||||
import { prepareServedState } from "./environmentState.js";
|
||||
import { environmentToolAnswer } from "./environmentToolAnswer.js";
|
||||
import { environmentToolAnswer } from "./environmentStateAnswer.js";
|
||||
import { EnvironmentStateStore } from "./environmentStateStore.js";
|
||||
|
||||
const fixture = environmentServeFixture();
|
||||
@@ -218,9 +218,7 @@ describe("planEnvironmentBundle", () => {
|
||||
expect(readme).not.toContain("agent-env run grafana-example");
|
||||
expect(readme).toContain("## What the environment serves");
|
||||
expect(readme).not.toContain("What replay means");
|
||||
expect(readme).toContain(
|
||||
"write tools are served only where the capture's later reads confirmed\ntheir effect",
|
||||
);
|
||||
expect(readme).toContain("write tools are not served yet");
|
||||
expect(readme).toContain(
|
||||
"`rea environment-serve --replay-only` serves pure replay",
|
||||
);
|
||||
@@ -392,7 +390,7 @@ describe("planEnvironmentBundle without a state", () => {
|
||||
const reads = servedSource.manifest.tools.filter(
|
||||
({ method }) => method === "GET",
|
||||
);
|
||||
const tools = Array.from({ length: 310 }, (_, index) => {
|
||||
const tools = Array.from({ length: 350 }, (_, index) => {
|
||||
const tool = reads[index % reads.length]!;
|
||||
return index < reads.length
|
||||
? tool
|
||||
@@ -418,7 +416,7 @@ describe("planEnvironmentBundle without a state", () => {
|
||||
served: false,
|
||||
code: "resource_constraint",
|
||||
});
|
||||
expect(planned.value.toolCount).toBe(310);
|
||||
expect(planned.value.toolCount).toBe(350);
|
||||
expect(planned.value.check).toEqual({
|
||||
tool: "get_annotations",
|
||||
recordOrdinal: 25,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { servedCatalog, type PreparedState } from "./environmentState.js";
|
||||
import {
|
||||
environmentToolAnswer,
|
||||
type EnvironmentToolAnswer,
|
||||
} from "./environmentToolAnswer.js";
|
||||
} from "./environmentStateAnswer.js";
|
||||
import { EnvironmentStateStore } from "./environmentStateStore.js";
|
||||
import type { EnvironmentToolDefinition } from "./environmentTools.js";
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface EnvironmentServedCheck {
|
||||
export interface EnvironmentBundlePlan {
|
||||
readonly name: string;
|
||||
readonly packageSource: EnvironmentPackageSource;
|
||||
/** Tools the bundle serves: every manifest tool for replay; from a state, the read tools and the served write tools. */
|
||||
/** Tools the bundle serves: every manifest tool for replay, the read tools from a state. */
|
||||
readonly toolCount: number;
|
||||
readonly check: EnvironmentServedCheck | null;
|
||||
/** Whether the bundle serves from the state, as `environment-serve` reports it. */
|
||||
@@ -282,8 +282,7 @@ 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 served only where the capture's later reads confirmed
|
||||
their effect. When the state cannot be derived, or
|
||||
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,
|
||||
@@ -333,7 +332,7 @@ export const planEnvironmentBundle = (
|
||||
// The catalog and answers the bundle's server will have, so the verifier and README predict them.
|
||||
const catalog = servedCatalog(
|
||||
source,
|
||||
state,
|
||||
state !== undefined,
|
||||
name,
|
||||
ENVIRONMENT_EXPORT_OPERATION,
|
||||
);
|
||||
|
||||
@@ -1,445 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
analysisViewCaptureEvidence,
|
||||
environmentManifestCaptureFixture,
|
||||
timedCaptureEvidence,
|
||||
type TimedCaptureRow,
|
||||
} from "../../../tests/fixtures/analysisView.js";
|
||||
import {
|
||||
environmentServeSource,
|
||||
environmentStateServeFixture,
|
||||
} from "../../../tests/fixtures/environmentServe.js";
|
||||
import {
|
||||
BOOK_NOT_FOUND,
|
||||
BOOK_ROWS,
|
||||
FIRST_BOOK,
|
||||
SECOND_BOOK,
|
||||
book,
|
||||
renamedBook,
|
||||
} from "../../../tests/fixtures/environmentStateWrites.js";
|
||||
import {
|
||||
scenarioCaptureEvidence,
|
||||
scenarioCaptureFixture,
|
||||
scenarioHeaderOnlyCaptureEvidence,
|
||||
} from "../../../tests/fixtures/browserScenarioCapture.js";
|
||||
import { compareEnvironment } from "./environmentCompare.js";
|
||||
import { environmentArguments, IdMap } from "./environmentCompareIds.js";
|
||||
import { matchProbeRequest } from "./environmentCompareMatch.js";
|
||||
import { isCut } from "./environmentCompareProbe.js";
|
||||
import { parseCaptureEvidence } from "./environmentSource.js";
|
||||
|
||||
const fixture = environmentStateServeFixture(timedCaptureEvidence(BOOK_ROWS));
|
||||
const source = environmentServeSource(fixture);
|
||||
|
||||
/** The books application re-seeded: the same books under other ids and creation times. */
|
||||
const LIVE_FIRST = book(101, "First Book", "2026-10-20T09:00:00Z");
|
||||
const LIVE_SECOND = book(102, "Second Book", "2026-10-20T10:00:00Z");
|
||||
const LIVE_FOURTH = book(103, "Fourth Book", "2026-10-20T11:00:00Z");
|
||||
const LIVE_RENAMED = renamedBook(LIVE_FIRST, "Renamed Book", 5);
|
||||
const ANN = { id: 1, name: "Ann" };
|
||||
const BOB = { id: 2, name: "Bob" };
|
||||
|
||||
const compare = (...probes: (readonly TimedCaptureRow[])[]) => {
|
||||
const report = compareEnvironment({
|
||||
source,
|
||||
captureValue: fixture.capture,
|
||||
probes: probes.map((rows, index) => ({
|
||||
label: `probe-${String(index + 1)}`,
|
||||
value: timedCaptureEvidence(rows),
|
||||
})),
|
||||
operation: "environment-compare",
|
||||
});
|
||||
if (!report.ok) throw report.error;
|
||||
return report.value;
|
||||
};
|
||||
|
||||
describe("matchProbeRequest", () => {
|
||||
it("prefers literal segments, requires format-rule parameters to fit, and maps query and body arguments", () => {
|
||||
const item = matchProbeRequest(source.manifest, {
|
||||
method: "GET",
|
||||
path: "/api/books/7",
|
||||
query: "?expand=all",
|
||||
body: undefined,
|
||||
});
|
||||
expect(item?.tool.path_template).toBe("/api/books/{numeric}");
|
||||
expect(item?.args).toEqual({ id: "7" });
|
||||
expect(item?.ignoredQuery).toEqual(["expand"]);
|
||||
const create = matchProbeRequest(source.manifest, {
|
||||
method: "POST",
|
||||
path: "/api/books",
|
||||
query: null,
|
||||
body: { name: "X" },
|
||||
});
|
||||
expect(create?.args).toEqual({ body: { name: "X" } });
|
||||
expect(
|
||||
matchProbeRequest(source.manifest, {
|
||||
method: "GET",
|
||||
path: "/api/books/x",
|
||||
query: null,
|
||||
body: undefined,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fits a format-rule parameter by its decoded value, as replay matches it", () => {
|
||||
const item = matchProbeRequest(source.manifest, {
|
||||
method: "GET",
|
||||
path: "/api/books/%31",
|
||||
query: null,
|
||||
body: undefined,
|
||||
});
|
||||
expect(item?.tool.path_template).toBe("/api/books/{numeric}");
|
||||
expect(item?.args).toEqual({ id: "1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareEnvironment", () => {
|
||||
it("agrees with a re-seeded application once seeded and created ids are mapped", () => {
|
||||
const report = compare([
|
||||
["GET", "/api/books", [LIVE_FIRST, LIVE_SECOND]],
|
||||
["GET", "/api/books/101", LIVE_FIRST],
|
||||
["POST", "/api/books", LIVE_FOURTH, 201, { name: "Fourth Book" }],
|
||||
["GET", "/api/books/103", LIVE_FOURTH],
|
||||
["PATCH", "/api/books/101", LIVE_RENAMED, 200, { name: "Renamed Book" }],
|
||||
["GET", "/api/books/101", LIVE_RENAMED],
|
||||
["DELETE", "/api/books/103", "", 204],
|
||||
["GET", "/api/books/103", BOOK_NOT_FOUND, 404],
|
||||
["GET", "/api/books", [LIVE_RENAMED, LIVE_SECOND]],
|
||||
]);
|
||||
expect(report.environment.state).toBe("served");
|
||||
expect(report.probes[0]?.results.filter((step) => !step.equal)).toEqual([]);
|
||||
expect(report.summary).toMatchObject({
|
||||
probes: 1,
|
||||
steps: 9,
|
||||
equal: 9,
|
||||
agreement: 1,
|
||||
});
|
||||
expect(report.probes[0]?.ids_mapped).toBe(3);
|
||||
expect(report.summary.normalised.ids).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resets the state per probe and categorises every kind of disagreement", () => {
|
||||
const report = compare(
|
||||
[
|
||||
["GET", "/api/books", [LIVE_FIRST, LIVE_SECOND]],
|
||||
["DELETE", "/api/books/101", { detail: "Book is referenced." }, 409],
|
||||
["GET", "/api/authors", []],
|
||||
["GET", "/api/books/555", LIVE_FIRST],
|
||||
],
|
||||
[["GET", "/api/books", [LIVE_SECOND, LIVE_FIRST, LIVE_FOURTH]]],
|
||||
);
|
||||
const [first, second] = report.probes;
|
||||
expect(first?.results.map((step) => step.categories)).toEqual([
|
||||
[],
|
||||
["status", "value"],
|
||||
["no-tool"],
|
||||
["unmapped-id", "status", "missing-field", "extra-field"],
|
||||
]);
|
||||
expect(second?.results[0]?.categories).toEqual(["length", "order"]);
|
||||
expect(second?.results[0]?.equal_ignoring_order).toBe(false);
|
||||
expect(report.summary.agreement_ignoring_order).toBe(
|
||||
report.summary.agreement,
|
||||
);
|
||||
expect(report.summary.categories).toMatchObject({
|
||||
status: 2,
|
||||
"no-tool": 1,
|
||||
"unmapped-id": 1,
|
||||
length: 1,
|
||||
order: 1,
|
||||
});
|
||||
expect(report.tools.map((tool) => tool.tool)).toContain(
|
||||
"delete_books_by_id",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the comparison translates and learns", () => {
|
||||
/** The books capture with a paged, filtered list and reports the state does not model, so other reports replay this one. */
|
||||
const extra = environmentStateServeFixture(
|
||||
timedCaptureEvidence([
|
||||
...BOOK_ROWS,
|
||||
["GET", "/api/books?limit=2&author=7", [FIRST_BOOK, SECOND_BOOK]],
|
||||
["GET", "/api/reports/1", [{ owner_id: 7 }]],
|
||||
["GET", "/api/authors", [ANN, BOB]],
|
||||
["GET", "/api/authors/1", ANN],
|
||||
["GET", "/api/authors/2", BOB],
|
||||
["GET", "/api/ping", ""],
|
||||
["POST", "/api/books/search", [FIRST_BOOK], 200, { q: "First" }],
|
||||
["POST", "/api/tags/search", [{ id: 1 }], 200, { q: "first" }],
|
||||
["GET", "/api/motd", '""'],
|
||||
]),
|
||||
);
|
||||
const extraSource = environmentServeSource(extra);
|
||||
const compareExtra = (rows: readonly TimedCaptureRow[]) => {
|
||||
const report = compareEnvironment({
|
||||
source: extraSource,
|
||||
captureValue: extra.capture,
|
||||
probes: [{ label: "probe", value: timedCaptureEvidence(rows) }],
|
||||
operation: "environment-compare",
|
||||
});
|
||||
if (!report.ok) throw report.error;
|
||||
return report.value.probes[0]!;
|
||||
};
|
||||
|
||||
it("translates a path id by the collection it names when two collections reuse a live id", () => {
|
||||
// Live book 101 is the environment's book 1, while live author 101 is its author 2.
|
||||
const probe = compareExtra([
|
||||
["GET", "/api/books", [LIVE_FIRST, LIVE_SECOND]],
|
||||
[
|
||||
"GET",
|
||||
"/api/authors",
|
||||
[
|
||||
{ ...ANN, id: 102 },
|
||||
{ ...BOB, id: 101 },
|
||||
],
|
||||
],
|
||||
["GET", "/api/authors/101", { ...BOB, id: 101 }],
|
||||
]);
|
||||
expect(probe.results.map((step) => step.equal)).toEqual([true, true, true]);
|
||||
});
|
||||
|
||||
it("refuses arguments the served tool's input schema refuses, as the server would", () => {
|
||||
// A search replays its recording whatever it is sent, but the server refuses a body that is not an object.
|
||||
const [step] = compareExtra([
|
||||
["POST", "/api/books/search", [FIRST_BOOK], 200, ["First"]],
|
||||
]).results;
|
||||
expect(step?.categories).toEqual(["refused"]);
|
||||
expect(step?.differences[0]?.env).toContain("Invalid arguments");
|
||||
});
|
||||
|
||||
it("agrees on an empty response however each side spells it, and keeps a JSON empty string", () => {
|
||||
expect(compareExtra([["GET", "/api/ping", ""]]).results[0]?.equal).toBe(
|
||||
true,
|
||||
);
|
||||
expect(compareExtra([["GET", "/api/motd", '""']]).results[0]?.equal).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("learns ids from a replayed recording only of the same call, body included", () => {
|
||||
expect(
|
||||
compareExtra([
|
||||
["POST", "/api/tags/search", [{ id: 102 }], 200, { q: "second" }],
|
||||
]).ids_mapped,
|
||||
).toBe(0);
|
||||
expect(
|
||||
compareExtra([
|
||||
["POST", "/api/tags/search", [{ id: 102 }], 200, { q: "first" }],
|
||||
]).ids_mapped,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("compares a large binary body only by status, as it does large text", () => {
|
||||
expect(isCut({ kind: "base64", encoded: "AAAA", whole: false })).toBe(true);
|
||||
expect(isCut({ kind: "base64", encoded: "AAAA", whole: true })).toBe(false);
|
||||
expect(isCut({ kind: "cut", text: "a" })).toBe(true);
|
||||
});
|
||||
|
||||
it("translates mapped ids in query values except those that page a list", () => {
|
||||
const call = matchProbeRequest(extraSource.manifest, {
|
||||
method: "GET",
|
||||
path: "/api/books",
|
||||
query: "?limit=101&author=101",
|
||||
body: undefined,
|
||||
});
|
||||
const map = new IdMap();
|
||||
map.learn(101, 1);
|
||||
expect(call?.pagingArguments).toEqual(["limit"]);
|
||||
expect(call && environmentArguments(call, map)).toEqual({
|
||||
limit: "101",
|
||||
author: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("learns no ids from another call's recording replayed in place of an answer", () => {
|
||||
const report = compareEnvironment({
|
||||
source: extraSource,
|
||||
captureValue: extra.capture,
|
||||
probes: [
|
||||
{
|
||||
label: "reports",
|
||||
value: timedCaptureEvidence([
|
||||
["GET", "/api/reports/2", [{ owner_id: 107 }]],
|
||||
]),
|
||||
},
|
||||
],
|
||||
operation: "environment-compare",
|
||||
});
|
||||
if (!report.ok) throw report.error;
|
||||
const [probe] = report.value.probes;
|
||||
expect(probe?.ids_mapped).toBe(0);
|
||||
expect(probe?.results[0]?.differences).toEqual([
|
||||
{ category: "value", pointer: "/0/owner_id", live: "107", env: "7" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser probes", () => {
|
||||
it("keeps a browser probe's request bodies, which are its call arguments", () => {
|
||||
const post = (purpose?: "manifest") => {
|
||||
const parsed = parseCaptureEvidence(
|
||||
"environment-compare",
|
||||
scenarioCaptureEvidence(),
|
||||
purpose,
|
||||
);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
return parsed.value.records.find((record) =>
|
||||
JSON.stringify(record.reported).includes('"method":"POST"'),
|
||||
)?.reported;
|
||||
};
|
||||
expect(JSON.stringify(post("manifest"))).toContain("postData");
|
||||
expect(JSON.stringify(post())).not.toContain("postData");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a probe did not observe", () => {
|
||||
const at = (second: number): string =>
|
||||
new Date(
|
||||
Date.parse("2026-10-20T09:00:00.000Z") + second * 1000,
|
||||
).toISOString();
|
||||
const exchange = (
|
||||
second: number,
|
||||
method: string,
|
||||
url: string | null,
|
||||
status: number,
|
||||
) => ({
|
||||
startedDateTime: at(second),
|
||||
request: { method, url },
|
||||
response: { status, content: { mimeType: "application/json" } },
|
||||
});
|
||||
const run = (value: unknown) =>
|
||||
compareEnvironment({
|
||||
source,
|
||||
captureValue: fixture.capture,
|
||||
probes: [{ label: "probe-1", value }],
|
||||
operation: "environment-compare",
|
||||
});
|
||||
|
||||
it("compares an unrecorded body by status only and lists the records it could not compare", () => {
|
||||
const report = run(
|
||||
analysisViewCaptureEvidence(
|
||||
environmentManifestCaptureFixture([
|
||||
exchange(0, "GET", "http://app.example/api/books/1", 200),
|
||||
exchange(1, "GET", null, 200),
|
||||
{ startedDateTime: at(2) },
|
||||
exchange(3, "GET", "http://app.example/api/books", 0),
|
||||
// A 204 carries no body, so its missing body is known to be empty.
|
||||
exchange(4, "DELETE", "http://app.example/api/books/2", 204),
|
||||
]),
|
||||
),
|
||||
);
|
||||
if (!report.ok) throw report.error;
|
||||
expect(report.value.probes[0]?.unobserved).toEqual([
|
||||
{ ordinal: 1, reason: "url-redacted" },
|
||||
{ ordinal: 2, reason: "request-unavailable" },
|
||||
{ ordinal: 3, reason: "no-response" },
|
||||
]);
|
||||
expect(report.value.summary).toMatchObject({
|
||||
steps: 2,
|
||||
equal: 2,
|
||||
unobserved: 3,
|
||||
});
|
||||
expect(report.value.summary.normalised.unobserved_bodies).toBe(1);
|
||||
});
|
||||
|
||||
it("makes a request whose answer was lost, so later reads see its effect", () => {
|
||||
const report = run(
|
||||
analysisViewCaptureEvidence(
|
||||
environmentManifestCaptureFixture([
|
||||
exchange(0, "DELETE", "http://app.example/api/books/2", 0),
|
||||
{
|
||||
...exchange(1, "GET", "http://app.example/api/books/2", 404),
|
||||
response: {
|
||||
status: 404,
|
||||
content: {
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(BOOK_NOT_FOUND),
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
if (!report.ok) throw report.error;
|
||||
expect(report.value.probes[0]?.unobserved).toEqual([
|
||||
{ ordinal: 0, reason: "no-response" },
|
||||
]);
|
||||
expect(report.value.summary).toMatchObject({ steps: 1, equal: 1 });
|
||||
});
|
||||
|
||||
it("makes no request to another origin, so it cannot change the environment", () => {
|
||||
const answered = (second: number, url: string, body: JsonValue) => ({
|
||||
...exchange(second, "GET", url, 200),
|
||||
response: {
|
||||
status: 200,
|
||||
content: { mimeType: "application/json", text: JSON.stringify(body) },
|
||||
},
|
||||
});
|
||||
const report = run(
|
||||
analysisViewCaptureEvidence(
|
||||
environmentManifestCaptureFixture([
|
||||
// The probe also called another server's API, at a path the application's tools match.
|
||||
exchange(0, "DELETE", "http://cdn.example/api/books/2", 204),
|
||||
answered(1, "http://app.example/api/books/1", FIRST_BOOK),
|
||||
answered(2, "http://app.example/api/books/2", SECOND_BOOK),
|
||||
]),
|
||||
),
|
||||
);
|
||||
if (!report.ok) throw report.error;
|
||||
expect(report.value.probes[0]?.unobserved).toEqual([
|
||||
{ ordinal: 0, reason: "other-origin" },
|
||||
]);
|
||||
expect(report.value.summary).toMatchObject({ steps: 2, equal: 2 });
|
||||
});
|
||||
|
||||
it("keeps the recorded order when a record has no start time, as state extraction does", () => {
|
||||
const { startedDateTime: _undated, ...read } = {
|
||||
...exchange(1, "GET", "http://app.example/api/books/2", 404),
|
||||
response: {
|
||||
status: 404,
|
||||
content: {
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(BOOK_NOT_FOUND),
|
||||
},
|
||||
},
|
||||
};
|
||||
const report = run(
|
||||
analysisViewCaptureEvidence(
|
||||
environmentManifestCaptureFixture([
|
||||
exchange(0, "DELETE", "http://app.example/api/books/2", 204),
|
||||
read,
|
||||
]),
|
||||
),
|
||||
);
|
||||
if (!report.ok) throw report.error;
|
||||
expect(report.value.summary).toMatchObject({ steps: 2, equal: 2 });
|
||||
});
|
||||
|
||||
it("refuses a browser probe that makes writes without recorded request bodies", () => {
|
||||
const report = run(
|
||||
scenarioCaptureEvidence(
|
||||
scenarioCaptureFixture(undefined, {
|
||||
request_body: false,
|
||||
response_body: true,
|
||||
header_values: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(report.ok).toBe(false);
|
||||
if (report.ok) return;
|
||||
expect(JSON.stringify(report.error)).toContain(
|
||||
"needs capture.network.request_body selected",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a browser probe captured without response bodies", () => {
|
||||
const report = run(scenarioHeaderOnlyCaptureEvidence());
|
||||
expect(report.ok).toBe(false);
|
||||
if (report.ok) return;
|
||||
expect(JSON.stringify(report.error)).toContain(
|
||||
"probe-1: a browser probe needs capture.network.response_body selected",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,487 +0,0 @@
|
||||
import { fromJsonSchema } from "@modelcontextprotocol/server";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
compareAnswers,
|
||||
DIFFERENCE_CATEGORIES,
|
||||
STEP_DIFFERENCE_LIMIT,
|
||||
type Answer,
|
||||
type Difference,
|
||||
type DifferenceCategory,
|
||||
type Normalised,
|
||||
} from "./environmentCompareDiff.js";
|
||||
import {
|
||||
environmentArguments,
|
||||
IdMap,
|
||||
learnIds,
|
||||
translatePath,
|
||||
} from "./environmentCompareIds.js";
|
||||
import {
|
||||
collectionScope,
|
||||
matchProbeRequest,
|
||||
parameterScopes,
|
||||
type ProbeToolCall,
|
||||
} from "./environmentCompareMatch.js";
|
||||
import {
|
||||
probeRequests,
|
||||
requestBody,
|
||||
sendsBodies,
|
||||
type ProbeStep,
|
||||
type UnobservedRecord,
|
||||
} from "./environmentCompareProbe.js";
|
||||
import {
|
||||
inputError,
|
||||
parseCaptureEvidence,
|
||||
replayedBody,
|
||||
type EnvironmentManifestTool,
|
||||
type EnvironmentSource,
|
||||
} from "./environmentSource.js";
|
||||
import {
|
||||
selectsRecordedLine,
|
||||
type ReplayArguments,
|
||||
type ReplayResult,
|
||||
} from "./environmentReplay.js";
|
||||
import { prepareServedState, servedCatalog } from "./environmentState.js";
|
||||
import type { StateAnswer } from "./environmentStateAnswer.js";
|
||||
import { EnvironmentStateStore } from "./environmentStateStore.js";
|
||||
import {
|
||||
environmentToolAnswer,
|
||||
type EnvironmentToolAnswer,
|
||||
} from "./environmentToolAnswer.js";
|
||||
import {
|
||||
environmentToolArguments,
|
||||
type EnvironmentToolDefinition,
|
||||
} from "./environmentTools.js";
|
||||
import { same } from "../../domain/analysisView/environmentStateRules.js";
|
||||
|
||||
/** One probe capture file: its label for the report and its parsed JSON. */
|
||||
export interface CompareProbeInput {
|
||||
readonly label: string;
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
||||
/** One probe request, how the environment answered it, and how that differs from the application. */
|
||||
export interface CompareStep {
|
||||
readonly ordinal: number;
|
||||
readonly method: string;
|
||||
readonly path: string;
|
||||
readonly tool: string | null;
|
||||
readonly live_status: number | null;
|
||||
readonly env_status: number | null;
|
||||
readonly equal: boolean;
|
||||
/** Equal but for item order, which an application that returns sets in no fixed order cannot reproduce either. */
|
||||
readonly equal_ignoring_order: boolean;
|
||||
readonly categories: DifferenceCategory[];
|
||||
readonly differences: Difference[];
|
||||
readonly ignored_query: string[];
|
||||
}
|
||||
|
||||
export interface CompareProbeReport {
|
||||
readonly label: string;
|
||||
readonly evidence_id: string;
|
||||
readonly steps: number;
|
||||
readonly equal: number;
|
||||
readonly ids_mapped: number;
|
||||
readonly id_conflicts: number;
|
||||
readonly normalised: Normalised;
|
||||
readonly results: CompareStep[];
|
||||
/** Records left uncompared, in probe order, each with its reason. */
|
||||
readonly unobserved: UnobservedRecord[];
|
||||
}
|
||||
|
||||
export interface CompareReport {
|
||||
readonly status: "complete";
|
||||
readonly environment: {
|
||||
readonly name: string;
|
||||
readonly manifest_evidence_id: string;
|
||||
readonly evidence_id: string;
|
||||
readonly state: "served" | "replay-only";
|
||||
};
|
||||
readonly summary: {
|
||||
readonly probes: number;
|
||||
readonly steps: number;
|
||||
readonly equal: number;
|
||||
readonly agreement: number | null;
|
||||
readonly agreement_ignoring_order: number | null;
|
||||
/** Probe records left uncompared, outside the steps agreement counts. */
|
||||
readonly unobserved: number;
|
||||
readonly categories: Record<DifferenceCategory, number>;
|
||||
readonly normalised: Normalised;
|
||||
};
|
||||
readonly tools: { tool: string; steps: number; equal: number }[];
|
||||
readonly probes: CompareProbeReport[];
|
||||
}
|
||||
|
||||
const NORMALISED_KEYS = [
|
||||
"ids",
|
||||
"clocks",
|
||||
"generated",
|
||||
"large_bodies",
|
||||
"unobserved_bodies",
|
||||
] as const;
|
||||
const emptyNormalised = (): Normalised => ({
|
||||
ids: 0,
|
||||
clocks: 0,
|
||||
generated: 0,
|
||||
large_bodies: 0,
|
||||
unobserved_bodies: 0,
|
||||
});
|
||||
|
||||
const isSuccess = (status: number | null): boolean =>
|
||||
status !== null && status >= 200 && status < 300;
|
||||
|
||||
/** Where a call's answer holds its own entities, and the collection they belong to: a read view's entity or list pointer, a write's response pointer, else the root. */
|
||||
const entityOf = (
|
||||
run: ProbeRun,
|
||||
tool: EnvironmentManifestTool,
|
||||
): { readonly scope: string; readonly at: string } => {
|
||||
const view = run.store?.state.views.get(tool.name);
|
||||
const plan = run.store?.state.writes.get(tool.name);
|
||||
const at =
|
||||
view?.shape === "item" || view?.shape === "list"
|
||||
? view.pointer
|
||||
: (plan?.response?.pointer ?? plan?.observation?.pointer ?? "");
|
||||
return { scope: collectionScope(tool), at };
|
||||
};
|
||||
|
||||
const fromState = (answer: ReplayResult): boolean =>
|
||||
(answer as Partial<StateAnswer>).state?.source === "state";
|
||||
|
||||
/** The environment's body as the application's is read: a replayed recording of empty text, unlike a JSON empty string, is no body. */
|
||||
const envBody = (run: ProbeRun, answer: ReplayResult): JsonValue => {
|
||||
if (answer.body !== "" || fromState(answer)) return answer.body;
|
||||
const exchange = run.source.exchanges.get(answer.replay.record_ordinal);
|
||||
return exchange !== undefined && replayedBody(exchange).kind === "json"
|
||||
? answer.body
|
||||
: null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the environment answered this call itself: from its state, or from a recording of exactly this call, every
|
||||
* recorded query value supplied and the body it sent, rather than replaying another call's recording.
|
||||
*/
|
||||
const answersCall = (
|
||||
run: ProbeRun,
|
||||
call: ProbeToolCall,
|
||||
args: Readonly<Record<string, JsonValue>>,
|
||||
answer: ReplayResult,
|
||||
): boolean => {
|
||||
if (fromState(answer)) return true;
|
||||
const exchange = run.source.exchanges.get(answer.replay.record_ordinal);
|
||||
if (
|
||||
!answer.replay.exact_match ||
|
||||
exchange === undefined ||
|
||||
!selectsRecordedLine(call.tool, exchange, args)
|
||||
)
|
||||
return false;
|
||||
const body = environmentToolArguments(call.tool).body;
|
||||
const sent = body === undefined ? undefined : args[body];
|
||||
return (
|
||||
sent === undefined || same(run.recordedBodies.get(exchange.ordinal), sent)
|
||||
);
|
||||
};
|
||||
|
||||
/** A served tool's input check: the first problem with a call's arguments, or undefined. */
|
||||
type InputCheck = (args: ReplayArguments) => string | undefined;
|
||||
|
||||
/** The input schema check the MCP server applies before a served tool answers, so a call it refuses is not compared as answered. */
|
||||
const inputCheck = (definition: EnvironmentToolDefinition): InputCheck => {
|
||||
const schema = fromJsonSchema<ReplayArguments>(definition.inputSchema)[
|
||||
"~standard"
|
||||
];
|
||||
return (args) => {
|
||||
const checked = schema.validate(args);
|
||||
return checked instanceof Promise
|
||||
? undefined
|
||||
: checked.issues?.[0]?.message;
|
||||
};
|
||||
};
|
||||
|
||||
interface ProbeRun {
|
||||
readonly source: EnvironmentSource;
|
||||
/** The request body of each capture record, by ordinal, that a replayed answer's call is compared with. */
|
||||
readonly recordedBodies: ReadonlyMap<number, JsonValue | undefined>;
|
||||
readonly answer: EnvironmentToolAnswer;
|
||||
readonly store: EnvironmentStateStore | undefined;
|
||||
/** The tools the environment offers, by name, with their input checks. */
|
||||
readonly served: ReadonlyMap<string, InputCheck>;
|
||||
readonly map: IdMap;
|
||||
readonly normalised: Normalised;
|
||||
}
|
||||
|
||||
const heldIds = (
|
||||
store: EnvironmentStateStore | undefined,
|
||||
): ReadonlySet<string> =>
|
||||
new Set(
|
||||
store?.snapshot().rea_state.entities.map((entity) => entity.id) ?? [],
|
||||
);
|
||||
|
||||
const stepOf = (
|
||||
base: Pick<CompareStep, "ordinal" | "method" | "path" | "live_status">,
|
||||
call: ProbeToolCall | undefined,
|
||||
outcome: { env_status: number | null; differences: Difference[] },
|
||||
): CompareStep => {
|
||||
const categories = [...new Set(outcome.differences.map((d) => d.category))];
|
||||
return {
|
||||
...base,
|
||||
tool: call?.tool.name ?? null,
|
||||
env_status: outcome.env_status,
|
||||
equal: outcome.differences.length === 0,
|
||||
equal_ignoring_order: outcome.differences.every(
|
||||
(difference) => difference.category === "order",
|
||||
),
|
||||
categories,
|
||||
differences: outcome.differences.slice(0, STEP_DIFFERENCE_LIMIT),
|
||||
ignored_query: [...(call?.ignoredQuery ?? [])],
|
||||
};
|
||||
};
|
||||
|
||||
/** Answer one probe request as the environment would and compare it with the application's answer; one without an answer is only made. */
|
||||
const compareStep = (
|
||||
run: ProbeRun,
|
||||
manifest: EnvironmentSource["manifest"],
|
||||
probe: ProbeStep,
|
||||
): CompareStep | undefined => {
|
||||
const { request, live } = probe;
|
||||
const base = {
|
||||
ordinal: probe.ordinal,
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
live_status: live?.status ?? null,
|
||||
};
|
||||
const report = (
|
||||
call: ProbeToolCall | undefined,
|
||||
outcome: { env_status: number | null; differences: Difference[] },
|
||||
): CompareStep | undefined =>
|
||||
live === undefined ? undefined : stepOf(base, call, outcome);
|
||||
// Matched in the environment's terms, so a tool whose path holds a seeded id literally still matches; each path
|
||||
// parameter translates by the collection it names, where the request already fits a tool.
|
||||
const fitted = matchProbeRequest(manifest, request);
|
||||
const scopes =
|
||||
fitted === undefined ? undefined : parameterScopes(fitted.tool);
|
||||
const call = matchProbeRequest(manifest, {
|
||||
...request,
|
||||
path: translatePath(request.path, run.map, (index) => scopes?.get(index)),
|
||||
});
|
||||
if (call === undefined)
|
||||
return report(call, {
|
||||
env_status: null,
|
||||
differences: [{ category: "no-tool", pointer: "" }],
|
||||
});
|
||||
const check = run.served.get(call.tool.name);
|
||||
if (check === undefined)
|
||||
return report(call, {
|
||||
env_status: null,
|
||||
differences: [{ category: "not-served", pointer: "" }],
|
||||
});
|
||||
const held = heldIds(run.store);
|
||||
const unknownPath = call.pathArguments.some((name) => {
|
||||
const value = call.args[name];
|
||||
return (
|
||||
typeof value === "string" && !run.map.hasEnv(value) && !held.has(value)
|
||||
);
|
||||
});
|
||||
const refusedWith = (message: string): CompareStep | undefined =>
|
||||
report(call, {
|
||||
env_status: null,
|
||||
differences: [
|
||||
{ category: "refused", pointer: "", env: message.slice(0, 200) },
|
||||
],
|
||||
});
|
||||
const args = environmentArguments(call, run.map);
|
||||
const problem = check(args);
|
||||
if (problem !== undefined)
|
||||
return refusedWith(`Invalid arguments: ${problem}`);
|
||||
const answered = run.answer(call.tool, args);
|
||||
if (!answered.ok) return refusedWith(answered.error.message);
|
||||
if (live === undefined) return undefined;
|
||||
const env: Answer = {
|
||||
status: answered.value.status,
|
||||
body: envBody(run, answered.value),
|
||||
};
|
||||
const entity = entityOf(run, call.tool);
|
||||
// Another call's recording holds other entities, whose ids would map wrongly and, the first pairing winning, for good.
|
||||
if (answersCall(run, call, args, answered.value))
|
||||
learnIds(live.body, env.body, run.map, {
|
||||
heldIds: heldIds(run.store),
|
||||
scope: entity.scope,
|
||||
entityAt: entity.at,
|
||||
});
|
||||
const compared = compareAnswers(live, env, run.map, entity);
|
||||
for (const key of NORMALISED_KEYS)
|
||||
run.normalised[key] += compared.normalised[key];
|
||||
const differences = [...compared.differences];
|
||||
if (unknownPath && isSuccess(live.status) && !isSuccess(env.status))
|
||||
differences.unshift({ category: "unmapped-id", pointer: "" });
|
||||
return stepOf(base, call, { env_status: env.status, differences });
|
||||
};
|
||||
|
||||
const probeReport = (
|
||||
probe: { readonly label: string; readonly evidenceId: string },
|
||||
run: ProbeRun,
|
||||
results: CompareStep[],
|
||||
unobserved: UnobservedRecord[],
|
||||
): CompareProbeReport => ({
|
||||
label: probe.label,
|
||||
evidence_id: probe.evidenceId,
|
||||
steps: results.length,
|
||||
equal: results.filter((step) => step.equal).length,
|
||||
ids_mapped: run.map.size,
|
||||
id_conflicts: run.map.conflicts,
|
||||
normalised: run.normalised,
|
||||
results,
|
||||
unobserved,
|
||||
});
|
||||
|
||||
const summaryOf = (
|
||||
probes: readonly CompareProbeReport[],
|
||||
): CompareReport["summary"] => {
|
||||
const steps = probes.flatMap((probe) => probe.results);
|
||||
const categories = Object.fromEntries(
|
||||
DIFFERENCE_CATEGORIES.map((category) => [category, 0]),
|
||||
) as Record<DifferenceCategory, number>;
|
||||
for (const step of steps)
|
||||
for (const category of step.categories) categories[category] += 1;
|
||||
const equal = steps.filter((step) => step.equal).length;
|
||||
const ratio = (count: number): number | null =>
|
||||
steps.length === 0
|
||||
? null
|
||||
: Math.round((count / steps.length) * 1000) / 1000;
|
||||
const normalised = emptyNormalised();
|
||||
for (const probe of probes)
|
||||
for (const key of NORMALISED_KEYS) normalised[key] += probe.normalised[key];
|
||||
return {
|
||||
probes: probes.length,
|
||||
steps: steps.length,
|
||||
equal,
|
||||
agreement: ratio(equal),
|
||||
agreement_ignoring_order: ratio(
|
||||
steps.filter((step) => step.equal_ignoring_order).length,
|
||||
),
|
||||
unobserved: probes.reduce((sum, probe) => sum + probe.unobserved.length, 0),
|
||||
categories,
|
||||
normalised,
|
||||
};
|
||||
};
|
||||
|
||||
const toolsOf = (
|
||||
probes: readonly CompareProbeReport[],
|
||||
): CompareReport["tools"] => {
|
||||
const tools = new Map<
|
||||
string,
|
||||
{ tool: string; steps: number; equal: number }
|
||||
>();
|
||||
for (const step of probes.flatMap((probe) => probe.results)) {
|
||||
if (step.tool === null) continue;
|
||||
const entry = tools.get(step.tool) ?? {
|
||||
tool: step.tool,
|
||||
steps: 0,
|
||||
equal: 0,
|
||||
};
|
||||
entry.steps += 1;
|
||||
if (step.equal) entry.equal += 1;
|
||||
tools.set(step.tool, entry);
|
||||
}
|
||||
return [...tools.values()].sort(
|
||||
(left, right) =>
|
||||
left.equal / left.steps - right.equal / right.steps ||
|
||||
(left.tool < right.tool ? -1 : 1),
|
||||
);
|
||||
};
|
||||
|
||||
/** Compare an environment with probe captures of the application: each probe from a reset state, every request answered and compared. */
|
||||
export const compareEnvironment = (input: {
|
||||
readonly source: EnvironmentSource;
|
||||
readonly captureValue: unknown;
|
||||
readonly probes: readonly CompareProbeInput[];
|
||||
readonly operation: string;
|
||||
}): Result<CompareReport, AnalysisError> => {
|
||||
const { source, operation } = input;
|
||||
const state = prepareServedState(source, input.captureValue, {
|
||||
operation,
|
||||
}).state;
|
||||
const catalog = servedCatalog(source, state, source.manifest.name, operation);
|
||||
if (!catalog.ok) return catalog;
|
||||
const served = new Map(
|
||||
catalog.value.tools.map((definition) => [
|
||||
definition.name,
|
||||
inputCheck(definition),
|
||||
]),
|
||||
);
|
||||
const recorded = parseCaptureEvidence(
|
||||
operation,
|
||||
input.captureValue,
|
||||
"manifest",
|
||||
);
|
||||
const recordedBodies = new Map(
|
||||
recorded.ok
|
||||
? recorded.value.records.map((record) => [
|
||||
record.ordinal,
|
||||
requestBody(record),
|
||||
])
|
||||
: [],
|
||||
);
|
||||
const probes: CompareProbeReport[] = [];
|
||||
for (const [index, probe] of input.probes.entries()) {
|
||||
// A probe's request bodies are its call arguments, which a replay projection of a browser capture drops.
|
||||
const capture = parseCaptureEvidence(operation, probe.value, "manifest");
|
||||
// Without response bodies only statuses would compare, and without request bodies writes would be made empty.
|
||||
const problem = !capture.ok
|
||||
? capture.error.message
|
||||
: !capture.value.responseBodies
|
||||
? "a browser probe needs capture.network.response_body selected, or only statuses would compare."
|
||||
: !capture.value.requestBodies && sendsBodies(capture.value.records)
|
||||
? "a browser probe that makes POST, PUT or PATCH calls needs capture.network.request_body selected, or those calls would be made without their bodies."
|
||||
: undefined;
|
||||
if (!capture.ok || problem !== undefined)
|
||||
return err(
|
||||
inputError(
|
||||
operation,
|
||||
[
|
||||
{
|
||||
path: ["probe", index],
|
||||
reason: "invalid_value",
|
||||
message: `${probe.label}: ${problem}`,
|
||||
},
|
||||
],
|
||||
"A probe file cannot be compared.",
|
||||
),
|
||||
);
|
||||
const store =
|
||||
state === undefined ? undefined : new EnvironmentStateStore(state);
|
||||
const run: ProbeRun = {
|
||||
source,
|
||||
recordedBodies,
|
||||
answer: environmentToolAnswer(source, store),
|
||||
store,
|
||||
served,
|
||||
map: new IdMap(),
|
||||
normalised: emptyNormalised(),
|
||||
};
|
||||
const { steps, unobserved } = probeRequests(capture.value.records);
|
||||
const results = steps.flatMap(
|
||||
(step) => compareStep(run, source.manifest, step) ?? [],
|
||||
);
|
||||
probes.push(
|
||||
probeReport(
|
||||
{ label: probe.label, evidenceId: capture.value.evidenceId },
|
||||
run,
|
||||
results,
|
||||
unobserved,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ok({
|
||||
status: "complete",
|
||||
environment: {
|
||||
name: source.manifest.name,
|
||||
manifest_evidence_id: source.manifestEvidenceId,
|
||||
evidence_id: source.evidenceId,
|
||||
state: state === undefined ? "replay-only" : "served",
|
||||
},
|
||||
summary: summaryOf(probes),
|
||||
tools: toolsOf(probes),
|
||||
probes,
|
||||
});
|
||||
};
|
||||
@@ -1,202 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { compareAnswers } from "./environmentCompareDiff.js";
|
||||
import { IdMap, learnIds } from "./environmentCompareIds.js";
|
||||
|
||||
const compare = (
|
||||
live: unknown,
|
||||
env: unknown,
|
||||
map = new IdMap(),
|
||||
status: [number, number] = [200, 200],
|
||||
) =>
|
||||
compareAnswers(
|
||||
{ status: status[0], body: live as never },
|
||||
{ status: status[1], body: env as never },
|
||||
map,
|
||||
);
|
||||
|
||||
describe("compareAnswers", () => {
|
||||
it("agrees on mapped ids, clocks of one format and generated values, and counts each", () => {
|
||||
const map = new IdMap();
|
||||
map.learn(101, 1);
|
||||
const result = compare(
|
||||
{
|
||||
id: 101,
|
||||
created_at: "2026-10-20T10:00:00Z",
|
||||
token: "3f2a9c8d7e",
|
||||
uuid: "6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
created_at: "2026-10-17T18:00:03Z",
|
||||
token: "9b8e7d6c5a",
|
||||
uuid: "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d",
|
||||
},
|
||||
map,
|
||||
);
|
||||
expect(result.differences).toEqual([]);
|
||||
expect(result.normalised).toEqual({
|
||||
ids: 1,
|
||||
clocks: 1,
|
||||
generated: 2,
|
||||
large_bodies: 0,
|
||||
unobserved_bodies: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("categorises status, missing and extra fields, values and clock formats", () => {
|
||||
const result = compare(
|
||||
{ name: "A", gone: 1, at: "2026-10-20T10:00:00.123+00:00" },
|
||||
{ name: "B", added: true, at: "2026-10-20T10:00:00Z" },
|
||||
new IdMap(),
|
||||
[409, 204],
|
||||
);
|
||||
expect(result.differences.map((d) => [d.category, d.pointer])).toEqual([
|
||||
["status", ""],
|
||||
["value", "/name"],
|
||||
["missing-field", "/gone"],
|
||||
["clock-format", "/at"],
|
||||
["extra-field", "/added"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports array length and order and compares paired items", () => {
|
||||
const result = compare(
|
||||
[
|
||||
{ name: "B", n: 2 },
|
||||
{ name: "A", n: 1 },
|
||||
{ name: "C", n: 3 },
|
||||
],
|
||||
[
|
||||
{ name: "A", n: 1 },
|
||||
{ name: "B", n: 5 },
|
||||
],
|
||||
);
|
||||
expect(result.differences.map((d) => [d.category, d.pointer])).toEqual([
|
||||
["length", ""],
|
||||
["order", ""],
|
||||
["value", "/0/n"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats an epoch number as a clock only under a time-named field", () => {
|
||||
expect(
|
||||
compare({ updated: 1760000000 }, { updated: 1760000999 }).differences,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
compare({ size: 1760000000 }, { size: 1760000999 }).differences.map(
|
||||
(d) => d.category,
|
||||
),
|
||||
).toEqual(["value"]);
|
||||
});
|
||||
|
||||
it("compares an object keyed by mapped ids under the environment's keys", () => {
|
||||
const map = new IdMap();
|
||||
map.learn("liveTable", "envTable");
|
||||
expect(
|
||||
compare(
|
||||
{ tables: { liveTable: { name: "T" } } },
|
||||
{ tables: { envTable: { name: "T" } } },
|
||||
map,
|
||||
).differences,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a reordered set of scalars as order alone and reads epoch array items as clocks", () => {
|
||||
expect(
|
||||
compare(
|
||||
{ access: ["a", "b", "c"] },
|
||||
{ access: ["c", "a", "b"] },
|
||||
).differences.map((d) => [d.category, d.pointer]),
|
||||
).toEqual([["order", "/access"]]);
|
||||
expect(
|
||||
compare({ stats: [[1792450800, 3]] }, { stats: [[1792285200, 3]] })
|
||||
.differences,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("compares a live body over the replay limit by status only and counts it", () => {
|
||||
const result = compareAnswers(
|
||||
{ status: 200, body: '{"data":[', cut: true },
|
||||
{ status: 200, body: null },
|
||||
new IdMap(),
|
||||
);
|
||||
expect(result.differences).toEqual([]);
|
||||
expect(result.normalised.large_bodies).toBe(1);
|
||||
});
|
||||
|
||||
it("compares a live body the probe did not record by status only and counts it", () => {
|
||||
const unrecorded = { status: 200, body: null, unobserved: true };
|
||||
const result = compareAnswers(
|
||||
unrecorded,
|
||||
{ status: 200, body: { id: 1 } },
|
||||
new IdMap(),
|
||||
);
|
||||
expect(result.differences).toEqual([]);
|
||||
expect(result.normalised.unobserved_bodies).toBe(1);
|
||||
expect(
|
||||
compareAnswers(unrecorded, { status: 404, body: null }, new IdMap())
|
||||
.differences,
|
||||
).toEqual([{ category: "status", pointer: "", live: "200", env: "404" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scalar sets of mapped ids", () => {
|
||||
it("differ only in order once live ids are mapped", () => {
|
||||
const map = new IdMap();
|
||||
map.learn(101, 1);
|
||||
map.learn(102, 2);
|
||||
const compared = compareAnswers(
|
||||
{ status: 200, body: { ids: [101, 102] } },
|
||||
{ status: 200, body: { ids: [2, 1] } },
|
||||
map,
|
||||
);
|
||||
expect(compared.differences).toEqual([
|
||||
{ category: "order", pointer: "/ids" },
|
||||
]);
|
||||
expect(compared.normalised.ids).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ids two collections reuse", () => {
|
||||
it("compares an entity's own id within the call's collection and any other id by the probe's mapping", () => {
|
||||
const map = new IdMap();
|
||||
map.learn(101, 1, "/api/books");
|
||||
map.learn(101, 2, "/api/authors");
|
||||
const answer = (body: JsonValue) => ({ status: 200, body });
|
||||
expect(
|
||||
compareAnswers(answer({ id: 101 }), answer({ id: 2 }), map, {
|
||||
scope: "/api/authors",
|
||||
}).differences,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
compareAnswers(answer({ book_id: 101 }), answer({ book_id: 2 }), map)
|
||||
.differences,
|
||||
).toEqual([
|
||||
{ category: "value", pointer: "/book_id", live: "101", env: "2" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapped answers", () => {
|
||||
it("compares the identity of an entity inside an envelope within the call's collection", () => {
|
||||
const map = new IdMap();
|
||||
map.learn(101, 1, "/api/books");
|
||||
learnIds(
|
||||
{ data: [{ id: 101, name: "Bob" }] },
|
||||
{ data: [{ id: 2, name: "Bob" }] },
|
||||
map,
|
||||
{ heldIds: new Set(), scope: "/api/authors", entityAt: "/data" },
|
||||
);
|
||||
expect(map.env(101, "/api/authors")).toBe(2);
|
||||
expect(
|
||||
compareAnswers(
|
||||
{ status: 200, body: { data: { id: 101, name: "Bob" } } },
|
||||
{ status: 200, body: { data: { id: 2, name: "Bob" } } },
|
||||
map,
|
||||
{ scope: "/api/authors", at: "/data" },
|
||||
).differences,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,315 +0,0 @@
|
||||
import type { JsonObject } from "../../domain/analysisView/environmentStateRules.js";
|
||||
import {
|
||||
isUuid,
|
||||
readClock,
|
||||
type ClockFormat,
|
||||
} from "../../domain/analysisView/environmentStateWriteValues.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
IDENTITY_FIELD,
|
||||
pairElements,
|
||||
translateValue,
|
||||
type IdMap,
|
||||
} from "./environmentCompareIds.js";
|
||||
|
||||
export const DIFFERENCE_CATEGORIES = [
|
||||
"status",
|
||||
"missing-field",
|
||||
"extra-field",
|
||||
"value",
|
||||
"length",
|
||||
"order",
|
||||
"clock-format",
|
||||
"refused",
|
||||
"not-served",
|
||||
"no-tool",
|
||||
"unmapped-id",
|
||||
] as const;
|
||||
export type DifferenceCategory = (typeof DIFFERENCE_CATEGORIES)[number];
|
||||
|
||||
/** One way an environment answer differs from the live one: where, and both values cut short. */
|
||||
export interface Difference {
|
||||
readonly category: DifferenceCategory;
|
||||
readonly pointer: string;
|
||||
readonly live?: string;
|
||||
readonly env?: string;
|
||||
}
|
||||
|
||||
/** Values the comparison treated as equal although they differ: mapped ids, clocks of one format, generated values. */
|
||||
export interface Normalised {
|
||||
ids: number;
|
||||
clocks: number;
|
||||
generated: number;
|
||||
/** Live bodies over the replay limit, compared by status only. */
|
||||
large_bodies: number;
|
||||
/** Live bodies the probe did not record, compared by status only. */
|
||||
unobserved_bodies: number;
|
||||
}
|
||||
|
||||
/** At most this many differences are kept per step. */
|
||||
export const STEP_DIFFERENCE_LIMIT = 20;
|
||||
const VALUE_CHARS = 120;
|
||||
const TIME_FIELD = /(at|time|date|timestamp|created|updated|modified)$/iu;
|
||||
|
||||
const isObject = (value: JsonValue | undefined): value is JsonObject =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const shown = (value: JsonValue | undefined): string => {
|
||||
const text = value === undefined ? "<absent>" : JSON.stringify(value);
|
||||
return text.length > VALUE_CHARS ? `${text.slice(0, VALUE_CHARS)}…` : text;
|
||||
};
|
||||
|
||||
const escapePointer = (key: string): string =>
|
||||
key.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
|
||||
const formatKey = (format: ClockFormat): string =>
|
||||
format.kind === "epoch"
|
||||
? `epoch-${format.unit}`
|
||||
: `iso${format.separator}${String(format.fraction)}${format.zone === "" ? "" : format.zone === "Z" ? "Z" : "offset"}`;
|
||||
|
||||
/** The format of a clock value: an ISO-8601 string, or an epoch number under a time-named field or as an array item. */
|
||||
const clockFormat = (
|
||||
value: JsonValue,
|
||||
key: string | undefined,
|
||||
): string | undefined => {
|
||||
if (typeof value === "number" && key !== undefined && !TIME_FIELD.test(key))
|
||||
return undefined;
|
||||
const clock = readClock(value);
|
||||
return clock === undefined ? undefined : formatKey(clock.format);
|
||||
};
|
||||
|
||||
const characterClasses = (text: string): string =>
|
||||
[
|
||||
...new Set(
|
||||
text
|
||||
.replaceAll(/\d/gu, "0")
|
||||
.replaceAll(/[a-z]/gu, "a")
|
||||
.replaceAll(/[A-Z]/gu, "A"),
|
||||
),
|
||||
]
|
||||
.sort()
|
||||
.join("");
|
||||
|
||||
/** Two values each side generated on its own: both UUIDs, or one length of eight or more with the same character classes and a digit. */
|
||||
const generatedPair = (live: string, env: string): boolean =>
|
||||
(isUuid(live) && isUuid(env)) ||
|
||||
(live.length === env.length &&
|
||||
live.length >= 8 &&
|
||||
/\d/u.test(live) &&
|
||||
/\d/u.test(env) &&
|
||||
characterClasses(live) === characterClasses(env));
|
||||
|
||||
class Differ {
|
||||
readonly differences: Difference[] = [];
|
||||
readonly normalised: Normalised = {
|
||||
ids: 0,
|
||||
clocks: 0,
|
||||
generated: 0,
|
||||
large_bodies: 0,
|
||||
unobserved_bodies: 0,
|
||||
};
|
||||
|
||||
/** The answer's own entities: where they sit (the object, or the array of them, at `at`) and the collection the call names, whose own mapping their identities compare by. */
|
||||
constructor(
|
||||
readonly map: IdMap,
|
||||
readonly entity: {
|
||||
readonly scope: string | undefined;
|
||||
readonly at: string;
|
||||
},
|
||||
) {}
|
||||
|
||||
add(difference: Difference): void {
|
||||
this.differences.push(difference);
|
||||
}
|
||||
|
||||
scalar(
|
||||
live: JsonValue,
|
||||
env: JsonValue,
|
||||
pointer: string,
|
||||
at: { readonly key?: string; readonly own?: boolean } = {},
|
||||
): void {
|
||||
if (live === env) return;
|
||||
const { key } = at;
|
||||
if (
|
||||
(typeof live === "string" || typeof live === "number") &&
|
||||
(typeof env === "string" || typeof env === "number")
|
||||
) {
|
||||
// An entity's own identity compares within the call's collection; any other id by the probe's mapping.
|
||||
const mapped = this.map.env(
|
||||
live,
|
||||
at.own === true && key !== undefined && IDENTITY_FIELD.test(key)
|
||||
? this.entity.scope
|
||||
: undefined,
|
||||
);
|
||||
if (mapped !== undefined && String(mapped) === String(env)) {
|
||||
this.normalised.ids += 1;
|
||||
return;
|
||||
}
|
||||
const liveClock = clockFormat(live, key);
|
||||
const envClock = clockFormat(env, key);
|
||||
if (liveClock !== undefined && envClock !== undefined) {
|
||||
if (liveClock === envClock) this.normalised.clocks += 1;
|
||||
else
|
||||
this.add({
|
||||
category: "clock-format",
|
||||
pointer,
|
||||
live: shown(live),
|
||||
env: shown(env),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof live === "string" &&
|
||||
typeof env === "string" &&
|
||||
!this.map.hasLive(live) &&
|
||||
!this.map.hasEnv(env) &&
|
||||
generatedPair(live, env)
|
||||
) {
|
||||
this.normalised.generated += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.add({
|
||||
category: "value",
|
||||
pointer,
|
||||
live: shown(live),
|
||||
env: shown(env),
|
||||
});
|
||||
}
|
||||
|
||||
array(live: JsonValue[], env: JsonValue[], pointer: string): void {
|
||||
const own = pointer === this.entity.at;
|
||||
if (this.sameItems(live, env, pointer)) return;
|
||||
const pairs = pairElements(live, env);
|
||||
if (live.length !== env.length || pairs.length !== live.length)
|
||||
this.add({
|
||||
category: "length",
|
||||
pointer,
|
||||
live: `${String(live.length)} items`,
|
||||
env: `${String(env.length)} items (${String(pairs.length)} paired)`,
|
||||
});
|
||||
const envOrder = pairs.map(([, right]) => right);
|
||||
if (
|
||||
envOrder.some((right, index) => index > 0 && right < envOrder[index - 1]!)
|
||||
)
|
||||
this.add({ category: "order", pointer });
|
||||
for (const [left, right] of pairs)
|
||||
this.value(live[left]!, env[right]!, `${pointer}/${String(left)}`, {
|
||||
own,
|
||||
});
|
||||
}
|
||||
|
||||
/** Arrays of scalars holding the same items in another order differ only in order, as sets an application returns unordered do. */
|
||||
sameItems(live: JsonValue[], env: JsonValue[], pointer: string): boolean {
|
||||
const scalar = (item: JsonValue): boolean =>
|
||||
item === null || typeof item !== "object";
|
||||
if (live.length !== env.length || !live.every(scalar) || !env.every(scalar))
|
||||
return false;
|
||||
// Live ids compare in the environment's terms, as scalar values do.
|
||||
const mapped = live.map((item) => translateValue(item, this.map));
|
||||
const key = (items: JsonValue[]): string =>
|
||||
JSON.stringify(items.map((item) => JSON.stringify(item)).sort());
|
||||
if (key(mapped) !== key(env)) return false;
|
||||
this.normalised.ids += mapped.filter(
|
||||
(item, index) => item !== live[index],
|
||||
).length;
|
||||
if (JSON.stringify(mapped) !== JSON.stringify(env))
|
||||
this.add({ category: "order", pointer });
|
||||
return true;
|
||||
}
|
||||
|
||||
object(
|
||||
live: JsonObject,
|
||||
env: JsonObject,
|
||||
pointer: string,
|
||||
own = false,
|
||||
): void {
|
||||
const self = own || pointer === this.entity.at;
|
||||
// An object keyed by ids compares each member under the environment's key.
|
||||
const envKey = (key: string): string => {
|
||||
const mapped = this.map.env(key);
|
||||
return mapped !== undefined && Object.hasOwn(env, String(mapped))
|
||||
? String(mapped)
|
||||
: key;
|
||||
};
|
||||
const matched = new Set<string>();
|
||||
for (const [key, value] of Object.entries(live)) {
|
||||
const child = `${pointer}/${escapePointer(key)}`;
|
||||
const other = envKey(key);
|
||||
if (!Object.hasOwn(env, other))
|
||||
this.add({
|
||||
category: "missing-field",
|
||||
pointer: child,
|
||||
live: shown(value),
|
||||
});
|
||||
else {
|
||||
matched.add(other);
|
||||
this.value(value, env[other]!, child, { key, own: self });
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(env))
|
||||
if (!matched.has(key))
|
||||
this.add({
|
||||
category: "extra-field",
|
||||
pointer: `${pointer}/${escapePointer(key)}`,
|
||||
env: shown(value),
|
||||
});
|
||||
}
|
||||
|
||||
/** `own` marks an item of the entity array, or with `key` a field of an own entity; a field's own children are not. */
|
||||
value(
|
||||
live: JsonValue,
|
||||
env: JsonValue,
|
||||
pointer: string,
|
||||
at: { readonly key?: string; readonly own?: boolean } = {},
|
||||
): void {
|
||||
if (Array.isArray(live) && Array.isArray(env))
|
||||
this.array(live, env, pointer);
|
||||
else if (isObject(live) && isObject(env))
|
||||
this.object(live, env, pointer, at.own === true && at.key === undefined);
|
||||
else if (
|
||||
Array.isArray(live) ||
|
||||
Array.isArray(env) ||
|
||||
isObject(live) ||
|
||||
isObject(env)
|
||||
)
|
||||
this.add({
|
||||
category: "value",
|
||||
pointer,
|
||||
live: shown(live),
|
||||
env: shown(env),
|
||||
});
|
||||
else this.scalar(live, env, pointer, at);
|
||||
}
|
||||
}
|
||||
|
||||
/** One answer: its status, and its JSON body, its text, or null without one. */
|
||||
export interface Answer {
|
||||
readonly status: number | null;
|
||||
readonly body: JsonValue;
|
||||
/** Set when the body was over the replay limit, so only a prefix is known. */
|
||||
readonly cut?: boolean;
|
||||
/** Set when the probe did not record the body, so only the status is known. */
|
||||
readonly unobserved?: boolean;
|
||||
}
|
||||
|
||||
/** Compare a live answer with the environment's after normalisation. */
|
||||
export const compareAnswers = (
|
||||
live: Answer,
|
||||
env: Answer,
|
||||
map: IdMap,
|
||||
entity: { readonly scope?: string; readonly at?: string } = {},
|
||||
): { readonly differences: Difference[]; readonly normalised: Normalised } => {
|
||||
const differ = new Differ(map, { scope: entity.scope, at: entity.at ?? "" });
|
||||
if (live.status !== env.status)
|
||||
differ.add({
|
||||
category: "status",
|
||||
pointer: "",
|
||||
live: String(live.status),
|
||||
env: String(env.status),
|
||||
});
|
||||
if (live.cut === true) differ.normalised.large_bodies += 1;
|
||||
else if (live.unobserved === true) differ.normalised.unobserved_bodies += 1;
|
||||
else differ.value(live.body, env.body, "");
|
||||
return { differences: differ.differences, normalised: differ.normalised };
|
||||
};
|
||||
@@ -1,205 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
IdMap,
|
||||
learnIds,
|
||||
pairElements,
|
||||
translatePath,
|
||||
translateValue,
|
||||
} from "./environmentCompareIds.js";
|
||||
|
||||
describe("IdMap", () => {
|
||||
it("keeps the first pairing of either side and counts conflicting ones", () => {
|
||||
const map = new IdMap();
|
||||
map.learn(101, 1);
|
||||
map.learn("101", 1);
|
||||
map.learn(101, 2);
|
||||
map.learn(102, 1);
|
||||
expect(map.env(101)).toBe(1);
|
||||
expect(map.env("101")).toBe(1);
|
||||
expect(map.hasEnv(1)).toBe(true);
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.conflicts).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores a pair of equal values", () => {
|
||||
const map = new IdMap();
|
||||
map.learn("same", "same");
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairElements", () => {
|
||||
it("pairs objects by the shared unique string field that pairs the most", () => {
|
||||
const live = [
|
||||
{ id: 102, name: "Second", kind: "book" },
|
||||
{ id: 101, name: "First", kind: "book" },
|
||||
];
|
||||
const env = [
|
||||
{ id: 1, name: "First", kind: "book" },
|
||||
{ id: 2, name: "Second", kind: "book" },
|
||||
{ id: 3, name: "Third", kind: "book" },
|
||||
];
|
||||
expect(pairElements(live, env)).toEqual([
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("pairs by position without a shared key or for scalar items", () => {
|
||||
expect(pairElements([{ n: 1 }, { n: 2 }], [{ n: 3 }])).toEqual([[0, 0]]);
|
||||
expect(pairElements(["a", "b"], ["c", "d"])).toEqual([
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("learnIds", () => {
|
||||
it("maps identifier fields of paired list items and of two single answers", () => {
|
||||
const map = new IdMap();
|
||||
learnIds(
|
||||
[
|
||||
{ id: 102, name: "Second", owner_id: "u-9" },
|
||||
{ id: 101, name: "First", owner_id: "u-9" },
|
||||
],
|
||||
[
|
||||
{ id: 1, name: "First", owner_id: "u-1" },
|
||||
{ id: 2, name: "Second", owner_id: "u-1" },
|
||||
],
|
||||
map,
|
||||
{ heldIds: new Set() },
|
||||
);
|
||||
expect([map.env(101), map.env(102), map.env("u-9")]).toEqual([1, 2, "u-1"]);
|
||||
learnIds(
|
||||
{ data: { uid: "x9", ref: "r-1" } },
|
||||
{ data: { uid: "e3", ref: "r-2" } },
|
||||
map,
|
||||
{ heldIds: new Set(["r-2"]) },
|
||||
);
|
||||
expect(map.env("r-1")).toBe("r-2");
|
||||
expect(map.env("x9")).toBeUndefined();
|
||||
learnIds({ workers: 0 }, { workers: 1 }, map, { heldIds: new Set(["1"]) });
|
||||
expect(map.env(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("learns nothing from items that share a key but no value, or differ in count without one", () => {
|
||||
const map = new IdMap();
|
||||
learnIds([{ code: "kelp", id: 1 }], [{ code: "menu", id: 2 }], map, {
|
||||
heldIds: new Set(["menu"]),
|
||||
});
|
||||
learnIds(
|
||||
[{ n: 1, id: 3 }],
|
||||
[
|
||||
{ n: 1, id: 4 },
|
||||
{ n: 2, id: 5 },
|
||||
],
|
||||
map,
|
||||
{ heldIds: new Set() },
|
||||
);
|
||||
expect(map.size).toBe(0);
|
||||
learnIds([{ n: 1, id: 3 }], [{ n: 1, id: 4 }], map, { heldIds: new Set() });
|
||||
expect(map.env(3)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("learnIds over objects keyed by ids", () => {
|
||||
it("pairs entries by a natural key in their values and maps the keys", () => {
|
||||
const map = new IdMap();
|
||||
learnIds(
|
||||
{
|
||||
data: {
|
||||
liveA: { id: "liveA", name: "Lamp Oil" },
|
||||
liveB: { id: "liveB", name: "Quarry" },
|
||||
},
|
||||
},
|
||||
{
|
||||
data: {
|
||||
envB: { id: "envB", name: "Quarry" },
|
||||
envA: { id: "envA", name: "Lamp Oil" },
|
||||
},
|
||||
},
|
||||
map,
|
||||
{ heldIds: new Set() },
|
||||
);
|
||||
expect([map.env("liveA"), map.env("liveB")]).toEqual(["envA", "envB"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("translateValue", () => {
|
||||
it("replaces mapped live identifiers at any depth in the original value's type", () => {
|
||||
const map = new IdMap();
|
||||
map.learn(101, 1);
|
||||
map.learn("abc", "xyz");
|
||||
expect(
|
||||
translateValue({ ids: [101, "101", "abc"], name: "abc def", n: 5 }, map),
|
||||
).toEqual({ ids: [1, "1", "xyz"], name: "abc def", n: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("translatePath", () => {
|
||||
it("replaces segments that decode to a mapped live id and re-encodes them", () => {
|
||||
const map = new IdMap();
|
||||
map.learn("live@1", "env@1");
|
||||
map.learn(7, 3);
|
||||
expect(translatePath("/api/releases/live%401/deploys/7/", map)).toBe(
|
||||
"/api/releases/env%401/deploys/3/",
|
||||
);
|
||||
expect(translatePath("/api/%E0%A4%A/x", map)).toBe("/api/%E0%A4%A/x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ids two collections reuse", () => {
|
||||
it("keeps each collection's pairing of a live id and translates a path id by its collection", () => {
|
||||
const map = new IdMap();
|
||||
learnIds([{ id: 101, name: "First" }], [{ id: 1, name: "First" }], map, {
|
||||
heldIds: new Set(),
|
||||
scope: "/api/books",
|
||||
});
|
||||
learnIds([{ id: 101, name: "Bob" }], [{ id: 2, name: "Bob" }], map, {
|
||||
heldIds: new Set(),
|
||||
scope: "/api/authors",
|
||||
});
|
||||
expect(map.env(101, "/api/authors")).toBe(2);
|
||||
expect(map.env(101)).toBe(1);
|
||||
expect(
|
||||
translatePath("/api/authors/101", map, (index) =>
|
||||
index === 2 ? "/api/authors" : undefined,
|
||||
),
|
||||
).toBe("/api/authors/2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("objects keyed by ids that overlap", () => {
|
||||
it("pairs entries by a natural key when some re-seeded keys are reused, and only when every entry pairs", () => {
|
||||
const map = new IdMap();
|
||||
learnIds(
|
||||
{ "2": { name: "A" }, "3": { name: "B" } },
|
||||
{ "1": { name: "A" }, "2": { name: "B" } },
|
||||
map,
|
||||
{ heldIds: new Set() },
|
||||
);
|
||||
expect([map.env("2"), map.env("3")]).toEqual(["1", "2"]);
|
||||
const partial = new IdMap();
|
||||
learnIds(
|
||||
{ "2": { name: "A" }, "3": { name: "C" } },
|
||||
{ "1": { name: "A" }, "2": { name: "B" } },
|
||||
partial,
|
||||
{ heldIds: new Set() },
|
||||
);
|
||||
expect(partial.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ordinary objects whose fields overlap", () => {
|
||||
it("does not read their fields as ids, even when every value pairs", () => {
|
||||
const map = new IdMap();
|
||||
learnIds(
|
||||
{ current: { name: "A" }, previous: { name: "B" } },
|
||||
{ current: { name: "B" }, next: { name: "A" } },
|
||||
map,
|
||||
{ heldIds: new Set() },
|
||||
);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,368 +0,0 @@
|
||||
import type { JsonObject } from "../../domain/analysisView/environmentStateRules.js";
|
||||
import { isUuid } from "../../domain/analysisView/environmentStateWriteValues.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import type { ProbeToolCall } from "./environmentCompareMatch.js";
|
||||
|
||||
/** Field names that hold an identifier: `id`, `uuid`, `guid`, or names ending in `id` or `_id`. */
|
||||
export const ID_FIELD = /^(id|uuid|guid)$|(_id|Id|ID|_uuid|Uuid)$/u;
|
||||
|
||||
const isObject = (value: JsonValue | undefined): value is JsonObject =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const isScalarId = (value: JsonValue | undefined): value is string | number =>
|
||||
(typeof value === "string" && value !== "") ||
|
||||
(typeof value === "number" && Number.isFinite(value));
|
||||
|
||||
/** Entity identity fields, whose values an answer's own collection scopes. */
|
||||
export const IDENTITY_FIELD = /^(id|uuid|guid)$/u;
|
||||
|
||||
/**
|
||||
* Live identifier values and the environment's values they stand for, in both directions: one map for the whole probe,
|
||||
* and one per collection for the ids of its own entities, since two collections can reuse a live id for entities the
|
||||
* environment holds under different ids.
|
||||
*/
|
||||
export class IdMap {
|
||||
readonly #toEnv = new Map<string, string | number>();
|
||||
readonly #toLive = new Map<string, string | number>();
|
||||
readonly #scoped = new Map<string, Map<string, string | number>>();
|
||||
readonly #envValues = new Set<string>();
|
||||
#conflicts = 0;
|
||||
|
||||
/** Pair a live value with an environment value; the first pairing of either side wins, and within a collection the first of that collection. */
|
||||
learn(live: string | number, env: string | number, scope?: string): void {
|
||||
const liveKey = String(live);
|
||||
const envKey = String(env);
|
||||
if (liveKey === envKey) return;
|
||||
if (scope !== undefined) {
|
||||
const scoped = this.#scoped.get(scope) ?? new Map();
|
||||
if (!scoped.has(liveKey)) scoped.set(liveKey, env);
|
||||
this.#scoped.set(scope, scoped);
|
||||
this.#envValues.add(envKey);
|
||||
}
|
||||
const known = this.#toEnv.get(liveKey);
|
||||
if (known !== undefined) {
|
||||
if (String(known) !== envKey) this.#conflicts += 1;
|
||||
return;
|
||||
}
|
||||
if (this.#toLive.has(envKey)) {
|
||||
this.#conflicts += 1;
|
||||
return;
|
||||
}
|
||||
this.#toEnv.set(liveKey, env);
|
||||
this.#toLive.set(envKey, live);
|
||||
this.#envValues.add(envKey);
|
||||
}
|
||||
|
||||
/** The environment's value for a live one: the collection's own when a scope is given and it knows one, else the probe's. */
|
||||
env(live: string | number, scope?: string): string | number | undefined {
|
||||
const liveKey = String(live);
|
||||
return (
|
||||
(scope === undefined
|
||||
? undefined
|
||||
: this.#scoped.get(scope)?.get(liveKey)) ?? this.#toEnv.get(liveKey)
|
||||
);
|
||||
}
|
||||
|
||||
hasLive(value: string | number): boolean {
|
||||
const key = String(value);
|
||||
return (
|
||||
this.#toEnv.has(key) ||
|
||||
[...this.#scoped.values()].some((scoped) => scoped.has(key))
|
||||
);
|
||||
}
|
||||
|
||||
hasEnv(value: string | number): boolean {
|
||||
return this.#envValues.has(String(value));
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.#toEnv.size;
|
||||
}
|
||||
|
||||
get conflicts(): number {
|
||||
return this.#conflicts;
|
||||
}
|
||||
}
|
||||
|
||||
/** A field every object element has as a non-empty string, unique on its side; not an identifier, which differs across sides. */
|
||||
const keyCandidates = (items: readonly JsonObject[]): Set<string> => {
|
||||
const candidates = new Set<string>();
|
||||
const first = items[0];
|
||||
if (first === undefined) return candidates;
|
||||
for (const key of Object.keys(first)) {
|
||||
if (ID_FIELD.test(key)) continue;
|
||||
const values = items.map((item) => item[key]);
|
||||
if (
|
||||
values.every((value) => typeof value === "string" && value !== "") &&
|
||||
new Set(values).size === values.length
|
||||
)
|
||||
candidates.add(key);
|
||||
}
|
||||
return candidates;
|
||||
};
|
||||
|
||||
/** How two arrays pair: by a natural key, by position because no key is shared, or by position although a shared key paired nothing. */
|
||||
interface Pairing {
|
||||
readonly pairs: Array<readonly [number, number]>;
|
||||
readonly by: "key" | "position" | "unmatched";
|
||||
}
|
||||
|
||||
const pairing = (
|
||||
live: readonly JsonValue[],
|
||||
env: readonly JsonValue[],
|
||||
): Pairing => {
|
||||
const byPosition = (by: Pairing["by"]): Pairing => ({
|
||||
pairs: Array.from(
|
||||
{ length: Math.min(live.length, env.length) },
|
||||
(_, index) => [index, index] as const,
|
||||
),
|
||||
by,
|
||||
});
|
||||
if (!live.every(isObject) || !env.every(isObject))
|
||||
return byPosition("position");
|
||||
const liveItems = live as readonly JsonObject[];
|
||||
const envItems = env as readonly JsonObject[];
|
||||
const envKeys = keyCandidates(envItems);
|
||||
const shared = [...keyCandidates(liveItems)].filter((key) =>
|
||||
envKeys.has(key),
|
||||
);
|
||||
let best: Array<readonly [number, number]> = [];
|
||||
for (const key of shared.sort()) {
|
||||
const envIndex = new Map(envItems.map((item, index) => [item[key], index]));
|
||||
const pairs: Array<readonly [number, number]> = [];
|
||||
for (const [index, item] of liveItems.entries()) {
|
||||
const match = envIndex.get(item[key]);
|
||||
if (match !== undefined) pairs.push([index, match]);
|
||||
}
|
||||
if (pairs.length > best.length) best = pairs;
|
||||
}
|
||||
if (best.length > 0) return { pairs: best, by: "key" };
|
||||
return byPosition(shared.length > 0 ? "unmatched" : "position");
|
||||
};
|
||||
|
||||
/**
|
||||
* Pair two arrays' object elements by the natural key that pairs the most, as [live index, env index]; arrays of other
|
||||
* values, or with no shared key, pair by position up to the shorter length.
|
||||
*/
|
||||
export const pairElements = (
|
||||
live: readonly JsonValue[],
|
||||
env: readonly JsonValue[],
|
||||
): Array<readonly [number, number]> => pairing(live, env).pairs;
|
||||
|
||||
/** Whether an object's keys are evidently identifiers: digits or UUIDs, or each the identity of the entry it keys. */
|
||||
const keyedByIds = (
|
||||
entries: readonly (readonly [string, JsonValue])[],
|
||||
): boolean =>
|
||||
entries.every(
|
||||
([key, value]) =>
|
||||
/^\d+$/u.test(key) ||
|
||||
isUuid(key) ||
|
||||
(isObject(value) &&
|
||||
Object.entries(value).some(
|
||||
([field, held]) =>
|
||||
IDENTITY_FIELD.test(field) &&
|
||||
isScalarId(held) &&
|
||||
String(held) === key,
|
||||
)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Two objects keyed by ids, whose object values pair by a natural key: each pair maps the live key to the environment's
|
||||
* and is learned from in turn. Returns whether the objects were read that way.
|
||||
*/
|
||||
const learnKeyedEntries = (
|
||||
live: JsonObject,
|
||||
env: JsonObject,
|
||||
map: IdMap,
|
||||
heldIds: ReadonlySet<string>,
|
||||
): boolean => {
|
||||
const liveEntries = Object.entries(live);
|
||||
const envEntries = Object.entries(env);
|
||||
const shared = liveEntries.filter(([key]) => Object.hasOwn(env, key)).length;
|
||||
if (
|
||||
liveEntries.length === 0 ||
|
||||
envEntries.length === 0 ||
|
||||
shared === liveEntries.length ||
|
||||
!liveEntries.every(([, value]) => isObject(value)) ||
|
||||
!envEntries.every(([, value]) => isObject(value))
|
||||
)
|
||||
return false;
|
||||
const paired = pairing(
|
||||
liveEntries.map(([, value]) => value),
|
||||
envEntries.map(([, value]) => value),
|
||||
);
|
||||
// Re-seeded ids can reuse a key for another entity, but ordinary fields overlap too: keys that overlap are read as ids
|
||||
// only when they evidently are ids and every entry pairs.
|
||||
if (
|
||||
paired.by !== "key" ||
|
||||
(shared > 0 &&
|
||||
(paired.pairs.length !== liveEntries.length ||
|
||||
!keyedByIds(liveEntries) ||
|
||||
!keyedByIds(envEntries)))
|
||||
)
|
||||
return false;
|
||||
for (const [left, right] of paired.pairs) {
|
||||
const [liveKey, liveValue] = liveEntries[left]!;
|
||||
const [envKey, envValue] = envEntries[right]!;
|
||||
map.learn(liveKey, envKey);
|
||||
learnIds(liveValue, envValue, map, { heldIds });
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** What learning reads besides the two values: the map it adds to, the ids the state holds, and where the answer's own entities sit. */
|
||||
interface LearnContext {
|
||||
readonly map: IdMap;
|
||||
readonly heldIds: ReadonlySet<string>;
|
||||
readonly scope: string | undefined;
|
||||
/** JSON pointer of the answer's own entity, or of the array holding its entities. */
|
||||
readonly entityAt: string;
|
||||
}
|
||||
|
||||
/** Two values under one key as a pair of ids, when they are: an identifier field, or outside one only a string the state holds as an id, since a counter that equals an id is not one. */
|
||||
const idPair = (
|
||||
key: string,
|
||||
value: JsonValue,
|
||||
other: JsonValue | undefined,
|
||||
heldIds: ReadonlySet<string>,
|
||||
): readonly [string | number, string | number] | undefined =>
|
||||
isScalarId(value) &&
|
||||
isScalarId(other) &&
|
||||
(ID_FIELD.test(key) ||
|
||||
(typeof value === "string" &&
|
||||
typeof other === "string" &&
|
||||
heldIds.has(other)))
|
||||
? [value, other]
|
||||
: undefined;
|
||||
|
||||
const childPointer = (at: string, key: string | number): string =>
|
||||
`${at}/${String(key).replaceAll("~", "~0").replaceAll("/", "~1")}`;
|
||||
|
||||
const learnAt = (
|
||||
live: JsonValue | undefined,
|
||||
env: JsonValue | undefined,
|
||||
context: LearnContext,
|
||||
place: { readonly at: string; readonly own: boolean },
|
||||
): void => {
|
||||
if (Array.isArray(live) && Array.isArray(env)) {
|
||||
// Items paired only by position stand for the same entities only when nothing could pair them by a key and the
|
||||
// arrays are the same length; otherwise a filtered or reordered list would teach wrong ids.
|
||||
const paired = pairing(live, env);
|
||||
if (
|
||||
paired.by === "unmatched" ||
|
||||
(paired.by === "position" && live.length !== env.length)
|
||||
)
|
||||
return;
|
||||
for (const [left, right] of paired.pairs)
|
||||
learnAt(live[left], env[right], context, {
|
||||
at: childPointer(place.at, left),
|
||||
own: place.at === context.entityAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isObject(live) || !isObject(env)) return;
|
||||
if (learnKeyedEntries(live, env, context.map, context.heldIds)) return;
|
||||
const own = place.own || place.at === context.entityAt;
|
||||
for (const [key, value] of Object.entries(live)) {
|
||||
if (!Object.hasOwn(env, key)) continue;
|
||||
const other = env[key];
|
||||
const pair = idPair(key, value, other, context.heldIds);
|
||||
if (pair !== undefined)
|
||||
context.map.learn(
|
||||
...pair,
|
||||
own && IDENTITY_FIELD.test(key) ? context.scope : undefined,
|
||||
);
|
||||
else
|
||||
learnAt(value, other, context, {
|
||||
at: childPointer(place.at, key),
|
||||
own: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Learn identifier pairs from the live and environment answers to one call: identifier fields, and strings the state holds
|
||||
* as ids, of objects that pair, at any depth. The identity fields of the answer's own entities, the object or the items of
|
||||
* the array at `entityAt` (the root by default), are also learned under `scope`, the collection the call names.
|
||||
*/
|
||||
export const learnIds = (
|
||||
live: JsonValue | undefined,
|
||||
env: JsonValue | undefined,
|
||||
map: IdMap,
|
||||
known: {
|
||||
/** Ids the environment's state holds, which mark a string outside identifier fields as an id. */
|
||||
readonly heldIds: ReadonlySet<string>;
|
||||
readonly scope?: string;
|
||||
readonly entityAt?: string;
|
||||
},
|
||||
): void =>
|
||||
learnAt(
|
||||
live,
|
||||
env,
|
||||
{
|
||||
map,
|
||||
heldIds: known.heldIds,
|
||||
scope: known.scope,
|
||||
entityAt: known.entityAt ?? "",
|
||||
},
|
||||
{ at: "", own: false },
|
||||
);
|
||||
|
||||
/** A value with every string or number equal to a mapped live identifier replaced by the environment's value, in its own type. */
|
||||
export const translateValue = (value: JsonValue, map: IdMap): JsonValue => {
|
||||
if (Array.isArray(value))
|
||||
return value.map((item) => translateValue(item, map));
|
||||
if (isObject(value))
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
translateValue(item, map),
|
||||
]),
|
||||
);
|
||||
if (!isScalarId(value)) return value;
|
||||
const env = map.env(value);
|
||||
if (env === undefined) return value;
|
||||
return typeof value === "string"
|
||||
? String(env)
|
||||
: typeof env === "number"
|
||||
? env
|
||||
: value;
|
||||
};
|
||||
|
||||
/** The arguments the environment is called with: path values as matched, already in its terms, paging values as sent, and every other value translated. */
|
||||
export const environmentArguments = (
|
||||
call: ProbeToolCall,
|
||||
map: IdMap,
|
||||
): Record<string, JsonValue> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(call.args).map(([name, value]) => [
|
||||
name,
|
||||
call.pathArguments.includes(name) || call.pagingArguments.includes(name)
|
||||
? value
|
||||
: translateValue(value, map),
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* A request path with every segment that decodes to a mapped live identifier replaced by the environment's value,
|
||||
* encoded; `scopeAt` names the collection of the segment at a position after the leading slash, whose own mapping wins.
|
||||
*/
|
||||
export const translatePath = (
|
||||
path: string,
|
||||
map: IdMap,
|
||||
scopeAt: (index: number) => string | undefined = () => undefined,
|
||||
): string =>
|
||||
path
|
||||
.split("/")
|
||||
.map((segment, index) => {
|
||||
let decoded = segment;
|
||||
try {
|
||||
decoded = decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
const env =
|
||||
decoded === "" ? undefined : map.env(decoded, scopeAt(index - 1));
|
||||
return env === undefined ? segment : encodeURIComponent(String(env));
|
||||
})
|
||||
.join("/");
|
||||
@@ -1,141 +0,0 @@
|
||||
import { isPagingParameter } from "../../domain/analysisView/environmentStatePaging.js";
|
||||
import { segmentRule } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { decodedPathSegment } from "./environmentReplay.js";
|
||||
import type {
|
||||
EnvironmentManifest,
|
||||
EnvironmentManifestTool,
|
||||
} from "./environmentSource.js";
|
||||
import { environmentToolArguments } from "./environmentTools.js";
|
||||
|
||||
const FORMAT_RULES: ReadonlySet<string> = new Set([
|
||||
"numeric",
|
||||
"uuid",
|
||||
"hex",
|
||||
"iso-date",
|
||||
]);
|
||||
|
||||
/** One request of a probe capture, as the comparison replays it. */
|
||||
export interface ProbeRequest {
|
||||
readonly method: string;
|
||||
readonly path: string;
|
||||
/** WHATWG query spelling including `?`, or null. */
|
||||
readonly query: string | null;
|
||||
readonly body: JsonValue | undefined;
|
||||
}
|
||||
|
||||
/** A probe request as a call of one manifest tool. */
|
||||
export interface ProbeToolCall {
|
||||
readonly tool: EnvironmentManifestTool;
|
||||
readonly args: Record<string, JsonValue>;
|
||||
/** Argument names of the path values, in path order. */
|
||||
readonly pathArguments: readonly string[];
|
||||
/** Argument names of query values that page a list: counts, never ids. */
|
||||
readonly pagingArguments: readonly string[];
|
||||
/** Query names the tool does not take; the call drops them. */
|
||||
readonly ignoredQuery: readonly string[];
|
||||
}
|
||||
|
||||
/** A tool's path segments up to `end`, a parameter as `{}`, so tools naming one parameter differently share collections. */
|
||||
const collectionAt = (tool: EnvironmentManifestTool, end: number): string => {
|
||||
const params = new Set(tool.params.map((param) => param.index));
|
||||
return `/${tool.path_template
|
||||
.slice(1)
|
||||
.split("/")
|
||||
.slice(0, end)
|
||||
.map((segment, index) => (params.has(index) ? "{}" : segment))
|
||||
.join("/")}`;
|
||||
};
|
||||
|
||||
/** The collection a call of the tool names: its path, less the trailing parameter of an item route. */
|
||||
export const collectionScope = (tool: EnvironmentManifestTool): string => {
|
||||
const length = tool.path_template.slice(1).split("/").length;
|
||||
return collectionAt(
|
||||
tool,
|
||||
tool.params.some((param) => param.index === length - 1)
|
||||
? length - 1
|
||||
: length,
|
||||
);
|
||||
};
|
||||
|
||||
/** The collection each path parameter names an entity of: the path before it, by the parameter's position. */
|
||||
export const parameterScopes = (
|
||||
tool: EnvironmentManifestTool,
|
||||
): ReadonlyMap<number, string> =>
|
||||
new Map(
|
||||
tool.params.map((param) => [param.index, collectionAt(tool, param.index)]),
|
||||
);
|
||||
|
||||
interface Fit {
|
||||
readonly tool: EnvironmentManifestTool;
|
||||
readonly literals: number;
|
||||
}
|
||||
|
||||
/** How a path fits a tool's template: the literal segments it matches, or undefined when it does not fit. */
|
||||
const fitOf = (
|
||||
tool: EnvironmentManifestTool,
|
||||
segments: readonly string[],
|
||||
): Fit | undefined => {
|
||||
const template = tool.path_template.slice(1).split("/");
|
||||
if (template.length !== segments.length) return undefined;
|
||||
const params = new Map(tool.params.map((param) => [param.index, param]));
|
||||
let literals = 0;
|
||||
for (const [index, part] of template.entries()) {
|
||||
const segment = segments[index]!;
|
||||
const param = params.get(index);
|
||||
if (param === undefined) {
|
||||
if (part !== segment && part !== decodedPathSegment(segment))
|
||||
return undefined;
|
||||
literals += 1;
|
||||
} else if (
|
||||
// Replay matches a path value decoded, so its format is the decoded value's.
|
||||
FORMAT_RULES.has(param.rule) &&
|
||||
segmentRule(decodedPathSegment(segment)) !== param.rule
|
||||
)
|
||||
return undefined;
|
||||
else if (segment === "") return undefined;
|
||||
}
|
||||
return { tool, literals };
|
||||
};
|
||||
|
||||
/** The tool of the request's method whose template fits the path with the most literal segments; the manifest's order breaks ties. */
|
||||
export const matchProbeRequest = (
|
||||
manifest: EnvironmentManifest,
|
||||
request: ProbeRequest,
|
||||
): ProbeToolCall | undefined => {
|
||||
const segments = request.path.slice(1).split("/");
|
||||
let best: Fit | undefined;
|
||||
for (const tool of manifest.tools) {
|
||||
if (tool.method !== request.method) continue;
|
||||
const fit = fitOf(tool, segments);
|
||||
if (
|
||||
fit !== undefined &&
|
||||
(best === undefined || fit.literals > best.literals)
|
||||
)
|
||||
best = fit;
|
||||
}
|
||||
if (best === undefined) return undefined;
|
||||
const { tool } = best;
|
||||
const names = environmentToolArguments(tool);
|
||||
const args: Record<string, JsonValue> = {};
|
||||
const pathArguments: string[] = [];
|
||||
for (const param of tool.params) {
|
||||
const name = names.path.get(param.name) ?? param.name;
|
||||
args[name] = decodedPathSegment(segments[param.index]!);
|
||||
pathArguments.push(name);
|
||||
}
|
||||
const ignoredQuery: string[] = [];
|
||||
const pagingArguments: string[] = [];
|
||||
if (request.query !== null)
|
||||
for (const [key, value] of new URLSearchParams(request.query.slice(1))) {
|
||||
const name = names.query.get(key);
|
||||
if (name === undefined) ignoredQuery.push(key);
|
||||
else if (!Object.hasOwn(args, name)) {
|
||||
args[name] = value;
|
||||
if (isPagingParameter(key)) pagingArguments.push(name);
|
||||
}
|
||||
}
|
||||
if (names.body !== undefined && request.body !== undefined)
|
||||
args[names.body] = request.body;
|
||||
return { tool, args, pathArguments, pagingArguments, ignoredQuery };
|
||||
};
|
||||
@@ -1,177 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { isJsonLike } from "../../domain/analysisView/EnvironmentManifestAccumulator.js";
|
||||
import {
|
||||
primaryOrigin,
|
||||
type OriginStats,
|
||||
} from "../../domain/analysisView/environmentManifestOrigin.js";
|
||||
import {
|
||||
mediaTypeEssence,
|
||||
readRecordedRequestLine,
|
||||
type RecordedRequestLine,
|
||||
type RequestLineExclusion,
|
||||
} from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import { parseExactJson } from "../../domain/exactJsonNumber.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import type { WebNetworkCaptureRecord } from "../../domain/webNetworkCapture.js";
|
||||
import type { Answer } from "./environmentCompareDiff.js";
|
||||
import type { ProbeRequest } from "./environmentCompareMatch.js";
|
||||
import {
|
||||
recordedExchange,
|
||||
replayedBody,
|
||||
type ReplayedBody,
|
||||
} from "./environmentSource.js";
|
||||
|
||||
/** A probe record left uncompared: without a usable request line, or at another origin than the application's, it is not replayed either; without an answer it is replayed, so the state follows, but nothing is compared. */
|
||||
export interface UnobservedRecord {
|
||||
readonly ordinal: number;
|
||||
readonly reason: RequestLineExclusion | "other-origin" | "no-response";
|
||||
}
|
||||
|
||||
/** One probe request with the application's answer, absent when the probe recorded none. */
|
||||
export interface ProbeStep {
|
||||
readonly ordinal: number;
|
||||
readonly request: ProbeRequest;
|
||||
readonly live?: Answer;
|
||||
}
|
||||
|
||||
/** Statuses whose answers carry no body, so a missing body is known to be empty. */
|
||||
const BODYLESS_STATUSES: ReadonlySet<number> = new Set([204, 205, 304]);
|
||||
|
||||
const startedSchema = z.object({ startedDateTime: z.string() });
|
||||
const postDataSchema = z.object({
|
||||
request: z.object({
|
||||
postData: z.object({ mimeType: z.string(), text: z.string() }),
|
||||
}),
|
||||
});
|
||||
|
||||
const methodSchema = z.object({ request: z.object({ method: z.string() }) });
|
||||
|
||||
const startedMs = (record: WebNetworkCaptureRecord): number | undefined => {
|
||||
const started = startedSchema.safeParse(record.reported);
|
||||
const ms = started.success ? Date.parse(started.data.startedDateTime) : NaN;
|
||||
return Number.isFinite(ms) ? ms : undefined;
|
||||
};
|
||||
|
||||
/** Whether a probe makes calls that carry a request body: a POST, PUT or PATCH. */
|
||||
export const sendsBodies = (
|
||||
records: readonly WebNetworkCaptureRecord[],
|
||||
): boolean =>
|
||||
records.some((record) => {
|
||||
const parsed = methodSchema.safeParse(record.reported);
|
||||
return (
|
||||
parsed.success &&
|
||||
["POST", "PUT", "PATCH"].includes(parsed.data.request.method)
|
||||
);
|
||||
});
|
||||
|
||||
/** The JSON a request body holds, its text when it is not JSON, or undefined without one. */
|
||||
export const requestBody = (
|
||||
record: WebNetworkCaptureRecord,
|
||||
): JsonValue | undefined => {
|
||||
const parsed = postDataSchema.safeParse(record.reported);
|
||||
if (!parsed.success) return undefined;
|
||||
const { mimeType, text } = parsed.data.request.postData;
|
||||
return isJsonLike(mediaTypeEssence(mimeType))
|
||||
? (parseExactJson(text) ?? text)
|
||||
: text;
|
||||
};
|
||||
|
||||
/** A body over the replay limit, text or bytes: only a prefix is known, so only the status compares. */
|
||||
export const isCut = (body: ReplayedBody): boolean =>
|
||||
body.kind === "cut" || (body.kind === "base64" && !body.whole);
|
||||
|
||||
/** The application's answer as an environment answer carries it: JSON, text, or null when it is empty; a body the probe did not record is marked. */
|
||||
const liveAnswer = (
|
||||
method: string,
|
||||
status: number,
|
||||
body: ReplayedBody,
|
||||
): Answer => {
|
||||
if (body.kind === "none")
|
||||
return method === "HEAD" || BODYLESS_STATUSES.has(status)
|
||||
? { status, body: null }
|
||||
: { status, body: null, unobserved: true };
|
||||
if (body.kind === "json") return { status, body: body.value };
|
||||
if (body.kind === "base64")
|
||||
return { status, body: body.encoded, cut: isCut(body) };
|
||||
return {
|
||||
status,
|
||||
body: body.text === "" ? null : body.text,
|
||||
cut: isCut(body),
|
||||
};
|
||||
};
|
||||
|
||||
/** The origin a probe calls the application at, chosen as the manifest chooses its own. */
|
||||
const probeOrigin = (
|
||||
lines: ReadonlyMap<number, RecordedRequestLine | null>,
|
||||
): string | null => {
|
||||
const origins = new Map<string, OriginStats>();
|
||||
for (const line of lines.values()) {
|
||||
if (line?.kind !== "line") continue;
|
||||
const stats = origins.get(line.origin) ?? { records: 0, jsonLike: 0 };
|
||||
stats.records += 1;
|
||||
if (isJsonLike(mediaTypeEssence(line.rawMime))) stats.jsonLike += 1;
|
||||
origins.set(line.origin, stats);
|
||||
}
|
||||
return primaryOrigin(origins);
|
||||
};
|
||||
|
||||
/** The probe's requests in the order they were made, each with the application's answer, and the records left uncompared. */
|
||||
export const probeRequests = (
|
||||
records: readonly WebNetworkCaptureRecord[],
|
||||
): { steps: ProbeStep[]; unobserved: UnobservedRecord[] } => {
|
||||
const steps: ProbeStep[] = [];
|
||||
const unobserved: UnobservedRecord[] = [];
|
||||
// Without a start time for every record the probe keeps its recorded order, as state extraction does.
|
||||
const starts = new Map(
|
||||
records.map((record) => [record.ordinal, startedMs(record)]),
|
||||
);
|
||||
const timed = [...starts.values()].every((ms) => ms !== undefined);
|
||||
const ordered = [...records].sort(
|
||||
(left, right) =>
|
||||
(timed ? starts.get(left.ordinal)! - starts.get(right.ordinal)! : 0) ||
|
||||
left.ordinal - right.ordinal,
|
||||
);
|
||||
const lines = new Map(
|
||||
records.map((record) => [
|
||||
record.ordinal,
|
||||
readRecordedRequestLine(record.reported, true, () => true),
|
||||
]),
|
||||
);
|
||||
// Another origin's request goes to some other server, so it neither calls nor changes the environment.
|
||||
const origin = probeOrigin(lines);
|
||||
for (const record of ordered) {
|
||||
const line = lines.get(record.ordinal);
|
||||
const exchange = recordedExchange(record);
|
||||
const { ordinal } = record;
|
||||
if (line?.kind === "line" && line.origin !== origin)
|
||||
unobserved.push({ ordinal, reason: "other-origin" });
|
||||
else if (line?.kind !== "line" || exchange === undefined)
|
||||
unobserved.push({
|
||||
ordinal,
|
||||
reason: line?.kind === "excluded" ? line.reason : "request-unavailable",
|
||||
});
|
||||
else {
|
||||
const request = {
|
||||
method: line.method,
|
||||
path: exchange.path,
|
||||
query: exchange.query,
|
||||
body: requestBody(record),
|
||||
};
|
||||
// A request whose answer was lost may still have changed the application, so it is made but not compared.
|
||||
if (exchange.status === null) {
|
||||
unobserved.push({ ordinal, reason: "no-response" });
|
||||
steps.push({ ordinal, request });
|
||||
} else
|
||||
steps.push({
|
||||
ordinal,
|
||||
request,
|
||||
live: liveAnswer(
|
||||
line.method,
|
||||
exchange.status,
|
||||
replayedBody(exchange),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { steps, unobserved };
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import { isJsonLike } from "../../domain/analysisView/EnvironmentManifestAccumulator.js";
|
||||
import { mediaTypeEssence } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import { parseExactJson } from "../../domain/exactJsonNumber.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
iterateUrlQueryParameters,
|
||||
@@ -10,7 +13,6 @@ import { environmentToolArguments } from "./environmentTools.js";
|
||||
import {
|
||||
REPLAY_BODY_LIMIT_BYTES,
|
||||
ENVIRONMENT_SERVE_OPERATION,
|
||||
replayedBody,
|
||||
type EnvironmentManifestTool,
|
||||
type EnvironmentSource,
|
||||
type RecordedExchange,
|
||||
@@ -181,6 +183,11 @@ const utf8Prefix = (text: string, maxBytes: number): string => {
|
||||
return text.slice(0, read);
|
||||
};
|
||||
|
||||
/** Bytes a padded base64 string decodes to. */
|
||||
const decodedBase64Bytes = (encoded: string): number =>
|
||||
Math.floor((encoded.length * 3) / 4) -
|
||||
(encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0);
|
||||
|
||||
/** Base64 characters, in whole groups, whose bytes fit the replay limit. */
|
||||
const BASE64_LIMIT_CHARS = Math.floor(REPLAY_BODY_LIMIT_BYTES / 3) * 4;
|
||||
|
||||
@@ -189,26 +196,29 @@ interface ReplayBody {
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
/** The reply body and whether it was cut, as the source classifies the retained body. */
|
||||
/** The limit applies to the recorded bytes, not to their base64 spelling. */
|
||||
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): ReplayBody => {
|
||||
const body = replayedBody(exchange);
|
||||
switch (body.kind) {
|
||||
case "none":
|
||||
return { body: null, truncated: false };
|
||||
case "base64":
|
||||
return body.whole
|
||||
? { body: body.encoded, truncated: false }
|
||||
: { body: body.encoded.slice(0, BASE64_LIMIT_CHARS), truncated: true };
|
||||
case "cut":
|
||||
return {
|
||||
body: utf8Prefix(body.text, REPLAY_BODY_LIMIT_BYTES),
|
||||
truncated: true,
|
||||
};
|
||||
case "json":
|
||||
return { body: body.value, truncated: false };
|
||||
case "text":
|
||||
return { body: body.text, truncated: false };
|
||||
const text = exchange.bodyText;
|
||||
if (text === null) return { body: null, truncated: false };
|
||||
if (exchange.bodyEncoding === "base64")
|
||||
return replayBase64Body(text, exchange.bodyCut === true);
|
||||
const prefix = utf8Prefix(text, REPLAY_BODY_LIMIT_BYTES);
|
||||
if (prefix.length < text.length || exchange.bodyCut === true)
|
||||
return { body: prefix, truncated: true };
|
||||
if (
|
||||
exchange.contentType !== null &&
|
||||
isJsonLike(mediaTypeEssence(exchange.contentType))
|
||||
) {
|
||||
const value = parseExactJson(text);
|
||||
if (value !== undefined) return { body: value, truncated: false };
|
||||
}
|
||||
return { body: text, truncated: false };
|
||||
};
|
||||
|
||||
/** Recorded paths and queries are cut to this many UTF-8 bytes in replies. */
|
||||
|
||||
@@ -9,11 +9,7 @@ import {
|
||||
readRecordedResponseBody,
|
||||
type RecordedResponseBody,
|
||||
} from "../../domain/analysisView/recordedResponseBody.js";
|
||||
import { isJsonLike } from "../../domain/analysisView/EnvironmentManifestAccumulator.js";
|
||||
import {
|
||||
mediaTypeEssence,
|
||||
readRecordedRequestLine,
|
||||
} from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import { readRecordedRequestLine } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import { browserScenarioCaptureSchema } from "../../domain/browserScenarioCapture.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
@@ -21,10 +17,8 @@ import {
|
||||
type AnalysisInputIssue,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { immutableEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { parseExactJson } from "../../domain/exactJsonNumber.js";
|
||||
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { immutableJsonSnapshotMember } from "../../domain/immutableJson.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
safeParseOwnedWebNetworkCapture,
|
||||
@@ -53,52 +47,6 @@ export interface RecordedExchange extends RecordedResponseBody {
|
||||
/** Replayed bodies larger than this are cut at a UTF-8 boundary and flagged. */
|
||||
export const REPLAY_BODY_LIMIT_BYTES = 256 * 1024;
|
||||
|
||||
/** How replay answers with an exchange's body; replay and tool descriptions both read it from here. */
|
||||
export type ReplayedBody =
|
||||
| { readonly kind: "none" }
|
||||
/** Non-UTF-8 bytes as base64; `whole` when every recorded byte fits the replay limit. */
|
||||
| {
|
||||
readonly kind: "base64";
|
||||
readonly encoded: string;
|
||||
readonly whole: boolean;
|
||||
}
|
||||
/** UTF-8 text over the replay limit or cut when retained; replay answers with a prefix. */
|
||||
| { readonly kind: "cut"; readonly text: string }
|
||||
| { readonly kind: "json"; readonly value: JsonValue }
|
||||
| { readonly kind: "text"; readonly text: string };
|
||||
|
||||
/** Bytes a padded base64 string decodes to. */
|
||||
const decodedBase64Bytes = (encoded: string): number =>
|
||||
Math.floor((encoded.length * 3) / 4) -
|
||||
(encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0);
|
||||
|
||||
/** Classify a retained body once: whole JSON-like UTF-8 text within the limit that parses exactly is JSON. */
|
||||
export const replayedBody = (exchange: RecordedExchange): ReplayedBody => {
|
||||
const text = exchange.bodyText;
|
||||
if (text === null) return { kind: "none" };
|
||||
if (exchange.bodyEncoding === "base64")
|
||||
return {
|
||||
kind: "base64",
|
||||
encoded: text,
|
||||
whole:
|
||||
exchange.bodyCut !== true &&
|
||||
decodedBase64Bytes(text) <= REPLAY_BODY_LIMIT_BYTES,
|
||||
};
|
||||
if (
|
||||
exchange.bodyCut === true ||
|
||||
// UTF-8 never takes fewer bytes than UTF-16 units, so the length check avoids encoding huge text.
|
||||
text.length > REPLAY_BODY_LIMIT_BYTES ||
|
||||
Buffer.byteLength(text, "utf8") > REPLAY_BODY_LIMIT_BYTES
|
||||
)
|
||||
return { kind: "cut", text };
|
||||
const value =
|
||||
exchange.contentType !== null &&
|
||||
isJsonLike(mediaTypeEssence(exchange.contentType))
|
||||
? parseExactJson(text)
|
||||
: undefined;
|
||||
return value === undefined ? { kind: "text", text } : { kind: "json", value };
|
||||
};
|
||||
|
||||
/** A HAR inspected by inspect_web_network_capture, or a session recorded by capture_browser_scenario. */
|
||||
export type EnvironmentCaptureKind = "web-network-capture" | "browser-scenario";
|
||||
|
||||
@@ -155,8 +103,7 @@ export const parseEvidenceFile = (
|
||||
}
|
||||
};
|
||||
|
||||
/** One capture record reduced to its request line and retained response body, or undefined without a request line. */
|
||||
export const recordedExchange = (
|
||||
const recordedExchange = (
|
||||
record: WebNetworkCaptureRecord,
|
||||
): RecordedExchange | undefined => {
|
||||
const line = readRecordedRequestLine(record.reported, true, () => true);
|
||||
@@ -214,21 +161,16 @@ export const parseViewEvidence = <Kind extends AnalysisViewResult["kind"]>(
|
||||
});
|
||||
};
|
||||
|
||||
/** Authenticate the evidence file and require complete capture Evidence of either kind; a browser capture's request bodies are kept only for the manifest purpose. */
|
||||
export const parseCaptureEvidence = (
|
||||
/** Authenticate the evidence file and require complete capture Evidence of either kind. */
|
||||
const parseCaptureEvidence = (
|
||||
operation: string,
|
||||
value: unknown,
|
||||
purpose: "manifest" | "replay" = "replay",
|
||||
): Result<
|
||||
{
|
||||
readonly records: readonly WebNetworkCaptureRecord[];
|
||||
readonly evidenceId: string;
|
||||
readonly captureKind: EnvironmentCaptureKind;
|
||||
readonly captureSha256: string | null;
|
||||
/** Whether the capture recorded response bodies: a browser scenario does only when they were selected. */
|
||||
readonly responseBodies: boolean;
|
||||
/** Whether the capture recorded request bodies, likewise. */
|
||||
readonly requestBodies: boolean;
|
||||
},
|
||||
AnalysisError
|
||||
> => {
|
||||
@@ -246,14 +188,12 @@ export const parseCaptureEvidence = (
|
||||
.safeParse(evidence.value.parameters);
|
||||
if (scenario.success)
|
||||
return ok({
|
||||
records: scenarioCaptureRecords(scenario.data, purpose).records,
|
||||
records: scenarioCaptureRecords(scenario.data, "replay").records,
|
||||
evidenceId,
|
||||
captureKind: "browser-scenario",
|
||||
captureSha256: definition.success
|
||||
? definition.data.scenario_sha256
|
||||
: null,
|
||||
responseBodies: scenario.data.scenario.network_content.response_body,
|
||||
requestBodies: scenario.data.scenario.network_content.request_body,
|
||||
});
|
||||
} else if (evidence.value.operation === "inspect_web_network_capture") {
|
||||
const records = immutableJsonSnapshotMember(normalized, "records");
|
||||
@@ -286,8 +226,6 @@ export const parseCaptureEvidence = (
|
||||
evidenceId,
|
||||
captureKind: "web-network-capture",
|
||||
captureSha256: capture.data.artifact.sha256,
|
||||
responseBodies: true,
|
||||
requestBodies: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
} from "../analysisView/AnalysisViewService.js";
|
||||
import { loadEnvironmentSource } from "./environmentSource.js";
|
||||
import {
|
||||
admitServedState,
|
||||
deriveEnvironmentState,
|
||||
loadEnvironmentState,
|
||||
prepareServedState,
|
||||
@@ -273,28 +272,6 @@ describe("prepareServedState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("admitServedState", () => {
|
||||
it("refuses a derived state data/get would write past what data/add loads back", () => {
|
||||
expect(admitServedState(state, "environment_serve").ok).toBe(true);
|
||||
// A euro sign is one UTF-16 unit but three bytes as data/get writes it.
|
||||
const notes = "\u20ac".repeat(12_000_000);
|
||||
const [first] = state.entities;
|
||||
const large = {
|
||||
...state,
|
||||
entities: [
|
||||
...state.entities,
|
||||
{ ...first!, id: "large", fields: { ...first!.fields, notes } },
|
||||
],
|
||||
};
|
||||
const admitted = admitServedState(large, "environment_serve");
|
||||
if (admitted.ok) throw new Error("Expected the state to be refused");
|
||||
expect(projectAnalysisError(admitted.error)).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
message: expect.stringContaining("larger than data/add loads"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -441,6 +418,30 @@ describe("EnvironmentStateStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("caller-owned state replacement", () => {
|
||||
it("does not retain mutable data from a caller-owned replacement", () => {
|
||||
const store = new EnvironmentStateStore(state);
|
||||
const document = documentWith((value) => {
|
||||
const entity = value.rea_state.entities.find((item) => item.id === "2");
|
||||
if (!entity) throw new Error("Expected the user entity");
|
||||
entity.fields = { ...(entity.fields as object), login: "renamed" };
|
||||
});
|
||||
expect(store.replace(document).ok).toBe(true);
|
||||
const callerEntity = document.rea_state.entities.find(
|
||||
(entity) => entity.id === "2",
|
||||
);
|
||||
if (!callerEntity) throw new Error("Expected the caller's user entity");
|
||||
(callerEntity.fields as Record<string, unknown>).login = "later edit";
|
||||
|
||||
expect(store.snapshot().rea_state.entities).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "2",
|
||||
fields: expect.objectContaining({ login: "renamed" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rea-state/v1 validation", () => {
|
||||
it.each([
|
||||
[
|
||||
@@ -484,12 +485,12 @@ describe("rea-state/v1 validation", () => {
|
||||
"a document for a tool that is not a document view",
|
||||
(document: Editable) => {
|
||||
document.rea_state.documents.push({
|
||||
tool: "get_users_by_id",
|
||||
path: "/api/users/2",
|
||||
tool: "get_org",
|
||||
path: "/api/org",
|
||||
value: {},
|
||||
});
|
||||
},
|
||||
`rea_state/documents/${String(state.documents.length)}/tool`,
|
||||
"rea_state/documents/0/tool",
|
||||
],
|
||||
[
|
||||
"a member the format does not define",
|
||||
@@ -593,3 +594,36 @@ describe("rea-state/v1 validation", () => {
|
||||
).toMatchObject({ path: "rea_state/version" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("wide state value validation", () => {
|
||||
it("preserves a wide caller value through the state replacement boundary", () => {
|
||||
const wide = Array.from({ length: 200_000 }, (_, index) => index % 10);
|
||||
const wideObject = Object.fromEntries(
|
||||
Array.from({ length: 20_000 }, (_, index) => [
|
||||
`field_${String(index)}`,
|
||||
index,
|
||||
]),
|
||||
);
|
||||
const document = documentWith((value) => {
|
||||
const fields = value.rea_state.entities[0]!.fields as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
value.rea_state.entities[0]!.fields = {
|
||||
...fields,
|
||||
caller_wide: wide,
|
||||
caller_wide_object: wideObject,
|
||||
};
|
||||
});
|
||||
const store = new EnvironmentStateStore(state);
|
||||
|
||||
expect(store.replace(document).ok).toBe(true);
|
||||
const retained = store.snapshot().rea_state.entities[0]?.fields.caller_wide;
|
||||
if (!Array.isArray(retained)) throw new Error("Expected the wide value");
|
||||
expect(retained).toHaveLength(wide.length);
|
||||
expect(retained.at(-1)).toBe(wide.at(-1));
|
||||
const retainedObject =
|
||||
store.snapshot().rea_state.entities[0]?.fields.caller_wide_object;
|
||||
expect(retainedObject).toEqual(wideObject);
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,10 @@ import {
|
||||
type StateType,
|
||||
type StateView,
|
||||
} from "../../domain/analysisView/environmentStateRules.js";
|
||||
import type { StateWritePlan } from "../../domain/analysisView/environmentStateWriteRules.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
import type {
|
||||
AnalysisInputIssue,
|
||||
AnalysisResourceConstraintError,
|
||||
type AnalysisInputIssue,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
@@ -23,9 +22,6 @@ import {
|
||||
type EnvironmentManifestTool,
|
||||
type EnvironmentSource,
|
||||
} from "./environmentSource.js";
|
||||
import { STATE_INTAKE_LIMIT_BYTES } from "./environmentStateDocument.js";
|
||||
import { initialStateBytes } from "./environmentStateStore.js";
|
||||
import { describeWrite } from "./environmentStateWriteDescription.js";
|
||||
import {
|
||||
environmentToolDefinitions,
|
||||
type EnvironmentDataPlane,
|
||||
@@ -45,22 +41,11 @@ export interface EnvironmentState {
|
||||
readonly views: ReadonlyMap<string, StateView>;
|
||||
readonly entities: readonly StateEntity[];
|
||||
readonly documents: readonly StateDocument[];
|
||||
/** Write plans keyed by tool name. */
|
||||
readonly writes: ReadonlyMap<string, StateWritePlan>;
|
||||
/** The instant the served clock starts from, or null when the capture has no times. */
|
||||
readonly writeClock: string | null;
|
||||
}
|
||||
|
||||
/** The write plans a catalog serves from: none before a state is derived. */
|
||||
type ServedWrites = Pick<EnvironmentState, "writes">;
|
||||
|
||||
/** Serving from the state serves read tools and the write tools whose effect the capture confirmed. */
|
||||
const servedFromState = (
|
||||
tool: EnvironmentManifestTool,
|
||||
state: ServedWrites,
|
||||
): boolean =>
|
||||
!changesState(tool.method, tool.path_template) ||
|
||||
state.writes.get(tool.name)?.served === true;
|
||||
/** 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 {
|
||||
@@ -68,35 +53,24 @@ export interface ServedCatalog {
|
||||
readonly dataPlane: EnvironmentDataPlane;
|
||||
}
|
||||
|
||||
/** The catalog an environment serves: every manifest tool for replay; from a state, the read tools, the confirmed write tools and the data plane. */
|
||||
/** 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,
|
||||
state: ServedWrites | undefined,
|
||||
fromState: boolean,
|
||||
name: string,
|
||||
operation: string = ENVIRONMENT_SERVE_OPERATION,
|
||||
): Result<ServedCatalog, AnalysisResourceConstraintError> => {
|
||||
const dataPlane: EnvironmentDataPlane =
|
||||
state === undefined ? "none" : "state";
|
||||
const dataPlane: EnvironmentDataPlane = fromState ? "state" : "none";
|
||||
const tools = environmentToolDefinitions(
|
||||
state === undefined
|
||||
? source
|
||||
: {
|
||||
...source,
|
||||
manifest: {
|
||||
...source.manifest,
|
||||
tools: source.manifest.tools.filter((tool) =>
|
||||
servedFromState(tool, state),
|
||||
),
|
||||
},
|
||||
},
|
||||
fromState
|
||||
? {
|
||||
...source.manifest,
|
||||
tools: source.manifest.tools.filter(servedFromState),
|
||||
}
|
||||
: source.manifest,
|
||||
source.evidenceId,
|
||||
name,
|
||||
{
|
||||
operation,
|
||||
dataPlane,
|
||||
...(state === undefined
|
||||
? {}
|
||||
: { describe: (tool) => describeWrite(state.writes.get(tool.name)) }),
|
||||
},
|
||||
{ operation, dataPlane },
|
||||
);
|
||||
return tools.ok ? ok({ tools: tools.value, dataPlane }) : tools;
|
||||
};
|
||||
@@ -186,8 +160,6 @@ export const loadEnvironmentState = (
|
||||
views: new Map(view.views.map((entry) => [entry.tool, entry])),
|
||||
entities: view.entities,
|
||||
documents: view.documents,
|
||||
writes: new Map(view.writes.map((plan) => [plan.tool, plan])),
|
||||
writeClock: view.write_clock,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -240,29 +212,6 @@ export interface PreparedState extends ServedState {
|
||||
>;
|
||||
}
|
||||
|
||||
/** The derived state, unless data/get would write it past what data/add loads back, so its own report could not be restored. */
|
||||
export const admitServedState = (
|
||||
state: EnvironmentState,
|
||||
operation: string,
|
||||
): Result<EnvironmentState, AnalysisResourceConstraintError> => {
|
||||
const bytes = initialStateBytes(state);
|
||||
return bytes > STATE_INTAKE_LIMIT_BYTES
|
||||
? err(
|
||||
new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"capacity",
|
||||
"The derived state is larger than data/add loads, so it cannot be served",
|
||||
{ max_state_bytes: STATE_INTAKE_LIMIT_BYTES, state_bytes: bytes },
|
||||
{
|
||||
retryable: false,
|
||||
remediationAction:
|
||||
"Capture fewer or smaller responses, or serve the environment as replay only.",
|
||||
},
|
||||
),
|
||||
)
|
||||
: ok(state);
|
||||
};
|
||||
|
||||
const unavailable = (error: AnalysisError): PreparedState => {
|
||||
const { code, message } = projectAnalysisError(error);
|
||||
return {
|
||||
@@ -281,20 +230,19 @@ export const prepareServedState = (
|
||||
} = {},
|
||||
): PreparedState => {
|
||||
const operation = options.operation ?? ENVIRONMENT_SERVE_OPERATION;
|
||||
const name = options.name ?? source.manifest.name;
|
||||
// The read catalog depends only on the manifest and name, so checking it first spares deriving a state that cannot be served.
|
||||
const reads = servedCatalog(source, { writes: new Map() }, name, operation);
|
||||
if (!reads.ok) return unavailable(reads.error);
|
||||
// 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);
|
||||
if (!derived.ok) return unavailable(derived.error);
|
||||
const admitted = admitServedState(derived.value, operation);
|
||||
if (!admitted.ok) return unavailable(admitted.error);
|
||||
// Served write tools join the catalog, so the whole catalog is admitted again.
|
||||
const catalog = servedCatalog(source, derived.value, name, operation);
|
||||
return catalog.ok
|
||||
return derived.ok
|
||||
? {
|
||||
state: derived.value,
|
||||
report: { served: true, evidence_id: derived.value.evidenceId },
|
||||
}
|
||||
: unavailable(catalog.error);
|
||||
: unavailable(derived.error);
|
||||
};
|
||||
@@ -265,27 +265,6 @@ describe("list answers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("answers an unfiltered call from a paging-only line of the parent its path arguments name", () => {
|
||||
const books = servingFrom([
|
||||
["GET", "/api/orgs/1/books?limit=50", [{ id: 1 }, { id: 2 }]],
|
||||
["GET", "/api/orgs/2/books?limit=50", [{ id: 3 }]],
|
||||
["GET", "/api/orgs/3/books?limit=50", [{ id: 4 }]],
|
||||
]);
|
||||
expect(answer(books, "get_orgs_books_by_id", { id: "2" })).toMatchObject({
|
||||
body: [{ id: 3 }],
|
||||
replay: { record_ordinal: 1, exact_match: false },
|
||||
state: {
|
||||
source: "state",
|
||||
collection: "/api/orgs/2/books",
|
||||
membership: "all",
|
||||
listed: 1,
|
||||
},
|
||||
});
|
||||
expect(answer(books, "get_orgs_books_by_id", { id: "9" })).toMatchObject({
|
||||
state: { source: "recording", reason: "unrecorded-request" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a list whose member lacks a field every recording carried, never answering a partial body", () => {
|
||||
const serving = servingOf();
|
||||
load(serving, (entities) =>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { stateBodyJson } from "../../domain/analysisView/EnvironmentStateAccumul
|
||||
import {
|
||||
collectionPathOf,
|
||||
listItems,
|
||||
missingRequiredFields,
|
||||
renderStateEntity,
|
||||
renderStateList,
|
||||
replaceAt,
|
||||
@@ -15,11 +14,6 @@ import {
|
||||
type StateListView,
|
||||
type StateView,
|
||||
} from "../../domain/analysisView/environmentStateRules.js";
|
||||
import {
|
||||
pageBound,
|
||||
pagesOnly,
|
||||
} from "../../domain/analysisView/environmentStatePaging.js";
|
||||
import { isAbsence } from "../../domain/analysisView/environmentStateWriteChecks.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
@@ -51,11 +45,7 @@ import { environmentToolArguments } from "./environmentTools.js";
|
||||
export type StateProvenance =
|
||||
| {
|
||||
readonly source: "recording";
|
||||
readonly reason:
|
||||
| RecordedViewReason
|
||||
| "no-view"
|
||||
| "unrecorded-request"
|
||||
| "page-exceeded";
|
||||
readonly reason: RecordedViewReason | "no-view" | "unrecorded-request";
|
||||
}
|
||||
| {
|
||||
readonly source: "state";
|
||||
@@ -74,16 +64,8 @@ export type StateProvenance =
|
||||
}
|
||||
| {
|
||||
readonly source: "state";
|
||||
readonly view: "document" | "document-write";
|
||||
readonly view: "document";
|
||||
readonly path: string;
|
||||
}
|
||||
| {
|
||||
readonly source: "state";
|
||||
/** A write applied to the state, or the recorded answer for an entity it does not hold. */
|
||||
readonly view: "create" | "update" | "delete" | "missing";
|
||||
readonly type: string;
|
||||
readonly collection: string;
|
||||
readonly id: string;
|
||||
};
|
||||
|
||||
/** A replay answer that also says whether the state or a recording produced it. */
|
||||
@@ -97,7 +79,7 @@ export interface StateServing {
|
||||
readonly store: EnvironmentStateStore;
|
||||
}
|
||||
|
||||
export const refused = (
|
||||
const refused = (
|
||||
tool: EnvironmentManifestTool,
|
||||
userMessage: string,
|
||||
): AnalysisError =>
|
||||
@@ -112,9 +94,12 @@ const shownEntity = (
|
||||
entity: StateEntity,
|
||||
volatile: ReadonlySet<string>,
|
||||
): Result<JsonObject, AnalysisError> => {
|
||||
const missing = missingRequiredFields(entity, view, volatile);
|
||||
const shown = renderStateEntity(entity, view, volatile);
|
||||
const missing = view.required_fields.filter(
|
||||
(name) => !Object.hasOwn(shown, name),
|
||||
);
|
||||
return missing.length === 0
|
||||
? ok(renderStateEntity(entity, view, volatile))
|
||||
? ok(shown)
|
||||
: err(
|
||||
refused(
|
||||
tool,
|
||||
@@ -137,12 +122,11 @@ const fromRecording = (
|
||||
};
|
||||
|
||||
/** A recorded body the state renders into, usable exactly when extraction could use it. */
|
||||
export const recordedJson = (
|
||||
exchange: RecordedExchange,
|
||||
): JsonValue | undefined => stateBodyJson(exchange.contentType, exchange);
|
||||
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. */
|
||||
export const stateAnswer = (
|
||||
const stateAnswer = (
|
||||
serving: StateServing,
|
||||
base: { readonly exchange: RecordedExchange; readonly exact: boolean },
|
||||
body: JsonValue,
|
||||
@@ -173,22 +157,22 @@ export const stateAnswer = (
|
||||
};
|
||||
|
||||
/** One collection segment an item call names: a template literal, or a supplied path value. */
|
||||
export interface CallSegment {
|
||||
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. */
|
||||
export interface ItemCall {
|
||||
interface ItemCall {
|
||||
readonly segments: readonly CallSegment[];
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
/** A call's path segments: template literals and supplied values, or undefined when a path argument is missing. */
|
||||
export const callSegments = (
|
||||
/** What an item call names, or undefined when a path argument is missing. */
|
||||
const itemCall = (
|
||||
tool: EnvironmentManifestTool,
|
||||
args: ReplayArguments,
|
||||
): CallSegment[] | undefined => {
|
||||
): ItemCall | undefined => {
|
||||
const names = environmentToolArguments(tool).path;
|
||||
const params = new Map(tool.params.map((param) => [param.index, param]));
|
||||
const segments: CallSegment[] = [];
|
||||
@@ -205,23 +189,12 @@ export const callSegments = (
|
||||
if (typeof value !== "string") return undefined;
|
||||
segments.push({ text: value, supplied: true });
|
||||
}
|
||||
return segments;
|
||||
};
|
||||
|
||||
/** What an item call names, or undefined when a path argument is missing. */
|
||||
export const itemCall = (
|
||||
tool: EnvironmentManifestTool,
|
||||
args: ReplayArguments,
|
||||
): ItemCall | undefined => {
|
||||
const segments = callSegments(tool, args);
|
||||
const last = segments?.pop();
|
||||
return segments !== undefined && last?.supplied === true
|
||||
? { segments, id: last.text }
|
||||
: undefined;
|
||||
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. */
|
||||
export const collectionMatch = (
|
||||
const collectionMatch = (
|
||||
collection: string,
|
||||
segments: readonly CallSegment[],
|
||||
): number | undefined => {
|
||||
@@ -238,7 +211,7 @@ export const collectionMatch = (
|
||||
};
|
||||
|
||||
/** The entity an item call names, preferring verbatim collection spellings as replay does; the id is tried percent-decoded, then as written. */
|
||||
export const namedEntity = (
|
||||
const namedEntity = (
|
||||
store: EnvironmentStateStore,
|
||||
type: string,
|
||||
call: ItemCall,
|
||||
@@ -259,60 +232,7 @@ export const namedEntity = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** The answer for an entity the state does not hold: the tool's latest recorded 400, 404 or 410, else a tool error. */
|
||||
export const missingAnswer = (
|
||||
serving: StateServing,
|
||||
tool: EnvironmentManifestTool,
|
||||
missing: {
|
||||
readonly type: string;
|
||||
readonly collection: string;
|
||||
readonly id: string;
|
||||
},
|
||||
): Result<StateAnswer, AnalysisError> => {
|
||||
// A read also answers with a 2xx JSON null, the absence the write gate accepts; a write never does, as a 2xx would
|
||||
// report success for an entity that is not there.
|
||||
const nullAbsence = tool.method === "GET";
|
||||
const latestAbsence = (ordinals: readonly number[]) =>
|
||||
ordinals
|
||||
.map((ordinal) => serving.source.exchanges.get(ordinal))
|
||||
.filter(
|
||||
(exchange): exchange is RecordedExchange =>
|
||||
exchange !== undefined &&
|
||||
(isAbsence(exchange.status) ||
|
||||
(nullAbsence &&
|
||||
exchange.status !== null &&
|
||||
exchange.status >= 200 &&
|
||||
exchange.status < 300 &&
|
||||
recordedJson(exchange) === null)),
|
||||
)
|
||||
.at(-1);
|
||||
// The tool's own recorded absence first, else that of an item view of the type.
|
||||
const itemTools = new Set(
|
||||
[...serving.store.state.views.values()].flatMap((view) =>
|
||||
view.shape === "item" && view.type === missing.type ? [view.tool] : [],
|
||||
),
|
||||
);
|
||||
const recorded =
|
||||
latestAbsence(tool.record_ordinals) ??
|
||||
latestAbsence(
|
||||
serving.source.manifest.tools
|
||||
.filter((candidate) => itemTools.has(candidate.name))
|
||||
.flatMap((candidate) => candidate.record_ordinals),
|
||||
);
|
||||
if (recorded === undefined)
|
||||
return err(
|
||||
refused(
|
||||
tool,
|
||||
`The state holds no ${missing.type} entity ${missing.id} in ${missing.collection}.`,
|
||||
),
|
||||
);
|
||||
return ok({
|
||||
...replayAnswer(serving.source, recorded, false),
|
||||
state: { source: "state", view: "missing", ...missing },
|
||||
});
|
||||
};
|
||||
|
||||
/** An item call renders the named entity into the recorded envelope; it never invents an entity; one the state does not hold answers the tool's recorded absence. */
|
||||
/** An item call renders the named entity into the recorded envelope; it never invents an entity. */
|
||||
const answerItem = (
|
||||
serving: StateServing,
|
||||
tool: EnvironmentManifestTool,
|
||||
@@ -325,11 +245,12 @@ const answerItem = (
|
||||
return err(refused(tool, `Tool ${tool.name} needs every path argument.`));
|
||||
const entity = namedEntity(store, view.type, call);
|
||||
if (entity === undefined)
|
||||
return missingAnswer(serving, tool, {
|
||||
type: view.type,
|
||||
collection: `/${call.segments.map(({ text }) => text).join("/")}`,
|
||||
id: call.id,
|
||||
});
|
||||
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,
|
||||
@@ -376,46 +297,18 @@ const checkedLine = (
|
||||
return selectReplayExchange(tool, lines, args)?.exchange;
|
||||
};
|
||||
|
||||
/** For a list of a whole collection, a call that supplies no query argument answers from a checked line of the collection its path arguments name that only pages it, chosen as replay chooses; a requested page answers from its recording. */
|
||||
const unfilteredLine = (
|
||||
source: EnvironmentSource,
|
||||
tool: EnvironmentManifestTool,
|
||||
checked: readonly number[],
|
||||
args: ReplayArguments,
|
||||
): RecordedExchange | undefined => {
|
||||
const query = environmentToolArguments(tool).query;
|
||||
const segments = callSegments(tool, args);
|
||||
if (
|
||||
segments === undefined ||
|
||||
[...query.values()].some((argument) => args[argument] !== undefined)
|
||||
)
|
||||
return undefined;
|
||||
const lines = new Map<number, RecordedExchange>();
|
||||
for (const ordinal of checked) {
|
||||
const exchange = source.exchanges.get(ordinal);
|
||||
if (
|
||||
exchange !== undefined &&
|
||||
pagesOnly(exchange.query ?? undefined) &&
|
||||
collectionMatch(collectionPathOf(exchange, false), segments) !== undefined
|
||||
)
|
||||
lines.set(ordinal, exchange);
|
||||
}
|
||||
return selectReplayExchange(tool, lines, args)?.exchange;
|
||||
};
|
||||
|
||||
type StateDocumentView = Extract<StateView, { shape: "document" }>;
|
||||
|
||||
/** One tool call: the tool, the arguments it was called with, and whether they name the answering line exactly. */
|
||||
/** One tool call: the tool and the arguments it was called with. */
|
||||
interface ToolCall {
|
||||
readonly tool: EnvironmentManifestTool;
|
||||
readonly args: ReplayArguments;
|
||||
readonly exact: boolean;
|
||||
}
|
||||
|
||||
/** A list call renders the members of the checked line it names from the state. */
|
||||
const answerList = (
|
||||
serving: StateServing,
|
||||
{ tool, args, exact }: ToolCall,
|
||||
{ tool, args }: ToolCall,
|
||||
view: StateListView,
|
||||
base: RecordedExchange,
|
||||
): Result<StateAnswer, AnalysisError> => {
|
||||
@@ -427,16 +320,6 @@ const answerList = (
|
||||
return fromRecording(source, tool, args, "body-unusable");
|
||||
const collection = collectionPathOf(base, false);
|
||||
const type = store.type(view.type);
|
||||
// A requested page the collection has outgrown is answered as recorded, never with more items than it asked for.
|
||||
const size = exact
|
||||
? pageBound(base.query ?? undefined, recorded.length)
|
||||
: undefined;
|
||||
if (
|
||||
view.membership === "all" &&
|
||||
size !== undefined &&
|
||||
store.inCollection(view.type, collection).length > size
|
||||
)
|
||||
return fromRecording(source, tool, args, "page-exceeded");
|
||||
const members: readonly StateEntity[] =
|
||||
view.membership === "all"
|
||||
? store.inCollection(view.type, collection)
|
||||
@@ -457,7 +340,7 @@ const answerList = (
|
||||
}
|
||||
return stateAnswer(
|
||||
serving,
|
||||
{ exchange: base, exact },
|
||||
{ exchange: base, exact: true },
|
||||
renderStateList(view, envelope, items),
|
||||
{
|
||||
source: "state",
|
||||
@@ -493,27 +376,16 @@ const answerCheckedLine = (
|
||||
view: StateListView | StateDocumentView,
|
||||
args: ReplayArguments,
|
||||
): Result<StateAnswer, AnalysisError> => {
|
||||
const named = checkedLine(
|
||||
const base = checkedLine(
|
||||
serving.source,
|
||||
tool,
|
||||
view.checked_record_ordinals,
|
||||
args,
|
||||
);
|
||||
const base =
|
||||
named ??
|
||||
(view.shape === "list" && view.membership === "all"
|
||||
? unfilteredLine(serving.source, tool, view.checked_record_ordinals, args)
|
||||
: undefined);
|
||||
if (base === undefined)
|
||||
return fromRecording(serving.source, tool, args, "unrecorded-request");
|
||||
// An unfiltered call answers on a line it did not name, so its replay match is not exact.
|
||||
return view.shape === "list"
|
||||
? answerList(
|
||||
serving,
|
||||
{ tool, args, exact: named !== undefined },
|
||||
view,
|
||||
base,
|
||||
)
|
||||
? answerList(serving, { tool, args }, view, base)
|
||||
: answerDocument(serving, tool, base);
|
||||
};
|
||||
|
||||
@@ -536,3 +408,18 @@ export const answerFromState = (
|
||||
return answerCheckedLine(serving, tool, view, args);
|
||||
}
|
||||
};
|
||||
|
||||
/** How a served environment answers one tool call. */
|
||||
export type EnvironmentToolAnswer = (
|
||||
tool: EnvironmentManifestTool,
|
||||
args: ReplayArguments,
|
||||
) => Result<ReplayResult, AnalysisError>;
|
||||
|
||||
/** 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);
|
||||
@@ -1,49 +0,0 @@
|
||||
import { collectionPathOf } from "../../domain/analysisView/environmentStateRules.js";
|
||||
import {
|
||||
callSegments,
|
||||
collectionMatch,
|
||||
type StateServing,
|
||||
} from "./environmentStateAnswer.js";
|
||||
import type { ReplayArguments } from "./environmentReplay.js";
|
||||
import type { EnvironmentManifestTool } from "./environmentSource.js";
|
||||
|
||||
/** The collections a create may go to: those the store knows, held ones first, then those the type's checked list lines read, so an empty one keeps its spelling. */
|
||||
const creatableCollectionsOf = (
|
||||
serving: StateServing,
|
||||
type: string,
|
||||
): readonly string[] => {
|
||||
const listed: string[] = [];
|
||||
for (const view of serving.store.state.views.values())
|
||||
if (view.shape === "list" && view.type === type)
|
||||
for (const ordinal of view.checked_record_ordinals) {
|
||||
const exchange = serving.source.exchanges.get(ordinal);
|
||||
if (exchange !== undefined)
|
||||
listed.push(collectionPathOf(exchange, false));
|
||||
}
|
||||
return [...new Set([...serving.store.knownCollectionsOf(type), ...listed])];
|
||||
};
|
||||
|
||||
/** The collection a create writes to: a known collection the call's path matches, else the path as supplied. */
|
||||
export const createdCollection = (
|
||||
serving: StateServing,
|
||||
tool: EnvironmentManifestTool,
|
||||
args: ReplayArguments,
|
||||
type: string,
|
||||
): string | undefined => {
|
||||
const segments = callSegments(tool, args);
|
||||
if (segments === undefined) return undefined;
|
||||
// The spelling the call supplies verbatim wins, as item calls choose their entity, then the first known: held ones lead.
|
||||
let held: string | undefined;
|
||||
let verbatim = -1;
|
||||
for (const collection of creatableCollectionsOf(serving, type)) {
|
||||
const matched = collectionMatch(collection, segments);
|
||||
if (matched !== undefined && matched > verbatim) {
|
||||
held = collection;
|
||||
verbatim = matched;
|
||||
}
|
||||
}
|
||||
return (
|
||||
held ??
|
||||
`/${segments.map(({ text, supplied }) => (supplied ? encodeURIComponent(text) : text)).join("/")}`
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { types } from "node:util";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
same,
|
||||
scalarText,
|
||||
segmentsOf,
|
||||
type JsonObject,
|
||||
type StateDocument,
|
||||
type StateEntity,
|
||||
type StateType,
|
||||
@@ -16,11 +16,10 @@ import {
|
||||
type AnalysisInputIssue,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { canonicalJson } from "../../domain/comparisonSemantics.js";
|
||||
import { parseExactJson } from "../../domain/exactJsonNumber.js";
|
||||
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
||||
import {
|
||||
jsonValueSchema,
|
||||
jsonValueValidationIssue,
|
||||
ownedJsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
@@ -43,37 +42,42 @@ export interface StateContent {
|
||||
readonly documents: readonly HeldDocument[];
|
||||
}
|
||||
|
||||
const fieldsSchema = z.record(z.string(), jsonValueSchema);
|
||||
const ordinalSchema = z.number().int().nonnegative();
|
||||
const stateDocumentSchemaFor = (jsonValue: z.ZodType<JsonValue, unknown>) => {
|
||||
const fieldsSchema = z.record(z.string(), jsonValue);
|
||||
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(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
return 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(jsonValue).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: jsonValue,
|
||||
record_ordinal: ordinalSchema.optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const stateDocumentSchema = stateDocumentSchemaFor(jsonValueSchema);
|
||||
const ownedStateDocumentSchema = stateDocumentSchemaFor(ownedJsonValueSchema);
|
||||
|
||||
type ParsedDocument = z.output<typeof stateDocumentSchema>["rea_state"];
|
||||
type ParsedEntity = ParsedDocument["entities"][number];
|
||||
@@ -111,47 +115,86 @@ const memberProblem = (path: Path): AnalysisInputIssue => {
|
||||
|
||||
/** 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 {
|
||||
interface Cursor {
|
||||
readonly value: unknown;
|
||||
readonly parent: Node | undefined;
|
||||
readonly parent: Cursor | undefined;
|
||||
readonly key: string | number;
|
||||
readonly entries?: Generator<string>;
|
||||
index: number;
|
||||
}
|
||||
const pathOf = (node: Node): (string | number)[] => {
|
||||
const pathOf = (node: Cursor): (string | number)[] => {
|
||||
const path: (string | number)[] = [];
|
||||
for (let at: Node = node; at.parent !== undefined; at = at.parent)
|
||||
for (let at: Cursor = 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()) {
|
||||
function* ownKeys(value: Record<string, unknown>): Generator<string> {
|
||||
// `for...in` is lazy. The ownership check keeps inherited enumerable
|
||||
// properties out, matching Object.entries without allocating all entries.
|
||||
for (const key in value) if (Object.hasOwn(value, key)) yield key;
|
||||
}
|
||||
const cursor = (
|
||||
value: unknown,
|
||||
parent: Cursor | undefined,
|
||||
key: string | number,
|
||||
): Cursor => ({
|
||||
value,
|
||||
parent,
|
||||
key,
|
||||
index: 0,
|
||||
...(!Array.isArray(value) && typeof value === "object" && value !== null
|
||||
? { entries: ownKeys(value as Record<string, unknown>) }
|
||||
: {}),
|
||||
});
|
||||
const pending: Cursor[] = [cursor(root, undefined, "")];
|
||||
while (pending.length > 0) {
|
||||
const node = pending[pending.length - 1]!;
|
||||
const { value } = node;
|
||||
if (typeof value !== "object" || value === null) continue;
|
||||
if (typeof value !== "object" || value === null) {
|
||||
pending.pop();
|
||||
continue;
|
||||
}
|
||||
// The canonical JSON validator reports proxies without invoking their traps.
|
||||
if (types.isProxy(value)) {
|
||||
pending.pop();
|
||||
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],
|
||||
});
|
||||
if (array) {
|
||||
if (node.index >= value.length) {
|
||||
pending.pop();
|
||||
continue;
|
||||
}
|
||||
const index = node.index++;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, index);
|
||||
if (
|
||||
descriptor?.enumerable !== true ||
|
||||
!Object.hasOwn(descriptor, "value")
|
||||
)
|
||||
continue;
|
||||
const child: unknown = descriptor.value;
|
||||
if (typeof child === "object" && child !== null)
|
||||
pending.push(cursor(child, node, index));
|
||||
continue;
|
||||
}
|
||||
const next = node.entries?.next();
|
||||
if (next === undefined || next.done) {
|
||||
pending.pop();
|
||||
continue;
|
||||
}
|
||||
const key = next.value;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor?.enumerable !== true || !Object.hasOwn(descriptor, "value"))
|
||||
continue;
|
||||
const child: unknown = descriptor.value;
|
||||
if (typeof child === "object" && child !== null)
|
||||
pending.push(cursor(child, node, key));
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Whether `data/add` accepts a value as a `fields` member or a document's `value`: bounded nesting, no `__proto__` member, and numbers exact as `data/get` writes them. */
|
||||
export const isLoadableValue = (value: JsonValue): boolean =>
|
||||
jsonValueValidationIssue(value) === undefined &&
|
||||
prototypeMemberPath(value) === undefined &&
|
||||
parseExactJson(JSON.stringify(value)) !== undefined;
|
||||
|
||||
/** The first member of an object `data/add` would refuse as `fields`, if any: a `__proto__` member, or one whose value does not load. */
|
||||
export const unloadableMember = (object: JsonObject): string | undefined =>
|
||||
Object.keys(object).find(
|
||||
(member) => member === "__proto__" || !isLoadableValue(object[member]!),
|
||||
);
|
||||
|
||||
/** The first schema problem, located without naming the caller's data. */
|
||||
const schemaProblem = (
|
||||
issues: readonly z.core.$ZodIssue[],
|
||||
@@ -383,9 +426,12 @@ const heldContent = (
|
||||
export const parseStateDocument = (
|
||||
state: EnvironmentState,
|
||||
value: unknown,
|
||||
owned = false,
|
||||
): Result<StateContent, AnalysisInputError> => {
|
||||
const prototype = prototypeMemberPath(value);
|
||||
const parsed = stateDocumentSchema.safeParse(value);
|
||||
const parsed = (
|
||||
owned ? ownedStateDocumentSchema : stateDocumentSchema
|
||||
).safeParse(value);
|
||||
const initial = new Map(
|
||||
state.entities.map((entity) => [entityKey(entity), entity]),
|
||||
);
|
||||
|
||||
@@ -2,11 +2,7 @@ import type {
|
||||
StateEntity,
|
||||
StateType,
|
||||
} from "../../domain/analysisView/environmentStateRules.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.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";
|
||||
@@ -19,6 +15,13 @@ import {
|
||||
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: {
|
||||
@@ -30,45 +33,10 @@ export interface StateSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
};
|
||||
|
||||
/** The bytes JSON writes for these array items and the commas between them. */
|
||||
const itemsBytes = (items: readonly unknown[]): number =>
|
||||
items.reduce<number>((sum, item) => sum + jsonBytes(item), 0) +
|
||||
Math.max(0, items.length - 1);
|
||||
|
||||
/** What `data/get` reports for some content of a state. */
|
||||
const snapshotOf = (
|
||||
state: EnvironmentState,
|
||||
entities: readonly StateEntity[],
|
||||
documents: readonly HeldDocument[],
|
||||
): StateSnapshot => ({
|
||||
rea_state: {
|
||||
version: 1,
|
||||
state_evidence_id: state.evidenceId,
|
||||
types: state.types,
|
||||
entities,
|
||||
documents,
|
||||
},
|
||||
});
|
||||
|
||||
/** What `data/get` writes for a state as the Evidence holds it, in UTF-8 bytes, summed item by item. */
|
||||
export const initialStateBytes = (state: EnvironmentState): number =>
|
||||
jsonBytes(snapshotOf(state, [], [])) +
|
||||
itemsBytes(state.entities) +
|
||||
itemsBytes(state.documents);
|
||||
|
||||
/**
|
||||
* 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. A mutation that would take
|
||||
* what `data/get` reports past the size `data/add` loads is refused, so that
|
||||
* report always loads back.
|
||||
* state, so every answer reads one consistent state.
|
||||
*/
|
||||
export class EnvironmentStateStore {
|
||||
#entities: ReadonlyMap<string, StateEntity> = new Map();
|
||||
@@ -77,48 +45,37 @@ export class EnvironmentStateStore {
|
||||
#documents: ReadonlyMap<string, HeldDocument> = new Map();
|
||||
#content: StateContent = { entities: [], documents: [] };
|
||||
readonly #types: ReadonlyMap<string, StateType>;
|
||||
readonly #clockStart: number;
|
||||
/** What `data/get` writes for the current state, in UTF-8 bytes; kept by each mutation rather than reserialized. */
|
||||
#bytes = 0;
|
||||
/** The bytes of `data/get` with no entities or documents. */
|
||||
readonly #frameBytes: number;
|
||||
readonly #initialBytes: number;
|
||||
/** Served writes since the state was last reset or loaded: the clock's seconds and the generators' counter. */
|
||||
#writes = 0;
|
||||
/** The largest value each sequence has generated since the last reset or load, so a deleted entity's value is not reused. */
|
||||
readonly #sequences = new Map<string, number>();
|
||||
/** The entities the state held when it was last reset or loaded, whose values no sequence reuses either. */
|
||||
#started: readonly StateEntity[] = [];
|
||||
/** Every collection spelling a type's entities have been held under since the last reset or load, kept when a collection empties. */
|
||||
readonly #knownCollections = new Map<string, string[]>();
|
||||
/** The collections of the Evidence's own state, which stay known after any load. */
|
||||
#evidenceCollections: ReadonlyMap<string, readonly string[]> = new Map();
|
||||
|
||||
constructor(readonly state: EnvironmentState) {
|
||||
this.#types = new Map(
|
||||
state.types.map((type) => [type.collection_template, type]),
|
||||
);
|
||||
this.#clockStart =
|
||||
state.writeClock === null ? 0 : Date.parse(state.writeClock);
|
||||
this.#frameBytes = jsonBytes(snapshotOf(state, [], []));
|
||||
this.#initialBytes = initialStateBytes(state);
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/** Restore the initial state the Evidence holds; the clock and counter start again. */
|
||||
/** Restore the initial state the Evidence holds. */
|
||||
reset(): void {
|
||||
this.#install(this.state, this.#initialBytes);
|
||||
this.#evidenceCollections = this.#typeCollections;
|
||||
this.#restart();
|
||||
this.#install(this.state);
|
||||
}
|
||||
|
||||
/** Validate a `rea-state/v1` document and, only when it is valid, make it the whole state; the clock and counter start again. */
|
||||
/** Validate a caller-owned document and copy its JSON values before retaining them. */
|
||||
replace(document: unknown): Result<void, AnalysisInputError> {
|
||||
const content = parseStateDocument(this.state, document);
|
||||
return this.#replace(document, false);
|
||||
}
|
||||
|
||||
/** Validate and retain a JSON document created exclusively by the serving boundary. */
|
||||
replaceOwnedDocument(document: unknown): Result<void, AnalysisInputError> {
|
||||
return this.#replace(document, true);
|
||||
}
|
||||
|
||||
#replace(
|
||||
document: unknown,
|
||||
owned: boolean,
|
||||
): Result<void, AnalysisInputError> {
|
||||
const content = parseStateDocument(this.state, document, owned);
|
||||
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.
|
||||
const bytes = this.#contentBytes(content.value);
|
||||
if (bytes > STATE_INTAKE_LIMIT_BYTES)
|
||||
if (jsonBytes(this.#snapshotOf(content.value)) > STATE_INTAKE_LIMIT_BYTES)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
ENVIRONMENT_SERVE_OPERATION,
|
||||
@@ -135,163 +92,10 @@ export class EnvironmentStateStore {
|
||||
],
|
||||
),
|
||||
);
|
||||
this.#install(content.value, bytes);
|
||||
this.#restart();
|
||||
this.#install(content.value);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** The write a served call would be: its number since the last reset or load, and its clock instant, one second past the previous applied write. */
|
||||
pendingWrite(): { readonly count: number; readonly clockMs: number } {
|
||||
const count = this.#writes + 1;
|
||||
return { count, clockMs: this.#clockStart + count * 1000 };
|
||||
}
|
||||
|
||||
/** Count an applied write and keep the sequence values it took, so a refused call takes none. */
|
||||
commitWrite(sequences: ReadonlyMap<string, number>): void {
|
||||
this.#writes += 1;
|
||||
for (const [key, value] of sequences)
|
||||
this.#sequences.set(key, Math.max(value, this.#sequences.get(key) ?? 0));
|
||||
}
|
||||
|
||||
/** The next value of a sequence: one past both the largest held value and the largest an applied write took. */
|
||||
sequenceAfter(key: string, held: number): number {
|
||||
return Math.max(held, (this.#sequences.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
#restart(): void {
|
||||
this.#writes = 0;
|
||||
this.#sequences.clear();
|
||||
this.#started = this.#content.entities;
|
||||
// A reset or load knows the Evidence's spellings and its own, never those of a state it replaced.
|
||||
this.#knownCollections.clear();
|
||||
this.#remember(this.#evidenceCollections);
|
||||
this.#remember(this.#typeCollections);
|
||||
}
|
||||
|
||||
#remember(named: ReadonlyMap<string, readonly string[]>): void {
|
||||
for (const [type, collections] of named) {
|
||||
const known = this.#knownCollections.get(type) ?? [];
|
||||
for (const collection of collections)
|
||||
if (!known.includes(collection)) known.push(collection);
|
||||
this.#knownCollections.set(type, known);
|
||||
}
|
||||
}
|
||||
|
||||
/** The entities of a type the state held when it was last reset or loaded. */
|
||||
startedWith(type: string): readonly StateEntity[] {
|
||||
return this.#started.filter((entity) => entity.type === type);
|
||||
}
|
||||
|
||||
/** Every held entity of a type, in state order. */
|
||||
ofType(type: string): readonly StateEntity[] {
|
||||
return this.#content.entities.filter((entity) => entity.type === type);
|
||||
}
|
||||
|
||||
/** Add an entity at `index` of its collection's order, unless the state would outgrow what `data/add` loads. */
|
||||
insert(
|
||||
entity: StateEntity,
|
||||
index: number,
|
||||
): Result<void, AnalysisResourceConstraintError> {
|
||||
const entities = this.#content.entities;
|
||||
const bytes = this.#grown(
|
||||
jsonBytes(entity) + (entities.length > 0 ? 1 : 0),
|
||||
);
|
||||
if (!bytes.ok) return bytes;
|
||||
const members = this.inCollection(entity.type, entity.collection);
|
||||
const anchor =
|
||||
index < members.length
|
||||
? entities.indexOf(members[index]!)
|
||||
: members.length > 0
|
||||
? entities.indexOf(members.at(-1)!) + 1
|
||||
: entities.length;
|
||||
this.#install(
|
||||
{
|
||||
...this.#content,
|
||||
entities: [
|
||||
...entities.slice(0, anchor),
|
||||
entity,
|
||||
...entities.slice(anchor),
|
||||
],
|
||||
},
|
||||
bytes.value,
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** Replace a held entity's values in place, unless the state would outgrow what `data/add` loads. */
|
||||
update(entity: StateEntity): Result<void, AnalysisResourceConstraintError> {
|
||||
const key = compositeKey([entity.type, entity.collection, entity.id]);
|
||||
const held = this.#entities.get(key);
|
||||
if (held === undefined) return ok(undefined);
|
||||
const bytes = this.#grown(jsonBytes(entity) - jsonBytes(held));
|
||||
if (!bytes.ok) return bytes;
|
||||
this.#install(
|
||||
{
|
||||
...this.#content,
|
||||
entities: this.#content.entities.map((candidate) =>
|
||||
candidate === held ? entity : candidate,
|
||||
),
|
||||
},
|
||||
bytes.value,
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
remove(entity: StateEntity): void {
|
||||
const held = this.#entities.get(
|
||||
compositeKey([entity.type, entity.collection, entity.id]),
|
||||
);
|
||||
if (held === undefined) return;
|
||||
const entities = this.#content.entities.filter(
|
||||
(candidate) => candidate !== held,
|
||||
);
|
||||
this.#install(
|
||||
{ ...this.#content, entities },
|
||||
this.#bytes - jsonBytes(held) - (entities.length > 0 ? 1 : 0),
|
||||
);
|
||||
}
|
||||
|
||||
/** Set a held document's value, unless the state would outgrow what `data/add` loads; a value that is not the Evidence's cites no record. */
|
||||
setDocument(
|
||||
tool: string,
|
||||
value: JsonValue,
|
||||
): Result<void, AnalysisResourceConstraintError> {
|
||||
const held = this.#documents.get(tool);
|
||||
if (held === undefined) return ok(undefined);
|
||||
const written = { tool: held.tool, path: held.path, value };
|
||||
const bytes = this.#grown(jsonBytes(written) - jsonBytes(held));
|
||||
if (!bytes.ok) return bytes;
|
||||
this.#install(
|
||||
{
|
||||
...this.#content,
|
||||
documents: this.#content.documents.map((document) =>
|
||||
document === held ? written : document,
|
||||
),
|
||||
},
|
||||
bytes.value,
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** The state's bytes after a change of `delta`, or a refusal when a change that grows it would pass the intake limit. */
|
||||
#grown(delta: number): Result<number, AnalysisResourceConstraintError> {
|
||||
const bytes = this.#bytes + delta;
|
||||
if (delta <= 0 || bytes <= STATE_INTAKE_LIMIT_BYTES) return ok(bytes);
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
ENVIRONMENT_SERVE_OPERATION,
|
||||
"capacity",
|
||||
"The write would take the served state past the size data/add loads",
|
||||
{ max_state_bytes: STATE_INTAKE_LIMIT_BYTES, state_bytes: bytes },
|
||||
{
|
||||
retryable: false,
|
||||
remediationAction:
|
||||
"Delete entities, or restore a smaller state with data/reset or data/add.",
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
type(collectionTemplate: string): StateType | undefined {
|
||||
return this.#types.get(collectionTemplate);
|
||||
}
|
||||
@@ -314,44 +118,28 @@ export class EnvironmentStateStore {
|
||||
return this.#typeCollections.get(type) ?? [];
|
||||
}
|
||||
|
||||
/** Every collection spelling a type's entities have been held under since the last reset or load, or the Evidence's state holds: those holding entities first. */
|
||||
knownCollectionsOf(type: string): readonly string[] {
|
||||
return [
|
||||
...new Set([
|
||||
...this.collectionsOf(type),
|
||||
...(this.#knownCollections.get(type) ?? []),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
document(tool: string): HeldDocument | undefined {
|
||||
return this.#documents.get(tool);
|
||||
}
|
||||
|
||||
snapshot(): StateSnapshot {
|
||||
return snapshotOf(
|
||||
this.state,
|
||||
this.#content.entities,
|
||||
this.#content.documents,
|
||||
);
|
||||
return this.#snapshotOf(this.#content);
|
||||
}
|
||||
|
||||
/** What `data/get` writes for the current state, in UTF-8 bytes. */
|
||||
byteLength(): number {
|
||||
return this.#bytes;
|
||||
}
|
||||
|
||||
/** What `data/get` writes for some content, summed item by item so the whole state is never one string. */
|
||||
#contentBytes(content: StateContent): number {
|
||||
return (
|
||||
this.#frameBytes +
|
||||
itemsBytes(content.entities) +
|
||||
itemsBytes(content.documents)
|
||||
);
|
||||
#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, bytes: number): void {
|
||||
#install(content: StateContent): void {
|
||||
const entities = new Map<string, StateEntity>();
|
||||
const collections = new Map<string, StateEntity[]>();
|
||||
const typeCollections = new Map<string, string[]>();
|
||||
@@ -375,11 +163,9 @@ export class EnvironmentStateStore {
|
||||
this.#entities = entities;
|
||||
this.#collections = collections;
|
||||
this.#typeCollections = typeCollections;
|
||||
this.#remember(typeCollections);
|
||||
this.#documents = new Map(
|
||||
content.documents.map((document) => [document.tool, document]),
|
||||
);
|
||||
this.#content = content;
|
||||
this.#bytes = bytes;
|
||||
}
|
||||
}
|
||||
@@ -1,826 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
servedFrom,
|
||||
type Served,
|
||||
} from "../../../tests/fixtures/environmentStateServing.js";
|
||||
import {
|
||||
BOOK_INIT_ROWS,
|
||||
BOOK_NOT_FOUND,
|
||||
BOOK_ROWS,
|
||||
DELETE_NOT_FOUND,
|
||||
FIRST_BOOK,
|
||||
SECOND_BOOK,
|
||||
THIRD_BOOK,
|
||||
book,
|
||||
bookClock,
|
||||
renamedBook,
|
||||
type Book,
|
||||
} from "../../../tests/fixtures/environmentStateWrites.js";
|
||||
import type { TimedCaptureRow } from "../../../tests/fixtures/analysisView.js";
|
||||
import { seededUuid } from "../../domain/analysisView/environmentStateWriteValues.js";
|
||||
import type { ReplayArguments } from "./environmentReplay.js";
|
||||
import { servedCatalog } from "./environmentState.js";
|
||||
import {
|
||||
environmentToolArguments,
|
||||
environmentToolDefinitions,
|
||||
} from "./environmentTools.js";
|
||||
|
||||
/** The book the first served create of "New Book" makes: the next id, one second past the served clock's start. */
|
||||
const NEW_BOOK = book(3, "New Book", bookClock(13));
|
||||
const CREATE_NEW_BOOK = { body: { name: "New Book" } };
|
||||
|
||||
const entityOf = (books: Served, id: string) =>
|
||||
books.store.snapshot().rea_state.entities.find((entity) => entity.id === id);
|
||||
|
||||
/** Load what data/get reports back through data/add: the state must come back byte for byte. */
|
||||
const expectReloadsUnchanged = (books: Served): void => {
|
||||
const before = books.snapshot();
|
||||
expect(books.store.replace(JSON.parse(before)).ok).toBe(true);
|
||||
expect(books.snapshot()).toBe(before);
|
||||
};
|
||||
|
||||
describe("served creates", () => {
|
||||
it("refuses an exhausted generated-value collision search without changing state", () => {
|
||||
const existing = {
|
||||
id: "123e4567-e89b-42d3-a456-426614174000",
|
||||
ref: "223e4567-e89b-42d3-a456-426614174000",
|
||||
name: "Existing",
|
||||
};
|
||||
const created = {
|
||||
id: "323e4567-e89b-42d3-a456-426614174000",
|
||||
ref: "423e4567-e89b-42d3-a456-426614174000",
|
||||
name: "Created",
|
||||
};
|
||||
const books = servedFrom([
|
||||
["GET", "/api/items", [existing]],
|
||||
["GET", `/api/items/${existing.id}`, existing],
|
||||
["POST", "/api/items", created, 201, { name: "Created" }],
|
||||
["GET", `/api/items/${created.id}`, created],
|
||||
["GET", "/api/items", [existing, created]],
|
||||
]);
|
||||
const plan = books.store.state.writes.get("post_items");
|
||||
if (plan?.served !== true)
|
||||
throw new Error("Expected post_items to be served");
|
||||
const rule = plan.rules.find((candidate) => candidate.rule === "unique");
|
||||
expect(rule).toMatchObject({ field: "ref", rule: "unique" });
|
||||
if (rule?.rule !== "unique") throw new Error("Expected a unique ref rule");
|
||||
const candidates = Array.from({ length: 65 }, (_, attempt) =>
|
||||
seededUuid(
|
||||
`${books.state.evidenceId}:${plan.tool}:${rule.field}:1:${String(attempt)}`,
|
||||
created.ref,
|
||||
),
|
||||
);
|
||||
const snapshot = books.store.snapshot();
|
||||
const collisions = candidates.map((ref, index) => ({
|
||||
type: "/api/items",
|
||||
collection: "/api/items",
|
||||
id: `collision-${String(index)}`,
|
||||
fields: { id: `collision-${String(index)}`, ref, name: "Collision" },
|
||||
view_fields: [],
|
||||
record_ordinals: [],
|
||||
}));
|
||||
const loaded = {
|
||||
...snapshot,
|
||||
rea_state: {
|
||||
...snapshot.rea_state,
|
||||
entities: [...snapshot.rea_state.entities, ...collisions],
|
||||
},
|
||||
};
|
||||
const replaced = books.store.replace(loaded);
|
||||
if (!replaced.ok) throw new Error(JSON.stringify(replaced.error));
|
||||
const before = books.snapshot();
|
||||
const pending = books.store.pendingWrite();
|
||||
|
||||
const refusal = books.refusal(plan.tool, { body: { name: "Created" } });
|
||||
|
||||
expect(JSON.stringify(refusal)).toContain(
|
||||
"Could not generate a unique value for ref after 64 attempts",
|
||||
);
|
||||
expect(books.snapshot()).toBe(before);
|
||||
expect(books.store.pendingWrite()).toEqual(pending);
|
||||
});
|
||||
it("creates a book with the next id, the served clock and a derived slug, which later reads show", () => {
|
||||
const books = servedFrom();
|
||||
const created = books.answer("post_books", CREATE_NEW_BOOK);
|
||||
expect(created).toMatchObject({
|
||||
status: 201,
|
||||
content_type: "application/json",
|
||||
state: {
|
||||
source: "state",
|
||||
view: "create",
|
||||
type: "/api/books",
|
||||
collection: "/api/books",
|
||||
id: "3",
|
||||
},
|
||||
});
|
||||
expect(created.body).toEqual(NEW_BOOK);
|
||||
expect(books.answer("get_books_by_id", { id: "3" })).toMatchObject({
|
||||
status: 200,
|
||||
body: NEW_BOOK,
|
||||
state: { source: "state", view: "item", id: "3" },
|
||||
});
|
||||
expect(books.answer("get_books")).toMatchObject({
|
||||
body: [FIRST_BOOK, SECOND_BOOK, NEW_BOOK],
|
||||
state: { source: "state", view: "list", membership: "all", listed: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a created book in data/get without record citations and loads it back unchanged", () => {
|
||||
const books = servedFrom();
|
||||
books.answer("post_books", CREATE_NEW_BOOK);
|
||||
expect(entityOf(books, "3")).toEqual({
|
||||
type: "/api/books",
|
||||
collection: "/api/books",
|
||||
id: "3",
|
||||
fields: NEW_BOOK,
|
||||
view_fields: [],
|
||||
record_ordinals: [],
|
||||
});
|
||||
expectReloadsUnchanged(books);
|
||||
expect(books.answer("get_books_by_id", { id: "3" }).body).toEqual(NEW_BOOK);
|
||||
});
|
||||
});
|
||||
|
||||
describe("served updates and deletes", () => {
|
||||
it("keeps an entity's identity when the application ignored the id an update sent", () => {
|
||||
const renamed = renamedBook(FIRST_BOOK, "Renamed Book", 3);
|
||||
const books = servedFrom([
|
||||
...BOOK_INIT_ROWS,
|
||||
[
|
||||
"PATCH",
|
||||
"/api/books/1",
|
||||
renamed,
|
||||
200,
|
||||
{ id: 999, name: "Renamed Book" },
|
||||
],
|
||||
["GET", "/api/books/1", renamed],
|
||||
]);
|
||||
const answer = books.answer("patch_books_by_id", {
|
||||
id: "2",
|
||||
body: { id: 999, name: "Second Edition" },
|
||||
});
|
||||
expect(answer.body).toMatchObject({ id: 2, name: "Second Edition" });
|
||||
expect(books.answer("get_books_by_id", { id: "2" }).body).toMatchObject({
|
||||
id: 2,
|
||||
});
|
||||
});
|
||||
|
||||
const SECOND_EDITION = renamedBook(SECOND_BOOK, "Second Edition", 13);
|
||||
const RENAME_SECOND = { id: "2", body: { name: "Second Edition" } };
|
||||
|
||||
it("changes only the sent members, the slug that follows them and the clock", () => {
|
||||
const books = servedFrom();
|
||||
expect(books.answer("patch_books_by_id", RENAME_SECOND)).toMatchObject({
|
||||
status: 200,
|
||||
body: SECOND_EDITION,
|
||||
state: { source: "state", view: "update", id: "2" },
|
||||
});
|
||||
expect(books.answer("get_books_by_id", { id: "2" }).body).toEqual(
|
||||
SECOND_EDITION,
|
||||
);
|
||||
expect(books.answer("get_books").body).toEqual([
|
||||
FIRST_BOOK,
|
||||
SECOND_EDITION,
|
||||
]);
|
||||
});
|
||||
|
||||
it("cites no records for an updated book in data/get, so it loads back unchanged", () => {
|
||||
const books = servedFrom();
|
||||
books.answer("patch_books_by_id", RENAME_SECOND);
|
||||
expect(entityOf(books, "2")).toMatchObject({
|
||||
fields: SECOND_EDITION,
|
||||
record_ordinals: [],
|
||||
});
|
||||
expectReloadsUnchanged(books);
|
||||
});
|
||||
|
||||
it("removes a deleted book; a later read answers the recorded 404 as a missing entity", () => {
|
||||
const books = servedFrom();
|
||||
expect(books.answer("delete_books_by_id", { id: "2" })).toMatchObject({
|
||||
status: 204,
|
||||
body: null,
|
||||
state: { source: "state", view: "delete", id: "2" },
|
||||
});
|
||||
expect(books.answer("get_books_by_id", { id: "2" })).toMatchObject({
|
||||
status: 404,
|
||||
body: BOOK_NOT_FOUND,
|
||||
state: {
|
||||
source: "state",
|
||||
view: "missing",
|
||||
type: "/api/books",
|
||||
collection: "/api/books",
|
||||
id: "2",
|
||||
},
|
||||
});
|
||||
expect(books.answer("get_books").body).toEqual([FIRST_BOOK]);
|
||||
});
|
||||
|
||||
it("answers an update or delete of a missing book with a recorded 404, else refuses it", () => {
|
||||
const books = servedFrom();
|
||||
const before = books.snapshot();
|
||||
// The update recorded no absence, so it answers the item view's; the delete answers its own.
|
||||
expect(
|
||||
books.answer("patch_books_by_id", { id: "99", body: { name: "Ghost" } }),
|
||||
).toMatchObject({
|
||||
status: 404,
|
||||
body: BOOK_NOT_FOUND,
|
||||
state: { view: "missing", id: "99" },
|
||||
});
|
||||
expect(books.answer("delete_books_by_id", { id: "99" })).toMatchObject({
|
||||
status: 404,
|
||||
body: DELETE_NOT_FOUND,
|
||||
state: { view: "missing", id: "99" },
|
||||
});
|
||||
expect(books.snapshot()).toBe(before);
|
||||
const unrecorded = servedFrom(
|
||||
BOOK_ROWS.filter(([, , , status]) => status !== 404),
|
||||
);
|
||||
for (const [name, args] of [
|
||||
["patch_books_by_id", { id: "99", body: { name: "Ghost" } }],
|
||||
["delete_books_by_id", { id: "99" }],
|
||||
] as const)
|
||||
expect(unrecorded.refusal(name, args)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
message: expect.stringContaining(
|
||||
"holds no /api/books entity 99 in /api/books",
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusals", () => {
|
||||
it("refuses a body the capture does not support and leaves the state unchanged", () => {
|
||||
const books = servedFrom();
|
||||
const before = books.snapshot();
|
||||
const refused: [string, ReplayArguments, string][] = [
|
||||
[
|
||||
"post_books",
|
||||
{ body: { name: "Extra", color: "red" } },
|
||||
"Member color is not one the capture showed stored",
|
||||
],
|
||||
["post_books", { body: {} }, "lacks required members name"],
|
||||
[
|
||||
"post_books",
|
||||
{ body: { name: 42 } },
|
||||
"Member name must be a JSON string",
|
||||
],
|
||||
["post_books", { body: ["New Book"] }, "takes a JSON object body"],
|
||||
[
|
||||
"patch_books_by_id",
|
||||
{ id: "1", body: "New Book" },
|
||||
"takes a JSON object body",
|
||||
],
|
||||
["patch_books_by_id", { id: "1", body: {} }, "sends none of the members"],
|
||||
[
|
||||
"patch_books_by_id",
|
||||
{ id: "1", body: { title: "New" } },
|
||||
"Member title is not one",
|
||||
],
|
||||
];
|
||||
for (const [name, args, message] of refused) {
|
||||
expect(books.refusal(name, args)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
message: expect.stringContaining(message),
|
||||
});
|
||||
expect(books.snapshot()).toBe(before);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a member the application stored differently only with the value the capture sent", () => {
|
||||
const bound = (entry: Book, format: string): Book => ({ ...entry, format });
|
||||
const books = servedFrom([
|
||||
[
|
||||
"GET",
|
||||
"/api/books",
|
||||
[bound(FIRST_BOOK, "PAPERBACK"), bound(SECOND_BOOK, "PAPERBACK")],
|
||||
],
|
||||
["GET", "/api/books/1", bound(FIRST_BOOK, "PAPERBACK")],
|
||||
["GET", "/api/books/2", bound(SECOND_BOOK, "PAPERBACK")],
|
||||
[
|
||||
"POST",
|
||||
"/api/books",
|
||||
bound(THIRD_BOOK, "HARDCOVER"),
|
||||
201,
|
||||
{ name: "Third Book", format: "hardcover" },
|
||||
],
|
||||
["GET", "/api/books/3", bound(THIRD_BOOK, "HARDCOVER")],
|
||||
]);
|
||||
const before = books.snapshot();
|
||||
expect(
|
||||
books.refusal("post_books", {
|
||||
body: { name: "Soft", format: "softcover" },
|
||||
}),
|
||||
).toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
"Member format is accepted only with the value the capture sent",
|
||||
),
|
||||
});
|
||||
expect(books.snapshot()).toBe(before);
|
||||
expect(
|
||||
books.answer("post_books", {
|
||||
body: { name: "Hard", format: "hardcover" },
|
||||
}).body,
|
||||
).toMatchObject({ id: 3, name: "Hard", format: "HARDCOVER" });
|
||||
});
|
||||
|
||||
it("generates the same values after a refused call as without it", () => {
|
||||
const refusedFirst = servedFrom();
|
||||
refusedFirst.refusal("post_books", { body: {} });
|
||||
refusedFirst.refusal("patch_books_by_id", {
|
||||
id: "1",
|
||||
body: { title: "New" },
|
||||
});
|
||||
expect(refusedFirst.answer("post_books", CREATE_NEW_BOOK).body).toEqual(
|
||||
servedFrom().answer("post_books", CREATE_NEW_BOOK).body,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated values", () => {
|
||||
it("answers the same calls identically after a reset, never reusing a deleted id", () => {
|
||||
const books = servedFrom();
|
||||
const session = () => {
|
||||
const answers = [
|
||||
books.answer("post_books", CREATE_NEW_BOOK),
|
||||
books.answer("patch_books_by_id", {
|
||||
id: "3",
|
||||
body: { name: "Newer Book" },
|
||||
}),
|
||||
books.answer("delete_books_by_id", { id: "3" }),
|
||||
books.answer("post_books", { body: { name: "Another Book" } }),
|
||||
books.answer("get_books"),
|
||||
];
|
||||
return { answers, text: JSON.stringify([answers, books.snapshot()]) };
|
||||
};
|
||||
const first = session();
|
||||
books.store.reset();
|
||||
expect(session().text).toBe(first.text);
|
||||
expect(first.answers[3]?.body).toMatchObject({
|
||||
id: 4,
|
||||
name: "Another Book",
|
||||
});
|
||||
});
|
||||
|
||||
it("restarts the clock and the sequences when data/add loads a state", () => {
|
||||
const books = servedFrom();
|
||||
const initial: unknown = JSON.parse(books.snapshot());
|
||||
const created = books.answer("post_books", CREATE_NEW_BOOK);
|
||||
books.answer("post_books", { body: { name: "Another Book" } });
|
||||
expect(books.store.replace(initial).ok).toBe(true);
|
||||
// Had the sequence and clock run on, this create would take id 5 one second later.
|
||||
expect(books.answer("post_books", CREATE_NEW_BOOK).body).toEqual(
|
||||
created.body,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("served catalog", () => {
|
||||
it("adds only the served write tools, saying what each does to the state", () => {
|
||||
const { source, state } = servedFrom();
|
||||
const catalog = servedCatalog(source, state, "books");
|
||||
if (!catalog.ok) throw catalog.error;
|
||||
const described = new Map(
|
||||
catalog.value.tools.map((tool) => [tool.name, tool]),
|
||||
);
|
||||
// The export write targets no state type, so it is not served.
|
||||
expect([...described.keys()].sort()).toEqual([
|
||||
"delete_books_by_id",
|
||||
"get_books",
|
||||
"get_books_by_id",
|
||||
"patch_books_by_id",
|
||||
"post_books",
|
||||
]);
|
||||
expect(described.get("post_books")?.description).toContain(
|
||||
"creates an entity of /api/books in the served environment state (body members name; required: name), modelled on the recorded calls",
|
||||
);
|
||||
expect(described.get("patch_books_by_id")?.description).toContain(
|
||||
"changes the entity the path names in the served environment state (body members name; send at least one)",
|
||||
);
|
||||
expect(described.get("delete_books_by_id")?.description).toContain(
|
||||
"removes the entity the path names from the served environment state",
|
||||
);
|
||||
expect(described.get("post_books")?.annotations.readOnlyHint).toBe(false);
|
||||
expect(described.get("get_books")?.description).toContain(
|
||||
"answers from the served environment state where the state backs this tool",
|
||||
);
|
||||
});
|
||||
|
||||
it("serves every manifest tool unchanged in replay mode", () => {
|
||||
const { source } = servedFrom();
|
||||
const replay = servedCatalog(source, undefined, "books");
|
||||
const definitions = environmentToolDefinitions(source, "books");
|
||||
if (!replay.ok || !definitions.ok) throw new Error("Expected a catalog");
|
||||
expect(replay.value).toEqual({
|
||||
tools: definitions.value,
|
||||
dataPlane: "none",
|
||||
});
|
||||
expect(replay.value.tools.map(({ name }) => name)).toContain(
|
||||
"post_exports",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("paged list lines", () => {
|
||||
it("answers an argument-less list from the state on a line that only pages, and another page from its recording", () => {
|
||||
const paged = servedFrom([
|
||||
["GET", "/api/books?limit=50", [FIRST_BOOK, SECOND_BOOK]],
|
||||
...BOOK_INIT_ROWS.slice(1),
|
||||
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
||||
["GET", "/api/books/3", THIRD_BOOK],
|
||||
["GET", "/api/books?limit=50", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]],
|
||||
]);
|
||||
const created = paged.answer("post_books", CREATE_NEW_BOOK).body;
|
||||
expect(paged.answer("get_books")).toMatchObject({
|
||||
body: [FIRST_BOOK, SECOND_BOOK, created],
|
||||
replay: { record_ordinal: 0, exact_match: false },
|
||||
state: { source: "state", view: "list", listed: 3 },
|
||||
});
|
||||
expect(paged.answer("get_books", { limit: "10" })).toMatchObject({
|
||||
body: [FIRST_BOOK, SECOND_BOOK],
|
||||
state: { source: "recording", reason: "unrecorded-request" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("path values", () => {
|
||||
const orgBook = (id: number, org: string, name: string): Book => ({
|
||||
id,
|
||||
org,
|
||||
name,
|
||||
});
|
||||
const ALPHA = orgBook(1, "my org1", "Alpha");
|
||||
const BETA = orgBook(2, "acme2", "Beta");
|
||||
const GAMMA = orgBook(3, "my org1", "Gamma");
|
||||
const DELTA = orgBook(4, "acme2", "Delta");
|
||||
/** Books under two organisations, one whose name the paths percent-encode, and a create under each. */
|
||||
const ROWS: readonly TimedCaptureRow[] = [
|
||||
["GET", "/api/orgs/my%20org1/books", [ALPHA]],
|
||||
["GET", "/api/orgs/acme2/books", [BETA]],
|
||||
["GET", "/api/orgs/my%20org1/books/1", ALPHA],
|
||||
["GET", "/api/orgs/acme2/books/2", BETA],
|
||||
["POST", "/api/orgs/my%20org1/books", GAMMA, 201, { name: "Gamma" }],
|
||||
["GET", "/api/orgs/my%20org1/books", [ALPHA, GAMMA]],
|
||||
["POST", "/api/orgs/acme2/books", DELTA, 201, { name: "Delta" }],
|
||||
["GET", "/api/orgs/acme2/books", [BETA, DELTA]],
|
||||
];
|
||||
|
||||
it("stores a parent path value decoded, as the gate learned it, whichever spelling the call uses", () => {
|
||||
for (const spelling of ["my%20org1", "my org1"]) {
|
||||
const books = servedFrom(ROWS);
|
||||
const tool = books.source.manifest.tools.find(
|
||||
(candidate) => candidate.method === "POST",
|
||||
)!;
|
||||
expect(books.state.writes.get(tool.name)).toMatchObject({
|
||||
served: true,
|
||||
rules: expect.arrayContaining([
|
||||
expect.objectContaining({ field: "org", rule: "path" }),
|
||||
]),
|
||||
});
|
||||
const [org] = environmentToolArguments(tool).path.values();
|
||||
books.answer(tool.name, { [org!]: spelling, body: { name: "Epsilon" } });
|
||||
const created = books.store
|
||||
.snapshot()
|
||||
.rea_state.entities.find(
|
||||
(entity) => entity.fields["name"] === "Epsilon",
|
||||
);
|
||||
expect(created).toMatchObject({
|
||||
collection: "/api/orgs/my%20org1/books",
|
||||
fields: { org: "my org1" },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("collections a create chooses", () => {
|
||||
const orgBook = (id: number, name: string): Book => ({ id, name });
|
||||
/** One organisation listed under two spellings and another organisation, each with a create. */
|
||||
const ROWS: readonly TimedCaptureRow[] = [
|
||||
["GET", "/api/orgs/%61cme1/books", [orgBook(1, "Alpha")]],
|
||||
["GET", "/api/orgs/acme1/books", [orgBook(2, "Beta")]],
|
||||
["GET", "/api/orgs/beta2/books", [orgBook(3, "Gamma")]],
|
||||
[
|
||||
"POST",
|
||||
"/api/orgs/acme1/books",
|
||||
orgBook(4, "Delta"),
|
||||
201,
|
||||
{ name: "Delta" },
|
||||
],
|
||||
["GET", "/api/orgs/acme1/books", [orgBook(2, "Beta"), orgBook(4, "Delta")]],
|
||||
[
|
||||
"POST",
|
||||
"/api/orgs/beta2/books",
|
||||
orgBook(5, "Epsilon"),
|
||||
201,
|
||||
{ name: "Epsilon" },
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/orgs/beta2/books",
|
||||
[orgBook(3, "Gamma"), orgBook(5, "Epsilon")],
|
||||
],
|
||||
];
|
||||
|
||||
it("creates in the collection the call spells verbatim, as item calls choose their entity", () => {
|
||||
const books = servedFrom(ROWS);
|
||||
const tool = books.source.manifest.tools.find(
|
||||
(candidate) => candidate.method === "POST",
|
||||
)!;
|
||||
expect(books.state.writes.get(tool.name)).toMatchObject({ served: true });
|
||||
const [org] = environmentToolArguments(tool).path.values();
|
||||
books.answer(tool.name, { [org!]: "acme1", body: { name: "Zeta" } });
|
||||
expect(
|
||||
books.store
|
||||
.snapshot()
|
||||
.rea_state.entities.find((entity) => entity.fields["name"] === "Zeta")
|
||||
?.collection,
|
||||
).toBe("/api/orgs/acme1/books");
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing entities after a delete", () => {
|
||||
it("answers with a recorded 2xx null, which the gate also took as an absence", () => {
|
||||
const books = servedFrom([
|
||||
...BOOK_INIT_ROWS,
|
||||
["DELETE", "/api/books/2", "", 204],
|
||||
["GET", "/api/books/2", null],
|
||||
]);
|
||||
expect(books.state.writes.get("delete_books_by_id")).toMatchObject({
|
||||
served: true,
|
||||
});
|
||||
books.answer("delete_books_by_id", { id: "1" });
|
||||
expect(books.answer("get_books_by_id", { id: "1" })).toMatchObject({
|
||||
status: 200,
|
||||
body: null,
|
||||
state: { view: "missing", id: "1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("never answers a write of a missing entity with success, even when the write recorded a 2xx null", () => {
|
||||
const books = servedFrom([
|
||||
...BOOK_INIT_ROWS,
|
||||
["DELETE", "/api/books/2", null, 200],
|
||||
["GET", "/api/books/2", BOOK_NOT_FOUND, 404],
|
||||
]);
|
||||
expect(books.state.writes.get("delete_books_by_id")).toMatchObject({
|
||||
served: true,
|
||||
});
|
||||
expect(books.answer("delete_books_by_id", { id: "9" })).toMatchObject({
|
||||
status: 404,
|
||||
body: BOOK_NOT_FOUND,
|
||||
state: { view: "missing", id: "9" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("values the state started with", () => {
|
||||
it("never reuses a seeded id for a create, even after its entity is deleted first", () => {
|
||||
const books = servedFrom();
|
||||
books.answer("delete_books_by_id", { id: "2" });
|
||||
books.answer("post_books", CREATE_NEW_BOOK);
|
||||
expect(entityOf(books, "2")).toBeUndefined();
|
||||
expect(entityOf(books, "3")?.fields).toMatchObject({ name: "New Book" });
|
||||
});
|
||||
|
||||
const orgBook = (id: number, org: string, name: string): Book => ({
|
||||
id,
|
||||
org,
|
||||
name,
|
||||
});
|
||||
/** Books under two organisations, one spelled `first` in its paths, with creates and a delete the lists confirm. */
|
||||
const twoOrgs = (first: string, org: string) => {
|
||||
const alpha = orgBook(1, org, "Alpha");
|
||||
const beta = orgBook(2, "acme2", "Beta");
|
||||
const gamma = orgBook(3, org, "Gamma");
|
||||
const delta = orgBook(4, "acme2", "Delta");
|
||||
const books = servedFrom([
|
||||
["GET", `/api/orgs/${first}/books`, [alpha]],
|
||||
["GET", "/api/orgs/acme2/books", [beta]],
|
||||
["GET", `/api/orgs/${first}/books/1`, alpha],
|
||||
["GET", "/api/orgs/acme2/books/2", beta],
|
||||
["POST", `/api/orgs/${first}/books`, gamma, 201, { name: "Gamma" }],
|
||||
["GET", `/api/orgs/${first}/books`, [alpha, gamma]],
|
||||
["POST", "/api/orgs/acme2/books", delta, 201, { name: "Delta" }],
|
||||
["GET", "/api/orgs/acme2/books", [beta, delta]],
|
||||
["DELETE", `/api/orgs/${first}/books/3`, "", 204],
|
||||
["GET", `/api/orgs/${first}/books`, [alpha]],
|
||||
]);
|
||||
const toolOf = (method: string) =>
|
||||
books.source.manifest.tools.find(
|
||||
(candidate) => candidate.method === method,
|
||||
)!;
|
||||
const create = (value: string, name: string): string | undefined => {
|
||||
const tool = toolOf("POST");
|
||||
const [parent] = environmentToolArguments(tool).path.values();
|
||||
books.answer(tool.name, { [parent!]: value, body: { name } });
|
||||
return books.store
|
||||
.snapshot()
|
||||
.rea_state.entities.find((entity) => entity.fields["name"] === name)
|
||||
?.collection;
|
||||
};
|
||||
return { books, toolOf, create };
|
||||
};
|
||||
|
||||
it("creates in a collection's recorded spelling after the collection empties", () => {
|
||||
const { books, toolOf, create } = twoOrgs("my%20org1", "my org1");
|
||||
const remove = toolOf("DELETE");
|
||||
const [parent, id] = environmentToolArguments(remove).path.values();
|
||||
for (const held of ["1", "3"])
|
||||
books.answer(remove.name, { [parent!]: "my%20org1", [id!]: held });
|
||||
expect(books.store.collectionsOf("/api/orgs/{org}/books")).not.toContain(
|
||||
"/api/orgs/my%20org1/books",
|
||||
);
|
||||
expect(create("my%20org1", "Epsilon")).toBe("/api/orgs/my%20org1/books");
|
||||
});
|
||||
|
||||
it("creates in the Evidence's spelling of a collection a loaded state does not hold", () => {
|
||||
const { books, create } = twoOrgs("my%20org1", "my org1");
|
||||
const { rea_state: held } = books.store.snapshot();
|
||||
expect(
|
||||
books.store.replace({
|
||||
rea_state: {
|
||||
version: held.version,
|
||||
entities: held.entities.filter(
|
||||
(entity) => !entity.collection.includes("%20"),
|
||||
),
|
||||
documents: held.documents,
|
||||
},
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
expect(create("my%20org1", "Epsilon")).toBe("/api/orgs/my%20org1/books");
|
||||
});
|
||||
|
||||
it("creates in the loaded state's collection when the Evidence spells the same one differently", () => {
|
||||
const { books, create } = twoOrgs("%61cme1", "acme1");
|
||||
const { rea_state: held } = books.store.snapshot();
|
||||
expect(
|
||||
books.store.replace({
|
||||
rea_state: {
|
||||
version: held.version,
|
||||
entities: held.entities.map((entity) =>
|
||||
entity.collection.includes("%61cme1")
|
||||
? {
|
||||
...entity,
|
||||
collection: entity.collection.replace("%61cme1", "a%63me1"),
|
||||
record_ordinals: [],
|
||||
}
|
||||
: entity,
|
||||
),
|
||||
documents: held.documents,
|
||||
},
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
// Both spellings decode to the argument; the one holding books wins over the Evidence's emptied one.
|
||||
expect(create("acme1", "Epsilon")).toBe("/api/orgs/a%63me1/books");
|
||||
});
|
||||
|
||||
it("creates in the spelling of a collection the capture listed empty", () => {
|
||||
const beta = orgBook(2, "acme2", "Beta");
|
||||
const gamma = orgBook(3, "my org1", "Gamma");
|
||||
const delta = orgBook(4, "acme2", "Delta");
|
||||
// The state starts before the first create, when the encoded organisation's list was empty.
|
||||
const books = servedFrom([
|
||||
["GET", "/api/orgs/my%20org1/books", []],
|
||||
["GET", "/api/orgs/acme2/books", [beta]],
|
||||
["GET", "/api/orgs/acme2/books/2", beta],
|
||||
["POST", "/api/orgs/my%20org1/books", gamma, 201, { name: "Gamma" }],
|
||||
["GET", "/api/orgs/my%20org1/books", [gamma]],
|
||||
["POST", "/api/orgs/acme2/books", delta, 201, { name: "Delta" }],
|
||||
["GET", "/api/orgs/acme2/books", [beta, delta]],
|
||||
]);
|
||||
const tool = books.source.manifest.tools.find(
|
||||
(candidate) => candidate.method === "POST",
|
||||
)!;
|
||||
const [parent] = environmentToolArguments(tool).path.values();
|
||||
books.answer(tool.name, {
|
||||
[parent!]: "my%20org1",
|
||||
body: { name: "Epsilon" },
|
||||
});
|
||||
expect(
|
||||
books.store
|
||||
.snapshot()
|
||||
.rea_state.entities.find(
|
||||
(entity) => entity.fields["name"] === "Epsilon",
|
||||
)?.collection,
|
||||
).toBe("/api/orgs/my%20org1/books");
|
||||
});
|
||||
|
||||
it("forgets the spellings of a loaded state once reset", () => {
|
||||
const { books, create } = twoOrgs("%61cme1", "acme1");
|
||||
const { rea_state: held } = books.store.snapshot();
|
||||
// A loaded state spells the first organisation plainly; the Evidence's state spells it encoded.
|
||||
expect(
|
||||
books.store.replace({
|
||||
rea_state: {
|
||||
version: held.version,
|
||||
// Moved to another spelling, the entity cites none of the Evidence's records.
|
||||
entities: held.entities.map((entity) =>
|
||||
entity.collection.includes("%61cme1")
|
||||
? {
|
||||
...entity,
|
||||
collection: entity.collection.replace("%61cme1", "acme1"),
|
||||
record_ordinals: [],
|
||||
}
|
||||
: entity,
|
||||
),
|
||||
documents: held.documents,
|
||||
},
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
books.store.reset();
|
||||
expect(create("acme1", "Epsilon")).toBe("/api/orgs/%61cme1/books");
|
||||
});
|
||||
});
|
||||
|
||||
describe("paged lines", () => {
|
||||
it("answers a requested page the collection has outgrown from its recording, and a call with no query from the state", () => {
|
||||
const books = servedFrom([
|
||||
["GET", "/api/books?limit=2", [FIRST_BOOK, SECOND_BOOK]],
|
||||
["GET", "/api/books/1", FIRST_BOOK],
|
||||
["GET", "/api/books/2", SECOND_BOOK],
|
||||
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
||||
// The application answers the same page with two books; serving would not answer it from the state.
|
||||
["GET", "/api/books?limit=2", [FIRST_BOOK, SECOND_BOOK]],
|
||||
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]],
|
||||
]);
|
||||
const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book;
|
||||
expect(books.answer("get_books", { limit: "2" })).toMatchObject({
|
||||
body: [FIRST_BOOK, SECOND_BOOK],
|
||||
state: { source: "recording", reason: "page-exceeded" },
|
||||
});
|
||||
expect(books.answer("get_books")).toMatchObject({
|
||||
body: [FIRST_BOOK, SECOND_BOOK, created],
|
||||
state: { source: "state", membership: "all", listed: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a whole list a zero limit names from the state, as zero lists every match", () => {
|
||||
const books = servedFrom([
|
||||
["GET", "/api/books?limit=0", [FIRST_BOOK, SECOND_BOOK]],
|
||||
["GET", "/api/books/1", FIRST_BOOK],
|
||||
["GET", "/api/books/2", SECOND_BOOK],
|
||||
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
||||
["GET", "/api/books/3", THIRD_BOOK],
|
||||
]);
|
||||
const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book;
|
||||
expect(books.answer("get_books", { limit: "0" })).toMatchObject({
|
||||
body: [FIRST_BOOK, SECOND_BOOK, created],
|
||||
state: { source: "state", membership: "all", listed: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a zero limit a whole line recorded empty from that recording once a create fills its collection", () => {
|
||||
const alpha = { id: 1, name: "Alpha", org: "1" };
|
||||
const beta = { id: 2, name: "Beta", org: "2" };
|
||||
const books = servedFrom([
|
||||
["GET", "/api/orgs/1/books", [alpha]],
|
||||
["GET", "/api/orgs/1/books/1", alpha],
|
||||
["GET", "/api/orgs/2/books?limit=0", []],
|
||||
["POST", "/api/orgs/2/books", beta, 201, { name: "Beta" }],
|
||||
["GET", "/api/orgs/2/books/2", beta],
|
||||
]);
|
||||
books.answer("post_orgs_books_by_id", { id: "2", body: { name: "Gamma" } });
|
||||
// Only a recording that listed items under it shows that zero lists every entity.
|
||||
expect(
|
||||
books.answer("get_orgs_books_by_id", { id: "2", limit: "0" }),
|
||||
).toMatchObject({
|
||||
body: [],
|
||||
state: { source: "recording", reason: "page-exceeded" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a range's bound beside a zero size its recording shows unbounded", () => {
|
||||
const books = servedFrom([
|
||||
["GET", "/api/books?limit=0&_start=0&_end=2", [FIRST_BOOK, SECOND_BOOK]],
|
||||
["GET", "/api/books/1", FIRST_BOOK],
|
||||
["GET", "/api/books/2", SECOND_BOOK],
|
||||
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
||||
["GET", "/api/books/3", THIRD_BOOK],
|
||||
]);
|
||||
books.answer("post_books", CREATE_NEW_BOOK);
|
||||
expect(
|
||||
books.answer("get_books", { limit: "0", _start: "0", _end: "2" }),
|
||||
).toMatchObject({
|
||||
body: [FIRST_BOOK, SECOND_BOOK],
|
||||
state: { source: "recording", reason: "page-exceeded" },
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a range the collection has outgrown from its recording", () => {
|
||||
const books = servedFrom([
|
||||
["GET", "/api/books?_start=0&_end=2", [FIRST_BOOK, SECOND_BOOK]],
|
||||
["GET", "/api/books/1", FIRST_BOOK],
|
||||
["GET", "/api/books/2", SECOND_BOOK],
|
||||
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
||||
["GET", "/api/books?_start=0&_end=2", [FIRST_BOOK, SECOND_BOOK]],
|
||||
["GET", "/api/books/3", THIRD_BOOK],
|
||||
]);
|
||||
books.answer("post_books", CREATE_NEW_BOOK);
|
||||
expect(books.answer("get_books", { _start: "0", _end: "2" })).toMatchObject(
|
||||
{
|
||||
body: [FIRST_BOOK, SECOND_BOOK],
|
||||
state: { source: "recording", reason: "page-exceeded" },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,529 +0,0 @@
|
||||
import {
|
||||
createdFields,
|
||||
documentBodyProblem,
|
||||
updatedEntity,
|
||||
updatedFields,
|
||||
valueAt,
|
||||
writtenDocument,
|
||||
writtenResponse,
|
||||
type GenerateValue,
|
||||
} from "../../domain/analysisView/environmentStateWriteApply.js";
|
||||
import { insertIndex } from "../../domain/analysisView/environmentStateWriteExplain.js";
|
||||
import {
|
||||
pathValuesOf,
|
||||
type GeneratedRule,
|
||||
type StateWritePlan,
|
||||
type WriteCall,
|
||||
} from "../../domain/analysisView/environmentStateWriteRules.js";
|
||||
import {
|
||||
formatClock,
|
||||
integerOf,
|
||||
isUuid,
|
||||
seededToken,
|
||||
seededUuid,
|
||||
spelledLike,
|
||||
} from "../../domain/analysisView/environmentStateWriteValues.js";
|
||||
import {
|
||||
isObject,
|
||||
scalarText,
|
||||
type JsonObject,
|
||||
type StateEntity,
|
||||
} from "../../domain/analysisView/environmentStateRules.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
replayAnswer,
|
||||
selectReplayExchange,
|
||||
type ReplayArguments,
|
||||
} from "./environmentReplay.js";
|
||||
import type { EnvironmentManifestTool } from "./environmentSource.js";
|
||||
import {
|
||||
callSegments,
|
||||
collectionMatch,
|
||||
itemCall,
|
||||
missingAnswer,
|
||||
namedEntity,
|
||||
recordedJson,
|
||||
refused,
|
||||
stateAnswer,
|
||||
type StateAnswer,
|
||||
type StateProvenance,
|
||||
type StateServing,
|
||||
} from "./environmentStateAnswer.js";
|
||||
import { createdCollection } from "./environmentStateCollections.js";
|
||||
import {
|
||||
isLoadableValue,
|
||||
unloadableMember,
|
||||
type HeldDocument,
|
||||
} from "./environmentStateDocument.js";
|
||||
import { environmentToolArguments } from "./environmentTools.js";
|
||||
|
||||
/** Attempts at a generated UUID or token before a collision with held values is refused. */
|
||||
const GENERATION_ATTEMPTS = 64;
|
||||
|
||||
/** One served write call: the tool, its plan and its arguments. */
|
||||
interface WriteRequest {
|
||||
readonly tool: EnvironmentManifestTool;
|
||||
readonly plan: StateWritePlan;
|
||||
readonly args: ReplayArguments;
|
||||
}
|
||||
|
||||
/** The path values a call supplies, keyed by template segment as write plans name them. */
|
||||
const pathValues = (
|
||||
tool: EnvironmentManifestTool,
|
||||
args: ReplayArguments,
|
||||
): Map<string, string> => {
|
||||
const names = environmentToolArguments(tool).path;
|
||||
const template = tool.path_template
|
||||
.slice(tool.path_template.indexOf("/") + 1)
|
||||
.split("/");
|
||||
const values = new Map<string, string>();
|
||||
for (const param of tool.params) {
|
||||
const value = args[names.get(param.name) ?? param.name];
|
||||
const segment = template[param.index] ?? "";
|
||||
if (typeof value === "string") values.set(segment.slice(1, -1), value);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
/** A refusal for a value `data/add` would refuse, so `data/get` always reports a state that loads back. */
|
||||
const unloadable = (
|
||||
tool: EnvironmentManifestTool,
|
||||
holder: string,
|
||||
): AnalysisError =>
|
||||
refused(
|
||||
tool,
|
||||
`${holder} would hold a value that is not exactly representable JSON, which data/add could not load back.`,
|
||||
);
|
||||
|
||||
/** The call's body: an object, nothing, or a refusal for any other value or one holding a value the state cannot hold. */
|
||||
const callBody = ({
|
||||
tool,
|
||||
args,
|
||||
}: WriteRequest): Result<JsonObject | undefined, AnalysisError> => {
|
||||
const name = environmentToolArguments(tool).body;
|
||||
const value = name === undefined ? undefined : args[name];
|
||||
if (value === undefined) return ok(undefined);
|
||||
if (!isObject(value as JsonValue))
|
||||
return err(refused(tool, `Tool ${tool.name} takes a JSON object body.`));
|
||||
const member = unloadableMember(value as JsonObject);
|
||||
return member === undefined
|
||||
? ok(value as JsonObject)
|
||||
: err(unloadable(tool, `Member ${member}`));
|
||||
};
|
||||
|
||||
/** The entity a recorded observation shows: the values a create keeps and an update's constants. */
|
||||
const observedEntity = (
|
||||
serving: StateServing,
|
||||
plan: StateWritePlan,
|
||||
): JsonObject | undefined => {
|
||||
if (plan.observation === null) return undefined;
|
||||
const exchange = serving.source.exchanges.get(plan.observation.ordinal);
|
||||
const body = exchange === undefined ? undefined : recordedJson(exchange);
|
||||
const object =
|
||||
body === undefined ? undefined : valueAt(body, plan.observation.pointer);
|
||||
return isObject(object) ? object : undefined;
|
||||
};
|
||||
|
||||
/** The largest integer the entities hold in a field, plus one. */
|
||||
const nextInteger = (entities: readonly StateEntity[], field: string): number =>
|
||||
entities.reduce(
|
||||
(largest, entity) =>
|
||||
Math.max(largest, integerOf(entity.fields[field]) ?? 0),
|
||||
0,
|
||||
) + 1;
|
||||
|
||||
/**
|
||||
* Values one served write generates: its clock instant, sequences one past the largest held
|
||||
* value, and UUIDs and tokens seeded by the state Evidence, tool, field and write count,
|
||||
* skipping values the type already holds.
|
||||
*/
|
||||
const generator = (
|
||||
serving: StateServing,
|
||||
request: WriteRequest,
|
||||
target: {
|
||||
readonly name: string;
|
||||
readonly collection: readonly StateEntity[];
|
||||
readonly observed: JsonObject;
|
||||
},
|
||||
): {
|
||||
readonly generate: GenerateValue;
|
||||
readonly taken: Map<string, number>;
|
||||
} => {
|
||||
const { store } = serving;
|
||||
const write = store.pendingWrite();
|
||||
const taken = new Map<string, number>();
|
||||
const sequence = (key: string, held: number): number => {
|
||||
const value = store.sequenceAfter(key, held);
|
||||
taken.set(key, value);
|
||||
return value;
|
||||
};
|
||||
const ofType = store.ofType(request.plan.target ?? "");
|
||||
// A sequence never reuses a value the state started with, even after its entity was deleted.
|
||||
const started = store.startedWith(request.plan.target ?? "");
|
||||
const startedIn = started.filter((held) => held.collection === target.name);
|
||||
const unused = (
|
||||
field: string,
|
||||
make: (attempt: number) => string,
|
||||
): Result<string, string> => {
|
||||
const held = new Set(
|
||||
ofType.map((entity) => scalarText(entity.fields[field])),
|
||||
);
|
||||
for (let attempt = 0; attempt < GENERATION_ATTEMPTS; attempt += 1) {
|
||||
const value = make(attempt);
|
||||
if (!held.has(value)) return ok(value);
|
||||
}
|
||||
return err(
|
||||
`Could not generate a unique value for ${field} after ${String(GENERATION_ATTEMPTS)} attempts without colliding with the held state.`,
|
||||
);
|
||||
};
|
||||
const generate = (rule: GeneratedRule): Result<JsonValue, string> => {
|
||||
const exemplar = target.observed[rule.field] ?? "";
|
||||
const seed = (attempt: number) =>
|
||||
`${store.state.evidenceId}:${request.tool.name}:${rule.field}:${String(write.count)}:${String(attempt)}`;
|
||||
switch (rule.rule) {
|
||||
case "clock":
|
||||
return ok(formatClock(write.clockMs, rule.format));
|
||||
case "sequence":
|
||||
return ok(
|
||||
spelledLike(
|
||||
exemplar,
|
||||
sequence(
|
||||
`${request.tool.name} ${rule.field} ${rule.scope === "collection" ? target.name : ""}`,
|
||||
nextInteger(
|
||||
rule.scope === "collection"
|
||||
? [...target.collection, ...startedIn]
|
||||
: [...ofType, ...started],
|
||||
rule.field,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case "unique":
|
||||
return unused(rule.field, (attempt) =>
|
||||
seededUuid(seed(attempt), String(exemplar)),
|
||||
);
|
||||
case "identity":
|
||||
if (rule.generator === "sequence")
|
||||
return ok(
|
||||
spelledLike(
|
||||
exemplar,
|
||||
sequence(
|
||||
`${request.plan.target ?? ""} ${rule.field} ${target.name}`,
|
||||
nextInteger([...target.collection, ...startedIn], rule.field),
|
||||
),
|
||||
),
|
||||
);
|
||||
return unused(rule.field, (attempt) =>
|
||||
rule.generator === "uuid" || isUuid(exemplar)
|
||||
? seededUuid(seed(attempt), String(exemplar))
|
||||
: seededToken(seed(attempt), String(exemplar)),
|
||||
);
|
||||
}
|
||||
};
|
||||
return { generate, taken };
|
||||
};
|
||||
|
||||
/** A write's answer on its recorded response's status and content type: a rendered body, none, or the recorded one. */
|
||||
const writeAnswer = (
|
||||
serving: StateServing,
|
||||
request: WriteRequest,
|
||||
rendered: { readonly fields: JsonObject | undefined; readonly id: string },
|
||||
state: StateProvenance,
|
||||
): Result<StateAnswer, AnalysisError> => {
|
||||
const { plan, tool, args } = request;
|
||||
const exchange =
|
||||
plan.response === null
|
||||
? undefined
|
||||
: serving.source.exchanges.get(plan.response.ordinal);
|
||||
if (plan.response === null || exchange === undefined)
|
||||
return err(refused(tool, `Tool ${tool.name} has no recorded response.`));
|
||||
const exact =
|
||||
selectReplayExchange(tool, new Map([[exchange.ordinal, exchange]]), args)
|
||||
?.exact === true;
|
||||
if (plan.response.shape === "recorded")
|
||||
return ok({ ...replayAnswer(serving.source, exchange, exact), state });
|
||||
const body = writtenResponse(plan.response, recordedJson(exchange), rendered);
|
||||
if (!body.ok) return err(refused(tool, body.error));
|
||||
return stateAnswer(serving, { exchange, exact }, body.value ?? null, state);
|
||||
};
|
||||
|
||||
const answerCreate = (
|
||||
serving: StateServing,
|
||||
request: WriteRequest,
|
||||
call: WriteCall,
|
||||
): Result<StateAnswer, AnalysisError> => {
|
||||
const { tool, plan } = request;
|
||||
const type = plan.target ?? "";
|
||||
const idField = serving.store.type(type)?.id_field ?? "";
|
||||
const collection = createdCollection(
|
||||
serving,
|
||||
request.tool,
|
||||
request.args,
|
||||
plan.target ?? "",
|
||||
);
|
||||
const observed = observedEntity(serving, plan);
|
||||
if (collection === undefined || observed === undefined)
|
||||
return err(refused(tool, `Tool ${tool.name} needs every path argument.`));
|
||||
if (plan.collection !== null && plan.collection !== collection)
|
||||
return err(
|
||||
refused(
|
||||
tool,
|
||||
`The recorded create's values are tied to ${plan.collection}; the environment models creates only there.`,
|
||||
),
|
||||
);
|
||||
const members = serving.store.inCollection(type, collection);
|
||||
const values = generator(serving, request, {
|
||||
name: collection,
|
||||
collection: members,
|
||||
observed,
|
||||
});
|
||||
// The gate learned path rules from the recorded path, decoded; the collection is this call's.
|
||||
const created = {
|
||||
...call,
|
||||
pathValues: pathValuesOf(tool.path_template, collection) ?? call.pathValues,
|
||||
};
|
||||
const fields = createdFields(plan, observed, created, values.generate);
|
||||
if (!fields.ok) return err(refused(tool, fields.error));
|
||||
const field = unloadableMember(fields.value);
|
||||
if (field !== undefined) return err(unloadable(tool, `Field ${field}`));
|
||||
const id = scalarText(fields.value[idField]);
|
||||
if (
|
||||
id === undefined ||
|
||||
serving.store.entity(type, collection, id) !== undefined
|
||||
)
|
||||
return err(
|
||||
refused(
|
||||
tool,
|
||||
`The state already holds a ${type} entity ${id ?? ""} in ${collection}.`,
|
||||
),
|
||||
);
|
||||
const state = {
|
||||
source: "state",
|
||||
view: "create",
|
||||
type,
|
||||
collection,
|
||||
id,
|
||||
} as const;
|
||||
const answer = writeAnswer(
|
||||
serving,
|
||||
request,
|
||||
{ fields: fields.value, id },
|
||||
state,
|
||||
);
|
||||
if (!answer.ok) return answer;
|
||||
const inserted = serving.store.insert(
|
||||
{
|
||||
type,
|
||||
collection,
|
||||
id,
|
||||
fields: fields.value,
|
||||
view_fields: [],
|
||||
record_ordinals: [],
|
||||
},
|
||||
insertIndex(
|
||||
plan.insert ?? { rule: "last" },
|
||||
members.map((entity) => entity.fields),
|
||||
fields.value,
|
||||
),
|
||||
);
|
||||
if (!inserted.ok) return inserted;
|
||||
serving.store.commitWrite(values.taken);
|
||||
return answer;
|
||||
};
|
||||
|
||||
/** The held entity an update or delete names, or the answer for one the state does not hold. */
|
||||
const targetEntity = (
|
||||
serving: StateServing,
|
||||
request: WriteRequest,
|
||||
): Result<StateEntity, Result<StateAnswer, AnalysisError>> => {
|
||||
const { tool, plan, args } = request;
|
||||
const call = itemCall(tool, args);
|
||||
if (call === undefined)
|
||||
return err(
|
||||
err(refused(tool, `Tool ${tool.name} needs every path argument.`)),
|
||||
);
|
||||
const entity = namedEntity(serving.store, plan.target ?? "", call);
|
||||
return entity === undefined
|
||||
? err(
|
||||
missingAnswer(serving, tool, {
|
||||
type: plan.target ?? "",
|
||||
collection: `/${call.segments.map(({ text }) => text).join("/")}`,
|
||||
id: call.id,
|
||||
}),
|
||||
)
|
||||
: ok(entity);
|
||||
};
|
||||
|
||||
const answerUpdate = (
|
||||
serving: StateServing,
|
||||
request: WriteRequest,
|
||||
call: WriteCall,
|
||||
): Result<StateAnswer, AnalysisError> => {
|
||||
const { tool, plan } = request;
|
||||
const target = targetEntity(serving, request);
|
||||
if (!target.ok) return target.error;
|
||||
const entity = target.value;
|
||||
const observed = observedEntity(serving, plan) ?? {};
|
||||
const values = generator(serving, request, {
|
||||
name: entity.collection,
|
||||
collection: serving.store.inCollection(entity.type, entity.collection),
|
||||
observed,
|
||||
});
|
||||
const written = updatedFields(
|
||||
plan,
|
||||
{ before: entity.fields, observed },
|
||||
{ ...call, id: entity.id },
|
||||
values.generate,
|
||||
);
|
||||
if (!written.ok) return err(refused(tool, written.error));
|
||||
// An increment can pass the exact integers.
|
||||
const field = unloadableMember(written.value);
|
||||
if (field !== undefined) return err(unloadable(tool, `Field ${field}`));
|
||||
const answer = writeAnswer(
|
||||
serving,
|
||||
request,
|
||||
{ fields: written.value, id: entity.id },
|
||||
{
|
||||
source: "state",
|
||||
view: "update",
|
||||
type: entity.type,
|
||||
collection: entity.collection,
|
||||
id: entity.id,
|
||||
},
|
||||
);
|
||||
if (!answer.ok) return answer;
|
||||
const updated = serving.store.update(updatedEntity(entity, written.value));
|
||||
if (!updated.ok) return updated;
|
||||
serving.store.commitWrite(values.taken);
|
||||
return answer;
|
||||
};
|
||||
|
||||
const answerDelete = (
|
||||
serving: StateServing,
|
||||
request: WriteRequest,
|
||||
): Result<StateAnswer, AnalysisError> => {
|
||||
const target = targetEntity(serving, request);
|
||||
if (!target.ok) return target.error;
|
||||
const entity = target.value;
|
||||
const answer = writeAnswer(
|
||||
serving,
|
||||
request,
|
||||
{ fields: entity.fields, id: entity.id },
|
||||
{
|
||||
source: "state",
|
||||
view: "delete",
|
||||
type: entity.type,
|
||||
collection: entity.collection,
|
||||
id: entity.id,
|
||||
},
|
||||
);
|
||||
if (answer.ok) {
|
||||
serving.store.remove(entity);
|
||||
serving.store.commitWrite(new Map());
|
||||
}
|
||||
return answer;
|
||||
};
|
||||
|
||||
/** The held document a call changes: only at the path the state holds it, with members the capture sent as the JSON types the document holds. */
|
||||
const documentTarget = (
|
||||
serving: StateServing,
|
||||
{ tool, plan, args }: WriteRequest,
|
||||
call: WriteCall,
|
||||
): Result<HeldDocument, AnalysisError> => {
|
||||
const held = serving.store.document(plan.target ?? "");
|
||||
const segments = callSegments(tool, args);
|
||||
if (held === undefined || plan.body === null)
|
||||
return err(
|
||||
refused(tool, `The state holds no document for ${plan.target ?? ""}.`),
|
||||
);
|
||||
if (segments === undefined)
|
||||
return err(refused(tool, `Tool ${tool.name} needs every path argument.`));
|
||||
// Document reads answer only on their recorded line, so a write elsewhere has no document to change.
|
||||
if (collectionMatch(held.path, segments) === undefined)
|
||||
return err(
|
||||
refused(
|
||||
tool,
|
||||
`The state holds this document only at ${held.path}; the environment models writes to it only there.`,
|
||||
),
|
||||
);
|
||||
const initial = serving.store.state.documents.find(
|
||||
(document) => document.tool === held.tool,
|
||||
);
|
||||
const problem = documentBodyProblem(plan, call, {
|
||||
initial: initial?.value,
|
||||
current: held.value,
|
||||
});
|
||||
return problem === undefined ? ok(held) : err(refused(tool, problem));
|
||||
};
|
||||
|
||||
const answerDocument = (
|
||||
serving: StateServing,
|
||||
request: WriteRequest,
|
||||
call: WriteCall,
|
||||
): Result<StateAnswer, AnalysisError> => {
|
||||
const { tool, plan } = request;
|
||||
const target = documentTarget(serving, request, call);
|
||||
if (!target.ok) return target;
|
||||
const held = target.value;
|
||||
const written = writtenDocument(plan, held.value, call);
|
||||
if (!written.ok) return err(refused(tool, written.error));
|
||||
if (!isLoadableValue(written.value))
|
||||
return err(unloadable(tool, "The document"));
|
||||
const state = {
|
||||
source: "state",
|
||||
view: "document-write",
|
||||
path: held.path,
|
||||
} as const;
|
||||
const answer =
|
||||
plan.response?.shape === "entity"
|
||||
? (() => {
|
||||
const exchange = serving.source.exchanges.get(plan.response.ordinal);
|
||||
return exchange === undefined
|
||||
? err(refused(tool, `Tool ${tool.name} has no recorded response.`))
|
||||
: stateAnswer(
|
||||
serving,
|
||||
{ exchange, exact: true },
|
||||
written.value,
|
||||
state,
|
||||
);
|
||||
})()
|
||||
: writeAnswer(serving, request, { fields: undefined, id: "" }, state);
|
||||
if (!answer.ok) return answer;
|
||||
const set = serving.store.setDocument(held.tool, written.value);
|
||||
if (!set.ok) return set;
|
||||
serving.store.commitWrite(new Map());
|
||||
return answer;
|
||||
};
|
||||
|
||||
/** Apply one served write tool call to the live state and answer it; a refused call leaves the state unchanged. */
|
||||
export const answerWrite = (
|
||||
serving: StateServing,
|
||||
request: WriteRequest,
|
||||
): Result<StateAnswer, AnalysisError> => {
|
||||
const body = callBody(request);
|
||||
if (!body.ok) return body;
|
||||
const call: WriteCall = {
|
||||
path: request.tool.path_template,
|
||||
pathValues: pathValues(request.tool, request.args),
|
||||
body: body.value,
|
||||
};
|
||||
switch (request.plan.effect) {
|
||||
case "create":
|
||||
return answerCreate(serving, request, call);
|
||||
case "update":
|
||||
return answerUpdate(serving, request, call);
|
||||
case "delete":
|
||||
return answerDelete(serving, request);
|
||||
case "document":
|
||||
return answerDocument(serving, request, call);
|
||||
case "none":
|
||||
return err(
|
||||
refused(
|
||||
request.tool,
|
||||
`Tool ${request.tool.name} is not served from the state.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
import type {
|
||||
StateWritePlan,
|
||||
WriteBody,
|
||||
} from "../../domain/analysisView/environmentStateWriteRules.js";
|
||||
|
||||
/** Members named in a description; the plan in the state Evidence holds every one. */
|
||||
const DESCRIBED_MEMBERS = 12;
|
||||
|
||||
const members = (names: readonly string[]): string =>
|
||||
names.length <= DESCRIBED_MEMBERS
|
||||
? names.join(", ")
|
||||
: `${names.slice(0, DESCRIBED_MEMBERS).join(", ")}, … (${String(names.length)} members; all in the state Evidence)`;
|
||||
|
||||
/** The body members a served write accepts, as a description clause. */
|
||||
const bodyClause = (
|
||||
body: WriteBody,
|
||||
effect: StateWritePlan["effect"],
|
||||
): string => {
|
||||
const parts: string[] = [];
|
||||
if (body.accepted.length > 0)
|
||||
parts.push(`body members ${members(body.accepted)}`);
|
||||
if (body.fixed.length > 0)
|
||||
parts.push(
|
||||
`${members(body.fixed.map(({ member }) => member))} only with the value the capture sent`,
|
||||
);
|
||||
if (body.required.length > 0)
|
||||
parts.push(`required: ${members(body.required)}`);
|
||||
else if (effect === "update") parts.push("send at least one");
|
||||
return parts.length === 0 ? "" : ` (${parts.join("; ")})`;
|
||||
};
|
||||
|
||||
/** What a served write tool does to the state, for its description; undefined for a tool the state does not serve. */
|
||||
export const describeWrite = (
|
||||
plan: StateWritePlan | undefined,
|
||||
): string | undefined => {
|
||||
if (plan?.served !== true) return undefined;
|
||||
const body = plan.body === null ? "" : bodyClause(plan.body, plan.effect);
|
||||
switch (plan.effect) {
|
||||
case "create":
|
||||
return `creates an entity of ${plan.target ?? ""} in the served environment state${body}, modelled on the recorded calls`;
|
||||
case "update":
|
||||
return `changes the entity the path names in the served environment state${body}, modelled on the recorded calls`;
|
||||
case "delete":
|
||||
return "removes the entity the path names from the served environment state, modelled on the recorded calls";
|
||||
case "document":
|
||||
return `changes a document of the served environment state${body}, modelled on the recorded calls`;
|
||||
case "none":
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -1,269 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { TimedCaptureRow } from "../../../tests/fixtures/analysisView.js";
|
||||
import {
|
||||
servedFrom,
|
||||
type Served,
|
||||
} from "../../../tests/fixtures/environmentStateServing.js";
|
||||
import {
|
||||
FIRST_BOOK,
|
||||
SECOND_BOOK,
|
||||
renamedBook,
|
||||
type Book,
|
||||
} from "../../../tests/fixtures/environmentStateWrites.js";
|
||||
import { MAX_JSON_DEPTH, type JsonValue } from "../../domain/jsonValue.js";
|
||||
import type { ReplayArguments } from "./environmentReplay.js";
|
||||
import { STATE_INTAKE_LIMIT_BYTES } from "./environmentStateDocument.js";
|
||||
|
||||
const DARK = { theme: "dark", meta: { n: 1 } };
|
||||
const LIGHT = { theme: "light", meta: { n: 2 } };
|
||||
|
||||
/** One organisation's settings document, read, patched with both members and read again. */
|
||||
const SETTINGS_ROWS: readonly TimedCaptureRow[] = [
|
||||
["GET", "/api/orgs/1/settings", DARK],
|
||||
["PATCH", "/api/orgs/1/settings", LIGHT, 200, LIGHT],
|
||||
["GET", "/api/orgs/1/settings", LIGHT],
|
||||
];
|
||||
|
||||
const READ_SETTINGS = "get_orgs_settings_by_id";
|
||||
const PATCH_SETTINGS = "patch_orgs_settings_by_id";
|
||||
|
||||
/** Every refused call is a tool error naming its problem, and leaves the state as it was. */
|
||||
const expectRefusedUnchanged = (
|
||||
served: Served,
|
||||
refused: readonly (readonly [string, ReplayArguments, string])[],
|
||||
): void => {
|
||||
const before = served.snapshot();
|
||||
for (const [name, args, message] of refused) {
|
||||
expect(served.refusal(name, args)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
message: expect.stringContaining(message),
|
||||
});
|
||||
expect(served.snapshot()).toBe(before);
|
||||
}
|
||||
};
|
||||
|
||||
/** What data/get reports loads back through data/add byte for byte. */
|
||||
const expectReloadsUnchanged = (served: Served): void => {
|
||||
const before = served.snapshot();
|
||||
expect(served.store.replace(JSON.parse(before)).ok).toBe(true);
|
||||
expect(served.snapshot()).toBe(before);
|
||||
};
|
||||
|
||||
const nested = (depth: number): JsonValue =>
|
||||
Array.from({ length: depth }).reduce<JsonValue>((inner) => [inner], 1);
|
||||
|
||||
describe("served document writes", () => {
|
||||
it("accepts only the members the capture sent, as the JSON types the document holds", () => {
|
||||
const settings = servedFrom(SETTINGS_ROWS);
|
||||
expect(settings.state.writes.get(PATCH_SETTINGS)).toMatchObject({
|
||||
served: true,
|
||||
effect: "document",
|
||||
});
|
||||
expectRefusedUnchanged(settings, [
|
||||
[
|
||||
PATCH_SETTINGS,
|
||||
{ id: "1", body: { theme: 5, extra: true } },
|
||||
"Member theme must be a JSON string",
|
||||
],
|
||||
[
|
||||
PATCH_SETTINGS,
|
||||
{ id: "1", body: { extra: true } },
|
||||
"Member extra is not one the capture showed stored",
|
||||
],
|
||||
]);
|
||||
const blue = { theme: "blue", meta: DARK.meta };
|
||||
expect(
|
||||
settings.answer(PATCH_SETTINGS, { id: "1", body: { theme: "blue" } }),
|
||||
).toMatchObject({
|
||||
body: blue,
|
||||
state: { view: "document-write", path: "/api/orgs/1/settings" },
|
||||
});
|
||||
expect(settings.answer(READ_SETTINGS, { id: "1" }).body).toEqual(blue);
|
||||
});
|
||||
|
||||
it("leaves a document write unserved when its recording changed a member's JSON type, which serving would refuse", () => {
|
||||
const retyped = { theme: 5, meta: LIGHT.meta };
|
||||
const settings = servedFrom([
|
||||
["GET", "/api/orgs/1/settings", DARK],
|
||||
["PATCH", "/api/orgs/1/settings", retyped, 200, retyped],
|
||||
["GET", "/api/orgs/1/settings", retyped],
|
||||
]);
|
||||
expect(settings.state.writes.get(PATCH_SETTINGS)).toMatchObject({
|
||||
served: false,
|
||||
reason: "unexplained-change",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not confirm a document write at another path, which serving refuses", () => {
|
||||
const settings = servedFrom([
|
||||
["GET", "/api/orgs/1/settings", DARK],
|
||||
["PATCH", "/api/orgs/2/settings", LIGHT, 200, LIGHT],
|
||||
["GET", "/api/orgs/1/settings", LIGHT],
|
||||
]);
|
||||
expect(settings.state.writes.get(PATCH_SETTINGS)).toMatchObject({
|
||||
served: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("changes the document only at the path the state holds it", () => {
|
||||
const settings = servedFrom(SETTINGS_ROWS);
|
||||
expectRefusedUnchanged(settings, [
|
||||
[
|
||||
PATCH_SETTINGS,
|
||||
{ id: "2", body: { theme: "blue" } },
|
||||
"holds this document only at /api/orgs/1/settings",
|
||||
],
|
||||
]);
|
||||
expect(settings.answer(READ_SETTINGS, { id: "1" }).body).toEqual(DARK);
|
||||
});
|
||||
});
|
||||
|
||||
describe("values the state can load back", () => {
|
||||
it("refuses a body value data/add would refuse, whatever member carries it", () => {
|
||||
const settings = servedFrom(SETTINGS_ROWS);
|
||||
const unexact =
|
||||
"Member meta would hold a value that is not exactly representable JSON";
|
||||
expectRefusedUnchanged(settings, [
|
||||
[PATCH_SETTINGS, { id: "1", body: { meta: { n: 1e20 } } }, unexact],
|
||||
[
|
||||
PATCH_SETTINGS,
|
||||
{
|
||||
id: "1",
|
||||
body: JSON.parse('{"meta":{"__proto__":{"n":1}}}') as JsonValue,
|
||||
},
|
||||
unexact,
|
||||
],
|
||||
[
|
||||
PATCH_SETTINGS,
|
||||
{ id: "1", body: { meta: { n: nested(MAX_JSON_DEPTH) } } },
|
||||
unexact,
|
||||
],
|
||||
]);
|
||||
settings.answer(PATCH_SETTINGS, { id: "1", body: { meta: { n: 3 } } });
|
||||
expectReloadsUnchanged(settings);
|
||||
});
|
||||
|
||||
it("refuses an increment past the exact integers, so data/get still loads back", () => {
|
||||
const versioned = (entry: Book, version: number): Book => ({
|
||||
...entry,
|
||||
version,
|
||||
});
|
||||
const last = Number.MAX_SAFE_INTEGER;
|
||||
const edition = versioned(renamedBook(SECOND_BOOK, "Second Edition", 3), 2);
|
||||
const books = servedFrom([
|
||||
[
|
||||
"GET",
|
||||
"/api/books",
|
||||
[versioned(FIRST_BOOK, last - 1), versioned(SECOND_BOOK, 1)],
|
||||
],
|
||||
["GET", "/api/books/1", versioned(FIRST_BOOK, last - 1)],
|
||||
["GET", "/api/books/2", versioned(SECOND_BOOK, 1)],
|
||||
["PATCH", "/api/books/2", edition, 200, { name: "Second Edition" }],
|
||||
["GET", "/api/books/2", edition],
|
||||
]);
|
||||
const rename = (name: string) => ({ id: "1", body: { name } });
|
||||
expect(
|
||||
books.answer("patch_books_by_id", rename("Renamed")).body,
|
||||
).toMatchObject({ version: last });
|
||||
expectRefusedUnchanged(books, [
|
||||
[
|
||||
"patch_books_by_id",
|
||||
rename("Renamed Again"),
|
||||
"Field version would hold a value that is not exactly representable JSON",
|
||||
],
|
||||
]);
|
||||
expectReloadsUnchanged(books);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the state size limit", () => {
|
||||
it("keeps the size data/get writes as each write changes the state", () => {
|
||||
const books = servedFrom();
|
||||
const settings = servedFrom(SETTINGS_ROWS);
|
||||
const tracked = (served: Served) =>
|
||||
expect(served.store.byteLength()).toBe(
|
||||
Buffer.byteLength(served.snapshot(), "utf8"),
|
||||
);
|
||||
tracked(books);
|
||||
books.answer("post_books", { body: { name: "Ünïcode Bøok" } });
|
||||
tracked(books);
|
||||
books.answer("patch_books_by_id", { id: "3", body: { name: "Short" } });
|
||||
tracked(books);
|
||||
books.answer("delete_books_by_id", { id: "1" });
|
||||
books.answer("delete_books_by_id", { id: "2" });
|
||||
books.answer("delete_books_by_id", { id: "3" });
|
||||
tracked(books);
|
||||
books.answer("post_books", { body: { name: "Only Book" } });
|
||||
tracked(books);
|
||||
settings.answer(PATCH_SETTINGS, { id: "1", body: { theme: "dark blue" } });
|
||||
tracked(settings);
|
||||
expect(settings.store.replace(JSON.parse(settings.snapshot())).ok).toBe(
|
||||
true,
|
||||
);
|
||||
tracked(settings);
|
||||
});
|
||||
|
||||
it("refuses a create that would take data/get past what data/add loads", () => {
|
||||
const books = servedFrom();
|
||||
// Each answer renders the book, so each stays under the reply body limit.
|
||||
const create = { body: { name: "a".repeat(100_000) } };
|
||||
let refused: ReturnType<Served["call"]> | undefined;
|
||||
for (let made = 0; made < 200 && refused === undefined; made += 1) {
|
||||
const result = books.call("post_books", create);
|
||||
if (!result.ok) refused = result;
|
||||
}
|
||||
const before = books.snapshot();
|
||||
expect(Buffer.byteLength(before, "utf8")).toBeLessThanOrEqual(
|
||||
STATE_INTAKE_LIMIT_BYTES,
|
||||
);
|
||||
expect(refused?.ok === false && refused.error).toMatchObject({
|
||||
resource: "capacity",
|
||||
reportedLimits: { max_state_bytes: STATE_INTAKE_LIMIT_BYTES },
|
||||
});
|
||||
expect(books.refusal("post_books", create)).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
});
|
||||
expect(books.snapshot()).toBe(before);
|
||||
expectReloadsUnchanged(books);
|
||||
// A smaller state has room again.
|
||||
books.answer("delete_books_by_id", { id: "3" });
|
||||
expect(books.answer("post_books", create).status).toBe(201);
|
||||
});
|
||||
|
||||
it("refuses a document that data/get would write past what data/add loads", () => {
|
||||
const books = servedFrom();
|
||||
const { rea_state: state } = books.store.snapshot();
|
||||
// Without the members data/get adds back, the document fits only until they return.
|
||||
const document = {
|
||||
rea_state: {
|
||||
version: state.version,
|
||||
entities: state.entities.map(({ type, collection, id, fields }) => ({
|
||||
type,
|
||||
collection,
|
||||
id,
|
||||
fields: { ...fields },
|
||||
})),
|
||||
documents: state.documents,
|
||||
},
|
||||
};
|
||||
const first = document.rea_state.entities[0]!.fields;
|
||||
first["name"] = `${String(first["name"])}${"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 = books.snapshot();
|
||||
const replaced = books.store.replace(document);
|
||||
expect(replaced.ok).toBe(false);
|
||||
expect(!replaced.ok && replaced.error.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
reason: "out_of_range",
|
||||
maximum: STATE_INTAKE_LIMIT_BYTES,
|
||||
}),
|
||||
]);
|
||||
expect(books.snapshot()).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import {
|
||||
replayEnvironmentTool,
|
||||
type ReplayArguments,
|
||||
type ReplayResult,
|
||||
} from "./environmentReplay.js";
|
||||
import type {
|
||||
EnvironmentManifestTool,
|
||||
EnvironmentSource,
|
||||
} from "./environmentSource.js";
|
||||
import { answerFromState } from "./environmentStateAnswer.js";
|
||||
import type { EnvironmentStateStore } from "./environmentStateStore.js";
|
||||
import { answerWrite } from "./environmentStateWriteAnswer.js";
|
||||
|
||||
/** How a served environment answers one tool call. */
|
||||
export type EnvironmentToolAnswer = (
|
||||
tool: EnvironmentManifestTool,
|
||||
args: ReplayArguments,
|
||||
) => Result<ReplayResult, AnalysisError>;
|
||||
|
||||
/** The answer a served environment gives: replay without a state store; with one, a served write applies to the live state and a read answers from it. */
|
||||
export const environmentToolAnswer = (
|
||||
source: EnvironmentSource,
|
||||
store: EnvironmentStateStore | undefined,
|
||||
): EnvironmentToolAnswer =>
|
||||
store === undefined
|
||||
? (tool, args) => replayEnvironmentTool(source, tool, args)
|
||||
: (tool, args) => {
|
||||
const plan = store.state.writes.get(tool.name);
|
||||
return plan?.served === true
|
||||
? answerWrite({ source, store }, { tool, plan, args })
|
||||
: answerFromState({ source, store }, tool, args);
|
||||
};
|
||||
@@ -1,12 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { environmentServeSource } from "../../../tests/fixtures/environmentServe.js";
|
||||
import type {
|
||||
EnvironmentManifestTool,
|
||||
RecordedExchange,
|
||||
} from "./environmentSource.js";
|
||||
import type { EnvironmentManifestTool } from "./environmentSource.js";
|
||||
import {
|
||||
ENVIRONMENT_CATALOG_LIMIT_BYTES,
|
||||
environmentCard,
|
||||
environmentToolArguments,
|
||||
environmentToolDefinitions as prepareDefinitions,
|
||||
@@ -21,16 +17,18 @@ const environmentToolDefinitions = (
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
};
|
||||
const withTools = (tools: readonly EnvironmentManifestTool[]) => ({
|
||||
...source,
|
||||
manifest: { ...source.manifest, tools: [...tools] },
|
||||
});
|
||||
const environmentToolInputSchema = (tool: EnvironmentManifestTool) => {
|
||||
const [definition] = environmentToolDefinitions(withTools([tool]));
|
||||
const [definition] = environmentToolDefinitions(
|
||||
{ ...source.manifest, tools: [tool] },
|
||||
source.evidenceId,
|
||||
);
|
||||
if (definition === undefined) throw new Error("Missing tool definition");
|
||||
return definition.inputSchema;
|
||||
};
|
||||
const definitions = environmentToolDefinitions(source);
|
||||
const definitions = environmentToolDefinitions(
|
||||
source.manifest,
|
||||
source.evidenceId,
|
||||
);
|
||||
|
||||
const tool = (name: string): EnvironmentManifestTool => {
|
||||
const found = source.manifest.tools.find((entry) => entry.name === name);
|
||||
@@ -46,7 +44,7 @@ describe("environmentToolDefinitions", () => {
|
||||
const users = tool("get_users_by_id");
|
||||
const definition = definitions.find(({ name }) => name === users.name);
|
||||
expect(definition?.description).toBe(
|
||||
`${REPLAY_CATALOG_INSTRUCTIONS} GET ${users.path_template}; returns a JSON object with fields "id", "uid", "login", "email"; recorded statuses 200; replays retained records [${users.record_ordinals.join(", ")}]; evidence ${source.evidenceId}`,
|
||||
`${REPLAY_CATALOG_INSTRUCTIONS} GET ${users.path_template}; replays retained records [${users.record_ordinals.join(", ")}]; evidence ${source.evidenceId}`,
|
||||
);
|
||||
expect(definition?.tool).toBe(users);
|
||||
});
|
||||
@@ -110,230 +108,6 @@ describe("environmentToolDefinitions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const described = (name: string): string | undefined =>
|
||||
definitions.find((definition) => definition.name === name)?.description;
|
||||
/** The users tool citing exactly the given exchanges, with the statuses its manifest recorded. */
|
||||
const usersAnswering = (
|
||||
answers: readonly Partial<RecordedExchange>[],
|
||||
statuses: readonly number[] = [200],
|
||||
): string | undefined => {
|
||||
const exchanges = new Map(
|
||||
answers.map((answer, index): [number, RecordedExchange] => [
|
||||
index,
|
||||
{
|
||||
ordinal: index,
|
||||
path: "/api/users/2",
|
||||
query: null,
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
bodyText: null,
|
||||
...answer,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const users = {
|
||||
...tool("get_users_by_id"),
|
||||
record_ordinals: [...exchanges.keys()],
|
||||
statuses: [...statuses],
|
||||
};
|
||||
const [definition] = environmentToolDefinitions({
|
||||
...withTools([users]),
|
||||
exchanges,
|
||||
});
|
||||
return definition?.description;
|
||||
};
|
||||
const returnsOf = (bodyText: string): string | undefined =>
|
||||
/\{numeric\}; (?<returns>[^;]*); recorded statuses/u.exec(
|
||||
usersAnswering([{ bodyText }]) ?? "",
|
||||
)?.groups?.returns;
|
||||
|
||||
describe("environment tool response shapes", () => {
|
||||
it("describes array, enveloped list and object responses one level deep with their statuses", () => {
|
||||
expect(described("get_folders")).toContain(
|
||||
'GET /api/folders; returns a JSON array of objects with fields "id", "uid", "title"; recorded statuses 200; replays',
|
||||
);
|
||||
expect(described("get_dashboards_versions_by_uid")).toContain(
|
||||
'; returns a JSON object whose "versions" lists objects with fields "id", "version"; recorded statuses 200; replays',
|
||||
);
|
||||
expect(described("get_dashboards_by_uid")).toContain(
|
||||
'; returns a JSON object with fields "meta", "dashboard"; recorded statuses 200; replays',
|
||||
);
|
||||
});
|
||||
|
||||
it("describes empty, scalar and fieldless bodies and bounds a long member name", () => {
|
||||
expect(
|
||||
[
|
||||
"[]",
|
||||
"[1,2]",
|
||||
'"text"',
|
||||
"null",
|
||||
"true",
|
||||
"3",
|
||||
"{}",
|
||||
"[{},{}]",
|
||||
'{"items":[]}',
|
||||
].map(returnsOf),
|
||||
).toEqual([
|
||||
"returns a JSON array",
|
||||
"returns a JSON array",
|
||||
"returns a JSON string",
|
||||
"returns a JSON null",
|
||||
"returns a JSON boolean",
|
||||
"returns a JSON number",
|
||||
"returns a JSON object with no fields",
|
||||
"returns a JSON array of objects with no fields",
|
||||
'returns a JSON object with fields "items"',
|
||||
]);
|
||||
const member = "m".repeat(2048);
|
||||
expect(
|
||||
usersAnswering([
|
||||
{ bodyText: JSON.stringify({ [member]: [{ id: 1 }, { id: 2 }] }) },
|
||||
]),
|
||||
).toContain(
|
||||
`{numeric}; returns a JSON object whose "${"m".repeat(1024)}"… (member display truncated; full value in capture Evidence) lists objects with fields "id"; recorded statuses 200;`,
|
||||
);
|
||||
});
|
||||
|
||||
it("quotes captured names, so a separator in one cannot read as more of the description", () => {
|
||||
const description = usersAnswering([
|
||||
{
|
||||
bodyText: JSON.stringify({
|
||||
"id, name": 1,
|
||||
"x; recorded statuses 500": 2,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(description).toContain(
|
||||
'returns a JSON object with fields "id, name", "x; recorded statuses 500"; recorded statuses 200;',
|
||||
);
|
||||
expect(
|
||||
usersAnswering([
|
||||
{ bodyText: JSON.stringify({ "a\nb": [{ id: 1 }, { id: 2 }] }) },
|
||||
]),
|
||||
).toContain('whose "a\\nb" lists objects with fields "id";');
|
||||
// A name too long to show whole is shown as a quoted prefix, so its newline cannot start a line of description.
|
||||
const long = `items; recorded statuses 500\n${"m".repeat(2048)}`;
|
||||
const shown = usersAnswering([
|
||||
{ bodyText: JSON.stringify({ [long]: [{ id: 1 }, { id: 2 }] }) },
|
||||
]);
|
||||
expect(shown).toContain('whose "items; recorded statuses 500\\nmmm');
|
||||
expect(shown).not.toContain("items; recorded statuses 500\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment tool response summaries", () => {
|
||||
it("describes the first cited 2xx record replay answers as JSON, then the manifest's statuses", () => {
|
||||
const description = usersAnswering(
|
||||
[
|
||||
{
|
||||
contentType: "text/html",
|
||||
bodyText: '{"looks":"like JSON"}',
|
||||
},
|
||||
{ status: 401, bodyText: '{"message":"Unauthorized","url":"/login"}' },
|
||||
{ bodyText: '{"id":2,"login":"probe"}' },
|
||||
{ bodyText: '[{"id":3,"email":"viewer@example.test"}]' },
|
||||
],
|
||||
[200, 401],
|
||||
);
|
||||
expect(description).toContain(
|
||||
'{numeric}; returns a JSON object with fields "id", "login"; recorded statuses 200, 401; replays retained records [0, 1, 2, 3];',
|
||||
);
|
||||
});
|
||||
|
||||
it("labels a JSON record of another status only when no 2xx record replays as JSON", () => {
|
||||
expect(
|
||||
usersAnswering(
|
||||
[
|
||||
{ contentType: "text/html", bodyText: "<p>Signed out</p>" },
|
||||
{ status: 401, bodyText: '{"message":"Unauthorized"}' },
|
||||
{ status: 403, bodyText: '{"errors":[{"message":"Forbidden"}]}' },
|
||||
],
|
||||
[200, 401, 403],
|
||||
),
|
||||
).toContain(
|
||||
'{numeric}; status 401 returns a JSON object with fields "message"; recorded statuses 200, 401, 403; replays',
|
||||
);
|
||||
expect(
|
||||
usersAnswering([{ status: null, bodyText: '{"id":2}' }], []),
|
||||
).toContain(
|
||||
'{numeric}; a record without a status returns a JSON object with fields "id"; no recorded status; replays',
|
||||
);
|
||||
});
|
||||
|
||||
it("states only the statuses when no cited exchange replays as JSON", () => {
|
||||
for (const answer of [
|
||||
{ contentType: "text/html", bodyText: "<p>Granite Ledger</p>" },
|
||||
{ bodyText: '{"id":9007199254740993}' },
|
||||
{ bodyText: '{"id":2}', bodyEncoding: "base64" as const },
|
||||
{ bodyText: '{"id":2}', bodyCut: true as const },
|
||||
{ bodyText: null },
|
||||
])
|
||||
expect(usersAnswering([answer])).toMatch(
|
||||
/\{numeric\}; recorded statuses 200; replays retained records/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("charges response summaries to the catalog reservation before retaining them", () => {
|
||||
const fields = Object.fromEntries(
|
||||
Array.from({ length: 300 }, (_, index) => [
|
||||
`field_${String(index).padStart(3, "0")}`,
|
||||
index,
|
||||
]),
|
||||
);
|
||||
const users = tool("get_users_by_id");
|
||||
const ordinal = users.record_ordinals[0] ?? 0;
|
||||
const catalog = (contentType: string) =>
|
||||
prepareDefinitions({
|
||||
...withTools(
|
||||
Array.from({ length: 200 }, (_, index) => ({
|
||||
...users,
|
||||
name: `get_users_${String(index)}`,
|
||||
record_ordinals: [ordinal],
|
||||
})),
|
||||
),
|
||||
exchanges: new Map([
|
||||
[
|
||||
ordinal,
|
||||
{
|
||||
ordinal,
|
||||
path: "/api/users/2",
|
||||
query: null,
|
||||
status: 200,
|
||||
contentType,
|
||||
bodyText: JSON.stringify(fields),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
expect(catalog("text/html").ok).toBe(true);
|
||||
const bounded = catalog("application/json");
|
||||
expect(bounded).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
resource: "capacity",
|
||||
reportedLimits: { max_catalog_bytes: ENVIRONMENT_CATALOG_LIMIT_BYTES },
|
||||
},
|
||||
});
|
||||
const [single] = environmentToolDefinitions({
|
||||
...withTools([users]),
|
||||
exchanges: new Map([
|
||||
[
|
||||
ordinal,
|
||||
{
|
||||
...source.exchanges.get(ordinal)!,
|
||||
bodyText: JSON.stringify(fields),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
expect(Buffer.byteLength(single?.description ?? "")).toBeLessThan(1700);
|
||||
expect(single?.description).toContain(
|
||||
"/300 shown; full list in capture Evidence)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment catalog description budgets", () => {
|
||||
it("bounds captured scalar prose while preserving complete lookup identities", () => {
|
||||
const query = "query-".repeat(1000);
|
||||
@@ -343,7 +117,10 @@ describe("environment catalog description budgets", () => {
|
||||
path_template: `/${"😀".repeat(1000)}`,
|
||||
query_parameter_names: [query],
|
||||
};
|
||||
const [definition] = environmentToolDefinitions(withTools([expanded]));
|
||||
const [definition] = environmentToolDefinitions(
|
||||
{ ...source.manifest, tools: [expanded] },
|
||||
source.evidenceId,
|
||||
);
|
||||
expect(definition?.tool).toBe(expanded);
|
||||
expect(Buffer.byteLength(definition?.description ?? "")).toBeLessThan(3500);
|
||||
expect(definition?.description).toContain(
|
||||
@@ -375,7 +152,10 @@ describe("environment catalog description budgets", () => {
|
||||
})),
|
||||
record_ordinals: ordinals,
|
||||
};
|
||||
const [definition] = environmentToolDefinitions(withTools([expanded]));
|
||||
const [definition] = environmentToolDefinitions(
|
||||
{ ...source.manifest, tools: [expanded] },
|
||||
source.evidenceId,
|
||||
);
|
||||
expect(definition?.tool).toBe(expanded);
|
||||
expect(expanded.params[0]?.observed_values).toBe(observations);
|
||||
expect(expanded.record_ordinals).toBe(ordinals);
|
||||
@@ -400,7 +180,10 @@ describe("environment catalog description budgets", () => {
|
||||
...users,
|
||||
path_template: `/api/${"segment/".repeat(200_000)}{id}`,
|
||||
};
|
||||
const [definition] = environmentToolDefinitions(withTools([long]));
|
||||
const [definition] = environmentToolDefinitions(
|
||||
{ ...source.manifest, tools: [long] },
|
||||
source.evidenceId,
|
||||
);
|
||||
expect(Buffer.byteLength(definition?.description ?? "")).toBeLessThan(2048);
|
||||
expect(definition?.description).toContain(
|
||||
"path display truncated; full value in manifest Evidence",
|
||||
@@ -547,22 +330,3 @@ describe("environmentCard", () => {
|
||||
expect(environmentCard("renamed", definitions).name).toBe("renamed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment tool annotations", () => {
|
||||
it("marks OPTIONS and TRACE safe and idempotent, as GET is", () => {
|
||||
const get = tool("get_users_by_id");
|
||||
const hints = environmentToolDefinitions(
|
||||
withTools(
|
||||
(["OPTIONS", "TRACE"] as const).map((method) => ({
|
||||
...get,
|
||||
name: `${method.toLowerCase()}_users_by_id`,
|
||||
method,
|
||||
})),
|
||||
),
|
||||
).map((definition) => definition.annotations);
|
||||
expect(hints).toEqual([
|
||||
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
||||
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,9 @@
|
||||
import {
|
||||
isObject,
|
||||
isQueryPost,
|
||||
listItems,
|
||||
locateList,
|
||||
pointerTokens,
|
||||
type JsonObject,
|
||||
} from "../../domain/analysisView/environmentStateRules.js";
|
||||
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
ENVIRONMENT_SERVE_OPERATION,
|
||||
replayedBody,
|
||||
type EnvironmentManifestTool,
|
||||
type EnvironmentSource,
|
||||
type RecordedExchange,
|
||||
import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js";
|
||||
import type {
|
||||
EnvironmentManifest,
|
||||
EnvironmentManifestTool,
|
||||
} from "./environmentSource.js";
|
||||
|
||||
/** Methods whose tools accept an optional request body object. */
|
||||
@@ -38,76 +27,36 @@ export interface EnvironmentToolInputSchema {
|
||||
readonly additionalProperties: false;
|
||||
}
|
||||
|
||||
/** MCP hints for the captured method; answers come from the capture, never the open world. */
|
||||
export interface EnvironmentToolAnnotations {
|
||||
readonly readOnlyHint: boolean;
|
||||
/** Set for `DELETE` only; other writes leave the MCP default (destructive). */
|
||||
readonly destructiveHint?: true;
|
||||
readonly idempotentHint: boolean;
|
||||
readonly openWorldHint: false;
|
||||
}
|
||||
|
||||
/** One manifest tool as an MCP tool: name, description, input schema and the hints MCP advertises; the card omits the hints. */
|
||||
/** One manifest tool as an MCP tool: name, description and input schema. */
|
||||
export interface EnvironmentToolDefinition {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly inputSchema: EnvironmentToolInputSchema;
|
||||
readonly annotations: EnvironmentToolAnnotations;
|
||||
readonly tool: EnvironmentManifestTool;
|
||||
}
|
||||
|
||||
/** RFC 9110 safe methods: a preflight `OPTIONS` reads, as `GET` does. */
|
||||
const SAFE_METHODS: ReadonlySet<string> = new Set([
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS",
|
||||
"TRACE",
|
||||
]);
|
||||
|
||||
const IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([
|
||||
...SAFE_METHODS,
|
||||
"PUT",
|
||||
"DELETE",
|
||||
]);
|
||||
|
||||
const environmentToolAnnotations = ({
|
||||
method,
|
||||
path_template: path,
|
||||
}: EnvironmentManifestTool): EnvironmentToolAnnotations => ({
|
||||
readOnlyHint: SAFE_METHODS.has(method) || isQueryPost(method, path),
|
||||
...(method === "DELETE" ? { destructiveHint: true as const } : {}),
|
||||
idempotentHint: IDEMPOTENT_METHODS.has(method),
|
||||
openWorldHint: false,
|
||||
});
|
||||
|
||||
/** Agent-context budget for each captured display sample, excluding its provenance notice. */
|
||||
const DESCRIPTION_SAMPLE_BYTES = 1024;
|
||||
|
||||
/** A display prefix is explicitly separate from the complete identity held by the Evidence named; `show` renders the text or its prefix. */
|
||||
const describeText = (
|
||||
text: string,
|
||||
label: string,
|
||||
holder = "manifest Evidence",
|
||||
show: (shown: string) => string = (shown) => shown,
|
||||
): string => {
|
||||
/** A display prefix is explicitly separate from the complete identity held by the manifest. */
|
||||
const describeText = (text: string, label: string): string => {
|
||||
if (
|
||||
text.length <= DESCRIPTION_SAMPLE_BYTES &&
|
||||
Buffer.byteLength(text, "utf8") <= DESCRIPTION_SAMPLE_BYTES
|
||||
)
|
||||
return show(text);
|
||||
return text;
|
||||
const { read } = new TextEncoder().encodeInto(
|
||||
text,
|
||||
new Uint8Array(DESCRIPTION_SAMPLE_BYTES),
|
||||
);
|
||||
return `${show(text.slice(0, read))}… (${label} display truncated; full value in ${holder})`;
|
||||
return `${text.slice(0, read)}… (${label} display truncated; full value in manifest Evidence)`;
|
||||
};
|
||||
|
||||
/** Sample whole values before joining; keep full lists in the Evidence that owns them. */
|
||||
/** Sample whole values before joining; keep full lists in the manifest that owns them. */
|
||||
const describeList = <Value>(
|
||||
values: Iterable<Value>,
|
||||
count: number,
|
||||
display: (value: Value) => string = String,
|
||||
holder = "manifest Evidence",
|
||||
): string => {
|
||||
if (count === 0) return "none";
|
||||
const sample: string[] = [];
|
||||
@@ -127,91 +76,9 @@ const describeList = <Value>(
|
||||
const listed = sample.join(", ");
|
||||
return sample.length === count
|
||||
? listed
|
||||
: `${listed}${sample.length === 0 ? "" : ", "}… (${String(sample.length)}/${String(count)} shown; full list in ${holder})`;
|
||||
: `${listed}${sample.length === 0 ? "" : ", "}… (${String(sample.length)}/${String(count)} shown; full list in manifest Evidence)`;
|
||||
};
|
||||
|
||||
/**
|
||||
* A captured name as a JSON string, so a separator, quote or newline in it cannot read as more of the
|
||||
* description. A name too long for a sample is left as it is: the sample refuses it either way.
|
||||
*/
|
||||
const quotedName = (name: string): string =>
|
||||
name.length > DESCRIPTION_SAMPLE_BYTES ? name : JSON.stringify(name);
|
||||
|
||||
/** Field names of the objects in first-seen order; the recorded bodies stay in the capture Evidence. */
|
||||
const withFields = (objects: readonly JsonObject[]): string => {
|
||||
const names = new Set<string>();
|
||||
for (const object of objects)
|
||||
for (const name of Object.keys(object)) names.add(name);
|
||||
return names.size === 0
|
||||
? "with no fields"
|
||||
: `with fields ${describeList(names, names.size, quotedName, "capture Evidence")}`;
|
||||
};
|
||||
|
||||
/** One level of a JSON body; a list is an array of objects found by the state's list rule. */
|
||||
const describeJson = (value: JsonValue): string => {
|
||||
const pointer = locateList(value, undefined);
|
||||
const items = pointer === undefined ? [] : (listItems(value, pointer) ?? []);
|
||||
if (pointer !== undefined && items.length > 0) {
|
||||
const [member] = pointerTokens(pointer);
|
||||
return member === undefined
|
||||
? `a JSON array of objects ${withFields(items)}`
|
||||
: `a JSON object whose ${describeText(member, "member", "capture Evidence", JSON.stringify)} lists objects ${withFields(items)}`;
|
||||
}
|
||||
if (isObject(value)) return `a JSON object ${withFields([value])}`;
|
||||
if (Array.isArray(value)) return "a JSON array";
|
||||
return `a JSON ${value === null ? "null" : typeof value}`;
|
||||
};
|
||||
|
||||
const isSuccess = (status: number | null): boolean =>
|
||||
status !== null && status >= 200 && status < 300;
|
||||
|
||||
/** The first cited record replay answers as JSON, preferring a 2xx one; each body is parsed at most once. */
|
||||
const firstJson = (
|
||||
tool: EnvironmentManifestTool,
|
||||
exchanges: ReadonlyMap<number, RecordedExchange>,
|
||||
):
|
||||
| { readonly status: number | null; readonly value: JsonValue }
|
||||
| undefined => {
|
||||
for (const success of [true, false])
|
||||
for (const ordinal of tool.record_ordinals) {
|
||||
const exchange = exchanges.get(ordinal);
|
||||
if (exchange === undefined || isSuccess(exchange.status) !== success)
|
||||
continue;
|
||||
const body = replayedBody(exchange);
|
||||
if (body.kind === "json")
|
||||
return { status: exchange.status, value: body.value };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** What the tool returns as JSON, labelled with the status unless it succeeded, then the manifest's recorded statuses. */
|
||||
const describeResponses = (
|
||||
tool: EnvironmentManifestTool,
|
||||
exchanges: ReadonlyMap<number, RecordedExchange>,
|
||||
): string => {
|
||||
const recorded =
|
||||
tool.statuses.length === 0
|
||||
? "no recorded status"
|
||||
: `recorded statuses ${describeList(tool.statuses, tool.statuses.length)}`;
|
||||
const found = firstJson(tool, exchanges);
|
||||
if (found === undefined) return recorded;
|
||||
const returns = isSuccess(found.status)
|
||||
? "returns"
|
||||
: found.status === null
|
||||
? "a record without a status returns"
|
||||
: `status ${String(found.status)} returns`;
|
||||
return `${returns} ${describeJson(found.value)}; ${recorded}`;
|
||||
};
|
||||
|
||||
/** One tool's description: method, path, response summary, what it does, the records it cites and the Evidence. */
|
||||
const toolDescription = (
|
||||
source: EnvironmentSource,
|
||||
tool: EnvironmentManifestTool,
|
||||
describe: EnvironmentCatalogOptions["describe"],
|
||||
answers: string,
|
||||
): string =>
|
||||
`${REPLAY_CATALOG_INSTRUCTIONS} ${describeText(tool.method, "method")} ${describeText(tool.path_template, "path")}; ${describeResponses(tool, source.exchanges)}; ${describe?.(tool) ?? answers} [${describeList(tool.record_ordinals, tool.record_ordinals.length)}]; evidence ${source.evidenceId}`;
|
||||
|
||||
/** Captured argument names longer than this are advertised under a short alias; the manifest keeps the name. */
|
||||
export const MAX_ARGUMENT_NAME_CHARS = 64;
|
||||
|
||||
@@ -337,18 +204,16 @@ export type EnvironmentDataPlane = "none" | "state";
|
||||
export interface EnvironmentCatalogOptions {
|
||||
readonly operation?: string;
|
||||
readonly dataPlane?: EnvironmentDataPlane;
|
||||
/** What a served tool does, in place of how it answers; undefined keeps the answering sentence. */
|
||||
readonly describe?: (tool: EnvironmentManifestTool) => string | undefined;
|
||||
}
|
||||
|
||||
/** Admit complete discovery before binding; six bytes per UTF-16 unit covers JSON escaping and UTF-8 expansion. */
|
||||
export const environmentToolDefinitions = (
|
||||
source: EnvironmentSource,
|
||||
name: string = source.manifest.name,
|
||||
manifest: EnvironmentManifest,
|
||||
evidenceId: string,
|
||||
name: string = manifest.name,
|
||||
{
|
||||
operation = ENVIRONMENT_SERVE_OPERATION,
|
||||
dataPlane = "none",
|
||||
describe,
|
||||
}: EnvironmentCatalogOptions = {},
|
||||
): Result<
|
||||
readonly EnvironmentToolDefinition[],
|
||||
@@ -389,22 +254,15 @@ export const environmentToolDefinitions = (
|
||||
? "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 source.manifest.tools) {
|
||||
const annotations = environmentToolAnnotations(tool);
|
||||
// Only MCP discovery carries the hints; charging them per tool over-charges the card harmlessly.
|
||||
if (
|
||||
!reserve(EMPTY_TOOL_BYTES + JSON.stringify({ annotations }).length) ||
|
||||
!text(tool.name)
|
||||
)
|
||||
return refused();
|
||||
for (const tool of manifest.tools) {
|
||||
if (!reserve(EMPTY_TOOL_BYTES) || !text(tool.name)) return refused();
|
||||
const count =
|
||||
tool.params.length +
|
||||
tool.query_parameter_names.length +
|
||||
(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();
|
||||
// The response summary is bounded by its samples and charged with the whole description.
|
||||
const description = toolDescription(source, tool, describe, answers);
|
||||
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<string, EnvironmentToolProperty>();
|
||||
@@ -431,7 +289,6 @@ export const environmentToolDefinitions = (
|
||||
required,
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations,
|
||||
tool,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ const parameters = (
|
||||
target_id: input.target_id,
|
||||
observation_ms: input.observation_ms,
|
||||
include_script_sources: input.include_script_sources,
|
||||
...(input.command_timeout_ms === undefined
|
||||
? {}
|
||||
: { command_timeout_ms: input.command_timeout_ms }),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user