Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8f674d22 | ||
|
|
95c8c9d157 |
No files matched your search
@@ -93,10 +93,14 @@ 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.
|
||||
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.
|
||||
`rea environment-serve manifest.json --evidence capture.json` derives that
|
||||
state at startup, answers read tools from it and serves `data/reset`,
|
||||
`data/add` and `data/get` on `/agentenv`; write tools are not served. It
|
||||
replays the capture instead with `--replay-only` or when the state is
|
||||
unavailable (not derivable, or a catalog over the discovery budget), and its
|
||||
stopped document's `state` says which applied.
|
||||
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.
|
||||
@@ -22,11 +22,6 @@ 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
|
||||
|
||||
+70
-40
@@ -5,7 +5,8 @@ environment: an MCP server whose tools answer from what one retained web
|
||||
network capture observed, plus the environment card the agentenv framework
|
||||
reads to discover it. By default the server derives the
|
||||
[state the capture observed](#the-state-a-capture-observed) at startup and
|
||||
answers read tools from it; `--replay-only` replays the capture's records
|
||||
answers read tools from it, and the write tools whose effect the capture
|
||||
confirmed by changing it; `--replay-only` replays the capture's records
|
||||
instead. Nothing is fetched from the recorded application; every answer comes
|
||||
from retained Evidence. `rea environment-export` writes the same pair as a
|
||||
bundle folder `agent-env run` builds and runs; see
|
||||
@@ -271,11 +272,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; 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, 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 |
|
||||
|
||||
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
|
||||
@@ -376,8 +377,11 @@ In replay, `extensions`, `operations` and `children_environments` are fixed to
|
||||
`null`, `[]` and `null`: a replay environment declares no extensions, no
|
||||
data-plane operations and no children. Served
|
||||
[from the state](#serve-from-the-state), the card declares the three data-plane
|
||||
operations and one intake extension instead, leaves write tools out and
|
||||
describes each tool as answering from the state where the state backs it.
|
||||
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.
|
||||
|
||||
## Serve from the state
|
||||
|
||||
@@ -400,7 +404,8 @@ A state that is unavailable does not stop the server: it logs a warning naming
|
||||
the error `code` and message before it binds, then serves replay. The state is
|
||||
unavailable when it cannot be derived, such as from a
|
||||
`capture_browser_scenario` capture that selected header values but no response
|
||||
bodies or over the view's budget, and when its catalog under the served name
|
||||
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
|
||||
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`
|
||||
@@ -417,26 +422,53 @@ 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, 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. |
|
||||
| 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. |
|
||||
|
||||
A list or document call that names no checked line, including a line recorded
|
||||
after a write or with other query values, answers from the recording. Every
|
||||
answer has the replay shape plus `state`: `{"source": "state", "view": …}`
|
||||
naming the entity, collection or document path, or `{"source": "recording",
|
||||
"reason": …}` with the view's reason, `no-view` or `unrecorded-request`. A
|
||||
"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
|
||||
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. Extraction does
|
||||
not state-back a document whose rendered JSON exceeds 256 KiB; that read stays
|
||||
recorded (`rendered-oversize`) and replays the captured body. 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`.
|
||||
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"`.
|
||||
|
||||
The data plane serves three JSON-RPC methods:
|
||||
|
||||
@@ -476,12 +508,6 @@ 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
|
||||
@@ -565,14 +591,17 @@ The card declares the operations and what `data/add` accepts:
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Export a bundle
|
||||
|
||||
@@ -640,8 +669,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 (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
|
||||
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
|
||||
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
|
||||
@@ -659,8 +688,9 @@ 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 no write tools yet,
|
||||
replay when the state is unavailable, and `--replay-only` for pure replay; it
|
||||
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
|
||||
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
|
||||
|
||||
+19
-40
@@ -385,8 +385,7 @@ section explicitly truncated.
|
||||
Disallowed-origin entries are filtered before retention.
|
||||
|
||||
Selected script-source collection shares a 32 MiB aggregate UTF-8 retention
|
||||
budget and the `command_timeout_ms` deadline (default 20000 ms) across the
|
||||
capture. The byte budget has no runtime override. An oversized source is
|
||||
budget and a 20-second deadline across the capture. 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
|
||||
@@ -394,25 +393,19 @@ 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. 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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
|
||||
Exhausted result capacity stops 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
|
||||
@@ -422,10 +415,8 @@ 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. 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.
|
||||
memory limit. 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
|
||||
@@ -439,24 +430,12 @@ 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 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,
|
||||
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,
|
||||
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
|
||||
@@ -465,7 +444,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 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 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-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,21 +23,9 @@ 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 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.
|
||||
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.
|
||||
|
||||
## Minimal launch example
|
||||
|
||||
|
||||
+3
-25
@@ -63,16 +63,6 @@ or repeat it to select several types.
|
||||
Selection limits decoding to the selected types; member references remain an
|
||||
assembly-wide inventory, so the output can still be large.
|
||||
|
||||
Select `--detail summary --evidence-bundle /absolute/path/members.json` to
|
||||
receive identity, selection, coverage, limitations and collection counts while
|
||||
saving the complete original Evidence in a new bundle. Summary requires that
|
||||
explicit destination; existing files are preserved. Later
|
||||
`inspect-analysis-view` calls can page the exact parent from the saved bundle
|
||||
without reopening the assembly. See
|
||||
[CLI summary and saved member rows](managed-code-analysis.md#cli-summary-and-saved-member-rows)
|
||||
for the complete command sequence. Omitted or `--detail complete` retains the
|
||||
full inline response.
|
||||
|
||||
Choose the workflow from the artifact's contents rather than its `.exe` or
|
||||
`.dll` extension. The managed parser requires PE/CLI metadata; it does not
|
||||
unpack .NET single-file hosts or decode IL2CPP. For the image, type and method
|
||||
@@ -289,12 +279,7 @@ Exports preserve an existing destination unless `--overwrite` is explicit.
|
||||
For application traces and version comparisons, see
|
||||
[JavaScript application workflows](javascript-application-workflows.md).
|
||||
MCP [retained Evidence references](mcp-contracts.md#retained-application-evidence-inputs)
|
||||
belong to one connection. Separate CLI calls can consume full saved records.
|
||||
`inspect-analysis-view REQUEST.json --evidence-bundle BUNDLE.json` resolves
|
||||
the request's `retained-evidence` source ID from that authenticated bundle,
|
||||
including bundles exported from an MCP session. The same view JSON works for
|
||||
every supported parent kind; no target is reopened and no record is selected
|
||||
by file order. Omit `--evidence-bundle` when the request supplies inline Evidence.
|
||||
belong to one connection. Separate CLI calls consume full saved records.
|
||||
|
||||
## Import historical source
|
||||
|
||||
@@ -414,7 +399,8 @@ evidence file's `evidence_id`; otherwise the command refuses with
|
||||
`invalid_request` before binding. Before binding it derives the
|
||||
`environment-state` view of the capture with the manifest's `template_rules`,
|
||||
as `inspect-analysis-view` would, and serves from it: read tools whose views
|
||||
are state-backed answer from a live state, write tools are not served, and
|
||||
are state-backed answer from a live state, write tools are served only where
|
||||
the capture's later reads confirmed their effect, 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
|
||||
@@ -551,14 +537,6 @@ command, objdump, Rizin and GDB, and the agent environment server
|
||||
signal-derived status. This describes the current source routing; it is not a
|
||||
claim that every command or provider runtime has been independently exercised.
|
||||
|
||||
Commands with `--evidence_output` select their delivered view, then publish the
|
||||
complete Evidence bundle atomically. Cancellation before publication leaves no
|
||||
new file. A publication that has committed can win a later cancellation; the
|
||||
`cancelled` result then keeps the written path and byte count under
|
||||
`details.diagnostics.evidence_bundle`. If staging cleanup stays incomplete
|
||||
after commit, the `cleanup_incomplete` error reports the same file as
|
||||
`details.published_path` and `details.bytes`.
|
||||
|
||||
When the reader of piped output closes early, as `rea ... | head` does, the
|
||||
CLI stops writing, prints no error and exits `141` (`128 + 13`, the status of a
|
||||
SIGPIPE-terminated writer). The operation itself may have completed; only its
|
||||
|
||||
@@ -245,10 +245,4 @@ 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 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.
|
||||
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.
|
||||
@@ -106,15 +106,6 @@ 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
|
||||
@@ -181,8 +172,7 @@ 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). Source selection
|
||||
does not bypass ASAR header/index admission.
|
||||
select exact [source paths](#select-sources-before-analysis).
|
||||
|
||||
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
|
||||
|
||||
@@ -106,11 +106,7 @@ nodes. REA evaluates compatible static candidates conservatively:
|
||||
that disagreement. If a mapped candidate's digest is unavailable and no
|
||||
mapped content match is known, the comparison remains `unknown`.
|
||||
6. Missing authorization mappings and incomplete or truncated input coverage
|
||||
remain unknown or truncated rather than becoming absence claims. A runtime
|
||||
location with no automatic artifact-root or caller-declared mapping into
|
||||
any layer, and no unique content match, is `unknown` with
|
||||
`no-authorized-location-mapping`, with or without captured bytes. Only a
|
||||
mapped location can be `unmatched` with `no-static-content-match`.
|
||||
remain unknown or truncated rather than becoming absence claims.
|
||||
|
||||
Every successful cross-layer `observed_as` edge has
|
||||
`authority: cross-layer-reconciliation` and `state: inferred`. The underlying
|
||||
|
||||
@@ -68,7 +68,7 @@ The background export belongs to the session. `close_binary`, or the end of a CL
|
||||
|
||||
The assembly label index is scanned once per export reader. Cancelling a request stops its wait without interrupting other readers; closing the session aborts and drains the shared scan before releasing its files.
|
||||
|
||||
Regex procedure and string search uses ECMAScript Unicode matching in a cancellable worker with a five-second matching deadline and a worker old-generation heap. Literal matching remains the default. Cancellation or a matching deadline leaves the session available. This release has no runtime override for that deadline. The worker requests 128 MiB. A positive Node.js `--max-old-space-size` on the process that starts REA, including `NODE_OPTIONS`, overrides that request and sets the effective heap. `--max-old-space-size-percentage` takes precedence over that flag in either order. REA does not turn the percentage into a MiB, so the effective heap stays unestablished instead of the 128 MiB request or the absolute flag. The reported `NODE_OPTIONS` value is the process initial environment when that block can be read. A later edit to the environment is not the value Node applied, and an unreadable initial value leaves the effective heap unestablished. The launch Node options are the initial command line on Linux when that buffer still has a program entry, not a later edit to `process.execArgv`. A preload can clear that array after V8 has applied the flags. Setting `process.title` or `--title` overwrites the command line, so a buffer without a program entry leaves the effective heap unestablished. `--env-file` or `--env-file-if-exists` can supply `NODE_OPTIONS` when the initial environment has none. That file is not part of the initial environment block, so the effective heap stays unestablished instead of the 128 MiB request. An initial `NODE_OPTIONS` is still used, because Node does not let the file replace it. A positive `--max-heap-size`, including the underscore form and launch `NODE_OPTIONS` on Node versions that allow it, sets the total heap rather than the old generation and can be lower than the 128 MiB request. The effective old generation stays unestablished while that value is in effect. A zero or empty value clears it. `--experimental-config-file` or `--experimental-default-config-file` can set `nodeOptions`, including a heap percentage, from a file that is not read back. The effective heap stays unestablished when either flag is present, including when an absolute `--max-old-space-size` is also on the command line or in the recovered `NODE_OPTIONS`. Node accepts the config path as `=` or the next argument.
|
||||
Regex procedure and string search uses ECMAScript Unicode matching in a cancellable worker with a five-second matching deadline and a 128 MiB worker heap. Literal matching remains the default. Cancellation or a matching deadline leaves the session available. This release has no runtime override for that deadline or heap.
|
||||
|
||||
Single-function subprocess output and each cached source facet share a 16 MiB buffered-text capacity. Cached spans are checked before allocation; larger text remains in the local export and the error identifies the file and range. `analyze_function` initially returns the complete dossier, which can still be large; later retained views do not remove that first transfer. Prefer the individual text tools for a focused question.
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ rejects selected summary before analysis; complete delivery remains available.
|
||||
|
||||
Complete `inspect_managed_members` delivery always includes the assembly-wide
|
||||
`member_refs` and `exported_types`, which `type_names` does not narrow, so even
|
||||
one selected or unmatched type can return megabytes. In MCP, select
|
||||
one selected or unmatched type can return megabytes. Select
|
||||
`{"path":"/absolute/path/Assembly.dll","type_names":["Namespace.Type"],"detail":"summary"}`
|
||||
to keep the complete source in the session and receive identity, metadata
|
||||
status, the selection with its unmatched names, coverage/issues, the separate
|
||||
@@ -115,49 +115,6 @@ literal pool's own coverage. Older Evidence that never examined exported types
|
||||
or user strings reports a null count and refuses those pages rather than
|
||||
returning an empty one.
|
||||
|
||||
#### CLI summary and saved member rows
|
||||
|
||||
The CLI accepts the same `complete` and `summary` detail choices. Omitted or
|
||||
`--detail complete` returns complete Evidence. To receive a summary, choose a
|
||||
new Evidence bundle destination so its original complete parent remains
|
||||
available after the command exits:
|
||||
|
||||
```bash
|
||||
rea inspect-managed-members /absolute/path/Assembly.dll --type Namespace.Type \
|
||||
--detail summary --evidence-bundle /absolute/path/evidence/members.json --json
|
||||
```
|
||||
|
||||
`--evidence-bundle` writes the complete original Evidence in REA's canonical
|
||||
bundle format. Its parent directory must exist, and it never replaces an
|
||||
existing path. Relative paths resolve against the command's working directory.
|
||||
The option also works with complete delivery when a saved bundle is useful.
|
||||
Summary without a destination is rejected before analysis. A publication failure
|
||||
returns a typed error with the completed Evidence under
|
||||
`details.partial_observation`; it does not report successful summary delivery.
|
||||
Cancellation before publication prevents the new output. Once atomic
|
||||
publication has started, it can complete despite cancellation; a cancelled
|
||||
delivery then retains the committed path and byte count under
|
||||
`details.diagnostics.evidence_bundle`.
|
||||
|
||||
Copy `normalized_result.parent_evidence_id` from the summary into the
|
||||
`source.evidence_id` field of the `managed-members` request above, then save that
|
||||
small request as `members-page.json` and run:
|
||||
|
||||
```bash
|
||||
rea inspect-analysis-view /absolute/path/evidence/members-page.json \
|
||||
--evidence-bundle /absolute/path/evidence/members.json --json
|
||||
```
|
||||
|
||||
The view command authenticates the selected bundle and resolves that exact
|
||||
parent ID. It does not reopen the assembly, so the original target may have
|
||||
been moved or removed. Subsequent calls can select another collection or use
|
||||
the returned `coverage.next_offset` to continue the same collection. An inline
|
||||
source already includes its parent and cannot also select `--evidence-bundle`.
|
||||
|
||||
Summary changes delivery, while type selection still controls decoding and the
|
||||
same inspection resource limits apply. Each separate CLI page reads and
|
||||
authenticates the saved bundle; the full source is retained on disk.
|
||||
|
||||
## Shipped scope
|
||||
|
||||
The canonical parser admits PE/CLI bytes and their metadata/CIL without loading
|
||||
@@ -665,9 +622,6 @@ 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. 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.
|
||||
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.
|
||||
@@ -234,14 +234,11 @@ 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 (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.
|
||||
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.
|
||||
|
||||
### Error recovery and deadline ownership
|
||||
|
||||
|
||||
@@ -202,38 +202,10 @@ Function dossiers retain this same string evidence. Native call edges retain
|
||||
Hopper's partial `CallReference` classification and exact endpoints; detailed
|
||||
reference flags remain unavailable rather than being invented.
|
||||
Regex searches use ECMAScript Unicode syntax in a cancellable worker with a
|
||||
five-second matching deadline and a worker old-generation heap. Deadline or
|
||||
five-second matching deadline and a 128 MiB worker heap. Deadline or
|
||||
cancellation stops matching while leaving the Hopper API available. This
|
||||
release has no runtime override for that deadline. The worker requests 128
|
||||
MiB. A positive Node.js `--max-old-space-size` on the process that starts
|
||||
REA, including `NODE_OPTIONS`, overrides that request and sets the effective
|
||||
heap. `--max-old-space-size-percentage` takes precedence over that flag in
|
||||
either order. REA does not turn the percentage into a MiB, so the effective
|
||||
heap stays unestablished instead of the 128 MiB request or the absolute flag.
|
||||
The reported `NODE_OPTIONS` value is the process initial environment when that
|
||||
block can be read. A later edit to the environment is not the value Node
|
||||
applied, and an unreadable initial value leaves the effective heap
|
||||
unestablished. The launch Node options are the initial command line on Linux
|
||||
when that buffer still has a program entry, not a later edit to
|
||||
`process.execArgv`. A preload can clear that array after V8 has applied the
|
||||
flags. Setting `process.title` or `--title` overwrites the command line, so a
|
||||
buffer without a program entry leaves the effective heap unestablished.
|
||||
`--env-file` or `--env-file-if-exists` can supply `NODE_OPTIONS`
|
||||
when the initial environment has none. That file is not part of the initial
|
||||
environment block, so the effective heap stays unestablished instead of the
|
||||
128 MiB request. An initial `NODE_OPTIONS` is still used, because Node does
|
||||
not let the file replace it. A positive `--max-heap-size`, including the
|
||||
underscore form and launch `NODE_OPTIONS` on Node versions that allow it,
|
||||
sets the total heap rather than the old generation and can be lower than the
|
||||
128 MiB request. The effective old generation stays unestablished while that
|
||||
value is in effect. A zero or empty value clears it.
|
||||
`--experimental-config-file` or `--experimental-default-config-file` can set
|
||||
`nodeOptions`, including a heap percentage, from a file that is not read
|
||||
back. The effective heap stays unestablished when either flag is present,
|
||||
including when an absolute `--max-old-space-size` is also on the command
|
||||
line or in the recovered `NODE_OPTIONS`. Node accepts the config path as
|
||||
`=` or the next argument.
|
||||
Literal mode retains Unicode casefold matching.
|
||||
release has no runtime override for that deadline or heap. Literal mode
|
||||
retains Unicode casefold matching.
|
||||
|
||||
## Bounded static traces
|
||||
|
||||
|
||||
@@ -18,13 +18,10 @@ tool, view kind, provider, network activity or write effect.
|
||||
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`. A
|
||||
value whose rendered JSON exceeds 256 KiB stays `rendered-oversize` and is
|
||||
replayed from the recorded body, which itself fit. 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`, `body-unusable` or
|
||||
`rendered-oversize`, the unreachable `not-entity` reason leaves the result
|
||||
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
|
||||
@@ -83,10 +80,8 @@ 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, a name whose
|
||||
JSON quoting exceeds the sample, a recorded body under 256 KiB whose rendered
|
||||
JSON exceeds it, non-JSON bodies and the catalog budget; the in-process and
|
||||
compiled MCP servers advertise the
|
||||
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
|
||||
|
||||
@@ -110,14 +110,3 @@ 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.
|
||||
|
||||
## After the squash onto main
|
||||
|
||||
The branch was squashed onto main once the state-serve branch merged (166fb082). Against a merge of the previous head
|
||||
into that main, the squash keeps both sides of every conflict and drops nothing of this branch. Two tests then failed
|
||||
on it and were refitted with the oversized-document fix in 2a926a0a: the bundle test of a state catalog over the
|
||||
discovery budget uses 300 read tools, inside the window where the state's catalog is refused and replay's, with its
|
||||
response summaries, still fits (280–320 by a probe), and the rea-state validation test names the pushed document by
|
||||
the count of documents the state holds. The replay-only card test pinned main's 11,131-byte card; it now pins this
|
||||
branch's 12,818-byte card, whose descriptions name each tool's recorded responses. The branch then merged main at
|
||||
d7ca93cc without conflicts.
|
||||
@@ -90,10 +90,6 @@ 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
|
||||
@@ -176,8 +172,7 @@ host or a UNC path, injected https downloads (status, declared and streamed
|
||||
size, the timeout whether or not the fetcher honours its signal), the default
|
||||
fetch's refused redirects, arrival-order application across requests, a batch
|
||||
another request's reset cannot split, cancellation by the server's stop and by
|
||||
a departed caller, a server that closes while a download is stalled, a
|
||||
retained state-file close that the next file read and `close` retry, the
|
||||
a departed caller, a server that closes while a download is stalled, 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,13 +44,6 @@ 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.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,406 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,360 @@
|
||||
# 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
-13
@@ -65,18 +65,6 @@ API dependency, and independent fixture compiler oracles keep their existing
|
||||
TypeScript versions.
|
||||
|
||||
`npm run build:cached` compiles the CLI/MCP runtime and its packaged skill.
|
||||
The build also records `dist/mcp-catalog.json` (compact inputs) and
|
||||
`dist/mcp-catalog-full.json` from the canonical SDK registrations and binds
|
||||
each to the runtime with its own SHA-256 digest. These are
|
||||
runtime artifacts, separate from the test catalog below. Default target-free
|
||||
MCP startup serves its schemas without constructing analysis validators;
|
||||
the first tool or guided-workflow request materializes the canonical runtime
|
||||
on that same SDK server. Explicit compact/full input selections preserve
|
||||
their advertised schemas throughout materialization. Initial-target,
|
||||
HTTP and output-schema-off startup keep the eager path; source runs without
|
||||
recorded artifacts also use eager composition. Compare memory both after discovery and after a completed
|
||||
request; report diagnostic GC separately from normal resident memory.
|
||||
|
||||
Turbo's strict task environment preserves caller-selected `GOMAXPROCS`,
|
||||
`GOMEMLIMIT`, and `UV_THREADPOOL_SIZE` for resource-constrained builds and
|
||||
checks. These execution controls do not change artifact content, so changing
|
||||
@@ -1818,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 are not served, that the runner exits `0` with
|
||||
manifest because write tools the capture did not confirm are not served, that the runner exits `0` with
|
||||
`tasks/smoke.json` reported as passed and the verifier's description naming
|
||||
that served tool count, and that no container exists
|
||||
after the run that did not exist before; the container inventory runs even when
|
||||
|
||||
@@ -120,8 +120,6 @@ 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
|
||||
@@ -595,12 +593,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, 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, 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 |
|
||||
|
||||
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
|
||||
@@ -610,41 +608,84 @@ 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), `query-varies` (another read of one path under
|
||||
more than one query) and `rendered-oversize` (a document value whose JSON
|
||||
renders to more than 256 KiB). A document holds the value of its one `path`, so for such
|
||||
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
|
||||
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 no effects of writes. The state
|
||||
computes responses or derived values, and write effects only as modelled from
|
||||
recorded calls. 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 whose recorded text and rendered JSON are each at most 256 KiB, 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 from it and loads replacement states through the
|
||||
AgentEnv data plane, as
|
||||
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
|
||||
[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. Those budgets have no runtime override. `capture_timeout_ms` does not
|
||||
raise them. Exceeding a budget returns no partial success.
|
||||
budget. 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 use `capture_timeout_ms` (default 30000) and independently supervised
|
||||
both commands have a 30-second wall deadline 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 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.
|
||||
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.
|
||||
|
||||
`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 `decode_timeout_ms` (default 20000). The codec heap, map budget, and reply budget have no runtime override. Reported actual V8 heap limits
|
||||
budget, one V8 worker and a 20-second deadline. 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,8 +86,7 @@ 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. Those structural budgets and the
|
||||
192 MiB codec heap have no runtime override. Exceeding a budget returns an explicit
|
||||
rows/segments and 64 nested section levels. 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.
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Summarize completed CLI trials; token counts are estimates, not model usage."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
import tiktoken
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('root', type=Path)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
root = args.root
|
||||
inputs = json.loads((root / 'measurement-inputs.json').read_text())
|
||||
expected = inputs['expected_runtime_bindings']
|
||||
assert expected['complete']['git_revision'] == inputs['baseline_revision']
|
||||
assert expected['selected']['git_revision'] == inputs['feature_parent_revision']
|
||||
assert expected['complete']['root'] != expected['selected']['root']
|
||||
assert expected['complete']['compiled_cli_sha256'] != expected['selected']['compiled_cli_sha256']
|
||||
assert expected['complete']['source_diff_sha256'] == hashlib.sha256(b'').hexdigest()
|
||||
assert expected['complete']['untracked_source_sha256'] == hashlib.sha256(b'[]').hexdigest()
|
||||
enc = tiktoken.get_encoding('cl100k_base')
|
||||
count = lambda text: len(enc.encode(text, disallowed_special=()))
|
||||
compact = lambda obj: json.dumps(obj, ensure_ascii=False, separators=(',', ':'))
|
||||
runs = []
|
||||
for case in sorted(root.glob('pair-*')):
|
||||
if not case.is_dir():
|
||||
continue
|
||||
report = json.loads((case / 'report.json').read_text())
|
||||
assert report['status'] == 'passed' and report['cleanup_verified']
|
||||
assert report['runtime_binding_verified']
|
||||
assert report['runtime_binding'] == expected[report['mode']], case.name
|
||||
assert str(Path(report['runtime']).resolve()) == report['runtime_binding']['root']
|
||||
receipt = next(json.loads(line) for line in reversed(case.with_name(case.name + '.log').read_text().splitlines()) if line.startswith('{"resource_scope":'))
|
||||
obs = receipt['payload_resource_observation']
|
||||
assert receipt['abort'] is None and receipt['payload_resource_observation_verified']
|
||||
assert obs['payload_exit_code'] == 0 and obs['payload_cleanup_verified']
|
||||
assert obs['resource_events']['memory.events']['oom_kill'] == 0
|
||||
tokens = {'question_and_recipe': count(report['question'] + '\n' + report['recipe']),
|
||||
'arguments': sum(count(compact(call['arguments'])) for call in report['calls']),
|
||||
'help_and_replies': sum(count((case / call['stdout_file']).read_text()) for call in report['calls'])}
|
||||
tokens['whole_task'] = sum(tokens.values())
|
||||
runs.append({'case': case.name, 'mode': report['mode'], 'cache': report['cache'], 'runtime': report['runtime'], 'runtime_binding': report['runtime_binding'], 'runtime_binding_audit_ms': report['runtime_binding_audit_ms'],
|
||||
'wall_seconds': receipt['elapsed_seconds'], 'cpu_seconds': obs['cpu_stat']['usage_usec'] / 1e6,
|
||||
'peak_process_family_bytes_including_page_cache': obs['memory_peak_bytes'],
|
||||
'tokens_estimated': tokens, 'calls': report['calls'],
|
||||
'parent_evidence_id': report['parent_evidence_id'], 'parent_semantic_sha256': report['parent_semantic_sha256'],
|
||||
'answer_sha256': report['answer_sha256'], 'oracle_verified': report['oracle_verified'],
|
||||
'cleanup_verified': True, 'resource_events': obs['resource_events']})
|
||||
assert len(runs) == 12
|
||||
for key in ['answer_sha256', 'parent_semantic_sha256', 'parent_evidence_id']:
|
||||
assert len({row[key] for row in runs}) == 1, key
|
||||
summary = {}
|
||||
for cache in ['cold', 'warm']:
|
||||
summary[cache] = {}
|
||||
for mode in ['complete', 'selected']:
|
||||
rows = [r for r in runs if r['mode'] == mode and r['cache'] == cache]
|
||||
assert len(rows) == 3
|
||||
summary[cache][mode] = {key: {'median': statistics.median(r[key] for r in rows), 'min': min(r[key] for r in rows), 'max': max(r[key] for r in rows)} for key in ['wall_seconds', 'cpu_seconds', 'peak_process_family_bytes_including_page_cache']}
|
||||
summary[cache][mode]['whole_task_tokens_estimated'] = [r['tokens_estimated']['whole_task'] for r in rows]
|
||||
result = {'status': 'passed', 'tokenizer': 'tiktoken==0.12.0/cl100k_base; estimates, not model usage',
|
||||
'context_scope': 'Question, supplied command recipe, actual selected-command help, all logical CLI argv and all stdout. No model invoked; reasoning, framing, irrelevant tool discovery and actual native-agent usage unmeasured.',
|
||||
'cache_scope': 'cold disables Node compile cache; warm primes the identical workflow once per runtime/mode before timing. Fresh processes and isolated HOME each trial; OS page cache uncontrolled.',
|
||||
'resource_scope': 'Whole task driver plus all CLI children; includes pre/post source-and-compiled-runtime binding verification, bundle publication/authentication, help, extra calls and local answer/oracle checking. Kernel cgroup peak includes file cache; not sampled RSS. Download/build/oracle preparation and priming excluded.',
|
||||
'acquisition': json.loads((root / 'acquisition.json').read_text()), 'measurement_inputs': inputs,
|
||||
'summary': summary, 'runs': runs}
|
||||
native = root / 'native-control'
|
||||
if native.exists():
|
||||
binding = json.loads((native / 'runtime-binding.json').read_text())
|
||||
assert binding['runtime_binding_verified'] and binding['runtime_binding'] == expected['complete']
|
||||
assert str(Path(binding['runtime']).resolve()) == binding['runtime_binding']['root']
|
||||
receipt = next(json.loads(line) for line in reversed((root / 'native-control.log').read_text().splitlines()) if line.startswith('{"resource_scope":'))
|
||||
obs = receipt['payload_resource_observation']
|
||||
assert receipt['abort'] is None and receipt['payload_resource_observation_verified'] and obs['payload_exit_code'] == 0 and obs['payload_cleanup_verified']
|
||||
baseline_case = root / next(run['case'] for run in runs if run['mode'] == 'complete')
|
||||
assert json.loads((native / 'answer.json').read_text()) == json.loads((baseline_case / 'answer.json').read_text())
|
||||
control_source = Path(__file__).with_name('managed-cli-local-control.py').read_text()
|
||||
result['existing_native_control'] = {'runtime': binding['runtime'], 'runtime_binding': binding['runtime_binding'], 'runtime_binding_audit_ms': binding['runtime_binding_audit_ms'], 'repetitions': 1,
|
||||
'tokens_estimated_script_plus_help_plus_answer': count(control_source) + count((native / 'help.txt').read_text()) + count((native / 'answer.json').read_text()),
|
||||
'token_scope': 'Projection source, original CLI help and returned answer; excludes shared question and launcher argv. No actual model usage measured.',
|
||||
'wall_seconds': receipt['elapsed_seconds'], 'cpu_seconds': obs['cpu_stat']['usage_usec'] / 1e6,
|
||||
'peak_process_family_bytes_including_page_cache': obs['memory_peak_bytes'], 'answer_matches': True, 'cleanup_verified': True}
|
||||
args.output.write_text(json.dumps(result, indent=2) + '\n')
|
||||
print(json.dumps(summary, indent=2))
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Existing native code-composition control: save complete stdout, return selected facts."""
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('runtime', type=Path)
|
||||
parser.add_argument('target', type=Path)
|
||||
parser.add_argument('output', type=Path)
|
||||
parser.add_argument('--measurement-inputs', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
args.output.mkdir()
|
||||
node = 'node'
|
||||
inputs = json.loads(args.measurement_inputs.read_text())
|
||||
expected = inputs['expected_runtime_bindings']['complete']
|
||||
assert expected['git_revision'] == inputs['baseline_revision']
|
||||
helper = Path(__file__).with_name('managed-cli-runtime.mjs')
|
||||
def capture_binding():
|
||||
return json.loads(subprocess.check_output([node, str(helper), str(args.runtime)], text=True))
|
||||
binding_started = time.monotonic()
|
||||
binding = capture_binding()
|
||||
binding_audit_ms = {'before': (time.monotonic() - binding_started) * 1000}
|
||||
assert binding == expected
|
||||
|
||||
entry = str(args.runtime / 'scripts/rea.mjs')
|
||||
help_result = subprocess.run([node, entry, 'inspect-managed-members', '--help'], check=True, capture_output=True, text=True)
|
||||
(args.output / 'help.txt').write_text(help_result.stdout)
|
||||
with (args.output / 'parent.json').open('w') as file:
|
||||
subprocess.run([node, entry, 'inspect-managed-members', str(args.target), '--type', 'Newtonsoft.Json.JsonConvert', '--json'], check=True, stdout=file)
|
||||
parent = json.loads((args.output / 'parent.json').read_text())
|
||||
data = parent['normalized_result']
|
||||
answer = {'parent_evidence_id': parent['evidence_id'], 'artifact_sha256': data['artifact']['sha256'],
|
||||
'selection': data['selection'], 'source_coverage': data['coverage'],
|
||||
'total_methods': len(data['methods']), 'total_member_refs': len(data['member_refs']),
|
||||
'methods': data['methods'][:8], 'member_refs': data['member_refs'][:16],
|
||||
'limitation': 'MemberRef rows are assembly-wide; methods are selected-type rows. Page exhaustion is not an absence claim.'}
|
||||
(args.output / 'answer.json').write_text(json.dumps(answer))
|
||||
binding_finished = time.monotonic()
|
||||
assert capture_binding() == binding
|
||||
binding_audit_ms['after'] = (time.monotonic() - binding_finished) * 1000
|
||||
(args.output / 'runtime-binding.json').write_text(json.dumps({'runtime': str(args.runtime), 'runtime_binding': binding, 'runtime_binding_verified': True, 'runtime_binding_audit_ms': binding_audit_ms}))
|
||||
print(json.dumps(answer))
|
||||
@@ -1,18 +0,0 @@
|
||||
import json,hashlib,argparse
|
||||
from pathlib import Path
|
||||
import dnfile
|
||||
parser=argparse.ArgumentParser();parser.add_argument('root',type=Path);root=parser.parse_args().root;path=root/'Newtonsoft.Json.dll'
|
||||
pe=dnfile.dnPE(str(path));assert pe.net is not None
|
||||
counts={name:len(getattr(pe.net.mdtables,name).rows) if getattr(pe.net.mdtables,name,None) else 0 for name in ['TypeDef','MethodDef','MemberRef','Field','Property','Event','Param','StandAloneSig','TypeSpec','ExportedType']}
|
||||
selected=[]
|
||||
for index,row in enumerate(pe.net.mdtables.TypeDef.rows,1):
|
||||
name=(str(row.TypeNamespace)+'.' if str(row.TypeNamespace) else '')+str(row.TypeName)
|
||||
if name!='Newtonsoft.Json.JsonConvert':continue
|
||||
selected.append({'token':f'0x{0x02000000|index:08x}','name':name,'methods':[{'token':f'0x{0x06000000|m.row_index:08x}','name':str(m.row.Name),'rva':m.row.Rva,'signature_hex':m.row.Signature.value.hex()} for m in row.MethodList]})
|
||||
assert len(selected)==1
|
||||
refs=[{'token':f'0x{0x0a000000|i:08x}','name':str(row.Name),'signature_hex':row.Signature.value.hex()} for i,row in enumerate(pe.net.mdtables.MemberRef.rows[:16],1)]
|
||||
assembly=pe.net.mdtables.Assembly.rows[0]
|
||||
oracle={'decoder':'dnfile==0.18.0 (independent Python PE/CLI parser)','assembly_sha256':hashlib.sha256(path.read_bytes()).hexdigest(),'assembly_name':str(assembly.Name),'assembly_version':'.'.join(str(getattr(assembly,k)) for k in ['MajorVersion','MinorVersion','BuildNumber','RevisionNumber']),'row_counts':counts,'selected_types':selected,'member_refs_first_16':refs,'target_execution':False}
|
||||
(root/'oracle.json').write_text(json.dumps(oracle,indent=2)+'\n')
|
||||
print(json.dumps({'assembly':oracle['assembly_name'],'version':oracle['assembly_version'],'row_counts':counts,'selected_type':selected[0]['name'],'selected_methods':len(selected[0]['methods']),'target_execution':False}))
|
||||
pe.close()
|
||||
@@ -1,106 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, readdir, realpath } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const sha = (value) => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
/** Bind a trial to its source checkout and compiled runtime, including deferred assets. */
|
||||
export const captureRuntimeBinding = async (runtime, kind = "cli") => {
|
||||
assert.ok(kind === "cli" || kind === "mcp", "Unknown runtime entry");
|
||||
const root = await realpath(runtime);
|
||||
const git = (args) =>
|
||||
execFileSync("git", args, { cwd: root, maxBuffer: 16 * 1024 * 1024 });
|
||||
const gitRevision = git(["rev-parse", "HEAD"]).toString().trim();
|
||||
const sourcePaths = ["src", "scripts", "package.json", "package-lock.json"];
|
||||
const sourceDiff = git([
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--binary",
|
||||
"HEAD",
|
||||
"--",
|
||||
...sourcePaths,
|
||||
]);
|
||||
const untracked = git([
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
"-z",
|
||||
"--",
|
||||
...sourcePaths,
|
||||
])
|
||||
.toString()
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
const untrackedEntries = [];
|
||||
for (const name of untracked)
|
||||
untrackedEntries.push([name, sha(await readFile(join(root, name)))]);
|
||||
const files = ["scripts/rea.mjs", "package.json", "package-lock.json"];
|
||||
const visit = async (directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
for (const entry of entries.sort((a, b) =>
|
||||
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
||||
)) {
|
||||
const path = join(directory, entry.name);
|
||||
assert.ok(!entry.isSymbolicLink(), `Unbound compiled CLI link: ${path}`);
|
||||
if (entry.isDirectory()) await visit(path);
|
||||
else if (entry.isFile() && /\.(?:[cm]?js|json)$/.test(entry.name))
|
||||
files.push(relative(root, path));
|
||||
}
|
||||
};
|
||||
// MCP materializes additional workers and reads build-owned JSON after
|
||||
// discovery. Bind every compiled JS/JSON asset, rather than only its entry.
|
||||
await visit(join(root, kind === "mcp" ? "dist" : "dist/cli-runtime"));
|
||||
assert.ok(
|
||||
files.includes(
|
||||
kind === "mcp"
|
||||
? join("dist", "mcp", "main.js")
|
||||
: join("dist", "cli-runtime", "cli.js"),
|
||||
),
|
||||
"Missing compiled runtime entry",
|
||||
);
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
for (const name of files.sort()) {
|
||||
const value = await readFile(join(root, name));
|
||||
bytes += value.length;
|
||||
hash.update(JSON.stringify([name, value.length, sha(value)]) + "\n");
|
||||
}
|
||||
return {
|
||||
binding_version: kind === "mcp" ? 2 : 1,
|
||||
root,
|
||||
node: process.version,
|
||||
git_revision: gitRevision,
|
||||
source_diff_sha256: sha(sourceDiff),
|
||||
untracked_source_sha256: sha(JSON.stringify(untrackedEntries)),
|
||||
...(kind === "mcp"
|
||||
? {
|
||||
compiled_runtime_sha256: hash.digest("hex"),
|
||||
compiled_runtime_files: files.length,
|
||||
compiled_runtime_bytes: bytes,
|
||||
}
|
||||
: {
|
||||
compiled_cli_sha256: hash.digest("hex"),
|
||||
compiled_cli_files: files.length,
|
||||
compiled_cli_bytes: bytes,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const assertRuntimeBinding = (actual, expected, mode) => {
|
||||
assert.ok(expected, `Missing expected runtime binding for ${mode}`);
|
||||
assert.deepEqual(actual, expected, `Runtime binding differs for ${mode}`);
|
||||
};
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
await captureRuntimeBinding(process.argv[2], process.argv[3]),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"scope": "Single completed SDK regression after shared JSON reader/admission changes; not a new paired source-to-bundle performance comparison. Existing semantic-node cap one answers static correspondence only; no equal semantic-graph coverage claim. Corpus/reference preparation performed earlier and excluded.",
|
||||
"artifact": {
|
||||
"name": "Redux Toolkit 2.2.7",
|
||||
"tarball_sha256": "f2517c466b50424b661a27e48039f15db7783e5822f46b5c7d39f20104ac67a9"
|
||||
},
|
||||
"runtime_parent_revision": "4fab2dfda16236dc407aa179920780d6a970dda3",
|
||||
"production_manifest": "managed-cli.json#measurement_inputs.production_sources_sha256",
|
||||
"input_schema_profile": "default compact",
|
||||
"output_schema_profile": "full",
|
||||
"tokens_estimated": {
|
||||
"input_catalog_once": 49054,
|
||||
"arguments": 307,
|
||||
"text_replies": 7017,
|
||||
"whole_task": 56378
|
||||
},
|
||||
"token_scope": "tiktoken==0.12.0/cl100k_base; one name/description/input-schema catalog plus actual arguments and MCP text replies; excludes output schemas, framing, model prompts/reasoning and actual model usage. Not actual native-agent token usage.",
|
||||
"actual_model_usage": null,
|
||||
"wall_seconds": 21.38,
|
||||
"cpu_seconds": 21.022683,
|
||||
"peak_process_family_bytes_including_page_cache": 974041088,
|
||||
"calls": [
|
||||
{
|
||||
"name": "analyze_javascript_application",
|
||||
"elapsed_ms": 9837.265845000002,
|
||||
"is_error": false,
|
||||
"reply_bytes": 25941
|
||||
},
|
||||
{
|
||||
"name": "compare_source_to_bundle",
|
||||
"elapsed_ms": 256.3669919999993,
|
||||
"is_error": false,
|
||||
"reply_bytes": 13199
|
||||
},
|
||||
{
|
||||
"name": "inspect_analysis_view",
|
||||
"elapsed_ms": 3012.057904000001,
|
||||
"is_error": false,
|
||||
"reply_bytes": 19393
|
||||
},
|
||||
{
|
||||
"name": "export_evidence_bundle",
|
||||
"elapsed_ms": 148.9011160000009,
|
||||
"is_error": false,
|
||||
"reply_bytes": 364
|
||||
}
|
||||
],
|
||||
"independent_proof": {
|
||||
"status": "passed",
|
||||
"method": "Independent Python JSON/SHA-256/POSIX path and basename joins; no REA parser or matcher.",
|
||||
"source_files": 180,
|
||||
"embedded_nodes": 99,
|
||||
"candidate_relations": 210,
|
||||
"strong_location_rows": 33,
|
||||
"limitation": "Static multiple-location projections; no semantic-equivalence or removal claim."
|
||||
},
|
||||
"resource_events": {
|
||||
"memory.events": {
|
||||
"low": 0,
|
||||
"high": 0,
|
||||
"max": 0,
|
||||
"oom": 0,
|
||||
"oom_kill": 0,
|
||||
"oom_group_kill": 0
|
||||
},
|
||||
"pids.events": {
|
||||
"max": 0
|
||||
}
|
||||
},
|
||||
"cleanup_verified": true
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
import {
|
||||
captureRuntimeBinding,
|
||||
assertRuntimeBinding,
|
||||
} from "./managed-cli-runtime.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
const execute = promisify(execFile);
|
||||
const [runtime, root, output, mode, cache = "cold"] = process.argv.slice(2);
|
||||
for (const value of [runtime, root, output])
|
||||
assert.equal(resolve(value), value);
|
||||
assert.ok(["complete", "selected"].includes(mode));
|
||||
assert.ok(["cold", "warm"].includes(cache));
|
||||
await mkdir(output);
|
||||
await mkdir(join(output, "tmp"));
|
||||
const oracle = JSON.parse(await readFile(join(root, "oracle.json"), "utf8"));
|
||||
const target = join(root, "Newtonsoft.Json.dll");
|
||||
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
||||
assert.equal(hash(await readFile(target)), oracle.assembly_sha256);
|
||||
const stable = (value) =>
|
||||
Array.isArray(value)
|
||||
? value.map(stable)
|
||||
: value !== null && typeof value === "object"
|
||||
? Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, stable(value[key])]),
|
||||
)
|
||||
: value;
|
||||
const report = {
|
||||
status: "incomplete",
|
||||
runtime,
|
||||
mode,
|
||||
cache,
|
||||
node: process.version,
|
||||
question:
|
||||
"Report identity/selection/source coverage for JsonConvert, its total method count and the first eight complete method records, plus the first sixteen assembly-wide MemberRef records and continuation coverage.",
|
||||
recipe:
|
||||
mode === "selected"
|
||||
? "Use --detail summary --evidence-bundle FILE; pass the exact parent_evidence_id to inspect-analysis-view --evidence-bundle FILE with source kind retained-evidence and view {kind:managed-members,collection:methods|member_refs,offset:0,limit:8|16}."
|
||||
: "Use inspect-managed-members --type Newtonsoft.Json.JsonConvert; the completed full Evidence supplies the requested rows and remains accessible.",
|
||||
calls: [],
|
||||
cleanup_verified: false,
|
||||
};
|
||||
const began = performance.now();
|
||||
const environment = {
|
||||
PATH: process.env.PATH,
|
||||
HOME: output,
|
||||
TMPDIR: join(output, "tmp"),
|
||||
LANG: "C.UTF-8",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
UV_THREADPOOL_SIZE: "1",
|
||||
NODE_OPTIONS: "--max-old-space-size=2048",
|
||||
...(cache === "cold"
|
||||
? { NODE_DISABLE_COMPILE_CACHE: "1" }
|
||||
: { NODE_COMPILE_CACHE: join(root, `compile-cache-${mode}`) }),
|
||||
};
|
||||
const call = async (label, args, json = true) => {
|
||||
const started = performance.now();
|
||||
const result = await execute(
|
||||
process.execPath,
|
||||
[join(runtime, "scripts/rea.mjs"), ...args],
|
||||
{
|
||||
cwd: output,
|
||||
env: environment,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
timeout: 120000,
|
||||
},
|
||||
);
|
||||
await writeFile(join(output, label + ".stdout"), result.stdout);
|
||||
await writeFile(join(output, label + ".stderr"), result.stderr);
|
||||
report.calls.push({
|
||||
label,
|
||||
arguments: ["rea", ...args],
|
||||
elapsed_ms: performance.now() - started,
|
||||
stdout_bytes: Buffer.byteLength(result.stdout),
|
||||
stdout_file: label + ".stdout",
|
||||
});
|
||||
return json ? JSON.parse(result.stdout) : result.stdout;
|
||||
};
|
||||
let parent, summary, methods, refs;
|
||||
try {
|
||||
const inputs = JSON.parse(
|
||||
await readFile(join(root, "measurement-inputs.json"), "utf8"),
|
||||
);
|
||||
const expected = inputs.expected_runtime_bindings?.[mode];
|
||||
assert.ok(expected, `Missing expected runtime for ${mode}`);
|
||||
assert.equal(
|
||||
expected.git_revision,
|
||||
mode === "complete"
|
||||
? inputs.baseline_revision
|
||||
: inputs.feature_parent_revision,
|
||||
);
|
||||
assert.notEqual(
|
||||
inputs.expected_runtime_bindings.complete.root,
|
||||
inputs.expected_runtime_bindings.selected.root,
|
||||
);
|
||||
assert.notEqual(
|
||||
inputs.expected_runtime_bindings.complete.compiled_cli_sha256,
|
||||
inputs.expected_runtime_bindings.selected.compiled_cli_sha256,
|
||||
);
|
||||
const bindingStarted = performance.now();
|
||||
report.runtime_binding = await captureRuntimeBinding(runtime);
|
||||
report.runtime_binding_audit_ms = {
|
||||
before: performance.now() - bindingStarted,
|
||||
};
|
||||
assertRuntimeBinding(report.runtime_binding, expected, mode);
|
||||
await call("help-members", ["inspect-managed-members", "--help"], false);
|
||||
if (mode === "complete") {
|
||||
parent = await call("complete", [
|
||||
"inspect-managed-members",
|
||||
target,
|
||||
"--type",
|
||||
"Newtonsoft.Json.JsonConvert",
|
||||
"--json",
|
||||
]);
|
||||
methods = parent.normalized_result.methods.slice(0, 8);
|
||||
refs = parent.normalized_result.member_refs.slice(0, 16);
|
||||
} else {
|
||||
await call("help-view", ["inspect-analysis-view", "--help"], false);
|
||||
const bundlePath = join(output, "parent.json");
|
||||
summary = await call("summary", [
|
||||
"inspect-managed-members",
|
||||
target,
|
||||
"--type",
|
||||
"Newtonsoft.Json.JsonConvert",
|
||||
"--detail",
|
||||
"summary",
|
||||
"--evidence-bundle",
|
||||
bundlePath,
|
||||
"--json",
|
||||
]);
|
||||
const id = summary.normalized_result.parent_evidence_id;
|
||||
assert.ok(id);
|
||||
const bundle = JSON.parse(await readFile(bundlePath, "utf8"));
|
||||
assert.equal(bundle.records.length, 1);
|
||||
parent = bundle.records[0];
|
||||
assert.equal(parent.evidence_id, id);
|
||||
for (const [collection, limit] of [
|
||||
["methods", 8],
|
||||
["member_refs", 16],
|
||||
]) {
|
||||
const value = await call(collection, [
|
||||
"inspect-analysis-view",
|
||||
JSON.stringify({
|
||||
source: { kind: "retained-evidence", evidence_id: id },
|
||||
view: { kind: "managed-members", collection, offset: 0, limit },
|
||||
}),
|
||||
"--evidence-bundle",
|
||||
bundlePath,
|
||||
"--json",
|
||||
]);
|
||||
const page = value.normalized_result;
|
||||
assert.equal(page.parent_evidence_id, id);
|
||||
assert.deepEqual(page.source_coverage, parent.normalized_result.coverage);
|
||||
assert.deepEqual(
|
||||
page.page.rows,
|
||||
parent.normalized_result[collection].slice(0, limit),
|
||||
);
|
||||
assert.equal(
|
||||
page.coverage.total,
|
||||
parent.normalized_result[collection].length,
|
||||
);
|
||||
assert.equal(page.coverage.next_offset, limit);
|
||||
assert.equal(page.coverage.exhausted, false);
|
||||
if (collection === "methods") methods = page.page.rows;
|
||||
else refs = page.page.rows;
|
||||
}
|
||||
}
|
||||
const data = parent.normalized_result;
|
||||
assert.equal(data.artifact.sha256, oracle.assembly_sha256);
|
||||
assert.equal(data.methods.length, 72);
|
||||
assert.equal(data.member_refs.length, 2149);
|
||||
assert.equal(data.types.length, 1);
|
||||
assert.equal(data.types[0].token, oracle.selected_types[0].token);
|
||||
for (let i = 0; i < data.methods.length; i++) {
|
||||
const row = data.methods[i],
|
||||
wanted = oracle.selected_types[0].methods[i];
|
||||
assert.equal(row.token, wanted.token);
|
||||
assert.equal(row.name, wanted.name);
|
||||
assert.equal(row.rva, wanted.rva);
|
||||
assert.equal(
|
||||
row.signature.raw_sha256,
|
||||
hash(Buffer.from(wanted.signature_hex, "hex")),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < refs.length; i++) {
|
||||
const row = refs[i],
|
||||
wanted = oracle.member_refs_first_16[i];
|
||||
assert.equal(row.token, wanted.token);
|
||||
assert.equal(row.name, wanted.name);
|
||||
assert.equal(
|
||||
row.signature.raw_sha256,
|
||||
hash(Buffer.from(wanted.signature_hex, "hex")),
|
||||
);
|
||||
}
|
||||
assert.equal(methods.length, 8);
|
||||
assert.equal(refs.length, 16);
|
||||
if (summary) {
|
||||
assert.equal(summary.normalized_result.summary.reported_counts.methods, 72);
|
||||
assert.equal(
|
||||
summary.normalized_result.summary.reported_counts.member_refs,
|
||||
2149,
|
||||
);
|
||||
assert.deepEqual(summary.normalized_result.summary.coverage, data.coverage);
|
||||
assert.deepEqual(
|
||||
summary.normalized_result.summary.selection,
|
||||
data.selection,
|
||||
);
|
||||
}
|
||||
const answer = {
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
artifact_sha256: oracle.assembly_sha256,
|
||||
selection: data.selection,
|
||||
source_coverage: data.coverage,
|
||||
total_methods: data.methods.length,
|
||||
total_member_refs: data.member_refs.length,
|
||||
methods,
|
||||
member_refs: refs,
|
||||
limitation:
|
||||
"MemberRef rows are assembly-wide; methods are selected-type rows. Page exhaustion is not an absence claim.",
|
||||
};
|
||||
report.parent_evidence_id = parent.evidence_id;
|
||||
report.parent_semantic_sha256 = hash(JSON.stringify(stable(parent)));
|
||||
report.answer_sha256 = hash(JSON.stringify(stable(answer)));
|
||||
report.oracle_verified = {
|
||||
all_selected_method_metadata: 72,
|
||||
returned_methods: 8,
|
||||
returned_member_refs: 16,
|
||||
};
|
||||
const bindingFinished = performance.now();
|
||||
assertRuntimeBinding(
|
||||
await captureRuntimeBinding(runtime),
|
||||
report.runtime_binding,
|
||||
mode,
|
||||
);
|
||||
report.runtime_binding_audit_ms.after = performance.now() - bindingFinished;
|
||||
report.runtime_binding_verified = true;
|
||||
report.status = "passed";
|
||||
await writeFile(join(output, "parent-evidence.json"), JSON.stringify(parent));
|
||||
await writeFile(join(output, "answer.json"), JSON.stringify(answer));
|
||||
} finally {
|
||||
report.elapsed_ms = performance.now() - began;
|
||||
report.cleanup_verified = true;
|
||||
await writeFile(
|
||||
join(output, "report.json"),
|
||||
JSON.stringify(report, null, 2) + "\n",
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
status: report.status,
|
||||
mode,
|
||||
cache,
|
||||
parent: report.parent_evidence_id,
|
||||
answer: report.answer_sha256,
|
||||
calls: report.calls.map((c) => ({
|
||||
label: c.label,
|
||||
elapsed_ms: c.elapsed_ms,
|
||||
bytes: c.stdout_bytes,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,219 +0,0 @@
|
||||
# Portable managed CLI member views
|
||||
|
||||
Issue #5148 / PR #5167. Baseline `main@3dbe314ada9f6dcc994ca4655dc0faf082503847`;
|
||||
feature integrates the original `7b3ed4d025edd24d062cde0327a53bd311e174fc`.
|
||||
The measured production-file SHA-256 manifest is in [managed-cli.json](managed-cli.json).
|
||||
These are working-tree measurements. Each trial verifies both source and the
|
||||
complete compiled CLI graph before and after execution against its explicitly
|
||||
selected build. The two roles have distinct checkout roots and compiled digests.
|
||||
Identity-audit time is included in whole-task costs and recorded separately.
|
||||
The audit reads compiled files before timing individual CLI calls, warming those
|
||||
filesystem pages; cold means disabled Node compile cache, not disk-cold startup.
|
||||
|
||||
## Question and independent checks
|
||||
|
||||
Report identity, selected type, source coverage, the total method count, the first
|
||||
eight complete method records, and the first sixteen assembly-wide MemberRef
|
||||
records, including continuation coverage. Keep complete original Evidence available.
|
||||
The question deliberately does not ask for every method or reference.
|
||||
|
||||
Public artifact: [Newtonsoft.Json 13.0.3](https://www.nuget.org/packages/Newtonsoft.Json/13.0.3),
|
||||
from the NuGet flat-container archive. The 2,441,966-byte archive SHA-256 is
|
||||
`872fc189e638ab1056555b03aaa38f68bcb54286e221aa646eb1129babf63c77`.
|
||||
Selected member `lib/net6.0/Newtonsoft.Json.dll`, 712,464 bytes, SHA-256
|
||||
`22c649f75fce5be7c7ccda8880473b634ef69ecf33f5d1ab8ad892caf47d5a07`.
|
||||
Package version 13.0.3 is distinct from metadata assembly version 13.0.0.0.
|
||||
No library code is executed; REA parses PE/CLI statically.
|
||||
|
||||
An independent Python oracle uses `dnfile==0.18.0` and `pefile==2024.8.26`.
|
||||
It finds 494 TypeDefs, 4,208 MethodDefs and 2,149 MemberRefs. The selected
|
||||
`Newtonsoft.Json.JsonConvert` has 72 methods. Every trial checks all 72 method
|
||||
tokens, names, RVAs and original signature hashes, plus the sixteen returned
|
||||
MemberRef tokens, names and signature hashes, against this oracle. This does
|
||||
not independently establish complete CIL semantics or opcode correctness.
|
||||
The full parent Evidence and requested answers are identical across all twelve
|
||||
trials; returned rows equal the exact parent slices. Selection and source
|
||||
coverage remain distinct from page exhaustion. MemberRefs remain assembly-wide.
|
||||
|
||||
## Completed-task cost
|
||||
|
||||
Linux x64, Node 24.18.0/npm 11.16.0. Three alternating pairs per cache condition,
|
||||
serial, one CPU; 4 GiB hard / 3 GiB early-stop family guard, no swap, 192-task
|
||||
ceiling. Every task includes actual selected-command help, all CLI calls,
|
||||
provider parsing, bundle publication/authentication, local answer extraction
|
||||
and oracle comparisons, plus both runtime-identity audits. Build, download, oracle preparation and warm priming
|
||||
are excluded. OS page cache is uncontrolled. No build/test overlapped timing.
|
||||
|
||||
Complete mode starts two CLI processes (help plus analysis). Selected mode starts
|
||||
five (two help commands, summary/publication, methods page, MemberRef page).
|
||||
Cold disables the Node compile cache; warm primes the exact workflow once per
|
||||
runtime/mode, then still starts fresh processes with isolated HOME each trial.
|
||||
All trials complete with verified process cleanup and no OOM/resource-stop event.
|
||||
|
||||
| Whole task, median | Complete | Summary + two pages |
|
||||
| ----------------------------------------------- | -------: | ------------------: |
|
||||
| Estimated context tokens, both cache conditions | 451,373 | 13,078 |
|
||||
| Cold wall seconds | 9.969 | 14.801 |
|
||||
| Cold family CPU seconds | 9.743 | 14.555 |
|
||||
| Cold kernel family peak MiB | 393.281 | 400.098 |
|
||||
| Warm wall seconds | 10.339 | 24.141 |
|
||||
| Warm family CPU seconds | 10.097 | 23.842 |
|
||||
| Warm kernel family peak MiB | 401.809 | 407.164 |
|
||||
|
||||
Context estimates include the question, supplied command recipe, all logical
|
||||
argv, actual help and stdout. Tokenizer `tiktoken==0.12.0`, `cl100k_base`.
|
||||
The reduction is 97.1% for this fixed task. There is no model invocation;
|
||||
actual billed/native-agent usage, reasoning and transport framing are unmeasured.
|
||||
The memory counter is the kernel cgroup process-family peak including file
|
||||
cache, not sampled RSS. Ranges and individual receipts are in the JSON.
|
||||
|
||||
This is an opt-in context/portability improvement with extra latency and CPU,
|
||||
not a speed or memory optimization. Complete provider parsing and full authentication of file-supplied bundles
|
||||
remain. Fresh managed Evidence is sealed at its producing factory and reused
|
||||
without another payload clone, including low-headroom error projection. Complete output remains the default and is preferable
|
||||
for small outputs or questions requiring every row. No cache or parser cap
|
||||
was introduced.
|
||||
|
||||
## Existing native code composition
|
||||
|
||||
The unchanged baseline can already redirect full stdout to a file and select
|
||||
rows in code. [The measured control](managed-cli-local-control.py) returns the
|
||||
identical answer and retains the complete original Evidence. One cold run:
|
||||
11.249 s wall, 11.000 s family CPU,
|
||||
375.3 MiB kernel peak. Projection source,
|
||||
original help and returned answer together estimate 10,555 tokens; the shared
|
||||
question and launcher argv are excluded. This single-run control does not
|
||||
establish stable performance or actual native-agent usage. It does demonstrate
|
||||
that raw CLI reply savings are not universal savings over code composition.
|
||||
The additive interface supplies authenticated reusable projections through the
|
||||
same application/domain owner as MCP, without caller-maintained JSON slicing.
|
||||
|
||||
## Correctness and resource boundaries
|
||||
|
||||
The public CLI regression pages all eight managed collections after process
|
||||
exit, target deletion and a working-directory change. Complete parent identity,
|
||||
original row order, signatures, source/page coverage and unknowns survive.
|
||||
Existing outputs are not replaced; failed publication retains completed facts.
|
||||
|
||||
The Evidence factory parses mutable caller data into independent trees, computes
|
||||
identity, then seals its fresh owned snapshot with active ancestry/iterators.
|
||||
It does not retain a set/array of every completed object. Existing sealed roots
|
||||
can be shared; mutable input aliases are cloned independently. General graph
|
||||
sealing keeps its separate cycle, sharing, hidden-field and atomic-failure rules.
|
||||
Managed and derived-view factories seal their own completed Evidence, so CLI
|
||||
error projection can borrow it without reauthentication or another payload copy.
|
||||
The view owner retains strict JSON normalization for optional-field contracts;
|
||||
the CLI no longer copies its already validated completed projection again.
|
||||
|
||||
Shared heap admission traverses by depth rather than queueing every report row
|
||||
or allocating an entries array. It reserves parsed working copies and actual
|
||||
escaping expansion before cloning/hashing, reporting UTF-16 storage explicitly.
|
||||
Canonical bundle equality streams existing ordinary JSON encoder parts, preserving
|
||||
native key order, omission, negative zero and Unicode semantics without two
|
||||
whole-document strings. All production bundle readers explicitly pass their
|
||||
operation: CLI import/export/compare/inspection, retained views, MCP import/
|
||||
comparison and both native-matching read passes. Errors retain the original
|
||||
cause, cleanup primary and lease through retry.
|
||||
|
||||
One process primitive dispatches queued callbacks and then their OS signal
|
||||
notifications. The managed producer checks it before accepting complete Evidence.
|
||||
Publication/read stages use it before their final checks. Inline, file and bundle
|
||||
CLI views share the existing command finalizer and actual resource scope; the
|
||||
finalizer checks interruption after close while preserving any primary/cleanup
|
||||
failure. Existing inline error labels remain intact. Synchronous analysis is
|
||||
cooperatively cancellable between stages, not preemptible.
|
||||
|
||||
Four compiled publication cases (SIGINT/SIGTERM × event/actual POSIX signal)
|
||||
fail without their checkpoint and pass restored. Eight additional compiled
|
||||
completion cases cover default complete delivery and inline projection; all fail
|
||||
with only their new completion checkpoints removed and pass restored. Fixture
|
||||
hooks match the selected target path or exact parent ID. Two real Newtonsoft
|
||||
publication runs and four real default/inline completion runs retain the identical
|
||||
parent or independently checked selected summary, exit 130/143 and preserve
|
||||
committed receipts where applicable. No target library code is executed.
|
||||
|
||||
All 556 focused tests across 36 files pass, including direct
|
||||
finalizer/cleanup ownership, WASM/Android cancellation and native matching.
|
||||
All six `npm run check` tasks plus architecture guards, and all six
|
||||
`npm run docs:check` tasks plus five README tests pass. The expanded domain
|
||||
project passes 5,617 tests in 287 files with
|
||||
3 skipped cases; this overlaps the focused gate. Its first
|
||||
batched shard hit two existing 30-second timeouts in unchanged large synthetic
|
||||
tests. Both passed isolated and in the fresh shard rerun under the same timeout;
|
||||
the cause of the batch variation is unverified. No production/timeout change
|
||||
was made for those cases. An earlier unsplit expansion was manually stopped
|
||||
before its heap ceiling; that incomplete attempt is not counted as a pass.
|
||||
|
||||
Eight producing/operation negative controls also catch graph-sized sealing/
|
||||
admission scratch and wrong operation attribution, including native reload.
|
||||
Native built-in instrumentation is confined to the isolated process-global lane.
|
||||
Initial test-only spy/probe/schema-import issues were corrected before these
|
||||
passing gates. No actual heap exhaustion is induced. Simulated providers do not
|
||||
qualify foreign engines; Windows OS signals remain unverified locally.
|
||||
|
||||
## Shared-reader real-artifact regression
|
||||
|
||||
The existing Redux Toolkit 2.2.7 source/bundle task also completes through the
|
||||
updated reader with default full output schemas and compact input schemas.
|
||||
An independent Python SHA-256/path oracle verifies 180 historical files,
|
||||
99 embedded source nodes and 210 candidate relations (33 strong-location rows,
|
||||
147 unknown). Original signals and partial source coverage remain visible.
|
||||
This uses the existing explicit semantic-node cap of one for static correspondence;
|
||||
it does not establish equivalent semantic-graph coverage or behavioral equivalence.
|
||||
|
||||
One completed task: 21.380 s wall,
|
||||
21.023 s family CPU,
|
||||
928.9 MiB kernel family peak,
|
||||
56,378 estimated context tokens (one input catalog,
|
||||
all arguments and MCP text replies; excludes output schemas, framing and model
|
||||
reasoning). Corpus/reference preparation was performed earlier and excluded.
|
||||
This is a regression verification with resource observation, not a new paired
|
||||
performance comparison. Actual model usage remains unmeasured. Details:
|
||||
[managed-cli-shared-read.json](managed-cli-shared-read.json).
|
||||
|
||||
## Reproduction
|
||||
|
||||
Extract the selected DLL to a private lab as `Newtonsoft.Json.dll`. Create an
|
||||
`acquisition.json` containing the URL, hashes, sizes and selected ZIP member
|
||||
shown above; never commit the DLL. Prepare the independent oracle:
|
||||
|
||||
```bash
|
||||
uv run --no-project --with dnfile==0.18.0 --with pefile==2024.8.26 python \
|
||||
experiments/analysis-efficiency/managed-cli-oracle.py /absolute/lab
|
||||
```
|
||||
|
||||
Build baseline and feature separately with the pinned toolchain. Run each under
|
||||
the shared resource guard, alternating complete/selected, selected/complete,
|
||||
complete/selected for cold and warm. Use a new output directory for every run:
|
||||
|
||||
```bash
|
||||
python3 experiments/analysis-efficiency/resource-run.py --observe-payload-exit -- \
|
||||
node experiments/analysis-efficiency/managed-cli-task.mjs \
|
||||
/absolute/runtime /absolute/lab /absolute/lab/pair-cold-1-selected selected cold
|
||||
```
|
||||
|
||||
Save each guard output to the adjacent `pair-*.log`. Warm mode uses the lab's
|
||||
mode-specific compile-cache directory; prime each identical workflow once first.
|
||||
Supply a `measurement-inputs.json` with exact revisions and production hashes,
|
||||
plus `expected_runtime_bindings.complete` and `.selected`. Capture each with
|
||||
`node experiments/analysis-efficiency/managed-cli-runtime.mjs /absolute/runtime`
|
||||
after building. Bind complete to the clean baseline revision, selected to the
|
||||
feature parent revision plus tracked/untracked source digests, with different
|
||||
roots and compiled-graph digests. The collector rejects a wrong build before any
|
||||
CLI call, rechecks it afterward, and aggregation independently enforces identity.
|
||||
A real wrong-runtime attempt is rejected before analysis. A copied trial with a
|
||||
tampered revision fails aggregation; removing only that consumer guard accepts it.
|
||||
The twelve-trial aggregation reproduces without private pilots. The native
|
||||
control also requires `--measurement-inputs /absolute/lab/measurement-inputs.json`
|
||||
and performs the same before/after identity checks.
|
||||
Then aggregate with:
|
||||
|
||||
```bash
|
||||
uv run --no-project --with tiktoken==0.12.0 python \
|
||||
experiments/analysis-efficiency/managed-cli-costs.py /absolute/lab \
|
||||
--output /absolute/results.json
|
||||
```
|
||||
|
||||
The original issue reporter's executable, foreign operating systems and actual
|
||||
model sessions remain unverified. Setup pilots and earlier unbound cohorts, including one failed setup run
|
||||
while compiled artifacts were absent after a typecheck failure, are excluded.
|
||||
@@ -226,8 +226,3 @@ keeps default valid calls and typed refusals while checking full producer
|
||||
constraints separately. Five isolated suites passed 14 tests across call paths,
|
||||
ADB start, browser scenarios, source selection and managed analysis. The first
|
||||
browser attempt failed and remains recorded; no performance cohort changed.
|
||||
|
||||
The subsequent [startup audit](mcp-startup.md) extends this harness with
|
||||
source/bundle bindings and a post-materialization directory check. Its new
|
||||
measurements are a separate cohort; reproduce this report at its recorded
|
||||
revision when comparing the original timing and model-session receipts.
|
||||
@@ -7,10 +7,6 @@ import { parseArgs } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import {
|
||||
captureRuntimeBinding,
|
||||
assertRuntimeBinding,
|
||||
} from "./managed-cli-runtime.mjs";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
@@ -19,68 +15,26 @@ const { values } = parseArgs({
|
||||
oracle: { type: "string" },
|
||||
output: { type: "string" },
|
||||
profile: { type: "string", default: "default" },
|
||||
phase: { type: "string", default: "analysis" },
|
||||
cache: { type: "string", default: "cold" },
|
||||
"cache-root": { type: "string" },
|
||||
bindings: { type: "string" },
|
||||
role: { type: "string" },
|
||||
},
|
||||
});
|
||||
assert.ok(["analysis", "discovery"].includes(values.phase));
|
||||
assert.ok(["cold", "warm"].includes(values.cache));
|
||||
for (const key of values.phase === "analysis"
|
||||
? ["runtime", "artifact", "oracle", "output"]
|
||||
: ["runtime", "output"])
|
||||
for (const key of ["runtime", "artifact", "oracle", "output"])
|
||||
assert.ok(
|
||||
values[key] && resolve(values[key]) === values[key],
|
||||
`${key} must be absolute`,
|
||||
);
|
||||
assert.ok(["default", "full", "compact"].includes(values.profile));
|
||||
if (values.cache === "warm")
|
||||
assert.ok(
|
||||
values["cache-root"] &&
|
||||
resolve(values["cache-root"]) === values["cache-root"],
|
||||
);
|
||||
await mkdir(values.output, { recursive: false });
|
||||
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
||||
let oracle;
|
||||
let artifactSha;
|
||||
if (values.phase === "analysis") {
|
||||
oracle = JSON.parse(await readFile(values.oracle, "utf8"));
|
||||
artifactSha = hash(await readFile(values.artifact));
|
||||
assert.equal(artifactSha, oracle.artifact_sha256);
|
||||
}
|
||||
let expectedBinding;
|
||||
let bindingBefore;
|
||||
let auditBeforeMs;
|
||||
let measurementInputsSha256;
|
||||
const producerSources = {};
|
||||
if (values.bindings !== undefined) {
|
||||
assert.ok(["baseline", "candidate"].includes(values.role));
|
||||
const inputBytes = await readFile(values.bindings);
|
||||
measurementInputsSha256 = hash(inputBytes);
|
||||
const inputs = JSON.parse(inputBytes.toString("utf8"));
|
||||
for (const name of ["mcp-input-catalog.mjs", "managed-cli-runtime.mjs"]) {
|
||||
producerSources[name] = hash(
|
||||
await readFile(new URL(name, import.meta.url)),
|
||||
);
|
||||
assert.equal(producerSources[name], inputs.harness_sources_sha256[name]);
|
||||
}
|
||||
expectedBinding = inputs.runtime_bindings[values.role];
|
||||
const start = performance.now();
|
||||
bindingBefore = await captureRuntimeBinding(values.runtime, "mcp");
|
||||
auditBeforeMs = performance.now() - start;
|
||||
assertRuntimeBinding(bindingBefore, expectedBinding, values.role);
|
||||
}
|
||||
const oracle = JSON.parse(await readFile(values.oracle, "utf8"));
|
||||
const artifactSha = hash(await readFile(values.artifact));
|
||||
assert.equal(artifactSha, oracle.artifact_sha256);
|
||||
const environment = {
|
||||
PATH: process.env.PATH,
|
||||
HOME: values.output,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
UV_THREADPOOL_SIZE: "1",
|
||||
NODE_OPTIONS: "--max-old-space-size=2048",
|
||||
...(values.cache === "cold"
|
||||
? { NODE_DISABLE_COMPILE_CACHE: "1" }
|
||||
: { NODE_COMPILE_CACHE: values["cache-root"] }),
|
||||
NODE_DISABLE_COMPILE_CACHE: "1",
|
||||
...(values.profile === "default"
|
||||
? {}
|
||||
: { REA_MCP_INPUT_SCHEMA_PROFILE: values.profile }),
|
||||
@@ -92,29 +46,14 @@ const transport = new StdioClientTransport({
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const protocolRequests = {};
|
||||
const send = transport.send.bind(transport);
|
||||
transport.send = (...args) => {
|
||||
const method = args[0].method;
|
||||
if (typeof method === "string")
|
||||
protocolRequests[method] = (protocolRequests[method] ?? 0) + 1;
|
||||
return send(...args);
|
||||
};
|
||||
const client = new Client({ name: "rea-catalog-task", version: "1" });
|
||||
const report = {
|
||||
profile: values.profile,
|
||||
phase: values.phase,
|
||||
cache: values.cache,
|
||||
cache_root: values["cache-root"] ?? null,
|
||||
role: values.role,
|
||||
measurement_inputs_sha256: measurementInputsSha256,
|
||||
producer_sources_sha256: producerSources,
|
||||
artifact_sha256: artifactSha,
|
||||
calls: [],
|
||||
checks: [],
|
||||
status: "incomplete",
|
||||
cleanup_verified: false,
|
||||
protocol_requests: protocolRequests,
|
||||
};
|
||||
const transcript = [];
|
||||
let stderr = "";
|
||||
@@ -124,15 +63,6 @@ transport.stderr?.on("data", (value) => {
|
||||
const began = performance.now();
|
||||
try {
|
||||
await client.connect(transport);
|
||||
report.protocol_era = client.getProtocolEra();
|
||||
await writeFile(
|
||||
join(values.output, "server.json"),
|
||||
JSON.stringify({
|
||||
info: client.getServerVersion(),
|
||||
capabilities: client.getServerCapabilities(),
|
||||
instructions: client.getInstructions(),
|
||||
}),
|
||||
);
|
||||
const catalog = await client.listTools();
|
||||
assert.equal(catalog.nextCursor, undefined);
|
||||
const definitions = new Map(catalog.tools.map((tool) => [tool.name, tool]));
|
||||
@@ -143,145 +73,97 @@ try {
|
||||
ajv.validateSchema(tool.inputSchema),
|
||||
`Invalid schema: ${tool.name}`,
|
||||
);
|
||||
{
|
||||
const encoded = JSON.stringify(catalog);
|
||||
await writeFile(join(values.output, "catalog.json"), encoded);
|
||||
report.catalog_sha256 = hash(encoded);
|
||||
}
|
||||
await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog));
|
||||
report.tool_names = [...definitions.keys()];
|
||||
if (values.phase === "discovery") {
|
||||
const prompts = await client.listPrompts();
|
||||
assert.equal(prompts.nextCursor, undefined);
|
||||
await writeFile(
|
||||
join(values.output, "prompts.json"),
|
||||
JSON.stringify(prompts),
|
||||
const call = async (name, args) => {
|
||||
const definition = definitions.get(name);
|
||||
assert.ok(definition, `Missing named tool ${name}`);
|
||||
const start = performance.now();
|
||||
const reply = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ toolDefinition: definition },
|
||||
);
|
||||
report.prompts_sha256 = hash(JSON.stringify(prompts));
|
||||
report.answer = {
|
||||
tools: report.tool_names,
|
||||
prompts: prompts.prompts.map((prompt) => prompt.name),
|
||||
};
|
||||
report.status = "passed";
|
||||
} else {
|
||||
const call = async (name, args) => {
|
||||
const definition = definitions.get(name);
|
||||
assert.ok(definition, `Missing named tool ${name}`);
|
||||
const start = performance.now();
|
||||
const reply = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ toolDefinition: definition },
|
||||
);
|
||||
report.calls.push({
|
||||
name,
|
||||
elapsed_ms: performance.now() - start,
|
||||
is_error: reply.isError === true,
|
||||
});
|
||||
transcript.push({ name, arguments: args, reply });
|
||||
return reply;
|
||||
};
|
||||
const input = {
|
||||
path: values.artifact,
|
||||
report.calls.push({
|
||||
name,
|
||||
elapsed_ms: performance.now() - start,
|
||||
is_error: reply.isError === true,
|
||||
});
|
||||
transcript.push({ name, arguments: args, reply });
|
||||
return reply;
|
||||
};
|
||||
const input = {
|
||||
path: values.artifact,
|
||||
view: {
|
||||
collection: "packages",
|
||||
names: ["serde"],
|
||||
fields: ["name", "version", "kind"],
|
||||
},
|
||||
};
|
||||
const first = await call("inspect_rust_binary", input);
|
||||
assert.notEqual(first.isError, true);
|
||||
const selected = first.structuredContent;
|
||||
assert.ok(selected?.normalized_result?.page);
|
||||
assert.deepEqual(selected.normalized_result.page.rows, oracle.serde_rows);
|
||||
assert.equal(selected.subject.digest.sha256, artifactSha);
|
||||
const parentId = selected.normalized_result.parent_evidence_id;
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const reply = await call("inspect_analysis_view", {
|
||||
source: { kind: "retained-evidence", evidence_id: parentId },
|
||||
view: {
|
||||
kind: "rust",
|
||||
collection: "packages",
|
||||
names: ["serde"],
|
||||
fields: ["name", "version", "kind"],
|
||||
},
|
||||
};
|
||||
const first = await call("inspect_rust_binary", input);
|
||||
assert.notEqual(first.isError, true);
|
||||
const selected = first.structuredContent;
|
||||
assert.ok(selected?.normalized_result?.page);
|
||||
assert.deepEqual(selected.normalized_result.page.rows, oracle.serde_rows);
|
||||
assert.equal(selected.subject.digest.sha256, artifactSha);
|
||||
const parentId = selected.normalized_result.parent_evidence_id;
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const reply = await call("inspect_analysis_view", {
|
||||
source: { kind: "retained-evidence", evidence_id: parentId },
|
||||
view: {
|
||||
kind: "rust",
|
||||
collection: "packages",
|
||||
names: ["serde"],
|
||||
fields: ["name", "version", "kind"],
|
||||
},
|
||||
});
|
||||
assert.notEqual(reply.isError, true);
|
||||
assert.deepEqual(
|
||||
reply.structuredContent.normalized_result.page.rows,
|
||||
oracle.serde_rows,
|
||||
);
|
||||
assert.equal(
|
||||
reply.structuredContent.normalized_result.parent_evidence_id,
|
||||
parentId,
|
||||
);
|
||||
}
|
||||
const malformed = await call("inspect_rust_binary", {
|
||||
path: values.artifact,
|
||||
view: { collection: "packages", offset: -1 },
|
||||
});
|
||||
assert.equal(malformed.isError, true);
|
||||
report.checks.push(
|
||||
"Independent package rows and digest; repeated retained identity; canonical negative-offset refusal",
|
||||
);
|
||||
const exported = await call("export_evidence_bundle", {
|
||||
path: join(values.output, "evidence.json"),
|
||||
});
|
||||
assert.notEqual(exported.isError, true);
|
||||
const bundle = JSON.parse(
|
||||
await readFile(join(values.output, "evidence.json"), "utf8"),
|
||||
);
|
||||
const parent = bundle.records.find(
|
||||
(record) => record.evidence_id === parentId,
|
||||
);
|
||||
assert.ok(parent, "Complete original Evidence is retained");
|
||||
assert.equal(parent.subject.digest.sha256, artifactSha);
|
||||
assert.notEqual(reply.isError, true);
|
||||
assert.deepEqual(
|
||||
parent.normalized_result.auditable_metadata.packages,
|
||||
oracle.packages,
|
||||
reply.structuredContent.normalized_result.page.rows,
|
||||
oracle.serde_rows,
|
||||
);
|
||||
assert.deepEqual(
|
||||
parent.normalized_result.auditable_metadata.dependency_edges,
|
||||
oracle.edges,
|
||||
assert.equal(
|
||||
reply.structuredContent.normalized_result.parent_evidence_id,
|
||||
parentId,
|
||||
);
|
||||
report.parent_evidence_id = parentId;
|
||||
report.parent_semantic_sha256 = hash(JSON.stringify(parent));
|
||||
report.answer = oracle.serde_rows;
|
||||
assert.deepEqual(
|
||||
await client.listTools(),
|
||||
catalog,
|
||||
"Advertisement changed after canonical materialization",
|
||||
);
|
||||
report.checks.push(
|
||||
"Catalog remains identical after canonical materialization",
|
||||
);
|
||||
report.status = "passed";
|
||||
}
|
||||
const malformed = await call("inspect_rust_binary", {
|
||||
path: values.artifact,
|
||||
view: { collection: "packages", offset: -1 },
|
||||
});
|
||||
assert.equal(malformed.isError, true);
|
||||
report.checks.push(
|
||||
"Independent package rows and digest; repeated retained identity; canonical negative-offset refusal",
|
||||
);
|
||||
const exported = await call("export_evidence_bundle", {
|
||||
path: join(values.output, "evidence.json"),
|
||||
});
|
||||
assert.notEqual(exported.isError, true);
|
||||
const bundle = JSON.parse(
|
||||
await readFile(join(values.output, "evidence.json"), "utf8"),
|
||||
);
|
||||
const parent = bundle.records.find(
|
||||
(record) => record.evidence_id === parentId,
|
||||
);
|
||||
assert.ok(parent, "Complete original Evidence is retained");
|
||||
assert.equal(parent.subject.digest.sha256, artifactSha);
|
||||
assert.deepEqual(
|
||||
parent.normalized_result.auditable_metadata.packages,
|
||||
oracle.packages,
|
||||
);
|
||||
assert.deepEqual(
|
||||
parent.normalized_result.auditable_metadata.dependency_edges,
|
||||
oracle.edges,
|
||||
);
|
||||
report.parent_evidence_id = parentId;
|
||||
report.parent_semantic_sha256 = hash(JSON.stringify(parent));
|
||||
report.answer = oracle.serde_rows;
|
||||
report.status = "passed";
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
report.cleanup_verified = transport.pid === null;
|
||||
assert.ok(report.cleanup_verified);
|
||||
report.elapsed_ms = performance.now() - began;
|
||||
if (expectedBinding !== undefined) {
|
||||
const start = performance.now();
|
||||
const after = await captureRuntimeBinding(values.runtime, "mcp");
|
||||
report.runtime_audit_ms = {
|
||||
before: auditBeforeMs,
|
||||
after: performance.now() - start,
|
||||
};
|
||||
assertRuntimeBinding(after, expectedBinding, values.role);
|
||||
assert.deepEqual(after, bindingBefore);
|
||||
assert.equal(
|
||||
hash(await readFile(values.bindings)),
|
||||
measurementInputsSha256,
|
||||
);
|
||||
for (const [name, digest] of Object.entries(producerSources))
|
||||
assert.equal(
|
||||
hash(await readFile(new URL(name, import.meta.url))),
|
||||
digest,
|
||||
);
|
||||
report.runtime_binding = after;
|
||||
report.runtime_binding_verified = true;
|
||||
}
|
||||
await writeFile(
|
||||
join(values.output, "report.json"),
|
||||
JSON.stringify(report, null, 2) + "\n",
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Verify paired SDK startup/analysis receipts and account for the whole task."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
|
||||
import tiktoken
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('root', type=Path)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
root = args.root
|
||||
input_bytes = (root / 'measurement-inputs.json').read_bytes()
|
||||
inputs = json.loads(input_bytes)
|
||||
expected = inputs['runtime_bindings']
|
||||
sha = lambda value: hashlib.sha256(value).hexdigest()
|
||||
compact = lambda value: json.dumps(value, ensure_ascii=False, separators=(',', ':'), allow_nan=False)
|
||||
encoding = tiktoken.get_encoding('cl100k_base')
|
||||
count = lambda value: len(encoding.encode(value, disallowed_special=()))
|
||||
for role in ['baseline', 'candidate']:
|
||||
assert expected[role]['git_revision'] == inputs[f'{role}_revision']
|
||||
assert expected[role]['source_diff_sha256'] == sha(b'')
|
||||
assert expected[role]['untracked_source_sha256'] == sha(b'[]')
|
||||
assert expected['baseline']['root'] != expected['candidate']['root']
|
||||
assert expected['baseline']['compiled_runtime_sha256'] != expected['candidate']['compiled_runtime_sha256']
|
||||
spec = importlib.util.spec_from_file_location('catalog_costs', Path(__file__).with_name('mcp-input-catalog-costs.py'))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
catalogs = {}
|
||||
identities = {}
|
||||
rows = []
|
||||
for case in inputs['cases']:
|
||||
directory = root / case['case']
|
||||
report = json.loads((directory / 'report.json').read_text())
|
||||
assert report['status'] == 'passed' and report['cleanup_verified']
|
||||
assert report['runtime_binding_verified']
|
||||
assert report['measurement_inputs_sha256'] == sha(input_bytes)
|
||||
for name, producer_digest in report['producer_sources_sha256'].items():
|
||||
assert producer_digest == inputs['harness_sources_sha256'][name]
|
||||
assert report['runtime_binding'] == expected[case['role']]
|
||||
for key in ['role', 'cache', 'profile', 'phase']:
|
||||
assert report[key] == case[key]
|
||||
raw = (directory / 'catalog.json').read_bytes()
|
||||
catalog_digest = sha(raw)
|
||||
assert catalog_digest == report['catalog_sha256']
|
||||
profile = case['profile']
|
||||
if profile not in catalogs:
|
||||
catalog = json.loads(raw)
|
||||
definitions = [{key: tool[key] for key in ['name', 'description', 'inputSchema']} for tool in catalog['tools']]
|
||||
catalogs[profile] = {'sha256': catalog_digest, 'tool_count': len(catalog['tools']),
|
||||
'input_catalog_tokens': count(compact(definitions)),
|
||||
'raw_catalog_tokens': count(raw.decode()), 'raw_catalog_bytes': len(raw)}
|
||||
assert catalog_digest == catalogs[profile]['sha256'], 'Directory differs across roles or phases'
|
||||
transcript = json.loads((directory / 'transcript.json').read_text())
|
||||
server = json.loads((directory / 'server.json').read_text())
|
||||
tokens = {'question_and_recipe': count(inputs['questions'][case['phase']] + '\n' + inputs['recipes'][case['phase']]),
|
||||
'server_instructions': count(server.get('instructions') or ''),
|
||||
'input_catalog_once': catalogs[profile]['input_catalog_tokens'],
|
||||
'arguments': sum(count(compact({'name': call['name'], 'arguments': call['arguments']})) for call in transcript),
|
||||
'text_replies': sum(count(block['text']) for call in transcript for block in call['reply']['content'] if block['type'] == 'text')}
|
||||
requests = report['protocol_requests']
|
||||
assert requests['initialize'] == 1
|
||||
if case['phase'] == 'analysis':
|
||||
assert len(transcript) == 5 and requests['tools/call'] == 5 and requests['tools/list'] == 2
|
||||
assert report['artifact_sha256'] == inputs['artifacts'][case['artifact']]['sha256']
|
||||
identity = (report['parent_evidence_id'], report['parent_semantic_sha256'], sha(compact(report['answer']).encode()))
|
||||
assert identities.setdefault(case['artifact'], identity) == identity
|
||||
assert sum(call['is_error'] for call in report['calls']) == 1
|
||||
else:
|
||||
assert not transcript and requests.get('tools/call', 0) == 0
|
||||
assert requests['tools/list'] == 1 and requests['prompts/list'] == 1
|
||||
prompts = (directory / 'prompts.json').read_bytes()
|
||||
assert sha(prompts) == report['prompts_sha256']
|
||||
assert identities.setdefault('prompts', report['prompts_sha256']) == report['prompts_sha256']
|
||||
tokens['prompt_directory'] = count(prompts.decode())
|
||||
tokens['whole_task'] = sum(tokens.values())
|
||||
resource = module.resource(directory)
|
||||
for name, guard_digest in resource['guard_sources_sha256'].items():
|
||||
assert guard_digest == inputs['harness_sources_sha256'][name]
|
||||
del resource['guard_sources_sha256']
|
||||
row = {**case, 'runtime_binding_sha256': sha(compact(report['runtime_binding']).encode()), 'runtime_audit_ms': report['runtime_audit_ms'],
|
||||
'catalog_sha256': catalog_digest, 'parent_evidence_id': report.get('parent_evidence_id'),
|
||||
'parent_semantic_sha256': report.get('parent_semantic_sha256'),
|
||||
'answer_sha256': sha(compact(report['answer']).encode()), 'tokens_estimated': tokens,
|
||||
'raw_catalog_result_tokens_all_lists': catalogs[profile]['raw_catalog_tokens'] * requests['tools/list'],
|
||||
'raw_catalog_result_bytes_all_lists': catalogs[profile]['raw_catalog_bytes'] * requests['tools/list'],
|
||||
'actual_model_usage': None, 'protocol_era': report['protocol_era'], 'protocol_requests': requests, 'calls': report['calls'],
|
||||
**resource,
|
||||
'receipt_sha256': {name: sha((directory / name).read_bytes()) for name in ['report.json', 'catalog.json', 'server.json', 'transcript.json']}}
|
||||
assert row['catalog_sha256'] == row['receipt_sha256']['catalog.json'] == catalogs[profile]['sha256']
|
||||
rows.append(row)
|
||||
assert len(rows) == 72
|
||||
summary = []
|
||||
for phase in ['discovery', 'analysis']:
|
||||
for cache in ['cold', 'warm']:
|
||||
for profile in ['default', 'full']:
|
||||
for artifact in ([None] if phase == 'discovery' else ['ripgrep', 'zellij']):
|
||||
item = {'phase': phase, 'cache': cache, 'profile': profile, 'artifact': artifact}
|
||||
for role in ['baseline', 'candidate']:
|
||||
selected = [row for row in rows if all(row[key] == item[key] for key in ['phase', 'cache', 'profile', 'artifact']) and row['role'] == role]
|
||||
assert len(selected) == 3
|
||||
item[role] = {metric: {'median': statistics.median(row[metric] for row in selected),
|
||||
'min': min(row[metric] for row in selected), 'max': max(row[metric] for row in selected)}
|
||||
for metric in ['wall_seconds', 'cpu_seconds', 'kernel_family_peak_bytes_including_page_cache']}
|
||||
item[role]['tokens_estimated'] = [row['tokens_estimated']['whole_task'] for row in selected]
|
||||
summary.append(item)
|
||||
result = {'status': 'passed', 'tokenizer': f'tiktoken=={tiktoken.__version__}/cl100k_base; estimates, not model usage',
|
||||
'aggregation_sources_sha256': {path.name: sha(path.read_bytes()) for path in [Path(__file__), Path(spec.origin)]},
|
||||
'context_scope': 'Question, explicit recipe, server instructions, one complete name/description/input-schema directory, every tool argument and text reply, plus prompt directory for discovery. This projection assumes the client retains a single unchanged directory. Raw catalog result costs include every observed tools/list result separately, with output schemas and without JSON-RPC envelopes. SDK initialization/framing, reasoning and actual model usage are unmeasured. The analysis task includes retained-read, rejection, export/oracle and post-materialization directory checks.',
|
||||
'cache_scope': 'Cold disables server Node compile cache. Warm enables a prepopulated role/profile-specific cache after one complete analysis priming run. Fresh server/client processes and HOME each trial. Driver compile cache disabled in both cohorts. Cache hit rate and OS page cache are unmeasured; source/runtime audits warm compiled-file pages before startup.',
|
||||
'resource_scope': 'Whole SDK driver, resource observer and owned REA process family. Includes before/after source-and-all-compiled-JS/JSON binding, SDK schema checks, extra validation calls, bundle export and independent oracle checking. Kernel cgroup peak includes file cache. Build/download/oracle preparation and cache priming excluded. Retained V8 heap and forced-GC diagnostics were not separately measured.',
|
||||
'measurement_inputs': inputs, 'catalogs': catalogs, 'summary': summary, 'records': rows,
|
||||
'warmup_receipts': {name: sha((root / name / 'report.json').read_bytes()) for name in [f'warmup-{profile}-{role}' for profile in ['default', 'full'] for role in ['baseline', 'candidate']]}}
|
||||
args.output.write_text(json.dumps(result, indent=2) + '\n')
|
||||
print(json.dumps(summary, indent=2))
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,61 +0,0 @@
|
||||
# MCP discovery and complete Rust tasks
|
||||
|
||||
Target-free stdio discovery previously constructed the canonical analysis runtime before the client could inspect its directory. The candidate records SDK advertisements at build time for default/explicit compact and explicit full inputs. Its first tool, prompt or completion request materializes canonical validators and handlers on the same SDK server. HTTP, output-schema-off, initial-target and unrecorded source runs retain eager composition.
|
||||
|
||||
Pinned baseline: [`main@c465ff5a8`](https://github.com/morluto/rea/commit/c465ff5a814b8e1a386fc547cbcd94df5c35f086). Measured candidate: [`04900abbc`](https://github.com/leeguooooo/rea/commit/04900abbcd3c3438fce4af932ff28400fe744a7d). Both use the same main base and locked dependencies. Before/after source and all compiled JS/JSON bindings identify the exact runtime used in every trial. The input manifest and producer scripts are also hashed and checked.
|
||||
|
||||
## Real tasks and independent facts
|
||||
|
||||
Official Alpine v3.21 packages supply ripgrep 14.1.1-r0 (3,416,168-byte executable) and Zellij 0.40.0-r0 (17,151,864 bytes). Their download URLs, archive/executable digests and aports revisions are retained in [the measurement data](mcp-startup.json). The independent Python ELF/section/zlib/JSON oracle checks 42 packages and 88 dependency edges for ripgrep, and 422 packages and 1,063 edges for Zellij. Serde versions are 1.0.210 and 1.0.137 respectively; the selected package kind remains explicit unknown (`null`). Verification covers embedded static build metadata; execution and runtime reachability remain unverified.
|
||||
|
||||
Each analysis task initializes the actual SDK client, lists and validates the input directory, selects serde name/version/kind, reads the exact retained parent twice, verifies canonical rejection of a negative offset, exports and independently checks the complete package/edge inventory, confirms unchanged advertisements after materialization, then closes. All five tool calls and both directory reads are included in the measured task. Discovery tasks initialize, list tools/prompts, validate the input directory and close without an analysis call.
|
||||
|
||||
All 72 paired trials and four priming runs passed. Each artifact has identical answers, parent Evidence IDs and complete semantic hashes across roles, profiles and cache modes. Both profiles retain byte-identical directories across baseline/candidate and before/after canonical materialization. The directory contains 213 tools. Complete Evidence is retained and exported through the existing shared workflow.
|
||||
|
||||
## Measured cost
|
||||
|
||||
Three alternating pairs per condition; table values are medians. Min/max ranges and individual receipts are in [mcp-startup.json](mcp-startup.json). Each cell is baseline → candidate. Kernel process-family peak includes file cache, SDK driver, observer and REA children.
|
||||
|
||||
| Task | Cache | Inputs | Wall seconds | Family CPU seconds | Family peak MiB |
|
||||
| --------- | ----- | ----------------- | ------------: | -----------------: | --------------: |
|
||||
| Directory | cold | compact (default) | 5.661 → 2.236 | 5.403 → 2.050 | 559.3 → 248.6 |
|
||||
| Directory | cold | full | 5.256 → 2.337 | 5.040 → 2.175 | 650.9 → 349.1 |
|
||||
| Directory | warm | compact (default) | 5.056 → 2.236 | 4.800 → 2.015 | 572.8 → 251.4 |
|
||||
| Directory | warm | full | 4.957 → 2.338 | 4.754 → 2.139 | 672.7 → 349.6 |
|
||||
| ripgrep | cold | compact (default) | 8.177 → 8.078 | 7.945 → 7.899 | 849.7 → 792.5 |
|
||||
| zellij | cold | compact (default) | 8.276 → 7.276 | 8.041 → 7.105 | 846.2 → 819.0 |
|
||||
| ripgrep | cold | full | 8.180 → 7.483 | 8.000 → 7.312 | 846.5 → 794.4 |
|
||||
| zellij | cold | full | 8.484 → 7.776 | 8.284 → 7.514 | 850.4 → 813.1 |
|
||||
| ripgrep | warm | compact (default) | 7.976 → 6.879 | 7.739 → 6.678 | 826.8 → 802.6 |
|
||||
| zellij | warm | compact (default) | 8.285 → 7.472 | 8.019 → 7.254 | 882.1 → 811.4 |
|
||||
| ripgrep | warm | full | 8.074 → 7.476 | 7.890 → 7.265 | 852.1 → 803.6 |
|
||||
| zellij | warm | full | 8.382 → 7.479 | 8.151 → 7.293 | 854.3 → 830.0 |
|
||||
|
||||
Discovery improves clearly in this cohort. Completed-task changes depend on the workload: default cold ripgrep is approximately unchanged. Initialization moves into the first actual analysis request; its latency must be assessed with the full workflow.
|
||||
|
||||
| Default ripgrep first RPC | Baseline ms | Candidate ms |
|
||||
| ------------------------- | ----------: | -----------: |
|
||||
| cold | 231.5 | 2532.1 |
|
||||
| warm | 227.1 | 2118.2 |
|
||||
|
||||
Within each profile, advertised context remains identical. Input-directory estimates are 49,670 tokens for default compact and 205,744 for full. Whole-task estimates include the supplied question/recipe, server instructions, directory once, every tool argument and text reply, and the prompt directory in discovery tasks. This projection assumes the client retains one unchanged directory; raw catalog result costs count every observed list result separately, including output schemas. These use tiktoken 0.14.0/cl100k_base; actual model usage, reasoning and SDK initialization/framing are unmeasured.
|
||||
|
||||
Linux x64 / AMD EPYC 7B12 / kernel 6.1.0-54-cloud-amd64 / Node 24.18.0, npm 11.16.0. Timed SDK tasks negotiated the legacy protocol era; auto/modern correctness is covered separately by compiled boundary tests. Trials run serially under a 1-CPU quota pinned to CPU 15, 4 GiB hard memory / 3 GiB early stop, no swap and 192 tasks. No resource stop, OOM or remaining payload process was observed. Cold disables server Node compile cache; warm uses a role/profile-specific cache populated by one complete task. Fresh processes and HOME are used each trial. Cache hit rate and OS page cache are unmeasured. Runtime audits warm compiled-file pages and are included in whole-task costs. Builds, downloads, oracle generation and cache priming are preparation outside those costs. Retained V8 heap and forced-GC measurements were not separately collected.
|
||||
|
||||
## Ownership and validation
|
||||
|
||||
`OwnedMcpServers` seals publication before taking its close snapshot, keeps failed exact close capabilities for retry and serves both eager and deferred paths. Deferred startup still joins servers published before an asynchronous serving handle settles. Post-snapshot and post-close factory calls are refused before allocation. Both production-boundary regressions fail with only the shared guard removed and pass restored.
|
||||
|
||||
The moved transport/delivery implementation preserves current complete-error retention, escaped-preview fitting, response/string-limit recovery, target-content provenance and native Evidence continuations. Canonical parsers remain authoritative after lazy binding. Both protocol eras, input/output profile choices, invalid input/output, completion cancellation, partial startup and cleanup retry are exercised.
|
||||
|
||||
Aggregation keeps catalog, producer-script and guard-script digests in distinct variables. Before publishing a trial it verifies that the catalog digest matches both its catalog-file receipt and the shared profile identity. Deliberately selecting the guard digest for that field is rejected. Regeneration preserves all measured timing, CPU, memory, context and parent-Evidence values.
|
||||
|
||||
Executed local gates: 125 focused tests across 14 files; a repeated 22-case schema/profile subset after the pinned main merge; all six static-check tasks plus architecture guards; builds for both pinned runtimes; six documentation checks and five README consistency tests. HTTP coverage includes the real compiled CLI session/replacement/Host tests. Runtime-binding admission rejects a mismatched compiled graph before MCP launch; aggregation rejects a tampered trial binding. The raw receipts remain in `/tmp/rea-perf-mcp-startup-20261022`, identified by hashes in the data. Real initial-target providers, foreign OS runtime behavior and other providers remain outside this measurement.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Use the pinned Node/npm toolchain and separate clean baseline/candidate checkouts. Build each with `npm ci` and `npm run build:cached` (the measured workspace shared one locked dependency installation). Acquire and verify the official packages listed in the data, extract the named executables and generate independent oracles with `mcp-input-catalog-oracle.py`.
|
||||
|
||||
Capture each checkout with `node experiments/analysis-efficiency/managed-cli-runtime.mjs CHECKOUT mcp`. Construct `measurement-inputs.json` using the recorded schema, updating absolute checkout/artifact/oracle paths and their independently verified bindings. Keep it immutable through a cohort; producer source digests cover `mcp-input-catalog.mjs`, `managed-cli-runtime.mjs`, `resource-run.py` and `resource_payload.py`.
|
||||
|
||||
Run `mcp-input-catalog.mjs` through `resource-run.py --observe-payload-exit` with `--runtime`, `--output`, `--bindings`, `--role baseline|candidate`, `--profile default|full`, `--phase discovery|analysis` and `--cache cold|warm`. Analysis also takes `--artifact` and `--oracle`; warm requires a role/profile-specific `--cache-root`. Disable the driver compile cache for both cohorts. Prime each warm role/profile once with the complete ripgrep task. Follow the 72-case order in the data, reversing role order in every second pair. Aggregate with `python mcp-startup-costs.py LAB --output mcp-startup.json` using tiktoken 0.14.0. The aggregator verifies every input/runtime/guard binding and completed receipt before producing summaries.
|
||||
+2
-2
@@ -305,8 +305,8 @@
|
||||
"verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs",
|
||||
"verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs",
|
||||
"mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check",
|
||||
"compile": "node scripts/clean-build-output.mjs && node scripts/run-typescript.mjs -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/record-mcp-catalog.mjs && node scripts/bundle-runtime.mjs",
|
||||
"compile:termux": "node scripts/clean-build-output.mjs && node --max-old-space-size=3072 node_modules/typescript-termux/lib/tsc.js -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/record-mcp-catalog.mjs && node scripts/bundle-runtime.mjs",
|
||||
"compile": "node scripts/clean-build-output.mjs && node scripts/run-typescript.mjs -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/bundle-runtime.mjs",
|
||||
"compile:termux": "node scripts/clean-build-output.mjs && node --max-old-space-size=3072 node_modules/typescript-termux/lib/tsc.js -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/bundle-runtime.mjs",
|
||||
"artifacts:mcp-catalog": "node scripts/generate-mcp-tool-catalog.mjs",
|
||||
"artifacts:skills": "node scripts/generate-skill-metadata.mjs",
|
||||
"artifacts:product-catalog": "node scripts/generate-product-catalog.mjs",
|
||||
|
||||
+14
-40
@@ -1,31 +1,6 @@
|
||||
import type { NormalizedWorkflowRun } from "./workflow-run.mjs";
|
||||
import type { StaticTaskContext } from "./static-task-context.mjs";
|
||||
|
||||
export interface CiRun {
|
||||
readonly id?: string | number;
|
||||
readonly run_attempt?: number;
|
||||
readonly status?: string;
|
||||
readonly conclusion?: string | null;
|
||||
readonly event?: string;
|
||||
readonly path?: string;
|
||||
readonly head_sha?: string;
|
||||
readonly head_branch?: string;
|
||||
readonly repository?: { readonly full_name?: string };
|
||||
readonly pull_requests?: readonly {
|
||||
readonly base?: {
|
||||
readonly sha?: string;
|
||||
readonly repo?: { readonly full_name?: string };
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CiJob {
|
||||
readonly name?: string;
|
||||
readonly status?: string;
|
||||
readonly conclusion?: string | null;
|
||||
readonly run_id?: number;
|
||||
readonly run_attempt?: number;
|
||||
}
|
||||
export type { StaticTaskContext } from "./static-task-context.mjs";
|
||||
|
||||
export interface StaticDiagnostic {
|
||||
readonly kind: "lint" | "jscpd";
|
||||
@@ -36,20 +11,15 @@ export interface StaticDiagnostic {
|
||||
readonly severity?: string;
|
||||
}
|
||||
|
||||
export interface StaticEvidence {
|
||||
readonly context: StaticTaskContext;
|
||||
readonly diagnostics: readonly StaticDiagnostic[];
|
||||
}
|
||||
|
||||
export function classifyFailure(input: {
|
||||
repository: string;
|
||||
defaultBranch: string;
|
||||
run: CiRun & NormalizedWorkflowRun;
|
||||
job: CiJob;
|
||||
run: Record<string, unknown> & NormalizedWorkflowRun;
|
||||
job: Record<string, unknown>;
|
||||
diagnostics: readonly StaticDiagnostic[];
|
||||
context: StaticTaskContext;
|
||||
mainRun: CiRun & NormalizedWorkflowRun;
|
||||
mainJob: CiJob;
|
||||
mainRun: Record<string, unknown> & NormalizedWorkflowRun;
|
||||
mainJob: Record<string, unknown>;
|
||||
mainDiagnostics: readonly StaticDiagnostic[];
|
||||
mainContext: StaticTaskContext;
|
||||
executionSourcesVerified: boolean;
|
||||
@@ -77,7 +47,7 @@ export function safeSha(value: unknown): string | null;
|
||||
export function validRepoPath(value: unknown): value is string;
|
||||
export function selectStaticCheckArtifact(
|
||||
page: unknown,
|
||||
run: Pick<CiRun, "id" | "head_sha">,
|
||||
run: { id?: string | number; head_sha?: string },
|
||||
runAttempt: number,
|
||||
name?: string,
|
||||
): unknown | null;
|
||||
@@ -87,8 +57,12 @@ export function readStaticCheckArtifact(options: {
|
||||
artifactId: number | string;
|
||||
token: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<StaticEvidence>;
|
||||
}): Promise<{
|
||||
context: StaticTaskContext;
|
||||
diagnostics: readonly StaticDiagnostic[];
|
||||
}>;
|
||||
|
||||
export function parseStaticArchive(
|
||||
archivePath: string,
|
||||
): Promise<StaticEvidence>;
|
||||
export function parseStaticArchive(archivePath: string): Promise<{
|
||||
context: StaticTaskContext;
|
||||
diagnostics: readonly StaticDiagnostic[];
|
||||
}>;
|
||||
@@ -1,5 +1,38 @@
|
||||
import type { CiRun, CiJob, StaticEvidence } from "./artifacts.mjs";
|
||||
import type { NormalizedWorkflowRun } from "./workflow-run.mjs";
|
||||
import type { StaticDiagnostic } from "./artifacts.mjs";
|
||||
export type { StaticDiagnostic } from "./artifacts.mjs";
|
||||
import type { StaticTaskContext } from "./static-task-context.mjs";
|
||||
export type { StaticTaskContext } from "./static-task-context.mjs";
|
||||
export interface CiRun {
|
||||
readonly id?: string | number;
|
||||
readonly run_attempt?: number;
|
||||
readonly status?: string;
|
||||
readonly conclusion?: string | null;
|
||||
readonly event?: string;
|
||||
readonly path?: string;
|
||||
readonly head_sha?: string;
|
||||
readonly head_branch?: string;
|
||||
readonly repository?: { readonly full_name?: string };
|
||||
readonly pull_requests?: readonly {
|
||||
readonly base?: {
|
||||
readonly sha?: string;
|
||||
readonly repo?: { readonly full_name?: string };
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CiJob {
|
||||
readonly name?: string;
|
||||
readonly status?: string;
|
||||
readonly conclusion?: string | null;
|
||||
readonly run_id?: number;
|
||||
readonly run_attempt?: number;
|
||||
}
|
||||
|
||||
export interface StaticEvidence {
|
||||
readonly context: StaticTaskContext;
|
||||
readonly diagnostics: readonly StaticDiagnostic[];
|
||||
}
|
||||
|
||||
export interface CiFailureReport {
|
||||
readonly version: 1;
|
||||
@@ -66,6 +99,25 @@ export interface FailureReportApi {
|
||||
): Promise<Readonly<Record<string, boolean>>>;
|
||||
}
|
||||
|
||||
export function classifyFailure(input: {
|
||||
repository: string;
|
||||
defaultBranch: string;
|
||||
run: CiRun & NormalizedWorkflowRun;
|
||||
job: CiJob;
|
||||
diagnostics: readonly StaticDiagnostic[];
|
||||
context: StaticTaskContext;
|
||||
mainRun: CiRun & NormalizedWorkflowRun;
|
||||
mainJob: CiJob;
|
||||
mainDiagnostics: readonly StaticDiagnostic[];
|
||||
mainContext: StaticTaskContext;
|
||||
executionSourcesVerified: boolean;
|
||||
ancestry: { status: string; mergeBaseSha: string | null };
|
||||
blobs: Readonly<Record<string, boolean>>;
|
||||
}): {
|
||||
classification: "matching-main-diagnostics" | "unknown";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function reportCiFailures(options: {
|
||||
repository: string;
|
||||
defaultBranch: string;
|
||||
|
||||
@@ -26,6 +26,8 @@ const MAX_MAIN_RUN_LOOKUPS = 100;
|
||||
const MAX_PATHS_PER_COMPARISON = 50;
|
||||
const MAX_API_REQUESTS = 250;
|
||||
|
||||
export { classifyFailure } from "./artifacts.mjs";
|
||||
|
||||
/** Uses only failed CI candidates present in the supplied bounded timing manifest. */
|
||||
export async function reportCiFailures({
|
||||
repository,
|
||||
|
||||
@@ -142,9 +142,6 @@ export const verifyScenarioPolicySetup = async (
|
||||
page,
|
||||
) => {
|
||||
const result = {};
|
||||
// Exercise a caller-selected handshake deadline within the existing teardown
|
||||
// guard; the default command deadline may legitimately outlast that guard.
|
||||
const setupCommandTimeoutMs = 2_000;
|
||||
for (const mode of ["http-websocket", "http-discovery", "deadline"]) {
|
||||
const proxy = await startProxy(endpoint, mode);
|
||||
const provider = createBrowserScenarioProvider(process.env);
|
||||
@@ -155,9 +152,6 @@ export const verifyScenarioPolicySetup = async (
|
||||
target_id: targetId,
|
||||
},
|
||||
start_url: { url: origin },
|
||||
...(mode === "deadline"
|
||||
? { command_timeout_ms: setupCommandTimeoutMs }
|
||||
: {}),
|
||||
environment: { service_workers: "block" },
|
||||
actions: [
|
||||
{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 },
|
||||
@@ -195,9 +189,7 @@ export const verifyScenarioPolicySetup = async (
|
||||
if (!next.ok) throw next.error;
|
||||
assert.equal(next.value.browser.cleanup, "disconnected-external");
|
||||
result[mode] = {
|
||||
...(mode === "deadline"
|
||||
? { timed_out: true, command_timeout_ms: setupCommandTimeoutMs }
|
||||
: { cancelled: true }),
|
||||
...(mode === "deadline" ? { timed_out: true } : { cancelled: true }),
|
||||
socket_closed: true,
|
||||
target_reused: true,
|
||||
};
|
||||
|
||||
@@ -39,7 +39,6 @@ export const parseDevelopmentTestRequest = (mode, arguments_) => {
|
||||
// runtime or typecheck prerequisites. Add a consumer here when it reads one.
|
||||
const artifactConsumers = {
|
||||
"artifacts:mcp-catalog": [
|
||||
"tests/boundary/mcp/deferredRuntime.test.ts",
|
||||
"tests/boundary/mcp/contractPresentation.test.ts",
|
||||
"tests/boundary/mcp/toolSchemaValidity.test.ts",
|
||||
],
|
||||
|
||||
@@ -47,13 +47,6 @@ 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,22 +35,11 @@ export async function measureMcpStartup({
|
||||
const startedAt = performance.now();
|
||||
rss.start();
|
||||
try {
|
||||
await withFixedPhaseBudget(
|
||||
"MCP initialize phase budget",
|
||||
policy.initializeBudgetMs,
|
||||
"ms",
|
||||
() => client.connect(transport, { timeout: policy.initializeBudgetMs }),
|
||||
);
|
||||
await client.connect(transport, { timeout: policy.initializeBudgetMs });
|
||||
const initializedAt = performance.now();
|
||||
const tools = await withFixedPhaseBudget(
|
||||
"MCP tools/list phase budget",
|
||||
policy.toolsListBudgetMs,
|
||||
"ms",
|
||||
() =>
|
||||
client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
}),
|
||||
);
|
||||
const tools = await client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
});
|
||||
const listedAt = performance.now();
|
||||
rss.stop();
|
||||
const measurement = {
|
||||
@@ -63,11 +52,7 @@ export async function measureMcpStartup({
|
||||
assertStartupBudgets(measurement, policy);
|
||||
return measurement;
|
||||
} catch (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 },
|
||||
);
|
||||
throw new Error(`packaged MCP startup probe failed: ${stderr}`, { cause });
|
||||
} finally {
|
||||
rss.stop();
|
||||
await Promise.allSettled([client.close(), transport.close()]);
|
||||
@@ -97,24 +82,12 @@ export async function profileMcpModuleLoading({
|
||||
version: "1.0.0",
|
||||
});
|
||||
try {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
await client.connect(transport, {
|
||||
timeout: policy.doctorDeadlineMs,
|
||||
});
|
||||
await client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
});
|
||||
return profile.result();
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), transport.close()]);
|
||||
@@ -224,46 +197,16 @@ 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 phase budget. ${fixedMcpStartupPhaseBudgetRecovery("MCP initialize phase budget", policy.initializeBudgetMs, "ms")}`,
|
||||
);
|
||||
throw new Error("packaged MCP exceeded the initialize regression budget");
|
||||
if (measurement.initialized_to_tools_list_ms > policy.toolsListBudgetMs)
|
||||
throw new Error(
|
||||
`packaged MCP exceeded the tools/list phase budget. ${fixedMcpStartupPhaseBudgetRecovery("MCP tools/list phase budget", policy.toolsListBudgetMs, "ms")}`,
|
||||
);
|
||||
throw new Error("packaged MCP exceeded the tools/list regression budget");
|
||||
if (
|
||||
measurement.process_started_to_tools_list_ms > policy.firstCatalogBudgetMs
|
||||
)
|
||||
throw new Error(
|
||||
`packaged MCP exceeded the first-catalog phase budget. ${fixedMcpStartupPhaseBudgetRecovery("MCP first-catalog phase budget", policy.firstCatalogBudgetMs, "ms")}`,
|
||||
"packaged MCP exceeded the first-catalog regression budget",
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
|
||||
// Runtime discovery is generated from the same canonical registrations, after
|
||||
// default identity has been recorded and before bundling. Test catalogs stay separate.
|
||||
const compiled = resolve(dirname(fileURLToPath(import.meta.url)), "../dist");
|
||||
const read = (name) => import(pathToFileURL(join(compiled, name)).href);
|
||||
const [
|
||||
{ createServer },
|
||||
{ createBinarySession },
|
||||
{ parseConfig },
|
||||
{ silentLogger },
|
||||
{ PROMPT_CONTRACTS },
|
||||
] = await Promise.all([
|
||||
read("server/createServer.js"),
|
||||
read("composition/binary.js"),
|
||||
read("config/parseConfig.js"),
|
||||
read("logger.js"),
|
||||
read("contracts/promptContracts.js"),
|
||||
]);
|
||||
const recordCatalog = async (profile) => {
|
||||
// Catalog generation is independent of the host account and user configuration.
|
||||
const home = await mkdtemp(join(tmpdir(), "rea-mcp-catalog-"));
|
||||
const environment = {
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
XDG_CONFIG_HOME: join(home, "config"),
|
||||
XDG_CACHE_HOME: join(home, "cache"),
|
||||
XDG_STATE_HOME: join(home, "state"),
|
||||
};
|
||||
const cleanup = [];
|
||||
let producerFailure;
|
||||
try {
|
||||
const config = parseConfig(environment);
|
||||
if (!config.ok) throw config.error;
|
||||
const session = createBinarySession(
|
||||
config.value,
|
||||
silentLogger,
|
||||
environment,
|
||||
);
|
||||
cleanup.push(async () => {
|
||||
const closed = await session.close({ retainProviderDocuments: true });
|
||||
if (!closed.ok) throw closed.error;
|
||||
});
|
||||
const server = createServer({ kind: "session", session }, { environment });
|
||||
cleanup.push(() => server.close());
|
||||
const client = new Client({ name: "rea-catalog-recorder", version: "1" });
|
||||
cleanup.push(() => client.close());
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const tools = (await client.listTools()).tools;
|
||||
const prompts = (await client.listPrompts()).prompts.map((prompt) => {
|
||||
const contract = PROMPT_CONTRACTS.find(
|
||||
(candidate) => candidate.name === prompt.name,
|
||||
);
|
||||
if (contract === undefined)
|
||||
throw new Error(`Missing prompt ${prompt.name}`);
|
||||
return { ...prompt, parameters: contract.arguments };
|
||||
});
|
||||
const catalog = {
|
||||
version: 1,
|
||||
info: client.getServerVersion(),
|
||||
capabilities: client.getServerCapabilities(),
|
||||
instructions: client.getInstructions(),
|
||||
identity: server.catalogIdentity,
|
||||
tools,
|
||||
prompts,
|
||||
};
|
||||
const encoded = JSON.stringify(catalog);
|
||||
const digest = createHash("sha256").update(encoded).digest("hex");
|
||||
await writeFile(
|
||||
join(
|
||||
compiled,
|
||||
profile === "full" ? "mcp-catalog-full.json" : "mcp-catalog.json",
|
||||
),
|
||||
encoded,
|
||||
);
|
||||
return digest;
|
||||
} catch (cause) {
|
||||
producerFailure = { cause };
|
||||
throw cause;
|
||||
} finally {
|
||||
const results = await Promise.allSettled(
|
||||
cleanup.reverse().map((close) => Promise.resolve().then(close)),
|
||||
);
|
||||
const failures = results.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
if (failures.length === 0) {
|
||||
try {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
} catch (cause) {
|
||||
failures.push(cause);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0)
|
||||
throw new AggregateError(
|
||||
producerFailure === undefined
|
||||
? failures
|
||||
: [producerFailure.cause, ...failures],
|
||||
`Catalog cleanup failed; temporary home retained at ${home}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const digests = {};
|
||||
for (const profile of ["compact", "full"])
|
||||
digests[profile] = await recordCatalog(profile);
|
||||
await writeFile(
|
||||
join(compiled, "recordedMcpCatalog.js"),
|
||||
`/** Recorded from the compiled runtime; do not edit. */\nexport const RECORDED_MCP_CATALOG_SHA256 = ${JSON.stringify(digests)};\n`,
|
||||
);
|
||||
@@ -784,33 +784,6 @@ 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,52 +7,6 @@ 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,17 +275,6 @@ 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
|
||||
@@ -351,7 +340,7 @@ export const parseDumpsysPackageOutput = (
|
||||
key === "installerPackageName" &&
|
||||
details.installerPackageName === null
|
||||
)
|
||||
details.installerPackageName = absentInstallerOrName(value);
|
||||
details.installerPackageName = value === "" ? null : value;
|
||||
else if (key === "userId" && details.userId === null) {
|
||||
const parsed = /^(\d+)/u.exec(value);
|
||||
if (parsed !== null) details.userId = Number(parsed[1]!);
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { win32 } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
const { spawnMock, tmpdirMock, lstatMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
tmpdirMock: vi.fn(),
|
||||
lstatMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:child_process")>()),
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
vi.mock("node:os", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:os")>()),
|
||||
tmpdir: tmpdirMock,
|
||||
}));
|
||||
vi.mock("node:fs/promises", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:fs/promises")>()),
|
||||
lstat: lstatMock,
|
||||
}));
|
||||
|
||||
import { createJadxWorkspaceRoot } from "./JadxWorkspaceRoot.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("decodes a Windows guardian path across split UTF-8 stdout writes", async () => {
|
||||
const previousPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: "win32",
|
||||
});
|
||||
const parent = "C:\\Users\\研究者\\AppData\\Local\\Temp";
|
||||
const path = win32.join(parent, `rea-android-${"a".repeat(32)}`);
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdin: new PassThrough(),
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
unref: vi.fn(),
|
||||
});
|
||||
child.stdin.once("finish", () => {
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
child.emit("close", 0, null);
|
||||
});
|
||||
tmpdirMock.mockReturnValue(parent);
|
||||
const missingPath = Object.assign(new Error("missing after cleanup"), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
lstatMock.mockRejectedValue(missingPath);
|
||||
spawnMock.mockReturnValue(child);
|
||||
const ready = Buffer.from(
|
||||
`${JSON.stringify({
|
||||
path,
|
||||
volumeSerial: "0123456789abcdef",
|
||||
fileId: "0123456789abcdef0123456789abcdef",
|
||||
privateDacl: true,
|
||||
})}\n`,
|
||||
);
|
||||
const split = ready.indexOf(Buffer.from("研")) + 1;
|
||||
|
||||
try {
|
||||
const start = createJadxWorkspaceRoot();
|
||||
setImmediate(() => {
|
||||
child.stdout.write(ready.subarray(0, split), () => {
|
||||
setImmediate(() => child.stdout.write(ready.subarray(split)));
|
||||
});
|
||||
});
|
||||
|
||||
const root = await start;
|
||||
try {
|
||||
expect(root.path).toBe(path);
|
||||
} finally {
|
||||
await expect(root.close()).resolves.toBeUndefined();
|
||||
}
|
||||
expect(lstatMock).toHaveBeenCalledWith(path);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: previousPlatform,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -105,9 +105,8 @@ class WindowsJadxWorkspaceRoot implements JadxWorkspaceRoot {
|
||||
},
|
||||
});
|
||||
let diagnostics = "";
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
diagnostics = (diagnostics + chunk).slice(-8192);
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
diagnostics = (diagnostics + chunk.toString()).slice(-8192);
|
||||
});
|
||||
const exit = new Promise<GuardianExit>((resolve) => {
|
||||
child.once("error", (error: Error) =>
|
||||
@@ -117,9 +116,8 @@ class WindowsJadxWorkspaceRoot implements JadxWorkspaceRoot {
|
||||
});
|
||||
const ready = new Promise<string>((resolve, reject) => {
|
||||
let frame = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
frame += chunk;
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
frame += chunk.toString();
|
||||
if (frame.length > 4096) {
|
||||
reject(
|
||||
new Error("Android workspace guardian readiness exceeded 4 KiB"),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { EvidenceCreationOptions } from "../domain/evidence.js";
|
||||
import type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { OfficialToolName } from "../contracts/officialToolContracts.js";
|
||||
@@ -147,7 +146,6 @@ export const createAnalysisExecutionEvidence = (
|
||||
input: unknown,
|
||||
execution: AnalysisExecution,
|
||||
confidence: "observed" | "derived",
|
||||
options?: EvidenceCreationOptions,
|
||||
): Evidence =>
|
||||
createEvidence(
|
||||
execution.subject === undefined
|
||||
@@ -169,7 +167,6 @@ export const createAnalysisExecutionEvidence = (
|
||||
locations: execution.locations,
|
||||
confidence,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
/** Provider-neutral application capability for one analysis operation. */
|
||||
|
||||
@@ -138,9 +138,6 @@ 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 {
|
||||
@@ -174,11 +171,6 @@ 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,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ExecutableFormatHint } from "../domain/dosCom.js";
|
||||
import { dispatchQueuedInterrupts } from "../process/queuedInterrupts.js";
|
||||
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js";
|
||||
@@ -60,11 +59,7 @@ import {
|
||||
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
|
||||
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
|
||||
import { resolveXrefsAddress } from "./XrefsAddressResolution.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { deliverAnalysisEvidence } from "./EvidenceBundleCommands.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import type { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import {
|
||||
directAnalysisCleanupFailure,
|
||||
@@ -151,81 +146,24 @@ export const runDirectAnalysis = async (
|
||||
/** Execute one provider-native semantic operation with atomic provenance. */
|
||||
export const runProviderAnalysis = async (
|
||||
dependencies: DirectAnalysisDependencies,
|
||||
...[path, tool, arguments_, logger = silentLogger, signal, delivery]:
|
||||
| readonly [
|
||||
path: string,
|
||||
tool: NativeToolName | ArtifactAnalysisOperation | ManagedToolName,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
logger?: Logger | undefined,
|
||||
signal?: AbortSignal | undefined,
|
||||
]
|
||||
| readonly [
|
||||
path: string,
|
||||
tool: "inspect_managed_members",
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
logger: Logger | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
delivery: {
|
||||
readonly detail?: "complete" | "summary";
|
||||
readonly evidenceBundlePath?: string;
|
||||
},
|
||||
]
|
||||
...[path, tool, arguments_, logger = silentLogger, signal]: readonly [
|
||||
path: string,
|
||||
tool: NativeToolName | ArtifactAnalysisOperation | ManagedToolName,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
logger?: Logger,
|
||||
signal?: AbortSignal,
|
||||
]
|
||||
): Promise<JsonValue> => {
|
||||
if (delivery !== undefined && tool !== "inspect_managed_members")
|
||||
return cliError(
|
||||
new AnalysisInputError(tool, undefined, [
|
||||
{
|
||||
path: ["delivery"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Evidence delivery options are only supported by inspect_managed_members",
|
||||
},
|
||||
]),
|
||||
if (isManagedToolName(tool))
|
||||
return runManagedProviderAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
tool,
|
||||
arguments_,
|
||||
signal,
|
||||
);
|
||||
if (
|
||||
delivery?.detail === "summary" &&
|
||||
delivery.evidenceBundlePath === undefined
|
||||
)
|
||||
return cliError(
|
||||
new AnalysisInputError(tool, undefined, [
|
||||
{
|
||||
path: ["evidence_bundle"],
|
||||
reason: "missing_argument",
|
||||
message:
|
||||
"summary detail requires --evidence-bundle so complete Evidence remains available to later CLI commands",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return withProcessCancellation(signal, async (operationSignal) => {
|
||||
if (isManagedToolName(tool)) {
|
||||
const original = await runManagedProviderAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
tool,
|
||||
arguments_,
|
||||
operationSignal,
|
||||
);
|
||||
await dispatchQueuedInterrupts();
|
||||
if (!original.ok) return cliError(original.error);
|
||||
if (operationSignal.aborted)
|
||||
return cliError(
|
||||
new AnalysisCancelledError(tool, {
|
||||
cause: operationSignal.reason,
|
||||
partialObservation: original.value,
|
||||
}),
|
||||
);
|
||||
const result =
|
||||
delivery?.evidenceBundlePath === undefined
|
||||
? original
|
||||
: await deliverAnalysisEvidence(
|
||||
original.value,
|
||||
delivery.evidenceBundlePath,
|
||||
delivery.detail,
|
||||
operationSignal,
|
||||
);
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
}
|
||||
return runAnalysis(dependencies, path, tool, arguments_, {
|
||||
return withProcessCancellation(signal, (operationSignal) =>
|
||||
runAnalysis(dependencies, path, tool, arguments_, {
|
||||
logger,
|
||||
snapshotPath: undefined,
|
||||
signal: operationSignal,
|
||||
@@ -234,8 +172,8 @@ export const runProviderAnalysis = async (
|
||||
: NATIVE_INSPECTION_OPERATIONS.has(tool)
|
||||
? { createSession: dependencies.createNativeBinarySession }
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/** Execute managed metadata inspection in an isolated managed-only session. */
|
||||
@@ -261,14 +199,13 @@ export const runManagedProviderExecution = async (
|
||||
);
|
||||
});
|
||||
|
||||
/** Preserve typed managed Evidence before selecting its CLI delivery. */
|
||||
const runManagedProviderAnalysis = async (
|
||||
dependencies: Pick<DirectAnalysisDependencies, "createManagedBinarySession">,
|
||||
dependencies: DirectAnalysisDependencies,
|
||||
path: string,
|
||||
tool: ManagedToolName,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
): Promise<JsonValue> => {
|
||||
const execution = await runManagedProviderExecution(
|
||||
dependencies,
|
||||
path,
|
||||
@@ -276,13 +213,9 @@ const runManagedProviderAnalysis = async (
|
||||
signal,
|
||||
arguments_,
|
||||
);
|
||||
if (!execution.ok) return execution;
|
||||
if (!execution.ok) return cliError(execution.error);
|
||||
const value = execution.value;
|
||||
return ok(
|
||||
createAnalysisExecutionEvidence(tool, arguments_, value, "observed", {
|
||||
immutable: true,
|
||||
}),
|
||||
);
|
||||
return createAnalysisExecutionEvidence(tool, arguments_, value, "observed");
|
||||
};
|
||||
|
||||
const runAnalysis = async (
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
import { dispatchQueuedInterrupts } from "../process/queuedInterrupts.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js";
|
||||
|
||||
import {
|
||||
EvidenceFileError,
|
||||
EvidenceIntegrityError,
|
||||
type PublishedEvidenceFile,
|
||||
} from "../domain/evidenceErrors.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import {
|
||||
createImmutableEvidenceBundle,
|
||||
type EvidenceBundle,
|
||||
} from "../domain/evidenceBundle.js";
|
||||
import type { InspectAnalysisViewInput } from "../domain/analysisView/analysisView.js";
|
||||
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
@@ -28,18 +14,12 @@ import {
|
||||
readImmutableEvidenceBundle,
|
||||
writeEvidenceBundle,
|
||||
} from "./EvidenceBundleFiles.js";
|
||||
import { inspectAnalysisViewValidated } from "./analysisView/AnalysisViewService.js";
|
||||
|
||||
/** Validate a standalone bundle and report its complete record/revision counts. */
|
||||
export const importEvidenceBundleCommand = async (
|
||||
path: string,
|
||||
): Promise<Result<JsonValue, AnalysisError>> => {
|
||||
const loaded = await readEvidenceBundle(
|
||||
path,
|
||||
undefined,
|
||||
undefined,
|
||||
"import_evidence_bundle",
|
||||
);
|
||||
const loaded = await readEvidenceBundle(path);
|
||||
if (!loaded.ok) return loaded;
|
||||
return ok({
|
||||
imported: loaded.value.records.length,
|
||||
@@ -55,170 +35,16 @@ export const exportEvidenceBundleCommand = async (
|
||||
overwrite: boolean,
|
||||
): Promise<Result<JsonValue, AnalysisError>> => {
|
||||
// The sealed snapshot lets the writer reuse this authentication.
|
||||
const loaded = await readImmutableEvidenceBundle(
|
||||
sourcePath,
|
||||
undefined,
|
||||
undefined,
|
||||
"export_evidence_bundle",
|
||||
);
|
||||
const loaded = await readImmutableEvidenceBundle(sourcePath);
|
||||
if (!loaded.ok) return loaded;
|
||||
return projectWrite(
|
||||
loaded.value,
|
||||
await persistEvidenceBundleCommand(loaded.value, outputPath, overwrite),
|
||||
);
|
||||
};
|
||||
|
||||
/** Publish complete parent Evidence before delivering it or its linked summary. */
|
||||
export const deliverAnalysisEvidence = async (
|
||||
evidence: Evidence,
|
||||
outputPath: string,
|
||||
detail: "complete" | "summary" = "complete",
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const headroom = requireAuthenticationHeadroom(evidence, {
|
||||
operation: evidence.operation,
|
||||
path: absoluteCallerPath(outputPath),
|
||||
description:
|
||||
"Insufficient heap headroom to authenticate the complete parent Evidence",
|
||||
});
|
||||
if (!headroom.ok)
|
||||
return err(headroom.error.retainPartialObservation(evidence));
|
||||
const bundle = createImmutableEvidenceBundle([evidence]);
|
||||
const delivered =
|
||||
detail === "summary"
|
||||
? inspectAnalysisViewValidated(
|
||||
{
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: evidence.evidence_id,
|
||||
},
|
||||
view: { kind: "summary" },
|
||||
},
|
||||
(id) => bundle.records.find((record) => record.evidence_id === id),
|
||||
)
|
||||
: ok(evidence);
|
||||
if (!delivered.ok) return delivered;
|
||||
const published = await publishEvidenceDelivery({
|
||||
bundle,
|
||||
complete: evidence,
|
||||
delivered: delivered.value,
|
||||
outputPath,
|
||||
operation: evidence.operation,
|
||||
signal,
|
||||
});
|
||||
return published.ok ? ok(published.value.delivered) : published;
|
||||
};
|
||||
|
||||
/** Selected Evidence delivered after its complete bundle was committed. */
|
||||
export interface PublishedEvidenceDelivery {
|
||||
readonly delivered: Evidence;
|
||||
readonly evidenceBundle: PublishedEvidenceFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a complete bundle, then deliver Evidence already projected from it.
|
||||
* Cancellation observed before commit leaves no output and retains the
|
||||
* complete Evidence. A committed publication wins a later cancellation race;
|
||||
* its receipt stays in the typed cancellation as diagnostics.evidence_bundle,
|
||||
* and a cleanup failure after commit keeps its own published receipt.
|
||||
*/
|
||||
export const publishEvidenceDelivery = async (input: {
|
||||
readonly bundle: EvidenceBundle;
|
||||
readonly complete: Evidence;
|
||||
readonly delivered: Evidence;
|
||||
readonly outputPath: string;
|
||||
readonly operation: string;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
}): Promise<Result<PublishedEvidenceDelivery, AnalysisError>> => {
|
||||
const { signal, operation } = input;
|
||||
const written = await persistEvidenceBundleCommand(
|
||||
input.bundle,
|
||||
input.outputPath,
|
||||
false,
|
||||
signal,
|
||||
operation,
|
||||
);
|
||||
// Deliver interrupts queued during final publication cleanup before the
|
||||
// command's signal owner accepts completion and removes its listeners.
|
||||
await dispatchQueuedInterrupts();
|
||||
if (!written.ok)
|
||||
return err(written.error.retainPartialObservation(input.complete));
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: signal.reason,
|
||||
partialObservation: input.delivered,
|
||||
diagnostics: { evidence_bundle: { ...written.value } },
|
||||
}),
|
||||
);
|
||||
return ok({ delivered: input.delivered, evidenceBundle: written.value });
|
||||
};
|
||||
|
||||
/** Resolve the caller's exact retained parent from an authenticated portable bundle. */
|
||||
export const inspectAnalysisViewFromBundle = async (
|
||||
input: InspectAnalysisViewInput,
|
||||
bundlePath: string,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
if (input.source.kind !== "retained-evidence")
|
||||
return err(
|
||||
new AnalysisInputError("inspect_analysis_view", undefined, [
|
||||
{
|
||||
path: ["source", "kind"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"--evidence-bundle requires a retained-evidence source with the exact parent evidence_id; inline Evidence already supplies its source",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const loaded = await readImmutableEvidenceBundle(
|
||||
bundlePath,
|
||||
signal,
|
||||
resources,
|
||||
"inspect_analysis_view",
|
||||
);
|
||||
// Authentication and projection are synchronous; dispatch queued interrupts before accepting either result.
|
||||
await dispatchQueuedInterrupts();
|
||||
if (!loaded.ok) return loaded;
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError("inspect_analysis_view", {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
const projected = inspectAnalysisViewValidated(input, (id) =>
|
||||
loaded.value.records.find((record) => record.evidence_id === id),
|
||||
);
|
||||
await dispatchQueuedInterrupts();
|
||||
if (projected.ok && signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError("inspect_analysis_view", {
|
||||
cause: signal.reason,
|
||||
partialObservation: projected.value,
|
||||
}),
|
||||
);
|
||||
return projected;
|
||||
};
|
||||
|
||||
/** Publish a completed bundle and retry its exact staged-output cleanup owner. */
|
||||
const persistEvidenceBundleCommand = async (
|
||||
bundle: EvidenceBundle,
|
||||
outputPath: string,
|
||||
overwrite: boolean,
|
||||
signal?: AbortSignal,
|
||||
operation?: string,
|
||||
): Promise<Result<PublishedEvidenceFile, AnalysisError>> => {
|
||||
const resources = new ArtifactResourceScope();
|
||||
const written = await writeEvidenceBundle(
|
||||
bundle,
|
||||
loaded.value,
|
||||
outputPath,
|
||||
overwrite,
|
||||
resources,
|
||||
signal,
|
||||
operation,
|
||||
);
|
||||
if (written.ok) return written;
|
||||
if (written.ok) return projectWrite(loaded.value, written);
|
||||
|
||||
// Confirmed release resolves the bound error to its primary outcome; a
|
||||
// failed retry leaves that error incomplete with its exact retained owner.
|
||||
@@ -228,7 +54,10 @@ const persistEvidenceBundleCommand = async (
|
||||
written.error instanceof EvidenceFileError &&
|
||||
written.error.published
|
||||
) {
|
||||
return ok(written.error.published);
|
||||
return projectWrite(loaded.value, {
|
||||
ok: true,
|
||||
value: written.error.published,
|
||||
});
|
||||
}
|
||||
return written;
|
||||
};
|
||||
@@ -241,18 +70,8 @@ export const compareEvidenceBundlesCommand = async (input: {
|
||||
// Sealed snapshots let comparison and its bundle digests reuse this
|
||||
// authentication instead of re-validating every record twice more.
|
||||
const [left, right] = await Promise.all([
|
||||
readImmutableEvidenceBundle(
|
||||
input.leftPath,
|
||||
undefined,
|
||||
undefined,
|
||||
"compare_bundles",
|
||||
),
|
||||
readImmutableEvidenceBundle(
|
||||
input.rightPath,
|
||||
undefined,
|
||||
undefined,
|
||||
"compare_bundles",
|
||||
),
|
||||
readImmutableEvidenceBundle(input.leftPath),
|
||||
readImmutableEvidenceBundle(input.rightPath),
|
||||
]);
|
||||
if (!left.ok) return left;
|
||||
if (!right.ok) return right;
|
||||
@@ -269,7 +88,7 @@ export const compareEvidenceBundlesCommand = async (input: {
|
||||
|
||||
const projectWrite = (
|
||||
bundle: EvidenceBundle,
|
||||
written: Result<PublishedEvidenceFile, AnalysisError>,
|
||||
written: Awaited<ReturnType<typeof writeEvidenceBundle>>,
|
||||
): Result<JsonValue, AnalysisError> =>
|
||||
written.ok
|
||||
? ok({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
|
||||
import {
|
||||
@@ -40,47 +39,45 @@ export const readEvidenceBundle = (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "import_evidence_bundle",
|
||||
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
|
||||
readBundle(path, parseEvidenceBundle, signal, resources, operation);
|
||||
readBundle(path, parseEvidenceBundle, signal, resources);
|
||||
|
||||
/** Read an authenticated immutable snapshot for a subsequent session import. */
|
||||
export const readImmutableEvidenceBundle = (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "import_evidence_bundle",
|
||||
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
|
||||
readBundle(path, parseImmutableEvidenceBundle, signal, resources, operation);
|
||||
readBundle(path, parseImmutableEvidenceBundle, signal, resources);
|
||||
|
||||
const readBundle = async (
|
||||
path: string,
|
||||
parseBundle: (input: unknown) => EvidenceBundle,
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "import_evidence_bundle",
|
||||
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> => {
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
new AnalysisCancelledError("import_evidence_bundle", {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
const loaded = await readJsonFile(path, signal, resources, operation);
|
||||
if (!loaded.ok) return loaded;
|
||||
const loaded = await readJsonFile(path, signal, resources);
|
||||
if (!loaded.ok) {
|
||||
if (loaded.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisCancelledError("import_evidence_bundle", {
|
||||
cause: loaded.error.cause,
|
||||
}),
|
||||
);
|
||||
return loaded;
|
||||
}
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
new AnalysisCancelledError("import_evidence_bundle", {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
const headroom = requireAuthenticationHeadroom(loaded.value, {
|
||||
operation,
|
||||
path: absoluteCallerPath(path),
|
||||
description:
|
||||
"Insufficient heap headroom to authenticate the Evidence bundle",
|
||||
});
|
||||
if (!headroom.ok) return headroom;
|
||||
let bundle: EvidenceBundle;
|
||||
try {
|
||||
bundle = parseBundle(loaded.value);
|
||||
@@ -124,7 +121,6 @@ export const writeEvidenceBundle = async (
|
||||
overwrite: boolean,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
operation = "export_evidence_bundle",
|
||||
): Promise<
|
||||
Result<
|
||||
{ readonly path: string; readonly bytes: number },
|
||||
@@ -132,11 +128,7 @@ export const writeEvidenceBundle = async (
|
||||
>
|
||||
> => {
|
||||
if (signal?.aborted === true)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
return err(new AnalysisCancelledError("export_evidence_bundle"));
|
||||
let checked: EvidenceBundle;
|
||||
try {
|
||||
checked = parseEvidenceBundle(bundle);
|
||||
@@ -152,6 +144,8 @@ export const writeEvidenceBundle = async (
|
||||
path,
|
||||
overwrite,
|
||||
resources,
|
||||
signal === undefined ? undefined : { signal, operation },
|
||||
signal === undefined
|
||||
? undefined
|
||||
: { signal, operation: "export_evidence_bundle" },
|
||||
);
|
||||
};
|
||||
@@ -22,9 +22,7 @@ describe("exact Evidence input resolution", () => {
|
||||
actual: null,
|
||||
},
|
||||
});
|
||||
expect(projected.message).toContain(
|
||||
"not available from the selected source",
|
||||
);
|
||||
expect(projected.message).toContain("not retained in this session");
|
||||
expect(projected.remediation.action).toContain(
|
||||
"complete inline Evidence",
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EvidenceInput } from "../domain/evidenceReference.js";
|
||||
import type { EvidenceInput } from "../contracts/evidenceInputContracts.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { EvidenceReferenceError } from "../domain/evidenceErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
@@ -18,7 +18,7 @@ export const resolveEvidenceInput = (
|
||||
new EvidenceReferenceError(
|
||||
input.evidence_id,
|
||||
"missing",
|
||||
"Evidence available to the selected source",
|
||||
"Evidence retained in this session",
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
type EvidenceSubjectTarget,
|
||||
} from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import type { MobileApplicationProjectionWithSources } from "../domain/mobileApplicationGraphProjection.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { requestInputError, workflowInputError } from "./workflowInputError.js";
|
||||
|
||||
@@ -24,20 +23,13 @@ interface InventoryProjectionResult {
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface InventoryProjectionEvidenceResult {
|
||||
readonly evidence: Evidence;
|
||||
readonly sources: readonly Evidence[];
|
||||
}
|
||||
|
||||
interface InventoryProjectionOptions<
|
||||
Input extends InventoryProjectionInput,
|
||||
Output extends InventoryProjectionResult,
|
||||
> {
|
||||
readonly rawInput: unknown;
|
||||
readonly schema: z.ZodType<Input>;
|
||||
readonly project: (
|
||||
input: Input,
|
||||
) => MobileApplicationProjectionWithSources<Output>;
|
||||
readonly project: (input: Input) => Output;
|
||||
readonly operation: string;
|
||||
readonly predicateType: string;
|
||||
readonly provider: EvidenceProvider;
|
||||
@@ -51,18 +43,17 @@ export const projectInventoryEvidence = <
|
||||
Output extends InventoryProjectionResult,
|
||||
>(
|
||||
options: InventoryProjectionOptions<Input, Output>,
|
||||
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
const parsed = options.schema.safeParse(options.rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
requestInputError(options.operation, parsed.error, options.rawInput),
|
||||
);
|
||||
try {
|
||||
const projected = options.project(parsed.data);
|
||||
const result = projected.projection;
|
||||
const first = projected.sources[0];
|
||||
return ok({
|
||||
evidence: createEvidence(
|
||||
const result = options.project(parsed.data);
|
||||
const first = parsed.data.inventory_evidence[0];
|
||||
return ok(
|
||||
createEvidence(
|
||||
first?.subject === null || first?.subject === undefined
|
||||
? undefined
|
||||
: {
|
||||
@@ -89,8 +80,7 @@ export const projectInventoryEvidence = <
|
||||
evidenceLinks: result.source_evidence_ids,
|
||||
},
|
||||
),
|
||||
sources: projected.sources,
|
||||
});
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
cause instanceof TypeError || cause instanceof z.ZodError
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createAnalysisProfile,
|
||||
type AnalysisProfileCommitment,
|
||||
} from "../domain/analysisProfile.js";
|
||||
|
||||
/** Provider identity for deterministic composed analysis workflows. */
|
||||
export const REA_WORKFLOW_PROVIDER = {
|
||||
id: "rea-workflow",
|
||||
@@ -111,6 +112,20 @@ export const JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER = {
|
||||
version: "1",
|
||||
} as const;
|
||||
|
||||
/** Provider identity for deterministic cross-artifact comparisons. */
|
||||
export const ARTIFACT_COMPARISON_PROVIDER = {
|
||||
id: "rea-artifact-comparison",
|
||||
name: "REA artifact comparison",
|
||||
version: "1",
|
||||
} as const;
|
||||
|
||||
/** Provider identity for deterministic cross-function comparisons. */
|
||||
export const FUNCTION_COMPARISON_PROVIDER = {
|
||||
id: "rea-function-comparison",
|
||||
name: "REA function comparison",
|
||||
version: "1",
|
||||
} as const;
|
||||
|
||||
/** Provider identity for deterministic Evidence bundle comparisons. */
|
||||
export const BUNDLE_COMPARISON_PROVIDER = {
|
||||
id: "rea-bundle-comparison",
|
||||
|
||||
@@ -4,50 +4,6 @@ import {
|
||||
requireAuthenticationHeadroom,
|
||||
} from "./JsonAuthenticationHeadroom.js";
|
||||
describe("JSON authentication headroom", () => {
|
||||
it("does not queue every row while deciding whether a wide report can fit", () => {
|
||||
let pending = 0;
|
||||
let peak = 0;
|
||||
const rows: { admission_probe: boolean }[] = [];
|
||||
for (let index = 0; index < 512; index++) {
|
||||
const row = {
|
||||
get admission_probe() {
|
||||
pending--;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
// Observe reading a row separately from examining its scalar. A width
|
||||
// queue reads every row first; an iterator finishes each row before advancing.
|
||||
Object.defineProperty(rows, index, {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
peak = Math.max(peak, ++pending);
|
||||
if (pending > 32)
|
||||
throw new Error(
|
||||
"Admission queued report rows before refusing their allocation",
|
||||
);
|
||||
return row;
|
||||
},
|
||||
});
|
||||
}
|
||||
const value = { rows };
|
||||
const result = requireAuthenticationHeadroom(value, {
|
||||
operation: "inspect_managed_members",
|
||||
path: "/tmp/parent.json",
|
||||
description: "Cannot authenticate",
|
||||
availableBytes: 0,
|
||||
});
|
||||
expect(pending).toBe(0);
|
||||
expect(peak).toBe(1);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
reportedLimits: {
|
||||
serialized_json_utf16_bytes: JSON.stringify(value).length * 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
it("estimates parsed JSON at no less than its UTF-16 text", () => {
|
||||
const text = "x".repeat(1000);
|
||||
expect(estimateJsonValueBytes(text)).toBeGreaterThanOrEqual(2000);
|
||||
@@ -86,28 +42,3 @@ describe("JSON authentication headroom", () => {
|
||||
).toEqual({ ok: true, value: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["control characters", { text: "\u0000\u0001\b\t\n\f\r".repeat(2000) }],
|
||||
[
|
||||
"escaped keys",
|
||||
{ ['\u0000\\"'.repeat(2000)]: [true, false, null, -0, 1e30] },
|
||||
],
|
||||
["surrogate boundaries", { text: "😀\ud800x\udfff".repeat(2000) }],
|
||||
])("reserves actual escaped JSON storage for %s", (_label, value) => {
|
||||
const result = requireAuthenticationHeadroom(value, {
|
||||
operation: "inspect_analysis_view",
|
||||
path: "/tmp/bundle.json",
|
||||
description: "Cannot authenticate bundle",
|
||||
availableBytes: estimateJsonValueBytes(value) * 2 + 1,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
reportedLimits: {
|
||||
serialized_json_utf16_bytes: JSON.stringify(value).length * 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,66 +1,32 @@
|
||||
import { jsonStringCodeUnits } from "../domain/jsonSerialization.js";
|
||||
import { getHeapStatistics } from "node:v8";
|
||||
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** Authentication clones parsed JSON and hashes its canonical commitment. */
|
||||
const AUTHENTICATION_COPIES = 2;
|
||||
|
||||
/** Parsed working copies and conservative UTF-16 storage for escaped JSON. */
|
||||
const estimateJsonStorage = (
|
||||
value: unknown,
|
||||
): { valueBytes: number; serializedUtf16Bytes: number } => {
|
||||
let valueBytes = 0;
|
||||
let serializedCharacters = 0;
|
||||
const stack: Iterator<unknown>[] = [[value][Symbol.iterator]()];
|
||||
/** Conservative bytes a parsed JSON value occupies: UTF-16 text plus container and slot overhead. */
|
||||
export const estimateJsonValueBytes = (value: unknown): number => {
|
||||
let bytes = 0;
|
||||
const stack: unknown[] = [value];
|
||||
while (stack.length > 0) {
|
||||
const next = stack[stack.length - 1]!.next();
|
||||
if (next.done) {
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
const item = next.value;
|
||||
if (typeof item === "string") {
|
||||
valueBytes += 64 + item.length * 2;
|
||||
serializedCharacters += jsonStringCodeUnits(item);
|
||||
} else if (Array.isArray(item)) {
|
||||
valueBytes += 128 + item.length * 16;
|
||||
serializedCharacters += 2 + Math.max(0, item.length - 1);
|
||||
stack.push(item.values());
|
||||
const item = stack.pop();
|
||||
if (typeof item === "string") bytes += 64 + item.length * 2;
|
||||
else if (Array.isArray(item)) {
|
||||
bytes += 128 + item.length * 16;
|
||||
for (const entry of item) stack.push(entry);
|
||||
} else if (typeof item === "object" && item !== null) {
|
||||
valueBytes += 128;
|
||||
serializedCharacters += 2;
|
||||
stack.push(
|
||||
(function* () {
|
||||
let count = 0;
|
||||
for (const key in item) {
|
||||
if (!Object.hasOwn(item, key)) continue;
|
||||
valueBytes += 64 + key.length * 2;
|
||||
serializedCharacters +=
|
||||
jsonStringCodeUnits(key) + 1 + (count++ === 0 ? 0 : 1);
|
||||
yield (item as Record<string, unknown>)[key];
|
||||
}
|
||||
})(),
|
||||
);
|
||||
} else {
|
||||
valueBytes += 16;
|
||||
serializedCharacters +=
|
||||
typeof item === "number"
|
||||
? String(item).length
|
||||
: item === true
|
||||
? 4
|
||||
: item === false
|
||||
? 5
|
||||
: 4;
|
||||
}
|
||||
bytes += 128;
|
||||
for (const [key, entry] of Object.entries(item)) {
|
||||
bytes += 64 + key.length * 2;
|
||||
stack.push(entry);
|
||||
}
|
||||
} else bytes += 16;
|
||||
}
|
||||
return { valueBytes, serializedUtf16Bytes: serializedCharacters * 2 };
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** Conservative parsed JSON bytes, independent of its escaped representation. */
|
||||
export const estimateJsonValueBytes = (value: unknown): number =>
|
||||
estimateJsonStorage(value).valueBytes;
|
||||
|
||||
/** Refuse before authentication when live heap headroom cannot hold its validation and commitment working copies. */
|
||||
export const requireAuthenticationHeadroom = (
|
||||
value: unknown,
|
||||
@@ -70,14 +36,11 @@ export const requireAuthenticationHeadroom = (
|
||||
readonly description: string;
|
||||
readonly availableBytes?: number;
|
||||
},
|
||||
): Result<void, AnalysisResourceConstraintError> => {
|
||||
): Result<void, AnalysisError> => {
|
||||
const availableBytes =
|
||||
options.availableBytes ?? getHeapStatistics().total_available_size;
|
||||
const { valueBytes, serializedUtf16Bytes } = estimateJsonStorage(value);
|
||||
// Canonical hashing and manifest normalization can still encode scalars or
|
||||
// metadata; account for their escaped representations before allocating them.
|
||||
const requiredBytes =
|
||||
(valueBytes + serializedUtf16Bytes) * AUTHENTICATION_COPIES;
|
||||
const valueBytes = estimateJsonValueBytes(value);
|
||||
const requiredBytes = valueBytes * AUTHENTICATION_COPIES;
|
||||
if (requiredBytes <= availableBytes) return ok(undefined);
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
@@ -87,7 +50,6 @@ export const requireAuthenticationHeadroom = (
|
||||
{
|
||||
input_path: options.path,
|
||||
projected_value_bytes: valueBytes,
|
||||
serialized_json_utf16_bytes: serializedUtf16Bytes,
|
||||
authentication_bytes: requiredBytes,
|
||||
available_heap_bytes: availableBytes,
|
||||
},
|
||||
|
||||
@@ -52,14 +52,13 @@ export const readJsonFile = async (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "read_evidence_file",
|
||||
): Promise<
|
||||
Result<
|
||||
unknown,
|
||||
EvidenceFileError | AnalysisResourceConstraintError | AnalysisCancelledError
|
||||
>
|
||||
> => {
|
||||
const read = await readJsonFileWithSource(path, signal, resources, operation);
|
||||
const read = await readJsonFileWithSource(path, signal, resources);
|
||||
return read.ok ? ok(read.value.data) : read;
|
||||
};
|
||||
|
||||
@@ -68,7 +67,6 @@ export const readJsonFileWithSource = (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "read_evidence_file",
|
||||
): Promise<
|
||||
Result<
|
||||
{ readonly data: unknown; readonly path: string },
|
||||
@@ -80,14 +78,13 @@ export const readJsonFileWithSource = (
|
||||
async (selected) => {
|
||||
const read = await readJsonInputFile(
|
||||
selected,
|
||||
operation,
|
||||
"read_evidence_file",
|
||||
signal,
|
||||
resources,
|
||||
);
|
||||
return read.ok ? ok({ data: read.value, path: selected }) : read;
|
||||
},
|
||||
signal,
|
||||
operation,
|
||||
);
|
||||
|
||||
/** Read selected JSON members through the same regular-file error boundary. */
|
||||
@@ -127,7 +124,6 @@ const readJsonFileInput = async <Value>(
|
||||
>
|
||||
>,
|
||||
signal?: AbortSignal,
|
||||
operation = "read_evidence_file",
|
||||
): Promise<
|
||||
Result<
|
||||
Value,
|
||||
@@ -157,7 +153,7 @@ const readJsonFileInput = async <Value>(
|
||||
if (cause instanceof AnalysisResourceConstraintError) return err(cause);
|
||||
if (cause instanceof JsonInputCancellationFailure)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
new AnalysisCancelledError("read_evidence_file", {
|
||||
cause: cause.reason,
|
||||
}),
|
||||
);
|
||||
@@ -170,7 +166,7 @@ const readJsonFileInput = async <Value>(
|
||||
);
|
||||
if (isJsonReadCancellation(cause, signal))
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
new AnalysisCancelledError("read_evidence_file", {
|
||||
cause: signal?.reason,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -35,13 +35,7 @@ import { MANAGED_WORKFLOW_PROVIDER } from "./InvestigationProviders.js";
|
||||
import { createProcessCaptureEvidence } from "./process/ProcessEvidence.js";
|
||||
import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js";
|
||||
|
||||
const applicationWithUnknowns = (
|
||||
keys: readonly string[],
|
||||
{
|
||||
kind = "unknown",
|
||||
label = "Unclassified application boundary",
|
||||
}: { kind?: "unknown" | "package"; label?: string } = {},
|
||||
) => {
|
||||
const applicationWithUnknowns = (keys: readonly string[]) => {
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture-source", name: "Fixture source", version: "1" },
|
||||
@@ -56,7 +50,7 @@ const applicationWithUnknowns = (
|
||||
const base = buildSyntheticJavaScriptApplicationGraph();
|
||||
const unknownNodes = keys.map((key) =>
|
||||
createJavaScriptApplicationNode({
|
||||
kind,
|
||||
kind: "unknown",
|
||||
identity: {
|
||||
strategy: "artifact-local-key",
|
||||
stability: "artifact-version",
|
||||
@@ -66,7 +60,7 @@ const applicationWithUnknowns = (
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label,
|
||||
label: "Unclassified application boundary",
|
||||
properties: {},
|
||||
evidence: artifactEvidence(
|
||||
APPLICATION_GRAPH_DIGESTS.asar,
|
||||
@@ -150,22 +144,6 @@ 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",
|
||||
@@ -349,87 +327,3 @@ 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?.trim() || `${node.kind} boundary`,
|
||||
title: node.observations[0]?.label ?? `${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?.trim() || `${node.kind} semantic boundary`,
|
||||
title: node.label ?? `${node.kind} semantic boundary`,
|
||||
policy: candidatePolicy,
|
||||
artifactSha256: node.identity.artifact_sha256,
|
||||
applicationNodeId: node.application_node_ids[0] ?? null,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import {
|
||||
createHistoricalSourceGraph,
|
||||
historicalSourceInventoryHasPartialFacts,
|
||||
historicalSourceLanguages,
|
||||
historicalSourceManifests,
|
||||
historicalSourceParseFailureKey,
|
||||
historicalSourceRelationshipKey,
|
||||
type HistoricalSourceGraph,
|
||||
type HistoricalSourceGraphInput,
|
||||
} from "../domain/referenceSourceGraph.js";
|
||||
@@ -47,13 +45,18 @@ const cancelled = (
|
||||
|
||||
const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true;
|
||||
|
||||
const relationshipKey = (
|
||||
relationship: HistoricalSourceGraphInput["relationships"][number],
|
||||
): string =>
|
||||
`${relationship.from_path}\u0000${relationship.to}\u0000${relationship.kind}\u0000${relationship.resolution}\u0000${relationship.parse_state}`;
|
||||
|
||||
const deduplicateRelationships = (
|
||||
relationships: HistoricalSourceGraphInput["relationships"],
|
||||
): HistoricalSourceGraphInput["relationships"] => {
|
||||
const seen = new Set<string>();
|
||||
const result: HistoricalSourceGraphInput["relationships"] = [];
|
||||
for (const relationship of relationships) {
|
||||
const key = historicalSourceRelationshipKey(relationship);
|
||||
const key = relationshipKey(relationship);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(relationship);
|
||||
@@ -90,6 +93,37 @@ const buildProvenance = (
|
||||
caller: options.caller,
|
||||
});
|
||||
|
||||
const deriveInventoryState = (
|
||||
input: Pick<
|
||||
HistoricalSourceGraphInput,
|
||||
| "entries"
|
||||
| "relationships"
|
||||
| "parse_failures"
|
||||
| "exclusions"
|
||||
| "limitations"
|
||||
>,
|
||||
): "complete" | "partial" | "unknown" => {
|
||||
if (input.limitations.length > 0) return "partial";
|
||||
if (input.exclusions.length > 0) return "partial";
|
||||
if (input.parse_failures.length > 0) return "partial";
|
||||
const partialEntry = input.entries.some(
|
||||
(entry) =>
|
||||
entry.limitations.length > 0 ||
|
||||
(entry.kind === "file" && entry.content_state !== "hashed") ||
|
||||
(entry.kind === "directory" && entry.tree_state !== "enumerated") ||
|
||||
(entry.kind === "symlink" && entry.target_state !== "internal") ||
|
||||
entry.kind === "unknown",
|
||||
);
|
||||
if (partialEntry) return "partial";
|
||||
const partialRelationship = input.relationships.some(
|
||||
({ parse_state, resolution }) =>
|
||||
parse_state !== "parsed" ||
|
||||
["unresolved", "unknown"].includes(resolution),
|
||||
);
|
||||
if (partialRelationship) return "partial";
|
||||
return "complete";
|
||||
};
|
||||
|
||||
const sortExclusions = (
|
||||
exclusions: HistoricalSourceGraphInput["exclusions"],
|
||||
): HistoricalSourceGraphInput["exclusions"] =>
|
||||
@@ -227,21 +261,16 @@ export const importReferenceSource = async (
|
||||
schema: "HistoricalSourceGraph",
|
||||
authority: "historical-reference",
|
||||
root_alias: "$REFERENCE_ROOT",
|
||||
inventory_state: historicalSourceInventoryHasPartialFacts({
|
||||
inventory_state: deriveInventoryState({
|
||||
entries: sortedEntries,
|
||||
relationships: uniqueRelationships,
|
||||
parse_failures: uniqueFailures,
|
||||
exclusions: sortedExclusions,
|
||||
limitations: sortedLimitations,
|
||||
})
|
||||
? "partial"
|
||||
: "complete",
|
||||
}),
|
||||
entries: sortedEntries,
|
||||
relationships: uniqueRelationships.sort((left, right) =>
|
||||
compareUnicodeCodePoints(
|
||||
historicalSourceRelationshipKey(left),
|
||||
historicalSourceRelationshipKey(right),
|
||||
),
|
||||
compareUnicodeCodePoints(relationshipKey(left), relationshipKey(right)),
|
||||
),
|
||||
parse_failures: uniqueFailures,
|
||||
exclusions: sortedExclusions,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
RegularFileCleanupFailure,
|
||||
retryRegularFileCleanup,
|
||||
} from "./RegularFileRead.js";
|
||||
import { referenceSourceResourceFailure } from "../reference/ReferenceSourceReaderErrors.js";
|
||||
import type { ReferenceSourceReaderError } from "../reference/ReferenceSourceReaderTypes.js";
|
||||
|
||||
export type ReferenceSourceVcsInfo =
|
||||
@@ -60,7 +59,13 @@ export const readReferenceSourceVcs = async (
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof ArtifactReaderFailure)) throw cause;
|
||||
return err(referenceSourceResourceFailure(cause));
|
||||
return err({
|
||||
tag: "reference-source-reader",
|
||||
code: cause.reason === "cancelled" ? "cancelled" : "io",
|
||||
message: cause.message,
|
||||
...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }),
|
||||
cause,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -405,20 +405,36 @@ export const clientConfigurationAligned = async (
|
||||
}
|
||||
const original = await readRegularFileText(client.configPath);
|
||||
const parsed = parseClientConfiguration(original, client.format);
|
||||
const desired = await clientConfigurationDesired(
|
||||
const policy = await readClientPolicyBlock(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
await clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
const policy = await readClientPolicyBlock(client, parsed, desired);
|
||||
if (!policy.ok) {
|
||||
throwCleanupFailure(policy.error);
|
||||
return false;
|
||||
}
|
||||
return policy.value === undefined && registrationCurrent(parsed, desired);
|
||||
return (
|
||||
policy.value === undefined &&
|
||||
registrationCurrent(
|
||||
parsed,
|
||||
await clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
)
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
// Unreadable configuration is treated as not aligned so setup repairs it.
|
||||
@@ -528,15 +544,18 @@ const inspectClientDocument = async (
|
||||
}
|
||||
try {
|
||||
const parsed = parseClientConfiguration(original, client.format);
|
||||
const desired = await clientConfigurationDesired(
|
||||
const policy = await readClientPolicyBlock(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
await clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
const policy = await readClientPolicyBlock(client, parsed, desired);
|
||||
if (!policy.ok) {
|
||||
throwCleanupFailure(policy.error);
|
||||
return {
|
||||
@@ -546,6 +565,14 @@ const inspectClientDocument = async (
|
||||
}
|
||||
if (policy.value !== undefined)
|
||||
return { status: "invalid", remediation: policy.value };
|
||||
const desired = await clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
if (registrationCurrent(parsed, desired))
|
||||
return { status: "already_current" };
|
||||
const fallback = await readZcodeFallbackServers(client, parsed);
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
redirectedWriteBlocker,
|
||||
type MsixPackageView,
|
||||
} from "./MsixPackageStorage.js";
|
||||
export { isUninstallFailure } from "./UninstallFailure.js";
|
||||
|
||||
interface ManagedPathStats {
|
||||
readonly uid?: number;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { gt, valid } from "semver";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type { IntegrationMaintenance } from "./UpdateMaintenance.js";
|
||||
export { isUpdateFailure } from "./UpdateFailure.js";
|
||||
|
||||
/** Global npm installation that owns the running package and its entry point. */
|
||||
export interface NpmInstallation {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { closeOwnedWebResources } from "./closeOwnedWebResources.js";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
@@ -93,6 +94,15 @@ export class WebModuleTraceService {
|
||||
}),
|
||||
);
|
||||
const input = parsed.data;
|
||||
for (const [path, value] of [
|
||||
["manifest_path", input.manifest_path],
|
||||
["import_map.path", input.import_map?.path],
|
||||
] as const)
|
||||
if (value !== undefined && !isAbsolute(value))
|
||||
return invalid(
|
||||
path.split("."),
|
||||
"Expected an absolute filesystem path on this host.",
|
||||
);
|
||||
for (const [path, value] of [
|
||||
["importer_url", input.importer_url],
|
||||
["import_map.base_url", input.import_map?.base_url],
|
||||
|
||||
@@ -43,30 +43,6 @@ 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())),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js";
|
||||
import { excludeCaptureCoordinates } from "../domain/webNetworkCaptureCoordinates.js";
|
||||
import { redactExplicitText } from "../domain/explicitSensitiveValues.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { WebNetworkCapturePort } from "./WebNetworkCapturePort.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
@@ -56,6 +57,16 @@ export class WebNetworkCaptureService {
|
||||
}),
|
||||
);
|
||||
const input = parsed.data;
|
||||
if (!isAbsolute(input.capture_path))
|
||||
return failure(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["capture_path"],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute filesystem path on this host.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (
|
||||
input.record_ordinals !== undefined &&
|
||||
new Set(input.record_ordinals).size !== input.record_ordinals.length
|
||||
@@ -80,6 +91,19 @@ export class WebNetworkCaptureService {
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (
|
||||
input.protobuf?.schema !== undefined &&
|
||||
!isAbsolute(input.protobuf.schema.descriptor_set_path)
|
||||
)
|
||||
return failure(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["protobuf", "schema", "descriptor_set_path"],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute descriptor-set path on this host.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const loaded = await this.port.inspect(input, options);
|
||||
if (!loaded.ok) {
|
||||
const partial = webNetworkCaptureSchema.safeParse(
|
||||
@@ -226,9 +250,6 @@ 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,7 +36,6 @@ export interface WebSourceMapPort {
|
||||
readonly path: string;
|
||||
readonly url: string;
|
||||
readonly position: WebSourceLocationInput["generated_position"];
|
||||
readonly decodeTimeoutMs?: number;
|
||||
},
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebSourceMapReport, AnalysisError>>;
|
||||
|
||||
@@ -11,55 +11,12 @@ import {
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import {
|
||||
DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS,
|
||||
webSourceLocationResultSchema,
|
||||
} from "../domain/webSourceLocation.js";
|
||||
import { 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 },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { closeOwnedWebResources } from "./closeOwnedWebResources.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
webArtifactFileSchema,
|
||||
} from "../domain/webScriptArtifacts.js";
|
||||
import {
|
||||
DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS,
|
||||
WEB_SOURCE_MAP_LIMITS,
|
||||
webSourceLocationInputSchema,
|
||||
webSourceLocationResultSchema,
|
||||
@@ -52,19 +52,17 @@ 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 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`;
|
||||
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";
|
||||
/** 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 `${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 `${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 runtime.process_memory_limit_bytes === null
|
||||
? `${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.`;
|
||||
? `${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.`;
|
||||
};
|
||||
|
||||
/** Join verified captured bytes with an explicitly selected source-map point. */
|
||||
@@ -127,20 +125,15 @@ 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, decodeTimeoutMs),
|
||||
codecBudgetLimitation(report.value.runtime),
|
||||
];
|
||||
const normalized = webSourceLocationResultSchema.safeParse({
|
||||
...report.value,
|
||||
@@ -208,6 +201,15 @@ const invalid = (
|
||||
const admitContext = (
|
||||
input: WebSourceLocationInput,
|
||||
): Result<null, AnalysisError> => {
|
||||
for (const [path, value] of [
|
||||
["manifest_path", input.manifest_path],
|
||||
["source_map.path", input.source_map.path],
|
||||
] as const)
|
||||
if (!isAbsolute(value))
|
||||
return invalid(
|
||||
path.split("."),
|
||||
"Expected an absolute filesystem path on this host.",
|
||||
);
|
||||
if (!URL.canParse(input.source_map.url))
|
||||
return invalid(
|
||||
["source_map", "url"],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { INSPECT_ANALYSIS_VIEW_OPERATION } from "../../domain/analysisView/analysisViewErrors.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ANALYSIS_VIEW_PROVIDER } from "../InvestigationProviders.js";
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
type AnalysisViewRequest,
|
||||
type InspectAnalysisViewInput,
|
||||
} from "../../domain/analysisView/analysisView.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
immutableJsonSnapshotMember,
|
||||
isImmutableJsonSnapshot,
|
||||
@@ -332,44 +332,39 @@ const createAnalysisViewEvidence = (
|
||||
? {}
|
||||
: { architecture: parent.subject.architecture }),
|
||||
};
|
||||
return createEvidence(
|
||||
target,
|
||||
ANALYSIS_VIEW_PROVIDER,
|
||||
{
|
||||
predicateType: "rea.analysis-view",
|
||||
operation: INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
parameters: {
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
view: jsonValueSchema.parse(input.view),
|
||||
},
|
||||
result: jsonValueSchema.parse(result),
|
||||
rawResult: null,
|
||||
confidence:
|
||||
// Grouping is whole-capture, so any templated group or parameter makes the result inferred;
|
||||
// entity types and identity in an extracted state always are.
|
||||
result.kind === "environment-state" ||
|
||||
((result.kind === "api-surface" ||
|
||||
result.kind === "environment-manifest") &&
|
||||
result.templated_groups > 0)
|
||||
? "inferred"
|
||||
: "derived",
|
||||
authority: parent.authority,
|
||||
limitations: result.limitations,
|
||||
...(result.kind === "native" && parent.relationship_coverage !== null
|
||||
? { relationshipCoverage: parent.relationship_coverage }
|
||||
: {}),
|
||||
locations: [
|
||||
...(result.artifact != null && result.artifact.path.length > 0
|
||||
? [{ kind: "artifact-path" as const, path: result.artifact.path }]
|
||||
: []),
|
||||
...(result.kind === "native" &&
|
||||
result.procedure_address !== null &&
|
||||
result.procedure_address.length > 0
|
||||
? [{ kind: "address" as const, address: result.procedure_address }]
|
||||
: []),
|
||||
],
|
||||
evidenceLinks: [parent.evidence_id],
|
||||
return createEvidence(target, ANALYSIS_VIEW_PROVIDER, {
|
||||
predicateType: "rea.analysis-view",
|
||||
operation: INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
parameters: {
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
view: jsonValueSchema.parse(input.view),
|
||||
},
|
||||
{ immutable: true },
|
||||
);
|
||||
result: jsonValueSchema.parse(result),
|
||||
rawResult: null,
|
||||
confidence:
|
||||
// Grouping is whole-capture, so any templated group or parameter makes the result inferred;
|
||||
// entity types and identity in an extracted state always are.
|
||||
result.kind === "environment-state" ||
|
||||
((result.kind === "api-surface" ||
|
||||
result.kind === "environment-manifest") &&
|
||||
result.templated_groups > 0)
|
||||
? "inferred"
|
||||
: "derived",
|
||||
authority: parent.authority,
|
||||
limitations: result.limitations,
|
||||
...(result.kind === "native" && parent.relationship_coverage !== null
|
||||
? { relationshipCoverage: parent.relationship_coverage }
|
||||
: {}),
|
||||
locations: [
|
||||
...(result.artifact != null && result.artifact.path.length > 0
|
||||
? [{ kind: "artifact-path" as const, path: result.artifact.path }]
|
||||
: []),
|
||||
...(result.kind === "native" &&
|
||||
result.procedure_address !== null &&
|
||||
result.procedure_address.length > 0
|
||||
? [{ kind: "address" as const, address: result.procedure_address }]
|
||||
: []),
|
||||
],
|
||||
evidenceLinks: [parent.evidence_id],
|
||||
});
|
||||
};
|
||||
@@ -32,13 +32,16 @@ export const readEvidenceFileSource = async (
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
const loaded = await readJsonFile(
|
||||
path,
|
||||
signal,
|
||||
resources,
|
||||
INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
);
|
||||
if (!loaded.ok) return loaded;
|
||||
const loaded = await readJsonFile(path, signal, resources);
|
||||
if (!loaded.ok) {
|
||||
if (loaded.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
|
||||
cause: loaded.error.cause,
|
||||
}),
|
||||
);
|
||||
return loaded;
|
||||
}
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
androidApplicationProjectionInputSchema,
|
||||
projectAndroidApplication,
|
||||
} from "../../domain/android/androidApplication.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import { ANDROID_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
import {
|
||||
projectInventoryEvidence,
|
||||
type InventoryProjectionEvidenceResult,
|
||||
} from "../InventoryProjectionEvidence.js";
|
||||
import { projectInventoryEvidence } from "../InventoryProjectionEvidence.js";
|
||||
|
||||
const OPERATION = "project_android_application_graph" as const;
|
||||
|
||||
/** Project authenticated APK inventory Evidence into Android application Evidence. */
|
||||
export const projectAndroidApplicationEvidence = (
|
||||
rawInput: unknown,
|
||||
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
return projectInventoryEvidence({
|
||||
rawInput,
|
||||
schema: androidApplicationProjectionInputSchema,
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
appleApplicationProjectionInputSchema,
|
||||
projectAppleApplication,
|
||||
} from "../../domain/apple/appleApplication.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import { APPLE_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
import {
|
||||
projectInventoryEvidence,
|
||||
type InventoryProjectionEvidenceResult,
|
||||
} from "../InventoryProjectionEvidence.js";
|
||||
import { projectInventoryEvidence } from "../InventoryProjectionEvidence.js";
|
||||
|
||||
const OPERATION = "project_apple_application_graph" as const;
|
||||
|
||||
/** Project authenticated IPA or macOS app inventory Evidence into Apple application evidence. */
|
||||
export const projectAppleApplicationEvidence = (
|
||||
rawInput: unknown,
|
||||
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
return projectInventoryEvidence({
|
||||
rawInput,
|
||||
schema: appleApplicationProjectionInputSchema,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { DesktopApplicationPort } from "./DesktopApplicationPort.js";
|
||||
import { ARTIFACT_GRAPH_PROVIDER } from "../InvestigationProviders.js";
|
||||
@@ -40,6 +41,16 @@ export class DesktopApplicationService {
|
||||
cause: parsed.error,
|
||||
}),
|
||||
);
|
||||
if (!isAbsolute(parsed.data.path))
|
||||
return err(
|
||||
new AnalysisInputError(operation, undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute directory path on this host.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const result = await this.reader.inspect(parsed.data, options);
|
||||
if (!result.ok) return result;
|
||||
const report = desktopApplicationSchema.safeParse(result.value);
|
||||
|
||||
@@ -25,18 +25,10 @@ it.each([
|
||||
throw new Error("must not run");
|
||||
},
|
||||
});
|
||||
const result = await service.inspect(input);
|
||||
expect(result).toMatchObject({
|
||||
expect(await service.inspect(input)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisInputError" },
|
||||
});
|
||||
if (input.path === "relative.elf")
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
issues: [{ path: ["path"], reason: "invalid_format" }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { BinaryLayoutPort } from "./BinaryLayoutPort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
@@ -64,6 +66,16 @@ export class BinaryLayoutService {
|
||||
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 inspected = await inspect(input.data);
|
||||
if (!inspected.ok) return inspected;
|
||||
const diagnostics = binaryLayoutSchema.shape.diagnostics.safeParse(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { PeResourcesPort } from "./PeResourcesPort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
@@ -45,6 +47,16 @@ export class PeResourcesService {
|
||||
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 = peResourcesSchema.safeParse(result.value);
|
||||
|
||||
@@ -70,18 +70,10 @@ it.each([
|
||||
throw new Error("must not run");
|
||||
},
|
||||
});
|
||||
const result = await service.inspect(input);
|
||||
expect(result).toMatchObject({
|
||||
expect(await service.inspect(input)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisInputError" },
|
||||
});
|
||||
if (input.path === "relative.elf")
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
issues: [{ path: ["path"], reason: "invalid_format" }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { RecordedCrashPort } from "./RecordedCrashPort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
@@ -65,6 +67,16 @@ export class RecordedCrashService {
|
||||
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 inspected = await inspect(input.data);
|
||||
if (!inspected.ok) return inspected;
|
||||
const diagnostics = recordedCrashSchema.shape.diagnostics.safeParse(
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type ExecutionOptions,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { PacketCaptureService } from "./PacketCaptureService.js";
|
||||
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
||||
import { inspectPacketCaptureInputSchema } from "../../domain/captures/packetCapture.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
|
||||
it("retains provider metadata in a late-cancellation observation", async () => {
|
||||
const input = inspectPacketCaptureInputSchema.parse({
|
||||
path: process.platform === "win32" ? "C:\\capture.pcap" : "/capture.pcap",
|
||||
fields: ["ip.addr"],
|
||||
});
|
||||
const provider = {
|
||||
id: "fixture-capture",
|
||||
name: "Fixture capture provider",
|
||||
version: "1",
|
||||
} as const;
|
||||
const subject = {
|
||||
path: input.path,
|
||||
sha256: "a".repeat(64),
|
||||
format: "file",
|
||||
} as const;
|
||||
const report = {
|
||||
artifact: { path: input.path, sha256: subject.sha256, bytes: 12 },
|
||||
selection: input,
|
||||
packets: [],
|
||||
output_window: {
|
||||
matching_packets: 0,
|
||||
returned_packets: 0,
|
||||
has_more: false,
|
||||
},
|
||||
read_scope: "to-eof",
|
||||
diagnostics: "",
|
||||
tool: {
|
||||
configured_path: "/fixture/tshark",
|
||||
path: "/fixture/tshark",
|
||||
sha256: "b".repeat(64),
|
||||
version_banner: "TShark fixture",
|
||||
version_diagnostics: "",
|
||||
arguments: [],
|
||||
},
|
||||
configuration: "private-empty-personal-profile",
|
||||
resource_limiter: {
|
||||
configured_path: "/fixture/prlimit",
|
||||
path: "/fixture/prlimit",
|
||||
sha256: "c".repeat(64),
|
||||
arguments: [],
|
||||
},
|
||||
limits: {
|
||||
input_bytes: 1,
|
||||
output_bytes: 1,
|
||||
command_timeout_ms: 1,
|
||||
address_space_bytes: 0,
|
||||
file_bytes: 0,
|
||||
cpu_seconds: 0,
|
||||
},
|
||||
hardware_acquisition: "not-performed",
|
||||
} as const;
|
||||
const rawResult = { producer: "fixture", bytes: 12 };
|
||||
const profile = createAnalysisProfile(provider, { filter: null });
|
||||
const relationshipCoverage = {
|
||||
status: "unknown",
|
||||
reason: "Packet relationships are not reported.",
|
||||
} as const;
|
||||
const execution = createAnalysisExecution(report, provider, {
|
||||
subject,
|
||||
rawResult,
|
||||
analysisProfile: profile,
|
||||
relationshipCoverage,
|
||||
locations: [{ kind: "artifact-path", path: input.path }],
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const reason = Object.freeze({ requested: "cancel after capture result" });
|
||||
const service = new PacketCaptureService({
|
||||
inspect: async (_input, options?: ExecutionOptions) => {
|
||||
controller.abort(reason);
|
||||
expect(options?.signal).toBe(controller.signal);
|
||||
return ok(execution);
|
||||
},
|
||||
inspectFields: async () => {
|
||||
throw new Error("Unexpected field discovery call");
|
||||
},
|
||||
close: async () => {},
|
||||
});
|
||||
|
||||
const result = await service.inspect(input, { signal: controller.signal });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected late cancellation");
|
||||
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
operation: "inspect_packet_capture",
|
||||
raw_result: rawResult,
|
||||
analysis_profile: profile,
|
||||
relationship_coverage: relationshipCoverage,
|
||||
});
|
||||
});
|
||||
@@ -3,25 +3,26 @@ import {
|
||||
packetFieldsCatalogSchema,
|
||||
type InspectPacketFieldsInput,
|
||||
} from "../../domain/captures/packetFields.js";
|
||||
import {
|
||||
createAnalysisExecutionEvidence,
|
||||
type AnalysisExecution,
|
||||
type ExecutionOptions,
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
ExecutionOptions,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import {
|
||||
inspectPacketCaptureInputSchema,
|
||||
packetCaptureSchema,
|
||||
type InspectPacketCaptureInput,
|
||||
} from "../../domain/captures/packetCapture.js";
|
||||
import { err, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
|
||||
export interface PacketCapturePort {
|
||||
/** Release per-call resources before returning, or retain failed cleanup for close/retry. */
|
||||
@@ -58,6 +59,16 @@ export class PacketCaptureService {
|
||||
cause: input.error,
|
||||
}),
|
||||
);
|
||||
if (!isAbsolute(input.data.path))
|
||||
return err(
|
||||
new AnalysisInputError(operation, undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute local path.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const decoded = await this.provider.inspect(input.data, options);
|
||||
if (!decoded.ok) return decoded;
|
||||
const execution = decoded.value;
|
||||
@@ -75,15 +86,7 @@ export class PacketCaptureService {
|
||||
"Capture analyzer returned malformed data or changed the selected artifact identity/options.",
|
||||
),
|
||||
);
|
||||
return completeProviderEvidence(
|
||||
createAnalysisExecutionEvidence(
|
||||
operation,
|
||||
input.data,
|
||||
execution,
|
||||
"observed",
|
||||
),
|
||||
options?.signal,
|
||||
);
|
||||
return this.#evidence(operation, input.data, execution, options);
|
||||
}
|
||||
async inspectFields(
|
||||
raw: unknown,
|
||||
@@ -117,14 +120,33 @@ export class PacketCaptureService {
|
||||
"Field discovery returned malformed data or changed the selected installation/options.",
|
||||
),
|
||||
);
|
||||
return completeProviderEvidence(
|
||||
createAnalysisExecutionEvidence(
|
||||
return this.#evidence(operation, input.data, execution, options);
|
||||
}
|
||||
#evidence(
|
||||
operation: string,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
execution: AnalysisExecution,
|
||||
options?: ExecutionOptions,
|
||||
): Result<Evidence, AnalysisError> {
|
||||
const evidence = createEvidence(
|
||||
execution.subject ?? undefined,
|
||||
execution.provider,
|
||||
{
|
||||
operation,
|
||||
input.data,
|
||||
execution,
|
||||
"observed",
|
||||
),
|
||||
options?.signal,
|
||||
parameters,
|
||||
result: execution.result,
|
||||
confidence: "observed",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
},
|
||||
);
|
||||
return options?.signal?.aborted
|
||||
? err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: options.signal.reason,
|
||||
partialObservation: evidence,
|
||||
}),
|
||||
)
|
||||
: ok(evidence);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { ElectronFusePort } from "./ElectronFusePort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
@@ -42,6 +45,16 @@ export class ElectronFuseService {
|
||||
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 inspected = await this.provider.inspect(input.data, options);
|
||||
if (!inspected.ok) return inspected;
|
||||
const report = electronFuseReportSchema.safeParse(inspected.value);
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "./environmentBundle.js";
|
||||
import type { EnvironmentSource } from "./environmentSource.js";
|
||||
import { prepareServedState } from "./environmentState.js";
|
||||
import { environmentToolAnswer } from "./environmentStateAnswer.js";
|
||||
import { environmentToolAnswer } from "./environmentToolAnswer.js";
|
||||
import { EnvironmentStateStore } from "./environmentStateStore.js";
|
||||
|
||||
const fixture = environmentServeFixture();
|
||||
@@ -218,7 +218,9 @@ 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 not served yet");
|
||||
expect(readme).toContain(
|
||||
"write tools are served only where the capture's later reads confirmed\ntheir effect",
|
||||
);
|
||||
expect(readme).toContain(
|
||||
"`rea environment-serve --replay-only` serves pure replay",
|
||||
);
|
||||
@@ -387,11 +389,10 @@ describe("planEnvironmentBundle without a state", () => {
|
||||
|
||||
it("serves and predicts replay when the state's catalog exceeds the discovery budget that replay's fits", () => {
|
||||
// Read tools only, so leaving out write tools saves nothing while each state description is longer.
|
||||
// 300 sits between the two catalogs on this fixture: replay still fits and the state catalog does not.
|
||||
const reads = servedSource.manifest.tools.filter(
|
||||
({ method }) => method === "GET",
|
||||
);
|
||||
const tools = Array.from({ length: 300 }, (_, index) => {
|
||||
const tools = Array.from({ length: 310 }, (_, index) => {
|
||||
const tool = reads[index % reads.length]!;
|
||||
return index < reads.length
|
||||
? tool
|
||||
@@ -417,7 +418,7 @@ describe("planEnvironmentBundle without a state", () => {
|
||||
served: false,
|
||||
code: "resource_constraint",
|
||||
});
|
||||
expect(planned.value.toolCount).toBe(300);
|
||||
expect(planned.value.toolCount).toBe(310);
|
||||
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 "./environmentStateAnswer.js";
|
||||
} from "./environmentToolAnswer.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, the read tools from a state. */
|
||||
/** Tools the bundle serves: every manifest tool for replay; from a state, the read tools and the served write tools. */
|
||||
readonly toolCount: number;
|
||||
readonly check: EnvironmentServedCheck | null;
|
||||
/** Whether the bundle serves from the state, as `environment-serve` reports it. */
|
||||
@@ -282,7 +282,8 @@ server derives the state the capture observed, the \`environment-state\` view
|
||||
of the capture with the manifest's template rules, and serves from it: read
|
||||
tools answer from the state where it backs them and from their recordings
|
||||
otherwise, \`/agentenv\` serves \`data/reset\`, \`data/add\` and \`data/get\` on
|
||||
it, and write tools are not served yet. When the state cannot be derived, or
|
||||
it, and write tools are served only where the capture's later reads confirmed
|
||||
their effect. 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,
|
||||
@@ -332,7 +333,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 !== undefined,
|
||||
state,
|
||||
name,
|
||||
ENVIRONMENT_EXPORT_OPERATION,
|
||||
);
|
||||
|
||||
@@ -13,13 +13,13 @@ import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
|
||||
import {
|
||||
REPLAY_BODY_LIMIT_BYTES,
|
||||
type EnvironmentSource,
|
||||
type RecordedExchange,
|
||||
} from "./environmentSource.js";
|
||||
import {
|
||||
replayEnvironmentTool,
|
||||
selectReplayExchange,
|
||||
} from "./environmentReplay.js";
|
||||
import type {
|
||||
EnvironmentSource,
|
||||
RecordedExchange,
|
||||
} from "./environmentSource.js";
|
||||
|
||||
const source = environmentServeSource();
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
type RecordedExchange,
|
||||
} from "./environmentSource.js";
|
||||
|
||||
export { REPLAY_BODY_LIMIT_BYTES } from "./environmentSource.js";
|
||||
|
||||
/** Caller arguments as the MCP tool receives them. */
|
||||
export type ReplayArguments = Readonly<Record<string, unknown>>;
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "../analysisView/AnalysisViewService.js";
|
||||
import { loadEnvironmentSource } from "./environmentSource.js";
|
||||
import {
|
||||
admitServedState,
|
||||
deriveEnvironmentState,
|
||||
loadEnvironmentState,
|
||||
prepareServedState,
|
||||
@@ -272,6 +273,28 @@ 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);
|
||||
@@ -418,30 +441,6 @@ 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([
|
||||
[
|
||||
@@ -485,8 +484,8 @@ 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_folders",
|
||||
path: "/api/folders",
|
||||
tool: "get_users_by_id",
|
||||
path: "/api/users/2",
|
||||
value: {},
|
||||
});
|
||||
},
|
||||
@@ -594,36 +593,3 @@ 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,10 +6,11 @@ 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 type {
|
||||
AnalysisInputIssue,
|
||||
import {
|
||||
AnalysisResourceConstraintError,
|
||||
type AnalysisInputIssue,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
@@ -22,6 +23,9 @@ 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,
|
||||
@@ -41,11 +45,22 @@ 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;
|
||||
}
|
||||
|
||||
/** 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 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;
|
||||
|
||||
/** The tools an environment serves and the data plane its card declares. */
|
||||
export interface ServedCatalog {
|
||||
@@ -53,27 +68,36 @@ export interface ServedCatalog {
|
||||
readonly dataPlane: EnvironmentDataPlane;
|
||||
}
|
||||
|
||||
/** The catalog an environment serves: every manifest tool for replay, the read tools and the data plane from a state. */
|
||||
/** The catalog an environment serves: every manifest tool for replay; from a state, the read tools, the confirmed write tools and the data plane. */
|
||||
export const servedCatalog = (
|
||||
source: EnvironmentSource,
|
||||
fromState: boolean,
|
||||
state: ServedWrites | undefined,
|
||||
name: string,
|
||||
operation: string = ENVIRONMENT_SERVE_OPERATION,
|
||||
): Result<ServedCatalog, AnalysisResourceConstraintError> => {
|
||||
const dataPlane: EnvironmentDataPlane = fromState ? "state" : "none";
|
||||
const catalogSource: EnvironmentSource = fromState
|
||||
? {
|
||||
...source,
|
||||
manifest: {
|
||||
...source.manifest,
|
||||
tools: source.manifest.tools.filter(servedFromState),
|
||||
const dataPlane: EnvironmentDataPlane =
|
||||
state === undefined ? "none" : "state";
|
||||
const tools = environmentToolDefinitions(
|
||||
state === undefined
|
||||
? source
|
||||
: {
|
||||
...source,
|
||||
manifest: {
|
||||
...source.manifest,
|
||||
tools: source.manifest.tools.filter((tool) =>
|
||||
servedFromState(tool, state),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
: source;
|
||||
const tools = environmentToolDefinitions(catalogSource, name, {
|
||||
operation,
|
||||
dataPlane,
|
||||
});
|
||||
name,
|
||||
{
|
||||
operation,
|
||||
dataPlane,
|
||||
...(state === undefined
|
||||
? {}
|
||||
: { describe: (tool) => describeWrite(state.writes.get(tool.name)) }),
|
||||
},
|
||||
);
|
||||
return tools.ok ? ok({ tools: tools.value, dataPlane }) : tools;
|
||||
};
|
||||
|
||||
@@ -162,6 +186,8 @@ 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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -214,6 +240,29 @@ 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 {
|
||||
@@ -232,19 +281,20 @@ export const prepareServedState = (
|
||||
} = {},
|
||||
): PreparedState => {
|
||||
const operation = options.operation ?? ENVIRONMENT_SERVE_OPERATION;
|
||||
// The state catalog depends only on the manifest and name, so checking it first spares deriving a state that cannot be served.
|
||||
const catalog = servedCatalog(
|
||||
source,
|
||||
true,
|
||||
options.name ?? source.manifest.name,
|
||||
operation,
|
||||
);
|
||||
if (!catalog.ok) return unavailable(catalog.error);
|
||||
const 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);
|
||||
const derived = deriveEnvironmentState(source, captureValue, operation);
|
||||
return derived.ok
|
||||
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
|
||||
? {
|
||||
state: derived.value,
|
||||
report: { served: true, evidence_id: derived.value.evidenceId },
|
||||
}
|
||||
: unavailable(derived.error);
|
||||
: unavailable(catalog.error);
|
||||
};
|
||||
@@ -9,10 +9,8 @@ import {
|
||||
environmentStateServeInputs,
|
||||
} from "../../../tests/fixtures/environmentServe.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
REPLAY_BODY_LIMIT_BYTES,
|
||||
type EnvironmentSource,
|
||||
} from "./environmentSource.js";
|
||||
import { REPLAY_BODY_LIMIT_BYTES } from "./environmentReplay.js";
|
||||
import type { EnvironmentSource } from "./environmentSource.js";
|
||||
import {
|
||||
answerFromState,
|
||||
type StateServing,
|
||||
@@ -267,6 +265,27 @@ 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) =>
|
||||
@@ -313,17 +332,6 @@ describe("recorded and document answers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("replays a read whose rendered document exceeds the body limit", () => {
|
||||
const values = Array.from({ length: 60_000 }, () => "1e3").join(",");
|
||||
const body = `{"values":[${values}]}`;
|
||||
const serving = servingFrom([["GET", "/api/config", body]]);
|
||||
expect(serving.store.state.documents).toEqual([]);
|
||||
expect(answer(serving, "get_config", {})).toMatchObject({
|
||||
replay: { record_ordinal: 0 },
|
||||
state: { source: "recording", reason: "rendered-oversize" },
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a document's recorded line with the state's value for it", () => {
|
||||
const capture = timedCaptureEvidence([
|
||||
["GET", "/api/settings", { theme: "dark", density: "compact" }],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { stateBodyJson } from "../../domain/analysisView/EnvironmentStateAccumul
|
||||
import {
|
||||
collectionPathOf,
|
||||
listItems,
|
||||
missingRequiredFields,
|
||||
renderStateEntity,
|
||||
renderStateList,
|
||||
replaceAt,
|
||||
@@ -14,6 +15,11 @@ 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,
|
||||
@@ -24,6 +30,7 @@ import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
decodedPathSegment,
|
||||
pathValueMatches,
|
||||
REPLAY_BODY_LIMIT_BYTES,
|
||||
replayAnswer,
|
||||
replayEnvironmentTool,
|
||||
selectReplayExchange,
|
||||
@@ -33,7 +40,6 @@ import {
|
||||
} from "./environmentReplay.js";
|
||||
import {
|
||||
ENVIRONMENT_SERVE_OPERATION,
|
||||
REPLAY_BODY_LIMIT_BYTES,
|
||||
type EnvironmentManifestTool,
|
||||
type EnvironmentSource,
|
||||
type RecordedExchange,
|
||||
@@ -45,7 +51,11 @@ import { environmentToolArguments } from "./environmentTools.js";
|
||||
export type StateProvenance =
|
||||
| {
|
||||
readonly source: "recording";
|
||||
readonly reason: RecordedViewReason | "no-view" | "unrecorded-request";
|
||||
readonly reason:
|
||||
| RecordedViewReason
|
||||
| "no-view"
|
||||
| "unrecorded-request"
|
||||
| "page-exceeded";
|
||||
}
|
||||
| {
|
||||
readonly source: "state";
|
||||
@@ -64,8 +74,16 @@ export type StateProvenance =
|
||||
}
|
||||
| {
|
||||
readonly source: "state";
|
||||
readonly view: "document";
|
||||
readonly view: "document" | "document-write";
|
||||
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. */
|
||||
@@ -79,7 +97,7 @@ export interface StateServing {
|
||||
readonly store: EnvironmentStateStore;
|
||||
}
|
||||
|
||||
const refused = (
|
||||
export const refused = (
|
||||
tool: EnvironmentManifestTool,
|
||||
userMessage: string,
|
||||
): AnalysisError =>
|
||||
@@ -94,12 +112,9 @@ const shownEntity = (
|
||||
entity: StateEntity,
|
||||
volatile: ReadonlySet<string>,
|
||||
): Result<JsonObject, AnalysisError> => {
|
||||
const shown = renderStateEntity(entity, view, volatile);
|
||||
const missing = view.required_fields.filter(
|
||||
(name) => !Object.hasOwn(shown, name),
|
||||
);
|
||||
const missing = missingRequiredFields(entity, view, volatile);
|
||||
return missing.length === 0
|
||||
? ok(shown)
|
||||
? ok(renderStateEntity(entity, view, volatile))
|
||||
: err(
|
||||
refused(
|
||||
tool,
|
||||
@@ -122,11 +137,12 @@ const fromRecording = (
|
||||
};
|
||||
|
||||
/** A recorded body the state renders into, usable exactly when extraction could use it. */
|
||||
const recordedJson = (exchange: RecordedExchange): JsonValue | undefined =>
|
||||
stateBodyJson(exchange.contentType, exchange);
|
||||
export const recordedJson = (
|
||||
exchange: RecordedExchange,
|
||||
): JsonValue | undefined => stateBodyJson(exchange.contentType, exchange);
|
||||
|
||||
/** The rendered body on the base record's status, content type and provenance; never cut. */
|
||||
const stateAnswer = (
|
||||
export const stateAnswer = (
|
||||
serving: StateServing,
|
||||
base: { readonly exchange: RecordedExchange; readonly exact: boolean },
|
||||
body: JsonValue,
|
||||
@@ -157,22 +173,22 @@ const stateAnswer = (
|
||||
};
|
||||
|
||||
/** One collection segment an item call names: a template literal, or a supplied path value. */
|
||||
interface CallSegment {
|
||||
export interface CallSegment {
|
||||
readonly text: string;
|
||||
readonly supplied: boolean;
|
||||
}
|
||||
|
||||
/** An item call's collection segments and identity value, kept as supplied rather than joined and re-split. */
|
||||
interface ItemCall {
|
||||
export interface ItemCall {
|
||||
readonly segments: readonly CallSegment[];
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
/** What an item call names, or undefined when a path argument is missing. */
|
||||
const itemCall = (
|
||||
/** A call's path segments: template literals and supplied values, or undefined when a path argument is missing. */
|
||||
export const callSegments = (
|
||||
tool: EnvironmentManifestTool,
|
||||
args: ReplayArguments,
|
||||
): ItemCall | undefined => {
|
||||
): CallSegment[] | undefined => {
|
||||
const names = environmentToolArguments(tool).path;
|
||||
const params = new Map(tool.params.map((param) => [param.index, param]));
|
||||
const segments: CallSegment[] = [];
|
||||
@@ -189,12 +205,23 @@ const itemCall = (
|
||||
if (typeof value !== "string") return undefined;
|
||||
segments.push({ text: value, supplied: true });
|
||||
}
|
||||
const last = segments.pop();
|
||||
return last?.supplied === true ? { segments, id: last.text } : undefined;
|
||||
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;
|
||||
};
|
||||
|
||||
/** How many supplied segments a held collection matches verbatim, when it matches the call under replay's path rule. */
|
||||
const collectionMatch = (
|
||||
export const collectionMatch = (
|
||||
collection: string,
|
||||
segments: readonly CallSegment[],
|
||||
): number | undefined => {
|
||||
@@ -211,7 +238,7 @@ const collectionMatch = (
|
||||
};
|
||||
|
||||
/** The entity an item call names, preferring verbatim collection spellings as replay does; the id is tried percent-decoded, then as written. */
|
||||
const namedEntity = (
|
||||
export const namedEntity = (
|
||||
store: EnvironmentStateStore,
|
||||
type: string,
|
||||
call: ItemCall,
|
||||
@@ -232,7 +259,60 @@ const namedEntity = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** An item call renders the named entity into the recorded envelope; it never invents an entity. */
|
||||
/** 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. */
|
||||
const answerItem = (
|
||||
serving: StateServing,
|
||||
tool: EnvironmentManifestTool,
|
||||
@@ -245,12 +325,11 @@ const answerItem = (
|
||||
return err(refused(tool, `Tool ${tool.name} needs every path argument.`));
|
||||
const entity = namedEntity(store, view.type, call);
|
||||
if (entity === undefined)
|
||||
return err(
|
||||
refused(
|
||||
tool,
|
||||
`The state holds no ${view.type} entity ${call.id} in /${call.segments.map(({ text }) => text).join("/")}.`,
|
||||
),
|
||||
);
|
||||
return missingAnswer(serving, tool, {
|
||||
type: view.type,
|
||||
collection: `/${call.segments.map(({ text }) => text).join("/")}`,
|
||||
id: call.id,
|
||||
});
|
||||
const shown = shownEntity(
|
||||
tool,
|
||||
view,
|
||||
@@ -297,18 +376,46 @@ 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 and the arguments it was called with. */
|
||||
/** One tool call: the tool, the arguments it was called with, and whether they name the answering line exactly. */
|
||||
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 }: ToolCall,
|
||||
{ tool, args, exact }: ToolCall,
|
||||
view: StateListView,
|
||||
base: RecordedExchange,
|
||||
): Result<StateAnswer, AnalysisError> => {
|
||||
@@ -320,6 +427,16 @@ 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)
|
||||
@@ -340,7 +457,7 @@ const answerList = (
|
||||
}
|
||||
return stateAnswer(
|
||||
serving,
|
||||
{ exchange: base, exact: true },
|
||||
{ exchange: base, exact },
|
||||
renderStateList(view, envelope, items),
|
||||
{
|
||||
source: "state",
|
||||
@@ -376,16 +493,27 @@ const answerCheckedLine = (
|
||||
view: StateListView | StateDocumentView,
|
||||
args: ReplayArguments,
|
||||
): Result<StateAnswer, AnalysisError> => {
|
||||
const base = checkedLine(
|
||||
const named = 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 }, view, base)
|
||||
? answerList(
|
||||
serving,
|
||||
{ tool, args, exact: named !== undefined },
|
||||
view,
|
||||
base,
|
||||
)
|
||||
: answerDocument(serving, tool, base);
|
||||
};
|
||||
|
||||
@@ -408,18 +536,3 @@ 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);
|
||||
@@ -0,0 +1,49 @@
|
||||
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,4 +1,3 @@
|
||||
import { types } from "node:util";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
same,
|
||||
scalarText,
|
||||
segmentsOf,
|
||||
type JsonObject,
|
||||
type StateDocument,
|
||||
type StateEntity,
|
||||
type StateType,
|
||||
@@ -16,10 +16,11 @@ 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,
|
||||
ownedJsonValueSchema,
|
||||
jsonValueValidationIssue,
|
||||
type JsonValue,
|
||||
} from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
@@ -42,42 +43,37 @@ export interface StateContent {
|
||||
readonly documents: readonly HeldDocument[];
|
||||
}
|
||||
|
||||
const stateDocumentSchemaFor = (jsonValue: z.ZodType<JsonValue, unknown>) => {
|
||||
const fieldsSchema = z.record(z.string(), jsonValue);
|
||||
const ordinalSchema = z.number().int().nonnegative();
|
||||
const fieldsSchema = z.record(z.string(), jsonValueSchema);
|
||||
const ordinalSchema = z.number().int().nonnegative();
|
||||
|
||||
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);
|
||||
const stateDocumentSchema = z.strictObject({
|
||||
rea_state: z.strictObject({
|
||||
version: z.literal(1),
|
||||
// `data/get` reports these; accepting them back lets its output be loaded unchanged.
|
||||
state_evidence_id: z.string().optional(),
|
||||
types: z.array(jsonValueSchema).optional(),
|
||||
entities: z.array(
|
||||
z.strictObject({
|
||||
type: z.string(),
|
||||
collection: z.string(),
|
||||
id: z.string(),
|
||||
fields: fieldsSchema,
|
||||
view_fields: z
|
||||
.array(z.strictObject({ tool: z.string(), fields: fieldsSchema }))
|
||||
.optional(),
|
||||
record_ordinals: z.array(ordinalSchema).optional(),
|
||||
}),
|
||||
),
|
||||
documents: z.array(
|
||||
z.strictObject({
|
||||
tool: z.string(),
|
||||
path: z.string(),
|
||||
value: jsonValueSchema,
|
||||
record_ordinal: ordinalSchema.optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
type ParsedDocument = z.output<typeof stateDocumentSchema>["rea_state"];
|
||||
type ParsedEntity = ParsedDocument["entities"][number];
|
||||
@@ -115,86 +111,47 @@ 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 Cursor {
|
||||
interface Node {
|
||||
readonly value: unknown;
|
||||
readonly parent: Cursor | undefined;
|
||||
readonly parent: Node | undefined;
|
||||
readonly key: string | number;
|
||||
readonly entries?: Generator<string>;
|
||||
index: number;
|
||||
}
|
||||
const pathOf = (node: Cursor): (string | number)[] => {
|
||||
const pathOf = (node: Node): (string | number)[] => {
|
||||
const path: (string | number)[] = [];
|
||||
for (let at: Cursor = node; at.parent !== undefined; at = at.parent)
|
||||
for (let at: Node = node; at.parent !== undefined; at = at.parent)
|
||||
path.push(at.key);
|
||||
return path.reverse();
|
||||
};
|
||||
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 pending: Node[] = [{ value: root, parent: undefined, key: "" }];
|
||||
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
||||
const { value } = node;
|
||||
if (typeof value !== "object" || value === null) {
|
||||
pending.pop();
|
||||
continue;
|
||||
}
|
||||
// The canonical JSON validator reports proxies without invoking their traps.
|
||||
if (types.isProxy(value)) {
|
||||
pending.pop();
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "object" || value === null) continue;
|
||||
const array = Array.isArray(value);
|
||||
if (!array && Object.hasOwn(value, "__proto__"))
|
||||
return [...pathOf(node), "__proto__"];
|
||||
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));
|
||||
const entries = Object.entries(value);
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1)
|
||||
pending.push({
|
||||
value: entries[index]![1],
|
||||
parent: node,
|
||||
key: array ? index : entries[index]![0],
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** 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[],
|
||||
@@ -426,12 +383,9 @@ const heldContent = (
|
||||
export const parseStateDocument = (
|
||||
state: EnvironmentState,
|
||||
value: unknown,
|
||||
owned = false,
|
||||
): Result<StateContent, AnalysisInputError> => {
|
||||
const prototype = prototypeMemberPath(value);
|
||||
const parsed = (
|
||||
owned ? ownedStateDocumentSchema : stateDocumentSchema
|
||||
).safeParse(value);
|
||||
const parsed = stateDocumentSchema.safeParse(value);
|
||||
const initial = new Map(
|
||||
state.entities.map((entity) => [entityKey(entity), entity]),
|
||||
);
|
||||
|
||||
Loaded 100 of 567 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user