chore: merge environment state serve (25b6622d) into environment state writes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-20 20:48:17 -07:00
commit 4aeefd9df0
304 files changed
+21890 -3117

No files matched your search

@@ -94,6 +94,10 @@ explicit path or endpoint and do not need it.
- Archive/package member inventory (ZIP/APK/IPA/MSIX/AppX or DMG):
`open_binary` with the supplied local path; use `inspect_artifact` when its
graph and findings help answer the question.
- Apple app plist, Interface Builder, asset catalog, or keyed archive resources:
`open_bundle_resources`, then the selected resource tool. This does not
require `CFBundleExecutable` or a deep provider. Resource sessions have no
executable digest or binary snapshot; use `open_binary` for native code.
- Android APK code, classes, methods, or incoming references:
`inspect_android_package`, then focused Android tools when advertised.
Archive member inventory still uses the archive route above.
@@ -11,6 +11,22 @@ Select columns, batch IDs and page individual facts; aliases remain unresolved.
Unpaired observations do not prove additions/removals. Existing matched dossier
pairs include inputs for `compare_functions`; otherwise analyze just chosen pairs.
## Apple app resources
Use `open_bundle_resources` with the selected app path, then `inspect_plist`,
`decode_interface_builder`, `inspect_asset_catalog`, or `inspect_keyed_archive`.
The resource session reads the app without resolving `CFBundleExecutable` or
starting a deep provider. Preserve `resource_root` provenance, per-resource
hashes where reported, and null executable identity; a directory path is not
an executable digest. Export retained Evidence before closing or switching
roots. Binary analysis snapshots do not apply. Use `open_binary` to switch to
executable analysis, and `close_binary` to release either session kind.
The four matching CLI commands accept the app path directly. For an archive
inside the app, use `inspect-keyed-archive APP_PATH RELATIVE_ARCHIVE_PATH`.
Host requirements for plist and compiled asset/Interface Builder tooling still
apply; a resource-only session does not establish provider availability.
## Desktop installation directories
When an installation directory is supplied and its runtime is unknown, use
+13 -3
View File
@@ -1,5 +1,10 @@
name: Cache npm package downloads
description: Restore npm's download cache and save it only from trusted default-branch events.
description: Restore npm's download cache. Trusted default-branch jobs that opt in may save it.
inputs:
save:
description: Save the shared download cache when this event is allowed to write it.
required: false
default: "false"
outputs:
can-save:
description: Whether this run may update the shared npm download cache.
@@ -23,20 +28,25 @@ runs:
can_save=true
fi
cache_key="npm-download-${RUNNER_OS}-${runtime_arch}-${LOCKFILES_HASH}"
# Same platform only. A lockfile change can reuse tarballs from the previous key.
restore_key="npm-download-${RUNNER_OS}-${runtime_arch}-"
{
printf 'can-save=%s\n' "${can_save}"
printf 'path=%s\n' "${cache_path}"
printf 'key=%s\n' "${cache_key}"
printf 'restore-key=%s\n' "${restore_key}"
} >> "${GITHUB_OUTPUT}"
- name: Restore and save npm package downloads
if: steps.context.outputs.can-save == 'true'
if: steps.context.outputs.can-save == 'true' && inputs.save == 'true'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.context.outputs.path }}
key: ${{ steps.context.outputs.key }}
restore-keys: ${{ steps.context.outputs.restore-key }}
- name: Restore npm package downloads without saving
if: steps.context.outputs.can-save != 'true'
if: steps.context.outputs.can-save != 'true' || inputs.save != 'true'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.context.outputs.path }}
key: ${{ steps.context.outputs.key }}
restore-keys: ${{ steps.context.outputs.restore-key }}
+64
View File
@@ -0,0 +1,64 @@
name: Cache retention
on:
workflow_run:
workflows: ["CI"]
types: [completed]
# Qualification calls ci.yml with actions: read. A nested workflow cannot raise
# that token, so cache deletion stays in this workflow.
permissions:
contents: read
actions: write
concurrency:
group: cache-retention-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: false
jobs:
retain:
name: Retain Turbo snapshots and npm download caches
# Restrict the triggering-source checkout to same-repository default-branch
# CI events. The helper runs from this trusted workflow revision; triggering
# source is used only for cache identity and is never executed. Static and
# build save snapshots before later lanes finish, so failed, timed-out, or
# cancelled runs still retain those snapshots.
if: >-
(github.event.workflow_run.conclusion == 'success' ||
github.event.workflow_run.conclusion == 'failure' ||
github.event.workflow_run.conclusion == 'timed_out' ||
github.event.workflow_run.conclusion == 'cancelled') &&
github.event.workflow_run.head_repository.full_name == github.repository &&
github.event.workflow_run.head_branch == github.event.repository.default_branch &&
(github.event.workflow_run.event == 'push' ||
github.event.workflow_run.event == 'schedule' ||
github.event.workflow_run.event == 'workflow_dispatch')
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: ${{ github.workflow_sha }}
path: trusted-retention
persist-credentials: false
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: ${{ github.event.workflow_run.head_sha }}
path: triggering-source
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: trusted-retention/.nvmrc
- name: Retain current Turbo snapshots and npm download caches
env:
GH_TOKEN: ${{ github.token }}
CI_REPOSITORY: ${{ github.repository }}
CACHE_PREFIX: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-
CACHE_KEEP_KEY: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('triggering-source/.nvmrc', 'triggering-source/.npmrc', 'triggering-source/package-lock.json') }}-${{ github.event.workflow_run.head_sha }}
CACHE_DEEP_PREFIX: rea-static-deep-v1-${{ runner.os }}-${{ runner.arch }}-
CACHE_DEEP_KEEP_KEY: rea-static-deep-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('triggering-source/.nvmrc', 'triggering-source/.npmrc', 'triggering-source/package-lock.json') }}-${{ github.event.workflow_run.head_sha }}
CACHE_KNIP_PREFIX: knip-meta-v1-${{ runner.os }}-
CACHE_KNIP_KEEP_KEY: knip-meta-v1-${{ runner.os }}-${{ hashFiles('triggering-source/knip.json', 'triggering-source/tsconfig.json', 'triggering-source/package.json', 'triggering-source/package-lock.json') }}-${{ github.event.workflow_run.head_sha }}
CACHE_PORTABLE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-
DEDUPE_NPM_DOWNLOADS: "true"
run: node trusted-retention/scripts/ci/cache-retention.mjs
+13 -3
View File
@@ -146,6 +146,9 @@ jobs:
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
with:
# Linux x64 writer. Parallel Linux jobs restore this key.
save: "true"
- run: npm ci
- name: Restore static task cache
if: needs.changes.outputs.code == 'true'
@@ -299,9 +302,11 @@ jobs:
# every shard's diagnostics and coverage. Passing PRs run the full suite.
fail-fast: ${{ github.event_name == 'pull_request' && needs.changes.outputs.tier == 'fast' }}
matrix:
# Fast feedback spends the same six test jobs on one source group and
# five built groups; full qualification retains six complete shards.
shard: ${{ fromJSON(needs.changes.outputs.tier == 'fast' && '["1/5","2/5","3/5","4/5","5/5"]' || '["1/6","2/6","3/6","4/6","5/6","6/6"]') }}
# Fast feedback uses one source group and eight built groups so the
# slowest built shard job stays inside a ten-minute execution budget.
# Classification, build, and report merge sit outside that job.
# Full qualification retains six complete shards with coverage.
shard: ${{ fromJSON(needs.changes.outputs.tier == 'fast' && '["1/8","2/8","3/8","4/8","5/8","6/8","7/8","8/8"]' || '["1/6","2/6","3/6","4/6","5/6","6/6"]') }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
@@ -658,6 +663,9 @@ jobs:
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
with:
# ubuntu-latest is the Linux x64 writer above. Other hosts save their own key.
save: ${{ matrix.os != 'ubuntu-latest' }}
- name: Verify native runtime identity
env:
EXPECTED_CI_PLATFORM: ${{ matrix.platform }}
@@ -794,6 +802,8 @@ jobs:
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
with:
save: "true"
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
+7 -3
View File
@@ -228,9 +228,13 @@ root supplies. Every edge below it has `loader_conditional: true`, and a
request that reuses it by install name is `conditional` too. Findings on those
edges say that they apply only if the conditional image loads.
Absolute install names such as `/usr/lib/libSystem.B.dylib` are
`outside-target`. On macOS 11 and later most of them live in the dyld shared
cache rather than on disk, so REA does not check them against the host.
Absolute install names that name a path beneath the analyzed root are checked
within that root, with symlinks and `..` resolved by the same bounded tree
resolver. Absolute paths outside the root, such as
`/usr/lib/libSystem.B.dylib`, are `outside-target`; on macOS 11 and later most
system libraries live in the dyld shared cache rather than on disk, so REA does
not check them against the host. Mach-O path strings are not converted from
`file:` URLs or looked up through unrelated host paths.
`findings` are derived, not observed:
+17 -1
View File
@@ -19,8 +19,24 @@ Inspect native SAZ or HAR 1.2 offline with REA, or connect Everywhere's separate
- `analyze_web_bundle` / `rea analyze-web-bundle` parses selected script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally source-map/original-source evidence.
- `observe_web_session` / `rea observe-web-session` arms an observation window of the requested duration for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata.
- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete observed `input_schema` declaration and canonical `input_schema_sha256` digest inline. Declarations are untrusted JSON data, including declared types, required fields, enums, examples and reference URLs. REA does not resolve schema references. These fields replace `input_schema_shape`. Registrations with the same name and URL in distinct observed frames retain separate identities. `tool_key` includes the observed frame ID; it is not a stable identity across navigation or browser sessions. Same-frame registration updates replace that owner only, and removal leaves other owners intact. Declared `readOnly`, `untrustedContent`, `consequential`, and `autosubmit` annotation hints are reported per registration: `true`/`false` are declared values, `null` means the declaration did not provide the hint in that capture, and a missing `consequential` key means the hint was not recorded, either in a historical capture or because the observed annotation was invalid. Consult discovery completeness and exclusions to distinguish these cases. Malformed annotation values retain the registration with incomplete coverage and an `invalid_protocol_value` exclusion. Other malformed protocol payloads can fail discovery explicitly. REA never exposes `WebMCP.invokeTool`.
- `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence.
For `analyze_web_bundle`, each `webmcp_declarations` entry's
`schema_property_names` is a source-derived projection of statically known own
property keys, not schema validation. `schema_property_names_coverage` reports
how complete that projection is: `complete` means a literal properties object
was fully enumerated (including an empty literal); `partial` means a literal
object was available but some computed or spread membership was unresolved,
and the name array retains keys that were known; `unknown` means the schema or
properties owner could not be enumerated as an object literal, including
explicit non-object values; `not_present` means no effective schema or own
`properties` member was present after the `inputSchema ?? input_schema` alias
selection. `not_present` describes this projection only: it does not validate a
schema or rule out properties expressed through other schema constructs. Older
observations may omit the coverage field; treat omission as `unknown` rather
than inferring completeness from the array.
Passive DOM comparison checks complete node inventories and complete frame
parent/child structure independently. Frame IDs and sibling enumeration order
are capture-local and do not establish a change; duplicate IDs, missing parents,
@@ -346,7 +362,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex
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, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. 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.
- WebSocket observations retain direction, opcode, and payload byte length. A missing or malformed CDP opcode is returned as `null`, with opcode-dependent payload byte length and shape also `null`, and marks `websocket_frames` incomplete. Missing or wrong-typed payload data is not treated as empty; its byte length is `null`, and a text payload shape is unknown. Numeric opcode `0` and an observed empty payload remain distinct from missing data. Opcode is nullable in the public inspection schema, so clients must handle unknown protocol values. When shape capture is selected, text messages are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw payloads 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 fingerprints hash all decoded response bytes, including bodies above 64 KiB, subject to the [64 MiB per-message CDP limit](#request-boundary). Missing or undecodable cache bodies and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
- Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers.
- Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. When CDP supplies an execution-context association, the accepted script retains its authorized frame ID for later attribution. Script metadata admission bounds retained identity and source-map strings to 8 MiB and 50,000 records. When a source-map URL cannot fit, the script identity remains and source-map coverage reports `resource_budget_exhausted`; rejected script observations are counted without retaining their IDs. Source content is omitted unless explicitly requested and is returned inline as a self-verifying artifact with its SHA-256 digest.
+7
View File
@@ -340,6 +340,13 @@ A scoped report has `scope.mode: "explicit"`. Its `scope_checks` determine
`environment_healthy` summarizes the full audit. `--target` adds a target check,
but the report remains audit-wide unless a scope option is also supplied.
An invalid `REA_LOG_LEVEL` fails every scope, including one client or the
installed skill, because the executable rejects it before any command. A
selected client also fails for every other setting that MCP startup rejects,
including `REA_GHIDRA_STARTUP_TIMEOUT_MS` and `REA_HOPPER_STARTUP_TIMEOUT_MS`.
Those settings still fail a selected provider and the audit-wide result. A
skill-only report leaves them informational.
Follow the remediation for the failed check. If a native target has several
available providers, choose one with `--provider` on the CLI or `provider_id`
on `open_binary`. A `capability_unavailable` failure carries
+11
View File
@@ -528,6 +528,17 @@ concrete version, selection source, and complete analysis profile through
choices return typed selection details. A selected provider is never replaced
automatically after a runtime failure.
`open_bundle_resources` selects a separate read-only app resource session
without resolving `CFBundleExecutable`, including when it is absent or
unresolvable. `binary_session` reports `session_kind: "bundle_resources"` and the
caller and canonical resource-root paths; it does not report a binary target or
deep-provider binding. `inspect_plist`, `decode_interface_builder`,
`inspect_asset_catalog`, and `inspect_keyed_archive` retain resource-root
provenance and use a null Evidence subject when executable identity is unknown.
Binary-only operations and binary analysis snapshots do not apply in this
session. `open_binary` can switch back to executable analysis, and
`close_binary` retires either session kind.
Each provider client receives `binary_session.analysis_run.run_id` before any
provider startup, including clients whose startup fails. `process_lineage` is
`not_observed` until a dynamic provider starts, then becomes `snapshots` with
+31 -1
View File
@@ -425,10 +425,25 @@ complete observation.
vtable descriptors. Records include exact virtual addresses/file offsets and
artifact evidence. Vtable indexes are metadata word offsets; witness indexes
start after the conformance header. Names unavailable in metadata remain null.
The `swift_class_vtable_descriptors` coverage counters count examined vtable
slots and successfully decoded slots, rather than type-section entries.
The `objc_class_method_ivar_metadata` counters count admitted class and
metaclass objects and successfully decoded objects.
Pointers are decoded through the image's fixups:
- `LC_DYLD_CHAINED_FIXUPS` rebases and binds, including the authenticated
arm64e formats;
- legacy `LC_DYLD_INFO` bind opcodes.
- legacy `LC_DYLD_INFO` rebase and bind opcodes.
A Swift conformance whose type or protocol reference is an external bind
keeps its name as `null` and carries the raw dyld identity in
`type_binding` or `protocol_binding`; that record and its facet coverage are
partial. Consumers of this output must handle nullable conformance names
and use the typed binding fields instead of substituting a symbol as a
display name. Indirect relative-pointer records also retain their binding
identity. A decoded rebase to virtual address zero remains an address; only
an unfixed zero in a nullable ABI field is treated as null. Incomplete or
conflicting fixup streams keep affected pointers unresolved; coverage reports
parsing and resource limits while retaining validated prefix facts.
So an external superclass resolves from its `_OBJC_CLASS_$_` bind (for
example `NSObject`). The `pointer_fixups` coverage facet names the mechanism.
@@ -466,6 +481,21 @@ complete observation.
that operation is unavailable; runtime failures remain failures rather than
being replaced by symbol-only results.
`get_objc_classes` keeps names from the provider inventory. It coalesces rows
only when their exact selected-image pointer slots decode to the same class
import, including its binding stream, raw library ordinal, resolved library, weak status, and addend. A literal filter is
applied to the original names before that join; if the filter selects only an
alias, the output keeps that exact row. `identity_coverage` distinguishes a
complete selected-inventory scan from partial, unsupported, and unknown cases.
Recognized Mach-O formats outside the native metadata reader's support retain
the provider's class labels with unknown import identity; malformed headers and
read failures remain errors.
The join also requires matching whole-artifact digests and matching provider-
owned selected-image facts: container, byte range, full raw CPU type/subtype,
and FAT table digest when present. A missing or overridden loader selection
remains unknown; the result describes agreement on the selected artifact image,
not an atomic observation of a running process or whole-program class coverage.
## Native desktop observation
`observe_native_ui` captures one explicitly selected existing PID/window ID.
+89 -28
View File
@@ -78,8 +78,9 @@ without the artifacts lock, so it can record hashes while `check:pr` or
`check:test` already holds that lock. The CI jobs still run the locked
`check:ci:fast` and `check:ci:deep` commands.
Use `npm run lint:details` or `npm run jscpd:details` for all console diagnostics.
Both duplicate commands scan production and test code with the same 3% limit;
the summary commands preserve checker failures and restore reports from cached
Both duplicate commands scan production and test code using the limit in
[`.jscpd.json`](https://github.com/morluto/rea/blob/main/.jscpd.json).
The summary commands preserve checker failures and restore reports from cached
static tasks. Both lint caches include the website, documentation configuration,
and experiment code scanned by the checker; a regression compares the actual
lint inventory with Turbo's resolved inputs.
@@ -146,6 +147,17 @@ the fixture cache. These subprocesses are outside aggregate Vitest coverage;
subprocess coverage attachment remains disabled. Cold-start import checks launch
independently without this fixture cache.
`analyzeJavaScriptDirectory` stdio MCP cases in one `cliTest` file share one
server and that compile cache. The first case pays startup; later cases reuse
the process. File teardown waits for startup still in flight, closes a server
registered during that wait, and does not open a server after close. A failed
handshake stays the caller error when its cleanup also fails, including a
rejection without a reason. That cleanup stays owned until a later close or
call releases it. Another server does not open while the cleanup remains, and
a cleanup that already succeeded is not run again. Pass
`mcpCall` when a case needs its own server, environment, or lifecycle. CLI
cases still start a fresh process.
The Apple artifact verifier step shares a job-temporary bytecode cache among
its fresh CLI/MCP children via `REA_ARTIFACT_NODE_COMPILE_CACHE`. The artifact
helper maps this setting to child `NODE_COMPILE_CACHE`; npm, Turbo, and nested
@@ -460,8 +472,8 @@ partial diagnostics and preflights do not establish complete mutation outcomes
or qualify the release revision for mutation coverage.
Fast CI starts the source-only projects after classification, independently of
build and static checks, and runs five built-dependent shards after the build.
The six test jobs still select every portable test. Full qualification keeps six
build and static checks, and runs eight built-dependent shards after the build.
Those nine test jobs still select every portable test. Full qualification keeps six
complete Vitest shards with coverage. Neither tier retries tests. Canonical
project ownership lives in `scripts/lib/test-projects.mjs`, shared by Vitest,
CI group execution, and development-test preparation. A test importing compiled
@@ -522,11 +534,35 @@ JSON artifact members without executing artifact content. Reports and limits
are retained in `ci-health-diagnostics`; its ten-minute job limit covers the
existing four-minute timing collection plus the bounded diagnostic stages.
Shard-count reduction remains deferred: the available successful timing sample
covers an older inventory, and file phase weights are not job wall time. The
one-source/five-built split retains six test jobs; it makes no hosted speedup
claim. Current complete timings and completed candidate hosted runs are needed
before enabling a smaller adaptive matrix.
Fast built shards target under ten minutes of uncontended shard-job execution.
That figure is the `test-shard` job. The job waits for classification and the
build, and the portable-test merge runs after the slowest shard. On 2026-10-20,
run `39451548945` measured those surrounding jobs with runner queue excluded:
classification 15 seconds, build 49 seconds, and the portable-test merge 29
seconds. Shard 1/5 of that run spent 22 seconds before built Vitest and 3
seconds after it, including `npm ci` at 9 seconds. `node_modules` is about 674
MiB, so publishing it as a shared artifact would transfer more data than that
install saves. The npm download cache stays the setup cache.
File timings from that run put the built suite at 7944 seconds of module time.
The sequencer keeps each file's full duration on one worker. Extra workers
overlap other files in the same sequence group, and `process-boundary` runs
after that group. Scheduling the unsplit inventory onto five shards peaks at
820 seconds of tests. That run's slowest built shard job lasted 925 seconds
(shard 4/5), and shard 1/5 spent 874 seconds in built Vitest, so hosted job
time sits above the file schedule. Six shards of the same inventory peak at
684 seconds of tests. Eight shards, after the 578-second loader file is split
by case count (54 cases stay in that file and 46 move into each of three
files), peak at 513 seconds of tests. With the measured 25-second shard setup,
that shard job is about 538 seconds. Adding the measured classification, build,
and merge makes an uncontended portable critical path of about 631 seconds,
still before the gap between this file schedule and hosted Vitest. Runner queue
is separate and grows when more shard jobs compete for runners.
`process-boundary` stays serial because process-tree sampling shares the host.
Raising the worker cap from two to four on this four-core host cut a six-file
acceptance sample from 96–101 seconds to 71 seconds and did not remove the
single-file floor, so the cap stays two. Full qualification keeps six covered
shards; its longer instrumentation is outside this fast budget.
The separate `CI timings` workflow batches recent completed CI and Release runs
hourly or on manual dispatch. It uploads `ci-job-timings-batch` with raw API pages and
@@ -568,8 +604,9 @@ metadata, and available manifests into `.cache/ci-build-cache/`.
`portable-build-cache.json` records availability per task. Evicted or partially
missing entries are omitted as whole entries, and consumers rebuild cache misses,
including when every entry is unavailable. Malformed plans, non-file entries,
and I/O errors other than disappearance still fail publication. Older cross-run entries stay in the
original cache. The Windows lane relies on the Linux static job for typechecking. They retain the normal build and prepack
and I/O errors other than disappearance still fail publication. After a trusted
static run, superseded cross-run Turbo snapshots for that OS and architecture
are deleted down to the two newest generations. The Windows lane relies on the Linux static job for typechecking. They retain the normal build and prepack
commands, which restore matching outputs or rebuild on a cache miss. Native
artifacts, dependency installs, package archives, and runtime verification remain
host-local. Keep platform-dependent outputs out of this shared cache.
@@ -1118,7 +1155,7 @@ suite, without the source-test shards or native package
lanes. Classification compares the PR head with its merge base, so later
base-branch changes do not expand that scope. Static and portable-test aggregate jobs
remain present and fail if classification fails. Every code change retains the
complete six-shard Linux suite; individual test files are not selected by
complete portable Linux suite; individual test files are not selected by
imports or filenames.
Ordinary PRs also produce an **observational** test-selection plan. The candidate
@@ -1129,7 +1166,7 @@ tests. Shared inputs, unknown paths, missing owner tests, and removed tests fall
back to the complete inventory. Renames account for both paths. Main, scheduled,
manual, and `ci:full` runs always propose the full suite.
All six shards still execute the complete suite. The existing report merge
The source group and every built shard together still execute the complete suite. The existing report merge
compares the proposal with actual module outcomes and retains
`.cache/ci-test-selection/report.json` in `test-results`, including selected and
omitted paths, changed input paths, missed failures, unobserved files, scheduling weights, PR head and
@@ -1211,7 +1248,7 @@ push: changes from failed or cancelled intermediate runs remain in scope.
Missing, untrusted, or non-ancestor history selects the fast Linux x64
baseline: portable checks and the Linux runtime lane. The lookup is
bounded to the last 100 successful push runs; lookup failures broaden selection.
Every code change still selects all six portable shards, without coverage
Every code change still selects the source group and all eight fast built shards, without coverage
instrumentation in this fast tier. Ordinary PR matrices cancel remaining
shards after the first failure; passing PRs execute the complete suite. Main,
`ci:full`, scheduled, manual, and release qualification retain every shard's
@@ -1324,13 +1361,15 @@ producer; newer schedules that reuse qualification without producing timings do
not hide the last full run's artifact.
CI assigns discovered files by historical duration, with a deterministic
fallback weight for new files, rather than dividing only by file count.
`process-boundary` files are weighted at their full duration because that
project runs files serially. Other projects are weighted at half their file
duration, matching the two-worker cap, so a serial file does not look as cheap
as a file that overlaps with another. Every
discovered file still belongs to exactly one shard; worker limits and project
ordering remain unchanged. Fast qualification keeps five built shards without
coverage. The merge job rejects successful reports with missing,
Each file keeps its full duration on one worker. Extra workers overlap other
files in the same sequence group, and `process-boundary` runs after that
group. History published before the competing-loader split still names only
`javascriptLiteralPercentPaths.test.ts`. Until that artifact records one of
the three new files, the sequencer assigns that recorded duration by the
pre-split case counts: 54/192 to the remaining file and 46/192 to each new
file. Every discovered file still belongs to exactly one shard; worker limits
and project ordering remain unchanged. Fast qualification keeps eight built
shards without coverage. The merge job rejects successful reports with missing,
unexpected, or duplicate portable files. Coverage instrumentation and aggregate
thresholds remain enforced in full qualification.
Missing or unusable history falls back to Vitest's default assignment. Only
@@ -1359,13 +1398,35 @@ token leaves the local Turbo cache in place.
The shared npm-cache action caches downloads, not `node_modules`, and every
consumer still runs `npm ci`. Keys cover the runner OS, actual Node architecture,
root lockfiles, and `.npmrc`. Only push, schedule, and manual events on the
repository's default branch may save shared npm caches; PRs restore only.
Static snapshots use the same write authority and are saved only after the
static job's validation succeeds. Main warms the new cache namespace after
merge; a cold cache or changed lockfile falls back to the normal install.
This prevents each PR from saving another branch-scoped copy of the same
download cache. It does not remove runner assignment waits.
root lockfiles, and `.npmrc`. An exact-key miss restores the newest cache for
that OS and architecture, so a lockfile change reuses unchanged tarballs while
`npm ci` still verifies the lockfile. Only push, schedule, and manual events on
the repository's default branch may save shared npm caches, and only from the
opted-in writer for that host: Static checks for Linux x64, the package matrix
for every other included host, and the Windows curated lane. Every other job
restores without saving, so parallel jobs do not publish duplicate copies.
Pull requests restore only. Static snapshots are saved only after the static
job's validation succeeds. Cache deletion is a
separate workflow, because reusable qualification calls this workflow and
cannot raise its read-only token. After a push, schedule, or manual CI run on
the default branch finishes, including when a later lane fails, times out, or
is cancelled, that workflow runs on `ubuntu-latest` and deletes older
`rea-static-v1`, `rea-static-deep-v1`, and `rea-portable-build-v2` entries for
that runner's OS and architecture, plus older `knip-meta-v1` entries for that
OS. It keeps two generations plus the key that run saved. A fast run that did
not publish a deep or Knip snapshot still leaves the two newest entries in
those families. It
deletes an npm download record only when another record has the same key, cache
version, and git ref, and it keeps the most recently accessed copy. A different
version or ref is a different restore target and stays. One cache id is one
record: a repeated id keeps the later access time when its key, version, ref,
size, and creation time agree, and an inconsistent repeat deletes nothing. An
incomplete or unexpected cache list deletes nothing.
Distinct lockfile generations of the download cache stay, so the restore
prefix still has a seed.
Main warms the new cache namespace after merge; a cold cache falls back to the
normal install. This prevents each PR from saving another branch-scoped copy of
the same download cache. It does not remove runner assignment waits.
Portable owner-based test selection remains observational. Its reports compare
proposed omissions with the full run; incomplete or skipped observations do not
+61
View File
@@ -0,0 +1,61 @@
export interface RetainedCacheEntry {
readonly id: number;
readonly key: string;
readonly createdAt: number;
readonly accessedAt: number;
readonly size: number;
}
export interface CacheListRecord {
readonly id: number;
readonly key: string;
readonly version: string;
readonly ref: string;
readonly created_at: string;
readonly last_accessed_at?: string;
readonly size_in_bytes: number;
}
export interface RetentionFamily {
readonly prefix: string;
readonly keepKey?: string;
}
export type CacheRequest = (
method: string,
endpoint: string,
) => Promise<unknown>;
export function assertRetentionPrefix(prefix: string): void;
export function selectPrefixDeletions(
caches: readonly unknown[],
family?: RetentionFamily,
keepGenerations?: number,
): RetainedCacheEntry[];
export function selectNpmDownloadDuplicateDeletions(
caches: readonly unknown[],
): RetainedCacheEntry[];
export function listAllCaches(
request: CacheRequest,
repository: string,
prefix: string,
): Promise<readonly CacheListRecord[]>;
export function retainCaches(options: {
repository: string;
request: CacheRequest;
deleteCache: (id: number) => Promise<void>;
families?: readonly RetentionFamily[];
dedupeNpmDownloads?: boolean;
dryRun?: boolean;
}): Promise<{
dryRun: boolean;
plan: readonly {
prefix: string;
deletions: readonly RetainedCacheEntry[];
}[];
npmDeletions: readonly RetainedCacheEntry[];
}>;
+358
View File
@@ -0,0 +1,358 @@
import { execFile } from "node:child_process";
import { realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const MAX_BUFFER = 8 * 1024 * 1024;
const REQUEST_TIMEOUT_MS = 20_000;
const MAX_PAGES = 10;
const PAGE_SIZE = 100;
// Keep the newest snapshots, not only the key just saved. A concurrent job may
// still be restoring the previous generation; deleting that id fails its restore.
// The following trusted run drops anything older than those two.
const RETAINED_CACHE_GENERATIONS = 2;
const PREFIX_PATTERNS = [
/^rea-static-v1-[A-Za-z0-9]+-[A-Za-z0-9]+-$/u,
/^rea-static-deep-v1-[A-Za-z0-9]+-[A-Za-z0-9]+-$/u,
/^rea-portable-build-v2-[A-Za-z0-9]+-[A-Za-z0-9]+-$/u,
/^knip-meta-v1-[A-Za-z0-9]+-$/u,
];
/** Reject prefixes that could match npm downloads or another platform. */
export function assertRetentionPrefix(prefix) {
if (
typeof prefix !== "string" ||
!PREFIX_PATTERNS.some((pattern) => pattern.test(prefix))
)
throw new Error(
"Cache retention prefix must be one Turbo family for one OS and architecture, or one Knip metadata family for one OS",
);
}
function normalizeCache(cache, index) {
if (typeof cache !== "object" || cache === null || Array.isArray(cache))
throw new Error(`Cache entry ${String(index + 1)} is malformed`);
const { id, key, size_in_bytes: size } = cache;
if (!Number.isSafeInteger(id) || id <= 0)
throw new Error(`Cache entry ${String(index + 1)} has an invalid id`);
if (typeof key !== "string" || key.length === 0 || /[\r\n]/u.test(key))
throw new Error(`Cache entry ${String(index + 1)} has an invalid key`);
const createdAt = Date.parse(cache.created_at);
const accessedAt = Date.parse(cache.last_accessed_at ?? cache.created_at);
if (!Number.isFinite(createdAt) || !Number.isFinite(accessedAt))
throw new Error(
`Cache entry ${String(index + 1)} has an invalid timestamp`,
);
if (!Number.isSafeInteger(size) || size < 0)
throw new Error(`Cache entry ${String(index + 1)} has an invalid size`);
// actions/cache restores a key only when this metadata hash matches.
const version = cache.version;
if (typeof version !== "string" || !/^[a-f0-9]{64}$/u.test(version))
throw new Error(`Cache entry ${String(index + 1)} has an invalid version`);
// The git ref is the cache's access scope. Another branch cannot use it.
const ref = cache.ref;
if (typeof ref !== "string" || !/^refs\/[^\s]+$/u.test(ref))
throw new Error(`Cache entry ${String(index + 1)} has an invalid ref`);
return { id, key, version, ref, createdAt, accessedAt, size };
}
function sameStableIdentity(left, right) {
return (
left.key === right.key &&
left.version === right.version &&
left.ref === right.ref &&
left.size === right.size &&
left.createdAt === right.createdAt
);
}
// The list API orders by last access. A restore between pages can place one
// id on two pages. That is still one cache: collapse it, or abort when the
// pages disagree, before any id can be both kept and deleted.
function uniqueById(entries) {
const byId = new Map();
for (const entry of entries) {
const existing = byId.get(entry.id);
if (existing === undefined) {
byId.set(entry.id, entry);
continue;
}
if (!sameStableIdentity(existing, entry))
throw new Error(
`Cache id ${String(entry.id)} was listed with inconsistent fields`,
);
if (entry.accessedAt > existing.accessedAt) byId.set(entry.id, entry);
}
return [...byId.values()];
}
function byNewest(left, right) {
if (left.createdAt !== right.createdAt)
return right.createdAt - left.createdAt;
return right.id - left.id;
}
/**
* Choose Turbo snapshot ids to delete. `keepKey` is protected even when it is
* older than the retained generations. Keys outside the prefix abort retention.
*/
export function selectPrefixDeletions(
caches,
{ prefix, keepKey } = {},
keepGenerations = RETAINED_CACHE_GENERATIONS,
) {
assertRetentionPrefix(prefix);
if (!Number.isSafeInteger(keepGenerations) || keepGenerations < 1)
throw new Error("Cache retention must keep at least one generation");
if (keepKey !== undefined) {
if (
typeof keepKey !== "string" ||
!keepKey.startsWith(prefix) ||
keepKey === prefix
)
throw new Error("Cache retention keep key must extend its prefix");
}
const entries = uniqueById(caches.map(normalizeCache));
for (const entry of entries)
if (!entry.key.startsWith(prefix))
throw new Error(
`Refusing to evaluate a cache outside ${prefix}: ${entry.key}`,
);
const protectedIds = new Set(
[...entries]
.sort(byNewest)
.slice(0, keepGenerations)
.map((entry) => entry.id),
);
if (keepKey !== undefined)
for (const entry of entries)
if (entry.key === keepKey) protectedIds.add(entry.id);
return entries.filter((entry) => !protectedIds.has(entry.id));
}
/**
* Drop extra copies of one npm restore target. Records that share a user key
* but differ by cache version or git ref stay, because a workflow can restore
* only a matching version inside its visible scope.
*/
export function selectNpmDownloadDuplicateDeletions(caches) {
const entries = uniqueById(caches.map(normalizeCache));
for (const entry of entries)
if (!entry.key.startsWith("npm-download-"))
throw new Error(
`Refusing to evaluate a non-download cache: ${entry.key}`,
);
const groups = new Map();
for (const entry of entries) {
const identity = `${entry.version}\n${entry.ref}\n${entry.key}`;
const group = groups.get(identity) ?? [];
group.push(entry);
groups.set(identity, group);
}
const deletions = [];
for (const group of groups.values()) {
if (group.length < 2) continue;
const ranked = [...group].sort((left, right) => {
if (left.accessedAt !== right.accessedAt)
return right.accessedAt - left.accessedAt;
if (left.createdAt !== right.createdAt)
return right.createdAt - left.createdAt;
return right.id - left.id;
});
deletions.push(...ranked.slice(1));
}
return deletions;
}
export async function listAllCaches(request, repository, prefix) {
const caches = [];
for (let page = 1; page <= MAX_PAGES; page += 1) {
const body = await request(
"GET",
`repos/${repository}/actions/caches?per_page=${String(PAGE_SIZE)}&page=${String(page)}&key=${encodeURIComponent(prefix)}`,
);
if (
typeof body !== "object" ||
body === null ||
Array.isArray(body) ||
!Array.isArray(body.actions_caches)
)
throw new Error("GitHub cache list is malformed");
caches.push(...body.actions_caches);
if (body.actions_caches.length < PAGE_SIZE) return caches;
}
throw new Error(
`Cache list for ${prefix} exceeded ${String(MAX_PAGES)} pages; deleted nothing`,
);
}
/**
* List every requested family before deleting. A malformed list or an unsafe
* prefix leaves the repository caches unchanged.
*/
export async function retainCaches({
repository,
request,
deleteCache,
families = [],
dedupeNpmDownloads = false,
dryRun = false,
}) {
if (
typeof repository !== "string" ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
)
throw new Error(
"CI_REPOSITORY must be an owner/name repository identifier",
);
for (const family of families) {
assertRetentionPrefix(family.prefix);
if (
family.keepKey !== undefined &&
(typeof family.keepKey !== "string" ||
!family.keepKey.startsWith(family.prefix) ||
family.keepKey === family.prefix)
)
throw new Error("Cache retention keep key must extend its prefix");
}
const plan = [];
for (const family of families) {
const caches = await listAllCaches(request, repository, family.prefix);
plan.push({
prefix: family.prefix,
deletions: selectPrefixDeletions(caches, family),
});
}
const npmDeletions = dedupeNpmDownloads
? selectNpmDownloadDuplicateDeletions(
await listAllCaches(request, repository, "npm-download-"),
)
: [];
if (!dryRun)
for (const entry of [
...plan.flatMap((family) => family.deletions),
...npmDeletions,
])
await deleteCache(entry.id);
return { dryRun, plan, npmDeletions };
}
function repositoryFromEnv(env) {
const repository = env.CI_REPOSITORY;
if (
typeof repository !== "string" ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
)
throw new Error(
"CI_REPOSITORY must be an owner/name repository identifier",
);
return repository;
}
function familyWithKeepKey(env, prefixName, keepName) {
const prefix = env[prefixName];
const keepKey = env[keepName];
const hasPrefix = typeof prefix === "string" && prefix.length > 0;
const hasKeepKey = typeof keepKey === "string" && keepKey.length > 0;
if (!hasPrefix && !hasKeepKey) return undefined;
if (!hasPrefix || !hasKeepKey)
throw new Error(`${keepName} is required with ${prefixName}`);
return { prefix, keepKey };
}
function familiesFromEnv(env) {
const families = [
familyWithKeepKey(env, "CACHE_PREFIX", "CACHE_KEEP_KEY"),
familyWithKeepKey(env, "CACHE_DEEP_PREFIX", "CACHE_DEEP_KEEP_KEY"),
familyWithKeepKey(env, "CACHE_KNIP_PREFIX", "CACHE_KNIP_KEEP_KEY"),
typeof env.CACHE_PORTABLE_PREFIX === "string" &&
env.CACHE_PORTABLE_PREFIX.length > 0
? { prefix: env.CACHE_PORTABLE_PREFIX }
: undefined,
].filter((family) => family !== undefined);
if (families.length === 0 && env.DEDUPE_NPM_DOWNLOADS !== "true")
throw new Error(
"Cache retention requires CACHE_PREFIX or CACHE_PORTABLE_PREFIX or npm dedupe",
);
return families;
}
function failureText(error) {
if (typeof error !== "object" || error === null) return String(error);
const stderr =
"stderr" in error && error.stderr !== undefined ? String(error.stderr) : "";
const message = error instanceof Error ? error.message : "";
return `${message}\n${stderr}`;
}
async function githubRequest(method, endpoint, env) {
const { stdout } = await execFileAsync(
"gh",
["api", "--method", method, endpoint],
{
env,
encoding: "utf8",
maxBuffer: MAX_BUFFER,
timeout: REQUEST_TIMEOUT_MS,
},
);
if (method === "DELETE") return undefined;
try {
return JSON.parse(stdout);
} catch (cause) {
throw new Error("GitHub cache API returned malformed JSON", { cause });
}
}
async function main() {
const env = process.env;
const dryRun = env.CACHE_RETENTION_DRY_RUN === "true";
const report = await retainCaches({
repository: repositoryFromEnv(env),
request: (method, endpoint) => githubRequest(method, endpoint, env),
deleteCache: async (id) => {
try {
await githubRequest(
"DELETE",
`repos/${repositoryFromEnv(env)}/actions/caches/${String(id)}`,
env,
);
} catch (error) {
if (/404|not found/iu.test(failureText(error))) return;
throw error;
}
},
families: familiesFromEnv(env),
dedupeNpmDownloads: env.DEDUPE_NPM_DOWNLOADS === "true",
dryRun,
});
const summary = {
dryRun: report.dryRun,
families: report.plan.map((family) => ({
prefix: family.prefix,
deleteCount: family.deletions.length,
deleteBytes: family.deletions.reduce((sum, entry) => sum + entry.size, 0),
})),
npmDuplicateDeleteCount: report.npmDeletions.length,
npmDuplicateDeleteBytes: report.npmDeletions.reduce(
(sum, entry) => sum + entry.size,
0,
),
};
process.stdout.write(`${JSON.stringify(summary)}\n`);
}
function isDirectRun() {
const entry = process.argv[1];
if (!entry) return false;
try {
return import.meta.url === pathToFileURL(realpathSync(entry)).href;
} catch {
return false;
}
}
if (isDirectRun()) await main();
+14 -3
View File
@@ -58,8 +58,7 @@ export async function artifactCliEvidence(
return evidence;
}
/** Run a real stdio MCP subprocess, owning and closing its transport. */
export async function withArtifactMcp(target, verify) {
const withOpenedArtifactMcp = async (target, openTool, verify) => {
const transport = new StdioClientTransport({
command: process.execPath,
args: [fileURLToPath(new URL("../rea.mjs", import.meta.url)), "mcp"],
@@ -70,15 +69,27 @@ export async function withArtifactMcp(target, verify) {
try {
await client.connect(transport);
const opened = await client.callTool({
name: "open_binary",
name: openTool,
arguments: { path: target },
});
assert.notEqual(opened.isError, true, JSON.stringify(opened));
if (openTool === "open_bundle_resources")
assert.equal(opened.structuredContent?.result?.caller_path, target);
await verify(client);
} finally {
await client.close();
await transport.close();
}
};
/** Run a real stdio MCP subprocess with an explicitly opened binary target. */
export async function withArtifactMcp(target, verify) {
return withOpenedArtifactMcp(target, "open_binary", verify);
}
/** Run a real stdio MCP subprocess with an explicitly opened bundle resource root. */
export async function withBundleResourcesMcp(target, verify) {
return withOpenedArtifactMcp(target, "open_bundle_resources", verify);
}
/** Require a successful structured MCP tool result rather than its text rendering. */
+24 -1
View File
@@ -94,7 +94,9 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
const cliArgs = normalizeCliArguments(args);
const outputArguments = validateCliOutputArguments(cliArgs);
const configurationError = cliConfigurationError(process.env);
if (configurationError !== undefined) {
// Doctor is the readiness report for this failure. Other commands still stop
// here so they do not run with an unusable log level.
if (configurationError !== undefined && !isDoctorCommand(cliArgs)) {
process.stderr.write(`${configurationError}\n`);
process.exitCode = 1;
} else if (!outputArguments.ok) {
@@ -135,6 +137,27 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
}
}
/** True when the first positional command is `doctor`, after global output flags. */
function isDoctorCommand(arguments_) {
const valuedFlags = new Set([
"--format",
"--filter-output",
"--token-limit",
"--token-offset",
]);
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index];
if (argument === "--") return arguments_[index + 1] === "doctor";
if (valuedFlags.has(argument)) {
index += 1;
continue;
}
if (argument.startsWith("-")) continue;
return argument === "doctor";
}
return false;
}
/**
* A reader that closes the pipe early, as `rea ... | head` does, has declined
* the rest of the output. Stop writing without reporting a failure, and exit
+31 -5
View File
@@ -6,6 +6,8 @@ import { fileURLToPath } from "node:url";
import { firstProcedureAddress } from "../dist/application/RealHopperAssertions.js";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js";
import { resolveHopperAnalysisProfile } from "../dist/hopper/HopperAnalysisProfile.js";
import { HopperApplicationLauncher } from "../dist/hopper/BridgeLauncher.js";
import { HopperClient } from "../dist/hopper/HopperClient.js";
@@ -26,6 +28,16 @@ const root = await realpath(
await mkdtemp(join(tmpdir(), "rea-hopper-deadlines-")),
);
const target = join(root, "deadline-fixture");
const profileResources = new ArtifactResourceScope();
const profileResourceScope = {
retain: (owner, resource) =>
profileResources.retain({
kind: "lease",
resource,
lease: AnalysisCleanupLease.forCloseOwner(owner, resource),
}),
run: (operation) => profileResources.run(operation),
};
let client;
let primaryFailure;
try {
@@ -39,6 +51,7 @@ try {
launcherPath,
loaderArgsOverride: [],
provider: HOPPER_PROVIDER_IDENTITY,
resourceScope: profileResourceScope,
});
if (!resolved.ok) throw resolved.error;
const profile = resolved.value.profile;
@@ -138,15 +151,28 @@ try {
primaryFailure = cause;
throw cause;
} finally {
let cleanupFailure;
if (client !== undefined) {
const closed = await client.close();
if (!closed.ok)
throw primaryFailure === undefined
? closed.error
if (!closed.ok) cleanupFailure = closed.error;
}
try {
await profileResources.close();
} catch (cause) {
cleanupFailure =
cleanupFailure === undefined
? cause
: new AggregateError(
[primaryFailure, closed.error],
"Hopper verification and cleanup failed",
[cleanupFailure, cause],
"Hopper profile and client cleanup failed",
);
}
await rm(root, { recursive: true, force: true });
if (cleanupFailure !== undefined)
throw primaryFailure === undefined
? cleanupFailure
: new AggregateError(
[primaryFailure, cleanupFailure],
"Hopper verification and cleanup failed",
);
}
+16 -1
View File
@@ -10,6 +10,7 @@ import {
artifactCli,
artifactMcpResult,
withArtifactMcp,
withBundleResourcesMcp,
} from "../../lib/artifact-e2e.mjs";
if (process.platform !== "darwin")
@@ -68,6 +69,9 @@ try {
const sha256 = createHash("sha256")
.update(await readFile(join(resources, "Assets.car")))
.digest("hex");
const executableSha256 = createHash("sha256")
.update(await readFile(join(app, "Contents", "MacOS", "Fixture")))
.digest("hex");
const cli = await artifactCli("inspect-asset-catalog", app);
assert.deepEqual(
cli.records.map(({ metadata }) => metadata),
@@ -80,7 +84,7 @@ try {
asset_name === "FixtureColor" && metadata.AssetType === "Color",
),
);
await withArtifactMcp(app, async (client) => {
await withBundleResourcesMcp(app, async (client) => {
assert.deepEqual(
await artifactMcpResult(client, "inspect_asset_catalog"),
cli,
@@ -99,6 +103,17 @@ try {
});
assert.equal(invalid.isError, true);
});
const { resource_root, target_sha256, ...resourcePayload } = cli;
assert.equal(resource_root.executable_sha256, null);
assert.equal(target_sha256, null);
await withArtifactMcp(app, async (client) => {
const binary = await artifactMcpResult(client, "inspect_asset_catalog");
assert.equal(binary.target_sha256, executableSha256);
assert.deepEqual(binary, {
...resourcePayload,
target_sha256: executableSha256,
});
});
report = {
ok: true,
mocked: false,
+3 -1
View File
@@ -30,7 +30,7 @@ const LINK_MODES = [
{
mode: "dyld-info",
flags: ["-Wl,-no_fixup_chains"],
fixups: "LC_DYLD_INFO rebase opcodes were not decoded",
fixups: "LC_DYLD_INFO rebase opcodes",
swift: true,
},
{ mode: "chained", flags: [], fixups: "chained fixups:", swift: true },
@@ -70,6 +70,7 @@ const inspect = async (path) => {
};
const checkObjc = (metadata, mode) => {
assert.equal(fixupCoverage(metadata)?.status, "complete");
const fixture = metadata.objc_classes.find(
({ name, is_meta_class: meta }) => name === "ReaDispatchFixture" && !meta,
);
@@ -159,6 +160,7 @@ const checkObjc = (metadata, mode) => {
};
const checkSwift = (metadata, mode) => {
assert.equal(fixupCoverage(metadata)?.status, "complete");
if (
!metadata.swift_dispatch_slots.some(
(slot) =>
+20 -2
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -12,6 +13,7 @@ import {
artifactCliEvidence,
artifactMcpResult,
withArtifactMcp,
withBundleResourcesMcp,
} from "../../lib/artifact-e2e.mjs";
const exec = promisify(execFile);
@@ -49,7 +51,10 @@ try {
appPath,
);
const decoded = evidence.normalized_result;
await withArtifactMcp(appPath, async (client) => {
const executableSha256 = createHash("sha256")
.update(await readFile(executablePath))
.digest("hex");
await withBundleResourcesMcp(appPath, async (client) => {
const mcp = await artifactMcpResult(client, "decode_interface_builder");
assert.deepEqual(
mcp,
@@ -57,6 +62,19 @@ try {
"CLI and MCP compiled archive results differ",
);
});
const { resource_root, target_sha256, ...resourcePayload } = decoded;
assert.equal(resource_root.executable_sha256, null);
assert.equal(target_sha256, null);
await withArtifactMcp(appPath, async (client) => {
const binary = await artifactMcpResult(client, "decode_interface_builder");
assert.equal(binary.target_sha256, executableSha256);
assert.equal(binary.graph.target_sha256, executableSha256);
assert.deepEqual(binary, {
...resourcePayload,
target_sha256: executableSha256,
graph: { ...decoded.graph, target_sha256: executableSha256 },
});
});
assert.equal(decoded.documents.length, 1);
assert.equal(
decoded.documents[0]?.relative_path,
+66 -1
View File
@@ -6,6 +6,7 @@ import { toolContract } from "../../../dist/contracts/toolContracts.js";
import {
artifactCliEvidence,
withArtifactMcp,
withBundleResourcesMcp,
} from "../../lib/artifact-e2e.mjs";
/** Inspect the compiled bundle with real native utilities through both adapters. */
@@ -16,15 +17,79 @@ export async function verifyNativeProvenance(app, executable) {
const operations = [
["inspect_macho", []],
["inspect_signature", []],
["inspect_plist", []],
["list_architectures", []],
["demangle_swift", symbols],
];
const observations = [];
const resourcePlistCli = toolContract("inspect_plist").outputSchema.parse(
await artifactCliEvidence("inspect-plist", app),
);
assert.equal(resourcePlistCli.subject, null);
assert.equal(resourcePlistCli.raw_result, null);
await withBundleResourcesMcp(app, async (client) => {
const response = await client.callTool({
name: "inspect_plist",
arguments: {},
});
assert.notEqual(response.isError, true, JSON.stringify(response));
const mcp = toolContract("inspect_plist").outputSchema.parse(
response.structuredContent,
);
assert.equal(mcp.subject, null);
assert.equal(mcp.raw_result, null);
assert.deepEqual(mcp.normalized_result, resourcePlistCli.normalized_result);
assert.deepEqual(
JSON.parse(response.content.find((item) => item.type === "text").text),
response.structuredContent,
);
await client.ping();
observations.push({
operation: "inspect_plist_resource_root",
commands: resourcePlistCli.normalized_result.provenance.length,
cli_bytes: Buffer.byteLength(JSON.stringify(resourcePlistCli)),
mcp_bytes: Buffer.byteLength(JSON.stringify(response)),
});
});
const binaryPlistCli = toolContract("inspect_plist").outputSchema.parse(
await artifactCliEvidence("inspect-plist", executable),
);
assert.equal(binaryPlistCli.subject?.digest.sha256, sha256);
assert.equal(binaryPlistCli.raw_result, null);
assert.equal(binaryPlistCli.normalized_result.resource_root, undefined);
await withArtifactMcp(app, async (client) => {
const definitions = new Map(
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
);
const plistResponse = await client.callTool(
{ name: "inspect_plist", arguments: {} },
{ toolDefinition: definitions.get("inspect_plist") },
);
assert.notEqual(plistResponse.isError, true, JSON.stringify(plistResponse));
const binaryPlistMcp = toolContract("inspect_plist").outputSchema.parse(
plistResponse.structuredContent,
);
assert.equal(binaryPlistMcp.subject?.digest.sha256, sha256);
assert.equal(binaryPlistMcp.raw_result, null);
assert.equal(binaryPlistMcp.normalized_result.resource_root, undefined);
assert.deepEqual(
binaryPlistMcp.normalized_result,
binaryPlistCli.normalized_result,
);
assert.deepEqual(
JSON.parse(
plistResponse.content.find((item) => item.type === "text").text,
),
plistResponse.structuredContent,
);
await client.ping();
observations.push({
operation: "inspect_plist_binary_bound",
commands: binaryPlistCli.normalized_result.provenance.length,
cli_bytes: Buffer.byteLength(JSON.stringify(binaryPlistCli)),
mcp_bytes: Buffer.byteLength(JSON.stringify(plistResponse)),
});
for (const [operation, arguments_] of operations) {
const cli = toolContract(operation).outputSchema.parse(
await artifactCliEvidence(
+37
View File
@@ -1,4 +1,5 @@
import { expect, it, vi } from "vitest";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
ExecFileOutputCancelledError,
execFileBytes,
@@ -183,6 +184,42 @@ it("does not infer cancellation from a foreign ABORT_ERR code", async () => {
});
});
it.each([true, false])(
"projects a truncated adb capture as retryable only for volatile output (%s)",
async (volatileOutput) => {
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("too much"), {
code: 1,
stdout: "",
stderr: "",
signal: null,
killed: false,
outputTruncated: true,
}),
);
const result = await runAdbCapture(
volatileOutput ? "read_adb_logcat" : "install_adb_package",
{ device: "device" },
"/host/adb",
volatileOutput ? ["logcat", "-d"] : ["install", "app.apk"],
{ timeoutMs: 100, maxOutputBytes: 1024, volatileOutput },
undefined,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a capture allowance refusal");
const projected = projectAnalysisError(result.error);
expect(projected.retryable).toBe(volatileOutput);
expect(projected.remediation.action).toContain(
"The adb output capture allowance",
);
expect(projected.remediation.action).toContain(
volatileOutput
? "live output may change between requests"
: "Repeating the unchanged request fails the same way.",
);
},
);
it("keeps a physical failure even when the later abort reason is that same error", async () => {
const controller = new AbortController();
const failure = new Error("physical command failure");
+15 -2
View File
@@ -9,6 +9,7 @@ import type {
AnalysisCapturedOutput,
AnalysisError,
} from "../domain/analysisErrorBase.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { err, ok, type Result } from "../domain/result.js";
import {
execFileBytes,
@@ -86,6 +87,11 @@ export const adbArguments = (
export interface AdbRunLimits {
readonly timeoutMs: number;
readonly maxOutputBytes: number;
/**
* Live device observations can shrink between calls. Mutation commands omit
* this so a truncated result stays non-retryable.
*/
readonly volatileOutput?: boolean;
}
/** Keep the selected device separate from the file addressed by the command. */
@@ -139,7 +145,14 @@ export const runAdbCapture = async (
"memory",
`adb output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
{ max_output_bytes: limits.maxOutputBytes },
{ cause },
{
cause,
...fixedAllowanceOptions(
"The adb output capture allowance",
limits.volatileOutput === true ? "live" : "capture",
),
...(limits.volatileOutput === true ? { retryable: true } : {}),
},
),
);
const stderr = failure.stderr.trim();
@@ -268,7 +281,7 @@ export const probeAdbPathExistence = async (
{ device: serial, path },
binary,
adbArguments(serial, ["shell", "test", "-e", path]),
limits,
{ ...limits, volatileOutput: true },
signal,
);
if (probed.ok) {
+8 -1
View File
@@ -12,6 +12,7 @@ import {
} from "../process/ExecFileOutput.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { adbCommandCapturedOutput } from "./AdbCommand.js";
@@ -146,7 +147,13 @@ export const inspectAdbClient = async (
"memory",
`adb version output exceeded the ${String(VERSION_MAX_OUTPUT_BYTES)}-byte capture budget`,
{ max_output_bytes: VERSION_MAX_OUTPUT_BYTES },
options,
{
...options,
...fixedAllowanceOptions(
"The adb version-output capture allowance",
"capture",
),
},
);
if (failure?.code === "ETIMEDOUT")
throw new AnalysisTimeoutError(
+15 -3
View File
@@ -1326,7 +1326,7 @@ export class AdbProvider {
"serial" in request.input ? request.input.serial : undefined,
["shell", ...arguments_],
),
limits,
{ ...limits, volatileOutput: true },
signal,
);
switch (operation) {
@@ -1346,6 +1346,7 @@ export class AdbProvider {
{
timeoutMs: DEVICES_TIMEOUT_MS,
maxOutputBytes: DEVICES_MAX_OUTPUT_BYTES,
volatileOutput: true,
},
signal,
);
@@ -1382,6 +1383,7 @@ export class AdbProvider {
{
timeoutMs: SHELL_TIMEOUT_MS,
maxOutputBytes: GETPROP_MAX_OUTPUT_BYTES,
volatileOutput: true,
},
signal,
);
@@ -1631,6 +1633,7 @@ export class AdbProvider {
{
timeoutMs: LISTING_TIMEOUT_MS,
maxOutputBytes: LISTING_MAX_OUTPUT_BYTES,
volatileOutput: true,
},
signal,
);
@@ -1917,6 +1920,7 @@ export class AdbProvider {
{
timeoutMs: PM_PATH_TIMEOUT_MS,
maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES,
volatileOutput: true,
},
signal,
);
@@ -2704,7 +2708,11 @@ export class AdbProvider {
"android.intent.category.LAUNCHER",
input.package,
]),
{ timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES },
{
timeoutMs: SHELL_TIMEOUT_MS,
maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES,
volatileOutput: true,
},
signal,
);
if (!resolved.ok) return resolved;
@@ -2918,7 +2926,11 @@ export class AdbProvider {
{ device: input.serial },
selection.binary,
adbArguments(input.serial, ["exec-out", "screencap", "-p"]),
{ timeoutMs: SCREEN_TIMEOUT_MS, maxOutputBytes: SCREEN_MAX_OUTPUT_BYTES },
{
timeoutMs: SCREEN_TIMEOUT_MS,
maxOutputBytes: SCREEN_MAX_OUTPUT_BYTES,
volatileOutput: true,
},
signal,
);
if (!captured.ok) return captured;
+5
View File
@@ -45,6 +45,7 @@ import {
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { err, ok, type Result } from "../domain/result.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
@@ -194,6 +195,10 @@ const apktoolCommandFailure = (
{
cause,
...(capturedOutput === undefined ? {} : { capturedOutput }),
...fixedAllowanceOptions(
"The apktool output capture allowance",
"capture",
),
},
);
// A physical launcher/capture failure does not establish target refusal.
+30 -18
View File
@@ -68,7 +68,11 @@ export interface AnalysisExecution {
*/
readonly referencesTruncated?: boolean;
readonly locations: readonly EvidenceLocation[];
readonly subject: EvidenceSubjectTarget | null;
/**
* Missing means the selected operation's default subject should be bound by
* its caller. Explicit null means the observation has no file subject.
*/
readonly subject?: EvidenceSubjectTarget | null;
}
interface CreateAnalysisExecutionOptions {
@@ -79,7 +83,7 @@ interface CreateAnalysisExecutionOptions {
readonly relationshipCoverage?: RelationshipCoverage;
readonly referencesTruncated?: boolean;
readonly locations?: readonly EvidenceLocation[];
readonly subject?: EvidenceSubjectTarget;
readonly subject?: EvidenceSubjectTarget | null;
}
/**
@@ -129,7 +133,7 @@ export function createAnalysisExecution(
? {}
: { referencesTruncated: options.referencesTruncated }),
locations: [...(options.locations ?? [])],
subject: options.subject ?? null,
...(options.subject === undefined ? {} : { subject: options.subject }),
};
}
@@ -143,21 +147,27 @@ export const createAnalysisExecutionEvidence = (
execution: AnalysisExecution,
confidence: "observed" | "derived",
): Evidence =>
createEvidence(execution.subject ?? undefined, execution.provider, {
operation,
parameters: jsonObjectSchema.parse(input),
result: execution.result,
rawResult: execution.rawResult,
limitations: execution.limitations,
...(execution.analysisProfile === undefined
? {}
: { analysisProfile: execution.analysisProfile }),
...(execution.relationshipCoverage === undefined
? {}
: { relationshipCoverage: execution.relationshipCoverage }),
locations: execution.locations,
confidence,
});
createEvidence(
execution.subject === undefined
? undefined
: (execution.subject ?? undefined),
execution.provider,
{
operation,
parameters: jsonObjectSchema.parse(input),
result: execution.result,
rawResult: execution.rawResult,
limitations: execution.limitations,
...(execution.analysisProfile === undefined
? {}
: { analysisProfile: execution.analysisProfile }),
...(execution.relationshipCoverage === undefined
? {}
: { relationshipCoverage: execution.relationshipCoverage }),
locations: execution.locations,
confidence,
},
);
/** Provider-neutral application capability for one analysis operation. */
export interface AnalysisOperationPort {
@@ -206,6 +216,8 @@ export interface AnalysisClient extends AnalysisOperationPort {
close(
options?: Pick<ExecutionOptions, "progress"> & {
readonly retainDocument?: boolean;
/** Report each owned cleanup failure as soon as its owner observes it. */
readonly onCleanupFailure?: (failure: AnalysisError) => void;
},
): Promise<Result<null, AnalysisError>>;
}
+58
View File
@@ -14,6 +14,7 @@ import {
import { decodeXmlPlistText } from "../domain/propertyListXmlText.js";
import { hasBinaryPlistSignature } from "../domain/apple/appleArchiveFormat.js";
import { err, ok, type Result } from "../domain/result.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
/** Decoded representation bound for one binary Info.plist. */
const BINARY_PLIST_DECODE_BYTES = 16 * 1024 * 1024;
@@ -42,6 +43,18 @@ export interface ResolvedAppBundle {
readonly infoPlist?: string;
}
/** Resource-only identity for an app directory; it is not a BinaryTarget. */
export interface ResolvedAppBundleResourceRoot {
/** Path selected by the caller, retained even when it is a symlink alias. */
readonly callerPath: string;
/** Canonical directory whose resources are inspected. */
readonly canonicalPath: string;
readonly infoPlistPath: string;
readonly layout: "contents" | "root";
/** No executable was opened or hashed by resource-root discovery. */
readonly executableSha256: string | null;
}
/** Filesystem operations used by the resolver, injectable for boundary tests. */
export interface AppBundleFileSystem {
readonly readFile: (path: string) => Promise<Buffer>;
@@ -90,6 +103,51 @@ export const resolveAppBundleExecutable = async (
);
};
/**
* Resolve only the root and Info.plist layout needed for read-only resource
* inspection. This intentionally does not require or guess CFBundleExecutable.
*/
export const resolveAppBundleResourceRoot = async (
path: string,
options: { readonly cwd?: string } = {},
): Promise<Result<ResolvedAppBundleResourceRoot, BinaryTargetError>> => {
const callerPath = absoluteCallerPath(
path,
options.cwd === undefined ? {} : { cwd: options.cwd },
);
try {
const canonicalInput = await realpath(callerPath);
const directLayout = await presentLayout(canonicalInput);
const wrapped =
directLayout === undefined
? await wrappedBundle(canonicalInput)
: undefined;
const root = wrapped ?? canonicalInput;
const layout = directLayout ?? (await presentLayout(root));
if (layout === undefined)
return err(
new BinaryTargetError(
callerPath,
`app resource root has no supported Info.plist layout: ${callerPath}`,
),
);
const canonicalPath = await realpath(root);
return ok({
callerPath,
canonicalPath,
infoPlistPath: join(canonicalPath, ...layout.plist),
layout: layout.plist[0] === "Contents" ? "contents" : "root",
executableSha256: null,
});
} catch (cause: unknown) {
return err(
new BinaryTargetError(callerPath, errorReason(cause, callerPath), {
cause,
}),
);
}
};
const presentLayout = async (
bundle: string,
): Promise<BundleLayout | undefined> => {
@@ -0,0 +1,101 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import {
createAnalysisExecution,
createAnalysisExecutionEvidence,
} from "./AnalysisProvider.js";
import { resolveAppBundleResourceRoot } from "./AppBundleExecutable.js";
import { bindExecutionTarget } from "./binary/BinarySessionExecution.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
const provider = { id: "test", name: "test", version: null } as const;
const target = {
path: "/Fixture.app/Contents/Info.plist",
sha256: "a".repeat(64),
kind: "artifact",
format: "plist",
} satisfies BinaryTarget;
it("resolves resource roots without requiring or hashing CFBundleExecutable", async () => {
const temp = await mkdtemp(join(tmpdir(), "rea-app-resource-root-"));
try {
const bundle = join(temp, "Fixture.app");
const contents = join(bundle, "Contents");
const alias = join(temp, "Selected.app");
await mkdir(contents, { recursive: true });
await writeFile(join(contents, "Info.plist"), "<plist><dict/></plist>");
await symlink(bundle, alias);
const resolved = await resolveAppBundleResourceRoot(alias);
expect(resolved).toEqual({
ok: true,
value: {
callerPath: alias,
canonicalPath: bundle,
infoPlistPath: join(bundle, "Contents", "Info.plist"),
layout: "contents",
executableSha256: null,
},
});
} finally {
await rm(temp, { recursive: true, force: true });
}
});
it("resolves a flat bundle resource root without inferring executable identity", async () => {
const temp = await mkdtemp(join(tmpdir(), "rea-ios-resource-root-"));
try {
const bundle = join(temp, "Fixture.app");
await mkdir(bundle);
await writeFile(join(bundle, "Info.plist"), "<plist><dict/></plist>");
const resolved = await resolveAppBundleResourceRoot(bundle);
expect(resolved).toMatchObject({
ok: true,
value: {
callerPath: bundle,
canonicalPath: bundle,
infoPlistPath: join(bundle, "Info.plist"),
layout: "root",
executableSha256: null,
},
});
} finally {
await rm(temp, { recursive: true, force: true });
}
});
it("distinguishes default target binding from an explicitly unbound observation", () => {
const defaultSubject = createAnalysisExecution({ ok: true }, provider);
const unbound = createAnalysisExecution({ ok: true }, provider, {
subject: null,
});
const bound = bindExecutionTarget(
{ ok: true, value: defaultSubject },
"inspect_plist",
target,
);
const preserved = bindExecutionTarget(
{ ok: true, value: unbound },
"inspect_plist",
target,
);
expect(bound.ok && bound.value.subject).toEqual({
path: target.path,
sha256: target.sha256,
format: "plist",
});
expect(preserved.ok && preserved.value.subject).toBeNull();
expect(
createAnalysisExecutionEvidence("inspect_plist", {}, unbound, "observed")
.subject,
).toBeNull();
});
+173 -12
View File
@@ -3,9 +3,19 @@ import type { BrowserObservationPort } from "./BrowserObservationPort.js";
import { listBrowserTargets } from "./BrowserObservationService.js";
import type { BrowserTargetList } from "../domain/browserObservationSchemas.js";
import { parseEvidence } from "../domain/evidence.js";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
expectDroppedPartial,
expectKeptPartial,
expectOutputError,
expectOwnerError,
} from "./ownerObservationExpectation.fixture.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { ok } from "../domain/result.js";
import { err, ok } from "../domain/result.js";
import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js";
import { inspectWebPage } from "./BrowserObservationService.js";
import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js";
@@ -71,11 +81,7 @@ it("does not let late cancellation hide invalid completed browser output", async
provider(async () => ok(malformed)),
input,
);
expect(output).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
if (!output.ok) expect(output.error).toBeInstanceOf(AnalysisOutputError);
expectOutputError(output);
const controller = new AbortController();
const late = await listBrowserTargets(
@@ -86,11 +92,7 @@ it("does not let late cancellation hide invalid completed browser output", async
input,
{ signal: controller.signal },
);
expect(late).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
if (!late.ok) expect(late.error).toBeInstanceOf(AnalysisOutputError);
expectOutputError(late);
});
it("converts a raw page inspection retained after browser cleanup failure", async () => {
@@ -215,3 +217,162 @@ it("preserves a prior caller reason without admitting browser work", async () =>
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
});
it("keeps page cancellation when the retained inspection is incomplete", async () => {
const reason = { requested: "stop during page inspection" };
const partial = {
target: { target_id: "page" },
network: {},
capture_window: {},
};
const failure = new AnalysisCancelledError("inspect_web_page", {
cause: reason,
partialObservation: partial as never,
});
const browser = {
...provider(async () => ok(report)),
inspectPage: async () => err(failure),
} as unknown as BrowserObservationPort;
const pageInput = inspectWebPageInputSchema.parse({
cdp_endpoint: input.cdp_endpoint.recorded,
allowed_origins: [],
target_id: "page",
observation_ms: 0,
include_script_sources: false,
});
const result = await inspectWebPage(browser, pageInput);
expectKeptPartial(expectOwnerError(result, failure, reason), partial);
});
it("keeps bundle cancellation and an earlier failure when retained facts are incomplete", async () => {
const reason = { requested: "stop during bundle analysis" };
const partial = { observations: [], capture: { status: "partial" } };
const cancelled = new AnalysisCancelledError("analyze_web_bundle", {
cause: reason,
partialObservation: partial as never,
});
const timedOut = new BrowserObservationError(
"analyze_web_bundle",
"timeout",
{ partialObservation: partial as never },
);
const selected = analyzeWebBundleInputSchema.parse({
cdp_endpoint: input.cdp_endpoint.recorded,
target_id: "page",
observation_ms: 0,
});
for (const failure of [cancelled, timedOut]) {
const browser = {
...provider(async () => ok(report)),
analyzeBundle: async () => err(failure),
} as unknown as BrowserObservationPort;
const result = await analyzeWebBundle(browser, selected);
expectKeptPartial(
expectOwnerError(result, failure, failure.cause),
partial,
failure === timedOut ? "provider_timeout" : "cancelled",
);
}
expect(projectAnalysisError(cancelled).code).toBe("cancelled");
});
const pageInput = () =>
inspectWebPageInputSchema.parse({
cdp_endpoint: input.cdp_endpoint.recorded,
allowed_origins: [],
target_id: "page",
observation_ms: 0,
include_script_sources: false,
});
const bundleInput = () =>
analyzeWebBundleInputSchema.parse({
cdp_endpoint: input.cdp_endpoint.recorded,
target_id: "page",
observation_ms: 0,
});
const browserReturning = (
failure: BrowserObservationError | AnalysisCancelledError,
) =>
({
...provider(async () => ok(report)),
inspectPage: async () => err(failure),
analyzeBundle: async () => err(failure),
}) as unknown as BrowserObservationPort;
it.each([
{
name: "bundle bytes",
operation: "analyze_web_bundle" as const,
partial: { observations: [], capture: { bytes: Number.NaN } },
},
{
name: "bundle observations",
operation: "analyze_web_bundle" as const,
partial: { observations: [undefined], capture: {} },
},
{
name: "page requests",
operation: "inspect_web_page" as const,
partial: {
target: { target_id: "page" },
network: { requests: Number.NaN },
capture_window: {},
},
},
])(
"drops a non-JSON $name partial and keeps cancellation primary",
async ({ operation, partial }) => {
const reason = { requested: `stop ${operation}` };
const failure = new AnalysisCancelledError(operation, {
cause: reason,
partialObservation: partial as never,
});
const result =
operation === "inspect_web_page"
? await inspectWebPage(browserReturning(failure), pageInput())
: await analyzeWebBundle(browserReturning(failure), bundleInput());
const error = expectOwnerError(result, failure, reason);
expectDroppedPartial(
error,
"Browser provider retained a non-JSON incomplete observation.",
);
expect(projectAnalysisError(error).details).not.toHaveProperty(
"partial_observation",
);
},
);
it.each([
{
name: "schema-shaped page",
partial: {
target: { target_id: "page" },
network: { requests: Number.NaN },
capture_window: {},
},
},
{
name: "shapeless page",
partial: { requests: Number.NaN },
},
])(
"drops a non-JSON $name partial and keeps timeout primary",
async ({ partial }) => {
const failure = new BrowserObservationError("inspect_web_page", "timeout", {
partialObservation: partial as never,
});
const error = expectOwnerError(
await inspectWebPage(browserReturning(failure), pageInput()),
failure,
undefined,
);
expectDroppedPartial(
error,
"Browser provider retained a non-JSON incomplete observation.",
"provider_timeout",
);
expect(projectAnalysisError(error).category).toBe("timeout");
},
);
+41 -53
View File
@@ -25,9 +25,11 @@ import type { ExecutionOptions } from "./AnalysisProvider.js";
import type { BrowserObservationPort } from "./BrowserObservationPort.js";
import { createBrowserEvidence } from "./BrowserEvidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
import {
completeProviderEvidence,
keepIncompleteObservation,
} from "./ProviderOperationExecution.js";
import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
/** List browser targets within the endpoint and origin scope in the request. */
export const listBrowserTargets = async (
@@ -275,7 +277,10 @@ const browserEvidence = (
);
};
/** Retain the completed capture or bundle facts under their actual operation. */
const NON_JSON_BROWSER_OBSERVATION =
"Browser provider retained a non-JSON incomplete observation.";
/** Promote completed page or bundle facts; keep only a JSON-safe incomplete observation. */
const retainBrowserObservation = (
error: AnalysisError,
input: InspectWebPageInput | AnalyzeWebBundleInput,
@@ -291,59 +296,42 @@ const retainBrowserObservation = (
"capture" in partial
) {
const bundle = webBundleAnalysisSchema.safeParse(partial);
if (!bundle.success) {
const malformed = new AnalysisOutputError(
errorOperation,
"Browser provider retained malformed completed bundle facts.",
{ cause: bundle.error },
);
return err(
error.cleanupIncomplete
? analysisErrorWithCleanupFailure(
malformed,
error,
errorOperation,
).bindCleanupDependencies([malformed, error])
: malformed,
if (bundle.success) {
error.partialObservation = createBrowserEvidence(
"analyze_web_bundle",
input,
bundle.data,
provider.identity(),
);
return err(error);
}
error.partialObservation = createBrowserEvidence(
"analyze_web_bundle",
input,
bundle.data,
provider.identity(),
);
return err(error);
}
if (
partial === undefined ||
"evidence_id" in partial ||
!("target" in partial) ||
!("network" in partial) ||
!("capture_window" in partial)
)
return err(error);
const inspection = webPageInspectionSchema.safeParse(partial);
if (!inspection.success) {
if (error instanceof AnalysisCancelledError)
return err(
new AnalysisOutputError(
errorOperation,
"Browser provider retained a malformed completed page inspection.",
{ cause: inspection.error },
),
} else if (
partial !== undefined &&
!("evidence_id" in partial) &&
"target" in partial &&
"network" in partial &&
"capture_window" in partial
) {
const inspection = webPageInspectionSchema.safeParse(partial);
if (inspection.success) {
const pageInput: InspectWebPageInput =
"fetch_source_maps" in input
? (({ fetch_source_maps: _fetchSourceMaps, ...page }) => page)(input)
: input;
error.partialObservation = createBrowserEvidence(
"inspect_web_page",
pageInput,
inspection.data,
provider.identity(),
);
return err(error);
return err(error);
}
}
const pageInput: InspectWebPageInput =
"fetch_source_maps" in input
? (({ fetch_source_maps: _fetchSourceMaps, ...page }) => page)(input)
: input;
error.partialObservation = createBrowserEvidence(
"inspect_web_page",
pageInput,
inspection.data,
provider.identity(),
return err(
keepIncompleteObservation(
error,
errorOperation,
NON_JSON_BROWSER_OBSERVATION,
),
);
return err(error);
};
@@ -1,13 +1,26 @@
import { expect, it } from "vitest";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { parseEvidence } from "../domain/evidence.js";
import { browserScenarioInputSchema } from "../domain/browserScenario.js";
import { err } from "../domain/result.js";
import type {
BrowserScenarioCapture,
BrowserScenarioPartialObservation,
} from "../domain/browserScenarioCapture.js";
import { err, ok } from "../domain/result.js";
import { scriptScenarioFixture } from "../../tests/fixtures/webScriptCapture.js";
import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js";
import { captureBrowserScenario } from "./BrowserScenarioCaptureService.js";
import {
expectDroppedPartial,
expectKeptPartial,
expectOwnerError,
} from "./ownerObservationExpectation.fixture.js";
const scenario = browserScenarioInputSchema.parse({
browser: { mode: "launch", executable_path: "/fixture/chrome" },
@@ -15,6 +28,34 @@ const scenario = browserScenarioInputSchema.parse({
actions: [{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 }],
});
const capture = scriptScenarioFixture();
const scenarioIdentity = {
id: "scenario-fixture",
name: "Scenario fixture",
version: "1",
};
const runScenario = (
failure: AnalysisError,
id = { id: "fixture", name: "fixture", version: "1" },
) =>
captureBrowserScenario(
{
identity: () => id,
captureScenario: async () => err(failure),
},
scenario,
);
const expectRetainedCapture = (
result: Awaited<ReturnType<typeof runScenario>>,
failure: AnalysisError,
) => {
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the retained capture");
expect(result.error).toBe(failure);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "capture_browser_scenario",
normalized_result: capture,
});
};
it("converts a retained raw scenario capture into partial Evidence", async () => {
const failure = new AnalysisCancelledError("capture_browser_scenario", {
@@ -23,23 +64,7 @@ it("converts a retained raw scenario capture into partial Evidence", async () =>
capture,
},
});
const provider: BrowserScenarioCapturePort = {
identity: () => ({
id: "scenario-fixture",
name: "Scenario fixture",
version: "1",
}),
captureScenario: async () => err(failure),
};
const result = await captureBrowserScenario(provider, scenario);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe(failure);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "capture_browser_scenario",
normalized_result: capture,
});
}
expectRetainedCapture(await runScenario(failure, scenarioIdentity), failure);
});
it("converts a raw scenario capture retained after cleanup failure", async () => {
@@ -57,24 +82,9 @@ it("converts a raw scenario capture retained after cleanup failure", async () =>
},
},
);
const provider: BrowserScenarioCapturePort = {
identity: () => ({
id: "scenario-fixture",
name: "Scenario fixture",
version: "1",
}),
captureScenario: async () => err(failure),
};
const result = await captureBrowserScenario(provider, scenario);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe(failure);
expect(result.error.cleanupIncomplete).toBe(true);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "capture_browser_scenario",
normalized_result: capture,
});
}
const result = await runScenario(failure, scenarioIdentity);
expectRetainedCapture(result, failure);
if (!result.ok) expect(result.error.cleanupIncomplete).toBe(true);
});
it.each([
@@ -103,3 +113,63 @@ it.each([
expect(calls).toBe(0);
},
);
it("keeps startup cancellation and its incomplete capture", async () => {
const reason = { requested: "stop during initial navigation" };
const completed = scriptScenarioFixture();
const {
completeness: _completeness,
steps: _steps,
browser,
...retained
} = completed;
const partial: BrowserScenarioPartialObservation = {
kind: "browser-scenario-observation",
capture: {
...retained,
browser: { ...browser, cleanup: "incomplete" },
steps: [],
},
};
const failure = new AnalysisCancelledError("capture_browser_scenario", {
cause: reason,
partialObservation: partial,
});
const result = await runScenario(failure);
expectKeptPartial(expectOwnerError(result, failure, reason), partial);
});
it.each([{ steps: [undefined] }, { started_at: Number.NaN }])(
"drops a non-JSON incomplete scenario capture and keeps cancellation primary",
async (capturePartial) => {
const reason = { requested: "stop during initial navigation" };
const partial: BrowserScenarioPartialObservation = {
kind: "browser-scenario-observation",
capture: capturePartial as never,
};
const failure = new AnalysisCancelledError("capture_browser_scenario", {
cause: reason,
partialObservation: partial,
});
const result = await runScenario(failure);
expectDroppedPartial(
expectOwnerError(result, failure, reason),
"Browser scenario provider retained a non-JSON incomplete observation.",
);
},
);
it("rejects a successful capture that is not a completed scenario", async () => {
const provider: BrowserScenarioCapturePort = {
identity: () => ({ id: "fixture", name: "fixture", version: "1" }),
captureScenario: async () => ok({} as BrowserScenarioCapture),
};
const result = await captureBrowserScenario(provider, scenario);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a completed-capture refusal");
expect(result.error).toBeInstanceOf(AnalysisOutputError);
expect(result.error.message).toContain(
"returned a malformed completed capture",
);
expect(result.error.partialObservation).toBeUndefined();
});
@@ -11,7 +11,10 @@ import type { Evidence } from "../domain/evidence.js";
import type { BrowserScenario } from "../domain/browserScenario.js";
import { err, type Result } from "../domain/result.js";
import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js";
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
import {
completeProviderEvidence,
keepIncompleteObservation,
} from "./ProviderOperationExecution.js";
const OPERATION = "capture_browser_scenario" as const;
const PROVIDER_ID = "rea-playwright-browser-scenario";
@@ -43,6 +46,8 @@ export const captureBrowserScenario = async (
partial.kind === "browser-scenario-observation"
) {
const report = browserScenarioCaptureSchema.safeParse(partial.capture);
// Only a completed capture becomes Evidence. A JSON-safe incomplete
// observation stays on the owner error, including typed cancellation.
if (
report.success &&
report.data.steps.every(({ status }) => status === "completed")
@@ -52,19 +57,16 @@ export const captureBrowserScenario = async (
report.data,
provider.identity(),
);
} else if (!report.success) {
if (captured.error instanceof AnalysisCancelledError)
return err(
new AnalysisOutputError(
OPERATION,
"Browser scenario provider retained a malformed completed capture.",
{ cause: report.error },
),
);
return captured;
}
}
return captured;
return err(
keepIncompleteObservation(
captured.error,
OPERATION,
"Browser scenario provider retained a non-JSON incomplete observation.",
),
);
}
const report = browserScenarioCaptureSchema.safeParse(captured.value);
if (!report.success)
@@ -74,7 +74,10 @@ export const retryClientConfigurationCleanup = async (
if (retried.cleanup !== undefined) observations.push(retried.cleanup);
primary = retried.primary;
const last = retried.failures.at(-1)!;
nested = last.outcome.kind === "failed" ? last.outcome.cause : undefined;
nested =
last.operationFailure === undefined && last.outcome.kind === "failed"
? last.outcome.cause
: undefined;
} else {
const released = await resources.release({
kind: "lease",
+42 -4
View File
@@ -24,10 +24,14 @@ import {
} from "../domain/evidenceErrors.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { access } from "node:fs/promises";
import { access, stat } from "node:fs/promises";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { readAnalysisSnapshot } from "./binary/AnalysisSnapshotFiles.js";
import { requiresNativeSourceAdmissionBeforeReplay } from "./binary/AnalysisSnapshotCache.js";
import { parseBinaryTarget } from "./BinaryTargetResolver.js";
import {
classifyBinaryTargetFilesystemFailure,
parseBinaryTarget,
} from "./BinaryTargetResolver.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
snapshotEvidenceForQuery,
@@ -56,11 +60,13 @@ import type { AnalysisProviderSelector } from "../contracts/providerSelection.js
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
import { resolveXrefsAddress } from "./XrefsAddressResolution.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import type { BinaryTargetError } from "../domain/configurationErrors.js";
import {
directAnalysisCleanupFailure,
managedAnalysisCleanupFailure,
withSessionCleanup,
} from "./DirectAnalysisCleanup.js";
import { BUNDLE_RESOURCE_OPERATIONS } from "./binary/BundleResourceSession.js";
type DirectAnalysisTool =
| "annotate_native_function"
@@ -97,7 +103,6 @@ const NATIVE_INSPECTION_OPERATIONS: ReadonlySet<string> = new Set([
const ARTIFACT_ANALYSIS_OPERATION_SET: ReadonlySet<string> = new Set(
ARTIFACT_ANALYSIS_OPERATIONS,
);
/**
* Open one binary, execute one tool, and always release provider resources.
* Unlike MCP mode, every CLI invocation is intentionally isolated and does not
@@ -249,6 +254,24 @@ const runAnalysis = async (
return withSessionCleanup(
session,
async () => {
let isDirectory = false;
if (BUNDLE_RESOURCE_OPERATIONS.has(tool)) {
const inspected = await inspectBundleResourceInput(path);
if (!inspected.ok) return cliError(inspected.error);
isDirectory = inspected.value;
}
if (BUNDLE_RESOURCE_OPERATIONS.has(tool) && isDirectory) {
const opened = await session.openBundleResources(path, { signal });
if (!opened.ok) return cliError(opened.error);
const execution = await session.execute(tool, arguments_, { signal });
if (!execution.ok) return cliError(execution.error);
return createAnalysisExecutionEvidence(
tool,
arguments_,
execution.value,
"observed",
);
}
const prepared = await prepareSnapshot({
path,
snapshotPath,
@@ -493,7 +516,9 @@ const executeAnalysisTool = async (input: {
}
if (!result.ok) return { output: cliError(result.error) };
const evidence = createEvidence(
result.value.subject ?? input.openedTarget,
result.value.subject === undefined
? input.openedTarget
: (result.value.subject ?? undefined),
result.value.provider,
{
operation: tool,
@@ -577,6 +602,19 @@ const prepareSnapshot = async (options: {
return ok({ snapshot: loaded.value, target: target.value });
};
const inspectBundleResourceInput = async (
path: string,
): Promise<Result<boolean, BinaryTargetError>> => {
const callerPath = absoluteCallerPath(path);
try {
return ok((await stat(callerPath)).isDirectory());
} catch (cause: unknown) {
const failure = classifyBinaryTargetFilesystemFailure(callerPath, cause);
if (failure !== undefined) return err(failure);
throw cause;
}
};
const allowsSnapshotReplay = (
route: SessionProviderRoute,
tool:
@@ -0,0 +1,117 @@
import { afterEach, expect, it, vi } from "vitest";
const statFailures = vi.hoisted(() => new Map<string, unknown>());
vi.mock("node:fs/promises", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs/promises")>();
return {
...original,
stat: vi.fn(async (...args: Parameters<typeof original.stat>) => {
const failure = statFailures.get(String(args[0]));
if (failure !== undefined) throw failure;
return original.stat(...args);
}),
};
});
import { parseConfig } from "../config/parseConfig.js";
import { classifyBinaryTargetFilesystemFailure } from "./BinaryTargetResolver.js";
import type { BinarySession } from "./binary/BinarySession.js";
import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js";
import { runProviderAnalysis } from "./DirectAnalysis.js";
afterEach(() => statFailures.clear());
it("retains the original filesystem cause on the shared typed error", () => {
const cause = Object.assign(new Error("permission denied"), {
code: "EACCES",
});
const failure = classifyBinaryTargetFilesystemFailure(
"/tmp/rea-stat-failure-cause.app",
cause,
);
expect(failure?.cause).toBe(cause);
expect(failure?.systemCode).toBe("EACCES");
});
it.each([
{ code: "ENOENT", projectedCode: "target_unavailable" },
{ code: "ENOTDIR", projectedCode: "target_unavailable" },
{ code: "EACCES", projectedCode: "access_denied", systemCode: "EACCES" },
{ code: "EPERM", projectedCode: "access_denied", systemCode: "EPERM" },
{ code: "EIO", projectedCode: "target_unavailable" },
])(
"stops CLI resource dispatch on stat failure $code and preserves its path",
async ({ code, projectedCode, systemCode }) => {
const path = `/tmp/rea-stat-failure-${code}.app`;
const cause = Object.assign(new Error(`${code}: injected stat failure`), {
code,
});
statFailures.set(path, cause);
const session = {
close: vi.fn(async () => ({ ok: true as const, value: null })),
open: vi.fn(),
openBundleResources: vi.fn(),
execute: vi.fn(),
} as unknown as BinarySession;
const dependencies: DirectAnalysisDependencies = {
readConfiguration: () => parseConfig({}),
createBinarySession: () => session,
createNativeDispatchBinarySession: () => session,
createArtifactBinarySession: () => session,
createNativeBinarySession: () => session,
createManagedBinarySession: () => session,
};
const output = await runProviderAnalysis(
dependencies,
path,
"inspect_keyed_archive",
{ path: "Resources/Archive.plist" },
);
expect(output).toMatchObject({
code: projectedCode,
details: {
path,
reason: expect.stringContaining(cause.message),
...(systemCode === undefined ? {} : { system_code: systemCode }),
},
});
expect(session.open).not.toHaveBeenCalled();
expect(session.openBundleResources).not.toHaveBeenCalled();
expect(session.execute).not.toHaveBeenCalled();
expect(session.close).toHaveBeenCalledOnce();
},
);
it("rethrows an unclassified stat failure instead of treating it as a file", async () => {
const path = "/tmp/rea-stat-failure-opaque.app";
const cause = new Error("opaque filesystem failure");
statFailures.set(path, cause);
const session = {
close: vi.fn(async () => ({ ok: true as const, value: null })),
open: vi.fn(),
openBundleResources: vi.fn(),
execute: vi.fn(),
} as unknown as BinarySession;
const dependencies: DirectAnalysisDependencies = {
readConfiguration: () => parseConfig({}),
createBinarySession: () => session,
createNativeDispatchBinarySession: () => session,
createArtifactBinarySession: () => session,
createNativeBinarySession: () => session,
createManagedBinarySession: () => session,
};
await expect(
runProviderAnalysis(dependencies, path, "inspect_keyed_archive", {
path: "Resources/Archive.plist",
}),
).rejects.toBe(cause);
expect(session.open).not.toHaveBeenCalled();
expect(session.openBundleResources).not.toHaveBeenCalled();
expect(session.execute).not.toHaveBeenCalled();
expect(session.close).toHaveBeenCalledOnce();
});
+268 -1
View File
@@ -89,7 +89,7 @@ describe("doctor Hopper startup timeout", () => {
},
);
it("keeps an invalid Hopper timeout informational outside the Hopper scope", async () => {
it("keeps an invalid Hopper timeout informational when no provider is selected", async () => {
const selectedHost = timeoutHost("0");
const result = await runDoctor(undefined, selectedHost, { providers: [] });
expect(result.healthy).toBe(true);
@@ -100,6 +100,273 @@ describe("doctor Hopper startup timeout", () => {
}),
);
});
it("requires an invalid Hopper timeout for a ready non-Hopper provider", async () => {
const ghidra = {
providerInspections: () =>
Promise.resolve([
{
id: "ghidra" as const,
configured: true,
available: true,
providerVersion: "12.1.4",
registrationEnvironment: { GHIDRA_INSTALL_DIR: "/tools/ghidra" },
checks: [
{
name: "configuration",
ok: true,
code: null,
detail: "/tools/ghidra",
remediation: null,
classification: "config_drift" as const,
},
],
},
]),
};
const blocked = await runDoctor(undefined, timeoutHost("abc", ghidra), {
providers: ["ghidra"],
});
expect(blocked.healthy).toBe(false);
expect(blocked.scope_checks).toContainEqual(
expect.objectContaining({
name: "hopper-startup-timeout",
ok: false,
classification: "config_drift",
}),
);
expect(
blocked.scope_checks.filter(
({ name }) => name === "hopper-startup-timeout",
),
).toHaveLength(1);
const ready = await runDoctor(undefined, timeoutHost("120000", ghidra), {
providers: ["ghidra"],
});
expect(ready.healthy).toBe(true);
expect(ready.scope_checks).toContainEqual(
expect.objectContaining({ name: "hopper-startup-timeout", ok: true }),
);
});
});
describe("doctor native analysis configuration", () => {
const configurationHost = (
environment: Readonly<NodeJS.ProcessEnv>,
): DoctorHost => ({
...systemDoctorHost({ environment: { ...environment } }),
...host({
installationPaths: () => Promise.resolve([]),
clientRegistrations: () => Promise.resolve([]),
}),
});
it.each([
["REA_ANALYSIS_PROVIDER", "Hopper"],
["REA_GHIDRA_STARTUP_TIMEOUT_MS", "abc"],
["REA_SWIFTC_COMPILE_TIMEOUT_MS", "0"],
["REA_LOG_LEVEL", "verbose"],
["REA_GHIDRA_COMPILER_SPEC_ID", "gcc"],
] as const)(
"rejects %s before scoped Hopper readiness reports success",
async (setting, value) => {
const result = await runDoctor(
undefined,
configurationHost({ [setting]: value }),
{ providers: ["hopper"] },
);
expect(result.healthy).toBe(false);
expect(result.environment_healthy).toBe(false);
expect(result.scope_checks).toContainEqual(
expect.objectContaining({
name: `runtime-configuration:${setting}`,
ok: false,
classification: "config_drift",
detail: expect.stringContaining(setting),
remediation: expect.stringContaining(setting),
details: { setting },
}),
);
},
);
it("keeps a lowercase provider preference ready for the selected engine", async () => {
const result = await runDoctor(
undefined,
configurationHost({ REA_ANALYSIS_PROVIDER: "hopper" }),
{ providers: ["hopper"] },
);
expect(result.healthy).toBe(true);
expect(
result.checks.some(({ name }) =>
name.startsWith("runtime-configuration:"),
),
).toBe(false);
});
it("keeps an invalid native setting informational when nothing starts MCP", async () => {
const result = await runDoctor(
undefined,
configurationHost({ REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc" }),
{ providers: [] },
);
expect(result.healthy).toBe(true);
expect(result.environment_healthy).toBe(false);
expect(result.scope_checks.map(({ name }) => name)).not.toContain(
"runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
);
expect(result.informational_checks).toContainEqual(
expect.objectContaining({
name: "runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
ok: false,
}),
);
});
it("does not treat a JEB-only timeout as native analysis configuration", async () => {
const result = await runDoctor(
undefined,
configurationHost({ REA_JEB_MCP_TIMEOUT_MS: "abc" }),
{ providers: ["hopper"] },
);
expect(result.healthy).toBe(true);
expect(
result.checks.some(({ name }) =>
name.startsWith("runtime-configuration:"),
),
).toBe(false);
});
});
describe("doctor MCP startup readiness", () => {
const configurationHost = (
environment: Readonly<NodeJS.ProcessEnv>,
): DoctorHost => ({
...systemDoctorHost({ environment: { ...environment } }),
...host({
installationPaths: () => Promise.resolve([]),
clientRegistrations: () => Promise.resolve([]),
}),
});
const alignedCodex: ClientRegistrationStatus = {
client: "codex",
config_path: "/home/user/.codex/config.toml",
command: ["/current/rea", "mcp"],
state: "aligned",
remediation: null,
};
const selectedHost = (
environment: Readonly<NodeJS.ProcessEnv>,
): DoctorHost => ({
...configurationHost(environment),
clientRegistrations: () => Promise.resolve([alignedCodex]),
});
it("requires an invalid log level for a client and for the skill", async () => {
const client = await runDoctor(
undefined,
selectedHost({
REA_LOG_LEVEL: "verbose",
REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc",
}),
{ clients: ["codex"] },
);
expect(client.healthy).toBe(false);
expect(client.scope_checks).toContainEqual(
expect.objectContaining({
name: "runtime-configuration:REA_LOG_LEVEL",
ok: false,
details: { setting: "REA_LOG_LEVEL" },
}),
);
expect(client.scope_checks).toContainEqual(
expect.objectContaining({
name: "runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
ok: false,
}),
);
expect(client.scope_checks).toContainEqual(
expect.objectContaining({ name: "registration:codex", ok: true }),
);
const skill = await runDoctor(
undefined,
selectedHost({
REA_LOG_LEVEL: "verbose",
REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc",
}),
{ skill: true },
);
expect(skill.healthy).toBe(false);
expect(skill.scope_checks).toContainEqual(
expect.objectContaining({
name: "runtime-configuration:REA_LOG_LEVEL",
ok: false,
}),
);
expect(skill.scope_checks.map(({ name }) => name)).not.toContain(
"runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
);
});
it("requires every MCP startup failure for an aligned client", async () => {
const ghidraTimeout = await runDoctor(
undefined,
selectedHost({ REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc" }),
{ clients: ["codex"] },
);
expect(ghidraTimeout.healthy).toBe(false);
expect(ghidraTimeout.scope_checks).toContainEqual(
expect.objectContaining({
name: "runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
ok: false,
}),
);
expect(ghidraTimeout.scope_checks).toContainEqual(
expect.objectContaining({ name: "registration:codex", ok: true }),
);
const hopperTimeout = await runDoctor(
undefined,
selectedHost({ REA_HOPPER_STARTUP_TIMEOUT_MS: "abc" }),
{ clients: ["codex"] },
);
expect(hopperTimeout.healthy).toBe(false);
expect(
hopperTimeout.scope_checks.filter(
({ name }) => name === "hopper-startup-timeout",
),
).toHaveLength(1);
const hopperAndClient = await runDoctor(
undefined,
selectedHost({ REA_HOPPER_STARTUP_TIMEOUT_MS: "abc" }),
{ clients: ["codex"], providers: ["hopper"] },
);
expect(
hopperAndClient.scope_checks.filter(
({ name }) => name === "hopper-startup-timeout",
),
).toHaveLength(1);
const skill = await runDoctor(
undefined,
selectedHost({ REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc" }),
{ skill: true },
);
expect(skill.healthy).toBe(true);
expect(skill.informational_checks).toContainEqual(
expect.objectContaining({
name: "runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
ok: false,
}),
);
});
});
describe("doctor runtime check", () => {
+12
View File
@@ -11,7 +11,10 @@ import { constants } from "node:fs";
import { access, readdir, stat } from "node:fs/promises";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import type { RetainedResourceScope } from "./AnalysisProvider.js";
import { nativeAnalysisConfigurationError } from "../config/parseConfig.js";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import type { ConfigurationSettingIssue } from "../domain/configurationErrors.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { join } from "node:path";
import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentation.js";
@@ -109,6 +112,11 @@ export interface DoctorHost {
readonly homeDirectory: string;
readonly configuredHopperPath?: string;
readonly configuredHopperStartupTimeout?: string;
/**
* Settings `parseConfig` rejects before native analysis starts.
* Undefined means this host did not run that admission.
*/
readonly configurationIssues?: readonly ConfigurationSettingIssue[];
readonly configuredIlspyCmdPath?: string;
macosVersion(): Promise<string | undefined>;
linuxDistribution(): Promise<LinuxDistribution | undefined>;
@@ -508,6 +516,9 @@ export const systemDoctorHost = (
const platform = options.platform ?? process.platform;
const architecture = options.architecture ?? process.arch;
const environment = options.environment ?? process.env;
const configurationError = nativeAnalysisConfigurationError(
snapshotEnvironment(environment, platform),
);
const hostExecFileOutput = options.execFileOutput ?? execFileOutput;
const homeDirectory = homeDirectoryFromEnvironment(environment, platform);
const commandEnvironment = { env: environment };
@@ -548,6 +559,7 @@ export const systemDoctorHost = (
configuredHopperStartupTimeout:
environment.REA_HOPPER_STARTUP_TIMEOUT_MS,
}),
configurationIssues: configurationError?.settings ?? [],
...(environment.REA_ILSPY_CMD_PATH === undefined
? {}
: { configuredIlspyCmdPath: environment.REA_ILSPY_CMD_PATH }),
+39
View File
@@ -37,6 +37,7 @@ export const collectDoctorDiagnostics = async (
checks.push(hopperCheck(host, hopperPath));
const hopperTimeoutCheck = optionalHopperStartupTimeoutCheck(host);
if (hopperTimeoutCheck !== undefined) checks.push(hopperTimeoutCheck);
checks.push(...runtimeConfigurationChecks(host));
const hopperVersionCheck = await optionalHopperVersionCheck(host, hopperPath);
if (hopperVersionCheck !== undefined) checks.push(hopperVersionCheck);
@@ -230,6 +231,44 @@ const optionalHopperStartupTimeoutCheck = (
);
};
/**
* Project every native-analysis admission failure except the Hopper timeout,
* which already has its own check. Provider scopes include engine settings.
* Settings that block process start are marked for every doctor scope.
*/
const runtimeConfigurationChecks = (
host: DoctorHost,
): readonly DoctorCheck[] => {
const issues = host.configurationIssues;
if (issues === undefined || issues.length === 0) return [];
const grouped: { setting: string; constraints: string[] }[] = [];
for (const issue of issues) {
if (issue.setting === "REA_HOPPER_STARTUP_TIMEOUT_MS") continue;
const existing = grouped.find((item) => item.setting === issue.setting);
if (existing === undefined)
grouped.push({ setting: issue.setting, constraints: [issue.constraint] });
else existing.constraints.push(issue.constraint);
}
return grouped.map(({ setting, constraints }) => ({
...check(
`runtime-configuration:${setting}`,
false,
constraints
.map((constraint) =>
constraint.includes(setting)
? constraint.replace(/\.$/u, "")
: `${setting}: ${constraint}`,
)
.join("; "),
{
classification: "config_drift",
remediation: `Correct ${setting} in the environment that starts REA (your shell or the MCP client's registration), then rerun.`,
},
),
details: { setting },
}));
};
const optionalHopperVersionCheck = async (
host: DoctorHost,
hopperPath: string | undefined,
+27
View File
@@ -1,4 +1,5 @@
import { uniqueSorted } from "../domain/canonicalOrdering.js";
import { blocksProcessStart } from "../logger.js";
import type {
ClientRegistrationStatus,
UnhealthyClientRegistrationStatus,
@@ -80,8 +81,33 @@ export const scopeDoctorChecks = (
const hopperHostChecks = input.scope.providers.includes("hopper")
? input.checks.filter(({ name }) => name === "host")
: [];
// parseConfig rejects these before the MCP server starts. Hopper's own
// scope already includes hopper-startup-timeout through its provider checks.
const blocksMcpStartup = (check: DoctorCheck): boolean =>
check.name.startsWith("runtime-configuration:") ||
check.name === "hopper-startup-timeout";
const blocksProcess = (check: DoctorCheck): boolean => {
const setting = check.details?.setting;
return (
check.name.startsWith("runtime-configuration:") &&
typeof setting === "string" &&
blocksProcessStart(setting)
);
};
const exercisesMcpStartup =
input.scope.clients.length > 0 || input.scope.providers.length > 0;
const startupChecks = (
exercisesMcpStartup
? input.checks.filter(blocksMcpStartup)
: input.checks.filter(blocksProcess)
).filter(
(check) =>
check.name !== "hopper-startup-timeout" ||
!input.scope.providers.includes("hopper"),
);
const scopeChecks = [
...coreChecks,
...startupChecks,
...hopperHostChecks,
...selectedProviderChecks,
...clientChecks,
@@ -91,6 +117,7 @@ export const scopeDoctorChecks = (
return {
healthy:
coreChecks.every(({ ok }) => ok) &&
startupChecks.every(({ ok }) => ok) &&
hopperHostChecks.every(({ ok }) => ok) &&
clientChecks.every(({ ok }) => ok) &&
skillChecks.every(({ ok }) => ok) &&
@@ -0,0 +1,446 @@
import { describe, expect, it, vi } from "vitest";
import {
createAnalysisExecution,
type AnalysisOperation,
type AnalysisOperationPort,
} from "./AnalysisProvider.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { EnhancedTools } from "./EnhancedTools.js";
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
import { objcSwiftMetadataSchema } from "../domain/native/objcSwiftMetadata.js";
import { err, ok } from "../domain/result.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import {
AnalysisOutputError,
AnalysisCancelledError,
} from "../domain/analysisErrorCore.js";
import { createAnalysisProfile } from "../domain/analysisProfile.js";
import type { MachoTargetSelection } from "../domain/apple/machoTargetSelection.js";
const sha256 = "a".repeat(64);
const provider = { id: "native", name: "Native", version: "1" };
const namesProvider = { id: "hopper", name: "Hopper", version: "2" };
const selection = {
kind: "macho-selected-image-v1" as const,
container: "thin" as const,
offset: 0,
byte_length: 4096,
cpu_type: 0x0100000c,
cpu_subtype: 0x80000002,
container_table_sha256: null,
};
const profile = createAnalysisProfile(namesProvider, {
target_selection: selection,
});
const subject = {
sha256,
path: "/fixture",
format: "mach-o" as const,
architecture: "arm64" as const,
};
const createPort = (
nativeOverrides: Partial<{
sha256: string;
architecture: BinaryArchitecture;
names: Array<{ address: string; value: string }>;
nameLimitations: string[];
metadataFailure: AnalysisError;
bindings: Array<{
requested_address: string;
symbol: string;
binding_source: "chained" | "bind" | "weak" | "lazy";
library_ordinal: number;
library: string | null;
weak: boolean;
addend: string;
}>;
targetSelection: MachoTargetSelection | null;
nameProfile: boolean;
}> = {},
): AnalysisOperationPort => ({
execute: async (operation: AnalysisOperation, parameters) => {
if (operation === "list_names")
return ok(
createAnalysisExecution(
nativeOverrides.names ?? [
{ address: "0x1000", value: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x1000", value: "OBJC_CLASS_$_Document" },
],
namesProvider,
{
subject,
limitations: nativeOverrides.nameLimitations ?? [],
...(nativeOverrides.nameProfile === false
? {}
: { analysisProfile: profile }),
},
),
);
if (operation !== "inspect_native_dispatch_metadata")
return err(
new AnalysisCapabilityUnavailableError(
provider.id,
operation,
"Unavailable in fixture",
),
);
expect(parameters).toMatchObject({
max_records: 20_000,
objc_class_import_addresses: [
...new Set(
(
nativeOverrides.names ?? [
{ address: "0x1000", value: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x1000", value: "OBJC_CLASS_$_Document" },
]
).map(({ address }) => address),
),
],
objc_class_import_addresses_complete: true,
});
if (nativeOverrides.metadataFailure !== undefined)
return err(nativeOverrides.metadataFailure);
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
if (nativeOverrides.targetSelection !== null)
result.target_selection = nativeOverrides.targetSelection ?? selection;
result.objc_class_import_bindings = nativeOverrides.bindings ?? [
{
requested_address: "0x1000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: false,
addend: "0",
},
];
result.objc_class_import_binding_coverage = {
facet: "objc_class_import_bindings",
status: "complete",
reason: null,
examined: 1,
decoded: result.objc_class_import_bindings.length,
};
return ok(
createAnalysisExecution(
{
target_sha256: nativeOverrides.sha256 ?? sha256,
target_architecture: nativeOverrides.architecture ?? "arm64",
provider,
analysis_profile_digest: null,
result,
},
provider,
{
subject: {
...subject,
...(nativeOverrides.sha256 === undefined
? {}
: { sha256: nativeOverrides.sha256 }),
...(nativeOverrides.architecture === undefined
? {}
: { architecture: nativeOverrides.architecture }),
},
},
),
);
},
});
it.each([
["list_names", "omitted"],
["list_names", "unbound"],
["inspect_native_dispatch_metadata", "omitted"],
["inspect_native_dispatch_metadata", "unbound"],
] as const)(
"retains class candidates when %s subject is %s",
async (operation, state) => {
const port = createPort();
const tools = new EnhancedTools({
execute: async (...args) => {
const result = await port.execute(...args);
if (!result.ok || args[0] !== operation) return result;
const value = { ...result.value };
if (state === "omitted") delete value.subject;
else value.subject = null;
return ok(value);
},
});
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 2,
classes: [
{ address: "0x1000", name: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x1000", name: "OBJC_CLASS_$_Document" },
],
identity_coverage: { status: "unknown" },
},
});
},
);
describe("Objective-C class import identity composition", () => {
it("joins only exact same-target bind slots and preserves literal filter selection", async () => {
const tools = new EnhancedTools(createPort());
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 1,
classes: [
{
address: "0x1000",
name: "OBJC_CLASS_$_Document",
binding_identity: {
symbol: "_OBJC_CLASS_$_Document",
slot_addresses: ["0x1000"],
},
},
],
identity_coverage: { status: "complete", reason: null },
},
});
await expect(
tools.execute("get_objc_classes", { pattern: "$GOT" }),
).resolves.toMatchObject({
ok: true,
value: {
count: 1,
classes: [{ address: "0x1000", name: "OBJC_CLASS_$_Document$GOT" }],
},
});
});
it("keeps rows unjoined when the native observation has a different subject", async () => {
const tools = new EnhancedTools(createPort({ sha256: "b".repeat(64) }));
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 2,
classes: [
{ address: "0x1000", name: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x1000", name: "OBJC_CLASS_$_Document" },
],
identity_coverage: { status: "unknown" },
},
});
});
it("keeps identity unknown when exact selected-image evidence is missing or mismatched", async () => {
const cases = [
{ nameProfile: false },
{ targetSelection: null },
{ targetSelection: { ...selection, cpu_type: 0x01000007 } },
{ targetSelection: { ...selection, cpu_subtype: 2 } },
{
targetSelection: {
...selection,
container: "fat" as const,
offset: 4096,
container_table_sha256: "b".repeat(64),
},
},
];
for (const overrides of cases) {
const tools = new EnhancedTools(createPort(overrides));
await expect(
tools.execute("get_objc_classes", {}),
).resolves.toMatchObject({
ok: true,
value: { count: 2, identity_coverage: { status: "unknown" } },
});
}
});
it("marks identity coverage partial when the selected names inventory is limited", async () => {
const tools = new EnhancedTools(
createPort({ nameLimitations: ["provider capped the name inventory"] }),
);
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 1,
identity_coverage: {
status: "partial",
reason: "list_names: provider capped the name inventory",
},
},
});
});
it("keeps names with unknown identity when binding inspection is unavailable", async () => {
const tools = new EnhancedTools(
createPort({
metadataFailure: new AnalysisCapabilityUnavailableError(
provider.id,
"inspect_native_dispatch_metadata",
"No static Mach-O reader",
),
}),
);
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 2,
identity_coverage: { status: "unknown" },
},
});
});
it("preserves non-capability binding failures", async () => {
const failure = new AnalysisOutputError(
"inspect_native_dispatch_metadata",
"fixture failed",
);
const tools = new EnhancedTools(createPort({ metadataFailure: failure }));
await expect(tools.execute("get_objc_classes", {})).resolves.toEqual({
ok: false,
error: failure,
});
});
});
it("preserves same-named rows whose exact import slots have different identities", async () => {
const addresses = ["0x1000", "0x2000", "0x3000", "0x4000"];
const name = "OBJC_CLASS_$_Document";
const tools = new EnhancedTools(
createPort({
names: [
...addresses.map((address) => ({ address, value: name })),
{ address: "0x1000", value: name },
],
bindings: [
{
requested_address: "0x1000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: false,
addend: "0",
},
{
requested_address: "0x2000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/UIKit.framework/UIKit",
weak: false,
addend: "0",
},
{
requested_address: "0x3000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: true,
addend: "0",
},
{
requested_address: "0x4000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: false,
addend: "1",
},
],
}),
);
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 4,
classes: addresses.map((address, index) => ({
address,
name,
binding_identity: {
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library:
index === 1
? "/System/Library/Frameworks/UIKit.framework/UIKit"
: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: index === 2,
addend: index === 3 ? "1" : "0",
slot_addresses: [address],
},
})),
identity_coverage: { status: "complete", reason: null },
},
});
});
describe("EnhancedTools cancellation ownership", () => {
it("preserves the caller reason at admission", async () => {
const reason = { request: "cancel-before-observation" };
const controller = new AbortController();
controller.abort(reason);
const execute = vi.fn(createPort().execute);
const tools = new EnhancedTools({ execute });
const result = await tools.execute(
"get_objc_classes",
{},
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Pre-cancelled observation was accepted");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(execute).not.toHaveBeenCalled();
});
it("preserves a post-success caller reason without replacing provider failures", async () => {
const reason = { request: "cancel-after-provider-success" };
const controller = new AbortController();
const successfulPort = createPort();
const successTools = new EnhancedTools({
execute: async (...args) => {
const result = await successfulPort.execute(...args);
if (args[0] === "list_names" && result.ok) controller.abort(reason);
return result;
},
});
const cancelled = await successTools.execute(
"get_objc_classes",
{},
controller.signal,
);
expect(cancelled.ok).toBe(false);
if (cancelled.ok)
throw new Error("Post-cancelled observation was accepted");
expect(cancelled.error).toBeInstanceOf(AnalysisCancelledError);
expect(cancelled.error.cause).toBe(reason);
const physicalFailure = new AnalysisOutputError(
"list_names",
"fixture read failed",
);
const failingController = new AbortController();
const failureTools = new EnhancedTools({
execute: async (...args) => {
if (args[0] === "list_names") {
failingController.abort(reason);
return err(physicalFailure);
}
return successfulPort.execute(...args);
},
});
const failed = await failureTools.execute(
"get_objc_classes",
{},
failingController.signal,
);
expect(failed).toEqual({ ok: false, error: physicalFailure });
});
});
+147 -7
View File
@@ -9,6 +9,7 @@ import type {
} from "./AnalysisProvider.js";
import {
enhancedInputSchemas,
MAX_ENHANCED_RECORDS,
type CallRelationshipDirection,
type EnhancedToolName,
} from "../contracts/enhancedInputs.js";
@@ -30,15 +31,24 @@ import {
import { err, ok, type Result } from "../domain/result.js";
import {
categorizeSwiftTypes,
discoverObjcClasses,
discoverObjcProtocols,
projectObjcClassCandidates,
selectObjcClassCandidates,
} from "../domain/symbolAnalysis.js";
import { inspectNativeDispatch } from "./native/NativeDispatchMetadataInspection.js";
import { traceNativeValues } from "./native/NativeValueTrace.js";
import { traceNativeUiAction } from "./native/NativeUiActionTrace.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { addressedValueSchema } from "../domain/native/nativeInventory.js";
import { nativeDispatchMetadataResultSchema } from "../domain/native/objcSwiftMetadata.js";
import { isMachOFormat } from "../domain/binaryTargetTypes.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import {
machoTargetSelectionMatchesArchitecture,
machoTargetSelectionMatchesFormat,
profileMachoTargetSelection,
sameMachoTargetSelection,
} from "../domain/apple/machoTargetSelection.js";
import {
invalidEnhancedInput,
@@ -187,6 +197,8 @@ export class EnhancedTools {
return this.#inspectNativeDispatchMetadata(
call.input.max_records,
signal,
call.input.objc_class_import_addresses,
call.input.objc_class_import_addresses_complete,
);
case "get_objc_classes":
return this.#objcClasses(call.input.pattern, signal);
@@ -286,15 +298,137 @@ export class EnhancedTools {
}
async #objcClasses(pattern: string, signal?: AbortSignal): EnhancedResult {
const names = await this.#allAddressed("list_names", signal);
return names.ok ? ok(discoverObjcClasses(names.value, pattern)) : names;
const namesExecution = await this.#observe("list_names", {}, signal);
if (!namesExecution.ok) return namesExecution;
const names = this.#parseAddressed(
"list_names",
namesExecution.value.result,
);
if (!names.ok) return names;
const candidates = selectObjcClassCandidates(names.value, pattern);
if (candidates.length === 0)
return ok(
projectObjcClassCandidates(candidates, {
bindings: [],
coverage: { status: "not_applicable", reason: null },
}),
);
const addresses = [...new Set(candidates.map(({ address }) => address))];
const scannedAddresses = addresses.slice(0, MAX_ENHANCED_RECORDS);
const bindingsExecution = await this.#observe(
"inspect_native_dispatch_metadata",
{
max_records: MAX_ENHANCED_RECORDS,
objc_class_import_addresses: scannedAddresses,
objc_class_import_addresses_complete:
addresses.length <= MAX_ENHANCED_RECORDS,
},
signal,
);
if (!bindingsExecution.ok) {
if (bindingsExecution.error._tag === "AnalysisCancelledError")
return bindingsExecution;
if (bindingsExecution.error._tag !== "AnalysisCapabilityUnavailableError")
return bindingsExecution;
return ok(
projectObjcClassCandidates(candidates, {
bindings: [],
coverage: {
status: "unknown",
reason: `Native class-import binding evidence is unavailable (${bindingsExecution.error._tag}).`,
},
}),
);
}
const native = nativeDispatchMetadataResultSchema.safeParse(
bindingsExecution.value.result,
);
const nameSubject = namesExecution.value.subject;
const bindingSubject = bindingsExecution.value.subject;
const namesSelection = profileMachoTargetSelection(
namesExecution.value.analysisProfile,
);
const nativeSelection = native.success
? native.data.result.target_selection
: undefined;
if (
!native.success ||
nameSubject === null ||
nameSubject === undefined ||
bindingSubject === null ||
bindingSubject === undefined ||
nameSubject.sha256 !== bindingSubject.sha256 ||
nameSubject.architecture === undefined ||
bindingSubject.architecture === undefined ||
nameSubject.architecture !== bindingSubject.architecture ||
!isMachOFormat(nameSubject.format) ||
nameSubject.format !== bindingSubject.format ||
native.data.target_sha256 !== nameSubject.sha256 ||
native.data.target_architecture !== nameSubject.architecture ||
native.data.provider.id !== bindingsExecution.value.provider.id ||
namesExecution.value.analysisProfile?.provider.id !==
namesExecution.value.provider.id ||
nativeSelection === undefined ||
!machoTargetSelectionMatchesArchitecture(
nativeSelection,
nameSubject.architecture,
) ||
!machoTargetSelectionMatchesFormat(nativeSelection, nameSubject.format) ||
!sameMachoTargetSelection(namesSelection, nativeSelection) ||
native.data.result.objc_class_import_bindings === undefined ||
native.data.result.objc_class_import_binding_coverage === undefined
)
return ok(
projectObjcClassCandidates(candidates, {
bindings: [],
coverage: {
status: "unknown",
reason:
"Native class-import facts did not prove matching artifact, architecture, and complete producer identity.",
},
}),
);
const coverage = native.data.result.objc_class_import_binding_coverage;
const inventoryLimitations = namesExecution.value.limitations;
const inventoryIncomplete = inventoryLimitations.length > 0;
const coverageReason = [
coverage.reason,
...inventoryLimitations.map((limitation) => `list_names: ${limitation}`),
]
.filter((reason): reason is string => reason !== null)
.join("; ");
return ok(
projectObjcClassCandidates(candidates, {
bindings: native.data.result.objc_class_import_bindings,
coverage: {
status:
coverage.status === "not_requested"
? "unknown"
: coverage.status === "complete" && inventoryIncomplete
? "partial"
: coverage.status,
reason: coverageReason.length === 0 ? null : coverageReason,
},
}),
);
}
#inspectNativeDispatchMetadata(
maxRecords: number,
signal?: AbortSignal,
objcClassImportAddresses?: readonly string[],
objcClassImportAddressesComplete?: boolean,
): EnhancedResult {
return inspectNativeDispatch(this.analysis, maxRecords, signal);
return inspectNativeDispatch(
this.analysis,
maxRecords,
signal,
objcClassImportAddresses,
objcClassImportAddressesComplete,
);
}
async #objcProtocols(signal?: AbortSignal): EnhancedResult {
@@ -677,7 +811,11 @@ export class EnhancedTools {
) {
const result = await this.#call(tool, {}, signal);
if (!result.ok) return result;
const parsed = z.array(addressedValueSchema).safeParse(result.value);
return this.#parseAddressed(tool, result.value);
}
#parseAddressed(tool: "list_names" | "list_procedures", value: JsonValue) {
const parsed = z.array(addressedValueSchema).safeParse(value);
return parsed.success
? ok(
parsed.data.map(({ address, value: name, objc_class_label }) => ({
@@ -715,13 +853,15 @@ export class EnhancedTools {
signal?: AbortSignal,
selectorOrigin?: ExecutionOptions["selectorOrigin"],
): Promise<Result<AnalysisExecution, AnalysisError>> {
if (isAborted(signal)) return err(new AnalysisCancelledError(name));
if (isAborted(signal))
return err(new AnalysisCancelledError(name, { cause: signal?.reason }));
const execution = await this.analysis.execute(name, arguments_, {
...(signal === undefined ? {} : { signal }),
...(selectorOrigin === undefined ? {} : { selectorOrigin }),
});
if (!execution.ok) return execution;
if (isAborted(signal)) return err(new AnalysisCancelledError(name));
if (isAborted(signal))
return err(new AnalysisCancelledError(name, { cause: signal?.reason }));
return execution;
}
}
+20 -17
View File
@@ -22,7 +22,6 @@ import {
canonicalJsonParts,
} from "../domain/jsonSerialization.js";
import { readJsonFile, writeTextParts } from "./JsonFiles.js";
import { RegularFileCleanupFailure } from "./RegularFileRead.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
type EvidenceReadFailure =
@@ -58,23 +57,27 @@ const readBundle = async (
resources?: ArtifactResourceScope,
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> => {
if (signal?.aborted)
return err(new AnalysisCancelledError("import_evidence_bundle"));
const loaded = await readJsonFile(path, signal, resources).catch(
(cause: unknown) => {
// A failed close transfers a live handle owner; preserve that failure.
if (
signal?.aborted === true &&
!(cause instanceof RegularFileCleanupFailure)
)
return err(
new AnalysisCancelledError("import_evidence_bundle", { cause }),
);
throw cause;
},
);
if (!loaded.ok) return loaded;
return err(
new AnalysisCancelledError("import_evidence_bundle", {
cause: signal.reason,
}),
);
const loaded = await readJsonFile(path, signal, resources);
if (!loaded.ok) {
if (loaded.error instanceof AnalysisCancelledError)
return err(
new AnalysisCancelledError("import_evidence_bundle", {
cause: loaded.error.cause,
}),
);
return loaded;
}
if (signal?.aborted)
return err(new AnalysisCancelledError("import_evidence_bundle"));
return err(
new AnalysisCancelledError("import_evidence_bundle", {
cause: signal.reason,
}),
);
let bundle: EvidenceBundle;
try {
bundle = parseBundle(loaded.value);
+20 -14
View File
@@ -39,19 +39,25 @@ export const executeFunctionAnalysisEvidence = async (
if (!dossier.ok) return dossier;
const observation = execution.value;
return ok(
createEvidence(observation.subject ?? target, observation.provider, {
operation: "analyze_function",
parameters: arguments_,
result: dossier.value,
rawResult: observation.rawResult,
limitations: observation.limitations,
locations: observation.locations,
...(observation.relationshipCoverage === undefined
? {}
: { relationshipCoverage: observation.relationshipCoverage }),
...(observation.analysisProfile === undefined
? {}
: { analysisProfile: observation.analysisProfile }),
}),
createEvidence(
observation.subject === undefined
? target
: (observation.subject ?? undefined),
observation.provider,
{
operation: "analyze_function",
parameters: arguments_,
result: dossier.value,
rawResult: observation.rawResult,
limitations: observation.limitations,
locations: observation.locations,
...(observation.relationshipCoverage === undefined
? {}
: { relationshipCoverage: observation.relationshipCoverage }),
...(observation.analysisProfile === undefined
? {}
: { analysisProfile: observation.analysisProfile }),
},
),
);
};
+44 -3
View File
@@ -19,6 +19,8 @@ import {
} from "../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../domain/result.js";
import {
JsonInputPhysicalReadFailure,
JsonInputCancellationFailure,
readJsonInputFile,
readJsonObjectFile,
type JsonObjectMemberReader,
@@ -46,7 +48,10 @@ export const readJsonFile = async (
signal?: AbortSignal,
resources?: ArtifactResourceScope,
): Promise<
Result<unknown, EvidenceFileError | AnalysisResourceConstraintError>
Result<
unknown,
EvidenceFileError | AnalysisResourceConstraintError | AnalysisCancelledError
>
> =>
readJsonFileInput(
path,
@@ -93,7 +98,10 @@ const readJsonFileInput = async <Value>(
>,
signal?: AbortSignal,
): Promise<
Result<Value, EvidenceFileError | AnalysisResourceConstraintError>
Result<
Value,
EvidenceFileError | AnalysisResourceConstraintError | AnalysisCancelledError
>
> => {
const requestedPath = absoluteCallerPath(path);
try {
@@ -116,7 +124,25 @@ const readJsonFileInput = async <Value>(
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
if (cause instanceof AnalysisResourceConstraintError) return err(cause);
if (signal?.aborted === true) throw cause;
if (cause instanceof JsonInputCancellationFailure)
return err(
new AnalysisCancelledError("read_evidence_file", {
cause: cause.reason,
}),
);
if (cause instanceof JsonInputPhysicalReadFailure)
return err(
new EvidenceFileError("read", "io", {
cause: cause.physicalCause,
path: requestedPath,
}),
);
if (isJsonReadCancellation(cause, signal))
return err(
new AnalysisCancelledError("read_evidence_file", {
cause: signal?.reason,
}),
);
return err(
new EvidenceFileError(
"read",
@@ -132,6 +158,21 @@ const readJsonFileInput = async <Value>(
}
};
const isJsonReadCancellation = (
cause: unknown,
signal?: AbortSignal,
): boolean => {
if (signal?.aborted !== true) return false;
if (cause === signal.reason) return true;
return (
cause instanceof Error &&
cause.name === "AbortError" &&
"code" in cause &&
cause.code === "ABORT_ERR" &&
cause.cause === signal.reason
);
};
/** Atomically write text to the caller-supplied path. */
export const writeTextFile = async (
encoded: string,
+223 -107
View File
@@ -1,5 +1,6 @@
import { constants } from "node:buffer";
import { createHash } from "node:crypto";
import type { Stats } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import { pipeline } from "node:stream/promises";
import { getHeapStatistics } from "node:v8";
@@ -10,10 +11,17 @@ import parser from "stream-json/parser.js";
import streamValues from "stream-json/streamers/stream-values.js";
import streamArray from "stream-json/streamers/stream-array.js";
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
import {
assertRegularFileActive,
RegularFileChangedError,
} from "../filesystem/RegularFile.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { sealOwnedJsonInput } from "./OwnedJsonInput.js";
import { withRegularFile } from "./RegularFileRead.js";
import {
RegularFileReadFailure,
withRegularFile,
type RegularFileReadOptions,
} from "./RegularFileRead.js";
import {
JSON_BYTE_ORDER_MARK_MESSAGE,
JSON_INPUT_RESOURCE_REMEDIATION,
@@ -34,6 +42,74 @@ const PACKED_TOKEN_PART_CODE_UNITS = 64 * 1024;
// This selects an implementation; larger files remain accepted through streaming.
const NATIVE_PARSE_READ_BUDGET = 8 * 1024 * 1024;
/** Physical descriptor failure remains primary if cancellation arrives afterward. */
export class JsonInputPhysicalReadFailure extends Error {
readonly _tag = "JsonInputPhysicalReadFailure";
readonly physicalCause: unknown;
constructor(physicalCause: unknown) {
super("JSON input descriptor read failed");
this.physicalCause = physicalCause;
}
}
/** Explicit cancellation fact; the caller's value is opaque and retained as data. */
export class JsonInputCancellationFailure extends Error {
readonly _tag = "JsonInputCancellationFailure";
constructor(readonly reason: unknown) {
super(
reason instanceof Error ? reason.message : "JSON input read cancelled",
);
}
}
interface JsonInputPhysicalReadObservation {
failure?: JsonInputPhysicalReadFailure;
}
const readPhysicalJsonInput = async <Value>(
observation: JsonInputPhysicalReadObservation,
read: () => Promise<Value>,
): Promise<Value> => {
try {
return await read();
} catch (physicalCause: unknown) {
const failure = new JsonInputPhysicalReadFailure(physicalCause);
observation.failure = failure;
throw failure;
}
};
const withJsonInputFile = <Value>(
path: string,
read: (handle: FileHandle, stats: Stats) => Promise<Value>,
options: Pick<RegularFileReadOptions, "signal" | "resources">,
): Promise<Value> =>
withRegularFile(path, read, {
...options,
cancellationFailure: (reason) => new JsonInputCancellationFailure(reason),
}).catch((cause: unknown) => {
if (cause instanceof RegularFileReadFailure) throw cause.operationCause;
throw cause;
});
const withJsonInputPipeline = async <Value>(
signal: AbortSignal | undefined,
run: (signal?: AbortSignal) => Promise<Value>,
): Promise<Value> => {
if (signal === undefined) return run();
const controller = new AbortController();
const cancel = (): void => {
controller.abort(new JsonInputCancellationFailure(signal.reason));
};
if (signal.aborted) cancel();
else signal.addEventListener("abort", cancel, { once: true });
try {
return await run(controller.signal);
} finally {
signal.removeEventListener("abort", cancel);
}
};
/** Heap a whole-file native JSON.parse may need: the assembler's 64x container expansion plus byte and text copies. */
export const nativeJsonParseRequiredBytes = (
inputBytes: number,
@@ -66,10 +142,11 @@ export const readJsonObjectFile = (
signal?: AbortSignal,
resources?: ArtifactResourceScope,
): Promise<Result<void, JsonInputFileFailure>> =>
withRegularFile(
withJsonInputFile(
path,
async (handle, stats) => {
let previousDigest: string | undefined;
const physicalRead: JsonInputPhysicalReadObservation = {};
try {
const native = await readNativeJson(
handle,
@@ -77,6 +154,8 @@ export const readJsonObjectFile = (
path,
operation,
signal,
false,
physicalRead,
);
if (native !== undefined) {
if (!native.ok) return native;
@@ -92,7 +171,7 @@ export const readJsonObjectFile = (
for (const members of scans)
for (const [key, member] of members) {
if (!Object.hasOwn(value, key)) continue;
signal?.throwIfAborted();
assertRegularFileActive(signal);
member.begin();
const selected: unknown = Reflect.get(value, key);
if (member.mode === "array" && !Array.isArray(selected)) {
@@ -105,7 +184,7 @@ export const readJsonObjectFile = (
Array.isArray(selected)
? selected
: [selected]) {
signal?.throwIfAborted();
assertRegularFileActive(signal);
try {
await member.accept(item);
} catch (cause: unknown) {
@@ -123,68 +202,80 @@ export const readJsonObjectFile = (
let consume:
| ((token: parser.Token) => typeof none | Promise<typeof none>)
| undefined;
await pipeline(
decodedChunks(handle, stats.size, path, signal, (bytes) => {
hash.update(bytes);
}),
asStream(
fun(
parser({
jsonStreaming: false,
streamValues: false,
packKeys: false,
packStrings: false,
packNumbers: false,
}),
packJsonScalars(operation, stats.size, path),
(token: parser.Token): typeof none | Promise<typeof none> => {
const opens =
token.name === "startObject" || token.name === "startArray";
const closes =
token.name === "endObject" || token.name === "endArray";
if (depth === 0) {
rootObject = token.name === "startObject";
} else if (
rootObject &&
depth === 1 &&
token.name === "keyValue"
) {
key = token.value;
} else if (rootObject && depth === 1 && !closes) {
const member =
key === undefined ? undefined : members.get(key);
consume = undefined;
if (member !== undefined) {
member.begin();
if (
member.mode === "array" &&
token.name !== "startArray"
) {
member.reject(
new TypeError(`JSON member ${key} must be an array`),
);
} else {
consume = memberConsumer(
member,
operation,
stats.size,
path,
);
}
}
key = undefined;
}
const selected = consume;
if (opens) depth += 1;
if (closes) depth -= 1;
if (rootObject && depth === 1 && token.name !== "keyValue")
consume = undefined;
if (selected === undefined) return none;
return selected(token);
await withJsonInputPipeline(signal, (pipelineSignal) =>
pipeline(
decodedChunks(
handle,
stats.size,
path,
signal,
(bytes) => {
hash.update(bytes);
},
physicalRead,
),
asStream(
fun(
parser({
jsonStreaming: false,
streamValues: false,
packKeys: false,
packStrings: false,
packNumbers: false,
}),
packJsonScalars(operation, stats.size, path),
(token: parser.Token): typeof none | Promise<typeof none> => {
const opens =
token.name === "startObject" ||
token.name === "startArray";
const closes =
token.name === "endObject" || token.name === "endArray";
if (depth === 0) {
rootObject = token.name === "startObject";
} else if (
rootObject &&
depth === 1 &&
token.name === "keyValue"
) {
key = token.value;
} else if (rootObject && depth === 1 && !closes) {
const member =
key === undefined ? undefined : members.get(key);
consume = undefined;
if (member !== undefined) {
member.begin();
if (
member.mode === "array" &&
token.name !== "startArray"
) {
member.reject(
new TypeError(
`JSON member ${key} must be an array`,
),
);
} else {
consume = memberConsumer(
member,
operation,
stats.size,
path,
);
}
}
key = undefined;
}
const selected = consume;
if (opens) depth += 1;
if (closes) depth -= 1;
if (rootObject && depth === 1 && token.name !== "keyValue")
consume = undefined;
if (selected === undefined) return none;
return selected(token);
},
),
),
pipelineSignal === undefined ? {} : { signal: pipelineSignal },
),
signal === undefined ? {} : { signal },
);
if (!rootObject)
return err(
@@ -197,6 +288,7 @@ export const readJsonObjectFile = (
}
return ok(undefined);
} catch (cause: unknown) {
if (physicalRead.failure !== undefined) throw physicalRead.failure;
const failure = jsonReadFailure(cause, operation, stats.size, path);
if (failure !== undefined) return err(failure);
throw cause;
@@ -256,11 +348,12 @@ export const readJsonInputFile = (
resources?: ArtifactResourceScope,
snapshot = false,
): Promise<Result<unknown, JsonInputFileFailure>> =>
withRegularFile(
withJsonInputFile(
path,
async (handle, stats) => {
let found = false;
let value: unknown;
const physicalRead: JsonInputPhysicalReadObservation = {};
try {
const native = await readNativeJson(
handle,
@@ -269,44 +362,59 @@ export const readJsonInputFile = (
operation,
signal,
snapshot,
physicalRead,
);
if (native !== undefined)
return native.ok && snapshot
? await sealOwnedJsonInput(native.value, operation, signal)
: native;
await pipeline(
decodedChunks(handle, stats.size, path, signal),
// Fuse synchronous token stages so only file chunks cross the async
// stream boundary. Admission still precedes scalar/value assembly.
asStream(
fun(
parser({
jsonStreaming: false,
streamValues: false,
packKeys: false,
packStrings: false,
packNumbers: false,
}),
packJsonScalars(operation, stats.size, path),
admitJsonAssembly(operation, stats.size, path, snapshot ? 2 : 1),
streamValues(),
(row: unknown): typeof none => {
if (
found ||
typeof row !== "object" ||
row === null ||
!("key" in row) ||
row.key !== 0 ||
!("value" in row)
)
throw new Error("Unexpected JSON parser result");
found = true;
value = row.value;
return none;
},
await withJsonInputPipeline(signal, (pipelineSignal) =>
pipeline(
decodedChunks(
handle,
stats.size,
path,
signal,
undefined,
physicalRead,
),
// Fuse synchronous token stages so only file chunks cross the async
// stream boundary. Admission still precedes scalar/value assembly.
asStream(
fun(
parser({
jsonStreaming: false,
streamValues: false,
packKeys: false,
packStrings: false,
packNumbers: false,
}),
packJsonScalars(operation, stats.size, path),
admitJsonAssembly(
operation,
stats.size,
path,
snapshot ? 2 : 1,
),
streamValues(),
(row: unknown): typeof none => {
if (
found ||
typeof row !== "object" ||
row === null ||
!("key" in row) ||
row.key !== 0 ||
!("value" in row)
)
throw new Error("Unexpected JSON parser result");
found = true;
value = row.value;
return none;
},
),
),
pipelineSignal === undefined ? {} : { signal: pipelineSignal },
),
signal === undefined ? {} : { signal },
);
return found
? snapshot
@@ -314,6 +422,7 @@ export const readJsonInputFile = (
: ok(value)
: err(invalidJson(operation, "JSON document is empty"));
} catch (cause: unknown) {
if (physicalRead.failure !== undefined) throw physicalRead.failure;
const failure = jsonReadFailure(cause, operation, stats.size, path);
if (failure !== undefined) return err(failure);
throw cause;
@@ -526,6 +635,7 @@ async function* decodedChunks(
path: string,
signal: AbortSignal | undefined,
observe?: (bytes: Uint8Array) => void,
physicalRead: JsonInputPhysicalReadObservation = {},
): AsyncGenerator<string> {
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
let leading = true;
@@ -539,14 +649,16 @@ async function* decodedChunks(
const bytes = Buffer.allocUnsafe(READ_CHUNK_BYTES);
let observedBytes = 0;
while (true) {
signal?.throwIfAborted();
const read = await handle.read(
bytes,
0,
Math.min(bytes.length, admittedBytes - observedBytes + 1),
observedBytes,
assertRegularFileActive(signal);
const read = await readPhysicalJsonInput(physicalRead, () =>
handle.read(
bytes,
0,
Math.min(bytes.length, admittedBytes - observedBytes + 1),
observedBytes,
),
);
signal?.throwIfAborted();
assertRegularFileActive(signal);
if (read.bytesRead === 0) break;
observedBytes += read.bytesRead;
if (observedBytes > admittedBytes) throw new RegularFileChangedError(path);
@@ -569,13 +681,16 @@ const readPrefix = async (
handle: FileHandle,
size: number,
signal: AbortSignal | undefined,
physicalRead: JsonInputPhysicalReadObservation,
): Promise<{ readonly bytes: Buffer; readonly complete: boolean }> => {
const bytes = Buffer.allocUnsafe(size);
let offset = 0;
while (offset < bytes.length) {
signal?.throwIfAborted();
const read = await handle.read(bytes, offset, bytes.length - offset, null);
signal?.throwIfAborted();
assertRegularFileActive(signal);
const read = await readPhysicalJsonInput(physicalRead, () =>
handle.read(bytes, offset, bytes.length - offset, null),
);
assertRegularFileActive(signal);
if (read.bytesRead === 0)
return { bytes: bytes.subarray(0, offset), complete: true };
offset += read.bytesRead;
@@ -591,9 +706,10 @@ const readNativeJson = async (
operation: string,
signal?: AbortSignal,
snapshot = false,
physicalRead: JsonInputPhysicalReadObservation = {},
): Promise<Result<unknown, JsonInputFileFailure> | undefined> => {
if (!nativeParseFits(size, snapshot)) return undefined;
const prefix = await readPrefix(handle, size + 1, signal);
const prefix = await readPrefix(handle, size + 1, signal, physicalRead);
if (!prefix.complete || prefix.bytes.length !== size)
throw new RegularFileChangedError(path);
const parsed = parseUtf8Json(
+16 -2
View File
@@ -7,7 +7,11 @@ import {
ArtifactResourceScope,
type ArtifactResourceOwner,
} from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import type { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import {
ArtifactReaderFailure,
artifactPrimaryCleanupLeases,
} from "../artifacts/ArtifactReader.js";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@@ -922,7 +926,9 @@ export class LinuxHopperPublisher {
"integrity",
"Retained Hopper evidence exceeds its resource byte budget.",
);
const bytes = await readBoundedFileBytes(handle, stats.size, signal);
const bytes = await readBoundedFileBytes(handle, stats.size, {
signal,
});
if (bytes === undefined)
throw new HopperReleaseError(
"integrity",
@@ -1061,12 +1067,17 @@ export class LinuxHopperPublisher {
export const retryLinuxHopperPublisherCleanup = async (): Promise<void> => {
const causes: unknown[] = [];
const observations = [];
const cleanupLeases = new Set<AnalysisCleanupLease>();
let cleanupLeaseAuthorityKnown = true;
for (const scope of pendingCleanup) {
try {
await scope.close();
pendingCleanup.delete(scope);
} catch (cause: unknown) {
causes.push(cause);
const leases = artifactPrimaryCleanupLeases(cause);
if (leases === undefined) cleanupLeaseAuthorityKnown = false;
else for (const lease of leases) cleanupLeases.add(lease);
observations.push(
ArtifactReaderFailure.cleanupObservation(
cause,
@@ -1088,6 +1099,9 @@ export const retryLinuxHopperPublisherCleanup = async (): Promise<void> => {
{
cause: new AggregateError(causes, "Hopper publisher cleanup failures"),
cleanup,
cleanupLeases: cleanupLeaseAuthorityKnown
? [...cleanupLeases]
: undefined,
},
);
}
@@ -1,8 +1,18 @@
import { expect, it, vi } from "vitest";
import { z } from "zod";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { executeProviderOperation } from "./ProviderOperationExecution.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
executeProviderOperation,
keepIncompleteObservation,
} from "./ProviderOperationExecution.js";
it("projects request-schema issues without exposing the request envelope", async () => {
const execute = vi.fn();
@@ -87,3 +97,84 @@ it("preserves operation correction outside the input envelope", async () => {
]);
expect(execute).not.toHaveBeenCalled();
});
const cancelledScenario = (partial: unknown) =>
new AnalysisCancelledError("capture_browser_scenario", {
cause: { requested: "stop during capture" },
partialObservation: partial as never,
});
const wrapScenarioCleanup = (primary: AnalysisCancelledError) =>
analysisErrorWithCleanupFailure(
primary,
new ProviderCleanupError("fixture", ["browser"], {}),
"capture_browser_scenario",
);
it("clears a non-JSON partial from the cleanup primary projection returns after retry", () => {
const partial = { steps: [] as unknown[] };
const primary = cancelledScenario(partial);
const composed = wrapScenarioCleanup(primary);
partial.steps.push(undefined);
const reason =
"Browser scenario provider retained a non-JSON incomplete observation.";
const retained = keepIncompleteObservation(
composed,
"capture_browser_scenario",
reason,
);
expect(retained).toBe(composed);
expect(composed.partialObservation).toBeUndefined();
expect(primary.partialObservation).toBeUndefined();
expect(composed.secondaryFailures).toMatchObject([
{ _tag: "AnalysisOutputError", reason },
]);
expect(primary.secondaryFailures[0]).toBeInstanceOf(AnalysisOutputError);
expect(projectAnalysisError(composed).code).toBe("cleanup_incomplete");
composed.resolveCleanup();
expect(projectAnalysisError(composed)).toMatchObject({
code: "cancelled",
category: "cancelled",
});
expect(projectAnalysisError(composed).details).not.toHaveProperty(
"partial_observation",
);
});
it("freezes one shared snapshot for a cleanup wrapper and its primary", () => {
const partial = { steps: ["kept"] };
const primary = cancelledScenario(partial);
const composed = wrapScenarioCleanup(primary);
keepIncompleteObservation(
composed,
"capture_browser_scenario",
"unused because the partial is JSON",
);
expect(composed.partialObservation).toBe(primary.partialObservation);
expect(composed.partialObservation).toEqual({ steps: ["kept"] });
expect(Object.isFrozen(composed.partialObservation)).toBe(true);
expect(composed.secondaryFailures).toHaveLength(0);
expect(primary.secondaryFailures).toHaveLength(0);
});
it("freezes a retained partial so a later mutation cannot break projection", () => {
const partial = { steps: [] as unknown[] };
const primary = cancelledScenario(partial);
keepIncompleteObservation(
primary,
"capture_browser_scenario",
"unused because the partial is JSON",
);
partial.steps.push(undefined);
expect(primary.partialObservation).toEqual({ steps: [] });
expect(Object.isFrozen(primary.partialObservation)).toBe(true);
expect(primary.secondaryFailures).toHaveLength(0);
expect(projectAnalysisError(primary)).toMatchObject({
code: "cancelled",
details: { partial_observation: { steps: [] } },
});
});
@@ -2,12 +2,19 @@ import { z } from "zod";
import type {
AnalysisError,
AnalysisErrorOptions,
AnalysisPartialObservation,
} from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import type { Evidence } from "../domain/evidence.js";
import {
freezeJsonSnapshot,
isImmutableJsonSnapshot,
} from "../domain/immutableJson.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { projectInputIssues } from "../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@@ -92,3 +99,49 @@ export const completeProviderEvidence = (
);
return ok(evidence);
};
/**
* Retain an incomplete observation when projection can serialize it.
* A JSON partial is replaced by one frozen snapshot, so a later mutation
* cannot make projection throw and projection does not copy it again.
* A partial that is not JSON is cleared from the returned error and from
* every cleanup primary projection can select later. Each cleared error
* records an output failure, leaving cancellation or timeout primary.
*/
export const keepIncompleteObservation = (
error: AnalysisError,
operation: string,
reason: string,
): AnalysisError => {
const seen = new Set<AnalysisError>();
// One source object can sit on a wrapper and its cleanup primary.
const judged = new Map<object, AnalysisPartialObservation | undefined>();
let current: AnalysisError | undefined = error;
while (current !== undefined && !seen.has(current)) {
seen.add(current);
const partial = current.partialObservation;
if (
partial !== undefined &&
!isImmutableJsonSnapshot(partial) &&
typeof partial === "object"
) {
// safeParse clones only after validation succeeds. Freeze that copy once.
let snapshot = judged.get(partial);
if (!judged.has(partial)) {
const parsed = jsonValueSchema.safeParse(partial);
snapshot = parsed.success
? (freezeJsonSnapshot(parsed.data) as AnalysisPartialObservation)
: undefined;
judged.set(partial, snapshot);
}
if (snapshot === undefined) {
current.partialObservation = undefined;
current.retainSecondaryFailure(
new AnalysisOutputError(operation, reason),
);
} else current.partialObservation = snapshot;
}
current = current.cleanupPrimary;
}
return error;
};
+48 -10
View File
@@ -5,6 +5,7 @@ import ignore from "ignore";
import {
readRegularFileText,
RegularFileCleanupFailure,
RegularFileReadFailure,
retryRegularFileCleanup,
} from "./RegularFileRead.js";
@@ -17,6 +18,7 @@ import {
type ReferenceSourceImportOptions,
} from "./ReferenceSourceImportTypes.js";
import { rootFilesystemFailure } from "../reference/ReferenceSourceReaderErrors.js";
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
/** Validated inputs ready for filesystem traversal. */
export interface PreparedReferenceSourceImport {
@@ -34,6 +36,25 @@ const failure = (
message,
});
class IgnorePolicyReadCancelled extends Error {
constructor(readonly reason: unknown) {
super("Reference source ignore policy read was cancelled", {
cause: reason,
});
this.name = "IgnorePolicyReadCancelled";
}
}
const isIgnorePolicyCancellation = (cause: unknown): boolean =>
cause instanceof IgnorePolicyReadCancelled;
const ignorePolicyCause = (cause: unknown): unknown =>
cause instanceof IgnorePolicyReadCancelled
? cause.reason
: cause instanceof RegularFileReadFailure
? cause.operationCause
: cause;
const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true;
const addMarkedPatterns = (
@@ -102,30 +123,46 @@ const buildIgnored = async (
throw cause;
present = false;
}
signal?.throwIfAborted();
if (signal?.aborted === true)
throw new IgnorePolicyReadCancelled(signal.reason);
if (present) {
let text: string;
try {
text = await readRegularFileText(policyPath, { signal });
text = await readRegularFileText(policyPath, {
signal,
cancellationFailure: (reason) => new IgnorePolicyReadCancelled(reason),
});
} catch (cause: unknown) {
if (signal?.aborted === true && cause === signal.reason) throw cause;
if (cause instanceof IgnorePolicyReadCancelled) throw cause;
if (!(cause instanceof RegularFileCleanupFailure)) throw cause;
const retry = await retryRegularFileCleanup(cause, resources, signal);
const operationCancellation =
cause.outcome.kind === "failed"
? regularFileCancellation(cause)
: undefined;
if (retry.cleanup !== undefined) {
const primary =
retry.outcome.kind === "failed"
? retry.outcome.cause
: cause.cleanupCause;
const primaryCause = ignorePolicyCause(primary);
return err({
...failure(
signal?.aborted === true ? "cancelled" : "io",
`Reference source ignore policy could not be read at ${policyPath}: ${errorMessage(primary)}`,
isIgnorePolicyCancellation(primary) ||
operationCancellation !== undefined
? "cancelled"
: "io",
`Reference source ignore policy could not be read at ${policyPath}: ${errorMessage(primaryCause)}`,
),
cleanup: retry.cleanup,
cause: primary,
cause: primaryCause,
});
}
if (retry.outcome.kind === "failed") throw retry.outcome.cause;
if (retry.outcome.kind === "failed") {
if (operationCancellation !== undefined)
throw new IgnorePolicyReadCancelled(operationCancellation.reason);
throw retry.outcome.cause;
}
text = retry.outcome.value;
}
addMarkedPatterns(ignored, [text], "project-ignored");
@@ -166,12 +203,13 @@ export const prepareReferenceSourceImport = async (
secrets: ignore().add([...options.policy.secretPatterns]),
});
} catch (cause: unknown) {
const causeValue = ignorePolicyCause(cause);
return err({
...failure(
options.signal?.aborted === true ? "cancelled" : "io",
`Reference source ignore policy could not be read at ${join(root.value, ".gitignore")}: ${errorMessage(cause)}`,
isIgnorePolicyCancellation(cause) ? "cancelled" : "io",
`Reference source ignore policy could not be read at ${join(root.value, ".gitignore")}: ${errorMessage(causeValue)}`,
),
cause,
cause: causeValue,
});
}
});
+90 -23
View File
@@ -10,7 +10,9 @@ import {
import {
RegularFileCleanupFailure,
RegularFileCancelled,
regularFileCancellation,
withOwnedRegularFile,
OwnedRegularFileOperationFailure,
type RegularFileOperationOutcome,
} from "../filesystem/OwnedRegularFile.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
@@ -24,6 +26,18 @@ export interface RegularFileReadOptions {
readonly resources?: ArtifactResourceScope;
/** Runs on the opened file's own state before any byte is read; throw to refuse the read. */
readonly admit?: (state: Stats) => void;
/** Tags cancellation at the bounded reader's cooperative checkpoints. */
readonly cancellationFailure?: (reason: unknown) => Error;
}
/** Preserves a physical descriptor-read cause until its owner has finalized. */
export class RegularFileReadFailure extends OwnedRegularFileOperationFailure {
override readonly _tag = "RegularFileReadFailure";
constructor(operationCause: unknown) {
super(operationCause);
this.name = this._tag;
}
}
export class InvalidUtf8FileError extends TypeError {
@@ -52,6 +66,7 @@ export const retryRegularFileCleanup = async <Value>(
}> => {
const outcome: RegularFileReadOutcome<Value> =
failure.outcome.kind === "failed" &&
failure.operationFailure === undefined &&
failure.outcome.cause instanceof RegularFileOperationCancelled &&
!(signal?.aborted === true && failure.outcome.cause === signal.reason)
? { kind: "failed", cause: failure.outcome.cause.reason }
@@ -93,7 +108,10 @@ export const retryRegularFileCleanupFailures = async (
? retried.outcome.cause
: current.cleanupCause;
const nested =
current.outcome.kind === "failed" ? current.outcome.cause : undefined;
current.operationFailure === undefined &&
current.outcome.kind === "failed"
? current.outcome.cause
: undefined;
if (!(nested instanceof RegularFileCleanupFailure)) break;
current = nested;
}
@@ -153,21 +171,51 @@ export const withRegularFile = async <Value>(
// Caller reasons are opaque, including objects shaped like file failures.
// They do not transfer ownership of any descriptor to this operation.
if (signal?.aborted === true && cause === signal.reason) throw cause;
if (cause instanceof RegularFileOperationCancelled) throw cause.reason;
if (cause instanceof RegularFileReadFailure) throw cause;
if (
cause instanceof RegularFileCancelled &&
cause.owner.handle === admitted
)
throw cause.reason;
if (
!(cause instanceof RegularFileCleanupFailure) ||
resources === undefined
cause.owner.handle !== admitted
)
throw cause;
const retry = await retryRegularFileCleanup(cause, resources, signal);
if (retry.cleanup !== undefined) throw cause;
if (retry.outcome.kind === "failed") throw retry.outcome.cause;
return retry.outcome.value;
if (cause instanceof RegularFileCleanupFailure) {
if (resources === undefined) throw cause;
const retry = await retryRegularFileCleanup(cause, resources, signal);
if (retry.cleanup !== undefined) throw cause;
if (retry.outcome.kind === "failed") {
if (
cause.operationFailure === undefined &&
signal?.aborted === true &&
retry.outcome.cause === signal.reason
)
throw signal.reason;
const cancellation =
cause.operationFailure === undefined
? (regularFileCancellation(retry.outcome.cause) ??
regularFileCancellation(cause))
: undefined;
if (cancellation !== undefined) {
if (options.cancellationFailure !== undefined)
throw options.cancellationFailure(cancellation.reason);
throw cancellation.reason;
}
if (
cause.operationFailure !== undefined &&
options.cancellationFailure !== undefined
)
throw cause.operationFailure;
throw retry.outcome.cause;
}
return retry.outcome.value;
}
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined) {
if (options.cancellationFailure !== undefined)
throw options.cancellationFailure(cancellation.reason);
throw cancellation.reason;
}
if (options.cancellationFailure !== undefined)
throw new RegularFileReadFailure(cause);
throw cause;
}
};
const value = await (resources === undefined
@@ -175,6 +223,8 @@ export const withRegularFile = async <Value>(
: resources.run(operation));
// A retained close may have recovered after the descriptor owner's outcome
// was captured. Include that retry and the scope's finalization in cancellation.
if (signal?.aborted === true && options.cancellationFailure !== undefined)
throw options.cancellationFailure(signal.reason);
signal?.throwIfAborted();
return value;
};
@@ -221,19 +271,36 @@ const readRegularFileContent = <Value>(
path: string,
decode: (bytes: Buffer, state: Stats) => Value,
options: RegularFileReadOptions,
): Promise<Value> =>
withRegularFile(
): Promise<Value> => {
const operation = withRegularFile(
path,
async (handle, stats) => {
options.admit?.(stats);
const bytes = await readBoundedFileBytes(
handle,
stats.size,
options.signal,
);
if (bytes === undefined || bytes.length !== stats.size)
throw new RegularFileChangedError(path);
return decode(bytes, stats);
try {
options.admit?.(stats);
const bytes = await readBoundedFileBytes(handle, stats.size, {
...(options.signal === undefined ? {} : { signal: options.signal }),
cancellationFailure: (reason) =>
new RegularFileOperationCancelled(reason),
operationFailure: (cause) => new RegularFileReadFailure(cause),
});
if (bytes === undefined || bytes.length !== stats.size)
throw new RegularFileChangedError(path);
return decode(bytes, stats);
} catch (cause: unknown) {
if (
options.cancellationFailure !== undefined &&
!(cause instanceof RegularFileOperationCancelled) &&
!(cause instanceof RegularFileReadFailure)
)
throw new RegularFileReadFailure(cause);
throw cause;
}
},
options,
);
if (options.cancellationFailure !== undefined) return operation;
return operation.catch((cause: unknown) => {
if (cause instanceof RegularFileReadFailure) throw cause.operationCause;
throw cause;
});
};
+3 -6
View File
@@ -53,12 +53,9 @@ const readSkillFileState = async (
withRegularFile(
path,
async (handle, stats) => {
const bytes = await readBoundedFileBytes(
handle,
stats.size,
undefined,
stats.size,
);
const bytes = await readBoundedFileBytes(handle, stats.size, {
expectedSize: stats.size,
});
if (bytes === undefined || bytes.length !== stats.size)
throw new RegularFileChangedError(path);
return { bytes, mode: stats.mode & 0o7777 };
+128 -7
View File
@@ -6,10 +6,20 @@ import {
webListenersFixture,
} from "./WebRuntimeService.fixture.js";
import { err, ok } from "../domain/result.js";
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
expectDroppedPartial,
expectKeptPartial,
expectOwnerError,
} from "./ownerObservationExpectation.fixture.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { parseEvidence } from "../domain/evidence.js";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
it("rejects malformed input and prior cancellation without calling a provider", async () => {
const provider = recordingWebRuntimePort();
@@ -83,11 +93,7 @@ it("converts raw execution retained by provider cleanup failure into partial Evi
{ operation: "observe_web_execution", partialObservation: execution },
);
const result = await new WebRuntimeService({
identity: () => ({
id: "web-runtime",
name: "Runtime fixture",
version: "1",
}),
identity: runtimeIdentity,
observeExecution: async () => err(failure),
inspectEventListeners: async () => err(failure),
}).observe(args);
@@ -161,3 +167,118 @@ it("retains completed execution Evidence on late abort and keeps invalid output
});
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
});
const runtimeIdentity = () => ({
id: "web-runtime",
name: "Runtime fixture",
version: "1",
});
const callRuntime = (
operation: "observe" | "inspect",
failure: AnalysisError,
) => {
const service = new WebRuntimeService({
identity: runtimeIdentity,
observeExecution: async () => err(failure),
inspectEventListeners: async () => err(failure),
});
return operation === "observe"
? service.observe({
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "selected-page",
observation_ms: 5,
})
: service.inspect({
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "selected-page",
selector: "#selected",
});
};
it.each([
{
operation: "observe" as const,
partial: {
instrumentation: { cleanup: "unconfirmed" },
window: { requested_ms: 5 },
},
},
{
operation: "inspect" as const,
partial: {
selected_node: { selector: "#selected" },
listeners: [],
},
},
])(
"keeps $operation cancellation when the retained observation is incomplete",
async ({ operation, partial }) => {
const reason = { requested: `stop incomplete ${operation}` };
const failure = new AnalysisCancelledError(
operation === "observe"
? "observe_web_execution"
: "inspect_web_event_listeners",
{ cause: reason, partialObservation: partial as never },
);
expectKeptPartial(
expectOwnerError(await callRuntime(operation, failure), failure, reason),
partial,
);
},
);
it.each([
{
operation: "observe" as const,
partial: {
instrumentation: { cleanup: "unconfirmed" },
window: { requested_ms: Number.NaN },
},
reason: "Provider retained a non-JSON incomplete execution observation.",
},
{
operation: "inspect" as const,
partial: {
selected_node: { selector: "#selected" },
listeners: [undefined],
},
reason:
"Provider retained a non-JSON incomplete event-listener observation.",
},
])(
"drops a non-JSON $operation partial and keeps cancellation primary",
async ({ operation, partial, reason: outputReason }) => {
const reason = { requested: `stop unsafe ${operation}` };
const failure = new AnalysisCancelledError(
operation === "observe"
? "observe_web_execution"
: "inspect_web_event_listeners",
{ cause: reason, partialObservation: partial as never },
);
expectDroppedPartial(
expectOwnerError(await callRuntime(operation, failure), failure, reason),
outputReason,
);
},
);
it("drops a non-JSON execution partial and keeps timeout primary", async () => {
const failure = new AnalysisTimeoutError("observe_web_execution", 5, {
partialObservation: {
instrumentation: { samples: [undefined] },
window: { requested_ms: 5 },
} as never,
});
const error = expectOwnerError(
await callRuntime("observe", failure),
failure,
undefined,
);
expectDroppedPartial(
error,
"Provider retained a non-JSON incomplete execution observation.",
"provider_timeout",
);
expect(projectAnalysisError(error).category).toBe("timeout");
});
+53 -59
View File
@@ -18,7 +18,10 @@ import {
inspectWebEventListenersInputSchema,
webEventListenersSchema,
} from "../domain/webEventListeners.js";
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
import {
completeProviderEvidence,
keepIncompleteObservation,
} from "./ProviderOperationExecution.js";
/** Shared public workflows keep instrumentation and listener inspection independently selectable. */
export class WebRuntimeService {
@@ -38,35 +41,19 @@ export class WebRuntimeService {
);
if (!parsed.ok) return parsed;
const result = await this.provider.observeExecution(parsed.value, options);
if (!result.ok) {
const partial = result.error.partialObservation;
if (
partial !== undefined &&
!("evidence_id" in partial) &&
"instrumentation" in partial &&
"window" in partial
) {
const verifiedPartial = webExecutionSchema.safeParse(partial);
if (verifiedPartial.success) {
const evidence = this.evidence(
operation,
parsed.value,
verifiedPartial.data,
);
if (evidence.ok) result.error.partialObservation = evidence.value;
else if (result.error instanceof AnalysisCancelledError)
return evidence;
} else if (result.error instanceof AnalysisCancelledError)
return err(
new AnalysisOutputError(
operation,
"Provider retained a malformed completed execution observation.",
{ cause: verifiedPartial.error },
),
);
}
return result;
}
if (!result.ok)
return this.retainRuntimePartial(
result.error,
operation,
"Provider retained a non-JSON incomplete execution observation.",
(partial) => {
if (!("instrumentation" in partial) || !("window" in partial))
return undefined;
const verified = webExecutionSchema.safeParse(partial);
if (!verified.success) return undefined;
return this.evidence(operation, parsed.value, verified.data);
},
);
const verified = webExecutionSchema.safeParse(result.value);
if (!verified.success)
return err(new AnalysisOutputError(operation, verified.error.message));
@@ -100,35 +87,19 @@ export class WebRuntimeService {
parsed.value,
options,
);
if (!result.ok) {
const partial = result.error.partialObservation;
if (
partial !== undefined &&
!("evidence_id" in partial) &&
"selected_node" in partial &&
"listeners" in partial
) {
const verifiedPartial = webEventListenersSchema.safeParse(partial);
if (verifiedPartial.success) {
const evidence = this.evidence(
operation,
parsed.value,
verifiedPartial.data,
);
if (evidence.ok) result.error.partialObservation = evidence.value;
else if (result.error instanceof AnalysisCancelledError)
return evidence;
} else if (result.error instanceof AnalysisCancelledError)
return err(
new AnalysisOutputError(
operation,
"Provider retained malformed completed event-listener data.",
{ cause: verifiedPartial.error },
),
);
}
return result;
}
if (!result.ok)
return this.retainRuntimePartial(
result.error,
operation,
"Provider retained a non-JSON incomplete event-listener observation.",
(partial) => {
if (!("selected_node" in partial) || !("listeners" in partial))
return undefined;
const verified = webEventListenersSchema.safeParse(partial);
if (!verified.success) return undefined;
return this.evidence(operation, parsed.value, verified.data);
},
);
const verified = webEventListenersSchema.safeParse(result.value);
if (!verified.success)
return err(new AnalysisOutputError(operation, verified.error.message));
@@ -145,6 +116,29 @@ export class WebRuntimeService {
return this.evidence(operation, parsed.value, verified.data, options);
}
/** Promote a schema-valid partial, or keep a JSON snapshot of an incomplete one. */
private retainRuntimePartial(
error: AnalysisError,
operation: string,
nonJsonReason: string,
promote: (
partial: NonNullable<AnalysisError["partialObservation"]>,
) => Result<Evidence, AnalysisError> | undefined,
): Result<Evidence, AnalysisError> {
const partial = error.partialObservation;
if (partial !== undefined && !("evidence_id" in partial)) {
const evidence = promote(partial);
if (evidence !== undefined) {
if (evidence.ok) {
error.partialObservation = evidence.value;
return err(error);
}
if (error instanceof AnalysisCancelledError) return evidence;
}
}
return err(keepIncompleteObservation(error, operation, nonJsonReason));
}
private evidence(
operation: string,
input:
@@ -1,11 +1,15 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { analysisViewCaptureEvidence } from "../../../tests/fixtures/analysisView.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import { JsonInputPhysicalReadFailure } from "../JsonInputFile.js";
import { failFileReadAfterAbort } from "../../../tests/support/failFileReadAfterAbort.js";
import { holdFirstFileRead } from "../../../tests/support/holdFirstFileRead.js";
import {
estimateJsonValueBytes,
readEvidenceFileSource,
@@ -43,12 +47,23 @@ describe("evidence-file source", () => {
it("refuses a cancelled request before reading and reads within the caller's resource scope", async () => {
const controller = new AbortController();
controller.abort();
expect(
await readEvidenceFileSource("/nonexistent/capture.json", {
const reason = new JsonInputPhysicalReadFailure(
new Error("opaque caller reason with physical-failure shape"),
);
controller.abort(reason);
const cancelled = await readEvidenceFileSource(
"/nonexistent/capture.json",
{
signal: controller.signal,
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
},
);
expect(cancelled).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
if (cancelled.ok || !(cancelled.error instanceof AnalysisCancelledError))
throw new Error("Expected cancelled analysis-view read");
expect(cancelled.error.cause).toBe(reason);
const path = join(
await createTestTempDirectory("rea-evidence-file-source-"),
"capture.json",
@@ -62,3 +77,120 @@ describe("evidence-file source", () => {
expect(read.value).toEqual(JSON.parse(JSON.stringify(parent)));
});
});
describe("evidence-file read failure provenance", () => {
it("keeps a physical null read cause after the analysis-view request aborts", async () => {
const root = await createTestTempDirectory("rea-view-late-io-");
const path = join(root, "capture.json");
await writeFile(path, JSON.stringify(analysisViewCaptureEvidence()));
const controller = new AbortController();
await failFileReadAfterAbort(
path,
controller,
new Error("later abort"),
null,
);
try {
const result = await readEvidenceFileSource(path, {
signal: controller.signal,
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "EvidenceFileError", reason: "io", path },
});
if (result.ok) throw new Error("Expected physical JSON read failure");
expect(result.error.cause).toBeNull();
} finally {
vi.restoreAllMocks();
}
});
it("preserves identical physical and abort causes for analysis-view reads", async () => {
const root = await createTestTempDirectory("rea-view-identical-abort-");
const path = join(root, "capture.json");
await writeFile(path, JSON.stringify(analysisViewCaptureEvidence()));
const controller = new AbortController();
const physical = Object.assign(new Error("descriptor EIO"), {
code: "EIO",
});
await failFileReadAfterAbort(path, controller, physical, physical);
try {
const result = await readEvidenceFileSource(path, {
signal: controller.signal,
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "EvidenceFileError", reason: "io" },
});
if (result.ok) throw new Error("Expected physical JSON read failure");
expect(result.error.cause).toBe(physical);
} finally {
vi.restoreAllMocks();
}
});
it("preserves the caller cancellation reason during an analysis-view read", async () => {
const root = await createTestTempDirectory("rea-view-read-cancel-");
const path = join(root, "capture.json");
await writeFile(path, JSON.stringify(analysisViewCaptureEvidence()));
const reader = await holdFirstFileRead(path);
const controller = new AbortController();
const reason = new Error("stop analysis-view read");
try {
const pending = readEvidenceFileSource(path, {
signal: controller.signal,
});
await reader.firstRead;
controller.abort(reason);
reader.release();
const result = await pending;
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
operation: "inspect_analysis_view",
},
});
if (result.ok || !(result.error instanceof AnalysisCancelledError))
throw new Error("Expected cancelled analysis-view read");
expect(result.error.cause).toBe(reason);
} finally {
controller.abort(reason);
reader.release();
vi.restoreAllMocks();
}
});
it("does not treat a caller reason with physical-read shape as a physical read", async () => {
const root = await createTestTempDirectory("rea-view-opaque-cancel-");
const path = join(root, "capture.json");
await writeFile(path, JSON.stringify(analysisViewCaptureEvidence()));
const reader = await holdFirstFileRead(path);
const controller = new AbortController();
const reason = new JsonInputPhysicalReadFailure(
new Error("opaque caller reason with physical-failure shape"),
);
try {
const pending = readEvidenceFileSource(path, {
signal: controller.signal,
});
await reader.firstRead;
controller.abort(reason);
reader.release();
const result = await pending;
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError", cause: reason },
});
if (result.ok || !(result.error instanceof AnalysisCancelledError))
throw new Error(
"Expected caller cancellation, not physical I/O failure",
);
expect(result.error.cause).toBe(reason);
} finally {
controller.abort(reason);
reader.release();
vi.restoreAllMocks();
}
});
});
@@ -16,7 +16,6 @@ import {
} from "../../domain/evidenceErrors.js";
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import { readJsonFile } from "../JsonFiles.js";
import { RegularFileCleanupFailure } from "../RegularFileRead.js";
/** The request's cancellation and the scope that owns descriptors whose close failed. */
export interface EvidenceFileReadOptions {
@@ -79,25 +78,27 @@ export const readEvidenceFileSource = async (
): Promise<Result<Evidence, AnalysisError>> => {
const { signal, resources } = options;
if (signal?.aborted)
return err(new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION));
const loaded = await readJsonFile(path, signal, resources).catch(
(cause: unknown) => {
// A failed close transfers a live handle owner; preserve that failure.
if (
signal?.aborted === true &&
!(cause instanceof RegularFileCleanupFailure)
)
return err(
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
cause,
}),
);
throw cause;
},
);
if (!loaded.ok) return loaded;
return err(
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
cause: signal.reason,
}),
);
const loaded = await readJsonFile(path, signal, resources);
if (!loaded.ok) {
if (loaded.error instanceof AnalysisCancelledError)
return err(
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
cause: loaded.error.cause,
}),
);
return loaded;
}
if (signal?.aborted)
return err(new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION));
return err(
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
cause: signal.reason,
}),
);
const headroom = requireAuthenticationHeadroom(loaded.value, path);
if (!headroom.ok) return headroom;
try {
@@ -45,16 +45,22 @@ export class AndroidAnalysisService {
if (!executed.ok) return executed;
const execution = executed.value;
return completeProviderEvidence(
createEvidence(execution.subject ?? target.value, execution.provider, {
operation,
parameters: jsonObjectSchema.parse(request.data.input),
result: execution.result,
rawResult: execution.rawResult,
limitations: execution.limitations,
locations: execution.locations,
confidence:
operation === "inspect_android_method" ? "derived" : "observed",
}),
createEvidence(
execution.subject === undefined
? target.value
: (execution.subject ?? undefined),
execution.provider,
{
operation,
parameters: jsonObjectSchema.parse(request.data.input),
result: execution.result,
rawResult: execution.rawResult,
limitations: execution.limitations,
locations: execution.locations,
confidence:
operation === "inspect_android_method" ? "derived" : "observed",
},
),
options?.signal,
);
}
@@ -175,6 +175,84 @@ it("rebinds replayed target subjects while preserving child artifact subjects",
).toMatchObject(childSubject);
});
it("binds an omitted subject to the selected target in snapshot replay", () => {
const cache = new AnalysisSnapshotCache();
const target = ANALYSIS_SNAPSHOT_TARGET;
const profile = ANALYSIS_SNAPSHOT_PROFILE;
const relocatedTarget = { ...target, path: "/tmp/relocated-default-target" };
const parameters = { address: "0x2a00" };
const execution = createAnalysisExecution(
{ name: "default-bound" },
ANALYSIS_SNAPSHOT_PROVIDER,
{ analysisProfile: profile },
);
expect(execution).not.toHaveProperty("subject");
cache.record({
target,
profile,
operation: "procedure_info",
parameters,
execution,
});
const snapshot = cache.export(
target,
profile,
createEvidenceBundle([
createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
operation: "procedure_info",
parameters,
result: { name: "default-bound" },
analysisProfile: profile,
}),
]),
);
if (!snapshot.ok) throw snapshot.error;
expect(snapshot.value.entries[0]?.execution.subject?.path).toBe(target.path);
const reparsed = parseAnalysisSnapshot(
JSON.parse(serializeAnalysisSnapshot(snapshot.value)),
);
expect(reparsed.entries[0]?.execution.subject?.path).toBe(target.path);
cache.clear();
const imported = cache.import(
reparsed,
{ target: relocatedTarget, profile },
() => ok(0),
);
expect(imported.ok).toBe(true);
expect(
cache.lookup(relocatedTarget, profile, "procedure_info", parameters)
?.subject?.path,
).toBe(relocatedTarget.path);
});
it("preserves an explicitly unbound subject through snapshot replay", () => {
const cache = new AnalysisSnapshotCache();
const target = ANALYSIS_SNAPSHOT_TARGET;
const profile = ANALYSIS_SNAPSHOT_PROFILE;
const parameters = { address: "0x3000" };
cache.record({
target,
profile,
operation: "procedure_info",
parameters,
execution: createAnalysisExecution(
{ name: "unbound" },
ANALYSIS_SNAPSHOT_PROVIDER,
{ subject: null, analysisProfile: profile },
),
});
expect(
cache.lookup(
{ ...target, path: "/tmp/relocated-unbound-target" },
profile,
"procedure_info",
parameters,
)?.subject,
).toBeNull();
});
describe("analysis snapshot cache capacity", () => {
it("shares capacity across query kinds while allowing replacements and fresh partitions", () => {
const cache = new AnalysisSnapshotCache();
@@ -254,7 +254,7 @@ export class AnalysisSnapshotCache {
readonly limitations: readonly string[];
readonly relationshipCoverage?: AnalysisExecution["relationshipCoverage"];
readonly locations: AnalysisExecution["locations"];
readonly subject: AnalysisExecution["subject"];
readonly subject: NonNullable<AnalysisExecution["subject"]> | null;
};
}): void {
this.select(input.target, input.profile);
@@ -292,7 +292,7 @@ export class AnalysisSnapshotCache {
if (cached === undefined) return undefined;
const cachedSubject = cached.execution.subject;
const subject =
cachedSubject === null
cachedSubject == null
? null
: rebindEvidenceSubjectTargetPath(
{
@@ -328,7 +328,7 @@ export class AnalysisSnapshotCache {
relationshipCoverage: cached.execution.relationship_coverage,
}),
locations: cached.execution.locations,
subject,
subject: subject ?? null,
});
}
@@ -357,7 +357,20 @@ export class AnalysisSnapshotCache {
binding: snapshotBinding(profile),
operation,
parameters,
execution,
execution: {
...execution,
subject:
execution.subject === undefined
? {
path: target.path,
sha256: target.sha256,
format: target.format,
...(target.architecture === undefined
? {}
: { architecture: target.architecture }),
}
: execution.subject,
},
});
this.#entries.set(entry.query_id, entry);
}
+418 -13
View File
@@ -7,9 +7,10 @@ import {
} from "../../domain/analysisProfile.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../../domain/result.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { createEvidence } from "../../domain/evidence.js";
@@ -48,7 +49,19 @@ import {
} from "./BinarySessionExecution.js";
import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { profilelessNativeDispatchProfile } from "./ProfilelessNativeWorkflowSnapshot.js";
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
import { AnalysisSnapshotMismatchError } from "../../domain/evidenceErrors.js";
import {
resolveAppBundleResourceRoot,
type ResolvedAppBundleResourceRoot,
} from "../AppBundleExecutable.js";
import {
BUNDLE_RESOURCE_OPERATIONS,
type ActiveBundleResourceSession,
type BundleResourceClientFactory,
} from "./BundleResourceSession.js";
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
);
@@ -69,6 +82,56 @@ interface SessionBinding extends SessionRun {
nativeSourceAdmitted: boolean;
}
interface CleanupFailureTracker {
readonly error: AnalysisError | undefined;
readonly onFailure: (failure: AnalysisError) => void;
}
const trackCleanupFailures = (
signal: AbortSignal | undefined,
operation: string,
): CleanupFailureTracker => {
let error: AnalysisError | undefined;
let cleanupFailure: AnalysisError | undefined;
let cancellation: AnalysisCancelledError | undefined;
const observed = new Set<AnalysisError>();
return {
get error() {
return error;
},
onFailure: (failure) => {
if (
observed.has(failure) ||
(failure.cleanupPrimary !== undefined &&
observed.has(failure.cleanupPrimary))
)
return;
observed.add(failure);
if (cleanupFailure === undefined) {
if (isAborted(signal))
cancellation = new AnalysisCancelledError(operation, {
cause: signal?.reason,
});
cleanupFailure = failure;
} else {
cleanupFailure = analysisErrorWithCleanupFailure(
cleanupFailure,
failure,
operation,
);
}
error =
cancellation === undefined
? cleanupFailure
: analysisErrorWithCleanupFailure(
cancellation,
cleanupFailure,
operation,
);
},
};
};
/**
* Owns the single active target shared by CLI and MCP adapters.
*
@@ -94,6 +157,9 @@ export class BinarySession
readonly #calls = new Map<Promise<unknown>, number>();
readonly #promptCompletionControllers = new Set<AbortController>();
readonly #providerRouter: SessionProviderRouter;
readonly #bundleResourceFactory: BundleResourceClientFactory | undefined;
#activeBundleResources: ActiveBundleResourceSession | undefined;
#pendingBundleResourceCleanup: ActiveBundleResourceSession[] = [];
readonly #runtimeUnavailability = new Map<
string,
{ readonly reason: string }
@@ -103,9 +169,161 @@ export class BinarySession
constructor(
providerRouter: SessionProviderRouter,
records: InvestigationRecords = new InvestigationRecords(),
bundleResourceFactory?: BundleResourceClientFactory,
) {
super(records);
this.#providerRouter = providerRouter;
this.#bundleResourceFactory = bundleResourceFactory;
}
/** Open an app resource root without requiring CFBundleExecutable. */
openBundleResources(
path: string,
options: Pick<ExecutionOptions, "signal"> = {},
): Promise<Result<ResolvedAppBundleResourceRoot, AnalysisError>> {
return this.#serialize(async (admittedThrough) => {
await this.#drainCalls(admittedThrough);
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
}),
);
if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true)
return err(this.#externalMutationCleanupFailure);
if (this.#bundleResourceFactory === undefined)
return err(
new AnalysisCapabilityUnavailableError(
"bundle-resources",
"open_bundle_resources",
"No app resource reader is configured.",
),
);
const resolved = await resolveAppBundleResourceRoot(path);
if (!resolved.ok) return resolved;
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
}),
);
const pendingClosed = await this.#retryPendingCleanup();
if (!pendingClosed.ok) return pendingClosed;
const pendingResourcesClosed =
await this.#retryPendingBundleResourceCleanup();
if (!pendingResourcesClosed.ok) return pendingResourcesClosed;
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
}),
);
const runId = randomUUID();
let bundleClient: Awaited<
ReturnType<BundleResourceClientFactory["createClient"]>
>;
try {
bundleClient = await this.#bundleResourceFactory.createClient(
resolved.value,
);
} catch (cause: unknown) {
return err(
cause instanceof AnalysisError
? cause
: new ProviderAdapterError(
"bundle-resources",
"open_bundle_resources",
{ cause },
),
);
}
const candidate: ActiveBundleResourceSession = {
root: resolved.value,
client: bundleClient.client,
capabilities: bundleClient.capabilities,
runId,
};
const started = await candidate.client.execute("health", {}, options);
if (!started.ok) {
this.#pendingBundleResourceCleanup.push(candidate);
const closed = await this.#retryPendingBundleResourceCleanup();
return err(
closed.ok
? started.error
: analysisErrorWithCleanupFailure(
started.error,
closed.error,
"open_bundle_resources",
),
);
}
if (isAborted(options.signal)) {
const cancelled = new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
});
this.#pendingBundleResourceCleanup.push(candidate);
const closed = await this.#retryPendingBundleResourceCleanup();
return err(
closed.ok
? cancelled
: analysisErrorWithCleanupFailure(
cancelled,
closed.error,
"open_bundle_resources",
),
);
}
const cleanupFailures = trackCleanupFailures(
options.signal,
"open_bundle_resources",
);
const closed = await this.#closeActive(
{},
false,
cleanupFailures.onFailure,
);
if (!closed.ok) {
this.#pendingBundleResourceCleanup.push(candidate);
const candidateClosed = await this.#retryPendingBundleResourceCleanup(
undefined,
cleanupFailures.onFailure,
);
const primary = cleanupFailures.error ?? closed.error;
return err(
cleanupFailures.error ??
(candidateClosed.ok
? primary
: analysisErrorWithCleanupFailure(
primary,
candidateClosed.error,
"open_bundle_resources",
)),
);
}
if (isAborted(options.signal)) {
const cancelled = new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
});
this.#pendingBundleResourceCleanup.push(candidate);
const candidateClosed = await this.#retryPendingBundleResourceCleanup(
undefined,
cleanupFailures.onFailure,
);
return err(
cleanupFailures.error ??
(candidateClosed.ok
? cancelled
: analysisErrorWithCleanupFailure(
cancelled,
candidateClosed.error,
"open_bundle_resources",
)),
);
}
this.#activeBundleResources = candidate;
this.resetSnapshotInvalidation();
return resolved;
});
}
/**
@@ -123,7 +341,11 @@ export class BinarySession
): Promise<Result<BinaryTarget, AnalysisError> | undefined> {
const live = await this.#retainedClientLive(retained.client, options);
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
if (!live) return undefined;
if (options.snapshot !== undefined) {
const imported = this.importAnalysisSnapshot(options.snapshot);
@@ -158,6 +380,7 @@ export class BinarySession
/** External live state cannot be replayed by a direct or composed CLI workflow. */
allowsSnapshotReplay(operation: AnalysisOperation): boolean {
if (this.#activeBundleResources !== undefined) return false;
const capabilities = this.#active?.route.capabilities;
const descriptor = capabilities?.get(operation);
if (descriptor !== undefined) return descriptor.cachePolicy !== "live";
@@ -201,7 +424,13 @@ export class BinarySession
options: BinarySessionOpenOptions = {},
): Promise<Result<ResolvedSessionOpen, AnalysisError>> {
if (isAborted(options.signal))
return Promise.resolve(err(new AnalysisCancelledError("open_binary")));
return Promise.resolve(
err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
),
);
return resolveSessionTarget({
router: this.#providerRouter,
current: this.#active,
@@ -285,7 +514,11 @@ export class BinarySession
): Promise<Result<BinarySessionOpenOptions, AnalysisError>> {
if (options.loadSnapshot === undefined) return ok(options);
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const loaded = await options.loadSnapshot();
if (!loaded.ok) return loaded;
const { loadSnapshot: _loaded, ...rest } = options;
@@ -300,13 +533,21 @@ export class BinarySession
if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true)
return err(this.#externalMutationCleanupFailure);
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const resolved = await resolve();
if (!resolved.ok) return resolved;
const { target, route, sameTarget } = resolved.value;
const { profile } = route;
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const activeProfile = this.#active?.profile;
const sameProfile =
activeProfile === null || activeProfile === undefined
@@ -314,11 +555,22 @@ export class BinarySession
: profile !== null && analysisProfilesEqual(activeProfile, profile);
await this.#drainCalls(admittedThrough);
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const pendingClosed = await this.#retryPendingCleanup();
if (!pendingClosed.ok) return pendingClosed;
const pendingResourcesClosed =
await this.#retryPendingBundleResourceCleanup();
if (!pendingResourcesClosed.ok) return pendingResourcesClosed;
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const retained = sameTarget && sameProfile ? this.#active : undefined;
if (retained !== undefined) {
const fastPath = await this.#tryRetainedFastPath(
@@ -363,10 +615,43 @@ export class BinarySession
await this.#failedOpen(
run,
previous,
new AnalysisCancelledError("open_binary"),
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
),
);
}
const cleanupFailures = trackCleanupFailures(options.signal, "open_binary");
if (this.#activeBundleResources !== undefined) {
this.#pendingBundleResourceCleanup.push(this.#activeBundleResources);
this.#activeBundleResources = undefined;
const retiredResources = await this.#retryPendingBundleResourceCleanup(
undefined,
cleanupFailures.onFailure,
);
if (!retiredResources.ok) {
const primary = cleanupFailures.error ?? retiredResources.error;
const failed = await this.#failedOpen(
run,
previous,
primary,
cleanupFailures.onFailure,
);
return err(cleanupFailures.error ?? failed);
}
}
if (isAborted(options.signal)) {
const cancelled = new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
});
const failed = await this.#failedOpen(
run,
previous,
cancelled,
cleanupFailures.onFailure,
);
return err(cleanupFailures.error ?? failed);
}
const profilelessWorkflowProfile =
profile === null ? profilelessNativeDispatchProfile(route) : undefined;
this.#active = {
@@ -409,6 +694,14 @@ export class BinarySession
) => Promise<Result<null, AnalysisError>>,
): Promise<Result<Result<Value, AnalysisError>, AnalysisError>> {
return this.#serialize(async (admittedThrough) => {
if (this.#activeBundleResources !== undefined)
return err(
new AnalysisCapabilityUnavailableError(
"bundle-resources",
operationName,
"External mutation requires an active executable analysis target.",
),
);
if (isAborted(signal))
return err(new AnalysisCancelledError(operationName));
await this.#drainCalls(admittedThrough);
@@ -499,14 +792,20 @@ export class BinarySession
readonly retainProviderDocuments?: boolean;
},
clearRecords = true,
onCleanupFailure?: (failure: AnalysisError) => void,
): Promise<Result<null, AnalysisError>> {
const previous = this.#active;
const previousResources = this.#activeBundleResources;
this.#active = undefined;
this.#activeBundleResources = undefined;
if (previousResources !== undefined)
this.#pendingBundleResourceCleanup.push(previousResources);
const closed =
previous === undefined
? await this.#retryPendingCleanup(
options.progress,
options.retainProviderDocuments,
onCleanupFailure,
)
: await this.#retireClient(previous, {
...(options.progress === undefined
@@ -515,12 +814,20 @@ export class BinarySession
...(options.retainProviderDocuments === true
? { retainDocument: true }
: {}),
...(onCleanupFailure === undefined ? {} : { onCleanupFailure }),
});
if (clearRecords) this.clearSessionRecords();
this.#clearRuntimeAvailability();
const fileResources = await this.closeWriteResources();
if (!fileResources.ok) onCleanupFailure?.(fileResources.error);
const candidateResources = await this.#providerRouter.closeOwnedResources();
if (!candidateResources.ok) onCleanupFailure?.(candidateResources.error);
const bundleClosed = await this.#retryPendingBundleResourceCleanup(
options.progress,
onCleanupFailure,
);
const resourceFailures = [
...(bundleClosed.ok ? [] : [bundleClosed.error]),
...(fileResources.ok ? [] : [fileResources.error]),
...(candidateResources.ok ? [] : [candidateResources.error]),
];
@@ -544,22 +851,61 @@ export class BinarySession
return resourceFailure === undefined ? ok(null) : err(resourceFailure);
}
async #retryPendingBundleResourceCleanup(
progress?: ExecutionOptions["progress"],
onCleanupFailure?: (failure: AnalysisError) => void,
): Promise<Result<null, AnalysisError>> {
if (this.#pendingBundleResourceCleanup.length === 0) return ok(null);
const pending = this.#pendingBundleResourceCleanup;
const retained: ActiveBundleResourceSession[] = [];
let failure: AnalysisError | undefined;
for (const session of pending) {
const closed = await closeAnalysisClient(
session.client,
"bundle-resources",
{
...(progress === undefined ? {} : { progress }),
...(onCleanupFailure === undefined ? {} : { onCleanupFailure }),
},
);
if (!closed.ok) {
retained.push(session);
failure =
failure === undefined
? closed.error
: analysisErrorWithCleanupFailure(
failure,
closed.error,
"close_binary",
);
}
}
this.#pendingBundleResourceCleanup = retained;
return failure === undefined ? ok(null) : err(failure);
}
async #retireClient(
run: SessionRun,
options: Pick<ExecutionOptions, "progress"> & {
readonly retainDocument?: boolean;
readonly onCleanupFailure?: (failure: AnalysisError) => void;
} = {},
): Promise<Result<null, AnalysisError>> {
this.#pendingCleanup = {
run,
retainDocument: options.retainDocument === true,
};
return this.#retryPendingCleanup(options.progress);
return this.#retryPendingCleanup(
options.progress,
undefined,
options.onCleanupFailure,
);
}
async #retryPendingCleanup(
progress?: ExecutionOptions["progress"],
retainDocument?: boolean,
onCleanupFailure?: (failure: AnalysisError) => void,
): Promise<Result<null, AnalysisError>> {
let pending = this.#pendingCleanup;
if (pending === undefined) return ok(null);
@@ -573,6 +919,7 @@ export class BinarySession
{
...(progress === undefined ? {} : { progress }),
...(pending.retainDocument ? { retainDocument: true } : {}),
...(onCleanupFailure === undefined ? {} : { onCleanupFailure }),
},
);
if (closed.ok) this.#pendingCleanup = undefined;
@@ -583,8 +930,12 @@ export class BinarySession
run: SessionRun,
previous: SessionBinding | undefined,
primary: AnalysisError,
onCleanupFailure?: (failure: AnalysisError) => void,
): Promise<AnalysisError> {
const closed = await this.#retireClient(run);
const closed = await this.#retireClient(
run,
onCleanupFailure === undefined ? {} : { onCleanupFailure },
);
if (!closed.ok)
return analysisErrorWithCleanupFailure(
primary,
@@ -606,11 +957,22 @@ export class BinarySession
/** Describe the active binary session. */
status(options: { readonly signal?: AbortSignal } = {}): Promise<JsonValue> {
const run = this.#active ?? this.#pendingCleanup?.run;
const resourceRun = this.#activeBundleResources;
const run =
this.#active ??
resourceRun ??
this.#pendingBundleResourceCleanup[0] ??
this.#pendingCleanup?.run;
const client = run?.client;
return binarySessionStatus({
...options,
target: this.#active?.target,
...(resourceRun === undefined
? {}
: {
bundleResourceRoot: resourceRun.root,
resourceCapabilities: resourceRun.capabilities,
}),
route: this.#currentRoute(),
router: this.#providerRouter,
runtimeUnavailability: this.#runtimeUnavailability,
@@ -620,6 +982,7 @@ export class BinarySession
providerOperationHealth: client?.operationHealthSnapshot?.() ?? null,
cleanupPending:
this.#pendingCleanup !== undefined ||
this.#pendingBundleResourceCleanup.length > 0 ||
this.hasPendingFileResources ||
this.#providerRouter.hasPendingOwnedResources(),
});
@@ -632,6 +995,31 @@ export class BinarySession
: structuredClone(this.#active.target);
}
activeBundleResourceRoot(): ResolvedAppBundleResourceRoot | undefined {
const root = this.#activeBundleResources?.root;
return root === undefined ? undefined : structuredClone(root);
}
override exportAnalysisSnapshot() {
if (this.#activeBundleResources !== undefined)
return err(
new AnalysisSnapshotMismatchError(
"Analysis snapshots do not apply to a resource-only app session because it has no executable identity or replay binding.",
),
);
return super.exportAnalysisSnapshot();
}
override importAnalysisSnapshot(snapshot: AnalysisSnapshot) {
if (this.#activeBundleResources !== undefined)
return err(
new AnalysisSnapshotMismatchError(
"Analysis snapshots do not apply to a resource-only app session because it has no executable identity or replay binding.",
),
);
return super.importAnalysisSnapshot(snapshot);
}
protected activeAnalysisBinding() {
return this.#active;
}
@@ -723,6 +1111,21 @@ export class BinarySession
transition,
);
if (!transitioned.ok) return transitioned;
const resources = this.#activeBundleResources;
if (resources !== undefined) {
if (!BUNDLE_RESOURCE_OPERATIONS.has(name))
return err(
new AnalysisCapabilityUnavailableError(
"bundle-resources",
name,
"This operation requires an executable analysis target; the active session contains only app resource access.",
),
);
const result = await resources.client.execute(name, arguments_, options);
if (!result.ok) return result;
const execution = { ...result.value, subject: null };
return ok(execution);
}
const prepared = prepareSessionExecution({
active: this.#active,
operation: name,
@@ -783,7 +1186,9 @@ export class BinarySession
name !== "trace_dylib_resolution"
) {
const evidence = createEvidence(
execution.value.subject ?? active.target,
execution.value.subject === undefined
? active.target
: (execution.value.subject ?? undefined),
execution.value.provider,
{
operation: name,
@@ -0,0 +1,799 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, it } from "vitest";
import {
createAnalysisExecution,
type AnalysisClient,
type ProviderIdentity,
} from "../AnalysisProvider.js";
import { parseBinaryTarget } from "../BinaryTargetResolver.js";
import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
import { BinarySession } from "./BinarySession.js";
import { SessionProviderRouter } from "./SessionProviderRouter.js";
import { bundleResourceRootSchema } from "../../domain/apple/bundleResourceRoot.js";
import { AnalysisSnapshotMismatchError } from "../../domain/evidenceErrors.js";
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { err } from "../../domain/result.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import { createEvidence } from "../../domain/evidence.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { ok } from "../../domain/result.js";
import { createTestProviderRouter } from "../../../tests/fixtures/binarySession.js";
const identity: ProviderIdentity = {
id: "bundle-resource-fixture",
name: "Bundle resource fixture",
version: "1",
};
const binaryIdentity: ProviderIdentity = {
id: "fixture",
name: "Fixture analysis provider",
version: "1",
};
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
);
});
it("opens a resource-only bundle without executable identity and keeps snapshot operations inapplicable", async () => {
const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-session-"));
temporaryDirectories.push(rootPath);
await writeFile(
join(rootPath, "Info.plist"),
'<?xml version="1.0"?><plist><dict/></plist>',
);
let openedRoot: string | undefined;
let closed = false;
const client: AnalysisClient = {
execute: async (operation) => {
if (operation === "health")
return { ok: true, value: createAnalysisExecution(null, identity) };
return {
ok: true,
value: createAnalysisExecution(
{ operation, target_sha256: null },
identity,
),
};
},
close: async () => {
closed = true;
return { ok: true, value: null };
},
};
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
new InvestigationRecords(),
{
createClient: (root) => {
openedRoot = root.canonicalPath;
return { client, capabilities: [] };
},
},
);
const opened = await session.openBundleResources(rootPath);
expect(opened.ok).toBe(true);
if (!opened.ok) throw opened.error;
expect(opened.value.executableSha256).toBeNull();
expect(openedRoot).toBe(opened.value.canonicalPath);
expect(
bundleResourceRootSchema.parse({
caller_path: opened.value.callerPath,
canonical_path: opened.value.canonicalPath,
info_plist_path: opened.value.infoPlistPath,
layout: opened.value.layout,
executable_sha256: opened.value.executableSha256,
}).executable_sha256,
).toBeNull();
const result = await session.execute("inspect_plist", {});
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.subject).toBeNull();
const binaryOnly = await session.execute("binary_overview", {});
expect(binaryOnly.ok).toBe(false);
expect(await session.status()).toMatchObject({
open: true,
session_kind: "bundle_resources",
resource_root: { canonical_path: opened.value.canonicalPath },
});
const snapshot = session.exportAnalysisSnapshot();
expect(snapshot.ok).toBe(false);
if (!snapshot.ok)
expect(snapshot.error).toBeInstanceOf(AnalysisSnapshotMismatchError);
const imported = session.importAnalysisSnapshot({} as AnalysisSnapshot);
expect(imported.ok).toBe(false);
if (!imported.ok)
expect(imported.error).toBeInstanceOf(AnalysisSnapshotMismatchError);
const closedResult = await session.close();
expect(closedResult.ok).toBe(true);
expect(closed).toBe(true);
});
it("keeps the exact caller reason for cancellation before resource-root resolution", async () => {
const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-pre-cancel-"));
temporaryDirectories.push(rootPath);
const reason = { origin: "caller-pre-aborted" };
const controller = new AbortController();
controller.abort(reason);
let clientCreated = false;
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
new InvestigationRecords(),
{
createClient: () => {
clientCreated = true;
throw new Error("pre-aborted call must not create a client");
},
},
);
const result = await session.openBundleResources(rootPath, {
signal: controller.signal,
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError", cause: reason },
});
expect(clientCreated).toBe(false);
});
it("retains ownership when an unhealthy resource client cannot be closed", async () => {
const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-cleanup-"));
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let closeAttempts = 0;
const client: AnalysisClient = {
execute: async () => err(new ProviderAdapterError(identity.id, "health")),
close: async () => {
closeAttempts += 1;
return closeAttempts === 1
? err(new ProviderAdapterError(identity.id, "close_binary"))
: { ok: true, value: null };
},
};
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
new InvestigationRecords(),
{ createClient: () => ({ client, capabilities: [] }) },
);
const opened = await session.openBundleResources(rootPath);
expect(opened.ok).toBe(false);
expect(closeAttempts).toBe(1);
expect(await session.status()).toMatchObject({ cleanup_pending: true });
expect(await session.status()).toMatchObject({ open: false });
const closed = await session.close();
expect(closed.ok).toBe(true);
expect(closeAttempts).toBe(2);
expect(await session.status()).toMatchObject({ cleanup_pending: false });
});
it("cancels a resource switch during prior-client retirement and closes its candidate", async () => {
const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-cancel-switch-"));
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let closeStarted!: () => void;
const closing = new Promise<void>((resolve) => {
closeStarted = resolve;
});
let releaseClose!: () => void;
const closeGate = new Promise<void>((resolve) => {
releaseClose = resolve;
});
let created = 0;
let candidateCloseCalls = 0;
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
new InvestigationRecords(),
{
createClient: () => {
created += 1;
const candidate = created > 1;
return {
client: {
execute: async () => ({
ok: true,
value: createAnalysisExecution(null, identity),
}),
close: async () => {
if (candidate) candidateCloseCalls += 1;
else {
closeStarted();
await closeGate;
}
return { ok: true, value: null };
},
},
capabilities: [],
};
},
},
);
expect((await session.openBundleResources(rootPath)).ok).toBe(true);
const controller = new AbortController();
const reason = { phase: "prior-resource-retirement" };
const opening = session.openBundleResources(rootPath, {
signal: controller.signal,
});
await Promise.race([
closing,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("prior resource client close did not start")),
1_000,
),
),
]);
controller.abort(reason);
releaseClose();
const result = await opening;
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
if (!result.ok) expect(result.error.cause).toBe(reason);
expect(candidateCloseCalls).toBe(1);
expect(await session.status()).toMatchObject({ open: false });
expect(await session.close()).toMatchObject({ ok: true });
});
it("keeps cancellation primary when resource retirement fails and retries the owner", async () => {
const rootPath = await mkdtemp(
join(tmpdir(), "rea-resource-cancel-close-failure-"),
);
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let closeStarted!: () => void;
const closing = new Promise<void>((resolve) => {
closeStarted = resolve;
});
let releaseClose!: () => void;
const closeGate = new Promise<void>((resolve) => {
releaseClose = resolve;
});
let created = 0;
let oldCloseAttempts = 0;
let candidateCloseAttempts = 0;
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
new InvestigationRecords(),
{
createClient: () => {
created += 1;
const candidate = created > 1;
return {
client: {
execute: async () => ({
ok: true,
value: createAnalysisExecution(null, identity),
}),
close: async () => {
if (candidate) {
candidateCloseAttempts += 1;
return { ok: true, value: null };
}
oldCloseAttempts += 1;
if (oldCloseAttempts === 1) {
closeStarted();
await closeGate;
}
return oldCloseAttempts < 3
? err(new ProviderAdapterError(identity.id, "close_binary"))
: { ok: true, value: null };
},
},
capabilities: [],
};
},
},
);
expect((await session.openBundleResources(rootPath)).ok).toBe(true);
const controller = new AbortController();
const reason = { phase: "resource-close-failed" };
const opening = session.openBundleResources(rootPath, {
signal: controller.signal,
});
await Promise.race([
closing,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("resource close did not start")),
1_000,
),
),
]);
controller.abort(reason);
releaseClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource switch");
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(true);
expect(candidateCloseAttempts).toBe(1);
expect(oldCloseAttempts).toBe(2);
expect(await session.status()).toMatchObject({
open: false,
cleanup_pending: true,
});
expect(await session.close()).toMatchObject({ ok: true });
expect(oldCloseAttempts).toBe(3);
});
it("keeps an earlier resource close failure primary when a later cleanup is cancelled", async () => {
const rootPath = await mkdtemp(
join(tmpdir(), "rea-resource-close-before-cancel-"),
);
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let candidateCloseStarted!: () => void;
const candidateClosing = new Promise<void>((resolve) => {
candidateCloseStarted = resolve;
});
let releaseCandidateClose!: () => void;
const candidateCloseGate = new Promise<void>((resolve) => {
releaseCandidateClose = resolve;
});
let created = 0;
let oldCloseAttempts = 0;
const originalFailure = new ProviderAdapterError(identity.id, "close_binary");
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
new InvestigationRecords(),
{
createClient: () => {
created += 1;
const candidate = created > 1;
return {
client: {
execute: async () => ({
ok: true,
value: createAnalysisExecution(null, identity),
}),
close: async () => {
if (candidate) {
candidateCloseStarted();
await candidateCloseGate;
return { ok: true, value: null };
}
oldCloseAttempts += 1;
return oldCloseAttempts === 1
? err(originalFailure)
: { ok: true, value: null };
},
},
capabilities: [],
};
},
},
);
expect((await session.openBundleResources(rootPath)).ok).toBe(true);
const controller = new AbortController();
const reason = { phase: "after-physical-close-failure" };
const opening = session.openBundleResources(rootPath, {
signal: controller.signal,
});
await Promise.race([
candidateClosing,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("candidate close did not start")),
1_000,
),
),
]);
controller.abort(reason);
releaseCandidateClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource switch");
expect(result.error).toBe(originalFailure);
expect(result.error.cleanupPrimary).toBeUndefined();
expect(oldCloseAttempts).toBe(2);
expect(await session.close()).toMatchObject({ ok: true });
});
it("keeps cancellation primary when binary retirement fails before opening resources", async () => {
const rootPath = await mkdtemp(
join(tmpdir(), "rea-binary-close-cancel-resource-"),
);
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let closeStarted!: () => void;
const closing = new Promise<void>((resolve) => {
closeStarted = resolve;
});
let releaseClose!: () => void;
const closeGate = new Promise<void>((resolve) => {
releaseClose = resolve;
});
let binaryCloseAttempts = 0;
let resourceCloseAttempts = 0;
const binaryClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health" ? null : "fixture-name",
binaryIdentity,
),
}),
close: async () => {
binaryCloseAttempts += 1;
if (binaryCloseAttempts === 1) {
closeStarted();
await closeGate;
return err(new ProviderAdapterError(binaryIdentity.id, "close_binary"));
}
return { ok: true, value: null };
},
};
const resourceClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health" ? null : { operation, target_sha256: null },
identity,
{ subject: null },
),
}),
close: async () => {
resourceCloseAttempts += 1;
return { ok: true, value: null };
},
};
const session = new BinarySession(
createTestProviderRouter(() => binaryClient, {
resolveAnalysisProfile: async () =>
ok({
profile: createAnalysisProfile(
{ ...binaryIdentity, version: binaryIdentity.version ?? "1" },
{ fixture: true },
),
}),
}),
new InvestigationRecords(),
{
createClient: () => ({ client: resourceClient, capabilities: [] }),
},
);
expect((await session.open(process.execPath)).ok).toBe(true);
expect((await session.execute("address_name", {})).ok).toBe(true);
const controller = new AbortController();
const reason = { phase: "binary-retirement" };
const opening = session.openBundleResources(rootPath, {
signal: controller.signal,
});
await Promise.race([
closing,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("binary close did not start")), 1_000),
),
]);
controller.abort(reason);
releaseClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource open");
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(true);
expect(resourceCloseAttempts).toBe(1);
expect(await session.status()).toMatchObject({
open: false,
cleanup_pending: true,
});
expect(await session.close()).toMatchObject({ ok: true });
expect(binaryCloseAttempts).toBe(2);
});
it("keeps an earlier resource close failure primary when binary candidate cleanup is cancelled", async () => {
const rootPath = await mkdtemp(
join(tmpdir(), "rea-resource-close-before-binary-cancel-"),
);
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let candidateCloseStarted!: () => void;
const candidateClosing = new Promise<void>((resolve) => {
candidateCloseStarted = resolve;
});
let releaseCandidateClose!: () => void;
const candidateCloseGate = new Promise<void>((resolve) => {
releaseCandidateClose = resolve;
});
let resourceCloseAttempts = 0;
let binaryCloseAttempts = 0;
const originalFailure = new ProviderAdapterError(identity.id, "close_binary");
const resourceClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health" ? null : { operation, target_sha256: null },
identity,
{ subject: null },
),
}),
close: async () => {
resourceCloseAttempts += 1;
return resourceCloseAttempts === 1
? err(originalFailure)
: { ok: true, value: null };
},
};
const binaryClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health" ? null : "fixture-name",
binaryIdentity,
),
}),
close: async () => {
binaryCloseAttempts += 1;
if (binaryCloseAttempts === 1) {
candidateCloseStarted();
await candidateCloseGate;
}
return { ok: true, value: null };
},
};
const router = createTestProviderRouter(() => binaryClient);
const route = router.initialRoute();
const parsedTarget = await parseBinaryTarget(process.execPath);
if (!parsedTarget.ok) throw parsedTarget.error;
const session = new BinarySession(router, new InvestigationRecords(), {
createClient: () => ({ client: resourceClient, capabilities: [] }),
});
expect((await session.openBundleResources(rootPath)).ok).toBe(true);
const controller = new AbortController();
const reason = { phase: "after-resource-close-failure" };
const opening = session.openResolvedTarget(
{ target: parsedTarget.value, route },
{ signal: controller.signal },
);
await Promise.race([
candidateClosing,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("binary candidate close did not start")),
1_000,
),
),
]);
controller.abort(reason);
releaseCandidateClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed binary open");
expect(result.error).toBe(originalFailure);
expect(result.error.cleanupPrimary).toBeUndefined();
expect(binaryCloseAttempts).toBe(1);
expect(resourceCloseAttempts).toBe(1);
expect(await session.close()).toMatchObject({ ok: true });
expect(resourceCloseAttempts).toBe(2);
});
it("keeps an earlier binary close failure primary when candidate cleanup is cancelled", async () => {
const rootPath = await mkdtemp(
join(tmpdir(), "rea-binary-close-before-cancel-"),
);
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let candidateCloseStarted!: () => void;
const candidateClosing = new Promise<void>((resolve) => {
candidateCloseStarted = resolve;
});
let releaseCandidateClose!: () => void;
const candidateCloseGate = new Promise<void>((resolve) => {
releaseCandidateClose = resolve;
});
let binaryCloseAttempts = 0;
let resourceCloseAttempts = 0;
const originalFailure = new ProviderAdapterError(
binaryIdentity.id,
"close_binary",
);
const binaryClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health" ? null : "fixture-name",
binaryIdentity,
),
}),
close: async () => {
binaryCloseAttempts += 1;
return binaryCloseAttempts === 1
? err(originalFailure)
: { ok: true, value: null };
},
};
const resourceClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health" ? null : { operation, target_sha256: null },
identity,
{ subject: null },
),
}),
close: async () => {
resourceCloseAttempts += 1;
if (resourceCloseAttempts === 1) {
candidateCloseStarted();
await candidateCloseGate;
}
return { ok: true, value: null };
},
};
const session = new BinarySession(
createTestProviderRouter(() => binaryClient, {
resolveAnalysisProfile: async () =>
ok({
profile: createAnalysisProfile(
{ ...binaryIdentity, version: binaryIdentity.version ?? "1" },
{ fixture: true },
),
}),
}),
new InvestigationRecords(),
{
createClient: () => ({ client: resourceClient, capabilities: [] }),
},
);
expect((await session.open(process.execPath)).ok).toBe(true);
expect((await session.execute("address_name", {})).ok).toBe(true);
const controller = new AbortController();
const reason = { phase: "after-binary-close-failure" };
const opening = session.openBundleResources(rootPath, {
signal: controller.signal,
});
await Promise.race([
candidateClosing,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("resource candidate close did not start")),
1_000,
),
),
]);
controller.abort(reason);
releaseCandidateClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource open");
expect(result.error).toBe(originalFailure);
expect(result.error.cleanupPrimary).toBeUndefined();
expect(binaryCloseAttempts).toBe(1);
expect(resourceCloseAttempts).toBe(1);
expect(await session.close()).toMatchObject({ ok: true });
expect(binaryCloseAttempts).toBe(2);
});
it("cancels a binary switch during resource retirement and retains null-subject Evidence", async () => {
const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-cancel-binary-"));
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let resourceCloseStarted!: () => void;
const closing = new Promise<void>((resolve) => {
resourceCloseStarted = resolve;
});
let releaseResourceClose!: () => void;
const closeGate = new Promise<void>((resolve) => {
releaseResourceClose = resolve;
});
let resourceCloseAttempts = 0;
const binaryClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health"
? null
: { document: "fixture", procedure_count: 0, string_count: 0 },
binaryIdentity,
),
}),
close: async () => ({ ok: true, value: null }),
};
const resourceClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health" ? null : { operation, target_sha256: null },
identity,
{ subject: null },
),
}),
close: async () => {
resourceCloseAttempts += 1;
resourceCloseStarted();
if (resourceCloseAttempts === 1) {
await closeGate;
return err(new ProviderAdapterError(identity.id, "close_binary"));
}
return { ok: true, value: null };
},
};
const session = new BinarySession(
createTestProviderRouter(() => binaryClient, {
resolveAnalysisProfile: async () =>
ok({
profile: createAnalysisProfile(
{ ...binaryIdentity, version: binaryIdentity.version ?? "1" },
{ fixture: true },
),
}),
}),
new InvestigationRecords(),
{
createClient: () => ({ client: resourceClient, capabilities: [] }),
},
);
expect((await session.openBundleResources(rootPath)).ok).toBe(true);
const observed = await session.execute("inspect_plist", {});
expect(observed.ok).toBe(true);
if (!observed.ok) throw observed.error;
const recorded = session.recordEvidence(
createEvidence(undefined, identity, {
operation: "inspect_plist",
parameters: {},
result: observed.value.result,
}),
);
expect(recorded.ok).toBe(true);
const recordsBefore = session.exportEvidenceBundle().records;
expect(recordsBefore).toHaveLength(1);
const evidenceId = recordsBefore[0]?.evidence_id;
if (evidenceId === undefined) throw new Error("Expected retained Evidence");
const controller = new AbortController();
const reason = { phase: "resource-retirement" };
const opening = session.open(process.execPath, { signal: controller.signal });
await Promise.race([
closing,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("resource client close did not start")),
1_000,
),
),
]);
controller.abort(reason);
releaseResourceClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancelled binary open");
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(true);
expect(await session.status()).toMatchObject({ open: false });
expect(session.exportEvidenceBundle().records).toHaveLength(1);
expect(session.evidenceById(evidenceId)).toMatchObject({ subject: null });
expect(await session.status()).toMatchObject({ cleanup_pending: true });
expect(await session.close()).toMatchObject({ ok: true });
expect(resourceCloseAttempts).toBe(2);
});
@@ -3,6 +3,7 @@ import type { AnalysisProvider } from "../AnalysisProvider.js";
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
import { BinarySession } from "./BinarySession.js";
import { SessionProviderRouter } from "./SessionProviderRouter.js";
import type { BundleResourceClientFactory } from "./BundleResourceSession.js";
/**
* Compose one provider-neutral session from deep-provider selection and
@@ -11,8 +12,10 @@ import { SessionProviderRouter } from "./SessionProviderRouter.js";
export const composeBinarySession = (
registry: AnalysisProviderRegistry,
auxiliaryProviders: readonly AnalysisProvider[] = [],
bundleResourceFactory?: BundleResourceClientFactory,
): BinarySession =>
new BinarySession(
SessionProviderRouter.selectable(registry, auxiliaryProviders),
new InvestigationRecords(),
bundleResourceFactory,
);
@@ -134,6 +134,7 @@ export const bindExecutionTarget = (
: { architecture: target.architecture }),
};
if (
result.value.subject !== undefined &&
result.value.subject !== null &&
result.value.subject.sha256 !== target.sha256 &&
!isArtifactBundleSubject(operation, result.value.subject, target)
@@ -144,7 +145,9 @@ export const bindExecutionTarget = (
`${operation}:subject`,
),
);
return ok({ ...result.value, subject: result.value.subject ?? subject });
return result.value.subject === undefined
? ok({ ...result.value, subject })
: result;
};
const isArtifactBundleSubject = (
@@ -40,6 +40,39 @@ import { SessionProviderRouter } from "./SessionProviderRouter.js";
* `operationHealthSnapshot`, the way `HopperClient.operationHealth` reports a
* recorded process exit.
*/
it("binds omitted provider subjects to the selected target", async () => {
const { session } = openSession();
try {
const opened = await session.open(process.execPath);
expect(opened.ok).toBe(true);
const execution = await session.execute("address_name", {});
expect(execution.ok).toBe(true);
if (!execution.ok || !opened.ok) return;
expect(execution.value.subject).toEqual({
path: opened.value.path,
sha256: opened.value.sha256,
format: opened.value.format,
...(opened.value.architecture === undefined
? {}
: { architecture: opened.value.architecture }),
});
} finally {
await session.close();
}
});
it("preserves an explicitly unbound provider observation", async () => {
const { session } = openSession({ explicitNullSubject: true });
try {
expect((await session.open(process.execPath)).ok).toBe(true);
const execution = await session.execute("address_name", {});
expect(execution.ok).toBe(true);
if (execution.ok) expect(execution.value.subject).toBeNull();
} finally {
await session.close();
}
});
describe("BinarySession open liveness", () => {
it("retires a locally blocked owner even when its native health is idle", async () => {
const { session, deep } = openSession();
@@ -349,6 +382,7 @@ const openSession = (
operationHealth?: boolean;
failOperationInitially?: boolean;
beforeFirstClose?: () => Promise<void>;
explicitNullSubject?: boolean;
} = {},
): {
session: BinarySession;
@@ -383,6 +417,7 @@ const killableDeepProvider = (options: {
operationHealth?: boolean;
failOperationInitially?: boolean;
beforeFirstClose?: () => Promise<void>;
explicitNullSubject?: boolean;
}): {
provider: AnalysisProviderCandidate;
created: KillableChild[];
@@ -433,6 +468,10 @@ const killableDeepProvider = (options: {
createAnalysisExecution(
operation === "health" ? null : `liveness:${operation}`,
deepIdentity,
operation !== "health" &&
options.explicitNullSubject === true
? { subject: null }
: {},
),
),
)
@@ -13,6 +13,7 @@ import type { Result } from "../../domain/result.js";
import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import type { AnalysisSnapshotLoader } from "./BinarySessionOpen.js";
import type { WorkflowSnapshotRecordInput } from "./BinarySessionRecords.js";
import type { ResolvedAppBundleResourceRoot } from "../AppBundleExecutable.js";
import type {
AnalysisExecution,
@@ -76,6 +77,11 @@ export interface BinarySessionPort
readonly providerId?: AnalysisProviderSelector;
},
): Promise<Result<BinaryTarget, AnalysisError>>;
/** Open a resource-only app root without selecting or inventing an executable target. */
openBundleResources(
path: string,
options?: Pick<ExecutionOptions, "signal">,
): Promise<Result<ResolvedAppBundleResourceRoot, AnalysisError>>;
close(
options?: Pick<ExecutionOptions, "progress"> & {
readonly retainProviderDocuments?: boolean;
+49 -24
View File
@@ -1,4 +1,5 @@
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import type { ResolvedAppBundleResourceRoot } from "../AppBundleExecutable.js";
import { binaryTargetSummary } from "./BinaryTargetSummary.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js";
@@ -20,6 +21,8 @@ import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"
interface BinarySessionStatusInput {
readonly target: BinaryTarget | undefined;
readonly bundleResourceRoot?: ResolvedAppBundleResourceRoot;
readonly resourceCapabilities?: readonly CapabilityDescriptor[];
readonly route: SessionProviderRoute;
readonly router: Pick<SessionProviderRouter, "candidateStatuses">;
readonly runtimeUnavailability: ReadonlyMap<
@@ -37,6 +40,8 @@ interface BinarySessionStatusInput {
/** Project internal provider routing state into the caller-visible session status. */
export const binarySessionStatus = async ({
target,
bundleResourceRoot,
resourceCapabilities = [],
route,
router,
runtimeUnavailability,
@@ -47,7 +52,11 @@ export const binarySessionStatus = async ({
cleanupPending = false,
signal,
}: BinarySessionStatusInput): Promise<JsonValue> => {
const routeCapabilities = [...route.capabilities.values()]
const routeCapabilities = (
bundleResourceRoot === undefined
? [...route.capabilities.values()]
: [...resourceCapabilities]
)
.filter(({ operation }) => operation !== "inventory_artifact")
.sort((left, right) =>
compareUnicodeCodePoints(left.operation, right.operation),
@@ -64,29 +73,32 @@ export const binarySessionStatus = async ({
});
const capabilities = providerCapabilitySets(routeCapabilities);
const analysisProviderBinding =
route.binding === null
bundleResourceRoot !== undefined
? null
: {
provider: providerSummary(route.binding.identity),
selection_source: route.binding.selectionSource,
analysis_profile: structuredClone(route.binding.profile),
};
const analysisProviderCandidates = (
await router.candidateStatuses(route, signal)
).map((candidate) => ({
provider: providerSummary(candidate.provider),
availability: structuredClone(candidate.availability),
target_support: structuredClone(candidate.targetSupport),
selected: candidate.selected,
capabilities: providerCapabilitySets(
candidate.capabilities
.slice()
.filter(({ operation }) => operation !== "inventory_artifact")
.sort((left, right) =>
compareUnicodeCodePoints(left.operation, right.operation),
),
),
}));
: route.binding === null
? null
: {
provider: providerSummary(route.binding.identity),
selection_source: route.binding.selectionSource,
analysis_profile: structuredClone(route.binding.profile),
};
const analysisProviderCandidates =
bundleResourceRoot !== undefined
? []
: (await router.candidateStatuses(route, signal)).map((candidate) => ({
provider: providerSummary(candidate.provider),
availability: structuredClone(candidate.availability),
target_support: structuredClone(candidate.targetSupport),
selected: candidate.selected,
capabilities: providerCapabilitySets(
candidate.capabilities
.slice()
.filter(({ operation }) => operation !== "inventory_artifact")
.sort((left, right) =>
compareUnicodeCodePoints(left.operation, right.operation),
),
),
}));
const common = {
cleanup_pending: cleanupPending,
capabilities,
@@ -105,7 +117,20 @@ export const binarySessionStatus = async ({
),
};
return target === undefined
? { open: false, ...common }
? bundleResourceRoot === undefined
? { open: false, ...common }
: {
open: true,
...common,
session_kind: "bundle_resources",
resource_root: {
caller_path: bundleResourceRoot.callerPath,
canonical_path: bundleResourceRoot.canonicalPath,
info_plist_path: bundleResourceRoot.infoPlistPath,
layout: bundleResourceRoot.layout,
executable_sha256: bundleResourceRoot.executableSha256,
},
}
: {
open: true,
...common,
@@ -0,0 +1,106 @@
import { expect, it } from "vitest";
import { combineBundleResourceClients } from "./BundleResourceSession.js";
import {
createAnalysisExecution,
type AnalysisClient,
type ProviderIdentity,
} from "../AnalysisProvider.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { err } from "../../domain/result.js";
const identity: ProviderIdentity = {
id: "bundle-resource-close-fixture",
name: "Bundle resource close fixture",
version: "1",
};
it("retains both child close failures when a resource owner rejects", async () => {
const attempts: string[] = [];
const artifact: AnalysisClient = {
execute: async () => ({
ok: true,
value: createAnalysisExecution(null, identity),
}),
close: async () => {
attempts.push("artifact");
throw new Error("artifact close rejected");
},
};
const native: AnalysisClient = {
execute: async () => ({
ok: true,
value: createAnalysisExecution(null, identity),
}),
close: async () => {
attempts.push("native");
throw new Error("native close rejected");
},
};
const closed = await combineBundleResourceClients(artifact, native).close();
expect(attempts.sort()).toEqual(["artifact", "native"]);
expect(closed.ok).toBe(false);
if (!closed.ok) expect(closed.error.cleanupIncomplete).toBe(true);
});
it("reports the first mixed child failure before awaiting the other owner and retries both", async () => {
let artifactCloseAttempts = 0;
let nativeCloseAttempts = 0;
let nativeCloseStarted!: () => void;
const nativeStarted = new Promise<void>((resolve) => {
nativeCloseStarted = resolve;
});
let releaseNativeClose!: () => void;
const nativeCloseGate = new Promise<void>((resolve) => {
releaseNativeClose = resolve;
});
let cleanupFailureObserved!: (failure: Error) => void;
const failureObserved = new Promise<Error>((resolve) => {
cleanupFailureObserved = resolve;
});
const artifact: AnalysisClient = {
execute: async () => ({
ok: true,
value: createAnalysisExecution(null, identity),
}),
close: async () => {
artifactCloseAttempts += 1;
return artifactCloseAttempts === 1
? err(
new ProviderCleanupError(identity.id, ["artifact-reader"], {
reason: "artifact owner remains open",
}),
)
: { ok: true, value: null };
},
};
const native: AnalysisClient = {
execute: async () => ({
ok: true,
value: createAnalysisExecution(null, identity),
}),
close: async () => {
nativeCloseAttempts += 1;
if (nativeCloseAttempts === 1) {
nativeCloseStarted();
await nativeCloseGate;
}
return { ok: true, value: null };
},
};
const combined = combineBundleResourceClients(artifact, native);
const closing = combined.close({
onCleanupFailure: cleanupFailureObserved,
});
await Promise.all([nativeStarted, failureObserved]);
releaseNativeClose();
const first = await closing;
expect(first.ok).toBe(false);
if (first.ok) throw new Error("Expected mixed close failure");
expect(first.error.cleanupIncomplete).toBe(true);
expect(await combined.close()).toMatchObject({ ok: true });
expect(artifactCloseAttempts).toBe(2);
expect(nativeCloseAttempts).toBe(2);
});
@@ -0,0 +1,85 @@
import type {
AnalysisClient,
AnalysisOperation,
CapabilityDescriptor,
} from "../AnalysisProvider.js";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { err, ok } from "../../domain/result.js";
import type { ResolvedAppBundleResourceRoot } from "../AppBundleExecutable.js";
import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js";
export const BUNDLE_NATIVE_RESOURCE_OPERATIONS = new Set<AnalysisOperation>([
"inspect_plist",
]);
export const BUNDLE_ARTIFACT_RESOURCE_OPERATIONS = new Set<AnalysisOperation>([
"decode_interface_builder",
"inspect_asset_catalog",
"inspect_keyed_archive",
]);
export const BUNDLE_RESOURCE_OPERATIONS = new Set<AnalysisOperation>([
...BUNDLE_NATIVE_RESOURCE_OPERATIONS,
...BUNDLE_ARTIFACT_RESOURCE_OPERATIONS,
]);
/** Provider-specific owner of the four read-only app-resource operations. */
export interface BundleResourceClientFactory {
createClient(
root: ResolvedAppBundleResourceRoot,
): BundleResourceClientBinding | Promise<BundleResourceClientBinding>;
}
export interface BundleResourceClientBinding {
readonly client: AnalysisClient;
readonly capabilities: readonly CapabilityDescriptor[];
}
export interface ActiveBundleResourceSession {
readonly root: ResolvedAppBundleResourceRoot;
readonly client: AnalysisClient;
readonly capabilities: readonly CapabilityDescriptor[];
readonly runId: string;
}
/** Route each resource read to the owner that understands its format. */
export const combineBundleResourceClients = (
artifactClient: AnalysisClient,
nativeClient: AnalysisClient,
): AnalysisClient => ({
execute: (operation, parameters, options) => {
if (operation === "health")
return artifactClient.execute(operation, parameters, options);
if (!BUNDLE_RESOURCE_OPERATIONS.has(operation))
return Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
"bundle-resources",
operation,
"This session provides read-only app resource inspection only.",
),
),
);
return BUNDLE_NATIVE_RESOURCE_OPERATIONS.has(operation)
? nativeClient.execute(operation, parameters, options)
: artifactClient.execute(operation, parameters, options);
},
close: async (options) => {
const [artifact, native] = await Promise.all([
closeAnalysisClient(artifactClient, "bundle-resources", options),
closeAnalysisClient(nativeClient, "bundle-resources", options),
]);
if (!artifact.ok && !native.ok)
return err(
analysisErrorWithCleanupFailure(
artifact.error,
native.error,
"close_binary",
),
);
if (!artifact.ok) return artifact;
if (!native.ok) return native;
return ok(null);
},
});
@@ -1,4 +1,5 @@
import {
appendFile,
chmod,
mkdir,
readFile,
@@ -70,6 +71,58 @@ it("retains the admitted tarball descriptor and refuses a pathname replacement d
}
});
it("keeps tarball ownership when the admitted inode grows during output", async () => {
const root = await createTestTempDirectory("rea-tarball-growth-");
const files = await writeEnvironmentServeFiles(root);
const inputs = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
if (!inputs.ok) throw inputs.error;
const tarball = join(root, "package.tgz");
await writeFile(tarball, Buffer.alloc(2 * 1024 * 1024, 0x41));
const directory = join(root, "bundle");
const original = SafeOutputTree.prototype.write;
let grown = false;
const writer = vi
.spyOn(SafeOutputTree.prototype, "write")
.mockImplementation(async function (
this: SafeOutputTree,
...args: Parameters<SafeOutputTree["write"]>
) {
const [relative] = args;
if (!grown && relative === "envs/grafana-example/rea-agents.tgz") {
grown = true;
await appendFile(tarball, Buffer.alloc(1024 * 1024, 0x42));
}
return original.apply(this, args);
});
try {
const result = await exportEnvironmentBundle({
...inputs.value,
source: environmentServeSource(),
directory,
packageTarball: tarball,
});
expect(grown).toBe(true);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisOutputError",
operation: "environment-export",
reason:
"Extracted content exceeds the inventoried size: envs/grafana-example/rea-agents.tgz",
},
});
if (result.ok)
throw new Error("Expected the changed tarball to be refused");
expect(result.error.cleanup).toBeUndefined();
expect(result.error.message).not.toContain("invalidated");
} finally {
writer.mockRestore();
}
});
it("uses confirmed publication after the output owner's descriptor cleanup completes", async () => {
const root = await createTestTempDirectory("rea-bundle-published-");
const files = await writeEnvironmentServeFiles(root);
@@ -10,6 +10,7 @@ import {
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
import { ArtifactFileCleanupFailure } from "../../artifacts/ArtifactFileOperation.js";
import { RegularFileCleanupFailure } from "../../filesystem/OwnedRegularFile.js";
import { readFileHandleChunks } from "../../filesystem/readFileHandleChunks.js";
import { streamChunkToBuffer } from "../../artifacts/StreamBytes.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
@@ -51,10 +52,7 @@ const writeTarball = async (
): Promise<WrittenBundleFile> => {
const hash = createHash("sha256");
let observed = 0;
for await (const chunk of source.handle.createReadStream({
start: 0,
autoClose: false,
})) {
for await (const chunk of readFileHandleChunks(source.handle)) {
const bytes = streamChunkToBuffer(chunk);
observed += bytes.length;
if (observed > source.size) throw new RegularFileChangedError(source.path);
@@ -63,7 +61,7 @@ const writeTarball = async (
if (observed !== source.size) throw new RegularFileChangedError(source.path);
const written = await tree.write(
relative,
source.handle.createReadStream({ start: 0, autoClose: false }),
readFileHandleChunks(source.handle),
{
bytes: source.size,
sha256: hash.digest("hex"),
@@ -46,7 +46,7 @@ export class FirmwareAnalysisService {
const executed = await this.provider.execute(parsed.data, options);
if (!executed.ok) return executed;
const execution = executed.value;
if (execution.subject === null)
if (execution.subject == null)
return err(
new AnalysisOutputError(
operation,
@@ -1,6 +1,5 @@
import { expect, it } from "vitest";
import { classifyBrowserCompleteness } from "../../domain/browserCompleteness.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
@@ -12,14 +11,27 @@ import {
electronTargetListSchema,
inspectElectronPageInputSchema,
} from "../../domain/javascript/electronObservation.js";
import { emptyElectronPageCoverage } from "../../domain/javascript/electronPageInspection.fixture.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { err, ok } from "../../domain/result.js";
import type { ElectronObservationPort } from "./ElectronObservationPort.js";
import {
inspectElectronPage,
listElectronTargets,
} from "./ElectronObservationService.js";
import {
expectDroppedPartial,
expectKeptPartial,
expectOwnerError,
} from "../ownerObservationExpectation.fixture.js";
const identity = { id: "electron-fixture", name: "Fixture", version: "1" };
const pageProvider = (failure: AnalysisError): ElectronObservationPort => ({
identity: () => identity,
listTargets: async () =>
err(new AnalysisCancelledError("list_electron_targets")),
inspectPage: async () => err(failure),
});
const input = inspectElectronPageInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "electron-page",
@@ -40,33 +52,7 @@ const inspection = electronPageInspectionSchema.parse({
file_path: "/app/index.html",
attached: false,
},
capture_window: {
started_at: "2026-01-01T00:00:00.000Z",
ended_at: "2026-01-01T00:00:00.000Z",
observation_ms: 0,
},
completeness: classifyBrowserCompleteness({
policyFilteredSections: new Set(),
attachLimitedSections: new Set(),
truncatedSections: new Set(),
unavailableSections: new Set(),
excluded: [],
droppedEvents: {
scripts: 0,
network_requests: 0,
console_events: 0,
websocket_connections: 0,
websocket_frames: 0,
webmcp_tools: 0,
timeline_events: 0,
},
}),
frames: [],
dom: { total_nodes: 0, nodes: [] },
scripts: { total: 0, items: [] },
resources: [],
workers: [],
limitations: [],
...emptyElectronPageCoverage(),
});
it("converts a retained raw Electron inspection into partial Evidence", async () => {
@@ -77,13 +63,7 @@ it("converts a retained raw Electron inspection into partial Evidence", async ()
cleanup: "transport-closed",
},
});
const provider: ElectronObservationPort = {
identity: () => identity,
listTargets: async () =>
err(new AnalysisCancelledError("list_electron_targets")),
inspectPage: async () => err(failure),
};
const result = await inspectElectronPage(provider, input);
const result = await inspectElectronPage(pageProvider(failure), input);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe(failure);
@@ -116,13 +96,7 @@ it("converts a raw inspection retained by provider cleanup failure", async () =>
},
},
);
const provider: ElectronObservationPort = {
identity: () => identity,
listTargets: async () =>
err(new AnalysisCancelledError("list_electron_targets")),
inspectPage: async () => err(failure),
};
const result = await inspectElectronPage(provider, input);
const result = await inspectElectronPage(pageProvider(failure), input);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe(failure);
@@ -244,3 +218,43 @@ it.each(["list", "inspect"] as const)(
expect(providerCalls).toBe(0);
},
);
it("keeps cancellation when the retained Electron inspection is incomplete", async () => {
const reason = { requested: "stop before page inspection completed" };
const partial = {
kind: "electron-page-observation",
inspection: { target_id: "electron-page" },
cleanup: "incomplete",
};
const failure = new AnalysisCancelledError("inspect_electron_page", {
cause: reason,
partialObservation: partial as never,
});
const result = await inspectElectronPage(pageProvider(failure), input);
expectKeptPartial(expectOwnerError(result, failure, reason), partial);
});
it.each([
{ inspection: { requests: Number.NaN }, cleanup: "incomplete" as const },
{
inspection: { scripts: [undefined] },
cleanup: "transport-closed" as const,
},
])(
"drops a non-JSON Electron inspection and keeps cancellation primary",
async (unsafe) => {
const reason = { requested: "stop before page inspection completed" };
const failure = new AnalysisCancelledError("inspect_electron_page", {
cause: reason,
partialObservation: {
kind: "electron-page-observation",
...unsafe,
} as never,
});
const result = await inspectElectronPage(pageProvider(failure), input);
expectDroppedPartial(
expectOwnerError(result, failure, reason),
"Electron provider retained a non-JSON incomplete observation.",
);
},
);
@@ -18,7 +18,10 @@ import {
electronPageInspectionSchema,
electronTargetListSchema,
} from "../../domain/javascript/electronObservation.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
import {
completeProviderEvidence,
keepIncompleteObservation,
} from "../ProviderOperationExecution.js";
/** List local file pages exposed by the explicitly selected endpoint. */
export const listElectronTargets = async (
@@ -79,34 +82,32 @@ export const inspectElectronPage = async (
partial.kind === "electron-page-observation"
) {
const report = electronPageInspectionSchema.safeParse(partial.inspection);
if (!report.success) {
if (result.error instanceof AnalysisCancelledError)
return err(
new AnalysisOutputError(
"inspect_electron_page",
"Electron provider retained a malformed completed page inspection.",
{ cause: report.error },
),
);
if (report.success) {
const inspection = {
...report.data,
limitations: [
...report.data.limitations,
partial.cleanup === "transport-closed"
? "REA closed its CDP transport; this does not imply target shutdown."
: "CDP transport cleanup remains incomplete.",
],
} satisfies ElectronPageInspection;
result.error.partialObservation = createElectronEvidence(
"inspect_electron_page",
input,
inspection,
ready.value.identity(),
);
return result;
}
const inspection = {
...report.data,
limitations: [
...report.data.limitations,
partial.cleanup === "transport-closed"
? "REA closed its CDP transport; this does not imply target shutdown."
: "CDP transport cleanup remains incomplete.",
],
} satisfies ElectronPageInspection;
result.error.partialObservation = createElectronEvidence(
"inspect_electron_page",
input,
inspection,
ready.value.identity(),
);
}
return result;
return err(
keepIncompleteObservation(
result.error,
"inspect_electron_page",
"Electron provider retained a non-JSON incomplete observation.",
),
);
}
const report = electronPageInspectionSchema.safeParse(result.value);
if (!report.success)
@@ -343,8 +343,9 @@ export const reconstructJavaScriptArtifact = async (
const OPERATION = "analyze_javascript_application";
/**
* Resolve the caller-selected input. A missing or unreadable selection is a
* caller or host-permission failure, not a damaged artifact.
* Resolve the caller-selected input. A missing, looping, overlong, or
* unreadable selection is a caller or host-resolution failure, not a damaged
* artifact.
*/
const resolveSelectedInput = async (inputPath: string): Promise<string> => {
try {
@@ -366,10 +367,26 @@ const resolveSelectedInput = async (inputPath: string): Promise<string> => {
message: `No file or directory exists at the selected input path (${code}): ${inputPath}`,
},
]);
if (code === "ELOOP" || code === "ENAMETOOLONG")
throw new AnalysisInputError(OPERATION, { cause }, [
{
path: ["input_path"],
reason: "invalid_value",
message: selectedInputResolutionMessage(code, inputPath),
},
]);
throw cause;
}
};
const selectedInputResolutionMessage = (
code: "ELOOP" | "ENAMETOOLONG",
inputPath: string,
): string =>
code === "ENAMETOOLONG"
? `Selected input path exceeds the host path or component length limit (ENAMETOOLONG): ${inputPath}`
: `Selected input path could not be resolved because symbolic-link traversal looped or exceeded the host limit (ELOOP): ${inputPath}`;
/** The selected input's kind, checked against the caller's requested format. */
const resolveFormat = async (
path: string,
@@ -82,7 +82,7 @@ export class JavaScriptRecoveryService {
const result = javascriptRecoveryResultSchema.safeParse(
execution.value.result,
);
if (!result.success || execution.value.subject === null)
if (!result.success || execution.value.subject == null)
return err(
new AnalysisOutputError(
OPERATION,
@@ -14,6 +14,35 @@ import {
listJavaScriptRuntimeTargets,
observeJavaScriptRuntime,
} from "./JavaScriptRuntimeObservationService.js";
import {
expectDroppedPartial,
expectKeptPartial,
expectOutputError,
expectOwnerError,
expectPreemptedCancellation,
} from "../ownerObservationExpectation.fixture.js";
const observeCancelled = async (partial: unknown) => {
const reason = { requested: "stop before runtime observation completed" };
const failure = new AnalysisCancelledError("observe_javascript_runtime", {
cause: reason,
partialObservation: partial as never,
});
const result = await observeJavaScriptRuntime(
{
identity: () => ({ id: "test", name: "Test", version: "1" }),
listTargets: async () =>
err(new AnalysisCancelledError("list_javascript_runtime_targets")),
observe: async () => err(failure),
},
{
inspector_endpoint: "http://127.0.0.1:9229",
target_id: "target",
observation_ms: 0,
},
);
return { result, failure, reason };
};
it("preserves failed Inspector cleanup when cancellation occurs during observation", async () => {
const controller = new AbortController();
@@ -126,11 +155,7 @@ it("does not let late cancellation hide an invalid completed Inspector report",
},
{ inspector_endpoint: "http://127.0.0.1:9229" },
);
expect(output).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
if (!output.ok) expect(output.error).toBeInstanceOf(AnalysisOutputError);
expectOutputError(output);
const controller = new AbortController();
const late = await listJavaScriptRuntimeTargets(
@@ -147,11 +172,7 @@ it("does not let late cancellation hide an invalid completed Inspector report",
{ inspector_endpoint: "http://127.0.0.1:9229" },
{ signal: controller.signal },
);
expect(late).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
if (!late.ok) expect(late.error).toBeInstanceOf(AnalysisOutputError);
expectOutputError(late);
});
it("converts a raw runtime observation retained by cleanup failure", async () => {
@@ -309,11 +330,23 @@ it.each(["list", "observe"] as const)(
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
}
expect(providerCalls).toBe(0);
expectPreemptedCancellation(result, reason, providerCalls);
},
);
it("keeps cancellation when the retained runtime observation is incomplete", async () => {
const partial = { runtime: "partial", scripts: [] };
const { result, failure, reason } = await observeCancelled(partial);
expectKeptPartial(expectOwnerError(result, failure, reason), partial);
});
it.each([{ scripts: [undefined] }, { elapsed_ms: Number.NaN }])(
"drops a non-JSON runtime partial and keeps cancellation primary",
async (partial) => {
const { result, failure, reason } = await observeCancelled(partial);
expectDroppedPartial(
expectOwnerError(result, failure, reason),
"Inspector retained a non-JSON incomplete observation.",
);
},
);
@@ -13,7 +13,10 @@ import type {
ObserveJavaScriptRuntimeInput,
} from "../../domain/javascript/javascriptRuntimeObservation.js";
import { err, ok, type Result } from "../../domain/result.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
import {
completeProviderEvidence,
keepIncompleteObservation,
} from "../ProviderOperationExecution.js";
import {
javascriptRuntimeObservationSchema,
javascriptRuntimeTargetListSchema,
@@ -76,15 +79,7 @@ export const observeJavaScriptRuntime = async (
!("kind" in partial)
) {
const report = javascriptRuntimeObservationSchema.safeParse(partial);
if (!report.success && result.error instanceof AnalysisCancelledError)
return err(
new AnalysisOutputError(
"observe_javascript_runtime",
"Inspector retained a malformed completed runtime observation.",
{ cause: report.error },
),
);
if (report.success)
if (report.success) {
result.error.partialObservation =
createJavaScriptRuntimeObservationEvidence(
"observe_javascript_runtime",
@@ -92,8 +87,16 @@ export const observeJavaScriptRuntime = async (
report.data,
ready.value.identity(),
);
return result;
}
}
return result;
return err(
keepIncompleteObservation(
result.error,
"observe_javascript_runtime",
"Inspector retained a non-JSON incomplete observation.",
),
);
}
const report = javascriptRuntimeObservationSchema.safeParse(result.value);
if (!report.success)
@@ -172,6 +172,56 @@ it("continues to project an ordinary member access as a property read", () => {
);
});
it("keeps a directly returned or passed property in backward provenance", () => {
const source = `
const settings = { index: "products" };
function viaLocal() {
const value = settings.index;
return value;
}
function viaMember() {
return settings.index;
}
function viaParen() {
return (settings.index);
}
const arrow = () => settings.index;
function take(value) { return value; }
function added() { return settings.index + "!"; }
const a = viaLocal();
const b = viaMember();
const direct = settings.index;
const c = viaParen();
const d = arrow();
const e = take(settings.index);
const g = take((settings.index));
const kept = settings.index;
const local = take(kept);
const concatenated = added();
`;
const graph = graphFor(source);
const provenance = (binding: string, candidates: boolean) => {
const node = graph.nodes.find(
(item) => item.kind === "binding" && item.label === binding,
);
if (node === undefined) throw new Error(`missing ${binding}`);
const trace = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: node.node_id },
direction: "backward-provenance",
include_ambiguous_dynamic_edges: candidates,
});
return trace.nodes.some(
(item) => item.kind === "literal" && item.properties.value === "products",
);
};
for (const binding of ["a", "b", "direct", "c", "d", "e", "g", "local"]) {
expect(provenance(binding, false), binding).toBe(true);
expect(provenance(binding, true), `${binding} candidates`).toBe(true);
}
expect(provenance("concatenated", false)).toBe(false);
expect(provenance("concatenated", true)).toBe(false);
});
it("keeps dynamic calls explicit and returns complete deterministic results", () => {
const graph = graphFor(`
const handlers = { ready() { return 1; } };
@@ -60,6 +60,7 @@ import {
projectSemanticFrontiers,
projectSemanticPromises,
projectSemanticReturnValues,
semanticObjectReadOccurrence,
type SemanticFlowProjectionContext,
} from "./JavaScriptSemanticGraphFlowProjection.js";
import {
@@ -828,6 +829,15 @@ const projectCalls = (context: FileContext): void => {
? "resolved"
: "candidate",
});
addRelation(context.state, {
source: semanticObjectReadOccurrence(
context,
argument.valueExpressionLocation,
),
target: argumentNode,
relation: "aliases",
resolution: "resolved",
});
}
}
for (const flow of context.ir.argumentFlows) {
@@ -1,4 +1,5 @@
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
import { javaScriptSemanticNodeId } from "../../domain/javascript/javascriptSemanticGraph.js";
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
@@ -190,6 +191,57 @@ const addPromiseUnknown = (
});
};
interface ObjectReadIndex {
readonly state: SemanticGraphProjectionState;
readonly nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
}
const objectReadIndexes = new WeakMap<JavaScriptSemanticIr, ObjectReadIndex>();
const objectReadRangeKey = (location: JavaScriptSourceRange): string =>
`${String(location.start.line)}:${String(location.start.column)}:${String(location.end.line)}:${String(location.end.column)}`;
/**
* The object projection owns a member read's occurrence. Index it once per
* file so return and argument flow can connect that expression without
* scanning every property read again.
*/
const objectReadOccurrences = (
context: SemanticFlowProjectionContext,
): ReadonlyMap<string, JavaScriptSemanticGraphNode> => {
const cached = objectReadIndexes.get(context.ir);
if (cached !== undefined && cached.state === context.state)
return cached.nodes;
const nodes = new Map<string, JavaScriptSemanticGraphNode>();
for (const operation of context.ir.objectOperations) {
if (operation.kind !== "read") continue;
const node = context.state.nodes.get(
javaScriptSemanticNodeId({
kind: "expression",
identity: {
artifact_sha256: context.file.sha256,
module_path: context.file.path,
source_range: operation.location,
role_key: operation.objectOperationId,
},
}),
);
if (node !== undefined)
nodes.set(objectReadRangeKey(operation.location), node);
}
objectReadIndexes.set(context.ir, { state: context.state, nodes });
return nodes;
};
/** Return the retained read occurrence for one unwrapped member expression. */
export const semanticObjectReadOccurrence = (
context: SemanticFlowProjectionContext,
location: JavaScriptSourceRange | null,
): JavaScriptSemanticGraphNode | undefined =>
location === null
? undefined
: objectReadOccurrences(context).get(objectReadRangeKey(location));
/** Link direct return expressions to their retained semantic references. */
export const projectSemanticReturnValues = (
context: SemanticFlowProjectionContext,
@@ -212,6 +264,15 @@ export const projectSemanticReturnValues = (
? "resolved"
: "candidate",
});
addSemanticGraphRelation(context.state, {
source: semanticObjectReadOccurrence(
context,
site.valueExpressionLocation,
),
target: returnNode,
relation: "aliases",
resolution: "resolved",
});
}
};
@@ -21,10 +21,22 @@ export const inspectNativeDispatch = async (
analysis: AnalysisOperationPort,
maxRecords: number,
signal?: AbortSignal,
objcClassImportAddresses?: readonly string[],
objcClassImportAddressesComplete?: boolean,
): EnhancedResult => {
const input = {
max_records: maxRecords,
...(objcClassImportAddresses === undefined
? {}
: {
objc_class_import_addresses: [...objcClassImportAddresses],
objc_class_import_addresses_complete:
objcClassImportAddressesComplete ?? true,
}),
};
const binaryMetadata = await analysis.execute(
NATIVE_DISPATCH_METADATA_SOURCES.binary,
{ max_records: maxRecords },
input,
signal === undefined ? {} : { signal },
);
if (binaryMetadata.ok)
@@ -59,20 +71,32 @@ export const inspectNativeDispatch = async (
"provider returned an invalid inventory",
),
);
const result = inspectNativeDispatchMetadata(
names.data.map((entry) => ({
address: entry.address,
name: "value" in entry ? entry.value : entry.name,
})),
maxRecords,
);
if (objcClassImportAddresses !== undefined) {
result.objc_class_import_bindings = [];
result.objc_class_import_binding_coverage = {
facet: "objc_class_import_bindings",
status: "unsupported",
reason: "Native Mach-O pointer binding inspection is unavailable.",
examined: 0,
decoded: 0,
};
}
return ok(
jsonValueSchema.parse(
nativeDispatchMetadataResultSchema.parse({
target_sha256: execution.value.subject?.sha256 ?? null,
target_architecture: execution.value.subject?.architecture ?? null,
provider: execution.value.provider,
analysis_profile_digest:
execution.value.analysisProfile?.digest ?? null,
result: inspectNativeDispatchMetadata(
names.data.map((entry) => ({
address: entry.address,
name: "value" in entry ? entry.value : entry.name,
})),
maxRecords,
),
result,
}),
),
);
+1 -1
View File
@@ -113,7 +113,7 @@ export const traceNativeValues = async (
continue;
}
const parsed = functionDossierSchema.safeParse(execution.value.result);
if (!parsed.success || execution.value.subject === null)
if (!parsed.success || execution.value.subject == null)
return err(
new AnalysisOutputError(
"trace_native_values",
@@ -0,0 +1,72 @@
import { expect } from "vitest";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { Result } from "../domain/result.js";
/** Assert the service returned the same owner error and its cause. */
export const expectOwnerError = <T>(
result: Result<T, AnalysisError>,
failure: AnalysisError,
cause: unknown,
): AnalysisError => {
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the owner failure");
expect(result.error).toBe(failure);
expect(result.error.cause).toBe(cause);
return result.error;
};
/** A JSON-safe incomplete observation stays on the owner error. */
export const expectKeptPartial = (
error: AnalysisError,
partial: unknown,
code: "cancelled" | "provider_timeout" = "cancelled",
): void => {
expect(error.partialObservation).toEqual(partial);
expect(error.secondaryFailures).toHaveLength(0);
expect(projectAnalysisError(error)).toMatchObject({
code,
details: { partial_observation: partial },
});
};
/** A completed value failed output validation, including after a late abort. */
export const expectOutputError = (
result: Result<unknown, AnalysisError>,
): void => {
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
};
/** Cancellation won before the provider ran. */
export const expectPreemptedCancellation = (
result: Result<unknown, AnalysisError>,
cause: unknown,
providerCalls: number,
): void => {
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(cause);
expect(providerCalls).toBe(0);
};
/** A non-JSON partial is cleared and recorded as a secondary output failure. */
export const expectDroppedPartial = (
error: AnalysisError,
reason: string,
code: "cancelled" | "provider_timeout" = "cancelled",
): void => {
expect(error.partialObservation).toBeUndefined();
expect(error.secondaryFailures).toMatchObject([
{ _tag: "AnalysisOutputError", reason },
]);
expect(projectAnalysisError(error).code).toBe(code);
};
@@ -0,0 +1,28 @@
import { expect, it } from "vitest";
import { PcapAnalysisService } from "./PcapAnalysisService.js";
it("preserves the exact caller reason when pcap admission is already aborted", async () => {
const reason = Object.freeze({ requested: "stop" });
const controller = new AbortController();
controller.abort(reason);
let calls = 0;
const service = new PcapAnalysisService({
inspectAvailability: () => Promise.reject(new Error("unused")),
close: () => Promise.resolve(),
execute: () => {
calls += 1;
return Promise.reject(new Error("provider must not run"));
},
});
const result = await service.execute(
"inspect_pcap_client",
{},
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
expect(calls).toBe(0);
});
+5 -1
View File
@@ -24,7 +24,11 @@ export class PcapAnalysisService {
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError(operation));
return err(
new AnalysisCancelledError(operation, {
cause: options.signal.reason,
}),
);
const request = pcapRequestSchema.safeParse({ operation, input });
if (!request.success)
return err(new AnalysisInputError(operation, { cause: request.error }));
+10 -11
View File
@@ -238,17 +238,16 @@ const readJson = async (
operation: "capture_process_scenario" | "compare_process_captures",
signal?: AbortSignal,
): Promise<unknown> => {
if (signal?.aborted) throw new AnalysisCancelledError(operation);
const loaded = await readJsonFile(path, signal).catch((cause: unknown) => {
if (
signal?.aborted === true &&
!(cause instanceof RegularFileCleanupFailure)
)
throw new AnalysisCancelledError(operation, { cause });
throw cause;
});
if (signal?.aborted) throw new AnalysisCancelledError(operation);
if (loaded.ok) return loaded.value;
if (signal?.aborted)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
const loaded = await readJsonFile(path, signal);
if (!loaded.ok && loaded.error instanceof AnalysisCancelledError)
throw new AnalysisCancelledError(operation, { cause: loaded.error.cause });
if (loaded.ok) {
if (signal?.aborted)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
return loaded.value;
}
if (loaded.error._tag !== "EvidenceFileError") throw loaded.error;
if (loaded.error.reason === "io") throw loaded.error;
const detail = describeValidationFailure(loaded.error.cause);
@@ -250,7 +250,9 @@ export class ReverseEngineeringService {
signal?: AbortSignal,
): Promise<Result<Evidence, AnalysisError>> {
if (signal?.aborted)
return Promise.resolve(err(new AnalysisCancelledError(operation)));
return Promise.resolve(
err(new AnalysisCancelledError(operation, { cause: signal.reason })),
);
if (this.#closing)
return Promise.resolve(
err(
@@ -311,7 +313,10 @@ export class ReverseEngineeringService {
signal?: AbortSignal,
): Promise<Result<Evidence, AnalysisError>> {
const { executable: command, arguments: args } = profile;
if (signal?.aborted) return err(new AnalysisCancelledError(operation));
if (signal?.aborted)
return err(
new AnalysisCancelledError(operation, { cause: signal.reason }),
);
const cleanup = await this.retryCleanup(operation);
if (!cleanup.ok) return cleanup;
const absolutePath =
@@ -322,8 +327,12 @@ export class ReverseEngineeringService {
digest = await this.#digest(absolutePath, signal, this.#stat);
} catch (cause) {
const code = commandFailureCode(cause);
if (signal?.aborted)
return err(new AnalysisCancelledError(operation, { cause }));
if (preflightCancelled(signal, cause, code))
return err(
new AnalysisCancelledError(operation, {
cause: callerCancellationCause(signal, cause),
}),
);
if (code === "EACCES" || code === "EPERM") {
const deniedPath = absolutePath === undefined ? command : absolutePath;
return err(
@@ -464,9 +473,9 @@ export class ReverseEngineeringService {
code === "ENOENT" ||
code === "ERR_PROVIDER_LAUNCH")
) {
if (signal?.aborted || code === "ABORT_ERR")
if (commandCancelled(signal, cause, code))
earlyFailure = new AnalysisCancelledError(operation, {
cause,
cause: callerCancellationCause(signal, cause),
...ownedCleanupOptions(cause),
});
else if (code === "ETIMEDOUT")
@@ -520,9 +529,9 @@ export class ReverseEngineeringService {
: null,
signal: capture.signal,
};
if (signal?.aborted || code === "ABORT_ERR")
if (commandCancelled(signal, cause, code))
failure = new AnalysisCancelledError(operation, {
cause,
cause: callerCancellationCause(signal, cause),
capturedOutput,
...ownedCleanupOptions(cause),
});
@@ -605,6 +614,7 @@ export class ReverseEngineeringService {
}
if (signal?.aborted && failure === undefined) {
failure = new AnalysisCancelledError(operation, {
cause: signal.reason,
capturedOutput: {
stdout,
stderr,
@@ -972,6 +982,48 @@ const digestFile = async (
});
};
const PHYSICAL_FAILURE_CODES = new Set([
"EACCES",
"EPERM",
"ETIMEDOUT",
"ENOENT",
"ENOTDIR",
"EINVAL",
"ELOOP",
"ENAMETOOLONG",
"E2BIG",
"EBUSY",
"ERR_PROVIDER_LAUNCH",
]);
/**
* A late abort does not replace a recognized physical failure. Other rejections
* observed while the caller signal is aborted remain cancellation. A bare
* `ABORT_ERR` is not cancellation until the command boundary, which already
* used that code for an aborted provider process.
*/
const preflightCancelled = (
signal: AbortSignal | undefined,
cause: unknown,
code: string | number | undefined,
): boolean => {
if (cause instanceof AnalysisCancelledError) return true;
if (signal?.aborted !== true) return false;
if (cause === signal.reason) return true;
return typeof code !== "string" || !PHYSICAL_FAILURE_CODES.has(code);
};
const commandCancelled = (
signal: AbortSignal | undefined,
cause: unknown,
code: string | number | undefined,
): boolean => code === "ABORT_ERR" || preflightCancelled(signal, cause, code);
const callerCancellationCause = (
signal: AbortSignal | undefined,
cause: unknown,
): unknown => (signal?.aborted === true ? signal.reason : cause);
const commandFailureCode = (cause: unknown): string | number | undefined => {
if (typeof cause !== "object" || cause === null) return undefined;
const code: unknown = Reflect.get(cause, "code");
@@ -145,3 +145,25 @@ it("preserves completed observed evidence when cancellation arrives after provid
value: { normalized_result: value },
});
});
it("preserves the exact caller reason when inspection is already aborted", async () => {
const reason = Object.freeze({ requested: "stop" });
const controller = new AbortController();
controller.abort(reason);
let calls = 0;
const service = new SqliteDatabaseService({
inspect: () => {
calls += 1;
return Promise.reject(new Error("provider must not run"));
},
});
const result = await service.inspect(
{ path: "/selected.db" },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
expect(calls).toBe(0);
});
@@ -71,7 +71,11 @@ export class SqliteDatabaseService {
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
return err(
new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
}),
);
const input = inspectSqliteDatabaseInputSchema.safeParse(rawInput);
if (!input.success)
return err(
@@ -95,7 +99,7 @@ export class SqliteDatabaseService {
const report = sqliteDatabaseSchema.safeParse(execution.result);
if (
!report.success ||
execution.subject === null ||
execution.subject == null ||
!matchesSelectedDatabase(report.data, input.data, execution.subject)
)
return err(
@@ -1,8 +1,13 @@
import { expect, it } from "vitest";
import { createEvidence } from "../../domain/evidence.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { wasmInterfaceComparisonSchema } from "../../contracts/wasm/wasmComparisonContracts.js";
import { compareWasmInterfaces } from "./WasmInterfaceComparisonService.js";
import type { WasmInterface } from "../../domain/wasm/wasmInterface.js";
import {
WASM_INTERFACE_REPRESENTATION_BYTES,
wasmInterfaceEntryRepresentationBytes,
type WasmInterface,
} from "../../domain/wasm/wasmInterface.js";
import { wasmArtifactFixture } from "../../../tests/fixtures/wasm/artifact.js";
const facts = (
@@ -179,6 +184,72 @@ it("resolves exact retained references and permits cancellation between comparis
});
});
/** One inventory under the comparison cap; two of them exceed it together. */
const admittedInventory = (label: string): WasmInterface => {
const base = facts(label).entries[0]!;
const minimum = Math.floor(WASM_INTERFACE_REPRESENTATION_BYTES / 2) + 4096;
let low = 0;
let high = 4_000_000;
let best = 0;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const name = `${label}-${"n".repeat(mid)}`;
const bytes = wasmInterfaceEntryRepresentationBytes({
...base,
name,
wat_form: `(export "${name}" (func 0))`,
});
if (bytes >= minimum) {
best = mid;
high = mid - 1;
} else low = mid + 1;
}
const name = `${label}-${"n".repeat(best)}`;
const entry = {
...base,
name,
wat_form: `(export "${name}" (func 0))`,
};
const bytes = wasmInterfaceEntryRepresentationBytes(entry);
if (
bytes >= WASM_INTERFACE_REPRESENTATION_BYTES ||
bytes + 512 >= WASM_INTERFACE_REPRESENTATION_BYTES
)
throw new Error("Sized inventory is not individually admitted");
return { state: "complete", issues: [], entries: [entry] };
};
it("refuses two admitted inventories that together exceed the comparison cap", async () => {
const left = admittedInventory("left");
const right = admittedInventory("right");
const empty: WasmInterface = { state: "complete", issues: [], entries: [] };
const alone = await compareWasmInterfaces({
left: evidence(left),
right: evidence(empty),
scope: "both",
});
expect(alone.ok).toBe(true);
const combined = await compareWasmInterfaces({
left: evidence(left),
right: evidence(right),
scope: "both",
});
expect(combined.ok).toBe(false);
if (combined.ok) throw new Error("Expected comparison allowance refusal");
const projected = projectAnalysisError(combined.error);
expect(projected.retryable).toBe(false);
expect(projected.code).toBe("resource_constraint");
expect(combined.error).toMatchObject({ resource: "capacity" });
expect(projected.remediation.action).toContain(
"The WASM interface comparison inventory",
);
expect(projected.remediation.action).toContain("fixed byte allowance");
expect(projected.remediation.action).not.toContain("fixed count");
expect(projected.remediation.action).not.toMatch(
/Wait for an active analysis/u,
);
});
it.each([false, true])(
"keeps a one-sided unsupported interface unknown with reverse=%s",
async (reverse) => {
@@ -22,6 +22,7 @@ import {
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../../domain/result.js";
@@ -133,6 +134,10 @@ export const compareWasmInterfaces = async (
"capacity",
"WASM comparison exceeds typed interface representation capacity",
{ maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES },
fixedAllowanceOptions(
"The WASM interface comparison inventory",
"bytes",
),
);
if (++work % 256 === 0) {
await setImmediate();
+120 -1
View File
@@ -44,6 +44,9 @@ import { createEvidence } from "../domain/evidence.js";
import { freezeJsonSnapshot } from "../domain/immutableJson.js";
import { createArtifactInspection } from "../domain/artifactInspection.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { ResolvedAppBundleResourceRoot } from "../application/AppBundleExecutable.js";
import type { BundleResourceRoot } from "../domain/apple/bundleResourceRoot.js";
import { BUNDLE_ARTIFACT_RESOURCE_OPERATIONS } from "../application/binary/BundleResourceSession.js";
/** Read-only inventory and exclusively owned extraction provider. */
export class ArtifactProvider implements AnalysisProvider {
@@ -71,6 +74,120 @@ export class ArtifactProvider implements AnalysisProvider {
createClient(target: BinaryTarget): AnalysisClient {
return new ArtifactClient(target, this.environment, this.#platform);
}
createBundleResourceClient(
root: ResolvedAppBundleResourceRoot,
): AnalysisClient {
return new BundleResourceArtifactClient(
root,
this.environment,
this.#platform,
);
}
}
const serializedResourceRoot = (
root: ResolvedAppBundleResourceRoot,
): BundleResourceRoot => ({
caller_path: root.callerPath,
canonical_path: root.canonicalPath,
info_plist_path: root.infoPlistPath,
layout: root.layout,
executable_sha256: root.executableSha256,
});
class BundleResourceArtifactClient implements AnalysisClient {
readonly #resourceScope = new ArtifactResourceScope();
constructor(
private readonly root: ResolvedAppBundleResourceRoot,
private readonly environment: Readonly<NodeJS.ProcessEnv>,
private readonly platform: NodeJS.Platform,
) {}
async execute(
operation: AnalysisOperation,
parameters: Readonly<Record<string, JsonValue>>,
options?: ExecutionOptions,
) {
if (operation === "health")
return ok(createAnalysisExecution(null, ARTIFACT_GRAPH_PROVIDER));
if (!BUNDLE_ARTIFACT_RESOURCE_OPERATIONS.has(operation))
return err(
new AnalysisCapabilityUnavailableError(
ARTIFACT_GRAPH_PROVIDER.id,
operation,
"The app resource reader supports only plist, Interface Builder, asset catalog, and keyed archive inspection.",
),
);
try {
const resourceRoot = serializedResourceRoot(this.root);
const result =
operation === "decode_interface_builder"
? await analyzeInterfaceBuilderBundle({
bundlePath: this.root.canonicalPath,
targetSha256: null,
resourceRoot,
limits: interfaceBuilderLimitsSchema.parse(parameters),
...(options?.signal === undefined
? {}
: { signal: options.signal }),
})
: operation === "inspect_keyed_archive"
? await inspectBundleKeyedArchive({
bundlePath: this.root.canonicalPath,
targetSha256: null,
resourceRoot,
parameters,
platform: this.platform,
...(options?.signal === undefined
? {}
: { signal: options.signal }),
})
: operation === "inspect_asset_catalog"
? await analyzeAppleAssetCatalogs({
bundlePath: this.root.canonicalPath,
targetSha256: null,
resourceRoot,
environment: this.environment,
page: parameters,
...(options?.signal === undefined
? {}
: { signal: options.signal }),
})
: (() => {
throw new AnalysisCapabilityUnavailableError(
ARTIFACT_GRAPH_PROVIDER.id,
operation,
"Property list inspection requires a platform-native reader.",
);
})();
return ok(
createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, {
limitations: "limitations" in result ? result.limitations : [],
}),
);
} catch (cause: unknown) {
return err(
translateArtifactFailure(operation as ArtifactAnalysisOperation, cause),
);
}
}
close(): Promise<Result<null, AnalysisError>> {
return this.#resourceScope.close().then(
() => ok(null),
(cause: unknown) =>
err(
new ProviderCleanupError(
ARTIFACT_GRAPH_PROVIDER.id,
["app bundle resource reader"],
{ reason: cause instanceof Error ? cause.message : String(cause) },
{ cause, operation: "close_binary" },
),
),
);
}
}
class ArtifactClient implements AnalysisClient {
@@ -225,7 +342,9 @@ class ArtifactClient implements AnalysisClient {
}),
);
} catch (cause: unknown) {
return err(translateArtifactFailure(operation, cause));
return err(
translateArtifactFailure(operation as ArtifactAnalysisOperation, cause),
);
}
}
@@ -0,0 +1,60 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { build } from "plist";
import { afterEach, expect, it } from "vitest";
import { ArtifactProvider } from "./ArtifactProvider.js";
import type { ResolvedAppBundleResourceRoot } from "../application/AppBundleExecutable.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
);
});
it("returns keyed archive evidence with root provenance and unknown executable identity", async () => {
const bundlePath = await mkdtemp(join(tmpdir(), "rea-resource-reader-"));
temporaryDirectories.push(bundlePath);
await mkdir(join(bundlePath, "Resources"));
await writeFile(
join(bundlePath, "Info.plist"),
'<?xml version="1.0"?><plist><dict/></plist>',
);
const archive = build({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null", "sample"],
$top: { root: { UID: 1 } },
});
await writeFile(join(bundlePath, "Resources", "Archive.plist"), archive);
const root: ResolvedAppBundleResourceRoot = {
callerPath: bundlePath,
canonicalPath: bundlePath,
infoPlistPath: join(bundlePath, "Info.plist"),
layout: "root",
executableSha256: null,
};
const provider = new ArtifactProvider({}, "linux");
const client = provider.createBundleResourceClient(root);
const execution = await client.execute("inspect_keyed_archive", {
path: "Resources/Archive.plist",
});
expect(execution.ok).toBe(true);
if (execution.ok) {
expect(execution.value.result).toMatchObject({
target_sha256: null,
resource_root: {
caller_path: bundlePath,
canonical_path: bundlePath,
executable_sha256: null,
},
archive_path: "Resources/Archive.plist",
});
}
expect((await client.close()).ok).toBe(true);
});
@@ -21,7 +21,8 @@ const MAX_TOTAL_METADATA_BYTES = 64 * 1024 * 1024;
/** Inspect compiled asset catalog metadata without extracting rendition bytes. */
export const analyzeAppleAssetCatalogs = async (input: {
readonly bundlePath: string;
readonly targetSha256: string;
readonly targetSha256: string | null;
readonly resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot;
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly page?: unknown;
readonly signal?: AbortSignal;
@@ -46,6 +47,9 @@ export const analyzeAppleAssetCatalogs = async (input: {
)({
bundlePath: input.bundlePath,
targetSha256: input.targetSha256,
...(input.resourceRoot === undefined
? {}
: { resourceRoot: input.resourceRoot }),
signal:
input.signal === undefined
? interfaceBuilderCancel.signal
@@ -71,6 +75,9 @@ export const analyzeAppleAssetCatalogs = async (input: {
);
const result = projectAppleAssetCatalogPage({
targetSha256: input.targetSha256,
...(input.resourceRoot === undefined
? {}
: { resourceRoot: input.resourceRoot }),
catalogs,
offset: page.offset,
limit: page.limit,
+22 -2
View File
@@ -1,6 +1,6 @@
import type { BigIntStats } from "node:fs";
import { lstat, readlink, realpath } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { isAbsolute, join, relative, sep } from "node:path";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { resolveTreePath } from "../../domain/apple/dyldPaths.js";
@@ -36,10 +36,25 @@ class FilesystemTreeView implements DylibTreeView {
constructor(
private readonly root: string,
private readonly sourceRoot: string,
private readonly files: DylibFileAccess,
private readonly signal?: AbortSignal,
) {}
rootRelativePath(absolutePath: string): string | undefined {
const roots = isAbsolute(this.sourceRoot)
? [this.root, this.sourceRoot]
: [this.root];
for (let root of roots) {
while (root.length > 1 && root.endsWith(sep)) root = root.slice(0, -1);
if (absolutePath === root) return "";
const prefix = root === sep ? sep : `${root}${sep}`;
if (absolutePath.startsWith(prefix))
return absolutePath.slice(prefix.length).split(sep).join("/");
}
return undefined;
}
entry(path: string): Promise<DylibTreeEntry | undefined> {
const cached = this.#entries.get(path);
if (cached !== undefined) return cached;
@@ -113,7 +128,12 @@ export const traceDylibResolution = async (options: {
.split(sep)
.join("/");
assertArtifactReadActive(options.signal);
const view = new FilesystemTreeView(root, files, options.signal);
const view = new FilesystemTreeView(
root,
options.rootPath,
files,
options.signal,
);
const { roots: requested, unclassified } =
parsed.data.roots !== undefined
? { roots: parsed.data.roots, unclassified: [] }
@@ -55,7 +55,8 @@ const MAX_REPORTED_OMITTED_ARCHIVES = 64;
/** Decode compiled Interface Builder archives from a local app bundle. */
export const analyzeInterfaceBuilderBundle = async (input: {
readonly bundlePath: string;
readonly targetSha256: string;
readonly targetSha256: string | null;
readonly resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot;
readonly limits?: unknown;
readonly signal?: AbortSignal;
}) => {
@@ -205,6 +206,9 @@ export const analyzeInterfaceBuilderBundle = async (input: {
}
const result = buildInterfaceBuilderAnalysis({
targetSha256: input.targetSha256,
...(input.resourceRoot === undefined
? {}
: { resourceRoot: input.resourceRoot }),
toolVersion: "rea-interface-builder/1",
documents,
limits,
+5 -1
View File
@@ -152,7 +152,8 @@ const keyedArchiveKindRemediation = (
/** Read exactly one regular, contained bundle entry without following symlinks. */
export const inspectBundleKeyedArchive = async (input: {
bundlePath: string;
targetSha256: string;
targetSha256: string | null;
resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot;
parameters: unknown;
signal?: AbortSignal;
/** Host whose available workflows the remediation names. */
@@ -228,6 +229,9 @@ export const inspectBundleKeyedArchive = async (input: {
try {
return keyedArchiveResultSchema.parse({
target_sha256: input.targetSha256,
...(input.resourceRoot === undefined
? {}
: { resource_root: input.resourceRoot }),
archive_path: entry.path,
archive_sha256: createHash("sha256").update(bytes).digest("hex"),
...decodeKeyedArchiveBytes(bytes, selected),
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { mkdir, rename, writeFile } from "node:fs/promises";
import { mkdir, realpath, rename, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
@@ -142,6 +142,241 @@ it("uses legacy simulator and device platform facts when resolving dylibs", asyn
);
});
describe("absolute dyld candidate paths at the filesystem reader boundary", () => {
it.each(["install-name", "rpath"] as const)(
"resolves an absolute in-root %s while preserving its source path",
async (kind) => {
const root = await createTestTempDirectory("rea-dylib-absolute-in-root-");
const canonicalRoot = await realpath(root);
const bridgePath = "Frameworks/libBridge.dylib";
const leafPath = "Frameworks/libLeaf.dylib";
const bridgeAbsolute = `${canonicalRoot}/${bridgePath}`;
const leafName = "@loader_path/libLeaf.dylib";
const executable = machoImage({
commands: [
buildVersionCommand(1),
...(kind === "rpath"
? [rpathCommand(`${canonicalRoot}/Frameworks`)]
: []),
dylibCommand(
LC.LOAD_DYLIB,
kind === "rpath" ? "@rpath/libBridge.dylib" : bridgeAbsolute,
),
],
});
const bridge = machoImage({
fileType: FILE_TYPE.dylib,
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, leafName),
],
});
const leaf = machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
});
await mkdir(join(root, "Frameworks"), { recursive: true });
await writeFile(join(root, "App"), executable);
await writeFile(bridgeAbsolute, bridge);
await writeFile(join(root, leafPath), leaf);
const trace = await traceDylibResolution({
rootPath: root,
targetPath: join(root, "App"),
targetSha256: createHash("sha256").update(executable).digest("hex"),
enumerateRoots: false,
parameters: { roots: ["App"] },
});
expect(trace.edges[0]).toMatchObject({
install_name:
kind === "rpath" ? "@rpath/libBridge.dylib" : bridgeAbsolute,
candidates: [
{
path: bridgeAbsolute,
...(kind === "rpath"
? { rpath: `${canonicalRoot}/Frameworks` }
: {}),
outcome: "resolved",
resolved_path: bridgePath,
},
],
resolution: { status: "resolved", image: bridgePath },
});
expect(trace.edges).toContainEqual(
expect.objectContaining({
loader: bridgePath,
install_name: leafName,
resolution: { status: "resolved", image: leafPath },
}),
);
expect(trace.images.map(({ path }) => path)).toEqual([
"App",
bridgePath,
leafPath,
]);
},
);
it("keeps a genuinely external absolute candidate outside the analyzed root", async () => {
const root = await createTestTempDirectory("rea-dylib-absolute-outside-");
const canonicalRoot = await realpath(root);
const executable = machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
dylibCommand(LC.LOAD_DYLIB, `${canonicalRoot}/../outside.dylib`),
dylibCommand(
LC.LOAD_DYLIB,
`file://${canonicalRoot}/Frameworks/libBridge.dylib`,
),
],
});
await writeFile(join(root, "App"), executable);
const trace = await traceDylibResolution({
rootPath: root,
targetPath: join(root, "App"),
targetSha256: createHash("sha256").update(executable).digest("hex"),
enumerateRoots: false,
parameters: { roots: ["App"] },
});
expect(trace.edges[0]).toMatchObject({
candidates: [
{
path: "/usr/lib/libSystem.B.dylib",
outcome: "outside-target",
resolved_path: null,
},
],
resolution: { status: "undetermined", image: null },
});
expect(trace.edges[1]).toMatchObject({
candidates: [{ outcome: "escapes-target", resolved_path: null }],
resolution: { status: "undetermined", image: null },
});
expect(trace.edges[2]).toMatchObject({
install_name: `file://${canonicalRoot}/Frameworks/libBridge.dylib`,
candidates: [
{
path: `file://${canonicalRoot}/Frameworks/libBridge.dylib`,
outcome: "undetermined",
resolved_path: null,
},
],
resolution: { status: "undetermined", image: null },
});
expect(trace.images.map(({ path }) => path)).toEqual(["App"]);
});
});
describe("absolute dyld candidate traversal", () => {
it("maps an absolute candidate spelled through the selected root symlink", async () => {
const root = await createTestTempDirectory(
"rea-dylib-absolute-root-alias-",
);
const aliasRoot = join(root, "bundle-alias");
const bridgePath = "Frameworks/libBridge.dylib";
const bridgeAbsolute = `${aliasRoot}/${bridgePath}`;
const executable = machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, bridgeAbsolute),
],
});
const bridge = machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
});
await symlink(".", aliasRoot);
await mkdir(join(root, "Frameworks"), { recursive: true });
await writeFile(join(root, "App"), executable);
await writeFile(join(root, bridgePath), bridge);
const trace = await traceDylibResolution({
rootPath: aliasRoot,
targetPath: `${aliasRoot}/App`,
targetSha256: createHash("sha256").update(executable).digest("hex"),
enumerateRoots: false,
parameters: { roots: ["App"] },
});
expect(trace.edges[0]).toMatchObject({
install_name: bridgeAbsolute,
candidates: [
{
path: bridgeAbsolute,
outcome: "resolved",
resolved_path: bridgePath,
},
],
resolution: { status: "resolved", image: bridgePath },
});
});
it.skipIf(process.platform === "win32")(
"preserves symlink-then-parent traversal for an absolute in-root path",
async () => {
const root = await createTestTempDirectory("rea-dylib-absolute-symlink-");
const canonicalRoot = await realpath(root);
const bridgePath = "Frameworks/Versions/libBridge.dylib";
const decoyPath = "Frameworks/libBridge.dylib";
const sourcePath = `${canonicalRoot}/Frameworks/Current/../libBridge.dylib`;
const executable = machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, sourcePath),
],
});
const bridge = machoImage({
fileType: FILE_TYPE.dylib,
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libLeaf.dylib"),
],
});
const leaf = machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
});
await mkdir(join(root, "Frameworks/Versions/A"), { recursive: true });
await symlink("Versions/A", join(root, "Frameworks/Current"));
await writeFile(join(root, "App"), executable);
await writeFile(join(root, bridgePath), bridge);
await writeFile(join(root, decoyPath), new Uint8Array([0, 1, 2]));
await writeFile(join(root, "Frameworks/Versions/libLeaf.dylib"), leaf);
const trace = await traceDylibResolution({
rootPath: root,
targetPath: join(root, "App"),
targetSha256: createHash("sha256").update(executable).digest("hex"),
enumerateRoots: false,
parameters: { roots: ["App"] },
});
expect(trace.edges[0]).toMatchObject({
candidates: [
{
path: sourcePath,
outcome: "resolved",
resolved_path: bridgePath,
},
],
resolution: { status: "resolved", image: bridgePath },
});
expect(trace.edges).toContainEqual(
expect.objectContaining({
loader: bridgePath,
resolution: {
status: "resolved",
image: "Frameworks/Versions/libLeaf.dylib",
},
}),
);
},
);
});
it("selects matching arm64e subtype slices when tracing a mixed FAT image", async () => {
const root = await createTestTempDirectory("rea-macho-arm64e-subtypes-");
const executablePath = "Contents/MacOS/App";
+53 -18
View File
@@ -25,7 +25,7 @@ import {
initiatorLocation,
integerOrNull,
isJsonMediaType,
isMainFrameNavigation,
cdpFrameIdentity,
} from "./CdpCaptureEventHelpers.js";
import type { CdpCaptureEventsState } from "./CdpCaptureEventState.js";
import {
@@ -509,28 +509,41 @@ export const handleWebSocketFrame = (
const connection = state.webSocketConnectionsById.get(requestId);
if (connection === undefined) return;
const response = recordValue(params.response);
const payload = cdpStringValue(response?.payloadData) ?? "";
const opcode = Math.max(0, Math.trunc(numberValue(response?.opcode) ?? 0));
const decodedBytes =
opcode === 1 ? undefined : paddedBase64ByteLength(payload);
if (opcode !== 1 && decodedBytes === undefined)
const opcodeValue = numberValue(response?.opcode);
// RFC 6455 encodes opcodes in four bits; keep reserved values as observed data.
const opcode =
opcodeValue !== undefined &&
Number.isInteger(opcodeValue) &&
opcodeValue >= 0 &&
opcodeValue <= 15
? opcodeValue
: null;
const payload = cdpStringValue(response?.payloadData);
const payloadBytes =
opcode === null || payload === undefined
? null
: opcode === 1
? Buffer.byteLength(payload)
: (paddedBase64ByteLength(payload) ?? null);
if (opcode === null || payloadBytes === null)
state.completeness.exclude("websocket_frames", "invalid_protocol_value");
connection.events.push({
direction,
opcode,
payload_bytes:
opcode === 1 ? Buffer.byteLength(payload) : (decodedBytes ?? null),
payload_bytes: payloadBytes,
payload_shape: captureWebSocketShape(state, payload, opcode),
});
};
const captureWebSocketShape = (
state: CdpCaptureEventsState,
payload: string,
opcode: number,
payload: string | undefined,
opcode: number | null,
): WebPageInspection["network"]["websocket_connections"][number]["events"][number]["payload_shape"] => {
if (!state.input.include_websocket_shapes) return null;
if (opcode === null) return null;
if (opcode !== 1) return { format: "binary", json_shape: null };
if (payload === undefined) return null;
const shape = inferJsonShape(payload);
return {
format: shape === null ? "text" : "json",
@@ -594,25 +607,47 @@ export const handleWebSocketCreated = (
export const handleFrameNavigated = (
state: CdpCaptureEventsState,
params: UnknownRecord,
params: unknown,
): void => {
const frame = recordValue(params.frame);
if (state.mainFrameId === undefined) return;
if (
!isMainFrameNavigation(
{ method: "Page.frameNavigated", params },
state.mainFrameId,
const identity = cdpFrameIdentity(
{ method: "Page.frameNavigated", params },
state.mainFrameId,
);
if (identity.identityIssue !== null || identity.parentIssue !== null) {
state.completeness.exclude("frames", "invalid_protocol_value");
if (
identity.selection === "unknown" &&
identity.parentRelation === "child" &&
identity.identityIssue !== null
)
)
return;
state.unknownRootIdentity = true;
return;
}
if (identity.selection === "other" && identity.parentRelation === "child")
return;
if (identity.selection === "unknown") {
state.completeness.exclude("frames", "invalid_protocol_value");
state.unknownRootIdentity = true;
return;
}
if (identity.selection === "other" && !identity.isRootReplacement) {
state.completeness.exclude("frames", "invalid_protocol_value");
state.unknownRootIdentity = true;
return;
}
const frame = recordValue(recordValue(params)?.frame);
const rawUrl = cdpStringValue(frame?.url);
const loaderId = cdpStringValue(frame?.loaderId);
// `frameNavigated` re-fires for a document already reported (same URL and
// loader). Treating that as a navigation aborted a valid capture with
// `target_changed`, which is a false claim that the target moved.
const document = `${String(loaderId ?? "")}\0${String(rawUrl ?? "")}`;
const document = `${String(identity.frameId ?? "")}\0${String(loaderId ?? "")}\0${String(rawUrl ?? "")}`;
if (state.committedDocument === document) return;
state.committedDocument = document;
if (identity.isRootReplacement)
state.mainFrameId = identity.frameId ?? undefined;
state.navigationDuringCapture = true;
if (
isHttpUrl(rawUrl) &&
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { cdpFrameIdentity } from "./CdpCaptureEventHelpers.js";
describe("CDP frame identity ownership", () => {
it.each([
["missing", {}],
["null", { frame: null }],
["non-object", { frame: "frame" }],
])("does not infer a root from a %s frame object", (_label, params) => {
expect(
cdpFrameIdentity(
{ method: "Page.frameNavigated", params },
"selected-main",
),
).toMatchObject({
applicability: "applicable",
selection: "unknown",
isRootReplacement: false,
identityIssue: "missing_frame",
parentRelation: "unknown",
parentId: null,
parentIssue: null,
});
});
it("recognizes a valid frame with no parent property as a root", () => {
expect(
cdpFrameIdentity(
{
method: "Page.frameNavigated",
params: { frame: { id: "replacement-main" } },
},
"selected-main",
),
).toMatchObject({
applicability: "applicable",
selection: "other",
isRootReplacement: true,
identityIssue: null,
parentRelation: "root",
parentId: null,
parentIssue: null,
});
});
it("does not interpret a malformed parent value as root evidence", () => {
expect(
cdpFrameIdentity(
{
method: "Page.frameNavigated",
params: { frame: { id: "replacement-main", parentId: "" } },
},
"selected-main",
),
).toMatchObject({
selection: "other",
isRootReplacement: false,
identityIssue: null,
parentRelation: "unknown",
parentId: null,
parentIssue: "invalid_parent_id",
});
});
it("preserves a self-parented frame ID but leaves its parent relation unknown", () => {
expect(
cdpFrameIdentity(
{
method: "Page.frameNavigated",
params: {
frame: { id: "candidate-root", parentId: "candidate-root" },
},
},
"selected-main",
),
).toMatchObject({
selection: "other",
isRootReplacement: false,
frameId: "candidate-root",
identityIssue: null,
parentRelation: "unknown",
parentId: null,
parentIssue: "self_parent",
});
});
it("retains the selected-main parent issue for a self-parented selected frame", () => {
expect(
cdpFrameIdentity(
{
method: "Page.frameNavigated",
params: { frame: { id: "selected-main", parentId: "selected-main" } },
},
"selected-main",
),
).toMatchObject({
selection: "main",
parentRelation: "unknown",
parentIssue: "main_frame_has_parent",
});
});
});
+123 -12
View File
@@ -8,21 +8,132 @@ import {
} from "./CdpCaptureValues.js";
import type { CdpEvent } from "./CdpConnection.js";
/** Identify a top-level navigation event from its untrusted CDP envelope. */
export const isMainFrameNavigation = (
export type FrameSelection = "main" | "other" | "unknown";
export type FrameParentRelation =
| "root"
| "child"
| "unknown"
| "not_applicable";
/** Normalized frame identity facts shared by all selected-frame consumers. */
export interface CdpFrameIdentity {
readonly applicability: "applicable" | "not_applicable";
readonly selection: FrameSelection;
readonly isRootReplacement: boolean;
readonly frameId: string | null;
readonly identityIssue:
| "missing_params"
| "invalid_params"
| "missing_frame"
| "missing_id"
| "invalid_id"
| null;
readonly parentRelation: FrameParentRelation;
readonly parentId: string | null;
readonly parentIssue:
| "invalid_parent_id"
| "self_parent"
| "main_frame_has_parent"
| null;
}
const nonEmptyString = (value: unknown): string | undefined =>
typeof value === "string" && value.length > 0 ? value : undefined;
const identityIssueFor = (
navigated: boolean,
frame: UnknownRecord | undefined,
rawId: unknown,
): CdpFrameIdentity["identityIssue"] => {
if (navigated && frame === undefined) return "missing_frame";
if (rawId === undefined) return "missing_id";
return nonEmptyString(rawId) === undefined ? "invalid_id" : null;
};
const parentFacts = (
navigated: boolean,
frame: UnknownRecord | undefined,
): Pick<CdpFrameIdentity, "parentRelation" | "parentId" | "parentIssue"> => {
if (!navigated)
return {
parentRelation: "not_applicable",
parentId: null,
parentIssue: null,
};
if (frame === undefined)
return { parentRelation: "unknown", parentId: null, parentIssue: null };
if (!("parentId" in frame))
return { parentRelation: "root", parentId: null, parentIssue: null };
const parentId = nonEmptyString(frame.parentId);
return parentId === undefined || parentId === nonEmptyString(frame.id)
? {
parentRelation: "unknown",
parentId: null,
parentIssue:
parentId === undefined ? "invalid_parent_id" : "self_parent",
}
: { parentRelation: "child", parentId, parentIssue: null };
};
/** Parse required frame identity separately from the optional parent fact. */
export const cdpFrameIdentity = (
event: CdpEvent,
mainFrameId: string,
): boolean => {
): CdpFrameIdentity => {
const navigated = event.method === "Page.frameNavigated";
const requested = event.method === "Page.frameRequestedNavigation";
const sameDocument = event.method === "Page.navigatedWithinDocument";
if (!navigated && !requested && !sameDocument)
return {
applicability: "not_applicable",
selection: "unknown",
isRootReplacement: false,
frameId: null,
identityIssue: null,
parentRelation: "not_applicable",
parentId: null,
parentIssue: null,
};
const params = recordValue(event.params);
if (params === undefined) return false;
const frameId =
event.method === "Page.frameNavigated"
? cdpStringValue(recordValue(params.frame)?.id)
: event.method === "Page.frameRequestedNavigation" ||
event.method === "Page.navigatedWithinDocument"
? cdpStringValue(params.frameId)
: undefined;
return frameId === mainFrameId;
if (params === undefined)
return {
applicability: "applicable",
selection: "unknown",
isRootReplacement: false,
frameId: null,
identityIssue:
event.params === undefined ? "missing_params" : "invalid_params",
parentRelation: "not_applicable",
parentId: null,
parentIssue: null,
};
const frame = navigated ? recordValue(params.frame) : undefined;
const rawId = navigated ? frame?.id : params.frameId;
const frameId = nonEmptyString(rawId);
const selection =
frameId === undefined
? "unknown"
: frameId === mainFrameId
? "main"
: "other";
const identityIssue = identityIssueFor(navigated, frame, rawId);
const parent = parentFacts(navigated, frame);
return {
applicability: "applicable",
selection,
isRootReplacement:
selection === "other" && parent.parentRelation === "root",
frameId: frameId ?? null,
identityIssue,
parentRelation: parent.parentRelation,
parentId: parent.parentId,
parentIssue:
selection === "main" &&
(parent.parentRelation === "child" ||
parent.parentIssue === "self_parent")
? "main_frame_has_parent"
: parent.parentIssue,
};
};
export const firstCallFrame = (
Loaded 100 of 304 files, more files were not shown because too many files have changed in this diff. Show more