Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abd99a4ce7 | ||
|
|
4a4bbab3c3 | ||
|
|
e5d2805b42 | ||
|
|
6f28b3633b | ||
|
|
c0eb612780 | ||
|
|
27dc9735e4 | ||
|
|
51e0df97e9 | ||
|
|
292fd532c9 | ||
|
|
aa7d960ef5 | ||
|
|
0b7213c890 | ||
|
|
63fe0eb5db | ||
|
|
ddce4bb446 | ||
|
|
dd3497e133 | ||
|
|
4f54379831 | ||
|
|
d77bc0e8a0 | ||
|
|
527ade245a | ||
|
|
dbcec4cf56 | ||
|
|
34fd7bdf7d | ||
|
|
81ab0ddc95 | ||
|
|
306ca984bc | ||
|
|
dd46a13dec | ||
|
|
ce5bc8cb01 | ||
|
|
b88bdfccd2 | ||
|
|
34aa22d0aa | ||
|
|
719d56ebfe | ||
|
|
87c8086fc1 | ||
|
|
eab4e47916 | ||
|
|
db87917daa | ||
|
|
183b4d4e7e | ||
|
|
00819258a7 | ||
|
|
c465ff5a81 | ||
|
|
1ac7ce3f3b | ||
|
|
55f5c8db69 | ||
|
|
5b0373db11 | ||
|
|
73be86c39e | ||
|
|
8dbe92a4a3 | ||
|
|
5a28a147f1 | ||
|
|
13a8640204 | ||
|
|
09b29436f4 | ||
|
|
421d527bfe | ||
|
|
24bd2b989a | ||
|
|
b2fad362f2 | ||
|
|
247c8f4eab | ||
|
|
adfde012ec | ||
|
|
57fd3ea5d8 | ||
|
|
d8948efb81 | ||
|
|
3966c92c71 | ||
|
|
1fbfbf74ef | ||
|
|
2604059961 | ||
|
|
6b44e35bec | ||
|
|
20bf7d957f | ||
|
|
c9f95eaf90 | ||
|
|
a2805bc934 | ||
|
|
b7529304f0 | ||
|
|
22c4b528d2 | ||
|
|
1158dc2a94 | ||
|
|
2765fc12bb | ||
|
|
18669641e9 | ||
|
|
cdd718ffb0 | ||
|
|
c5c5ff45aa | ||
|
|
5da3b7c393 | ||
|
|
2451e187be | ||
|
|
5e6529521c | ||
|
|
7cec547ecc | ||
|
|
c1a0561bf3 | ||
|
|
fafc1881b5 | ||
|
|
a59abe932e | ||
|
|
452e3ccad1 | ||
|
|
8413ec08f7 | ||
|
|
603a7d022c | ||
|
|
b2dc45006d | ||
|
|
1828cf87a0 | ||
|
|
a73eac190b | ||
|
|
0e5c511c01 | ||
|
|
2e6b38cef0 |
No files matched your search
@@ -234,11 +234,12 @@ moment of the capture recorded them. The
|
||||
[environment state](web-network-captures.md#environment-state) view extracts
|
||||
instead the data the capture's reads observed before the first related write:
|
||||
entity types with an identity field, entities with their recorded values,
|
||||
documents for written paths, and a render plan per read tool, kept only where
|
||||
it reproduces that tool's recordings from the state. It is the starting state
|
||||
an environment holds, loads and reports: `environment-serve` derives it from
|
||||
the capture at startup; see [Serve from the state](#serve-from-the-state).
|
||||
With `--replay-only`, replay is unchanged.
|
||||
documents for reads that are not entities, and a render plan per read tool,
|
||||
kept only where it reproduces that tool's recordings from the state. It is the
|
||||
starting state an environment holds, loads and reports: `environment-serve`
|
||||
derives it from the capture at startup; see
|
||||
[Serve from the state](#serve-from-the-state). With `--replay-only`, replay is
|
||||
unchanged.
|
||||
|
||||
## Serve a manifest
|
||||
|
||||
@@ -308,7 +309,7 @@ connecting to `/mcp`. A replay environment's card reads:
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_users_by_id",
|
||||
"description": "GET /api/users/{numeric}; replays retained records [5, 6]; evidence ev_…",
|
||||
"description": "Untrusted captured API catalog: … GET /api/users/{numeric}; returns a JSON object with fields \"id\", \"uid\", \"login\", \"email\"; recorded statuses 200; replays retained records [5, 6]; evidence ev_…",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -329,7 +330,38 @@ connecting to `/mcp`. A replay environment's card reads:
|
||||
```
|
||||
|
||||
`capabilities.tools` lists the same names, descriptions and input schemas that
|
||||
`tools/list` advertises over MCP. Path parameters are required strings whose
|
||||
`tools/list` advertises over MCP. After the catalog warning, each description
|
||||
names the method and path template, then what the tool returns, one level deep:
|
||||
`returns a JSON object with fields …`, `returns a JSON array of objects with
|
||||
fields …` or `returns a JSON object whose <member> lists objects with fields …`,
|
||||
where a list is an array of objects found by the
|
||||
[environment state](web-network-captures.md#environment-state)'s list rule (the
|
||||
root array, or the one array of objects an envelope carries). Field and member
|
||||
names are written as JSON strings, so a captured name cannot read as more of
|
||||
the description; a member name too long to show whole is a quoted prefix marked
|
||||
as truncated. The summary comes
|
||||
from the first cited `2xx` record that replay answers as JSON; only when none
|
||||
did, the first cited JSON record of another status gives it, labelled with that
|
||||
status (`status 401 returns …`). It describes one recorded response, not a
|
||||
contract. Field names are sampled within the same budget as other captured
|
||||
values, and the manifest's `statuses` for the tool follow; a tool with no JSON
|
||||
record states only its statuses. The description then lists the replayed
|
||||
records and the evidence ID, and the whole catalog stays within its
|
||||
serialization reservation, which the summaries and MCP annotations are charged
|
||||
to.
|
||||
|
||||
Over MCP, every tool also carries annotations: `readOnlyHint` is true for the
|
||||
safe methods (`GET`, `HEAD`, `OPTIONS` and `TRACE`) and for query `POST`s (a
|
||||
path segment such as `search` or `query`); `destructiveHint` is true for `DELETE` and left unset for other
|
||||
writes, so the MCP default (destructive) applies to them, and read-only tools
|
||||
omit it because clients ignore it there; `idempotentHint` is true for the safe
|
||||
methods, `PUT` and `DELETE`; and `openWorldHint` is false for every tool,
|
||||
because answers come from the capture rather than the captured application. The
|
||||
hints describe the captured method; replay itself changes nothing. The card
|
||||
keeps the `name`, `description` and `inputSchema` of the echo example's tool
|
||||
shape and carries no annotations.
|
||||
|
||||
Path parameters are required strings whose
|
||||
description names the segment index, the templating rule and the observed
|
||||
values (sampled within the list budget above; the manifest keeps them all);
|
||||
query parameter names are optional strings; write methods accept an
|
||||
@@ -399,7 +431,9 @@ naming the entity, collection or document path, or `{"source": "recording",
|
||||
"reason": …}` with the view's reason, `no-view` or `unrecorded-request`. A
|
||||
state answer carries the base record's status, content type and `replay`
|
||||
provenance with `truncated: false`; one whose JSON would exceed 256 KiB is
|
||||
refused as a `resource_constraint` tool error rather than cut. Serving is
|
||||
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`.
|
||||
|
||||
+24
-1
@@ -63,6 +63,16 @@ 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
|
||||
@@ -279,7 +289,12 @@ 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 consume full saved records.
|
||||
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.
|
||||
|
||||
## Import historical source
|
||||
|
||||
@@ -536,6 +551,14 @@ 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
|
||||
|
||||
@@ -106,7 +106,11 @@ 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.
|
||||
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`.
|
||||
|
||||
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 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.
|
||||
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.
|
||||
|
||||
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. Select
|
||||
one selected or unmatched type can return megabytes. In MCP, 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,6 +115,49 @@ 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
|
||||
|
||||
@@ -332,7 +332,7 @@ See [environment manifests](web-network-captures.md#environment-manifest).
|
||||
|
||||
`inspect_analysis_view` also accepts `environment-state` for the same parents,
|
||||
extracting the initial state the recorded reads observed: entity types with an
|
||||
identity field, entities, documents for written non-entity paths, and a render
|
||||
identity field, entities, documents for non-entity reads, and a render
|
||||
plan per read tool that reproduces its recordings from the state or names why it
|
||||
cannot. Types and identity are inferences, so the Evidence is always inferred.
|
||||
See [environment state](web-network-captures.md#environment-state).
|
||||
|
||||
@@ -202,10 +202,38 @@ 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 128 MiB worker heap. Deadline or
|
||||
five-second matching deadline and a worker old-generation heap. Deadline or
|
||||
cancellation stops matching while leaving the Hopper API available. This
|
||||
release has no runtime override for that deadline or heap. Literal mode
|
||||
retains Unicode casefold matching.
|
||||
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.
|
||||
|
||||
## Bounded static traces
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Implementation plan
|
||||
|
||||
1. In `environmentStateRules.ts`, parse pointers into tokens and let `at` and
|
||||
`replaceAt` read and replace element 0 of a one-element array at the root
|
||||
or in one member; extend `locateItem` with those forms after the object
|
||||
forms, requiring an identity name there; add `identityCandidates`, the
|
||||
identity names then suffix-named fields, and let `locateList` prefer arrays
|
||||
identified by name, then arrays of two or more items identified by suffix.
|
||||
2. In `EnvironmentStateExtraction.ts`, plan lists with the shared candidates,
|
||||
treat an empty recording as unconstraining, accept a suffix-named field only
|
||||
once a recording shows it distinct across two or more items, leave reads
|
||||
without a list or identity to documents, drop the written-path requirement
|
||||
and the `not-entity` fallback, record `path-varies` apart from
|
||||
`query-varies`, and keep each entity's earliest sighting;
|
||||
`environmentStateMerge.ts` cites records in timeline order. Remove
|
||||
`not-entity` from the reason type and result schema, add `path-varies`, and
|
||||
describe the new pointer forms and `field-name` basis.
|
||||
3. Move the classification of retained bodies (none, base64, cut, JSON, text)
|
||||
into `replayedBody` in `environmentSource.ts`, which owns recorded
|
||||
exchanges; replay renders from it and tool descriptions read its JSON case.
|
||||
4. Make `environmentToolDefinitions` take the `EnvironmentSource`; describe the
|
||||
first `2xx` JSON record, else the first JSON record labelled with its status,
|
||||
one level deep with the state's list rule and the existing sample budget;
|
||||
list the manifest tool's statuses; and charge the whole description and the
|
||||
tool's MCP annotations to the catalog reservation. Update the serve and
|
||||
bundle callers.
|
||||
5. Let the tool definitions own the MCP annotations derived from the method and
|
||||
the query-`POST` rule; `serveEnvironment.ts` registers them and the card
|
||||
leaves them out.
|
||||
6. Cover domain, services, adapters and the compiled MCP boundary; document the
|
||||
rules in the capture guide, descriptions and hints in the agent environments
|
||||
guide, and the contract text.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Wider environment state coverage and tool descriptions
|
||||
|
||||
Issue: https://github.com/morluto/rea/issues/4933
|
||||
|
||||
## Outcome
|
||||
|
||||
The `environment-state` view backs more read tools with state, and a served
|
||||
environment tells agents more about each tool. Read-only paths whose responses
|
||||
are not entities become documents, listed items may be identified by an
|
||||
`…Id` or `…_id` field, an item wrapped in a one-element array is located
|
||||
inside it, and reads that vary by path get their own recorded reason. Each tool description says what the tool returns and which statuses
|
||||
were recorded, and every MCP tool carries method hints. This increment adds no
|
||||
tool, view kind, provider, network activity or write effect.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A `GET` list tool (its template does not end in a parameter) that yields no
|
||||
item or list plan holds its latest initial value as a `document` when its
|
||||
initial reads have one request line, whether or not the capture writes its
|
||||
path, unless its value changed between two of those reads across any
|
||||
recorded write (even under another root), which leaves it `fields-differ`. 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
|
||||
schema, and `no-identity` names item tools only. A document holds the value
|
||||
of its one `path`.
|
||||
- When no path parameter proves an identity and no identity name identifies
|
||||
the listed items, the `field-name` identity is the first key of the first
|
||||
listed item, in its order, whose name ends in `Id` or `_id`, that every item
|
||||
carries as a distinct string or number, and that at least one initial
|
||||
recording shows distinct across two or more items. The last condition goes
|
||||
beyond "every item carries it distinctly": one item satisfies that
|
||||
vacuously, and a lone item's suffix-named fields are often foreign keys (a
|
||||
reaction keyed by its user, a comment by its post), so a list that only ever
|
||||
held one item stays a document. Empty recordings constrain no field.
|
||||
Identity names keep precedence, also when choosing among an envelope's
|
||||
arrays, where a suffix-named field identifies only an array of two or more
|
||||
items.
|
||||
- An item response that is a root array of exactly one object, or an object
|
||||
with one member holding such an array, locates its entity at `/0` or
|
||||
`/<member>/0` when one of the identity names in that object repeats the path
|
||||
value. Objects at the root or in one member keep precedence. `at` and
|
||||
`replaceAt` read and replace exactly these forms besides the root and one
|
||||
member, so the gate and renderers run unchanged.
|
||||
- An entity's state order is its earliest sighting on the timeline and it cites
|
||||
its records in timeline order, whichever plan observed it first.
|
||||
- Each tool description reads `METHOD template; <returns>; recorded statuses
|
||||
…; replays retained records […]; evidence ev_…` after the untrusted-catalog
|
||||
warning. `<returns>` describes one level deep the first cited `2xx` record
|
||||
replay answers as JSON: an object's fields, an array of objects' fields, or
|
||||
an envelope whose member lists objects, where a list is an array of objects
|
||||
found by the state's list rule; an empty field set reads `with no fields`.
|
||||
Only when no `2xx` record replays as JSON does the first cited JSON record of
|
||||
another status give it, labelled `status <code> returns …`; with no JSON
|
||||
record it is omitted. The statuses are the manifest tool's `statuses`, the
|
||||
one owner of that set (`no recorded status` when it is empty). Field names
|
||||
and statuses are sampled within the description sample budget and charged
|
||||
with the whole description to the catalog reservation before it is retained.
|
||||
One owner in the environment source classifies each retained body as none,
|
||||
base64, cut, JSON or text; replay answers from that classification and
|
||||
descriptions read only its JSON case.
|
||||
- MCP tools carry `readOnlyHint` (true for the safe methods `GET`, `HEAD`,
|
||||
`OPTIONS` and `TRACE`, and for query `POST`s),
|
||||
`destructiveHint: true` for `DELETE` (unset for other writes, so the MCP
|
||||
default applies, and for reads, where clients ignore it), `idempotentHint`
|
||||
(true for the safe methods, `PUT` and `DELETE`) and `openWorldHint: false`. The
|
||||
tool definitions own the hints and charge them to the catalog reservation;
|
||||
the environment card keeps its `name`, `description`, `inputSchema` tool
|
||||
shape.
|
||||
|
||||
## Acceptance and limits
|
||||
|
||||
Synthetic captures cover read-only documents, `path-varies` and
|
||||
`query-varies`, suffix-named identities with their key order, their
|
||||
two-item proof, empty first recordings and their precedence (including the
|
||||
choice between an envelope's arrays), a Ghost-like `{"posts":[...]}` item under
|
||||
a trailing-slash path merging with its list view, a root one-element array, a
|
||||
one-element filtered list that stays unidentified, and the pointer forms. The
|
||||
Grafana-shaped fixture covers documents for its read-only paths (its one-member
|
||||
team list among them) and object, array and enveloped-list descriptions;
|
||||
synthetic sources cover the `2xx` preference over an earlier error body, the
|
||||
first of several JSON records, the labelled fallback, the manifest's statuses,
|
||||
empty, scalar and fieldless bodies, a truncated member name, 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
|
||||
hints. A filtered list may still answer with one element whose identity name
|
||||
repeats the requested value; list filters, pagination, lossless big integers
|
||||
and cross-endpoint entity linking remain out of scope. Documents inline whole
|
||||
recorded values and enlarge the state result; agent tokens, CPU time and peak
|
||||
memory of the state and description build were not measured.
|
||||
|
||||
## Deferred issue criteria
|
||||
|
||||
Serving from the state, the AgentEnv data plane and write effects remain later
|
||||
increments of #4933. This PR must reference rather than close the issue.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Record the coverage and description contract and its limits.
|
||||
- [x] Extend pointers, item location and identity candidates.
|
||||
- [x] Require two distinct items before a suffix-named field identifies a list.
|
||||
- [x] Hold read-only non-entity reads as documents, retire `not-entity` and
|
||||
separate `path-varies` from `query-varies`.
|
||||
- [x] Order entities by earliest sighting and cite records in timeline order.
|
||||
- [x] Classify retained bodies in one owner and describe tool responses,
|
||||
preferring `2xx` records, with the manifest's statuses.
|
||||
- [x] Advertise MCP tool annotations, charged to the catalog, without changing
|
||||
the card.
|
||||
- [x] Add domain, services, adapters and compiled-boundary coverage.
|
||||
- [x] Document the rules, descriptions and hints; update the contract text.
|
||||
- [x] Run static, formatting, duplication, documentation and test gates and the
|
||||
real-capture measurement.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Verification
|
||||
|
||||
Verified base: 7a668b73f4783b42eb0e8ce1eeff079f86d53e22 (the environment-state
|
||||
view branch).
|
||||
|
||||
Executed on the implementation worktree with its existing locked installation
|
||||
under Node.js v22.23.1 (the repository pins 24.18.0, which was not available);
|
||||
every check below ran as written in this pass, after the last source change.
|
||||
|
||||
- `npm run format` then `npm run format:check`: passed.
|
||||
- `npm run check:fast`: typecheck and lint passed (3959 files, no errors). The
|
||||
touched files carry two lint warnings, both present on the base: `max-params`
|
||||
in `environmentReplay.ts` and an unused `_` in `environmentCapacity.test.ts`.
|
||||
- `npx knip`: passed (exit 0).
|
||||
- `npm run jscpd`: 3.40% duplicated lines over 3348 files, under the 3.5%
|
||||
gate. The three clones that touch changed test files pair the same blocks as
|
||||
on the base; none involves code this change adds.
|
||||
- `npm run scan:todos`: no markers.
|
||||
- `npm run docs:check`: the built documentation checks and the README
|
||||
consistency conformance test (5 tests) passed.
|
||||
- `npx vitest run --project domain --project services`: 341 files, 5859 tests
|
||||
passed, 5 pre-existing skips.
|
||||
- `npm run build:cached`: passed.
|
||||
- `npm run test:focused --` the environment serve adapters and capacity tests,
|
||||
the environment serve MCP boundary, the environment serve and export CLI
|
||||
boundaries, the environment serve shutdown process boundary, the
|
||||
analysis-view MCP and CLI boundaries, the compact input-schema contract and
|
||||
MCP boundary, the tool-schema validity boundary, the analysis-detail and
|
||||
advertised-input-example contracts and the qlog acceptance test: 14 files,
|
||||
113 tests passed, 1 pre-existing skip.
|
||||
|
||||
Real captures (outside the test suite): the built view ran through the
|
||||
application service on the same 44 recorded captures (39 HAR, 5 browser
|
||||
scenario) with all three template rules, in 7 to 50 ms each. 42 produced a
|
||||
state, and the same two browser-scenario captures are refused with
|
||||
`resource_constraint` as on the base. The base column is the brief's baseline,
|
||||
which the same script reproduced on the base build in an earlier pass; this
|
||||
pass did not rebuild the base.
|
||||
|
||||
| Measure | Base | This change |
|
||||
| ---------------------------------- | ----- | ----------- |
|
||||
| Types | 568 | 576 |
|
||||
| Entities | 2,213 | 2,246 |
|
||||
| Documents | 2 | 299 |
|
||||
| Item views | 211 | 213 |
|
||||
| List views | 497 | 504 |
|
||||
| Document views | 2 | 299 |
|
||||
| State-backed read tools (of 1,258) | 710 | 1,016 |
|
||||
| Recorded: `not-entity` | 306 | (retired) |
|
||||
| Recorded: `no-identity` | 129 | 32 |
|
||||
| Recorded: `path-varies` | (new) | 32 |
|
||||
| Recorded: `query-varies` | 5 | 69 |
|
||||
| Recorded: `body-unusable` | 32 | 32 |
|
||||
| Recorded: `list-differs` | 31 | 31 |
|
||||
| Recorded: `no-init-recording` | 26 | 26 |
|
||||
| Recorded: `query-body` | 12 | 12 |
|
||||
| Recorded: `fields-differ` | 3 | 4 |
|
||||
| Recorded: `envelope-varies` | 4 | 4 |
|
||||
|
||||
Seven list types gained a suffix-named identity (`permissionId`, `product_id`,
|
||||
`ItemId`, `user_id`, `channel_id`, `spl_id` and `eventId`). Seven more had one
|
||||
before the two-item proof and lost it, because every initial response listed a
|
||||
single item: an Airbyte organization, a Mattermost reaction and team
|
||||
membership, and a Redpanda broker and schema reference became documents; a
|
||||
NOAA station read is `query-varies` and a Redpanda consumer read
|
||||
`path-varies`. Two Ghost item tools render from `/members/0` and `/tags/0`. A
|
||||
third Ghost item tool is located at `/posts/0` but stays `fields-differ`: its
|
||||
recordings differ by an `include` query.
|
||||
|
||||
State result size, as the JSON length of each Evidence summed over the 42
|
||||
states: 2,677 KiB on the base and 3,196 KiB now (+19%), measured by the same
|
||||
script. The largest growth per capture is Metabase (133 to 236 KiB), Directus
|
||||
(21 to 93), Redpanda Console (26 to 68) and Vault (9 to 43). Documents take
|
||||
492,516 of the 3,274,243 UTF-8 bytes of the results now; the base held two.
|
||||
Agent tokens, CPU time and peak memory of the state view were not measured.
|
||||
|
||||
Tool descriptions on the 44 captures' manifests: all 44 catalogs were admitted
|
||||
within their reservation. 1,336 of 1,400 tools state what they return: 1,333
|
||||
from a `2xx` record and three labelled with another status (Miniflux and
|
||||
NocoDB 404, Shlink 501), because none of their records succeeded as JSON. The
|
||||
summaries add 261,502 bytes to a 1,185,893-byte catalog of names, descriptions
|
||||
and input schemas (median 136, 95th percentile 554, maximum 1,133 bytes per
|
||||
tool); five reached the field sample bound (one Keycloak, one Lychee and three
|
||||
Sentry tools with 46 to 105 fields). The MCP annotations add 113,182 bytes to
|
||||
`tools/list` and none to the card. Building a catalog took a median 1.6 ms and
|
||||
at most 7.6 ms (Metabase), after the source was loaded. Agent tokens, CPU time
|
||||
and peak memory of the description build were not measured.
|
||||
|
||||
After merging the environment state view's review fixes (its branch at
|
||||
6ad63bcc, which also brings main's evidence-file sources), every gate ran
|
||||
again on the merged tree: `npm run check:fast`, `npm run format:check`,
|
||||
`npx knip`, `npm run scan:todos` and `npm run docs:check` passed; `npm run
|
||||
jscpd`: 3.39% duplicated lines over 3362 files; `npx vitest run --project
|
||||
domain --project services`: 344 files, 5914 tests passed, 5 pre-existing
|
||||
skips; `npm run build:cached`, then `npm run test:focused --` the serve and
|
||||
tool-definition adapters, the serve MCP and CLI boundaries, the export CLI
|
||||
boundary, the analysis-view MCP and CLI boundaries, the compact input-schema
|
||||
budget and the analysis-detail and advertised-input-example contracts: 10
|
||||
files, 98 tests passed.
|
||||
|
||||
The real-capture comparison was repeated on the merged code, with the base
|
||||
measured on the merged state view without this change: all 44 captures now
|
||||
produce a state, and 1,011 of 1,277 read tools are state-backed against 625
|
||||
on the base (216 item, 416 list and 379 document views against 214, 409 and
|
||||
2). Recorded reasons: `no-identity` 216 to 32 and `not-entity` 310 to none;
|
||||
`query-varies` 5 to 76 and `path-varies` 36 are document reads under more
|
||||
than one query or path; `list-differs` 35, `body-unusable` 33,
|
||||
`no-init-recording` 28, `query-body` 12, `envelope-varies` 4 are unchanged,
|
||||
and `fields-differ` is 9 to 10.
|
||||
|
||||
Not covered here: serving from the state, the AgentEnv data plane and write
|
||||
effects, which are later increments.
|
||||
|
||||
## 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.
|
||||
@@ -65,6 +65,18 @@ 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
|
||||
|
||||
@@ -522,9 +522,9 @@ Pass the same parent to `inspect_analysis_view` with
|
||||
`view: {"kind":"environment-state"}` to extract the initial state the
|
||||
capture's reads observed, and a render plan per read tool that reproduces those
|
||||
reads from it. Where the environment manifest names an application's tools, the
|
||||
state holds its data: entities by type and identity, plus documents for written
|
||||
paths whose reads are not entities. Use the `template_rules` of the manifest the
|
||||
state accompanies so tool names match; the view groups records the same way.
|
||||
state holds its data: entities by type and identity, plus documents for reads
|
||||
that are not entities. Use the `template_rules` of the manifest the state
|
||||
accompanies so tool names match; the view groups records the same way.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -560,13 +560,27 @@ refused for them, as the manifest is not.
|
||||
|
||||
Each `GET` tool whose template ends in a parameter is an item tool, and its
|
||||
type's `collection_template` is the template without that parameter; any other
|
||||
`GET` tool is a list tool of its own template. An item tool proves its type's
|
||||
`GET` tool is a list tool of its own template. Empty segments are ignored, so a
|
||||
trailing slash names the same collection. An item tool proves its type's
|
||||
`id_field` when every initial response carries the requested path value in the
|
||||
same field, at the root or in one wrapper member (`id_basis: "path-parameter"`).
|
||||
Otherwise the field is the first of `id`, `uid`, `uuid`, `_id`, `ID`, `UID`,
|
||||
`Id`, `guid`, `key`, `slug` and `name` that every listed item carries as a
|
||||
distinct string or number (`id_basis: "field-name"`). A list is the response
|
||||
array, or the one array of objects in an envelope that is not itself an entity.
|
||||
same field of one entity object (`id_basis: "path-parameter"`). The object is
|
||||
the response itself or one wrapper member; failing both, it is the only element
|
||||
of a one-element array at the root (pointer `/0`) or in one member
|
||||
(`/<member>/0`), as in `{"posts":[{...}]}` or `{"data":[{...}]}`, provided one
|
||||
of the identity names below repeats the value, since a filtered list may also
|
||||
answer with one element that repeats its parent's value. Otherwise the field is
|
||||
the first of `id`, `uid`, `uuid`, `_id`, `ID`, `UID`, `Id`, `guid`, `key`,
|
||||
`slug` and `name` that every listed item carries as a distinct string or
|
||||
number; when none does, it is the first key of the first listed item, in its
|
||||
order, whose name ends in `Id` or `_id` (such as `orgId` or `user_id`), that
|
||||
every item carries as a distinct string or number, and that at least one
|
||||
initial response shows distinct across two or more items
|
||||
(`id_basis: "field-name"`). A single listed item is distinct from nothing, and
|
||||
its suffix-named fields are often foreign keys, so a list that only ever held
|
||||
one item is left to a document. Empty initial lists constrain no field. A list
|
||||
is the response array, or the one array of objects in an envelope that is not
|
||||
itself an entity, preferring an array whose items an identity name identifies,
|
||||
then an array of two or more items a suffix-named field identifies.
|
||||
|
||||
`entities` merge every initial observation of one identity within one
|
||||
collection path: an item view's latest values win over a list view's, a view
|
||||
@@ -583,9 +597,9 @@ Every entity cites its `record_ordinals`.
|
||||
|
||||
| Shape | Plan |
|
||||
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `item` | The entity object at `pointer` (the root or one member), the ordered `fields` the view shows and the `required_fields` every recording had, and `envelope_ordinal`: the recording whose response, with the entity replaced, every item response shares |
|
||||
| `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 path's latest initial value, held in `documents` |
|
||||
| `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
|
||||
@@ -594,11 +608,13 @@ regard to member order; a volatile field compares its recorded value. Each
|
||||
plan lists the `checked_record_ordinals`. Otherwise the tool is a recorded view
|
||||
with reason `envelope-varies`, `fields-differ` or `list-differs`; the other
|
||||
reasons are `query-body` (a query `POST`), `no-init-recording`,
|
||||
`body-unusable`, `not-entity`, `no-identity` and `query-varies` (a document
|
||||
read under several queries). Documents are held only for list-shaped paths
|
||||
that a `POST`, `PUT` or `PATCH` tool writes: a non-entity read of an item path
|
||||
such as `/api/files/{id}`, or of a path that is only deleted, stays recorded
|
||||
with `no-identity` or `not-entity`. A body is usable when its content type is
|
||||
`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
|
||||
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__`.
|
||||
|
||||
@@ -610,7 +626,9 @@ that the state establishes no authorization model, no way the application
|
||||
computes responses or derived values, and no effects of writes. The state
|
||||
accumulator charges the manifest's admission and grouping, every record line,
|
||||
each body as temporary bytes before it is decoded and parsed, and every value
|
||||
it keeps, within the same 96 MiB budget. The view performs no network activity
|
||||
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
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""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))
|
||||
@@ -0,0 +1,44 @@
|
||||
"""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))
|
||||
@@ -0,0 +1,18 @@
|
||||
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()
|
||||
@@ -0,0 +1,106 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
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
@@ -0,0 +1,219 @@
|
||||
# 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,3 +226,8 @@ 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,6 +7,10 @@ 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: {
|
||||
@@ -15,26 +19,68 @@ 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" },
|
||||
},
|
||||
});
|
||||
for (const key of ["runtime", "artifact", "oracle", "output"])
|
||||
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"])
|
||||
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");
|
||||
const oracle = JSON.parse(await readFile(values.oracle, "utf8"));
|
||||
const artifactSha = hash(await readFile(values.artifact));
|
||||
assert.equal(artifactSha, oracle.artifact_sha256);
|
||||
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 environment = {
|
||||
PATH: process.env.PATH,
|
||||
HOME: values.output,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
UV_THREADPOOL_SIZE: "1",
|
||||
NODE_OPTIONS: "--max-old-space-size=2048",
|
||||
NODE_DISABLE_COMPILE_CACHE: "1",
|
||||
...(values.cache === "cold"
|
||||
? { NODE_DISABLE_COMPILE_CACHE: "1" }
|
||||
: { NODE_COMPILE_CACHE: values["cache-root"] }),
|
||||
...(values.profile === "default"
|
||||
? {}
|
||||
: { REA_MCP_INPUT_SCHEMA_PROFILE: values.profile }),
|
||||
@@ -46,14 +92,29 @@ 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 = "";
|
||||
@@ -63,6 +124,15 @@ 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]));
|
||||
@@ -73,97 +143,145 @@ try {
|
||||
ajv.validateSchema(tool.inputSchema),
|
||||
`Invalid schema: ${tool.name}`,
|
||||
);
|
||||
await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog));
|
||||
{
|
||||
const encoded = JSON.stringify(catalog);
|
||||
await writeFile(join(values.output, "catalog.json"), encoded);
|
||||
report.catalog_sha256 = hash(encoded);
|
||||
}
|
||||
report.tool_names = [...definitions.keys()];
|
||||
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 },
|
||||
if (values.phase === "discovery") {
|
||||
const prompts = await client.listPrompts();
|
||||
assert.equal(prompts.nextCursor, undefined);
|
||||
await writeFile(
|
||||
join(values.output, "prompts.json"),
|
||||
JSON.stringify(prompts),
|
||||
);
|
||||
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 },
|
||||
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,
|
||||
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.notEqual(reply.isError, true);
|
||||
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(
|
||||
reply.structuredContent.normalized_result.page.rows,
|
||||
oracle.serde_rows,
|
||||
parent.normalized_result.auditable_metadata.packages,
|
||||
oracle.packages,
|
||||
);
|
||||
assert.equal(
|
||||
reply.structuredContent.normalized_result.parent_evidence_id,
|
||||
parentId,
|
||||
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;
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""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
@@ -0,0 +1,61 @@
|
||||
# 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/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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
+40
-14
@@ -1,6 +1,31 @@
|
||||
import type { NormalizedWorkflowRun } from "./workflow-run.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 StaticDiagnostic {
|
||||
readonly kind: "lint" | "jscpd";
|
||||
@@ -11,15 +36,20 @@ 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: Record<string, unknown> & NormalizedWorkflowRun;
|
||||
job: Record<string, unknown>;
|
||||
run: CiRun & NormalizedWorkflowRun;
|
||||
job: CiJob;
|
||||
diagnostics: readonly StaticDiagnostic[];
|
||||
context: StaticTaskContext;
|
||||
mainRun: Record<string, unknown> & NormalizedWorkflowRun;
|
||||
mainJob: Record<string, unknown>;
|
||||
mainRun: CiRun & NormalizedWorkflowRun;
|
||||
mainJob: CiJob;
|
||||
mainDiagnostics: readonly StaticDiagnostic[];
|
||||
mainContext: StaticTaskContext;
|
||||
executionSourcesVerified: boolean;
|
||||
@@ -47,7 +77,7 @@ export function safeSha(value: unknown): string | null;
|
||||
export function validRepoPath(value: unknown): value is string;
|
||||
export function selectStaticCheckArtifact(
|
||||
page: unknown,
|
||||
run: { id?: string | number; head_sha?: string },
|
||||
run: Pick<CiRun, "id" | "head_sha">,
|
||||
runAttempt: number,
|
||||
name?: string,
|
||||
): unknown | null;
|
||||
@@ -57,12 +87,8 @@ export function readStaticCheckArtifact(options: {
|
||||
artifactId: number | string;
|
||||
token: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<{
|
||||
context: StaticTaskContext;
|
||||
diagnostics: readonly StaticDiagnostic[];
|
||||
}>;
|
||||
}): Promise<StaticEvidence>;
|
||||
|
||||
export function parseStaticArchive(archivePath: string): Promise<{
|
||||
context: StaticTaskContext;
|
||||
diagnostics: readonly StaticDiagnostic[];
|
||||
}>;
|
||||
export function parseStaticArchive(
|
||||
archivePath: string,
|
||||
): Promise<StaticEvidence>;
|
||||
@@ -1,38 +1,5 @@
|
||||
import type { NormalizedWorkflowRun } from "./workflow-run.mjs";
|
||||
import type { StaticDiagnostic } from "./artifacts.mjs";
|
||||
export type { StaticDiagnostic } from "./artifacts.mjs";
|
||||
import type { CiRun, CiJob, StaticEvidence } 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;
|
||||
@@ -99,25 +66,6 @@ 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,8 +26,6 @@ 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,6 +142,9 @@ 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);
|
||||
@@ -152,6 +155,9 @@ 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 },
|
||||
@@ -189,7 +195,9 @@ 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 } : { cancelled: true }),
|
||||
...(mode === "deadline"
|
||||
? { timed_out: true, command_timeout_ms: setupCommandTimeoutMs }
|
||||
: { cancelled: true }),
|
||||
socket_closed: true,
|
||||
target_reused: true,
|
||||
};
|
||||
|
||||
@@ -39,6 +39,7 @@ 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",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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`,
|
||||
);
|
||||
@@ -0,0 +1,89 @@
|
||||
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,8 +105,9 @@ class WindowsJadxWorkspaceRoot implements JadxWorkspaceRoot {
|
||||
},
|
||||
});
|
||||
let diagnostics = "";
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
diagnostics = (diagnostics + chunk.toString()).slice(-8192);
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
diagnostics = (diagnostics + chunk).slice(-8192);
|
||||
});
|
||||
const exit = new Promise<GuardianExit>((resolve) => {
|
||||
child.once("error", (error: Error) =>
|
||||
@@ -116,8 +117,9 @@ class WindowsJadxWorkspaceRoot implements JadxWorkspaceRoot {
|
||||
});
|
||||
const ready = new Promise<string>((resolve, reject) => {
|
||||
let frame = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
frame += chunk.toString();
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
frame += chunk;
|
||||
if (frame.length > 4096) {
|
||||
reject(
|
||||
new Error("Android workspace guardian readiness exceeded 4 KiB"),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -146,6 +147,7 @@ export const createAnalysisExecutionEvidence = (
|
||||
input: unknown,
|
||||
execution: AnalysisExecution,
|
||||
confidence: "observed" | "derived",
|
||||
options?: EvidenceCreationOptions,
|
||||
): Evidence =>
|
||||
createEvidence(
|
||||
execution.subject === undefined
|
||||
@@ -167,6 +169,7 @@ export const createAnalysisExecutionEvidence = (
|
||||
locations: execution.locations,
|
||||
confidence,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
/** Provider-neutral application capability for one analysis operation. */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -59,7 +60,11 @@ import {
|
||||
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
|
||||
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
|
||||
import { resolveXrefsAddress } from "./XrefsAddressResolution.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { deliverAnalysisEvidence } from "./EvidenceBundleCommands.js";
|
||||
import type { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import {
|
||||
directAnalysisCleanupFailure,
|
||||
@@ -146,24 +151,81 @@ 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]: readonly [
|
||||
path: string,
|
||||
tool: NativeToolName | ArtifactAnalysisOperation | ManagedToolName,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
logger?: Logger,
|
||||
signal?: AbortSignal,
|
||||
]
|
||||
...[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;
|
||||
},
|
||||
]
|
||||
): Promise<JsonValue> => {
|
||||
if (isManagedToolName(tool))
|
||||
return runManagedProviderAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
tool,
|
||||
arguments_,
|
||||
signal,
|
||||
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",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return withProcessCancellation(signal, (operationSignal) =>
|
||||
runAnalysis(dependencies, path, tool, arguments_, {
|
||||
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_, {
|
||||
logger,
|
||||
snapshotPath: undefined,
|
||||
signal: operationSignal,
|
||||
@@ -172,8 +234,8 @@ export const runProviderAnalysis = async (
|
||||
: NATIVE_INSPECTION_OPERATIONS.has(tool)
|
||||
? { createSession: dependencies.createNativeBinarySession }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/** Execute managed metadata inspection in an isolated managed-only session. */
|
||||
@@ -199,13 +261,14 @@ export const runManagedProviderExecution = async (
|
||||
);
|
||||
});
|
||||
|
||||
/** Preserve typed managed Evidence before selecting its CLI delivery. */
|
||||
const runManagedProviderAnalysis = async (
|
||||
dependencies: DirectAnalysisDependencies,
|
||||
dependencies: Pick<DirectAnalysisDependencies, "createManagedBinarySession">,
|
||||
path: string,
|
||||
tool: ManagedToolName,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<JsonValue> => {
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const execution = await runManagedProviderExecution(
|
||||
dependencies,
|
||||
path,
|
||||
@@ -213,9 +276,13 @@ const runManagedProviderAnalysis = async (
|
||||
signal,
|
||||
arguments_,
|
||||
);
|
||||
if (!execution.ok) return cliError(execution.error);
|
||||
if (!execution.ok) return execution;
|
||||
const value = execution.value;
|
||||
return createAnalysisExecutionEvidence(tool, arguments_, value, "observed");
|
||||
return ok(
|
||||
createAnalysisExecutionEvidence(tool, arguments_, value, "observed", {
|
||||
immutable: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const runAnalysis = async (
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
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 type { EvidenceBundle } from "../domain/evidenceBundle.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 { JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
@@ -14,12 +28,18 @@ 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);
|
||||
const loaded = await readEvidenceBundle(
|
||||
path,
|
||||
undefined,
|
||||
undefined,
|
||||
"import_evidence_bundle",
|
||||
);
|
||||
if (!loaded.ok) return loaded;
|
||||
return ok({
|
||||
imported: loaded.value.records.length,
|
||||
@@ -35,16 +55,170 @@ export const exportEvidenceBundleCommand = async (
|
||||
overwrite: boolean,
|
||||
): Promise<Result<JsonValue, AnalysisError>> => {
|
||||
// The sealed snapshot lets the writer reuse this authentication.
|
||||
const loaded = await readImmutableEvidenceBundle(sourcePath);
|
||||
const loaded = await readImmutableEvidenceBundle(
|
||||
sourcePath,
|
||||
undefined,
|
||||
undefined,
|
||||
"export_evidence_bundle",
|
||||
);
|
||||
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(
|
||||
loaded.value,
|
||||
bundle,
|
||||
outputPath,
|
||||
overwrite,
|
||||
resources,
|
||||
signal,
|
||||
operation,
|
||||
);
|
||||
if (written.ok) return projectWrite(loaded.value, written);
|
||||
if (written.ok) return written;
|
||||
|
||||
// Confirmed release resolves the bound error to its primary outcome; a
|
||||
// failed retry leaves that error incomplete with its exact retained owner.
|
||||
@@ -54,10 +228,7 @@ export const exportEvidenceBundleCommand = async (
|
||||
written.error instanceof EvidenceFileError &&
|
||||
written.error.published
|
||||
) {
|
||||
return projectWrite(loaded.value, {
|
||||
ok: true,
|
||||
value: written.error.published,
|
||||
});
|
||||
return ok(written.error.published);
|
||||
}
|
||||
return written;
|
||||
};
|
||||
@@ -70,8 +241,18 @@ 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),
|
||||
readImmutableEvidenceBundle(input.rightPath),
|
||||
readImmutableEvidenceBundle(
|
||||
input.leftPath,
|
||||
undefined,
|
||||
undefined,
|
||||
"compare_bundles",
|
||||
),
|
||||
readImmutableEvidenceBundle(
|
||||
input.rightPath,
|
||||
undefined,
|
||||
undefined,
|
||||
"compare_bundles",
|
||||
),
|
||||
]);
|
||||
if (!left.ok) return left;
|
||||
if (!right.ok) return right;
|
||||
@@ -88,7 +269,7 @@ export const compareEvidenceBundlesCommand = async (input: {
|
||||
|
||||
const projectWrite = (
|
||||
bundle: EvidenceBundle,
|
||||
written: Awaited<ReturnType<typeof writeEvidenceBundle>>,
|
||||
written: Result<PublishedEvidenceFile, AnalysisError>,
|
||||
): Result<JsonValue, AnalysisError> =>
|
||||
written.ok
|
||||
? ok({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
|
||||
import {
|
||||
@@ -39,45 +40,47 @@ export const readEvidenceBundle = (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "import_evidence_bundle",
|
||||
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
|
||||
readBundle(path, parseEvidenceBundle, signal, resources);
|
||||
readBundle(path, parseEvidenceBundle, signal, resources, operation);
|
||||
|
||||
/** 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);
|
||||
readBundle(path, parseImmutableEvidenceBundle, signal, resources, operation);
|
||||
|
||||
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("import_evidence_bundle", {
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
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;
|
||||
}
|
||||
const loaded = await readJsonFile(path, signal, resources, operation);
|
||||
if (!loaded.ok) return loaded;
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError("import_evidence_bundle", {
|
||||
new AnalysisCancelledError(operation, {
|
||||
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);
|
||||
@@ -121,6 +124,7 @@ export const writeEvidenceBundle = async (
|
||||
overwrite: boolean,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
operation = "export_evidence_bundle",
|
||||
): Promise<
|
||||
Result<
|
||||
{ readonly path: string; readonly bytes: number },
|
||||
@@ -128,7 +132,11 @@ export const writeEvidenceBundle = async (
|
||||
>
|
||||
> => {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("export_evidence_bundle"));
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
let checked: EvidenceBundle;
|
||||
try {
|
||||
checked = parseEvidenceBundle(bundle);
|
||||
@@ -144,8 +152,6 @@ export const writeEvidenceBundle = async (
|
||||
path,
|
||||
overwrite,
|
||||
resources,
|
||||
signal === undefined
|
||||
? undefined
|
||||
: { signal, operation: "export_evidence_bundle" },
|
||||
signal === undefined ? undefined : { signal, operation },
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,9 @@ describe("exact Evidence input resolution", () => {
|
||||
actual: null,
|
||||
},
|
||||
});
|
||||
expect(projected.message).toContain("not retained in this session");
|
||||
expect(projected.message).toContain(
|
||||
"not available from the selected source",
|
||||
);
|
||||
expect(projected.remediation.action).toContain(
|
||||
"complete inline Evidence",
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EvidenceInput } from "../contracts/evidenceInputContracts.js";
|
||||
import type { EvidenceInput } from "../domain/evidenceReference.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 retained in this session",
|
||||
"Evidence available to the selected source",
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
|
||||
@@ -23,13 +24,20 @@ 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) => Output;
|
||||
readonly project: (
|
||||
input: Input,
|
||||
) => MobileApplicationProjectionWithSources<Output>;
|
||||
readonly operation: string;
|
||||
readonly predicateType: string;
|
||||
readonly provider: EvidenceProvider;
|
||||
@@ -43,17 +51,18 @@ export const projectInventoryEvidence = <
|
||||
Output extends InventoryProjectionResult,
|
||||
>(
|
||||
options: InventoryProjectionOptions<Input, Output>,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
|
||||
const parsed = options.schema.safeParse(options.rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
requestInputError(options.operation, parsed.error, options.rawInput),
|
||||
);
|
||||
try {
|
||||
const result = options.project(parsed.data);
|
||||
const first = parsed.data.inventory_evidence[0];
|
||||
return ok(
|
||||
createEvidence(
|
||||
const projected = options.project(parsed.data);
|
||||
const result = projected.projection;
|
||||
const first = projected.sources[0];
|
||||
return ok({
|
||||
evidence: createEvidence(
|
||||
first?.subject === null || first?.subject === undefined
|
||||
? undefined
|
||||
: {
|
||||
@@ -80,7 +89,8 @@ export const projectInventoryEvidence = <
|
||||
evidenceLinks: result.source_evidence_ids,
|
||||
},
|
||||
),
|
||||
);
|
||||
sources: projected.sources,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
cause instanceof TypeError || cause instanceof z.ZodError
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
createAnalysisProfile,
|
||||
type AnalysisProfileCommitment,
|
||||
} from "../domain/analysisProfile.js";
|
||||
|
||||
/** Provider identity for deterministic composed analysis workflows. */
|
||||
export const REA_WORKFLOW_PROVIDER = {
|
||||
id: "rea-workflow",
|
||||
@@ -112,20 +111,6 @@ 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,6 +4,50 @@ 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);
|
||||
@@ -42,3 +86,28 @@ 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,32 +1,66 @@
|
||||
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;
|
||||
|
||||
/** 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];
|
||||
/** 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]()];
|
||||
while (stack.length > 0) {
|
||||
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);
|
||||
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());
|
||||
} else if (typeof item === "object" && item !== null) {
|
||||
bytes += 128;
|
||||
for (const [key, entry] of Object.entries(item)) {
|
||||
bytes += 64 + key.length * 2;
|
||||
stack.push(entry);
|
||||
}
|
||||
} else bytes += 16;
|
||||
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;
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
return { valueBytes, serializedUtf16Bytes: serializedCharacters * 2 };
|
||||
};
|
||||
|
||||
/** 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,
|
||||
@@ -36,11 +70,14 @@ export const requireAuthenticationHeadroom = (
|
||||
readonly description: string;
|
||||
readonly availableBytes?: number;
|
||||
},
|
||||
): Result<void, AnalysisError> => {
|
||||
): Result<void, AnalysisResourceConstraintError> => {
|
||||
const availableBytes =
|
||||
options.availableBytes ?? getHeapStatistics().total_available_size;
|
||||
const valueBytes = estimateJsonValueBytes(value);
|
||||
const requiredBytes = valueBytes * AUTHENTICATION_COPIES;
|
||||
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;
|
||||
if (requiredBytes <= availableBytes) return ok(undefined);
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
@@ -50,6 +87,7 @@ export const requireAuthenticationHeadroom = (
|
||||
{
|
||||
input_path: options.path,
|
||||
projected_value_bytes: valueBytes,
|
||||
serialized_json_utf16_bytes: serializedUtf16Bytes,
|
||||
authentication_bytes: requiredBytes,
|
||||
available_heap_bytes: availableBytes,
|
||||
},
|
||||
|
||||
@@ -52,13 +52,14 @@ 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);
|
||||
const read = await readJsonFileWithSource(path, signal, resources, operation);
|
||||
return read.ok ? ok(read.value.data) : read;
|
||||
};
|
||||
|
||||
@@ -67,6 +68,7 @@ export const readJsonFileWithSource = (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
resources?: ArtifactResourceScope,
|
||||
operation = "read_evidence_file",
|
||||
): Promise<
|
||||
Result<
|
||||
{ readonly data: unknown; readonly path: string },
|
||||
@@ -78,13 +80,14 @@ export const readJsonFileWithSource = (
|
||||
async (selected) => {
|
||||
const read = await readJsonInputFile(
|
||||
selected,
|
||||
"read_evidence_file",
|
||||
operation,
|
||||
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. */
|
||||
@@ -124,6 +127,7 @@ const readJsonFileInput = async <Value>(
|
||||
>
|
||||
>,
|
||||
signal?: AbortSignal,
|
||||
operation = "read_evidence_file",
|
||||
): Promise<
|
||||
Result<
|
||||
Value,
|
||||
@@ -153,7 +157,7 @@ const readJsonFileInput = async <Value>(
|
||||
if (cause instanceof AnalysisResourceConstraintError) return err(cause);
|
||||
if (cause instanceof JsonInputCancellationFailure)
|
||||
return err(
|
||||
new AnalysisCancelledError("read_evidence_file", {
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: cause.reason,
|
||||
}),
|
||||
);
|
||||
@@ -166,7 +170,7 @@ const readJsonFileInput = async <Value>(
|
||||
);
|
||||
if (isJsonReadCancellation(cause, signal))
|
||||
return err(
|
||||
new AnalysisCancelledError("read_evidence_file", {
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: signal?.reason,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import {
|
||||
createHistoricalSourceGraph,
|
||||
historicalSourceInventoryHasPartialFacts,
|
||||
historicalSourceLanguages,
|
||||
historicalSourceManifests,
|
||||
historicalSourceParseFailureKey,
|
||||
historicalSourceRelationshipKey,
|
||||
type HistoricalSourceGraph,
|
||||
type HistoricalSourceGraphInput,
|
||||
} from "../domain/referenceSourceGraph.js";
|
||||
@@ -45,18 +47,13 @@ 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 = relationshipKey(relationship);
|
||||
const key = historicalSourceRelationshipKey(relationship);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(relationship);
|
||||
@@ -93,37 +90,6 @@ 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"] =>
|
||||
@@ -261,16 +227,21 @@ export const importReferenceSource = async (
|
||||
schema: "HistoricalSourceGraph",
|
||||
authority: "historical-reference",
|
||||
root_alias: "$REFERENCE_ROOT",
|
||||
inventory_state: deriveInventoryState({
|
||||
inventory_state: historicalSourceInventoryHasPartialFacts({
|
||||
entries: sortedEntries,
|
||||
relationships: uniqueRelationships,
|
||||
parse_failures: uniqueFailures,
|
||||
exclusions: sortedExclusions,
|
||||
limitations: sortedLimitations,
|
||||
}),
|
||||
})
|
||||
? "partial"
|
||||
: "complete",
|
||||
entries: sortedEntries,
|
||||
relationships: uniqueRelationships.sort((left, right) =>
|
||||
compareUnicodeCodePoints(relationshipKey(left), relationshipKey(right)),
|
||||
compareUnicodeCodePoints(
|
||||
historicalSourceRelationshipKey(left),
|
||||
historicalSourceRelationshipKey(right),
|
||||
),
|
||||
),
|
||||
parse_failures: uniqueFailures,
|
||||
exclusions: sortedExclusions,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
RegularFileCleanupFailure,
|
||||
retryRegularFileCleanup,
|
||||
} from "./RegularFileRead.js";
|
||||
import { referenceSourceResourceFailure } from "../reference/ReferenceSourceReaderErrors.js";
|
||||
import type { ReferenceSourceReaderError } from "../reference/ReferenceSourceReaderTypes.js";
|
||||
|
||||
export type ReferenceSourceVcsInfo =
|
||||
@@ -59,13 +60,7 @@ export const readReferenceSourceVcs = async (
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof ArtifactReaderFailure)) throw cause;
|
||||
return err({
|
||||
tag: "reference-source-reader",
|
||||
code: cause.reason === "cancelled" ? "cancelled" : "io",
|
||||
message: cause.message,
|
||||
...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }),
|
||||
cause,
|
||||
});
|
||||
return err(referenceSourceResourceFailure(cause));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -405,36 +405,20 @@ export const clientConfigurationAligned = async (
|
||||
}
|
||||
const original = await readRegularFileText(client.configPath);
|
||||
const parsed = parseClientConfiguration(original, client.format);
|
||||
const policy = await readClientPolicyBlock(
|
||||
const desired = await clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
await clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
const policy = await readClientPolicyBlock(client, parsed, desired);
|
||||
if (!policy.ok) {
|
||||
throwCleanupFailure(policy.error);
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
policy.value === undefined &&
|
||||
registrationCurrent(
|
||||
parsed,
|
||||
await clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
)
|
||||
);
|
||||
return policy.value === undefined && registrationCurrent(parsed, desired);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
// Unreadable configuration is treated as not aligned so setup repairs it.
|
||||
@@ -544,18 +528,15 @@ const inspectClientDocument = async (
|
||||
}
|
||||
try {
|
||||
const parsed = parseClientConfiguration(original, client.format);
|
||||
const policy = await readClientPolicyBlock(
|
||||
const desired = await clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
await clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
const policy = await readClientPolicyBlock(client, parsed, desired);
|
||||
if (!policy.ok) {
|
||||
throwCleanupFailure(policy.error);
|
||||
return {
|
||||
@@ -565,14 +546,6 @@ 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,7 +53,6 @@ import {
|
||||
redirectedWriteBlocker,
|
||||
type MsixPackageView,
|
||||
} from "./MsixPackageStorage.js";
|
||||
export { isUninstallFailure } from "./UninstallFailure.js";
|
||||
|
||||
interface ManagedPathStats {
|
||||
readonly uid?: number;
|
||||
|
||||
@@ -3,7 +3,6 @@ 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,6 +1,5 @@
|
||||
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 {
|
||||
@@ -94,15 +93,6 @@ 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],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
@@ -57,16 +56,6 @@ 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
|
||||
@@ -91,19 +80,6 @@ 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(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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 {
|
||||
@@ -209,15 +208,6 @@ 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,5 +1,6 @@
|
||||
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";
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
type AnalysisViewRequest,
|
||||
type InspectAnalysisViewInput,
|
||||
} from "../../domain/analysisView/analysisView.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
immutableJsonSnapshotMember,
|
||||
isImmutableJsonSnapshot,
|
||||
@@ -332,39 +332,44 @@ 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),
|
||||
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],
|
||||
},
|
||||
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],
|
||||
});
|
||||
{ immutable: true },
|
||||
);
|
||||
};
|
||||
@@ -32,16 +32,13 @@ export const readEvidenceFileSource = async (
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
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;
|
||||
}
|
||||
const loaded = await readJsonFile(
|
||||
path,
|
||||
signal,
|
||||
resources,
|
||||
INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
);
|
||||
if (!loaded.ok) return loaded;
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
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 } from "../InventoryProjectionEvidence.js";
|
||||
import {
|
||||
projectInventoryEvidence,
|
||||
type InventoryProjectionEvidenceResult,
|
||||
} 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<Evidence, AnalysisError> => {
|
||||
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
|
||||
return projectInventoryEvidence({
|
||||
rawInput,
|
||||
schema: androidApplicationProjectionInputSchema,
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
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 } from "../InventoryProjectionEvidence.js";
|
||||
import {
|
||||
projectInventoryEvidence,
|
||||
type InventoryProjectionEvidenceResult,
|
||||
} 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<Evidence, AnalysisError> => {
|
||||
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
|
||||
return projectInventoryEvidence({
|
||||
rawInput,
|
||||
schema: appleApplicationProjectionInputSchema,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { DesktopApplicationPort } from "./DesktopApplicationPort.js";
|
||||
import { ARTIFACT_GRAPH_PROVIDER } from "../InvestigationProviders.js";
|
||||
@@ -41,16 +40,6 @@ 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,10 +25,18 @@ it.each([
|
||||
throw new Error("must not run");
|
||||
},
|
||||
});
|
||||
expect(await service.inspect(input)).toMatchObject({
|
||||
const result = await service.inspect(input);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisInputError" },
|
||||
});
|
||||
if (input.path === "relative.elf")
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
issues: [{ path: ["path"], reason: "invalid_format" }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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";
|
||||
@@ -66,16 +64,6 @@ 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,10 +1,8 @@
|
||||
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";
|
||||
@@ -47,16 +45,6 @@ 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,10 +70,18 @@ it.each([
|
||||
throw new Error("must not run");
|
||||
},
|
||||
});
|
||||
expect(await service.inspect(input)).toMatchObject({
|
||||
const result = await service.inspect(input);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisInputError" },
|
||||
});
|
||||
if (input.path === "relative.elf")
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
issues: [{ path: ["path"], reason: "invalid_format" }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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";
|
||||
@@ -67,16 +65,6 @@ 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(
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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,26 +3,25 @@ import {
|
||||
packetFieldsCatalogSchema,
|
||||
type InspectPacketFieldsInput,
|
||||
} from "../../domain/captures/packetFields.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
ExecutionOptions,
|
||||
import {
|
||||
createAnalysisExecutionEvidence,
|
||||
type AnalysisExecution,
|
||||
type ExecutionOptions,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import {
|
||||
inspectPacketCaptureInputSchema,
|
||||
packetCaptureSchema,
|
||||
type InspectPacketCaptureInput,
|
||||
} from "../../domain/captures/packetCapture.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { err, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
export interface PacketCapturePort {
|
||||
/** Release per-call resources before returning, or retain failed cleanup for close/retry. */
|
||||
@@ -59,16 +58,6 @@ 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;
|
||||
@@ -86,7 +75,15 @@ export class PacketCaptureService {
|
||||
"Capture analyzer returned malformed data or changed the selected artifact identity/options.",
|
||||
),
|
||||
);
|
||||
return this.#evidence(operation, input.data, execution, options);
|
||||
return completeProviderEvidence(
|
||||
createAnalysisExecutionEvidence(
|
||||
operation,
|
||||
input.data,
|
||||
execution,
|
||||
"observed",
|
||||
),
|
||||
options?.signal,
|
||||
);
|
||||
}
|
||||
async inspectFields(
|
||||
raw: unknown,
|
||||
@@ -120,33 +117,14 @@ export class PacketCaptureService {
|
||||
"Field discovery returned malformed data or changed the selected installation/options.",
|
||||
),
|
||||
);
|
||||
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,
|
||||
{
|
||||
return completeProviderEvidence(
|
||||
createAnalysisExecutionEvidence(
|
||||
operation,
|
||||
parameters,
|
||||
result: execution.result,
|
||||
confidence: "observed",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
},
|
||||
input.data,
|
||||
execution,
|
||||
"observed",
|
||||
),
|
||||
options?.signal,
|
||||
);
|
||||
return options?.signal?.aborted
|
||||
? err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: options.signal.reason,
|
||||
partialObservation: evidence,
|
||||
}),
|
||||
)
|
||||
: ok(evidence);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
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 {
|
||||
@@ -45,16 +42,6 @@ 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);
|
||||
|
||||
@@ -387,10 +387,11 @@ 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: 350 }, (_, index) => {
|
||||
const tools = Array.from({ length: 300 }, (_, index) => {
|
||||
const tool = reads[index % reads.length]!;
|
||||
return index < reads.length
|
||||
? tool
|
||||
@@ -416,7 +417,7 @@ describe("planEnvironmentBundle without a state", () => {
|
||||
served: false,
|
||||
code: "resource_constraint",
|
||||
});
|
||||
expect(planned.value.toolCount).toBe(350);
|
||||
expect(planned.value.toolCount).toBe(300);
|
||||
expect(planned.value.check).toEqual({
|
||||
tool: "get_annotations",
|
||||
recordOrdinal: 25,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { isJsonLike } from "../../domain/analysisView/EnvironmentManifestAccumulator.js";
|
||||
import { mediaTypeEssence } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import { parseExactJson } from "../../domain/exactJsonNumber.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
iterateUrlQueryParameters,
|
||||
@@ -13,13 +10,12 @@ import { environmentToolArguments } from "./environmentTools.js";
|
||||
import {
|
||||
REPLAY_BODY_LIMIT_BYTES,
|
||||
ENVIRONMENT_SERVE_OPERATION,
|
||||
replayedBody,
|
||||
type EnvironmentManifestTool,
|
||||
type EnvironmentSource,
|
||||
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>>;
|
||||
|
||||
@@ -183,11 +179,6 @@ const utf8Prefix = (text: string, maxBytes: number): string => {
|
||||
return text.slice(0, read);
|
||||
};
|
||||
|
||||
/** Bytes a padded base64 string decodes to. */
|
||||
const decodedBase64Bytes = (encoded: string): number =>
|
||||
Math.floor((encoded.length * 3) / 4) -
|
||||
(encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0);
|
||||
|
||||
/** Base64 characters, in whole groups, whose bytes fit the replay limit. */
|
||||
const BASE64_LIMIT_CHARS = Math.floor(REPLAY_BODY_LIMIT_BYTES / 3) * 4;
|
||||
|
||||
@@ -196,29 +187,26 @@ interface ReplayBody {
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
/** The limit applies to the recorded bytes, not to their base64 spelling. */
|
||||
const replayBase64Body = (encoded: string, cut: boolean): ReplayBody =>
|
||||
!cut && decodedBase64Bytes(encoded) <= REPLAY_BODY_LIMIT_BYTES
|
||||
? { body: encoded, truncated: false }
|
||||
: { body: encoded.slice(0, BASE64_LIMIT_CHARS), truncated: true };
|
||||
|
||||
/** Parse a retained body as JSON when its content type is JSON-like; otherwise return the text. */
|
||||
/** The reply body and whether it was cut, as the source classifies the retained body. */
|
||||
const replayBody = (exchange: RecordedExchange): ReplayBody => {
|
||||
const text = exchange.bodyText;
|
||||
if (text === null) return { body: null, truncated: false };
|
||||
if (exchange.bodyEncoding === "base64")
|
||||
return replayBase64Body(text, exchange.bodyCut === true);
|
||||
const prefix = utf8Prefix(text, REPLAY_BODY_LIMIT_BYTES);
|
||||
if (prefix.length < text.length || exchange.bodyCut === true)
|
||||
return { body: prefix, truncated: true };
|
||||
if (
|
||||
exchange.contentType !== null &&
|
||||
isJsonLike(mediaTypeEssence(exchange.contentType))
|
||||
) {
|
||||
const value = parseExactJson(text);
|
||||
if (value !== undefined) return { body: value, truncated: false };
|
||||
const body = replayedBody(exchange);
|
||||
switch (body.kind) {
|
||||
case "none":
|
||||
return { body: null, truncated: false };
|
||||
case "base64":
|
||||
return body.whole
|
||||
? { body: body.encoded, truncated: false }
|
||||
: { body: body.encoded.slice(0, BASE64_LIMIT_CHARS), truncated: true };
|
||||
case "cut":
|
||||
return {
|
||||
body: utf8Prefix(body.text, REPLAY_BODY_LIMIT_BYTES),
|
||||
truncated: true,
|
||||
};
|
||||
case "json":
|
||||
return { body: body.value, truncated: false };
|
||||
case "text":
|
||||
return { body: body.text, truncated: false };
|
||||
}
|
||||
return { body: text, truncated: false };
|
||||
};
|
||||
|
||||
/** Recorded paths and queries are cut to this many UTF-8 bytes in replies. */
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
readRecordedResponseBody,
|
||||
type RecordedResponseBody,
|
||||
} from "../../domain/analysisView/recordedResponseBody.js";
|
||||
import { readRecordedRequestLine } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import { isJsonLike } from "../../domain/analysisView/EnvironmentManifestAccumulator.js";
|
||||
import {
|
||||
mediaTypeEssence,
|
||||
readRecordedRequestLine,
|
||||
} from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import { browserScenarioCaptureSchema } from "../../domain/browserScenarioCapture.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
@@ -17,8 +21,10 @@ import {
|
||||
type AnalysisInputIssue,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { immutableEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { parseExactJson } from "../../domain/exactJsonNumber.js";
|
||||
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { immutableJsonSnapshotMember } from "../../domain/immutableJson.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
safeParseOwnedWebNetworkCapture,
|
||||
@@ -47,6 +53,52 @@ export interface RecordedExchange extends RecordedResponseBody {
|
||||
/** Replayed bodies larger than this are cut at a UTF-8 boundary and flagged. */
|
||||
export const REPLAY_BODY_LIMIT_BYTES = 256 * 1024;
|
||||
|
||||
/** How replay answers with an exchange's body; replay and tool descriptions both read it from here. */
|
||||
export type ReplayedBody =
|
||||
| { readonly kind: "none" }
|
||||
/** Non-UTF-8 bytes as base64; `whole` when every recorded byte fits the replay limit. */
|
||||
| {
|
||||
readonly kind: "base64";
|
||||
readonly encoded: string;
|
||||
readonly whole: boolean;
|
||||
}
|
||||
/** UTF-8 text over the replay limit or cut when retained; replay answers with a prefix. */
|
||||
| { readonly kind: "cut"; readonly text: string }
|
||||
| { readonly kind: "json"; readonly value: JsonValue }
|
||||
| { readonly kind: "text"; readonly text: string };
|
||||
|
||||
/** Bytes a padded base64 string decodes to. */
|
||||
const decodedBase64Bytes = (encoded: string): number =>
|
||||
Math.floor((encoded.length * 3) / 4) -
|
||||
(encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0);
|
||||
|
||||
/** Classify a retained body once: whole JSON-like UTF-8 text within the limit that parses exactly is JSON. */
|
||||
export const replayedBody = (exchange: RecordedExchange): ReplayedBody => {
|
||||
const text = exchange.bodyText;
|
||||
if (text === null) return { kind: "none" };
|
||||
if (exchange.bodyEncoding === "base64")
|
||||
return {
|
||||
kind: "base64",
|
||||
encoded: text,
|
||||
whole:
|
||||
exchange.bodyCut !== true &&
|
||||
decodedBase64Bytes(text) <= REPLAY_BODY_LIMIT_BYTES,
|
||||
};
|
||||
if (
|
||||
exchange.bodyCut === true ||
|
||||
// UTF-8 never takes fewer bytes than UTF-16 units, so the length check avoids encoding huge text.
|
||||
text.length > REPLAY_BODY_LIMIT_BYTES ||
|
||||
Buffer.byteLength(text, "utf8") > REPLAY_BODY_LIMIT_BYTES
|
||||
)
|
||||
return { kind: "cut", text };
|
||||
const value =
|
||||
exchange.contentType !== null &&
|
||||
isJsonLike(mediaTypeEssence(exchange.contentType))
|
||||
? parseExactJson(text)
|
||||
: undefined;
|
||||
return value === undefined ? { kind: "text", text } : { kind: "json", value };
|
||||
};
|
||||
|
||||
/** A HAR inspected by inspect_web_network_capture, or a session recorded by capture_browser_scenario. */
|
||||
export type EnvironmentCaptureKind = "web-network-capture" | "browser-scenario";
|
||||
|
||||
|
||||
@@ -485,12 +485,12 @@ describe("rea-state/v1 validation", () => {
|
||||
"a document for a tool that is not a document view",
|
||||
(document: Editable) => {
|
||||
document.rea_state.documents.push({
|
||||
tool: "get_org",
|
||||
path: "/api/org",
|
||||
tool: "get_folders",
|
||||
path: "/api/folders",
|
||||
value: {},
|
||||
});
|
||||
},
|
||||
"rea_state/documents/0/tool",
|
||||
`rea_state/documents/${String(state.documents.length)}/tool`,
|
||||
],
|
||||
[
|
||||
"a member the format does not define",
|
||||
|
||||
@@ -61,17 +61,19 @@ export const servedCatalog = (
|
||||
operation: string = ENVIRONMENT_SERVE_OPERATION,
|
||||
): Result<ServedCatalog, AnalysisResourceConstraintError> => {
|
||||
const dataPlane: EnvironmentDataPlane = fromState ? "state" : "none";
|
||||
const tools = environmentToolDefinitions(
|
||||
fromState
|
||||
? {
|
||||
const catalogSource: EnvironmentSource = fromState
|
||||
? {
|
||||
...source,
|
||||
manifest: {
|
||||
...source.manifest,
|
||||
tools: source.manifest.tools.filter(servedFromState),
|
||||
}
|
||||
: source.manifest,
|
||||
source.evidenceId,
|
||||
name,
|
||||
{ operation, dataPlane },
|
||||
);
|
||||
},
|
||||
}
|
||||
: source;
|
||||
const tools = environmentToolDefinitions(catalogSource, name, {
|
||||
operation,
|
||||
dataPlane,
|
||||
});
|
||||
return tools.ok ? ok({ tools: tools.value, dataPlane }) : tools;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
environmentStateServeInputs,
|
||||
} from "../../../tests/fixtures/environmentServe.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { REPLAY_BODY_LIMIT_BYTES } from "./environmentReplay.js";
|
||||
import type { EnvironmentSource } from "./environmentSource.js";
|
||||
import {
|
||||
REPLAY_BODY_LIMIT_BYTES,
|
||||
type EnvironmentSource,
|
||||
} from "./environmentSource.js";
|
||||
import {
|
||||
answerFromState,
|
||||
type StateServing,
|
||||
@@ -311,6 +313,17 @@ 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" }],
|
||||
|
||||
@@ -24,7 +24,6 @@ import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
decodedPathSegment,
|
||||
pathValueMatches,
|
||||
REPLAY_BODY_LIMIT_BYTES,
|
||||
replayAnswer,
|
||||
replayEnvironmentTool,
|
||||
selectReplayExchange,
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
} from "./environmentReplay.js";
|
||||
import {
|
||||
ENVIRONMENT_SERVE_OPERATION,
|
||||
REPLAY_BODY_LIMIT_BYTES,
|
||||
type EnvironmentManifestTool,
|
||||
type EnvironmentSource,
|
||||
type RecordedExchange,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { environmentServeSource } from "../../../tests/fixtures/environmentServe.js";
|
||||
import type { EnvironmentManifestTool } from "./environmentSource.js";
|
||||
import type {
|
||||
EnvironmentManifestTool,
|
||||
RecordedExchange,
|
||||
} from "./environmentSource.js";
|
||||
import {
|
||||
ENVIRONMENT_CATALOG_LIMIT_BYTES,
|
||||
environmentCard,
|
||||
environmentToolArguments,
|
||||
environmentToolDefinitions as prepareDefinitions,
|
||||
@@ -17,18 +21,16 @@ const environmentToolDefinitions = (
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
};
|
||||
const withTools = (tools: readonly EnvironmentManifestTool[]) => ({
|
||||
...source,
|
||||
manifest: { ...source.manifest, tools: [...tools] },
|
||||
});
|
||||
const environmentToolInputSchema = (tool: EnvironmentManifestTool) => {
|
||||
const [definition] = environmentToolDefinitions(
|
||||
{ ...source.manifest, tools: [tool] },
|
||||
source.evidenceId,
|
||||
);
|
||||
const [definition] = environmentToolDefinitions(withTools([tool]));
|
||||
if (definition === undefined) throw new Error("Missing tool definition");
|
||||
return definition.inputSchema;
|
||||
};
|
||||
const definitions = environmentToolDefinitions(
|
||||
source.manifest,
|
||||
source.evidenceId,
|
||||
);
|
||||
const definitions = environmentToolDefinitions(source);
|
||||
|
||||
const tool = (name: string): EnvironmentManifestTool => {
|
||||
const found = source.manifest.tools.find((entry) => entry.name === name);
|
||||
@@ -44,7 +46,7 @@ describe("environmentToolDefinitions", () => {
|
||||
const users = tool("get_users_by_id");
|
||||
const definition = definitions.find(({ name }) => name === users.name);
|
||||
expect(definition?.description).toBe(
|
||||
`${REPLAY_CATALOG_INSTRUCTIONS} GET ${users.path_template}; replays retained records [${users.record_ordinals.join(", ")}]; evidence ${source.evidenceId}`,
|
||||
`${REPLAY_CATALOG_INSTRUCTIONS} GET ${users.path_template}; returns a JSON object with fields "id", "uid", "login", "email"; recorded statuses 200; replays retained records [${users.record_ordinals.join(", ")}]; evidence ${source.evidenceId}`,
|
||||
);
|
||||
expect(definition?.tool).toBe(users);
|
||||
});
|
||||
@@ -108,6 +110,243 @@ describe("environmentToolDefinitions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const described = (name: string): string | undefined =>
|
||||
definitions.find((definition) => definition.name === name)?.description;
|
||||
/** The users tool citing exactly the given exchanges, with the statuses its manifest recorded. */
|
||||
const usersAnswering = (
|
||||
answers: readonly Partial<RecordedExchange>[],
|
||||
statuses: readonly number[] = [200],
|
||||
): string | undefined => {
|
||||
const exchanges = new Map(
|
||||
answers.map((answer, index): [number, RecordedExchange] => [
|
||||
index,
|
||||
{
|
||||
ordinal: index,
|
||||
path: "/api/users/2",
|
||||
query: null,
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
bodyText: null,
|
||||
...answer,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const users = {
|
||||
...tool("get_users_by_id"),
|
||||
record_ordinals: [...exchanges.keys()],
|
||||
statuses: [...statuses],
|
||||
};
|
||||
const [definition] = environmentToolDefinitions({
|
||||
...withTools([users]),
|
||||
exchanges,
|
||||
});
|
||||
return definition?.description;
|
||||
};
|
||||
const returnsOf = (bodyText: string): string | undefined =>
|
||||
/\{numeric\}; (?<returns>[^;]*); recorded statuses/u.exec(
|
||||
usersAnswering([{ bodyText }]) ?? "",
|
||||
)?.groups?.returns;
|
||||
|
||||
describe("environment tool response shapes", () => {
|
||||
it("describes array, enveloped list and object responses one level deep with their statuses", () => {
|
||||
expect(described("get_folders")).toContain(
|
||||
'GET /api/folders; returns a JSON array of objects with fields "id", "uid", "title"; recorded statuses 200; replays',
|
||||
);
|
||||
expect(described("get_dashboards_versions_by_uid")).toContain(
|
||||
'; returns a JSON object whose "versions" lists objects with fields "id", "version"; recorded statuses 200; replays',
|
||||
);
|
||||
expect(described("get_dashboards_by_uid")).toContain(
|
||||
'; returns a JSON object with fields "meta", "dashboard"; recorded statuses 200; replays',
|
||||
);
|
||||
});
|
||||
|
||||
it("describes empty, scalar and fieldless bodies and bounds a long member name", () => {
|
||||
expect(
|
||||
[
|
||||
"[]",
|
||||
"[1,2]",
|
||||
'"text"',
|
||||
"null",
|
||||
"true",
|
||||
"3",
|
||||
"{}",
|
||||
"[{},{}]",
|
||||
'{"items":[]}',
|
||||
].map(returnsOf),
|
||||
).toEqual([
|
||||
"returns a JSON array",
|
||||
"returns a JSON array",
|
||||
"returns a JSON string",
|
||||
"returns a JSON null",
|
||||
"returns a JSON boolean",
|
||||
"returns a JSON number",
|
||||
"returns a JSON object with no fields",
|
||||
"returns a JSON array of objects with no fields",
|
||||
'returns a JSON object with fields "items"',
|
||||
]);
|
||||
const member = "m".repeat(2048);
|
||||
expect(
|
||||
usersAnswering([
|
||||
{ bodyText: JSON.stringify({ [member]: [{ id: 1 }, { id: 2 }] }) },
|
||||
]),
|
||||
).toContain(
|
||||
`{numeric}; returns a JSON object whose "${"m".repeat(1022)}"… (member display truncated; full value in capture Evidence) lists objects with fields "id"; recorded statuses 200;`,
|
||||
);
|
||||
});
|
||||
|
||||
it("quotes captured names, so a separator in one cannot read as more of the description", () => {
|
||||
const description = usersAnswering([
|
||||
{
|
||||
bodyText: JSON.stringify({
|
||||
"id, name": 1,
|
||||
"x; recorded statuses 500": 2,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(description).toContain(
|
||||
'returns a JSON object with fields "id, name", "x; recorded statuses 500"; recorded statuses 200;',
|
||||
);
|
||||
expect(
|
||||
usersAnswering([
|
||||
{ bodyText: JSON.stringify({ "a\nb": [{ id: 1 }, { id: 2 }] }) },
|
||||
]),
|
||||
).toContain('whose "a\\nb" lists objects with fields "id";');
|
||||
// A name too long to show whole is shown as a quoted prefix, so its newline cannot start a line of description.
|
||||
const long = `items; recorded statuses 500\n${"m".repeat(2048)}`;
|
||||
const shown = usersAnswering([
|
||||
{ bodyText: JSON.stringify({ [long]: [{ id: 1 }, { id: 2 }] }) },
|
||||
]);
|
||||
expect(shown).toContain('whose "items; recorded statuses 500\\nmmm');
|
||||
expect(shown).not.toContain("items; recorded statuses 500\n");
|
||||
});
|
||||
|
||||
it("applies the sample budget after JSON escaping", () => {
|
||||
const member = "\u0000".repeat(1024);
|
||||
const description = usersAnswering([
|
||||
{ bodyText: JSON.stringify({ [member]: [{ id: 1 }] }) },
|
||||
]);
|
||||
const shown = /whose (?<name>"(?:\\u0000)*")/u.exec(description ?? "")
|
||||
?.groups?.name;
|
||||
expect(shown).toBeDefined();
|
||||
expect(Buffer.byteLength(shown ?? "")).toBeLessThanOrEqual(1024);
|
||||
expect(description).toContain("member display truncated");
|
||||
expect(description).not.toContain("\u0000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment tool response summaries", () => {
|
||||
it("describes the first cited 2xx record replay answers as JSON, then the manifest's statuses", () => {
|
||||
const description = usersAnswering(
|
||||
[
|
||||
{
|
||||
contentType: "text/html",
|
||||
bodyText: '{"looks":"like JSON"}',
|
||||
},
|
||||
{ status: 401, bodyText: '{"message":"Unauthorized","url":"/login"}' },
|
||||
{ bodyText: '{"id":2,"login":"probe"}' },
|
||||
{ bodyText: '[{"id":3,"email":"viewer@example.test"}]' },
|
||||
],
|
||||
[200, 401],
|
||||
);
|
||||
expect(description).toContain(
|
||||
'{numeric}; returns a JSON object with fields "id", "login"; recorded statuses 200, 401; replays retained records [0, 1, 2, 3];',
|
||||
);
|
||||
});
|
||||
|
||||
it("labels a JSON record of another status only when no 2xx record replays as JSON", () => {
|
||||
expect(
|
||||
usersAnswering(
|
||||
[
|
||||
{ contentType: "text/html", bodyText: "<p>Signed out</p>" },
|
||||
{ status: 401, bodyText: '{"message":"Unauthorized"}' },
|
||||
{ status: 403, bodyText: '{"errors":[{"message":"Forbidden"}]}' },
|
||||
],
|
||||
[200, 401, 403],
|
||||
),
|
||||
).toContain(
|
||||
'{numeric}; status 401 returns a JSON object with fields "message"; recorded statuses 200, 401, 403; replays',
|
||||
);
|
||||
expect(
|
||||
usersAnswering([{ status: null, bodyText: '{"id":2}' }], []),
|
||||
).toContain(
|
||||
'{numeric}; a record without a status returns a JSON object with fields "id"; no recorded status; replays',
|
||||
);
|
||||
});
|
||||
|
||||
it("states only the statuses when no cited exchange replays as JSON", () => {
|
||||
for (const answer of [
|
||||
{ contentType: "text/html", bodyText: "<p>Granite Ledger</p>" },
|
||||
{ bodyText: '{"id":9007199254740993}' },
|
||||
{ bodyText: '{"id":2}', bodyEncoding: "base64" as const },
|
||||
{ bodyText: '{"id":2}', bodyCut: true as const },
|
||||
{ bodyText: null },
|
||||
])
|
||||
expect(usersAnswering([answer])).toMatch(
|
||||
/\{numeric\}; recorded statuses 200; replays retained records/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("charges response summaries to the catalog reservation before retaining them", () => {
|
||||
const fields = Object.fromEntries(
|
||||
Array.from({ length: 300 }, (_, index) => [
|
||||
`field_${String(index).padStart(3, "0")}`,
|
||||
index,
|
||||
]),
|
||||
);
|
||||
const users = tool("get_users_by_id");
|
||||
const ordinal = users.record_ordinals[0] ?? 0;
|
||||
const catalog = (contentType: string) =>
|
||||
prepareDefinitions({
|
||||
...withTools(
|
||||
Array.from({ length: 200 }, (_, index) => ({
|
||||
...users,
|
||||
name: `get_users_${String(index)}`,
|
||||
record_ordinals: [ordinal],
|
||||
})),
|
||||
),
|
||||
exchanges: new Map([
|
||||
[
|
||||
ordinal,
|
||||
{
|
||||
ordinal,
|
||||
path: "/api/users/2",
|
||||
query: null,
|
||||
status: 200,
|
||||
contentType,
|
||||
bodyText: JSON.stringify(fields),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
expect(catalog("text/html").ok).toBe(true);
|
||||
const bounded = catalog("application/json");
|
||||
expect(bounded).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
resource: "capacity",
|
||||
reportedLimits: { max_catalog_bytes: ENVIRONMENT_CATALOG_LIMIT_BYTES },
|
||||
},
|
||||
});
|
||||
const [single] = environmentToolDefinitions({
|
||||
...withTools([users]),
|
||||
exchanges: new Map([
|
||||
[
|
||||
ordinal,
|
||||
{
|
||||
...source.exchanges.get(ordinal)!,
|
||||
bodyText: JSON.stringify(fields),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
expect(Buffer.byteLength(single?.description ?? "")).toBeLessThan(1700);
|
||||
expect(single?.description).toContain(
|
||||
"/300 shown; full list in capture Evidence)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment catalog description budgets", () => {
|
||||
it("bounds captured scalar prose while preserving complete lookup identities", () => {
|
||||
const query = "query-".repeat(1000);
|
||||
@@ -117,10 +356,7 @@ describe("environment catalog description budgets", () => {
|
||||
path_template: `/${"😀".repeat(1000)}`,
|
||||
query_parameter_names: [query],
|
||||
};
|
||||
const [definition] = environmentToolDefinitions(
|
||||
{ ...source.manifest, tools: [expanded] },
|
||||
source.evidenceId,
|
||||
);
|
||||
const [definition] = environmentToolDefinitions(withTools([expanded]));
|
||||
expect(definition?.tool).toBe(expanded);
|
||||
expect(Buffer.byteLength(definition?.description ?? "")).toBeLessThan(3500);
|
||||
expect(definition?.description).toContain(
|
||||
@@ -152,10 +388,7 @@ describe("environment catalog description budgets", () => {
|
||||
})),
|
||||
record_ordinals: ordinals,
|
||||
};
|
||||
const [definition] = environmentToolDefinitions(
|
||||
{ ...source.manifest, tools: [expanded] },
|
||||
source.evidenceId,
|
||||
);
|
||||
const [definition] = environmentToolDefinitions(withTools([expanded]));
|
||||
expect(definition?.tool).toBe(expanded);
|
||||
expect(expanded.params[0]?.observed_values).toBe(observations);
|
||||
expect(expanded.record_ordinals).toBe(ordinals);
|
||||
@@ -180,10 +413,7 @@ describe("environment catalog description budgets", () => {
|
||||
...users,
|
||||
path_template: `/api/${"segment/".repeat(200_000)}{id}`,
|
||||
};
|
||||
const [definition] = environmentToolDefinitions(
|
||||
{ ...source.manifest, tools: [long] },
|
||||
source.evidenceId,
|
||||
);
|
||||
const [definition] = environmentToolDefinitions(withTools([long]));
|
||||
expect(Buffer.byteLength(definition?.description ?? "")).toBeLessThan(2048);
|
||||
expect(definition?.description).toContain(
|
||||
"path display truncated; full value in manifest Evidence",
|
||||
@@ -330,3 +560,22 @@ describe("environmentCard", () => {
|
||||
expect(environmentCard("renamed", definitions).name).toBe("renamed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment tool annotations", () => {
|
||||
it("marks OPTIONS and TRACE safe and idempotent, as GET is", () => {
|
||||
const get = tool("get_users_by_id");
|
||||
const hints = environmentToolDefinitions(
|
||||
withTools(
|
||||
(["OPTIONS", "TRACE"] as const).map((method) => ({
|
||||
...get,
|
||||
name: `${method.toLowerCase()}_users_by_id`,
|
||||
method,
|
||||
})),
|
||||
),
|
||||
).map((definition) => definition.annotations);
|
||||
expect(hints).toEqual([
|
||||
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
||||
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,20 @@
|
||||
import {
|
||||
isObject,
|
||||
isQueryPost,
|
||||
listItems,
|
||||
locateList,
|
||||
pointerTokens,
|
||||
type JsonObject,
|
||||
} from "../../domain/analysisView/environmentStateRules.js";
|
||||
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js";
|
||||
import type {
|
||||
EnvironmentManifest,
|
||||
EnvironmentManifestTool,
|
||||
import {
|
||||
ENVIRONMENT_SERVE_OPERATION,
|
||||
replayedBody,
|
||||
type EnvironmentManifestTool,
|
||||
type EnvironmentSource,
|
||||
type RecordedExchange,
|
||||
} from "./environmentSource.js";
|
||||
|
||||
/** Methods whose tools accept an optional request body object. */
|
||||
@@ -27,36 +38,98 @@ export interface EnvironmentToolInputSchema {
|
||||
readonly additionalProperties: false;
|
||||
}
|
||||
|
||||
/** One manifest tool as an MCP tool: name, description and input schema. */
|
||||
/** MCP hints for the captured method; answers come from the capture, never the open world. */
|
||||
export interface EnvironmentToolAnnotations {
|
||||
readonly readOnlyHint: boolean;
|
||||
/** Set for `DELETE` only; other writes leave the MCP default (destructive). */
|
||||
readonly destructiveHint?: true;
|
||||
readonly idempotentHint: boolean;
|
||||
readonly openWorldHint: false;
|
||||
}
|
||||
|
||||
/** One manifest tool as an MCP tool: name, description, input schema and the hints MCP advertises; the card omits the hints. */
|
||||
export interface EnvironmentToolDefinition {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly inputSchema: EnvironmentToolInputSchema;
|
||||
readonly annotations: EnvironmentToolAnnotations;
|
||||
readonly tool: EnvironmentManifestTool;
|
||||
}
|
||||
|
||||
/** RFC 9110 safe methods: a preflight `OPTIONS` reads, as `GET` does. */
|
||||
const SAFE_METHODS: ReadonlySet<string> = new Set([
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS",
|
||||
"TRACE",
|
||||
]);
|
||||
|
||||
const IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([
|
||||
...SAFE_METHODS,
|
||||
"PUT",
|
||||
"DELETE",
|
||||
]);
|
||||
|
||||
const environmentToolAnnotations = ({
|
||||
method,
|
||||
path_template: path,
|
||||
}: EnvironmentManifestTool): EnvironmentToolAnnotations => ({
|
||||
readOnlyHint: SAFE_METHODS.has(method) || isQueryPost(method, path),
|
||||
...(method === "DELETE" ? { destructiveHint: true as const } : {}),
|
||||
idempotentHint: IDEMPOTENT_METHODS.has(method),
|
||||
openWorldHint: false,
|
||||
});
|
||||
|
||||
/** Agent-context budget for each captured display sample, excluding its provenance notice. */
|
||||
const DESCRIPTION_SAMPLE_BYTES = 1024;
|
||||
|
||||
/** A display prefix is explicitly separate from the complete identity held by the manifest. */
|
||||
const describeText = (text: string, label: string): string => {
|
||||
/** Whether a rendered sample fits the display budget. Callers' `show` never shortens its input. */
|
||||
const sampleFits = (shown: string): boolean =>
|
||||
shown.length <= DESCRIPTION_SAMPLE_BYTES &&
|
||||
Buffer.byteLength(shown, "utf8") <= DESCRIPTION_SAMPLE_BYTES;
|
||||
|
||||
/**
|
||||
* A display prefix is explicitly separate from the complete identity held by the Evidence named.
|
||||
* `show` renders the text or its prefix. The budget applies to that rendering, so JSON quoting of a
|
||||
* name full of NULs or newlines cannot outgrow the sample.
|
||||
*/
|
||||
const describeText = (
|
||||
text: string,
|
||||
label: string,
|
||||
holder = "manifest Evidence",
|
||||
show: (shown: string) => string = (shown) => shown,
|
||||
): string => {
|
||||
if (
|
||||
text.length <= DESCRIPTION_SAMPLE_BYTES &&
|
||||
Buffer.byteLength(text, "utf8") <= DESCRIPTION_SAMPLE_BYTES
|
||||
)
|
||||
return text;
|
||||
const { read } = new TextEncoder().encodeInto(
|
||||
text,
|
||||
new Uint8Array(DESCRIPTION_SAMPLE_BYTES),
|
||||
);
|
||||
return `${text.slice(0, read)}… (${label} display truncated; full value in manifest Evidence)`;
|
||||
) {
|
||||
const shown = show(text);
|
||||
if (sampleFits(shown)) return shown;
|
||||
}
|
||||
let low = 0;
|
||||
let high = Math.min(text.length, DESCRIPTION_SAMPLE_BYTES);
|
||||
let best = 0;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (sampleFits(show(text.slice(0, mid)))) {
|
||||
best = mid;
|
||||
low = mid + 1;
|
||||
} else high = mid - 1;
|
||||
}
|
||||
// A lone surrogate is not a character. Step off a high surrogate so the prefix stays whole.
|
||||
if (best > 0 && best < text.length) {
|
||||
const unit = text.charCodeAt(best - 1);
|
||||
if (unit >= 0xd8_00 && unit <= 0xdb_ff) best -= 1;
|
||||
}
|
||||
return `${show(text.slice(0, best))}… (${label} display truncated; full value in ${holder})`;
|
||||
};
|
||||
|
||||
/** Sample whole values before joining; keep full lists in the manifest that owns them. */
|
||||
/** Sample whole values before joining; keep full lists in the Evidence that owns them. */
|
||||
const describeList = <Value>(
|
||||
values: Iterable<Value>,
|
||||
count: number,
|
||||
display: (value: Value) => string = String,
|
||||
holder = "manifest Evidence",
|
||||
): string => {
|
||||
if (count === 0) return "none";
|
||||
const sample: string[] = [];
|
||||
@@ -76,7 +149,80 @@ const describeList = <Value>(
|
||||
const listed = sample.join(", ");
|
||||
return sample.length === count
|
||||
? listed
|
||||
: `${listed}${sample.length === 0 ? "" : ", "}… (${String(sample.length)}/${String(count)} shown; full list in manifest Evidence)`;
|
||||
: `${listed}${sample.length === 0 ? "" : ", "}… (${String(sample.length)}/${String(count)} shown; full list in ${holder})`;
|
||||
};
|
||||
|
||||
/**
|
||||
* A captured name as a JSON string, so a separator, quote or newline in it cannot read as more of the
|
||||
* description. A name too long for a sample is left as it is: the sample refuses it either way.
|
||||
*/
|
||||
const quotedName = (name: string): string =>
|
||||
name.length > DESCRIPTION_SAMPLE_BYTES ? name : JSON.stringify(name);
|
||||
|
||||
/** Field names of the objects in first-seen order; the recorded bodies stay in the capture Evidence. */
|
||||
const withFields = (objects: readonly JsonObject[]): string => {
|
||||
const names = new Set<string>();
|
||||
for (const object of objects)
|
||||
for (const name of Object.keys(object)) names.add(name);
|
||||
return names.size === 0
|
||||
? "with no fields"
|
||||
: `with fields ${describeList(names, names.size, quotedName, "capture Evidence")}`;
|
||||
};
|
||||
|
||||
/** One level of a JSON body; a list is an array of objects found by the state's list rule. */
|
||||
const describeJson = (value: JsonValue): string => {
|
||||
const pointer = locateList(value, undefined);
|
||||
const items = pointer === undefined ? [] : (listItems(value, pointer) ?? []);
|
||||
if (pointer !== undefined && items.length > 0) {
|
||||
const [member] = pointerTokens(pointer);
|
||||
return member === undefined
|
||||
? `a JSON array of objects ${withFields(items)}`
|
||||
: `a JSON object whose ${describeText(member, "member", "capture Evidence", JSON.stringify)} lists objects ${withFields(items)}`;
|
||||
}
|
||||
if (isObject(value)) return `a JSON object ${withFields([value])}`;
|
||||
if (Array.isArray(value)) return "a JSON array";
|
||||
return `a JSON ${value === null ? "null" : typeof value}`;
|
||||
};
|
||||
|
||||
const isSuccess = (status: number | null): boolean =>
|
||||
status !== null && status >= 200 && status < 300;
|
||||
|
||||
/** The first cited record replay answers as JSON, preferring a 2xx one; each body is parsed at most once. */
|
||||
const firstJson = (
|
||||
tool: EnvironmentManifestTool,
|
||||
exchanges: ReadonlyMap<number, RecordedExchange>,
|
||||
):
|
||||
| { readonly status: number | null; readonly value: JsonValue }
|
||||
| undefined => {
|
||||
for (const success of [true, false])
|
||||
for (const ordinal of tool.record_ordinals) {
|
||||
const exchange = exchanges.get(ordinal);
|
||||
if (exchange === undefined || isSuccess(exchange.status) !== success)
|
||||
continue;
|
||||
const body = replayedBody(exchange);
|
||||
if (body.kind === "json")
|
||||
return { status: exchange.status, value: body.value };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** What the tool returns as JSON, labelled with the status unless it succeeded, then the manifest's recorded statuses. */
|
||||
const describeResponses = (
|
||||
tool: EnvironmentManifestTool,
|
||||
exchanges: ReadonlyMap<number, RecordedExchange>,
|
||||
): string => {
|
||||
const recorded =
|
||||
tool.statuses.length === 0
|
||||
? "no recorded status"
|
||||
: `recorded statuses ${describeList(tool.statuses, tool.statuses.length)}`;
|
||||
const found = firstJson(tool, exchanges);
|
||||
if (found === undefined) return recorded;
|
||||
const returns = isSuccess(found.status)
|
||||
? "returns"
|
||||
: found.status === null
|
||||
? "a record without a status returns"
|
||||
: `status ${String(found.status)} returns`;
|
||||
return `${returns} ${describeJson(found.value)}; ${recorded}`;
|
||||
};
|
||||
|
||||
/** Captured argument names longer than this are advertised under a short alias; the manifest keeps the name. */
|
||||
@@ -208,9 +354,8 @@ export interface EnvironmentCatalogOptions {
|
||||
|
||||
/** Admit complete discovery before binding; six bytes per UTF-16 unit covers JSON escaping and UTF-8 expansion. */
|
||||
export const environmentToolDefinitions = (
|
||||
manifest: EnvironmentManifest,
|
||||
evidenceId: string,
|
||||
name: string = manifest.name,
|
||||
source: EnvironmentSource,
|
||||
name: string = source.manifest.name,
|
||||
{
|
||||
operation = ENVIRONMENT_SERVE_OPERATION,
|
||||
dataPlane = "none",
|
||||
@@ -254,15 +399,22 @@ export const environmentToolDefinitions = (
|
||||
? "answers from the served environment state where the state backs this tool, otherwise replays retained records"
|
||||
: "replays retained records";
|
||||
const definitions: EnvironmentToolDefinition[] = [];
|
||||
for (const tool of manifest.tools) {
|
||||
if (!reserve(EMPTY_TOOL_BYTES) || !text(tool.name)) return refused();
|
||||
for (const tool of source.manifest.tools) {
|
||||
const annotations = environmentToolAnnotations(tool);
|
||||
// Only MCP discovery carries the hints; charging them per tool over-charges the card harmlessly.
|
||||
if (
|
||||
!reserve(EMPTY_TOOL_BYTES + JSON.stringify({ annotations }).length) ||
|
||||
!text(tool.name)
|
||||
)
|
||||
return refused();
|
||||
const count =
|
||||
tool.params.length +
|
||||
tool.query_parameter_names.length +
|
||||
(WRITE_METHODS.has(tool.method) ? 1 : 0);
|
||||
// A lower bound rejects enormous argument lists before constructing their alias map.
|
||||
if (!reserve(count * EMPTY_PROPERTY_BYTES)) return refused();
|
||||
const description = `${REPLAY_CATALOG_INSTRUCTIONS} ${describeText(tool.method, "method")} ${describeText(tool.path_template, "path")}; ${answers} [${describeList(tool.record_ordinals, tool.record_ordinals.length)}]; evidence ${evidenceId}`;
|
||||
// The response summary is bounded by its samples and charged with the whole description.
|
||||
const description = `${REPLAY_CATALOG_INSTRUCTIONS} ${describeText(tool.method, "method")} ${describeText(tool.path_template, "path")}; ${describeResponses(tool, source.exchanges)}; ${answers} [${describeList(tool.record_ordinals, tool.record_ordinals.length)}]; evidence ${source.evidenceId}`;
|
||||
if (!text(description)) return refused();
|
||||
const args = environmentToolArguments(tool);
|
||||
const properties = new Map<string, EnvironmentToolProperty>();
|
||||
@@ -289,6 +441,7 @@ export const environmentToolDefinitions = (
|
||||
required,
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations,
|
||||
tool,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { EvmInterfacePort } from "./EvmInterfacePort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
@@ -47,16 +45,6 @@ export class EvmInterfaceService {
|
||||
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 diagnostics = diagnosticsSchema.safeParse(inspected.value.result);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../../domain/firmware/firmwareAnalysis.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
||||
|
||||
const fixture = (
|
||||
operation: FirmwareOperation,
|
||||
@@ -68,15 +69,35 @@ it.each(["inspect_firmware_regions", "extract_firmware"] as const)(
|
||||
"retains %s Evidence after late cancellation and preserves provider failure precedence",
|
||||
async (operation) => {
|
||||
const { input, execution } = fixture(operation);
|
||||
const profile = createAnalysisProfile(
|
||||
{ ...execution.provider, version: "1" },
|
||||
{
|
||||
signature_database: "caller-selected snapshot",
|
||||
},
|
||||
);
|
||||
const coverage = {
|
||||
status: "unknown",
|
||||
reason: "No call graph examined",
|
||||
} as const;
|
||||
const observation = {
|
||||
...execution,
|
||||
analysisProfile: profile,
|
||||
relationshipCoverage: coverage,
|
||||
};
|
||||
const control = await new FirmwareAnalysisService({
|
||||
execute: async () => ok(execution),
|
||||
execute: async () => ok(observation),
|
||||
}).execute(operation, input);
|
||||
if (!control.ok) throw control.error;
|
||||
expect(control.value).toMatchObject({
|
||||
raw_result: { source: "controlled producer" },
|
||||
analysis_profile: profile,
|
||||
relationship_coverage: coverage,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = await new FirmwareAnalysisService({
|
||||
execute: async () => {
|
||||
controller.abort();
|
||||
return ok(execution);
|
||||
return ok(observation);
|
||||
},
|
||||
}).execute(operation, input, { signal: controller.signal });
|
||||
expect(result).toMatchObject({
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import {
|
||||
createAnalysisExecutionEvidence,
|
||||
type ExecutionOptions,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { FirmwareAnalysisPort } from "./FirmwareAnalysisPort.js";
|
||||
import {
|
||||
firmwareRequestSchema,
|
||||
@@ -12,8 +15,7 @@ import {
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
@@ -71,15 +73,12 @@ export class FirmwareAnalysisService {
|
||||
),
|
||||
);
|
||||
return completeProviderEvidence(
|
||||
createEvidence(execution.subject, execution.provider, {
|
||||
createAnalysisExecutionEvidence(
|
||||
operation,
|
||||
parameters: jsonObjectSchema.parse(parsed.data.input),
|
||||
result: execution.result,
|
||||
rawResult: execution.rawResult,
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
confidence: "observed",
|
||||
}),
|
||||
parsed.data.input,
|
||||
execution,
|
||||
"observed",
|
||||
),
|
||||
options?.signal,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { GoBinaryPort } from "./GoBinaryPort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
@@ -53,16 +51,6 @@ export class GoBinaryService {
|
||||
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 { detail, ...providerInput } = input.data;
|
||||
if (detail === "summary") {
|
||||
const ready = requireAnalysisViewRetention(OPERATION, retention);
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
harmonyApplicationProjectionInputSchema,
|
||||
projectHarmonyApplication,
|
||||
} from "../../domain/harmony/harmonyApplication.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import { HARMONY_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
import { projectInventoryEvidence } from "../InventoryProjectionEvidence.js";
|
||||
import {
|
||||
projectInventoryEvidence,
|
||||
type InventoryProjectionEvidenceResult,
|
||||
} from "../InventoryProjectionEvidence.js";
|
||||
|
||||
const OPERATION = "project_harmony_application_graph" as const;
|
||||
|
||||
/** Project authenticated HarmonyOS package inventory Evidence into application evidence. */
|
||||
export const projectHarmonyApplicationEvidence = (
|
||||
rawInput: unknown,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
): Result<InventoryProjectionEvidenceResult, AnalysisError> => {
|
||||
return projectInventoryEvidence({
|
||||
rawInput,
|
||||
schema: harmonyApplicationProjectionInputSchema,
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
Il2cppSeedWriterPort,
|
||||
Il2cppSeedReceipt,
|
||||
} from "./Il2cppSeedWriterPort.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
@@ -111,19 +110,6 @@ export class Il2cppMetadataService {
|
||||
cause: input.error,
|
||||
}),
|
||||
);
|
||||
for (const field of ["path", "binary_path"] as const) {
|
||||
const value = input.data[field];
|
||||
if (value !== undefined && !isAbsolute(value))
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: [field],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute filesystem path on this host.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.data.binary_architecture !== undefined &&
|
||||
input.data.binary_path === undefined
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
||||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
||||
import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
import {
|
||||
addElectronInferenceEdge,
|
||||
electronFindingSourceNode,
|
||||
electronRangeKey,
|
||||
} from "./ElectronBoundaryGraphContext.js";
|
||||
import {
|
||||
artifactLocalIdentity,
|
||||
@@ -191,13 +191,15 @@ const matches = (
|
||||
path.steps.some(
|
||||
(step) =>
|
||||
step.kind === sourceStepKind &&
|
||||
electronRangeKey(step.location) === electronRangeKey(finding.location),
|
||||
javascriptSourceRangeKey(step.location) ===
|
||||
javascriptSourceRangeKey(finding.location),
|
||||
) &&
|
||||
(invocation === undefined ||
|
||||
path.steps.some(
|
||||
(step) =>
|
||||
(step.kind === "call" || step.kind === "exposure") &&
|
||||
electronRangeKey(step.location) === electronRangeKey(invocation),
|
||||
javascriptSourceRangeKey(step.location) ===
|
||||
javascriptSourceRangeKey(invocation),
|
||||
))
|
||||
);
|
||||
};
|
||||
@@ -108,23 +108,3 @@ export const addElectronInferenceEdge = (
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
/** Deterministic source-range key for artifact-local graph identities. */
|
||||
export const electronRangeKey = (range: JavaScriptSourceRange): string =>
|
||||
`${String(range.start.line)}:${String(range.start.column)}-${String(range.end.line)}:${String(range.end.column)}`;
|
||||
|
||||
/** Test exact source-range containment without assuming runtime reachability. */
|
||||
export const electronRangeContains = (
|
||||
outer: JavaScriptSourceRange,
|
||||
inner: JavaScriptSourceRange,
|
||||
): boolean =>
|
||||
comparePoint(outer.start, inner.start) <= 0 &&
|
||||
comparePoint(outer.end, inner.end) >= 0;
|
||||
|
||||
const comparePoint = (
|
||||
left: JavaScriptSourceRange["start"],
|
||||
right: JavaScriptSourceRange["start"],
|
||||
): number =>
|
||||
left.line === right.line
|
||||
? left.column - right.column
|
||||
: left.line - right.line;
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
javascriptSourceRangeKey,
|
||||
javascriptSourceRangeContains,
|
||||
} from "../../domain/javascript/javascriptSourceCoordinates.js";
|
||||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
||||
import type { ElectronSenderValidationFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
@@ -21,8 +25,6 @@ import {
|
||||
addElectronInferenceEdge,
|
||||
electronFindingSourceNode,
|
||||
electronObservationIdentity,
|
||||
electronRangeContains,
|
||||
electronRangeKey,
|
||||
} from "./ElectronBoundaryGraphContext.js";
|
||||
|
||||
interface IpcGraphState {
|
||||
@@ -182,7 +184,7 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
|
||||
identity: artifactLocalIdentity(
|
||||
file.sha256,
|
||||
"electron-ipc-handler",
|
||||
`${finding.side}:${finding.operation}:${finding.channel ?? finding.channel_expression}:${electronRangeKey(handlerRange)}`,
|
||||
`${finding.side}:${finding.operation}:${finding.channel ?? finding.channel_expression}:${javascriptSourceRangeKey(handlerRange)}`,
|
||||
),
|
||||
observations: [
|
||||
{
|
||||
@@ -338,7 +340,7 @@ const containingHandler = (
|
||||
(record) =>
|
||||
record.file.path === file.path &&
|
||||
record.finding.handler_location !== null &&
|
||||
electronRangeContains(
|
||||
javascriptSourceRangeContains(
|
||||
record.finding.handler_location,
|
||||
validation.location,
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
||||
import type { ElectronNativeAddonBindingFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import {
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
addElectronInferenceEdge,
|
||||
electronFindingSourceNode,
|
||||
electronObservationIdentity,
|
||||
electronRangeKey,
|
||||
} from "./ElectronBoundaryGraphContext.js";
|
||||
|
||||
interface NativeBindingInput {
|
||||
@@ -65,7 +65,7 @@ const addNativeBinding = (input: NativeBindingInput): void => {
|
||||
value.members,
|
||||
value.namespace_access ?? null,
|
||||
value.dynamic_member_access ?? null,
|
||||
electronRangeKey(value.location),
|
||||
javascriptSourceRangeKey(value.location),
|
||||
]),
|
||||
),
|
||||
observations: [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
||||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
||||
import type {
|
||||
ElectronBrowserWindowFinding,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
addElectronAstEdge,
|
||||
addElectronInferenceEdge,
|
||||
electronFindingSourceNode,
|
||||
electronRangeKey,
|
||||
} from "./ElectronBoundaryGraphContext.js";
|
||||
import {
|
||||
artifactPathResolutionProperties,
|
||||
@@ -86,11 +86,11 @@ const addBrowserWindow = (
|
||||
identity: artifactLocalIdentity(
|
||||
file.sha256,
|
||||
"electron-browser-window",
|
||||
electronRangeKey(value.location),
|
||||
javascriptSourceRangeKey(value.location),
|
||||
),
|
||||
observations: [
|
||||
{
|
||||
label: `BrowserWindow@${electronRangeKey(value.location)}`,
|
||||
label: `BrowserWindow@${javascriptSourceRangeKey(value.location)}`,
|
||||
properties: {
|
||||
source_path: file.path,
|
||||
options_status: value.options_status,
|
||||
@@ -202,7 +202,7 @@ const addContextBridge = (
|
||||
identity: artifactLocalIdentity(
|
||||
file.sha256,
|
||||
"electron-context-bridge",
|
||||
`${value.world}:${value.api_key ?? value.api_key_expression ?? "[missing]"}:${electronRangeKey(value.location)}`,
|
||||
`${value.world}:${value.api_key ?? value.api_key_expression ?? "[missing]"}:${javascriptSourceRangeKey(value.location)}`,
|
||||
),
|
||||
observations: [
|
||||
{
|
||||
@@ -271,7 +271,7 @@ const addUtilityProcess = (
|
||||
identity: artifactLocalIdentity(
|
||||
file.sha256,
|
||||
"electron-utility-process",
|
||||
`${value.module_path ?? value.module_expression}:${electronRangeKey(value.location)}`,
|
||||
`${value.module_path ?? value.module_expression}:${javascriptSourceRangeKey(value.location)}`,
|
||||
),
|
||||
observations: [
|
||||
{
|
||||
|
||||
@@ -184,15 +184,12 @@ const staticRuntimeSource = (
|
||||
evidence.normalized_result,
|
||||
[...path, "normalized_result"],
|
||||
);
|
||||
const unmatchedLink = result.evidence_links.find(
|
||||
(evidenceId) => !evidence.evidence_links.includes(evidenceId),
|
||||
assertResultEvidenceLinksInEnvelope(
|
||||
result.evidence_links,
|
||||
evidence,
|
||||
path,
|
||||
"Runtime reconciliation result",
|
||||
);
|
||||
if (unmatchedLink !== undefined)
|
||||
throw invalidEvidence(
|
||||
[...path, "normalized_result"],
|
||||
"evidence_links",
|
||||
`Runtime reconciliation result references Evidence ${unmatchedLink} outside its envelope.`,
|
||||
);
|
||||
const applicationLayer = result.static_layers.find(
|
||||
({ role }) => role === "application",
|
||||
);
|
||||
@@ -229,15 +226,12 @@ const managedApplicationSource = (
|
||||
evidence.normalized_result,
|
||||
[...path, "normalized_result"],
|
||||
);
|
||||
const unmatchedLink = result.evidence_links.find(
|
||||
(evidenceId) => !evidence.evidence_links.includes(evidenceId),
|
||||
assertResultEvidenceLinksInEnvelope(
|
||||
result.evidence_links,
|
||||
evidence,
|
||||
path,
|
||||
"Managed application graph result",
|
||||
);
|
||||
if (unmatchedLink !== undefined)
|
||||
throw invalidEvidence(
|
||||
[...path, "normalized_result"],
|
||||
"evidence_links",
|
||||
`Managed application graph result references Evidence ${unmatchedLink} outside its envelope.`,
|
||||
);
|
||||
return {
|
||||
evidence,
|
||||
graph: result.graph,
|
||||
@@ -247,6 +241,25 @@ const managedApplicationSource = (
|
||||
};
|
||||
};
|
||||
|
||||
const assertResultEvidenceLinksInEnvelope = (
|
||||
resultEvidenceLinks: readonly string[],
|
||||
evidence: Evidence,
|
||||
path: readonly (string | number)[],
|
||||
resultLabel:
|
||||
| "Runtime reconciliation result"
|
||||
| "Managed application graph result",
|
||||
): void => {
|
||||
const unmatchedLink = resultEvidenceLinks.find(
|
||||
(evidenceId) => !evidence.evidence_links.includes(evidenceId),
|
||||
);
|
||||
if (unmatchedLink !== undefined)
|
||||
throw invalidEvidence(
|
||||
[...path, "normalized_result"],
|
||||
"evidence_links",
|
||||
`${resultLabel} references Evidence ${unmatchedLink} outside its envelope.`,
|
||||
);
|
||||
};
|
||||
|
||||
const assertApplicationSubject = (
|
||||
evidence: Evidence,
|
||||
result: JavaScriptApplicationAnalysisResult,
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
type JavaScriptApplicationAnalysisResult,
|
||||
parseOwnedJavaScriptApplicationAnalysisSteps,
|
||||
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
@@ -149,13 +152,8 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
typeof cause.code === "string" &&
|
||||
FILESYSTEM_ERROR_CODES.has(cause.code)
|
||||
)
|
||||
return err(new ArtifactOperationError(OPERATION, "io"));
|
||||
const filesystemFailure = hostFilesystemFailure(cause);
|
||||
if (filesystemFailure !== undefined) return err(filesystemFailure);
|
||||
return err(
|
||||
new ProviderAdapterError(JAVASCRIPT_APPLICATION_PROVIDER.id, OPERATION, {
|
||||
cause,
|
||||
@@ -208,6 +206,43 @@ const describeResultSchemaFailure = (cause: z.ZodError): string => {
|
||||
const escapePointerPart = (part: string): string =>
|
||||
part.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
|
||||
/**
|
||||
* Project a host filesystem failure that no narrower owner classified. Selected
|
||||
* input denial is classified at selection; this failure may concern any path,
|
||||
* including private workspace files, so it keeps the host's own facts rather
|
||||
* than claiming a selected-read denial.
|
||||
*/
|
||||
const hostFilesystemFailure = (cause: unknown): AnalysisError | undefined => {
|
||||
if (!(cause instanceof Error)) return undefined;
|
||||
const code: unknown = Reflect.get(cause, "code");
|
||||
if (typeof code !== "string" || !FILESYSTEM_ERROR_CODES.has(code))
|
||||
return undefined;
|
||||
const syscall: unknown = Reflect.get(cause, "syscall");
|
||||
const path: unknown = Reflect.get(cause, "path");
|
||||
const facts = {
|
||||
system_code: code,
|
||||
...(typeof syscall === "string" ? { syscall } : {}),
|
||||
...(typeof path === "string" ? { path } : {}),
|
||||
};
|
||||
if (code === "EMFILE" || code === "ENFILE")
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"capacity",
|
||||
`Host file-descriptor limit reached (${code})${typeof path === "string" ? ` while accessing ${path}` : ""}`,
|
||||
facts,
|
||||
{
|
||||
cause,
|
||||
retryable: true,
|
||||
remediationAction:
|
||||
"Close unused files or raise the open-file limit for the process or host running REA, then retry.",
|
||||
},
|
||||
);
|
||||
return new ArtifactOperationError(OPERATION, "io", undefined, cause.message, {
|
||||
cause,
|
||||
diagnostics: facts,
|
||||
});
|
||||
};
|
||||
|
||||
const FILESYSTEM_ERROR_CODES = new Set([
|
||||
"ENOENT",
|
||||
"EACCES",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { constants } from "node:fs";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import { access, lstat, realpath } from "node:fs/promises";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
|
||||
@@ -91,8 +91,8 @@ export const reconstructJavaScriptArtifact = async (
|
||||
};
|
||||
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
|
||||
abortIfNeeded(signal);
|
||||
const path = await resolveSelectedInput(input.input_path);
|
||||
const format = await resolveFormat(path, input);
|
||||
const { path, metadata } = await resolveSelectedInput(input.input_path);
|
||||
const format = selectedFormat(path, metadata, input);
|
||||
const snapshot = await scanCanonicalArtifactInventoryInScope(path, {
|
||||
resourceScope: scope,
|
||||
signal,
|
||||
@@ -346,13 +346,21 @@ const OPERATION = "analyze_javascript_application";
|
||||
/**
|
||||
* Resolve the caller-selected input. A missing, looping, overlong, or
|
||||
* unreadable selection is a caller or host-resolution failure, not a damaged
|
||||
* artifact.
|
||||
* artifact. A selected directory must also be searchable: without search
|
||||
* permission its entries can be listed but not inspected, so no entry is
|
||||
* readable. Other host failures propagate with their errno facts intact.
|
||||
*/
|
||||
const resolveSelectedInput = async (inputPath: string): Promise<string> => {
|
||||
const resolveSelectedInput = async (
|
||||
inputPath: string,
|
||||
): Promise<{ readonly path: string; readonly metadata: Stats }> => {
|
||||
try {
|
||||
const path = await realpath(inputPath);
|
||||
await access(path, constants.R_OK);
|
||||
return path;
|
||||
const metadata = await lstat(path);
|
||||
await access(
|
||||
path,
|
||||
metadata.isDirectory() ? constants.R_OK | constants.X_OK : constants.R_OK,
|
||||
);
|
||||
return { path, metadata };
|
||||
} catch (cause: unknown) {
|
||||
const code =
|
||||
cause instanceof Error && "code" in cause ? String(cause.code) : "";
|
||||
@@ -389,11 +397,11 @@ const selectedInputResolutionMessage = (
|
||||
: `Selected input path could not be resolved because symbolic-link traversal looped or exceeded the host limit (ELOOP): ${inputPath}`;
|
||||
|
||||
/** The selected input's kind, checked against the caller's requested format. */
|
||||
const resolveFormat = async (
|
||||
const selectedFormat = (
|
||||
path: string,
|
||||
metadata: Stats,
|
||||
input: JavaScriptArtifactReconstructionInput,
|
||||
): Promise<"asar" | "directory"> => {
|
||||
const metadata = await lstat(path);
|
||||
): "asar" | "directory" => {
|
||||
const observed = metadata.isDirectory()
|
||||
? "directory"
|
||||
: metadata.isFile() && path.toLowerCase().endsWith(".asar")
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
addSemanticGraphUnknown,
|
||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
import {
|
||||
projectSemanticResultBindings,
|
||||
type SemanticFlowProjectionContext,
|
||||
} from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
|
||||
/** Project literal EventEmitter registrations, removals, and dispatches. */
|
||||
export const projectSemanticEvents = (
|
||||
@@ -180,18 +183,11 @@ const projectTimerSchedule = (
|
||||
relation: "schedules-timer",
|
||||
resolution: "resolved",
|
||||
});
|
||||
for (const projected of operation.projectedResultBindings)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: timerNodes.get(operation.timerId),
|
||||
target: context.bindingNodes.get(projected.bindingId),
|
||||
relation: "destructures",
|
||||
resolution:
|
||||
projected.resolution === "complete" ? "resolved" : "candidate",
|
||||
properties: {
|
||||
projection_path: [...projected.projectionPath],
|
||||
projection_resolution: projected.resolution,
|
||||
},
|
||||
});
|
||||
projectSemanticResultBindings(
|
||||
context,
|
||||
timerNodes.get(operation.timerId),
|
||||
operation.projectedResultBindings,
|
||||
);
|
||||
};
|
||||
|
||||
const projectTimerCancellation = (
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
addSemanticGraphUnknown,
|
||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
import {
|
||||
projectSemanticResultBindings,
|
||||
type SemanticFlowProjectionContext,
|
||||
} from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
|
||||
interface ChildUnknownInput {
|
||||
readonly context: SemanticFlowProjectionContext;
|
||||
@@ -80,18 +83,7 @@ const projectSpawn = (
|
||||
relation: "spawns",
|
||||
resolution: spawn.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
for (const projected of spawn.projectedResultBindings)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: child,
|
||||
target: context.bindingNodes.get(projected.bindingId),
|
||||
relation: "destructures",
|
||||
resolution:
|
||||
projected.resolution === "complete" ? "resolved" : "candidate",
|
||||
properties: {
|
||||
projection_path: [...projected.projectionPath],
|
||||
projection_resolution: projected.resolution,
|
||||
},
|
||||
});
|
||||
projectSemanticResultBindings(context, child, spawn.projectedResultBindings);
|
||||
if (spawn.argvCount !== null)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: callSite,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
||||
import type { z } from "zod";
|
||||
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
@@ -338,7 +339,5 @@ const applicationLocationKey = (
|
||||
[
|
||||
artifactSha256,
|
||||
path,
|
||||
range === null
|
||||
? "artifact-path"
|
||||
: `${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`,
|
||||
range === null ? "artifact-path" : javascriptSourceRangeKey(range),
|
||||
].join("\u0000");
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
addSemanticGraphUnknown,
|
||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
import {
|
||||
projectSemanticResultBindings,
|
||||
type SemanticFlowProjectionContext,
|
||||
} from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
import { semanticResourceLimitReason } from "../../domain/javascript/javascriptSemanticResourceLimits.js";
|
||||
|
||||
/** Project environment, argv, file, default, and precedence candidates. */
|
||||
@@ -179,18 +182,11 @@ const projectRequestConstruction = (
|
||||
relation: "constructs-request",
|
||||
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
for (const projected of operation.projectedResultBindings)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: request,
|
||||
target: context.bindingNodes.get(projected.bindingId),
|
||||
relation: "destructures",
|
||||
resolution:
|
||||
projected.resolution === "complete" ? "resolved" : "candidate",
|
||||
properties: {
|
||||
projection_path: [...projected.projectionPath],
|
||||
projection_resolution: projected.resolution,
|
||||
},
|
||||
});
|
||||
projectSemanticResultBindings(
|
||||
context,
|
||||
request,
|
||||
operation.projectedResultBindings,
|
||||
);
|
||||
for (const field of operation.fields)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source:
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
||||
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
||||
import { javaScriptSemanticNodeId } from "../../domain/javascript/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
|
||||
import type {
|
||||
JavaScriptSemanticIr,
|
||||
JavaScriptSemanticProjectedResultBinding,
|
||||
} from "../../domain/javascript/javascriptSemanticIr.js";
|
||||
import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
|
||||
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
@@ -34,6 +38,26 @@ export interface SemanticFlowProjectionContext {
|
||||
) => JavaScriptSemanticGraphNode | undefined;
|
||||
}
|
||||
|
||||
/** Project result destructuring consistently for timers, child processes, and requests. */
|
||||
export const projectSemanticResultBindings = (
|
||||
context: Pick<SemanticFlowProjectionContext, "state" | "bindingNodes">,
|
||||
source: JavaScriptSemanticGraphNode | undefined,
|
||||
bindings: readonly JavaScriptSemanticProjectedResultBinding[],
|
||||
): void => {
|
||||
for (const projected of bindings)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source,
|
||||
target: context.bindingNodes.get(projected.bindingId),
|
||||
relation: "destructures",
|
||||
resolution:
|
||||
projected.resolution === "complete" ? "resolved" : "candidate",
|
||||
properties: {
|
||||
projection_path: [...projected.projectionPath],
|
||||
projection_resolution: projected.resolution,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** Project explicit static Promise/task ownership without runtime claims. */
|
||||
export const projectSemanticPromises = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
@@ -198,9 +222,6 @@ interface ObjectReadIndex {
|
||||
|
||||
const objectReadIndexes = new WeakMap<JavaScriptSemanticIr, ObjectReadIndex>();
|
||||
|
||||
const objectReadRangeKey = (location: JavaScriptSourceRange): string =>
|
||||
`${String(location.start.line)}:${String(location.start.column)}:${String(location.end.line)}:${String(location.end.column)}`;
|
||||
|
||||
/**
|
||||
* The object projection owns a member read's occurrence. Index it once per
|
||||
* file so return and argument flow can connect that expression without
|
||||
@@ -227,7 +248,7 @@ const objectReadOccurrences = (
|
||||
}),
|
||||
);
|
||||
if (node !== undefined)
|
||||
nodes.set(objectReadRangeKey(operation.location), node);
|
||||
nodes.set(javascriptSourceRangeKey(operation.location), node);
|
||||
}
|
||||
objectReadIndexes.set(context.ir, { state: context.state, nodes });
|
||||
return nodes;
|
||||
@@ -240,7 +261,7 @@ export const semanticObjectReadOccurrence = (
|
||||
): JavaScriptSemanticGraphNode | undefined =>
|
||||
location === null
|
||||
? undefined
|
||||
: objectReadOccurrences(context).get(objectReadRangeKey(location));
|
||||
: objectReadOccurrences(context).get(javascriptSourceRangeKey(location));
|
||||
|
||||
/** Link direct return expressions to their retained semantic references. */
|
||||
export const projectSemanticReturnValues = (
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
compareJavaScriptSourcePoints,
|
||||
javascriptSourceRangeContains,
|
||||
javascriptSourceRangeKey,
|
||||
} from "../../domain/javascript/javascriptSourceCoordinates.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
||||
import { JAVASCRIPT_SEMANTIC_RELATION_FAMILIES } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
||||
@@ -40,7 +45,7 @@ export const createSemanticCallableOwnerLookup = (
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
pointCompare(left.range.start, right.range.start) ||
|
||||
compareJavaScriptSourcePoints(left.range.start, right.range.start) ||
|
||||
left.order - right.order,
|
||||
);
|
||||
const tree = buildSourceIntervalTree(entries, 0, entries.length);
|
||||
@@ -54,12 +59,13 @@ export const createSemanticCallableOwnerLookup = (
|
||||
const current = pending.pop();
|
||||
if (
|
||||
current === undefined ||
|
||||
pointCompare(current.minimumStart, location.start) > 0 ||
|
||||
pointCompare(current.maximumEnd, location.end) < 0
|
||||
compareJavaScriptSourcePoints(current.minimumStart, location.start) >
|
||||
0 ||
|
||||
compareJavaScriptSourcePoints(current.maximumEnd, location.end) < 0
|
||||
)
|
||||
continue;
|
||||
const candidate = current.entry;
|
||||
if (contains(candidate.range, location)) {
|
||||
if (javascriptSourceRangeContains(candidate.range, location)) {
|
||||
const node = nodes.get(candidate.callableId);
|
||||
const rank =
|
||||
best === undefined
|
||||
@@ -93,9 +99,15 @@ const buildSourceIntervalTree = <Interval extends SourceInterval>(
|
||||
let minimumEnd = entry.range.end;
|
||||
let maximumEnd = entry.range.end;
|
||||
for (const child of [left, right]) {
|
||||
if (child !== null && pointCompare(child.minimumEnd, minimumEnd) < 0)
|
||||
if (
|
||||
child !== null &&
|
||||
compareJavaScriptSourcePoints(child.minimumEnd, minimumEnd) < 0
|
||||
)
|
||||
minimumEnd = child.minimumEnd;
|
||||
if (child !== null && pointCompare(child.maximumEnd, maximumEnd) > 0)
|
||||
if (
|
||||
child !== null &&
|
||||
compareJavaScriptSourcePoints(child.maximumEnd, maximumEnd) > 0
|
||||
)
|
||||
maximumEnd = child.maximumEnd;
|
||||
}
|
||||
return {
|
||||
@@ -120,7 +132,7 @@ export const createSemanticNodeRangeLookup = (
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
pointCompare(left.range.start, right.range.start) ||
|
||||
compareJavaScriptSourcePoints(left.range.start, right.range.start) ||
|
||||
left.order - right.order,
|
||||
);
|
||||
const tree = buildSourceIntervalTree(entries, 0, entries.length);
|
||||
@@ -132,11 +144,12 @@ export const createSemanticNodeRangeLookup = (
|
||||
const current = pending.pop();
|
||||
if (
|
||||
current === undefined ||
|
||||
pointCompare(current.maximumStart, range.start) < 0 ||
|
||||
pointCompare(current.minimumEnd, range.end) > 0
|
||||
compareJavaScriptSourcePoints(current.maximumStart, range.start) < 0 ||
|
||||
compareJavaScriptSourcePoints(current.minimumEnd, range.end) > 0
|
||||
)
|
||||
continue;
|
||||
if (contains(range, current.entry.range)) matches.push(current.entry);
|
||||
if (javascriptSourceRangeContains(range, current.entry.range))
|
||||
matches.push(current.entry);
|
||||
if (current.left !== null) pending.push(current.left);
|
||||
if (current.right !== null) pending.push(current.right);
|
||||
}
|
||||
@@ -155,18 +168,15 @@ export const createSemanticCallSiteLookup = (
|
||||
) => JavaScriptSemanticGraphNode | undefined) => {
|
||||
const identifiers = new Map<string, string>();
|
||||
for (const call of ir.callSites) {
|
||||
const key = rangeKey(call.location);
|
||||
const key = javascriptSourceRangeKey(call.location);
|
||||
if (!identifiers.has(key)) identifiers.set(key, call.callSiteId);
|
||||
}
|
||||
return (range) => {
|
||||
const identifier = identifiers.get(rangeKey(range));
|
||||
const identifier = identifiers.get(javascriptSourceRangeKey(range));
|
||||
return identifier === undefined ? undefined : nodes.get(identifier);
|
||||
};
|
||||
};
|
||||
|
||||
const rangeKey = (range: JavaScriptSourceRange): string =>
|
||||
`${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`;
|
||||
|
||||
/** Report extractor support without treating missing families as absence. */
|
||||
export const semanticFamilyStatus = (
|
||||
family: (typeof JAVASCRIPT_SEMANTIC_RELATION_FAMILIES)[number],
|
||||
@@ -191,17 +201,5 @@ export const semanticFamilyStatus = (
|
||||
return "partial";
|
||||
};
|
||||
|
||||
const contains = (
|
||||
outer: JavaScriptSourceRange,
|
||||
inner: JavaScriptSourceRange,
|
||||
): boolean =>
|
||||
pointCompare(outer.start, inner.start) <= 0 &&
|
||||
pointCompare(outer.end, inner.end) >= 0;
|
||||
|
||||
const pointCompare = (
|
||||
left: JavaScriptSourceRange["start"],
|
||||
right: JavaScriptSourceRange["start"],
|
||||
): number => left.line - right.line || left.column - right.column;
|
||||
|
||||
const positionKey = (range: JavaScriptSourceRange): string =>
|
||||
`${String(range.start.line).padStart(12, "0")}:${String(range.start.column).padStart(12, "0")}`;
|
||||
@@ -37,16 +37,13 @@ export const resolveSourceToBundleReference = async (
|
||||
}),
|
||||
);
|
||||
if (!("kind" in reference)) return ok({ reference });
|
||||
const read = await readJsonFileWithSource(reference.path, signal, resources);
|
||||
if (!read.ok) {
|
||||
return read.error instanceof AnalysisCancelledError
|
||||
? err(
|
||||
new AnalysisCancelledError("compare_source_to_bundle", {
|
||||
cause: read.error.cause,
|
||||
}),
|
||||
)
|
||||
: read;
|
||||
}
|
||||
const read = await readJsonFileWithSource(
|
||||
reference.path,
|
||||
signal,
|
||||
resources,
|
||||
"compare_source_to_bundle",
|
||||
);
|
||||
if (!read.ok) return read;
|
||||
await setImmediate();
|
||||
if (cancelled())
|
||||
return err(
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type EvidenceLookup,
|
||||
} from "../EvidenceInputResolver.js";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import { FUNCTION_COMPARISON_PROVIDER } from "../InvestigationProviders.js";
|
||||
import { FUNCTION_COMPARISON_PROVIDER } from "../../domain/comparisonEvidenceIdentity.js";
|
||||
|
||||
const OPERATION = "match_native_functions";
|
||||
export interface NativeFunctionMatchingResult {
|
||||
|
||||
@@ -83,6 +83,7 @@ export const loadNativeMatchingBundleSources = async ({
|
||||
paths[file]!,
|
||||
admission.signal,
|
||||
resources,
|
||||
admission.operation,
|
||||
);
|
||||
if (!loaded.ok) throw loaded.error;
|
||||
snapshots.push(await recordSnapshot(loaded.value, admission));
|
||||
@@ -141,6 +142,7 @@ export const loadNativeMatchingBundleSources = async ({
|
||||
paths[file]!,
|
||||
admission.signal,
|
||||
resources,
|
||||
admission.operation,
|
||||
);
|
||||
if (!loaded.ok) throw loaded.error;
|
||||
if ((await recordSnapshot(loaded.value, admission)) !== snapshots[file])
|
||||
|
||||
Loaded 100 of 419 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user