chore: integrate canonical Evidence changes for MCP error delivery

This commit is contained in:
N0zoM1z0 2026-10-09 12:43:17 +00:00
commit 7bf63ed7f8
643 changed files with 12727 additions and 10243 deletions

View File

@ -102,7 +102,7 @@ jobs:
with:
name: rea-dist
path: dist
- name: Restore generated outputs from downloaded build
- name: Generate test artifacts from the downloaded runtime
run: |
node scripts/generate-mcp-tool-catalog.mjs
node scripts/generate-skill-metadata.mjs
@ -450,7 +450,6 @@ jobs:
tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts
tests/boundary/filesystem/setupUnavailableProvider.test.ts
tests/boundary/filesystem/windowsNpxRegistration.test.ts
src/application/ClientRegistrationIdentity.test.ts
tests/boundary/filesystem/webScriptManifestPaths.test.ts
tests/boundary/process/ProcessEvidence.host.test.ts
tests/acceptance/webModuleCli.test.ts

5
.gitignore vendored
View File

@ -58,11 +58,8 @@ Thumbs.db
!/website/.dev.vars.example
!/website/.env*.example
# Build-generated MCP tool catalog (regenerated by `npm run build`; never commit)
src/generatedMcpToolCatalog.ts
# Derived catalogs, portable conformance projections, and packaged skill bundles
# (regenerated from this checkout by `npm run build:cached`; never commit)
# (regenerated by `npm run docs:generate`; never commit)
/skills/
/docs/public/product-catalog.json
/docs/verification/managed-conformance-manifest.json

View File

@ -24,15 +24,15 @@ REA is a layered ESM TypeScript application. Dependencies flow inward from pure
- `src/application/` composes shared CLI/MCP workflows, including Evidence provenance, unknowns, and eligible snapshot bindings; `src/server/` translates MCP requests; `src/cli.ts` and `src/main.ts` are the CLI and MCP entry points.
- `src/process/` owns shared process lifecycle primitives, not provider wire protocols. Reuse its supervision and identity primitives before adding provider-local lifecycle code; a PID and executable pathname alone do not establish ownership after exit or reuse. `bridge/` contains provider-side adapters.
- `tests/` contains unit, composition, boundary, acceptance, and conformance tests. `scripts/verify-*` and capability directories under `scripts/verify/` contain real-toolchain checks.
- `docs/public/product-catalog.json`, `docs/verification/managed-conformance-*.json`, and `skills/` are ignored build outputs. Update source contracts and authored instructions in `skill-src/`, then run `npm run build:cached`; never commit derived catalog digests or portable conformance projections.
- `src/generatedMcpToolCatalog.ts` is build-generated and gitignored. Never commit it; resolve any trace of it in merges by deleting it and running `npm run build:cached`.
- `docs/public/product-catalog.json`, `docs/verification/managed-conformance-*.json`, and `skills/` are ignored build outputs. Update source contracts and authored instructions in `skill-src/`, then run `npm run docs:generate`; never commit derived catalog digests or portable conformance projections.
- `.cache/mcp-tool-catalog.json` is generated test metadata. Generate it with `npm run mcp-catalog:generate`; source checking and runtime builds do not consume it.
## Build, Test, and Development Commands
- `npm ci`: install the locked dependencies.
- `npm run build:cached`: build the CLI and MCP server.
- `npm run build:cached`: compile the CLI/MCP runtime and bundle its authored skill. Test catalogs and documentation/conformance artifacts have separate tasks.
- `npm run test:local`: run changed source tests without building; pass exact source test paths to run them regardless of Git status.
- `npm run test:focused -- PATH...`: run exact test files; build first for boundary, acceptance, or process-global tests.
- `npm run test:focused -- PATH...`: run exact test files, preparing runtime and generated artifacts required by the selected tests.
- `npm run check:changed`: run cached static checks and source tests affected since the branch merge base (default `origin/main`).
- `npm run check:fast`: run cached typecheck and lint checks.
- `npm run check:pr`: opt into the complete local deterministic gate and generated-document checks for broad changes; CI owns full coverage. Routine iterations need focused tests and relevant checks, not the whole gate each time.
@ -94,7 +94,7 @@ See [docs/tool-design.md](docs/tool-design.md) for the design checklist. When us
## Testing Guidelines
Prioritize real end-to-end workflows, boundary integration, and golden producer data. Keep a focused module test when it covers a distinct failure or semantic case absent from those workflows; test paths do not establish depth. See [docs/testing.md](docs/testing.md) for pruning and classification rules.
Prefer full end-to-end workflows through the public CLI or MCP with real providers and no mocked dependencies, then integration across production boundaries, then goldens captured from real producers. Keep a focused module test only for a distinct failure or semantic case that stronger workflows cannot reliably reproduce. Delete redundant getter, serialization, enum, count, and snapshot assertions when a stronger consumer workflow already proves the claim. Test paths, compiled imports, and suites named E2E do not establish end-to-end coverage. See [docs/testing.md](docs/testing.md) for pruning and classification rules.
Treat advertised examples as executable contracts: derive them from representative producer data, exercise them through the advertised CLI or MCP workflow, and assert the behavior they claim to demonstrate. Share production projections where practical and declare omitted coverage explicitly.

View File

@ -42,9 +42,10 @@ npm run check:fast
`npm ci` installs the exact dependencies and prepares the Husky hooks without
building the project. Run `npm run build:cached` when you need the standalone
CLI or MCP server; `npm run build` remains the uncached compiler leaf used by
Turbo. Turbo caches deterministic builds and static checks across Git
worktrees. After a package, lockfile, or managed-skill version change, run
CLI or MCP server; it compiles the runtime and bundles the authored skill.
`npm run build` forces those tasks to rerun. Neither command generates test
catalogs, documentation projections, or managed verification evidence. Turbo
caches deterministic builds and static checks across Git worktrees. After a package, lockfile, or managed-skill version change, run
`npm run metadata:generate` before building.
Keep dependencies flowing inward through the existing domain, contracts, provider, application, server, and adapter layers. Parse unknown values at process and protocol boundaries, model expected failures with `Result`, and preserve the canonical tool inventory defined by `TOOL_CONTRACTS` unless a deliberate contract change updates every verifier, generated catalog artifact, and snapshot. Keep tool discovery complete and report capability- and session-scoped availability through `binary_session`.
@ -96,7 +97,22 @@ Packaging, setup, installation, or distribution changes also require
`npm run verify:package` and `npm pack --dry-run`. Provider behavior changes
require the matching real-provider `verify:*` lane.
`check:fast` runs cached typecheck and lint, reporting diagnostics on failure.
`check:fast` runs cached source/test typecheck and lint without compiling or
running generators. `npm run test:prepare` prepares all artifacts consumed by
the complete suite; focused tests prepare only their runtime and declared
artifact dependencies. The test-only MCP catalog lives at
`.cache/mcp-tool-catalog.json` and can be regenerated independently with
`npm run mcp-catalog:generate`.
Build and generation commands hold one SQLite exclusive transaction around
their whole Turbo graph, including cache restoration. The persistent database
in `.cache/rea-command-locks/` is a lock identity, not a stale-file marker;
the operating system releases ownership when the command supervisor exits.
On POSIX, that supervisor retains the transaction and process-group identity
through cancellation, including loss of its parent runner. Windows cancellation
waits for the command to complete. Do not remove the database while commands
are running.
`check` adds formatting, dead-code, and package-metadata freshness checks.
Formatting uses Oxfmt and the committed `.oxfmtrc.json`; generated sources use
the same configuration. Pre-commit formats and lints staged files; pre-push runs
@ -104,8 +120,10 @@ the same configuration. Pre-commit formats and lints staged files; pre-push runs
`docs:check` builds and validates generated metadata for the current checkout.
The product catalog (`docs/public/product-catalog.json`), portable managed
conformance projections (`docs/verification/managed-conformance-*.json`), and
packaged skill (`skills/`) are ignored build outputs. Edit skill instructions
and references in `skill-src/`; the build adds catalog-dependent metadata to
packaged skill (`skills/`) are ignored generated outputs. `docs:generate`
prepares documentation and managed evidence; `evidence:generate` prepares only
the managed commitment and its runtime/skill dependencies. Edit skill
instructions and references in `skill-src/`; the skill task adds metadata to
the packaged copy without rewriting authored files. The generated manifest
commits to that exact packaged skill bundle. It is a portable projection of
the deterministic managed verifier, not a record of optional real-provider runs.

View File

@ -157,7 +157,7 @@ Additional tools and host support depend on the target:
| Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](https://rea.tools/guides/browser/) |
| Saved network captures | Requests, responses, exposed payloads and source locations | HAR; mitmdump on Linux for native mitmproxy captures; [capture guide](docs/web-network-captures.md) |
| .NET assemblies | Metadata, CIL instructions, declared native dependencies and build comparisons | Static inspection; [managed-code guide](docs/managed-code-analysis.md) |
| Android APKs | Manifest declarations, classes, decompiled methods and references | Headless JADX and a full JDK on Linux/macOS; [Android guide](docs/android-analysis.md) |
| Android APKs | Manifest declarations, classes, decompiled methods and references | Headless JADX and a full JDK on Linux/macOS/Windows x64; [Android guide](docs/android-analysis.md) |
| Firmware | Regions, extraction results and native-analysis handoffs | Binwalk / Unblob on Linux; [firmware guide](docs/firmware-analysis.md) |
| Packages and resources | File inventories, digests, plists, Apple bundle anatomy and extracted resources | [Artifact and JavaScript guide](docs/javascript-artifact-reconstruction.md), [Apple applications](docs/apple-application-analysis.md) |
| Process behavior | Terminal output, interactions, exit and filesystem observations, and run comparisons | Linux/macOS with a native PTY; [process capture](docs/process-capture.md) |
@ -298,7 +298,7 @@ For exact options, prerequisites and result contracts:
- [Readiness and troubleshooting](docs/installation.md#check-readiness-for-your-task): diagnose one agent or analysis engine.
- [CLI and Evidence](docs/cli.md): commands, provider selection, snapshots, import/export and exit statuses.
- [MCP contracts](docs/mcp-contracts.md) and [agent prompts](docs/mcp-prompts.md): tool results, sessions and guided investigations.
- [Tool catalog](docs/mcp-contracts.md#generated-catalog): build-generated inventory of tools, providers and CLI commands.
- [Tool catalog](docs/mcp-contracts.md#generated-catalog): generated inventory of tools, providers and CLI commands.
- [Roadmap](docs/roadmap.md): planned work and capability trackers.
Report vulnerabilities through [SECURITY.md](SECURITY.md).

View File

@ -252,7 +252,7 @@ No request can cross a concurrent target or provider transition.
canonical path, source digest, generic kind and format, selected architecture,
and available architectures. Hopper loader arguments leave the domain model.
Provider-owned databases use a generic analysis-database classification; their
adapter determines support. A legacy `.hop` target is therefore accepted only
adapter determines support. A `.hop` analysis database is therefore accepted only
by Hopper without teaching the domain how to open it. Ghidra v1 continues to use
private temporary projects and does not open user projects.
@ -336,19 +336,19 @@ entries, so snapshots must be recaptured before they can be used as a cache.
An embedded Evidence bundle can still be imported separately through the
Evidence import flow; it is never promoted into an analysis cache.
### 8. Add profile commitments to new deep Evidence while preserving legacy records
### 8. Commit concrete or explicitly unknown analysis profiles in Evidence
The optional `analysis_profile` field uses the canonical envelope above. Every
newly created deep-analysis observation must include it, and its provider ID
and version must match the record's provider. The field participates in the
Evidence semantic ID.
Every Evidence record includes `analysis_profile`, either the canonical profile
envelope above or `null` when no profile was observed. Deep-analysis observations
commit the resolved provider ID, version, and effective configuration in this
profile. The field participates in the Evidence semantic ID.
Existing Evidence records without `analysis_profile` remain valid and keep
their existing IDs. Absence on a legacy deep record means the analysis profile
is unknown; REA does not infer or enrich it during import. Such evidence can be
displayed and cited but cannot establish profile compatibility, seed snapshot
replay, or support an "unchanged" conclusion that depends on identical analysis
semantics.
An explicitly unknown profile can be displayed and cited, but cannot establish
profile compatibility, seed snapshot replay, or support an "unchanged"
conclusion that depends on identical analysis semantics. Snapshot query entries
must have matching profile-bound Evidence; imports reject missing or mismatched
observations instead of silently dropping query entries. Both `entries` and
`workflow_entries` are required arrays, including when empty.
Different providers or profile digests are never snapshot-compatible. A
derived comparison may compare cross-provider normalized facts only when its
@ -407,12 +407,12 @@ means no dynamic provider has supplied a snapshot. Once one or more start,
snapshots are not live inventories, and a point-in-time observation never proves
historical absence.
The existing flat `provider`, `providers`, and `capabilities` status fields stay
during the 1.x compatibility window. Their effective deep-operation entries
come only from the active binding; target-free ambiguity is unavailable rather
than arbitrarily collapsed. The new candidate fields are authoritative for
selection. A future major release may remove the synthetic composite provider
field after callers have migrated.
Status exposes `analysis_provider_candidates` for configured deep providers and
`analysis_provider_binding` for the selected concrete provider and profile.
`capabilities` retains effective operation availability and exact provider
provenance, including auxiliary operation families. There are no synthetic
flat `provider` or `providers` aliases. Provider prompt completion uses the
actual selectable candidates.
Selection failures extend the tagged `ProviderSelectionError` with a stable
reason:
@ -448,26 +448,20 @@ snapshot's provider.
- Tool names and normalized output contracts remain provider-neutral. REA does
not add `ghidra_decompile` or `hopper_decompile` tools.
## Compatibility and migration
## Provider and snapshot representations
| Existing behavior or data | Decision |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| No provider selector, only Hopper usable | `auto` selects the sole candidate; behavior remains equivalent |
| No deep provider usable | Automatic open may remain unbound for auxiliary operations; deep capabilities explain why they are unavailable |
| Hopper and Ghidra both usable, no preference | Open fails with `ambiguous`; installation order never becomes policy |
| Existing Hopper environment settings | Remain supported as Hopper adapter configuration |
| `open_binary` and CLI structured `loaderArgs` output | Retained as a deprecated Hopper-only compatibility projection for 1.x; the analysis profile is authoritative |
| `BinaryTarget.loaderArgs` | Removed; Hopper derives them inside its adapter and commits normalized semantics in the profile |
| Legacy `.hop` input | Classified generically as an analysis database and accepted only by Hopper |
| Snapshot v1 | Not accepted as the current snapshot format; recapture before cache replay |
| Existing Evidence without a profile | Accepted unchanged; profile compatibility is unknown |
| New deep-analysis Evidence | Must include `analysis_profile`; its semantic ID commits the field |
| Existing status fields | Retained for 1.x with additive binding and candidate fields |
| Provider failure after selection | Returned from the selected provider; no transparent retry through another engine |
The deprecated `loaderArgs` response must never be read to select a provider or
validate a snapshot. It is empty for non-Hopper bindings and may be removed only
with a separately documented major contract change.
| Behavior or data | Contract |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| No selector, one usable deep provider | `auto` selects the sole candidate |
| No usable deep provider | Automatic open may remain unbound for auxiliary operations; deep capabilities explain their unavailability |
| Several usable deep providers without a preference | Open fails with `ambiguous`; installation order never becomes policy |
| Hopper environment settings | Adapter configuration committed into the typed analysis profile, including effective loader arguments |
| `open_binary` output | Provider-neutral target identity only; no `loaderArgs` projection |
| `.hop` input | Analysis database accepted only by Hopper |
| Snapshots | Concrete provider/profile binding, required primitive and workflow entry arrays, and matching authenticated Evidence |
| Evidence without an observed profile | Explicit `analysis_profile: null`; ineligible for profile-dependent replay |
| Snapshot save receipt | `primitive_entries`, `workflow_entries`, and `evidence_records` counts without a duplicate `entries` alias |
| Provider failure after selection | Returned from that provider, without transparent retry through another engine |
## Required behavior scenarios

View File

@ -23,6 +23,23 @@ export REA_JADX_MCP_JAR=/absolute/path/jadx-headless-mcp-0.7.1-all.jar
export JAVA_HOME=/absolute/path/existing-jdk
```
On Windows x64, use a REA package with its matching bundled Windows native
controls **including owned protocol stdin support**. This Windows boundary is
new on repository main; an older released package can still reject this host.
PowerShell accepts paths containing spaces without additional escaping
inside these environment values:
```powershell
$env:REA_JADX_MCP_JAR = 'D:\tools\jadx-headless-mcp-0.7.1-all.jar'
$env:JAVA_HOME = 'D:\tools\jdk-21'
rea inspect-android-package 'D:\targets\Example.apk'
```
REA selects `JAVA_HOME\bin\java.exe`, or resolves `java.exe` on the selected
Windows `PATH`. A missing or mismatched native artifact reports the package
prerequisite rather than attempting an unowned Java launch. Windows arm64 is
outside this provider's current native boundary.
Readiness checks the selected JAR's ZIP directory for the classes consumed by
REA's metadata bridge, as well as Java's compiler and version modules. It does
not load an APK or execute engine code. Invalid archives and missing bridge
@ -34,7 +51,9 @@ the exhausted read budget and the selected JAR path.
The current metadata bridge is verified on macOS arm64 with OpenJDK 21 and the
public Appium ApiDemos fixture. The POSIX adapter also supports Linux; the new
bridge has not yet undergone real verification on Linux/JDK 25.
Windows is unsupported for this provider's owned stdio process boundary.
Windows x64 uses an owned Job Object with bidirectional stdio, atomic job
assignment and kill-on-owner-close. Real Windows verification uses JDK 21 and
the pinned JADX 0.7.1 JAR; see the real verification lane below.
## Answer an analyst question
@ -138,6 +157,12 @@ target/configuration changes, failure, timeout, cancellation, MCP disconnect or
server shutdown. One-shot CLI commands always join cleanup before returning.
REA never writes to the original APK or launches target code.
On Windows, stdin writes preserve stream backpressure and run outside the Node
event loop. Cancellation terminates the retained job, including descendants,
before workspace cleanup. An abrupt CLI/server process termination closes the
job and stops Java, but cannot run JavaScript workspace cleanup; temporary
workspace files can remain after that forced exit.
If Java cannot start the bridge, REA reports the selected executable and retained
startup diagnostics. Select a full JDK through `JAVA_HOME` or `PATH`, and use
`java --list-modules` to confirm `jdk.compiler` is present. A runnable JRE alone
@ -197,6 +222,12 @@ without the compiler module. It checks real Java startup failures, missing
selectors, recovery after failures, and CLI cancellation with owned cleanup.
The original APK and host Java configuration remain unchanged.
The same lane runs on Windows x64 with the bundled native controls and a full
JDK. It checks CLI/MCP results, real MCP cancellation and disconnect, and Java
exit after abrupt CLI owner termination. Windows forced termination is checked
separately from the POSIX CLI SIGTERM handler; it does not claim a structured
cancellation result or workspace removal after the owner is forcibly killed.
The download script verifies fixed SHA-256 values, reuses matching files and
refuses to overwrite a different existing file. APK/JAR files live under ignored
`_reference/` and are excluded from package contents. The lane exercises actual

View File

@ -31,6 +31,14 @@ overall coverage becomes `partial` and a limitation explains the omission.
Duplicate inventory pages are merged by artifact identity and occurrence path;
their Evidence IDs remain attached as source Evidence.
Content nodes identify bytes by SHA-256 and report only content-derived formats.
Each occurrence reports its own `artifact_kind`, `artifact_format`, and executable
permission. Identical bytes can therefore appear as a script, resource, or native
addon at different paths without overwriting another occurrence's role. Package
formats such as APK and IPA belong to the occurrence; their ZIP bytes share a
content format. Graph and directory identities use locale-independent Unicode
ordering.
For IPA inventories, archive components outside every application root remain
in the component lists. Their JavaScript and native candidates are grouped with
other unrooted components, preserving the inventory projection's path pairing

View File

@ -110,8 +110,7 @@ cached result. `close_binary` accepts `snapshot_path` and optional
leaves the session open so the caller can resolve the output failure.
The MCP save receipt reports `primitive_entries`, `workflow_entries`, and
`evidence_records` separately; the existing `entries` field remains the
primitive count. Zero primitive bindings can still accompany retained workflow
`evidence_records` separately. Zero primitive bindings can still accompany retained workflow
results and Evidence. These are cached observations, not a saved provider
database: only eligible exact queries can reuse a result, and new or live
queries can still require provider startup. The CLI uses the same snapshot

View File

@ -515,8 +515,9 @@ import and auto-analysis. The first Ghidra-backed query starts that work lazily.
The provider startup deadline is 330,000 ms by default for import, analysis,
bridge, and health readiness. Large binaries can need more: set
`REA_GHIDRA_STARTUP_TIMEOUT_MS` to an integer between 1 and 2,147,483,647
milliseconds in the server environment. An absent, empty, invalid or out-of-range
value keeps the default. REA parses the supplied configuration environment and
milliseconds in the server environment. An absent setting uses the default;
empty, invalid or out-of-range supplied values fail configuration validation.
REA parses the supplied configuration environment and
passes the deadline to each provider client; a startup failure is returned by the query that triggered it,
without exposing partial analysis. This deadline is separate from MCP transport
initialization and the client's deadline for that individual tool call. A client

View File

@ -189,8 +189,8 @@ writeFileSync("trace-input.json", JSON.stringify({
rea trace-application-feature ./trace-input.json --json
```
MCP analysis returns an envelope containing `result`, `evidence_id`, and full
`evidence`. If the connected server advertises retained references, reuse its
MCP analysis returns the complete Evidence record, with the operation result
in `normalized_result` and its identity in `evidence_id`. If the connected server advertises retained references, reuse its
exact returned ID as `{"kind":"retained-evidence","evidence_id":"RETURNED_ID"}`
in `application`, or `left`/`right` for comparisons. `RETURNED_ID` is a template,
not a literal valid ID. Native Evidence arrays still use complete records.

View File

@ -2,7 +2,7 @@
## Generated catalog
Run `npm run build:cached` in a source checkout to generate the machine-readable
Run `npm run docs:generate` in a source checkout to generate the machine-readable
catalog at `docs/public/product-catalog.json`. Documentation deployments serve
the same file at [/rea/product-catalog.json](/rea/product-catalog.json). PR CI retains it with the packaged
skill and portable conformance projections in the `generated-docs` artifact.
@ -24,28 +24,28 @@ health transition leaves that catalog unchanged and does not emit
`notifications/tools/list_changed`.
Advertised input and output schemas contain no reachable recursive references.
The input compatibility profile limits nesting to ten object, array, and
`anyOf`/`oneOf`/`allOf` levels, following local references per path. Property
maps, reference definitions, and example data do not add schema levels. Tests
check the complete catalog after SDK conversion and its generated counterpart.
This is REA's local compatibility profile; individual model APIs can impose
additional limits.
Schemas share repeated definitions through schema-local references while
preserving complete fields and validation rules. Input properties retain their
descriptions and literal examples. The SDK advertises an object root for
canonical object unions and retains each branch's requirements and exclusions.
Tests validate the schemas after SDK conversion and compare advertised
validation with actual calls. Individual model APIs can impose additional
nesting limits; complete producer captures can exceed ten structural levels.
Input and output schemas share repeated definitions through schema-local
references while preserving their complete fields and validation rules. Input
properties retain their descriptions and literal examples. Root input unions
keep their inline object presentation to avoid redundant branch nesting.
Canonical Zod validation and the input compatibility profile continue to apply.
`compare_web_captures` accepts exactly one of two input shapes:
For passive `compare_web_captures` inputs, pass each complete
`inspect_web_page` result in `before.inspection` or `after.inspection`, with an
optional complete `discover_webmcp_tools` result in the matching `webmcp` field.
The input schema describes these producer-result objects as round-trip payloads.
For scenario comparisons, pass complete `capture_browser_scenario` results in
`before_scenario` and `after_scenario`. REA validates all nested fields with the
original capture schemas; observation output schemas remain complete. Scenario
authoring inputs and comparison normalization options retain their full
advertised structure.
- Passive: `before` and `after` each contain `inspection`, the complete
`normalized_result` from `inspect_web_page`. Each may also contain `webmcp`,
the complete `normalized_result` from `discover_webmcp_tools`, or null.
- Scenario: `before_scenario` and `after_scenario` contain complete
`normalized_result` objects from `capture_browser_scenario`. Optional
`normalization` defaults to `{ "rules": [] }`.
The advertised schema includes complete nested capture fields and rejects
incomplete pairs, mixed comparison families, and structurally malformed
captures. Domain validation additionally checks relationships such as event
sequence references and retained counts; JSON Schema does not express those
cross-field invariants.
Call `binary_session` with `{}` and read `result.tool_availability` to choose a
callable operation for the current target, provider, host, and negotiated client
@ -128,11 +128,11 @@ auto-analysis, bridge connection, and health readiness before returning analysis
These deadlines have different owners:
| Deadline | Owner and effect |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MCP initialize | The client bounds transport/REA connection startup, before any target query. |
| Ghidra startup | REA allows 330,000 ms by default; `REA_GHIDRA_STARTUP_TIMEOUT_MS` accepts 1–2,147,483,647 ms. Invalid values keep the default. Startup failure is reported by the first provider query. |
| Individual tool call | The client bounds its wait, including cold engine startup. The pinned client SDK 2.3.1 defaults to 60,000 ms and can cancel earlier than REA's startup deadline. |
| Deadline | Owner and effect |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MCP initialize | The client bounds transport/REA connection startup, before any target query. |
| Ghidra startup | REA allows 330,000 ms by default; `REA_GHIDRA_STARTUP_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid supplied values fail configuration validation. Startup failure is reported by the first provider query. |
| Individual tool call | The client bounds its wait, including cold engine startup. The pinned client SDK 2.3.1 defaults to 60,000 ms and can cancel earlier than REA's startup deadline. |
For an already connected client using the pinned SDK, request options are the
**second** argument of `callTool`:
@ -188,18 +188,21 @@ increasing a client deadline alone does not fix those failures.
## Tool results
Evidence-producing tools return `{ result, evidence_id, evidence }` in both
text and structured content. `evidence` is the complete canonical Evidence
record, including `normalized_result`, which equals `result`. The same record
is retained in the session bundle. Read `result` directly, or pass `evidence`
to a compatible comparison tool: `analyze_function` Evidence can be passed
Evidence-producing tools return the complete canonical Evidence record in both
text and structured content. Read `structuredContent.normalized_result` for the
operation result and `structuredContent.evidence_id` for its identity. The same
record is retained in the session bundle. `analysis_profile` is always present:
a concrete profile object or `null`; either value participates in semantic
identity. Records omitting this field are rejected. Pass the returned Evidence
directly to a compatible comparison tool: `analyze_function` Evidence can be passed
directly to `compare_functions`, and `inspect_artifact` Evidence to
`compare_artifacts`. Use `get_evidence_bundle` when the task needs broader
retained session history or an explicit bundle for transfer.
REA prepares complete MCP results within the pinned stdio client's 10 MiB
receive-buffer budget, including both text and structured representations and
room for the JSON-RPC envelope. If a result cannot fit, REA returns
room for the JSON-RPC envelope. Response budget settings are captured at startup;
restart or recreate the server to apply changes. If a result cannot fit, REA returns
`resource_constraint` with `details.resource: "transport"` before constructing
a document-sized string. Analysis Evidence remains complete in the current
session. Its exact reference is reported in

View File

@ -1,13 +1,18 @@
# Testing REA
Prefer evidence in this order: full end-to-end workflows with real production
providers, integration tests across data/API boundaries, then golden regressions
from real captured inputs. Keep focused module tests for distinct failure or
semantic cases that these workflows cannot reliably reproduce. A test's path
or suite name does not establish its behavioral depth.
Prefer evidence in this order: full end-to-end workflows through the public
CLI or MCP with real production providers and no mocked dependencies,
integration across production data/API boundaries, then golden regressions
from real captured inputs. Keep a focused module test only for a distinct
failure or semantic case that stronger workflows cannot reliably reproduce.
A test's path, compiled imports, or suite name does not establish its depth.
Avoid tests that mirror getters, enum mappings, fixture helpers, or prescribed
call sequences, or assert cache object identity without a caller-visible contract.
Delete tests that only mirror getters, serialization helpers, enum mappings,
fixture helpers, prescribed call sequences, arbitrary counts or snapshots,
or cache object identity without a caller-visible contract. Do not retain a
weaker test merely because it is fast when a stronger workflow already proves
the same claim. Keep format-specific malformed representations and actual
capacity, cleanup, permission and target-identity regressions.
Prefer representative workflows over a Cartesian matrix when rows cross the
same boundary. Retain combinations that exercise a distinct interaction, such as
filtering within a full-output envelope. Keep distinct command handlers,
@ -458,6 +463,13 @@ Electron GUI behavior or another engine.
## Android APK analysis
The real Android lane also runs on Windows x64 with its matching bundled native
controls. It verifies bidirectional protocol input, CLI/MCP result parity,
real MCP cancellation/disconnect and Java exit after abrupt CLI owner
termination. The forced Windows exit does not exercise the POSIX SIGTERM
handler and can leave temporary workspace files. `verify:windows-native`
separately checks binary input, backpressure, EOF and pending-write job closure.
`npm run verify:android` requires an existing Java 17+ and an explicit
`REA_JADX_MCP_JAR` for jadx-headless-mcp 0.7.1. Set `REA_ANDROID_TEST_APK` to the
fixed public ApiDemos v6.0.18 fixture. Obtain both with the explicit
@ -647,6 +659,17 @@ The dry-run option reports the chosen merge base, scope and build prerequisite
without executing tests or building. A missing Git base reports how to fetch
it or select another revision.
Source typechecking needs no compiled runtime or generated test metadata.
`npm run build:cached` compiles the CLI/MCP runtime and its packaged skill.
`npm run test:prepare` also generates the MCP contract test catalog, product
catalog, and portable managed evidence used by the complete suite. Focused
tests prepare those extra outputs only when their selected files consume them.
The MCP test catalog is JSON in `.cache/mcp-tool-catalog.json`; the tracked
test loader supplies its types without making generation a source-check
prerequisite. Run `npm run mcp-catalog:generate` to refresh it independently.
Documentation generation and managed conformance run through their separate
`docs:generate` and `evidence:generate` task graphs.
Changed selection can miss runtime registration, generated data, shell
entrypoints, bridges, or other relationships absent from the import graph.
An empty changed selection means no tests were selected, not verified

View File

@ -10,8 +10,7 @@ verified manifest into one caller-selected, absent directory.
For passive inspection, select `include_script_sources: true` (CLI:
`--include-script-sources`). For scenarios, select
`capture.network.response_body: true`. Save either the complete Evidence record
or its normalized result as UTF-8 JSON. The compact MCP wrapper's `evidence`
field contains the complete Evidence record.
or its normalized result as UTF-8 JSON. MCP `structuredContent` contains the complete Evidence record.
```sh
rea export-web-scripts /analysis/capture.json /analysis/exported-scripts --json

View File

@ -5,6 +5,7 @@
"src/cli.ts",
"src/contracts/errorSchemas.ts",
"src/evaluation/AgentEvaluationFixtures.ts",
"src/application/RealHopperAssertions.ts",
"tests/processOwnershipRunner.ts"
],
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],

View File

@ -94,7 +94,8 @@ struct Runtime : Resource {
Runtime() : Resource(Kind::Runtime) {}
};
struct Process : Resource {
Handle job, process, stdoutRead, stderrRead;
Handle job, process, stdoutRead, stderrRead, stdinWrite;
bool inputPending = false;
DWORD pid = 0;
Process() : Resource(Kind::Process) {}
};
@ -122,7 +123,8 @@ void closeRuntime(Runtime& runtime);
napi_value filesystemCall(napi_env env, const std::wstring& operation,
const std::vector<napi_value>& args);
std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::wstring& commandLine,
const std::wstring& cwd, const std::vector<std::wstring>& environment);
const std::wstring& cwd, const std::vector<std::wstring>& environment,
bool protocolStdin = false);
napi_value processCall(napi_env env, const std::wstring& operation,
const std::vector<napi_value>& args);
napi_value inspect(napi_env env);

View File

@ -34,16 +34,17 @@ static Handle createJob() {
return job;
}
static void pipe(Handle& read, Handle& write) {
static void pipe(Handle& read, Handle& write, bool parentReads = true) {
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
HANDLE rawRead, rawWrite;
require(CreatePipe(&rawRead, &rawWrite, &attributes, 0), "Create owned process output pipe failed");
read.reset(rawRead); write.reset(rawWrite);
require(SetHandleInformation(read.get(), HANDLE_FLAG_INHERIT, 0), "Protect process output handle inheritance failed");
require(SetHandleInformation(parentReads ? read.get() : write.get(), HANDLE_FLAG_INHERIT, 0), "Protect parent process pipe handle inheritance failed");
}
std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::wstring& commandLine,
const std::wstring& cwd, const std::vector<std::wstring>& environment) {
const std::wstring& cwd, const std::vector<std::wstring>& environment,
bool protocolStdin) {
const auto executable = ordinaryDriveSeparators(command);
require(executable.size() >= 3 && executable[1] == L':' && executable[2] == L'\\',
"Owned Windows process requires an absolute executable path", command, ERROR_INVALID_PARAMETER);
@ -52,9 +53,13 @@ std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::ws
Handle stdoutWrite, stderrWrite;
pipe(result->stdoutRead, stdoutWrite); pipe(result->stderrRead, stderrWrite);
SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
Handle input(CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
&security, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr));
require(input.valid(), "Open owned process null input failed");
Handle input;
if (protocolStdin) pipe(input, result->stdinWrite, false);
else {
input.reset(CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
&security, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr));
require(input.valid(), "Open owned process null input failed");
}
Attributes attributes(2);
HANDLE job = result->job.get();
attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &job, sizeof(job));
@ -198,16 +203,93 @@ static napi_value callerToken(napi_env env) {
return result;
}
struct InputWork {
napi_async_work work = nullptr;
napi_deferred deferred = nullptr;
napi_ref processReference = nullptr;
Process* process;
Handle pipe;
std::vector<unsigned char> bytes;
size_t written = 0;
std::unique_ptr<Failure> failure;
explicit InputWork(Process& process) : process(&process) {}
};
static void writeInput(napi_env, void* pointer) {
auto& work = *static_cast<InputWork*>(pointer);
try {
// Anonymous pipe writes may block. Keep them off the JS thread so output
// polling and job termination remain live while a child stops reading.
while (work.written < work.bytes.size()) {
DWORD written = 0;
const auto count = static_cast<DWORD>(std::min<size_t>(65536, work.bytes.size() - work.written));
require(WriteFile(work.pipe.get(), work.bytes.data() + work.written, count, &written, nullptr),
"Write owned process input failed");
require(written > 0, "Owned process input write made no progress", L"", ERROR_WRITE_FAULT);
work.written += written;
}
} catch (const Failure& failure) {
work.failure = std::make_unique<Failure>(failure);
} catch (const std::exception& failure) {
work.failure = std::make_unique<Failure>(failure.what(), L"", ERROR_GEN_FAILURE);
}
}
static void completeInput(napi_env env, napi_status status, void* pointer) {
std::unique_ptr<InputWork> work(static_cast<InputWork*>(pointer));
work->process->inputPending = false;
if (work->failure) napi_reject_deferred(env, work->deferred, failureValue(env, *work->failure));
else if (status != napi_ok)
napi_reject_deferred(env, work->deferred, failureValue(env, Failure("Owned process input worker was cancelled", L"", ERROR_OPERATION_ABORTED)));
else napi_resolve_deferred(env, work->deferred, number(env, work->written));
napi_delete_reference(env, work->processReference);
napi_delete_async_work(env, work->work);
}
static napi_value inputWrite(napi_env env, napi_value owner, Process& process, napi_value input) {
require(process.stdinWrite.valid(), "Owned process has no writable input", L"", ERROR_BROKEN_PIPE);
require(!process.inputPending, "Owned process input already has a pending write", L"", ERROR_BUSY);
auto work = std::make_unique<InputWork>(process);
void* bytes = nullptr;
size_t size = 0;
bool buffer = false;
check(napi_is_buffer(env, input, &buffer));
require(buffer, "Owned process input must be a Buffer", L"", ERROR_INVALID_PARAMETER);
check(napi_get_buffer_info(env, input, &bytes, &size));
if (size > 0) work->bytes.assign(static_cast<unsigned char*>(bytes), static_cast<unsigned char*>(bytes) + size);
HANDLE pipe;
require(DuplicateHandle(GetCurrentProcess(), process.stdinWrite.get(), GetCurrentProcess(), &pipe,
0, FALSE, DUPLICATE_SAME_ACCESS), "Retain owned process input handle failed");
work->pipe.reset(pipe);
napi_value promise;
check(napi_create_promise(env, &work->deferred, &promise));
check(napi_create_reference(env, owner, 1, &work->processReference));
try {
check(napi_create_async_work(env, nullptr, string(env, std::string("REA owned process input")),
writeInput, completeInput, work.get(), &work->work));
check(napi_queue_async_work(env, work->work));
process.inputPending = true;
} catch (...) {
if (work->work) napi_delete_async_work(env, work->work);
napi_delete_reference(env, work->processReference);
throw;
}
work.release();
return promise;
}
napi_value processCall(napi_env env, const std::wstring& operation, const std::vector<napi_value>& args) {
if (operation == L"process_caller_token") {
require(args.empty(), "Caller token observation takes no arguments", L"", ERROR_INVALID_PARAMETER);
return callerToken(env);
}
if (operation == L"process_spawn") {
require(args.size() == 4, "Wrong native process launch argument count", L"", ERROR_INVALID_PARAMETER);
require(args.size() == 4 || args.size() == 5, "Wrong native process launch argument count", L"", ERROR_INVALID_PARAMETER);
bool protocolStdin = false;
if (args.size() == 5) check(napi_get_value_bool(env, args[4], &protocolStdin));
std::vector<std::wstring> environment;
for (const auto& entry : array(env, args[3])) environment.push_back(wide(env, entry));
auto process = spawnProcess(wide(env, args[0]), wide(env, args[1]), wide(env, args[2]), environment);
auto process = spawnProcess(wide(env, args[0]), wide(env, args[1]), wide(env, args[2]), environment, protocolStdin);
auto result = object(env);
set(env, result, "pid", number(env, process->pid));
set(env, result, "jobAssigned", boolean(env, true));
@ -215,8 +297,16 @@ napi_value processCall(napi_env env, const std::wstring& operation, const std::v
set(env, result, "handle", wrap(env, std::move(process)));
return result;
}
if (operation == L"process_stdin_write") {
require(args.size() == 2, "Wrong native process input argument count", L"", ERROR_INVALID_PARAMETER);
return inputWrite(env, args[0], static_cast<Process&>(resource(env, args[0], Kind::Process)), args[1]);
}
require(args.size() == 1, "Wrong native process handle argument count", L"", ERROR_INVALID_PARAMETER);
auto& process = static_cast<Process&>(resource(env, args[0], Kind::Process));
if (operation == L"process_stdin_close") {
process.stdinWrite.reset();
return null(env);
}
if (operation == L"process_poll") {
auto result = object(env);
bool stdoutEnded = false, stderrEnded = false;
@ -240,7 +330,7 @@ napi_value processCall(napi_env env, const std::wstring& operation, const std::v
}
if (operation == L"process_close") {
// Closing the job is the crash-safe authority; no PID enumeration is used.
process.job.reset(); process.process.reset(); process.stdoutRead.reset(); process.stderrRead.reset();
process.job.reset(); process.process.reset(); process.stdoutRead.reset(); process.stderrRead.reset(); process.stdinWrite.reset();
process.closed = true;
return null(env);
}
@ -266,6 +356,7 @@ napi_value inspect(napi_env env) {
set(env, result, "privateDacl", boolean(env, true));
set(env, result, "atomicJobAssignment", boolean(env, true));
set(env, result, "killOnOwnerClose", boolean(env, true));
set(env, result, "protocolStdin", boolean(env, true));
return result;
}

View File

@ -80,27 +80,26 @@
],
"scripts": {
"deps:check": "node scripts/check-dependency-install.mjs",
"metadata:generate": "node scripts/generate-package-metadata.mjs",
"metadata:generate": "node scripts/run-exclusive.mjs artifacts node scripts/generate-package-metadata.mjs",
"metadata:check": "node scripts/generate-package-metadata.mjs --check",
"mcp-registry:check": "node scripts/verify-mcp-registry-metadata.mjs",
"mcp-catalog:generate": "npm run build:cached",
"mcp-catalog:check": "node scripts/generate-mcp-tool-catalog.mjs --check",
"prebuild": "npm run deps:check && npm run metadata:check",
"build": "node scripts/run-exclusive.mjs build npm run build:unlocked",
"build:unlocked": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs",
"mcp-catalog:generate": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:mcp-catalog",
"mcp-catalog:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run mcp-catalog:check:built",
"prebuild": "npm run deps:check",
"build": "node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills --force",
"precheck": "npm run deps:check",
"check": "turbo run typecheck lint format:check knip metadata:check",
"check": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint format:check knip metadata:check",
"precheck:fast": "npm run deps:check",
"check:fast": "turbo run typecheck lint --output-logs=errors-only",
"precheck:changed": "npm run deps:check",
"check:changed": "npm run check:fast && npm run test:changed",
"precheck:test": "npm run deps:check",
"check:test": "turbo run typecheck lint format:check knip metadata:check test:run",
"check:test": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint format:check knip metadata:check test:run",
"precheck:pr": "npm run deps:check",
"check:pr": "turbo run typecheck lint format:check knip metadata:check test:run && npm run docs:check && npm run jscpd && npm run scan:todos",
"check:pr": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint format:check knip metadata:check test:run && npm run docs:check && npm run jscpd && npm run scan:todos",
"precheck:ci": "npm run deps:check",
"check:ci": "turbo run typecheck lint format:check knip metadata:check jscpd scan:todos",
"build:cached": "node scripts/check-dependency-install.mjs && turbo run build",
"check:ci": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint format:check knip metadata:check jscpd scan:todos",
"build:cached": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"lint": "oxlint && npm run verify:module-boundaries",
@ -110,33 +109,33 @@
"scan:todos": "node scripts/scan-todos.mjs",
"start": "node scripts/rea.mjs mcp",
"test": "npm run test:with-build",
"test:with-build": "npm run build:cached && vitest run",
"test:with-build": "npm run test:prepare && vitest run",
"test:run": "vitest run",
"test:local": "node scripts/run-development-tests.mjs local",
"test:fast": "vitest run --project domain --project services --project adapters --project composition --project conformance --project evaluation",
"pretest:boundary": "npm run build:cached",
"pretest:boundary": "npm run test:prepare",
"test:boundary": "vitest run --project boundary --project process-boundary --project mcp-boundary --project process-global",
"pretest:mcp": "npm run build:cached",
"pretest:mcp": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills artifacts:mcp-catalog",
"test:mcp": "vitest run --project mcp-boundary",
"pretest:acceptance": "npm run build:cached",
"test:acceptance": "vitest run --project acceptance",
"test:changed": "node scripts/run-development-tests.mjs changed",
"pretest:integration": "npm run build:cached",
"pretest:integration": "npm run test:prepare",
"test:integration": "vitest run --project boundary --project process-boundary --project mcp-boundary --project process-global --project acceptance",
"pretest:coverage": "npm run build:cached",
"pretest:coverage": "npm run test:prepare",
"test:coverage": "vitest run --coverage.enabled --coverage.provider=v8",
"test:ci": "npm run test:coverage -- --reporter=default --reporter=junit --outputFile.junit=test-results.xml",
"pretest:ci:shard": "npm run build:cached",
"pretest:ci:shard": "npm run test:prepare",
"test:ci:shard": "npm run test:ci:shard:run",
"test:ci:shard:run": "vitest run --coverage.enabled --coverage.provider=v8 --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 --coverage.thresholds.branches=0 --coverage.thresholds.statements=0 --reporter=default --reporter=blob",
"test:ci:merge": "vitest --merge-reports --coverage.enabled --coverage.provider=v8",
"pretest:verbose": "npm run build:cached",
"pretest:verbose": "npm run test:prepare",
"test:verbose": "vitest run --reporter=verbose",
"test:watch": "vitest --project domain --project services --project adapters --project composition --project conformance --project evaluation --changed",
"pretest:watch:all": "npm run build:cached",
"pretest:watch:all": "npm run test:prepare",
"test:watch:all": "vitest --changed",
"typecheck": "tsc --noEmit",
"verify:test-temp-hygiene": "npm run build:cached && node scripts/verify-test-temp-hygiene.mjs",
"verify:test-temp-hygiene": "npm run test:prepare && node scripts/verify-test-temp-hygiene.mjs",
"verify:hopper": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper.mjs",
"verify:hopper:deadlines": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper-deadlines.mjs",
"verify:hopper:fat": "npm run build:cached && node scripts/verify-real-hopper-fat.mjs",
@ -181,18 +180,17 @@
"probe:windows-capabilities": "npm run build:cached && node scripts/windows-capabilities.mjs",
"verify:published": "node scripts/verify-published-package.mjs",
"config:print": "node scripts/print-mcp-config.mjs",
"evidence:generate": "npm run build:cached && node scripts/generate-completion-ledger.mjs",
"evidence:check": "npm run build:cached && node scripts/generate-completion-ledger.mjs --check",
"evidence:generate": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:managed-evidence",
"evidence:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run evidence:check:built",
"predocs:dev": "npm run docs:generate",
"docs:dev": "vitepress dev docs",
"predocs:build": "npm run docs:generate",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"docs:generate": "node scripts/run-exclusive.mjs docs npm run docs:generate:unlocked",
"docs:generate:unlocked": "npm run metadata:generate && npm run build:cached && node scripts/generate-error-schema.mjs",
"docs:check": "node scripts/run-exclusive.mjs docs turbo run docs:check:built",
"docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-mcp-tool-catalog.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check",
"docs:error-schema": "npm run build:cached && node scripts/generate-error-schema.mjs",
"docs:generate": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts npm run docs:prepare",
"docs:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run docs:check:built",
"docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check",
"docs:error-schema": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:error-schema",
"prepack": "node scripts/prepack.mjs",
"prepare": "node scripts/prepare.mjs",
"verify:asset-catalog": "npm run build:cached && node scripts/verify/apple/asset-catalog.mjs",
@ -217,7 +215,17 @@
"verify:web:network-captures": "npm run build:cached && node scripts/verify-web-network-captures.mjs",
"verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs",
"verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs",
"verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs"
"verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs",
"mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check",
"compile": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json",
"artifacts:mcp-catalog": "node scripts/generate-mcp-tool-catalog.mjs",
"artifacts:skills": "node scripts/generate-skill-metadata.mjs",
"artifacts:product-catalog": "node scripts/generate-product-catalog.mjs",
"artifacts:managed-evidence": "node scripts/generate-completion-ledger.mjs",
"artifacts:error-schema": "node scripts/generate-error-schema.mjs",
"test:prepare": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:mcp-catalog artifacts:product-catalog artifacts:managed-evidence",
"evidence:check:built": "node scripts/generate-completion-ledger.mjs --check",
"docs:prepare": "node scripts/generate-package-metadata.mjs && turbo run artifacts:product-catalog artifacts:managed-evidence artifacts:error-schema"
},
"lint-staged": {
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [

View File

@ -4,7 +4,4 @@ import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
await Promise.all([
rm(join(root, "dist"), { recursive: true, force: true }),
rm(join(root, ".cache", "tsconfig.build.tsbuildinfo"), { force: true }),
]);
await rm(join(root, "dist"), { recursive: true, force: true });

View File

@ -1,13 +1,12 @@
import { mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { formatGeneratedFile } from "./lib/format-generated-file.mjs";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { McpServer } from "@modelcontextprotocol/server";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../dist/contracts/managed/managedWorkflowToolContracts.js";
import { auxiliaryAnalysisProviderDeclarations } from "../dist/composition/auxiliaryAnalysisProviders.js";
import { toolRegistrationOptions } from "../dist/server/toolRegistrationOptions.js";
import { ensureGeneratedFile } from "./lib/generated-file.mjs";
@ -17,10 +16,9 @@ for (const argument of arguments_)
throw new Error(`Unknown MCP tool catalog option: ${argument}`);
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
// This build-generated source supports schema and provider conformance tests;
// This generated metadata supports schema conformance tests;
// production MCP tools are registered from their canonical contracts directly.
// Keep the test metadata portable and regenerate it with `npm run build`.
const CATALOG_PLATFORM = "darwin";
// Keep the test metadata portable and regenerate it with `npm run mcp-catalog:generate`.
const sessionToolNames = new Set([
...TOOL_CONTRACTS.filter(({ kind }) => kind === "session").map(
({ name }) => name,
@ -87,10 +85,6 @@ async function sdkToolCatalog() {
await Promise.allSettled([client.close(), server.close()]);
}
}
const auxiliaryProviders = auxiliaryAnalysisProviderDeclarations(
CATALOG_PLATFORM,
).map(({ identity, capabilities }) => ({ identity, capabilities }));
const canonicalizeJson = (value) => {
if (Array.isArray(value)) return value.map(canonicalizeJson);
if (value === null || typeof value !== "object") return value;
@ -101,101 +95,13 @@ const canonicalizeJson = (value) => {
);
};
const payloadJson = JSON.stringify(
canonicalizeJson({ catalog, auxiliaryProviders }),
null,
2,
);
const source = await formatGeneratedFile(
"src/generatedMcpToolCatalog.ts",
`import type { ToolAnnotations } from "@modelcontextprotocol/server";
import type {
CapabilityDescriptor,
ProviderIdentity,
} from "./application/AnalysisProvider.js";
import type { ToolKind } from "./contracts/toolContractTypes.js";
import type { ToolEffects } from "./contracts/toolEffects.js";
interface GeneratedTool {
readonly name: string;
readonly analysisOperation: CapabilityDescriptor["operation"] | null;
readonly title: string;
readonly description: string;
readonly kind: ToolKind;
readonly requiresSession: boolean;
readonly inputSchema: Readonly<Record<string, unknown>>;
readonly outputSchema: Readonly<Record<string, unknown>>;
readonly annotations: ToolAnnotations;
readonly effects: ToolEffects;
}
interface GeneratedProvider {
readonly identity: ProviderIdentity;
readonly capabilities: readonly CapabilityDescriptor[];
}
interface GeneratedPayload {
readonly catalog: readonly GeneratedTool[];
readonly auxiliaryProviders: readonly GeneratedProvider[];
}
const GENERATED_PAYLOAD_JSON = ${JSON.stringify(payloadJson)};
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
if (!isGeneratedPayload(generatedPayload))
throw new TypeError("Generated MCP catalog payload is invalid");
/** Generated from TOOL_CONTRACTS; do not edit. */
export const GENERATED_MCP_TOOL_CATALOG = generatedPayload.catalog;
/** Generated lightweight metadata for lazily loaded auxiliary providers. */
export const GENERATED_AUXILIARY_PROVIDERS = generatedPayload.auxiliaryProviders;
function isGeneratedPayload(value: unknown): value is GeneratedPayload {
return (
isRecord(value) &&
Array.isArray(value.catalog) &&
value.catalog.every(isGeneratedTool) &&
Array.isArray(value.auxiliaryProviders) &&
value.auxiliaryProviders.every(isGeneratedProvider)
);
}
function isGeneratedTool(value: unknown): value is GeneratedTool {
return (
isRecord(value) &&
typeof value.name === "string" &&
(typeof value.analysisOperation === "string" ||
value.analysisOperation === null) &&
typeof value.title === "string" &&
typeof value.description === "string" &&
typeof value.kind === "string" &&
typeof value.requiresSession === "boolean" &&
isRecord(value.inputSchema) &&
isRecord(value.outputSchema) &&
isRecord(value.annotations) &&
isRecord(value.effects)
);
}
function isGeneratedProvider(value: unknown): value is GeneratedProvider {
return (
isRecord(value) &&
isRecord(value.identity) &&
Array.isArray(value.capabilities)
);
}
function isRecord(
value: unknown,
): value is Readonly<Record<string, unknown>> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
`,
);
const payloadJson = JSON.stringify(canonicalizeJson({ catalog }), null, 2);
const outputPath = join(root, ".cache/mcp-tool-catalog.json");
if (!arguments_.has("--check"))
await mkdir(dirname(outputPath), { recursive: true });
await ensureGeneratedFile({
path: join(root, "src/generatedMcpToolCatalog.ts"),
source,
path: outputPath,
source: `${payloadJson}\n`,
check: arguments_.has("--check"),
generateCommand: "npm run mcp-catalog:generate",
});

View File

@ -75,6 +75,9 @@ export async function withArtifactMcp(target, verify) {
export async function artifactMcpResult(client, name, arguments_ = {}) {
const result = await client.callTool({ name, arguments: arguments_ });
assert.notEqual(result.isError, true, JSON.stringify(result));
assert.ok(result.structuredContent?.result, "MCP omitted structured result");
return result.structuredContent.result;
assert.ok(
result.structuredContent?.normalized_result,
"MCP omitted structured result",
);
return result.structuredContent.normalized_result;
}

View File

@ -48,8 +48,7 @@ export async function verifyBrowserDomDestinations(
});
assert.notEqual(result.isError, true, mcpTextValue(result));
assertFormDestinations(
parseEvidence(JSON.parse(mcpTextValue(result)).evidence)
.normalized_result,
parseEvidence(JSON.parse(mcpTextValue(result))).normalized_result,
documentUrl,
);
return { cli: true, stdio_mcp: true, current_document_destinations: 4 };

View File

@ -105,9 +105,7 @@ export async function verifyBrowserModules(
arguments: input,
});
assert.notEqual(response.isError, true, mcpTextValue(response));
const mcpEvidence = parseEvidence(
JSON.parse(mcpTextValue(response)).evidence,
);
const mcpEvidence = parseEvidence(JSON.parse(mcpTextValue(response)));
const mcpResult = assertTrace(mcpEvidence, oracle, site, scriptIndex);
assert.deepEqual(mcpResult, cliResult, "CLI and MCP module traces differ");
assert.equal(
@ -143,7 +141,7 @@ export async function verifyBrowserModules(
},
}),
),
).evidence,
),
);
const result = webModuleTraceResultSchema.parse(
withoutMap.normalized_result,
@ -236,7 +234,7 @@ export async function verifyBrowserModules(
},
});
assert.notEqual(response.isError, true, mcpTextValue(response));
evidence = parseEvidence(JSON.parse(mcpTextValue(response)).evidence);
evidence = parseEvidence(JSON.parse(mcpTextValue(response)));
}
const result = webModuleTraceResultSchema.parse(
evidence.normalized_result,

View File

@ -112,10 +112,7 @@ export async function verifyBrowserNetworkEvidence(
{ timeout: 60_000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
const evidence = parseEvidence({
...result.structuredContent?.evidence,
normalized_result: result.structuredContent?.result,
});
const evidence = parseEvidence(result.structuredContent);
mcpProof = assertBrowserNetworkEvidence(
browserScenarioCaptureSchema.parse(evidence.normalized_result),
site.origin,
@ -130,10 +127,7 @@ export async function verifyBrowserNetworkEvidence(
{ timeout: 60_000 },
);
assert.notEqual(connected.isError, true, JSON.stringify(connected));
const connectedEvidence = parseEvidence({
...connected.structuredContent?.evidence,
normalized_result: connected.structuredContent?.result,
});
const connectedEvidence = parseEvidence(connected.structuredContent);
assert.deepEqual(
assertBrowserNetworkEvidence(
browserScenarioCaptureSchema.parse(

View File

@ -8,7 +8,7 @@ import { chromium } from "playwright-core";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { PlaywrightBrowserScenarioProvider } from "../../dist/browser/PlaywrightBrowserScenarioProvider.js";
import { createBrowserScenarioProvider } from "../../dist/composition/browserScenario.js";
import { PlaywrightScenarioEvents } from "../../dist/browser/PlaywrightScenarioEvents.js";
import { BrowserScenarioSecrets } from "../../dist/browser/BrowserScenarioSecrets.js";
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
@ -53,9 +53,9 @@ const scenarioFor = (executable, origin, events) =>
});
const capture = async (scenario) => {
const result = await new PlaywrightBrowserScenarioProvider().captureScenario(
scenario,
);
const result = await createBrowserScenarioProvider(
process.env,
).captureScenario(scenario);
if (!result.ok) throw result.error;
assert.ok(
result.value.steps.every((step) => step.status === "completed"),
@ -163,10 +163,7 @@ const verifyTransports = async (executable, origin) => {
{ timeout: 60_000 },
);
assert.notEqual(captured.isError, true, JSON.stringify(captured));
const evidence = parseEvidence({
...captured.structuredContent?.evidence,
normalized_result: captured.structuredContent?.result,
});
const evidence = parseEvidence(captured.structuredContent);
assertNetwork(
browserScenarioCaptureSchema.parse(evidence.normalized_result),
origin,

View File

@ -126,7 +126,7 @@ export async function verifyBrowserRuntime(
mcpTextValue(listenerResponse),
);
const mcpListenerProof = assertListeners(
parseEvidence(JSON.parse(mcpTextValue(listenerResponse)).evidence),
parseEvidence(JSON.parse(mcpTextValue(listenerResponse))),
site,
);
const mcpProof = await executionMcp(
@ -231,7 +231,7 @@ async function executionMcp(
await actionPromise;
assert.notEqual(response.isError, true, mcpTextValue(response));
return assertExecution(
parseEvidence(JSON.parse(mcpTextValue(response)).evidence),
parseEvidence(JSON.parse(mcpTextValue(response))),
site,
requireBlock,
);

View File

@ -95,10 +95,7 @@ export async function verifyLargeScreenshotE2e(endpoint, origin) {
{ timeout: 60000 },
);
assert.notEqual(captured.isError, true, JSON.stringify(captured));
const mcpEvidence = parseEvidence({
...captured.structuredContent?.evidence,
normalized_result: captured.structuredContent?.result,
});
const mcpEvidence = parseEvidence(captured.structuredContent);
const mcpScreenshot = webScreenshotSchema.parse(
mcpEvidence.normalized_result,
);

View File

@ -69,7 +69,7 @@ export async function verifyBrowserScriptExport(
const call = async (name, args) => {
const result = await client.callTool({ name, arguments: args });
assert.notEqual(result.isError, true, mcpTextValue(result));
return parseEvidence(JSON.parse(mcpTextValue(result)).evidence);
return parseEvidence(JSON.parse(mcpTextValue(result)));
};
const mcpCapture = await call("capture_browser_scenario", scenario);
const mcpInput = await save("mcp", mcpCapture);

View File

@ -184,9 +184,7 @@ async function assertPublicPair(cli, client, oracle) {
arguments: input,
});
assert.notEqual(response.isError, true, mcpTextValue(response));
const mcpEvidence = parseEvidence(
JSON.parse(mcpTextValue(response)).evidence,
);
const mcpEvidence = parseEvidence(JSON.parse(mcpTextValue(response)));
assert.deepEqual(assertPoint(mcpEvidence, oracle), expected);
assert.equal(evidence.parameters.source_map.url, input.source_map.url);
return expected;

View File

@ -200,7 +200,7 @@ export const providerCatalog = (sources) => {
sources.webNetworkCaptureContracts.WEB_NETWORK_CAPTURE_TOOL_CONTRACTS,
},
{
identity: sources.browserProvider.CDP_BROWSER_PROVIDER_IDENTITY,
identity: sources.browserIdentities.CDP_BROWSER_PROVIDER_IDENTITY,
contracts: [
...sources.browserContracts.BROWSER_TOOL_CONTRACTS,
...sources.webRuntimeContracts.WEB_RUNTIME_TOOL_CONTRACTS,
@ -208,19 +208,17 @@ export const providerCatalog = (sources) => {
},
{
identity:
sources.browserScenarioProvider
.PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY,
sources.browserIdentities.PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY,
contracts:
sources.browserScenarioContracts.BROWSER_SCENARIO_TOOL_CONTRACTS,
},
{
identity: sources.electronProvider.CDP_ELECTRON_PROVIDER_IDENTITY,
identity: sources.browserIdentities.CDP_ELECTRON_PROVIDER_IDENTITY,
contracts: observationContracts,
},
{
identity:
sources.electronActiveProvider
.PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY,
sources.browserIdentities.PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY,
contracts: activeContracts,
},
{

View File

@ -75,10 +75,7 @@ const SOURCE_PATHS = {
idaProvider: "dist/ida/IdaProviderCapabilities.js",
nativeProvider: "dist/native/NativeMacOSProviderMetadata.js",
artifactProviders: "dist/application/InvestigationProviders.js",
browserProvider: "dist/browser/CdpBrowserProvider.js",
browserScenarioProvider: "dist/browser/PlaywrightBrowserScenarioProvider.js",
electronProvider: "dist/browser/CdpElectronProvider.js",
electronActiveProvider: "dist/browser/PlaywrightElectronActiveProvider.js",
browserIdentities: "dist/browser/providerIdentities.js",
v8InspectorProvider: "dist/inspector/V8InspectorProvider.js",
evidence: "dist/domain/evidence.js",
evidenceBundle: "dist/domain/evidenceBundle.js",

View File

@ -11,11 +11,12 @@ export function parseDevelopmentTestRequest(
arguments_: readonly string[],
): DevelopmentTestRequest;
/** Plan source feedback or a strict explicit run with its build prerequisite. */
/** Plan source feedback or explicit tests with runtime/artifact prerequisites. */
export function developmentTestPlan(
request: DevelopmentTestRequest,
baseCommit?: string,
): {
readonly needsBuild: boolean;
readonly artifactTasks: readonly string[];
readonly vitestArguments: readonly string[];
};

View File

@ -49,7 +49,20 @@ export const parseDevelopmentTestRequest = (mode, arguments_) => {
return { mode, paths: [...new Set(paths)], base, dryRun };
};
/** Plan source feedback or a strict explicit run with its build prerequisite. */
// Test-only outputs are declared beside the test planner rather than becoming
// runtime or typecheck prerequisites. Add a consumer here when it reads one.
const artifactConsumers = {
"artifacts:mcp-catalog": [
"tests/boundary/mcp/contractPresentation.test.ts",
"tests/boundary/mcp/toolSchemaValidity.test.ts",
],
"artifacts:product-catalog": ["tests/boundary/cli/productCatalog.test.ts"],
"artifacts:managed-evidence": [
"tests/boundary/filesystem/setupSkill.test.ts",
],
};
/** Plan source feedback or explicit tests with their runtime/artifact prerequisites. */
export const developmentTestPlan = (request, baseCommit) => {
const explicit = request.paths.length > 0;
if (
@ -60,6 +73,10 @@ export const developmentTestPlan = (request, baseCommit) => {
const projects = request.mode === "focused" ? [] : sourceProjects;
return {
needsBuild: request.paths.some((path) => !sourceTest(path)),
artifactTasks: Object.entries(artifactConsumers).flatMap(
([task, consumers]) =>
request.paths.some((path) => consumers.includes(path)) ? [task] : [],
),
vitestArguments: [
"run",
...projects.flatMap((project) => ["--project", project]),

View File

@ -1,6 +1,7 @@
import {
requireMcpToolError,
requireMcpResult,
requireMcpLifecycleResult,
requireMcpEvidenceResult,
} from "./mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
@ -25,7 +26,7 @@ export async function verifyLegacyGhidraReferenceSnapshot(
client,
{ target, procedure, omittedEdge, entrypoint, env },
) {
const bundle = requireMcpResult(
const bundle = requireMcpLifecycleResult(
await client.callTool({
name: "get_evidence_bundle",
arguments: {},
@ -100,6 +101,7 @@ export async function verifyLegacyGhidraReferenceSnapshot(
},
}),
],
workflow_entries: [],
evidence_bundle: createEvidenceBundle([evidence]),
});
assert.equal(
@ -110,7 +112,7 @@ export async function verifyLegacyGhidraReferenceSnapshot(
const path = join(env.TMPDIR, `legacy-reference-${randomUUID()}.json`);
await writeFile(path, serializeAnalysisSnapshot(snapshot), { flag: "wx" });
try {
const session = requireMcpResult(
const session = requireMcpLifecycleResult(
await client.callTool({ name: "binary_session", arguments: {} }),
"binary_session",
);
@ -131,14 +133,14 @@ export async function verifyLegacyGhidraReferenceSnapshot(
/without this snapshot.*fresh snapshot/u,
);
assert.deepEqual(
requireMcpResult(
requireMcpLifecycleResult(
await client.callTool({ name: "binary_session", arguments: {} }),
"binary_session",
),
session,
"A rejected snapshot must preserve the selected session",
);
const retained = requireMcpResult(
const retained = requireMcpEvidenceResult(
await client.callTool({
name: "analyze_function",
arguments: { procedure },

View File

@ -1,4 +1,5 @@
import { analysisErrorProjectionSchema } from "../../dist/contracts/errorSchemas.js";
import { toolContract } from "../../dist/contracts/toolContracts.js";
/** Read the textual JSON projection from one MCP tool call. */
export const mcpTextValue = (result) => {
@ -15,25 +16,58 @@ export const requireMcpToolError = (result) => {
return analysisErrorProjectionSchema.parse(jsonValue(result).error);
};
/** Return the compact result projection from one successful MCP tool call. */
export const requireMcpResult = (result, operation) => {
if (result.isError === true) {
const requireOutput = (result, operation, contract) => {
if (result.isError === true)
throw new Error(`${operation} failed: ${mcpTextValue(result)}`);
}
const value = jsonValue(result);
if (value === null || typeof value !== "object" || !("result" in value)) {
throw new Error(`${operation} omitted its result projection`);
}
return value.result;
if (contract.name !== operation)
throw new Error(`${operation} received the ${contract.name} contract`);
return contract.outputSchema.parse(jsonValue(result));
};
const operationResultKey = (contract) => {
const shape = contract.outputSchema.shape;
if (shape === undefined)
throw new Error(`${contract.name} has an unsupported MCP output schema`);
const evidence = Object.hasOwn(shape, "normalized_result");
const lifecycle = Object.hasOwn(shape, "result");
if (evidence === lifecycle)
throw new Error(`${contract.name} has an unsupported MCP output schema`);
return evidence ? "normalized_result" : "result";
};
/** Validate an Evidence-producing tool output and return its normalized result. */
export const requireMcpEvidenceResult = (result, operation) => {
const contract = toolContract(operation);
if (operationResultKey(contract) !== "normalized_result")
throw new Error(`${operation} requires the lifecycle result extractor`);
return requireOutput(result, operation, contract).normalized_result;
};
/** Validate a lifecycle tool output and return its operation result. */
export const requireMcpLifecycleResult = (result, operation) => {
const contract = toolContract(operation);
if (operationResultKey(contract) !== "result")
throw new Error(`${operation} requires the Evidence result extractor`);
return requireOutput(result, operation, contract).result;
};
/** Project a mixed verifier call using its exact named canonical output schema. */
export const requireMcpOperationResult = (
result,
operation,
contract = toolContract(operation),
) => {
const key = operationResultKey(contract);
return requireOutput(result, operation, contract)[key];
};
/** Verify provider identity directly from the tool result. */
export const requireEvidenceProvider = (result, operation, providerId) => {
const evidence = jsonValue(result);
if (
evidence?.evidence?.provider?.id !== providerId ||
evidence?.evidence?.analysis_profile?.provider?.id !== providerId ||
typeof evidence.evidence.analysis_profile.provider.version !== "string"
evidence?.provider?.id !== providerId ||
evidence?.analysis_profile?.provider?.id !== providerId ||
typeof evidence.analysis_profile.provider.version !== "string"
) {
throw new Error(`${operation} omitted its concrete provider provenance`);
}
@ -46,10 +80,10 @@ export const requireWorkflowEvidenceProvider = (
expected,
) => {
const evidence = jsonValue(result);
const profile = evidence?.evidence?.analysis_profile;
const profile = evidence?.analysis_profile;
const upstream = profile?.parameters?.upstream_analysis_profile;
if (
evidence?.evidence?.provider?.id !== expected.workflowProviderId ||
evidence?.provider?.id !== expected.workflowProviderId ||
profile?.provider?.id !== expected.workflowProviderId ||
upstream?.provider?.id !== expected.upstreamProviderId ||
typeof upstream.provider.version !== "string"

View File

@ -14,14 +14,6 @@ const MIGRATED_PROVIDER_ROOTS = new Set([
"inspector",
"javascript",
]);
const PROVIDER_ROOTS = new Set([
...MIGRATED_PROVIDER_ROOTS,
"artifacts",
"browser",
"dotnet",
"native",
]);
const repositoryPath = (root, path) =>
relative(root, path).replaceAll("\\", "/");
@ -88,11 +80,6 @@ const failedBoundary = (file, target) => {
/^src\/(?:cli|main)\./u.test(target))
)
return "artifact-acquisition";
if (
PROVIDER_ROOTS.has(layer) &&
/^src\/generatedMcpToolCatalog\.(?:js|ts)$/u.test(target)
)
return "provider-generated-catalog";
if (PURE_LAYERS.has(layer)) {
if (
(/^src\/[^/]+\//u.test(target) && !PURE_LAYERS.has(targetLayer)) ||

View File

@ -179,12 +179,12 @@ async function verifyNativeValueAdapters(
{ timeout: 180000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
const mcpEvidence = parseEvidence(result.structuredContent?.evidence);
const mcpEvidence = parseEvidence(result.structuredContent);
assert.equal(mcpEvidence.operation, "trace_native_values");
assert.equal(mcpEvidence.provider.id, "rea-workflow");
assert.deepEqual(mcpEvidence.analysis_profile, workflowProfile);
const mcpGraph = verifyGraph(
result.structuredContent?.result,
result.structuredContent?.normalized_result,
target,
procedure,
globalAddress,
@ -196,9 +196,7 @@ async function verifyNativeValueAdapters(
{ timeout: 180000 },
);
assert.notEqual(analyzed.isError, true, JSON.stringify(analyzed));
const functionEvidence = parseEvidence(
analyzed.structuredContent?.evidence,
);
const functionEvidence = parseEvidence(analyzed.structuredContent);
assert.equal(functionEvidence.operation, "analyze_function");
assert.equal(functionEvidence.provider.id, "ghidra");
const functionProfile = analysisProfileSchema.parse(
@ -207,26 +205,24 @@ async function verifyNativeValueAdapters(
assert.deepEqual(functionProfile, upstreamProfile);
assert.deepEqual(
functionEvidence.normalized_result,
analyzed.structuredContent?.result,
analyzed.structuredContent?.normalized_result,
);
const comparison = await client.callTool(
{
name: "compare_functions",
arguments: {
left: analyzed.structuredContent.evidence,
right: analyzed.structuredContent.evidence,
left: analyzed.structuredContent,
right: analyzed.structuredContent,
},
},
{ timeout: 180000 },
);
assert.notEqual(comparison.isError, true, JSON.stringify(comparison));
const comparisonEvidence = parseEvidence(
comparison.structuredContent?.evidence,
);
const comparisonEvidence = parseEvidence(comparison.structuredContent);
assert.equal(comparisonEvidence.normalized_result.status, "unchanged");
assert.deepEqual(
comparisonEvidence.normalized_result,
comparison.structuredContent?.result,
comparison.structuredContent?.normalized_result,
);
assert.ok(
comparisonEvidence.evidence_links.includes(functionEvidence.evidence_id),

View File

@ -9,7 +9,7 @@ export async function verifyGhidraAddressBoundaries(client, entry, cli) {
{ timeout: 240000 },
);
assert.notEqual(reply.isError, true, JSON.stringify(reply));
return reply.structuredContent.result;
return reply.structuredContent.normalized_result;
};
const before = await query("analyze_function", { procedure: entry });
// The segmented parser used to discard the high 32 bits and alias this entry.

View File

@ -1,7 +1,7 @@
import {
requireMcpToolError,
mcpTextValue,
requireMcpResult,
requireMcpOperationResult,
} from "./mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
@ -13,6 +13,8 @@ import { promisify } from "node:util";
import Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { analysisErrorProjectionSchema } from "../../dist/contracts/errorSchemas.js";
import { verifyLegacyGhidraReferenceSnapshot } from "./ghidra-reference-snapshot-e2e.mjs";
import { verifyGhidraSnapshotLifecycle } from "./real-ghidra-snapshot-lifecycle.mjs";
import { verifyGhidraTargetAdmission } from "./real-ghidra-target-admission.mjs";
@ -44,7 +46,7 @@ export async function verifyGhidraBoundaries(
let rejectedCalls = 0;
const call = async (name, args = {}) => {
const reply = await client.callTool({ name, arguments: args }, options);
const result = requireMcpResult(reply, name);
const result = requireMcpOperationResult(reply, name);
const validate = validators.get(name);
assert.ok(validate, `${name} missing from catalog`);
assert.ok(

View File

@ -161,6 +161,7 @@ export async function verifyGhidraNamespaceAnnotations({
async function requireNamespaceDemangler(env) {
const inspection = inspectGhidraInstallation({
environment: process.env,
installDir: env.GHIDRA_INSTALL_DIR,
...(env.JAVA_HOME === undefined ? {} : { javaHome: env.JAVA_HOME }),
});

View File

@ -1,6 +1,6 @@
import {
requireMcpToolError,
requireMcpResult,
requireMcpOperationResult,
} from "./mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
@ -20,7 +20,7 @@ export async function verifyGhidraSnapshotLifecycle(
let successfulCalls = 0;
let rejectedCalls = 0;
const call = async (name, args = {}) => {
const result = requireMcpResult(
const result = requireMcpOperationResult(
await client.callTool({ name, arguments: args }, { timeout: 180000 }),
name,
);

View File

@ -6,7 +6,12 @@ import { basename, join } from "node:path";
import { promisify } from "node:util";
import Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { mcpTextValue, requireMcpResult } from "./mcp-verifier-results.mjs";
import {
mcpTextValue,
requireMcpEvidenceResult,
requireMcpOperationResult,
requireMcpLifecycleResult,
} from "./mcp-verifier-results.mjs";
import { verifyHopperCliSelectors } from "./real-hopper-cli-selectors.mjs";
import { verifyLinuxHopperLaunchIsolation } from "./real-hopper-session-isolation.mjs";
import { verifyHopperNavigationAndText } from "./real-hopper-navigation.mjs";
@ -44,7 +49,7 @@ export async function verifyHopperBoundaryContracts(
const rejectedOperations = new Set();
const call = async (name, args = {}) => {
const reply = await client.callTool({ name, arguments: args }, options);
const result = requireMcpResult(reply, name);
const result = requireMcpOperationResult(reply, name);
const validate = validators.get(name);
assert.ok(validate, `${name} is missing from tools/list`);
assert.ok(
@ -301,7 +306,7 @@ export async function verifyHopperLifecycleAndCli(client, options, targets) {
const primary = join(directory, `primary-${suffix}`);
const secondary = join(directory, `secondary-${suffix}`);
const call = async (name, args = {}) =>
requireMcpResult(
requireMcpOperationResult(
await client.callTool({ name, arguments: args }, options),
name,
);
@ -508,14 +513,14 @@ const verifyBatchCancellation = async (client, options, procedures) => {
/aborted/u,
);
assert.ok(cancelled, "cancellation did not follow a real Hopper response");
const recovered = requireMcpResult(
const recovered = requireMcpEvidenceResult(
await client.callTool({ name: "list_procedures", arguments: {} }, options),
"list_procedures after cancellation",
"list_procedures",
);
assert.deepEqual(recovered, procedures);
const status = requireMcpResult(
const status = requireMcpLifecycleResult(
await client.callTool({ name: "binary_session", arguments: {} }, options),
"binary_session after cancellation",
"binary_session",
);
assert.equal(status.provider_operation_health.state, "idle");
};

View File

@ -1,12 +1,12 @@
import assert from "node:assert/strict";
import { requireMcpResult } from "./mcp-verifier-results.mjs";
import { requireMcpEvidenceResult } from "./mcp-verifier-results.mjs";
export const verifyHopperFunctionBasics = async (
client,
options,
procedure,
) => {
const containment = requireMcpResult(
const containment = requireMcpEvidenceResult(
await client.callTool(
{
name: "resolve_containing_procedure",
@ -25,7 +25,7 @@ export const verifyHopperFunctionBasics = async (
);
}
const references = requireMcpResult(
const references = requireMcpEvidenceResult(
await client.callTool(
{
name: "procedure_references",
@ -42,7 +42,7 @@ export const verifyHopperFunctionBasics = async (
throw new Error("procedure_references returned an invalid result");
}
const instructions = requireMcpResult(
const instructions = requireMcpEvidenceResult(
await client.callTool(
{
name: "read_function_instructions",

View File

@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { HOPPER_PROVIDER_IDENTITY } from "../../dist/hopper/HopperProvider.js";
import {
requireMcpResult,
requireMcpEvidenceResult,
requireEvidenceProvider,
} from "./mcp-verifier-results.mjs";
@ -13,7 +13,7 @@ export async function verifyHopperInventories(client, options) {
{ name: operation, arguments: {} },
options,
);
const inventory = requireMcpResult(called, operation);
const inventory = requireMcpEvidenceResult(called, operation);
requireEvidenceProvider(called, operation, HOPPER_PROVIDER_IDENTITY.id);
assert.ok(
Array.isArray(inventory) && inventory.length > 0,
@ -30,7 +30,7 @@ export async function verifyHopperInventories(client, options) {
);
previous = address;
}
const filtered = requireMcpResult(
const filtered = requireMcpEvidenceResult(
await client.callTool(
{
name: operation,

View File

@ -107,7 +107,7 @@ export async function verifyRealHopperFixture({
},
options,
),
"set_comment fixture marker",
"set_comment",
);
if (commentSet !== true)
throw new Error("Hopper did not read back the fixture comment marker");
@ -138,7 +138,7 @@ export async function verifyRealHopperFixture({
},
options,
),
"procedure_references native calls",
"procedure_references",
);
assert.deepEqual(entryDossier.unresolved_calls, observed.unresolved_calls);
for (const call of calls) {
@ -266,7 +266,7 @@ export async function resolveFixtureProcedure(
},
options,
),
`search_procedures ${expectedName}`,
"search_procedures",
);
const exactMatches = Array.isArray(matches)
? matches.filter(
@ -301,5 +301,5 @@ export const analyzeFixtureProcedure = async (
},
options,
),
`analyze_function ${procedure.name}`,
"analyze_function",
);

View File

@ -0,0 +1,237 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
/** Exercise owned Windows stdin through its production Writable and native boundary. */
export async function verifyWindowsProtocolInput(packageRoot, workspace) {
const { WindowsOwnedProcess } = await import(
pathToFileURL(join(packageRoot, "dist/windows/WindowsOwnedProcess.js"))
);
const { requireWindowsNativeAuthority } = await import(
pathToFileURL(join(packageRoot, "dist/windows/WindowsNativeLoader.js"))
);
const native = requireWindowsNativeAuthority();
assert.equal(native.inspection.protocolStdin, true);
const launch = (script) => acquire(WindowsOwnedProcess, workspace, script);
const echo = await verifyEcho(launch);
const cancellation = await verifyBlockedCancellation(launch);
await verifyNativePendingWrite(native, workspace);
const { JadxMcpTransport } = await import(
pathToFileURL(join(packageRoot, "dist/android/JadxMcpTransport.js"))
);
await verifyTransportPendingWrite(JadxMcpTransport, workspace);
return {
echo,
cancellation,
native_pending_write_closed: true,
forged_input_handle_rejected: true,
malformed_input_rejected: true,
provider_transport_pending_write_cancelled: true,
};
}
function acquire(Process, workspace, script) {
const child = new Process(
process.execPath,
["-e", script],
workspace,
process.env,
false,
true,
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (bytes) => {
stdout += bytes.toString();
});
child.stderr.on("data", (bytes) => {
stderr += bytes.toString();
});
const inputErrors = [];
child.stdin.on("error", (cause) => inputErrors.push(cause.message));
const closed = new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => resolve({ code, stdout, stderr }));
});
return { child, closed, inputErrors, stdout: () => stdout };
}
async function verifyEcho(launch) {
const acquired = launch(`
const {createHash} = require('node:crypto');
const hash = createHash('sha256'); let count = 0;
process.stdin.on('data', bytes => { hash.update(bytes); count += bytes.length; });
process.stdin.pause(); setTimeout(() => process.stdin.resume(), 250);
process.stdin.on('end', () => console.log(JSON.stringify({count,sha256:hash.digest('hex')})));
`);
const bytes = Buffer.from(
"caller-selected UTF8: 路径 Ω; binary:\0\n".repeat(60000),
);
const expected = {
count: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
};
let ticks = 0;
const timer = setInterval(() => ticks++, 20);
try {
let writable;
await new Promise((resolve, reject) => {
writable = acquired.child.stdin.write(bytes, (cause) =>
cause ? reject(cause) : resolve(),
);
// The native worker owns its copy before returning to the caller.
bytes.fill(0x5a);
});
assert.equal(
writable,
false,
"Large protocol input did not expose backpressure",
);
assert.ok(ticks > 0, "Input write blocked the JS event loop");
acquired.child.stdin.end();
const result = await deadline(acquired.closed);
assert.equal(result.code, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), expected);
assert.deepEqual(acquired.inputErrors, []);
assert.equal((await acquired.child.cleanup()).cleaned, true);
return {
bytes: expected.count,
sha256: expected.sha256,
backpressure: true,
event_loop_responsive: true,
eof_observed: true,
};
} finally {
clearInterval(timer);
await acquired.child.cleanup();
}
}
async function verifyBlockedCancellation(launch) {
const acquired = launch(`
const {spawn} = require('node:child_process');
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {stdio:'inherit'});
console.log('owned descendant ' + child.pid);
setInterval(() => {}, 1000);
`);
try {
for (
let attempt = 0;
attempt < 100 && !acquired.stdout().includes("owned descendant");
attempt++
)
await delay(25);
assert.match(acquired.stdout(), /owned descendant \d+/u);
const write = new Promise((resolve) =>
acquired.child.stdin.write(Buffer.alloc(2 * 1024 * 1024), (cause) =>
resolve(cause),
),
);
await delay(100);
const cleanup = await acquired.child.cleanup();
assert.equal(cleanup.cleaned, true, cleanup.reason);
assert.ok(
await deadline(write),
"Closing the owned job reported a successful blocked write",
);
await deadline(acquired.closed);
return { blocked_write_cancelled: true, descendant_job_settled: true };
} finally {
await acquired.child.cleanup();
}
}
async function verifyNativePendingWrite(native, workspace) {
const quote = (value) =>
`"${value.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\+)$/u, "$1$1")}"`;
const child = native.call("process_spawn", [
process.execPath,
[process.execPath, "-e", "setInterval(() => {}, 1000)"]
.map(quote)
.join(" "),
workspace,
Object.entries(process.env)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${value}`),
true,
]);
let rejected;
try {
assert.throws(
() => native.call("process_stdin_write", [child.handle, "not a Buffer"]),
/must be a Buffer/u,
);
const pending = native.call("process_stdin_write", [
child.handle,
Buffer.alloc(2 * 1024 * 1024),
]);
rejected = pending.then(
() => false,
(cause) => cause,
);
assert.throws(
() =>
native.call("process_stdin_write", [
child.handle,
Buffer.from("second"),
]),
/pending write/u,
);
assert.throws(
() => native.call("process_stdin_write", [{}, Buffer.from("forged")]),
/does not belong to REA/u,
);
} finally {
// The worker retains its handle/buffer after the JS resource closes.
native.call("process_close", [child.handle]);
}
const failure = await deadline(rejected);
assert.ok(failure instanceof Error);
assert.match(failure.message, /Write owned process input failed/u);
}
async function verifyTransportPendingWrite(Transport, workspace) {
const transport = new Transport({
command: process.execPath,
arguments: ["-e", "setInterval(() => {}, 1000)"],
cwd: workspace,
});
transport.onerror = () => {};
try {
await transport.start();
const pending = transport
.send({
jsonrpc: "2.0",
id: "blocked",
method: "probe",
params: { payload: "x".repeat(2 * 1024 * 1024) },
})
.then(
() => false,
(cause) => cause,
);
await delay(100);
await transport.close();
assert.ok(
await deadline(pending),
"Transport cancellation accepted a blocked write",
);
} finally {
await transport.close();
}
}
async function deadline(promise) {
let timer;
return Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error("Owned input fixture exceeded its deadline")),
10000,
);
}),
]).finally(() => clearTimeout(timer));
}

View File

@ -44,7 +44,6 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
const {
renderCliOutputArgumentError,
renderEmptyFilteredCliOutput,
sanitizeCliOutput,
validateCliOutputArguments,
} = await import("../dist/cliOutput.js");
const outputArguments = validateCliOutputArguments(args);
@ -57,13 +56,11 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
await createCli(process.env, resultOutput).serve(args, {
stdout: (output) => {
if (resultOutput?.handled) {
if (resultOutput.failed)
process.stderr.write(sanitizeCliOutput(output));
if (resultOutput.failed) process.stderr.write(output);
return;
}
const sanitized = sanitizeCliOutput(output);
if (sanitized.length > 0) wroteOutput = true;
process.stdout.write(sanitized);
if (output.length > 0) wroteOutput = true;
process.stdout.write(output);
},
});
if (!wroteOutput && !resultOutput?.handled)

View File

@ -47,15 +47,24 @@ try {
? `Focused tests: ${request.paths.join(", ")}`
: `Source-test feedback${baseCommit ? ` from merge base ${baseCommit}` : ""}; compiled boundaries and real providers need explicit verification.`,
);
if (plan.needsBuild) {
if (!process.env.npm_execpath)
throw new Error("Run compiled tests through npm run test:focused");
const built = await run(process.execPath, [
process.env.npm_execpath,
"run",
"build:cached",
if (plan.needsBuild || plan.artifactTasks.length > 0) {
const dependencies = await run(process.execPath, [
"scripts/check-dependency-install.mjs",
]);
if (built.code !== 0) process.exitCode = built.code;
if (dependencies.code !== 0)
throw new Error(
"Test prerequisites require the locked dependency installation",
);
const prepared = await run(process.execPath, [
"scripts/run-exclusive.mjs",
"artifacts",
process.execPath,
resolve("node_modules/turbo/bin/turbo"),
"run",
...(plan.needsBuild ? ["artifacts:skills"] : []),
...plan.artifactTasks,
]);
if (prepared.code !== 0) process.exitCode = prepared.code;
}
if (!process.exitCode && !shutdownSignal) {
const tested = await run(process.execPath, [

View File

@ -0,0 +1,119 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
const [, , lockName, ...command] = process.argv;
const lockRoot = resolve(process.cwd(), ".cache", "rea-command-locks");
const signalExitCodes = { SIGINT: 130, SIGTERM: 143 };
let database;
let child;
let forceTimer;
let shutdownSignal;
process.on("SIGINT", () => handleSignal("SIGINT"));
process.on("SIGTERM", () => handleSignal("SIGTERM"));
process.on("disconnect", handleDisconnect);
process.on("message", (message) => {
if (message === "SIGINT" || message === "SIGTERM") handleSignal(message);
});
if (!process.connected) {
// The public wrapper can disappear before ESM startup installs listeners.
// A disconnected supervisor has no authority to start a new command.
process.exitCode = signalExitCodes.SIGTERM;
} else if (
typeof lockName !== "string" ||
!/^[a-z][a-z0-9-]*$/u.test(lockName) ||
command.length === 0
) {
console.error("Usage: run-exclusive.mjs <lock-name> <command> [args...]");
process.exitCode = 64;
disconnectParent();
} else {
await run();
}
async function run() {
try {
await mkdir(lockRoot, { recursive: true });
// SQLite owns acquisition and OS-backed lock release, including process
// death. Keep the database pathname stable; deleting it would split owners.
database = new DatabaseSync(join(lockRoot, `${lockName}.sqlite`));
try {
database.exec("BEGIN EXCLUSIVE");
} catch (cause) {
if (isBusyError(cause))
throw new Error(
`Another ${lockName} command is already running. Wait for it to finish.`,
{ cause },
);
throw cause;
}
if (shutdownSignal === undefined) {
const result = await runCommand();
process.exitCode = result.code ?? 1;
}
} catch (cause) {
console.error(cause instanceof Error ? cause.message : String(cause));
process.exitCode = 1;
} finally {
database?.close();
clearTimeout(forceTimer);
if (shutdownSignal !== undefined)
process.exitCode = signalExitCodes[shutdownSignal];
disconnectParent();
}
}
function runCommand() {
return new Promise((resolveResult, reject) => {
child = spawn(command[0], command.slice(1), {
cwd: process.cwd(),
env: process.env,
shell: process.platform === "win32",
stdio: "inherit",
});
child.once("error", reject);
child.once("close", (code) => {
// During POSIX cancellation the group leader must stay alive, retaining
// both its group identity and SQLite ownership until the entire group is
// killed. A completed command can still have uncooperative descendants.
if (shutdownSignal === undefined || process.platform === "win32")
resolveResult({ code });
});
});
}
function disconnectParent() {
process.off("disconnect", handleDisconnect);
if (process.connected) process.disconnect();
}
function handleDisconnect() {
handleSignal("SIGTERM");
}
function handleSignal(signal) {
if (shutdownSignal !== undefined) return;
shutdownSignal = signal;
if (child === undefined || process.platform === "win32") return;
// Establish cancellation before signalling the group. This supervisor is
// its live leader, so the group identifier cannot be reused during cleanup.
process.kill(-process.pid, signal);
forceTimer = setTimeout(() => process.kill(-process.pid, "SIGKILL"), 1_000);
}
function isBusyError(cause) {
return (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ERR_SQLITE_ERROR" &&
"errcode" in cause &&
(cause.errcode === 5 || cause.errcode === 6)
);
}

View File

@ -1,155 +1,40 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const [, , lockName, ...command] = process.argv;
const lockRoot = resolve(process.cwd(), ".cache", "rea-command-locks");
const lockPath = join(lockRoot, `${lockName ?? "invalid"}.lock`);
const ownerPath = join(lockPath, "owner.json");
const signalExitCodes = { SIGINT: 130, SIGTERM: 143 };
let lockHeld = false;
let child;
let forceTimer;
let shutdownSignal;
const supervisor = spawn(
process.execPath,
[
fileURLToPath(new URL("./run-exclusive-command.mjs", import.meta.url)),
...process.argv.slice(2),
],
{
detached: process.platform !== "win32",
stdio: ["inherit", "inherit", "inherit", "ipc"],
},
);
process.on("SIGINT", () => handleSignal("SIGINT"));
process.on("SIGTERM", () => handleSignal("SIGTERM"));
if (
typeof lockName !== "string" ||
!/^[a-z][a-z0-9-]*$/u.test(lockName) ||
command.length === 0
) {
console.error("Usage: run-exclusive.mjs <lock-name> <command> [args...]");
process.exitCode = 64;
} else {
await run();
}
async function run() {
try {
await acquireLock();
const result = await runCommand();
process.exitCode =
shutdownSignal === undefined
? (result.code ?? 1)
: signalExitCodes[shutdownSignal];
} catch (cause) {
console.error(cause instanceof Error ? cause.message : String(cause));
process.exitCode = 1;
} finally {
await releaseLock();
}
}
async function acquireLock() {
await mkdir(lockRoot, { recursive: true });
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await mkdir(lockPath);
await writeFile(
ownerPath,
JSON.stringify({
pid: process.pid,
command: command.join(" "),
started_at: new Date().toISOString(),
}),
"utf8",
);
lockHeld = true;
return;
} catch (cause) {
if (!isAlreadyExistsError(cause)) throw cause;
const owner = await readOwner();
if (owner === null || isProcessAlive(owner.pid))
throw new Error(
`Another ${lockName} command is already running (pid ${String(owner?.pid ?? "unknown")}). Wait for it to finish or inspect ${lockPath}.`,
);
await rm(lockPath, { force: true, recursive: true });
}
}
throw new Error(`Could not acquire the ${lockName} command lock`);
}
async function releaseLock() {
if (!lockHeld) return;
lockHeld = false;
try {
const owner = await readOwner();
if (owner?.pid === process.pid)
await rm(lockPath, { force: true, recursive: true });
} catch (cause) {
console.error(
`Could not remove the ${lockName} command lock: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
}
function runCommand() {
return new Promise((resolveResult, reject) => {
child = spawn(command[0], command.slice(1), {
cwd: process.cwd(),
env: process.env,
shell: process.platform === "win32",
stdio: "inherit",
});
child.once("error", reject);
child.once("close", (code) => {
clearTimeout(forceTimer);
resolveResult({ code });
});
for (const signal of Object.keys(signalExitCodes))
process.on(signal, () => {
if (shutdownSignal !== undefined) return;
shutdownSignal = signal;
if (supervisor.connected)
supervisor.send(signal, (cause) => {
if (cause !== null)
console.error(`Could not forward cancellation: ${cause.message}`);
});
});
}
function handleSignal(signal) {
if (shutdownSignal !== undefined) return;
shutdownSignal = signal;
if (child !== undefined && child.exitCode === null) {
child.kill(signal);
forceTimer = setTimeout(() => child?.kill("SIGKILL"), 1_000);
forceTimer.unref?.();
}
}
async function readOwner() {
try {
const parsed = JSON.parse(await readFile(ownerPath, "utf8"));
const pid =
typeof parsed === "object" && parsed !== null
? Reflect.get(parsed, "pid")
: undefined;
if (typeof pid !== "number" || !Number.isInteger(pid)) return null;
return { pid };
} catch (cause) {
if (isMissingFileError(cause)) return null;
throw cause;
}
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (cause) {
return !isMissingProcessError(cause);
}
}
function isAlreadyExistsError(cause) {
return isNodeError(cause) && cause.code === "EEXIST";
}
function isMissingFileError(cause) {
return isNodeError(cause) && cause.code === "ENOENT";
}
function isMissingProcessError(cause) {
return isNodeError(cause) && cause.code === "ESRCH";
}
function isNodeError(cause) {
return typeof cause === "object" && cause !== null && "code" in cause;
}
supervisor.once("error", (cause) => {
console.error(cause.message);
process.exitCode = 1;
});
supervisor.once("close", (code) => {
process.exitCode =
shutdownSignal === undefined
? (code ?? 1)
: signalExitCodes[shutdownSignal];
});

View File

@ -1254,8 +1254,7 @@ async function inspect(
return value.error;
}
assert.notEqual(response.isError, true, mcpTextValue(response));
envelope = value.evidence;
assert.deepEqual(value.result, envelope.normalized_result);
envelope = value;
} else {
let response;
try {

View File

@ -118,7 +118,7 @@ try {
undefined,
`Unexpected public failure: ${JSON.stringify(value)}`,
);
const evidence = parseEvidence(mode === "cli" ? value : value.evidence);
const evidence = parseEvidence(value);
const report = evidence.normalized_result;
assert.equal(report.diagnostics.truncated, false);
assert.equal(evidence.provider.version, "evmole@0.9.3");
@ -157,7 +157,7 @@ try {
})),
evidence.raw_result.functions ?? [],
);
if (mode === "mcp") assert.deepEqual(value.result, report);
if (mode === "mcp") assert.deepEqual(value.normalized_result, report);
return report;
};
const ownershipProbe = join(root.path, "ownership-prerequisite.hex");

View File

@ -118,9 +118,14 @@ try {
);
const target = await parseBinaryTarget(executable);
if (!target.ok) throw target.error;
const observation = await observeNativeUi(target.value, "observe_native_ui", {
...scope,
});
const observation = await observeNativeUi(
target.value,
"observe_native_ui",
{
...scope,
},
{ environment: process.env },
);
if (
!observation.ok &&
!/accessibility-denied|screen-recording-denied/u.test(
@ -150,6 +155,7 @@ try {
{ kind: "wait", milliseconds: 200 },
],
},
{ environment: process.env },
);
if (!scenario.ok) throw scenario.error;
if (
@ -234,6 +240,7 @@ try {
{ ...target.value, sha256: "0".repeat(64) },
"observe_native_ui",
scope,
{ environment: process.env },
);
if (mismatch.ok || !mismatch.error.message.includes("target-mismatch"))
throw new Error("Real helper did not reject changed target bytes");

View File

@ -8,13 +8,17 @@ export async function verifyPackageCapabilitiesAndSearch({ cli, environment }) {
if (!Array.isArray(capabilities.capabilities))
throw new Error("packaged capabilities CLI failed");
const providers = json(await run(cli, ["providers", "--json"], environment));
const candidates = providers.analysis_provider_candidates;
if (
!Array.isArray(providers.providers) ||
providers.providers.some(({ id }) => typeof id !== "string") ||
!Array.isArray(candidates) ||
candidates.some(
(candidate) =>
typeof candidate?.provider?.id !== "string" ||
candidate.selected !== false,
) ||
providers.analysis_provider_binding !== null ||
providers.analysis_provider_candidates?.find(
({ provider }) => provider?.id === "hopper",
)?.target_support?.status !== "unknown"
candidates.find(({ provider }) => provider?.id === "hopper")?.target_support
?.status !== "unknown"
)
throw new Error("packaged providers CLI failed");
if (process.platform !== "linux") {

View File

@ -97,7 +97,7 @@ const verifyMcpNonLinuxCurrentDocument = async (
mcpOptions,
current,
) => {
if (json(prompts.mcpText(current)).result !== "fixture")
if (json(prompts.mcpText(current)).normalized_result !== "fixture")
throw new Error("packaged MCP bridge call failed");
const batch = await client.callTool(
{
@ -106,7 +106,7 @@ const verifyMcpNonLinuxCurrentDocument = async (
},
mcpOptions,
);
const batchResult = json(prompts.mcpText(batch)).result;
const batchResult = json(prompts.mcpText(batch)).normalized_result;
if (
batch.isError === true ||
batchResult?.total !== 1 ||
@ -176,10 +176,10 @@ const verifyMcpInlineArtifactEvidence = async (
mcpOptions,
);
assert.notEqual(inspected.isError, true, JSON.stringify(inspected));
const source = parseEvidence(inspected.structuredContent?.evidence);
const source = parseEvidence(inspected.structuredContent);
assert.deepEqual(
source.normalized_result,
inspected.structuredContent?.result,
inspected.structuredContent?.normalized_result,
);
assert.deepEqual(
json(prompts.mcpText(inspected)),
@ -189,17 +189,17 @@ const verifyMcpInlineArtifactEvidence = async (
{
name: "compare_artifacts",
arguments: {
left: inspected.structuredContent.evidence,
right: inspected.structuredContent.evidence,
left: inspected.structuredContent,
right: inspected.structuredContent,
},
},
mcpOptions,
);
assert.notEqual(compared.isError, true, JSON.stringify(compared));
const comparison = parseEvidence(compared.structuredContent?.evidence);
const comparison = parseEvidence(compared.structuredContent);
assert.deepEqual(
comparison.normalized_result,
compared.structuredContent?.result,
compared.structuredContent?.normalized_result,
);
assert.ok(comparison.evidence_links.includes(source.evidence_id));
const bundle = await client.callTool(
@ -220,12 +220,12 @@ const verifyMcpInlineArtifactEvidence = async (
mcpOptions,
);
assert.notEqual(extracted.isError, true, JSON.stringify(extracted));
const extraction = parseEvidence(extracted.structuredContent?.evidence);
const extraction = parseEvidence(extracted.structuredContent);
assert.deepEqual(
extraction.normalized_result,
extracted.structuredContent?.result,
extracted.structuredContent?.normalized_result,
);
const materialized = extracted.structuredContent?.result;
const materialized = extracted.structuredContent?.normalized_result;
assert.equal(typeof materialized?.output_root, "string");
outputRoot = materialized.output_root;
assert.equal(materialized.containment_verified, true);

View File

@ -42,12 +42,8 @@ export async function verifyPackagePack({ root, workspace }) {
throw new Error(
"package included authored skill sources instead of only the generated bundle",
);
if (
packedFiles.some((path) =>
/^package\/(?:dist|src)\/generatedMcpToolCatalog\./u.test(path),
)
)
throw new Error("package included the test-only generated MCP catalog");
if (packedFiles.some((path) => path.startsWith("package/.cache/")))
throw new Error("package included development cache or test metadata");
const skill = (
await exec("tar", [
"-xOf",

View File

@ -1,6 +1,7 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
@ -171,7 +172,14 @@ try {
})
).stdout,
);
assert.ok(JSON.stringify(providers).includes("ghidra"));
assert.equal(providers.analysis_provider_binding, null);
assert.ok(Array.isArray(providers.analysis_provider_candidates));
const ghidra = providers.analysis_provider_candidates.find(
({ provider }) => provider.id === "ghidra",
);
assert.ok(ghidra !== undefined);
assert.equal(ghidra.selected, false);
assert.equal(ghidra.target_support.status, "unknown");
report.cli.providers = true;
const inspected = await exec(
process.execPath,
@ -210,12 +218,9 @@ try {
{ timeout: 360_000 },
);
assert.notEqual(result.isError, true, `Packaged operation failed: ${name}`);
const structured =
result.structuredContent ??
JSON.parse(result.content.find((item) => item.type === "text").text);
const parsed = contract.outputSchema.parse(structured);
const projected = requireMcpOperationResult(result, name, contract);
report.mcpOperations.push(name);
return parsed.result;
return projected;
};
try {
await client.connect(transport);

View File

@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url";
import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js";
import { waitForBrowserDevtoolsPort } from "../dist/browser/BrowserProcessStartup.js";
import { PlaywrightBrowserScenarioProvider } from "../dist/browser/PlaywrightBrowserScenarioProvider.js";
import { createBrowserScenarioProvider } from "../dist/composition/browserScenario.js";
import {
inspectWebPageInputSchema,
listBrowserTargetsInputSchema,
@ -240,10 +240,9 @@ try {
try {
attachedScenario = await runScenarioCli(attachedScenarioInput);
} catch (cliError) {
const direct =
await new PlaywrightBrowserScenarioProvider().captureScenario(
attachedScenarioInput,
);
const direct = await createBrowserScenarioProvider(
process.env,
).captureScenario(attachedScenarioInput);
if (!direct.ok) {
const underlying = direct.error.cause;
const details =
@ -274,16 +273,17 @@ try {
throw new Error("Scenario attachment terminated its external browser");
const profilesBefore = await scenarioProfiles();
const launchedScenario =
await new PlaywrightBrowserScenarioProvider().captureScenario(
browserScenario(
{
mode: "launch",
executable_path: executable,
},
site.origin,
),
);
const launchedScenario = await createBrowserScenarioProvider(
process.env,
).captureScenario(
browserScenario(
{
mode: "launch",
executable_path: executable,
},
site.origin,
),
);
if (!launchedScenario.ok) throw launchedScenario.error;
if (
launchedScenario.value.browser.cleanup !== "terminated-owned-process" ||

View File

@ -50,7 +50,9 @@ const input = electronActiveObservationInputSchema.parse({
],
});
const verifierRun = createVerifierRun();
const result = await new PlaywrightElectronActiveProvider().capture(input);
const result = await new PlaywrightElectronActiveProvider(process.env).capture(
input,
);
if (!result.ok) throw result.error;
const output = {

View File

@ -1,5 +1,6 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
@ -28,6 +29,7 @@ if (process.env.GHIDRA_INSTALL_DIR === undefined)
"verify:ghidra:com prerequisite missing: GHIDRA_INSTALL_DIR (bring your own Ghidra).",
);
const installation = inspectGhidraInstallation({
environment: process.env,
installDir: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
@ -331,12 +333,15 @@ async function call(name, args = {}) {
{ timeout: 240000 },
);
assert.notEqual(value.isError, true, JSON.stringify(value));
if (name === "open_binary") return value.structuredContent.result;
if (name === "close_binary") return value.structuredContent;
const evidence = parseEvidence(value.structuredContent.evidence);
const projected = requireMcpOperationResult(value, name);
if (name === "open_binary" || name === "close_binary") return projected;
const evidence = parseEvidence(value.structuredContent);
assert.equal(evidence.subject.digest.sha256, sha256);
assert.deepEqual(evidence.normalized_result, value.structuredContent.result);
return value.structuredContent.result;
assert.deepEqual(
evidence.normalized_result,
value.structuredContent.normalized_result,
);
return projected;
}
async function cli(command, address, options = []) {
const { stdout } = await promisify(execFile)(

View File

@ -1,5 +1,6 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
@ -38,6 +39,7 @@ if (process.env.GHIDRA_INSTALL_DIR === undefined)
"verify:ghidra:dos prerequisite missing: GHIDRA_INSTALL_DIR (bring your own Ghidra).",
);
const installation = inspectGhidraInstallation({
environment: process.env,
installDir: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
@ -552,11 +554,8 @@ async function call(name, arguments_ = {}) {
{ timeout: 240000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
assert.ok(
result.structuredContent && "result" in result.structuredContent,
`${name} omitted structured result`,
);
return result.structuredContent.result;
assert.ok(result.structuredContent, `${name} omitted structured result`);
return requireMcpOperationResult(result, name);
}
async function evidenceCall(name, arguments_) {
const result = await client.callTool(
@ -564,10 +563,7 @@ async function evidenceCall(name, arguments_) {
{ timeout: 240000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
const evidence = parseEvidence({
...result.structuredContent?.evidence,
normalized_result: result.structuredContent?.result,
});
const evidence = parseEvidence(result.structuredContent);
assert.equal(evidence.subject.digest.sha256, sha256);
assert.equal(evidence.provider.id, "ghidra");
assertProfile(evidence.analysis_profile);

View File

@ -1,5 +1,6 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
@ -392,12 +393,13 @@ async function call(name, args = {}) {
{ timeout: 360000 },
);
assert.notEqual(response.isError, true, JSON.stringify(response));
if (name === "close_binary") return response.structuredContent;
const evidence = parseEvidence(response.structuredContent.evidence);
const projected = requireMcpOperationResult(response, name);
if (name === "close_binary") return projected;
const evidence = parseEvidence(response.structuredContent);
assert.equal(evidence.subject.digest.sha256, sha256);
assert.deepEqual(
evidence.normalized_result,
response.structuredContent.result,
response.structuredContent.normalized_result,
);
return response.structuredContent.result;
return projected;
}

View File

@ -10,12 +10,13 @@ import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
import { toolContract } from "../dist/contracts/toolContracts.js";
import { requireMcpResult } from "./lib/mcp-verifier-results.mjs";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
const execute = promisify(execFile);
const run = createVerifierRun();
const installation = inspectGhidraInstallation({
environment: process.env,
installDir: process.env.GHIDRA_INSTALL_DIR,
javaHome: process.env.JAVA_HOME,
});
@ -156,5 +157,5 @@ async function call(name, arguments_) {
);
if (name === "analyze_swift_types")
toolContract(name).outputSchema.parse(response.structuredContent);
return requireMcpResult(response, name);
return requireMcpOperationResult(response, name);
}

View File

@ -1,5 +1,6 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
@ -74,6 +75,7 @@ if (!compilerOnly) {
"Ghidra switch lane prerequisite missing: GHIDRA_INSTALL_DIR (bring your own)",
);
installation = inspectGhidraInstallation({
environment: process.env,
installDir: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
@ -237,15 +239,21 @@ async function verifyTarget(path, sha256, fixtures, variant) {
const observed = await toolResult(client, "inspect_native_api", {
procedure: procedure.address,
});
assertWorkflowIdentity(observed.evidence, sha256);
assert.equal(observed.result.procedure.address, procedure.address);
assertWorkflowIdentity(observed, sha256);
assert.equal(
observed.normalized_result.procedure.address,
procedure.address,
);
const summary = assertSwitchBoundary(
observed.result.boundary,
observed.normalized_result.boundary,
observationFixture,
);
assert.deepEqual(
[...observed.result.residual_unknowns].sort(),
expectedResidualUnknowns(observed.result.boundary, fixture).sort(),
[...observed.normalized_result.residual_unknowns].sort(),
expectedResidualUnknowns(
observed.normalized_result.boundary,
fixture,
).sort(),
"Residual unknowns must preserve unresolved ABI types and exactly the expected switch uncertainty",
);
const cli = await cliSwitchEvidence(
@ -259,20 +267,20 @@ async function verifyTarget(path, sha256, fixtures, variant) {
assertSwitchBoundary(cli.normalized_result.boundary, observationFixture);
assert.deepEqual(
cli.normalized_result.boundary.jump_tables,
observed.result.boundary.jump_tables,
observed.normalized_result.boundary.jump_tables,
"CLI/MCP switch mappings/defaults/data-source evidence differ",
);
assert.deepEqual(
cli.normalized_result.residual_unknowns,
observed.result.residual_unknowns,
observed.normalized_result.residual_unknowns,
);
observations.push({
name: fixture.name,
...summary,
cli_mcp_equal: true,
limitations: observed.result.boundary.limitations,
residual_unknowns: observed.result.residual_unknowns,
jump_tables: observed.result.boundary.jump_tables,
limitations: observed.normalized_result.boundary.limitations,
residual_unknowns: observed.normalized_result.residual_unknowns,
jump_tables: observed.normalized_result.boundary.jump_tables,
});
}
return { observations, stderr };
@ -319,14 +327,15 @@ async function toolResult(client, name, arguments_ = {}) {
{ timeout: 240000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
assert.ok(
result.structuredContent && "result" in result.structuredContent,
`${name} omitted structured result`,
);
assert.ok(result.structuredContent, `${name} omitted structured result`);
return result.structuredContent;
}
async function call(client, name, arguments_ = {}) {
return (await toolResult(client, name, arguments_)).result;
const result = await client.callTool(
{ name, arguments: arguments_ },
{ timeout: 240000 },
);
return requireMcpOperationResult(result, name);
}
async function cliSwitchEvidence(entrypoint, path, procedure, env) {

View File

@ -48,6 +48,7 @@ if (installDir === undefined || !isAbsolute(installDir))
"Set GHIDRA_INSTALL_DIR to the absolute root of Ghidra 12.1.4.",
);
const installation = inspectGhidraInstallation({
environment: process.env,
installDir,
...(process.env.JAVA_HOME === undefined
? {}
@ -94,6 +95,7 @@ if (!profile.ok || profile.value.profile === null)
const client = new GhidraClient({
platform: installation.platform,
launcher: new GhidraHeadlessLauncher({
environment: process.env,
analyzeHeadlessPath: installation.analyzeHeadlessPath,
...(process.env.JAVA_HOME === undefined
? {}
@ -112,6 +114,7 @@ const client = new GhidraClient({
let coordinates;
let report;
let primaryFailure;
const observed = new Set();
try {
const started = await client.start();
@ -257,8 +260,18 @@ try {
"no-gui-or-mutation-authority",
],
};
} catch (cause) {
primaryFailure = cause;
throw cause;
} finally {
await client.close();
const closed = await client.close();
if (!closed.ok)
throw primaryFailure === undefined
? closed.error
: new AggregateError(
[primaryFailure, closed.error],
"Ghidra verification and cleanup failed",
);
if (coordinates !== undefined) await assertCleanup(coordinates);
}
if (report === undefined)

View File

@ -50,6 +50,7 @@ if (installDir === undefined || !isAbsolute(installDir))
"Set GHIDRA_INSTALL_DIR to the absolute root of an extracted Ghidra 12.1.4 release.",
);
const installation = inspectGhidraInstallation({
environment: process.env,
installDir,
...(process.env.JAVA_HOME === undefined
? {}
@ -442,6 +443,7 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
const client = new GhidraClient({
launcher: new GhidraHeadlessLauncher({
environment: process.env,
analyzeHeadlessPath: installation.analyzeHeadlessPath,
...(process.env.JAVA_HOME === undefined
? {}
@ -458,6 +460,7 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
});
let runtimeCoordinates;
let primaryFailure;
try {
const started = await client.start();
if (!started.ok) throw started.error;
@ -539,8 +542,18 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
native_api_cli: nativeApiCli,
native_values: nativeValues,
};
} catch (cause) {
primaryFailure = cause;
throw cause;
} finally {
await client.close();
const closed = await client.close();
if (!closed.ok)
throw primaryFailure === undefined
? closed.error
: new AggregateError(
[primaryFailure, closed.error],
"Ghidra verification and cleanup failed",
);
if (runtimeCoordinates !== undefined)
await assertCleanup(runtimeCoordinates);
}

View File

@ -27,6 +27,7 @@ const root = await realpath(
);
const target = join(root, "deadline-fixture");
let client;
let primaryFailure;
try {
await copyFile(fixtures.primary.path, target);
const parsed = await parseBinaryTarget(target);
@ -40,14 +41,27 @@ try {
provider: HOPPER_PROVIDER_IDENTITY,
});
if (!resolved.ok) throw resolved.error;
const image = resolved.value.profile?.parameters.prepared_image;
const profile = resolved.value.profile;
assert.notEqual(
profile,
null,
"This lane requires a resolved Hopper launcher profile",
);
const loaderArgs = profile.parameters.loader?.arguments;
assert.ok(
Array.isArray(loaderArgs) &&
loaderArgs.every((argument) => typeof argument === "string"),
"The Hopper profile must report its effective loader arguments",
);
const image = profile.parameters.prepared_image;
client = new HopperClient({
launcher: new HopperApplicationLauncher({
environment: process.env,
launcherPath,
targetPath: target,
targetKind: "executable",
launchMode: "native",
loaderArgs: resolved.value.compatibility.loaderArgs,
loaderArgs,
...(image === undefined
? {}
: {
@ -102,7 +116,7 @@ try {
const activityAfterDeadline = client.requestActivity();
await call("health");
assert.equal(client.requestActivity(), null);
const closed = await client.closeWithOutcome();
const closed = await client.close();
if (!closed.ok) throw closed.error;
console.log(
JSON.stringify(
@ -120,7 +134,19 @@ try {
2,
),
);
} catch (cause) {
primaryFailure = cause;
throw cause;
} finally {
if (client !== undefined) await client.close();
if (client !== undefined) {
const closed = await client.close();
if (!closed.ok)
throw primaryFailure === undefined
? closed.error
: new AggregateError(
[primaryFailure, closed.error],
"Hopper verification and cleanup failed",
);
}
await rm(root, { recursive: true, force: true });
}

View File

@ -18,7 +18,10 @@ import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { mcpTextValue, requireMcpResult } from "./lib/mcp-verifier-results.mjs";
import {
mcpTextValue,
requireMcpOperationResult,
} from "./lib/mcp-verifier-results.mjs";
import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/hopper/HopperTargetLease.js";
import { parseConfig } from "../dist/config.js";
@ -219,7 +222,7 @@ try {
{ name, arguments: args },
{ timeout: 180_000 },
);
const result = requireMcpResult(reply, name);
const result = requireMcpOperationResult(reply, name);
const schema = catalog.tools.find(
(tool) => tool.name === name,
)?.outputSchema;

View File

@ -27,7 +27,9 @@ import {
import {
mcpTextValue,
requireEvidenceProvider,
requireMcpResult,
requireMcpOperationResult,
requireMcpLifecycleResult,
requireMcpEvidenceResult,
requireWorkflowEvidenceProvider,
} from "./lib/mcp-verifier-results.mjs";
import {
@ -64,7 +66,6 @@ const sessionsBefore = await snapshotHopperRuntime(
);
const ownedProcessIds = new Set();
const textValue = mcpTextValue;
const requireSuccessfulTool = requireMcpResult;
const requireOverview = (value, operation) => {
if (
@ -87,7 +88,7 @@ const verifyRelationships = async (client, options, procedure) => {
const related = {};
for (const operation of ["procedure_callers", "procedure_callees"]) {
related[operation] = requireAddressArray(
requireSuccessfulTool(
requireMcpEvidenceResult(
await client.callTool(
{ name: operation, arguments: { procedure } },
options,
@ -98,7 +99,7 @@ const verifyRelationships = async (client, options, procedure) => {
);
}
related.xrefs = requireAddressArray(
requireSuccessfulTool(
requireMcpEvidenceResult(
await client.callTool(
{ name: "xrefs", arguments: { address: procedure } },
options,
@ -111,7 +112,7 @@ const verifyRelationships = async (client, options, procedure) => {
};
const verifyByteAccess = async (client, options, firstAddress) => {
const byteRead = requireSuccessfulTool(
const byteRead = requireMcpEvidenceResult(
await client.callTool(
{ name: "read_bytes", arguments: { address: firstAddress, length: 16 } },
options,
@ -127,7 +128,7 @@ const verifyByteAccess = async (client, options, firstAddress) => {
) {
throw new Error("read_bytes returned an invalid bounded byte window");
}
const mappedOffset = requireSuccessfulTool(
const mappedOffset = requireMcpEvidenceResult(
await client.callTool(
{
name: "address_to_file_offset",
@ -151,7 +152,7 @@ const verifyByteAccess = async (client, options, firstAddress) => {
};
const verifyCurrentTarget = async (client, options) => {
const procedures = requireSuccessfulTool(
const procedures = requireMcpEvidenceResult(
await client.callTool({ name: "list_procedures", arguments: {} }, options),
"list_procedures",
);
@ -167,7 +168,7 @@ const verifyCurrentTarget = async (client, options) => {
options,
firstAddress,
);
const boundedResult = requireSuccessfulTool(
const boundedResult = requireMcpEvidenceResult(
await client.callTool(
{
name: "batch_decompile",
@ -196,7 +197,7 @@ const verifyCurrentTarget = async (client, options) => {
"batch_decompile",
);
const dossier = requireFunctionDossier(
requireSuccessfulTool(
requireMcpEvidenceResult(
await client.callTool(
{
name: "analyze_function",
@ -274,7 +275,10 @@ try {
const fullSessionStatus = () =>
client.callTool({ name: "binary_session", arguments: {} }, options);
const initialSession = await fullSessionStatus();
const initialStatus = requireMcpResult(initialSession, "binary_session");
const initialStatus = requireMcpLifecycleResult(
initialSession,
"binary_session",
);
const listed = await client.listTools();
const availableTools = initialStatus.tool_availability;
if (!Array.isArray(availableTools))
@ -300,7 +304,7 @@ try {
options,
);
if (opened.isError === true) throw new Error(textValue(opened));
const firstStatus = requireMcpResult(
const firstStatus = requireMcpLifecycleResult(
await fullSessionStatus(),
"binary_session",
);
@ -312,7 +316,7 @@ try {
{ name: "list_documents", arguments: {} },
options,
);
requireSuccessfulTool(documents, "list_documents");
requireMcpEvidenceResult(documents, "list_documents");
requireBridgeProgress(progressUpdates);
const rejectedProcedure = await client.callTool(
{
@ -327,7 +331,7 @@ try {
{ name: "binary_overview", arguments: {} },
options,
);
requireSuccessfulTool(overview, "binary_overview");
requireMcpEvidenceResult(overview, "binary_overview");
const inventoryCounts = await verifyHopperInventories(client, options);
requireEvidenceProvider(
documents,
@ -343,9 +347,9 @@ try {
options,
);
requireTruthfulMemoryRegions(
requireSuccessfulTool(segments, "list_segments"),
requireMcpEvidenceResult(segments, "list_segments"),
);
const firstDocuments = requireSuccessfulTool(documents, "list_documents");
const firstDocuments = requireMcpEvidenceResult(documents, "list_documents");
if (
!Array.isArray(firstDocuments) ||
firstDocuments.length === 0 ||
@ -356,10 +360,10 @@ try {
client,
options,
firstDocuments,
requireSuccessfulTool,
requireMcpEvidenceResult,
);
const firstOverview = requireOverview(
requireSuccessfulTool(overview, "binary_overview"),
requireMcpEvidenceResult(overview, "binary_overview"),
"binary_overview",
);
if (firstOverview.document !== currentDocument)
@ -372,7 +376,7 @@ try {
options,
document: currentDocument,
oracle: fixtureTargets.oracle,
normalizedResult: requireSuccessfulTool,
normalizedResult: requireMcpEvidenceResult,
});
const boundaryContracts = await verifyHopperBoundaryContracts(
client,
@ -385,7 +389,7 @@ try {
options,
);
if (switched.isError === true) throw new Error(textValue(switched));
const secondSession = requireMcpResult(
const secondSession = requireMcpLifecycleResult(
await fullSessionStatus(),
"binary_session",
);
@ -400,19 +404,19 @@ try {
options,
);
const verifiedSecondOverview = requireOverview(
requireSuccessfulTool(secondOverview, "binary_overview"),
requireMcpEvidenceResult(secondOverview, "binary_overview"),
"binary_overview after target switch",
);
const secondAnalysis = await verifyCurrentTarget(client, options);
const documentsAfterTargetSwitch = requireMcpResult(
const documentsAfterTargetSwitch = requireMcpEvidenceResult(
await client.callTool({ name: "list_documents", arguments: {} }, options),
"list_documents after target switch",
"list_documents",
);
const secondDocument = await requireCurrentDocument(
client,
options,
documentsAfterTargetSwitch,
requireSuccessfulTool,
requireMcpEvidenceResult,
);
if (verifiedSecondOverview.document !== secondDocument)
throw new Error(
@ -432,9 +436,9 @@ try {
);
if (reopenedTarget.isError === true)
throw new Error(textValue(reopenedTarget));
const documentsAfterReopen = requireMcpResult(
const documentsAfterReopen = requireMcpEvidenceResult(
await client.callTool({ name: "list_documents", arguments: {} }, options),
"list_documents after reopening target B",
"list_documents",
);
if (
!Array.isArray(documentsAfterReopen) ||
@ -446,7 +450,7 @@ try {
const largeInventory = await openAndVerifyLargeFixture({
client,
options,
normalizedResult: requireSuccessfulTool,
normalizedResult: requireMcpEvidenceResult,
path: largeTarget,
expectedCount: fixtureTargets.largeOracle.symbolCount,
symbolPrefix: fixtureTargets.largeOracle.symbolPrefix,
@ -471,7 +475,7 @@ try {
let terminalInstructions = null;
if (fixtureTargets.unicode !== undefined) {
const call = async (name, args = {}) =>
requireSuccessfulTool(
requireMcpOperationResult(
await client.callTool({ name, arguments: args }, options),
name,
);
@ -492,7 +496,7 @@ try {
: { unicode: fixtureTargets.unicode.path }),
ownedProcessIds,
});
const closedSession = requireMcpResult(
const closedSession = requireMcpLifecycleResult(
await fullSessionStatus(),
"binary_session",
);

View File

@ -145,11 +145,11 @@ const mcp = async (cliDossier) => {
const analyzed = await call("analyze_function", {
procedure: values.procedure,
});
const dossier = functionDossierSchema.parse(observe(analyzed.evidence));
const dossier = functionDossierSchema.parse(observe(analyzed));
assert.deepEqual(dossier.procedure, cliDossier.procedure);
assert.equal(dossier.pseudocode, cliDossier.pseudocode);
assert.deepEqual(dossier.assembly, cliDossier.assembly);
assert.equal(analyzed.evidence_id, analyzed.evidence.evidence_id);
assert.equal(analyzed.operation, "analyze_function");
for (const [name, args] of [
["list_procedures", {}],
["list_strings", {}],
@ -166,7 +166,7 @@ const mcp = async (cliDossier) => {
["xrefs", { address: dossier.procedure.address }],
]) {
const result = await call(name, args);
observe(result.evidence);
observe(result);
}
const invalid = await client.callTool({
name: "xrefs",
@ -191,7 +191,7 @@ const mcp = async (cliDossier) => {
};
const cleanup = async () => {
phase = "upstream lifecycle verification";
const upstream = createIdaMcpConnection(config);
const upstream = createIdaMcpConnection(config, process.env);
try {
await upstream.connect();
if (config.mode === "attached") {

View File

@ -912,8 +912,8 @@ async function inspect(mode, input, errorCategory) {
return value.error;
}
assert.notEqual(result.isError, true, mcpTextValue(result));
assertSensitiveLimitations(value.evidence, input.sensitive_values ?? []);
return value.result;
assertSensitiveLimitations(value, input.sensitive_values ?? []);
return value.normalized_result;
}
const args = [
entrypoint,

View File

@ -17,6 +17,7 @@ import {
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { verifyWindowsProtocolInput } from "./lib/windows-protocol-input.mjs";
if (process.platform !== "win32")
throw new Error(
@ -411,6 +412,10 @@ setInterval(()=>{},1000);\n`,
await delay();
assert.equal(live(ownerClose.pid), false);
report.processes.killOnOwnerClose = true;
report.processes.protocolInput = await verifyWindowsProtocolInput(
packageRoot,
workspace,
);
const exited = launch([
"--max-old-space-size=128",
"-e",

View File

@ -13,6 +13,8 @@ import fixture from "../../fixtures/android/apidemos.json" with { type: "json" }
import { verifyAndroidCliCancellation } from "./cli-cancellation.mjs";
import { verifyAndroidMetadata } from "./metadata.mjs";
import { verifyAndroidFailureRecovery } from "./failure-recovery.mjs";
import { resolveJadxConfiguration } from "../../../dist/android/JadxConfiguration.js";
import { verifyWindowsAndroidLifecycle } from "./windows-lifecycle.mjs";
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const apk = process.env.REA_ANDROID_TEST_APK;
@ -33,10 +35,10 @@ assert.equal(
JADX_RELEASE.sha256,
"Real Android lane requires the audited headless JADX release JAR",
);
const java =
process.env.JAVA_HOME === undefined
? "java"
: resolve(process.env.JAVA_HOME, "bin/java");
const { java, jvmArguments } = await resolveJadxConfiguration(
process.env,
"inspect_android_package",
);
const execute = promisify(execFile);
await execute(java, ["-version"], { timeout: 10_000 });
const entrypoint =
@ -49,6 +51,7 @@ await verifyAndroidMetadata({
apk: path,
repository,
execute,
jvmArguments,
bridge: resolve(dirname(entrypoint), "../bridge/android/ReaJadxBridge.java"),
});
const tasks = [
@ -136,8 +139,8 @@ try {
);
assert.notEqual(response.isError, true, JSON.stringify(response));
const structured = response.structuredContent;
assert.ok(structured !== undefined && structured.evidence !== undefined);
const evidence = parseEvidence(structured.evidence);
assert.ok(structured !== undefined);
const evidence = parseEvidence(structured);
assert.deepEqual(
evidence.normalized_result,
cliResults.get(task.name).normalized_result,
@ -156,7 +159,7 @@ try {
cliResults.get(task.name).limitations,
);
assert.equal(evidence.confidence, cliResults.get(task.name).confidence);
assert.deepEqual(structured.result, evidence.normalized_result);
assert.deepEqual(structured.normalized_result, evidence.normalized_result);
console.log(`PASS MCP/CLI parity ${task.name}`);
}
} catch (cause) {
@ -200,15 +203,22 @@ assert.ok(
reference.dex_offset === null && reference.source_line === null,
),
);
await verifyAndroidCliCancellation({
const lifecycleOptions = {
entrypoint,
environment,
path,
repository,
execute,
});
};
const windowsLifecycle =
process.platform === "win32"
? await verifyWindowsAndroidLifecycle(lifecycleOptions)
: undefined;
if (windowsLifecycle === undefined)
await verifyAndroidCliCancellation(lifecycleOptions);
await verifyAndroidFailureRecovery({
java,
jvmArguments,
entrypoint,
environment,
path,
@ -230,7 +240,10 @@ const report = {
cli_mcp_parity: true,
original_apk_unchanged: true,
apk_executed: false,
cli_sigterm_cleanup: true,
cli_sigterm_cleanup: process.platform !== "win32",
...(windowsLifecycle === undefined
? {}
: { windows_lifecycle: windowsLifecycle }),
metadata_without_code_generation: true,
stable_overload_selection: true,
};

View File

@ -17,6 +17,7 @@ import { TOOL_CONTRACTS } from "../../../dist/contracts/toolContracts.js";
/** Real unusable Java runtimes and selectors must lead to applicable recovery. */
export async function verifyAndroidFailureRecovery({
java,
jvmArguments = [],
entrypoint,
environment,
path,
@ -25,6 +26,8 @@ export async function verifyAndroidFailureRecovery({
fixture,
}) {
const root = await mkdtemp(join(tmpdir(), "rea-android-recovery-"));
const javaName = process.platform === "win32" ? "java.exe" : "java";
const jlinkName = process.platform === "win32" ? "jlink.exe" : "jlink";
try {
const withoutJar = { ...environment };
delete withoutJar.REA_JADX_MCP_JAR;
@ -37,10 +40,10 @@ export async function verifyAndroidFailureRecovery({
});
let jlink;
for (const candidate of isAbsolute(java)
? [join(dirname(java), "jlink")]
? [join(dirname(java), jlinkName)]
: (environment.PATH ?? "")
.split(delimiter)
.map((directory) => join(directory, "jlink"))) {
.map((directory) => join(directory, jlinkName))) {
try {
await access(candidate);
jlink = candidate;
@ -54,10 +57,22 @@ export async function verifyAndroidFailureRecovery({
"Android failure-recovery verification requires the full JDK's jlink via JAVA_HOME or PATH.",
);
const jre = join(root, "jre");
await execute(jlink, ["--add-modules", "java.base", "--output", jre], {
timeout: 60_000,
await execute(
jlink,
[
...jvmArguments.map((argument) => `-J${argument}`),
"--add-modules",
"java.base",
"--output",
jre,
],
{
timeout: 60_000,
},
);
await execute(join(jre, "bin", javaName), ["-version"], {
timeout: 10_000,
});
await execute(join(jre, "bin/java"), ["-version"], { timeout: 10_000 });
const empty = join(root, "empty-path");
await mkdir(empty);
if (process.platform === "linux") {
@ -75,6 +90,8 @@ export async function verifyAndroidFailureRecovery({
}
const withoutJavaHome = { ...environment };
delete withoutJavaHome.JAVA_HOME;
for (const key of Object.keys(withoutJavaHome))
if (key.toLowerCase() === "path") delete withoutJavaHome[key];
const invalidJar = join(root, "invalid.jar");
await writeFile(invalidJar, "not a Java archive");
const environments = [
@ -86,7 +103,8 @@ export async function verifyAndroidFailureRecovery({
{
label: "missing java",
env: { ...withoutJavaHome, PATH: empty },
observation: "ENOENT",
observation:
process.platform === "win32" ? "Cannot find java.exe" : "ENOENT",
},
{
label: "invalid engine archive with a working JDK",
@ -224,7 +242,7 @@ export async function verifyAndroidFailureRecovery({
);
assert.notEqual(control.isError, true, JSON.stringify(control));
assert.equal(
control.structuredContent.result.package_name,
control.structuredContent.normalized_result.package_name,
fixture.package,
);
console.log("PASS MCP valid package after selector failures");

View File

@ -11,11 +11,16 @@ export const verifyAndroidMetadata = async ({
bridge,
repository,
execute,
jvmArguments = [],
}) => {
const javac =
process.env.JAVA_HOME === undefined
? "javac"
: resolve(process.env.JAVA_HOME, "bin/javac");
: resolve(
process.env.JAVA_HOME,
"bin",
process.platform === "win32" ? "javac.exe" : "javac",
);
try {
await execute(javac, ["-version"], { timeout: 10_000 });
} catch (cause) {
@ -29,6 +34,7 @@ export const verifyAndroidMetadata = async ({
await execute(
javac,
[
...jvmArguments.map((argument) => `-J${argument}`),
"-classpath",
jar,
"-d",
@ -44,6 +50,7 @@ export const verifyAndroidMetadata = async ({
const result = await execute(
java,
[
...jvmArguments,
"-classpath",
`${classes}${delimiter}${jar}`,
"ReaJadxMetadataRegression",

View File

@ -0,0 +1,301 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import {
mkdtemp,
readdir,
realpath,
rmdir,
symlink,
unlink,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { resolveJadxConfiguration } from "../../../dist/android/JadxConfiguration.js";
/** Verify real Windows cancellation, disconnect and abrupt CLI owner exit. */
export async function verifyWindowsAndroidLifecycle(options) {
assert.equal(process.platform, "win32");
await verifyJavaSelection(options.environment);
const cancellation = await verifyMcp(options, "cancel");
await verifyMcp(options, "disconnect");
await verifyCliOwnerExit(options);
return {
mcp_cancelled_engine_exited: true,
mcp_cancelled_workspace_removed: true,
mcp_ping_and_next_call: true,
mcp_disconnect_engine_exited: true,
cli_abrupt_owner_exit_engine_exited: true,
// A forced Windows process exit cannot execute JavaScript workspace cleanup.
cli_abrupt_owner_exit_workspace_cleanup: false,
observed_resources: cancellation,
java_home_and_quoted_case_insensitive_path: true,
};
}
async function verifyJavaSelection(environment) {
const selected = await resolveJadxConfiguration(
environment,
"inspect_android_package",
);
const root = await mkdtemp(join(tmpdir(), "rea-windows-java-selection-"));
const alias = join(root, "JDK 路径 with spaces");
const pathEnvironment = Object.fromEntries(
Object.entries(environment).filter(
([key]) => !["path", "java_home"].includes(key.toLowerCase()),
),
);
try {
// The owned junction borrows the selected JDK directory without copying it.
await symlink(dirname(selected.java), alias, "junction");
pathEnvironment.Path = `"${alias}"`;
const onPath = await resolveJadxConfiguration(
pathEnvironment,
"inspect_android_package",
);
assert.equal(onPath.java, await realpath(selected.java));
console.log(
"PASS Windows JAVA_HOME and quoted Unicode/space Path selection",
);
} finally {
await unlink(alias).catch((cause) => {
if (cause.code !== "ENOENT") throw cause;
});
await rmdir(root);
}
}
async function verifyMcp({ entrypoint, environment, path, repository }, mode) {
const baseline = await runtimeRoots();
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
cwd: repository,
env: environment,
stderr: "pipe",
});
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
const client = new Client({
name: `rea-windows-android-${mode}`,
version: "1",
});
let observer;
try {
await client.connect(transport);
assert.ok(transport.pid !== null);
observer = observeJava(transport.pid);
const controller = new AbortController();
const pending = client
.callTool(
{ name: "inspect_android_package", arguments: { path } },
{ timeout: 150000, signal: controller.signal },
)
.then(
(response) => ({ response }),
(error) => ({ error }),
);
const resources = await observer.acquired;
if (mode === "cancel") controller.abort();
else await client.close();
const outcome = await deadline(pending, 15000);
assert.ok("error" in outcome, "Cancelled request published a success");
await observer.verifyExit();
await waitFor(
async () => {
const remaining = await runtimeRoots();
return remaining.every((root) => baseline.includes(root));
},
15000,
"Owned Android workspace remained after cleanup",
);
if (mode === "cancel") {
await waitFor(
() => stderr.includes('"status":"error"'),
15000,
`Server did not finish the cancelled request: ${stderr}`,
);
assert.ok(
!stderr.includes('"status":"ok"'),
"Cancelled operation was logged successful",
);
await client.ping();
const next = await client.callTool(
{ name: "inspect_android_package", arguments: { path } },
{ timeout: 150000 },
);
assert.notEqual(next.isError, true, JSON.stringify(next));
assert.ok(next.structuredContent?.evidence_id);
}
console.log(
`PASS Windows MCP ${mode}: real Java exit and workspace cleanup`,
);
return resources;
} finally {
await client.close();
await transport.close();
await observer?.close();
}
}
async function verifyCliOwnerExit({
entrypoint,
environment,
path,
repository,
}) {
const child = spawn(
process.execPath,
[entrypoint, "inspect-android-package", path, "--format", "json"],
{ cwd: repository, env: environment, stdio: ["ignore", "pipe", "pipe"] },
);
let output = "";
child.stdout.on("data", (chunk) => {
output = (output + chunk.toString()).slice(-65536);
});
child.stderr.resume();
const exited = new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", resolve);
});
assert.ok(child.pid !== undefined);
const observer = observeJava(child.pid);
try {
await observer.acquired;
// Node's Windows SIGTERM terminates the retained child handle abruptly.
// The Java job must settle through kill-on-owner-close without JS cleanup.
assert.equal(child.kill(), true);
await deadline(exited, 15000);
await observer.verifyExit();
assert.equal(output, "", "Aborted CLI published successful Evidence");
console.log("PASS Windows CLI abrupt owner exit: real Java job cleanup");
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill();
await deadline(exited, 15000);
await observer.close();
}
}
function observeJava(ownerPid) {
assert.ok(Number.isSafeInteger(ownerPid) && ownerPid > 0);
// Retain actual process handles and creation times. The process table only
// discovers the child; no PID or executable path grants termination authority.
const source = `
$ErrorActionPreference='Stop'
$owner=Get-Process -Id ${ownerPid}
$null=$owner.Handle; $ownerStart=$owner.StartTime
$java=$null; $deadline=[DateTime]::UtcNow.AddSeconds(45)
try {
while ([DateTime]::UtcNow -lt $deadline) {
if ($owner.HasExited) { throw 'REA owner exited before Java acquisition' }
$rows=Get-CimInstance Win32_Process -Filter 'ParentProcessId = ${ownerPid}'
foreach ($row in $rows) {
if ($row.Name -eq 'java.exe' -and $row.CommandLine -like '*ReaJadxBridge.java*') {
$candidate=Get-Process -Id $row.ProcessId
$null=$candidate.Handle
$sameCreation=$candidate.StartTime.ToUniversalTime().ToString('yyyyMMddHHmmssffffff') -eq $row.CreationDate.ToUniversalTime().ToString('yyyyMMddHHmmssffffff')
if ($sameCreation -and $candidate.StartTime -ge $ownerStart -and !$candidate.HasExited) {
$java=$candidate
$resourceArgs=@([regex]::Matches($row.CommandLine,'-Xmx[0-9]+m|-XX:ActiveProcessorCount=[0-9]+') | ForEach-Object {$_.Value})
@{affinity=[int64]$java.ProcessorAffinity; priority=[string]$java.PriorityClass; jvm_arguments=$resourceArgs} | ConvertTo-Json -Compress
break
}
$candidate.Dispose()
}
}
if ($null -ne $java) { break }
Start-Sleep -Milliseconds 100
}
if ($null -eq $java) { throw 'No real owned Java engine was acquired' }
$null=[Console]::ReadLine()
if (!$java.WaitForExit(15000)) { throw 'Retained Java process did not exit' }
@{exited=$java.HasExited} | ConvertTo-Json -Compress
} finally { if ($null -ne $java) {$java.Dispose()}; $owner.Dispose() }
`;
const observer = spawn(
join(
process.env.SystemRoot ?? "C:\\Windows",
"System32",
"WindowsPowerShell",
"v1.0",
"powershell.exe",
),
[
"-NoProfile",
"-NonInteractive",
"-EncodedCommand",
Buffer.from(source, "utf16le").toString("base64"),
],
{ stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
);
let stdout = "";
let stderr = "";
observer.stdout.on("data", (chunk) => {
stdout = (stdout + chunk.toString()).slice(-65536);
});
observer.stderr.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
const exited = new Promise((resolve, reject) => {
observer.once("error", reject);
observer.once("close", resolve);
});
const acquired = waitFor(
() => stdout.includes("\n") || observer.exitCode !== null,
50000,
"Java process observer did not acquire a handle",
).then(() => {
assert.ok(stdout.includes("\n"), stderr);
return JSON.parse(stdout.split(/\r?\n/u)[0]);
});
return {
acquired,
async verifyExit() {
observer.stdin.end("verify\n");
assert.equal(await deadline(exited, 20000), 0, stderr);
assert.equal(
JSON.parse(stdout.trim().split(/\r?\n/u).at(-1)).exited,
true,
);
},
async close() {
if (observer.exitCode === null && observer.signalCode === null) {
observer.stdin.end();
observer.kill();
}
await deadline(exited, 20000);
},
};
}
async function runtimeRoots() {
return (await readdir(tmpdir())).filter((name) =>
name.startsWith("rea-android-"),
);
}
async function waitFor(predicate, milliseconds, message) {
const until = Date.now() + milliseconds;
while (Date.now() < until) {
if (await predicate()) return;
await delay(50);
}
throw new Error(message);
}
async function deadline(promise, milliseconds) {
let timer;
return Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error("Windows lifecycle deadline exceeded")),
milliseconds,
);
}),
]).finally(() => clearTimeout(timer));
}

View File

@ -109,10 +109,10 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi
let targets;
for (let attempt = 0; attempt < 30; attempt++) {
targets = await call("list_electron_targets", { cdp_endpoint: endpoint });
if (targets.result.targets.length > 0) break;
if (targets.normalized_result.targets.length > 0) break;
await pause(100);
}
const target = targets.result.targets.find(
const target = targets.normalized_result.targets.find(
(item) => item.file_path === join(root, "renderer.html"),
);
assert.ok(target, JSON.stringify(targets));
@ -123,7 +123,7 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi
observation_ms: 50,
});
assert.ok(
page.result.scripts.items.some(
page.normalized_result.scripts.items.some(
(script) =>
script.file_path === join(root, "renderer.js") &&
script.source.included,
@ -135,13 +135,13 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi
const reconciled = await call("reconcile_javascript_runtime", {
static_layers: [
{
analysis: staticAnalysis.evidence,
analysis: staticAnalysis,
runtime_mappings: [{ kind: "file-root", root }],
},
],
runtime_observations: [page.evidence],
runtime_observations: [page],
});
assert.ok(reconciled.result.runtime_captures.length > 0);
assert.ok(reconciled.normalized_result.runtime_captures.length > 0);
const cli = await execute(
process.execPath,
[
@ -174,7 +174,7 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi
);
const capture = active.isError
? requireMcpToolError(active).details.partial_observation.capture
: active.structuredContent.result;
: active.structuredContent.normalized_result;
if (active.isError) {
assert.equal(
requireMcpToolError(active).code,

View File

@ -149,13 +149,13 @@ try {
{ timeout: 30_000 },
);
const inspectedMcp = await call("inspect_firmware_regions", { path });
const mcpEvidence = parseEvidence(inspectedMcp.evidence);
const mcpEvidence = parseEvidence(inspectedMcp);
assert.deepEqual(verifyRegions(mcpEvidence), cliRegions);
const extractedMcp = await call("extract_firmware", {
path,
output_directory: join(root, "mcp-output"),
});
const mcpFiles = await verifyFiles(parseEvidence(extractedMcp.evidence));
const mcpFiles = await verifyFiles(parseEvidence(extractedMcp));
const contentIdentity = (result) =>
result.files.map(({ relative_path, sha256, size }) => ({
relative_path,
@ -168,7 +168,7 @@ try {
output_directory: join(root, "selected-output"),
range: { offset: oracle.offset, length: oracle.length },
});
const selectedFiles = await verifyFiles(parseEvidence(selected.evidence));
const selectedFiles = await verifyFiles(parseEvidence(selected));
assert.equal(selectedFiles.selection.offset, oracle.offset);
assert.equal(selectedFiles.selection.length, oracle.length);
const depth = await call("extract_firmware", {
@ -177,7 +177,7 @@ try {
max_depth: 1,
});
const depthResult = firmwareResultSchemas.extract_firmware.parse(
depth.result,
depth.normalized_result,
);
assert.equal(depthResult.coverage, "partial");
assert.ok(depthResult.depth_limited_paths.length > 0);
@ -192,7 +192,7 @@ try {
output_directory: join(root, "ext4-output"),
});
const result = firmwareResultSchemas.extract_firmware.parse(
response.result,
response.normalized_result,
);
assert.ok(result.chunks.some((chunk) => chunk.handler === "extfs"));
assert.equal(result.coverage, "complete");
@ -234,7 +234,7 @@ try {
const dossier = await call("analyze_function", {
procedure: "firmware_probe",
});
const evidence = parseEvidence(dossier.evidence);
const evidence = parseEvidence(dossier);
assert.equal(
evidence.subject.digest.sha256,
oracle.files["usr/bin/probe"].sha256,
@ -242,7 +242,7 @@ try {
const pseudocode = await call("procedure_pseudo_code", {
procedure: "firmware_probe",
});
assert.equal(parseEvidence(pseudocode.evidence).provider.id, "ghidra");
assert.equal(parseEvidence(pseudocode).provider.id, "ghidra");
assert.ok(
JSON.stringify(evidence.normalized_result).includes("firmware_probe"),
);

View File

@ -170,8 +170,8 @@ async function runMcp(name, arguments_) {
true,
JSON.stringify(response.structuredContent),
);
assert.ok(response.structuredContent?.result);
return response.structuredContent.result;
assert.ok(response.structuredContent?.normalized_result);
return response.structuredContent.normalized_result;
}
async function inspectorUrl(process_) {

View File

@ -45,7 +45,7 @@ const call = async (name, args) => {
timeout: 150000,
});
assert.notEqual(result.isError, true, mcpTextValue(result));
return parseEvidence(JSON.parse(mcpTextValue(result)).evidence);
return parseEvidence(JSON.parse(mcpTextValue(result)));
};
const fingerprint = (bytes) => createHash("sha256").update(bytes).digest("hex");
const counts = [];

View File

@ -47,8 +47,8 @@ export async function connectRecordedCrash({ entrypoint, environment }) {
return value.error;
}
assert.notEqual(response.isError, true, JSON.stringify(value));
evidence = parseEvidence(value.evidence);
assert.deepEqual(value.result, evidence.normalized_result);
evidence = parseEvidence(value);
assert.deepEqual(value.normalized_result, evidence.normalized_result);
} else {
assert.equal(mode, "cli");
let response;

View File

@ -19,9 +19,12 @@ can select Java. REA setup does not install or configure that JAR, and doctor
provider scopes cover Hopper/Ghidra rather than JADX. Use existing tools and
report missing prerequisites for the Android task specifically.
Real verification covers Linux. macOS has the POSIX launch boundary but no
claimed real-provider verification; Windows is unsupported. Calls use one
worker, a bounded JVM heap, and provider deadlines. The APK is never launched,
Linux and macOS use the POSIX owned-process boundary. Windows x64 requires the
matching bundled native controls and uses an owned Job Object with bidirectional
stdio. A full JDK, including `jdk.compiler`, is required on every host; Windows
`JAVA_HOME` selects `bin\java.exe`. Consult the Android guide for actual host
verification coverage. Calls use one worker and provider deadlines; optional
`REA_JADX_HEAP_MIB` selects the JVM heap. The APK is never launched,
and no emulator is required.
Manifest entries and decompiled text are static observations, not execution or

View File

@ -1,8 +1,9 @@
# Evidence, comparison, and verification workflows
REA returns Evidence with each result. Read that result directly, cite its
Evidence ID when another tool or your explanation needs a stable reference, and
preserve authority, limitations, coverage, and residual unknowns. If a result
Evidence-producing tools return the complete Evidence record directly in MCP
`structuredContent` and CLI JSON. Read `normalized_result` for operation data,
and cite `evidence_id` when another tool or your explanation needs a stable
reference. Preserve authority, limitations, coverage, and residual unknowns. If a result
is incomplete or paginated, continue only when the remaining data matters to
the task; do not fetch a bundle or resource merely to read a result already
returned inline. When complete `inspect_binary_layout` or

View File

@ -1,6 +1,7 @@
import { constants } from "node:fs";
import { access, realpath, stat } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { delimiter, isAbsolute, join, resolve } from "node:path";
import { systemWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
import type { JsonValue } from "../domain/jsonValue.js";
@ -116,7 +117,7 @@ const readJadxConfiguration = async (
): Promise<JadxConfiguration> => {
requireSupportedHost();
const jar = await resolveJar(environment);
const java = resolveJava(environment);
const java = await resolveJava(environment, signal);
await requireJavaHomeExecutable(environment.JAVA_HOME, java);
const jvmArguments = resolveJvmArguments(environment);
await inspectJavaRuntime(java, environment, signal);
@ -169,9 +170,26 @@ const requireEngineArchive = async (
const requireSupportedHost = (): void => {
if (process.platform === "linux" || process.platform === "darwin") return;
if (process.platform === "win32" && process.arch === "x64") {
const loaded = systemWindowsNativeAuthority();
if (loaded.available && loaded.authority.inspection.protocolStdin === true)
return;
throw configurationFailure(
"runtime_missing",
loaded.available
? "The bundled Windows native artifact lacks owned protocol stdin; use a matching current Windows x64 REA package."
: loaded.reason,
{
platform: process.platform,
architecture: process.arch,
phase: "process-ownership",
owned_protocol_stdin: false,
},
);
}
throw configurationFailure(
"unsupported_host",
`Android JADX subprocess ownership is unsupported on ${process.platform}; use Linux or macOS. The metadata bridge has real-provider verification on macOS arm64.`,
`Android JADX subprocess ownership is unsupported on ${process.platform}/${process.arch}; use Linux, macOS, or Windows x64 with its bundled native controls.`,
{ platform: process.platform },
);
};
@ -206,12 +224,52 @@ const resolveJar = async (
}
};
const resolveJava = (
const resolveJava = async (
environment: Readonly<Record<string, string | undefined>>,
): string => {
if (environment.JAVA_HOME === undefined) return "java";
signal?: AbortSignal,
): Promise<string> => {
const executable = process.platform === "win32" ? "java.exe" : "java";
if (environment.JAVA_HOME === undefined) {
if (process.platform !== "win32") return executable;
const path =
Object.entries(environment).find(
([key]) => key.toLowerCase() === "path",
)?.[1] ?? "";
for (const directory of [process.cwd(), ...path.split(delimiter)]) {
signal?.throwIfAborted();
// Windows PATH commonly quotes individual entries containing spaces.
const selected =
directory.startsWith('"') && directory.endsWith('"')
? directory.slice(1, -1)
: directory;
const candidate = resolve(selected, executable);
try {
await access(candidate, constants.R_OK | constants.X_OK);
if ((await stat(candidate)).isFile()) return await realpath(candidate);
} catch (cause: unknown) {
if (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
)
continue;
throw configurationFailure(
"runtime_missing",
`Cannot inspect selected Java executable ${candidate}: ${errorText(cause)}`,
{ java_executable: candidate },
cause,
);
}
}
throw configurationFailure(
"runtime_missing",
"Cannot find java.exe on the selected Windows PATH. Select a full JDK with JAVA_HOME or PATH, then verify it with java --list-modules. REA does not install Java.",
{ java_executable: executable },
);
}
if (isAbsolute(environment.JAVA_HOME))
return join(environment.JAVA_HOME, "bin", "java");
return join(environment.JAVA_HOME, "bin", executable);
throw configurationFailure(
"open_options_invalid",
"JAVA_HOME must select an existing JDK by absolute path; omit it to use java on PATH.",

View File

@ -5,6 +5,7 @@ import {
type Transport,
} from "@modelcontextprotocol/client";
import { randomUUID } from "node:crypto";
import { finished } from "node:stream/promises";
import type {
OwnedProviderProcessSpawnOptions,
SpawnedOwnedProviderProcess,
@ -19,7 +20,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
const MAX_OPERATION_BYTES = 32 * 1024 * 1024;
/** Injectable process acquisition seam; the production launcher owns a POSIX group. */
/** Injectable acquisition seam; production owns a POSIX group or Windows job. */
export type JadxLauncher = (
options: OwnedProviderProcessSpawnOptions,
) => Promise<SpawnedOwnedProviderProcess>;
@ -100,7 +101,10 @@ export class JadxMcpTransport implements Transport {
/** Verifies owned process exit before declaring the transport closed. */
close(): Promise<void> {
this.#closePromise ??= this.#stop();
this.#closePromise ??= this.#stop().catch((cause: unknown) => {
this.#closePromise = undefined;
throw cause;
});
return this.#closePromise;
}
@ -136,8 +140,19 @@ export class JadxMcpTransport implements Transport {
process?.stderr?.off("data", this.#countStderr);
process?.off("error", this.#error);
process?.off("exit", this.#exit);
process?.stdin?.off("error", this.#error);
const input = process?.stdin;
// A job termination can complete an in-flight pipe write asynchronously.
// Observe its errors until the owned Writable has finished closing.
const inputClosed =
input === undefined || input === null
? undefined
: finished(input, { readable: false, cleanup: true }).catch(
() => undefined,
);
const stopped = await this.#supervisor?.stop();
input?.destroy();
await inputClosed;
input?.off("error", this.#error);
this.onclose?.();
if (stopped?.status === "incomplete")
throw new ProviderCleanupError("jadx", [this.runId], {

View File

@ -14,6 +14,7 @@ import {
} from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { err, ok, type Result } from "../domain/result.js";
@ -103,18 +104,23 @@ const executionError = (context: {
});
};
const cleanup = async <Value>(
session: JadxSession | undefined,
root: PrivateRuntimeRoot | undefined,
previous: Result<Value, AnalysisError>,
): Promise<Result<Value, AnalysisError>> => {
interface PendingCleanup {
session: JadxSession | undefined;
root: PrivateRuntimeRoot | undefined;
readonly previousError: AnalysisError | undefined;
}
const cleanup = async (
resources: PendingCleanup,
): Promise<Result<void, AnalysisError>> => {
const { session, root, previousError } = resources;
const diagnostics = {
previous_error: previous.ok
? null
: { tag: previous.error._tag, message: previous.error.message },
previous_error:
previousError === undefined ? null : projectAnalysisError(previousError),
};
try {
await session?.close();
resources.session = undefined;
} catch (cause) {
// Retain an uncertain workspace until the caller can resolve process ownership.
return err(
@ -132,12 +138,13 @@ const cleanup = async <Value>(
? (cause.diagnostics ?? null)
: null,
},
{ cause },
{ cause: previousError ?? cause },
),
);
}
try {
await root?.close();
resources.root = undefined;
} catch (cause) {
return err(
new ProviderCleanupError(
@ -147,11 +154,11 @@ const cleanup = async <Value>(
...diagnostics,
reason: cause instanceof Error ? cause.message : String(cause),
},
{ cause },
{ cause: previousError ?? cause },
),
);
}
return previous;
return ok(undefined);
};
interface RetainedSession {
@ -166,15 +173,13 @@ interface RetainedSession {
/** One serialized, immutable APK session per REA instance, retired on idle or shutdown. */
export class JadxProvider implements AndroidAnalysisPort {
#tail: Promise<void> = Promise.resolve();
#cleanupFailure: AnalysisError | undefined;
#pendingCleanup: PendingCleanup | undefined;
#retained: RetainedSession | undefined;
#idle: NodeJS.Timeout | undefined;
readonly #shutdown = new AbortController();
#closePromise: Promise<void> | undefined;
constructor(
readonly environment: Readonly<
Record<string, string | undefined>
> = process.env,
readonly environment: Readonly<Record<string, string | undefined>>,
readonly launcher?: JadxLauncher,
) {}
@ -209,11 +214,12 @@ export class JadxProvider implements AndroidAnalysisPort {
try {
if (signal.aborted)
return err(new AnalysisCancelledError(request.operation));
if (this.#cleanupFailure !== undefined) return err(this.#cleanupFailure);
clearTimeout(this.#idle);
if (this.#pendingCleanup !== undefined) {
const retired = await this.#retire();
if (!retired.ok) return retired;
}
const outcome = await this.#execute(target, request, { signal });
if (!outcome.ok && outcome.error.cleanupIncomplete)
this.#cleanupFailure = outcome.error;
if (outcome.ok) this.#scheduleIdle();
return outcome;
} finally {
@ -225,29 +231,38 @@ export class JadxProvider implements AndroidAnalysisPort {
close(): Promise<void> {
this.#shutdown.abort();
clearTimeout(this.#idle);
this.#closePromise ??= this.#tail.then(() => this.#retire());
this.#closePromise ??= this.#tail
.then(async () => {
const retired = await this.#retire();
if (!retired.ok) throw retired.error;
})
.catch((cause: unknown) => {
this.#closePromise = undefined;
throw cause;
});
return this.#closePromise;
}
async #retire(): Promise<void> {
if (this.#cleanupFailure !== undefined) throw this.#cleanupFailure;
const retained = this.#retained;
this.#retained = undefined;
const result = await cleanup(
retained?.session,
retained?.root,
ok(undefined),
);
if (!result.ok) {
this.#cleanupFailure = result.error;
throw result.error;
async #retire(): Promise<Result<void, AnalysisError>> {
if (this.#pendingCleanup === undefined && this.#retained !== undefined) {
this.#pendingCleanup = {
session: this.#retained.session,
root: this.#retained.root,
previousError: undefined,
};
this.#retained = undefined;
}
if (this.#pendingCleanup === undefined) return ok(undefined);
const result = await cleanup(this.#pendingCleanup);
if (result.ok) this.#pendingCleanup = undefined;
return result;
}
#scheduleIdle(): void {
this.#idle = setTimeout(() => {
const retirement = this.#tail.then(() => this.#retire());
this.#tail = retirement.catch(() => undefined);
this.#tail = this.#tail.then(async () => {
await this.#retire();
});
}, 60_000);
this.#idle.unref();
}
@ -264,7 +279,6 @@ export class JadxProvider implements AndroidAnalysisPort {
: AbortSignal.any([options.signal, timeout]);
let root: PrivateRuntimeRoot | undefined;
let session: JadxSession | undefined;
let outcome: Outcome;
try {
if (target.format !== "apk")
throw new AnalysisCapabilityUnavailableError(
@ -305,8 +319,10 @@ export class JadxProvider implements AndroidAnalysisPort {
this.environment.JAVA_HOME,
],
});
if (this.#retained !== undefined && this.#retained.key !== key)
await this.#retire();
if (this.#retained !== undefined && this.#retained.key !== key) {
const retired = await this.#retire();
if (!retired.ok) return retired;
}
let retained = this.#retained;
if (retained === undefined) {
root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" });
@ -359,7 +375,7 @@ export class JadxProvider implements AndroidAnalysisPort {
root = retained.root;
session = retained.session;
const { snapshot } = retained;
outcome = ok(
const outcome = ok(
await session.execute({
request,
target,
@ -373,20 +389,20 @@ export class JadxProvider implements AndroidAnalysisPort {
this.#retained = retained;
return outcome;
} catch (cause) {
outcome = err(
executionError({
cause,
request,
timeout,
signal: options?.signal,
session,
}),
);
const previousError = executionError({
cause,
request,
timeout,
signal: options?.signal,
session,
});
// Admission can fail before selecting an existing session for execution.
root ??= this.#retained?.root;
session ??= this.#retained?.session;
this.#retained = undefined;
this.#pendingCleanup = { session, root, previousError };
const retired = await this.#retire();
return retired.ok ? err(previousError) : retired;
}
// An existing session also needs retirement when admission fails before selection.
root ??= this.#retained?.root;
session ??= this.#retained?.session;
this.#retained = undefined;
return cleanup(session, root, outcome);
}
}

View File

@ -9,9 +9,7 @@ import {
type AnalysisOperationPort,
} from "./AnalysisProvider.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
AnalysisOutputError,
AnalysisProtocolError,
} from "../domain/analysisErrorCore.js";
import { err, ok } from "../domain/result.js";
@ -94,111 +92,6 @@ describe("analysis context queries: navigation", () => {
});
});
describe("analysis context queries: headless document resolution", () => {
it("resolves a sole headless document when current-document selection is unavailable", async () => {
const calls: string[] = [];
const analysis: AnalysisOperationPort = {
execute: (operation) => {
calls.push(operation);
if (operation === "current_document")
return Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
"ghidra",
operation,
"headless sessions have no cursor-selected document",
),
),
);
if (operation === "list_documents")
return Promise.resolve(
ok(createAnalysisExecution(["fixture"], provider)),
);
if (operation === "resolve_containing_procedure")
return Promise.resolve(
ok(
createAnalysisExecution(
{
query_address: "0x401000",
found: false,
procedure: null,
reason: "not_in_procedure",
},
provider,
),
),
);
return Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
"ghidra",
operation,
"unsupported",
),
),
);
},
};
await expect(
inspectAddressContext(analysis, { address: "0x401000" }),
).resolves.toMatchObject({
ok: true,
value: {
document: "fixture",
name: { state: "unavailable" },
procedure: { state: "available" },
comment: { state: "unavailable" },
inline_comment: { state: "unavailable" },
bookmarks: { state: "unavailable" },
},
});
expect(calls.slice(0, 2)).toEqual(["current_document", "list_documents"]);
});
it("does not use list-documents fallback for non-capability document errors", async () => {
const failure = new AnalysisProtocolError(
"current-document response malformed",
);
const calls: string[] = [];
const analysis: AnalysisOperationPort = {
execute: (operation) => {
calls.push(operation);
return Promise.resolve(err(failure));
},
};
await expect(
inspectAddressContext(analysis, { address: "0x401000" }),
).resolves.toEqual(err(failure));
expect(calls).toEqual(["current_document"]);
});
it("requires exactly one listed document for a headless fallback", async () => {
const analysis: AnalysisOperationPort = {
execute: (operation) =>
Promise.resolve(
operation === "current_document"
? err(
new AnalysisCapabilityUnavailableError(
"ghidra",
operation,
"no selected document",
),
)
: ok(createAnalysisExecution(["one", "two"], provider)),
),
};
await expect(
inspectAddressContext(analysis, { address: "0x401000" }),
).resolves.toMatchObject({
ok: false,
error: expect.any(AnalysisOutputError),
});
});
});
describe("analysis context queries: address facets", () => {
it("keeps unsupported address facets local and filters bookmarks", async () => {
const bookmarkInputs: Readonly<Record<string, unknown>>[] = [];

View File

@ -102,13 +102,12 @@ export interface AnalysisClient extends AnalysisOperationPort {
requestActivitySnapshots?(): readonly ProviderRequestActivitySnapshot[];
/** Current operational health for the active provider bridge. */
operationHealthSnapshot?(): ProviderOperationHealth | undefined;
/** Close with a typed result when the provider can verify cleanup. */
closeWithOutcome?(
/** Close owned resources and retain failed cleanup for a later retry. */
close(
options?: Pick<ExecutionOptions, "progress"> & {
readonly retainDocument?: boolean;
},
): Promise<Result<null, AnalysisError>>;
close(): Promise<void>;
}
/** Per-client identity allocated before any provider side effect starts. */
@ -116,22 +115,15 @@ export interface AnalysisClientContext {
readonly runId: string;
}
export type AnalysisClientFactory = (
target: BinaryTarget,
profile?: AnalysisProfileCommitment,
context?: AnalysisClientContext,
) => AnalysisClient;
export interface ProviderIdentity {
readonly id: string;
readonly name: string;
readonly version: string | null;
}
/** Profile plus opaque compatibility output resolved before provider startup. */
/** Typed analysis profile resolved before provider startup; null means unresolved. */
export interface AnalysisProfileResolution {
readonly profile: AnalysisProfileCommitment | null;
readonly compatibility: Readonly<Record<string, JsonValue>>;
}
/** Cancellation context for side-effect-free provider profile resolution. */

View File

@ -100,11 +100,7 @@ const browserParameters = (
result: BrowserEvidenceResult,
): EvidenceObservation["parameters"] => {
if (!("cdp_endpoint" in input)) {
if (
"before_scenario" in input &&
input.before_scenario !== undefined &&
input.after_scenario !== undefined
)
if ("before_scenario" in input)
return {
comparison_kind: "browser_scenario",
before_browser: input.before_scenario.browser,
@ -112,15 +108,10 @@ const browserParameters = (
before_start_origin: input.before_scenario.scenario.start_origin,
after_start_origin: input.after_scenario.scenario.start_origin,
normalization_sha256: commitBrowserScenarioNormalization(
input.normalization ?? { rules: [] },
input.normalization,
).sha256,
};
if (
input.before !== undefined &&
input.after !== undefined &&
"inspection" in input.before &&
"inspection" in input.after
)
if ("inspection" in input.before && "inspection" in input.after)
return {
before_target_id: input.before.inspection.target.target_id,
before_capture_ended_at:

View File

@ -2,38 +2,18 @@ import { canonicalDigest } from "../domain/comparisonSemantics.js";
import type { ProviderIdentity } from "./AnalysisProvider.js";
import type { BrowserScenario } from "../domain/browserScenario.js";
import type { BrowserScenarioCapture } from "../domain/browserScenarioCapture.js";
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import {
createEvidence,
type Evidence,
type EvidenceObservation,
} from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js";
const browserScenarioParameters = (
scenario: BrowserScenario,
): EvidenceObservation["parameters"] => ({
...jsonObjectSchema.parse(scenario),
scenario_sha256: canonicalDigest(scenario, "Browser scenario"),
browser_mode: scenario.browser.mode,
...(scenario.browser.mode === "launch"
? { browser_headless: scenario.browser.headless }
: {}),
start_url: sanitizeBrowserUrl(scenario.start_url.url),
environment: scenario.environment,
actions: scenario.actions.map(({ step_id, action }) => ({
step_id,
action,
})),
action_timeouts_ms: scenario.actions.map((item) =>
"timeout_ms" in item ? (item.timeout_ms ?? null) : null,
),
secret_declarations: scenario.secrets.map(
({ secret_id, environment_variable }) => ({
secret_id,
environment_variable,
}),
),
capture: scenario.capture,
});
/** Create Evidence without retaining resolved scenario secret values. */

View File

@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import { toolAvailability } from "../contracts/toolOutputSchemaPrimitives.js";
import { buildCapabilityInventory } from "./CapabilityInventory.js";
it("reports module syntax and native resolution availability separately", () => {
@ -40,10 +39,6 @@ const enabledPolicy: Parameters<typeof buildCapabilityInventory>[1] = {
v8InspectorObservationEnabled: true,
};
const availableAndroidAvailability: NonNullable<
Parameters<typeof buildCapabilityInventory>[1]["androidAnalysisAvailability"]
> = { status: "available", code: null, reason: null, diagnostics: {} };
const status = (
options: {
readonly open?: boolean;
@ -90,49 +85,7 @@ const entry = (
return found;
};
describe("capability inventory: provider status", () => {
it("reports firmware inspection and extraction prerequisites independently", () => {
const policy = {
processCaptureEnabled: true,
firmwareInspectionEnabled: true,
};
expect(entry("inspect_firmware_regions", status(), policy)).toMatchObject({
available: true,
surface: "firmware-provider",
});
expect(entry("extract_firmware", status(), policy)).toMatchObject({
available: false,
reason: "provider_missing",
remediation: expect.stringContaining("REA_UNBLOB_COMMAND"),
});
expect(
entry("extract_firmware", status(), {
processCaptureEnabled: true,
firmwareExtractionEnabled: true,
}),
).toMatchObject({ available: true });
});
it("keeps Android APK operations target-free and reports their explicit engine prerequisite", () => {
expect(
entry("inspect_android_package", status(), {
processCaptureEnabled: true,
androidAnalysisAvailability: availableAndroidAvailability,
}),
).toMatchObject({
available: true,
surface: "android-provider",
reason: "available",
});
expect(
entry("inspect_android_package", status(), {
processCaptureEnabled: true,
}),
).toMatchObject({
available: false,
reason: "provider_missing",
remediation: expect.stringContaining("REA_JADX_MCP_JAR"),
});
});
describe("capability failure reasons", () => {
it.each([
{
label: "requires a target",
@ -216,212 +169,9 @@ describe("capability inventory: provider status", () => {
if (remediation === null) expect(availability.remediation).toBeNull();
else expect(availability.remediation).toContain(remediation);
});
it("keeps configured workflows callable and reports only real host or provider limits", () => {
const inventory = buildCapabilityInventory(status(), {
...enabledPolicy,
processCaptureEnabled: false,
browserObservationEnabled: false,
browserScenarioEnabled: false,
electronObservationEnabled: false,
electronAutomationEnabled: false,
v8InspectorObservationEnabled: false,
});
const byName = new Map(inventory.map((item) => [item.name, item]));
expect(byName.get("capture_process_scenario")).toMatchObject({
available: false,
reason: "unsupported_host",
});
expect(byName.get("inspect_web_page")).toMatchObject({
available: false,
reason: "provider_missing",
});
});
});
describe("capability inventory: active-target operations", () => {
const ACTIVE_TARGET_TOOLS = [
"inspect_macho",
"inspect_signature",
"list_architectures",
"demangle_swift",
"inspect_artifact",
"extract_artifact",
] as const;
const declared = (
names: readonly string[],
availability: {
readonly available: boolean;
readonly reason: string | null;
} = {
available: true,
reason: null,
},
) => names.map((operation) => ({ operation, ...availability }));
const reasons = (
sessionStatus: ReturnType<typeof status>,
names: readonly string[],
policy = enabledPolicy,
) =>
Object.fromEntries(
names.map((name) => [name, entry(name, sessionStatus, policy).reason]),
);
it("requires an open target even when the provider declares the operation", () => {
const closed = status({ capabilities: declared(ACTIVE_TARGET_TOOLS) });
for (const name of ACTIVE_TARGET_TOOLS)
expect(entry(name, closed)).toMatchObject({
available: false,
reason: "target_required",
remediation: expect.stringContaining("open_binary"),
});
});
it("keeps explicit-path operations available without a target", () => {
const explicitPath = [
"inspect_managed_artifact",
"inspect_binary_layout",
"inspect_recorded_crash",
"inspect_evm_interface",
];
expect(
reasons(status(), explicitPath, {
...enabledPolicy,
binaryLayoutEnabled: true,
recordedCrashEnabled: true,
evmInterfaceEnabled: true,
}),
).toEqual(
Object.fromEntries(explicitPath.map((name) => [name, "available"])),
);
});
it("reserves Mach-O operations for an open Mach-O executable", () => {
const capabilities = declared(ACTIVE_TARGET_TOOLS);
const machO = status({
open: true,
kind: "executable",
format: "mach-o",
capabilities,
});
const zip = status({
open: true,
kind: "archive",
format: "zip",
capabilities,
});
const elf = status({
open: true,
kind: "executable",
format: "elf",
capabilities,
});
expect(reasons(machO, ACTIVE_TARGET_TOOLS)).toEqual(
Object.fromEntries(
ACTIVE_TARGET_TOOLS.map((name) => [name, "available"]),
),
);
for (const target of [zip, elf])
expect(reasons(target, ACTIVE_TARGET_TOOLS)).toEqual({
inspect_macho: "target_unsupported",
inspect_signature: "available",
list_architectures: "target_unsupported",
demangle_swift: "available",
inspect_artifact: "available",
extract_artifact: "available",
});
expect(entry("inspect_macho", zip).remediation).toContain("Mach-O");
});
it("reports an unsupported host before asking for a target", () => {
const capabilities = declared(["inspect_macho"], {
available: false,
reason: "Native macOS utilities require macOS.",
}).map((capability) => ({
...capability,
availability_code: "unsupported_host" as const,
}));
for (const sessionStatus of [
status({ capabilities }),
status({ open: true, kind: "archive", format: "zip", capabilities }),
])
expect(entry("inspect_macho", sessionStatus)).toMatchObject({
reason: "unsupported_host",
remediation: "Native macOS utilities require macOS.",
});
const composed = capabilities.map((capability) => ({
...capability,
operation: "inspect_native_dispatch_metadata",
}));
expect(
entry(
"inspect_native_dispatch_metadata",
status({ capabilities: composed }),
).reason,
).toBe("target_required");
});
});
describe("capability inventory: caller guidance", () => {
it("gives every unavailable public tool an actionable remediation", () => {
const inventory = buildCapabilityInventory(status(), {
...enabledPolicy,
processCaptureEnabled: false,
browserObservationEnabled: false,
electronObservationEnabled: false,
v8InspectorObservationEnabled: false,
});
for (const availability of inventory)
if (!availability.available) {
expect(availability.reason).not.toBe("available");
expect(availability.remediation).toEqual(expect.any(String));
expect(availability.remediation?.length).toBeGreaterThan(0);
}
});
it("keeps the public availability schema consistent with generated states", () => {
const inventory = buildCapabilityInventory(status(), enabledPolicy);
const unavailable = inventory.find(({ available }) => !available);
expect(unavailable).toBeDefined();
expect(toolAvailability.safeParse(unavailable).success).toBe(true);
expect(
toolAvailability.safeParse({ ...unavailable, available: true }).success,
).toBe(false);
expect(
toolAvailability.safeParse({
...unavailable,
available: false,
reason: "available",
}).success,
).toBe(false);
});
it("does not require interactive elicitation for process capture", () => {
const withoutElicitation = buildCapabilityInventory(
status(),
enabledPolicy,
).find(({ name }) => name === "capture_process_scenario");
const withElicitation = buildCapabilityInventory(status(), enabledPolicy, {
elicitation_form: true,
elicitation_url: false,
roots: false,
sampling: false,
}).find(({ name }) => name === "capture_process_scenario");
expect(withoutElicitation?.client_requirements).toEqual({
required: [],
optional: [],
missing_required: [],
missing_optional: [],
});
expect(withElicitation?.client_requirements).toEqual({
required: [],
optional: [],
missing_required: [],
missing_optional: [],
});
});
describe("partial navigation availability", () => {
it("allows explicit documents when the provider has no current-document getter", () => {
const capabilities = [
"current_document",
@ -510,28 +260,32 @@ describe("capability inventory: caller guidance", () => {
});
});
describe("optional JavaScript recovery availability", () => {
it("keeps optional recovery target-free and does not disable other static workflows", () => {
expect(entry("recover_javascript_sources", status())).toMatchObject({
available: false,
reason: "provider_missing",
remediation: expect.stringContaining("REA_WAKARU_COMMAND"),
});
it("reports host denial before target mismatch but keeps composed workflow prerequisites", () => {
const capability = {
operation: "inspect_macho",
available: false,
availability_code: "unsupported_host" as const,
reason: "Native macOS utilities require macOS.",
};
for (const target of [
{},
{ open: true, kind: "archive" as const, format: "zip" },
]) {
expect(
entry("recover_javascript_sources", status(), {
processCaptureEnabled: true,
javascriptRecoveryEnabled: true,
}),
entry("inspect_macho", status({ ...target, capabilities: [capability] })),
).toMatchObject({
available: true,
surface: "application",
reason: "unsupported_host",
remediation: capability.reason,
});
expect(entry("analyze_javascript_application", status())).toMatchObject({
available: true,
});
expect(entry("inspect_analysis_view", status())).toMatchObject({
available: true,
surface: "application",
});
});
}
expect(
entry(
"inspect_native_dispatch_metadata",
status({
capabilities: [
{ ...capability, operation: "inspect_native_dispatch_metadata" },
],
}),
).reason,
).toBe("target_required");
});

View File

@ -352,7 +352,7 @@ const workflowAvailabilityFor = ({
: "provider_missing",
remediation:
policy.androidAnalysisAvailability?.reason ??
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux or macOS.",
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
};
const browser = browserProviderAvailability(name, kind, policy);
if (browser !== null) return browser;

View File

@ -1,97 +0,0 @@
import { describe, expect, it } from "vitest";
import { parse as parseToml } from "smol-toml";
import {
clientConfigurationServersKey,
parseClientConfiguration,
serializeClientConfiguration,
} from "./ClientConfigurationDocument.js";
describe("JSON client configuration BOM handling", () => {
const format = "json" as const;
const serversKey = clientConfigurationServersKey(format);
it("reports malformed BOM-prefixed JSON at the original offset", () => {
const text = `\uFEFF{\r\n "${serversKey}": {"rea": @}\r\n}`;
expect(() => parseClientConfiguration(text, format)).toThrow(
new SyntaxError(
`Invalid JSON/JSONC at offset ${text.indexOf("@")}: InvalidSymbol`,
),
);
});
it.each([
["\uFEFF\uFEFF{}", 1],
[" \uFEFF{}", 1],
['{"setting":\uFEFFtrue}', 11],
['\uFEFF{"setting":\uFEFFtrue}', 12],
] as const)("rejects a nonleading BOM in %j", (text, offset) => {
expect(() => parseClientConfiguration(text, format)).toThrow(
new SyntaxError(`Invalid JSON/JSONC at offset ${offset}: InvalidSymbol`),
);
});
});
describe("TOML client configuration BOM handling", () => {
it.each(["", "\uFEFF"])("parses a document with prefix %j", (bom) => {
const text = '[mcp_servers.rea]\ncommand = "rea"\n';
expect(parseClientConfiguration(`${bom}${text}`, "toml").servers).toEqual({
rea: { command: "rea" },
});
});
});
describe("Codex TOML serialization", () => {
const unrelated = [
"# Keep this explanation.",
'notify = ["a", "b"]',
"literal = 'C:\\demo\\path'",
"",
].join("\n");
const rea = { command: "npx", args: ["-y", "rea-agents@6.1.0", "mcp"] };
const registered = `${unrelated}\n[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "rea-agents@6.1.0", "mcp"]\n`;
const documentOf = (text: string): Record<string, unknown> =>
parseClientConfiguration(text, "toml").document;
const serialize = (document: Record<string, unknown>, original: string) =>
serializeClientConfiguration(document, "toml", original, [
["mcp_servers", "rea"],
]);
it("adds REA's table without rewriting comments or unrelated values", () => {
const document = { ...documentOf(unrelated), mcp_servers: { rea } };
const written = serialize(document, unrelated);
expect(written.startsWith(unrelated)).toBe(true);
expect(parseToml(written)).toEqual(document);
});
it("removes REA's table and leaves the rest of the file byte for byte", () => {
const document = { ...documentOf(registered), mcp_servers: {} };
expect(serialize(document, registered)).toBe(unrelated);
});
it("leaves a root disabled_mcp_servers list, which Codex does not read", () => {
const original = `# Codex ignores this list.\ndisabled_mcp_servers = ["rea"]\n`;
const document = { ...documentOf(original), mcp_servers: { rea } };
const written = serialize(document, original);
expect(written.startsWith(original)).toBe(true);
expect(parseToml(written)).toEqual(document);
});
it("rewrites the document when an edit would not hold the requested values", () => {
const document = {
...documentOf(unrelated),
notify: ["changed"],
mcp_servers: { rea },
};
const written = serialize(document, unrelated);
expect(written).not.toContain("# Keep this explanation.");
expect(parseToml(written)).toEqual(document);
});
});

View File

@ -1,52 +0,0 @@
import { expect, it } from "vitest";
import { PRODUCT_IDENTITY } from "../identity.js";
import {
isOwnedClientRegistrationCommand,
npxRegistrationCommand,
} from "./ClientRegistrationIdentity.js";
import { setupRegistrationCommand } from "./SetupHost.js";
it("uses a Windows executable for npm-run setup and preserves POSIX launchers", () => {
expect(setupRegistrationCommand("win32", true)).toEqual([
"cmd.exe",
"/d",
"/c",
"npx",
"-y",
PRODUCT_IDENTITY.registrationPackageSpecifier,
"mcp",
]);
for (const platform of ["linux", "darwin"] as const)
expect(setupRegistrationCommand(platform, true)).toEqual([
"npx",
"-y",
PRODUCT_IDENTITY.registrationPackageSpecifier,
"mcp",
]);
});
it.each(
[
npxRegistrationCommand("win32"),
["cmd", "/c", "npx", "-y", "rea-agents@5.0.0", "mcp"],
["npx", "-y", "rea-agents@5.0.0", "mcp"],
].map((command) => ({ command })),
)("recognizes owned current and legacy launchers: $command", ({ command }) => {
expect(isOwnedClientRegistrationCommand(command)).toBe(true);
});
it.each(
[
["cmd.exe", "/d", "/c", "node", "custom.js", "mcp"],
["cmd.exe", "/k", "npx", "-y", "rea-agents@5.0.0", "mcp"],
["cmd.exe", "/c", "npx -y rea-agents@5.0.0 mcp"],
["cmd.exe", "/c", "npx", "-y", "another-package", "mcp"],
["cmd.exe", "/c", "npx", "-y", "rea-agents@5.0.0", "mcp", "&", "other"],
["cmd.exe", "/c", "npx", "-y", "rea-agents@5.0.0 & other", "mcp"],
].map((command) => ({ command })),
)(
"does not claim custom shell commands as REA-owned: $command",
({ command }) => {
expect(isOwnedClientRegistrationCommand(command)).toBe(false);
},
);

View File

@ -1,5 +1,4 @@
import type { ExecutableFormatHint } from "../domain/dosCom.js";
import { parseConfig } from "../config.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { EnhancedTools } from "./EnhancedTools.js";
import { executeFunctionAnalysisEvidence } from "./FunctionAnalysisEvidence.js";
@ -132,7 +131,7 @@ export const runProviderAnalysis = async (
/** Execute managed metadata inspection in an isolated managed-only session. */
export const runManagedProviderExecution = async (
dependencies: DirectAnalysisDependencies,
dependencies: Pick<DirectAnalysisDependencies, "createManagedBinarySession">,
path: string,
tool: ManagedToolName,
signal?: AbortSignal,
@ -189,18 +188,10 @@ const runAnalysis = async (
readonly signal: AbortSignal;
readonly providerId?: AnalysisProviderSelector;
readonly formatHint?: ExecutableFormatHint;
/**
* Environment the configuration is read from. Defaults to the process
* environment so existing callers are unchanged, but a caller may supply
* one — which is what lets the MCP path's injected environment reach this
* code, and what makes configuration-driven behaviour testable without
* mutating `process.env`.
*/
readonly environment?: Readonly<Record<string, string | undefined>>;
},
): Promise<JsonValue> => {
const { logger, signal, snapshotPath } = options;
const config = parseConfig(options.environment ?? process.env);
const config = dependencies.readConfiguration();
if (!config.ok) return cliError(config.error);
const session = dependencies.createBinarySession(config.value, logger);
return withSessionCleanup(

View File

@ -1,9 +1,11 @@
import type { AppConfig } from "../config.js";
import type { AppConfig, parseConfig } from "../config.js";
import type { Logger } from "../logger.js";
import type { BinarySession } from "./binary/BinarySession.js";
/** Existing one-shot session factories supplied by the production boundary. */
/** Configuration and one-shot session factories supplied by the production boundary. */
export interface DirectAnalysisDependencies {
/** Read the caller-selected configuration without consulting ambient state. */
readonly readConfiguration: () => ReturnType<typeof parseConfig>;
readonly createBinarySession: (
config: AppConfig,
logger: Logger,

View File

@ -1,4 +1,3 @@
import { parseConfig } from "../config.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
import { createServerIdentity } from "../serverIdentity.js";
@ -6,11 +5,13 @@ import { silentLogger, type Logger } from "../logger.js";
import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js";
const runSessionStatus = async (
dependencies: Pick<DirectAnalysisDependencies, "createBinarySession">,
dependencies: Pick<
DirectAnalysisDependencies,
"createBinarySession" | "readConfiguration"
>,
logger: Logger = silentLogger,
environment: Readonly<Record<string, string | undefined>> = process.env,
): Promise<JsonValue> => {
const config = parseConfig(environment);
const config = dependencies.readConfiguration();
if (!config.ok) return { error: projectAnalysisError(config.error) };
const session = dependencies.createBinarySession(config.value, logger);
try {
@ -27,14 +28,18 @@ const runSessionStatus = async (
/** List binary-session provider candidates and auxiliary operation availability. */
export const runProviderStatus = (
dependencies: Pick<DirectAnalysisDependencies, "createBinarySession">,
dependencies: Pick<
DirectAnalysisDependencies,
"createBinarySession" | "readConfiguration"
>,
logger: Logger = silentLogger,
environment: Readonly<Record<string, string | undefined>> = process.env,
) => runSessionStatus(dependencies, logger, environment);
) => runSessionStatus(dependencies, logger);
/** List binary-session operation descriptors; this is not the full MCP catalog. */
export const runCapabilityStatus = (
dependencies: Pick<DirectAnalysisDependencies, "createBinarySession">,
dependencies: Pick<
DirectAnalysisDependencies,
"createBinarySession" | "readConfiguration"
>,
logger: Logger = silentLogger,
environment: Readonly<Record<string, string | undefined>> = process.env,
) => runSessionStatus(dependencies, logger, environment);
) => runSessionStatus(dependencies, logger);

View File

@ -7,6 +7,7 @@ export const createDoctorHostFixture = (
overrides: Partial<DoctorHost> = {},
): DoctorHost => ({
platform: "darwin",
homeDirectory: "/fixture/home",
architecture: "x64",
nodeVersion: "24.18.0",
macosVersion: () => Promise.resolve("14.0"),

View File

@ -1,7 +1,7 @@
import { constants } from "node:fs";
import { access, readFile, readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { delimiter, join } from "node:path";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { join } from "node:path";
import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentation.js";
import { parseBinaryTarget } from "./BinaryTargetResolver.js";
@ -76,7 +76,7 @@ export interface DoctorHost {
readonly platform: NodeJS.Platform;
readonly architecture: NodeJS.Architecture;
readonly nodeVersion: string;
readonly homeDirectory?: string;
readonly homeDirectory: string;
readonly configuredHopperPath?: string;
readonly configuredIlspyCmdPath?: string;
macosVersion(): Promise<string | undefined>;
@ -287,21 +287,6 @@ const installationState = (
): DoctorIdentity["installations"]["state"] =>
paths.length === 0 ? "unknown" : paths.length === 1 ? "single" : "multiple";
const homeFromEnvironment = (
environment: NodeJS.ProcessEnv,
platform: NodeJS.Platform,
): string => {
const selected =
platform === "win32"
? (environment.USERPROFILE ??
(environment.HOMEDRIVE !== undefined &&
environment.HOMEPATH !== undefined
? `${environment.HOMEDRIVE}${environment.HOMEPATH}`
: environment.HOME))
: (environment.HOME ?? environment.USERPROFILE);
return selected ?? homedir();
};
/** Optional outer-adapter diagnostics composed without reversing dependencies. */
export interface SystemDoctorHostOptions {
readonly platform?: NodeJS.Platform;
@ -322,7 +307,7 @@ export const systemDoctorHost = (
const architecture = options.architecture ?? process.arch;
const environment = options.environment ?? process.env;
const hostExecFileOutput = options.execFileOutput ?? execFileOutput;
const homeDirectory = homeFromEnvironment(environment, platform);
const homeDirectory = homeDirectoryFromEnvironment(environment, platform);
const commandEnvironment = { env: environment };
return {
platform,
@ -451,8 +436,8 @@ export const systemDoctorHost = (
};
const readMacosVersion = async (
run: typeof execFileOutput = execFileOutput,
options: { readonly env: NodeJS.ProcessEnv } = { env: process.env },
run: typeof execFileOutput,
options: { readonly env: NodeJS.ProcessEnv },
): Promise<string | undefined> => {
try {
return (await run("sw_vers", ["-productVersion"], options)).stdout.trim();
@ -465,9 +450,9 @@ const readMacosVersion = async (
const executableAvailable = async (
path: string,
platform: NodeJS.Platform = process.platform,
run: typeof execFileOutput = execFileOutput,
options: { readonly env: NodeJS.ProcessEnv } = { env: process.env },
platform: NodeJS.Platform,
run: typeof execFileOutput,
options: { readonly env: NodeJS.ProcessEnv },
): Promise<boolean> => {
try {
await access(path, constants.X_OK);
@ -490,9 +475,7 @@ const uncomposedLinuxDemoRuntimeCheck = (): Promise<DoctorCheck> =>
remediation: "Run rea doctor through the production CLI adapter.",
});
const manualHopperPaths = async (
home: string = homedir(),
): Promise<readonly string[]> => {
const manualHopperPaths = async (home: string): Promise<readonly string[]> => {
const paths: string[] = [];
for (const root of ["/Applications", join(home, "Applications")]) {
try {

View File

@ -1,5 +1,3 @@
import { homedir } from "node:os";
import {
SUPPORTED_NODE_VERSION_PROSE,
supportsNodeVersion,
@ -126,7 +124,7 @@ const findHopper = async (host: DoctorHost): Promise<string | undefined> => {
? [
DEFAULT_HOPPER,
SYSTEM_LINUX_HOPPER,
linuxHopperLauncherPath(host.homeDirectory ?? homedir()),
linuxHopperLauncherPath(host.homeDirectory),
...(await host.manualHopperPaths()),
]
: [host.configuredHopperPath];

Some files were not shown because too many files have changed in this diff Show More