Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7183b654d | ||
|
|
f5847a8ea9 | ||
|
|
41fa1b2f05 | ||
|
|
78c1c03815 | ||
|
|
47789cdaa8 | ||
|
|
d7ca93cc2c | ||
|
|
82e4e032df | ||
|
|
ca2ca16146 | ||
|
|
14c4c2a1d7 | ||
|
|
4842f23765 | ||
|
|
7721d59e1a | ||
|
|
76dbc32f39 | ||
|
|
f23066f0da | ||
|
|
cbbd317fb5 | ||
|
|
d54c4345d3 | ||
|
|
bdf0be87c6 | ||
|
|
0155b89953 |
No files matched your search
@@ -219,6 +219,13 @@ explicit path or endpoint and do not need it.
|
||||
delivery requires `--evidence_output`.
|
||||
See the
|
||||
[Rust binary guide](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md).
|
||||
- PE Authenticode certificate table: `inspect_pe_certificates` with the explicit
|
||||
local PE32/PE32+ file. This built-in target-free inspection requires no
|
||||
Windows SDK. Preserve WIN_CERTIFICATE ranges, types, payload SHA-256, and
|
||||
PKCS#7 signer/X.509 identity fields. A missing security directory is observed
|
||||
absence of that table, not proof the image is unsigned in every catalog sense.
|
||||
Signatures are not verified. See the
|
||||
[PE certificate guide](https://github.com/morluto/rea/blob/main/docs/pe-certificates.md).
|
||||
|
||||
- Explicit local SQLite database snapshot: `inspect_sqlite_database`. Inspect
|
||||
with Node.js 24.x >=24.15 or 26+ providing native SQLite limits. Read schema
|
||||
|
||||
@@ -22,6 +22,11 @@ requires complete application analysis; `source_paths: []` is invalid, not an
|
||||
unfiltered request. `detail: "summary"` reduces delivery
|
||||
and does not by itself reduce analysis work.
|
||||
|
||||
ASAR header/index admission is fixed at 16 MiB / 16777216 bytes for this
|
||||
release and has no runtime override. `source_paths`, `detail`, and
|
||||
`max_heap_mb` do not bypass or raise it. Analyze an extracted directory, or
|
||||
supply an archive whose header fits.
|
||||
|
||||
Exact `#alias` specifiers resolve through the importer's
|
||||
enclosing package `imports` map to package-local files; nested `node_modules`
|
||||
packages use their own map. Wildcard, recursive, and external-package import
|
||||
|
||||
@@ -94,6 +94,19 @@ dependencies. An omitted package kind stays null. A rustc commit hash is
|
||||
inferred from a read-only `/rustc/<40-hex>/` path and does not by itself
|
||||
identify the compiler. See [Rust binaries](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md).
|
||||
|
||||
## PE Authenticode certificate tables
|
||||
|
||||
Use `inspect_pe_certificates` for an explicit local PE32/PE32+ image when the
|
||||
question concerns the Authenticode certificate table, WIN_CERTIFICATE types, or
|
||||
embedded PKCS#7 signer and X.509 identity fields. No `open_binary`, Windows SDK,
|
||||
or target execution is required. The CLI equivalent is
|
||||
`inspect-pe-certificates PATH`. Preserve artifact SHA-256, table file ranges,
|
||||
payload hashes, and original validity-time spellings. A missing
|
||||
`IMAGE_DIRECTORY_ENTRY_SECURITY` directory is observed absence of that table,
|
||||
not proof the image is unsigned in every catalog or overlay sense. Signatures,
|
||||
chains, timestamps, and Authenticode digest coverage remain unobserved. See
|
||||
[PE Authenticode certificate tables](https://github.com/morluto/rea/blob/main/docs/pe-certificates.md).
|
||||
|
||||
## SQLite snapshots
|
||||
|
||||
Use `inspect_sqlite_database` with an explicit local database snapshot for its
|
||||
|
||||
@@ -191,6 +191,7 @@ Additional tools and host support depend on the target:
|
||||
| IL2CPP metadata | Unity global-metadata.dat assembly images, type and method names and index ranges | Built-in static decoding of versions 24, 27, 29, 31 and 39; no Unity required |
|
||||
| Godot PCK | Archive inventory and extraction, original header/directory facts and selected content identities | Built-in static formats 2, 3 and 4; no Godot required |
|
||||
| Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](docs/pe-certificates.md) |
|
||||
| WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) |
|
||||
| SQLite databases | Tables, columns, indexes, views, triggers and selected ordinary-table rows | Local database/WAL snapshot; Node.js 24.x >=24.15 or 26+ with native SQLite limits |
|
||||
|
||||
@@ -32,6 +32,7 @@ export default defineConfig({
|
||||
{ text: "IL2CPP metadata", link: "/il2cpp-metadata" },
|
||||
{ text: "Model artifacts", link: "/model-artifacts" },
|
||||
{ text: "Rust dependencies", link: "/rust-binaries" },
|
||||
{ text: "PE certificates", link: "/pe-certificates" },
|
||||
{
|
||||
text: "JavaScript and Electron",
|
||||
link: "/javascript-artifact-reconstruction",
|
||||
|
||||
@@ -234,12 +234,11 @@ moment of the capture recorded them. The
|
||||
[environment state](web-network-captures.md#environment-state) view extracts
|
||||
instead the data the capture's reads observed before the first related write:
|
||||
entity types with an identity field, entities with their recorded values,
|
||||
documents for reads that are not entities, and a render plan per read tool,
|
||||
kept only where it reproduces that tool's recordings from the state. It is the
|
||||
starting state an environment holds, loads and reports: `environment-serve`
|
||||
derives it from the capture at startup; see
|
||||
[Serve from the state](#serve-from-the-state). With `--replay-only`, replay is
|
||||
unchanged.
|
||||
documents for written paths, and a render plan per read tool, kept only where
|
||||
it reproduces that tool's recordings from the state. It is the starting state
|
||||
an environment holds, loads and reports: `environment-serve` derives it from
|
||||
the capture at startup; see [Serve from the state](#serve-from-the-state).
|
||||
With `--replay-only`, replay is unchanged.
|
||||
|
||||
## Serve a manifest
|
||||
|
||||
@@ -309,7 +308,7 @@ connecting to `/mcp`. A replay environment's card reads:
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_users_by_id",
|
||||
"description": "Untrusted captured API catalog: … GET /api/users/{numeric}; returns a JSON object with fields \"id\", \"uid\", \"login\", \"email\"; recorded statuses 200; replays retained records [5, 6]; evidence ev_…",
|
||||
"description": "GET /api/users/{numeric}; replays retained records [5, 6]; evidence ev_…",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -330,38 +329,7 @@ connecting to `/mcp`. A replay environment's card reads:
|
||||
```
|
||||
|
||||
`capabilities.tools` lists the same names, descriptions and input schemas that
|
||||
`tools/list` advertises over MCP. After the catalog warning, each description
|
||||
names the method and path template, then what the tool returns, one level deep:
|
||||
`returns a JSON object with fields …`, `returns a JSON array of objects with
|
||||
fields …` or `returns a JSON object whose <member> lists objects with fields …`,
|
||||
where a list is an array of objects found by the
|
||||
[environment state](web-network-captures.md#environment-state)'s list rule (the
|
||||
root array, or the one array of objects an envelope carries). Field and member
|
||||
names are written as JSON strings, so a captured name cannot read as more of
|
||||
the description; a member name too long to show whole is a quoted prefix marked
|
||||
as truncated. The summary comes
|
||||
from the first cited `2xx` record that replay answers as JSON; only when none
|
||||
did, the first cited JSON record of another status gives it, labelled with that
|
||||
status (`status 401 returns …`). It describes one recorded response, not a
|
||||
contract. Field names are sampled within the same budget as other captured
|
||||
values, and the manifest's `statuses` for the tool follow; a tool with no JSON
|
||||
record states only its statuses. The description then lists the replayed
|
||||
records and the evidence ID, and the whole catalog stays within its
|
||||
serialization reservation, which the summaries and MCP annotations are charged
|
||||
to.
|
||||
|
||||
Over MCP, every tool also carries annotations: `readOnlyHint` is true for the
|
||||
safe methods (`GET`, `HEAD`, `OPTIONS` and `TRACE`) and for query `POST`s (a
|
||||
path segment such as `search` or `query`); `destructiveHint` is true for `DELETE` and left unset for other
|
||||
writes, so the MCP default (destructive) applies to them, and read-only tools
|
||||
omit it because clients ignore it there; `idempotentHint` is true for the safe
|
||||
methods, `PUT` and `DELETE`; and `openWorldHint` is false for every tool,
|
||||
because answers come from the capture rather than the captured application. The
|
||||
hints describe the captured method; replay itself changes nothing. The card
|
||||
keeps the `name`, `description` and `inputSchema` of the echo example's tool
|
||||
shape and carries no annotations.
|
||||
|
||||
Path parameters are required strings whose
|
||||
`tools/list` advertises over MCP. Path parameters are required strings whose
|
||||
description names the segment index, the templating rule and the observed
|
||||
values (sampled within the list budget above; the manifest keeps them all);
|
||||
query parameter names are optional strings; write methods accept an
|
||||
|
||||
+40
-19
@@ -385,7 +385,8 @@ section explicitly truncated.
|
||||
Disallowed-origin entries are filtered before retention.
|
||||
|
||||
Selected script-source collection shares a 32 MiB aggregate UTF-8 retention
|
||||
budget and a 20-second deadline across the capture. An oversized source is
|
||||
budget and the `command_timeout_ms` deadline (default 20000 ms) across the
|
||||
capture. The byte budget has no runtime override. An oversized source is
|
||||
omitted while later smaller sources may still fit. When the deadline expires,
|
||||
remaining source reads are skipped. Script metadata, per-item omission reasons,
|
||||
and `script_sources` completeness remain available. A single CDP response can
|
||||
@@ -393,19 +394,25 @@ still be materialized up to the separate 64 MiB transport limit before source
|
||||
admission; the retention budget does not bound that transient allocation.
|
||||
|
||||
Bundle parsing and semantic inspection run sequentially in an owned child,
|
||||
without executing captured code. `max_heap_mb` defaults to 1024 MiB V8
|
||||
old-space; `analysis_timeout_ms` defaults to 30000 per script, including startup
|
||||
and transfer. A failed or timed-out script becomes an explicit unknown while
|
||||
completed findings and later scripts remain available. `total_analysis_timeout_ms`
|
||||
defaults to 300000 across script analysis; expiry returns a typed timeout with
|
||||
partial Evidence and identifies unstarted scripts. These controls are also
|
||||
available as CLI flags with hyphenated names. Capture and optional source-map
|
||||
fetching have separate budgets.
|
||||
without executing captured code. These are separate phases. `max_heap_mb`
|
||||
defaults to 1024 MiB V8 old-space (range 128..16384). `analysis_timeout_ms`
|
||||
defaults to 30000 per script, including startup and transfer.
|
||||
`total_analysis_timeout_ms` defaults to 300000 across script analysis. A
|
||||
per-script deadline above the effective total is rejected. A failed or
|
||||
timed-out script becomes an explicit unknown while completed findings and later
|
||||
scripts remain available. Total-deadline expiry returns a typed timeout with
|
||||
partial Evidence and identifies unstarted scripts. `source_map_fetch_timeout_ms`
|
||||
defaults to 30000 and is an item limitation, not that analysis timeout. These
|
||||
controls are also available as CLI flags with hyphenated names. Worker protocol
|
||||
and result byte budgets have no runtime override.
|
||||
|
||||
One web-bundle capture/analysis is admitted per REA process at a time. Completed
|
||||
script results share an 8 MiB serialized transfer budget, with host heap
|
||||
reservation checked before decoding and validating another result.
|
||||
Exhausted result capacity stops further script launches and returns a typed
|
||||
When the host reservation is what reaches zero, the failure names that heap
|
||||
headroom and the remaining fixed budget. When the 8 MiB allowance itself is
|
||||
exhausted, the failure names that fixed budget and says it has no runtime
|
||||
override. Either stop ends further script launches and returns a typed
|
||||
resource failure with completed facts and explicit unstarted-script unknowns.
|
||||
Worker control frames are limited to 64 KiB. Cleanup must confirm child termination
|
||||
before releasing its private files; failed cleanup retains its exact owner and
|
||||
@@ -415,8 +422,10 @@ facts, including cancellation during final cleanup.
|
||||
The worker heap setting is not a process-family RSS limit. Node enforces V8
|
||||
old-space flags; Bun ignores them. Windows Bun instead uses an owned process
|
||||
commit limit with a 512 MiB runtime reserve; other Bun hosts have no enforced
|
||||
memory limit. These bounds do not establish an aggregate host-memory limit
|
||||
across other REA operations or external processes.
|
||||
memory limit. An allocation failure before that Windows limit is reported as
|
||||
reached still names the configured limit and says it was not reached. These
|
||||
bounds do not establish an aggregate host-memory limit across other REA
|
||||
operations or external processes.
|
||||
|
||||
Resource-tree inventories distinguish a valid empty `resources` array from a
|
||||
missing or malformed collection. Invalid resource entries are omitted with
|
||||
@@ -430,12 +439,24 @@ an invalid supplied size remains unknown with incomplete resource coverage while
|
||||
the resource's other facts are retained. Zero and nonnegative fractional sizes
|
||||
are preserved.
|
||||
|
||||
CDP discovery has a 10-second deadline. Each WebSocket handshake and command
|
||||
response has its own 20-second deadline, separate from the caller-selected
|
||||
observation window. A stalled handshake or command reports `provider_timeout`;
|
||||
caller cancellation still reports cancellation. WebSocket messages remain subject
|
||||
to the [64 MiB limit](#request-boundary). REA does not impose a message-count or
|
||||
pending-command quota. On cancellation, it closes only its own WebSocket,
|
||||
CDP discovery has a 10-second response deadline. HTTP discovery documents
|
||||
use an 8 MiB budget. Browser-socket discovery (`Browser.getVersion`,
|
||||
`Target.getTargets`) uses the 64 MiB protocol message budget. Neither budget
|
||||
nor the discovery deadline has a runtime override. `list_browser_targets` accepts no
|
||||
`command_timeout_ms`: HTTP discovery and browser-socket discovery
|
||||
(`Browser.getVersion`, `Target.getTargets`) stay on that fixed deadline, and
|
||||
the socket closes without a later command. Other CDP tools use
|
||||
`command_timeout_ms` (default 20000) for each WebSocket handshake and command
|
||||
response, separate from the caller-selected observation window, discovery, and
|
||||
the 5000 ms cleanup grace. A browser-level socket's handshake and the following
|
||||
`Target.attachToTarget` each receive that full deadline; they do not share one
|
||||
budget. A stalled handshake or command reports
|
||||
`provider_timeout` and names `command_timeout_ms` only when that operation
|
||||
accepts it; caller cancellation still reports cancellation. WebSocket messages
|
||||
remain subject to the [64 MiB limit](#request-boundary), which has no runtime
|
||||
override. REA does
|
||||
not impose a message-count or pending-command quota. On cancellation, it closes
|
||||
only its own WebSocket,
|
||||
waiting up to one second before terminating it. Malformed unsolicited events
|
||||
and malformed replies to pending commands poison the connection so later
|
||||
commands fail closed. A correlated reply's session ID must match the command's
|
||||
@@ -444,7 +465,7 @@ rejects it as unknown; other command rejections retain their reported failure.
|
||||
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Scripts are different: scripts the page parsed before REA attached are still reported under `scripts`. A page that loaded before attach can therefore list its scripts while `network.requests` lacks the requests that fetched them. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
|
||||
|
||||
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation has a 30-second deadline and a 64 MiB aggregate streamed-response budget across redirects. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding also bounds raw resolved identities to 64 MiB and aggregate inline evidence to 32 MiB. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
|
||||
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation uses `source_map_fetch_timeout_ms` (default 30000) and a 64 MiB aggregate streamed-response budget across redirects. The byte budget has no runtime override. A fetch overrun is an item limitation, not a thrown analysis timeout. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding also bounds raw resolved identities to 64 MiB and aggregate inline evidence to 32 MiB. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
|
||||
|
||||
source-map items retain the script's `declared_url` separately from the observed
|
||||
`effective_url`. successful maps use the latter as their source-resolution base.
|
||||
|
||||
@@ -23,9 +23,21 @@ that rejection does not roll back browser effects. REA removes its owned
|
||||
future-document scripts, restores live frame methods, then disconnects.
|
||||
Add secret declarations, storage seeds, and additional artifact or event
|
||||
capture only when needed. Action, secret, and storage counts are not capped.
|
||||
Duration, action, and navigation timeouts remain fixed provider-owned liveness
|
||||
limits. `capture.limits` controls retained event and network-content budgets;
|
||||
it does not change those liveness deadlines.
|
||||
Duration, action, and navigation timeouts remain separate from CDP command
|
||||
deadlines. `command_timeout_ms` (default 20000; omit it to keep that default
|
||||
and leave it out of Evidence) bounds launch startup, the connect-over-CDP
|
||||
handshake, target lookup, environment commands, and storage CDP commands,
|
||||
including replies from attached child targets. Set it in the scenario JSON.
|
||||
`capture-browser-scenario` has no `--command-timeout-ms` option.
|
||||
It does not change action `timeout_ms`, `wait_for_timeout.duration_ms`, or
|
||||
navigation. Service-worker restoration keeps a fixed 5000 ms grace and does
|
||||
not inherit a raised `command_timeout_ms`. CDP discovery stays on its own
|
||||
fixed deadline. When service workers are blocked, the registration check
|
||||
before discovery has its own fixed 10000 ms deadline; `command_timeout_ms`
|
||||
does not apply because discovery has not started. The discovery deadline
|
||||
covers endpoint discovery only. The following WebSocket handshake and
|
||||
`Target.attachToTarget` each use `command_timeout_ms`. `capture.limits` controls retained event and network-content
|
||||
budgets; it does not change those liveness deadlines.
|
||||
|
||||
## Minimal launch example
|
||||
|
||||
|
||||
+12
@@ -145,6 +145,18 @@ dependency edges, and the section's file range. A missing `.dep-v0` section is
|
||||
null metadata, not proof that the binary has no dependencies. See
|
||||
[Rust binaries](rust-binaries.md).
|
||||
|
||||
Inspect a PE Authenticode certificate table without executing the image or
|
||||
verifying signatures:
|
||||
|
||||
```bash
|
||||
rea inspect-pe-certificates /absolute/path/to/application.exe --json
|
||||
```
|
||||
|
||||
The workflow reports `WIN_CERTIFICATE` entries and, for PKCS#7 signedData,
|
||||
embedded X.509 serial, issuer, subject, and validity fields. A missing security
|
||||
directory is observed absence of that table. See
|
||||
[PE Authenticode certificate tables](pe-certificates.md).
|
||||
|
||||
Configure [Hopper or Ghidra](installation.md#hopper), the
|
||||
[IDA adapter](ida-provider.md), or [Kuna](kuna-provider.md) before analyzing a
|
||||
native target. Substitute
|
||||
|
||||
@@ -245,4 +245,10 @@ application path and capture outcome to the static graph, while frames, scripts,
|
||||
workers, and execution claims remain unavailable until a separate passive runtime
|
||||
capture provides them.
|
||||
|
||||
ASAR observation reads the header through one open file handle, validates its declared lengths against the archive size, and limits encoded JSON headers to 16 MiB before parsing. The header cache also retains at most 16 MiB of encoded headers across eight archives. Oversized headers produce a scope refusal.
|
||||
ASAR observation reads the header through one open file handle and validates its
|
||||
declared lengths against the archive size. Header/index admission is the same
|
||||
fixed 16 MiB / 16777216 byte budget used by static ASAR reads. This release has
|
||||
no runtime override. An oversized header is not parsed; selecting a member path
|
||||
does not bypass admission, and the page stays unauthorized. The header cache
|
||||
separately retains at most 16 MiB of already-admitted encoded headers across
|
||||
eight archives. Evicting that cache does not raise admission.
|
||||
+1
-1
@@ -50,7 +50,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md).
|
||||
| Task | Guides |
|
||||
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) |
|
||||
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
|
||||
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [PE certificates](pe-certificates.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
|
||||
| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) |
|
||||
| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) |
|
||||
| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) |
|
||||
|
||||
@@ -106,6 +106,15 @@ only reduces delivery; source selection reduces text acquisition and analysis.
|
||||
Explicit native provider or snapshot routing rejects `--source-path` rather
|
||||
than ignoring it.
|
||||
|
||||
ASAR header/index admission is a fixed 16 MiB / 16777216 bytes for this
|
||||
release. There is no runtime override. The archive index is read before any
|
||||
member can be selected, so `source_paths`, `--source-path`, `detail:
|
||||
"summary"`, `max_heap_mb`, and available host memory do not bypass or raise
|
||||
it. When the header exceeds that allowance, the failure names this budget.
|
||||
Analyze an extracted application directory, which does not admit an ASAR
|
||||
header, or supply an archive whose header fits. Repeating the unchanged
|
||||
request fails the same way.
|
||||
|
||||
## Read the analysis
|
||||
|
||||
`analyze_javascript_application` returns the complete analysis Evidence by
|
||||
@@ -172,7 +181,8 @@ for the tool-result contract.
|
||||
|
||||
Use MCP `detail: "summary"` for a large application, then read selected views of
|
||||
the retained analysis. Summary changes delivery only. To reduce source parsing,
|
||||
select exact [source paths](#select-sources-before-analysis).
|
||||
select exact [source paths](#select-sources-before-analysis). Source selection
|
||||
does not bypass ASAR header/index admission.
|
||||
|
||||
If a complete response exceeds the transport budget, use its retained Evidence
|
||||
ID for a summary, module page, or item, or export the bundle. See
|
||||
|
||||
@@ -622,6 +622,9 @@ which managed methods executed or how the CLR resolved them.
|
||||
|
||||
Managed member comparisons report a `metadata` dimension when an observed
|
||||
MethodDef flags or implementation flags value differs, including accessibility
|
||||
or synchronization changes. This is separate from the CIL/signature matching
|
||||
tiers: identical instructions do not establish unchanged method metadata. The
|
||||
reported difference is static metadata evidence, not proof of runtime behavior.
|
||||
or synchronization changes. Observed method-body `init_locals` and `max_stack`
|
||||
differences also contribute to `metadata`, even when CIL decoding is incomplete.
|
||||
An unavailable header value keeps body coverage unknown rather than establishing
|
||||
equality or a difference. These comparisons are separate from the CIL/signature
|
||||
matching tiers: identical instructions do not establish unchanged method metadata.
|
||||
The reported difference is static metadata evidence, not proof of runtime behavior.
|
||||
@@ -234,11 +234,14 @@ The destination changes only when a complete export is atomically published.
|
||||
|
||||
Clients that explicitly configure a larger receive buffer can set the REA
|
||||
server's `REA_MCP_MAX_RESPONSE_BYTES` environment variable to the same byte
|
||||
count. This setting must be a safe decimal integer at least 10485760; REA
|
||||
reserves 1024 bytes for the envelope. Raising it restores complete inline
|
||||
delivery for responses that fit that buffer and Node's single-string limit.
|
||||
It does not change the client's buffer, analysis coverage, or retained content.
|
||||
Ordinary responses keep their existing complete result contract.
|
||||
count. This setting must be a safe decimal integer at least 10485760 (10 MiB);
|
||||
REA reserves 1024 bytes for the envelope. The value is read when the REA
|
||||
process starts, so a change requires a restart and a matching client receive
|
||||
buffer. Raising it restores complete inline delivery for responses that fit
|
||||
that buffer. It does not raise REA's reserved single-string limit, which is
|
||||
1024 UTF-16 code units below Node's single-string ceiling, and it does not
|
||||
raise that Node ceiling. It does not change analysis coverage or retained
|
||||
content. Ordinary responses keep their existing complete result contract.
|
||||
|
||||
### Error recovery and deadline ownership
|
||||
|
||||
@@ -329,7 +332,7 @@ See [environment manifests](web-network-captures.md#environment-manifest).
|
||||
|
||||
`inspect_analysis_view` also accepts `environment-state` for the same parents,
|
||||
extracting the initial state the recorded reads observed: entity types with an
|
||||
identity field, entities, documents for non-entity reads, and a render
|
||||
identity field, entities, documents for written non-entity paths, and a render
|
||||
plan per read tool that reproduces its recordings from the state or names why it
|
||||
cannot. Types and identity are inferences, so the Evidence is always inferred.
|
||||
See [environment state](web-network-captures.md#environment-state).
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# PE Authenticode certificate tables
|
||||
|
||||
`inspect_pe_certificates` / `inspect-pe-certificates` answers whether one
|
||||
selected local PE32/PE32+ image declares an `IMAGE_DIRECTORY_ENTRY_SECURITY`
|
||||
table, which `WIN_CERTIFICATE` entries that table stores, and which PKCS#7
|
||||
signer and X.509 fields those entries carry. It reads the file without
|
||||
executing it and does not require a Windows SDK, catalog file, or disassembler.
|
||||
|
||||
```bash
|
||||
rea inspect-pe-certificates /absolute/path/to/application.exe --json
|
||||
```
|
||||
|
||||
The CLI resolves relative paths against the caller's working directory. MCP
|
||||
requires an absolute filesystem path and preserves that exact selection:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "inspect_pe_certificates",
|
||||
"arguments": { "path": "/artifacts/application.exe" }
|
||||
}
|
||||
```
|
||||
|
||||
## Observations
|
||||
|
||||
The inline Evidence result contains the artifact path, SHA-256, and byte length.
|
||||
`table` is the file-offset security directory, or `null` when the directory is
|
||||
absent or zero. Each `entries[]` item reports `WIN_CERTIFICATE` length,
|
||||
revision, type, and the payload SHA-256.
|
||||
|
||||
For `WIN_CERTIFICATE` type 2 (PKCS#7 signedData) `pkcs7` contains digest
|
||||
algorithm OIDs, embedded X.509 serial/issuer/subject/validity fields, and
|
||||
signer identifier facts. Other certificate types keep `pkcs7` null.
|
||||
|
||||
A missing table is observed absence of that directory. It is not proof the
|
||||
image is unsigned in every catalog or overlay sense.
|
||||
|
||||
## Limits and failures
|
||||
|
||||
The selected file is limited to 64 MiB by default (512 MiB maximum). At most
|
||||
eight `WIN_CERTIFICATE` entries are examined by default. Malformed PE headers,
|
||||
truncated certificate entries, and PKCS#7 that is not structurally signedData
|
||||
fail without partial success. A security directory that ends in fewer than
|
||||
eight header bytes, including zeros, is a truncated `WIN_CERTIFICATE` and
|
||||
fails the same way. SignedData must contain at least one `SignerInfo`, and
|
||||
each `SignerInfo` must carry a non-empty signature value. Decoded certificate
|
||||
name text shares a 64 KiB budget per PKCS#7 payload.
|
||||
|
||||
This workflow does not verify signatures, certificate chains, timestamps, or
|
||||
Authenticode digest coverage of image bytes.
|
||||
@@ -143,6 +143,7 @@ npx rea-agents@latest setup
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| الملفات الثنائية للشيفرة الأصلية | شيفرة شبه برمجية، وتعليمات تجميع، وسلاسل نصية، ورموز، واستدعاءات، ومراجع | Hopper أو Ghidra أو IDA؛ [تحليل الشيفرة الأصلية](../native-investigation.md) |
|
||||
| بنية ELF دون تشغيل | الأقسام والمقاطع والرموز ومعلومات إعادة التموضع الأصلية، وآليات الحماية المحتملة المستدلّ عليها بالفحص الثابت | pwntools يوفّره المستدعي على Linux x64؛ [تشخيص الملفات الثنائية](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| شيفرة EVM البايتية | محددات التوجيه، وإزاحات البايتات، والمعاملات المستنتجة، وقابلية تغيير الحالة | مدخل محلي يحمل بايتات خامًا أو تمثيلًا سداسيًا عشريًا على Linux x64؛ [دليل EVM دون تشغيل](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| أعطال Linux المسجّلة | سجلات note الخام، ومسجّلات المعالج/الإشارات لكل خيط مسجّل، وترشيحات اختيارية لخرائط الذاكرة | pwntools يوفّره المستدعي على Linux x64؛ GDB/pwndbg اختياريان؛ [الأعطال المسجّلة](../recorded-crashes.md) |
|
||||
|
||||
@@ -143,6 +143,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz
|
||||
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native Binärdateien | Pseudocode, Assemblercode, Zeichenketten, Symbole, Aufrufe und Verweise | Hopper, Ghidra oder IDA; [native Analyse](../native-investigation.md) |
|
||||
| Offline-ELF-Struktur | Sektionen, Segmente, ursprüngliche Symbole/Relokationen und mögliche Schutzmaßnahmen aus statischer Analyse | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; [Binärdiagnostik](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| EVM-Bytecode | Dispatch-Selektoren, Byte-Offsets, abgeleitete Argumente und Veränderbarkeit des Zustands | Lokale Eingabe mit Rohbytes oder Hexdarstellung unter Linux x64; [Offline-EVM-Leitfaden](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Aufgezeichnete Linux-Abstürze | Unverarbeitete Note-Einträge, Register/Signale aller aufgezeichneten Threads und optionale Zuordnungskandidaten | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; optional GDB/pwndbg; [aufgezeichnete Abstürze](../recorded-crashes.md) |
|
||||
|
||||
@@ -149,6 +149,7 @@ npx rea-agents@latest setup
|
||||
| Δομή ELF χωρίς εκτέλεση | Ενότητες, τμήματα, αρχικά σύμβολα/εγγραφές μετεγκατάστασης και ενδείξεις πιθανών μέτρων προστασίας από στατική ανάλυση | pwntools που παρέχει ο χρήστης σε Linux x64· [διαγνωστικά δυαδικών αρχείων](../binary-diagnostics.md) |
|
||||
| Δυαδικά αρχεία Go | Ενσωματωμένη έκδοση μεταγλωττιστή, modules, αντικαταστάσεις και ρυθμίσεις μεταγλώττισης | Ενσωματωμένη στατική εξέταση ELF/PE/thin Mach-O· δεν απαιτείται εγκατάσταση του Go |
|
||||
| Δυαδικά αρχεία Rust | Ενσωματωμένα ονόματα, εκδόσεις, πηγές και σχέσεις εξάρτησης των crates από το cargo-auditable | Ενσωματωμένη στατική εξέταση ELF/PE/thin Mach-O· [δυαδικά αρχεία Rust](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Αρχείο WASM | Έλεγχο εγκυρότητας, ενότητες, ακριβείς μορφές εισαγωγών/εξαγωγών σε WAT και αποκωδικοποιημένο WAT | WABT 1.0.42 που παρέχει ο χρήστης· `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Selectors επιλογής συνάρτησης, μετατοπίσεις σε bytes, εκτιμώμενα ορίσματα και δυνατότητα μεταβολής της κατάστασης | Τοπική είσοδος σε ακατέργαστα bytes ή δεκαεξαδική μορφή σε Linux x64· [οδηγός EVM χωρίς εκτέλεση](../evm-bytecode.md) |
|
||||
| Βάσεις δεδομένων SQLite | Πίνακες, στήλες, ευρετήρια, όψεις, triggers και επιλεγμένες γραμμές κανονικών πινάκων | Τοπικό στιγμιότυπο βάσης δεδομένων/WAL· Node.js 24.x >=24.15 ή 26+ με εγγενή όρια SQLite |
|
||||
|
||||
@@ -143,6 +143,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Binarios nativos | Pseudocódigo, ensamblador, cadenas, símbolos, llamadas y referencias | Hopper, Ghidra o IDA; [análisis nativo](../native-investigation.md) |
|
||||
| Estructura ELF sin ejecutar | Secciones, segmentos, símbolos/reubicaciones originales y posibles medidas de mitigación detectadas mediante análisis estático | pwntools proporcionado por quien llama, en Linux x64; [diagnóstico de binarios](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Bytecode EVM | Selectores de despacho, desplazamientos en bytes, argumentos inferidos y mutabilidad | Entrada local con bytes sin procesar o representación hexadecimal, en Linux x64; [guía de EVM sin ejecutar](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Fallos de Linux registrados | Registros note sin procesar, registros/señales de cada hilo registrado y posibles correspondencias de memoria opcionales | pwntools proporcionado por quien llama, en Linux x64; GDB/pwndbg opcionales; [fallos registrados](../recorded-crashes.md) |
|
||||
|
||||
@@ -135,6 +135,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، بههمراه np
|
||||
| ---------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| باینریهای بومی | شبهکد، اسمبلی، رشتهها، نمادها، فراخوانیها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](../native-investigation.md) |
|
||||
| ساختار ELF آفلاین | بخشها، سگمنتها، نمادها و relocationهای اصلی و گزینههای احتمالی کاهش آسیبپذیری ایستا | pwntools تأمینشده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| بایتکد EVM | selectorهای dispatch، offsetهای بایتی، آرگومانهای استنباطشده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| کرشهای ثبتشدهٔ لینوکس | یادداشتهای خام، رجیسترها/سیگنالهای تمام threadهای ثبتشده و گزینههای احتمالی نگاشت | pwntools تأمینشده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرشهای ثبتشده](../recorded-crashes.md) |
|
||||
|
||||
@@ -146,6 +146,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les
|
||||
| ---------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Binaires natifs | Pseudo-code, assembleur, chaînes, symboles, appels et références | Hopper, Ghidra ou IDA ; [analyse native](../native-investigation.md) |
|
||||
| Structure ELF hors ligne | Sections, segments, symboles et relocations d'origine, et protections statiques candidates | pwntools fourni par l'utilisateur sous Linux x64 ; [diagnostics binaires](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Artefact WASM | Validation, sections, formes WAT exactes d'import/export et WAT décodé | WABT 1.0.42 fourni par l'utilisateur ; `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Sélecteurs de dispatch, offsets en octets, arguments inférés et mutabilité | Fichier local brut ou hexadécimal sous Linux x64 ; [guide EVM hors ligne](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
|
||||
@@ -149,6 +149,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis:
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Biner native | Pseudocode, assembly, string, simbol, pemanggilan, dan referensi | Hopper, Ghidra, atau IDA; [analisis native](../native-investigation.md) |
|
||||
| Tata letak ELF offline | Bagian, segmen, simbol/relokasi asli, dan kandidat mitigasi statis | pwntools yang disediakan pemanggil pada Linux x64; [diagnostik biner](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Artefak WASM | Validasi, bagian, bentuk WAT import/ekspor yang tepat, dan WAT yang didekode | WABT 1.0.42 yang disediakan pemanggil; `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
|
||||
@@ -146,6 +146,7 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Binari nativi | Pseudocodice, assembly, stringhe, simboli, chiamate e riferimenti | Hopper, Ghidra o IDA; [analisi nativa](../native-investigation.md) |
|
||||
| Layout ELF offline | Sezioni, segmenti, simboli/relocazioni originali e candidati mitigazioni statiche | pwntools fornito dal chiamante su Linux x64; [diagnostica binaria](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Artefatto WASM | Convalida, sezioni, forme WAT di import/export esatte e WAT decodificato | WABT 1.0.42 fornito dal chiamante; `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Selettori di dispatch, offset dei byte, argomenti inferiti e mutabilità | Carrier raw/hex locale; [guida EVM offline](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
|
||||
@@ -143,6 +143,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | Hopper、Ghidra、または IDA;[ネイティブ分析](../native-investigation.md) |
|
||||
| オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](../recorded-crashes.md) |
|
||||
|
||||
@@ -153,6 +153,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
|
||||
| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) |
|
||||
| Go 바이너리 | 내장된 컴파일러 버전, 모듈, `replace` 지시어, 빌드 설정 | REA 내장 ELF/PE/thin Mach-O 정적 검사; Go 설치 불필요 |
|
||||
| Rust 바이너리 | cargo-auditable이 내장한 크레이트 이름, 버전, 소스, 의존성 관계 | REA 내장 ELF/PE/thin Mach-O 정적 검사; [Rust 바이너리](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| WASM 아티팩트 | 유효성 검사, 섹션, import/export의 정확한 WAT 형식, 디코딩된 WAT | 호출자 제공 WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) |
|
||||
| SQLite 데이터베이스 | 테이블, 열, 인덱스, 뷰, 트리거, 선택한 일반 테이블의 행 | 로컬 데이터베이스/WAL 스냅샷; Node.js 24.15 이상의 24.x 또는 26 이상(네이티브 SQLite 한도 적용) |
|
||||
|
||||
@@ -151,6 +151,7 @@ REA-യ്ക്ക് Node.js 22.x (>=22.19), 24.x (>=24.11), അല്ലെ
|
||||
| Offline ELF layout | Sections, segments, യഥാർത്ഥ symbols/relocations, static mitigation candidates | Linux x64-ൽ caller നൽകുന്ന pwntools; [binary diagnostics](../binary-diagnostics.md) |
|
||||
| Go binaries | ഉൾച്ചേർത്ത compiler version, modules, replacements, build settings | Built-in static ELF/PE/thin Mach-O പരിശോധന; Go installation ആവശ്യമില്ല |
|
||||
| Rust binaries | ഉൾച്ചേർത്ത cargo-auditable crate പേരുകൾ, versions, sources, dependency edges | Built-in static ELF/PE/thin Mach-O പരിശോധന; [Rust binaries](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| WASM artifact | Validation, sections, കൃത്യമായ import/export WAT രൂപങ്ങൾ, decoded WAT | Caller നൽകുന്ന WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM bytecode | Dispatch selectors, byte offsets, അനുമാനിച്ച arguments, mutability | Linux x64-ൽ local raw/hex carrier; [offline EVM guide](../evm-bytecode.md) |
|
||||
| SQLite databases | Tables, columns, indexes, views, triggers, തിരഞ്ഞെടുത്ത സാധാരണ table rows | Local database/WAL snapshot; native SQLite പരിമിതികളോടെ Node.js 24.x >=24.15 അല്ലെങ്കിൽ 26+ |
|
||||
|
||||
@@ -143,6 +143,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na
|
||||
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Natywne pliki binarne | Pseudokod, kod asemblera, ciągi znaków, symbole, wywołania i odwołania | Hopper, Ghidra lub IDA; [analiza kodu natywnego](../native-investigation.md) |
|
||||
| Układ pliku ELF w trybie offline | Sekcje, segmenty, oryginalne symbole/relokacje i potencjalne mechanizmy zabezpieczeń wskazane przez analizę statyczną | pwntools dostarczone przez użytkownika w systemie Linux x64; [diagnostyka plików binarnych](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Kod bajtowy EVM | Selektory wywołań, przesunięcia bajtowe, wywnioskowane argumenty i mutowalność | Lokalny plik z surowymi bajtami lub zapisem szesnastkowym w systemie Linux x64; [przewodnik EVM offline](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Zarejestrowane awarie systemu Linux | Surowe rekordy note, rejestry/sygnały każdego zarejestrowanego wątku i opcjonalni kandydaci na mapowania | pwntools dostarczone przez użytkownika w systemie Linux x64; opcjonalnie GDB/pwndbg; [zarejestrowane awarie](../recorded-crashes.md) |
|
||||
|
||||
@@ -143,6 +143,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer
|
||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Binários nativos | Pseudocódigo, assembly, strings, símbolos, chamadas e referências | Hopper, Ghidra ou IDA; [análise nativa](../native-investigation.md) |
|
||||
| Estrutura ELF sem execução | Seções, segmentos, símbolos/relocações originais e possíveis mecanismos de mitigação identificados por análise estática | pwntools fornecido pelo chamador no Linux x64; [diagnóstico de binários](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Bytecode EVM | Seletores de despacho, offsets de bytes, argumentos inferidos e mutabilidade | Entrada local com bytes brutos ou representação hexadecimal no Linux x64; [guia de EVM sem execução](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Falhas do Linux registradas | Registros note brutos, registradores/sinais de cada thread registrada e candidatos opcionais a mapeamentos | pwntools fornecido pelo chamador no Linux x64; GDB/pwndbg opcionais; [falhas registradas](../recorded-crashes.md) |
|
||||
|
||||
@@ -145,6 +145,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так
|
||||
| Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](../binary-diagnostics.md) |
|
||||
| Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) |
|
||||
| Установленные настольные приложения | Возможные среды выполнения, файлы с доказательствами, компоненты и дальнейшие шаги | Ограниченная проверка каталогов и заголовков;[Настольные приложения](../desktop-applications.md) |
|
||||
| JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -143,6 +143,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26
|
||||
| ---------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ไบนารีเนทีฟ | โค้ดเทียม แอสเซมบลี สตริง สัญลักษณ์ การเรียก และการอ้างอิง | Hopper, Ghidra หรือ IDA; [การวิเคราะห์เนทีฟ](../native-investigation.md) |
|
||||
| โครงสร้าง ELF แบบออฟไลน์ | เซกชัน เซกเมนต์ สัญลักษณ์/รีโลเคชันต้นฉบับ และตัวเลือกกลไกป้องกันที่พบจากการวิเคราะห์แบบสถิต | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; [การวินิจฉัยไบนารี](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| ไบต์โค้ด EVM | ตัวเลือกฟังก์ชันสำหรับการเรียก ออฟเซ็ตไบต์ อาร์กิวเมนต์และความสามารถในการเปลี่ยนสถานะที่อนุมานได้ | ไฟล์บนเครื่องที่มีไบต์ดิบหรือข้อมูลเลขฐานสิบหกบน Linux x64; [คู่มือ EVM แบบออฟไลน์](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| ข้อมูลการแครชของ Linux ที่บันทึกไว้ | ระเบียน note ดิบ รีจิสเตอร์/สัญญาณของทุกเธรดที่บันทึกไว้ และตัวเลือกการแมปเพิ่มเติม | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; เลือกใช้ GDB/pwndbg เพิ่มเติมได้; [การแครชที่บันทึกไว้](../recorded-crashes.md) |
|
||||
|
||||
@@ -147,6 +147,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç
|
||||
| Çevrimdışı ELF yapısı | Bölümler, segmentler, özgün semboller/yeniden konumlandırmalar ve statik analizden olası güvenlik önlemleri | Linux x64 üzerinde çağıranın sağladığı pwntools; [ikili dosya tanılama](../binary-diagnostics.md) |
|
||||
| Go ikili dosyaları | Gömülü compiler sürümü, modüller, replacement'lar ve build ayarları | Yerleşik statik ELF/PE/thin Mach-O incelemesi; Go kurulumu gerekmez |
|
||||
| Rust ikili dosyaları | Gömülü cargo-auditable crate adları, sürümleri, kaynakları ve bağımlılık ilişkileri | Yerleşik statik ELF/PE/thin Mach-O incelemesi; [Rust ikili dosyaları](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| WASM artefaktı | Doğrulama, bölümler, tam import/export WAT biçimleri ve decode edilmiş WAT | Çağıranın sağladığı WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM bayt kodu | Yönlendirme seçicileri, bayt ofsetleri, çıkarımla belirlenen argümanlar ve durum değiştirilebilirliği | Linux x64 üzerinde yerel ham bayt/onaltılık girdi; [çevrimdışı EVM rehberi](../evm-bytecode.md) |
|
||||
| SQLite veritabanları | Tablolar, sütunlar, index'ler, view'lar, trigger'lar ve seçilen sıradan tablo satırları | Yerel database/WAL snapshot'ı; native SQLite sınırlarıyla Node.js 24.x >=24.15 veya 26+ |
|
||||
|
||||
@@ -143,6 +143,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Нативні бінарні файли | Псевдокод, асемблерний код, рядки, символи, виклики й посилання | Hopper, Ghidra або IDA; [нативний аналіз](../native-investigation.md) |
|
||||
| Структура ELF без запуску | Секції, сегменти, початкові символи/релокації та можливі захисні механізми за даними статичного аналізу | pwntools, наданий стороною, що викликає інструмент, у Linux x64; [діагностика бінарних файлів](../binary-diagnostics.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Байт-код EVM | Селектори диспетчеризації, зміщення байтів, виведені припущення про аргументи та змінюваність стану | Локальні необроблені байти або шістнадцяткове представлення у Linux x64; [посібник EVM без запуску](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Записані аварійні завершення в Linux | Необроблені записи note, регістри/сигнали кожного записаного потоку й необов'язкові кандидати зіставлення пам'яті | pwntools, наданий стороною, що викликає інструмент, у Linux x64; необов'язкові GDB/pwndbg; [записані аварійні завершення](../recorded-crashes.md) |
|
||||
|
||||
@@ -145,6 +145,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np
|
||||
| Bố cục ELF ngoại tuyến | Các section, segment, ký hiệu/thông tin tái định vị gốc và các biện pháp giảm thiểu có thể có từ phân tích tĩnh | pwntools do bên gọi cung cấp trên Linux x64; [chẩn đoán tệp nhị phân](../binary-diagnostics.md) |
|
||||
| Bytecode EVM | Bộ chọn điều phối, độ lệch byte, tham số suy luận và khả năng thay đổi trạng thái | Đầu vào cục bộ chứa byte thô hoặc dạng thập lục phân trên Linux x64; [hướng dẫn EVM ngoại tuyến](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| Sự cố Linux đã ghi lại | Bản ghi note thô, thanh ghi/tín hiệu của mọi luồng được ghi lại và các ứng viên ánh xạ tùy chọn | pwntools do bên gọi cung cấp trên Linux x64; GDB/pwndbg tùy chọn; [sự cố đã ghi lại](../recorded-crashes.md) |
|
||||
| Thư mục cài đặt ứng dụng máy tính | Các runtime có khả năng được dùng, tệp bằng chứng, dấu hiệu thành phần và bước tiếp theo | Kiểm tra thư mục và phần đầu tệp có giới hạn;[Ứng dụng máy tính](../desktop-applications.md) |
|
||||
| JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](../javascript-application-workflows.md) |
|
||||
|
||||
+27
-26
@@ -141,32 +141,33 @@ AI 代理通過 MCP 呼叫 REA,檢查目標並追蹤相關程式碼。REA 返
|
||||
|
||||
REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。額外工具和主機支援取決於分析目標:
|
||||
|
||||
| 目標 | REA 返回的內容 | 要求與指南 |
|
||||
| --------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| 原生二進位檔案 | 偽程式碼、組合語言、字串、符號、呼叫和引用 | Hopper、Ghidra 或 IDA;[原生分析](../native-investigation.md) |
|
||||
| 離線 ELF 佈局 | 節、段、原始符號/重定位資訊及靜態防護機制候選項 | Linux x64 上由呼叫方提供的 pwntools;[二進位診斷](../binary-diagnostics.md) |
|
||||
| Go 二進位檔案 | 內嵌編譯器版本、模組、替換項和建置設定 | 內建靜態 ELF/PE/thin Mach-O 檢查;無需安裝 Go |
|
||||
| WASM 工件 | 驗證、節、精確的匯入/匯出 WAT 形式及解碼後的 WAT | 呼叫方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
|
||||
| EVM 位元組碼 | 分派選擇器、位元組偏移、推斷的參數和狀態可變性 | Linux x64 上的本地原始位元組/十六進位輸入載體;[離線 EVM 指南](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) |
|
||||
| 桌面安裝目錄 | 執行階段候選、支持檔案、元件標記與後續操作 | 有界的目錄與檔頭檢查;[桌面應用](../desktop-applications.md) |
|
||||
| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](../javascript-application-workflows.md) |
|
||||
| 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](../browser-observation.md) |
|
||||
| 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](../web-network-captures.md) |
|
||||
| 封包擷取 | 端點、DNS、TLS SNI、通訊協定分佈和 HTTP 請求 | 呼叫端提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) |
|
||||
| .NET 程式集 | 中繼資料、CIL 指令、聲明的原生依賴和建置對比 | 靜態檢查;[托管程式碼指南](../managed-code-analysis.md) |
|
||||
| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
|
||||
| Android APK | 清單聲明、類別、反編譯的方法和引用 | Linux/macOS/Windows x64 上的無界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) |
|
||||
| Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](../adb-device-analysis.md) |
|
||||
| JEB 專案 | 引擎支援的專案單元、成品摘要和型別/方法的虛擬碼 | 由呼叫端啟動、提供 MCP 的 JEB 用戶端,透過 REA_JEB_MCP_URL 選擇;[JEB 指南](../jeb-analysis.md) |
|
||||
| Android 資源 | 解碼後的清單、字串表、語系和版本資訊 | 呼叫端提供的 Apktool 啟動器;[資源指南](../apktool-resource-analysis.md) |
|
||||
| Flutter 應用 | Dart 快照雜湊、依 ABI 的程式庫雜湊及引擎建置來源 | 無(靜態 APK 解析);[Flutter 指南](../flutter-analysis.md) |
|
||||
| 韌體 | 區域、提取結果和轉交原生分析的內容 | Linux 上的 Binwalk / Unblob;[韌體指南](../firmware-analysis.md) |
|
||||
| 封包記錄 | 指定欄位、重複值、篩選條件與解碼規則 | Linux: TShark 4.x + prlimit; [封包分析指南](../packet-captures.md) |
|
||||
| 錄製的邏輯訊號 | 解碼註解、樣本範圍與診斷資訊 | Linux: sigrok-cli 0.7.2 + prlimit; [訊號記錄指南](../signal-recordings.md) |
|
||||
| 軟體包與資源 | 檔案清單、摘要、plist、Apple bundle 結構和提取的資源 | [製品與 JavaScript 指南](../javascript-artifact-reconstruction.md)、[Apple 應用](../apple-application-analysis.md) |
|
||||
| 處理程序行為 | 終端輸出、互動、退出和檔案系統觀察結果,以及執行對比 | 支援原生 PTY 的 Linux/macOS;[處理程序擷取](../process-capture.md) |
|
||||
| 目標 | REA 返回的內容 | 要求與指南 |
|
||||
| --------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| 原生二進位檔案 | 偽程式碼、組合語言、字串、符號、呼叫和引用 | Hopper、Ghidra 或 IDA;[原生分析](../native-investigation.md) |
|
||||
| 離線 ELF 佈局 | 節、段、原始符號/重定位資訊及靜態防護機制候選項 | Linux x64 上由呼叫方提供的 pwntools;[二進位診斷](../binary-diagnostics.md) |
|
||||
| Go 二進位檔案 | 內嵌編譯器版本、模組、替換項和建置設定 | 內建靜態 ELF/PE/thin Mach-O 檢查;無需安裝 Go |
|
||||
| WASM 工件 | 驗證、節、精確的匯入/匯出 WAT 形式及解碼後的 WAT | 呼叫方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
|
||||
| EVM 位元組碼 | 分派選擇器、位元組偏移、推斷的參數和狀態可變性 | Linux x64 上的本地原始位元組/十六進位輸入載體;[離線 EVM 指南](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) |
|
||||
| 桌面安裝目錄 | 執行階段候選、支持檔案、元件標記與後續操作 | 有界的目錄與檔頭檢查;[桌面應用](../desktop-applications.md) |
|
||||
| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](../javascript-application-workflows.md) |
|
||||
| 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](../browser-observation.md) |
|
||||
| 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](../web-network-captures.md) |
|
||||
| 封包擷取 | 端點、DNS、TLS SNI、通訊協定分佈和 HTTP 請求 | 呼叫端提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) |
|
||||
| .NET 程式集 | 中繼資料、CIL 指令、聲明的原生依賴和建置對比 | 靜態檢查;[托管程式碼指南](../managed-code-analysis.md) |
|
||||
| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
|
||||
| Android APK | 清單聲明、類別、反編譯的方法和引用 | Linux/macOS/Windows x64 上的無界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) |
|
||||
| Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](../adb-device-analysis.md) |
|
||||
| JEB 專案 | 引擎支援的專案單元、成品摘要和型別/方法的虛擬碼 | 由呼叫端啟動、提供 MCP 的 JEB 用戶端,透過 REA_JEB_MCP_URL 選擇;[JEB 指南](../jeb-analysis.md) |
|
||||
| Android 資源 | 解碼後的清單、字串表、語系和版本資訊 | 呼叫端提供的 Apktool 啟動器;[資源指南](../apktool-resource-analysis.md) |
|
||||
| Flutter 應用 | Dart 快照雜湊、依 ABI 的程式庫雜湊及引擎建置來源 | 無(靜態 APK 解析);[Flutter 指南](../flutter-analysis.md) |
|
||||
| 韌體 | 區域、提取結果和轉交原生分析的內容 | Linux 上的 Binwalk / Unblob;[韌體指南](../firmware-analysis.md) |
|
||||
| 封包記錄 | 指定欄位、重複值、篩選條件與解碼規則 | Linux: TShark 4.x + prlimit; [封包分析指南](../packet-captures.md) |
|
||||
| 錄製的邏輯訊號 | 解碼註解、樣本範圍與診斷資訊 | Linux: sigrok-cli 0.7.2 + prlimit; [訊號記錄指南](../signal-recordings.md) |
|
||||
| 軟體包與資源 | 檔案清單、摘要、plist、Apple bundle 結構和提取的資源 | [製品與 JavaScript 指南](../javascript-artifact-reconstruction.md)、[Apple 應用](../apple-application-analysis.md) |
|
||||
| 處理程序行為 | 終端輸出、互動、退出和檔案系統觀察結果,以及執行對比 | 支援原生 PTY 的 Linux/macOS;[處理程序擷取](../process-capture.md) |
|
||||
|
||||
靜態 JavaScript 和 .NET 檢查讀取提供的檔案,不執行應用。執行階段擷取會以你的使用者權限執行目標或與之互動;各執行階段指南說明瞭具體影響。
|
||||
|
||||
|
||||
+27
-26
@@ -141,32 +141,33 @@ npx rea-agents@latest setup
|
||||
|
||||
REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。额外工具和主机支持取决于分析目标:
|
||||
|
||||
| 目标 | REA 返回的内容 | 要求与指南 |
|
||||
| --------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| 原生二进制文件 | 伪代码、汇编、字符串、符号、调用和引用 | Hopper、Ghidra 或 IDA;[原生分析](../native-investigation.md) |
|
||||
| 离线 ELF 布局 | 节、段、原始符号/重定位信息及静态防护机制候选项 | Linux x64 上由调用方提供的 pwntools;[二进制诊断](../binary-diagnostics.md) |
|
||||
| Go 二进制文件 | 内嵌编译器版本、模块、替换项和构建设置 | 内置静态 ELF/PE/thin Mach-O 检查;无需安装 Go |
|
||||
| WASM 工件 | 验证、节、精确的导入/导出 WAT 形式及解码后的 WAT | 调用方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
|
||||
| EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | Linux x64 上的本地原始字节/十六进制输入载体;[离线 EVM 指南](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) |
|
||||
| 桌面安装目录 | 运行时候选、支持文件、组件标记和后续操作 | 有界的目录与文件头检查;[桌面应用](../desktop-applications.md) |
|
||||
| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](../javascript-application-workflows.md) |
|
||||
| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](../browser-observation.md) |
|
||||
| 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](../web-network-captures.md) |
|
||||
| 数据包捕获 | 端点、DNS、TLS SNI、协议分布和 HTTP 请求 | 调用方提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) |
|
||||
| .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](../managed-code-analysis.md) |
|
||||
| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
|
||||
| Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS/Windows x64 上的无界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) |
|
||||
| Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](../adb-device-analysis.md) |
|
||||
| JEB 项目 | 引擎支持的项目单元、工件摘要和类型/方法的伪代码 | 由调用方启动、提供 MCP 的 JEB 客户端,通过 REA_JEB_MCP_URL 选择;[JEB 指南](../jeb-analysis.md) |
|
||||
| Android 资源 | 解码后的清单、字符串表、语言区域和版本信息 | 调用方提供的 Apktool 启动器;[资源指南](../apktool-resource-analysis.md) |
|
||||
| Flutter 应用 | Dart 快照哈希、按 ABI 的库摘要及引擎构建溯源 | 无(静态 APK 解析);[Flutter 指南](../flutter-analysis.md) |
|
||||
| 固件 | 区域、提取结果和转交原生分析的内容 | Linux 上的 Binwalk / Unblob;[固件指南](../firmware-analysis.md) |
|
||||
| 抓包文件 | 指定字段、重复值、过滤条件与解码规则 | Linux: TShark 4.x + prlimit; [抓包分析指南](../packet-captures.md) |
|
||||
| 录制的逻辑信号 | 解码注释、样本范围和诊断信息 | Linux: sigrok-cli 0.7.2 + prlimit; [信号记录指南](../signal-recordings.md) |
|
||||
| 软件包与资源 | 文件清单、摘要、plist、Apple bundle 结构和提取的资源 | [制品与 JavaScript 指南](../javascript-artifact-reconstruction.md)、[Apple 应用](../apple-application-analysis.md) |
|
||||
| 进程行为 | 终端输出、交互、退出和文件系统观察结果,以及运行对比 | 支持原生 PTY 的 Linux/macOS;[进程捕获](../process-capture.md) |
|
||||
| 目标 | REA 返回的内容 | 要求与指南 |
|
||||
| --------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| 原生二进制文件 | 伪代码、汇编、字符串、符号、调用和引用 | Hopper、Ghidra 或 IDA;[原生分析](../native-investigation.md) |
|
||||
| 离线 ELF 布局 | 节、段、原始符号/重定位信息及静态防护机制候选项 | Linux x64 上由调用方提供的 pwntools;[二进制诊断](../binary-diagnostics.md) |
|
||||
| Go 二进制文件 | 内嵌编译器版本、模块、替换项和构建设置 | 内置静态 ELF/PE/thin Mach-O 检查;无需安装 Go |
|
||||
| WASM 工件 | 验证、节、精确的导入/导出 WAT 形式及解码后的 WAT | 调用方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
|
||||
| EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | Linux x64 上的本地原始字节/十六进制输入载体;[离线 EVM 指南](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) |
|
||||
| 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) |
|
||||
| 桌面安装目录 | 运行时候选、支持文件、组件标记和后续操作 | 有界的目录与文件头检查;[桌面应用](../desktop-applications.md) |
|
||||
| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](../javascript-application-workflows.md) |
|
||||
| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](../browser-observation.md) |
|
||||
| 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](../web-network-captures.md) |
|
||||
| 数据包捕获 | 端点、DNS、TLS SNI、协议分布和 HTTP 请求 | 调用方提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) |
|
||||
| .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](../managed-code-analysis.md) |
|
||||
| PyInstaller | CArchive, PYZ, Python bytecode | Static inspection; [Python packaged applications](../python-packaged-applications.md) |
|
||||
| Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS/Windows x64 上的无界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) |
|
||||
| Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](../adb-device-analysis.md) |
|
||||
| JEB 项目 | 引擎支持的项目单元、工件摘要和类型/方法的伪代码 | 由调用方启动、提供 MCP 的 JEB 客户端,通过 REA_JEB_MCP_URL 选择;[JEB 指南](../jeb-analysis.md) |
|
||||
| Android 资源 | 解码后的清单、字符串表、语言区域和版本信息 | 调用方提供的 Apktool 启动器;[资源指南](../apktool-resource-analysis.md) |
|
||||
| Flutter 应用 | Dart 快照哈希、按 ABI 的库摘要及引擎构建溯源 | 无(静态 APK 解析);[Flutter 指南](../flutter-analysis.md) |
|
||||
| 固件 | 区域、提取结果和转交原生分析的内容 | Linux 上的 Binwalk / Unblob;[固件指南](../firmware-analysis.md) |
|
||||
| 抓包文件 | 指定字段、重复值、过滤条件与解码规则 | Linux: TShark 4.x + prlimit; [抓包分析指南](../packet-captures.md) |
|
||||
| 录制的逻辑信号 | 解码注释、样本范围和诊断信息 | Linux: sigrok-cli 0.7.2 + prlimit; [信号记录指南](../signal-recordings.md) |
|
||||
| 软件包与资源 | 文件清单、摘要、plist、Apple bundle 结构和提取的资源 | [制品与 JavaScript 指南](../javascript-artifact-reconstruction.md)、[Apple 应用](../apple-application-analysis.md) |
|
||||
| 进程行为 | 终端输出、交互、退出和文件系统观察结果,以及运行对比 | 支持原生 PTY 的 Linux/macOS;[进程捕获](../process-capture.md) |
|
||||
|
||||
静态 JavaScript 和 .NET 检查读取提供的文件,不运行应用。运行时捕获会以你的用户权限运行目标或与之交互;各运行时指南说明了具体影响。
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# Implementation plan
|
||||
|
||||
1. In `environmentStateRules.ts`, parse pointers into tokens and let `at` and
|
||||
`replaceAt` read and replace element 0 of a one-element array at the root
|
||||
or in one member; extend `locateItem` with those forms after the object
|
||||
forms, requiring an identity name there; add `identityCandidates`, the
|
||||
identity names then suffix-named fields, and let `locateList` prefer arrays
|
||||
identified by name, then arrays of two or more items identified by suffix.
|
||||
2. In `EnvironmentStateExtraction.ts`, plan lists with the shared candidates,
|
||||
treat an empty recording as unconstraining, accept a suffix-named field only
|
||||
once a recording shows it distinct across two or more items, leave reads
|
||||
without a list or identity to documents, drop the written-path requirement
|
||||
and the `not-entity` fallback, record `path-varies` apart from
|
||||
`query-varies`, and keep each entity's earliest sighting;
|
||||
`environmentStateMerge.ts` cites records in timeline order. Remove
|
||||
`not-entity` from the reason type and result schema, add `path-varies`, and
|
||||
describe the new pointer forms and `field-name` basis.
|
||||
3. Move the classification of retained bodies (none, base64, cut, JSON, text)
|
||||
into `replayedBody` in `environmentSource.ts`, which owns recorded
|
||||
exchanges; replay renders from it and tool descriptions read its JSON case.
|
||||
4. Make `environmentToolDefinitions` take the `EnvironmentSource`; describe the
|
||||
first `2xx` JSON record, else the first JSON record labelled with its status,
|
||||
one level deep with the state's list rule and the existing sample budget;
|
||||
list the manifest tool's statuses; and charge the whole description and the
|
||||
tool's MCP annotations to the catalog reservation. Update the serve and
|
||||
bundle callers.
|
||||
5. Let the tool definitions own the MCP annotations derived from the method and
|
||||
the query-`POST` rule; `serveEnvironment.ts` registers them and the card
|
||||
leaves them out.
|
||||
6. Cover domain, services, adapters and the compiled MCP boundary; document the
|
||||
rules in the capture guide, descriptions and hints in the agent environments
|
||||
guide, and the contract text.
|
||||
@@ -1,94 +0,0 @@
|
||||
# Wider environment state coverage and tool descriptions
|
||||
|
||||
Issue: https://github.com/morluto/rea/issues/4933
|
||||
|
||||
## Outcome
|
||||
|
||||
The `environment-state` view backs more read tools with state, and a served
|
||||
environment tells agents more about each tool. Read-only paths whose responses
|
||||
are not entities become documents, listed items may be identified by an
|
||||
`…Id` or `…_id` field, an item wrapped in a one-element array is located
|
||||
inside it, and reads that vary by path get their own recorded reason. Each tool description says what the tool returns and which statuses
|
||||
were recorded, and every MCP tool carries method hints. This increment adds no
|
||||
tool, view kind, provider, network activity or write effect.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A `GET` list tool (its template does not end in a parameter) that yields no
|
||||
item or list plan holds its latest initial value as a `document` when its
|
||||
initial reads have one request line, whether or not the capture writes its
|
||||
path, unless its value changed between two of those reads across any
|
||||
recorded write (even under another root), which leaves it `fields-differ`. More request lines give `path-varies` when they differ by path (a
|
||||
template with a parameter before its end) and `query-varies` when they share
|
||||
one path. With every such read now a document, `path-varies`, `query-varies`
|
||||
or `body-unusable`, the unreachable `not-entity` reason leaves the result
|
||||
schema, and `no-identity` names item tools only. A document holds the value
|
||||
of its one `path`.
|
||||
- When no path parameter proves an identity and no identity name identifies
|
||||
the listed items, the `field-name` identity is the first key of the first
|
||||
listed item, in its order, whose name ends in `Id` or `_id`, that every item
|
||||
carries as a distinct string or number, and that at least one initial
|
||||
recording shows distinct across two or more items. The last condition goes
|
||||
beyond "every item carries it distinctly": one item satisfies that
|
||||
vacuously, and a lone item's suffix-named fields are often foreign keys (a
|
||||
reaction keyed by its user, a comment by its post), so a list that only ever
|
||||
held one item stays a document. Empty recordings constrain no field.
|
||||
Identity names keep precedence, also when choosing among an envelope's
|
||||
arrays, where a suffix-named field identifies only an array of two or more
|
||||
items.
|
||||
- An item response that is a root array of exactly one object, or an object
|
||||
with one member holding such an array, locates its entity at `/0` or
|
||||
`/<member>/0` when one of the identity names in that object repeats the path
|
||||
value. Objects at the root or in one member keep precedence. `at` and
|
||||
`replaceAt` read and replace exactly these forms besides the root and one
|
||||
member, so the gate and renderers run unchanged.
|
||||
- An entity's state order is its earliest sighting on the timeline and it cites
|
||||
its records in timeline order, whichever plan observed it first.
|
||||
- Each tool description reads `METHOD template; <returns>; recorded statuses
|
||||
…; replays retained records […]; evidence ev_…` after the untrusted-catalog
|
||||
warning. `<returns>` describes one level deep the first cited `2xx` record
|
||||
replay answers as JSON: an object's fields, an array of objects' fields, or
|
||||
an envelope whose member lists objects, where a list is an array of objects
|
||||
found by the state's list rule; an empty field set reads `with no fields`.
|
||||
Only when no `2xx` record replays as JSON does the first cited JSON record of
|
||||
another status give it, labelled `status <code> returns …`; with no JSON
|
||||
record it is omitted. The statuses are the manifest tool's `statuses`, the
|
||||
one owner of that set (`no recorded status` when it is empty). Field names
|
||||
and statuses are sampled within the description sample budget and charged
|
||||
with the whole description to the catalog reservation before it is retained.
|
||||
One owner in the environment source classifies each retained body as none,
|
||||
base64, cut, JSON or text; replay answers from that classification and
|
||||
descriptions read only its JSON case.
|
||||
- MCP tools carry `readOnlyHint` (true for the safe methods `GET`, `HEAD`,
|
||||
`OPTIONS` and `TRACE`, and for query `POST`s),
|
||||
`destructiveHint: true` for `DELETE` (unset for other writes, so the MCP
|
||||
default applies, and for reads, where clients ignore it), `idempotentHint`
|
||||
(true for the safe methods, `PUT` and `DELETE`) and `openWorldHint: false`. The
|
||||
tool definitions own the hints and charge them to the catalog reservation;
|
||||
the environment card keeps its `name`, `description`, `inputSchema` tool
|
||||
shape.
|
||||
|
||||
## Acceptance and limits
|
||||
|
||||
Synthetic captures cover read-only documents, `path-varies` and
|
||||
`query-varies`, suffix-named identities with their key order, their
|
||||
two-item proof, empty first recordings and their precedence (including the
|
||||
choice between an envelope's arrays), a Ghost-like `{"posts":[...]}` item under
|
||||
a trailing-slash path merging with its list view, a root one-element array, a
|
||||
one-element filtered list that stays unidentified, and the pointer forms. The
|
||||
Grafana-shaped fixture covers documents for its read-only paths (its one-member
|
||||
team list among them) and object, array and enveloped-list descriptions;
|
||||
synthetic sources cover the `2xx` preference over an earlier error body, the
|
||||
first of several JSON records, the labelled fallback, the manifest's statuses,
|
||||
empty, scalar and fieldless bodies, a truncated member name, non-JSON bodies
|
||||
and the catalog budget; the in-process and compiled MCP servers advertise the
|
||||
hints. A filtered list may still answer with one element whose identity name
|
||||
repeats the requested value; list filters, pagination, lossless big integers
|
||||
and cross-endpoint entity linking remain out of scope. Documents inline whole
|
||||
recorded values and enlarge the state result; agent tokens, CPU time and peak
|
||||
memory of the state and description build were not measured.
|
||||
|
||||
## Deferred issue criteria
|
||||
|
||||
Serving from the state, the AgentEnv data plane and write effects remain later
|
||||
increments of #4933. This PR must reference rather than close the issue.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Record the coverage and description contract and its limits.
|
||||
- [x] Extend pointers, item location and identity candidates.
|
||||
- [x] Require two distinct items before a suffix-named field identifies a list.
|
||||
- [x] Hold read-only non-entity reads as documents, retire `not-entity` and
|
||||
separate `path-varies` from `query-varies`.
|
||||
- [x] Order entities by earliest sighting and cite records in timeline order.
|
||||
- [x] Classify retained bodies in one owner and describe tool responses,
|
||||
preferring `2xx` records, with the manifest's statuses.
|
||||
- [x] Advertise MCP tool annotations, charged to the catalog, without changing
|
||||
the card.
|
||||
- [x] Add domain, services, adapters and compiled-boundary coverage.
|
||||
- [x] Document the rules, descriptions and hints; update the contract text.
|
||||
- [x] Run static, formatting, duplication, documentation and test gates and the
|
||||
real-capture measurement.
|
||||
@@ -1,112 +0,0 @@
|
||||
# Verification
|
||||
|
||||
Verified base: 7a668b73f4783b42eb0e8ce1eeff079f86d53e22 (the environment-state
|
||||
view branch).
|
||||
|
||||
Executed on the implementation worktree with its existing locked installation
|
||||
under Node.js v22.23.1 (the repository pins 24.18.0, which was not available);
|
||||
every check below ran as written in this pass, after the last source change.
|
||||
|
||||
- `npm run format` then `npm run format:check`: passed.
|
||||
- `npm run check:fast`: typecheck and lint passed (3959 files, no errors). The
|
||||
touched files carry two lint warnings, both present on the base: `max-params`
|
||||
in `environmentReplay.ts` and an unused `_` in `environmentCapacity.test.ts`.
|
||||
- `npx knip`: passed (exit 0).
|
||||
- `npm run jscpd`: 3.40% duplicated lines over 3348 files, under the 3.5%
|
||||
gate. The three clones that touch changed test files pair the same blocks as
|
||||
on the base; none involves code this change adds.
|
||||
- `npm run scan:todos`: no markers.
|
||||
- `npm run docs:check`: the built documentation checks and the README
|
||||
consistency conformance test (5 tests) passed.
|
||||
- `npx vitest run --project domain --project services`: 341 files, 5859 tests
|
||||
passed, 5 pre-existing skips.
|
||||
- `npm run build:cached`: passed.
|
||||
- `npm run test:focused --` the environment serve adapters and capacity tests,
|
||||
the environment serve MCP boundary, the environment serve and export CLI
|
||||
boundaries, the environment serve shutdown process boundary, the
|
||||
analysis-view MCP and CLI boundaries, the compact input-schema contract and
|
||||
MCP boundary, the tool-schema validity boundary, the analysis-detail and
|
||||
advertised-input-example contracts and the qlog acceptance test: 14 files,
|
||||
113 tests passed, 1 pre-existing skip.
|
||||
|
||||
Real captures (outside the test suite): the built view ran through the
|
||||
application service on the same 44 recorded captures (39 HAR, 5 browser
|
||||
scenario) with all three template rules, in 7 to 50 ms each. 42 produced a
|
||||
state, and the same two browser-scenario captures are refused with
|
||||
`resource_constraint` as on the base. The base column is the brief's baseline,
|
||||
which the same script reproduced on the base build in an earlier pass; this
|
||||
pass did not rebuild the base.
|
||||
|
||||
| Measure | Base | This change |
|
||||
| ---------------------------------- | ----- | ----------- |
|
||||
| Types | 568 | 576 |
|
||||
| Entities | 2,213 | 2,246 |
|
||||
| Documents | 2 | 299 |
|
||||
| Item views | 211 | 213 |
|
||||
| List views | 497 | 504 |
|
||||
| Document views | 2 | 299 |
|
||||
| State-backed read tools (of 1,258) | 710 | 1,016 |
|
||||
| Recorded: `not-entity` | 306 | (retired) |
|
||||
| Recorded: `no-identity` | 129 | 32 |
|
||||
| Recorded: `path-varies` | (new) | 32 |
|
||||
| Recorded: `query-varies` | 5 | 69 |
|
||||
| Recorded: `body-unusable` | 32 | 32 |
|
||||
| Recorded: `list-differs` | 31 | 31 |
|
||||
| Recorded: `no-init-recording` | 26 | 26 |
|
||||
| Recorded: `query-body` | 12 | 12 |
|
||||
| Recorded: `fields-differ` | 3 | 4 |
|
||||
| Recorded: `envelope-varies` | 4 | 4 |
|
||||
|
||||
Seven list types gained a suffix-named identity (`permissionId`, `product_id`,
|
||||
`ItemId`, `user_id`, `channel_id`, `spl_id` and `eventId`). Seven more had one
|
||||
before the two-item proof and lost it, because every initial response listed a
|
||||
single item: an Airbyte organization, a Mattermost reaction and team
|
||||
membership, and a Redpanda broker and schema reference became documents; a
|
||||
NOAA station read is `query-varies` and a Redpanda consumer read
|
||||
`path-varies`. Two Ghost item tools render from `/members/0` and `/tags/0`. A
|
||||
third Ghost item tool is located at `/posts/0` but stays `fields-differ`: its
|
||||
recordings differ by an `include` query.
|
||||
|
||||
State result size, as the JSON length of each Evidence summed over the 42
|
||||
states: 2,677 KiB on the base and 3,196 KiB now (+19%), measured by the same
|
||||
script. The largest growth per capture is Metabase (133 to 236 KiB), Directus
|
||||
(21 to 93), Redpanda Console (26 to 68) and Vault (9 to 43). Documents take
|
||||
492,516 of the 3,274,243 UTF-8 bytes of the results now; the base held two.
|
||||
Agent tokens, CPU time and peak memory of the state view were not measured.
|
||||
|
||||
Tool descriptions on the 44 captures' manifests: all 44 catalogs were admitted
|
||||
within their reservation. 1,336 of 1,400 tools state what they return: 1,333
|
||||
from a `2xx` record and three labelled with another status (Miniflux and
|
||||
NocoDB 404, Shlink 501), because none of their records succeeded as JSON. The
|
||||
summaries add 261,502 bytes to a 1,185,893-byte catalog of names, descriptions
|
||||
and input schemas (median 136, 95th percentile 554, maximum 1,133 bytes per
|
||||
tool); five reached the field sample bound (one Keycloak, one Lychee and three
|
||||
Sentry tools with 46 to 105 fields). The MCP annotations add 113,182 bytes to
|
||||
`tools/list` and none to the card. Building a catalog took a median 1.6 ms and
|
||||
at most 7.6 ms (Metabase), after the source was loaded. Agent tokens, CPU time
|
||||
and peak memory of the description build were not measured.
|
||||
|
||||
After merging the environment state view's review fixes (its branch at
|
||||
6ad63bcc, which also brings main's evidence-file sources), every gate ran
|
||||
again on the merged tree: `npm run check:fast`, `npm run format:check`,
|
||||
`npx knip`, `npm run scan:todos` and `npm run docs:check` passed; `npm run
|
||||
jscpd`: 3.39% duplicated lines over 3362 files; `npx vitest run --project
|
||||
domain --project services`: 344 files, 5914 tests passed, 5 pre-existing
|
||||
skips; `npm run build:cached`, then `npm run test:focused --` the serve and
|
||||
tool-definition adapters, the serve MCP and CLI boundaries, the export CLI
|
||||
boundary, the analysis-view MCP and CLI boundaries, the compact input-schema
|
||||
budget and the analysis-detail and advertised-input-example contracts: 10
|
||||
files, 98 tests passed.
|
||||
|
||||
The real-capture comparison was repeated on the merged code, with the base
|
||||
measured on the merged state view without this change: all 44 captures now
|
||||
produce a state, and 1,011 of 1,277 read tools are state-backed against 625
|
||||
on the base (216 item, 416 list and 379 document views against 214, 409 and
|
||||
2). Recorded reasons: `no-identity` 216 to 32 and `not-entity` 310 to none;
|
||||
`query-varies` 5 to 76 and `path-varies` 36 are document reads under more
|
||||
than one query or path; `list-differs` 35, `body-unusable` 33,
|
||||
`no-init-recording` 28, `query-body` 12, `envelope-varies` 4 are unchanged,
|
||||
and `fields-differ` is 9 to 10.
|
||||
|
||||
Not covered here: serving from the state, the AgentEnv data plane and write
|
||||
effects, which are later increments.
|
||||
@@ -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 reads
|
||||
that are not entities. Use the `template_rules` of the manifest the state
|
||||
accompanies so tool names match; the view groups records the same way.
|
||||
state holds its data: entities by type and identity, plus documents for written
|
||||
paths whose reads are not entities. Use the `template_rules` of the manifest the
|
||||
state accompanies so tool names match; the view groups records the same way.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -560,27 +560,13 @@ refused for them, as the manifest is not.
|
||||
|
||||
Each `GET` tool whose template ends in a parameter is an item tool, and its
|
||||
type's `collection_template` is the template without that parameter; any other
|
||||
`GET` tool is a list tool of its own template. Empty segments are ignored, so a
|
||||
trailing slash names the same collection. An item tool proves its type's
|
||||
`GET` tool is a list tool of its own template. An item tool proves its type's
|
||||
`id_field` when every initial response carries the requested path value in the
|
||||
same field of one entity object (`id_basis: "path-parameter"`). The object is
|
||||
the response itself or one wrapper member; failing both, it is the only element
|
||||
of a one-element array at the root (pointer `/0`) or in one member
|
||||
(`/<member>/0`), as in `{"posts":[{...}]}` or `{"data":[{...}]}`, provided one
|
||||
of the identity names below repeats the value, since a filtered list may also
|
||||
answer with one element that repeats its parent's value. Otherwise the field is
|
||||
the first of `id`, `uid`, `uuid`, `_id`, `ID`, `UID`, `Id`, `guid`, `key`,
|
||||
`slug` and `name` that every listed item carries as a distinct string or
|
||||
number; when none does, it is the first key of the first listed item, in its
|
||||
order, whose name ends in `Id` or `_id` (such as `orgId` or `user_id`), that
|
||||
every item carries as a distinct string or number, and that at least one
|
||||
initial response shows distinct across two or more items
|
||||
(`id_basis: "field-name"`). A single listed item is distinct from nothing, and
|
||||
its suffix-named fields are often foreign keys, so a list that only ever held
|
||||
one item is left to a document. Empty initial lists constrain no field. A list
|
||||
is the response array, or the one array of objects in an envelope that is not
|
||||
itself an entity, preferring an array whose items an identity name identifies,
|
||||
then an array of two or more items a suffix-named field identifies.
|
||||
same field, at the root or in one wrapper member (`id_basis: "path-parameter"`).
|
||||
Otherwise the field is the first of `id`, `uid`, `uuid`, `_id`, `ID`, `UID`,
|
||||
`Id`, `guid`, `key`, `slug` and `name` that every listed item carries as a
|
||||
distinct string or number (`id_basis: "field-name"`). A list is the response
|
||||
array, or the one array of objects in an envelope that is not itself an entity.
|
||||
|
||||
`entities` merge every initial observation of one identity within one
|
||||
collection path: an item view's latest values win over a list view's, a view
|
||||
@@ -597,9 +583,9 @@ Every entity cites its `record_ordinals`.
|
||||
|
||||
| Shape | Plan |
|
||||
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `item` | The entity object at `pointer` (the root, one member, or `/0` or `/<member>/0`), the ordered `fields` the view shows and the `required_fields` every recording had, and `envelope_ordinal`: the recording whose response, with the entity replaced, every item response shares |
|
||||
| `item` | The entity object at `pointer` (the root or one member), the ordered `fields` the view shows and the `required_fields` every recording had, and `envelope_ordinal`: the recording whose response, with the entity replaced, every item response shares |
|
||||
| `list` | The array at `pointer`, its `fields`, `membership` (`all`: every entity of the collection in state order, when each recording listed exactly that, had no query, no item-total member (`total`, `count`, `totalCount`, `item_count`, `totalcount`, `itemcount`, but not `total_pages` or `per_page`; a number or a string of digits) differing from the number listed, and no `has_more`, `next` or next-page token member set; `recorded`: the entities each recording listed) and `counters` that equal the number listed |
|
||||
| `document` | The 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`) |
|
||||
| `document` | The path's latest initial value, held in `documents` |
|
||||
| `recorded` | A `reason` the tool replays its recordings instead |
|
||||
|
||||
An item or list plan is kept only when it reproduces the latest initial
|
||||
@@ -608,12 +594,11 @@ regard to member order; a volatile field compares its recorded value. Each
|
||||
plan lists the `checked_record_ordinals`. Otherwise the tool is a recorded view
|
||||
with reason `envelope-varies`, `fields-differ` or `list-differs`; the other
|
||||
reasons are `query-body` (a query `POST`), `no-init-recording`,
|
||||
`body-unusable`, `no-identity` (an item read without a proved identity),
|
||||
`path-varies` (another read under more than one path, as a template with a
|
||||
parameter before its end) and `query-varies` (another read of one path under
|
||||
more than one query). A document holds the value of its one `path`, so for such
|
||||
a template it says nothing about the template's other paths. A body is usable
|
||||
when its content type is
|
||||
`body-unusable`, `not-entity`, `no-identity` and `query-varies` (a document
|
||||
read under several queries). Documents are held only for list-shaped paths
|
||||
that a `POST`, `PUT` or `PATCH` tool writes: a non-entity read of an item path
|
||||
such as `/api/files/{id}`, or of a path that is only deleted, stays recorded
|
||||
with `no-identity` or `not-entity`. A body is usable when its content type is
|
||||
JSON-like, it is UTF-8 text of at most 256 KiB, every number survives native
|
||||
conversion exactly, and no member is named `__proto__`.
|
||||
|
||||
@@ -625,9 +610,7 @@ 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. Documents hold whole recorded response
|
||||
values of up to 256 KiB each, so a capture with many read-only paths yields a
|
||||
correspondingly larger state result. The view performs no network activity
|
||||
it keeps, within the same 96 MiB budget. The view performs no network activity
|
||||
and serves nothing; `rea environment-serve` derives it from the capture at
|
||||
startup, serves read tools from it and loads replacement states through the
|
||||
AgentEnv data plane, as
|
||||
@@ -636,13 +619,14 @@ AgentEnv data plane, as
|
||||
## Resource and lifecycle boundaries
|
||||
|
||||
Complete evidence has a 32 MiB input, 96 MiB decoder reply and 64-level nesting
|
||||
budget. Exceeding a budget returns no partial success.
|
||||
budget. Those budgets have no runtime override. `capture_timeout_ms` does not
|
||||
raise them. Exceeding a budget returns no partial success.
|
||||
Oversized selected captures return an input error identifying `capture_path`
|
||||
and the byte budget; owned reply/output failures remain output or provider errors.
|
||||
HAR, SAZ and qlog run in an owned
|
||||
192 MiB Node old-generation heap with one V8 worker. Mitmproxy decoding applies
|
||||
Linux limits of 768 MiB address space, 30 seconds CPU and 96 MiB file output;
|
||||
both commands have a 30-second wall deadline and independently supervised
|
||||
both commands use `capture_timeout_ms` (default 30000) and independently supervised
|
||||
process cleanup. Owned decoder diagnostics retain at most 1 MiB across stdout
|
||||
and stderr; the collector drops overflow as it arrives, reports the limit
|
||||
failure and verifies cleanup. Heap limits alone do not establish aggregate RSS limits.
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ The result contains directly registered listener types/flags, callback coordinat
|
||||
|
||||
## Bounds and verification
|
||||
|
||||
Each protocol message has a 64 MiB budget; script/context metadata and request metadata each have an 8 MiB retained budget. Complete retained source text has a 32 MiB budget. Oversized or malformed evidence returns no partial successful result. Each command and the complete inspection/source-join phase have a 20-second deadline; cleanup has a separate 5-second command deadline plus transport closure. The selected observation duration must fit the host timer's positive 32-bit millisecond range. REA does not control the externally owned browser's CPU or memory.
|
||||
Each protocol message has a 64 MiB budget; script/context metadata and request metadata each have an 8 MiB retained budget. Complete retained source text has a 32 MiB budget. Oversized or malformed evidence returns no partial successful result. Each command and the complete inspection/source-join phase use `command_timeout_ms` (default 20000). Cleanup uses a separate 5000 ms grace; `command_timeout_ms` does not raise or shorten it, and this release has no runtime override for that grace. Transport closure follows the grace. The selected observation duration must fit the host timer's positive 32-bit millisecond range. REA does not control the externally owned browser's CPU or memory.
|
||||
|
||||
`npm run verify:browser:runtime` uses caller-supplied `REA_BROWSER_EXECUTABLE` and the native `ps` command on Linux/macOS against an owned synthetic fixture, through real public CLI and stdio MCP. It checks exact Unicode/CRLF text/digests, same-URL identities, same-document navigation, inert declared names, executed and zero-count branches, function-only unknowns on repeated observation, listener selection, initiator association, actual arming and preservation of the external page. A separate fixture page closes during an armed MCP window to verify prompt termination and inline cleanup diagnostics. The optional positional entrypoint to `scripts/verify-browser-runtime.mjs` permits the same checks against an isolated installed tarball.
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ implement every current source-map extension.
|
||||
One owned Node subprocess decodes the map. Its private request directory is
|
||||
removed before success is returned. Only this child's inherited `NODE_OPTIONS`
|
||||
is removed; explicit V8 flags set a 192 MiB old-generation and 8 MiB semi-space
|
||||
budget, one V8 worker and a 20-second deadline. Reported actual V8 heap limits
|
||||
budget, one V8 worker and `decode_timeout_ms` (default 20000). The codec heap, map budget, and reply budget have no runtime override. Reported actual V8 heap limits
|
||||
are checked. These are heap limits, not an aggregate process RSS limit. When
|
||||
REA runs on Bun, the subprocess is Bun, which ignores these V8 flags: `runtime`
|
||||
records `id: "bun"`, the Bun version, its Node.js compatibility version and a
|
||||
@@ -86,7 +86,8 @@ cleanup overrides success with `cleanup_incomplete` and the owning
|
||||
operation/resources.
|
||||
|
||||
Complete evidence is bounded by a 4 MiB map, 32 MiB codec reply, 262144 decoded
|
||||
rows/segments and 64 nested section levels. Exceeding a budget returns an explicit
|
||||
rows/segments and 64 nested section levels. Those structural budgets and the
|
||||
192 MiB codec heap have no runtime override. Exceeding a budget returns an explicit
|
||||
failure, without partial results. The existing 32 MiB manifest and 16 MiB selected
|
||||
script boundaries also apply. No global toolchain or browser configuration changes
|
||||
are made.
|
||||
|
||||
@@ -238,7 +238,9 @@ export const providerCatalog = (sources) => {
|
||||
identity: sources.pwntoolsProvider.PWNTOOLS_PROVIDER_IDENTITY,
|
||||
contracts:
|
||||
sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter(
|
||||
({ name }) => name !== "inspect_pe_resources",
|
||||
({ name }) =>
|
||||
name !== "inspect_pe_resources" &&
|
||||
name !== "inspect_pe_certificates",
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -248,6 +250,13 @@ export const providerCatalog = (sources) => {
|
||||
({ name }) => name === "inspect_pe_resources",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.artifactProviders.PE_CERTIFICATES_PROVIDER,
|
||||
contracts:
|
||||
sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter(
|
||||
({ name }) => name === "inspect_pe_certificates",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.captureProvider.TSHARK_PROVIDER_IDENTITY,
|
||||
contracts: sources.captureContracts.CAPTURE_TOOL_CONTRACTS,
|
||||
|
||||
@@ -47,6 +47,13 @@ export function profileMcpModuleLoading(
|
||||
/** Parse Linux procfs VmRSS text into bytes. */
|
||||
export function parseLinuxVmRssBytes(status: string): number | null;
|
||||
|
||||
/** Name a fixed MCP startup phase budget. Mirrors `fixedMcpPhaseBudgetRecovery`. */
|
||||
export function fixedMcpStartupPhaseBudgetRecovery(
|
||||
phase: string,
|
||||
value: number,
|
||||
unit: "ms" | "bytes",
|
||||
): string;
|
||||
|
||||
/** Collect unique file-backed ESM module URLs from Node diagnostics. */
|
||||
export function createEsmModuleProfileCollector(packageName?: string): {
|
||||
append(chunk: string): void;
|
||||
|
||||
@@ -35,11 +35,22 @@ export async function measureMcpStartup({
|
||||
const startedAt = performance.now();
|
||||
rss.start();
|
||||
try {
|
||||
await client.connect(transport, { timeout: policy.initializeBudgetMs });
|
||||
await withFixedPhaseBudget(
|
||||
"MCP initialize phase budget",
|
||||
policy.initializeBudgetMs,
|
||||
"ms",
|
||||
() => client.connect(transport, { timeout: policy.initializeBudgetMs }),
|
||||
);
|
||||
const initializedAt = performance.now();
|
||||
const tools = await client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
});
|
||||
const tools = await withFixedPhaseBudget(
|
||||
"MCP tools/list phase budget",
|
||||
policy.toolsListBudgetMs,
|
||||
"ms",
|
||||
() =>
|
||||
client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
}),
|
||||
);
|
||||
const listedAt = performance.now();
|
||||
rss.stop();
|
||||
const measurement = {
|
||||
@@ -52,7 +63,11 @@ export async function measureMcpStartup({
|
||||
assertStartupBudgets(measurement, policy);
|
||||
return measurement;
|
||||
} catch (cause) {
|
||||
throw new Error(`packaged MCP startup probe failed: ${stderr}`, { cause });
|
||||
const detail = cause instanceof Error ? cause.message : String(cause);
|
||||
throw new Error(
|
||||
`packaged MCP startup probe failed: ${detail}${stderr.length === 0 ? "" : `\n${stderr}`}`,
|
||||
{ cause },
|
||||
);
|
||||
} finally {
|
||||
rss.stop();
|
||||
await Promise.allSettled([client.close(), transport.close()]);
|
||||
@@ -82,12 +97,24 @@ export async function profileMcpModuleLoading({
|
||||
version: "1.0.0",
|
||||
});
|
||||
try {
|
||||
await client.connect(transport, {
|
||||
timeout: policy.doctorDeadlineMs,
|
||||
});
|
||||
await client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
});
|
||||
await withFixedPhaseBudget(
|
||||
"MCP doctor startup deadline",
|
||||
policy.doctorDeadlineMs,
|
||||
"ms",
|
||||
() =>
|
||||
client.connect(transport, {
|
||||
timeout: policy.doctorDeadlineMs,
|
||||
}),
|
||||
);
|
||||
await withFixedPhaseBudget(
|
||||
"MCP tools/list phase budget",
|
||||
policy.toolsListBudgetMs,
|
||||
"ms",
|
||||
() =>
|
||||
client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
}),
|
||||
);
|
||||
return profile.result();
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), transport.close()]);
|
||||
@@ -197,16 +224,46 @@ const createLinuxRssSampler = (pid) => {
|
||||
};
|
||||
};
|
||||
|
||||
/** Same sentence as `fixedMcpPhaseBudgetRecovery`; this script cannot import that TypeScript module. */
|
||||
export const fixedMcpStartupPhaseBudgetRecovery = (phase, value, unit) =>
|
||||
`REA's fixed ${phase} is ${String(value)} ${unit} and this release has no runtime override.`;
|
||||
|
||||
const phaseTimeout = (cause) => {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return (
|
||||
message.includes("Request timed out") ||
|
||||
message.includes("Maximum total timeout exceeded") ||
|
||||
message.includes("timed out")
|
||||
);
|
||||
};
|
||||
|
||||
const withFixedPhaseBudget = async (phase, value, unit, operation) => {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (cause) {
|
||||
if (!phaseTimeout(cause)) throw cause;
|
||||
const detail = cause instanceof Error ? cause.message : String(cause);
|
||||
throw new Error(
|
||||
`${detail} ${fixedMcpStartupPhaseBudgetRecovery(phase, value, unit)}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const assertStartupBudgets = (measurement, policy) => {
|
||||
if (measurement.process_started_to_initialized_ms > policy.initializeBudgetMs)
|
||||
throw new Error("packaged MCP exceeded the initialize regression budget");
|
||||
throw new Error(
|
||||
`packaged MCP exceeded the initialize phase budget. ${fixedMcpStartupPhaseBudgetRecovery("MCP initialize phase budget", policy.initializeBudgetMs, "ms")}`,
|
||||
);
|
||||
if (measurement.initialized_to_tools_list_ms > policy.toolsListBudgetMs)
|
||||
throw new Error("packaged MCP exceeded the tools/list regression budget");
|
||||
throw new Error(
|
||||
`packaged MCP exceeded the tools/list phase budget. ${fixedMcpStartupPhaseBudgetRecovery("MCP tools/list phase budget", policy.toolsListBudgetMs, "ms")}`,
|
||||
);
|
||||
if (
|
||||
measurement.process_started_to_tools_list_ms > policy.firstCatalogBudgetMs
|
||||
)
|
||||
throw new Error(
|
||||
"packaged MCP exceeded the first-catalog regression budget",
|
||||
`packaged MCP exceeded the first-catalog phase budget. ${fixedMcpStartupPhaseBudgetRecovery("MCP first-catalog phase budget", policy.firstCatalogBudgetMs, "ms")}`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -784,6 +784,33 @@ it("projects package details from dumpsys package output", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Package Manager prints an absent installer as the text "null"; the
|
||||
// normalized result must report the absence, not a package named "null"
|
||||
// (morluto/rea#3943).
|
||||
it("reports a null installer declaration as an absent installer", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
"dumpsys:package": `Packages:
|
||||
Package [com.example] (abcdef):
|
||||
versionName=1.2.3
|
||||
installerPackageName=null
|
||||
requested permissions:
|
||||
android.permission.INTERNET
|
||||
`,
|
||||
}),
|
||||
);
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "inspect_adb_package",
|
||||
input: { serial: "emulator-5554", package: "com.example" },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
version_name: "1.2.3",
|
||||
installer_package_name: null,
|
||||
requested_permissions_count: 1,
|
||||
coverage: "complete",
|
||||
});
|
||||
});
|
||||
|
||||
// PackageInfo.getLongVersionCode() combines versionCodeMajor and versionCode
|
||||
// into a signed 64-bit long, which JSON numbers cannot hold exactly.
|
||||
it.each(["9007199254740993", "-4294967296"])(
|
||||
|
||||
@@ -7,6 +7,52 @@ import {
|
||||
parseWindowFocusOutput,
|
||||
} from "./AdbShellOutput.js";
|
||||
|
||||
const packageDump = (installerLine: string | undefined): string =>
|
||||
[
|
||||
"Packages:",
|
||||
" Package [com.example] (abcdef):",
|
||||
" versionName=1.2.3",
|
||||
...(installerLine === undefined ? [] : [` ${installerLine}`]),
|
||||
" pkgFlags=[ HAS_CODE ]",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
describe("parseDumpsysPackageOutput", () => {
|
||||
it("keeps a reported installer package name", () => {
|
||||
expect(
|
||||
parseDumpsysPackageOutput(
|
||||
packageDump("installerPackageName=com.android.vending"),
|
||||
).installerPackageName,
|
||||
).toBe("com.android.vending");
|
||||
});
|
||||
|
||||
// Package Manager prints an absent String field as the text "null"; a
|
||||
// missing or empty declaration means the same absence (morluto/rea#3943).
|
||||
it.each([
|
||||
["the literal null sentinel", "installerPackageName=null"],
|
||||
["an empty value", "installerPackageName="],
|
||||
["a missing line", undefined],
|
||||
])("reports an absent installer as null for %s", (_case, line) => {
|
||||
expect(parseDumpsysPackageOutput(packageDump(line))).toMatchObject({
|
||||
versionName: "1.2.3",
|
||||
installerPackageName: null,
|
||||
});
|
||||
});
|
||||
|
||||
// A manifest may declare the literal string "null" as its version name and
|
||||
// dumpsys prints it the same way as an absent one, so the text is kept.
|
||||
it("keeps a version name printed as the text null", () => {
|
||||
expect(
|
||||
parseDumpsysPackageOutput(
|
||||
packageDump("installerPackageName=null").replace(
|
||||
"versionName=1.2.3",
|
||||
"versionName=null",
|
||||
),
|
||||
).versionName,
|
||||
).toBe("null");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWindowFocusOutput display contexts", () => {
|
||||
it.each([false, true])(
|
||||
"retains both display owners independent of block order: %s",
|
||||
|
||||
@@ -275,6 +275,17 @@ export interface AdbPackageDetails {
|
||||
|
||||
const DETAIL_LINE = /^\s+([A-Za-z][A-Za-z0-9_]*)=(.*)$/u;
|
||||
|
||||
/**
|
||||
* Package Manager prints its `String` fields by concatenation, so an absent
|
||||
* `installerPackageName` appears as the literal text `null`
|
||||
* (morluto/rea#3943). No installer can be named "null", so an empty value or
|
||||
* that sentinel is the absence of the fact and becomes the nullable result's
|
||||
* null. `versionName` keeps the printed text: a manifest may declare the
|
||||
* string "null" on purpose, and the parser cannot tell the two apart.
|
||||
*/
|
||||
const absentInstallerOrName = (value: string): string | null =>
|
||||
value === "" || value === "null" ? null : value;
|
||||
|
||||
/**
|
||||
* Project `dumpsys package PACKAGE` output. Only the listed fields are
|
||||
* extracted; the input is version-dependent and everything else is ignored
|
||||
@@ -340,7 +351,7 @@ export const parseDumpsysPackageOutput = (
|
||||
key === "installerPackageName" &&
|
||||
details.installerPackageName === null
|
||||
)
|
||||
details.installerPackageName = value === "" ? null : value;
|
||||
details.installerPackageName = absentInstallerOrName(value);
|
||||
else if (key === "userId" && details.userId === null) {
|
||||
const parsed = /^(\d+)/u.exec(value);
|
||||
if (parsed !== null) details.userId = Number(parsed[1]!);
|
||||
|
||||
@@ -138,6 +138,9 @@ const browserParameters = (
|
||||
: "target_id" in input && observedOrigin !== undefined
|
||||
? [observedOrigin]
|
||||
: [],
|
||||
...("command_timeout_ms" in input && input.command_timeout_ms !== undefined
|
||||
? { command_timeout_ms: input.command_timeout_ms }
|
||||
: {}),
|
||||
};
|
||||
if (!("target_id" in input)) return scope;
|
||||
return {
|
||||
@@ -171,6 +174,11 @@ const browserParameters = (
|
||||
...(input.total_analysis_timeout_ms === undefined
|
||||
? {}
|
||||
: { total_analysis_timeout_ms: input.total_analysis_timeout_ms }),
|
||||
...(input.source_map_fetch_timeout_ms === undefined
|
||||
? {}
|
||||
: {
|
||||
source_map_fetch_timeout_ms: input.source_map_fetch_timeout_ms,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -499,6 +499,8 @@ const workflowAvailabilityFor = ({
|
||||
};
|
||||
if (name === "inspect_pe_resources")
|
||||
return { reason: "available", remediation: null };
|
||||
if (name === "inspect_pe_certificates")
|
||||
return { reason: "available", remediation: null };
|
||||
if (name === "inspect_evm_interface") {
|
||||
if (policy.evmInterfaceEnabled !== true)
|
||||
return {
|
||||
|
||||
@@ -49,6 +49,13 @@ export const PE_RESOURCES_PROVIDER = Object.freeze({
|
||||
version: "1",
|
||||
} as const);
|
||||
|
||||
/** Provider identity for portable, execution-free PE Authenticode table decoding. */
|
||||
export const PE_CERTIFICATES_PROVIDER = Object.freeze({
|
||||
id: "rea-pe-certificates",
|
||||
name: "REA PE certificate parser",
|
||||
version: "1",
|
||||
} as const);
|
||||
|
||||
/** Provider identity for deterministic managed-code comparison workflows. */
|
||||
export const MANAGED_WORKFLOW_PROVIDER = {
|
||||
id: "rea-dotnet-workflows",
|
||||
|
||||
@@ -35,7 +35,13 @@ import { MANAGED_WORKFLOW_PROVIDER } from "./InvestigationProviders.js";
|
||||
import { createProcessCaptureEvidence } from "./process/ProcessEvidence.js";
|
||||
import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js";
|
||||
|
||||
const applicationWithUnknowns = (keys: readonly string[]) => {
|
||||
const applicationWithUnknowns = (
|
||||
keys: readonly string[],
|
||||
{
|
||||
kind = "unknown",
|
||||
label = "Unclassified application boundary",
|
||||
}: { kind?: "unknown" | "package"; label?: string } = {},
|
||||
) => {
|
||||
const source = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture-source", name: "Fixture source", version: "1" },
|
||||
@@ -50,7 +56,7 @@ const applicationWithUnknowns = (keys: readonly string[]) => {
|
||||
const base = buildSyntheticJavaScriptApplicationGraph();
|
||||
const unknownNodes = keys.map((key) =>
|
||||
createJavaScriptApplicationNode({
|
||||
kind: "unknown",
|
||||
kind,
|
||||
identity: {
|
||||
strategy: "artifact-local-key",
|
||||
stability: "artifact-version",
|
||||
@@ -60,7 +66,7 @@ const applicationWithUnknowns = (keys: readonly string[]) => {
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label: "Unclassified application boundary",
|
||||
label,
|
||||
properties: {},
|
||||
evidence: artifactEvidence(
|
||||
APPLICATION_GRAPH_DIGESTS.asar,
|
||||
@@ -144,6 +150,22 @@ describe("reconstruction obligation candidates", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to a kind-based title when an application node label is blank", () => {
|
||||
const {
|
||||
unknownNodes: [node],
|
||||
generated,
|
||||
} = applicationWithUnknowns(["blank-label"], {
|
||||
kind: "package",
|
||||
label: " ",
|
||||
});
|
||||
|
||||
expect(
|
||||
generated.candidates.find(
|
||||
({ target }) => target.application_node_id === node?.node_id,
|
||||
)?.title,
|
||||
).toBe("package boundary");
|
||||
});
|
||||
|
||||
it("does not bind an unknown process image to a packaged artifact obligation", () => {
|
||||
const scenario = parseProcessScenario({
|
||||
executable: "/path/whose-launch-identity-is-unknown",
|
||||
@@ -327,3 +349,87 @@ it("keeps each semantic candidate bound to its own file and authority context",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to a kind-based title when a semantic node label is blank", () => {
|
||||
const contexts = new JavaScriptSemanticEvidenceContextRegistry();
|
||||
const base = javascriptApplicationAnalysisResultSchema.parse(
|
||||
JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.normalized_result,
|
||||
);
|
||||
const source = proofEvidence("blank-label-file");
|
||||
const module = createJavaScriptSemanticGraphNode(
|
||||
{
|
||||
kind: "module",
|
||||
identity: {
|
||||
artifact_sha256: base.root_artifact_sha256,
|
||||
module_path: "app.js",
|
||||
source_range: null,
|
||||
role_key: "module",
|
||||
},
|
||||
function_node_id: null,
|
||||
application_node_ids: [],
|
||||
label: "app.js",
|
||||
properties: {},
|
||||
evidence: artifactEvidence(base.root_artifact_sha256, "app.js"),
|
||||
},
|
||||
contexts,
|
||||
);
|
||||
const sha256 = "4".repeat(64);
|
||||
const request = createJavaScriptSemanticGraphNode(
|
||||
{
|
||||
kind: "request",
|
||||
identity: {
|
||||
artifact_sha256: sha256,
|
||||
module_path: "file.js",
|
||||
source_range: {
|
||||
start: { line: 1, column: 0 },
|
||||
end: { line: 1, column: 8 },
|
||||
},
|
||||
role_key: "request-1",
|
||||
},
|
||||
function_node_id: null,
|
||||
application_node_ids: [],
|
||||
label: " ",
|
||||
properties: {},
|
||||
evidence: {
|
||||
...artifactEvidence(sha256, "file.js", "ast-static-analysis"),
|
||||
evidence_ids: [source.evidence_id],
|
||||
},
|
||||
},
|
||||
contexts,
|
||||
);
|
||||
const { graph_id: _graphId, ...semantic } = base.semantic_graph;
|
||||
const semanticGraph = createJavaScriptSemanticGraph({
|
||||
...semantic,
|
||||
root_node_ids: [module.node_id],
|
||||
nodes: [module, request],
|
||||
evidence_contexts: contexts.contexts,
|
||||
});
|
||||
const example = JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE;
|
||||
const application = createEvidence(
|
||||
{
|
||||
path: base.input_path,
|
||||
sha256: base.root_artifact_sha256,
|
||||
format: base.format,
|
||||
},
|
||||
example.provider,
|
||||
{
|
||||
predicateType: example.predicate_type,
|
||||
operation: example.operation,
|
||||
parameters: example.parameters,
|
||||
result: jsonValueSchema.parse({ ...base, semantic_graph: semanticGraph }),
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
evidenceLinks: [source.evidence_id],
|
||||
},
|
||||
);
|
||||
const generated = deriveReconstructionObligationCandidates(
|
||||
createEvidenceBundle([source, application]),
|
||||
[],
|
||||
);
|
||||
|
||||
expect(
|
||||
generated.candidates.find(
|
||||
({ target }) => target.semantic_node_id === request.node_id,
|
||||
)?.title,
|
||||
).toBe("request semantic boundary");
|
||||
});
|
||||
@@ -114,7 +114,7 @@ const deriveApplicationCandidates = (
|
||||
candidates,
|
||||
generatedCandidate({
|
||||
key: `application:${node.node_id}:${candidatePolicy.family}`,
|
||||
title: node.observations[0]?.label ?? `${node.kind} boundary`,
|
||||
title: node.observations[0]?.label?.trim() || `${node.kind} boundary`,
|
||||
policy: candidatePolicy,
|
||||
artifactSha256: source.rootArtifactSha256,
|
||||
applicationNodeId: node.node_id,
|
||||
@@ -143,7 +143,7 @@ const deriveApplicationCandidates = (
|
||||
candidates,
|
||||
generatedCandidate({
|
||||
key: `semantic:${node.node_id}:${candidatePolicy.family}`,
|
||||
title: node.label ?? `${node.kind} semantic boundary`,
|
||||
title: node.label?.trim() || `${node.kind} semantic boundary`,
|
||||
policy: candidatePolicy,
|
||||
artifactSha256: node.identity.artifact_sha256,
|
||||
applicationNodeId: node.application_node_ids[0] ?? null,
|
||||
|
||||
@@ -43,6 +43,30 @@ it.each(["REDACTED", "[", "]", "/capture.har"])(
|
||||
},
|
||||
);
|
||||
|
||||
it("records an explicit capture deadline and omits the default", async () => {
|
||||
const seen: Array<number | undefined> = [];
|
||||
const service = new WebNetworkCaptureService({
|
||||
inspect: (input) => {
|
||||
seen.push(input.capture_timeout_ms);
|
||||
return Promise.resolve(ok(fixture()));
|
||||
},
|
||||
});
|
||||
const explicit = await service.inspect({
|
||||
capture_path: "/capture.har",
|
||||
format: "har",
|
||||
capture_timeout_ms: 250,
|
||||
});
|
||||
if (!explicit.ok) throw explicit.error;
|
||||
expect(explicit.value.parameters.capture_timeout_ms).toBe(250);
|
||||
const omitted = await service.inspect({
|
||||
capture_path: "/capture.har",
|
||||
format: "har",
|
||||
});
|
||||
if (!omitted.ok) throw omitted.error;
|
||||
expect(omitted.value.parameters).not.toHaveProperty("capture_timeout_ms");
|
||||
expect(seen).toEqual([250, undefined]);
|
||||
});
|
||||
|
||||
it("keeps observed artifact identity when the entire sensitive path is excluded", async () => {
|
||||
const result = await new WebNetworkCaptureService({
|
||||
inspect: () => Promise.resolve(ok(fixture())),
|
||||
|
||||
@@ -250,6 +250,9 @@ export class WebNetworkCaptureService {
|
||||
},
|
||||
}),
|
||||
sensitive_value_count: input.sensitive_values.length,
|
||||
...(input.capture_timeout_ms === undefined
|
||||
? {}
|
||||
: { capture_timeout_ms: input.capture_timeout_ms }),
|
||||
});
|
||||
return ok(
|
||||
createEvidence(
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface WebSourceMapPort {
|
||||
readonly path: string;
|
||||
readonly url: string;
|
||||
readonly position: WebSourceLocationInput["generated_position"];
|
||||
readonly decodeTimeoutMs?: number;
|
||||
},
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebSourceMapReport, AnalysisError>>;
|
||||
|
||||
@@ -11,12 +11,55 @@ import {
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { webSourceLocationResultSchema } from "../domain/webSourceLocation.js";
|
||||
import {
|
||||
DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS,
|
||||
webSourceLocationResultSchema,
|
||||
} from "../domain/webSourceLocation.js";
|
||||
import { WebSourceLocationService } from "./WebSourceLocationService.js";
|
||||
import type { WebSourceMapPort } from "./WebSourceLocationPorts.js";
|
||||
|
||||
const service = (data = webSourceLocationFixture(), port = decoder) =>
|
||||
new WebSourceLocationService({ load: () => Promise.resolve(ok(data)) }, port);
|
||||
const expectCodecDeadline = (
|
||||
evidence: {
|
||||
readonly limitations: readonly string[];
|
||||
readonly normalized_result: unknown;
|
||||
},
|
||||
timeoutMs: number,
|
||||
): void => {
|
||||
const phrase = `${String(timeoutMs)} ms decode_timeout_ms deadline`;
|
||||
const result = webSourceLocationResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(
|
||||
evidence.limitations.filter((text) => text.includes(phrase)),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
result.limitations.filter((text) => text.includes(phrase)),
|
||||
).toHaveLength(1);
|
||||
};
|
||||
it("passes an explicit decode deadline and omits the default from Evidence", async () => {
|
||||
const seen: Array<number | undefined> = [];
|
||||
const port: WebSourceMapPort = {
|
||||
trace: (input) => {
|
||||
seen.push(input.decodeTimeoutMs);
|
||||
return decoder.trace(input);
|
||||
},
|
||||
};
|
||||
const explicit = await service(undefined, port).trace({
|
||||
...args,
|
||||
decode_timeout_ms: 250,
|
||||
});
|
||||
if (!explicit.ok) throw explicit.error;
|
||||
expect(explicit.value.parameters.decode_timeout_ms).toBe(250);
|
||||
expectCodecDeadline(explicit.value, 250);
|
||||
const omitted = await service(undefined, port).trace(args);
|
||||
if (!omitted.ok) throw omitted.error;
|
||||
expect(omitted.value.parameters).not.toHaveProperty("decode_timeout_ms");
|
||||
expectCodecDeadline(omitted.value, DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS);
|
||||
expect(seen).toEqual([250, undefined]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...args, manifest_path: "relative.json" },
|
||||
{ ...args, script_index: -1 },
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
webArtifactFileSchema,
|
||||
} from "../domain/webScriptArtifacts.js";
|
||||
import {
|
||||
DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS,
|
||||
WEB_SOURCE_MAP_LIMITS,
|
||||
webSourceLocationInputSchema,
|
||||
webSourceLocationResultSchema,
|
||||
@@ -52,17 +53,19 @@ const LIMITATIONS = [
|
||||
"Lookup returns all segments at the greatest generated position not greater than the selected point, including earlier lines and generated-only segments. A mapped point does not establish an executed range or UI causality.",
|
||||
"Regular maps and inline indexed sections are supported. External section URLs are unsupported; unknown extensions are retained in raw map text and are not interpreted.",
|
||||
];
|
||||
const CODEC_BUDGET =
|
||||
"Complete evidence has a 4 MiB map, 32 MiB reply, 262144 decoded rows/segments and 64-level section budget. The owned codec has a 20-second deadline";
|
||||
const codecBudget = (timeoutMs: number): string =>
|
||||
`Complete evidence has a 4 MiB map, 32 MiB reply, 262144 decoded rows/segments and 64-level section budget. The owned codec has a ${String(timeoutMs)} ms decode_timeout_ms deadline`;
|
||||
/** Bun accepts but ignores the codec's V8 heap flags; Windows enforces a commit limit instead. */
|
||||
const codecBudgetLimitation = (
|
||||
runtime: WebSourceMapReport["runtime"],
|
||||
timeoutMs: number,
|
||||
): string => {
|
||||
const budget = codecBudget(timeoutMs);
|
||||
if (runtime.id === "node-v8")
|
||||
return `${CODEC_BUDGET}, ${String(WEB_SOURCE_MAP_LIMITS.codecHeapMiB)} MiB old-generation heap and one V8 worker; cleanup may extend the deadline. V8 heap limits do not establish aggregate RSS limits.`;
|
||||
return `${budget}, ${String(WEB_SOURCE_MAP_LIMITS.codecHeapMiB)} MiB old-generation heap and one V8 worker; cleanup may extend the deadline. V8 heap limits do not establish aggregate RSS limits.`;
|
||||
return runtime.process_memory_limit_bytes === null
|
||||
? `${CODEC_BUDGET}; cleanup may extend the deadline. The codec ran on Bun, which does not enforce REA's V8 heap flags, so no codec heap limit is established.`
|
||||
: `${CODEC_BUDGET}; cleanup may extend the deadline. The codec ran on Bun, which does not enforce REA's V8 heap flags; a Windows Job Object limited its committed memory to ${String(runtime.process_memory_limit_bytes)} bytes, which is not a heap or RSS limit.`;
|
||||
? `${budget}; cleanup may extend the deadline. The codec ran on Bun, which does not enforce REA's V8 heap flags, so no codec heap limit is established.`
|
||||
: `${budget}; cleanup may extend the deadline. The codec ran on Bun, which does not enforce REA's V8 heap flags; a Windows Job Object limited its committed memory to ${String(runtime.process_memory_limit_bytes)} bytes, which is not a heap or RSS limit.`;
|
||||
};
|
||||
|
||||
/** Join verified captured bytes with an explicitly selected source-map point. */
|
||||
@@ -125,15 +128,20 @@ export class WebSourceLocationService {
|
||||
path: map.file.path,
|
||||
url: map.url,
|
||||
position: input.generated_position,
|
||||
...(input.decode_timeout_ms === undefined
|
||||
? {}
|
||||
: { decodeTimeoutMs: input.decode_timeout_ms }),
|
||||
},
|
||||
options,
|
||||
);
|
||||
if (!traced.ok) return traced;
|
||||
const report = verifyReport(input, map.file.sha256, traced.value);
|
||||
if (!report.ok) return report;
|
||||
const decodeTimeoutMs =
|
||||
input.decode_timeout_ms ?? DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS;
|
||||
const limitations = [
|
||||
...LIMITATIONS,
|
||||
codecBudgetLimitation(report.value.runtime),
|
||||
codecBudgetLimitation(report.value.runtime, decodeTimeoutMs),
|
||||
];
|
||||
const normalized = webSourceLocationResultSchema.safeParse({
|
||||
...report.value,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type {
|
||||
InspectPeCertificatesInput,
|
||||
PeCertificates,
|
||||
} from "../../domain/native/peCertificates.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
|
||||
/** Stable read and PE certificate-table decoding, independent of a disassembler session. */
|
||||
export interface PeCertificatesPort {
|
||||
readonly identity: ProviderIdentity;
|
||||
close?(): Promise<void>;
|
||||
inspect(
|
||||
input: InspectPeCertificatesInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<PeCertificates, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { PeCertificatesService } from "./PeCertificatesService.js";
|
||||
import { PE_CERTIFICATES_PROVIDER } from "../InvestigationProviders.js";
|
||||
import type { PeCertificates } from "../../domain/native/peCertificates.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const identity = PE_CERTIFICATES_PROVIDER;
|
||||
const report: PeCertificates = {
|
||||
artifact: {
|
||||
path: "/artifacts/app.exe",
|
||||
sha256: "a".repeat(64),
|
||||
bytes: 512,
|
||||
},
|
||||
format: "pe32",
|
||||
machine: 0x14c,
|
||||
table: null,
|
||||
entries: [],
|
||||
coverage: { status: "complete", examined_entries: 0, certificates: 0 },
|
||||
limitations: ["IMAGE_DIRECTORY_ENTRY_SECURITY is absent."],
|
||||
};
|
||||
|
||||
it("rejects a relative path and extra arguments before reading", async () => {
|
||||
let calls = 0;
|
||||
const service = new PeCertificatesService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
calls += 1;
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
});
|
||||
const relative = await service.inspect({ path: "app.exe" });
|
||||
const extra = await service.inspect({
|
||||
path: report.artifact.path,
|
||||
extra: true,
|
||||
});
|
||||
expect(relative.ok).toBe(false);
|
||||
expect(extra.ok).toBe(false);
|
||||
if (!relative.ok) expect(relative.error).toBeInstanceOf(AnalysisInputError);
|
||||
if (!extra.ok) expect(extra.error).toBeInstanceOf(AnalysisInputError);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("records the selected PE and an absent certificate table", async () => {
|
||||
const result = await new PeCertificatesService({
|
||||
identity,
|
||||
inspect: () => Promise.resolve(ok(report)),
|
||||
}).inspect({ path: report.artifact.path });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value).toMatchObject({
|
||||
confidence: "observed",
|
||||
subject: { format: "pe", architecture: "x86" },
|
||||
normalized_result: report,
|
||||
});
|
||||
});
|
||||
|
||||
it("discards an observation if the caller cancels while the provider completes", async () => {
|
||||
const controller = new AbortController();
|
||||
const service = new PeCertificatesService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
controller.abort();
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
});
|
||||
const result = await service.inspect(
|
||||
{ path: report.artifact.path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { PeCertificatesPort } from "./PeCertificatesPort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
inspectPeCertificatesInputSchema,
|
||||
peCertificatesSchema,
|
||||
type PeCertificates,
|
||||
} from "../../domain/native/peCertificates.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import {
|
||||
decodePeMachine,
|
||||
peOptionalHeaderMagic,
|
||||
type PeArchitecture,
|
||||
} from "../../domain/peMachine.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "inspect_pe_certificates";
|
||||
|
||||
/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */
|
||||
export class PeCertificatesService {
|
||||
constructor(readonly provider: PeCertificatesPort) {}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.provider.close?.() ?? Promise.resolve();
|
||||
}
|
||||
|
||||
async inspect(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const input = inspectPeCertificatesInputSchema.safeParse(rawInput);
|
||||
if (!input.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, {
|
||||
cause: input.error,
|
||||
}),
|
||||
);
|
||||
if (!isAbsolute(input.data.path))
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute filesystem path on this host.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const result = await this.provider.inspect(input.data, options);
|
||||
if (!result.ok) return result;
|
||||
const report = peCertificatesSchema.safeParse(result.value);
|
||||
if (
|
||||
!report.success ||
|
||||
report.data.artifact.path !== input.data.path ||
|
||||
!validReport(
|
||||
report.data,
|
||||
input.data.max_entries,
|
||||
input.data.max_file_bytes,
|
||||
)
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"PE certificate provider returned malformed output or changed artifact identity.",
|
||||
),
|
||||
);
|
||||
const value = report.data;
|
||||
const architecture = subjectArchitecture(value);
|
||||
const locations = [
|
||||
{ kind: "artifact-path" as const, path: value.artifact.path },
|
||||
...(value.table === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
kind: "file-offset-range" as const,
|
||||
start: value.table.location.offset,
|
||||
end: value.table.location.offset + value.table.location.bytes,
|
||||
},
|
||||
]),
|
||||
];
|
||||
return completeProviderEvidence(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
format: "pe",
|
||||
sha256: value.artifact.sha256,
|
||||
...(architecture === undefined ? {} : { architecture }),
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: value,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
locations,
|
||||
},
|
||||
),
|
||||
options?.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const subjectArchitecture = (
|
||||
report: PeCertificates,
|
||||
): PeArchitecture | undefined => {
|
||||
const machine = decodePeMachine(report.machine);
|
||||
if (machine === undefined || machine.nativeOsOverride) return undefined;
|
||||
const magic = report.format === "pe32-plus" ? 0x20b : 0x10b;
|
||||
return magic === peOptionalHeaderMagic(machine.architecture)
|
||||
? machine.architecture
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const validReport = (
|
||||
report: PeCertificates,
|
||||
maxEntries: number,
|
||||
maxBytes: number,
|
||||
): boolean => {
|
||||
const inside = ({ offset, bytes }: { offset: number; bytes: number }) =>
|
||||
offset <= report.artifact.bytes - bytes;
|
||||
if (
|
||||
report.artifact.bytes > maxBytes ||
|
||||
report.coverage.examined_entries > maxEntries ||
|
||||
report.coverage.certificates !==
|
||||
report.entries.reduce(
|
||||
(count, entry) => count + (entry.pkcs7?.certificates.length ?? 0),
|
||||
0,
|
||||
) ||
|
||||
report.entries.length > maxEntries
|
||||
)
|
||||
return false;
|
||||
if (report.table !== null && !inside(report.table.location)) return false;
|
||||
return report.entries.every(
|
||||
(entry) => inside(entry.location) && inside(entry.payload.location),
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import { isJsonLike } from "../../domain/analysisView/EnvironmentManifestAccumulator.js";
|
||||
import { mediaTypeEssence } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import { parseExactJson } from "../../domain/exactJsonNumber.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
iterateUrlQueryParameters,
|
||||
@@ -10,7 +13,6 @@ import { environmentToolArguments } from "./environmentTools.js";
|
||||
import {
|
||||
REPLAY_BODY_LIMIT_BYTES,
|
||||
ENVIRONMENT_SERVE_OPERATION,
|
||||
replayedBody,
|
||||
type EnvironmentManifestTool,
|
||||
type EnvironmentSource,
|
||||
type RecordedExchange,
|
||||
@@ -181,6 +183,11 @@ const utf8Prefix = (text: string, maxBytes: number): string => {
|
||||
return text.slice(0, read);
|
||||
};
|
||||
|
||||
/** Bytes a padded base64 string decodes to. */
|
||||
const decodedBase64Bytes = (encoded: string): number =>
|
||||
Math.floor((encoded.length * 3) / 4) -
|
||||
(encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0);
|
||||
|
||||
/** Base64 characters, in whole groups, whose bytes fit the replay limit. */
|
||||
const BASE64_LIMIT_CHARS = Math.floor(REPLAY_BODY_LIMIT_BYTES / 3) * 4;
|
||||
|
||||
@@ -189,26 +196,29 @@ interface ReplayBody {
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
/** The reply body and whether it was cut, as the source classifies the retained body. */
|
||||
/** The limit applies to the recorded bytes, not to their base64 spelling. */
|
||||
const replayBase64Body = (encoded: string, cut: boolean): ReplayBody =>
|
||||
!cut && decodedBase64Bytes(encoded) <= REPLAY_BODY_LIMIT_BYTES
|
||||
? { body: encoded, truncated: false }
|
||||
: { body: encoded.slice(0, BASE64_LIMIT_CHARS), truncated: true };
|
||||
|
||||
/** Parse a retained body as JSON when its content type is JSON-like; otherwise return the text. */
|
||||
const replayBody = (exchange: RecordedExchange): ReplayBody => {
|
||||
const body = replayedBody(exchange);
|
||||
switch (body.kind) {
|
||||
case "none":
|
||||
return { body: null, truncated: false };
|
||||
case "base64":
|
||||
return body.whole
|
||||
? { body: body.encoded, truncated: false }
|
||||
: { body: body.encoded.slice(0, BASE64_LIMIT_CHARS), truncated: true };
|
||||
case "cut":
|
||||
return {
|
||||
body: utf8Prefix(body.text, REPLAY_BODY_LIMIT_BYTES),
|
||||
truncated: true,
|
||||
};
|
||||
case "json":
|
||||
return { body: body.value, truncated: false };
|
||||
case "text":
|
||||
return { body: body.text, truncated: false };
|
||||
const text = exchange.bodyText;
|
||||
if (text === null) return { body: null, truncated: false };
|
||||
if (exchange.bodyEncoding === "base64")
|
||||
return replayBase64Body(text, exchange.bodyCut === true);
|
||||
const prefix = utf8Prefix(text, REPLAY_BODY_LIMIT_BYTES);
|
||||
if (prefix.length < text.length || exchange.bodyCut === true)
|
||||
return { body: prefix, truncated: true };
|
||||
if (
|
||||
exchange.contentType !== null &&
|
||||
isJsonLike(mediaTypeEssence(exchange.contentType))
|
||||
) {
|
||||
const value = parseExactJson(text);
|
||||
if (value !== undefined) return { body: value, truncated: false };
|
||||
}
|
||||
return { body: text, truncated: false };
|
||||
};
|
||||
|
||||
/** Recorded paths and queries are cut to this many UTF-8 bytes in replies. */
|
||||
|
||||
@@ -9,11 +9,7 @@ import {
|
||||
readRecordedResponseBody,
|
||||
type RecordedResponseBody,
|
||||
} from "../../domain/analysisView/recordedResponseBody.js";
|
||||
import { isJsonLike } from "../../domain/analysisView/EnvironmentManifestAccumulator.js";
|
||||
import {
|
||||
mediaTypeEssence,
|
||||
readRecordedRequestLine,
|
||||
} from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import { readRecordedRequestLine } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import { browserScenarioCaptureSchema } from "../../domain/browserScenarioCapture.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
@@ -21,10 +17,8 @@ import {
|
||||
type AnalysisInputIssue,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { immutableEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { parseExactJson } from "../../domain/exactJsonNumber.js";
|
||||
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { immutableJsonSnapshotMember } from "../../domain/immutableJson.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
safeParseOwnedWebNetworkCapture,
|
||||
@@ -53,52 +47,6 @@ export interface RecordedExchange extends RecordedResponseBody {
|
||||
/** Replayed bodies larger than this are cut at a UTF-8 boundary and flagged. */
|
||||
export const REPLAY_BODY_LIMIT_BYTES = 256 * 1024;
|
||||
|
||||
/** How replay answers with an exchange's body; replay and tool descriptions both read it from here. */
|
||||
export type ReplayedBody =
|
||||
| { readonly kind: "none" }
|
||||
/** Non-UTF-8 bytes as base64; `whole` when every recorded byte fits the replay limit. */
|
||||
| {
|
||||
readonly kind: "base64";
|
||||
readonly encoded: string;
|
||||
readonly whole: boolean;
|
||||
}
|
||||
/** UTF-8 text over the replay limit or cut when retained; replay answers with a prefix. */
|
||||
| { readonly kind: "cut"; readonly text: string }
|
||||
| { readonly kind: "json"; readonly value: JsonValue }
|
||||
| { readonly kind: "text"; readonly text: string };
|
||||
|
||||
/** Bytes a padded base64 string decodes to. */
|
||||
const decodedBase64Bytes = (encoded: string): number =>
|
||||
Math.floor((encoded.length * 3) / 4) -
|
||||
(encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0);
|
||||
|
||||
/** Classify a retained body once: whole JSON-like UTF-8 text within the limit that parses exactly is JSON. */
|
||||
export const replayedBody = (exchange: RecordedExchange): ReplayedBody => {
|
||||
const text = exchange.bodyText;
|
||||
if (text === null) return { kind: "none" };
|
||||
if (exchange.bodyEncoding === "base64")
|
||||
return {
|
||||
kind: "base64",
|
||||
encoded: text,
|
||||
whole:
|
||||
exchange.bodyCut !== true &&
|
||||
decodedBase64Bytes(text) <= REPLAY_BODY_LIMIT_BYTES,
|
||||
};
|
||||
if (
|
||||
exchange.bodyCut === true ||
|
||||
// UTF-8 never takes fewer bytes than UTF-16 units, so the length check avoids encoding huge text.
|
||||
text.length > REPLAY_BODY_LIMIT_BYTES ||
|
||||
Buffer.byteLength(text, "utf8") > REPLAY_BODY_LIMIT_BYTES
|
||||
)
|
||||
return { kind: "cut", text };
|
||||
const value =
|
||||
exchange.contentType !== null &&
|
||||
isJsonLike(mediaTypeEssence(exchange.contentType))
|
||||
? parseExactJson(text)
|
||||
: undefined;
|
||||
return value === undefined ? { kind: "text", text } : { kind: "json", value };
|
||||
};
|
||||
|
||||
/** A HAR inspected by inspect_web_network_capture, or a session recorded by capture_browser_scenario. */
|
||||
export type EnvironmentCaptureKind = "web-network-capture" | "browser-scenario";
|
||||
|
||||
|
||||
@@ -418,6 +418,30 @@ describe("EnvironmentStateStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("caller-owned state replacement", () => {
|
||||
it("does not retain mutable data from a caller-owned replacement", () => {
|
||||
const store = new EnvironmentStateStore(state);
|
||||
const document = documentWith((value) => {
|
||||
const entity = value.rea_state.entities.find((item) => item.id === "2");
|
||||
if (!entity) throw new Error("Expected the user entity");
|
||||
entity.fields = { ...(entity.fields as object), login: "renamed" };
|
||||
});
|
||||
expect(store.replace(document).ok).toBe(true);
|
||||
const callerEntity = document.rea_state.entities.find(
|
||||
(entity) => entity.id === "2",
|
||||
);
|
||||
if (!callerEntity) throw new Error("Expected the caller's user entity");
|
||||
(callerEntity.fields as Record<string, unknown>).login = "later edit";
|
||||
|
||||
expect(store.snapshot().rea_state.entities).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "2",
|
||||
fields: expect.objectContaining({ login: "renamed" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rea-state/v1 validation", () => {
|
||||
it.each([
|
||||
[
|
||||
@@ -570,3 +594,36 @@ describe("rea-state/v1 validation", () => {
|
||||
).toMatchObject({ path: "rea_state/version" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("wide state value validation", () => {
|
||||
it("preserves a wide caller value through the state replacement boundary", () => {
|
||||
const wide = Array.from({ length: 200_000 }, (_, index) => index % 10);
|
||||
const wideObject = Object.fromEntries(
|
||||
Array.from({ length: 20_000 }, (_, index) => [
|
||||
`field_${String(index)}`,
|
||||
index,
|
||||
]),
|
||||
);
|
||||
const document = documentWith((value) => {
|
||||
const fields = value.rea_state.entities[0]!.fields as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
value.rea_state.entities[0]!.fields = {
|
||||
...fields,
|
||||
caller_wide: wide,
|
||||
caller_wide_object: wideObject,
|
||||
};
|
||||
});
|
||||
const store = new EnvironmentStateStore(state);
|
||||
|
||||
expect(store.replace(document).ok).toBe(true);
|
||||
const retained = store.snapshot().rea_state.entities[0]?.fields.caller_wide;
|
||||
if (!Array.isArray(retained)) throw new Error("Expected the wide value");
|
||||
expect(retained).toHaveLength(wide.length);
|
||||
expect(retained.at(-1)).toBe(wide.at(-1));
|
||||
const retainedObject =
|
||||
store.snapshot().rea_state.entities[0]?.fields.caller_wide_object;
|
||||
expect(retainedObject).toEqual(wideObject);
|
||||
});
|
||||
});
|
||||
@@ -61,19 +61,17 @@ export const servedCatalog = (
|
||||
operation: string = ENVIRONMENT_SERVE_OPERATION,
|
||||
): Result<ServedCatalog, AnalysisResourceConstraintError> => {
|
||||
const dataPlane: EnvironmentDataPlane = fromState ? "state" : "none";
|
||||
const catalogSource: EnvironmentSource = fromState
|
||||
? {
|
||||
...source,
|
||||
manifest: {
|
||||
const tools = environmentToolDefinitions(
|
||||
fromState
|
||||
? {
|
||||
...source.manifest,
|
||||
tools: source.manifest.tools.filter(servedFromState),
|
||||
},
|
||||
}
|
||||
: source;
|
||||
const tools = environmentToolDefinitions(catalogSource, name, {
|
||||
operation,
|
||||
dataPlane,
|
||||
});
|
||||
}
|
||||
: source.manifest,
|
||||
source.evidenceId,
|
||||
name,
|
||||
{ operation, dataPlane },
|
||||
);
|
||||
return tools.ok ? ok({ tools: tools.value, dataPlane }) : tools;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { types } from "node:util";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
@@ -16,7 +17,11 @@ import {
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { canonicalJson } from "../../domain/comparisonSemantics.js";
|
||||
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
jsonValueSchema,
|
||||
ownedJsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
|
||||
import type { EnvironmentState } from "./environmentState.js";
|
||||
@@ -37,37 +42,42 @@ export interface StateContent {
|
||||
readonly documents: readonly HeldDocument[];
|
||||
}
|
||||
|
||||
const fieldsSchema = z.record(z.string(), jsonValueSchema);
|
||||
const ordinalSchema = z.number().int().nonnegative();
|
||||
const stateDocumentSchemaFor = (jsonValue: z.ZodType<JsonValue, unknown>) => {
|
||||
const fieldsSchema = z.record(z.string(), jsonValue);
|
||||
const ordinalSchema = z.number().int().nonnegative();
|
||||
|
||||
const stateDocumentSchema = z.strictObject({
|
||||
rea_state: z.strictObject({
|
||||
version: z.literal(1),
|
||||
// `data/get` reports these; accepting them back lets its output be loaded unchanged.
|
||||
state_evidence_id: z.string().optional(),
|
||||
types: z.array(jsonValueSchema).optional(),
|
||||
entities: z.array(
|
||||
z.strictObject({
|
||||
type: z.string(),
|
||||
collection: z.string(),
|
||||
id: z.string(),
|
||||
fields: fieldsSchema,
|
||||
view_fields: z
|
||||
.array(z.strictObject({ tool: z.string(), fields: fieldsSchema }))
|
||||
.optional(),
|
||||
record_ordinals: z.array(ordinalSchema).optional(),
|
||||
}),
|
||||
),
|
||||
documents: z.array(
|
||||
z.strictObject({
|
||||
tool: z.string(),
|
||||
path: z.string(),
|
||||
value: jsonValueSchema,
|
||||
record_ordinal: ordinalSchema.optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
return z.strictObject({
|
||||
rea_state: z.strictObject({
|
||||
version: z.literal(1),
|
||||
// `data/get` reports these; accepting them back lets its output be loaded unchanged.
|
||||
state_evidence_id: z.string().optional(),
|
||||
types: z.array(jsonValue).optional(),
|
||||
entities: z.array(
|
||||
z.strictObject({
|
||||
type: z.string(),
|
||||
collection: z.string(),
|
||||
id: z.string(),
|
||||
fields: fieldsSchema,
|
||||
view_fields: z
|
||||
.array(z.strictObject({ tool: z.string(), fields: fieldsSchema }))
|
||||
.optional(),
|
||||
record_ordinals: z.array(ordinalSchema).optional(),
|
||||
}),
|
||||
),
|
||||
documents: z.array(
|
||||
z.strictObject({
|
||||
tool: z.string(),
|
||||
path: z.string(),
|
||||
value: jsonValue,
|
||||
record_ordinal: ordinalSchema.optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const stateDocumentSchema = stateDocumentSchemaFor(jsonValueSchema);
|
||||
const ownedStateDocumentSchema = stateDocumentSchemaFor(ownedJsonValueSchema);
|
||||
|
||||
type ParsedDocument = z.output<typeof stateDocumentSchema>["rea_state"];
|
||||
type ParsedEntity = ParsedDocument["entities"][number];
|
||||
@@ -105,31 +115,82 @@ const memberProblem = (path: Path): AnalysisInputIssue => {
|
||||
|
||||
/** The path of the first `__proto__` member in document order; schema parsing would drop such a member rather than refuse it. */
|
||||
const prototypeMemberPath = (root: unknown): Path | undefined => {
|
||||
interface Node {
|
||||
interface Cursor {
|
||||
readonly value: unknown;
|
||||
readonly parent: Node | undefined;
|
||||
readonly parent: Cursor | undefined;
|
||||
readonly key: string | number;
|
||||
readonly entries?: Generator<string>;
|
||||
index: number;
|
||||
}
|
||||
const pathOf = (node: Node): (string | number)[] => {
|
||||
const pathOf = (node: Cursor): (string | number)[] => {
|
||||
const path: (string | number)[] = [];
|
||||
for (let at: Node = node; at.parent !== undefined; at = at.parent)
|
||||
for (let at: Cursor = node; at.parent !== undefined; at = at.parent)
|
||||
path.push(at.key);
|
||||
return path.reverse();
|
||||
};
|
||||
const pending: Node[] = [{ value: root, parent: undefined, key: "" }];
|
||||
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
||||
function* ownKeys(value: Record<string, unknown>): Generator<string> {
|
||||
// `for...in` is lazy. The ownership check keeps inherited enumerable
|
||||
// properties out, matching Object.entries without allocating all entries.
|
||||
for (const key in value) if (Object.hasOwn(value, key)) yield key;
|
||||
}
|
||||
const cursor = (
|
||||
value: unknown,
|
||||
parent: Cursor | undefined,
|
||||
key: string | number,
|
||||
): Cursor => ({
|
||||
value,
|
||||
parent,
|
||||
key,
|
||||
index: 0,
|
||||
...(!Array.isArray(value) && typeof value === "object" && value !== null
|
||||
? { entries: ownKeys(value as Record<string, unknown>) }
|
||||
: {}),
|
||||
});
|
||||
const pending: Cursor[] = [cursor(root, undefined, "")];
|
||||
while (pending.length > 0) {
|
||||
const node = pending[pending.length - 1]!;
|
||||
const { value } = node;
|
||||
if (typeof value !== "object" || value === null) continue;
|
||||
if (typeof value !== "object" || value === null) {
|
||||
pending.pop();
|
||||
continue;
|
||||
}
|
||||
// The canonical JSON validator reports proxies without invoking their traps.
|
||||
if (types.isProxy(value)) {
|
||||
pending.pop();
|
||||
continue;
|
||||
}
|
||||
const array = Array.isArray(value);
|
||||
if (!array && Object.hasOwn(value, "__proto__"))
|
||||
return [...pathOf(node), "__proto__"];
|
||||
const entries = Object.entries(value);
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1)
|
||||
pending.push({
|
||||
value: entries[index]![1],
|
||||
parent: node,
|
||||
key: array ? index : entries[index]![0],
|
||||
});
|
||||
if (array) {
|
||||
if (node.index >= value.length) {
|
||||
pending.pop();
|
||||
continue;
|
||||
}
|
||||
const index = node.index++;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, index);
|
||||
if (
|
||||
descriptor?.enumerable !== true ||
|
||||
!Object.hasOwn(descriptor, "value")
|
||||
)
|
||||
continue;
|
||||
const child: unknown = descriptor.value;
|
||||
if (typeof child === "object" && child !== null)
|
||||
pending.push(cursor(child, node, index));
|
||||
continue;
|
||||
}
|
||||
const next = node.entries?.next();
|
||||
if (next === undefined || next.done) {
|
||||
pending.pop();
|
||||
continue;
|
||||
}
|
||||
const key = next.value;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor?.enumerable !== true || !Object.hasOwn(descriptor, "value"))
|
||||
continue;
|
||||
const child: unknown = descriptor.value;
|
||||
if (typeof child === "object" && child !== null)
|
||||
pending.push(cursor(child, node, key));
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -365,9 +426,12 @@ const heldContent = (
|
||||
export const parseStateDocument = (
|
||||
state: EnvironmentState,
|
||||
value: unknown,
|
||||
owned = false,
|
||||
): Result<StateContent, AnalysisInputError> => {
|
||||
const prototype = prototypeMemberPath(value);
|
||||
const parsed = stateDocumentSchema.safeParse(value);
|
||||
const parsed = (
|
||||
owned ? ownedStateDocumentSchema : stateDocumentSchema
|
||||
).safeParse(value);
|
||||
const initial = new Map(
|
||||
state.entities.map((entity) => [entityKey(entity), entity]),
|
||||
);
|
||||
|
||||
@@ -58,9 +58,21 @@ export class EnvironmentStateStore {
|
||||
this.#install(this.state);
|
||||
}
|
||||
|
||||
/** Validate a `rea-state/v1` document and, only when it is valid, make it the whole state. */
|
||||
/** Validate a caller-owned document and copy its JSON values before retaining them. */
|
||||
replace(document: unknown): Result<void, AnalysisInputError> {
|
||||
const content = parseStateDocument(this.state, document);
|
||||
return this.#replace(document, false);
|
||||
}
|
||||
|
||||
/** Validate and retain a JSON document created exclusively by the serving boundary. */
|
||||
replaceOwnedDocument(document: unknown): Result<void, AnalysisInputError> {
|
||||
return this.#replace(document, true);
|
||||
}
|
||||
|
||||
#replace(
|
||||
document: unknown,
|
||||
owned: boolean,
|
||||
): Result<void, AnalysisInputError> {
|
||||
const content = parseStateDocument(this.state, document, owned);
|
||||
if (!content.ok) return content;
|
||||
// data/get writes members a document may omit, so the state it reports must still be one data/add loads.
|
||||
if (jsonBytes(this.#snapshotOf(content.value)) > STATE_INTAKE_LIMIT_BYTES)
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { environmentServeSource } from "../../../tests/fixtures/environmentServe.js";
|
||||
import type {
|
||||
EnvironmentManifestTool,
|
||||
RecordedExchange,
|
||||
} from "./environmentSource.js";
|
||||
import type { EnvironmentManifestTool } from "./environmentSource.js";
|
||||
import {
|
||||
ENVIRONMENT_CATALOG_LIMIT_BYTES,
|
||||
environmentCard,
|
||||
environmentToolArguments,
|
||||
environmentToolDefinitions as prepareDefinitions,
|
||||
@@ -21,16 +17,18 @@ const environmentToolDefinitions = (
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
};
|
||||
const withTools = (tools: readonly EnvironmentManifestTool[]) => ({
|
||||
...source,
|
||||
manifest: { ...source.manifest, tools: [...tools] },
|
||||
});
|
||||
const environmentToolInputSchema = (tool: EnvironmentManifestTool) => {
|
||||
const [definition] = environmentToolDefinitions(withTools([tool]));
|
||||
const [definition] = environmentToolDefinitions(
|
||||
{ ...source.manifest, tools: [tool] },
|
||||
source.evidenceId,
|
||||
);
|
||||
if (definition === undefined) throw new Error("Missing tool definition");
|
||||
return definition.inputSchema;
|
||||
};
|
||||
const definitions = environmentToolDefinitions(source);
|
||||
const definitions = environmentToolDefinitions(
|
||||
source.manifest,
|
||||
source.evidenceId,
|
||||
);
|
||||
|
||||
const tool = (name: string): EnvironmentManifestTool => {
|
||||
const found = source.manifest.tools.find((entry) => entry.name === name);
|
||||
@@ -46,7 +44,7 @@ describe("environmentToolDefinitions", () => {
|
||||
const users = tool("get_users_by_id");
|
||||
const definition = definitions.find(({ name }) => name === users.name);
|
||||
expect(definition?.description).toBe(
|
||||
`${REPLAY_CATALOG_INSTRUCTIONS} GET ${users.path_template}; returns a JSON object with fields "id", "uid", "login", "email"; recorded statuses 200; replays retained records [${users.record_ordinals.join(", ")}]; evidence ${source.evidenceId}`,
|
||||
`${REPLAY_CATALOG_INSTRUCTIONS} GET ${users.path_template}; replays retained records [${users.record_ordinals.join(", ")}]; evidence ${source.evidenceId}`,
|
||||
);
|
||||
expect(definition?.tool).toBe(users);
|
||||
});
|
||||
@@ -110,230 +108,6 @@ describe("environmentToolDefinitions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const described = (name: string): string | undefined =>
|
||||
definitions.find((definition) => definition.name === name)?.description;
|
||||
/** The users tool citing exactly the given exchanges, with the statuses its manifest recorded. */
|
||||
const usersAnswering = (
|
||||
answers: readonly Partial<RecordedExchange>[],
|
||||
statuses: readonly number[] = [200],
|
||||
): string | undefined => {
|
||||
const exchanges = new Map(
|
||||
answers.map((answer, index): [number, RecordedExchange] => [
|
||||
index,
|
||||
{
|
||||
ordinal: index,
|
||||
path: "/api/users/2",
|
||||
query: null,
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
bodyText: null,
|
||||
...answer,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const users = {
|
||||
...tool("get_users_by_id"),
|
||||
record_ordinals: [...exchanges.keys()],
|
||||
statuses: [...statuses],
|
||||
};
|
||||
const [definition] = environmentToolDefinitions({
|
||||
...withTools([users]),
|
||||
exchanges,
|
||||
});
|
||||
return definition?.description;
|
||||
};
|
||||
const returnsOf = (bodyText: string): string | undefined =>
|
||||
/\{numeric\}; (?<returns>[^;]*); recorded statuses/u.exec(
|
||||
usersAnswering([{ bodyText }]) ?? "",
|
||||
)?.groups?.returns;
|
||||
|
||||
describe("environment tool response shapes", () => {
|
||||
it("describes array, enveloped list and object responses one level deep with their statuses", () => {
|
||||
expect(described("get_folders")).toContain(
|
||||
'GET /api/folders; returns a JSON array of objects with fields "id", "uid", "title"; recorded statuses 200; replays',
|
||||
);
|
||||
expect(described("get_dashboards_versions_by_uid")).toContain(
|
||||
'; returns a JSON object whose "versions" lists objects with fields "id", "version"; recorded statuses 200; replays',
|
||||
);
|
||||
expect(described("get_dashboards_by_uid")).toContain(
|
||||
'; returns a JSON object with fields "meta", "dashboard"; recorded statuses 200; replays',
|
||||
);
|
||||
});
|
||||
|
||||
it("describes empty, scalar and fieldless bodies and bounds a long member name", () => {
|
||||
expect(
|
||||
[
|
||||
"[]",
|
||||
"[1,2]",
|
||||
'"text"',
|
||||
"null",
|
||||
"true",
|
||||
"3",
|
||||
"{}",
|
||||
"[{},{}]",
|
||||
'{"items":[]}',
|
||||
].map(returnsOf),
|
||||
).toEqual([
|
||||
"returns a JSON array",
|
||||
"returns a JSON array",
|
||||
"returns a JSON string",
|
||||
"returns a JSON null",
|
||||
"returns a JSON boolean",
|
||||
"returns a JSON number",
|
||||
"returns a JSON object with no fields",
|
||||
"returns a JSON array of objects with no fields",
|
||||
'returns a JSON object with fields "items"',
|
||||
]);
|
||||
const member = "m".repeat(2048);
|
||||
expect(
|
||||
usersAnswering([
|
||||
{ bodyText: JSON.stringify({ [member]: [{ id: 1 }, { id: 2 }] }) },
|
||||
]),
|
||||
).toContain(
|
||||
`{numeric}; returns a JSON object whose "${"m".repeat(1024)}"… (member display truncated; full value in capture Evidence) lists objects with fields "id"; recorded statuses 200;`,
|
||||
);
|
||||
});
|
||||
|
||||
it("quotes captured names, so a separator in one cannot read as more of the description", () => {
|
||||
const description = usersAnswering([
|
||||
{
|
||||
bodyText: JSON.stringify({
|
||||
"id, name": 1,
|
||||
"x; recorded statuses 500": 2,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(description).toContain(
|
||||
'returns a JSON object with fields "id, name", "x; recorded statuses 500"; recorded statuses 200;',
|
||||
);
|
||||
expect(
|
||||
usersAnswering([
|
||||
{ bodyText: JSON.stringify({ "a\nb": [{ id: 1 }, { id: 2 }] }) },
|
||||
]),
|
||||
).toContain('whose "a\\nb" lists objects with fields "id";');
|
||||
// A name too long to show whole is shown as a quoted prefix, so its newline cannot start a line of description.
|
||||
const long = `items; recorded statuses 500\n${"m".repeat(2048)}`;
|
||||
const shown = usersAnswering([
|
||||
{ bodyText: JSON.stringify({ [long]: [{ id: 1 }, { id: 2 }] }) },
|
||||
]);
|
||||
expect(shown).toContain('whose "items; recorded statuses 500\\nmmm');
|
||||
expect(shown).not.toContain("items; recorded statuses 500\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment tool response summaries", () => {
|
||||
it("describes the first cited 2xx record replay answers as JSON, then the manifest's statuses", () => {
|
||||
const description = usersAnswering(
|
||||
[
|
||||
{
|
||||
contentType: "text/html",
|
||||
bodyText: '{"looks":"like JSON"}',
|
||||
},
|
||||
{ status: 401, bodyText: '{"message":"Unauthorized","url":"/login"}' },
|
||||
{ bodyText: '{"id":2,"login":"probe"}' },
|
||||
{ bodyText: '[{"id":3,"email":"viewer@example.test"}]' },
|
||||
],
|
||||
[200, 401],
|
||||
);
|
||||
expect(description).toContain(
|
||||
'{numeric}; returns a JSON object with fields "id", "login"; recorded statuses 200, 401; replays retained records [0, 1, 2, 3];',
|
||||
);
|
||||
});
|
||||
|
||||
it("labels a JSON record of another status only when no 2xx record replays as JSON", () => {
|
||||
expect(
|
||||
usersAnswering(
|
||||
[
|
||||
{ contentType: "text/html", bodyText: "<p>Signed out</p>" },
|
||||
{ status: 401, bodyText: '{"message":"Unauthorized"}' },
|
||||
{ status: 403, bodyText: '{"errors":[{"message":"Forbidden"}]}' },
|
||||
],
|
||||
[200, 401, 403],
|
||||
),
|
||||
).toContain(
|
||||
'{numeric}; status 401 returns a JSON object with fields "message"; recorded statuses 200, 401, 403; replays',
|
||||
);
|
||||
expect(
|
||||
usersAnswering([{ status: null, bodyText: '{"id":2}' }], []),
|
||||
).toContain(
|
||||
'{numeric}; a record without a status returns a JSON object with fields "id"; no recorded status; replays',
|
||||
);
|
||||
});
|
||||
|
||||
it("states only the statuses when no cited exchange replays as JSON", () => {
|
||||
for (const answer of [
|
||||
{ contentType: "text/html", bodyText: "<p>Granite Ledger</p>" },
|
||||
{ bodyText: '{"id":9007199254740993}' },
|
||||
{ bodyText: '{"id":2}', bodyEncoding: "base64" as const },
|
||||
{ bodyText: '{"id":2}', bodyCut: true as const },
|
||||
{ bodyText: null },
|
||||
])
|
||||
expect(usersAnswering([answer])).toMatch(
|
||||
/\{numeric\}; recorded statuses 200; replays retained records/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("charges response summaries to the catalog reservation before retaining them", () => {
|
||||
const fields = Object.fromEntries(
|
||||
Array.from({ length: 300 }, (_, index) => [
|
||||
`field_${String(index).padStart(3, "0")}`,
|
||||
index,
|
||||
]),
|
||||
);
|
||||
const users = tool("get_users_by_id");
|
||||
const ordinal = users.record_ordinals[0] ?? 0;
|
||||
const catalog = (contentType: string) =>
|
||||
prepareDefinitions({
|
||||
...withTools(
|
||||
Array.from({ length: 200 }, (_, index) => ({
|
||||
...users,
|
||||
name: `get_users_${String(index)}`,
|
||||
record_ordinals: [ordinal],
|
||||
})),
|
||||
),
|
||||
exchanges: new Map([
|
||||
[
|
||||
ordinal,
|
||||
{
|
||||
ordinal,
|
||||
path: "/api/users/2",
|
||||
query: null,
|
||||
status: 200,
|
||||
contentType,
|
||||
bodyText: JSON.stringify(fields),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
expect(catalog("text/html").ok).toBe(true);
|
||||
const bounded = catalog("application/json");
|
||||
expect(bounded).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
resource: "capacity",
|
||||
reportedLimits: { max_catalog_bytes: ENVIRONMENT_CATALOG_LIMIT_BYTES },
|
||||
},
|
||||
});
|
||||
const [single] = environmentToolDefinitions({
|
||||
...withTools([users]),
|
||||
exchanges: new Map([
|
||||
[
|
||||
ordinal,
|
||||
{
|
||||
...source.exchanges.get(ordinal)!,
|
||||
bodyText: JSON.stringify(fields),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
expect(Buffer.byteLength(single?.description ?? "")).toBeLessThan(1700);
|
||||
expect(single?.description).toContain(
|
||||
"/300 shown; full list in capture Evidence)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment catalog description budgets", () => {
|
||||
it("bounds captured scalar prose while preserving complete lookup identities", () => {
|
||||
const query = "query-".repeat(1000);
|
||||
@@ -343,7 +117,10 @@ describe("environment catalog description budgets", () => {
|
||||
path_template: `/${"😀".repeat(1000)}`,
|
||||
query_parameter_names: [query],
|
||||
};
|
||||
const [definition] = environmentToolDefinitions(withTools([expanded]));
|
||||
const [definition] = environmentToolDefinitions(
|
||||
{ ...source.manifest, tools: [expanded] },
|
||||
source.evidenceId,
|
||||
);
|
||||
expect(definition?.tool).toBe(expanded);
|
||||
expect(Buffer.byteLength(definition?.description ?? "")).toBeLessThan(3500);
|
||||
expect(definition?.description).toContain(
|
||||
@@ -375,7 +152,10 @@ describe("environment catalog description budgets", () => {
|
||||
})),
|
||||
record_ordinals: ordinals,
|
||||
};
|
||||
const [definition] = environmentToolDefinitions(withTools([expanded]));
|
||||
const [definition] = environmentToolDefinitions(
|
||||
{ ...source.manifest, tools: [expanded] },
|
||||
source.evidenceId,
|
||||
);
|
||||
expect(definition?.tool).toBe(expanded);
|
||||
expect(expanded.params[0]?.observed_values).toBe(observations);
|
||||
expect(expanded.record_ordinals).toBe(ordinals);
|
||||
@@ -400,7 +180,10 @@ describe("environment catalog description budgets", () => {
|
||||
...users,
|
||||
path_template: `/api/${"segment/".repeat(200_000)}{id}`,
|
||||
};
|
||||
const [definition] = environmentToolDefinitions(withTools([long]));
|
||||
const [definition] = environmentToolDefinitions(
|
||||
{ ...source.manifest, tools: [long] },
|
||||
source.evidenceId,
|
||||
);
|
||||
expect(Buffer.byteLength(definition?.description ?? "")).toBeLessThan(2048);
|
||||
expect(definition?.description).toContain(
|
||||
"path display truncated; full value in manifest Evidence",
|
||||
@@ -547,22 +330,3 @@ describe("environmentCard", () => {
|
||||
expect(environmentCard("renamed", definitions).name).toBe("renamed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment tool annotations", () => {
|
||||
it("marks OPTIONS and TRACE safe and idempotent, as GET is", () => {
|
||||
const get = tool("get_users_by_id");
|
||||
const hints = environmentToolDefinitions(
|
||||
withTools(
|
||||
(["OPTIONS", "TRACE"] as const).map((method) => ({
|
||||
...get,
|
||||
name: `${method.toLowerCase()}_users_by_id`,
|
||||
method,
|
||||
})),
|
||||
),
|
||||
).map((definition) => definition.annotations);
|
||||
expect(hints).toEqual([
|
||||
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
||||
{ readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,9 @@
|
||||
import {
|
||||
isObject,
|
||||
isQueryPost,
|
||||
listItems,
|
||||
locateList,
|
||||
pointerTokens,
|
||||
type JsonObject,
|
||||
} from "../../domain/analysisView/environmentStateRules.js";
|
||||
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
ENVIRONMENT_SERVE_OPERATION,
|
||||
replayedBody,
|
||||
type EnvironmentManifestTool,
|
||||
type EnvironmentSource,
|
||||
type RecordedExchange,
|
||||
import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js";
|
||||
import type {
|
||||
EnvironmentManifest,
|
||||
EnvironmentManifestTool,
|
||||
} from "./environmentSource.js";
|
||||
|
||||
/** Methods whose tools accept an optional request body object. */
|
||||
@@ -38,76 +27,36 @@ export interface EnvironmentToolInputSchema {
|
||||
readonly additionalProperties: false;
|
||||
}
|
||||
|
||||
/** MCP hints for the captured method; answers come from the capture, never the open world. */
|
||||
export interface EnvironmentToolAnnotations {
|
||||
readonly readOnlyHint: boolean;
|
||||
/** Set for `DELETE` only; other writes leave the MCP default (destructive). */
|
||||
readonly destructiveHint?: true;
|
||||
readonly idempotentHint: boolean;
|
||||
readonly openWorldHint: false;
|
||||
}
|
||||
|
||||
/** One manifest tool as an MCP tool: name, description, input schema and the hints MCP advertises; the card omits the hints. */
|
||||
/** One manifest tool as an MCP tool: name, description and input schema. */
|
||||
export interface EnvironmentToolDefinition {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly inputSchema: EnvironmentToolInputSchema;
|
||||
readonly annotations: EnvironmentToolAnnotations;
|
||||
readonly tool: EnvironmentManifestTool;
|
||||
}
|
||||
|
||||
/** RFC 9110 safe methods: a preflight `OPTIONS` reads, as `GET` does. */
|
||||
const SAFE_METHODS: ReadonlySet<string> = new Set([
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS",
|
||||
"TRACE",
|
||||
]);
|
||||
|
||||
const IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([
|
||||
...SAFE_METHODS,
|
||||
"PUT",
|
||||
"DELETE",
|
||||
]);
|
||||
|
||||
const environmentToolAnnotations = ({
|
||||
method,
|
||||
path_template: path,
|
||||
}: EnvironmentManifestTool): EnvironmentToolAnnotations => ({
|
||||
readOnlyHint: SAFE_METHODS.has(method) || isQueryPost(method, path),
|
||||
...(method === "DELETE" ? { destructiveHint: true as const } : {}),
|
||||
idempotentHint: IDEMPOTENT_METHODS.has(method),
|
||||
openWorldHint: false,
|
||||
});
|
||||
|
||||
/** Agent-context budget for each captured display sample, excluding its provenance notice. */
|
||||
const DESCRIPTION_SAMPLE_BYTES = 1024;
|
||||
|
||||
/** A display prefix is explicitly separate from the complete identity held by the Evidence named; `show` renders the text or its prefix. */
|
||||
const describeText = (
|
||||
text: string,
|
||||
label: string,
|
||||
holder = "manifest Evidence",
|
||||
show: (shown: string) => string = (shown) => shown,
|
||||
): string => {
|
||||
/** A display prefix is explicitly separate from the complete identity held by the manifest. */
|
||||
const describeText = (text: string, label: string): string => {
|
||||
if (
|
||||
text.length <= DESCRIPTION_SAMPLE_BYTES &&
|
||||
Buffer.byteLength(text, "utf8") <= DESCRIPTION_SAMPLE_BYTES
|
||||
)
|
||||
return show(text);
|
||||
return text;
|
||||
const { read } = new TextEncoder().encodeInto(
|
||||
text,
|
||||
new Uint8Array(DESCRIPTION_SAMPLE_BYTES),
|
||||
);
|
||||
return `${show(text.slice(0, read))}… (${label} display truncated; full value in ${holder})`;
|
||||
return `${text.slice(0, read)}… (${label} display truncated; full value in manifest Evidence)`;
|
||||
};
|
||||
|
||||
/** Sample whole values before joining; keep full lists in the Evidence that owns them. */
|
||||
/** Sample whole values before joining; keep full lists in the manifest that owns them. */
|
||||
const describeList = <Value>(
|
||||
values: Iterable<Value>,
|
||||
count: number,
|
||||
display: (value: Value) => string = String,
|
||||
holder = "manifest Evidence",
|
||||
): string => {
|
||||
if (count === 0) return "none";
|
||||
const sample: string[] = [];
|
||||
@@ -127,80 +76,7 @@ const describeList = <Value>(
|
||||
const listed = sample.join(", ");
|
||||
return sample.length === count
|
||||
? listed
|
||||
: `${listed}${sample.length === 0 ? "" : ", "}… (${String(sample.length)}/${String(count)} shown; full list in ${holder})`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
: `${listed}${sample.length === 0 ? "" : ", "}… (${String(sample.length)}/${String(count)} shown; full list in manifest Evidence)`;
|
||||
};
|
||||
|
||||
/** Captured argument names longer than this are advertised under a short alias; the manifest keeps the name. */
|
||||
@@ -332,8 +208,9 @@ export interface EnvironmentCatalogOptions {
|
||||
|
||||
/** Admit complete discovery before binding; six bytes per UTF-16 unit covers JSON escaping and UTF-8 expansion. */
|
||||
export const environmentToolDefinitions = (
|
||||
source: EnvironmentSource,
|
||||
name: string = source.manifest.name,
|
||||
manifest: EnvironmentManifest,
|
||||
evidenceId: string,
|
||||
name: string = manifest.name,
|
||||
{
|
||||
operation = ENVIRONMENT_SERVE_OPERATION,
|
||||
dataPlane = "none",
|
||||
@@ -377,22 +254,15 @@ export const environmentToolDefinitions = (
|
||||
? "answers from the served environment state where the state backs this tool, otherwise replays retained records"
|
||||
: "replays retained records";
|
||||
const definitions: EnvironmentToolDefinition[] = [];
|
||||
for (const tool of source.manifest.tools) {
|
||||
const annotations = environmentToolAnnotations(tool);
|
||||
// Only MCP discovery carries the hints; charging them per tool over-charges the card harmlessly.
|
||||
if (
|
||||
!reserve(EMPTY_TOOL_BYTES + JSON.stringify({ annotations }).length) ||
|
||||
!text(tool.name)
|
||||
)
|
||||
return refused();
|
||||
for (const tool of manifest.tools) {
|
||||
if (!reserve(EMPTY_TOOL_BYTES) || !text(tool.name)) return refused();
|
||||
const count =
|
||||
tool.params.length +
|
||||
tool.query_parameter_names.length +
|
||||
(WRITE_METHODS.has(tool.method) ? 1 : 0);
|
||||
// A lower bound rejects enormous argument lists before constructing their alias map.
|
||||
if (!reserve(count * EMPTY_PROPERTY_BYTES)) return refused();
|
||||
// The response summary is bounded by its samples and charged with the whole description.
|
||||
const description = `${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}`;
|
||||
const description = `${REPLAY_CATALOG_INSTRUCTIONS} ${describeText(tool.method, "method")} ${describeText(tool.path_template, "path")}; ${answers} [${describeList(tool.record_ordinals, tool.record_ordinals.length)}]; evidence ${evidenceId}`;
|
||||
if (!text(description)) return refused();
|
||||
const args = environmentToolArguments(tool);
|
||||
const properties = new Map<string, EnvironmentToolProperty>();
|
||||
@@ -419,7 +289,6 @@ export const environmentToolDefinitions = (
|
||||
required,
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations,
|
||||
tool,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ const parameters = (
|
||||
target_id: input.target_id,
|
||||
observation_ms: input.observation_ms,
|
||||
include_script_sources: input.include_script_sources,
|
||||
...(input.command_timeout_ms === undefined
|
||||
? {}
|
||||
: { command_timeout_ms: input.command_timeout_ms }),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
@@ -188,6 +188,7 @@ const artifactFailureToOperationError = (
|
||||
...(cause.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: cause.partialObservation }),
|
||||
...(cause.admission === undefined ? {} : { admission: cause.admission }),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ export const translateArtifactFailure = (
|
||||
...(cause.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: cause.partialObservation }),
|
||||
...(cause.admission === undefined
|
||||
? {}
|
||||
: { admission: cause.admission }),
|
||||
},
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ArtifactAdmissionLimit } from "../domain/artifactOperationError.js";
|
||||
import type {
|
||||
ArtifactIntegrityClaim,
|
||||
ArtifactIntegrityDetails,
|
||||
@@ -28,6 +29,8 @@ export interface ArtifactReaderFailureOptions extends ErrorOptions {
|
||||
readonly partialObservation?: AnalysisPartialObservation | undefined;
|
||||
/** The constrained resource bound for a limit failure. */
|
||||
readonly boundary?: ArtifactReaderBoundary;
|
||||
/** Named admission whose recovery must not be inferred from other limit text. */
|
||||
readonly admission?: ArtifactAdmissionLimit;
|
||||
}
|
||||
|
||||
/** Archive-neutral entry metadata. Reader adapters never choose output paths. */
|
||||
@@ -192,6 +195,8 @@ export class ArtifactReaderFailure extends Error {
|
||||
readonly partialObservation: AnalysisPartialObservation | undefined;
|
||||
/** The constrained bound for a `limit` reason, when the reader knows it. */
|
||||
readonly boundary: ArtifactReaderBoundary | undefined;
|
||||
/** Named admission for a limit whose public recovery is owned by the producer. */
|
||||
readonly admission: ArtifactAdmissionLimit | undefined;
|
||||
|
||||
constructor(
|
||||
readonly reason:
|
||||
@@ -217,6 +222,7 @@ export class ArtifactReaderFailure extends Error {
|
||||
options?.cleanupLeases ?? (this.cleanup === undefined ? [] : undefined);
|
||||
this.partialObservation = options?.partialObservation;
|
||||
this.boundary = options?.boundary;
|
||||
this.admission = options?.admission;
|
||||
}
|
||||
|
||||
/** Preserve the primary failure and observation. Supplied leases must cover all merged cleanup. */
|
||||
@@ -261,6 +267,9 @@ export class ArtifactReaderFailure extends Error {
|
||||
...(primary.boundary === undefined
|
||||
? {}
|
||||
: { boundary: primary.boundary }),
|
||||
...(primary.admission === undefined
|
||||
? {}
|
||||
: { admission: primary.admission }),
|
||||
...(mergedObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: mergedObservation }),
|
||||
|
||||
@@ -5,9 +5,37 @@ import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** Shared resource budget for the eagerly decoded ASAR header, not member bytes. */
|
||||
/**
|
||||
* Fixed header/index admission for this release. The header is decoded before
|
||||
* any member path can be selected. There is no caller or configuration override.
|
||||
*/
|
||||
export const MAX_ASAR_HEADER_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
const MEBIBYTE = 1024 * 1024;
|
||||
|
||||
/** User-visible recovery for the fixed ASAR header/index admission budget. */
|
||||
export const asarHeaderAdmissionRecovery = (): string => {
|
||||
const bytes = MAX_ASAR_HEADER_BYTES;
|
||||
const mebibytes = bytes / MEBIBYTE;
|
||||
const allowance = Number.isInteger(mebibytes)
|
||||
? `${String(mebibytes)} MiB / ${String(bytes)} bytes`
|
||||
: `${String(bytes)} bytes`;
|
||||
return [
|
||||
`ASAR header/index admission is limited to ${allowance}.`,
|
||||
"This release has no runtime override.",
|
||||
"Selecting fewer sources or narrowing an internal path does not bypass header admission, because the archive index is read before any member can be selected.",
|
||||
"Raising max_heap_mb or available host memory does not raise it.",
|
||||
"Analyze an extracted application directory, which does not admit an ASAR header, or supply an archive whose header fits this allowance.",
|
||||
"Repeating the unchanged request fails the same way.",
|
||||
].join(" ");
|
||||
};
|
||||
|
||||
/** Refuse an ASAR whose header/index exceeds the fixed admission budget. */
|
||||
export const asarHeaderAdmissionFailure = (): ArtifactReaderFailure =>
|
||||
new ArtifactReaderFailure("limit", asarHeaderAdmissionRecovery(), {
|
||||
admission: { kind: "asar-header", maximumBytes: MAX_ASAR_HEADER_BYTES },
|
||||
});
|
||||
|
||||
/** Absolute archive prefix required to inspect an admitted header. */
|
||||
export const MAX_ASAR_HEADER_PROBE_BYTES = MAX_ASAR_HEADER_BYTES + 8;
|
||||
|
||||
@@ -345,10 +373,7 @@ export const admitAsarHeader = async (
|
||||
const parsed = parseAsarHeaderExtents(prefix, observed.size);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
if (parsed.value.headerBytes > MAX_ASAR_HEADER_BYTES)
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
`ASAR header exceeds the ${String(MAX_ASAR_HEADER_BYTES)} byte decode budget`,
|
||||
);
|
||||
throw asarHeaderAdmissionFailure();
|
||||
return {
|
||||
archiveBytes: observed.size,
|
||||
...parsed.value,
|
||||
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
Uint8ArrayWriter,
|
||||
ZipWriter,
|
||||
} from "@zip.js/zip.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
|
||||
import { ZipArtifactReader } from "./ZipArtifactReader.js";
|
||||
import { openRegularFile } from "../filesystem/RegularFile.js";
|
||||
import { MINIMUM_ARTIFACT_EXPANDED_BYTES } from "../domain/artifactExpansionBudget.js";
|
||||
|
||||
/** Small real ZIP carriers with forged sizes; metadata-only controls, never decompressed. */
|
||||
@@ -53,6 +54,165 @@ const enumerate = async (reader: ZipArtifactReader): Promise<number> => {
|
||||
return files;
|
||||
};
|
||||
|
||||
const admittedZip = async () => {
|
||||
const directory = await createTestTempDirectory("rea-zip-short-read-");
|
||||
const path = join(directory, "valid.zip");
|
||||
const content = "Complete ZIP member bytes, including café.";
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("member.txt", new TextReader(content));
|
||||
await writeFile(path, await writer.close());
|
||||
const handle = await openRegularFile(path, { symlinks: "reject" });
|
||||
// Keep the actual descriptor, but select the positional overload used by
|
||||
// the ZIP adapter so the spy does not infer FileHandle.read's options form.
|
||||
const positional: {
|
||||
read(
|
||||
buffer: Buffer,
|
||||
offset: number,
|
||||
length: number,
|
||||
position: number,
|
||||
): Promise<{ bytesRead: number; buffer: Buffer }>;
|
||||
} = handle;
|
||||
return { path, content, handle, positional, initial: await handle.stat() };
|
||||
};
|
||||
|
||||
describe("ZipArtifactReader positional reads", () => {
|
||||
it("enumerates and extracts a real ZIP when each descriptor read returns a short prefix", async () => {
|
||||
const fixture = await admittedZip();
|
||||
const nativeRead = fixture.positional.read.bind(fixture.handle);
|
||||
const reads = vi
|
||||
.spyOn(fixture.positional, "read")
|
||||
.mockImplementation(async (...args) => {
|
||||
const [buffer, offset, length, position] = args;
|
||||
return nativeRead(buffer, offset, Math.min(length, 3), position);
|
||||
});
|
||||
const reader = new ZipArtifactReader(
|
||||
fixture.path,
|
||||
"zip",
|
||||
undefined,
|
||||
fixture,
|
||||
);
|
||||
try {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
expect(entries.map(({ path }) => path)).toEqual(["member.txt"]);
|
||||
const entry = entries[0];
|
||||
if (entry === undefined) throw new Error("Expected a ZIP member");
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of await reader.open(entry))
|
||||
chunks.push(Buffer.from(chunk));
|
||||
expect(Buffer.concat(chunks)).toEqual(Buffer.from(fixture.content));
|
||||
expect(reads.mock.calls.length).toBeGreaterThan(3);
|
||||
} finally {
|
||||
reads.mockRestore();
|
||||
await reader.close();
|
||||
await fixture.handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports premature EOF inside the admitted extent as an integrity failure", async () => {
|
||||
const fixture = await admittedZip();
|
||||
const nativeRead = fixture.positional.read.bind(fixture.handle);
|
||||
let calls = 0;
|
||||
const reads = vi
|
||||
.spyOn(fixture.positional, "read")
|
||||
.mockImplementation(async (...args) => {
|
||||
const [buffer, offset, length, position] = args;
|
||||
if (calls++ > 0) return { buffer, bytesRead: 0 };
|
||||
return nativeRead(buffer, offset, Math.min(length, 3), position);
|
||||
});
|
||||
const reader = new ZipArtifactReader(
|
||||
fixture.path,
|
||||
"zip",
|
||||
undefined,
|
||||
fixture,
|
||||
);
|
||||
try {
|
||||
await expect(enumerate(reader)).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
});
|
||||
expect(calls).toBe(2);
|
||||
} finally {
|
||||
reads.mockRestore();
|
||||
await reader.close();
|
||||
await fixture.handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps metadata allocation admission ahead of short reads", async () => {
|
||||
const fixture = await admittedZip();
|
||||
const reads = vi.spyOn(fixture.positional, "read");
|
||||
const reader = new ZipArtifactReader(fixture.path, "zip", 2, fixture);
|
||||
try {
|
||||
await expect(enumerate(reader)).rejects.toMatchObject({
|
||||
reason: "limit",
|
||||
});
|
||||
expect(reads).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
reads.mockRestore();
|
||||
await reader.close();
|
||||
await fixture.handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not publish an entry cancelled during short metadata reads", async () => {
|
||||
const fixture = await admittedZip();
|
||||
const controller = new AbortController();
|
||||
const nativeRead = fixture.positional.read.bind(fixture.handle);
|
||||
const reads = vi
|
||||
.spyOn(fixture.positional, "read")
|
||||
.mockImplementation(async (...args) => {
|
||||
const [buffer, offset, length, position] = args;
|
||||
const result = await nativeRead(
|
||||
buffer,
|
||||
offset,
|
||||
Math.min(length, 3),
|
||||
position,
|
||||
);
|
||||
controller.abort();
|
||||
return result;
|
||||
});
|
||||
const reader = new ZipArtifactReader(
|
||||
fixture.path,
|
||||
"zip",
|
||||
undefined,
|
||||
fixture,
|
||||
);
|
||||
const iterator = reader.entries(controller.signal)[Symbol.asyncIterator]();
|
||||
try {
|
||||
await expect(iterator.next()).rejects.toMatchObject({
|
||||
reason: "cancelled",
|
||||
});
|
||||
} finally {
|
||||
await iterator.return?.();
|
||||
reads.mockRestore();
|
||||
await reader.close();
|
||||
await fixture.handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a changed admitted descriptor before reading ZIP bytes", async () => {
|
||||
const fixture = await admittedZip();
|
||||
await writeFile(fixture.path, Buffer.alloc(0));
|
||||
const reads = vi.spyOn(fixture.positional, "read");
|
||||
const reader = new ZipArtifactReader(
|
||||
fixture.path,
|
||||
"zip",
|
||||
undefined,
|
||||
fixture,
|
||||
);
|
||||
try {
|
||||
await expect(enumerate(reader)).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
});
|
||||
expect(reads).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
reads.mockRestore();
|
||||
await reader.close();
|
||||
await fixture.handle.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZipArtifactReader decompression budget", () => {
|
||||
it("refuses an archive whose entries declare more than its size can justify", async () => {
|
||||
const directory = await createTestTempDirectory("rea-zip-budget-bomb-");
|
||||
|
||||
@@ -144,8 +144,22 @@ class NodeFileReader extends Reader<string> {
|
||||
`ZIP metadata read requires ${readBytes} bytes, exceeding the ${this.maximumReadBytes}-byte read budget`,
|
||||
);
|
||||
const bytes = Buffer.alloc(readBytes);
|
||||
const read = await handle.read(bytes, 0, bytes.length, index);
|
||||
return bytes.subarray(0, read.bytesRead);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const { bytesRead } = await handle.read(
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
index + offset,
|
||||
);
|
||||
if (bytesRead === 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`ZIP source ended before its admitted extent: ${this.path}`,
|
||||
);
|
||||
offset += bytesRead;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async closeHandle(): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { hasGodotPckHeader } from "../../domain/godotPckFormat.js";
|
||||
import type { ArtifactClassificationEvidence } from "../ArtifactClassificationProbe.js";
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import { asarHeaderAdmissionFailure } from "../AsarHeader.js";
|
||||
import { hasDestinationPath } from "../ArtifactPaths.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
import { isJavaScriptSourcePath } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
@@ -529,11 +529,7 @@ export const classifyArtifactContent = (
|
||||
path.toLowerCase().endsWith(".asar") &&
|
||||
asarHeader.state !== "rejected"
|
||||
) {
|
||||
if (asarHeader.state === "limit")
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
"ASAR header exceeds the bounded classification or decode budget",
|
||||
);
|
||||
if (asarHeader.state === "limit") throw asarHeaderAdmissionFailure();
|
||||
if (asarHeader.state === "matched")
|
||||
return { kind: "container", format: "asar" };
|
||||
}
|
||||
|
||||
@@ -178,4 +178,4 @@ const browserStartupMessage = (
|
||||
readonly stderr: string;
|
||||
},
|
||||
): string =>
|
||||
`Chrome CDP startup ${failure}; executable=${diagnostics.executable}; elapsed_ms=${String(diagnostics.elapsedMs)}; startup_timeout_ms=${String(diagnostics.startupTimeoutMs)}; exit_code=${diagnostics.exitCode === null ? "null" : String(diagnostics.exitCode)}; signal=${diagnostics.signalCode ?? "null"}; stderr=${diagnostics.stderr.length === 0 ? "<empty>" : diagnostics.stderr}`;
|
||||
`Chrome CDP startup ${failure}; executable=${diagnostics.executable}; elapsed_ms=${String(diagnostics.elapsedMs)}; timeoutMs=${String(diagnostics.startupTimeoutMs)} (DevToolsActivePort wait; omitted means ${String(BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS)} ms); exit_code=${diagnostics.exitCode === null ? "null" : String(diagnostics.exitCode)}; signal=${diagnostics.signalCode ?? "null"}; stderr=${diagnostics.stderr.length === 0 ? "<empty>" : diagnostics.stderr}`;
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
type CdpTargetSession,
|
||||
} from "./CdpTargetSession.js";
|
||||
import { capturePage, type CapturedPage } from "./CdpPageCapture.js";
|
||||
import { selectedCdpCommandTimeoutMs } from "../domain/browserDeadline.js";
|
||||
import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js";
|
||||
import { authorizeCdpTarget } from "./CdpAuthorizedTarget.js";
|
||||
import { observeCdpSession } from "./CdpObservationSession.js";
|
||||
@@ -359,7 +360,7 @@ interface CdpTargetOperationSession<Input> {
|
||||
const withTargetSession = async <
|
||||
Input extends Pick<
|
||||
InspectWebPageInput,
|
||||
"cdp_endpoint" | "target_id" | "allowed_origins"
|
||||
"cdp_endpoint" | "target_id" | "allowed_origins" | "command_timeout_ms"
|
||||
>,
|
||||
Output,
|
||||
>(
|
||||
@@ -370,12 +371,16 @@ const withTargetSession = async <
|
||||
owner: CdpTargetSessionOwner,
|
||||
partialObservation?: (output: Output) => AnalysisPartialObservation,
|
||||
): Promise<Output> => {
|
||||
const commandTimeoutMs = selectedCdpCommandTimeoutMs(
|
||||
input.command_timeout_ms,
|
||||
);
|
||||
const endpoint = await openCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
operation,
|
||||
options.signal,
|
||||
undefined,
|
||||
owner,
|
||||
commandTimeoutMs,
|
||||
);
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
let outcome: Result<Output, unknown>;
|
||||
@@ -390,7 +395,7 @@ const withTargetSession = async <
|
||||
target,
|
||||
operation,
|
||||
options.signal,
|
||||
undefined,
|
||||
{ commandTimeoutMs },
|
||||
owner,
|
||||
)
|
||||
: await attachCdpTargetSession(
|
||||
|
||||
@@ -5,6 +5,14 @@ import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { CdpCommandRejection } from "./CdpCommandRejection.js";
|
||||
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
|
||||
import {
|
||||
cdpCleanupGraceExpired,
|
||||
cdpCommandDeadlineRecovery,
|
||||
cdpCommandTimeoutIsSelectable,
|
||||
cdpCommandTimeoutUnrelatedControl,
|
||||
DEFAULT_CDP_COMMAND_TIMEOUT_MS,
|
||||
noRuntimeOverrideSentence,
|
||||
} from "../domain/browserDeadline.js";
|
||||
import { safeParseJson } from "../domain/safeJson.js";
|
||||
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
|
||||
|
||||
@@ -29,6 +37,7 @@ interface PendingCommand {
|
||||
readonly resolve: (value: unknown) => void;
|
||||
readonly reject: (error: AnalysisError) => void;
|
||||
readonly dispose: () => void;
|
||||
readonly reschedule: (timeoutMs: number) => void;
|
||||
}
|
||||
|
||||
/** Correlated JSON-RPC transport for one CDP target operation. */
|
||||
@@ -46,12 +55,16 @@ export class CdpConnection {
|
||||
#closePromise: Promise<void> | undefined;
|
||||
#protocolFailed = false;
|
||||
#transportFailure: "disconnected" | "payload_limit" = "disconnected";
|
||||
#commandTimeoutMs: number;
|
||||
#deadlineKind: "command" | "cleanup" = "command";
|
||||
|
||||
private constructor(
|
||||
private readonly socket: WebSocket,
|
||||
private readonly operation: BrowserObservationOperation,
|
||||
private readonly maxPayloadBytes: number,
|
||||
commandTimeoutMs: number,
|
||||
) {
|
||||
this.#commandTimeoutMs = commandTimeoutMs;
|
||||
socket.on("message", (data) => this.#receive(data));
|
||||
socket.on("close", () => this.#disconnect());
|
||||
socket.on("error", (cause) =>
|
||||
@@ -68,15 +81,21 @@ export class CdpConnection {
|
||||
url: string,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
limits?: { readonly maxPayloadBytes: number },
|
||||
limits?: {
|
||||
readonly maxPayloadBytes?: number;
|
||||
readonly commandTimeoutMs?: number;
|
||||
/** Handshake budget. Discovery passes its own deadline so a short command budget cannot start first. */
|
||||
readonly handshakeTimeoutMs?: number;
|
||||
},
|
||||
onConnected?: (connection: CdpConnection) => void,
|
||||
): Promise<CdpConnection> {
|
||||
if (signal?.aborted === true)
|
||||
throw new AnalysisCancelledError(operation, { cause: signal.reason });
|
||||
const maxPayloadBytes =
|
||||
limits === undefined
|
||||
? WEB_RUNTIME_LIMITS.protocolBytes
|
||||
: limits.maxPayloadBytes;
|
||||
limits?.maxPayloadBytes ?? WEB_RUNTIME_LIMITS.protocolBytes;
|
||||
const commandTimeoutMs =
|
||||
limits?.commandTimeoutMs ?? DEFAULT_CDP_COMMAND_TIMEOUT_MS;
|
||||
const handshakeTimeoutMs = limits?.handshakeTimeoutMs ?? commandTimeoutMs;
|
||||
if (
|
||||
!Number.isSafeInteger(maxPayloadBytes) ||
|
||||
maxPayloadBytes <= 0 ||
|
||||
@@ -92,7 +111,12 @@ export class CdpConnection {
|
||||
maxPayload: maxPayloadBytes,
|
||||
perMessageDeflate: false,
|
||||
});
|
||||
const connection = new CdpConnection(socket, operation, maxPayloadBytes);
|
||||
const connection = new CdpConnection(
|
||||
socket,
|
||||
operation,
|
||||
maxPayloadBytes,
|
||||
commandTimeoutMs,
|
||||
);
|
||||
try {
|
||||
onConnected?.(connection);
|
||||
} catch (cause: unknown) {
|
||||
@@ -100,7 +124,7 @@ export class CdpConnection {
|
||||
throw cause;
|
||||
}
|
||||
try {
|
||||
await waitForOpen(socket, operation, signal);
|
||||
await waitForOpen(socket, operation, signal, handshakeTimeoutMs);
|
||||
} catch (cause: unknown) {
|
||||
if (onConnected === undefined) {
|
||||
try {
|
||||
@@ -146,6 +170,22 @@ export class CdpConnection {
|
||||
return () => this.#protocolFailureListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Apply the post-discovery command deadline to commands that have not started. */
|
||||
selectCommandTimeout(timeoutMs: number): void {
|
||||
if (this.#deadlineKind === "cleanup") return;
|
||||
this.#commandTimeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restoration uses the fixed cleanup grace. Commands already waiting are
|
||||
* rescheduled from now so a raised command deadline cannot extend them.
|
||||
*/
|
||||
beginCleanupGrace(timeoutMs: number): void {
|
||||
this.#deadlineKind = "cleanup";
|
||||
this.#commandTimeoutMs = timeoutMs;
|
||||
for (const pending of this.#pending.values()) pending.reschedule(timeoutMs);
|
||||
}
|
||||
|
||||
/** Bound each response separately from the caller-selected observation window. */
|
||||
async send(
|
||||
method: string,
|
||||
@@ -180,22 +220,44 @@ export class CdpConnection {
|
||||
}),
|
||||
);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
const failTimeout = (): void => {
|
||||
const pending = this.#pending.get(id);
|
||||
if (pending === undefined) return;
|
||||
this.#complete(id, pending);
|
||||
if (this.#deadlineKind === "cleanup") {
|
||||
const detail = cdpCleanupGraceExpired();
|
||||
reject(
|
||||
new BrowserObservationError(this.operation, "timeout", {
|
||||
detail,
|
||||
remediationAction: detail,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const recovery = cdpCommandDeadlineRecovery(
|
||||
this.#commandTimeoutMs,
|
||||
"command",
|
||||
method,
|
||||
cdpCommandTimeoutIsSelectable(this.operation),
|
||||
);
|
||||
reject(
|
||||
new BrowserObservationError(this.operation, "timeout", {
|
||||
detail: `CDP command ${method} exceeded its ${String(WEB_RUNTIME_LIMITS.commandTimeoutMs)} ms response deadline.`,
|
||||
detail: recovery.userMessage,
|
||||
remediationAction: recovery.remediationAction,
|
||||
}),
|
||||
);
|
||||
}, WEB_RUNTIME_LIMITS.commandTimeoutMs);
|
||||
};
|
||||
let timer = setTimeout(failTimeout, this.#commandTimeoutMs);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
this.#pending.set(id, {
|
||||
method,
|
||||
...(sessionId === undefined ? {} : { sessionId }),
|
||||
resolve,
|
||||
reject,
|
||||
reschedule: (timeoutMs: number): void => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(failTimeout, timeoutMs);
|
||||
},
|
||||
dispose: () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
@@ -427,7 +489,10 @@ export class CdpConnection {
|
||||
reason,
|
||||
reason === "payload_limit"
|
||||
? {
|
||||
detail: `CDP message exceeded the selected ${String(this.maxPayloadBytes)} byte protocol budget.`,
|
||||
detail: noRuntimeOverrideSentence(
|
||||
`CDP message exceeded the ${String(this.maxPayloadBytes)} byte protocol budget.`,
|
||||
cdpCommandTimeoutUnrelatedControl(this.operation),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
@@ -456,6 +521,7 @@ const waitForOpen = async (
|
||||
socket: WebSocket,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
commandTimeoutMs: number = DEFAULT_CDP_COMMAND_TIMEOUT_MS,
|
||||
): Promise<void> =>
|
||||
await new Promise((resolve, reject) => {
|
||||
const onOpen = (): void => {
|
||||
@@ -492,12 +558,19 @@ const waitForOpen = async (
|
||||
const timer = setTimeout(() => {
|
||||
terminateSocket(socket);
|
||||
cleanup();
|
||||
const recovery = cdpCommandDeadlineRecovery(
|
||||
commandTimeoutMs,
|
||||
"handshake",
|
||||
undefined,
|
||||
cdpCommandTimeoutIsSelectable(operation),
|
||||
);
|
||||
reject(
|
||||
new BrowserObservationError(operation, "timeout", {
|
||||
detail: `CDP WebSocket handshake exceeded its ${String(WEB_RUNTIME_LIMITS.commandTimeoutMs)} ms deadline.`,
|
||||
detail: recovery.userMessage,
|
||||
remediationAction: recovery.remediationAction,
|
||||
}),
|
||||
);
|
||||
}, WEB_RUNTIME_LIMITS.commandTimeoutMs);
|
||||
}, commandTimeoutMs);
|
||||
socket.once("open", onOpen);
|
||||
socket.once("error", onFailure);
|
||||
socket.once("close", onClose);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ingestElectronScriptEvent,
|
||||
type ElectronScriptDraft,
|
||||
} from "./CdpElectronScriptEvents.js";
|
||||
import { CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON } from "./CdpScriptSourceBudget.js";
|
||||
import { captureElectronScripts } from "./CdpElectronScripts.js";
|
||||
import { inspectElectronPageInputSchema } from "../domain/javascript/electronObservation.js";
|
||||
|
||||
@@ -221,8 +222,7 @@ it("admits a prefix and fitting suffix around an oversized Electron source", asy
|
||||
expect(byHash.get("prefix")?.included).toBe(true);
|
||||
expect(byHash.get("oversized")).toEqual({
|
||||
included: false,
|
||||
reason:
|
||||
"script source was omitted because the aggregate 32 MiB source budget was exhausted",
|
||||
reason: CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
|
||||
});
|
||||
expect(byHash.get("suffix")?.included).toBe(true);
|
||||
expect(completeness.snapshot().excluded).toContainEqual({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type InspectElectronPageInput,
|
||||
type ListElectronTargetsInput,
|
||||
} from "../domain/javascript/electronObservation.js";
|
||||
import { selectedCdpCommandTimeoutMs } from "../domain/browserDeadline.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
@@ -277,7 +278,11 @@ export class CdpElectronProvider implements ElectronObservationPort {
|
||||
target,
|
||||
"inspect_electron_page",
|
||||
options.signal,
|
||||
undefined,
|
||||
{
|
||||
commandTimeoutMs: selectedCdpCommandTimeoutMs(
|
||||
input.command_timeout_ms,
|
||||
),
|
||||
},
|
||||
this.#owner,
|
||||
);
|
||||
result = ok(
|
||||
|
||||
@@ -11,9 +11,10 @@ import type { ElectronScriptDraft } from "./CdpElectronScriptEvents.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { selectedCdpCommandTimeoutMs } from "../domain/browserDeadline.js";
|
||||
import {
|
||||
CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
|
||||
CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
|
||||
cdpScriptSourceDeadlineOmissionReason,
|
||||
CdpScriptSourceBudget,
|
||||
} from "./CdpScriptSourceBudget.js";
|
||||
|
||||
@@ -34,9 +35,14 @@ export const captureElectronScripts = async (
|
||||
let total = 0;
|
||||
const items: ElectronPageInspection["scripts"]["items"] = [];
|
||||
const seen = new Set<string>();
|
||||
const commandTimeoutMs = selectedCdpCommandTimeoutMs(
|
||||
input.request.command_timeout_ms,
|
||||
);
|
||||
const deadlineOmission =
|
||||
cdpScriptSourceDeadlineOmissionReason(commandTimeoutMs);
|
||||
const sourceBudget =
|
||||
input.request.include_script_sources && input.scripts.length > 0
|
||||
? new CdpScriptSourceBudget(input.signal)
|
||||
? new CdpScriptSourceBudget(input.signal, commandTimeoutMs)
|
||||
: undefined;
|
||||
try {
|
||||
for (const script of input.scripts) {
|
||||
@@ -68,7 +74,7 @@ export const captureElectronScripts = async (
|
||||
let source: ElectronScriptSource = input.request.include_script_sources
|
||||
? {
|
||||
included: false,
|
||||
reason: CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
|
||||
reason: deadlineOmission,
|
||||
}
|
||||
: {
|
||||
included: false,
|
||||
@@ -129,7 +135,9 @@ const captureScriptSource = async (
|
||||
excludeScriptSource(input.completeness);
|
||||
return {
|
||||
included: false,
|
||||
reason: CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
|
||||
reason: cdpScriptSourceDeadlineOmissionReason(
|
||||
sourceBudget.commandTimeoutMs,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (!sourceBudget.admit(text)) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { once } from "node:events";
|
||||
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
@@ -206,6 +207,49 @@ describe("CDP discovery cancellation ownership", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("names the browser-socket message budget when that discovery deadline expires", async () => {
|
||||
const failure = await withCdpDiscoveryDeadline(
|
||||
"list_browser_targets",
|
||||
undefined,
|
||||
(signal) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
1,
|
||||
).catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(BrowserObservationError);
|
||||
if (!(failure instanceof BrowserObservationError)) return;
|
||||
expect(failure.userMessage).toContain(
|
||||
`${String(WEB_RUNTIME_LIMITS.protocolBytes)}-byte browser-socket message budget`,
|
||||
);
|
||||
expect(failure.userMessage).not.toContain(
|
||||
"8388608-byte discovery document budget",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the HTTP discovery document budget when that response deadline expires", async () => {
|
||||
const { endpoint } = await listen(() => undefined);
|
||||
const failure = await readCdpJson(
|
||||
new URL(endpoint),
|
||||
"list_browser_targets",
|
||||
undefined,
|
||||
{ bytes: 8 * 1024 * 1024, timeoutMs: 20 },
|
||||
).catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(BrowserObservationError);
|
||||
if (!(failure instanceof BrowserObservationError)) return;
|
||||
expect(failure.userMessage).toContain(
|
||||
"8388608-byte discovery document budget",
|
||||
);
|
||||
expect(failure.userMessage).toContain("20 ms");
|
||||
expect(failure.userMessage).not.toContain(
|
||||
`${String(WEB_RUNTIME_LIMITS.protocolBytes)}-byte browser-socket message budget`,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves an independent typed cancellation when the discovery deadline also expires", async () => {
|
||||
const primary = new AnalysisCancelledError("inspect_web_page", {
|
||||
cause: "independent cancellation",
|
||||
|
||||
+39
-15
@@ -2,6 +2,11 @@ import { request, type ClientRequest, type IncomingMessage } from "node:http";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
|
||||
cdpCommandTimeoutUnrelatedControl,
|
||||
noRuntimeOverrideSentence,
|
||||
} from "../domain/browserDeadline.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
@@ -9,6 +14,7 @@ import type { BrowserObservationOperation } from "../domain/browserObservationEr
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js";
|
||||
import { safeParseJson } from "../domain/safeJson.js";
|
||||
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
|
||||
import type { CdpConnection } from "./CdpConnection.js";
|
||||
|
||||
const endpointVersionSchema = z.object({
|
||||
@@ -30,12 +36,36 @@ const endpointTargetSchema = z.object({
|
||||
});
|
||||
const endpointTargetsSchema = z.array(endpointTargetSchema);
|
||||
|
||||
/** Per-response bounds for loopback CDP discovery documents. */
|
||||
/** Per-response bounds for loopback CDP discovery documents. There is no runtime override. */
|
||||
const CDP_DISCOVERY_RESPONSE_LIMITS = {
|
||||
bytes: 8 * 1024 * 1024,
|
||||
timeoutMs: 10_000,
|
||||
timeoutMs: CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
|
||||
} as const;
|
||||
|
||||
const discoveryBudget = (phase: "response" | "browser-socket"): string =>
|
||||
phase === "response"
|
||||
? `The ${String(CDP_DISCOVERY_RESPONSE_LIMITS.bytes)}-byte discovery document budget`
|
||||
: `The ${String(WEB_RUNTIME_LIMITS.protocolBytes)}-byte browser-socket message budget`;
|
||||
|
||||
const discoveryTimeout = (
|
||||
operation: BrowserObservationOperation,
|
||||
timeoutMs: number,
|
||||
phase: "response" | "browser-socket",
|
||||
): BrowserObservationError => {
|
||||
const subject =
|
||||
phase === "response"
|
||||
? `CDP discovery response exceeded its ${String(timeoutMs)} ms deadline.`
|
||||
: `CDP browser-socket discovery exceeded its ${String(timeoutMs)} ms deadline.`;
|
||||
const fixed = noRuntimeOverrideSentence(
|
||||
`${discoveryBudget(phase)} and the ${String(timeoutMs)} ms discovery deadline stay fixed.`,
|
||||
cdpCommandTimeoutUnrelatedControl(operation),
|
||||
);
|
||||
return new BrowserObservationError(operation, "timeout", {
|
||||
detail: `${subject} ${fixed}`,
|
||||
remediationAction: fixed,
|
||||
});
|
||||
};
|
||||
|
||||
interface CdpDiscoveryResponseLimits {
|
||||
readonly bytes: number;
|
||||
readonly timeoutMs: number;
|
||||
@@ -181,9 +211,7 @@ export const withCdpDiscoveryDeadline = async <T>(
|
||||
if (abortSource === "caller")
|
||||
throw new AnalysisCancelledError(operation, { cause: abortReason });
|
||||
if (abortSource === "deadline")
|
||||
throw new BrowserObservationError(operation, "timeout", {
|
||||
detail: `CDP browser-socket discovery exceeded its ${String(timeoutMs)} ms deadline.`,
|
||||
});
|
||||
throw discoveryTimeout(operation, timeoutMs, "browser-socket");
|
||||
}
|
||||
// A concrete failure produced before cancellation remains authoritative.
|
||||
if (cause instanceof AnalysisError) {
|
||||
@@ -192,9 +220,7 @@ export const withCdpDiscoveryDeadline = async <T>(
|
||||
cause instanceof AnalysisCancelledError &&
|
||||
cause.cause === abortReason
|
||||
)
|
||||
throw new BrowserObservationError(operation, "timeout", {
|
||||
detail: `CDP browser-socket discovery exceeded its ${String(timeoutMs)} ms deadline.`,
|
||||
});
|
||||
throw discoveryTimeout(operation, timeoutMs, "browser-socket");
|
||||
throw cause;
|
||||
}
|
||||
throw cause;
|
||||
@@ -485,7 +511,10 @@ export const readCdpJson = async (
|
||||
if (bytes > limits.bytes) {
|
||||
finish(
|
||||
new BrowserObservationError(operation, "payload_limit", {
|
||||
detail: `CDP discovery response exceeds its ${String(limits.bytes)} byte limit.`,
|
||||
detail: noRuntimeOverrideSentence(
|
||||
`CDP discovery response exceeds its ${String(limits.bytes)}-byte limit.`,
|
||||
cdpCommandTimeoutUnrelatedControl(operation),
|
||||
),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
@@ -535,12 +564,7 @@ export const readCdpJson = async (
|
||||
onAbort();
|
||||
if (settled) return;
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
finish(
|
||||
new BrowserObservationError(operation, "timeout", {
|
||||
detail: `CDP discovery response exceeded its ${String(limits.timeoutMs)} ms deadline.`,
|
||||
}),
|
||||
),
|
||||
() => finish(discoveryTimeout(operation, limits.timeoutMs, "response")),
|
||||
limits.timeoutMs,
|
||||
);
|
||||
request_.end();
|
||||
|
||||
@@ -4,6 +4,10 @@ import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
|
||||
import { CdpCaptureEvents } from "./CdpCaptureEvents.js";
|
||||
import type { CdpConnection } from "./CdpConnection.js";
|
||||
import {
|
||||
CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
|
||||
CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
|
||||
} from "./CdpScriptSourceBudget.js";
|
||||
import { captureScripts } from "./CdpPageCaptureScripts.js";
|
||||
import type { CaptureContext } from "./CdpPageCapture.js";
|
||||
import type { CapturedScript } from "./CdpCaptureEventTypes.js";
|
||||
@@ -74,8 +78,7 @@ it("admits a prefix and fitting suffix around an oversized script source", async
|
||||
expect(byId.get("prefix")).toMatchObject({ included: true });
|
||||
expect(byId.get("oversized")).toEqual({
|
||||
included: false,
|
||||
reason:
|
||||
"script source was omitted because the aggregate 32 MiB source budget was exhausted",
|
||||
reason: CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
|
||||
});
|
||||
expect(byId.get("suffix")).toMatchObject({ included: true });
|
||||
expect(events.completeness.snapshot().excluded).toContainEqual({
|
||||
@@ -174,13 +177,11 @@ it("stops source reads at the shared collection deadline and retains metadata",
|
||||
expect(result.inventory.items.map((item) => item.source)).toEqual([
|
||||
{
|
||||
included: false,
|
||||
reason:
|
||||
"script source was omitted because the 20-second source collection deadline elapsed",
|
||||
reason: CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
|
||||
},
|
||||
{
|
||||
included: false,
|
||||
reason:
|
||||
"script source was omitted because the 20-second source collection deadline elapsed",
|
||||
reason: CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
|
||||
},
|
||||
]);
|
||||
expect(events.completeness.snapshot().excluded).toContainEqual({
|
||||
|
||||
@@ -16,9 +16,10 @@ import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
|
||||
import { selectedCdpCommandTimeoutMs } from "../domain/browserDeadline.js";
|
||||
import {
|
||||
CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
|
||||
CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON,
|
||||
cdpScriptSourceDeadlineOmissionReason,
|
||||
CdpScriptSourceBudget,
|
||||
} from "./CdpScriptSourceBudget.js";
|
||||
|
||||
@@ -59,9 +60,14 @@ export const captureScripts = async (
|
||||
readonly sourceMapRawUrl: string | null;
|
||||
}[] = [];
|
||||
const scripts = [...events.scripts.values()];
|
||||
const commandTimeoutMs = selectedCdpCommandTimeoutMs(
|
||||
context.input.command_timeout_ms,
|
||||
);
|
||||
const deadlineOmission =
|
||||
cdpScriptSourceDeadlineOmissionReason(commandTimeoutMs);
|
||||
const sourceBudget =
|
||||
context.input.include_script_sources && scripts.length > 0
|
||||
? new CdpScriptSourceBudget(context.signal)
|
||||
? new CdpScriptSourceBudget(context.signal, commandTimeoutMs)
|
||||
: undefined;
|
||||
try {
|
||||
for (const script of scripts) {
|
||||
@@ -76,7 +82,7 @@ export const captureScripts = async (
|
||||
};
|
||||
let source: WebPageInspection["scripts"]["items"][number]["source"] =
|
||||
context.input.include_script_sources
|
||||
? sourceExcluded(CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON)
|
||||
? sourceExcluded(deadlineOmission)
|
||||
: sourceExcluded("source capture was not selected");
|
||||
if (
|
||||
context.input.include_script_sources &&
|
||||
@@ -166,7 +172,9 @@ const captureScriptSource = async (
|
||||
"script_sources",
|
||||
"resource_budget_exhausted",
|
||||
);
|
||||
return sourceExcluded(CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON);
|
||||
return sourceExcluded(
|
||||
cdpScriptSourceDeadlineOmissionReason(sourceBudget.commandTimeoutMs),
|
||||
);
|
||||
}
|
||||
if (!sourceBudget.admit(content)) {
|
||||
state.events.completeness.exclude(
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import {
|
||||
noRuntimeOverrideSentence,
|
||||
selectedCdpCommandTimeoutMs,
|
||||
} from "../domain/browserDeadline.js";
|
||||
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
|
||||
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
|
||||
export const CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON = `script source was omitted because the aggregate ${String(WEB_RUNTIME_LIMITS.sourceBytes / MIB)} MiB source budget was exhausted`;
|
||||
export const CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON = `script source was omitted because the ${String(WEB_RUNTIME_LIMITS.commandTimeoutMs / 1000)}-second source collection deadline elapsed`;
|
||||
export const CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON = `script source was omitted because the aggregate ${String(WEB_RUNTIME_LIMITS.sourceBytes)}-byte (${String(WEB_RUNTIME_LIMITS.sourceBytes / MIB)} MiB) source budget was exhausted. ${noRuntimeOverrideSentence("That source budget stays fixed.", "command_timeout_ms")}`;
|
||||
|
||||
/** Name command_timeout_ms on a passive script-source collection miss. */
|
||||
export const cdpScriptSourceDeadlineOmissionReason = (
|
||||
timeoutMs: number,
|
||||
): string =>
|
||||
`script source was omitted because the ${String(timeoutMs)} ms command_timeout_ms source collection deadline elapsed (deadlineExpired). This is not the caller observation window. ${noRuntimeOverrideSentence(`The ${String(WEB_RUNTIME_LIMITS.sourceBytes)}-byte source budget stays fixed.`, "command_timeout_ms")}`;
|
||||
|
||||
export const CDP_SCRIPT_SOURCE_DEADLINE_OMISSION_REASON =
|
||||
cdpScriptSourceDeadlineOmissionReason(selectedCdpCommandTimeoutMs(undefined));
|
||||
|
||||
/** Shared admission for source text retained from passive CDP script reads. */
|
||||
export class CdpScriptSourceBudget {
|
||||
readonly #deadline: ProviderStartupDeadline;
|
||||
readonly commandTimeoutMs: number;
|
||||
#retainedBytes = 0;
|
||||
|
||||
constructor(signal?: AbortSignal) {
|
||||
this.#deadline = new ProviderStartupDeadline(
|
||||
WEB_RUNTIME_LIMITS.commandTimeoutMs,
|
||||
signal,
|
||||
);
|
||||
constructor(
|
||||
signal?: AbortSignal,
|
||||
commandTimeoutMs: number = selectedCdpCommandTimeoutMs(undefined),
|
||||
) {
|
||||
this.commandTimeoutMs = commandTimeoutMs;
|
||||
this.#deadline = new ProviderStartupDeadline(commandTimeoutMs, signal);
|
||||
}
|
||||
|
||||
get signal(): AbortSignal {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
|
||||
DEFAULT_CDP_COMMAND_TIMEOUT_MS,
|
||||
} from "../domain/browserDeadline.js";
|
||||
import type { BrowserObservationEndpoint } from "../domain/browserObservation.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
@@ -43,33 +47,53 @@ export const openCdpEndpoint = async (
|
||||
signal?: AbortSignal,
|
||||
timeoutMs?: number,
|
||||
owner?: CdpTargetSessionOwner,
|
||||
commandTimeoutMs?: number,
|
||||
): Promise<CdpOpenedEndpoint> => {
|
||||
if (endpoint.transport === "http_discovery")
|
||||
return {
|
||||
discovery: await discoverCdpEndpoint(endpoint.url, operation, signal),
|
||||
connection: undefined,
|
||||
};
|
||||
const discoveryTimeoutMs = timeoutMs ?? CDP_DISCOVERY_RESPONSE_TIMEOUT_MS;
|
||||
// The browser-socket handshake and discovery commands stay on the discovery
|
||||
// deadline. The selected command deadline starts only after discovery returns.
|
||||
const discoveryLimits = {
|
||||
commandTimeoutMs: discoveryTimeoutMs,
|
||||
// Stay past the discovery abort so a stalled open is labeled as discovery,
|
||||
// not as a command_timeout_ms handshake.
|
||||
handshakeTimeoutMs: discoveryTimeoutMs + 1_000,
|
||||
};
|
||||
return await withCdpDiscoveryDeadline(
|
||||
operation,
|
||||
signal,
|
||||
async (deadlineSignal) => {
|
||||
const connection =
|
||||
owner === undefined
|
||||
? await CdpConnection.connect(endpoint.url, operation, deadlineSignal)
|
||||
? await CdpConnection.connect(
|
||||
endpoint.url,
|
||||
operation,
|
||||
deadlineSignal,
|
||||
discoveryLimits,
|
||||
)
|
||||
: await owner.connect(
|
||||
endpoint.url,
|
||||
operation,
|
||||
{ signal: deadlineSignal },
|
||||
"browser-endpoint",
|
||||
discoveryLimits,
|
||||
);
|
||||
try {
|
||||
const discovery = await discoverCdpBrowserSocket(
|
||||
connection,
|
||||
endpoint.url,
|
||||
operation,
|
||||
deadlineSignal,
|
||||
);
|
||||
connection.selectCommandTimeout(
|
||||
commandTimeoutMs ?? DEFAULT_CDP_COMMAND_TIMEOUT_MS,
|
||||
);
|
||||
return {
|
||||
discovery: await discoverCdpBrowserSocket(
|
||||
connection,
|
||||
endpoint.url,
|
||||
operation,
|
||||
deadlineSignal,
|
||||
),
|
||||
discovery,
|
||||
connection,
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
@@ -85,7 +109,7 @@ export const openCdpEndpoint = async (
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
timeoutMs,
|
||||
discoveryTimeoutMs,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -95,7 +119,10 @@ export const openCdpTargetSession = async (
|
||||
target: CdpEndpointTarget,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
limits?: { readonly maxPayloadBytes: number },
|
||||
limits?: {
|
||||
readonly maxPayloadBytes?: number;
|
||||
readonly commandTimeoutMs?: number;
|
||||
},
|
||||
owner?: CdpTargetSessionOwner,
|
||||
): Promise<CdpTargetSession> => {
|
||||
const webSocket = cdpTargetWebSocket(discovery, target, operation);
|
||||
|
||||
@@ -67,7 +67,11 @@ export class CdpTargetSessionOwner {
|
||||
operation: BrowserObservationOperation,
|
||||
options: ExecutionOptions,
|
||||
resource: string,
|
||||
limits?: { readonly maxPayloadBytes: number },
|
||||
limits?: {
|
||||
readonly maxPayloadBytes?: number;
|
||||
readonly commandTimeoutMs?: number;
|
||||
readonly handshakeTimeoutMs?: number;
|
||||
},
|
||||
): Promise<CdpConnection> {
|
||||
if (this.#closing)
|
||||
throw new ProviderCleanupError(
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
|
||||
import type {
|
||||
AnalyzeWebBundleInput,
|
||||
WebBundleAnalysis,
|
||||
import {
|
||||
DEFAULT_WEB_BUNDLE_HEAP_MB,
|
||||
DEFAULT_WEB_BUNDLE_SCRIPT_TIMEOUT_MS,
|
||||
DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS,
|
||||
webBundleTotalTimeoutRecovery,
|
||||
type AnalyzeWebBundleInput,
|
||||
type WebBundleAnalysis,
|
||||
} from "../domain/webBundleAnalysis.js";
|
||||
import { buildCapturedWebBundleAnalysis } from "../domain/webBundleAnalyzerResult.js";
|
||||
import { isIncludedScript } from "../domain/webBundleAnalyzerInspection.js";
|
||||
@@ -19,7 +23,6 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
|
||||
import { WebBundleAnalysisWorker } from "./WebBundleAnalysisWorker.js";
|
||||
import { WEB_BUNDLE_WORKER_LIMITS } from "./WebBundleAnalysisWorkerProtocol.js";
|
||||
|
||||
// A process-wide admission, rather than one permit per provider instance, bounds
|
||||
// simultaneous web analyzer children and their source/result transfer reservations.
|
||||
@@ -147,8 +150,7 @@ export class IsolatedWebBundleAnalyzer {
|
||||
];
|
||||
const caller = AbortSignal.any(signals);
|
||||
const deadline = new ProviderStartupDeadline(
|
||||
input.total_analysis_timeout_ms ??
|
||||
WEB_BUNDLE_WORKER_LIMITS.totalTimeoutMs,
|
||||
input.total_analysis_timeout_ms ?? DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS,
|
||||
caller,
|
||||
);
|
||||
let failure: AnalysisError | undefined;
|
||||
@@ -162,7 +164,7 @@ export class IsolatedWebBundleAnalyzer {
|
||||
detail: caller.aborted
|
||||
? "The operation was cancelled before this script was started."
|
||||
: deadline.interruption === "timeout"
|
||||
? `The total captured-script analysis deadline of ${String(input.total_analysis_timeout_ms ?? WEB_BUNDLE_WORKER_LIMITS.totalTimeoutMs)} ms expired before this script was started.`
|
||||
? `The total_analysis_timeout_ms deadline of ${String(input.total_analysis_timeout_ms ?? DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS)} ms expired before this script was started (deadlineExpired). This is not the per-script analysis_timeout_ms. A per-script timeout above the total is rejected.`
|
||||
: resultCapacityExhausted
|
||||
? "The host could not reserve more web-analysis result capacity; this script was not started."
|
||||
: "An earlier analyzer failure prevented this script from starting.",
|
||||
@@ -171,13 +173,13 @@ export class IsolatedWebBundleAnalyzer {
|
||||
try {
|
||||
await WebBundleAnalysisWorker.retryPending();
|
||||
this.#worker = this.createWorker(
|
||||
input.max_heap_mb ?? WEB_BUNDLE_WORKER_LIMITS.heapMb,
|
||||
input.max_heap_mb ?? DEFAULT_WEB_BUNDLE_HEAP_MB,
|
||||
);
|
||||
for (const script of scripts) {
|
||||
if (deadline.signal.aborted) break;
|
||||
const analyzed = await this.#worker.analyze(
|
||||
script,
|
||||
input.analysis_timeout_ms ?? WEB_BUNDLE_WORKER_LIMITS.scriptTimeoutMs,
|
||||
input.analysis_timeout_ms ?? DEFAULT_WEB_BUNDLE_SCRIPT_TIMEOUT_MS,
|
||||
deadline.signal,
|
||||
);
|
||||
if (analyzed.ok) results.push(analyzed.value);
|
||||
@@ -198,8 +200,8 @@ export class IsolatedWebBundleAnalyzer {
|
||||
detail:
|
||||
error instanceof AnalysisResourceConstraintError &&
|
||||
error.reportedLimits !== null
|
||||
? `${error.message} Reported limits: ${JSON.stringify(error.reportedLimits)}`
|
||||
: error.message,
|
||||
? `${error.userMessage ?? error.reason} Reported limits: ${JSON.stringify(error.reportedLimits)}`
|
||||
: (error.userMessage ?? error.message),
|
||||
},
|
||||
);
|
||||
if (error.cleanupIncomplete) {
|
||||
@@ -283,13 +285,15 @@ export class IsolatedWebBundleAnalyzer {
|
||||
).bindCleanupDependencies([cancelled, failure])
|
||||
: cancelled;
|
||||
} else if (!hasPrimaryFailure && deadline.interruption === "timeout") {
|
||||
const totalTimeoutMs =
|
||||
input.total_analysis_timeout_ms ?? DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS;
|
||||
const timeout = new AnalysisTimeoutError(
|
||||
"analyze_web_bundle",
|
||||
input.total_analysis_timeout_ms ??
|
||||
WEB_BUNDLE_WORKER_LIMITS.totalTimeoutMs,
|
||||
totalTimeoutMs,
|
||||
{
|
||||
cause: deadline.signal.reason,
|
||||
partialObservation: partial ?? inspection,
|
||||
...webBundleTotalTimeoutRecovery(totalTimeoutMs),
|
||||
},
|
||||
);
|
||||
failure =
|
||||
|
||||
@@ -205,7 +205,7 @@ export const projectElectronActiveCapture = (
|
||||
"Process metrics are an Electron API snapshot and do not prove hostile-local-user isolation.",
|
||||
...(hookSnapshot.dropped > 0
|
||||
? [
|
||||
`The active hook retained ${hookSnapshot.retained} of ${hookSnapshot.observed} observed events within a ${hookSnapshot.retention_budget_bytes}-byte retained-data budget; ${hookSnapshot.dropped} events were dropped (${hookSnapshot.dropped_ipc} IPC, ${hookSnapshot.dropped_runtime} runtime).`,
|
||||
`The active hook retained ${hookSnapshot.retained} of ${hookSnapshot.observed} observed events within a ${hookSnapshot.retention_budget_bytes}-byte (${String(hookSnapshot.retention_budget_bytes / (1024 * 1024))} MiB) retained-data budget; ${hookSnapshot.dropped} events were dropped (${hookSnapshot.dropped_ipc} IPC, ${hookSnapshot.dropped_runtime} runtime). This release has no runtime override.`,
|
||||
]
|
||||
: []),
|
||||
"External shell opens, external navigation, permission grants, downloads, popup windows, updater relaunches, and OS integration are blocked and recorded by the active hook; other application filesystem and network behavior is not sandboxed by this provider.",
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
Page,
|
||||
} from "playwright-core";
|
||||
|
||||
import { DEFAULT_CDP_COMMAND_TIMEOUT_MS } from "../domain/browserDeadline.js";
|
||||
import { browserScenarioSchema } from "../domain/browserScenario.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import {
|
||||
@@ -1011,3 +1012,313 @@ it("bounds an opening failure response while retaining the same late cleanup lea
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
const scenarioActions = [
|
||||
{ step_id: "settle", action: "wait_for_timeout" as const, duration_ms: 1 },
|
||||
];
|
||||
|
||||
it("applies command_timeout_ms to launch startup", async () => {
|
||||
let timeout: number | undefined;
|
||||
const scenario = browserScenarioSchema.parse({
|
||||
browser: { mode: "launch", executable_path: process.execPath },
|
||||
start_url: { url: "https://example.test" },
|
||||
command_timeout_ms: 4_321,
|
||||
actions: scenarioActions,
|
||||
});
|
||||
await expect(
|
||||
openPlaywrightScenarioBrowser(
|
||||
scenario,
|
||||
{},
|
||||
{
|
||||
launcher: {
|
||||
connectOverCDP: async () => {
|
||||
throw new Error("Unexpected connection");
|
||||
},
|
||||
launchPersistentContext: async (_profile, options) => {
|
||||
timeout = options?.timeout;
|
||||
throw new Error("launch boundary reached");
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("launch boundary reached");
|
||||
expect(timeout).toBe(4_321);
|
||||
});
|
||||
|
||||
it("uses the default command deadline for an omitted launch timeout", async () => {
|
||||
let timeout: number | undefined;
|
||||
const scenario = browserScenarioSchema.parse({
|
||||
browser: { mode: "launch", executable_path: process.execPath },
|
||||
start_url: { url: "https://example.test" },
|
||||
actions: scenarioActions,
|
||||
});
|
||||
await expect(
|
||||
openPlaywrightScenarioBrowser(
|
||||
scenario,
|
||||
{},
|
||||
{
|
||||
launcher: {
|
||||
connectOverCDP: async () => {
|
||||
throw new Error("Unexpected connection");
|
||||
},
|
||||
launchPersistentContext: async (_profile, options) => {
|
||||
timeout = options?.timeout;
|
||||
throw new Error("launch boundary reached");
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("launch boundary reached");
|
||||
expect(timeout).toBe(DEFAULT_CDP_COMMAND_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("applies command_timeout_ms to the primary connect handshake when service workers are allowed", async () => {
|
||||
let timeout: number | undefined;
|
||||
const started = performance.now();
|
||||
const scenario = browserScenarioSchema.parse({
|
||||
browser: {
|
||||
mode: "connect",
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "primary-handshake",
|
||||
},
|
||||
start_url: { url: "https://example.test" },
|
||||
environment: { service_workers: "allow" },
|
||||
command_timeout_ms: 50,
|
||||
actions: scenarioActions,
|
||||
});
|
||||
const opening = openPlaywrightScenarioBrowser(
|
||||
scenario,
|
||||
{},
|
||||
{
|
||||
launcher: {
|
||||
connectOverCDP: async (_endpoint, options) => {
|
||||
timeout = options?.timeout;
|
||||
return new Promise(() => undefined);
|
||||
},
|
||||
launchPersistentContext: async () => {
|
||||
throw new Error("Unexpected launch");
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const failure = await opening.then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
expect(failure).toMatchObject({
|
||||
reason: "timeout",
|
||||
userMessage: expect.stringContaining("50 ms command_timeout_ms"),
|
||||
remediationAction: expect.stringContaining("capture-browser-scenario JSON"),
|
||||
});
|
||||
expect(
|
||||
(failure as { remediationAction?: string }).remediationAction,
|
||||
).not.toContain("--command-timeout-ms");
|
||||
expect(timeout).toBe(50);
|
||||
expect(performance.now() - started).toBeLessThan(2_000);
|
||||
});
|
||||
|
||||
it("applies command_timeout_ms to primary target lookup when service workers are allowed", async () => {
|
||||
const started = performance.now();
|
||||
const scenario = browserScenarioSchema.parse({
|
||||
browser: {
|
||||
mode: "connect",
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "primary-target-info",
|
||||
},
|
||||
start_url: { url: "https://example.test" },
|
||||
environment: { service_workers: "allow" },
|
||||
command_timeout_ms: 50,
|
||||
actions: scenarioActions,
|
||||
});
|
||||
const browser = {
|
||||
close: async () => undefined,
|
||||
contexts: () => [
|
||||
{
|
||||
pages: () => [{} as Page],
|
||||
newCDPSession: async () =>
|
||||
({
|
||||
send: () => new Promise(() => undefined),
|
||||
detach: async () => undefined,
|
||||
}) as unknown as CDPSession,
|
||||
} as unknown as BrowserContext,
|
||||
],
|
||||
isConnected: () => true,
|
||||
version: () => "fixture",
|
||||
};
|
||||
await expect(
|
||||
openPlaywrightScenarioBrowser(
|
||||
scenario,
|
||||
{},
|
||||
{
|
||||
launcher: {
|
||||
connectOverCDP: async (_endpoint, options) => {
|
||||
expect(options?.timeout).toBe(50);
|
||||
return browser;
|
||||
},
|
||||
launchPersistentContext: async () => {
|
||||
throw new Error("Unexpected launch");
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
reason: "timeout",
|
||||
userMessage: expect.stringContaining("Target.getTargetInfo"),
|
||||
});
|
||||
expect(performance.now() - started).toBeLessThan(2_000);
|
||||
});
|
||||
|
||||
it("closes a context that launches after its deadline before removing the profile", async () => {
|
||||
const order: string[] = [];
|
||||
let profilePath = "";
|
||||
const scenario = browserScenarioSchema.parse({
|
||||
browser: { mode: "launch", executable_path: process.execPath },
|
||||
start_url: { url: "https://example.test" },
|
||||
command_timeout_ms: 40,
|
||||
actions: scenarioActions,
|
||||
});
|
||||
const failure = await openPlaywrightScenarioBrowser(
|
||||
scenario,
|
||||
{},
|
||||
{
|
||||
launcher: {
|
||||
connectOverCDP: async () => {
|
||||
throw new Error("Unexpected connection");
|
||||
},
|
||||
launchPersistentContext: async (profile) => {
|
||||
profilePath = profile;
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
order.push("launched");
|
||||
return {
|
||||
close: async () => {
|
||||
order.push("close");
|
||||
},
|
||||
} as unknown as BrowserContext;
|
||||
},
|
||||
},
|
||||
},
|
||||
).then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
expect(failure).toMatchObject({ reason: "timeout" });
|
||||
expect(
|
||||
(failure as { remediationAction?: string }).remediationAction,
|
||||
).not.toContain("--command-timeout-ms");
|
||||
expect(order).toEqual(["launched", "close"]);
|
||||
await expect(access(profilePath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("closes a browser that connects after its deadline without waiting to report the timeout", async () => {
|
||||
let closed = false;
|
||||
const started = performance.now();
|
||||
const scenario = browserScenarioSchema.parse({
|
||||
browser: {
|
||||
mode: "connect",
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "late-connect-target",
|
||||
},
|
||||
start_url: { url: "https://example.test" },
|
||||
environment: { service_workers: "allow" },
|
||||
command_timeout_ms: 40,
|
||||
actions: scenarioActions,
|
||||
});
|
||||
await expect(
|
||||
openPlaywrightScenarioBrowser(
|
||||
scenario,
|
||||
{},
|
||||
{
|
||||
launcher: {
|
||||
connectOverCDP: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
return {
|
||||
close: async () => {
|
||||
closed = true;
|
||||
},
|
||||
contexts: () => [],
|
||||
isConnected: () => true,
|
||||
version: () => "late-connect",
|
||||
};
|
||||
},
|
||||
launchPersistentContext: async () => {
|
||||
throw new Error("Unexpected launch");
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ reason: "timeout" });
|
||||
expect(performance.now() - started).toBeLessThan(2_000);
|
||||
for (let attempt = 0; attempt < 50 && !closed; attempt += 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
|
||||
it("restores an environment mutation that completes after its deadline", async () => {
|
||||
let restored = false;
|
||||
const scenario = browserScenarioSchema.parse({
|
||||
browser: {
|
||||
mode: "connect",
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "late-environment-target",
|
||||
},
|
||||
start_url: { url: "https://example.test" },
|
||||
environment: { service_workers: "allow" },
|
||||
command_timeout_ms: 40,
|
||||
actions: scenarioActions,
|
||||
});
|
||||
const discoverySession = {
|
||||
send: vi.fn(async () => ({
|
||||
targetInfo: { targetId: "late-environment-target" },
|
||||
})),
|
||||
detach: vi.fn(async () => undefined),
|
||||
} as unknown as CDPSession;
|
||||
const environmentSession = {
|
||||
send: vi.fn(async () => undefined),
|
||||
detach: vi.fn(async () => undefined),
|
||||
} as unknown as CDPSession;
|
||||
const page = {
|
||||
emulateMedia: vi.fn(async (options: { colorScheme?: string | null }) => {
|
||||
if (options.colorScheme === null) {
|
||||
restored = true;
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
}),
|
||||
isClosed: vi.fn(() => false),
|
||||
} as unknown as Page;
|
||||
const context = {
|
||||
pages: () => [page],
|
||||
newCDPSession: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(discoverySession)
|
||||
.mockResolvedValueOnce(environmentSession),
|
||||
} as unknown as BrowserContext;
|
||||
const browser = {
|
||||
close: vi.fn(async () => undefined),
|
||||
contexts: () => [context],
|
||||
isConnected: vi.fn(() => true),
|
||||
version: () => "late-environment",
|
||||
};
|
||||
await expect(
|
||||
openPlaywrightScenarioBrowser(
|
||||
scenario,
|
||||
{},
|
||||
{
|
||||
launcher: {
|
||||
connectOverCDP: async () => browser,
|
||||
launchPersistentContext: async () => {
|
||||
throw new Error("Unexpected launch");
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
reason: "timeout",
|
||||
userMessage: expect.stringContaining("Emulation.setEmulatedMedia"),
|
||||
});
|
||||
expect(restored).toBe(true);
|
||||
expect(environmentSession.send).not.toHaveBeenCalledWith(
|
||||
"Emulation.clearDeviceMetricsOverride",
|
||||
);
|
||||
expect(browser.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -25,6 +25,13 @@ import {
|
||||
withPlaywrightExecutionBoundary,
|
||||
PLAYWRIGHT_CLEANUP_CONFIRMATION_TIMEOUT_MS,
|
||||
} from "./PlaywrightExecutionBoundary.js";
|
||||
import {
|
||||
releaseLateScenarioCommand,
|
||||
scenarioCommandPending,
|
||||
scenarioCommandTimeoutMs,
|
||||
settleScenarioCommand,
|
||||
withScenarioCommandDeadline,
|
||||
} from "./ScenarioCommandDeadline.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
|
||||
@@ -369,22 +376,58 @@ const allowedBrowserEnvironment = (
|
||||
TERMUX_EXEC__PROC_SELF_EXE: process.execPath,
|
||||
};
|
||||
};
|
||||
const ownLateScenarioCommand = (
|
||||
cause: unknown,
|
||||
retainCleanup: ((lease: AnalysisCleanupLease) => void) | undefined,
|
||||
resource: string,
|
||||
release: () => Promise<void> = () => releaseLateScenarioCommand(cause),
|
||||
): boolean => {
|
||||
if (scenarioCommandPending(cause) === undefined) return false;
|
||||
const late = new AnalysisCleanupLease(release, [resource]);
|
||||
retainCleanup?.(late);
|
||||
void late.release().catch(() => undefined);
|
||||
return true;
|
||||
};
|
||||
|
||||
const findConnectedPage = async (
|
||||
browser: Pick<Browser, "contexts">,
|
||||
targetId: string,
|
||||
signal: AbortSignal | undefined,
|
||||
commandTimeoutMs: number,
|
||||
retainCleanup: ((lease: AnalysisCleanupLease) => void) | undefined,
|
||||
): Promise<{ readonly context: BrowserContext; readonly page: Page }> => {
|
||||
for (const context of browser.contexts())
|
||||
for (const page of context.pages()) {
|
||||
const session = await context.newCDPSession(page);
|
||||
let session: CDPSession;
|
||||
try {
|
||||
session = await withScenarioCommandDeadline(
|
||||
() => context.newCDPSession(page),
|
||||
commandTimeoutMs,
|
||||
"command",
|
||||
"Target.attachToTarget",
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
ownLateScenarioCommand(cause, retainCleanup, "browser_transport");
|
||||
throw cause;
|
||||
}
|
||||
try {
|
||||
throwIfScenarioCancelled(signal);
|
||||
const { targetInfo } = await session.send("Target.getTargetInfo");
|
||||
const { targetInfo } = await withScenarioCommandDeadline(
|
||||
() => session.send("Target.getTargetInfo"),
|
||||
commandTimeoutMs,
|
||||
"command",
|
||||
"Target.getTargetInfo",
|
||||
);
|
||||
throwIfScenarioCancelled(signal);
|
||||
if (targetInfo.targetId !== targetId) continue;
|
||||
return { context, page };
|
||||
} finally {
|
||||
await session.detach();
|
||||
await withScenarioCommandDeadline(
|
||||
() => session.detach(),
|
||||
commandTimeoutMs,
|
||||
"command",
|
||||
"Target.detachFromTarget",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new BrowserObservationError(OPERATION, "target_not_found");
|
||||
@@ -402,15 +445,31 @@ class AttachedScenarioEnvironmentOwner {
|
||||
private constructor(
|
||||
private readonly page: Page,
|
||||
private readonly session: CDPSession,
|
||||
private readonly commandTimeoutMs: number,
|
||||
) {}
|
||||
|
||||
static async create(
|
||||
context: BrowserContext,
|
||||
page: Page,
|
||||
commandTimeoutMs: number,
|
||||
retainCleanup: ((lease: AnalysisCleanupLease) => void) | undefined,
|
||||
): Promise<AttachedScenarioEnvironmentOwner> {
|
||||
let session: CDPSession;
|
||||
try {
|
||||
session = await withScenarioCommandDeadline(
|
||||
() => context.newCDPSession(page),
|
||||
commandTimeoutMs,
|
||||
"command",
|
||||
"Target.attachToTarget",
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
ownLateScenarioCommand(cause, retainCleanup, "browser_transport");
|
||||
throw cause;
|
||||
}
|
||||
return new AttachedScenarioEnvironmentOwner(
|
||||
page,
|
||||
await context.newCDPSession(page),
|
||||
session,
|
||||
commandTimeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -437,52 +496,85 @@ class AttachedScenarioEnvironmentOwner {
|
||||
return application;
|
||||
}
|
||||
|
||||
async #command(work: Promise<unknown>, method: string): Promise<void> {
|
||||
await withScenarioCommandDeadline(
|
||||
() => work,
|
||||
this.commandTimeoutMs,
|
||||
"command",
|
||||
method,
|
||||
);
|
||||
}
|
||||
|
||||
async #applyEnvironment(
|
||||
environment: BrowserScenario["environment"],
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<void> {
|
||||
await this.page.emulateMedia({
|
||||
colorScheme: environment.color_scheme,
|
||||
reducedMotion: environment.reduced_motion,
|
||||
});
|
||||
this.#mediaOverride = true;
|
||||
await this.#command(
|
||||
this.page
|
||||
.emulateMedia({
|
||||
colorScheme: environment.color_scheme,
|
||||
reducedMotion: environment.reduced_motion,
|
||||
})
|
||||
.then(() => {
|
||||
this.#mediaOverride = true;
|
||||
}),
|
||||
"Emulation.setEmulatedMedia",
|
||||
);
|
||||
throwIfScenarioCancelled(signal);
|
||||
|
||||
// setViewportSize also resizes the external browser window. CDP metrics
|
||||
// provide the scenario viewport without changing that caller-owned window.
|
||||
await this.session.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: environment.viewport.width,
|
||||
height: environment.viewport.height,
|
||||
deviceScaleFactor: environment.viewport.device_scale_factor,
|
||||
mobile: false,
|
||||
dontSetVisibleSize: true,
|
||||
});
|
||||
this.#deviceMetricsOverride = true;
|
||||
await this.#command(
|
||||
this.session
|
||||
.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: environment.viewport.width,
|
||||
height: environment.viewport.height,
|
||||
deviceScaleFactor: environment.viewport.device_scale_factor,
|
||||
mobile: false,
|
||||
dontSetVisibleSize: true,
|
||||
})
|
||||
.then(() => {
|
||||
this.#deviceMetricsOverride = true;
|
||||
}),
|
||||
"Emulation.setDeviceMetricsOverride",
|
||||
);
|
||||
throwIfScenarioCancelled(signal);
|
||||
|
||||
await this.session.send("Emulation.setLocaleOverride", {
|
||||
locale: environment.locale,
|
||||
});
|
||||
this.#localeOverride = true;
|
||||
await this.#command(
|
||||
this.session
|
||||
.send("Emulation.setLocaleOverride", {
|
||||
locale: environment.locale,
|
||||
})
|
||||
.then(() => {
|
||||
this.#localeOverride = true;
|
||||
}),
|
||||
"Emulation.setLocaleOverride",
|
||||
);
|
||||
throwIfScenarioCancelled(signal);
|
||||
|
||||
await this.session.send("Emulation.setTimezoneOverride", {
|
||||
timezoneId: environment.timezone,
|
||||
});
|
||||
this.#timezoneOverride = true;
|
||||
await this.#command(
|
||||
this.session
|
||||
.send("Emulation.setTimezoneOverride", {
|
||||
timezoneId: environment.timezone,
|
||||
})
|
||||
.then(() => {
|
||||
this.#timezoneOverride = true;
|
||||
}),
|
||||
"Emulation.setTimezoneOverride",
|
||||
);
|
||||
throwIfScenarioCancelled(signal);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.#detached) return;
|
||||
// The execution boundary can report cancellation before a pending CDP
|
||||
// command settles. Wait for that producer so every successful mutation is
|
||||
// recorded before deciding which state this owner must restore.
|
||||
// The execution boundary can report cancellation or a command deadline
|
||||
// before a pending CDP command settles. Wait for that producer, including
|
||||
// a command that finishes after its deadline, so every successful mutation
|
||||
// is recorded before deciding which state this owner must restore.
|
||||
try {
|
||||
await this.#application;
|
||||
} catch {
|
||||
// The operation path reports application failures. Cleanup still owns
|
||||
// and restores every mutation that completed before the failure.
|
||||
} catch (cause: unknown) {
|
||||
await settleScenarioCommand(cause);
|
||||
}
|
||||
const failures: unknown[] = [];
|
||||
if (this.#timezoneOverride) {
|
||||
@@ -577,6 +669,7 @@ type ScenarioBrowserLauncher = Pick<
|
||||
> & {
|
||||
readonly connectOverCDP: (
|
||||
endpoint: string,
|
||||
options?: { readonly timeout?: number },
|
||||
) => Promise<Pick<Browser, "contexts" | "close" | "isConnected" | "version">>;
|
||||
};
|
||||
|
||||
@@ -591,6 +684,7 @@ const openAttachedScenarioBrowser = async (
|
||||
launcher: ScenarioBrowserLauncher,
|
||||
signal: AbortSignal | undefined,
|
||||
retainCleanup: ((lease: AnalysisCleanupLease) => void) | undefined,
|
||||
commandTimeoutMs: number,
|
||||
): Promise<OpenedScenarioBrowser> => {
|
||||
const lease = await acquireAttachedScenario(browserRequest, signal);
|
||||
let cleanupOwner: PlaywrightScenarioBrowserCleanupOwner | undefined;
|
||||
@@ -600,7 +694,14 @@ const openAttachedScenarioBrowser = async (
|
||||
if (priorOwner !== undefined) await priorOwner.close();
|
||||
throwIfScenarioCancelled(signal);
|
||||
|
||||
const browser = await launcher.connectOverCDP(browserRequest.cdp_endpoint);
|
||||
const browser = await withScenarioCommandDeadline(
|
||||
() =>
|
||||
launcher.connectOverCDP(browserRequest.cdp_endpoint, {
|
||||
timeout: commandTimeoutMs,
|
||||
}),
|
||||
commandTimeoutMs,
|
||||
"handshake",
|
||||
);
|
||||
const cleanup: PlaywrightScenarioBrowserCleanupOwner =
|
||||
new PlaywrightScenarioBrowserCleanupOwner({
|
||||
closeBrowser: async (disconnectAtBoundary) => {
|
||||
@@ -651,14 +752,26 @@ const openAttachedScenarioBrowser = async (
|
||||
lease.admission.cleanupOwner = cleanup;
|
||||
throwIfScenarioCancelled(signal);
|
||||
const target = await withPlaywrightExecutionBoundary(
|
||||
() => findConnectedPage(browser, browserRequest.target_id, signal),
|
||||
() =>
|
||||
findConnectedPage(
|
||||
browser,
|
||||
browserRequest.target_id,
|
||||
signal,
|
||||
commandTimeoutMs,
|
||||
retainCleanup,
|
||||
),
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
throwIfScenarioCancelled(signal);
|
||||
const configuredEnvironment = await withPlaywrightExecutionBoundary(
|
||||
() =>
|
||||
AttachedScenarioEnvironmentOwner.create(target.context, target.page),
|
||||
AttachedScenarioEnvironmentOwner.create(
|
||||
target.context,
|
||||
target.page,
|
||||
commandTimeoutMs,
|
||||
retainCleanup,
|
||||
),
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
@@ -676,7 +789,16 @@ const openAttachedScenarioBrowser = async (
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
if (cleanupOwner === undefined) {
|
||||
lease.release();
|
||||
const owned = ownLateScenarioCommand(
|
||||
cause,
|
||||
retainCleanup,
|
||||
"browser_transport",
|
||||
async () => {
|
||||
await releaseLateScenarioCommand(cause);
|
||||
lease.release();
|
||||
},
|
||||
);
|
||||
if (!owned) lease.release();
|
||||
throw cause;
|
||||
}
|
||||
const cleanup = cleanupOwner;
|
||||
@@ -703,6 +825,7 @@ export const openPlaywrightScenarioBrowser = async (
|
||||
// Load Playwright only when a scenario opens; MCP startup stays off its module graph.
|
||||
const launcher =
|
||||
options.launcher ?? (await import("playwright-core")).chromium;
|
||||
const commandTimeoutMs = scenarioCommandTimeoutMs(scenario);
|
||||
if (scenario.browser.mode === "connect")
|
||||
return openAttachedScenarioBrowser(
|
||||
scenario.browser,
|
||||
@@ -710,44 +833,52 @@ export const openPlaywrightScenarioBrowser = async (
|
||||
launcher,
|
||||
options.signal,
|
||||
options.retainCleanup,
|
||||
commandTimeoutMs,
|
||||
);
|
||||
|
||||
const profilePath = await mkdtemp(join(tmpdir(), "rea-browser-scenario-"));
|
||||
let context: BrowserContext;
|
||||
const executablePath = scenario.browser.executable_path;
|
||||
const headless = scenario.browser.headless;
|
||||
try {
|
||||
await chmod(profilePath, 0o700);
|
||||
context = await launcher.launchPersistentContext(profilePath, {
|
||||
executablePath: scenario.browser.executable_path,
|
||||
headless: scenario.browser.headless,
|
||||
acceptDownloads: false,
|
||||
viewport: {
|
||||
width: scenario.environment.viewport.width,
|
||||
height: scenario.environment.viewport.height,
|
||||
},
|
||||
deviceScaleFactor: scenario.environment.viewport.device_scale_factor,
|
||||
locale: scenario.environment.locale,
|
||||
timezoneId: scenario.environment.timezone,
|
||||
colorScheme: scenario.environment.color_scheme,
|
||||
reducedMotion: scenario.environment.reduced_motion,
|
||||
serviceWorkers: scenario.environment.service_workers,
|
||||
env: allowedBrowserEnvironment(environment),
|
||||
handleSIGHUP: false,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
timeout: 0,
|
||||
// Termux's Chromium cannot create its renderer process through Android's
|
||||
// executable boundary. Single-process mode keeps the browser and page in
|
||||
// the Termux-launched process so scenario navigation remains available.
|
||||
args:
|
||||
process.platform === "android"
|
||||
? ["--no-sandbox", "--single-process"]
|
||||
: [],
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
const cleanup = new AnalysisCleanupLease(
|
||||
() => rm(profilePath, { recursive: true, force: true, maxRetries: 3 }),
|
||||
["browser_profile"],
|
||||
context = await withScenarioCommandDeadline(
|
||||
() =>
|
||||
launcher.launchPersistentContext(profilePath, {
|
||||
executablePath,
|
||||
headless,
|
||||
acceptDownloads: false,
|
||||
viewport: {
|
||||
width: scenario.environment.viewport.width,
|
||||
height: scenario.environment.viewport.height,
|
||||
},
|
||||
deviceScaleFactor: scenario.environment.viewport.device_scale_factor,
|
||||
locale: scenario.environment.locale,
|
||||
timezoneId: scenario.environment.timezone,
|
||||
colorScheme: scenario.environment.color_scheme,
|
||||
reducedMotion: scenario.environment.reduced_motion,
|
||||
serviceWorkers: scenario.environment.service_workers,
|
||||
env: allowedBrowserEnvironment(environment),
|
||||
handleSIGHUP: false,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
timeout: commandTimeoutMs,
|
||||
// Termux's Chromium cannot create its renderer process through Android's
|
||||
// executable boundary. Single-process mode keeps the browser and page in
|
||||
// the Termux-launched process so scenario navigation remains available.
|
||||
args:
|
||||
process.platform === "android"
|
||||
? ["--no-sandbox", "--single-process"]
|
||||
: [],
|
||||
}),
|
||||
commandTimeoutMs,
|
||||
"handshake",
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
const cleanup = new AnalysisCleanupLease(async () => {
|
||||
await releaseLateScenarioCommand(cause);
|
||||
await rm(profilePath, { recursive: true, force: true, maxRetries: 3 });
|
||||
}, ["browser_profile"]);
|
||||
try {
|
||||
return await failBrowserScenarioOperation(cleanup, cause);
|
||||
} catch (failure: unknown) {
|
||||
|
||||
@@ -3,6 +3,15 @@ import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { Frame, Page } from "playwright-core";
|
||||
|
||||
import {
|
||||
CDP_CLEANUP_GRACE_MS,
|
||||
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
|
||||
selectedCdpCommandTimeoutMs,
|
||||
} from "../domain/browserDeadline.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { browserObservationEndpointSchema } from "../domain/browserObservation.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { CdpConnection } from "./CdpConnection.js";
|
||||
@@ -18,7 +27,64 @@ const OPERATION = "capture_browser_scenario" as const;
|
||||
// Restoration performs several sequential CDP mutations before the transport's
|
||||
// own bounded close. A single round-trip deadline is too short for the complete
|
||||
// chain on a loaded Windows browser, while retries must still stay bounded.
|
||||
const POLICY_CLEANUP_CONFIRMATION_TIMEOUT_MS = 5_000;
|
||||
const POLICY_CLEANUP_CONFIRMATION_TIMEOUT_MS = CDP_CLEANUP_GRACE_MS;
|
||||
/**
|
||||
* First blocked-service-worker registration check, before discovery starts.
|
||||
* It keeps the previous fixed 10-second bound. command_timeout_ms does not
|
||||
* apply yet, and this timer is not the discovery or attach deadline.
|
||||
*/
|
||||
export const SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS =
|
||||
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS;
|
||||
|
||||
const serviceWorkerPolicyCheckTimeout = (): AnalysisTimeoutError => {
|
||||
const timeoutMs = SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS;
|
||||
return new AnalysisTimeoutError(OPERATION, timeoutMs, {
|
||||
userMessage: `Blocked service-worker policy check exceeded its ${String(timeoutMs)} ms deadline before CDP discovery. command_timeout_ms does not apply to this check.`,
|
||||
remediationAction: `The ${String(timeoutMs)} ms blocked service-worker policy check stays fixed. This release has no runtime override. command_timeout_ms does not raise it.`,
|
||||
});
|
||||
};
|
||||
|
||||
const withServiceWorkerPolicyDeadline = <Value>(
|
||||
work: () => Promise<Value>,
|
||||
signal: AbortSignal,
|
||||
): Promise<Value> => {
|
||||
if (signal.aborted)
|
||||
return Promise.reject(
|
||||
new AnalysisCancelledError(OPERATION, { cause: signal.reason }),
|
||||
);
|
||||
return new Promise<Value>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
fail(serviceWorkerPolicyCheckTimeout());
|
||||
}, SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const succeed = (value: Value): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
const fail = (error: unknown): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
fail(new AnalysisCancelledError(OPERATION, { cause: signal.reason }));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
void work().then(succeed, fail);
|
||||
});
|
||||
};
|
||||
const scriptResponse = z.object({ identifier: z.string() });
|
||||
const restorationResponse = z.object({
|
||||
result: z.object({ value: z.literal("policy_owned").nullable() }),
|
||||
@@ -60,6 +126,9 @@ export class PlaywrightScenarioServiceWorkers {
|
||||
private readonly page: Page,
|
||||
private readonly endpoint: string,
|
||||
private readonly targetId: string,
|
||||
private readonly commandTimeoutMs: number = selectedCdpCommandTimeoutMs(
|
||||
undefined,
|
||||
),
|
||||
) {}
|
||||
|
||||
initialize(): Promise<void> {
|
||||
@@ -67,41 +136,47 @@ export class PlaywrightScenarioServiceWorkers {
|
||||
}
|
||||
|
||||
private async install(): Promise<void> {
|
||||
this.#session = await withCdpDiscoveryDeadline(
|
||||
await withServiceWorkerPolicyDeadline(
|
||||
() => this.assertBlocked(),
|
||||
this.#setup.signal,
|
||||
);
|
||||
// Discovery stays on its fixed deadline. The following handshake and
|
||||
// Target.attachToTarget each use command_timeout_ms.
|
||||
const opened = await withCdpDiscoveryDeadline(
|
||||
OPERATION,
|
||||
this.#setup.signal,
|
||||
async (signal) => {
|
||||
await withPlaywrightExecutionBoundary(
|
||||
() => this.assertBlocked(),
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
const opened = await openCdpEndpoint(
|
||||
(signal) =>
|
||||
openCdpEndpoint(
|
||||
browserObservationEndpointSchema.parse(this.endpoint),
|
||||
OPERATION,
|
||||
signal,
|
||||
);
|
||||
const target = opened.discovery.targets.find(
|
||||
({ id }) => id === this.targetId,
|
||||
);
|
||||
if (target === undefined) {
|
||||
await opened.connection?.close();
|
||||
throw new BrowserObservationError(OPERATION, "target_not_found");
|
||||
}
|
||||
const connection =
|
||||
opened.connection ??
|
||||
(await CdpConnection.connect(
|
||||
opened.discovery.webSocket.url,
|
||||
OPERATION,
|
||||
signal,
|
||||
));
|
||||
return await attachCdpTargetSession(
|
||||
connection,
|
||||
target,
|
||||
OPERATION,
|
||||
signal,
|
||||
);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
this.commandTimeoutMs,
|
||||
),
|
||||
);
|
||||
const target = opened.discovery.targets.find(
|
||||
({ id }) => id === this.targetId,
|
||||
);
|
||||
if (target === undefined) {
|
||||
await opened.connection?.close();
|
||||
throw new BrowserObservationError(OPERATION, "target_not_found");
|
||||
}
|
||||
const transport =
|
||||
opened.connection ??
|
||||
(await CdpConnection.connect(
|
||||
opened.discovery.webSocket.url,
|
||||
OPERATION,
|
||||
this.#setup.signal,
|
||||
{ commandTimeoutMs: this.commandTimeoutMs },
|
||||
));
|
||||
if (this.#closed)
|
||||
transport.beginCleanupGrace(POLICY_CLEANUP_CONFIRMATION_TIMEOUT_MS);
|
||||
this.#session = await attachCdpTargetSession(
|
||||
transport,
|
||||
target,
|
||||
OPERATION,
|
||||
this.#setup.signal,
|
||||
);
|
||||
const connection = this.#session.connection;
|
||||
this.#removeListener = connection.onEvent((event) => {
|
||||
@@ -318,6 +393,10 @@ export class PlaywrightScenarioServiceWorkers {
|
||||
close(stopLoading = false): Promise<void> {
|
||||
this.#closed = true;
|
||||
this.#stopRequested ||= stopLoading;
|
||||
// Restoration commands keep the fixed grace even when command_timeout_ms was raised.
|
||||
this.#session?.connection.beginCleanupGrace(
|
||||
POLICY_CLEANUP_CONFIRMATION_TIMEOUT_MS,
|
||||
);
|
||||
// Abort discovery/handshakes and reads, never an already-sent page mutation.
|
||||
this.#setup.abort();
|
||||
const restoration = (this.#restoration ??= this.restore());
|
||||
@@ -372,6 +451,9 @@ export class PlaywrightScenarioServiceWorkers {
|
||||
|
||||
private async restore(): Promise<void> {
|
||||
await this.#initialization?.catch(() => undefined);
|
||||
this.#session?.connection.beginCleanupGrace(
|
||||
POLICY_CLEANUP_CONFIRMATION_TIMEOUT_MS,
|
||||
);
|
||||
if (this.#released) return;
|
||||
const session = this.#session;
|
||||
if (session === undefined) return;
|
||||
|
||||
@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
|
||||
finishEvents: vi.fn(),
|
||||
policySend: vi.fn(),
|
||||
policyTransportClose: vi.fn(),
|
||||
policyBeginCleanupGrace: vi.fn(),
|
||||
policyEvent: undefined as
|
||||
| ((event: { method: string; params: unknown; sessionId?: string }) => void)
|
||||
| undefined,
|
||||
@@ -33,6 +34,7 @@ vi.mock("./CdpTargetSession.js", () => ({
|
||||
return () => undefined;
|
||||
},
|
||||
close: mocks.policyTransportClose,
|
||||
beginCleanupGrace: mocks.policyBeginCleanupGrace,
|
||||
},
|
||||
}),
|
||||
attachCdpTargetSession: async (connection: unknown) => ({
|
||||
|
||||
@@ -286,6 +286,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
||||
opened.page,
|
||||
scenario.browser.cdp_endpoint,
|
||||
scenario.browser.target_id,
|
||||
scenario.command_timeout_ms,
|
||||
)
|
||||
: undefined;
|
||||
try {
|
||||
|
||||
@@ -403,6 +403,35 @@ it("cancels a pending storage-key reply and still releases its CDP owner", async
|
||||
expect(cdp.detachCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("bounds a stalled child CDP reply by command_timeout_ms", async () => {
|
||||
const cdp = new FakeCdp();
|
||||
const { page, contextApi } = fixture(cdp);
|
||||
const input = browserScenarioSchema.parse({
|
||||
...scenario(),
|
||||
command_timeout_ms: 40,
|
||||
});
|
||||
const started = performance.now();
|
||||
const owner = await createInitializedStorage(contextApi, page, input);
|
||||
expect(owner).toBeDefined();
|
||||
cdp.emit("Target.attachedToTarget", {
|
||||
sessionId: "iframe-session",
|
||||
targetInfo: { type: "iframe", targetId: "iframe-target" },
|
||||
});
|
||||
await expect(owner?.settle()).rejects.toMatchObject({
|
||||
_tag: "BrowserObservationError",
|
||||
operation: "capture_browser_scenario",
|
||||
reason: "timeout",
|
||||
userMessage: expect.stringContaining(
|
||||
"CDP command Page.enable exceeded its 40 ms command_timeout_ms",
|
||||
),
|
||||
remediationAction: expect.stringContaining(
|
||||
"Set command_timeout_ms in the capture-browser-scenario JSON above 40",
|
||||
),
|
||||
});
|
||||
expect(performance.now() - started).toBeLessThan(4_000);
|
||||
await owner?.close();
|
||||
});
|
||||
|
||||
it("releases the CDP owner when an attached target reply remains pending", async () => {
|
||||
const cdp = new FakeCdp();
|
||||
const { page, contextApi } = fixture(cdp);
|
||||
|
||||
@@ -8,6 +8,12 @@ import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
|
||||
import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js";
|
||||
import {
|
||||
releaseLateScenarioCommand,
|
||||
scenarioCommandPending,
|
||||
scenarioCommandTimeoutMs,
|
||||
withScenarioCommandDeadline,
|
||||
} from "./ScenarioCommandDeadline.js";
|
||||
|
||||
const breakpointResponse = z.object({ breakpointId: z.string() });
|
||||
const scriptIdentifierResponse = z.object({ identifier: z.string() });
|
||||
@@ -87,6 +93,7 @@ interface StorageTarget {
|
||||
readonly send: (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
timeoutMs?: number,
|
||||
) => Promise<unknown>;
|
||||
readonly contexts: Map<
|
||||
number,
|
||||
@@ -138,6 +145,7 @@ export class PlaywrightScenarioStorage {
|
||||
private constructor(
|
||||
private readonly cdp: CDPSession,
|
||||
private readonly signal: AbortSignal | undefined,
|
||||
private readonly commandTimeoutMs: number,
|
||||
) {}
|
||||
|
||||
static async create(
|
||||
@@ -154,11 +162,18 @@ export class PlaywrightScenarioStorage {
|
||||
)
|
||||
return undefined;
|
||||
|
||||
const commandTimeoutMs = scenarioCommandTimeoutMs(scenario);
|
||||
const connection = context.newCDPSession(page);
|
||||
let cdp: CDPSession;
|
||||
try {
|
||||
cdp = await withPlaywrightExecutionBoundary(
|
||||
() => connection,
|
||||
() =>
|
||||
withScenarioCommandDeadline(
|
||||
() => connection,
|
||||
commandTimeoutMs,
|
||||
"command",
|
||||
"Target.attachToTarget",
|
||||
),
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
@@ -185,10 +200,20 @@ export class PlaywrightScenarioStorage {
|
||||
() => cleanup.release(),
|
||||
STORAGE_CLEANUP_TIMEOUT_MS,
|
||||
).catch(() => undefined);
|
||||
} else if (scenarioCommandPending(cause) !== undefined) {
|
||||
const cleanup = new AnalysisCleanupLease(
|
||||
() => releaseLateScenarioCommand(cause),
|
||||
["browser_storage_transport"],
|
||||
);
|
||||
retainCleanup?.(cleanup);
|
||||
void withPlaywrightExecutionBoundary(
|
||||
() => cleanup.release(),
|
||||
STORAGE_CLEANUP_TIMEOUT_MS,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
const owner = new PlaywrightScenarioStorage(cdp, signal);
|
||||
const owner = new PlaywrightScenarioStorage(cdp, signal, commandTimeoutMs);
|
||||
owner.addSeeds(scenario.storage.local_storage, "local", secrets);
|
||||
owner.addSeeds(scenario.storage.session_storage, "session", secrets);
|
||||
return owner;
|
||||
@@ -209,14 +234,25 @@ export class PlaywrightScenarioStorage {
|
||||
context: BrowserContext,
|
||||
): Promise<void> {
|
||||
const rootInfo = targetInfoResponse.parse(
|
||||
await this.cdp.send("Target.getTargetInfo"),
|
||||
await withScenarioCommandDeadline(
|
||||
() => this.cdp.send("Target.getTargetInfo"),
|
||||
this.commandTimeoutMs,
|
||||
"command",
|
||||
"Target.getTargetInfo",
|
||||
),
|
||||
);
|
||||
if (this.#closed) return;
|
||||
const root = this.createTarget(
|
||||
undefined,
|
||||
rootInfo.targetInfo.targetId,
|
||||
(method, params) =>
|
||||
this.cdp.send(method as Parameters<CDPSession["send"]>[0], params),
|
||||
(method, params, timeoutMs = this.commandTimeoutMs) =>
|
||||
withScenarioCommandDeadline(
|
||||
() =>
|
||||
this.cdp.send(method as Parameters<CDPSession["send"]>[0], params),
|
||||
timeoutMs,
|
||||
"command",
|
||||
method,
|
||||
),
|
||||
);
|
||||
this.#targets.set("", root);
|
||||
this.listen(page, context);
|
||||
@@ -401,11 +437,16 @@ export class PlaywrightScenarioStorage {
|
||||
|
||||
private fail(cause: unknown): void {
|
||||
this.#generation += 1;
|
||||
this.#failure ??= new BrowserObservationError(
|
||||
"capture_browser_scenario",
|
||||
"protocol_error",
|
||||
{ cause },
|
||||
);
|
||||
// A child command deadline is already the caller-facing failure. Storing
|
||||
// it keeps the command name, selected deadline, and recovery advice.
|
||||
this.#failure ??=
|
||||
cause instanceof BrowserObservationError && cause.reason === "timeout"
|
||||
? cause
|
||||
: new BrowserObservationError(
|
||||
"capture_browser_scenario",
|
||||
"protocol_error",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
private async seedPausedFrame(
|
||||
@@ -479,7 +520,8 @@ export class PlaywrightScenarioStorage {
|
||||
let resumeFailure: unknown;
|
||||
try {
|
||||
await withPlaywrightExecutionBoundary(
|
||||
() => target.send("Debugger.resume"),
|
||||
() =>
|
||||
target.send("Debugger.resume", undefined, STORAGE_CLEANUP_TIMEOUT_MS),
|
||||
STORAGE_CLEANUP_TIMEOUT_MS,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
@@ -508,18 +550,41 @@ export class PlaywrightScenarioStorage {
|
||||
event.targetInfo.type === "iframe"
|
||||
? parent.topLevelTargetId
|
||||
: event.targetInfo.targetId,
|
||||
async (method, params = {}) => {
|
||||
async (method, params = {}, timeoutMs = this.commandTimeoutMs) => {
|
||||
const id = ++this.#sequence;
|
||||
const reply = new Promise<unknown>((resolve, reject) =>
|
||||
this.#replies.set(id, { sessionId, resolve, reject }),
|
||||
);
|
||||
let rejectReply: ((error: Error) => void) | undefined;
|
||||
const reply = new Promise<unknown>((resolve, reject) => {
|
||||
rejectReply = reject;
|
||||
this.#replies.set(id, { sessionId, resolve, reject });
|
||||
});
|
||||
try {
|
||||
const sent = parent.send("Target.sendMessageToTarget", {
|
||||
sessionId,
|
||||
message: JSON.stringify({ id, method, params }),
|
||||
});
|
||||
const [, result] = await Promise.all([sent, reply]);
|
||||
const sent = parent.send(
|
||||
"Target.sendMessageToTarget",
|
||||
{
|
||||
sessionId,
|
||||
message: JSON.stringify({ id, method, params }),
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
// Delivery through the root session and the nested reply share one deadline.
|
||||
const [, result] = await withScenarioCommandDeadline(
|
||||
() => Promise.all([sent, reply]),
|
||||
timeoutMs,
|
||||
"command",
|
||||
method,
|
||||
);
|
||||
return result;
|
||||
} catch (cause: unknown) {
|
||||
rejectReply?.(
|
||||
cause instanceof Error
|
||||
? cause
|
||||
: new BrowserObservationError(
|
||||
"capture_browser_scenario",
|
||||
"protocol_error",
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
throw cause;
|
||||
} finally {
|
||||
this.#replies.delete(id);
|
||||
}
|
||||
@@ -545,7 +610,12 @@ export class PlaywrightScenarioStorage {
|
||||
if (this.#targets.get(sessionId) !== target) return;
|
||||
try {
|
||||
await withPlaywrightExecutionBoundary(
|
||||
() => target.send("Runtime.runIfWaitingForDebugger"),
|
||||
() =>
|
||||
target.send(
|
||||
"Runtime.runIfWaitingForDebugger",
|
||||
undefined,
|
||||
STORAGE_CLEANUP_TIMEOUT_MS,
|
||||
),
|
||||
STORAGE_CLEANUP_TIMEOUT_MS,
|
||||
);
|
||||
target.waitingForDebugger = false;
|
||||
@@ -633,7 +703,7 @@ export class PlaywrightScenarioStorage {
|
||||
const root = this.#targets.get("");
|
||||
if (root !== undefined) {
|
||||
await withPlaywrightExecutionBoundary(
|
||||
() => root.send("Page.stopLoading"),
|
||||
() => this.cdp.send("Page.stopLoading"),
|
||||
STORAGE_CLEANUP_TIMEOUT_MS,
|
||||
);
|
||||
this.#loadingStopped = true;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import {
|
||||
browserDeadlineRetry,
|
||||
cdpCommandDeadlineRecovery,
|
||||
CDP_CLEANUP_GRACE_MS,
|
||||
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
|
||||
DEFAULT_CDP_COMMAND_TIMEOUT_MS,
|
||||
MAX_BROWSER_DEADLINE_MS,
|
||||
selectedCdpCommandTimeoutMs,
|
||||
type CdpCommandDeadlinePhase,
|
||||
} from "../domain/browserDeadline.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
|
||||
const pendingScenarioCommands = new WeakMap<
|
||||
BrowserObservationError,
|
||||
Promise<unknown>
|
||||
>();
|
||||
|
||||
/** Selected scenario CDP deadline, or today's 20000 ms default when omitted. */
|
||||
export const scenarioCommandTimeoutMs = (
|
||||
scenario: Pick<BrowserScenario, "command_timeout_ms">,
|
||||
): number => selectedCdpCommandTimeoutMs(scenario.command_timeout_ms);
|
||||
|
||||
const playwrightTimedOut = (cause: unknown): cause is Error =>
|
||||
cause instanceof Error &&
|
||||
(cause.name === "TimeoutError" || cause.message.startsWith("Timeout "));
|
||||
|
||||
/**
|
||||
* `capture-browser-scenario` accepts only scenario JSON, so recovery names
|
||||
* `command_timeout_ms` there and does not advertise a CLI flag.
|
||||
*/
|
||||
const scenarioCommandDeadlineRecovery = (
|
||||
timeoutMs: number,
|
||||
phase: Extract<CdpCommandDeadlinePhase, "handshake" | "command">,
|
||||
method?: string,
|
||||
): { readonly userMessage: string; readonly remediationAction: string } => {
|
||||
const shared = cdpCommandDeadlineRecovery(timeoutMs, phase, method);
|
||||
const increase =
|
||||
timeoutMs < MAX_BROWSER_DEADLINE_MS
|
||||
? `Set command_timeout_ms in the capture-browser-scenario JSON above ${String(timeoutMs)} (integer milliseconds from 1 to ${String(MAX_BROWSER_DEADLINE_MS)}; omitted means ${String(DEFAULT_CDP_COMMAND_TIMEOUT_MS)}).`
|
||||
: `command_timeout_ms is already at the maximum ${String(MAX_BROWSER_DEADLINE_MS)} ms; do not increase it further.`;
|
||||
return {
|
||||
userMessage: shared.userMessage,
|
||||
remediationAction: `${increase} ${browserDeadlineRetry} command_timeout_ms does not raise the fixed protocol, retained-event, or source byte budgets, the ${String(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)} ms CDP discovery response deadline, or the ${String(CDP_CLEANUP_GRACE_MS)} ms cleanup grace. Those have no runtime override.`,
|
||||
};
|
||||
};
|
||||
|
||||
/** The scenario command still running after this deadline rejection, if any. */
|
||||
export const scenarioCommandPending = (
|
||||
cause: unknown,
|
||||
): Promise<unknown> | undefined =>
|
||||
cause instanceof BrowserObservationError
|
||||
? pendingScenarioCommands.get(cause)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Observe a timed-out scenario command through completion.
|
||||
* The result stays with its caller; this does not close or detach it.
|
||||
*/
|
||||
export const settleScenarioCommand = async (cause: unknown): Promise<void> => {
|
||||
await scenarioCommandPending(cause)?.catch(() => undefined);
|
||||
};
|
||||
|
||||
const releasableCommandResult = (
|
||||
value: unknown,
|
||||
): value is {
|
||||
readonly close?: () => Promise<unknown>;
|
||||
readonly detach?: () => Promise<unknown>;
|
||||
} => typeof value === "object" && value !== null;
|
||||
|
||||
/** Close or detach a browser, context, or session that arrives after its deadline. */
|
||||
export const releaseLateScenarioCommand = async (
|
||||
cause: unknown,
|
||||
): Promise<void> => {
|
||||
const pending = scenarioCommandPending(cause);
|
||||
if (pending === undefined) return;
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await pending;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!releasableCommandResult(value)) return;
|
||||
if (typeof value.detach === "function") {
|
||||
await value.detach();
|
||||
return;
|
||||
}
|
||||
if (typeof value.close === "function") await value.close();
|
||||
};
|
||||
|
||||
/**
|
||||
* Bound one Playwright CDP handshake or command by the scenario command deadline.
|
||||
* The underlying operation stays reachable after a timeout so its owner can
|
||||
* close the returned handle or restore a mutation that lands late.
|
||||
* Playwright's own timeout, when it rejects first, is reported as that deadline.
|
||||
*/
|
||||
export const withScenarioCommandDeadline = async <T>(
|
||||
work: () => Promise<T>,
|
||||
timeoutMs: number,
|
||||
phase: Extract<CdpCommandDeadlinePhase, "handshake" | "command">,
|
||||
method?: string,
|
||||
): Promise<T> => {
|
||||
let operation: Promise<T>;
|
||||
try {
|
||||
operation = work();
|
||||
} catch (cause: unknown) {
|
||||
operation = Promise.reject(cause);
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let settled = false;
|
||||
const recovery = (): {
|
||||
readonly userMessage: string;
|
||||
readonly remediationAction: string;
|
||||
} => scenarioCommandDeadlineRecovery(timeoutMs, phase, method);
|
||||
try {
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const fail = (cause: unknown): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(cause);
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
const deadline = recovery();
|
||||
const timeout = new BrowserObservationError(OPERATION, "timeout", {
|
||||
detail: deadline.userMessage,
|
||||
remediationAction: deadline.remediationAction,
|
||||
});
|
||||
pendingScenarioCommands.set(timeout, operation);
|
||||
fail(timeout);
|
||||
}, timeoutMs);
|
||||
operation.then(
|
||||
(value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
},
|
||||
(cause: unknown) => {
|
||||
if (playwrightTimedOut(cause)) {
|
||||
const deadline = recovery();
|
||||
fail(
|
||||
new BrowserObservationError(OPERATION, "timeout", {
|
||||
detail: deadline.userMessage,
|
||||
remediationAction: deadline.remediationAction,
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
fail(cause);
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
Loaded 100 of 210 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user