Compare commits

...
Author SHA1 Message Date
morluto 64228f8144 test(browser): verify typed worker refusals across navigations 2026-10-15 03:37:16 +08:00
morluto 576abe196f test(process): use owned host-aware settlement fixtures 2026-10-15 03:37:16 +08:00
morluto b2d2e43068 fix(mcp): authenticate inventory facts before prompt completion 2026-10-15 03:37:15 +08:00
morluto acae8a5382 refactor(artifacts): share inspection validation and prune legacy fixtures 2026-10-15 03:31:06 +08:00
morluto 1deb6e87bf test: isolate host discovery and update current capture fixtures 2026-10-15 03:25:16 +08:00
morluto fd3d29bc8d fix(artifacts): bind inspection provenance to every retained inventory 2026-10-15 03:25:16 +08:00
morluto 27faf8b983 fix(javascript): keep source-module semantics independent of sibling bytes 2026-10-15 03:25:16 +08:00
morluto 3a1aa3fd03 fix(evidence)!: reject missing recorded inventory and capture facts
BREAKING CHANGE: Inventory Evidence must explicitly report integrity_contradictions, and browser capture Evidence must report every resolved budget. Caller input defaults remain supported; stored observations are never filled with inferred defaults.
2026-10-15 03:11:37 +08:00
morluto 3b272dc8e2 style(apple): normalize class parser module formatting 2026-10-15 03:08:26 +08:00
morluto 5bea037552 refactor(apple): remove unused class identity re-export 2026-10-15 03:07:39 +08:00
morluto 2c36f8d8e5 style(test): format coverage regression fixtures 2026-10-15 03:06:59 +08:00
morluto 054be92874 docs: align workflow recovery and current Evidence contracts 2026-10-15 03:06:26 +08:00
morluto 67f4b79b32 fix(browser)!: expose capture budgets and queue admitted content reads
BREAKING CHANGE: Browser capture results require resolved limits, event families, and omission facts. Caller-selected budgets replace hidden retention ceilings; admitted content reads queue behind bounded concurrency.
2026-10-15 03:06:26 +08:00
morluto b01db67586 test(hopper): verify external exit recovery and retained ownership 2026-10-15 03:06:26 +08:00
morluto 3c4bd35180 fix(jeb): probe live operations with owned discovery deadlines 2026-10-15 03:06:26 +08:00
morluto b647e4a4e8 fix(cli): preserve Cutter choices and reject invalid runtime arguments 2026-10-15 03:06:26 +08:00
morluto 7f2f0b78c4 fix(ghidra): preserve MIPS overrides and filter entry metadata 2026-10-15 03:06:26 +08:00
morluto 61141032f7 fix(windows): explain missing native artifacts and verify output drain 2026-10-15 03:06:26 +08:00
morluto 303dc2aa73 fix(setup): own Hopper replacement staging and committed outcomes 2026-10-15 03:06:26 +08:00
morluto 98c3c527d8 fix(apple): distinguish JVM resources and UIKit event connections 2026-10-15 03:06:26 +08:00
morluto 5f986d4869 fix(process): attribute finite trace omissions and cleanup states 2026-10-15 03:06:26 +08:00
morluto b9539e9c1c fix(functions): preserve coverage diagnostics separately from blockers 2026-10-15 03:06:26 +08:00
morluto 92e17ccfce fix(javascript): preserve module identity and boundary fields 2026-10-15 03:06:26 +08:00
morluto 9392590f2f fix(artifacts)!: authenticate complete inspection and summary inputs
BREAKING CHANGE: Artifact inventories require scoped coverage. Obsolete coverage_status and prose-based coverage inference are removed. Inspection carriers authenticate every retained inventory substep.
2026-10-15 03:06:26 +08:00
morluto ef0c8005bc refactor(evidence)!: require current coverage commitments
BREAKING CHANGE: Evidence and analysis snapshots require explicit nullable relationship coverage and include it in their identity commitments. Historical records without the field are rejected; saved files are not migrated.
2026-10-15 03:06:26 +08:00
鸡哥 75104040df fix(javascript): track public CommonJS export receiver lifetimes (#3567)
* fix(javascript): track public CommonJS export receiver lifetimes

* fix(javascript): retain exports across uncertain identity effects
2026-10-15 02:46:14 +08:00
357996cc89 test(perf): measure genuine SAZ selection and retained recovery (#3584)
* test(perf): pin genuine Fiddler captures and raw HTTP oracles

Pin two public Microsoft SAZ captures with 13 and 1311 sessions, immutable source/digest/member coverage and predeclared caller-ordered transaction questions. Independently preserve stored HTTP headers, binary/empty bodies and archive coordinates; admit only known corpus bytes before ZIP allocation. Five reader regressions and both genuine selected-record checks pass. No production or performance-gain claim.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* test(perf): measure SAZ selection and retained recovery workflows

Add authenticated complete/ordinal/retained-page SDK experiments, fixed real-Codex prompts and independent cited-byte grading. Preserve actual failed RPCs and exact retained-reference recovery when complete MCP delivery exceeds its receive buffer. Verify selected CLI/MCP facts, parameters and semantic Evidence identity. Keep a genuine partial-message admission-negative probe. Focused 12 tests and 10 actual-Evidence grading cases pass; no formal model or gain claim yet.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>

* test(perf): verify resolved SAZ Evidence selection parameters

Distinguish omitted request ordinals from the complete resolved ordinal scope recorded by WebNetworkCaptureService. Share the expected sanitized Evidence representation between SDK checks and model grading; preserve original caller arguments. A focused regression and both saved genuine baseline RPCs confirm the correction. Keep original collector failures and costs unchanged; freeze a new driver before repeated measurements.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>

* docs(perf): retain mixed SAZ whole-task costs and failure boundaries

Record both SDK batches, twelve corrected workflows, all four actual Codex attempts, independent byte equivalence and verified payload-phase CPU/memory. Distinguish successful-analysis MCP transport limits from real provider timeouts and negative partial-message admission. Three model questions complete; the only completed pair saves 1.6 percent input but takes 2 percent longer, with no consistent benefit. Keep production APIs/defaults/cache unchanged and preserve all deviations, failed attempts and unknowns.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-15 02:45:44 +08:00
e79e014f23 fix(browser): preserve passive network inventory uncertainty
Preserve uncertainty in offline passive network comparisons when request
identity, metadata, or per-kind inventory coverage is incomplete. Retain
proven HTTP/WebSocket changes without using missing information to infer
membership, duplicate counts, or equality.

Compare redirect resource types only where both observations retain them.
Keep metadata completeness with its owner, stream HTTP content digests,
and require full coverage for multiplicity and WebSocket stream inventories.
Preserve independently known metadata changes and full-inventory counts.

Keep the original JavaScript configuration-escape, JSON, cancellation,
SAZ and schema-documentation fixes compatible with current main. Split
public CLI comparisons into isolated test cases without loosening timeouts.
Validate actual Chromium/HTTP captures through CLI and real stdio SDK,
with explicit limits and unchanged source bytes.

Co-authored-by: To3akaRin <131595336+To3akaRin@users.noreply.github.com>
Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-15 02:28:46 +08:00
morluto b8510ee00b test(mcp): remove cache and event order dependence from fault probes
Inject IDA diagnostic capacity faults per connection so prior module imports cannot bypass them. Use the CDP fixture event barrier before asserting complete body shapes, and document profile and renderer verification.
2026-10-15 01:41:31 +08:00
morluto 36615d9165 fix(mcp): honor Frida partial evidence retention acknowledgments
Use shared recorded-partial delivery on start, load and instrument errors. Return a retained reference only after a successful write; preserve the original inline partial when retention fails or is unavailable.
2026-10-15 01:41:31 +08:00
morluto c5fb86d194 fix(mcp): bind catalog identity to output schema profiles
Include the selected output advertisement profile while retaining canonical output validation in the identity. Reuse the SDK output projection and select doctor expectations from both profiles. Correct the compact input error documentation.
2026-10-15 01:41:31 +08:00
morluto 0df0e4f53e fix(contracts): honor options outside the schema projection cache
Bypass target-only memoization when Standard JSON Schema library options are supplied. Verify shared and inline projections remain independent in either call order.
2026-10-15 01:41:31 +08:00
morluto 204b9f3ac1 fix(contracts): preserve array guidance across MCP schema profiles
Keep item schemas when compact inputs reduce nested structure, expose literal tuple elements to items-first renderers, and preserve SQLite table-selection guidance across compaction. Verify catalog-wide client constraints and canonical rejection before dispatch. Document the stock Pi renderer size limit and caller-selected recovery.
2026-10-15 01:41:31 +08:00
Dery Ferdika a732c859dd fix(contracts): reject NUL bytes in Frida, Rizin, and GDB local path inputs (#3575)
Route the caller-selected local path fields in load_frida_script,
instrument_with_frida, start_frida_session, start_rizin_debug_session,
and start_gdb_session through the shared localPathStringSchema so a NUL
byte is refused at the advertised contract boundary instead of reaching
the filesystem or a provider subprocess and surfacing as a raw error.

Extend the OS-invalid-local-input acceptance harness to the tools that
were missing from it.
2026-10-15 01:17:54 +08:00
morluto 10aa74dfed fix(mcp): preserve tuple constraints in advertised schemas
Declare fixed tuple tails and lengths at the canonical boundary so client array validation accepts every tool. Preserve nonempty candidate constraints in managed and application comparisons. Add catalog-wide client checks and parser/schema parity coverage for both dialects and projection directions.

Fixes #3576
2026-10-15 01:12:46 +08:00
93e75bc8fe fix(setup): retain Hermes YAML scalar source during updates
Retain source nodes for unchanged scalar values and string keys when
replacing a Hermes registration. Discarding quoted YAML 1.2 keys could
make Hermes' C reader reject duplicate boolean keys and switch to the
pure reader, changing the interpretation of unrelated settings.

Use linear mapping lookup, quote newly created YAML 1.2 strings for
YAML 1.1 compatibility, and preserve direct numeric edit formatting.
Extend actual CLI coverage for both existing reader-selection directions;
verify strings, exact unrelated numeric/timestamp values, backups and
idempotence with the actual Hermes Python loader.

Follow-up to #3102 and main's independent Hermes scalar fix.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-15 00:57:57 +08:00
228 changed files with 10577 additions and 1657 deletions

No files matched your search

+1
View File
@@ -741,6 +741,7 @@ jobs:
tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts
tests/boundary/filesystem/webScriptManifestPaths.test.ts
tests/boundary/process/ProcessEvidence.host.test.ts
tests/boundary/process/windowsOutputCapture.test.ts
tests/acceptance/webModuleCli.test.ts
tests/acceptance/webSourceLocationCli.test.ts
tests/boundary/cli/referenceSourceImportStatus.test.ts
+2 -1
View File
@@ -1340,7 +1340,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
ReferenceIterator incomingIterator = currentProgram.getReferenceManager().getReferencesTo(address);
while (incomingIterator.hasNext()) {
monitor.checkCancelled();
addReference(incoming, incomingIterator.next());
Reference reference = incomingIterator.next();
if (!reference.isEntryPointReference()) addReference(incoming, reference);
}
List<Reference> orderedIncoming = new ArrayList<>(incoming.values());
orderedIncoming.sort(REFERENCE_ORDER);
+26
View File
@@ -281,6 +281,32 @@ REA retains local observation data and removes only URL userinfo credentials, ex
- Accessibility structure and roles are retained by default, while names and descriptions require `include_accessibility_text: true`.
- Network observations retain method, status, MIME type, size, type, initiator stack location, and complete URLs with userinfo credentials removed. Redirects reported by CDP are attached as ordered `redirects` on the one request ID, with the prior request URL, CDP response URL, method/type, response status/MIME/encoded length, request timestamp, and the next `requestWillBeSent` event timestamp. CDP delivers `redirectResponse` with that next request event, so the latter is an event boundary rather than an exact response timestamp. Redirect response headers and bodies are discarded; hops outside the selected origins are discarded with the request chain. Headers are discarded after an allowlisted projection of length/encoding, structured CSP/Link/policy fields, and untrusted agent hints. Cookies and authorization headers are never retained.
- Request/response bodies are not requested or parsed by default. When selected, only allowed-origin JSON media types are read, converted immediately to complete value-free typed paths, types and observation counts, and discarded. Each path segment is either `{ "kind": "property", "name": "raw property name" }` or `{ "kind": "array-element" }`; array-element types include primitives and nested arrays. Literal `*` properties remain distinct from array elements. `Network.getResponseBody` is never sent unless JSON body shape capture is selected. Comparison uses body shapes only when both captures selected them, and compares request/response shapes only where both observations provide those facts. Unavailable or truncated selected coverage makes equivalence unknown; observed differences still report changed.
For normalized passive inputs with complete request inventories on both sides,
request additions and removals remain observable even when selected body shapes,
response metadata, or WebSocket coverage are unavailable. HTTP request and
WebSocket inventory coverage are evaluated separately; complete HTTP coverage
cannot prove a missing WebSocket connection. Limited request inventory still
prevents request-absence claims. This comparison rule does not make live CDP's
attach-limited inventory complete or establish body-shape equality.
An unavailable HTTP resource type also leaves request identity incomplete:
observations with the same method and URL remain possible matches across types,
and identical unknown types cannot establish complete equality. Ambiguous type
groups cannot prove membership or content changes either: a missing type may
redistribute repeated requests between buckets without changing the requests.
Their comparison remains unknown, while independently identified groups retain
proven changes. Different methods/URLs and complete empty peer inventories still
support absence claims.
Relevant dropped-event counts also limit inventory completeness even when no
section is listed as truncated: HTTP uses `network_requests`, while WebSocket
uses `websocket_connections` and `websocket_frames`. Drops in other event kinds
and the aggregate total do not by themselves limit HTTP inventory. Proven
modifications remain reportable despite incomplete inventory.
Repeated identical HTTP content samples establish multiplicity changes only
with complete request inventories; limited inventories retain known content
comparisons without interpreting duplicate sample counts as changes.
WebSocket stream inventories and connection counts likewise require complete
WebSocket coverage. Redirect resource types use only shared known metadata:
an omitted type leaves equality unknown rather than proving a modification.
- Console observations with a stack source retain call type, argument types, timestamp, and source location. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded.
- WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, text frames are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained.
- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
+56 -19
View File
@@ -23,9 +23,9 @@ that rejection does not roll back browser effects. REA removes its owned
future-document scripts, restores live frame methods, then disconnects.
Add secret declarations, storage seeds, and additional artifact or event
capture only when needed. Action, secret, and storage counts are not capped.
Duration, action, and navigation
timeouts remain fixed provider-owned liveness limits; the request does not
accept a caller-controlled `limits` object.
Duration, action, and navigation timeouts remain fixed provider-owned liveness
limits. `capture.limits` controls retained event and network-content budgets;
it does not change those liveness deadlines.
## Minimal launch example
@@ -179,12 +179,44 @@ declared action. Each step reports action status, elapsed time, sanitized URLs,
event bounds, and independently typed capture states for screenshot, DOM,
accessibility, URL, history, and storage. Console, page-error, network,
WebSocket, frame, worker, popup, and cancelled-download events are attributed to
steps and all observed events and requested artifacts are returned inline.
There are no event, frame, worker, popup, WebSocket, DOM-node,
accessibility-node, storage-entry, screenshot-count, or cumulative
metadata-byte caps. Requested artifacts and observed events are returned
inline without application-defined size ceilings. Other missing sections
remain explicit and make the capture ineligible for equality claims.
steps. Retained events and requested artifacts are returned inline; omitted
events are counted by kind and identify affected network transactions.
Event and selected network-content retention have caller-selected limits under
`capture.limits`. Omitted values use the defaults below; every supplied value
must be a positive safe integer, and callers can raise or lower a default.
Byte limits are conservative allocation-cost estimates for retained event
values, including UTF-16 storage and JSON expansion, rather than serialized
bytes or a promise about exact process memory. `events.omissions.by_kind`
reports counts and reasons for omitted event kinds. Its
`network_transactions` rows identify affected transaction IDs and omitted
event kinds; `network_transaction_range` identifies the contiguous IDs beyond
the transaction limit. Omission metadata is bounded by the selected
transaction limit. Other missing sections remain explicit and make the capture
ineligible for equality claims.
The default limits are 10,000 events, 4 MiB estimated allocation for one
event, 16 MiB estimated allocation across retained events, 1,024 network
transactions, 64 KiB of body bytes per transaction phase, 1 MiB estimated
allocation for body redaction/base64, 1 MiB for retained header values, and
1 MiB for network metadata. The same limits are included in the capture
Evidence. Playwright materializes response bodies before REA can inspect their
byte length; `max_network_body_bytes` bounds subsequent retained copies and
does not bound the browser API read itself. A body that exceeds its selected
limit has an explicit `body-size-limit` or `body-allocation-limit` unavailable
state with the observed and allowed values.
Additional implementation bounds are 16 simultaneous network-content reads,
a 5-second deadline for each provider API read, and 2,048 live event-listener
owners. These are not caller-selectable. The concurrency bound queues admitted
content jobs and does not itself omit observations; a provider read timeout
produces a typed unavailable state, while exceeding listener capacity produces
event-kind omission counts. A preflight avoids body redaction and base64 copies
when the currently available event budget already rejects the body.
Concurrent reads may each pass that check before their events are retained;
their retained-copy work is bounded by 16 times the selected per-body
allocation limit. The Playwright response Buffer itself is materialized before
REA can apply that limit. Requested artifacts and retained events are returned
inline within the declared limits.
Attach-mode captures also declare the unavoidable pre-attach event gap.
Selected event families are observed on the root page and discovered descendant
@@ -288,16 +320,21 @@ retained representation after redaction.
Only completed responses are read. At the end of the final step, observation
stops; requests still in flight emit `request-unfinished`, and their observed
responses have explicitly unavailable bodies. This cutoff does not wait for
long-lived streaming connections. Already-started content reads settle before
browser cleanup, with a provider-owned five-second timeout per read and
cancellation support. Failed or unexposed reads retain per-content states and make selected
event coverage incomplete. Body events can arrive during later steps or final
drain; event bounds describe receipt during the step, while the source event
reference establishes the transaction phase. Comparison attributes content, like
every other event of its transaction, to the step that sent the request (the
receipt step when that request was not observed) and excludes the sequence
reference from normalization.
responses have explicitly unavailable bodies. Already-admitted content reads
drain before browser cleanup. At most 16 reads run at once; queued work is
bounded by the selected network-transaction limit and by successful admission
of its request or response event. Each provider API read has its own five-second
timeout once it starts, so queue wait adds to total drain and cleanup time; this
is not a five-second total capture cutoff. Cancellation interrupts active reads
and queued jobs produce cancelled states without calling their provider APIs.
This drain does not wait for long-lived unfinished streaming connections and
does not refetch unfinished response bodies. Failed or unexposed reads retain
per-content states and make selected event coverage incomplete. Body events can
arrive during later steps or final drain; event bounds describe receipt during
the step, while the source event reference establishes the transaction phase.
Comparison attributes content, like every other event of its transaction, to
the step that sent the request (the receipt step when that request was not
observed) and excludes the sequence reference from normalization.
Playwright scenario capture does not supply initiator stacks; passive CDP
observation retains its own initiator evidence.
+38 -5
View File
@@ -319,11 +319,44 @@ redirected, which keeps large results smaller for agents and scripts; pipe
through `jq .` for indented files. Output selection and formatting do not change
operation status.
| Status | Meaning |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | The operation completed. Its result may include partial evidence, warnings or unresolved questions. |
| `1` | The operation could not complete. Structured output identifies invalid input, permissions, cancellation, timeouts or another failure when available. |
| `128 + N` | Signal `N` ended the process, where the shell or runtime preserves the conventional signal-derived status. |
For `cutter-command`, `--json-output` requests command-specific JSON from
Cutter when available. This is separate from `--format json`, which selects the
CLI result representation; the global `--json` flag selects that same CLI
representation.
Cutter still accepts deprecated `--json=true` and `--json=false` compatibility
values. When both forms are present, `--json-output` takes precedence in either
argument order. Bare `--json` retains its global meaning of selecting JSON CLI
output formatting.
| Status | Meaning |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | The operation completed. Its result may include partial evidence, warnings or unresolved questions. |
| `1` | The operation could not complete. Structured output identifies invalid input, permissions, cancellation, timeouts or another failure when available. |
| `128 + N` | Signal-derived status. An unhandled signal may have ended the process, with the shell or runtime preserving the conventional status; a command may instead have handled the signal, waited for its operation and cleanup to settle, and assigned the same status (`130` for SIGINT, `143` for SIGTERM). In the handled case, it may also have printed a structured `cancelled` result. |
A `cancelled` result does not by itself determine the exit status; it depends on
the command's signal handling. Commands routed through the shared
`withCommandCancellation` handler in the CLI set status `130` for SIGINT or
`143` for SIGTERM after their operation settles. Current source registrations
use that handler for process capture and comparison (`capture-process`,
`compare-process-captures`), JavaScript application analysis (`analyze` when it
selects the JavaScript lane, and `analyze-javascript-application`), selected
browser, web and Electron commands (including `analyze-web-bundle`,
`observe-web-execution`, `capture-browser-scenario` and
`capture-electron-scenario`), and the platform and provider command families
for Android/ADB, Apktool/JEB/Flutter, firmware, PCAP and signal analysis,
WASM/EVM, Go/Rust and binary diagnostics, Frida instrumentation, the Cutter
command, objdump, Rizin and GDB. In these paths, a returned structured
`cancelled` result can be printed before the wrapper assigns the
signal-derived status. This describes the current source routing; it is not a
claim that every command or provider runtime has been independently exercised.
`inspect-artifact` also returns a structured cancelled result, but its direct
analysis cancellation path does not set a signal-derived exit status, so it
exits `1`. Setup has its own contract: cancellation exits `0`. Treat the
structured result and process status as separate signals when scripting
cancellation.
`setup --dry-run` returns `planned` and exits `0`; a cancelled setup also exits
`0`. Setup returns `1` for `needs_confirmation` or `needs_human`.
+22
View File
@@ -6,6 +6,28 @@ and provider lanes. Each record below states its host, client version, model
fixture, and remaining coverage; a native-client run with a loopback model does
not establish live model-service acceptance.
## Published Pi code-mode renderer limit
A separate probe of the published `@earendil-works/pi-codemode` 1.1.0 and 1.2.0
declaration renderers rendered the full `compare_web_captures` input as
`args: unknown`: its generated TypeScript type exceeds Pi's 16,000-character
default. REA keeps the full advertisement's fields by inlining references when
the shared form would exceed Pi's 32-reference expansion limit; retaining that
shared form would leave later fields unknown instead. The existing
`REA_MCP_INPUT_SCHEMA_PROFILE=compact` profile lets callers stay below
its character limit by advertising reduced nested validation structure, while
REA continues to validate calls against the complete canonical input schema.
Compact applies to the server's advertised inputs and is not an automatic
full-profile fallback; see the [compact input schema profile](mcp-contracts.md#compact-input-schema-profile)
for details of its reduced forms.
Custom renderer hosts can raise `inputMaxChars` through `renderToolSignature` or
`renderToolSample`; `renderDeclarations` does not expose that override. The
probe exercised the published renderer code, not a native Pi host or model, and
does not establish broader Pi compatibility. See the pinned upstream
[`declarations.ts`](https://github.com/earendil-works/pi/blob/b45e0ebcc5ae2fc42a6f2f23104d10e656e6bedb/packages/codemode/src/declarations.ts)
and [renderer usage notes](https://github.com/earendil-works/pi/blob/b45e0ebcc5ae2fc42a6f2f23104d10e656e6bedb/packages/codemode/README.md#declarations-for-the-model).
## End-to-end, integration and golden evidence
The optional `verify:qwen-client`, `verify:pi-client`, and `verify:hermes-client`
+7
View File
@@ -60,6 +60,8 @@ provider implicitly.
`trace_javascript_semantics` queries the authenticated companion graph without
rereading mutable files. A seed may identify a semantic or application node,
literal, function fingerprint, property, endpoint, event, or boundary field.
For a recovered parse, coercion, or validation boundary, its field value is the
static operation's method name (for example, `Number`).
The query declares backward provenance, forward influence, callers, or
ownership and can restrict admitted relation kinds. REA returns every matching
seed and the complete reachable subgraph; callers do not choose node, relation,
@@ -83,6 +85,11 @@ callee bodies and built-ins such as `Object.assign`, `Reflect.set`, and
values. This uncertainty does not prove that a call actually mutated a value.
Interprocedural aliasing beyond the supported call-site references remains
outside this mutation pass.
For process configuration, only known environment value slots and canonical
argument indexes are treated as primitive when passed to calls. Unknown or
inherited selections can expose a mutable prototype, so later reads of that
configuration container retain uncertainty; earlier top-level reads and unrelated
configuration containers remain independent.
Function fingerprints commit normalized syntax, control-flow shape, relation
shape, literal sets, arity, and detected effects without using local names or
+6
View File
@@ -16,6 +16,12 @@ URL after validating its syntax. That validation does not establish that the
JEB service is reachable or that a project is open; use the existing
`inspect-jeb-client` command for a read-only service query.
MCP `binary_session` probes the live JEB service and its advertised tool catalog.
Availability is reported per operation: a reachable headless client can support
inspection and decompilation while refusing `open_jeb_project`. Catalog discovery
uses one 60-second deadline across connection and pagination; a responsive server
cannot keep discovery running indefinitely by returning more pages.
Verified against JEB 5.48.0 (Linux x64, licensed build) serving `jeb-mcp-server` 1.3.0 over streamable HTTP.
## Operations
+24 -17
View File
@@ -38,13 +38,10 @@ socket and device entries are not opened. Case-distinct archive members remain
separate observations. Their case-insensitive materialization diagnostic does
not reduce traversal coverage when both spellings were read.
Current output also retains `coverage_status` (`complete` or `partial`) for existing clients, derived from the scoped traversal status. The owning parser normalizes retained `coverage_status` records to scoped coverage and rejects conflicting declarations.
The coverage field is optional when reading older retained Evidence. The owning
inventory parser recognizes historical REA scope notices for compatibility;
unfamiliar legacy limitations remain conservative. Typed records do not derive
coverage from limitation text. Pages from one inventory must carry the same
coverage declaration.
Current inventory Evidence requires scoped `coverage` with a `complete` or
`unknown` status. Consumers use this producer-owned fact and reject records
without it; limitations are explanatory and do not supply or infer coverage.
Pages from one inventory must carry the same coverage declaration.
Function dossiers can declare per-facet coverage in the optional
`normalized_result.facet_availability` map. Each supplied facet is either
@@ -664,10 +661,14 @@ advertised. Every advertised example is a distinct valid canonical input.
Root object unions are presented as
one object without root `anyOf`, `oneOf`, or `allOf` for model API compatibility.
The projection merges properties, retains shared required fields and
group-specific field dependencies, and describes the accepted input groups.
Canonical runtime validation enforces the exact union, including exclusions
that the advertised projection does not express. Tests validate the schemas
The projection merges properties, retains shared required fields, and
describes the accepted input groups. Which fields belong together is guidance
in that description rather than a `dependentRequired` or `dependencies`
keyword, because Gemini function declarations reject both and one unknown
keyword fails the client's whole tool catalog. Canonical runtime validation
enforces the exact union, including partner fields and exclusions that the
advertised projection does not express, so a client must not rely on the
advertised schema to reject an incomplete group. Tests validate the schemas
after SDK conversion and check advertised validation and actual calls.
Individual model APIs can impose additional nesting limits; complete producer
captures can exceed ten structural levels.
@@ -711,9 +712,9 @@ JSON Schema:
in a reduced form: property names, reference-resolved types (including union
alternatives), first-level guidance, and the root's required-field constraints,
with nested validation detail elided. The
canonical schema still rejects malformed calls server-side, so callers
following the reduced advertisement receive the canonical typed error
instead of silent acceptance.
registered canonical schema still rejects malformed calls before invoking a
handler. That rejection can use the MCP SDK's input-validation diagnostic;
it does not guarantee the application's typed input error.
- Schemas deeper than ten nodes use the same reduced projection, with
root-property names, types, guidance, and required fields retained. The
server continues to enforce the full canonical input contract.
@@ -732,6 +733,11 @@ identity. Explicit schema IDs and relative external reference bases are
preserved. This keeps every tool and validation rule in discovery and does not
change the SDK's catalog invalidation or availability checks.
The server identity's tool-schema digest includes both the canonical output
contracts and the selected output-schema profile. `full` and `off` therefore
have distinct identities even though `off` keeps the same canonical output
validation on the server.
Some stdio hosts reject a complete `tools/list` frame before they load any
tools. The opt-in `REA_MCP_OUTPUT_SCHEMA_PROFILE=off` setting omits `outputSchema`
from the advertised catalog for hosts with a small frame limit (the measured
@@ -768,9 +774,10 @@ identities remains unknown.
`normalization` defaults to `{ "rules": [] }`.
The advertised schema includes complete nested capture fields and rejects
incomplete pairs and structurally malformed captures. Mixed comparison families
can pass advertised validation; the SDK rejects them against the canonical
input schema before invoking the handler. Domain validation additionally checks
incomplete pairs and structurally malformed captures. Mixed comparison families,
and `normalization` without both scenario captures, can pass advertised
validation; the SDK rejects them against the canonical input schema before
invoking the handler. Domain validation additionally checks
relationships such as event sequence references and retained counts; JSON
Schema does not express those cross-field invariants.
+3 -2
View File
@@ -481,8 +481,9 @@ user's permissions and may change files, show UI or use the network.
Debugging must be allowed for the user (Developer Tools access). A
hardened-runtime target without `com.apple.security.get-task-allow` cannot be
debugged; `inspect_signature` reports this as the debugger-attach facet, and
the tool fails with a `debugger-attach-denied` reason.
debugged; `inspect_signature` reports the signature's `hardened_runtime` value
and its `entitlements`, including `com.apple.security.get-task-allow` when it
is present. A denied trace reports the `debugger-attach-denied` reason.
## Provider and verification boundaries
+12
View File
@@ -98,6 +98,18 @@ isolated filesystem boundary tests because it loads both source and compiled
module graphs. Adapter, other boundary, acceptance, and process-global projects
retain per-file isolation. See `vitest.config.ts` for current settings.
Inject producer faults through scoped dependencies in shared workers: a module
mock cannot replace a consumer that was imported earlier. Check these tests
with the consumer preloaded as well as alone. For simulated protocol traces,
use an acknowledged event barrier before asserting complete observations;
a zero-duration capture does not guarantee queued events have arrived.
Check advertised schemas after SDK conversion for every supported schema
profile. Meta-schema validity does not establish client acceptance or useful
rendered declarations. Keep canonical rejection checks separate from the
intentionally reduced compact advertisement, and probe actual client renderers
when changing schema representations.
The reused CLI boundary workers cap V8 old space at 384 MiB, based on their
profiled allocation and cleanup behavior. This encourages collection between
files; spawned REA processes retain their own heap settings.
+6
View File
@@ -87,6 +87,12 @@ malformed, unavailable, cancellation, and partial outcomes through affected
consumers. Real-engine claims require the matching provider lane; see
[testing.md](testing.md).
For fixed tuples in public contracts, use `src/domain/fixedTupleSchema.ts`:
Zod's tuple projection omits implicit length bounds and Draft 2020-12 `items`,
which some clients require even alongside `prefixItems`. Tuples with a rest
schema also need explicit minimum lengths. Check parser/schema parity for empty,
short, overlong, and malformed values in both input and output projections.
When discoverability changes, evaluate representative broad, direct, and negative
tasks on the intended host. Record host/model/catalog, selected tools, arguments,
errors, retries, and task outcomes. Schema validity and keyword heuristics do not
+5 -1
View File
@@ -117,7 +117,11 @@ gRPC message bytes. Missing members remain null, distinct from empty bodies.
See Telerik's [SAZ format reference](https://www.telerik.com/fiddler/fiddler-everywhere/documentation/knowledge-base/fiddler-archives).
The existing ZIP reader verifies read members; expanded archive contents are
limited to 32 MiB in addition to the input/output budgets below. Encrypted
limited to 32 MiB in addition to the input/output budgets below. Exceeding this
expanded-byte budget returns `invalid_request` / `invalid_input` through both
CLI and MCP, with an `out_of_range` issue at
`capture_path` and `maximum_expanded_capture_bytes`. Selecting records does not
bypass the complete-archive admission check or return partial Evidence. Encrypted
archives and unrecognized raw session members are unsupported. SAZ requires an
enforced decoder memory bound: Node's fixed heap, or the Windows Job Object
commit limit. Bun on hosts without that Job Object limit returns capability
@@ -1,5 +1,11 @@
# Analysis efficiency measurements
For genuine Fiddler captures, see the [SAZ selection checkpoint](saz-selection-workflows.md).
`measure.mjs --saz-selection baseline|focused|retained` compares default complete
delivery (including retained-reference recovery), caller-selected ordinals, and
summary/preview/complete/repeated pages. Each failed RPC remains in task costs.
The body question uses stored bytes, not decoded HTTP application payloads.
Tracks the experiments for [#2016](https://github.com/morluto/rea/issues/2016).
The production CLI/MCP contracts and defaults remain the compatibility baseline.
Downloaded artifacts, runtime copies, exported Evidence, transcripts and detailed
@@ -181,6 +181,7 @@ def main():
'inspect_android_class', 'inspect_android_method',
'inspect_firmware_regions', 'inspect_managed_artifact',
'inspect_sqlite_database', 'inspect_pcap_capture',
'inspect_web_network_capture',
'inspect_packet_capture', 'inspect_packet_fields',
'decode_signal_recording', 'inspect_signal_decoders',
'inspect_managed_members', 'inspect_managed_native_boundaries',
@@ -373,6 +373,66 @@
"reference": "https://raw.githubusercontent.com/sigrokproject/sigrok-dumps/0ad13477abc959d37fc9a5acbd23901c371c9c76/i2c/wii_nunchuk/README",
"license_scope": "Repository README declares dumps public domain unless otherwise noted; selected READMEs state no exception; decoder software licensing is separate."
}
},
{
"id": "xbox-clear_nomp.saz",
"file": "clear_nomp.saz",
"bytes": 43515,
"sha256": "3e808663a99cbb9160186f7419633248200fbc6863d2c897f5d4d9972dde0375",
"provenance": {
"project": "Microsoft Xbox Live Trace Analyzer",
"version": "ad880ff00b26c4cae5e3c8864d3df438a03890ba",
"url": "https://raw.githubusercontent.com/microsoft/xbox-live-trace-analyzer/ad880ff00b26c4cae5e3c8864d3df438a03890ba/Source/TestData/CertWarnings/clear_nomp.saz",
"git_blob": "2f1ba66dacb8f8bac290cab59dff8fe750a46ce7",
"license": "MIT; upstream LICENSE.md",
"producer_archive_comment": "Fiddler (v4.5.1.2) Session Archive. See http://fiddler2.com"
},
"saz_question": {
"record_ordinals": [12, 0, 6],
"total_records": 13,
"members": 42,
"expanded_bytes": 62609
}
},
{
"id": "xbox-clear_mp_cs10.saz",
"file": "clear_mp_cs10.saz",
"bytes": 5223519,
"sha256": "4021b201848e4feea3402c380fda5d2df43fd0bf1c72ea550e60f2da3b3d5b44",
"provenance": {
"project": "Microsoft Xbox Live Trace Analyzer",
"version": "ad880ff00b26c4cae5e3c8864d3df438a03890ba",
"url": "https://raw.githubusercontent.com/microsoft/xbox-live-trace-analyzer/ad880ff00b26c4cae5e3c8864d3df438a03890ba/Source/TestData/CertWarnings/clear_mp_cs10.saz",
"git_blob": "dac534d625594c7a1d57b0b12ed12ea9c6cb6a5b",
"license": "MIT; upstream LICENSE.md",
"producer_archive_comment": "Fiddler (v4.6.2.0) Session Archive. See https://fiddler2.com"
},
"saz_question": {
"record_ordinals": [1310, 0, 655],
"total_records": 1311,
"members": 3939,
"expanded_bytes": 9223820
}
},
{
"id": "xbox-x49_mp_cs21.saz",
"file": "x49_mp_cs21.saz",
"bytes": 572224,
"sha256": "97cdca8b0608e3f11995fd64b64390b9fd8d8e3efcac89f517f24b44968047f6",
"provenance": {
"project": "Microsoft Xbox Live Trace Analyzer",
"version": "ad880ff00b26c4cae5e3c8864d3df438a03890ba",
"url": "https://raw.githubusercontent.com/microsoft/xbox-live-trace-analyzer/ad880ff00b26c4cae5e3c8864d3df438a03890ba/Source/TestData/CertWarnings/x49_mp_cs21.saz",
"git_blob": "e7794c44892ca4fa1729fa012e698e8284337f15",
"license": "MIT; upstream LICENSE.md",
"producer_archive_comment": "Fiddler (v4.6.2.26757) Session Archive. See https://fiddler2.com"
},
"saz_question": {
"record_ordinals": [102, 0, 51],
"total_records": 103,
"members": 315,
"expanded_bytes": 1301927
}
}
]
}
@@ -14,6 +14,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { Ajv2020 } from "ajv/dist/2020.js";
import { PACKET_ARTIFACTS, measurePacketEntry } from "./packet-entry.mjs";
import { SIGNAL_ARTIFACTS, measureSignalEntry } from "./signal-entry.mjs";
import { SAZ_ARTIFACTS, measureSazEntry } from "./saz-entry.mjs";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const { values } = parseArgs({
@@ -33,6 +34,7 @@ const { values } = parseArgs({
"sqlite-selection": { type: "string", default: "all" },
"packet-selection": { type: "string", default: "baseline" },
"signal-selection": { type: "string", default: "baseline" },
"saz-selection": { type: "string", default: "baseline" },
},
});
if (
@@ -43,6 +45,11 @@ if (
!isAbsolute(values.path) ||
!isAbsolute(values.output) ||
!["complete", "summary"].includes(values.delivery) ||
!["baseline", "focused", "retained"].includes(values["saz-selection"]) ||
(!Object.hasOwn(SAZ_ARTIFACTS, values.artifact) &&
values["saz-selection"] !== "baseline") ||
(Object.hasOwn(SAZ_ARTIFACTS, values.artifact) &&
values.delivery !== "complete") ||
!["baseline", "focused", "discovery", "pages"].includes(
values["signal-selection"],
) ||
@@ -174,6 +181,12 @@ const fingerprints = {
signal_oracle_source_sha256: await fileSha(
fileURLToPath(new URL("./signal-oracle.py", import.meta.url)),
),
saz_workflow_source_sha256: await fileSha(
fileURLToPath(new URL("./saz-entry.mjs", import.meta.url)),
),
saz_oracle_source_sha256: await fileSha(
fileURLToPath(new URL("./saz-oracle.py", import.meta.url)),
),
runtime_files_sha256: sha(JSON.stringify(runtimeFiles)),
runtime_dispatcher_sha256: await fileSha(
resolve(values["runtime-root"], "scripts/rea.mjs"),
@@ -754,6 +767,12 @@ const inspectPcapTarget = async () => {
};
const workflows = {
...Object.fromEntries(
Object.keys(SAZ_ARTIFACTS).map((artifact) => [
artifact,
() => measureSazEntry({ values, call, inputSha }),
]),
),
...Object.fromEntries(
Object.keys(SIGNAL_ARTIFACTS).map((artifact) => [
artifact,
@@ -1188,6 +1207,9 @@ const report = {
artifact_sha256: inputSha,
artifact_sha256_after: finalInputSha,
delivery: values.delivery,
saz_selection: Object.hasOwn(SAZ_ARTIFACTS, values.artifact)
? values["saz-selection"]
: null,
sdk_schema_mode: values.schemas,
retained_verification: values["verify-retained"],
native_question: NATIVE_ARTIFACTS.includes(values.artifact)
@@ -0,0 +1,266 @@
import assert from "node:assert/strict";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { isDeepStrictEqual, parseArgs } from "node:util";
import {
sazRequest,
verifySazRecords,
verifySazOracleIdentity,
sazEvidenceParameters,
} from "./saz-entry.mjs";
const { values } = parseArgs({
options: {
trial: { type: "string" },
oracle: { type: "string" },
"runtime-root": { type: "string" },
artifact: { type: "string" },
selection: { type: "string" },
},
});
if (
!values.trial ||
!values.oracle ||
!values["runtime-root"] ||
!["baseline", "focused"].includes(values.selection)
)
throw new Error(
"Use --trial DIR --oracle JSON --runtime-root DIR --artifact ID --selection baseline|focused",
);
const load = async (file) =>
JSON.parse(await readFile(resolve(values.trial, file), "utf8"));
const settings = await load("trial-settings.json"),
summary = await load("summary.json");
const oracle = JSON.parse(await readFile(values.oracle, "utf8"));
verifySazOracleIdentity(values.artifact, oracle);
const runtime = (file) =>
pathToFileURL(resolve(values["runtime-root"], file)).href;
const { parseEvidence } = await import(runtime("dist/domain/evidence.js"));
const { inspectWebNetworkCaptureInputSchema, webNetworkCaptureSchema } =
await import(runtime("dist/domain/webNetworkCapture.js"));
const { inspectAnalysisViewInputSchema, analysisViewResultSchema } =
await import(runtime("dist/domain/analysisView/analysisView.js"));
const calls = (await readFile(resolve(values.trial, "events.jsonl"), "utf8"))
.split("\n")
.filter(Boolean)
.map(JSON.parse)
.filter(
(event) =>
event.type === "item.completed" && event.item?.type === "mcp_tool_call",
)
.map((event) => event.item);
const planned = inspectWebNetworkCaptureInputSchema.parse(
sazRequest(
values.artifact,
settings.selected_artifact_path,
values.selection,
),
);
const reasons = [],
workflowReasons = [],
authenticated = new Map(),
parents = new Set(),
conflicts = new Set();
let transportRecovery = false;
for (const call of calls) {
try {
assert.equal(call.server, "rea");
assert.equal(call.status, "completed");
assert.ok(!call.error, "RPC did not provide observations");
const text = call.result.content.filter((item) => item.type === "text");
assert.equal(text.length, 1, "Ambiguous Evidence text carrier");
let evidence = JSON.parse(text[0].text);
if (call.result.isError) {
const limits = evidence.error?.details?.reported_limits;
assert.equal(call.tool, "inspect_web_network_capture");
assert.equal(evidence.error?.code, "resource_constraint");
assert.equal(
evidence.error.details.operation,
"inspect_web_network_capture",
);
assert.equal(limits?.boundary, "mcp-response");
const input = inspectWebNetworkCaptureInputSchema.parse(call.arguments);
assert.deepEqual(input, planned);
const source = inspectAnalysisViewInputSchema.parse({
source: limits.evidence_reference,
view: { kind: "summary" },
}).source;
assert.equal(source.kind, "retained-evidence");
parents.add(source.evidence_id);
transportRecovery = true;
continue;
}
if (call.result.structuredContent !== undefined)
assert.deepEqual(evidence, call.result.structuredContent);
evidence = parseEvidence(evidence);
assert.equal(evidence.subject.local_path, settings.selected_artifact_path);
assert.equal(evidence.subject.digest.sha256, oracle.sha256);
let records;
if (call.tool === "inspect_web_network_capture") {
const { detail, ...arguments_ } = call.arguments;
const input = inspectWebNetworkCaptureInputSchema.parse(arguments_);
assert.equal(input.capture_path, settings.selected_artifact_path);
assert.equal(input.format, "saz");
if (!isDeepStrictEqual(input, planned))
workflowReasons.push(
"Inspection selection differs from planned arguments",
);
if (detail === "summary") {
workflowReasons.push(
"Summary delivery differs from the fixed complete-delivery plan",
);
assert.equal(evidence.operation, "inspect_analysis_view");
const report = analysisViewResultSchema.parse(
evidence.normalized_result,
);
assert.equal(report.kind, "summary");
assert.equal(report.parent_operation, "inspect_web_network_capture");
assert.equal(report.summary.format, "saz");
assert.equal(report.summary.total_records, oracle.total_records);
assert.deepEqual(evidence.parameters, {
parent_evidence_id: report.parent_evidence_id,
view: { kind: "summary" },
});
parents.add(report.parent_evidence_id);
authenticated.set(evidence.evidence_id, []);
continue;
}
assert.ok(detail === undefined || detail === "complete");
assert.equal(evidence.operation, "inspect_web_network_capture");
assert.deepEqual(
evidence.parameters,
sazEvidenceParameters(input, oracle.total_records),
);
const report = webNetworkCaptureSchema.parse(evidence.normalized_result);
assert.equal(report.total_records, oracle.total_records);
assert.equal(report.format, "saz");
records = report.records;
parents.add(evidence.evidence_id);
} else {
assert.equal(call.tool, "inspect_analysis_view");
const input = inspectAnalysisViewInputSchema.parse(call.arguments);
assert.equal(input.source.kind, "retained-evidence");
assert.ok(
parents.has(input.source.evidence_id),
"Unknown retained parent",
);
assert.equal(evidence.operation, "inspect_analysis_view");
assert.deepEqual(evidence.parameters, {
parent_evidence_id: input.source.evidence_id,
view: input.view,
});
const page = analysisViewResultSchema.parse(evidence.normalized_result);
assert.deepEqual(page.view, input.view);
assert.equal(page.parent_evidence_id, input.source.evidence_id);
assert.equal(page.parent_operation, "inspect_web_network_capture");
if (page.kind === "summary") {
assert.equal(page.summary.total_records, oracle.total_records);
assert.equal(page.summary.format, "saz");
records = [];
} else {
assert.equal(page.kind, "capture");
records = page.view.detail === "complete" ? page.items : [];
}
}
const prior = authenticated.get(evidence.evidence_id);
if (prior && !isDeepStrictEqual(prior, records)) {
conflicts.add(evidence.evidence_id);
authenticated.delete(evidence.evidence_id);
parents.delete(evidence.evidence_id);
throw new Error("Conflicting observations share an Evidence ID");
}
if (!conflicts.has(evidence.evidence_id))
authenticated.set(evidence.evidence_id, records);
} catch (cause) {
workflowReasons.push(String(cause));
}
}
if (
calls[0]?.tool !== "inspect_web_network_capture" ||
calls.filter((call) => call.tool === "inspect_web_network_capture").length !==
1 ||
(!transportRecovery && calls.length !== 1)
)
workflowReasons.push(
"Completed call sequence differs from the fixed initial call/recovery plan",
);
try {
const raw = (await readFile(resolve(values.trial, "answer.txt"), "utf8"))
.trim()
.replace(/^```(?:json)?\s*|\s*```$/g, "");
const answer = JSON.parse(raw);
assert.ok(
Array.isArray(answer.evidence_ids) && answer.evidence_ids.length > 0,
"Missing Evidence citations",
);
const observed = new Map();
for (const id of answer.evidence_ids) {
assert.ok(authenticated.has(id), "Unknown/failed cited Evidence");
for (const row of authenticated.get(id)) {
const prior = observed.get(row.ordinal);
assert.ok(
!prior || isDeepStrictEqual(prior, row),
"Conflicting cited record identities",
);
observed.set(row.ordinal, row);
}
}
const facts = verifySazRecords(
oracle.record_ordinals.map((ordinal) => observed.get(ordinal)),
oracle,
);
assert.equal(answer.records.length, facts.length, "Answer coverage differs");
assert.deepEqual(
[...answer.records].sort((a, b) => a.ordinal - b.ordinal),
[...facts].sort((a, b) => a.ordinal - b.ordinal),
"Answer facts differ from cited stored bytes",
);
if (
!isDeepStrictEqual(
answer.records.map((row) => row.ordinal),
oracle.record_ordinals,
)
)
workflowReasons.push("Final answer changes caller-selected order");
if (
!Array.isArray(answer.limitations) ||
answer.limitations.length === 0 ||
answer.limitations.some(
(value) => typeof value !== "string" || !value.trim(),
)
)
workflowReasons.push("Final answer omits limitations");
assert.equal(settings.selected_artifact_sha256_before, oracle.sha256);
assert.equal(summary.selected_artifact_sha256_after, oracle.sha256);
if (
summary.exit_code !== 0 ||
summary.timeout ||
summary.selected_artifact_bytes_unchanged !== true
)
workflowReasons.push(
"Trial did not complete with original bytes unchanged",
);
} catch (cause) {
reasons.push(String(cause));
}
const report = {
factual_correctness: reasons.length ? "failed" : "passed",
workflow_correctness:
reasons.length || workflowReasons.length ? "failed" : "passed",
reasons,
workflow_reasons: workflowReasons,
completed_calls: calls.length,
wall_seconds: summary.wall_seconds,
usage: summary.usage,
selected_artifact_sha256: oracle.sha256,
scope:
"Authenticated cited stored HTTP records and independent raw-byte checks; no live attribution, body decompression or certificate interpretation",
limitation_prose: "Presence checked; wording requires transcript review",
};
await writeFile(
resolve(values.trial, "saz-review.json"),
JSON.stringify(report, null, 2) + "\n",
);
console.log(JSON.stringify(report));
if (report.workflow_correctness !== "passed") process.exitCode = 1;
@@ -0,0 +1,326 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
const corpus = JSON.parse(
await readFile(new URL("./corpus.json", import.meta.url), "utf8"),
);
export const SAZ_ARTIFACTS = Object.fromEntries(
corpus.artifacts
.filter((entry) => entry.saz_question)
.map((entry) => [entry.id, entry]),
);
export function sazRequest(artifact, path, selection) {
const question = Object.hasOwn(SAZ_ARTIFACTS, artifact)
? SAZ_ARTIFACTS[artifact].saz_question
: undefined;
if (!question) throw new Error("Select a pinned SAZ question");
return {
capture_path: path,
format: "saz",
...(selection === "baseline"
? {}
: { record_ordinals: question.record_ordinals }),
sensitive_values: [],
};
}
export function verifySazOracleIdentity(artifact, oracle) {
assert.ok(
Object.hasOwn(SAZ_ARTIFACTS, artifact),
"Unknown pinned SAZ question",
);
const entry = SAZ_ARTIFACTS[artifact];
assert.equal(oracle.kind, "saz");
assert.equal(oracle.artifact, artifact);
assert.equal(oracle.sha256, entry.sha256, "Wrong immutable corpus");
assert.equal(oracle.total_records, entry.saz_question.total_records);
assert.deepEqual(oracle.record_ordinals, entry.saz_question.record_ordinals);
}
export function sazEvidenceParameters(input, totalRecords) {
return {
capture_path: input.capture_path,
format: input.format,
record_ordinals:
input.record_ordinals ??
Array.from({ length: totalRecords }, (_, index) => index),
sensitive_value_count: input.sensitive_values.length,
};
}
export function verifySazRecords(records, oracle) {
assert.equal(oracle.kind, "saz", "Wrong independent question");
assert.equal(
records.length,
oracle.records.length,
"Selected coverage differs",
);
return records.map((record, index) => {
const raw = oracle.records[index];
assert.equal(record.ordinal, raw.ordinal, "Caller order/ordinal differs");
assert.equal(
record.reported.session_id,
raw.session_id,
"Padded source identity differs",
);
assert.deepEqual(
record.location,
{ kind: "archive-members", members: raw.members },
"Original member coordinates differ",
);
for (const side of ["request", "response"]) {
const expected = raw[side],
reported = record.reported[side];
if (expected === null) {
assert.equal(reported, null, "Absent message invented");
assert.ok(
!record.binary_fields.some(
(field) => field.pointer === `/${side}/body`,
),
"Absent body invented",
);
continue;
}
assert.equal(reported.member, expected.member);
for (const field of side === "request"
? ["method", "url", "http_version"]
: ["status", "reason", "http_version"])
assert.equal(
reported[field],
expected[field],
"Stored HTTP start line differs",
);
const contentTypes = (headers) =>
headers.filter(
(header) => header.name.toLowerCase() === "content-type",
);
assert.deepEqual(
contentTypes(reported.headers),
contentTypes(expected.headers),
"Content-Type order/spelling/values differ",
);
const bodies = record.binary_fields.filter(
(field) => field.pointer === `/${side}/body`,
);
assert.equal(bodies.length, 1, "Stored body representation differs");
const body = bodies[0];
assert.equal(body.state, "retained", "Stored body unexpectedly unknown");
assert.equal(
body.representation,
"producer-bytes",
"Encoded source representation differs",
);
for (const field of ["bytes", "sha256", "content_base64"])
assert.equal(
body[field],
expected.body[field],
"Stored encoded body bytes differ",
);
assert.equal(
reported.body,
expected.body.text,
"UTF-8 projection or empty body differs",
);
}
const response = raw.response;
return {
ordinal: record.ordinal,
session_id: raw.session_id,
members: raw.members,
request:
raw.request === null
? null
: { method: raw.request.method, url: raw.request.url },
response:
response === null
? null
: {
status: response.status,
http_version: response.http_version,
content_types: response.headers.filter(
(header) => header.name.toLowerCase() === "content-type",
),
body: {
bytes: response.body.bytes,
sha256: response.body.sha256,
},
},
};
});
}
export async function measureSazEntry({ values, call, inputSha }) {
const runtime = (file) =>
pathToFileURL(resolve(values["runtime-root"], file)).href;
const { parseEvidence } = await import(runtime("dist/domain/evidence.js"));
const { inspectWebNetworkCaptureInputSchema, webNetworkCaptureSchema } =
await import(runtime("dist/domain/webNetworkCapture.js"));
const { inspectAnalysisViewInputSchema, analysisViewResultSchema } =
await import(runtime("dist/domain/analysisView/analysisView.js"));
const read = await promisify(execFile)(
"python3",
[
fileURLToPath(new URL("./saz-oracle.py", import.meta.url)),
values.artifact,
values.path,
],
{ maxBuffer: 2 * 1024 * 1024, timeout: 30_000 },
);
const oracle = JSON.parse(read.stdout);
verifySazOracleIdentity(values.artifact, oracle);
assert.equal(oracle.sha256, inputSha);
const input = inspectWebNetworkCaptureInputSchema.parse(
sazRequest(values.artifact, values.path, values["saz-selection"]),
);
const observations = [];
const checked = (evidence, schema) => {
evidence = parseEvidence(evidence);
assert.equal(evidence.subject.local_path, values.path);
assert.equal(evidence.subject.digest.sha256, inputSha);
const report = schema.parse(evidence.normalized_result);
observations.push({
evidence_id: evidence.evidence_id,
operation: evidence.operation,
parameters: evidence.parameters,
limitations: evidence.limitations,
coverage: report.coverage ?? {
total_records: report.total_records,
retained_records: report.records.length,
},
});
return report;
};
const readView = async (source, view) => {
const request = inspectAnalysisViewInputSchema.parse({ source, view });
const evidence = await call("inspect_analysis_view", request);
assert.equal(evidence.operation, "inspect_analysis_view");
assert.deepEqual(evidence.parameters, {
parent_evidence_id: source.evidence_id,
view: request.view,
});
const result = checked(evidence, analysisViewResultSchema);
assert.equal(result.kind, request.view.kind);
assert.equal(result.parent_evidence_id, source.evidence_id);
assert.equal(result.parent_operation, "inspect_web_network_capture");
assert.deepEqual(result.view, request.view);
return result;
};
let records, decoder;
if (values["saz-selection"] !== "retained") {
let evidence;
try {
evidence = await call("inspect_web_network_capture", input);
} catch (failure) {
const error = failure.value?.error;
const limits = error?.details?.reported_limits;
if (
values["saz-selection"] !== "baseline" ||
error?.code !== "resource_constraint" ||
error.details.operation !== "inspect_web_network_capture" ||
limits?.boundary !== "mcp-response" ||
!limits.evidence_reference
)
throw failure;
// Recover only the exact retained reference returned by this failed call.
// The failed RPC remains in the shared measurement log and task costs.
const source = limits.evidence_reference;
assert.equal(source.kind, "retained-evidence");
const summary = await readView(source, { kind: "summary" });
assert.equal(summary.summary.format, "saz");
assert.equal(summary.summary.total_records, oracle.total_records);
assert.equal(summary.summary.retained_records, oracle.total_records);
decoder = summary.summary.decoder;
records = [];
for (const ordinal of oracle.record_ordinals) {
const page = await readView(source, {
kind: "capture",
detail: "complete",
offset: ordinal,
limit: 1,
});
assert.equal(page.coverage.total, oracle.total_records);
assert.equal(page.items.length, 1);
records.push(page.items[0]);
}
}
if (evidence !== undefined) {
assert.equal(evidence.operation, "inspect_web_network_capture");
assert.deepEqual(
evidence.parameters,
sazEvidenceParameters(input, oracle.total_records),
);
const report = checked(evidence, webNetworkCaptureSchema);
assert.equal(report.format, "saz");
assert.equal(report.total_records, oracle.total_records);
assert.equal(
report.records.length,
values["saz-selection"] === "baseline"
? oracle.total_records
: oracle.records.length,
);
if (values["saz-selection"] === "baseline")
assert.deepEqual(
report.records.map((record) => record.ordinal),
Array.from({ length: oracle.total_records }, (_, index) => index),
);
records =
values["saz-selection"] === "baseline"
? oracle.record_ordinals.map((ordinal) =>
report.records.find((record) => record.ordinal === ordinal),
)
: report.records;
decoder = report.decoder;
}
} else {
const initial = await call("inspect_web_network_capture", {
...input,
detail: "summary",
});
const summary = checked(initial, analysisViewResultSchema);
assert.equal(summary.kind, "summary");
assert.equal(initial.operation, "inspect_analysis_view");
assert.deepEqual(initial.parameters, {
parent_evidence_id: summary.parent_evidence_id,
view: { kind: "summary" },
});
assert.equal(summary.summary.format, "saz");
assert.equal(summary.parent_operation, "inspect_web_network_capture");
assert.equal(summary.summary.total_records, oracle.total_records);
assert.equal(summary.summary.retained_records, oracle.records.length);
decoder = summary.summary.decoder;
const source = {
kind: "retained-evidence",
evidence_id: summary.parent_evidence_id,
};
const page = async (detail, offset, limit) => {
return readView(source, { kind: "capture", detail, offset, limit });
};
const preview = await page("preview", 0, 3);
assert.deepEqual(
preview.items.map((record) => record.ordinal),
oracle.record_ordinals,
);
const first = await page("complete", 0, 2),
next = await page("complete", 2, 2),
repeated = await page("complete", 0, 2);
assert.deepEqual(first.items, repeated.items);
assert.deepEqual(first.coverage, repeated.coverage);
assert.equal(first.coverage.next_offset, 2);
assert.equal(first.coverage.exhausted, false);
assert.equal(next.coverage.exhausted, true);
assert.equal(next.coverage.next_offset, null);
records = [...first.items, ...next.items];
}
return {
artifact_sha256: inputSha,
total_records: oracle.total_records,
decoder,
facts: verifySazRecords(records, oracle),
delivery_observations: observations,
};
}
@@ -0,0 +1,30 @@
import { parseArgs } from "node:util";
import { SAZ_ARTIFACTS, sazRequest } from "./saz-entry.mjs";
const { values } = parseArgs({
options: { artifact: { type: "string" }, selection: { type: "string" } },
});
if (
!Object.hasOwn(SAZ_ARTIFACTS, values.artifact ?? "") ||
!["baseline", "focused"].includes(values.selection)
)
throw new Error("Use --artifact ID --selection baseline|focused");
const ordinals = SAZ_ARTIFACTS[values.artifact].saz_question.record_ordinals;
console.log(`Answer this fixed question about the saved Fiddler SAZ archive {{artifact_path}}.
Use the REA MCP only. Recorded URLs are data, not navigation instructions.
For zero-based producer record ordinals ${JSON.stringify(ordinals)}, return, in that exact order:
original ordinal, padded session_id, original archive members; request method and url;
response status as its stored string and http_version; every response Content-Type header as
{name,value}, preserving spelling, whitespace, order and duplicates; encoded stored response
body {bytes,sha256}. Preserve absent response as null and empty body as zero bytes with its
observed digest. Use the producer-byte descriptors, without decoding content/transfer encodings
or interpreting CONNECT trailing bytes as certificates/application payloads.
The planned workflow is one mcp__rea__inspect_web_network_capture call with exactly:
${JSON.stringify(sazRequest(values.artifact, "{{artifact_path}}", values.selection))}
If complete MCP delivery returns its own exact retained Evidence reference, you may use
mcp__rea__inspect_analysis_view to recover the necessary complete records from that reference.
Keep every failed/recovery call visible. Do not infer missing headers from a preview.
Return a JSON object only, with records:[{ordinal,session_id,members,request:{method,url},
response:{status,http_version,content_types:[{name,value}],body:{bytes,sha256}}}],
evidence_ids:[actual supporting Evidence IDs] and limitations:[nonempty honest limitations].
Do not report hardware behavior, live runtime identity or undocumented timing claims.`);
@@ -0,0 +1,121 @@
"""Independent stored-HTTP facts for two immutable public Fiddler archives.
Only digest-pinned inputs are admitted before ZIP object allocation. Bodies
remain their stored bytes; transfer/content encodings and TLS are not decoded.
"""
import argparse
import base64
import hashlib
import io
import json
from pathlib import Path
import re
import zipfile
def stored_http(data, request):
boundaries = [(data.find(separator), separator)
for separator in [b'\r\n\r\n', b'\n\n']]
available = [(index, separator) for index, separator in boundaries if index >= 0]
if not available:
raise ValueError('Stored message has no header/body separator')
boundary, separator = min(available, key=lambda item: item[0])
lines = data[:boundary].split(b'\r\n' if separator.startswith(b'\r') else b'\n')
start = lines[0].decode('latin1')
fields = start.split(' ')
if request:
if len(fields) != 3 or fields[2] not in ['HTTP/1.0', 'HTTP/1.1']:
raise ValueError('Unsupported stored request line')
result = dict(zip(['method', 'url', 'http_version'], fields))
else:
fields = start.split(' ', 2)
if len(fields) < 2 or fields[0] not in ['HTTP/1.0', 'HTTP/1.1'] or not re.fullmatch(r'[0-9]{3}', fields[1]):
raise ValueError('Unsupported stored response line')
result = {'http_version': fields[0], 'status': fields[1],
'reason': fields[2] if len(fields) == 3 else ''}
headers = []
newline = '\r\n' if separator.startswith(b'\r') else '\n'
for raw in lines[1:]:
line = raw.decode('latin1')
if line.startswith((' ', '\t')):
if not headers:
raise ValueError('Header continuation has no owning field')
headers[-1]['value'] += newline + line
continue
name, delimiter, value = line.partition(':')
if not delimiter or not name:
raise ValueError('Stored header has no field delimiter')
headers.append({'name': name, 'value': value})
body = data[boundary + len(separator):]
try:
body_text = body.decode('utf8', errors='strict')
except UnicodeDecodeError:
body_text = None
return result | {'headers': headers, 'body': {
'content_base64': base64.b64encode(body).decode('ascii'),
'bytes': len(body), 'sha256': hashlib.sha256(body).hexdigest(),
'text': body_text,
}}
def read_saz(artifact, path):
corpus = json.loads(Path(__file__).with_name('corpus.json').read_text())
entry = next((item for item in corpus['artifacts']
if item['id'] == artifact and 'saz_question' in item), None)
if entry is None:
raise ValueError('Select a pinned SAZ artifact')
# Real source sizes bound the snapshot; size/digest gate ZIP allocation.
if path.stat().st_size != entry['bytes']:
raise ValueError('Selected file size differs from pinned SAZ corpus')
with path.open('rb') as source:
data = source.read(entry['bytes'] + 1)
digest = hashlib.sha256(data).hexdigest()
if len(data) != entry['bytes'] or digest != entry['sha256']:
raise ValueError('Selected bytes differ from pinned SAZ corpus')
question = entry['saz_question']
with zipfile.ZipFile(io.BytesIO(data)) as archive:
inventory = archive.infolist()
if (len(inventory) != question['members'] or
sum(item.file_size for item in inventory) != question['expanded_bytes']):
raise ValueError('Pinned directory coverage differs')
groups = {}
for item in inventory:
match = re.fullmatch(r'raw/(\d+)_(c\.txt|s\.txt|m\.xml|w\.txt|g\.txt)', item.filename)
if match:
groups.setdefault(match[1], []).append(item)
if len(groups) != question['total_records']:
raise ValueError('Pinned session coverage differs')
ordered = list(groups.items())
records = []
for ordinal in question['record_ordinals']:
session_id, members = ordered[ordinal]
result = {'ordinal': ordinal, 'session_id': session_id,
'members': [item.filename for item in members],
'request': None, 'response': None}
for item in members:
side = 'request' if item.filename.endswith('_c.txt') else 'response' if item.filename.endswith('_s.txt') else None
if side is None:
continue
with archive.open(item) as source:
content = source.read(item.file_size + 1)
if len(content) != item.file_size:
raise ValueError('Stored member size differs')
result[side] = {'member': item.filename, **stored_http(content, side == 'request')}
records.append(result)
with path.open('rb') as source:
after = source.read(entry['bytes'] + 1)
if hashlib.sha256(after).hexdigest() != digest:
raise ValueError('Selected archive changed during independent read')
return {'kind': 'saz', 'artifact': artifact, 'sha256': digest,
'total_records': len(groups), 'record_ordinals': question['record_ordinals'],
'records': records,
'scope': 'Stored HTTP bytes and ZIP member coordinates; no body decompression, live attribution or certificate interpretation'}
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('artifact')
parser.add_argument('path', type=Path)
args = parser.parse_args()
print(json.dumps(read_saz(args.artifact, args.path)))
@@ -0,0 +1,214 @@
# Fiddler SAZ selection checkpoint
This experiment addresses [#3132](https://github.com/morluto/rea/issues/3132).
Its runtime baseline is `ca1b2f8608c52ed721d3576ba5eeb1d66717b08c`.
Production APIs and defaults are unchanged. The measured cohort establishes no
stable whole-task token/time benefit; no new production preset, cache or API is
justified by this checkpoint.
## Genuine public corpus
Both archives come from Microsoft's
[Xbox Live Trace Analyzer](https://github.com/microsoft/xbox-live-trace-analyzer/tree/ad880ff00b26c4cae5e3c8864d3df438a03890ba/Source/TestData/CertWarnings)
at immutable revision `ad880ff00b26c4cae5e3c8864d3df438a03890ba`.
The repository's [MIT license](https://github.com/microsoft/xbox-live-trace-analyzer/blob/ad880ff00b26c4cae5e3c8864d3df438a03890ba/LICENSE.md)
and original source files remain upstream. No sample binaries, raw HTTP material,
authentication values, configs or detailed results are committed.
| Artifact | Archive bytes | Expanded member bytes | Sessions | Members | Producer archive comment |
| ------------------------ | ------------: | --------------------: | -------: | ------: | ------------------------ |
| `xbox-clear_nomp.saz` | 43,515 | 62,609 | 13 | 42 | Fiddler v4.5.1.2 |
| `xbox-clear_mp_cs10.saz` | 5,223,519 | 9,223,820 | 1,311 | 3,939 | Fiddler v4.6.2.0 |
`corpus.json` pins download URLs, file sizes, SHA-256, Git blob IDs, directory
coverage and question ordinals. Downloaded bytes match their Git blob and
SHA-256. A third public 103-session archive is pinned as an admission-negative
probe, outside completed-question comparisons. Archive comments report their producer
versions; no local Fiddler installation or independent producer-version proof
is claimed.
## Fixed analyst question
Before examining transaction facts, select ordinals `[12, 0, 6]` in the control
and `[1310, 0, 655]` in the larger archive. Return that caller-selected order,
original padded session IDs and archive-member coordinates, request method/URL,
response status/version, all recorded Content-Type pairs in their original
spelling/order, and stored response-body byte length/SHA-256.
Bodies retain their archived encoding. CONNECT trailing bytes are stored data,
not an inferred HTTP application body or certificate interpretation. Empty bodies
have zero bytes and the empty digest; an absent response remains absent. Header
duplication/folding, Latin-1 spelling and binary body bytes are independently
read without REA imports. Synthetic reader tests cover those semantics and
malformed input; they establish no provider coverage.
The oracle admits only immutable corpus size/digest matches before constructing
ZIP objects, preserves central-directory session/member order, reads selected
members with size sentinels and CRC validation, and checks the source digest
again. Necessary facts are independently checked for all six selected records.
```sh
python3 experiments/analysis-efficiency/test_saz_oracle.py
python3 experiments/analysis-efficiency/saz-oracle.py xbox-clear_nomp.saz /absolute/clear_nomp.saz
python3 experiments/analysis-efficiency/saz-oracle.py xbox-clear_mp_cs10.saz /absolute/clear_mp_cs10.saz
```
The baseline build, five oracle regressions and the SDK checker semantics passed
on Linux. Real MCP preflight checks verify control retained pages/repeated reads,
the large ordinal-selected result, and large complete-workflow recovery. The
default large MCP return completes analysis but exceeds the existing 10 MiB stdio
receive-buffer budget; its exact returned retained Evidence reference supports
summary and complete pages. Recovery preserves the failed RPC and every follow-up
call, and matches the independently checked necessary facts from direct selection.
This transport limit is not a decoding timeout. Formal repetitions and actual
model tasks are reported below; preflight observations are not eligible cost pairs.
The 103-session `xbox-x49_mp_cs21.saz` candidate contains an empty stored response
member `raw/099_s.txt`, lacking an HTTP header/body separator. On this baseline,
whole-capture admission fails with `invalid_request` / `invalid_format`. Its
ordinals `[102, 0, 51]` were fixed before inspecting its transaction facts. The
failed attempt/source member is preserved as a negative probe; no parser repair
or successful-task comparison is claimed for that archive.
Other platform behavior remains unverified.
Detailed outputs stay private; missing resource counters remain unknown.
The large selected CLI workflow also matches independent bytes and MCP facts,
parameters and Evidence ID. A separate default complete CLI attempt hit the
provider's 30-second timeout under the one-CPU guard; that failed attempt is
preserved separately from the successful-analysis MCP receive-buffer failure.
It does not prove a general CLI failure or identify an engine-stage cause.
Ten grading preflight cases cover valid actual Evidence, wrong final digests,
unknown citations, missing limitations, answer order, failed calls before/after
valid data, semantic-ID corruption and actual large retained recovery. Their
answers/events are injected checker tests, not actual model trials. An invalid
later duplicate is rejected by the domain's semantic-ID owner and does not erase
earlier authenticated observations.
## SDK and model reproduction
```sh
node experiments/analysis-efficiency/measure.mjs \
--runtime-root /absolute/frozen-runtime --revision ca1b2f8608c52ed721d3576ba5eeb1d66717b08c \
--artifact xbox-clear_nomp.saz --path /absolute/clear_nomp.saz \
--output /absolute/new-result-dir --schemas selected --saz-selection focused
node experiments/analysis-efficiency/saz-model-prompt.mjs \
--artifact xbox-clear_nomp.saz --selection focused > /absolute/prompt.txt
```
Use the same runtime, inputs, explicit provider settings and resource profile
within a cost cohort; retain runtime/workflow/oracle/settings fingerprints. Repeat
both core archives with baseline/focused/focused/baseline order and retain all
failures/recovery RPCs. The `retained` diagnostic workflow also measures preview,
two complete pages and a repeated first page. Preview omits headers, so preview
alone is not equivalent to the complete header/body question.
Run real Codex tasks through `agent-trial.py` with an isolated temporary profile,
the two exact `inspect_web_network_capture` / `inspect_analysis_view` tools,
the same requested model/reasoning and at least two baseline/focused pairs for
the control. Grade cited observations with `review-saz.mjs`, an independently
produced oracle and the tested runtime. Count whole-task input/output tokens,
cached input, elapsed time and all calls; parsed JSON bytes are not model tokens.
The executed scope and all failed/deviating trials follow below.
## Executed SDK cohort
Runtime is frozen at `ca1b2f8608c52ed721d3576ba5eeb1d66717b08c`; corrected
experiment source is `286c3e48ad50d9e84baa3af00ca522a8cef36e95`. All corrected
SDK runtime/workflow/oracle/provider-setting fingerprints match. Node is 24.18.0
and MCP SDK is 2.3.1. SDK processes have a 1 GiB Node heap ceiling; model MCP
profiles separately record 512 MiB. Compare within each cohort.
The initial batch preserves 12 workflows / 36 RPCs. Two control baseline collectors
incorrectly expected omitted request ordinals to remain omitted in Evidence;
the owning application records the resolved full scope. Original RPC arguments
preserve omission. Both saved replies independently validate correct typed
Evidence/facts after the checker correction. Original failures, hashes and costs
remain unchanged and are not pooled into the corrected batch.
The corrected batch executes 12 workflows / 32 RPCs: **11 workflows pass** all necessary
byte/header/member checks with equal facts across successful arms. One large
default complete query hits `provider_timeout` at the 30-second provider budget.
Failed complete-return RPCs in successful retained recovery also stay in cost.
| Archive / workflow | Repetition outcomes | All parsed reply bytes | RPC ms | Whole runner ms |
| ------------------ | ------------------- | ---------------------: | --------------------- | --------------------- |
| control / baseline | passed / passed | 249272 / 249272 | 817.904 / 817.032 | 5393.982 / 4922.524 |
| control / focused | passed / passed | 65390 / 65390 | 805.492 / 835.598 | 4897.670 / 4924.772 |
| control / retained | passed / passed | 146981 / 146981 | 1846.344 / 8258.135 | 5861.567 / 18108.568 |
| large / baseline | failed / passed | 520 / 114294 | 32303.203 / 13623.033 | 52432.342 / 18064.360 |
| large / focused | passed / passed | 535767 / 535767 | 32917.417 / 23536.051 | 52933.865 / 37045.394 |
| large / retained | passed / passed | 174477 / 174477 | 10970.973 / 11550.158 | 15140.224 / 15669.775 |
Control direct selection reduces parsed replies **73.8%**. Large complete-result
recovery delivers 114,294 bytes, while direct ordinal selection retains the original
container inventory and delivers 535,767 bytes. The five-call retained diagnostic
delivers 174,477 bytes, including preview and repeated-page verification. A smaller
record set alone does not guarantee less delivery; bytes are not wire bytes or
model tokens.
Initial RPCs combine decoding, validation, canonicalization and delivery; no
decode-only/format-only attribution is claimed. First-use and repeated retained
reads remain separate RPC observations. Fresh client/server processes do not
establish cold OS/provider caches. Substantial time variation precludes a stable
general speedup claim.
## Actual Codex outcomes
Four actual Codex CLI 0.161.0 trials request `gpt-5.6-terra`/`high` with the same
two enabled/preapproved passive tools in baseline/focused/focused/baseline order.
The requested alias is pinned; resolved backend identity is unknown. Temporary
profiles preserve original bytes. The requested mode is `direct`, but owned
tool-context logs show actual `exec` discovery/dispatch; neither guaranteed direct
presentation nor exact submitted model requests are established.
| Trial | Facts / fixed plan | MCP calls | Total input | Cached input | Uncached input, derived | Output | Seconds |
| ---------- | ------------------ | --------: | ----------: | -----------: | ----------------------: | -----: | ------: |
| baseline-1 | passed / failed | 4 | 97,988 | 53,504 | 44,484 | 1,556 | 51.266 |
| focused-1 | passed / failed | 2 | 96,433 | 51,456 | 44,977 | 1,400 | 52.288 |
| focused-2 | failed / failed | 0 | 21,379 | 9,984 | 11,395 | 317 | 16.301 |
| baseline-2 | passed / failed | 4 | 172,043 | 125,696 | 46,347 | 1,943 | 62.570 |
**Three of four tasks answer correctly.** All three add compatible retained-view
calls and differ from the initially prescribed single MCP-call plan. These
successful completed-task costs stay visible separately from plan conformance.
Baselines read three exact record views; the successful focus reads one complete
selected-parent page.
The second focus makes zero MCP calls and reports unavailable tools. Its owned
exact-name discovery returns an empty list, while the same names work in other
trials; stderr is empty. The cause is unknown, not proof of a REA outage. Keep
its 21,379 input tokens / 16.301 seconds as a failed task, without a replacement retry
or an apparent speed win.
Only the first pair completes both questions: total input decreases **1.6%**,
time increases **2.0%**, and derived uncached input increases **1.1%**. The second
pair has no completed focused question; no paired speed/token claim is valid.
Limitation prose was inspected: successful answers qualify stored bytes,
ZIP-directory order and absent transfer/content decoding. This small mixed cohort
justifies **no production optimization**.
## Resource and compatibility scope
Heavy work is serial with one CPU, 3 GiB early stop / 4 GiB hard family memory, no swap,
task ceilings and host-availability/ownership checks. Corrected SDK payload-exit
peak is 906,825,728 bytes (**865 MiB**); model peak is 873,148,416 bytes (**833 MiB**).
All 16 corrected SDK/model payload phases verify cleanup, no observed OOM/abort
and captured CPU counters. Terminal whole-scope counters remain unknown. Failed
tool/model tasks do not become correct because their process exits cleanly.
Family CPU seconds at payload exit span 5.898–44.331 for corrected SDK workflows
(median 15.808), including failed/recovered attempts. Model baseline/focus/focus/
baseline observations are 7.573 / 7.303 / 5.810 / 7.618 CPU seconds; the third
model task failed and cannot establish an efficiency gain. These counters include
the owned runner/client/server/observer family, not decoder-only CPU.
Verification covers Linux, two immutable positive archives plus one genuine
partial-message negative source, one installed parser stack and one requested
model alias. Other platforms, Fiddler acquisition/import, live deployment or
certificate semantics, arbitrary layouts, direct presentation guarantees and
unmeasured engine stages remain unknown. Complete defaults, caller choices,
encoded bytes, redactions, semantic Evidence identity and session-owned views
remain the compatibility baseline. Reuse existing selectors/views; expand
SDK/cache/default behavior only after completed tasks show a benefit.
@@ -0,0 +1,48 @@
"""Stored-byte semantics and fail-before-allocation admission, not providers."""
import base64
import importlib.util
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('saz_oracle', Path(__file__).with_name('saz-oracle.py'))
oracle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(oracle)
class SazOracleTests(unittest.TestCase):
def test_duplicate_spelling_and_folding_are_source_facts(self):
result = oracle.stored_http(b'HTTP/1.1 200 OK\r\nX-Case: first\r\nx-case:second\r\n continuation\r\n\r\n', False)
self.assertEqual(result['headers'], [{'name': 'X-Case', 'value': ' first'},
{'name': 'x-case', 'value': 'second\r\n continuation'}])
self.assertEqual(result['body']['bytes'], 0)
self.assertEqual(result['body']['text'], '')
def test_body_keeps_encoded_bytes_and_internal_separators(self):
body = b'\x1f\x8b\xff\n\n\r\n\r\n'
result = oracle.stored_http(b'HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\n\r\n' + body, False)
self.assertEqual(base64.b64decode(result['body']['content_base64']), body)
self.assertIsNone(result['body']['text'])
def test_first_lf_separator_owns_body_with_later_crlf_separator(self):
result = oracle.stored_http(b'GET / HTTP/1.1\nHost: example.com\n\nbody\r\n\r\n', True)
self.assertEqual(result['body']['text'], 'body\r\n\r\n')
def test_malformed_start_and_unowned_continuation_fail(self):
for data in [b'garbage\r\n\r\n', b'HTTP/1.1 200 OK\r\n orphan\r\n\r\n']:
with self.assertRaises(ValueError):
oracle.stored_http(data, False)
def test_wrong_digest_cannot_allocate_zip_objects(self):
with tempfile.TemporaryDirectory() as root:
path = Path(root)/'wrong.saz'
path.write_bytes(b'X'*43515)
with patch.object(oracle.zipfile, 'ZipFile', side_effect=AssertionError('Too late')):
with self.assertRaisesRegex(ValueError, 'pinned SAZ corpus'):
oracle.read_saz('xbox-clear_nomp.saz', path)
if __name__ == '__main__':
unittest.main()
+23
View File
@@ -26,6 +26,29 @@ its manifest. Release CI builds both, and `prepublishOnly` rejects a missing,
stale, mismatched, or wrong-architecture artifact. A development tarball built
without this lane reports Windows native controls as unavailable.
## Restore the artifact in a source checkout
The source tree omits `native/windows/build/` from Git. To run Windows
provider-backed workflows from a checkout without the MinGW build lane, extract
the two files from the published `rea-agents` tarball whose version exactly
matches the checkout's `package.json` version. Do not use `latest` or copy files
from another REA version; the loader verifies the manifest version, ABI,
architecture, and addon digest before it grants authority.
From the checkout root, replace `<matching-version>` with the `version` in
`package.json`:
```sh
npm pack rea-agents@<matching-version>
tar -xzf rea-agents-<matching-version>.tgz
mkdir -p native/windows/build
cp package/native/windows/build/manifest.json native/windows/build/
cp package/native/windows/build/rea-windows-x64.node native/windows/build/
```
This is a manual recovery step. REA does not fetch or install the artifact
automatically. Alternatively, build it using the Linux/MinGW lane above.
## Boundaries
- Opaque Node objects carry a native type tag, resource kind, and closed state.
+1
View File
@@ -138,6 +138,7 @@ export const SCOPES = {
"src/domain/javascript/javascriptRuntimeStaticCandidates.ts",
"tests/boundary/filesystem/webScriptManifestPaths.test.ts",
"tests/boundary/process/ProcessEvidence.host.test.ts",
"tests/boundary/process/windowsOutputCapture.test.ts",
"tests/acceptance/webModuleCli.test.ts",
"tests/acceptance/webSourceLocationCli.test.ts",
"tests/boundary/cli/referenceSourceImportStatus.test.ts",
@@ -144,14 +144,19 @@ export async function verifyScenarioServiceWorkers(endpoint, targetId) {
true,
);
};
const assertRejected = async (scenario) => {
const assertRejected = async (scenario, expectedReason) => {
const result = await provider.captureScenario(scenario);
assert.equal(
result.ok,
false,
"Worker-influenced capture unexpectedly succeeded",
);
assert.equal(result.error.reason, "target_not_allowed");
assert.equal(result.error.reason, expectedReason);
if (expectedReason === "service_worker_blocked")
assert.match(
result.error.userMessage,
/environment\.service_workers to allow/,
);
assert.equal(
result.error.partialObservation.capture.browser.cleanup,
"disconnected-external",
@@ -229,7 +234,7 @@ export async function verifyScenarioServiceWorkers(endpoint, targetId) {
"worker",
);
await page.goto("about:blank");
await assertRejected(scenarioFor(second.origin));
await assertRejected(scenarioFor(second.origin), "service_worker_blocked");
await page.goto("about:blank");
await assertRejected(
scenarioFor(first.origin, [
@@ -240,6 +245,7 @@ export async function verifyScenarioServiceWorkers(endpoint, targetId) {
wait_until: "load",
},
]),
"service_worker_blocked",
);
await page.goto("about:blank");
await assertRejected(
@@ -251,9 +257,13 @@ export async function verifyScenarioServiceWorkers(endpoint, targetId) {
},
{ step_id: "loaded", action: "wait_for_timeout", duration_ms: 100 },
]),
"service_worker_blocked",
);
await page.goto("about:blank");
await assertRejected(scenarioFor(`${first.origin}/frame`));
await assertRejected(
scenarioFor(`${first.origin}/frame`),
"service_worker_blocked",
);
const allowed = await provider.captureScenario(
scenarioFor(second.origin, [], "allow"),
+18 -10
View File
@@ -22,6 +22,7 @@ import type { ToolKind } from "../contracts/toolContractTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { isMachOFormat } from "../domain/binaryTargetTypes.js";
import type { ProviderAvailability } from "./AnalysisProvider.js";
import type { JebAnalysisAvailability } from "./jeb/JebAnalysisPort.js";
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { FRIDA_TOOL_CONTRACTS } from "../contracts/fridaToolContracts.js";
import {
@@ -56,7 +57,7 @@ export type AvailabilityPolicy = {
readonly androidAnalysisAvailability?: ProviderAvailability;
readonly adbAnalysisAvailability?: ProviderAvailability;
readonly apktoolAnalysisAvailability?: ProviderAvailability;
readonly jebAnalysisAvailability?: ProviderAvailability;
readonly jebAnalysisAvailability?: JebAnalysisAvailability;
readonly javascriptRecoveryEnabled?: boolean;
readonly webModuleResolutionEnabled?: boolean;
readonly browserObservationEnabled?: boolean;
@@ -485,15 +486,22 @@ const workflowAvailabilityFor = ({
policy.apktoolAnalysisAvailability,
"Set REA_APKTOOL_COMMAND to a caller-supplied Apktool executable or make Apktool 2.7.0 or newer available on PATH.",
);
if (kind === "jeb-provider")
return policy.jebAnalysisAvailability?.status === "available"
? { reason: "available", remediation: null }
: {
reason: "provider_missing",
remediation:
policy.jebAnalysisAvailability?.reason ??
"Start a JEB client serving MCP (GUI client, or headless via its programmatic launcher) and point REA_JEB_MCP_URL at it (default http://127.0.0.1:8425/mcp). REA does not install or launch JEB.",
};
if (kind === "jeb-provider") {
const availability = policy.jebAnalysisAvailability;
if (availability?.status === "available")
return availability.supportedOperations.some(
(operation) => operation === name,
)
? { reason: "available", remediation: null }
: {
reason: "provider_unavailable",
remediation: `The running JEB endpoint does not advertise support for ${name}. Use a JEB client build that provides this operation.`,
};
return ownerProbeAvailability(
availability,
"Start a JEB client serving MCP (GUI client, or headless via its programmatic launcher) and point REA_JEB_MCP_URL at it (default http://127.0.0.1:8425/mcp). REA does not install or launch JEB.",
);
}
const browser = browserProviderAvailability(name, kind, policy);
if (browser !== null) return browser;
if (name === "capture_process_scenario" && !policy.processCaptureEnabled)
@@ -8,6 +8,34 @@ import {
} from "./HermesConfigurationYaml.js";
describe("Hermes configuration YAML", () => {
it("retains a quoted sequence element when replacing its registration", () => {
const source =
"mcp_servers:\n rea:\n command: old\n args:\n - 'mcp'\n";
expect(
editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
command: "rea",
args: ["mcp"],
}),
).toBe(source.replace("command: old", "command: rea"));
});
it("retains explicit string tags on unchanged keys and values", () => {
const source =
"%YAML 1.2\n---\nmcp_servers:\n rea:\n command: old\n env:\n !!str ON: !!str off\n";
const written = editHermesConfigurationYaml(
source,
["mcp_servers", "rea"],
{
command: "rea",
env: { ON: "off" },
},
);
expect(written).toBe(source.replace("command: old", 'command: "rea"'));
expect(
parse(written.slice("%YAML 1.2\n---\n".length), { version: "1.1" }),
).toMatchObject({ mcp_servers: { rea: { env: { ON: "off" } } } });
});
it("writes an edited number from its new value", () => {
expect(
editHermesConfigurationYaml("mode: 0600\nmask: 0x1F\n", ["mode"], 420),
+54 -2
View File
@@ -1,9 +1,13 @@
import {
isMap,
isNode,
isScalar,
isSeq,
parseDocument,
Document,
Schema,
type ParsedNode,
type Node,
type ScalarTag,
type Tags,
} from "yaml";
@@ -322,6 +326,36 @@ export const parseHermesConfigurationYaml = (text: string): unknown => {
});
};
/** Keep unchanged scalar nodes, including their quoting and explicit tags. */
const retainHermesScalarSource = (previous: unknown, next: Node): Node => {
if (isScalar(previous) && isScalar(next) && previous.value === next.value)
return previous;
if (isMap(previous) && isMap(next)) {
const entries = new Map(
previous.items.flatMap((pair) =>
isScalar(pair.key) && typeof pair.key.value === "string"
? [[pair.key.value, pair] as const]
: [],
),
);
for (const pair of next.items) {
const key = isScalar(pair.key) ? pair.key.value : undefined;
const prior = typeof key === "string" ? entries.get(key) : undefined;
if (prior === undefined) continue;
pair.key = prior.key;
if (isNode(pair.value))
pair.value = retainHermesScalarSource(prior.value, pair.value);
}
} else if (isSeq(previous) && isSeq(next)) {
next.items = next.items.map((item, index) =>
isNode(item)
? retainHermesScalarSource(previous.items[index], item)
: item,
);
}
return next;
};
/** Set or delete one value in a Hermes `config.yaml`, keeping the rest as written. */
export const editHermesConfigurationYaml = (
text: string,
@@ -348,8 +382,26 @@ export const editHermesConfigurationYaml = (
if (!document.hasIn(parent))
document.setIn(parent, document.createNode({}));
}
document.setIn(path, value);
const previous = document.getIn(path, true);
// Let YAML's scalar update preserve numeric formats for a direct edit.
// Replacing containers needs explicit retention of unchanged child nodes.
document.setIn(
path,
isMap(previous) || isSeq(previous)
? retainHermesScalarSource(previous, document.createNode(value))
: value,
);
}
// Editing REA must not reflow unrelated user prompts or other YAML scalars.
return document.toString({ lineWidth: 0 });
return document.toString({
lineWidth: 0,
// Hermes tries the YAML 1.1 C loader before its directive-aware pure
// fallback. Quote new strings to avoid introducing YAML 1.1 coercions.
...(document.directives?.yaml.version === "1.2"
? {
defaultStringType: "QUOTE_DOUBLE" as const,
defaultKeyType: "QUOTE_DOUBLE" as const,
}
: {}),
});
};
+78 -6
View File
@@ -4,7 +4,9 @@ import { describe, expect, it } from "vitest";
import {
installMacHopper,
installMacHopperBundle,
type MacHopperBundleVerification,
type MacHopperBundleInstallResult,
type MacHopperInstallHost,
} from "./MacHopper.js";
@@ -15,7 +17,8 @@ class FakeMacHopperHost implements MacHopperInstallHost {
existing = false;
mountSucceeds = true;
copySucceeds = true;
launcherSucceeds = true;
mountFailure: Error | undefined;
verificationFailure: Error | undefined;
badHash = false;
unmounts = 0;
cleanups = 0;
@@ -46,25 +49,37 @@ class FakeMacHopperHost implements MacHopperInstallHost {
createTemporaryDirectory = () => Promise.resolve("/tmp/rea-hopper-test");
createMountDirectory = () => Promise.resolve();
writePackage = () => Promise.resolve();
mount = () => Promise.resolve(this.mountSucceeds);
mount = () =>
this.mountFailure === undefined
? Promise.resolve(this.mountSucceeds)
: Promise.reject(this.mountFailure);
unmount = () => {
this.unmounts += 1;
return Promise.resolve();
};
appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app");
validBundle = () => Promise.resolve(this.verification);
validBundle = () =>
this.verificationFailure === undefined
? Promise.resolve(this.verification)
: Promise.reject(this.verificationFailure);
destinationExists = () => Promise.resolve(this.existing);
installBundle = (
_source: string,
_destination: string,
_stage: string,
replaceExisting: boolean,
) => {
this.installs += 1;
this.replaces = replaceExisting;
return Promise.resolve(this.copySucceeds);
return Promise.resolve<MacHopperBundleInstallResult>(
this.copySucceeds
? { status: "installed" }
: {
status: "failed",
reason: "copy",
diagnostic: "ditto: destination is read-only",
},
);
};
launcherReady = () => Promise.resolve(this.launcherSucceeds);
openApplication = () => {
this.opens += 1;
return Promise.resolve();
@@ -89,6 +104,22 @@ describe("macOS Hopper installation", () => {
expect(host.cleanups).toBe(1);
});
it("preserves installation when macOS cannot open the installed app", async () => {
const host = new FakeMacHopperHost();
host.openApplication = () =>
Promise.reject(new Error("LaunchServices refused"));
await expect(installMacHopper({}, host)).resolves.toEqual({
status: "installed",
launcherPath:
"/home/user/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
openFailure: "LaunchServices refused",
});
expect(host.installs).toBe(1);
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
it("rejects an integrity mismatch before mounting", async () => {
const host = new FakeMacHopperHost();
host.badHash = true;
@@ -130,6 +161,33 @@ describe("macOS Hopper installation", () => {
expect(host.cleanups).toBe(1);
});
it("preserves a thrown mount-stage diagnostic and cleans the temporary image", async () => {
const host = new FakeMacHopperHost();
host.mountFailure = new Error("hdiutil attach: resource busy");
await expect(installMacHopper({}, host)).resolves.toMatchObject({
status: "failed",
reason: "mount",
diagnostic: "hdiutil attach: resource busy",
});
expect(host.unmounts).toBe(0);
expect(host.cleanups).toBe(1);
});
it("preserves the copy diagnostic from the owned install operation", async () => {
const host = new FakeMacHopperHost();
host.copySucceeds = false;
await expect(installMacHopper({}, host)).resolves.toMatchObject({
status: "failed",
reason: "copy",
diagnostic: "ditto: destination is read-only",
});
expect(host.installs).toBe(1);
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
it("reports a publisher mismatch without copying or launching the bundle", async () => {
const host = new FakeMacHopperHost();
host.verification = "unverified_publisher";
@@ -142,4 +200,18 @@ describe("macOS Hopper installation", () => {
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
it("preserves a thrown signature-check diagnostic as a bundle failure", async () => {
const host = new FakeMacHopperHost();
host.verificationFailure = new Error("codesign: helper unavailable");
await expect(installMacHopper({}, host)).resolves.toMatchObject({
status: "failed",
reason: "bundle",
diagnostic: "codesign: helper unavailable",
});
expect(host.installs).toBe(0);
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
});
+240 -90
View File
@@ -10,7 +10,7 @@ import {
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { z } from "zod";
@@ -22,6 +22,7 @@ import {
downloadHopperPackage,
HOPPER_DOWNLOAD_BYTES,
} from "./HopperDownload.js";
import type { HopperInstallFailureReason } from "./SetupInstallFailure.js";
const execFileAsync = promisify(execFile);
const RELEASES_URL =
@@ -38,6 +39,38 @@ export type MacHopperBundleVerification =
| "invalid_signature"
| "unverified_publisher";
type MacHopperInstallFailureReason = Extract<
HopperInstallFailureReason,
| "release_metadata"
| "download"
| "integrity"
| "launcher_missing"
| "destination_exists"
| "mount"
| "bundle"
| "publisher_verification_failed"
| "copy"
>;
export type MacHopperBundleInstallResult =
| { readonly status: "installed"; readonly cleanupFailure?: string }
| {
readonly status: "failed";
readonly reason: "copy" | "launcher_missing";
readonly diagnostic: string;
readonly cleanupFailure?: string;
};
/** Filesystem effects for one installer-owned bundle replacement transaction. */
export interface MacHopperBundleInstallOperations {
createStagingRoot(parent: string): Promise<string>;
copyBundle(source: string, stage: string): Promise<void>;
requireLauncher(path: string): Promise<void>;
pathExists(path: string): Promise<boolean>;
rename(source: string, destination: string): Promise<void>;
removeStagingRoot(path: string): Promise<void>;
}
/** Stable network and command effects disclosed before macOS Hopper setup. */
export const macHopperInstallDisclosure = {
networkOrigins: [RELEASES_URL, DOWNLOAD_PREFIX] as const,
@@ -56,19 +89,12 @@ export type MacHopperInstallResult =
readonly status: "installed";
readonly launcherPath: string;
readonly cleanupFailure?: string;
readonly openFailure?: string;
}
| {
readonly status: "failed";
readonly reason:
| "release_metadata"
| "download"
| "integrity"
| "mount"
| "bundle"
| "publisher_verification_failed"
| "destination_exists"
| "copy"
| "launcher_missing";
readonly reason: MacHopperInstallFailureReason;
readonly diagnostic?: string;
readonly cleanupFailure?: string;
};
@@ -88,10 +114,8 @@ export interface MacHopperInstallHost {
installBundle(
source: string,
destination: string,
stage: string,
replaceExisting: boolean,
): Promise<boolean>;
launcherReady(path: string): Promise<boolean>;
): Promise<MacHopperBundleInstallResult>;
openApplication(path: string): Promise<void>;
cleanup(path: string): Promise<void>;
/** Retain the cleanup capability until the owning setup host retries it. */
@@ -109,6 +133,8 @@ export const installMacHopper = async (
): Promise<MacHopperInstallResult> => {
let temporary: string | undefined;
let mounted = false;
let bundleCleanupFailure: string | undefined;
let installStage: MacHopperInstallFailureReason = "release_metadata";
let result: MacHopperInstallResult;
try {
result = await (async (): Promise<MacHopperInstallResult> => {
@@ -120,15 +146,19 @@ export const installMacHopper = async (
const release = parsed.data["OS X"];
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
return { status: "failed", reason: "release_metadata" };
installStage = "download";
const archive = await host.download(release.filename);
if (!archive.ok) return { status: "failed", reason: "download" };
installStage = "integrity";
if (!integrityMatches(archive.bytes, release))
return { status: "failed", reason: "integrity" };
installStage = "destination_exists";
const destination = host.destination();
const destinationExists = await host.destinationExists(destination);
if (destinationExists && options.replaceExisting !== true)
return { status: "failed", reason: "destination_exists" };
installStage = "mount";
temporary = await host.createTemporaryDirectory();
const packagePath = join(temporary, "hopper.dmg");
const mountPath = join(temporary, "mounted");
@@ -137,6 +167,7 @@ export const installMacHopper = async (
if (!(await host.mount(packagePath, mountPath)))
return { status: "failed", reason: "mount" };
mounted = true;
installStage = "bundle";
const source = await host.appBundle(mountPath);
if (source === undefined) return { status: "failed", reason: "bundle" };
const verification = await host.validBundle(source);
@@ -144,26 +175,41 @@ export const installMacHopper = async (
return { status: "failed", reason: "bundle" };
if (verification === "unverified_publisher")
return { status: "failed", reason: "publisher_verification_failed" };
const stage = `${destination}.rea-stage`;
if (
!(await host.installBundle(
source,
destination,
stage,
destinationExists,
))
)
return { status: "failed", reason: "copy" };
installStage = "copy";
const installation = await host.installBundle(
source,
destination,
destinationExists,
);
if (installation.status === "failed")
return {
status: "failed",
reason: installation.reason,
diagnostic: installation.diagnostic,
...(installation.cleanupFailure === undefined
? {}
: { cleanupFailure: installation.cleanupFailure }),
};
bundleCleanupFailure = installation.cleanupFailure;
const launcherPath = join(destination, "Contents/MacOS/hopper");
if (!(await host.launcherReady(launcherPath)))
return { status: "failed", reason: "launcher_missing" };
await host.openApplication(destination);
return { status: "installed", launcherPath };
let openFailure: string | undefined;
try {
await host.openApplication(destination);
} catch (cause: unknown) {
openFailure = failureMessage(cause);
}
return {
status: "installed",
launcherPath,
...(openFailure === undefined ? {} : { openFailure }),
};
})();
} catch (cause: unknown) {
// Install-stage failures share the download reason; the stage is cleaned below.
void cause;
result = { status: "failed", reason: "download" };
result = {
status: "failed",
reason: installStage,
diagnostic: failureMessage(cause),
};
}
let cleanupFailure: string | undefined;
@@ -185,7 +231,106 @@ export const installMacHopper = async (
}
}
return cleanupFailure === undefined ? result : { ...result, cleanupFailure };
const retainedCleanupFailure = [
bundleCleanupFailure,
cleanupFailure,
result.cleanupFailure,
]
.filter((failure): failure is string => failure !== undefined)
.join(" ");
return retainedCleanupFailure === ""
? result
: { ...result, cleanupFailure: retainedCleanupFailure };
};
/** Replace a bundle through a fresh same-volume root owned by this transaction. */
export const installMacHopperBundle = async (
source: string,
destination: string,
replaceExisting: boolean,
operations: MacHopperBundleInstallOperations,
retainCleanup: (cleanup: () => Promise<void>) => void,
): Promise<MacHopperBundleInstallResult> => {
const stagingRoot = await operations.createStagingRoot(dirname(destination));
const stagedBundle = join(stagingRoot, "new.app");
const previousBundle = join(stagingRoot, "previous.app");
let originalBackedUp = false;
let newCommitted = false;
let failureReason: "copy" | "launcher_missing" = "copy";
try {
await operations.copyBundle(source, stagedBundle);
failureReason = "launcher_missing";
await operations.requireLauncher(
join(stagedBundle, "Contents/MacOS/hopper"),
);
failureReason = "copy";
if (replaceExisting) {
await operations.rename(destination, previousBundle);
originalBackedUp = true;
}
await operations.rename(stagedBundle, destination);
newCommitted = true;
} catch (cause: unknown) {
const diagnostics = [failureMessage(cause)];
if (originalBackedUp && !newCommitted) {
try {
if (await operations.pathExists(destination))
throw new Error(
"destination now contains another installation; preserving the previous bundle in its owned staging root",
);
await operations.rename(previousBundle, destination);
originalBackedUp = false;
} catch (rollbackCause: unknown) {
diagnostics.push(`rollback failed: ${failureMessage(rollbackCause)}`);
const cleanupFailure = `The previous Hopper bundle remains owned at ${previousBundle}; cleanup will retry restoration only if the destination is empty.`;
retainCleanup(async () => {
if (originalBackedUp) {
if (await operations.pathExists(destination))
throw new Error(
`Hopper destination is occupied; preserving owned backup ${previousBundle}`,
);
await operations.rename(previousBundle, destination);
originalBackedUp = false;
}
await operations.removeStagingRoot(stagingRoot);
});
return {
status: "failed",
reason: failureReason,
diagnostic: diagnostics.join("; "),
cleanupFailure,
};
}
}
try {
await operations.removeStagingRoot(stagingRoot);
} catch (cleanupCause: unknown) {
const cleanupFailure = `Hopper staging root cleanup failed: ${failureMessage(cleanupCause)}`;
retainCleanup(() => operations.removeStagingRoot(stagingRoot));
diagnostics.push(cleanupFailure);
return {
status: "failed",
reason: failureReason,
diagnostic: diagnostics.join("; "),
cleanupFailure,
};
}
return {
status: "failed",
reason: failureReason,
diagnostic: diagnostics.join("; "),
};
}
try {
await operations.removeStagingRoot(stagingRoot);
return { status: "installed" };
} catch (cause: unknown) {
const cleanupFailure = `Hopper staging root cleanup failed after installation committed: ${failureMessage(cause)}`;
retainCleanup(() => operations.removeStagingRoot(stagingRoot));
return { status: "installed", cleanupFailure };
}
};
/** Compose installer paths and subprocesses from one selected caller context. */
@@ -200,7 +345,7 @@ export const systemMacHopperInstallHost = (
createMountDirectory: (path) => mkdir(path),
writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
async mount(packagePath, mountPath) {
return commandSucceeds(
await execFileAsync(
"hdiutil",
[
"attach",
@@ -210,20 +355,14 @@ export const systemMacHopperInstallHost = (
mountPath,
packagePath,
],
environment,
{ env: environment },
);
return true;
},
async unmount(mountPath) {
if (
!(await commandSucceeds(
"hdiutil",
["detach", mountPath, "-force"],
environment,
))
)
throw new Error(
`hdiutil detach failed for ${JSON.stringify(mountPath)}`,
);
await execFileAsync("hdiutil", ["detach", mountPath, "-force"], {
env: environment,
});
},
async appBundle(mountPath) {
const entries = await readdir(mountPath, { withFileTypes: true });
@@ -261,49 +400,32 @@ export const systemMacHopperInstallHost = (
await access(path);
return true;
} catch (cause: unknown) {
// best-effort cleanup: optional existence probing; absence means missing.
void cause;
return false;
}
},
async installBundle(source, destination, stage, replaceExisting) {
const previous = `${stage}-previous`;
try {
await mkdir(join(homeDirectory, "Applications"), { recursive: true });
await rm(stage, { recursive: true, force: true });
await rm(previous, { recursive: true, force: true });
await execFileAsync("ditto", [source, stage], { env: environment });
await access(join(stage, "Contents/MacOS/hopper"));
if (replaceExisting) await rename(destination, previous);
await rename(stage, destination);
if (replaceExisting)
await rm(previous, { recursive: true, force: true });
return true;
} catch (cause: unknown) {
// Install failure rolls back below; the false return reports it.
void cause;
await rm(stage, { recursive: true, force: true });
if (replaceExisting) {
try {
await rename(previous, destination);
} catch (rollbackCause: unknown) {
// The original destination was not moved, or rollback is impossible.
void rollbackCause;
}
}
return false;
}
},
async launcherReady(path) {
try {
await access(path);
return true;
} catch (cause: unknown) {
// best-effort cleanup: optional launcher probing; absence means not ready.
void cause;
return false;
if (isMissingPath(cause)) return false;
throw cause;
}
},
installBundle: (source, destination, replaceExisting) =>
installMacHopperBundle(
source,
destination,
replaceExisting,
{
async createStagingRoot(parent) {
await mkdir(parent, { recursive: true });
return mkdtemp(join(parent, ".rea-hopper-stage-"));
},
copyBundle: async (bundle, staged) => {
await execFileAsync("ditto", [bundle, staged], {
env: environment,
});
},
requireLauncher: (path) => access(path).then(() => undefined),
pathExists,
rename,
removeStagingRoot: (path) => rm(path, { recursive: true }),
},
(cleanup) => pendingCleanup.add(cleanup),
),
async openApplication(path) {
await execFileAsync("open", [path], { env: environment });
},
@@ -316,7 +438,7 @@ export const systemMacHopperInstallHost = (
await cleanup();
pendingCleanup.delete(cleanup);
} catch (cause: unknown) {
failure ??= `Hopper DMG cleanup remains incomplete: ${failureMessage(cause)}`;
failure ??= `Hopper installer cleanup remains incomplete: ${failureMessage(cause)}`;
}
}
return failure;
@@ -343,13 +465,41 @@ const commandSucceeds = async (
await execFileAsync(command, args, { env: environment });
return true;
} catch (cause: unknown) {
// best-effort cleanup: optional command probing; failure means unavailable.
void cause;
return false;
if (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
typeof cause.code === "number"
)
return false;
throw cause;
}
};
const failureMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
cause instanceof Error
? [
cause.message,
"stderr" in cause && typeof cause.stderr === "string"
? cause.stderr.trim()
: "",
]
.filter((value) => value !== "")
.join(": ")
: String(cause);
const isMissingPath = (cause: unknown): boolean =>
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT";
const pathExists = async (path: string): Promise<boolean> => {
try {
await access(path);
return true;
} catch (cause: unknown) {
if (isMissingPath(cause)) return false;
throw cause;
}
};
const releaseSchema = z.object({
filename: z.string().url(),
file_length: z.string().regex(/^\d+$/u),
+19
View File
@@ -311,6 +311,25 @@ describe("setup plan application", () => {
expect(result.remediation).toBeUndefined();
});
it("records an installed Hopper when opening the app needs manual recovery", async () => {
const host = new FakeSetupHost("darwin");
host.installHopper = () =>
Promise.resolve({
status: "installed",
launcherPath: "/Applications/Hopper.app/Contents/MacOS/hopper",
warning:
"Hopper was installed, but macOS could not open it. Open it manually.",
});
const result = await runSetup(options(true, true), host);
expect(result).toMatchObject({
status: "needs_human",
appliedActions: ["installed_hopper"],
remediation: expect.stringContaining("Open it manually"),
});
});
it("reuses existing Hopper without selecting every detected client", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
+15 -4
View File
@@ -306,6 +306,7 @@ const runSetupWorkflow = async (
}
let selectedClients: readonly SetupClient[] =
planSelection.clientsToConfigure;
let hopperWarning: string | undefined;
if (
planSelection.installHopper &&
(interactiveApproval || options.installHopper)
@@ -322,7 +323,12 @@ const runSetupWorkflow = async (
clientStates,
});
if ("failure" in install) return install.failure;
({ hopperPath, providerEnvironment, selectedClients } = install);
({
hopperPath,
providerEnvironment,
selectedClients,
warning: hopperWarning,
} = install);
}
const clientFailure = await configureDetectedClients({
host,
@@ -355,6 +361,7 @@ const runSetupWorkflow = async (
doctor.healthy,
hopperPath,
),
hopperWarning,
manualRemediation,
);
return {
@@ -407,14 +414,17 @@ const installHopperAction = async (input: {
};
}
input.appliedActions.push("installed_hopper");
const warning = [installed.warning, installed.cleanupFailure]
.filter((value): value is string => value !== undefined)
.join(" ");
emitProgress(input.options, {
actionId: "install_hopper",
label,
state: installed.cleanupFailure === undefined ? "completed" : "warning",
state: warning === "" ? "completed" : "warning",
detail:
installed.cleanupFailure === undefined
warning === ""
? installed.launcherPath
: `${installed.launcherPath}; ${installed.cleanupFailure}`,
: `${installed.launcherPath}; ${warning}`,
});
const providerEnvironment = {
...input.providerEnvironment,
@@ -423,6 +433,7 @@ const installHopperAction = async (input: {
return {
hopperPath: installed.launcherPath,
providerEnvironment,
...(warning === "" ? {} : { warning }),
selectedClients: await filterClientsNeedingConfigure(
input.host,
input.selectedClients,
+33 -7
View File
@@ -203,18 +203,29 @@ export const systemSetupHost = (
return {
status: "installed",
launcherPath: result.launcherPath,
...(result.openFailure === undefined
? {}
: {
warning: `Hopper was installed, but macOS could not open it: ${result.openFailure}. Open ${result.launcherPath} manually to complete first-run setup.`,
}),
...(result.cleanupFailure === undefined
? {}
: { cleanupFailure: result.cleanupFailure }),
};
const failure = setupInstallFailure(result.reason);
if (result.cleanupFailure === undefined) return failure;
return failure.status === "failed"
? {
...failure,
remediation: `${failure.remediation} ${result.cleanupFailure}`,
}
: failure;
const diagnostic = setupInstallerDiagnostic(result.diagnostic);
return {
...failure,
remediation: [
failure.remediation,
...(diagnostic === undefined
? []
: [`Installer detail: ${diagnostic}`]),
...(result.cleanupFailure === undefined
? []
: [result.cleanupFailure]),
].join(" "),
};
},
detectedClients: () => detectClients(homeDirectory, platform, environment),
supportedClients: () =>
@@ -248,6 +259,21 @@ export const systemSetupHost = (
};
};
const setupInstallerDiagnostic = (
value: string | undefined,
): string | undefined => {
if (value === undefined) return undefined;
const normalized = value
.replace(/\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\([0-9A-Za-z]|[@-Z\\^_])/gu, "")
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
.replace(/\s+/gu, " ")
.trim();
if (normalized === "") return undefined;
return normalized.length <= 400
? normalized
: `${normalized.slice(0, 397).trimEnd()}...`;
};
const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
configureClient: (
client: SetupClient,
+7 -1
View File
@@ -23,6 +23,7 @@ export type SetupHopperInstallResult =
readonly status: "installed";
readonly launcherPath: string;
readonly cleanupFailure?: string;
readonly warning?: string;
}
| {
readonly status: "failed";
@@ -30,6 +31,11 @@ export type SetupHopperInstallResult =
readonly remediation: string;
};
export type SetupHopperInstallFailure = Extract<
SetupHopperInstallResult,
{ readonly status: "failed" }
>;
export type HopperInstallFailureReason =
| "unsupported_host"
| "release_metadata"
@@ -93,7 +99,7 @@ const packageManagerRemediation = (
export const setupInstallFailure = (
reason: HopperInstallFailureReason,
packageFailure?: LinuxPackageInstallFailure,
): SetupHopperInstallResult => {
): SetupHopperInstallFailure => {
switch (reason) {
case "unsupported_host":
return {
@@ -0,0 +1,166 @@
import { expect, it } from "vitest";
import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../contracts/artifactComparisonExample.js";
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
import {
createEvidence,
parseEvidence,
type Evidence,
} from "../../domain/evidence.js";
import {
EvidenceIntegrityError,
EvidenceReferenceError,
} from "../../domain/evidenceErrors.js";
import { resolveArtifactComparisonEvidence } from "./ArtifactComparisonEvidence.js";
const artifactSummary = () => {
const parent = ARTIFACT_COMPARISON_EXAMPLE.left;
const summary = inspectAnalysisView(
{
source: { kind: "inline", evidence: parent },
view: { kind: "summary" },
},
(evidenceId) => (evidenceId === parent.evidence_id ? parent : undefined),
);
if (!summary.ok) throw summary.error;
return { parent, summary: summary.value };
};
const recreateSummary = (
summary: Evidence,
normalizedResult: Evidence["normalized_result"],
parameters: Evidence["parameters"] = summary.parameters,
): Evidence => {
if (summary.subject === null)
throw new Error("artifact summary fixture must identify its subject");
return createEvidence(
{
path: summary.subject.local_path,
sha256: summary.subject.digest.sha256,
format: summary.subject.format,
...(summary.subject.architecture === null
? {}
: { architecture: summary.subject.architecture }),
},
summary.provider,
{
predicateType: summary.predicate_type,
operation: summary.operation,
parameters,
result: normalizedResult,
...(summary.analysis_profile === null
? {}
: { analysisProfile: summary.analysis_profile }),
rawResult: summary.raw_result,
confidence: summary.confidence,
authority: summary.authority,
environment: summary.environment,
limitations: summary.limitations,
...(summary.relationship_coverage === null
? {}
: { relationshipCoverage: summary.relationship_coverage }),
locations: summary.locations,
evidenceLinks: summary.evidence_links,
},
);
};
it("reports a missing retained parent instead of treating summary facts as inventory", () => {
const { summary } = artifactSummary();
const resolved = resolveArtifactComparisonEvidence(summary, undefined);
expect(resolved.ok).toBe(false);
if (!resolved.ok) {
expect(resolved.error).toBeInstanceOf(EvidenceReferenceError);
expect(resolved.error).toMatchObject({ reason: "missing" });
}
});
it("uses retained Evidence resolution to reject a mismatched parent identity", () => {
const { summary } = artifactSummary();
const otherParent = ARTIFACT_COMPARISON_EXAMPLE.right;
const resolved = resolveArtifactComparisonEvidence(
summary,
() => otherParent,
);
expect(resolved.ok).toBe(false);
if (!resolved.ok) {
expect(resolved.error).toBeInstanceOf(EvidenceReferenceError);
expect(resolved.error).toMatchObject({ reason: "identity_mismatch" });
}
});
it("rejects a summary that declares the wrong parent operation", () => {
const { parent, summary } = artifactSummary();
const summaryResult = artifactInventorySummaryViewResultSchema.parse(
summary.normalized_result,
);
const normalizedResult = {
...summaryResult,
parent_operation: "inspect_artifact",
};
const forged = recreateSummary(summary, normalizedResult);
const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) =>
evidenceId === parent.evidence_id ? parent : undefined,
);
expect(resolved.ok).toBe(false);
if (!resolved.ok)
expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError);
});
it("rejects summary parameters that do not identify the projected parent", () => {
const { parent, summary } = artifactSummary();
const forged = recreateSummary(summary, summary.normalized_result, {
...summary.parameters,
parent_evidence_id: `ev_${"f".repeat(64)}`,
});
const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) =>
evidenceId === parent.evidence_id ? parent : undefined,
);
expect(resolved.ok).toBe(false);
if (!resolved.ok)
expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError);
});
it("rejects summary facts that do not reproject from the complete parent", () => {
const { parent, summary } = artifactSummary();
const summaryResult = artifactInventorySummaryViewResultSchema.parse(
summary.normalized_result,
);
const normalizedResult = {
...summaryResult,
summary: {
...summaryResult.summary,
available_counts: {
...summaryResult.summary.available_counts,
nodes: summaryResult.summary.available_counts.nodes + 1,
},
},
};
const forged = recreateSummary(summary, normalizedResult);
const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) =>
evidenceId === parent.evidence_id ? parent : undefined,
);
expect(resolved.ok).toBe(false);
if (!resolved.ok)
expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError);
});
it("accepts display-only subject changes excluded from Evidence identity", () => {
const { parent, summary } = artifactSummary();
if (summary.subject === null)
throw new Error("artifact summary fixture must identify its subject");
const displayOnly = parseEvidence({
...summary,
subject: {
...summary.subject,
name: "renamed.app",
local_path: "/display-only/renamed.app",
},
});
const resolved = resolveArtifactComparisonEvidence(
displayOnly,
(evidenceId) => (evidenceId === parent.evidence_id ? parent : undefined),
);
expect(resolved).toEqual({ ok: true, value: parent });
});
@@ -0,0 +1,61 @@
import type { EvidenceLookup } from "../EvidenceInputResolver.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewService.js";
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
import {
EvidenceIntegrityError,
EvidenceReferenceError,
} from "../../domain/evidenceErrors.js";
import type { Evidence } from "../../domain/evidence.js";
import { err, ok, type Result } from "../../domain/result.js";
/** Resolve only an artifact summary's exact retained owner before comparison. */
export const resolveArtifactComparisonEvidence = (
evidence: Evidence,
evidenceById: EvidenceLookup | undefined,
): Result<Evidence, AnalysisError> => {
if (evidence.operation !== "inspect_analysis_view") return ok(evidence);
const summary = artifactInventorySummaryViewResultSchema.safeParse(
evidence.normalized_result,
);
if (!summary.success)
return err(
new AnalysisInputError("compare_artifacts", undefined, [
{
path: ["evidence"],
reason: "invalid_value",
message:
"Artifact comparison accepts only an artifact inventory summary from inspect_analysis_view.",
},
]),
);
const parentId = summary.data.parent_evidence_id;
const parent = evidenceById?.(parentId);
if (parent === undefined)
return err(
new EvidenceReferenceError(
parentId,
"missing",
"retained complete artifact inventory Evidence",
null,
),
);
const reprojected = inspectAnalysisViewValidated(
{
source: { kind: "retained-evidence", evidence_id: parentId },
view: { kind: "summary" },
},
(evidenceId) => (evidenceId === parentId ? parent : undefined),
);
if (!reprojected.ok) return reprojected;
if (reprojected.value.evidence_id !== evidence.evidence_id)
return err(
new EvidenceIntegrityError(
"Artifact summary does not match its retained parent inventory.",
),
);
return ok(parent);
};
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import type { RelationshipCoverage } from "../../domain/relationshipCoverage.js";
import {
ANALYSIS_SNAPSHOT_PROFILE,
ANALYSIS_SNAPSHOT_PROVIDER,
@@ -502,57 +503,58 @@ describe("analysis snapshot cache partitioning", () => {
});
});
describe("relationship coverage snapshot replay", () => {
it("authenticates relationship coverage and replays legacy reads as unknown", () => {
const cache = new AnalysisSnapshotCache();
const operation = "procedure_callees" as const;
const parameters = { procedure: "0x1000", document: "fixture" };
const coverage = {
status: "complete" as const,
diagnostics: ["The provider retained an unresolved call target."],
};
const execution = createAnalysisExecution(
["0x2000"],
ANALYSIS_SNAPSHOT_PROVIDER,
{
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
rawResult: ["0x2000"],
relationshipCoverage: coverage,
subject: {
path: ANALYSIS_SNAPSHOT_TARGET.path,
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
format: ANALYSIS_SNAPSHOT_TARGET.format,
...(ANALYSIS_SNAPSHOT_TARGET.architecture === undefined
? {}
: { architecture: ANALYSIS_SNAPSHOT_TARGET.architecture }),
},
},
);
cache.record({
target: ANALYSIS_SNAPSHOT_TARGET,
profile: ANALYSIS_SNAPSHOT_PROFILE,
const createRelationshipCoverageSnapshot = (
relationshipCoverage?: RelationshipCoverage,
) => {
const operation = "procedure_callees" as const;
const parameters = { procedure: "0x1000", document: "fixture" };
const cache = new AnalysisSnapshotCache();
const execution = createAnalysisExecution(
["0x2000"],
ANALYSIS_SNAPSHOT_PROVIDER,
{
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
rawResult: ["0x2000"],
subject: ANALYSIS_SNAPSHOT_TARGET,
...(relationshipCoverage === undefined ? {} : { relationshipCoverage }),
},
);
cache.record({
target: ANALYSIS_SNAPSHOT_TARGET,
profile: ANALYSIS_SNAPSHOT_PROFILE,
operation,
parameters,
execution,
});
const evidence = createEvidence(
ANALYSIS_SNAPSHOT_TARGET,
ANALYSIS_SNAPSHOT_PROVIDER,
{
operation,
parameters,
execution,
});
const evidence = createEvidence(
ANALYSIS_SNAPSHOT_TARGET,
ANALYSIS_SNAPSHOT_PROVIDER,
{
operation,
parameters,
result: ["0x2000"],
rawResult: ["0x2000"],
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
relationshipCoverage: coverage,
},
);
const exported = cache.export(
ANALYSIS_SNAPSHOT_TARGET,
ANALYSIS_SNAPSHOT_PROFILE,
createEvidenceBundle([evidence]),
);
if (!exported.ok) throw exported.error;
result: ["0x2000"],
rawResult: ["0x2000"],
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
...(relationshipCoverage === undefined ? {} : { relationshipCoverage }),
},
);
const exported = cache.export(
ANALYSIS_SNAPSHOT_TARGET,
ANALYSIS_SNAPSHOT_PROFILE,
createEvidenceBundle([evidence]),
);
if (!exported.ok) throw exported.error;
return { cache, operation, parameters, snapshot: exported.value };
};
describe("relationship coverage snapshot replay", () => {
it("authenticates coverage and rejects missing serialized fields", () => {
const coverage: RelationshipCoverage = {
status: "complete",
diagnostics: ["The provider retained an unresolved call target."],
};
const { cache, operation, parameters, snapshot } =
createRelationshipCoverageSnapshot(coverage);
expect(
cache.lookup(
ANALYSIS_SNAPSHOT_TARGET,
@@ -562,7 +564,7 @@ describe("relationship coverage snapshot replay", () => {
)?.relationshipCoverage,
).toEqual(coverage);
const tampered = structuredClone(exported.value);
const tampered = structuredClone(snapshot);
const tamperedEntry = tampered.entries[0];
if (tamperedEntry === undefined) throw new Error("snapshot entry missing");
tamperedEntry.execution.relationship_coverage = {
@@ -573,26 +575,36 @@ describe("relationship coverage snapshot replay", () => {
new AnalysisSnapshotCache().import(tampered, undefined, () => ok(0)).ok,
).toBe(false);
const legacy = structuredClone(exported.value);
const legacyEntry = legacy.entries[0];
if (legacyEntry === undefined) throw new Error("snapshot entry missing");
Reflect.deleteProperty(legacyEntry.execution, "relationship_coverage");
const legacyEvidence = createEvidence(
ANALYSIS_SNAPSHOT_TARGET,
ANALYSIS_SNAPSHOT_PROVIDER,
{
operation,
parameters,
result: ["0x2000"],
rawResult: ["0x2000"],
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
},
const missingCoverage = structuredClone(snapshot);
const missingCoverageEntry = missingCoverage.entries[0];
if (missingCoverageEntry === undefined)
throw new Error("snapshot entry missing");
Reflect.deleteProperty(
missingCoverageEntry.execution,
"relationship_coverage",
);
Reflect.deleteProperty(legacyEvidence, "relationship_coverage");
legacy.evidence_bundle = createEvidenceBundle([legacyEvidence]);
parseAnalysisSnapshot(legacy);
const missingCoverageEvidence = missingCoverage.evidence_bundle.records[0];
if (missingCoverageEvidence === undefined)
throw new Error("snapshot Evidence missing");
Reflect.deleteProperty(missingCoverageEvidence, "relationship_coverage");
expect(() => parseAnalysisSnapshot(missingCoverage)).toThrow();
expect(
new AnalysisSnapshotCache().import(missingCoverage, undefined, () =>
ok(0),
).ok,
).toBe(false);
});
it("replays absent coverage as explicitly unknown", () => {
const { operation, parameters, snapshot } =
createRelationshipCoverageSnapshot();
expect(snapshot.entries[0]?.execution.relationship_coverage).toBeNull();
expect(
snapshot.evidence_bundle.records[0]?.relationship_coverage,
).toBeNull();
const replayCache = new AnalysisSnapshotCache();
expect(replayCache.import(legacy, undefined, () => ok(0)).ok).toBe(true);
expect(replayCache.import(snapshot, undefined, () => ok(0)).ok).toBe(true);
expect(
replayCache.lookup(
ANALYSIS_SNAPSHOT_TARGET,
@@ -105,8 +105,8 @@ export const addJavaScriptSourceModules = (
label: file.path,
properties: {
semantic_role: "source-module",
source_sha256: file.sha256,
path: file.path,
container_sha256: file.container_sha256,
logical_module_key: file.path,
module_format: moduleFormat(file.path, semantic.ir),
import_relationships: semantic.ir.moduleLinks.filter(
@@ -421,6 +421,33 @@ it("keeps duplicate function fingerprints ambiguous", () => {
expect(query.summary.total_seed_matches).toBe(2);
});
it("selects analyzer-produced boundaries by their method field", () => {
const graph = graphFor("const port = Number(process.env.PORT);");
const boundary = graph.nodes.find(({ kind }) => kind === "boundary");
if (boundary === undefined) throw new Error("Expected a boundary node");
expect(boundary.properties).toMatchObject({
boundary_kind: "coerce",
field: "Number",
method: "Number",
});
expect(
queryJavaScriptSemanticGraph(graph, {
seed: { kind: "boundary-field", field: "Number" },
direction: "forward-influence",
}),
).toMatchObject({
seed_node_ids: [boundary.node_id],
summary: { total_seed_matches: 1 },
});
expect(
queryJavaScriptSemanticGraph(graph, {
seed: { kind: "boundary-field", field: "missing-field" },
direction: "forward-influence",
}).summary.total_seed_matches,
).toBe(0);
});
const semanticNodeFor = (roleKey: string) => {
const file: JavaScriptArtifactFile = {
path: "budget.js",
@@ -263,6 +263,7 @@ const addBoundaryNode = (
null),
properties: {
boundary_kind: operation.kind,
field: operation.method,
method: operation.method,
},
});
+9 -2
View File
@@ -3,17 +3,24 @@ import type {
ExecutionOptions,
ProviderAvailability,
} from "../AnalysisProvider.js";
import type { JebRequest } from "../../domain/jeb/jebAnalysis.js";
import type { JebOperation, JebRequest } from "../../domain/jeb/jebAnalysis.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Result } from "../../domain/result.js";
/** Live engine readiness and the operations advertised by that engine. */
export type JebAnalysisAvailability =
| (Extract<ProviderAvailability, { status: "available" }> & {
readonly supportedOperations: readonly JebOperation[];
})
| Extract<ProviderAvailability, { status: "unavailable" }>;
/**
* JEB inspection boundary served by a caller-supplied running engine client.
* Implementation details belong to the provider adapter.
*/
export interface JebAnalysisPort {
/** Probe the running JEB client without opening a project. */
inspectAvailability(signal?: AbortSignal): Promise<ProviderAvailability>;
inspectAvailability(signal?: AbortSignal): Promise<JebAnalysisAvailability>;
/** Release engine connection state; the JEB client itself keeps running. */
close(): Promise<void>;
execute(
@@ -27,6 +27,12 @@ const fakePort = (
code: null,
reason: null,
diagnostics: {},
supportedOperations: [
"inspect_jeb_client",
"list_jeb_units",
"open_jeb_project",
"decompile_jeb_item",
],
};
},
async close() {},
@@ -460,12 +460,26 @@ describe("Mach-O load command reader inputs", () => {
"i386",
);
// A Java class file shares the FAT magic; version fields are not a slice count.
const javaClasses = [javaClassFile(), javaClassFile(4000)];
expect(javaClasses.map(({ length }) => length)).toEqual([57, 4060]);
const javaClasses = [
javaClassFile(),
javaClassFile(4000),
javaClassFile(4000, true),
];
for (const javaClass of javaClasses) {
expect(hasMachoMagic(javaClass)).toBe(true);
expect(await read(javaClass)).toEqual({ status: "not-mach-o" });
}
const malformedFat = Buffer.alloc(28);
malformedFat.writeUInt32BE(0xcafebabe, 0);
malformedFat.writeUInt32BE(1, 4);
malformedFat.writeUInt32BE(0x0100000c, 8);
malformedFat.writeUInt32BE(4096, 16);
malformedFat.writeUInt32BE(32, 20);
malformedFat.writeUInt32BE(12, 24);
expect(await read(malformedFat)).toMatchObject({
status: "malformed",
reason: expect.stringContaining("slice range extends beyond the file"),
});
expect(await read(new TextEncoder().encode("#!/bin/sh\n"))).toEqual({
status: "not-mach-o",
});
@@ -477,6 +491,35 @@ describe("Mach-O load command reader inputs", () => {
await read(Uint8Array.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00])),
).toEqual({ status: "malformed", reason: "Mach-O header is truncated" });
});
it("keeps oversized CAFEBABE identity unknown when class payload is sparse", async () => {
const classPrefix = Buffer.from(javaClassFile(4000, true));
const payloadBytes = MAX_LOAD_COMMAND_BYTES + 1;
const attributeHeader = Buffer.alloc(6);
attributeHeader.writeUInt16BE(5, 0);
attributeHeader.writeUInt32BE(payloadBytes, 2);
classPrefix.writeUInt16BE(1, classPrefix.length - 2);
const prefix = Buffer.concat([classPrefix, attributeHeader]);
const size = prefix.length + payloadBytes;
const sparseReadAt = async (offset: number, length: number) => {
const result = Buffer.alloc(Math.min(length, size - offset));
if (offset < prefix.length)
prefix.copy(
result,
0,
offset,
Math.min(prefix.length, offset + length),
);
return result;
};
expect(await readMachoImage(sparseReadAt, size)).toEqual({
status: "unsupported",
reason: expect.stringContaining(
"class identity scan exceeds the 16 MiB limit",
),
});
});
});
describe("Mach-O load command reader failures", () => {
+18 -2
View File
@@ -141,7 +141,14 @@ export const readMachoImage = async (
: { status: "not-mach-o" };
const view = viewOf(head);
const magic = view.getUint32(0, false);
let header = readFatContainerHeader(head, size);
let header: ReturnType<typeof readFatContainerHeader>;
if (magic === FAT_MAGIC && size <= MAX_LOAD_COMMAND_BYTES) {
const complete = await readAt(0, size);
header =
complete.byteLength === size
? readFatContainerHeader(complete, size)
: readFatContainerHeader(head, size);
} else header = readFatContainerHeader(head, size);
if (header.state !== "not-fat") {
if (header.state === "needs-data") {
header = readFatContainerHeader(
@@ -169,8 +176,17 @@ export const readMachoImage = async (
);
}
if (identity.state === "not-mach-o") return { status: "not-mach-o" };
if (identity.state === "malformed")
if (identity.state === "malformed") {
// A large CAFEBABE input whose bounded prefix resembles an invalid
// known-CPU FAT table remains unknown until its JVM identity can be
// checked; do not turn the resource limit into a malformed claim.
if (magic === FAT_MAGIC && size > MAX_LOAD_COMMAND_BYTES)
throw new MachoFormatIssue(
"unsupported",
"FAT/JVM identity is unresolved because the class identity scan exceeds the 16 MiB limit",
);
throw new MachoFormatIssue(identity.category, identity.reason);
}
if (identity.state === "needs-slice-header")
return { status: "not-mach-o" };
return {
+4 -4
View File
@@ -1,8 +1,8 @@
import { realpath } from "node:fs/promises";
import {
artifactInventoryOutputSchema,
type ArtifactInventoryOutput,
artifactInventoryResultSchema,
type ArtifactInventoryResult,
} from "../../domain/artifactGraph.js";
import { abortIfNeeded } from "../ArtifactHash.js";
import { scanCanonicalArtifactInventoryInScope } from "./scanCanonical.js";
@@ -13,9 +13,9 @@ import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySn
export const inventoryArtifact = async (
inputPath: string,
options: ArtifactInventoryOptions,
): Promise<ArtifactInventoryOutput> => {
): Promise<ArtifactInventoryResult> => {
const snapshot = await scanArtifactInventory(inputPath, options);
return artifactInventoryOutputSchema.parse(snapshot);
return artifactInventoryResultSchema.parse(snapshot);
};
/** Scan an artifact once and retain the complete immutable graph for projection. */
-1
View File
@@ -289,7 +289,6 @@ const buildInventorySnapshot = async (
provenance: reader?.provenance() ?? [],
integrity_contradictions: integrityContradictions,
coverage,
coverage_status: coverage.status === "complete" ? "complete" : "partial",
limitations: buildLimitations(
[...traversal.limitations, ...edgeResult.limitations],
integrityContradictions,
+5 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js";
const secret = (secret_id: string) => ({ source: "secret", secret_id });
const literal = (value: string) => ({
@@ -136,6 +137,7 @@ describe("browserScenarioSchema", () => {
service_workers: "block",
});
expect(parsed.capture).toEqual({
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
network: {
request_body: false,
response_body: false,
@@ -145,7 +147,9 @@ describe("browserScenarioSchema", () => {
at_end: ["dom", "storage"],
events: ["console", "page-errors", "network", "websockets"],
});
expect(parsed).not.toHaveProperty("limits");
expect(parsed.capture.limits).toEqual(
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
);
});
it("allows callers to choose visible browsers and service workers", () => {
@@ -90,6 +90,7 @@ export const browserScenarioCaptureData = (input: {
.sort(),
network_content: input.scenario.capture.network,
event_families: [...new Set(input.scenario.capture.events)].sort(),
limits: input.scenario.capture.limits,
},
duration_ms: Math.max(0, Math.round(performance.now() - input.startedAt)),
steps: [...input.steps],
@@ -2,6 +2,7 @@ import type { BrowserScenario } from "../domain/browserScenario.js";
import type { BrowserScenarioAction } from "../domain/browserScenarioValues.js";
import type {
BrowserScenarioEvent,
BrowserScenarioEventOmissions,
BrowserStepArtifacts,
} from "../domain/browserScenarioCaptureValues.js";
import type { SanitizedBrowserUrl } from "../domain/browserObservation.js";
@@ -23,6 +24,7 @@ export interface BrowserScenarioSessionPort {
readonly retained: number;
readonly dropped: number;
readonly items: readonly BrowserScenarioEvent[];
readonly omissions: BrowserScenarioEventOmissions;
};
/** Gaps in selected event families that prevent complete capture claims. */
eventLimitations?(): readonly string[];
+7 -1
View File
@@ -73,7 +73,13 @@ import { comparePngScreenshots } from "./PngVisualDiff.js";
import { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const;
const CLEANUP_DOMAINS = [
"Network",
"Debugger",
"Runtime",
"Page",
"WebMCP",
] as const;
/** Passive browser observation through a selected user-owned CDP endpoint. */
export class CdpBrowserProvider implements BrowserObservationPort {
+2
View File
@@ -130,10 +130,12 @@ describe("CDP DOM string and namespace validity", () => {
{ field: "nodeName", index: -1 },
{ field: "nodeName", index: 20 },
{ field: "nodeName", index: 2, malformedString: true },
{ field: "nodeValue", index: -2 },
{ field: "nodeValue", index: 20 },
{ field: "nodeValue", index: 5, malformedString: true },
{ field: "attributeName", index: -1 },
{ field: "attributeName", index: 20 },
{ field: "attributeValue", index: -2 },
{ field: "attributeValue", index: 20 },
] as const)(
// -1 is DOMSnapshot's empty string for values, never for names.
@@ -145,15 +145,16 @@ class FakeSession implements BrowserScenarioSessionPort {
return 0;
}
events(): {
readonly retained: number;
readonly dropped: number;
readonly items: readonly BrowserScenarioEvent[];
} {
events(): ReturnType<BrowserScenarioSessionPort["events"]> {
return {
retained: this.eventItems.length,
dropped: 0,
items: this.eventItems,
omissions: {
by_kind: [],
network_transactions: [],
network_transaction_range: null,
},
};
}
@@ -559,9 +560,16 @@ describe("PlaywrightBrowserScenarioProvider operation failures", () => {
},
steps: [{ step_id: "scenario_start" }],
events: {
retained: 1,
dropped: 0,
items: [
expect.objectContaining({ text: "navigation started" }),
],
omissions: {
by_kind: [],
network_transactions: [],
network_transaction_range: null,
},
},
},
},
+118 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { Page } from "playwright-core";
import type { BrowserContext, Page, Request, Response } from "playwright-core";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
@@ -76,7 +76,124 @@ describe("declared secrets in scenario events", () => {
});
});
const captureNetworkBudgetFixture = async (
maxTotalEventAllocationBytes?: number,
) => {
const pageListeners = new Map<string, (value: unknown) => void>();
const contextListeners = new Map<string, (value: unknown) => void>();
const context = {
on: (name: string, listener: (value: unknown) => void) =>
contextListeners.set(name, listener),
off: vi.fn(),
} as unknown as BrowserContext;
const page = {
url: () => "http://127.0.0.1/fixture",
context: () => context,
on: (name: string, listener: (value: unknown) => void) =>
pageListeners.set(name, listener),
off: vi.fn(),
} as unknown as Page;
const scenario = browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: "/fixture/chrome" },
start_url: { url: "http://127.0.0.1/fixture" },
actions: [{ step_id: "ready", action: "wait_for_timeout", duration_ms: 1 }],
capture: {
events: ["network"],
network: { response_body: true },
...(maxTotalEventAllocationBytes === undefined
? {}
: {
limits: {
max_total_event_allocation_bytes: maxTotalEventAllocationBytes,
},
}),
},
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {});
if (secrets === undefined) throw new Error("Expected resolved secrets");
const redactBytes = vi.spyOn(secrets, "redactBytes");
const capture = new PlaywrightScenarioEvents({
page,
context,
ownsContext: true,
enabled: new Set(["network"]),
network: scenario.capture.network,
limits: scenario.capture.limits,
secrets,
});
for (let index = 0; index < 43; index += 1) {
const post = index >= 40;
const body = post
? Buffer.from('{"ok":true}')
: Buffer.from("x".repeat(60_000));
const request = {
method: () => (post ? "POST" : "GET"),
url: () => `http://127.0.0.1/${post ? "final" : "data"}/${String(index)}`,
resourceType: () => "fetch",
postDataBuffer: () => (post ? Buffer.from("{}") : null),
redirectedFrom: () => null,
failure: () => null,
headers: () => ({ "content-type": "application/json" }),
headersArray: async () => [
{ name: "content-type", value: "application/json" },
],
} as unknown as Request;
const response = {
request: () => request,
url: () => request.url(),
status: () => 200,
headers: () => ({ "content-type": "application/json" }),
headersArray: async () => [
{ name: "content-type", value: "application/json" },
],
body: async () => body,
} as unknown as Response;
contextListeners.get("request")?.(request);
contextListeners.get("response")?.(response);
contextListeners.get("requestfinished")?.(request);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
await capture.finish();
return {
result: capture.result(),
redactedByteValues: redactBytes.mock.calls.length,
};
};
describe("PlaywrightScenarioEvents", () => {
it("identifies omitted network transaction content and honors a raised budget", async () => {
const baseline = await captureNetworkBudgetFixture();
const raised = await captureNetworkBudgetFixture(32 * 1024 * 1024);
expect(baseline.result.dropped).toBeGreaterThan(0);
expect(
baseline.result.omissions.by_kind.find(
({ kind }) => kind === "network-content",
),
).toMatchObject({
count: expect.any(Number),
reasons: expect.arrayContaining(["max-total-event-allocation-bytes"]),
});
expect(
baseline.result.omissions.network_transactions.length,
).toBeGreaterThan(0);
expect(baseline.redactedByteValues).toBe(
2 *
baseline.result.items.filter(
(event) => event.kind === "network-content",
).length,
);
expect(raised.result).toMatchObject({
dropped: 0,
omissions: { network_transactions: [] },
});
expect(
raised.result.items.filter(
(event) =>
event.kind === "network-content" && event.phase === "response",
),
).toHaveLength(43);
});
it("observes requested popup page errors independently of popup lifecycle events", () => {
const listeners = new Map<string, (value: unknown) => void>();
const popupListeners = new Map<string, (value: unknown) => void>();
+210 -50
View File
@@ -17,6 +17,14 @@ import {
} from "../domain/browserScenarioCaptureValues.js";
import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import type { BrowserNetworkContentSelection } from "../domain/browserNetworkEvidence.js";
import type { BrowserNetworkHeaders } from "../domain/browserNetworkEvidence.js";
import type { BrowserScenarioCaptureLimits } from "../domain/browserScenarioCaptureLimits.js";
import type {
BrowserScenarioEventAdmission,
BrowserScenarioEventBudgetReason,
BrowserScenarioEventOmissions,
} from "../domain/browserScenarioCaptureValues.js";
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js";
import { PlaywrightScenarioNetwork } from "./network/PlaywrightScenarioNetwork.js";
type EventName =
@@ -43,14 +51,12 @@ interface EventCaptureOptions {
readonly enabled: ReadonlySet<EventName>;
readonly secrets: BrowserScenarioSecrets;
readonly network?: BrowserNetworkContentSelection;
readonly limits?: BrowserScenarioCaptureLimits;
readonly signal?: AbortSignal;
}
// Conservative allocation accounting includes UTF-16 storage and worst-case
// JSON escaping. Reserve before schema cloning; never serialize to measure.
const MAX_EVENT_COST = 4 * 1024 * 1024;
const MAX_CAPTURE_COST = 16 * 1024 * 1024;
const MAX_EVENTS = 10_000;
// JSON escaping. Check before schema cloning; never serialize to measure.
const MAX_LISTENER_OWNERS = 2048;
const allocationCost = (value: unknown, remaining: number): number => {
if (typeof value === "string") return value.length * 8 + 2;
@@ -65,15 +71,28 @@ const allocationCost = (value: unknown, remaining: number): number => {
return cost;
};
type EventKind = BrowserScenarioEvent["kind"];
/** Arrival-ordered Playwright event capture with current-step attribution. */
export class PlaywrightScenarioEvents {
private readonly items: BrowserScenarioEvent[] = [];
private retainedCost = 0;
private dropped = 0;
private readonly omittedKinds = new Map<
EventKind,
{ count: number; reasons: Set<BrowserScenarioEventBudgetReason> }
>();
private readonly omittedNetwork = new Map<
string,
{
readonly kinds: Set<EventKind>;
readonly reasons: Set<BrowserScenarioEventBudgetReason>;
}
>();
private stepIndex = 0;
private sequence = 0;
private readonly enabled: ReadonlySet<EventName>;
private readonly secrets: BrowserScenarioSecrets;
private readonly limits: BrowserScenarioCaptureLimits;
private readonly ownsContext: boolean;
private pages = new WeakSet<Page>();
private readonly incompleteFamilies = new Set<EventName>();
@@ -92,6 +111,7 @@ export class PlaywrightScenarioEvents {
if (network.request_body || network.response_body || network.header_values)
this.enabled = new Set([...this.enabled, "network"]);
this.secrets = options.secrets;
this.limits = options.limits ?? DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS;
this.ownsContext = options.ownsContext === true;
this.pages.add(options.page);
if (this.ownsContext && this.enabled.size > 0) {
@@ -104,9 +124,13 @@ export class PlaywrightScenarioEvents {
const context = options.context ?? options.page.context();
const collector = new PlaywrightScenarioNetwork({
selection: network,
limits: this.limits,
secrets: options.secrets,
emit: (event) => this.push(event),
admitText: (values) => this.admitProducerText(values),
admitText: (kind, values) => this.admitProducerText(kind, values, true),
admitContentBody: (input) => this.admitContentBody(input),
omitNetwork: (id, kind, reason) => this.omitNetwork(id, kind, reason),
omitEvent: (kind, reason) => this.omitEvent(kind, reason),
...(options.signal === undefined ? {} : { signal: options.signal }),
});
this.network = collector;
@@ -181,7 +205,7 @@ export class PlaywrightScenarioEvents {
const gaps = [...popupGaps, ...(this.network?.limitations() ?? [])];
if (this.dropped > 0)
gaps.push(
"Browser event collection reached its resource budget; omitted events and descendant observations are unknown.",
"Browser event collection reached a selected resource budget; per-kind omissions and affected network transaction IDs are listed in events.omissions, and event coverage is incomplete.",
);
return this.enabled.has("network") && !this.ownsContext
? [
@@ -195,25 +219,55 @@ export class PlaywrightScenarioEvents {
readonly retained: number;
readonly dropped: number;
readonly items: readonly BrowserScenarioEvent[];
readonly omissions: BrowserScenarioEventOmissions;
} {
const omittedKinds = new Map(
[...this.omittedKinds].map(
([kind, omission]) =>
[
kind,
{ count: omission.count, reasons: new Set(omission.reasons) },
] as const,
),
);
return {
retained: this.items.length,
dropped: this.dropped,
items: this.items,
omissions: {
by_kind: [...omittedKinds]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([kind, omission]) => ({
kind,
count: omission.count,
reasons: [...omission.reasons].sort(),
})),
network_transactions: [...this.omittedNetwork]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([transaction_id, omission]) => ({
transaction_id,
omitted_kinds: [...omission.kinds].sort(),
reasons: [...omission.reasons].sort(),
})),
network_transaction_range:
this.network?.omittedTransactionRange() ?? null,
},
};
}
private push(event: UnindexedEvent): number {
if (this.sealed) return 0;
private push(event: UnindexedEvent): BrowserScenarioEventAdmission {
this.sequence += 1;
if (this.items.length >= MAX_EVENTS) {
this.dropped += 1;
return 0;
}
const cost = allocationCost(event, MAX_EVENT_COST) + 128;
if (cost > MAX_EVENT_COST || this.retainedCost + cost > MAX_CAPTURE_COST) {
this.dropped += 1;
return 0;
const cost =
allocationCost(event, this.limits.max_event_allocation_bytes) + 128;
const reason = this.admissionReason(cost);
if (this.sealed || reason !== undefined) {
const finalReason = reason ?? "max-events";
this.omitEvent(event.kind, finalReason);
return {
status: "omitted",
sequence: this.sequence,
reason: finalReason,
};
}
this.retainedCost += cost;
const parsed = browserScenarioEventSchema.parse({
@@ -222,44 +276,134 @@ export class PlaywrightScenarioEvents {
step_index: this.stepIndex,
});
this.items.push(parsed);
return this.sequence;
return { status: "retained", sequence: this.sequence };
}
private admitText(...values: readonly string[]): boolean {
return this.admitProducerText(values);
private admitText(kind: EventKind, ...values: readonly string[]): boolean {
return this.admitProducerText(kind, values, false).status === "retained";
}
/** Admit conservative output cost before running redaction or cloning. */
private admitProducerText(values: Iterable<string>): boolean {
if (this.sealed) return false;
private admitProducerText(
kind: EventKind,
values: Iterable<string>,
networkMetadata: boolean,
): BrowserScenarioEventAdmission {
let cost = 4096;
for (const value of values) {
cost += this.secrets.redactionAllocationCost(value.length);
if (
cost > MAX_EVENT_COST ||
cost > MAX_CAPTURE_COST - this.retainedCost
) {
this.sequence += 1;
this.dropped += 1;
return false;
}
}
if (
this.items.length >= MAX_EVENTS ||
cost > MAX_EVENT_COST ||
cost > MAX_CAPTURE_COST - this.retainedCost
) {
const reason =
networkMetadata &&
cost > this.limits.max_network_metadata_allocation_bytes
? "max-network-metadata-allocation-bytes"
: this.admissionReason(cost);
if (reason !== undefined) {
this.sequence += 1;
this.dropped += 1;
return false;
this.omitEvent(kind, reason);
return { status: "omitted", sequence: this.sequence, reason };
}
return true;
return { status: "retained", sequence: this.sequence + 1 };
}
private admitOwner(): boolean {
if (this.sealed) return false;
if (this.stopListeners.size < MAX_LISTENER_OWNERS) return true;
private admissionReason(
cost: number,
): BrowserScenarioEventBudgetReason | undefined {
if (this.sealed || this.items.length >= this.limits.max_events)
return "max-events";
if (cost > this.limits.max_event_allocation_bytes)
return "max-event-allocation-bytes";
if (cost > this.limits.max_total_event_allocation_bytes - this.retainedCost)
return "max-total-event-allocation-bytes";
return undefined;
}
private admitContentBody(input: {
readonly transactionId: string;
readonly sourceEventSequence: number;
readonly phase: "request" | "response";
readonly headers: BrowserNetworkHeaders;
readonly observedBytes: number;
readonly allocationBytes: number;
readonly mediaType: string | undefined;
}): BrowserScenarioEventAdmission {
const kind = "network-content";
const eventCost =
allocationCost(
{
kind,
sequence: this.sequence + 1,
step_index: this.stepIndex,
transaction_id: input.transactionId,
source_event_sequence: input.sourceEventSequence,
phase: input.phase,
headers: input.headers,
body: {
state: "captured",
representation:
input.phase === "request"
? "browser-exposed-request-bytes"
: "playwright-response-body-bytes",
encoding: "base64",
content: null,
bytes: input.observedBytes,
sha256: "0".repeat(64),
media_type: null,
redacted: false,
},
},
this.limits.max_event_allocation_bytes,
) -
64 +
input.allocationBytes +
(input.mediaType === undefined
? 0
: Math.max(
input.mediaType.length * 8 + 2,
this.secrets.redactionAllocationCost(input.mediaType.length),
)) +
128;
const reason = this.admissionReason(eventCost);
if (reason !== undefined) {
this.sequence += 1;
this.omitEvent(kind, reason);
return { status: "omitted", sequence: this.sequence, reason };
}
return { status: "retained", sequence: this.sequence + 1 };
}
private omitEvent(
kind: EventKind,
reason: BrowserScenarioEventBudgetReason,
): void {
this.dropped += 1;
let omission = this.omittedKinds.get(kind);
if (omission === undefined) {
omission = { count: 0, reasons: new Set() };
this.omittedKinds.set(kind, omission);
}
omission.count += 1;
omission.reasons.add(reason);
}
private omitNetwork(
transactionId: string,
kind: EventKind,
reason: BrowserScenarioEventBudgetReason,
): void {
let omission = this.omittedNetwork.get(transactionId);
if (omission === undefined) {
omission = { kinds: new Set(), reasons: new Set() };
this.omittedNetwork.set(transactionId, omission);
}
omission.kinds.add(kind);
omission.reasons.add(reason);
}
private admitOwner(kind: EventKind): boolean {
if (this.stopListeners.size < MAX_LISTENER_OWNERS) return true;
this.sequence += 1;
this.omitEvent(kind, "max-listener-owners");
return false;
}
@@ -294,7 +438,8 @@ export class PlaywrightScenarioEvents {
}
if (this.enabled.has("page-errors")) {
const onError = (error: Error) => {
if (!this.admitText(error.message, error.stack ?? "")) return;
if (!this.admitText("page-error", error.message, error.stack ?? ""))
return;
this.push({
kind: "page-error",
message: this.secrets.redact(error.message),
@@ -348,7 +493,8 @@ export class PlaywrightScenarioEvents {
private console(message: ConsoleMessage): void {
const rawText = message.text();
const location = message.location();
if (!this.admitText(rawText, location.url, message.type())) return;
if (!this.admitText("console", rawText, location.url, message.type()))
return;
this.push({
kind: "console",
level: this.secrets.redact(message.type()),
@@ -358,8 +504,8 @@ export class PlaywrightScenarioEvents {
}
private webSocket(socket: WebSocket): void {
if (!this.admitOwner()) return;
if (!this.admitText(socket.url())) return;
if (!this.admitOwner("websocket-opened")) return;
if (!this.admitText("websocket-opened", socket.url())) return;
const url = this.safeUrl(socket.url());
this.push({ kind: "websocket-opened", url });
const onSent = ({ payload }: { payload: string | Buffer }) =>
@@ -396,7 +542,7 @@ export class PlaywrightScenarioEvents {
});
return;
}
if (!this.admitText(payload)) return;
if (!this.admitText(kind, payload)) return;
const text = this.secrets.redact(payload);
this.push({
kind,
@@ -412,7 +558,7 @@ export class PlaywrightScenarioEvents {
kind: "frame-attached" | "frame-detached" | "frame-navigated",
frame: Frame,
): void {
if (!this.admitText(frame.url(), frame.name())) return;
if (!this.admitText(kind, frame.url(), frame.name())) return;
this.push({
kind,
url: frame.url() === "" ? null : this.safeUrl(frame.url()),
@@ -421,7 +567,11 @@ export class PlaywrightScenarioEvents {
}
private worker(worker: Worker): void {
if (!this.admitOwner() || !this.admitText(worker.url())) return;
if (
!this.admitOwner("worker-created") ||
!this.admitText("worker-created", worker.url())
)
return;
const details = {
url: this.safeUrl(worker.url()),
name: null,
@@ -437,7 +587,11 @@ export class PlaywrightScenarioEvents {
private popup(page: Page): void {
if (this.pages.has(page)) return;
if (!this.admitOwner() || !this.admitText(page.url())) return;
if (
!this.admitOwner("popup-opened") ||
!this.admitText("popup-opened", page.url())
)
return;
this.pages.add(page);
for (const family of [
"frames",
@@ -453,7 +607,7 @@ export class PlaywrightScenarioEvents {
if (this.enabled.has("popups")) {
this.push({ kind: "popup-opened", ...opened });
const onClose = () => {
if (!this.admitText(page.url())) return;
if (!this.admitText("popup-closed", page.url())) return;
this.push({
kind: "popup-closed",
url: page.url() === "" ? null : this.safeUrl(page.url()),
@@ -467,7 +621,13 @@ export class PlaywrightScenarioEvents {
}
private download(download: Download): void {
if (this.admitText(download.suggestedFilename(), download.url()))
if (
this.admitText(
"download-cancelled",
download.suggestedFilename(),
download.url(),
)
)
this.push({
kind: "download-cancelled",
suggested_filename: this.secrets.redact(download.suggestedFilename()),
@@ -207,23 +207,29 @@ export class PlaywrightScenarioServiceWorkers {
async assertBlocked(): Promise<void> {
if (this.#session !== undefined) await this.settle();
for (const frame of this.page.frames()) {
let violatesPolicy: unknown;
let failure: unknown;
try {
violatesPolicy = await frame.evaluate(`(async () => {
if (!("serviceWorker" in navigator)) return false;
failure = await frame.evaluate(`(async () => {
if (!("serviceWorker" in navigator)) return null;
const serviceWorker = navigator.serviceWorker;
if (${this.#session !== undefined} &&
(!Object.hasOwn(serviceWorker.register, Symbol.for(${this.marker})) ||
typeof globalThis[${this.restoreKey}] !== "function")) return true;
if (serviceWorker.controller !== null) return true;
return (await serviceWorker.getRegistrations()).length > 0;
typeof globalThis[${this.restoreKey}] !== "function")) return "policy_unprotected";
if (serviceWorker.controller !== null) return "service_worker_blocked";
return (await serviceWorker.getRegistrations()).length > 0
? "service_worker_blocked"
: null;
})()`);
} catch (cause: unknown) {
if (frame.isDetached()) continue;
throw cause;
}
if (violatesPolicy)
if (failure === "policy_unprotected")
throw new BrowserObservationError(OPERATION, "target_not_allowed");
if (failure === "service_worker_blocked")
throw new BrowserObservationError(OPERATION, "service_worker_blocked");
if (failure !== null)
throw new BrowserObservationError(OPERATION, "protocol_error");
}
}
+16 -5
View File
@@ -65,7 +65,16 @@ vi.mock("./PlaywrightScenarioEvents.js", () => ({
}
result() {
return { events: [] };
return {
retained: 0,
dropped: 0,
items: [],
omissions: {
by_kind: [],
network_transactions: [],
network_transaction_range: null,
},
};
}
limitations() {
@@ -105,7 +114,7 @@ const fixture = () => {
const page = Object.assign(new EventEmitter(), {
url: () => "about:blank",
goto: vi.fn(async () => undefined),
evaluate: vi.fn(async (_script: unknown) => false),
evaluate: vi.fn(async (_script: unknown): Promise<unknown> => null),
addInitScript: vi.fn(async (_script: unknown) => undefined),
context: () => context,
frames: () => [page],
@@ -190,7 +199,9 @@ it("stops attached navigation without storage and retries rejected policy cleanu
it("rejects an init script accepted by CDP when the live realm is unprotected", async () => {
const { page, cleanup } = fixture();
page.evaluate.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
page.evaluate
.mockResolvedValueOnce(null)
.mockResolvedValueOnce("policy_unprotected");
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
@@ -484,7 +495,7 @@ it("waits for a pending registration installation before cancelled cleanup", asy
it("rejects attached block when caller-owned registrations already exist", async () => {
const { cleanup, page } = fixture();
page.evaluate.mockResolvedValueOnce(true);
page.evaluate.mockResolvedValueOnce("service_worker_blocked");
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
@@ -499,7 +510,7 @@ it("rejects attached block when caller-owned registrations already exist", async
PlaywrightScenarioSession.open(attached, {}, {}),
).rejects.toMatchObject({
_tag: "BrowserObservationError",
reason: "target_not_allowed",
reason: "service_worker_blocked",
});
expect(page.addInitScript).not.toHaveBeenCalled();
expect(cleanup.close).toHaveBeenCalledTimes(1);
+1
View File
@@ -247,6 +247,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
enabled: new Set(scenario.capture.events),
secrets,
network: scenario.capture.network,
limits: scenario.capture.limits,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
return new PlaywrightScenarioSession(opened, {
+36
View File
@@ -0,0 +1,36 @@
import { expect, it } from "vitest";
import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js";
import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js";
const input = analyzeWebBundleInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
allowed_origins: ["http://127.0.0.1:9222"],
target_id: "page-1",
fetch_source_maps: true,
});
it("reports an empty selected request set without starting fetch work", async () => {
const result = await fetchWebSourceMaps([], input, undefined, {
fetch: async () => {
throw new Error("No source-map request should be made");
},
});
expect(result).toEqual({
status: "not_requested",
requested: 0,
processed: 0,
items: [],
});
});
it("honors cancellation before returning an empty request set", async () => {
const controller = new AbortController();
const reason = new Error("cancelled before fetching");
controller.abort(reason);
await expect(fetchWebSourceMaps([], input, controller.signal)).rejects.toBe(
reason,
);
});
+12
View File
@@ -81,6 +81,14 @@ export const fetchWebSourceMaps = async (
signal?: AbortSignal,
host: SourceMapFetchHost = { fetch: globalThis.fetch },
): Promise<SourceMaps> => {
throwIfAborted(signal);
if (requests.length === 0)
return webSourceMapsSchema.parse({
status: "not_requested",
requested: 0,
processed: 0,
items: [],
});
const items: SourceMapItem[] = [];
const operationController = new AbortController();
const timeout = setTimeout(
@@ -170,6 +178,10 @@ export const fetchWebSourceMaps = async (
});
};
const throwIfAborted = (signal?: AbortSignal): void => {
if (signal?.aborted === true) throw signal.reason;
};
const sourceMapOutputLimitation = (unprocessed: number): string =>
`source-map output budget could not retain results for ${String(unprocessed)} requested map${unprocessed === 1 ? "" : "s"}. their results are unknown.`;
+69 -16
View File
@@ -8,6 +8,10 @@ import type {
BrowserNetworkBody,
BrowserNetworkHeaders,
} from "../../domain/browserNetworkEvidence.js";
import type {
BrowserScenarioEventAdmission,
BrowserScenarioEventBudgetReason,
} from "../../domain/browserScenarioCaptureValues.js";
import type { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js";
import { withPlaywrightExecutionBoundary } from "../PlaywrightExecutionBoundary.js";
@@ -20,9 +24,26 @@ export interface NetworkHeadersSource {
/** A bounded read cannot delay browser cleanup indefinitely. */
export interface NetworkReadOptions {
readonly timeoutMs: number;
readonly maxBodyBytes: number;
readonly maxBodyAllocationBytes: number;
readonly maxHeaderAllocationBytes: number;
readonly maxMetadataAllocationBytes: number;
readonly signal?: AbortSignal;
}
export type NetworkBodyCapture =
| BrowserNetworkBody
| {
readonly state: "omitted";
readonly reason: BrowserScenarioEventBudgetReason;
};
export type NetworkBodyPreflight = (input: {
readonly observedBytes: number;
readonly allocationBytes: number;
readonly mediaType: string | undefined;
}) => BrowserScenarioEventAdmission;
const credentialHeaders = new Set([
"authorization",
"proxy-authorization",
@@ -33,12 +54,14 @@ const credentialHeaders = new Set([
const errorMessage = (
cause: unknown,
secrets: BrowserScenarioSecrets,
maxMetadataAllocationBytes: number,
): string => {
const message =
cause instanceof Error && cause.message !== ""
? cause.message
: "Network read failed";
return secrets.redactionAllocationCost(message.length) <= 1024 * 1024
return secrets.redactionAllocationCost(message.length) <=
maxMetadataAllocationBytes
? secrets.redact(message)
: "Network read failed; diagnostic exceeds the allocation budget.";
};
@@ -58,10 +81,10 @@ export const captureNetworkHeaders = async (
let cost = 0;
for (const { name, value } of headers) {
cost += secrets.redactionAllocationCost(name.length + value.length) + 128;
if (cost > 1024 * 1024)
if (cost > options.maxHeaderAllocationBytes)
return {
state: "unavailable",
message: "Headers exceed the 1 MiB allocation budget.",
message: `Headers exceed the ${String(options.maxHeaderAllocationBytes)}-byte allocation budget.`,
};
}
return {
@@ -80,7 +103,10 @@ export const captureNetworkHeaders = async (
}),
};
} catch (cause: unknown) {
return { state: "unavailable", message: errorMessage(cause, secrets) };
return {
state: "unavailable",
message: errorMessage(cause, secrets, options.maxMetadataAllocationBytes),
};
}
};
@@ -91,7 +117,8 @@ export const captureNetworkBody = async (
source: NetworkHeadersSource,
secrets: BrowserScenarioSecrets,
options: NetworkReadOptions,
): Promise<BrowserNetworkBody> => {
preflight?: NetworkBodyPreflight,
): Promise<NetworkBodyCapture> => {
try {
const observed = await withPlaywrightExecutionBoundary(
read,
@@ -101,29 +128,55 @@ export const captureNetworkBody = async (
if (observed === null) return { state: "not_exposed" };
// This bounds subsequent redaction, base64 and validation copies, but the
// Playwright API itself has already materialized the body (see PR limits).
if (observed.length > 64 * 1024)
const maxBodyBytes = options.maxBodyBytes;
if (observed.length > maxBodyBytes)
return {
state: "unavailable",
reason: "body-read-failed",
message: "Body exceeds the 64 KiB retained-content budget.",
reason: "body-size-limit",
message: `Playwright materialized the body before its ${String(maxBodyBytes)}-byte retained-body budget was checked.`,
limit_bytes: maxBodyBytes,
observed_bytes: observed.length,
};
if (secrets.redactionAllocationCost(observed.length) > 1024 * 1024)
const bodyAllocationCost = secrets.redactionAllocationCost(observed.length);
const base64AllocationCost = Math.ceil(observed.length / 3) * 4 * 8 + 2;
const maxBodyAllocationBytes = options.maxBodyAllocationBytes;
const observedBodyAllocationBytes = Math.max(
bodyAllocationCost,
base64AllocationCost,
);
if (observedBodyAllocationBytes > maxBodyAllocationBytes)
return {
state: "unavailable",
reason: "body-read-failed",
message: "Body redaction exceeds the allocation budget.",
reason: "body-allocation-limit",
message:
"Body redaction exceeds the selected network body allocation budget.",
limit_allocation_bytes: maxBodyAllocationBytes,
observed_allocation_bytes: observedBodyAllocationBytes,
};
const retained = secrets.redactBytes(observed);
const contentType = source.headers()["content-type"];
if (
contentType !== undefined &&
secrets.redactionAllocationCost(contentType.length) > 64 * 1024
secrets.redactionAllocationCost(contentType.length) >
options.maxHeaderAllocationBytes
)
return {
state: "unavailable",
reason: "body-read-failed",
message: "Content type exceeds the retained metadata budget.",
reason: "body-allocation-limit",
message:
"Content type exceeds the selected network header allocation budget.",
limit_allocation_bytes: options.maxHeaderAllocationBytes,
observed_allocation_bytes: secrets.redactionAllocationCost(
contentType.length,
),
};
const admission = preflight?.({
observedBytes: observed.length,
allocationBytes: observedBodyAllocationBytes,
mediaType: contentType,
});
if (admission?.status === "omitted")
return { state: "omitted", reason: admission.reason };
const retained = secrets.redactBytes(observed);
return {
state: "captured",
representation:
@@ -149,7 +202,7 @@ export const captureNetworkBody = async (
: cause instanceof AnalysisTimeoutError
? "body-read-timeout"
: "body-read-failed",
message: errorMessage(cause, secrets),
message: errorMessage(cause, secrets, options.maxMetadataAllocationBytes),
};
}
};
@@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from "vitest";
import { browserScenarioSchema } from "../../domain/browserScenario.js";
import {
type BrowserScenarioEventAdmission,
browserScenarioEventSchema,
type BrowserScenarioEvent,
} from "../../domain/browserScenarioCaptureValues.js";
@@ -100,6 +101,7 @@ const harness = (
selected: BrowserNetworkContentSelection = selection,
signal?: AbortSignal,
secretValue = "私密",
limitsInput: unknown = {},
) => {
const scenario = browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: "/fixture/chrome" },
@@ -108,14 +110,17 @@ const harness = (
secrets: [
{ secret_id: "declared", environment_variable: "FIXTURE_SECRET" },
],
capture: { limits: limitsInput },
});
const secrets = BrowserScenarioSecrets.resolve(scenario, {
FIXTURE_SECRET: secretValue,
});
if (secrets === undefined) throw new Error("Fixture secret was not resolved");
const events: BrowserScenarioEvent[] = [];
const omittedEventCounts = new Map<string, number>();
const collector = new PlaywrightScenarioNetwork({
selection: selected,
limits: scenario.capture.limits,
secrets,
readTimeoutMs: 50,
...(signal === undefined ? {} : { signal }),
@@ -124,10 +129,38 @@ const harness = (
events.push(
browserScenarioEventSchema.parse({ ...event, sequence, step_index: 0 }),
);
return sequence;
return { status: "retained", sequence };
},
admitText: () => ({ status: "retained", sequence: events.length + 1 }),
admitContentBody: () => ({
status: "retained",
sequence: events.length + 1,
}),
omitNetwork: (transactionId, kind, reason) => {
let omission = networkOmissions.get(transactionId);
if (omission === undefined) {
omission = { kinds: new Set(), reasons: new Set() };
networkOmissions.set(transactionId, omission);
}
omission.kinds.add(kind);
omission.reasons.add(reason);
},
omitEvent: (kind) => {
omittedEventCounts.set(kind, (omittedEventCounts.get(kind) ?? 0) + 1);
},
});
return { collector, events, secrets };
const networkOmissions = new Map<
string,
{ kinds: Set<string>; reasons: Set<string> }
>();
return {
collector,
events,
limits: scenario.capture.limits,
secrets,
networkOmissions,
omittedEventCounts,
};
};
const bodyText = (event: BrowserScenarioEvent) => {
@@ -350,6 +383,144 @@ it("does not read unfinished streaming responses and reports the cutoff", async
expect(collector.limitations().length).toBeGreaterThan(0);
});
it("queues completed body reads behind the active-read limit", async () => {
const { collector, events } = harness({
request_body: false,
response_body: true,
header_values: false,
});
const requests = Array.from(
{ length: 20 },
(_, index) => new RequestFixture(`request-${String(index)}`),
);
const releases: Array<(bytes: Buffer) => void> = [];
let activeReads = 0;
let maximumActiveReads = 0;
for (const request of requests) {
const response = new ResponseFixture(request, "unused");
response.read = () =>
new Promise((resolve) => {
activeReads += 1;
maximumActiveReads = Math.max(maximumActiveReads, activeReads);
releases.push((bytes) => {
activeReads -= 1;
resolve(bytes);
});
});
collector.request(request);
collector.response(response);
collector.finished(request);
}
let releasedReads = 0;
while (releasedReads < requests.length) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
const batch = releases.splice(0);
expect(batch.length).toBeGreaterThan(0);
batch.forEach((release) => release(Buffer.from("captured")));
releasedReads += batch.length;
}
await collector.finish();
const content = events.filter(
(event) => event.kind === "network-content" && event.phase === "response",
);
expect(maximumActiveReads).toBe(16);
expect(content).toHaveLength(20);
expect(
content.every(
(event) =>
event.kind === "network-content" && event.body.state === "captured",
),
).toBe(true);
expect(events.filter(({ kind }) => kind === "network-content")).toHaveLength(
20,
);
});
it("starts each queued body's timeout when its read begins", async () => {
vi.useFakeTimers();
const { collector, events } = harness({
request_body: false,
response_body: true,
header_values: false,
});
const requests = Array.from(
{ length: 20 },
(_, index) => new RequestFixture(`request-${String(index)}`),
);
for (const request of requests) {
const response = new ResponseFixture(request, "unused");
response.read = () =>
new Promise((resolve) => {
setTimeout(() => resolve(Buffer.from("captured")), 40);
});
collector.request(request);
collector.response(response);
collector.finished(request);
}
const finishing = collector.finish();
await vi.advanceTimersByTimeAsync(40);
await vi.advanceTimersByTimeAsync(40);
await finishing;
const content = events.filter(
(event) => event.kind === "network-content" && event.phase === "response",
);
expect(content).toHaveLength(20);
expect(
content.every(
(event) =>
event.kind === "network-content" && event.body.state === "captured",
),
).toBe(true);
});
it("settles queued reads as cancelled without starting their body APIs", async () => {
const controller = new AbortController();
const { collector, events } = harness(
{
request_body: false,
response_body: true,
header_values: false,
},
controller.signal,
);
const requests = Array.from(
{ length: 20 },
(_, index) => new RequestFixture(`request-${String(index)}`),
);
const responses = requests.map((request) => {
const response = new ResponseFixture(request, "unused");
response.read = () => new Promise(() => undefined);
return response;
});
requests.forEach((request, index) => {
collector.request(request);
collector.response(responses[index]!);
collector.finished(request);
});
expect(responses.filter(({ bodyReads }) => bodyReads > 0)).toHaveLength(16);
controller.abort();
await collector.finish();
expect(responses.filter(({ bodyReads }) => bodyReads > 0)).toHaveLength(16);
const content = events.filter(
(event) => event.kind === "network-content" && event.phase === "response",
);
expect(content).toHaveLength(20);
expect(
content.every(
(event) =>
event.kind === "network-content" &&
event.body.state === "unavailable" &&
event.body.reason === "cancelled",
),
).toBe(true);
});
it("bounds hung completed-body reads and rejects late mutation after finalization", async () => {
vi.useFakeTimers();
const { collector, events } = harness();
@@ -554,9 +725,9 @@ it("bounds admitted transactions before invoking content producers", async () =>
await collector.finish();
expect(admitted[0]?.bodyReads).toBe(1);
expect(
admitted
.slice(16)
.every((request) => request.bodyReads === 0 && request.headerReads === 0),
admitted.every(
(request) => request.bodyReads === 1 && request.headerReads === 1,
),
).toBe(true);
expect(rejected.bodyReads).toBe(0);
expect(rejected.headerReads).toBe(0);
@@ -568,6 +739,55 @@ it("bounds admitted transactions before invoking content producers", async () =>
expect(collector.limitations().join(" ")).toContain("resource budget");
});
it("uses the caller's transaction limit and reports the exact omitted ID range", async () => {
const { collector, events, omittedEventCounts } = harness(
selection,
undefined,
"fixture",
{ max_network_transactions: 2 },
);
const requests = Array.from(
{ length: 4 },
(_, index) => new RequestFixture(`body-${String(index)}`),
);
for (const request of requests) collector.request(request);
collector.response(new ResponseFixture(requests[2]!, "body-2"));
collector.finished(requests[2]!);
await collector.finish();
expect(collector.omittedTransactionRange()).toEqual({
first_transaction_id: "request-3",
last_transaction_id: "request-4",
count: 2,
reason: "max-network-transactions",
});
expect(events.filter(({ kind }) => kind === "request")).toHaveLength(2);
expect(Object.fromEntries(omittedEventCounts)).toEqual({
request: 2,
"network-content": 3,
response: 1,
"request-finished": 1,
});
expect(requests[2]?.bodyReads).toBe(0);
});
it("uses the caller's body-byte limit and returns typed size evidence", async () => {
const { collector, events } = harness(selection, undefined, "fixture", {
max_network_body_bytes: 4,
});
const request = new RequestFixture("12345");
collector.request(request);
await collector.finish();
expect(events.find(({ kind }) => kind === "network-content")).toMatchObject({
transaction_id: "request-1",
body: {
state: "unavailable",
reason: "body-size-limit",
limit_bytes: 4,
observed_bytes: 5,
},
});
});
it("keeps header names and selected body when unselected values are oversized", async () => {
const { collector, events } = harness({
request_body: true,
@@ -605,6 +825,9 @@ it("omits oversized body copies and retains ordinary content", async () => {
const content = events.filter((event) => event.kind === "network-content");
expect(content[0]?.body).toMatchObject({
state: "unavailable",
reason: "body-size-limit",
limit_bytes: 64 * 1024,
observed_bytes: 64 * 1024 + 1,
message: expect.stringContaining("budget"),
});
expect(bodyText(content[1]!)).toBe("ordinary");
@@ -628,13 +851,24 @@ it("reserves redaction expansion before creating replacement copies", async () =
});
it("does not read content when phase metadata was omitted", async () => {
const { secrets } = harness();
const { secrets, limits } = harness();
const redact = vi.spyOn(secrets, "redact");
const collector = new PlaywrightScenarioNetwork({
selection,
limits,
secrets,
admitText: () => false,
emit: () => 0,
admitText: () => ({
status: "omitted",
sequence: 1,
reason: "max-total-event-allocation-bytes",
}),
emit: (): BrowserScenarioEventAdmission => ({
status: "retained",
sequence: 1,
}),
admitContentBody: () => ({ status: "retained", sequence: 1 }),
omitNetwork: () => undefined,
omitEvent: () => undefined,
});
const request = new RequestFixture("ordinary");
collector.request(request);
@@ -642,7 +876,7 @@ it("does not read content when phase metadata was omitted", async () => {
expect(redact).not.toHaveBeenCalled();
expect(request.bodyReads + request.headerReads).toBe(0);
expect(collector.limitations().join(" ")).toContain(
"aggregate browser event budget",
"max-total-event-allocation-bytes",
);
expect(collector.limitations().join(" ")).toContain("incomplete");
});
+312 -92
View File
@@ -1,12 +1,19 @@
import type {
BrowserNetworkBody,
BrowserNetworkHeaders,
BrowserNetworkContentSelection,
} from "../../domain/browserNetworkEvidence.js";
import type { BrowserScenarioCaptureLimits } from "../../domain/browserScenarioCaptureLimits.js";
import type {
BrowserScenarioEventAdmission,
BrowserScenarioEventBudgetReason,
} from "../../domain/browserScenarioCaptureValues.js";
import type { BrowserScenarioEvent } from "../../domain/browserScenarioCaptureValues.js";
import type { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js";
import {
captureNetworkBody,
captureNetworkHeaders,
type NetworkBodyCapture,
type NetworkHeadersSource,
type NetworkReadOptions,
} from "./PlaywrightNetworkContent.js";
@@ -56,12 +63,42 @@ interface Transaction {
requestObserved: boolean;
}
interface QueuedContentRead {
readonly run: () => Promise<void>;
readonly resolve: () => void;
readonly reject: (cause: unknown) => void;
}
interface NetworkCaptureOptions {
readonly selection: BrowserNetworkContentSelection;
readonly limits: BrowserScenarioCaptureLimits;
readonly secrets: BrowserScenarioSecrets;
readonly emit: (event: UnindexedNetworkEvent) => number;
readonly emit: (
event: UnindexedNetworkEvent,
) => BrowserScenarioEventAdmission;
/** Check aggregate event capacity before redacting producer-owned metadata. */
readonly admitText?: (values: Iterable<string>) => boolean;
readonly admitText: (
kind: UnindexedNetworkEvent["kind"],
values: Iterable<string>,
) => BrowserScenarioEventAdmission;
readonly admitContentBody: (input: {
readonly transactionId: string;
readonly sourceEventSequence: number;
readonly phase: "request" | "response";
readonly headers: BrowserNetworkHeaders;
readonly observedBytes: number;
readonly allocationBytes: number;
readonly mediaType: string | undefined;
}) => BrowserScenarioEventAdmission;
readonly omitNetwork: (
transactionId: string,
kind: UnindexedNetworkEvent["kind"],
reason: BrowserScenarioEventBudgetReason,
) => void;
readonly omitEvent: (
kind: UnindexedNetworkEvent["kind"],
reason: BrowserScenarioEventBudgetReason,
) => void;
readonly signal?: AbortSignal;
/** Production uses 5s; a fixture can supply its own bounded-read clock. */
readonly readTimeoutMs?: number;
@@ -73,15 +110,33 @@ export class PlaywrightScenarioNetwork {
ScenarioNetworkRequest,
Transaction
>();
private readonly omittedRequests = new WeakSet<ScenarioNetworkRequest>();
private readonly omittedPhases = new WeakMap<
ScenarioNetworkRequest,
Set<string>
>();
private totalTransactions = 0;
private omittedTransactionCount = 0;
private firstOmittedTransaction = 0;
private lastOmittedTransaction = 0;
/** One request and one response-content job at most per admitted transaction. */
private readonly pending: Promise<void>[] = [];
private readonly queuedReads: QueuedContentRead[] = [];
private readonly gaps = new Set<string>();
private accepting = true;
private activeReads = 0;
private readonly readOptions: NetworkReadOptions;
private readonly limits: BrowserScenarioCaptureLimits;
constructor(private readonly options: NetworkCaptureOptions) {
this.limits = options.limits;
this.readOptions = {
timeoutMs: options.readTimeoutMs ?? 5_000,
maxBodyBytes: this.limits.max_network_body_bytes,
maxBodyAllocationBytes: this.limits.max_network_body_allocation_bytes,
maxHeaderAllocationBytes: this.limits.max_network_header_allocation_bytes,
maxMetadataAllocationBytes:
this.limits.max_network_metadata_allocation_bytes,
...(options.signal === undefined ? {} : { signal: options.signal }),
};
}
@@ -90,30 +145,59 @@ export class PlaywrightScenarioNetwork {
request(request: ScenarioNetworkRequest): void {
if (!this.accepting) return;
const transaction = this.transaction(request);
if (transaction === undefined || transaction.requestObserved) return;
if (!this.admitMetadata(request)) return;
if (transaction === undefined) {
this.omitOmittedTransactionPhase(request, "request");
if (
this.options.selection.request_body ||
this.options.selection.header_values
)
this.omitOmittedTransactionContent(request, "request");
return;
}
if (transaction.requestObserved) return;
if (!this.admitMetadata(transaction, "request", request)) return;
transaction.requestObserved = true;
const sequence = this.options.emit({
const admission = this.options.emit({
...this.metadata(request, transaction),
kind: "request",
status: null,
failure: null,
});
if (sequence === 0) return;
if (admission.status === "omitted") {
this.options.omitNetwork(transaction.id, "request", admission.reason);
return;
}
const sequence = admission.sequence;
if (
this.options.selection.request_body ||
this.options.selection.header_values
)
this.collect(transaction, sequence, "request", request, () =>
this.options.selection.request_body
? captureNetworkBody(
async () => request.postDataBuffer(),
"request",
request,
this.options.secrets,
this.readOptions,
)
: Promise.resolve({ state: "not_requested" }),
this.collect(
transaction,
sequence,
"request",
request,
(headers) =>
this.options.selection.request_body
? captureNetworkBody(
async () => request.postDataBuffer(),
"request",
request,
this.options.secrets,
this.readOptions,
(body) =>
this.options.admitContentBody({
transactionId: transaction.id,
sourceEventSequence: sequence,
phase: "request",
headers,
observedBytes: body.observedBytes,
allocationBytes: body.allocationBytes,
mediaType: body.mediaType,
}),
)
: Promise.resolve({ state: "not_requested" }),
this.options.selection.header_values,
);
}
@@ -122,9 +206,15 @@ export class PlaywrightScenarioNetwork {
if (!this.accepting) return;
const request = response.request();
const transaction = this.transaction(request);
if (transaction === undefined) {
this.omitOmittedTransactionPhase(request, "response");
this.observeOmittedTransactionPhase(request, "response");
return;
}
if (
transaction === undefined ||
!this.admitMetadata(
transaction,
"response",
request,
undefined,
[],
@@ -132,7 +222,7 @@ export class PlaywrightScenarioNetwork {
)
)
return;
const sequence = this.options.emit({
const admission = this.options.emit({
...this.metadata(request, transaction),
kind: "response",
url: this.options.secrets.sanitizeUrl(response.url()),
@@ -140,7 +230,11 @@ export class PlaywrightScenarioNetwork {
header_names: this.headerNames(response),
failure: null,
});
if (sequence === 0) return;
if (admission.status === "omitted") {
this.options.omitNetwork(transaction.id, "response", admission.reason);
return;
}
const sequence = admission.sequence;
transaction.response = { source: response, sequence };
}
@@ -148,15 +242,31 @@ export class PlaywrightScenarioNetwork {
finished(request: ScenarioNetworkRequest): void {
if (!this.accepting) return;
const transaction = this.transaction(request);
if (transaction === undefined || !this.admitMetadata(request)) return;
if (transaction === undefined) {
this.omitOmittedTransactionPhase(request, "request-finished");
const observed = this.omittedPhases.get(request);
if (
observed?.has("response") &&
(this.options.selection.response_body ||
this.options.selection.header_values)
)
this.omitOmittedTransactionContent(request, "response");
return;
}
if (!this.admitMetadata(transaction, "request-finished", request)) return;
transaction.terminal = "finished";
const sequence = this.options.emit({
const admission = this.options.emit({
...this.metadata(request, transaction),
kind: "request-finished",
status: null,
failure: null,
});
if (sequence === 0) {
if (admission.status === "omitted") {
this.options.omitNetwork(
transaction.id,
"request-finished",
admission.reason,
);
transaction.responseScheduled = true;
return;
}
@@ -167,18 +277,31 @@ export class PlaywrightScenarioNetwork {
failed(request: ScenarioNetworkRequest): void {
if (!this.accepting) return;
const transaction = this.transaction(request);
if (transaction === undefined) return;
if (transaction === undefined) {
this.omitOmittedTransactionPhase(request, "request-failed");
return;
}
transaction.terminal = "failed";
const failure = request.failure()?.errorText || "Unknown request failure";
if (!this.admitMetadata(request, undefined, [failure])) return;
if (
!this.admitMetadata(transaction, "request-failed", request, undefined, [
failure,
])
)
return;
const message = this.options.secrets.redact(failure);
const sequence = this.options.emit({
const admission = this.options.emit({
...this.metadata(request, transaction),
kind: "request-failed",
status: null,
failure: message,
});
if (sequence === 0) {
if (admission.status === "omitted") {
this.options.omitNetwork(
transaction.id,
"request-failed",
admission.reason,
);
transaction.responseScheduled = true;
return;
}
@@ -189,20 +312,21 @@ export class PlaywrightScenarioNetwork {
});
}
/** Stop observing, mark unfinished transactions, and settle bounded content reads. */
/** Stop observing, mark unfinished transactions, and drain admitted content reads. */
async finish(): Promise<void> {
if (!this.accepting) {
await Promise.all(this.pending);
await this.settlePendingReads();
return;
}
this.accepting = false;
for (const [request, transaction] of this.transactions) {
if (transaction.terminal === undefined) {
if (!this.admitMetadata(request)) continue;
if (!this.admitMetadata(transaction, "request-unfinished", request))
continue;
this.gaps.add(
"Some network requests had not completed when capture ended; later activity is unknown.",
);
const sequence = this.options.emit({
const admission = this.options.emit({
...this.metadata(request, transaction),
kind: "request-unfinished",
status: null,
@@ -212,7 +336,12 @@ export class PlaywrightScenarioNetwork {
? "cancelled"
: "capture-ended",
});
if (sequence === 0) {
if (admission.status === "omitted") {
this.options.omitNetwork(
transaction.id,
"request-unfinished",
admission.reason,
);
transaction.responseScheduled = true;
continue;
}
@@ -227,7 +356,15 @@ export class PlaywrightScenarioNetwork {
});
}
}
await Promise.all(this.pending);
await this.settlePendingReads();
}
private async settlePendingReads(): Promise<void> {
const results = await Promise.allSettled(this.pending);
const failed = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (failed !== undefined) throw failed.reason;
}
/** Missing selected content contributes to aggregate completeness. */
@@ -235,14 +372,38 @@ export class PlaywrightScenarioNetwork {
return [...this.gaps];
}
omittedTransactionRange(): {
readonly first_transaction_id: string;
readonly last_transaction_id: string;
readonly count: number;
readonly reason: "max-network-transactions";
} | null {
return this.omittedTransactionCount === 0
? null
: {
first_transaction_id: `request-${String(this.firstOmittedTransaction)}`,
last_transaction_id: `request-${String(this.lastOmittedTransaction)}`,
count: this.omittedTransactionCount,
reason: "max-network-transactions",
};
}
private transaction(
request: ScenarioNetworkRequest,
): Transaction | undefined {
const known = this.transactions.get(request);
if (known !== undefined) return known;
if (this.transactions.size >= 1024) {
if (this.omittedRequests.has(request)) return undefined;
this.totalTransactions += 1;
const id = `request-${String(this.totalTransactions)}`;
if (this.transactions.size >= this.limits.max_network_transactions) {
this.omittedRequests.add(request);
if (this.omittedTransactionCount === 0)
this.firstOmittedTransaction = this.totalTransactions;
this.lastOmittedTransaction = this.totalTransactions;
this.omittedTransactionCount += 1;
this.gaps.add(
"Network collection reached its 1024-transaction resource budget; further transactions were omitted.",
`Network collection reached its ${String(this.limits.max_network_transactions)}-transaction resource budget; omitted transaction IDs are listed in events.omissions.network_transaction_range.`,
);
return undefined;
}
@@ -250,7 +411,7 @@ export class PlaywrightScenarioNetwork {
const predecessorTransaction =
predecessor === null ? undefined : this.transactions.get(predecessor);
const transaction: Transaction = {
id: `request-${this.transactions.size + 1}`,
id,
redirectedFrom: predecessorTransaction?.id ?? null,
redirectedFromState:
predecessor === null
@@ -265,27 +426,58 @@ export class PlaywrightScenarioNetwork {
return transaction;
}
private admitProducerStrings(values: Iterable<string>): boolean {
const collected: string[] = [];
let cost = 0;
for (const value of values) {
cost += this.options.secrets.redactionAllocationCost(value.length) + 128;
if (cost > 1024 * 1024) {
this.gaps.add(
"Some network metadata exceeded its allocation budget and was omitted.",
);
return false;
}
collected.push(value);
}
if (this.options.admitText?.(collected) ?? true) return true;
private admitProducerStrings(
transaction: Transaction,
kind: UnindexedNetworkEvent["kind"],
values: Iterable<string>,
): boolean {
const admission = this.options.admitText(kind, values);
if (admission.status === "retained") return true;
this.options.omitNetwork(transaction.id, kind, admission.reason);
this.gaps.add(
"Some network metadata was omitted because the aggregate browser event budget was reached; affected transactions are incomplete.",
`Network ${kind} metadata was omitted for ${transaction.id} because ${admission.reason} was reached; network coverage is incomplete.`,
);
return false;
}
private omitOmittedTransactionPhase(
request: ScenarioNetworkRequest,
kind: UnindexedNetworkEvent["kind"],
): void {
if (!this.omittedRequests.has(request)) return;
if (!this.observeOmittedTransactionPhase(request, kind)) return;
this.options.omitEvent(kind, "max-network-transactions");
}
private observeOmittedTransactionPhase(
request: ScenarioNetworkRequest,
phase: string,
): boolean {
let observed = this.omittedPhases.get(request);
if (observed === undefined) {
observed = new Set();
this.omittedPhases.set(request, observed);
}
if (observed.has(phase)) return false;
observed.add(phase);
return true;
}
private omitOmittedTransactionContent(
request: ScenarioNetworkRequest,
phase: "request" | "response",
): void {
if (!this.omittedRequests.has(request)) return;
if (
!this.observeOmittedTransactionPhase(request, `network-content:${phase}`)
)
return;
this.options.omitEvent("network-content", "max-network-transactions");
}
private admitMetadata(
transaction: Transaction,
kind: UnindexedNetworkEvent["kind"],
source: NetworkHeadersSource,
url?: string,
additionalStrings: readonly string[] = [],
@@ -295,6 +487,8 @@ export class PlaywrightScenarioNetwork {
}[] = [],
): boolean {
return this.admitProducerStrings(
transaction,
kind,
this.metadataStrings(source, url, additionalStrings, additionalSources),
);
}
@@ -379,7 +573,7 @@ export class PlaywrightScenarioNetwork {
response.sequence,
"response",
response.source,
() =>
(headers) =>
this.options.selection.response_body
? unavailable === undefined
? captureNetworkBody(
@@ -388,9 +582,20 @@ export class PlaywrightScenarioNetwork {
response.source,
this.options.secrets,
this.readOptions,
(body) =>
this.options.admitContentBody({
transactionId: transaction.id,
sourceEventSequence: response.sequence,
phase: "response",
headers,
observedBytes: body.observedBytes,
allocationBytes: body.allocationBytes,
mediaType: body.mediaType,
}),
)
: Promise.resolve(unavailable)
: Promise.resolve({ state: "not_requested" }),
this.options.selection.header_values,
);
}
@@ -399,54 +604,69 @@ export class PlaywrightScenarioNetwork {
sequence: number,
phase: "request" | "response",
source: NetworkHeadersSource,
body: () => Promise<BrowserNetworkBody>,
body: (headers: BrowserNetworkHeaders) => Promise<NetworkBodyCapture>,
includeHeaders: boolean,
): void {
if (this.activeReads >= 16) {
const message = "Network content exceeds the 16 concurrent-read budget.";
this.gaps.add(message);
this.options.emit({
const collect = async (): Promise<void> => {
const retainedHeaders = includeHeaders
? await captureNetworkHeaders(
source,
this.options.secrets,
this.readOptions,
)
: { state: "not_requested" as const };
const retainedBody = await body(retainedHeaders);
if (retainedBody.state === "omitted") {
this.options.omitNetwork(
transaction.id,
"network-content",
retainedBody.reason,
);
return;
}
if (
retainedHeaders.state === "unavailable" ||
retainedBody.state === "unavailable" ||
retainedBody.state === "not_exposed"
)
this.gaps.add(
"Some selected network content was unavailable; per-transaction content states retain the reason.",
);
const admission = this.options.emit({
kind: "network-content",
transaction_id: transaction.id,
source_event_sequence: sequence,
phase,
headers: this.options.selection.header_values
? { state: "unavailable", message }
: { state: "not_requested" },
body: this.options.selection[
phase === "request" ? "request_body" : "response_body"
]
? { state: "unavailable", reason: "body-read-failed", message }
: { state: "not_requested" },
headers: retainedHeaders,
body: retainedBody,
});
return;
}
this.activeReads += 1;
const headers = this.options.selection.header_values
? captureNetworkHeaders(source, this.options.secrets, this.readOptions)
: Promise.resolve({ state: "not_requested" } as const);
if (admission.status === "omitted")
this.options.omitNetwork(
transaction.id,
"network-content",
admission.reason,
);
};
this.pending.push(
Promise.all([headers, body()])
.then(([retainedHeaders, retainedBody]) => {
if (
retainedHeaders.state === "unavailable" ||
retainedBody.state === "unavailable" ||
retainedBody.state === "not_exposed"
)
this.gaps.add(
"Some selected network content was unavailable; per-transaction content states retain the reason.",
);
this.options.emit({
kind: "network-content",
transaction_id: transaction.id,
source_event_sequence: sequence,
phase,
headers: retainedHeaders,
body: retainedBody,
});
})
.finally(() => {
this.activeReads -= 1;
}),
new Promise<void>((resolve, reject) => {
this.queuedReads.push({ run: collect, resolve, reject });
this.pumpContentReads();
}),
);
}
private pumpContentReads(): void {
while (this.activeReads < 16 && this.queuedReads.length > 0) {
const job = this.queuedReads.shift();
if (job === undefined) return;
this.activeReads += 1;
void job
.run()
.then(job.resolve, job.reject)
.finally(() => {
this.activeReads -= 1;
this.pumpContentReads();
});
}
}
}
+15 -9
View File
@@ -1,7 +1,6 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { z } from "zod";
import { PROMPT_CONTRACTS } from "./contracts/promptContracts.js";
import type { PromptContract } from "./contracts/promptContracts.js";
@@ -9,11 +8,15 @@ import { TOOL_CONTRACTS } from "./contracts/toolContracts.js";
import type { ToolContract } from "./contracts/toolContractTypes.js";
import { CLI_COMMAND_NAMES } from "./cliCommandNames.js";
import { compareUnicodeCodePoints } from "./domain/unicodeCodePointOrder.js";
import { advertisedInputJsonSchema } from "./contracts/toolSchemaMetadata.js";
import {
advertisedInputJsonSchema,
toolOutputSchemaWithMetadata,
} from "./contracts/toolSchemaMetadata.js";
import {
presentAdvertisedInputSchema,
type CompactInputSchemaPresentation,
} from "./contracts/compactInputSchema.js";
import type { McpOutputSchemaProfile } from "./config/mcpOutputSchemaProfile.js";
const sortByName = (
contracts: readonly ToolContract[],
@@ -46,12 +49,9 @@ const createToolCatalog = (
advertisedInputJsonSchema(contract),
compactPresentation,
),
output_schema: {
type: "object",
...z.toJSONSchema(contract.outputSchema, {
unrepresentable: "any",
}),
},
output_schema: toolOutputSchemaWithMetadata(contract)[
"~standard"
].jsonSchema.output({ target: "draft-2020-12" }),
}));
const createPromptCatalog = (contracts: readonly PromptContract[]) =>
@@ -84,8 +84,12 @@ export const createCatalogDigests = (
contracts: readonly ToolContract[] = TOOL_CONTRACTS,
promptContracts: readonly PromptContract[] = PROMPT_CONTRACTS,
compactPresentation: CompactInputSchemaPresentation | undefined = undefined,
outputSchemaProfile: McpOutputSchemaProfile = "full",
) => {
const tools = createToolCatalog(sortByName(contracts), compactPresentation);
const tools = {
output_schema_profile: outputSchemaProfile,
catalog: createToolCatalog(sortByName(contracts), compactPresentation),
};
const prompts = createPromptCatalog(promptContracts);
return {
tools_sha256: digest(tools),
@@ -100,6 +104,7 @@ export const createCatalogDigests = (
export const createCatalogIdentity = (
compactPresentation: CompactInputSchemaPresentation | undefined = undefined,
outputSchemaProfile: McpOutputSchemaProfile = "full",
) => {
const selectedPresentation =
compactPresentation === undefined
@@ -117,6 +122,7 @@ export const createCatalogIdentity = (
TOOL_CONTRACTS,
PROMPT_CONTRACTS,
selectedPresentation,
outputSchemaProfile,
));
},
} as const;
+24 -5
View File
@@ -1,5 +1,6 @@
import { z } from "incur";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js";
import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js";
import { createAdbDeviceAnalysisProvider } from "../composition/adb.js";
@@ -351,12 +352,30 @@ export const registerAdbCommands = (
),
}),
run: ({ args, options }) => {
const extras = options.extra.map((entry) => {
const extras: {
readonly type: "string" | "boolean" | "int" | "long" | "float";
readonly key: string;
readonly value: string;
}[] = [];
for (const [index, entry] of options.extra.entries()) {
const first = entry.indexOf(":");
const second = entry.indexOf(":", first + 1);
if (first === -1 || second === -1)
throw new Error(`--extra expects type:key:value, received: ${entry}`);
return {
return logCliCommand(
logger,
CLI_COMMANDS.startAdbActivity,
async () => {
throw new AnalysisInputError("start_adb_activity", undefined, [
{
path: ["extras", index],
reason: "invalid_format",
message: "Expected --extra in type:key:value form.",
expected: "type:key:value",
},
]);
},
);
extras.push({
type: entry.slice(0, first) as
| "string"
| "boolean"
@@ -365,8 +384,8 @@ export const registerAdbCommands = (
| "float",
key: entry.slice(first + 1, second),
value: entry.slice(second + 1),
};
});
});
}
return execute(CLI_COMMANDS.startAdbActivity, "start_adb_activity", {
serial: args.serial,
action: args.action,
+11 -3
View File
@@ -45,10 +45,18 @@ export const registerCutterCommands = (
.refine((value) => !/[\r\n]/u.test(value)),
}),
options: z.object({
jsonOutput: z
.boolean()
.optional()
.describe(
"Return command-specific JSON output when available (independent of CLI output formatting).",
),
json: z
.boolean()
.default(false)
.describe("Return command-specific JSON output when available."),
.optional()
.describe(
"Deprecated compatibility input: use --json-output; pass --json=true or --json=false (bare --json formats CLI output).",
),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
@@ -58,7 +66,7 @@ export const registerCutterCommands = (
session_id: args.sessionId,
expected_generation: args.expectedGeneration,
command: args.command,
json: options.json,
json: options.jsonOutput ?? options.json ?? false,
},
{ signal },
);
+15
View File
@@ -215,6 +215,21 @@ export const registerFridaCommands = (
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.instrumentWithFrida, async () => {
if (args.mode === "attach" && options.argument !== undefined)
return projectAnalysisError(
new AnalysisInputError(
CLI_COMMANDS.instrumentWithFrida,
undefined,
[
{
path: ["argument"],
reason: "invalid_value",
message:
"--argument is only valid in spawn mode; remove it when attaching to a process.",
},
],
),
);
if (
(options.script === undefined) ===
(options.scriptFile === undefined)
@@ -4,7 +4,7 @@ export const API_SURFACE_EXAMPLE = {
source: {
kind: "retained-evidence",
evidence_id:
"ev_9e218c18bc6159ec9c8492a9130c286496fb1b6d9f03ec2f4ea3321ce2b15097",
"ev_90073e9478eada1e2cc351b523e6ba9231c9b4bc9708773567b880094db9d4c7",
},
view: {
kind: "api-surface",
@@ -14,12 +14,12 @@ export const API_SURFACE_EXAMPLE = {
},
output: {
parent_evidence_id:
"ev_9e218c18bc6159ec9c8492a9130c286496fb1b6d9f03ec2f4ea3321ce2b15097",
"ev_90073e9478eada1e2cc351b523e6ba9231c9b4bc9708773567b880094db9d4c7",
parent_operation: "inspect_web_network_capture",
parent_digest:
"9e218c18bc6159ec9c8492a9130c286496fb1b6d9f03ec2f4ea3321ce2b15097",
"90073e9478eada1e2cc351b523e6ba9231c9b4bc9708773567b880094db9d4c7",
view_digest:
"47c1859b7a86dd10770061767300bdb6ec177a9254e8dc7863d5ea8603343da6",
"c9494341920dc4cff6ecc6d8ed2c5e3abf15976642c253248e220d45f290ca7b",
artifact: {
path: "/fixtures/api-surface.har",
sha256:
+33 -24
View File
@@ -1,12 +1,22 @@
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
import {
artifactGraphDigest,
artifactIdForContent,
artifactManifestId,
occurrenceIdForLocation,
} from "../domain/artifactIdentity.js";
import type {
ArtifactEdge,
ArtifactNode,
ArtifactOccurrence,
IntegrityContradiction,
} from "../domain/artifactGraph.js";
import { createEvidence } from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
const inventory = (digit: string) => {
const sha = digit.repeat(64);
const artifactId = `art_${digestCanonicalValue({ sha256: sha }, "Artifact example")}`;
const occurrenceId = `occ_${digestCanonicalValue({ root: artifactId }, "Artifact example")}`;
const nodes = [
const artifactId = artifactIdForContent(sha);
const nodes: ArtifactNode[] = [
{
artifact_id: artifactId,
format: "file",
@@ -18,9 +28,13 @@ const inventory = (digit: string) => {
limitations: [],
},
];
const occurrences = [
const occurrences: ArtifactOccurrence[] = [
{
occurrence_id: occurrenceId,
occurrence_id: occurrenceIdForLocation({
rootArtifactId: artifactId,
logicalPath: ".",
entryKind: "file",
}),
artifact_id: artifactId,
parent_occurrence_id: null,
logical_path: ".",
@@ -36,36 +50,31 @@ const inventory = (digit: string) => {
limitations: [],
},
];
const graphSha256 = digestCanonicalValue(
{
nodes,
occurrences,
edges: [],
integrity_contradictions: [],
},
"Artifact example",
);
const edges: ArtifactEdge[] = [];
const integrityContradictions: IntegrityContradiction[] = [];
const graphSha256 = artifactGraphDigest({
nodes,
occurrences,
edges,
contradictions: integrityContradictions,
});
return jsonValueSchema.parse({
manifest: {
manifest_id: `agm_${digestCanonicalValue(
{
root_artifact_id: artifactId,
graph_sha256: graphSha256,
},
"Artifact example",
)}`,
manifest_id: artifactManifestId(artifactId, graphSha256),
root_artifact_id: artifactId,
root_sha256: sha,
root_format: "file",
graph_sha256: graphSha256,
node_count: nodes.length,
occurrence_count: occurrences.length,
edge_count: 0,
edge_count: edges.length,
},
nodes,
occurrences,
edges: [],
edges,
provenance: [],
integrity_contradictions: integrityContradictions,
coverage: { scope: "root", status: "complete" },
limitations: [],
});
};
@@ -26,7 +26,7 @@ export const BROWSER_SCENARIO_TOOL_CONTRACTS = [
name: "capture_browser_scenario",
...toolContractMetadata("capture_browser_scenario"),
description:
"Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, and a URL-only final capture are defaults. capture.network selects request_body, response_body, and header_values independently; selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Results are returned inline.",
"Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, a URL-only final capture, and measured event-retention budgets are defaults. capture.network selects request_body, response_body, and header_values independently; capture.limits lets callers raise or lower the event count, retained-allocation estimates, network transaction count, and network content budgets. Selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Budget omissions identify affected event kinds and network transactions. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Results are returned inline.",
kind: "browser-provider",
inputSchema: browserScenarioInputSchema,
outputSchema: evidenceResultOf(browserScenarioCaptureSchema),
+12 -1
View File
@@ -23,6 +23,7 @@ import {
browserCaptureComparisonInputSchema,
browserCaptureComparisonSchema,
} from "../domain/browserCaptureComparison.js";
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js";
import {
captureWebScreenshotInputSchema,
compareWebScreenshotsInputSchema,
@@ -85,6 +86,7 @@ const exampleScenarioCapture = () => ({
header_values: false,
},
event_families: [],
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
},
duration_ms: 10,
steps: [
@@ -117,7 +119,16 @@ const exampleScenarioCapture = () => ({
completeness: scenarioCompleteness,
},
],
events: { retained: 0, dropped: 0, items: [] },
events: {
retained: 0,
dropped: 0,
items: [],
omissions: {
by_kind: [],
network_transactions: [],
network_transaction_range: null,
},
},
completeness: scenarioCompleteness,
limitations: [],
});
+6 -1
View File
@@ -263,7 +263,12 @@ describe("compact input schema budget and reduction", () => {
expect(presentation).toBe("reduced");
expect(schema.properties).toEqual({
capture: { type: "object", description: "Complete capture." },
observations: { type: "array" },
observations: {
type: "array",
items: {
type: ["null", "boolean", "object", "array", "number", "string"],
},
},
label: { type: ["string", "null"] },
});
const validate = new Ajv2020({ strict: false }).compile(schema);
+9 -1
View File
@@ -289,7 +289,15 @@ const reduceToShallowProperties = (
: {};
return [
name,
{ ...(type === undefined ? {} : { type }), ...description },
{
...(type === undefined ? {} : { type }),
// Reduced inputs intentionally omit nested validation, but clients
// still require an item schema to accept an array declaration.
...(types?.includes("array")
? { items: { type: jsonValueTypes } }
: {}),
...description,
},
];
}),
);
+5 -4
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { localPathStringSchema } from "../domain/localPath.js";
import type { ToolContract } from "./toolContractTypes.js";
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
import { toolContractMetadata } from "./toolEffects.js";
@@ -122,7 +123,7 @@ export const FRIDA_TOOL_CONTRACTS = [
mode: z.enum(["attach", "spawn"]),
...deviceSelector.shape,
pid: z.number().int().positive().optional(),
program: z.string().min(1).optional(),
program: localPathStringSchema.optional(),
argv: z.array(z.string()).optional(),
})
.refine(
@@ -156,7 +157,7 @@ export const FRIDA_TOOL_CONTRACTS = [
session_id: sessionId,
source_kind: z.enum(["inline", "file"]),
source: z.string().optional(),
path: z.string().min(1).optional(),
path: localPathStringSchema.optional(),
})
.refine(
(value) =>
@@ -271,11 +272,11 @@ export const FRIDA_TOOL_CONTRACTS = [
mode: z.enum(["attach", "spawn"]),
...deviceSelector.shape,
pid: z.number().int().positive().optional(),
program: z.string().min(1).optional(),
program: localPathStringSchema.optional(),
argv: z.array(z.string()).optional(),
source_kind: z.enum(["inline", "file"]),
source: z.string().optional(),
path: z.string().min(1).optional(),
path: localPathStringSchema.optional(),
duration_ms: z.number().int().min(0).max(60_000).default(1_000),
})
.refine(
+2 -3
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { localPathStringSchema } from "../domain/localPath.js";
import type { ToolContract } from "./toolContractTypes.js";
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
import { toolContractMetadata } from "./toolEffects.js";
@@ -29,9 +30,7 @@ export const GDB_TOOL_CONTRACTS = [
description:
"Start a BYO GDB process in MI3 mode when available, suppress startup files, and disable auto-loading. Persistent GDB sessions are available only on Linux, where REA can verify live inferior output descriptors through /proc before detaching; macOS descriptor verification is not implemented, and Windows Job Object cleanup can terminate debugger inferiors. Optionally set inferior_tty to a caller-selected terminal for future inferior runs; REA verifies the setting through GDB/MI before the session becomes ready. The selected terminal must remain available after GDB exits. A live inferior can be detached during close only while this durable output sink remains verified. The session persists in this REA MCP server process and receives an explicit session ID.",
inputSchema: z.strictObject({
inferior_tty: z
.string()
.min(1)
inferior_tty: localPathStringSchema
.optional()
.describe(
"Caller-selected terminal path for future inferior runs. GDB must report the exact configured path before the session is ready.",
+2 -2
View File
@@ -172,8 +172,8 @@ const flattenRootObjectUnion = (
variants.set(name, known);
}
const required = sharedRequiredProperties(branches);
// A field unique to a group still needs that group's required partners.
// Counting properties alone would let optional fields pad an incomplete pair.
// A coarse property minimum rejects underspecified objects. Partner fields
// and exact group membership remain enforced by the canonical runtime parser.
const fewestRequired = Math.min(
...branches.map((branch) => requiredProperties(branch).length),
);
+3 -2
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { localPathStringSchema } from "../domain/localPath.js";
import type { ToolContract } from "./toolContractTypes.js";
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
import { toolContractMetadata } from "./toolEffects.js";
@@ -24,8 +25,8 @@ export const RIZIN_DEBUG_TOOL_CONTRACTS = [
description:
"Start a persistent Rizin debugger session using `-N` to suppress user settings and scripts while retaining plugins, `-0` for NUL framing, and `-d` with an optional caller-selected IO debugger backend. Requires a caller-selected Linux TTY for inferior stdout and stderr; the target may write directly to that terminal while running and after detach. REA verifies both destinations and `dbg.exitkills=false` before detaching. Backend and target capabilities depend on the installed build. The session lives in this REA MCP process. Persistent sessions require Linux `/proc` descriptor readback. In a shared MCP server, starting this session retires any active binary-analysis session; after target effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
inputSchema: z.strictObject({
path: z.string().min(1),
inferior_tty: z.string().min(1),
path: localPathStringSchema,
inferior_tty: localPathStringSchema,
backend: z.string().min(1).optional(),
}),
outputSchema: z.strictObject({
+1 -1
View File
@@ -104,7 +104,7 @@ export const SESSION_TOOL_CONTRACTS = [
),
session(
"compare_artifacts",
"Compare complete artifact inventories by logical occurrence path, content identity, metadata, and graph relations. Pass the inventory Evidence nested in each inspect_artifact result as left and right. Every delta cites both inputs, and gaps yield truncated or unknown, never equivalence. Returns every change inline.",
"Compare complete artifact inventories by logical occurrence path, content identity, metadata, and graph relations. Pass the inventory Evidence nested in each inspect_artifact result as left and right, or pass its inspect_analysis_view summary while the complete parent Evidence remains retained in this session. Summary facts are not treated as a complete inventory; comparison resolves the exact retained parent. Every delta cites both complete inputs, and gaps yield truncated or unknown, never equivalence. Returns every change inline.",
artifactComparisonInputSchema,
),
session(
+33 -1
View File
@@ -10,9 +10,41 @@ import {
presentInputJsonSchema,
} from "./inputSchemaPresentation.js";
import { toolContract } from "./toolContracts.js";
import { toolInputSchemaWithMetadata } from "./toolSchemaMetadata.js";
import {
toolInputSchemaWithMetadata,
transformAdvertisedInputJsonSchema,
} from "./toolSchemaMetadata.js";
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "./javascript/javascriptRuntimeReconciliationExample.js";
it.each(["draft-2020-12", "draft-07"] as const)(
"honors projection options independently of cached wire transformations in %s",
(target) => {
const range = z.strictObject({ offset: z.number(), length: z.number() });
const canonical = toolInputSchemaWithMetadata({
...toolContract("open_binary"),
inputSchema: z.strictObject({ before: range, after: range }),
});
const transform = (schema: Record<string, unknown>) => ({
...schema,
title: "Transformed range inputs",
});
const options = { target, libraryOptions: { reused: "inline" as const } };
for (const inlineFirst of [false, true]) {
const wrapped = transformAdvertisedInputJsonSchema(canonical, transform);
const project = wrapped["~standard"].jsonSchema.input;
if (inlineFirst) project(options);
const shared = project({ target });
expect(JSON.stringify(shared)).toContain('"$ref":');
const inline = project(options);
expect(inline).toEqual(
transform(canonical["~standard"].jsonSchema.input(options)),
);
expect(JSON.stringify(inline)).not.toContain('"$ref":');
expect(project({ target })).toBe(shared);
}
},
);
it.each(["draft-2020-12", "draft-07"] as const)(
"preserves the complete inline Evidence contract before deferred authentication in %s",
(target) => {
+2 -2
View File
@@ -45,7 +45,7 @@ import {
listArchitecturesSchema,
} from "../domain/native/nativeInspection.js";
import { artifactExtractionResultSchema } from "../domain/artifactGraph.js";
import { artifactInspectionOutputSchema } from "../domain/artifactInspection.js";
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
import { interfaceBuilderAnalysisSchema } from "../domain/apple/interfaceBuilderGraph.js";
import { dylibResolutionResultSchema } from "../domain/apple/dylibResolution.js";
import { keyedArchiveResultSchema } from "../domain/apple/keyedArchive.js";
@@ -404,7 +404,7 @@ export const nativeOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
export const artifactOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
inspect_artifact: evidenceResultOf(
z.union([
artifactInspectionOutputSchema,
artifactInspectionResultSchema,
artifactInventorySummaryViewResultSchema,
]),
),
+5 -2
View File
@@ -146,7 +146,10 @@ export const transformAdvertisedInputJsonSchema = <Schema>(
const byTarget = new Map<string, Record<string, unknown>>();
const transformedInput = (options: unknown): unknown => {
const target = Reflect.get(options as object, "target");
if (typeof target === "string") {
const cacheable =
typeof target === "string" &&
Reflect.get(options as object, "libraryOptions") === undefined;
if (cacheable) {
const cached = byTarget.get(target);
if (cached !== undefined) return cached;
}
@@ -154,7 +157,7 @@ export const transformAdvertisedInputJsonSchema = <Schema>(
const transformed = isSchemaRecord(projected)
? transform(projected)
: projected;
if (typeof target === "string" && isSchemaRecord(transformed))
if (cacheable && isSchemaRecord(transformed))
byTarget.set(target, transformed);
return transformed;
};
+221
View File
@@ -0,0 +1,221 @@
import { Ajv } from "ajv";
import { Ajv2020 } from "ajv/dist/2020.js";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { packetFieldsSchema } from "../domain/captures/packetCapture.js";
import { swiftStringLiteralSchema } from "../domain/hopperValues.js";
import { applicationVersionComparisonResultSchema } from "../domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
import { pinvokeVerificationSchema } from "../domain/managed/managedNativeVerificationSchemas.js";
import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js";
import { compactAdvertisedInputSchema } from "./compactInputSchema.js";
const directions = [
[],
["callers"],
["callees"],
["callers", "callees"],
["callees", "callers"],
["callers", "callers"],
["callees", "callees"],
["callers", "callees", "callers"],
["incoming"],
[null],
null,
];
const literal = {
representation: "small",
source_address: "0x1000",
address: null,
value: "hello",
registers: ["x0", "x1"],
words: ["0x6f6c6c6568", "0xe500000000000000"],
};
const ambiguousItem = {
item_id: `javc_item_${"a".repeat(64)}`,
node_kind: "javascript-module",
status: "unknown",
left_node_id: `jag_node_${"b".repeat(64)}`,
right_node_id: null,
match: {
status: "ambiguous",
basis: "none",
confidence: "unknown",
candidate_left_node_ids: [],
candidate_right_node_ids: [`jag_node_${"c".repeat(64)}`],
},
dimensions: [],
evidence_links: [`ev_${"d".repeat(64)}`, `ev_${"e".repeat(64)}`],
limitations: [],
};
const verifiedCandidates = pinvokeVerificationSchema.options.find(
(option) => option.shape.status.value === "verified",
)?.shape.candidates;
if (verifiedCandidates === undefined)
throw new Error("Missing verified native candidate contract");
const nativeSymbol = {
evidence_id: `ev_${"f".repeat(64)}`,
operation: "analyze_function",
name: "NativeEntry",
address: "0x1000",
module_name: null,
module_path: null,
source: "function-dossier",
};
const cases: readonly {
readonly name: string;
readonly schema: z.ZodType;
readonly samples: readonly {
readonly value: unknown;
readonly valid: boolean;
}[];
}[] = [
...(["partial", "unknown"] as const).map((status) => ({
name: `${status} relationship directions`,
schema: relationshipCoverageSchema,
samples: directions.map((limited_directions) => ({
value: {
status,
...(status === "partial"
? { limitations: ["Read limit reached."] }
: { reason: "Coverage unavailable." }),
limited_directions,
},
valid:
Array.isArray(limited_directions) &&
['["callers"]', '["callees"]', '["callers","callees"]'].includes(
JSON.stringify(limited_directions),
),
})),
})),
{
name: "nonempty managed native candidates",
schema: verifiedCandidates,
samples: [
{ value: [nativeSymbol], valid: true },
{ value: [nativeSymbol, nativeSymbol], valid: true },
{ value: [], valid: false },
{ value: [null], valid: false },
],
},
{
name: "Swift register and word pairs",
schema: swiftStringLiteralSchema,
samples: [
{ value: literal, valid: true },
...["registers", "words"].flatMap((field) =>
[[], ["x0"], ["x0", "x1", "x2"], ["x0", null], null].map((value) => ({
value: { ...literal, [field]: value },
valid: false,
})),
),
],
},
{
name: "packet frame numbers",
schema: packetFieldsSchema,
samples: [
{ value: { "frame.number": ["1"], "ip.addr": ["a", "b"] }, valid: true },
...[[], ["1", "2"], ["0"], [1], null].map((value) => ({
value: { "frame.number": value },
valid: false,
})),
],
},
{
name: "nonempty ambiguous application candidates",
schema: applicationVersionComparisonResultSchema.shape.items,
samples: [
{ value: [ambiguousItem], valid: true },
{
value: [
{
...ambiguousItem,
match: { ...ambiguousItem.match, candidate_right_node_ids: [] },
},
],
valid: false,
},
],
},
];
describe.each(["draft-2020-12", "draft-07"] as const)(
"tuple JSON Schema parity in %s",
(target) => {
describe.each(["input", "output"] as const)("%s projection", (io) => {
it.each(cases)("preserves $name", ({ schema, samples }) => {
const projected = z.toJSONSchema(schema, { io, target });
const ajv =
target === "draft-2020-12"
? new Ajv2020({ strict: false, validateFormats: false })
: new Ajv({ strict: false, validateFormats: false });
expect(ajv.validateSchema(projected)).toBe(true);
const validate = ajv.compile(projected);
for (const { value, valid } of samples) {
expect(schema.safeParse(value).success, JSON.stringify(value)).toBe(
valid,
);
expect(validate(value), JSON.stringify(value)).toBe(valid);
}
});
});
},
);
describe("fixed tuple element guidance in advertised projections", () => {
it.each(["draft-2020-12", "draft-07"] as const)(
"keeps literal item guidance in full and compact %s projections",
(target) => {
const full = z.toJSONSchema(relationshipCoverageSchema, {
io: "output",
target,
});
for (const schema of [
full,
compactAdvertisedInputSchema(full, 64 * 1024).schema,
]) {
const tupleSchemas: Record<string, unknown>[] = [];
const visit = (value: unknown): void => {
if (Array.isArray(value)) {
value.forEach(visit);
} else if (typeof value === "object" && value !== null) {
const candidate = value as Record<string, unknown>;
if (
Array.isArray(candidate.prefixItems) ||
(Array.isArray(candidate.items) && "additionalItems" in candidate)
)
tupleSchemas.push(candidate);
Object.values(candidate).forEach(visit);
}
};
visit(schema);
expect(tupleSchemas).toHaveLength(6);
for (const tuple of tupleSchemas) {
expect(tuple).toMatchObject({
type: "array",
minItems: expect.any(Number),
maxItems: expect.any(Number),
});
const positional = z
.array(z.object({ const: z.string() }))
.parse(tuple.prefixItems ?? tuple.items);
const rest = tuple.prefixItems ? tuple.items : tuple.additionalItems;
const ajv =
target === "draft-2020-12"
? new Ajv2020({ strict: false })
: new Ajv({ strict: false });
const validateItem = ajv.compile(
z.record(z.string(), z.unknown()).parse(rest),
);
for (const value of ["callers", "callees", "incoming", null])
expect(validateItem(value), JSON.stringify(tuple)).toBe(
positional.some((position) => position.const === value),
);
}
}
},
);
});
+23 -5
View File
@@ -278,11 +278,11 @@ export class CutterBridgeClient {
),
}
: undefined);
this.#directory =
configured ??
(this.#platform === "win32" && localAppData
? join(localAppData, "REA", "CutterBridge")
: join(homedir(), ".cache", "rea", "cutter-bridge"));
this.#directory = resolveBridgeDirectory(
configured,
this.#platform,
localAppData,
);
}
async listSessions(): Promise<CutterBridgeDiscovery> {
@@ -889,3 +889,21 @@ export class CutterBridgeClient {
});
}
}
const resolveBridgeDirectory = (
configured: string | undefined,
platform: NodeJS.Platform,
localAppData: string | undefined,
): string => {
if (configured !== undefined && configured !== "") {
if (configured === "~") return homedir();
if (configured.startsWith("~/"))
return join(homedir(), configured.slice(2));
if (platform === "win32" && configured.startsWith("~\\"))
return join(homedir(), configured.slice(2));
return configured;
}
return platform === "win32" && localAppData
? join(localAppData, "REA", "CutterBridge")
: join(homedir(), ".cache", "rea", "cutter-bridge");
};
+9 -1
View File
@@ -35,6 +35,11 @@ import type { AnalysisErrorProjection } from "./analysisErrorProjection.js";
export const analysisErrorRemediationAction = (
error: AnalysisError,
): string => {
if (
error instanceof BrowserObservationError &&
error.reason === "service_worker_blocked"
)
return "Set environment.service_workers to allow to permit this capture, or remove the service worker registration through the browser. REA never removes existing registrations.";
if (error instanceof AnalysisCapabilityUnavailableError && error.retryable)
return "Review details.reason, then retry the same analysis after the condition clears.";
if (error instanceof EvidenceFileError && error.published !== undefined)
@@ -177,6 +182,7 @@ const browserErrorCategory = (
if (
reason === "target_not_found" ||
reason === "target_not_allowed" ||
reason === "service_worker_blocked" ||
reason === "target_changed" ||
reason === "endpoint_unreachable" ||
reason === "disconnected"
@@ -300,12 +306,14 @@ const hopperErrorUserMessage = (error: AnalysisError): string | undefined => {
const RESTART_OWNED_PROVIDER =
"Restart the owned provider by calling close_binary, then open the target again and retry.";
const RESTART_UNREACHABLE_HOPPER = `${RESTART_OWNED_PROVIDER} If Hopper may have exited, call export_evidence_bundle before close_binary; closing clears retained records.`;
const hopperProcessRemediation = (error: HopperProcessError): string => {
if (error.failureCode !== undefined && error.userMessage !== undefined)
return error.userMessage;
if (error.stage === "launch")
return "Review the captured launcher diagnostics and Hopper setup, then retry opening the target.";
if (error.providerState === "unreachable") return RESTART_UNREACHABLE_HOPPER;
if (providerRetryAction(error.providerState) === "restart_provider")
return RESTART_OWNED_PROVIDER;
return "Read binary_session.provider_operation_health for this request before retrying or restarting the owned provider.";
@@ -335,7 +343,7 @@ const hopperProcessMessage = (error: HopperProcessError): string => {
return `Hopper exited during ${where} (${exit}).${startup}${recovery}${request}`;
}
if (error.providerState === "unreachable")
return `Hopper became unreachable during ${where}; its process exit was not observed. ${RESTART_OWNED_PROVIDER}${startup}${request}`;
return `Hopper became unreachable during ${where}; its process exit was not observed. ${RESTART_UNREACHABLE_HOPPER}${startup}${request}`;
return `Hopper failed during ${where} and REA could not determine whether the provider is busy, exited, or unreachable.${startup} Read binary_session.provider_operation_health for this request before retrying or restarting.${request}`;
};
+42 -1
View File
@@ -392,7 +392,30 @@ it("retains native partial observations and cleanup uncertainty on provider fail
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
});
describe("analysis error projection: provider failures", () => {
describe("analysis error projection: browser provider failures", () => {
it("explains the service-worker policy refusal and names its recovery", () => {
const projected = projectAnalysisError(
new BrowserObservationError(
"capture_browser_scenario",
"service_worker_blocked",
),
);
expect(projected).toMatchObject({
code: "target_unavailable",
category: "unavailable",
retryable: false,
message: expect.stringContaining("service-worker policy is block"),
remediation: {
action: expect.stringContaining("environment.service_workers to allow"),
},
details: {
operation: "capture_browser_scenario",
reason: "service_worker_blocked",
},
});
});
it("projects the primary browser failure alongside incomplete cleanup", () => {
const projected = projectAnalysisError(
new BrowserObservationError(
@@ -421,7 +444,9 @@ describe("analysis error projection: provider failures", () => {
},
});
});
});
describe("analysis error projection: Hopper provider failures", () => {
it("reports a timed-out active Hopper request and actionable retry guidance", () => {
const projected = projectAnalysisError(
new HopperTimeoutError(30_000, "trace_feature", 42, "busy"),
@@ -487,7 +512,9 @@ describe("analysis error projection: provider failures", () => {
message: expect.stringContaining("Hopper analysis failed"),
});
});
});
describe("analysis error projection: detached provider diagnostics", () => {
it.each(["adapter", "cancelled", "timeout"])(
"preserves detached, actionable diagnostics for %s errors",
(kind) => {
@@ -515,7 +542,9 @@ describe("analysis error projection: provider failures", () => {
expect(error.diagnostics?.exit_code).toBe(1);
},
);
});
describe("analysis error projection: Hopper startup failures", () => {
it("maps the Linux startup compatibility exit codes without host internals", () => {
const expected = [
[70, "private_display_unavailable"],
@@ -765,6 +794,18 @@ describe("analysis error projection: operational diagnostics", () => {
expect(unknown.message).toContain("could not determine");
expect(unknown.message).not.toContain("stopped");
expect(unknown.remediation.action).toContain("provider_operation_health");
const unreachable = projectAnalysisError(
new HopperProcessError(null, {
operation: "search_strings",
providerState: "unreachable",
}),
);
expect(unreachable.message).toContain(
"If Hopper may have exited, call export_evidence_bundle before close_binary",
);
expect(unreachable.message).toContain("closing clears retained records");
expect(unreachable.remediation.action).toContain("export_evidence_bundle");
});
});
+1
View File
@@ -135,6 +135,7 @@ const browserErrorCode = (
if (
reason === "target_not_found" ||
reason === "target_not_allowed" ||
reason === "service_worker_blocked" ||
reason === "target_changed"
)
return "target_unavailable";
+16 -2
View File
@@ -234,6 +234,7 @@ describe("analysis snapshot contract", () => {
raw_result: null,
provider: binding.provider,
limitations: [],
relationship_coverage: null,
locations: [],
subject: target,
},
@@ -262,6 +263,7 @@ describe("analysis snapshot contract", () => {
raw_result: null,
provider: binding.provider,
limitations: [],
relationship_coverage: null,
locations: [],
subject: {
path: "a\u0000b",
@@ -293,6 +295,7 @@ describe("analysis snapshot contract", () => {
raw_result: null,
provider: binding.provider,
limitations: [],
relationship_coverage: null,
locations: [],
subject: null,
},
@@ -456,6 +459,7 @@ describe("analysis snapshot Evidence binding", () => {
raw_result: rawResult,
provider: binding.provider,
limitations: ["fixture limitation"],
relationship_coverage: null,
locations: [{ kind: "address" as const, address: "0x1000" }],
subject,
},
@@ -480,11 +484,21 @@ describe("analysis snapshot Evidence binding", () => {
workflow_entries: [],
evidence_bundle: createEvidenceBundle([evidence]),
};
const parsedLegacySnapshot = parseAnalysisSnapshot(snapshot);
const parsedSnapshot = parseAnalysisSnapshot(snapshot);
expect(
parsedLegacySnapshot.entries[0]?.execution.relationship_coverage,
parsedSnapshot.entries[0]?.execution.relationship_coverage,
).toBeNull();
const missingCoverage = structuredClone(snapshot);
const missingCoverageEntry = missingCoverage.entries[0];
if (missingCoverageEntry === undefined)
throw new Error("snapshot entry missing");
Reflect.deleteProperty(
missingCoverageEntry.execution,
"relationship_coverage",
);
expect(() => parseAnalysisSnapshot(missingCoverage)).toThrow();
expect(() =>
parseAnalysisSnapshot({
...snapshot,
+4 -8
View File
@@ -145,7 +145,7 @@ const entrySchema = z.object({
raw_result: jsonValueSchema.nullable(),
provider: committedProviderSchema,
limitations: z.array(z.string()),
relationship_coverage: relationshipCoverageSchema.nullable().default(null),
relationship_coverage: relationshipCoverageSchema.nullable(),
locations: z.array(evidenceLocationSchema),
subject: subjectSchema.nullable(),
}),
@@ -160,7 +160,7 @@ const workflowEntrySchema = z.object({
provider: committedProviderSchema,
analysis_profile: analysisProfileSchema,
limitations: z.array(z.string()),
relationship_coverage: relationshipCoverageSchema.nullable().default(null),
relationship_coverage: relationshipCoverageSchema.nullable(),
locations: z.array(evidenceLocationSchema),
subject: subjectSchema.nullable(),
}),
@@ -305,9 +305,7 @@ const snapshotContentDigest = (
format: execution.subject.format,
architecture: execution.subject.architecture,
},
...(execution.relationship_coverage === null
? {}
: { relationship_coverage: execution.relationship_coverage }),
relationship_coverage: execution.relationship_coverage,
});
return contentDigest({
...metadata,
@@ -872,9 +870,7 @@ const evidenceMatchesWorkflowEntry = (
const evidenceContentMetadata = (evidence: Evidence) => {
return {
limitations: evidence.limitations,
...(evidence.relationship_coverage === null
? {}
: { relationship_coverage: evidence.relationship_coverage }),
relationship_coverage: evidence.relationship_coverage,
locations: evidence.locations,
subject:
evidence.subject === null
@@ -4,6 +4,7 @@ import { expect, it } from "vitest";
import { functionDossierSchema } from "../hopperValues.js";
import { ghidraFunctionDossier } from "../ghidraValues.fixture.js";
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../contracts/artifactComparisonExample.js";
import {
analysisViewBindJavaScriptGraphs,
analysisViewJavaScriptAnalysis,
@@ -25,6 +26,7 @@ import {
pageViewCoverage,
} from "./analysisViewCoverage.js";
import { sourceMapReference } from "../javascript/javascriptApplicationGraph.fixture.js";
import { createArtifactInspection } from "../artifactInspection.js";
import { javascriptModulePath } from "./javascriptApplicationView.js";
import {
inspectAnalysisViewInputSchema,
@@ -162,6 +164,28 @@ it("preserves native API limitations and unavailable residual unknowns", () => {
);
});
it("rejects artifact summary views when inspection substep coverage count disagrees", () => {
const source = ARTIFACT_COMPARISON_EXAMPLE.left;
const inspection = createArtifactInspection(source);
const parent: AnalysisViewParent = {
evidenceId: `ev_${"a".repeat(64)}`,
operation: "inspect_artifact",
normalizedResult: {
...inspection,
coverage: {
...inspection.coverage,
substeps_completed: inspection.substeps.length + 1,
},
},
subject: source.subject,
limitations: [],
};
expect(projectAnalysisView(parent, { kind: "summary" })).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
});
it("pages every retained dossier collection and the native API record", () => {
const original = functionDossierSchema.parse(ghidraFunctionDossier());
const page = (
@@ -4,15 +4,11 @@ import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import { err, ok, type Result } from "../result.js";
import {
parseArtifactInventoryEvidence,
parseArtifactInspectionInventoryResult,
parseArtifactInventoryResult,
validateArtifactInventorySubject,
type InventorySet,
} from "../artifactInventoryEvidence.js";
import {
artifactInspectionResultSchema,
type ArtifactInspectionResult,
} from "../artifactInspection.js";
import type { ArtifactInspectionResult } from "../artifactInspection.js";
import type {
AnalysisViewParent,
AnalysisViewRequest,
@@ -52,36 +48,14 @@ const parseSource = (
evidenceIds: [parent.evidenceId],
inspection: null,
});
const inspection = artifactInspectionResultSchema.parse(
const parsed = parseArtifactInspectionInventoryResult(
parent.normalizedResult,
parent.subject ?? null,
);
for (const step of inspection.substeps) {
if (
step.evidence_id !== step.evidence.evidence_id ||
step.evidence.operation !== step.operation
)
throw new TypeError(
"Inspection substep identity differs from its source Evidence",
);
}
const parsed = parseArtifactInventoryEvidence(
inspection.substeps.map(({ evidence }) => evidence),
);
const manifest = parsed.inventory.manifest;
validateArtifactInventorySubject(parent.subject ?? null, manifest);
if (
inspection.subject.manifest_id !== manifest.manifest_id ||
inspection.subject.root_artifact_id !== manifest.root_artifact_id ||
inspection.subject.root_sha256 !== manifest.root_sha256 ||
inspection.subject.root_format !== manifest.root_format
)
throw new TypeError(
"Inspection subject differs from its inventory manifest",
);
return ok({
inventory: parsed.inventory,
evidenceIds: parsed.evidence.map(({ evidence_id }) => evidence_id),
inspection,
evidenceIds: parsed.evidenceIds,
inspection: parsed.inspection,
});
} catch (cause: unknown) {
if (!(cause instanceof TypeError) && !(cause instanceof z.ZodError))
+2 -1
View File
@@ -138,7 +138,8 @@ const inventoryEvidence = (
occurrences: sortedOccurrences,
edges: [],
provenance: [],
coverage_status: "complete",
integrity_contradictions: [],
coverage: { scope: "container-entries", status: "complete" },
limitations: [],
}),
},
+66 -10
View File
@@ -590,9 +590,11 @@ describe("AppKit keyed archive connectors", () => {
});
describe("keyed archive unresolved connectors", () => {
// A rooted UIKit archive whose only connector is UID 3, with UID 1 as the
// owner, UID 2 as the destination and UID 7 as the "view" label.
const rootedOutletArchive = (connection: Record<string, unknown>) => ({
const rootedOutletArchive = (
connection: Record<string, unknown>,
connectionClass = "UIRuntimeOutletConnection",
label = "view",
) => ({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: [
@@ -606,14 +608,10 @@ describe("keyed archive unresolved connectors", () => {
},
{ $classname: "UIView", $classes: ["UIView", "UIResponder", "NSObject"] },
{
$classname: "UIRuntimeOutletConnection",
$classes: [
"UIRuntimeOutletConnection",
"UIRuntimeConnection",
"NSObject",
],
$classname: connectionClass,
$classes: [connectionClass, "UIRuntimeConnection", "NSObject"],
},
"view",
label,
{
$class: { CF$UID: 11 },
"NS.objects": [{ CF$UID: 1 }, { CF$UID: 2 }, { CF$UID: 3 }],
@@ -664,6 +662,43 @@ describe("keyed archive unresolved connectors", () => {
}),
);
});
it("projects keyed UIKit event connections as actions and retains the mask", () => {
const raw = rootedOutletArchive(
{
UISource: { CF$UID: 1 },
UIDestination: { CF$UID: 2 },
UILabel: { CF$UID: 7 },
UIEventMask: 64,
},
"UIRuntimeEventConnection",
"tapped:",
);
expect(parseInterfaceBuilderRecords(raw).connections[0]).toMatchObject({
kind: "action",
source_id: "1",
destination_id: "2",
label: "tapped:",
attributes: { UIEventMask: 64 },
});
const analysis = project(raw);
expect(analysis.graph.nodes).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "action", name: "tapped:" }),
expect.objectContaining({ kind: "objc_selector", name: "tapped:" }),
]),
);
expect(analysis.graph.edges).toContainEqual(
expect.objectContaining({ relation: "target_action" }),
);
expect(analysis.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "connections:Main.nib",
status: "complete",
examined: 1,
omitted: 0,
}),
);
});
it("reports a recognized connector with no resolvable owner as unresolved", () => {
const raw = rootedOutletArchive({
@@ -686,6 +721,27 @@ describe("keyed archive unresolved connectors", () => {
}),
);
expect(analysis.graph.truncated).toBe(true);
const eventWithoutSource = rootedOutletArchive(
{
UIDestination: { CF$UID: 2 },
UILabel: { CF$UID: 7 },
UIEventMask: 64,
},
"UIRuntimeEventConnection",
"tapped:",
);
expect(
parseInterfaceBuilderRecords(eventWithoutSource).unresolvedConnections,
).toBe(1);
expect(project(eventWithoutSource).graph.coverage).toContainEqual(
expect.objectContaining({
facet: "connections:Main.nib",
status: "partial",
reason: "connection_unresolved",
omitted: 1,
}),
);
});
});
@@ -523,7 +523,8 @@ const classifyConnection = (
): InterfaceBuilderConnection["kind"] => {
const value = type.toLowerCase();
if (value.includes("outlet")) return "outlet";
if (value.includes("action")) return "action";
if (value.includes("action") || value.includes("eventconnection"))
return "action";
if (value.includes("segue")) return "segue";
return "other";
};
+261
View File
@@ -0,0 +1,261 @@
/** Class-file identity used to disambiguate JVM CAFEBABE from Mach-O FAT. */
class ClassFileCursor {
private offset = 0;
private readonly view: DataView;
constructor(private readonly bytes: Uint8Array) {
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
get atEnd(): boolean {
return this.offset === this.bytes.byteLength;
}
u1(): number | null {
if (!this.has(1)) return null;
return this.bytes[this.offset++]!;
}
u2(): number | null {
if (!this.has(2)) return null;
const value = this.view.getUint16(this.offset, false);
this.offset += 2;
return value;
}
u4(): number | null {
if (!this.has(4)) return null;
const value = this.view.getUint32(this.offset, false);
this.offset += 4;
return value;
}
skip(length: number): boolean {
if (!Number.isSafeInteger(length) || length < 0 || !this.has(length))
return false;
this.offset += length;
return true;
}
private has(length: number): boolean {
return this.offset <= this.bytes.byteLength - length;
}
}
interface ConstantPool {
readonly tags: (number | null)[];
readonly first: (number | null)[];
readonly second: (number | null)[];
}
const readConstantPool = (
cursor: ClassFileCursor,
count: number,
): ConstantPool | null => {
const pool: ConstantPool = {
tags: Array.from({ length: count }, () => null),
first: Array.from({ length: count }, () => null),
second: Array.from({ length: count }, () => null),
};
for (let index = 1; index < count; index += 1) {
const tag = cursor.u1();
if (tag === null) return null;
if (!readConstant(cursor, tag, index, pool)) return null;
if (tag === 5 || tag === 6) index += 1;
}
return pool;
};
const readConstant = (
cursor: ClassFileCursor,
tag: number,
index: number,
pool: ConstantPool,
): boolean => {
switch (tag) {
case 1: {
const length = cursor.u2();
if (length === null || !cursor.skip(length)) return false;
break;
}
case 3:
case 4:
if (cursor.u4() === null) return false;
break;
case 5:
case 6:
if (index + 1 >= pool.tags.length || !cursor.skip(8)) return false;
break;
case 7:
case 8:
case 16:
case 19:
case 20:
pool.first[index] = cursor.u2();
if (pool.first[index] === null) return false;
break;
case 9:
case 10:
case 11:
case 12:
case 17:
case 18:
pool.first[index] = cursor.u2();
pool.second[index] = cursor.u2();
if (pool.first[index] === null || pool.second[index] === null)
return false;
break;
case 15:
pool.first[index] = cursor.u1();
pool.second[index] = cursor.u2();
if (pool.first[index] === null || pool.second[index] === null)
return false;
break;
default:
return false;
}
pool.tags[index] = tag;
return true;
};
const poolHasTag = (
pool: ConstantPool,
index: number,
...tags: number[]
): boolean =>
Number.isInteger(index) && index > 0 && tags.includes(pool.tags[index] ?? -1);
const hasValidPoolReferences = (pool: ConstantPool, major: number): boolean => {
for (let index = 1; index < pool.tags.length; index += 1) {
const tag = pool.tags[index] ?? null;
const first = pool.first[index] ?? null;
const second = pool.second[index] ?? null;
if ([7, 8, 16, 19, 20].includes(tag ?? -1)) {
if (first === null || !poolHasTag(pool, first, 1)) return false;
if (
(tag === 16 && major < 51) ||
((tag === 19 || tag === 20) && major < 53)
)
return false;
} else if ([9, 10, 11].includes(tag ?? -1)) {
if (
first === null ||
second === null ||
!poolHasTag(pool, first, 7) ||
!poolHasTag(pool, second, 12)
)
return false;
} else if (tag === 12) {
if (
first === null ||
second === null ||
!poolHasTag(pool, first, 1) ||
!poolHasTag(pool, second, 1)
)
return false;
} else if (tag === 15 && !validMethodHandle(pool, first, second, major)) {
return false;
} else if (tag === 17 || tag === 18) {
if (
second === null ||
!poolHasTag(pool, second, 12) ||
major < (tag === 17 ? 55 : 51)
)
return false;
}
}
return true;
};
const validMethodHandle = (
pool: ConstantPool,
kind: number | null,
reference: number | null,
major: number,
): boolean => {
if (kind === null || reference === null || kind < 1 || kind > 9 || major < 51)
return false;
if (kind <= 4) return poolHasTag(pool, reference, 9);
if (kind === 5 || kind === 8) return poolHasTag(pool, reference, 10);
if (kind === 6 || kind === 7)
return poolHasTag(pool, reference, ...(major >= 52 ? [10, 11] : [10]));
return poolHasTag(pool, reference, 11);
};
const readAttributes = (
cursor: ClassFileCursor,
pool: ConstantPool,
count: number,
): boolean => {
for (let index = 0; index < count; index += 1) {
const name = cursor.u2();
const length = cursor.u4();
if (
name === null ||
!poolHasTag(pool, name, 1) ||
length === null ||
!cursor.skip(length)
)
return false;
}
return true;
};
const readMembers = (cursor: ClassFileCursor, pool: ConstantPool): boolean => {
const count = cursor.u2();
if (count === null) return false;
for (let index = 0; index < count; index += 1) {
if (cursor.u2() === null) return false;
const name = cursor.u2();
const descriptor = cursor.u2();
const attributes = cursor.u2();
if (
name === null ||
descriptor === null ||
!poolHasTag(pool, name, 1) ||
!poolHasTag(pool, descriptor, 1) ||
attributes === null ||
!readAttributes(cursor, pool, attributes)
)
return false;
}
return true;
};
/** Validate class-file framing and constant-pool references before FAT parsing. */
export const isJvmClassFile = (bytes: Uint8Array): boolean => {
const cursor = new ClassFileCursor(bytes);
if (cursor.u4() !== 0xcafebabe) return false;
const minor = cursor.u2();
const major = cursor.u2();
const poolCount = cursor.u2();
if (
minor === null ||
major === null ||
major < 45 ||
poolCount === null ||
poolCount === 0
)
return false;
const pool = readConstantPool(cursor, poolCount);
if (pool === null || !hasValidPoolReferences(pool, major)) return false;
if (cursor.u2() === null) return false;
const thisClass = cursor.u2();
const superClass = cursor.u2();
if (
thisClass === null ||
!poolHasTag(pool, thisClass, 7) ||
superClass === null ||
(superClass !== 0 && !poolHasTag(pool, superClass, 7))
)
return false;
const interfaces = cursor.u2();
if (interfaces === null || !cursor.skip(interfaces * 2)) return false;
if (!readMembers(cursor, pool) || !readMembers(cursor, pool)) return false;
const attributes = cursor.u2();
return (
attributes !== null &&
readAttributes(cursor, pool, attributes) &&
cursor.atEnd
);
};
+4
View File
@@ -1,3 +1,5 @@
import { isJvmClassFile } from "./jvmClassFile.js";
/** Physical facts for one entry in a Mach-O FAT architecture table. */
export interface FatSliceDeclaration {
readonly index: number;
@@ -68,6 +70,8 @@ export const readFatContainerHeader = (
state: "malformed",
reason: "FAT file size is invalid or contradicts retained bytes",
};
if (bytes.byteLength === fileSize && isJvmClassFile(bytes))
return { state: "not-fat" };
if (bytes.byteLength < 8) {
return fileSize < 8
? { state: "malformed", reason: "FAT header is truncated" }
+48
View File
@@ -61,6 +61,7 @@ describe("artifact graph path and provenance fields", () => {
effects: ["read"],
},
],
coverage: { scope: "container-entries", status: "complete" },
integrity_contradictions: [],
limitations: [],
});
@@ -69,4 +70,51 @@ describe("artifact graph path and provenance fields", () => {
expect(parsed.provenance[0]?.arguments).toHaveLength(1_025);
expect(parsed.provenance[0]?.arguments[0]).toBe(arguments_[0]);
});
it("requires current coverage and integrity facts and rejects the obsolete status alias", () => {
const inventory = {
manifest: {
manifest_id: `agm_${"b".repeat(64)}`,
root_artifact_id: `art_${"a".repeat(64)}`,
root_sha256: "c".repeat(64),
root_format: "plist",
graph_sha256: "d".repeat(64),
node_count: 1,
occurrence_count: 1,
edge_count: 0,
},
nodes: [],
occurrences: [],
edges: [],
provenance: [],
integrity_contradictions: [],
limitations: [],
};
expect(artifactInventoryResultSchema.safeParse(inventory).success).toBe(
false,
);
const { integrity_contradictions: _facts, ...withoutIntegrityFacts } =
inventory;
const currentWithoutIntegrityFacts = {
...withoutIntegrityFacts,
coverage: { scope: "root", status: "complete" },
};
expect(
artifactInventoryResultSchema.safeParse(currentWithoutIntegrityFacts)
.success,
).toBe(false);
expect(
artifactInventoryResultSchema.safeParse({
...currentWithoutIntegrityFacts,
integrity_contradictions: [],
}).success,
).toBe(true);
expect(
artifactInventoryResultSchema.safeParse({
...inventory,
coverage_status: "complete",
}).success,
).toBe(false);
});
});
+4 -18
View File
@@ -196,32 +196,18 @@ export type ArtifactInventoryCoverage = z.infer<
typeof artifactInventoryCoverageSchema
>;
/** Complete inventory observation containing the stable artifact graph. */
export const artifactInventoryResultSchema = z.object({
/** Current inventory observation containing the stable artifact graph. */
export const artifactInventoryResultSchema = z.strictObject({
manifest: artifactGraphManifestSchema,
nodes: z.array(artifactNodeSchema),
occurrences: z.array(artifactOccurrenceSchema),
edges: z.array(artifactEdgeSchema),
provenance: z.array(artifactCommandSchema),
integrity_contradictions: z.array(integrityContradictionSchema).default([]),
// Omitted by older retained Evidence; the inventory parser owns its compatibility policy.
coverage: artifactInventoryCoverageSchema.optional(),
integrity_contradictions: z.array(integrityContradictionSchema),
coverage: artifactInventoryCoverageSchema,
limitations: z.array(z.string()),
/** Optional only for persisted inventory Evidence written before this field. */
coverage_status: z.enum(["complete", "partial"]).optional(),
});
/** Current producers always declare coverage; historical Evidence inputs may omit it. */
export const artifactInventoryOutputSchema =
artifactInventoryResultSchema.safeExtend({
coverage: artifactInventoryCoverageSchema,
coverage_status:
artifactInventoryResultSchema.shape.coverage_status.unwrap(),
});
export type ArtifactInventoryOutput = z.infer<
typeof artifactInventoryOutputSchema
>;
/** One safely materialized artifact beneath caller-approved output root. */
const extractedArtifactSchema = z.object({
artifact_id: artifactIdSchema,
+5 -21
View File
@@ -1,7 +1,6 @@
import { z } from "zod";
import {
artifactInventoryOutputSchema,
artifactInventoryResultSchema,
type ArtifactInventoryResult,
} from "./artifactGraph.js";
@@ -75,7 +74,10 @@ export const artifactInspectionResultSchema = z.strictObject({
operation: z.literal("inventory_artifact"),
status: z.literal("completed"),
evidence_id: evidenceIdSchema,
evidence: evidenceSchema,
evidence: evidenceSchema.safeExtend({
operation: z.literal("inventory_artifact"),
normalized_result: artifactInventoryResultSchema,
}),
}),
)
.min(1),
@@ -86,7 +88,7 @@ export const artifactInspectionResultSchema = z.strictObject({
next_probes: z.array(nextProbeSchema),
coverage: z.strictObject({
status: z.enum(["complete-within-substeps", "partial", "truncated"]),
substeps_completed: z.literal(1),
substeps_completed: z.number().int().min(1),
observations_retained: z.number().int().min(0),
observations_omitted: z.number().int().min(0),
hypotheses_retained: z.number().int().min(0),
@@ -104,24 +106,6 @@ export type ArtifactInspectionResult = z.infer<
typeof artifactInspectionResultSchema
>;
/**
* The current producer guarantees a concrete inventory payload. Specialize
* its output without narrowing the historical inspection/Evidence input
* parser or applying inventory defaults inside an authenticated imported record.
*/
export const artifactInspectionOutputSchema =
artifactInspectionResultSchema.safeExtend({
substeps: z
.array(
artifactInspectionResultSchema.shape.substeps.element.safeExtend({
evidence: evidenceSchema.safeExtend({
normalized_result: artifactInventoryOutputSchema,
}),
}),
)
.min(1),
});
/** Project one successful inventory Evidence record into an inspection report. */
export const createArtifactInspection = (
inventoryInput: unknown,
@@ -0,0 +1,75 @@
import { expect, it } from "vitest";
import { ARTIFACT_COMPARISON_EXAMPLE } from "../contracts/artifactComparisonExample.js";
import { createArtifactInspection } from "./artifactInspection.js";
import { parseArtifactInventoryEvidence } from "./artifactInventoryEvidence.js";
import { createEvidence } from "./evidence.js";
import { jsonValueSchema } from "./jsonValue.js";
const inventoryEvidence = ARTIFACT_COMPARISON_EXAMPLE.left;
const target = inventoryEvidence.subject;
if (target === null) throw new Error("Artifact fixture has no target");
const inspectionEvidence = (result: unknown, subject = target) =>
createEvidence(
{
path: subject.local_path,
sha256: subject.digest.sha256,
format: subject.format,
},
inventoryEvidence.provider,
{
operation: "inspect_artifact",
parameters: {},
result: jsonValueSchema.parse(result),
confidence: "observed",
authority: "shipped-artifact",
},
);
const inspection = createArtifactInspection(inventoryEvidence);
it("accepts an inspection whose carrier, declaration, and substep identities agree", () => {
expect(
parseArtifactInventoryEvidence(inspectionEvidence(inspection)).inventory,
).toMatchObject({ complete: true, coverage: { status: "complete" } });
});
it("rejects an inspection carrier whose Evidence subject disagrees with its nested inventory", () => {
const carrier = inspectionEvidence(inspection, {
...target,
digest: { sha256: "a".repeat(64) },
});
expect(() => parseArtifactInventoryEvidence(carrier)).toThrow(
"Artifact inspection Evidence subject does not match its root manifest",
);
});
it("rejects an inspection declaration whose root manifest disagrees with its nested inventory", () => {
const mismatched = {
...inspection,
subject: {
...inspection.subject,
root_artifact_id: `art_${"f".repeat(64)}`,
},
};
expect(() =>
parseArtifactInventoryEvidence(inspectionEvidence(mismatched)),
).toThrow(
"Artifact inspection subject does not match its inventory manifest",
);
});
it("rejects a substep evidence ID that disagrees with its authenticated nested Evidence", () => {
const [first, ...remaining] = inspection.substeps;
if (first === undefined) throw new Error("Expected inventory substep");
const mismatched = {
...inspection,
substeps: [{ ...first, evidence_id: `ev_${"f".repeat(64)}` }, ...remaining],
};
expect(() =>
parseArtifactInventoryEvidence(inspectionEvidence(mismatched)),
).toThrow(
"Artifact inspection substep evidence ID does not match nested Evidence",
);
});
+118 -96
View File
@@ -17,11 +17,10 @@ import {
occurrenceIdForLocation,
} from "./artifactIdentity.js";
import { parseEvidence, type Evidence } from "./evidence.js";
import { artifactInspectionResultSchema } from "./artifactInspection.js";
import {
isZipPackageFormat,
isZipInventoryScopeLimitation,
} from "./zipPackageFormat.js";
artifactInspectionResultSchema,
type ArtifactInspectionResult,
} from "./artifactInspection.js";
/** Complete or partially assembled inventory used by comparison workflows. */
export interface InventorySet {
@@ -50,50 +49,136 @@ export interface ParsedInventorySet {
readonly inventory: InventorySet;
}
interface ParsedArtifactInspectionInventory {
readonly inspection: ArtifactInspectionResult;
readonly inventory: InventorySet;
readonly evidenceIds: readonly string[];
}
/** Parse, merge, and verify inventory Evidence pages. */
export const parseArtifactInventoryEvidence = (
input: unknown,
): ParsedInventorySet => {
const pages = (Array.isArray(input) ? input : [input]).map((value) => {
const evidence = parseEvidence(value);
const inventoryEvidence =
evidence.operation === "inventory_artifact"
? evidence
: evidence.operation === "inspect_artifact"
? artifactInspectionResultSchema.parse(evidence.normalized_result)
.substeps[0]?.evidence
: undefined;
if (inventoryEvidence === undefined)
const evidence = (Array.isArray(input) ? input : [input]).map(parseEvidence);
const pages = evidence.flatMap((carrier) => {
if (carrier.operation === "inventory_artifact")
return [parseInventoryEvidencePage(carrier)];
if (carrier.operation !== "inspect_artifact")
throw new TypeError(
"Artifact comparison requires inspect_artifact Evidence",
);
const inventory = artifactInventoryResultSchema.parse(
inventoryEvidence.normalized_result,
);
validateArtifactInventorySubject(
inventoryEvidence.subject,
inventory.manifest,
);
validateInventoryPage(inventory);
return { evidence, inventory };
return parseInspectionInventoryPayload(
carrier.normalized_result,
carrier.subject,
).pages;
});
return {
evidence: pages.map(({ evidence }) => evidence),
inventory: assembleInventoryResults(
pages.map(({ inventory }) => inventory),
),
evidence,
inventory: assembleInventoryResults(pages),
};
};
/** Parse and authenticate an already-owned inspection payload and its pages. */
export const parseArtifactInspectionInventoryResult = (
input: unknown,
subject: Evidence["subject"],
): ParsedArtifactInspectionInventory => {
const parsed = parseInspectionInventoryPayload(input, subject);
return {
inspection: parsed.inspection,
inventory: assembleInventoryResults(parsed.pages),
evidenceIds: parsed.evidenceIds,
};
};
/** Shared owner boundary for full Evidence and already-authenticated parents. */
const parseInspectionInventoryPayload = (
input: unknown,
subject: Evidence["subject"],
): {
readonly inspection: ArtifactInspectionResult;
readonly pages: readonly ArtifactInventoryResult[];
readonly evidenceIds: readonly string[];
} => {
const inspection = artifactInspectionResultSchema.parse(input);
if (inspection.coverage.substeps_completed !== inspection.substeps.length)
throw new TypeError(
"Artifact inspection substep count does not match its coverage declaration",
);
const substeps = inspection.substeps.map((step) => {
const evidence = parseEvidence(step.evidence);
if (step.evidence_id !== evidence.evidence_id)
throw new TypeError(
"Artifact inspection substep evidence ID does not match nested Evidence",
);
return {
evidenceId: evidence.evidence_id,
page: parseInventoryEvidencePage(evidence),
};
});
const firstPage = substeps[0]?.page;
if (firstPage === undefined)
throw new TypeError("Artifact inspection requires inventory substeps");
validateInspectionSubject(subject, inspection, firstPage.manifest);
return {
inspection,
pages: substeps.map(({ page }) => page),
evidenceIds: substeps.map(({ evidenceId }) => evidenceId),
};
};
/** Ensure the inspection declaration and carrier identify its inventory. */
const validateInspectionSubject = (
subject: Evidence["subject"],
inspection: ArtifactInspectionResult,
manifest: ArtifactInventoryResult["manifest"],
): void => {
if (
inspection.subject.manifest_id !== manifest.manifest_id ||
inspection.subject.root_artifact_id !== manifest.root_artifact_id ||
inspection.subject.root_sha256 !== manifest.root_sha256 ||
inspection.subject.root_format !== manifest.root_format
)
throw new TypeError(
"Artifact inspection subject does not match its inventory manifest",
);
if (
subject === null ||
subject.digest.sha256 !== manifest.root_sha256 ||
subject.format !== manifest.root_format
)
throw new TypeError(
"Artifact inspection Evidence subject does not match its root manifest",
);
};
/** Validate one already authenticated parent payload with the same graph proofs. */
export const parseArtifactInventoryResult = (
input: unknown,
subject: Evidence["subject"],
): InventorySet => {
): InventorySet =>
assembleInventoryResults([parseInventoryPage(input, subject)]);
/** Parse one already authenticated inventory result into its validated facts. */
const parseInventoryPage = (
input: unknown,
subject: Evidence["subject"],
): ArtifactInventoryResult => {
const inventory = artifactInventoryResultSchema.parse(input);
validateArtifactInventorySubject(subject, inventory.manifest);
validateInventoryPage(inventory);
return assembleInventoryResults([inventory]);
return inventory;
};
/** Parse one authenticated inventory carrier into its validated owner facts. */
const parseInventoryEvidencePage = (
evidence: Evidence,
): ArtifactInventoryResult => {
if (evidence.operation !== "inventory_artifact")
throw new TypeError(
"Artifact inventory page must be inventory_artifact Evidence",
);
return parseInventoryPage(evidence.normalized_result, evidence.subject);
};
/** Both inventory carriers require the same authenticated root identity. */
@@ -137,10 +222,7 @@ const assembleInventoryResults = (
throw new TypeError("Artifact inventory requires Evidence pages");
const manifestJson = canonicalJson(first.manifest, "Artifact inventory");
const coverageJson = canonicalJson(
{
coverage: first.coverage ?? null,
coverage_status: first.coverage_status ?? null,
},
first.coverage,
"Artifact inventory coverage",
);
const nodes = new Map<string, ArtifactNode>();
@@ -150,13 +232,8 @@ const assembleInventoryResults = (
if (canonicalJson(page.manifest, "Artifact inventory") !== manifestJson)
throw new TypeError("Artifact inventory pages do not share one manifest");
if (
canonicalJson(
{
coverage: page.coverage ?? null,
coverage_status: page.coverage_status ?? null,
},
"Artifact inventory coverage",
) !== coverageJson
canonicalJson(page.coverage, "Artifact inventory coverage") !==
coverageJson
)
throw new TypeError(
"Artifact inventory pages do not share one coverage declaration",
@@ -221,7 +298,7 @@ const orderedInventory = (
edges,
integrityContradictions: first.integrity_contradictions,
limitations,
coverage: resolveInventoryCoverage({ ...first, limitations }),
coverage: first.coverage,
complete:
nodes.length === first.manifest.node_count &&
occurrences.length === first.manifest.occurrence_count &&
@@ -267,61 +344,6 @@ const orderOccurrences = (
});
};
// Normalize both retained coverage formats at this owning boundary.
const resolveInventoryCoverage = (
inventory: ArtifactInventoryResult,
): ArtifactInventoryCoverage => {
const legacy = legacyInventoryCoverage(
inventory.manifest.root_format,
inventory.limitations,
inventory.integrity_contradictions.length,
);
if (inventory.coverage !== undefined) {
if (
inventory.coverage_status !== undefined &&
(inventory.coverage.status === "complete") !==
(inventory.coverage_status === "complete")
)
throw new TypeError(
"Artifact inventory has conflicting coverage declarations",
);
return inventory.coverage;
}
return inventory.coverage_status === undefined
? legacy
: {
...legacy,
status:
inventory.coverage_status === "complete" ? "complete" : "unknown",
};
};
// Retained pre-coverage records cannot classify arbitrary prose. Recognize only
// the historical producer vocabulary here; unfamiliar diagnostics remain unknown.
const legacyInventoryCoverage = (
format: ArtifactInventoryResult["manifest"]["root_format"],
limitations: readonly string[],
contradictionCount: number,
): ArtifactInventoryCoverage => ({
scope:
isZipPackageFormat(format) ||
["directory", "asar", "dmg", "pkg", "mach-o-universal"].includes(format)
? "container-entries"
: "root",
status: limitations.some(
(limitation) =>
limitation !== "Artifact has no child container entries." &&
!isZipInventoryScopeLimitation(limitation) &&
!(
contradictionCount > 0 &&
limitation ===
`${String(contradictionCount)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`
),
)
? "unknown"
: "complete",
});
const validateCompleteInventory = (inventory: InventorySet): void => {
const nodeIds = new Set(inventory.nodes.map(({ artifact_id: id }) => id));
const occurrenceIds = new Set(
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff. Show more