Merge pull request #107 from morluto/morl/roadmap_implementation

fix(errors): return actionable caller-safe failures
This commit is contained in:
morluto 2026-07-14 03:22:48 +08:00 committed by GitHub
commit fb0da031cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 1955 additions and 348 deletions

View File

@ -4,7 +4,7 @@
# REA: Reverse Engineer Anything
### One CLI and MCP server for coding agents to reverse engineer anything
### One CLI and MCP server for agents to reverse engineer anything
**See a feature you like. Understand how it works, down to the binary level.**
@ -24,7 +24,7 @@
---
See a feature in an app that you want in your own product? Give the app to your coding agent—even without its source code. With REA, the agent can investigate the feature, explain how it works, show its evidence, and build a version adapted to your stack and requirements.
See a feature in an app that you want in your own product? Give the app to your agent—even without its source code. With REA, the agent can investigate the feature, explain how it works, show its evidence, and build a version adapted to your stack and requirements.
REA gives agents one consistent way to investigate software. Today that includes deep native analysis through Hopper, complete function dossiers, reproducible Evidence v2 records, and controlled process capture. The longer-term toolkit extends the same agent workflow to packaged apps, JavaScript bundles, websites, APIs, protocols, mobile artifacts, firmware, runtime behavior, and differences between versions.
@ -73,7 +73,7 @@ REA shows how it reached its conclusions. It does not claim to recover original
| | |
| ------------------------ | ------------------------------------------------------------------------------------ |
| **Built for agents** | Ask what an app does and let your agent inspect it instead of guessing. |
| **CLI and MCP** | Run the same reverse-engineering capabilities from your terminal or coding agent. |
| **CLI and MCP** | Run the same reverse-engineering capabilities from your terminal or agent. |
| **Complexity handled** | REA installs and manages the reverse-engineering tools behind the scenes. |
| **From insight to code** | Understand a feature, then build your own version in the same coding session. |
| **Local by design** | Analysis runs on your Mac. REA does not upload the app to a hosted analysis service. |
@ -88,7 +88,7 @@ npm install --global rea-agents
rea setup
```
Installing the CLI does not update Homebrew, Node.js, npm, Hopper, or coding-agent configuration. `rea setup` detects what is already present, prints every proposed change, and asks before applying it.
Installing the CLI does not update Homebrew, Node.js, npm, Hopper, or agent configuration. `rea setup` detects what is already present, prints every proposed change, and asks before applying it.
REA detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin. Registrations are additive, backup-first, and read back after writing. You can safely rerun setup.
@ -100,7 +100,7 @@ curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
Pass installer options after `bash -s --`, for example `--dry-run`, `--no-setup`, or `--version 1.0.0`. The curl wrapper never installs prerequisites or configures integrations itself. See [Installation and setup](docs/installation.md) for its exact mutation boundary.
### With a coding agent — recommended
### With an agent — recommended
```bash
npx skills add morluto/rea
@ -118,7 +118,7 @@ npx -y rea-agents doctor
npx -y rea-agents analyze /Applications/Notes.app
```
Review the setup plan before confirming it. Restart a configured coding agent so it loads REA.
Review the setup plan before confirming it. Restart a configured agent so it loads REA.
### From Terminal — install the `rea` command
@ -191,7 +191,7 @@ rea uninstall --purge-data # also removes only ~/.rea/cache and ~/.rea/state
Uninstall preserves Hopper, Node.js, evidence, captures, external evidence roots, unrelated skills, and other MCP servers. It refuses malformed client configuration and never follows purge-data symlinks.
### CLI or coding agent?
### CLI or agent?
| If you want to… | Use |
| --------------------------------------------------------- | -------------------------------------------------------------- |
@ -303,9 +303,9 @@ Roadmap items describe direction, not shipped support. New providers must produc
See the [static-analysis provider evaluation](docs/provider-evaluation.md) for the current research matrix and admission gate.
## Using REA with other coding agents
## Using REA with other agents
Setup currently configures Claude Desktop and Cursor automatically. Any coding agent that supports local MCP servers can use REA with the configuration below.
Setup currently configures Claude Desktop and Cursor automatically. Any agent that supports local MCP servers can use REA with the configuration below.
### Manual MCP configuration
@ -324,7 +324,7 @@ Setup currently configures Claude Desktop and Cursor automatically. Any coding a
```mermaid
flowchart LR
Agent["Coding agent"] --> REA["REA<br/>CLI + MCP"]
Agent["Agent"] --> REA["REA<br/>CLI + MCP"]
Terminal --> REA
REA --> Workspace["Investigation workspace<br/>evidence + artifacts + captures"]
Workspace --> Router["Capability router"]
@ -442,7 +442,7 @@ No. Setup can install Hopper for you, but Hopper remains separate software with
<details>
<summary><strong>Does REA upload the app?</strong></summary>
REA has no hosted analysis service. Current providers analyze artifacts and capture behavior locally. Your coding agent or model provider may have its own data policy, so review that separately.
REA has no hosted analysis service. Current providers analyze artifacts and capture behavior locally. Your agent or model provider may have its own data policy, so review that separately.
</details>
@ -456,7 +456,7 @@ No decompiler can guarantee the original source. REA gives an agent pseudocode,
<details>
<summary><strong>Which agents can use REA?</strong></summary>
Any coding agent that can run a local MCP server can use the manual configuration. Setup currently detects and configures Claude Desktop and Cursor automatically.
Any agent that can run a local MCP server can use the manual configuration. Setup currently detects and configures Claude Desktop and Cursor automatically.
</details>

View File

@ -16,7 +16,7 @@
<li class="">
<div class="tsd-signature tsd-anchor-link" id="detectclients"><span class="tsd-kind-call-signature">detectClients</span><span class="tsd-signature-symbol">(</span><span class="tsd-kind-parameter">home</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-type">string</span><span class="tsd-signature-symbol">)</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-type">Promise</span><span class="tsd-signature-symbol">&lt;</span><span class="tsd-signature-keyword">readonly</span> <a href="../interfaces/application_Setup.SetupClient.html" class="tsd-signature-type tsd-kind-interface">SetupClient</a><span class="tsd-signature-symbol">[]</span><span class="tsd-signature-symbol">&gt;</span><a href="#detectclients" aria-label="Permalink" class="tsd-anchor-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="../assets/icons.svg#icon-anchor"></use></svg></a></div>
<div class="tsd-description">
<div class="tsd-comment tsd-typography"><p>Detect supported coding agents from stable per-user installation markers.</p>
<div class="tsd-comment tsd-typography"><p>Detect supported agents from stable per-user installation markers.</p>
</div>
<div class="tsd-parameters">
<h4 class="tsd-parameters-title">Parameters</h4>

View File

@ -1,6 +1,6 @@
# Installation and setup
REA separates installing its CLI from configuring external software and coding agents.
REA separates installing its CLI from configuring external software and agents.
## Install the CLI
@ -35,7 +35,7 @@ Supported options are `--version <semver>`, `--dry-run`, `--no-setup`, `--no-pro
`rea setup` discovers the current state, prints one plan, and asks `Continue? [Y/n]`. The plan identifies:
- an existing Hopper installation or the official package it proposes to install;
- each detected coding-agent configuration path;
- each detected agent configuration path;
- the REA skill destination;
- external software and package-manager effects.

View File

@ -1,6 +1,6 @@
# Installation roadmap
REA setup currently configures the capabilities that exist today: its coding-agent integrations, bundled skill, and optional Hopper provider.
REA setup currently configures the capabilities that exist today: its agent integrations, bundled skill, and optional Hopper provider.
When REA has at least two real optional toolchains, setup can evolve into a capability-oriented wizard. It may ask whether the operator wants to investigate native binaries, websites, mobile applications, firmware, or runtime protocols, then propose only the tools needed for the selected work.

View File

@ -0,0 +1,103 @@
harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implementation_status,verification_result
1,src/domain/errors.ts:AnalysisCapabilityUnavailableError,Requested analysis unavailable,"Provider {providerId} cannot execute {operation}: {reason}",Exposes provider and internal operation/reason,"This analysis is unavailable for the current target. Choose another analysis or target.",implemented,focused assertions passed; full `npm run check` passed
2,src/domain/errors.ts:ProviderAdapterError,Provider adapter fails,"Provider {providerId} adapter failed during {operation}",Exposes provider and implementation boundary,"Analysis could not complete. Retry once; if it continues, run `rea doctor`.",implemented,focused assertions passed; full `npm run check` passed
3,src/domain/errors.ts:HopperRemoteError,Remote analysis request fails,"Hopper request failed ({code}): {safeMessage}",Exposes provider name code and upstream text,"Analysis could not complete. Retry once; if it continues, run `rea doctor`.",implemented,focused assertions passed; full `npm run check` passed
4,src/domain/errors.ts:HopperProtocolError,Malformed provider protocol result,Raw constructor message,Can expose provider protocol and implementation details,"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",implemented,focused assertions passed; full `npm run check` passed
5,src/domain/errors.ts:AnalysisOutputError,Analysis output fails parsing,"Invalid analysis output for {operation}: {reason}",Exposes internal operation and parser reason,"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",implemented,focused assertions passed; full `npm run check` passed
6,src/domain/errors.ts:AnalysisProtocolError,Application protocol fails,Raw constructor message,Can expose protocol internals,"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",implemented,focused assertions passed; full `npm run check` passed
7,src/domain/errors.ts:HopperProcessError,Analysis subprocess stops,"Hopper bridge stopped unexpectedly with code {exitCode}",Exposes provider bridge and process code,"Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again.",implemented,focused assertions passed; full `npm run check` passed
8,src/domain/errors.ts:HopperStartError,Analysis subprocess cannot start,Hopper application bridge could not be started,Exposes provider and bridge implementation,"Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again.",implemented,focused assertions passed; full `npm run check` passed
9,src/domain/errors.ts:BinaryTargetError,Target cannot open,"Cannot open artifact: {reason}",May expose raw reason and lacks checks,"REA could not open that app or binary. Check that the path exists, is readable, and points to a supported file.",implemented,focused assertions passed; full `npm run check` passed
10,src/domain/errors.ts:ConfigurationError,Environment configuration invalid,Raw constructor message,May expose provider-specific variables and lacks recovery,"REA configuration is invalid. Run `rea doctor` and fix the reported setting.",implemented,focused assertions passed; full `npm run check` passed
11,src/domain/errors.ts:ArtifactOperationError,Artifact inventory or extraction fails,"Artifact {operation} failed: {reason} at {logicalPath} (declared_sha256=...)",Exposes paths hashes and internal reason,"Reason-specific plain message with safe recovery step.",implemented,focused assertions passed; full `npm run check` passed
12,src/domain/errors.ts:EvidenceFileError,Evidence file operation fails,"Evidence bundle {operation} failed: {reason}",Internal reason token gives weak recovery,"Reason-specific plain message with safe recovery step.",implemented,focused assertions passed; full `npm run check` passed
13,src/domain/errors.ts:EvidenceIntegrityError,Evidence validation fails,Raw constructor message,May expose validation internals and lacks recovery,"Evidence is invalid or has changed. Recreate or re-import it, then try again.",implemented,focused assertions passed; full `npm run check` passed
14,src/domain/errors.ts:UnknownRegistryError,Evidence-state mutation fails,"Residual unknown registry mutation failed: {reason}",Exposes internal subsystem and state token,"Evidence state changed before the update completed. Refresh the current state and try again.",implemented,focused assertions passed; full `npm run check` passed
15,src/domain/errors.ts:ProcessCaptureError,Process capture fails,Raw constructor message,May expose process and cleanup internals,"Process capture could not complete. Check command access and limits, then try again.",implemented,focused assertions passed; full `npm run check` passed
16,src/domain/errors.ts:AnalysisTimeoutError,Analysis deadline expires,"Analysis operation timed out after {timeoutMs}ms: {operation}",Exposes internal deadline and operation; weak recovery,"Analysis took too long. Try a smaller request, then run `rea doctor` if it continues.",implemented,focused assertions passed; full `npm run check` passed
17,src/domain/errors.ts:HopperTimeoutError,Provider deadline expires,"Hopper did not respond within {timeoutMs}ms",Exposes provider and internal deadline,"Analysis took too long. Try a smaller request, then run `rea doctor` if it continues.",implemented,focused assertions passed; full `npm run check` passed
18,src/domain/errors.ts:ProviderSelectionError,No configured provider supports operation,"No configured provider can execute {operation}",Exposes provider architecture and internal operation,"This analysis is unavailable for the current target. Choose another analysis or target.",implemented,focused assertions passed; full `npm run check` passed
19,src/domain/errors.ts:EvidenceLimitError,Evidence ledger limit reached,"Evidence ledger {limit} limit exceeded ({maximum})",Exposes internal storage model,"Evidence is too large for this session. Reduce the evidence set and try again.",implemented,focused assertions passed; full `npm run check` passed
20,src/domain/errors.ts:AnalysisInputError,Parsed analysis input invalid,"Invalid analysis input for {operation}",Exposes operation identifier and lacks correction,"Analysis input is invalid. Check the arguments and try again.",implemented,focused assertions passed; full `npm run check` passed
21,src/domain/errors.ts:AnalysisCancelledError,Caller cancels analysis,"Analysis operation was cancelled: {operation}",Exposes operation identifier,"Analysis was cancelled. Start it again when ready.",implemented,focused assertions passed; full `npm run check` passed
22,src/domain/errors.ts:HopperCancelledError,Caller cancels provider request,Hopper operation was cancelled,Exposes provider name,"Analysis was cancelled. Start it again when ready.",implemented,focused assertions passed; full `npm run check` passed
23,src/domain/errors.ts:NoBinaryOpenError,Analysis requested without target,"No app is open. Ask the user which app to investigate, then call open_binary with its local path.",Low risk; already supplies agent recovery,Keep current copy,implemented,focused assertions passed; full `npm run check` passed
24,src/server/toolResult.ts:successResult,Successful value violates public output schema,AnalysisOutputError: Analysis output does not match the tool contract,Exposes internal error tag and contract implementation,"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",implemented,focused assertions passed; full `npm run check` passed
24.5,src/server/registerOfficialTools.ts:capability unknown,Approved unknown tracking records an unavailable analysis,"TOOL_NAME is unavailable: PROVIDER_REASON",Exposes an internal tool identifier and provider diagnostic in persistent user-visible evidence,"The requested analysis is unavailable for the current target.",implemented,verified by focused MCP integration test
24.6,src/application/ReferenceSourceImportEntries.ts:failed entry,A partial reference import contains an unreadable entry,Low-level reader diagnostic stored in entry limitations,Can persist filesystem or reader implementation details,"Code- and entry-specific safe guidance with a retry, limit, exclusion, or platform recovery step.",implemented,verified by exhaustive focused projection test
24.7,src/server/registerSessionTools.ts:process residual,Capture records an approved residual unknown,SCOPE: RAW_REASON,Persists adapter diagnostic and implementation wording,"Was SCOPE behavior fully observed during capture?",implemented,verified by focused session MCP test
24.8,src/server/registerInvestigationTools.ts:changed behavior unknown,Changed-behavior workflow remains incomplete,"Changed behavior result EVIDENCE_ID remains STATUS",Persists internal evidence identifier,"Did both versions behave the same under a complete controlled replay?",implemented,verified by focused investigation MCP test
24.9,src/server/registerInvestigationTools.ts:call path unknown,Call-path workflow remains incomplete,Call path START_ADDRESS → GOAL_ADDRESS is STATUS,Persists binary addresses and internal status wording,Can the requested call path be established from complete analysis?,implemented,verified by focused unresolved call-path MCP test
24.91,src/server/registerInvestigationTools.ts:static runtime unknown,Static/runtime correlation remains incomplete,Static/runtime correlation result EVIDENCE_ID is STATUS,Persists internal evidence identifier,Does runtime behavior match the available static analysis?,implemented,verified by focused unresolved static/runtime MCP test
24.92,src/server/registerInvestigationTools.ts:reconstruction unknown,Reconstruction verification retains unknown claims,Reconstruction SPECIFICATION_HASH has unknown claims,Persists internal hash,Does the reconstruction satisfy every declared claim?,implemented,verified by focused unknown reconstruction MCP test
24.93,src/server/registerFunctionComparisonTool.ts:probe rationale,Function comparison is incomplete,Capture complete dossiers with equal providers and analysis limits.,Exposes provider architecture and gives ambiguous equality guidance,"Capture complete dossiers for both functions under the same target context and analysis limits.",implemented,verified by focused function-comparison MCP test
24.95,incur:CLI argument validation,Caller omits or mistypes a command argument,VALIDATION_ERROR with raw Zod Details and fieldErrors including code/path/expected/received,Exposes validator representation and buries the useful correction,"REA could not read the command arguments. Check the named argument and its expected type, then try again.",implemented,verified by sanitizer unit tests and real dispatcher missing-argument test
25,src/application/ReferenceSourceImport.ts:cancelled,Reference-source import is cancelled,Reference source import cancelled,No recovery timing guidance,"Reference source import was cancelled. Start it again when ready.",implemented,focused assertions passed
26,src/application/ReferenceSourceImportPolicy.ts:invalid-limits,Configured import limits are not positive integers,Import limits must be positive integers,Does not tell user where recovery lies,"Reference source limits are invalid. Use positive integer limits, then try again.",implemented,focused assertions passed
27,src/application/ReferenceSourceImportPolicy.ts:invalid-root,Source path cannot resolve or is not a directory,Reference source root could not be resolved / Reference source root is not a directory,Multiple low-level copies and no complete path check,"Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory.",implemented,focused assertions passed
28,src/application/ReferenceSourceImportPolicy.ts:policy,No approved root exists or requested root is outside policy,No approved reference source roots / Reference source root is outside approved roots,Does not identify safe configuration recovery,"Reference source directory is not approved. Add its directory to `REA_REFERENCE_ROOTS_JSON`, restart REA, then try again.",implemented,focused assertions passed
29,src/application/ReferenceSourceImport.ts:io,Reader fails during bounded traversal,Raw reader error message,May expose filesystem details and inconsistent recovery,"Reference source files could not be read. Check directory permissions and try again.",implemented,focused assertions passed
30,src/application/ReferenceSourceImport.ts:parse,Historical graph validation fails,Raw thrown graph validation message,Can expose internal graph invariants,"Reference source could not be indexed. Check that the source tree is readable, then try again.",implemented,focused assertions passed
31,src/application/ReferenceSourceImportTypes.ts:limit,Import reports terminal limit failure,No reachable constructor found,Declared state may be unreachable; false verification risk,Remove unreachable top-level limit variant; per-entry limit guidance remains caller-visible.,removed,"verified by typecheck, exhaustive projection test, and source search"
32,src/application/Setup.ts:configureDetectedClients(backup),Agent configuration backup fails,"{clientName} configuration backup verification failed; no successful configuration was reported.",Exposes machine client key and obscures no-write guarantee,"Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.",implemented,focused assertions passed
33,src/application/Setup.ts:configureDetectedClients(write),Agent configuration write fails,"{clientName} configuration write verification failed; no successful configuration was reported.",Exposes machine client key and gives no recovery,"Agent configuration could not be updated. Check file permissions, then rerun setup.",implemented,focused assertions passed
34,src/application/Setup.ts:configureDetectedClients(readback),Written agent configuration cannot be verified,"{clientName} configuration readback verification failed; no successful configuration was reported.",Exposes machine client key and omits backup recovery,"Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.",implemented,focused assertions passed
35,src/application/Setup.ts:installSkill,Skill install or semantic readback fails,Agent skill installation or readback failed.,No useful recovery action,"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",implemented,focused assertions passed
36,src/main.ts:serveStdio.onerror,Established MCP client connection reports an error,MCP stdio transport error,Exposes transport implementation and gives no recovery,REA lost its MCP client connection. Restart REA from your MCP client.,implemented,verified by injected transport onerror runtime test
37,src/main.ts:serveStdio throw,MCP connection cannot be created,Failed to start MCP stdio transport,Exposes transport implementation and gives no recovery,REA could not start its MCP connection. Restart REA from your MCP client; run `rea doctor` if it fails again.,implemented,verified by injected transport startup-throw runtime test
38,src/main.ts:requestShutdown catch,Connection or analysis session close rejects,Failed to shut down cleanly,Does not explain duplicate-process risk or recovery,REA could not close cleanly. End the REA process before starting it again.,implemented,verified by injected handle-close rejection runtime test
39,src/main.ts:entrypoint catch,Unhandled rejection escapes run(),Unexpected server startup failure,Vague and lacks diagnostic recovery,"REA could not start. Run `rea doctor`, then restart REA from your MCP client.",implemented,verified by injected entrypoint rejection test
40,src/application/Upgrade.ts:unknown-install-method,Running package is not owned by detectable global npm installation,Update manually with: npm install --global rea-agents@latest,Already gives exact safe recovery,Keep current copy,unchanged,existing focused assertion passed
41,src/application/Upgrade.ts:install,npm global install subprocess exits unsuccessfully,npm install --global rea-agents@latest failed.,Does not identify bounded likely checks before retry,"REA could not update through npm. Check npm registry access and global install permissions, then run: npm install --global rea-agents@latest",implemented,focused assertion passed
42,src/application/Uninstall.ts:removeClient read,Existing agent configuration cannot be read,Configuration could not be read.,No recovery step,"Configuration could not be read. Check file permissions, then rerun uninstall.",implemented,verified by injected filesystem read failure test
43,src/application/Uninstall.ts:removeClient parse,Agent configuration is malformed,Malformed configuration was preserved without mutation.,Does not name required repair format or retry,"Configuration is not valid JSON/TOML and was not changed. Repair it, then rerun uninstall.",implemented,focused JSON assertion passed; TOML path uses same branch
44,src/application/Uninstall.ts:removeClient backup,Configuration backup copy fails,Configuration backup failed; no mutation was attempted.,No recovery step,"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",implemented,verified by injected filesystem backup failure test
45,src/application/Uninstall.ts:removeClient update,Write or semantic readback fails,Configuration update failed; the original was restored and {backupPath} was retained.,Exposes local path and lacks clear next sequence,"Report whether the original was restored; if restoration also fails, direct the user to the retained backup.",implemented,verified by injected update and restoration failure tests
45.5,src/application/Uninstall.ts:removeClient restore,Configuration update fails and restoring the original also fails,"Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall.","High risk; persistent configuration may be damaged, but retained backup is authoritative",Keep new recovery copy,implemented,verified by injected restoration failure test
46,src/application/Uninstall.ts:removeManagedPath symlink,Managed cache/state/skill path is a symbolic link,Refused to follow symlink {path}.,Exposes local path and does not explain manual safety check,"REA did not remove this item because its managed path is a symbolic link. Verify the link target before removing it manually.",implemented,focused assertion passed
47,src/application/Uninstall.ts:removeManagedPath failure,Managed item removal fails,{path} could not be removed.,Exposes local path and gives no recovery,"This item could not be removed. Check file permissions, then rerun uninstall.",implemented,verified by injected managed-path removal failure test
48,src/application/Doctor.ts:node,Runtime version is outside supported ranges,Install Node.js 22.19+ or 24.11+.,Low risk; exact dependency recovery,Keep current copy,unchanged,verified by doctor unit test
49,src/application/Doctor.ts:host,Host OS/distribution is unsupported,"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.",Low risk; clearly states supported choices,Keep current copy,unchanged,verified by doctor unit tests
50,src/application/Doctor.ts:analysis engine,Optional deep-analysis executable cannot be found,"Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",Low risk in setup-specific diagnostic; exact recovery paths,Keep current copy,unchanged,verified by doctor unit tests
51,src/application/Doctor.ts:target,Optional target path fails shared validation,Supply a readable local app or program path.,Low risk; tells caller valid target class,Keep current copy,unchanged,verified by doctor unit test
52,src/application/Setup.ts:approval,Setup plan lacks approval,"Review the setup plan, then rerun interactively or with --yes.",Low risk; exact authorization recovery,Keep current copy,unchanged,verified by setup unit tests
53,src/application/Setup.ts:engine install,Approved analysis-engine installation returns no launcher,Hopper installation failed; install Hopper manually or rerun setup after resolving the reported system error.,Mentions component intentionally being installed and offers two bounded recovery paths,Keep current copy,unchanged,verified by setup unit test
54,src/application/Setup.ts:activation,New analysis-engine install requires one-time GUI activation,"Open Hopper, complete its one-time activation, then rerun rea doctor --json.",Low risk; exact manual step and verification command,Keep current copy,unchanged,verified by setup unit test
55,src/application/Setup.ts:optional engine,Setup completes without optional deep-analysis engine,Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis.,Exposes provider terminology but this is an explanatory setup status rather than an error; recovery command is exact,Keep current copy,unchanged,verified by setup unit test
56,src/application/Setup.ts:doctor fallback,Post-setup diagnostics remain unhealthy,Run rea doctor and apply each reported remediation.,Low risk; delegates to structured per-check recovery,Keep current copy,unchanged,verified by exact setup unit test
57,src/application/Setup.ts:unsupported platform,Platform is neither macOS nor Linux,REA supports Hopper on macOS and selected 64-bit Linux distributions.,Low risk; states support boundary,Keep current copy,unchanged,verified by setup unit test
58,src/application/Setup.ts:unsupported Node,Runtime version cannot run setup,Install Node.js 22.19+ or 24.11+ and rerun setup.,Low risk; exact dependency and retry,Keep current copy,unchanged,verified by setup host tests
59,src/application/Setup.ts:unsupported macOS,macOS version is missing or older than 12,Upgrade to macOS 12 or newer.,Low risk; exact support floor,Keep current copy,unchanged,verified by setup host tests
60,src/application/Setup.ts:unsupported Linux,Linux distribution is outside supported set,"REA supports Hopper on Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.",Low risk; exact supported alternatives,Keep current copy,unchanged,verified by setup unit test
61,src/application/ProcessCli.ts:readJson read,Process scenario or evidence file cannot be read,Raw filesystem exception through CLI framework,Can expose local paths/codes and no recovery,"Process input file could not be read. Check that the path exists and is readable.",implemented,verified by focused CLI test
62,src/application/ProcessCli.ts:readJson size,Process scenario or evidence file exceeds 64 MiB,Process JSON input exceeds byte limit,Does not state limit or recovery,"Process input file is too large. Reduce it below 64 MiB, then try again.",implemented,verified by focused sparse-file CLI test
63,src/application/ProcessCli.ts:readJson parse,Process scenario or evidence file is malformed JSON,Raw JSON parser exception through CLI framework,Can expose parser implementation and lacks recovery,"Process input file is not valid JSON. Repair the file, then try again.",implemented,verified by focused CLI test
64,src/application/ProcessCli.ts:capture scenario parse,Scenario fails Process Capture schema,Raw Zod validation exception through CLI framework,Exposes schema internals and field paths without stable recovery,"Process scenario is invalid. Check its required fields and limits, then try again.",implemented,verified by focused CLI test
65,src/application/ProcessCli.ts:legacy evidence,Comparison receives Process Capture v3 Evidence,"Process Capture v3 is unsupported. Re-run the scenario with this REA version using capture_process_scenario to produce Process Capture v4; captures cannot be upgraded because v4 requires new manifest and settlement evidence.",Long but accurately explains why recapture is required and gives exact tool recovery,Keep current copy,unchanged,verified by focused CLI test
66,src/application/ProcessCli.ts:wrong evidence,Comparison receives valid Evidence from another workflow,Expected REA Process Capture v4 Evidence,Uses internal schema name and does not explain recovery,"Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.",implemented,verified by focused CLI test
67,src/application/ProcessCli.ts:unexpected,Unexpected parser/comparison/canonicalization failure escapes expected classifications,Raw thrown exception through CLI framework,Can expose stack or internal invariant,"Process command could not complete. Check the input files and run `rea doctor`, then try again.",implemented,verified by direct unexpected-cause projection test
68,src/server/registerEvidenceTools.ts:object guard,Validated provider-tool input is unexpectedly not an object,Provider tool input was not an object,Exposes provider architecture and implies caller can fix impossible state,Remove the impossible duplicate guard and parse through the declared JSON-object schema.,removed,verified by typecheck and MCP integration tests
69,src/server/registerEnhancedTools.ts:object guard,Validated enhanced-tool input is unexpectedly not an object,Validated enhanced input was not a JSON object,Exposes validation/JSON implementation detail,Remove the impossible duplicate guard and parse through the declared JSON-object schema.,removed,verified by typecheck and enhanced MCP integration tests
70,src/server/registerOfficialTools.ts:object guard,Validated direct-tool input is unexpectedly not an object,Validated MCP tool input was not a JSON object,Exposes MCP validation implementation detail,Remove the impossible duplicate guard and parse through the declared JSON-object schema.,removed,verified by typecheck and official MCP integration tests
71,src/server/registerProcessComparisonTool.ts:canonical guard,Process comparison value cannot become canonical JSON,Process capture could not be canonicalized,Names internal serialization invariant and gives no recovery,Remove the impossible canonicalization error branch; compare schema-validated values directly.,removed,verified by process-comparison MCP tests and source search
72,src/server/sessionEvidence.ts:canonical guard,Recorded Evidence cannot become canonical JSON,Evidence could not be canonicalized,Names internal serialization invariant and gives no recovery,Remove the impossible canonicalization error branch; compare parsed Evidence directly.,removed,verified by session Evidence integrity tests and source search
73,install.sh:--version value,--version has no following value,--version requires a value.,Low risk; identifies correction,Keep current copy,unchanged,verified by shell branch inspection
74,install.sh:unknown option,Installer receives an unsupported flag,unknown option with caller-provided flag,Low risk; identifies offending input,Keep current copy,unchanged,verified by shell branch inspection
75,install.sh:platform,Installer runs outside macOS or supported Linux,REA supports macOS and selected 64-bit Linux distributions.,Low risk; states support boundary,Keep current copy,unchanged,verified by shell branch inspection
76,install.sh:curl missing,curl is absent,curl is required.,No recovery instruction,"curl is required. Install curl, then rerun this installer.",implemented,verified by exact missing-curl installer test
77,install.sh:node missing,node is absent,Node.js 22.19+ or 24.11+ is required; install it from nodejs.org and retry.,Low risk; exact versions and recovery,Keep current copy,unchanged,verified by shell branch inspection
78,install.sh:npm missing,npm is absent,npm is required; install it with Node.js and retry.,Low risk; exact recovery,Keep current copy,unchanged,verified by shell branch inspection
79,install.sh:node unreadable,node exists but does not report a parseable version,could not determine the active Node.js version.,No cause-local recovery,"The active Node.js version could not be read. Check that node works and is on PATH, then retry.",implemented,verified by exact unreadable-Node-version installer test
80,install.sh:Node 22 floor,Node 22 minor version is below 19,Node.js VERSION is unsupported; use Node.js 22.19+ or 24.11+.,Low risk; exact detected version and supported alternatives,Keep current copy,unchanged,verified by exact installer test using Node 20 and branch inspection
81,install.sh:Node 24 floor,Node 24 minor version is below 11,Node.js VERSION is unsupported; use Node.js 24.11 or newer.,Low risk; exact detected version and floor,Keep current copy,unchanged,verified by shell branch inspection
82,install.sh:version syntax,Explicit version is not exact semantic version,version must be an exact semantic version.,Low risk; exact correction,Keep current copy,unchanged,verified by shell branch inspection
83,install.sh:release lookup,Latest GitHub release request fails,could not resolve the latest GitHub release.,No fallback despite supporting pinned versions,"The latest REA release could not be resolved. Check network access or pass --version VERSION, then retry.",implemented,verified by exact release-lookup installer test
84,install.sh:release response,Latest release response is not valid expected JSON,Node JSON parser stack followed by the safe release-response message,Leaks a parser stack and buries recovery guidance,The release response was invalid. Retry later or pass --version VERSION.,implemented,verified by exact malformed-release test with parser stderr suppressed
85,install.sh:release tag,Latest release tag does not match REA release convention,latest release tag is not a valid rea-agents release.,Exposes package naming detail and omits fallback,The latest release tag was invalid. Retry later or pass --version VERSION.,implemented,verified by exact invalid-release-tag installer test
86,install.sh:npm prefix,npm cannot return its global prefix,could not determine the npm global prefix.,No recovery instruction,"The npm global prefix could not be read. Repair the npm configuration, then retry.",implemented,verified by exact macOS npm-prefix installer test
87,install.sh:npm install,npm global installation exits nonzero,npm package installation failed.,No likely causes or recovery,"npm could not install REA. Check registry access and npm permissions, then retry.",implemented,verified by exact installer test
88,install.sh:binary absent,npm exits successfully without executable,the rea command was not installed at local path,Leaks local path and no recovery,"npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry.",implemented,verified by exact absent-installed-command test
89,install.sh:version read,Installed rea executable cannot report its version,could not read the installed REA version.,No recovery instruction,"The installed REA version could not be read. Reinstall the requested version, then retry.",implemented,verified by exact unreadable-installed-version test
90,install.sh:version mismatch,Installed rea reports a different version,installed version ACTUAL does not match EXPECTED.,Low risk; gives exact integrity mismatch,Keep current copy,unchanged,verified by exact installer test
90.5,@modelcontextprotocol/server:tool input validation,Caller sends arguments that violate a declared tool schema,"Input validation error: Invalid arguments for tool TOOL: FIELD: Invalid input: expected TYPE, received TYPE",Low risk; public tool and field names plus exact type correction,Keep SDK-generated copy,unchanged,verified by real in-memory MCP client/server call with wrong argument type
90.6,incur:unknown CLI command,Caller enters an unknown REA command,"'COMMAND' is not a command for 'rea'. Suggested command: rea --help",Low risk; identifies input and exact discovery action,Keep framework-generated copy,unchanged,verified by real packaged dispatcher invocation
1 harm_rank location trigger current_copy user_risk proposed_replacement implementation_status verification_result
2 1 src/domain/errors.ts:AnalysisCapabilityUnavailableError Requested analysis unavailable Provider {providerId} cannot execute {operation}: {reason} Exposes provider and internal operation/reason This analysis is unavailable for the current target. Choose another analysis or target. implemented focused assertions passed; full `npm run check` passed
3 2 src/domain/errors.ts:ProviderAdapterError Provider adapter fails Provider {providerId} adapter failed during {operation} Exposes provider and implementation boundary Analysis could not complete. Retry once; if it continues, run `rea doctor`. implemented focused assertions passed; full `npm run check` passed
4 3 src/domain/errors.ts:HopperRemoteError Remote analysis request fails Hopper request failed ({code}): {safeMessage} Exposes provider name code and upstream text Analysis could not complete. Retry once; if it continues, run `rea doctor`. implemented focused assertions passed; full `npm run check` passed
5 4 src/domain/errors.ts:HopperProtocolError Malformed provider protocol result Raw constructor message Can expose provider protocol and implementation details Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`. implemented focused assertions passed; full `npm run check` passed
6 5 src/domain/errors.ts:AnalysisOutputError Analysis output fails parsing Invalid analysis output for {operation}: {reason} Exposes internal operation and parser reason Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`. implemented focused assertions passed; full `npm run check` passed
7 6 src/domain/errors.ts:AnalysisProtocolError Application protocol fails Raw constructor message Can expose protocol internals Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`. implemented focused assertions passed; full `npm run check` passed
8 7 src/domain/errors.ts:HopperProcessError Analysis subprocess stops Hopper bridge stopped unexpectedly with code {exitCode} Exposes provider bridge and process code Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again. implemented focused assertions passed; full `npm run check` passed
9 8 src/domain/errors.ts:HopperStartError Analysis subprocess cannot start Hopper application bridge could not be started Exposes provider and bridge implementation Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again. implemented focused assertions passed; full `npm run check` passed
10 9 src/domain/errors.ts:BinaryTargetError Target cannot open Cannot open artifact: {reason} May expose raw reason and lacks checks REA could not open that app or binary. Check that the path exists, is readable, and points to a supported file. implemented focused assertions passed; full `npm run check` passed
11 10 src/domain/errors.ts:ConfigurationError Environment configuration invalid Raw constructor message May expose provider-specific variables and lacks recovery REA configuration is invalid. Run `rea doctor` and fix the reported setting. implemented focused assertions passed; full `npm run check` passed
12 11 src/domain/errors.ts:ArtifactOperationError Artifact inventory or extraction fails Artifact {operation} failed: {reason} at {logicalPath} (declared_sha256=...) Exposes paths hashes and internal reason Reason-specific plain message with safe recovery step. implemented focused assertions passed; full `npm run check` passed
13 12 src/domain/errors.ts:EvidenceFileError Evidence file operation fails Evidence bundle {operation} failed: {reason} Internal reason token gives weak recovery Reason-specific plain message with safe recovery step. implemented focused assertions passed; full `npm run check` passed
14 13 src/domain/errors.ts:EvidenceIntegrityError Evidence validation fails Raw constructor message May expose validation internals and lacks recovery Evidence is invalid or has changed. Recreate or re-import it, then try again. implemented focused assertions passed; full `npm run check` passed
15 14 src/domain/errors.ts:UnknownRegistryError Evidence-state mutation fails Residual unknown registry mutation failed: {reason} Exposes internal subsystem and state token Evidence state changed before the update completed. Refresh the current state and try again. implemented focused assertions passed; full `npm run check` passed
16 15 src/domain/errors.ts:ProcessCaptureError Process capture fails Raw constructor message May expose process and cleanup internals Process capture could not complete. Check command access and limits, then try again. implemented focused assertions passed; full `npm run check` passed
17 16 src/domain/errors.ts:AnalysisTimeoutError Analysis deadline expires Analysis operation timed out after {timeoutMs}ms: {operation} Exposes internal deadline and operation; weak recovery Analysis took too long. Try a smaller request, then run `rea doctor` if it continues. implemented focused assertions passed; full `npm run check` passed
18 17 src/domain/errors.ts:HopperTimeoutError Provider deadline expires Hopper did not respond within {timeoutMs}ms Exposes provider and internal deadline Analysis took too long. Try a smaller request, then run `rea doctor` if it continues. implemented focused assertions passed; full `npm run check` passed
19 18 src/domain/errors.ts:ProviderSelectionError No configured provider supports operation No configured provider can execute {operation} Exposes provider architecture and internal operation This analysis is unavailable for the current target. Choose another analysis or target. implemented focused assertions passed; full `npm run check` passed
20 19 src/domain/errors.ts:EvidenceLimitError Evidence ledger limit reached Evidence ledger {limit} limit exceeded ({maximum}) Exposes internal storage model Evidence is too large for this session. Reduce the evidence set and try again. implemented focused assertions passed; full `npm run check` passed
21 20 src/domain/errors.ts:AnalysisInputError Parsed analysis input invalid Invalid analysis input for {operation} Exposes operation identifier and lacks correction Analysis input is invalid. Check the arguments and try again. implemented focused assertions passed; full `npm run check` passed
22 21 src/domain/errors.ts:AnalysisCancelledError Caller cancels analysis Analysis operation was cancelled: {operation} Exposes operation identifier Analysis was cancelled. Start it again when ready. implemented focused assertions passed; full `npm run check` passed
23 22 src/domain/errors.ts:HopperCancelledError Caller cancels provider request Hopper operation was cancelled Exposes provider name Analysis was cancelled. Start it again when ready. implemented focused assertions passed; full `npm run check` passed
24 23 src/domain/errors.ts:NoBinaryOpenError Analysis requested without target No app is open. Ask the user which app to investigate, then call open_binary with its local path. Low risk; already supplies agent recovery Keep current copy implemented focused assertions passed; full `npm run check` passed
25 24 src/server/toolResult.ts:successResult Successful value violates public output schema AnalysisOutputError: Analysis output does not match the tool contract Exposes internal error tag and contract implementation Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`. implemented focused assertions passed; full `npm run check` passed
26 24.5 src/server/registerOfficialTools.ts:capability unknown Approved unknown tracking records an unavailable analysis TOOL_NAME is unavailable: PROVIDER_REASON Exposes an internal tool identifier and provider diagnostic in persistent user-visible evidence The requested analysis is unavailable for the current target. implemented verified by focused MCP integration test
27 24.6 src/application/ReferenceSourceImportEntries.ts:failed entry A partial reference import contains an unreadable entry Low-level reader diagnostic stored in entry limitations Can persist filesystem or reader implementation details Code- and entry-specific safe guidance with a retry, limit, exclusion, or platform recovery step. implemented verified by exhaustive focused projection test
28 24.7 src/server/registerSessionTools.ts:process residual Capture records an approved residual unknown SCOPE: RAW_REASON Persists adapter diagnostic and implementation wording Was SCOPE behavior fully observed during capture? implemented verified by focused session MCP test
29 24.8 src/server/registerInvestigationTools.ts:changed behavior unknown Changed-behavior workflow remains incomplete Changed behavior result EVIDENCE_ID remains STATUS Persists internal evidence identifier Did both versions behave the same under a complete controlled replay? implemented verified by focused investigation MCP test
30 24.9 src/server/registerInvestigationTools.ts:call path unknown Call-path workflow remains incomplete Call path START_ADDRESS → GOAL_ADDRESS is STATUS Persists binary addresses and internal status wording Can the requested call path be established from complete analysis? implemented verified by focused unresolved call-path MCP test
31 24.91 src/server/registerInvestigationTools.ts:static runtime unknown Static/runtime correlation remains incomplete Static/runtime correlation result EVIDENCE_ID is STATUS Persists internal evidence identifier Does runtime behavior match the available static analysis? implemented verified by focused unresolved static/runtime MCP test
32 24.92 src/server/registerInvestigationTools.ts:reconstruction unknown Reconstruction verification retains unknown claims Reconstruction SPECIFICATION_HASH has unknown claims Persists internal hash Does the reconstruction satisfy every declared claim? implemented verified by focused unknown reconstruction MCP test
33 24.93 src/server/registerFunctionComparisonTool.ts:probe rationale Function comparison is incomplete Capture complete dossiers with equal providers and analysis limits. Exposes provider architecture and gives ambiguous equality guidance Capture complete dossiers for both functions under the same target context and analysis limits. implemented verified by focused function-comparison MCP test
34 24.95 incur:CLI argument validation Caller omits or mistypes a command argument VALIDATION_ERROR with raw Zod Details and fieldErrors including code/path/expected/received Exposes validator representation and buries the useful correction REA could not read the command arguments. Check the named argument and its expected type, then try again. implemented verified by sanitizer unit tests and real dispatcher missing-argument test
35 25 src/application/ReferenceSourceImport.ts:cancelled Reference-source import is cancelled Reference source import cancelled No recovery timing guidance Reference source import was cancelled. Start it again when ready. implemented focused assertions passed
36 26 src/application/ReferenceSourceImportPolicy.ts:invalid-limits Configured import limits are not positive integers Import limits must be positive integers Does not tell user where recovery lies Reference source limits are invalid. Use positive integer limits, then try again. implemented focused assertions passed
37 27 src/application/ReferenceSourceImportPolicy.ts:invalid-root Source path cannot resolve or is not a directory Reference source root could not be resolved / Reference source root is not a directory Multiple low-level copies and no complete path check Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory. implemented focused assertions passed
38 28 src/application/ReferenceSourceImportPolicy.ts:policy No approved root exists or requested root is outside policy No approved reference source roots / Reference source root is outside approved roots Does not identify safe configuration recovery Reference source directory is not approved. Add its directory to `REA_REFERENCE_ROOTS_JSON`, restart REA, then try again. implemented focused assertions passed
39 29 src/application/ReferenceSourceImport.ts:io Reader fails during bounded traversal Raw reader error message May expose filesystem details and inconsistent recovery Reference source files could not be read. Check directory permissions and try again. implemented focused assertions passed
40 30 src/application/ReferenceSourceImport.ts:parse Historical graph validation fails Raw thrown graph validation message Can expose internal graph invariants Reference source could not be indexed. Check that the source tree is readable, then try again. implemented focused assertions passed
41 31 src/application/ReferenceSourceImportTypes.ts:limit Import reports terminal limit failure No reachable constructor found Declared state may be unreachable; false verification risk Remove unreachable top-level limit variant; per-entry limit guidance remains caller-visible. removed verified by typecheck, exhaustive projection test, and source search
42 32 src/application/Setup.ts:configureDetectedClients(backup) Agent configuration backup fails {clientName} configuration backup verification failed; no successful configuration was reported. Exposes machine client key and obscures no-write guarantee Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup. implemented focused assertions passed
43 33 src/application/Setup.ts:configureDetectedClients(write) Agent configuration write fails {clientName} configuration write verification failed; no successful configuration was reported. Exposes machine client key and gives no recovery Agent configuration could not be updated. Check file permissions, then rerun setup. implemented focused assertions passed
44 34 src/application/Setup.ts:configureDetectedClients(readback) Written agent configuration cannot be verified {clientName} configuration readback verification failed; no successful configuration was reported. Exposes machine client key and omits backup recovery Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup. implemented focused assertions passed
45 35 src/application/Setup.ts:installSkill Skill install or semantic readback fails Agent skill installation or readback failed. No useful recovery action REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup. implemented focused assertions passed
46 36 src/main.ts:serveStdio.onerror Established MCP client connection reports an error MCP stdio transport error Exposes transport implementation and gives no recovery REA lost its MCP client connection. Restart REA from your MCP client. implemented verified by injected transport onerror runtime test
47 37 src/main.ts:serveStdio throw MCP connection cannot be created Failed to start MCP stdio transport Exposes transport implementation and gives no recovery REA could not start its MCP connection. Restart REA from your MCP client; run `rea doctor` if it fails again. implemented verified by injected transport startup-throw runtime test
48 38 src/main.ts:requestShutdown catch Connection or analysis session close rejects Failed to shut down cleanly Does not explain duplicate-process risk or recovery REA could not close cleanly. End the REA process before starting it again. implemented verified by injected handle-close rejection runtime test
49 39 src/main.ts:entrypoint catch Unhandled rejection escapes run() Unexpected server startup failure Vague and lacks diagnostic recovery REA could not start. Run `rea doctor`, then restart REA from your MCP client. implemented verified by injected entrypoint rejection test
50 40 src/application/Upgrade.ts:unknown-install-method Running package is not owned by detectable global npm installation Update manually with: npm install --global rea-agents@latest Already gives exact safe recovery Keep current copy unchanged existing focused assertion passed
51 41 src/application/Upgrade.ts:install npm global install subprocess exits unsuccessfully npm install --global rea-agents@latest failed. Does not identify bounded likely checks before retry REA could not update through npm. Check npm registry access and global install permissions, then run: npm install --global rea-agents@latest implemented focused assertion passed
52 42 src/application/Uninstall.ts:removeClient read Existing agent configuration cannot be read Configuration could not be read. No recovery step Configuration could not be read. Check file permissions, then rerun uninstall. implemented verified by injected filesystem read failure test
53 43 src/application/Uninstall.ts:removeClient parse Agent configuration is malformed Malformed configuration was preserved without mutation. Does not name required repair format or retry Configuration is not valid JSON/TOML and was not changed. Repair it, then rerun uninstall. implemented focused JSON assertion passed; TOML path uses same branch
54 44 src/application/Uninstall.ts:removeClient backup Configuration backup copy fails Configuration backup failed; no mutation was attempted. No recovery step Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall. implemented verified by injected filesystem backup failure test
55 45 src/application/Uninstall.ts:removeClient update Write or semantic readback fails Configuration update failed; the original was restored and {backupPath} was retained. Exposes local path and lacks clear next sequence Report whether the original was restored; if restoration also fails, direct the user to the retained backup. implemented verified by injected update and restoration failure tests
56 45.5 src/application/Uninstall.ts:removeClient restore Configuration update fails and restoring the original also fails Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall. High risk; persistent configuration may be damaged, but retained backup is authoritative Keep new recovery copy implemented verified by injected restoration failure test
57 46 src/application/Uninstall.ts:removeManagedPath symlink Managed cache/state/skill path is a symbolic link Refused to follow symlink {path}. Exposes local path and does not explain manual safety check REA did not remove this item because its managed path is a symbolic link. Verify the link target before removing it manually. implemented focused assertion passed
58 47 src/application/Uninstall.ts:removeManagedPath failure Managed item removal fails {path} could not be removed. Exposes local path and gives no recovery This item could not be removed. Check file permissions, then rerun uninstall. implemented verified by injected managed-path removal failure test
59 48 src/application/Doctor.ts:node Runtime version is outside supported ranges Install Node.js 22.19+ or 24.11+. Low risk; exact dependency recovery Keep current copy unchanged verified by doctor unit test
60 49 src/application/Doctor.ts:host Host OS/distribution is unsupported REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux. Low risk; clearly states supported choices Keep current copy unchanged verified by doctor unit tests
61 50 src/application/Doctor.ts:analysis engine Optional deep-analysis executable cannot be found Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH. Low risk in setup-specific diagnostic; exact recovery paths Keep current copy unchanged verified by doctor unit tests
62 51 src/application/Doctor.ts:target Optional target path fails shared validation Supply a readable local app or program path. Low risk; tells caller valid target class Keep current copy unchanged verified by doctor unit test
63 52 src/application/Setup.ts:approval Setup plan lacks approval Review the setup plan, then rerun interactively or with --yes. Low risk; exact authorization recovery Keep current copy unchanged verified by setup unit tests
64 53 src/application/Setup.ts:engine install Approved analysis-engine installation returns no launcher Hopper installation failed; install Hopper manually or rerun setup after resolving the reported system error. Mentions component intentionally being installed and offers two bounded recovery paths Keep current copy unchanged verified by setup unit test
65 54 src/application/Setup.ts:activation New analysis-engine install requires one-time GUI activation Open Hopper, complete its one-time activation, then rerun rea doctor --json. Low risk; exact manual step and verification command Keep current copy unchanged verified by setup unit test
66 55 src/application/Setup.ts:optional engine Setup completes without optional deep-analysis engine Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis. Exposes provider terminology but this is an explanatory setup status rather than an error; recovery command is exact Keep current copy unchanged verified by setup unit test
67 56 src/application/Setup.ts:doctor fallback Post-setup diagnostics remain unhealthy Run rea doctor and apply each reported remediation. Low risk; delegates to structured per-check recovery Keep current copy unchanged verified by exact setup unit test
68 57 src/application/Setup.ts:unsupported platform Platform is neither macOS nor Linux REA supports Hopper on macOS and selected 64-bit Linux distributions. Low risk; states support boundary Keep current copy unchanged verified by setup unit test
69 58 src/application/Setup.ts:unsupported Node Runtime version cannot run setup Install Node.js 22.19+ or 24.11+ and rerun setup. Low risk; exact dependency and retry Keep current copy unchanged verified by setup host tests
70 59 src/application/Setup.ts:unsupported macOS macOS version is missing or older than 12 Upgrade to macOS 12 or newer. Low risk; exact support floor Keep current copy unchanged verified by setup host tests
71 60 src/application/Setup.ts:unsupported Linux Linux distribution is outside supported set REA supports Hopper on Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux. Low risk; exact supported alternatives Keep current copy unchanged verified by setup unit test
72 61 src/application/ProcessCli.ts:readJson read Process scenario or evidence file cannot be read Raw filesystem exception through CLI framework Can expose local paths/codes and no recovery Process input file could not be read. Check that the path exists and is readable. implemented verified by focused CLI test
73 62 src/application/ProcessCli.ts:readJson size Process scenario or evidence file exceeds 64 MiB Process JSON input exceeds byte limit Does not state limit or recovery Process input file is too large. Reduce it below 64 MiB, then try again. implemented verified by focused sparse-file CLI test
74 63 src/application/ProcessCli.ts:readJson parse Process scenario or evidence file is malformed JSON Raw JSON parser exception through CLI framework Can expose parser implementation and lacks recovery Process input file is not valid JSON. Repair the file, then try again. implemented verified by focused CLI test
75 64 src/application/ProcessCli.ts:capture scenario parse Scenario fails Process Capture schema Raw Zod validation exception through CLI framework Exposes schema internals and field paths without stable recovery Process scenario is invalid. Check its required fields and limits, then try again. implemented verified by focused CLI test
76 65 src/application/ProcessCli.ts:legacy evidence Comparison receives Process Capture v3 Evidence Process Capture v3 is unsupported. Re-run the scenario with this REA version using capture_process_scenario to produce Process Capture v4; captures cannot be upgraded because v4 requires new manifest and settlement evidence. Long but accurately explains why recapture is required and gives exact tool recovery Keep current copy unchanged verified by focused CLI test
77 66 src/application/ProcessCli.ts:wrong evidence Comparison receives valid Evidence from another workflow Expected REA Process Capture v4 Evidence Uses internal schema name and does not explain recovery Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again. implemented verified by focused CLI test
78 67 src/application/ProcessCli.ts:unexpected Unexpected parser/comparison/canonicalization failure escapes expected classifications Raw thrown exception through CLI framework Can expose stack or internal invariant Process command could not complete. Check the input files and run `rea doctor`, then try again. implemented verified by direct unexpected-cause projection test
79 68 src/server/registerEvidenceTools.ts:object guard Validated provider-tool input is unexpectedly not an object Provider tool input was not an object Exposes provider architecture and implies caller can fix impossible state Remove the impossible duplicate guard and parse through the declared JSON-object schema. removed verified by typecheck and MCP integration tests
80 69 src/server/registerEnhancedTools.ts:object guard Validated enhanced-tool input is unexpectedly not an object Validated enhanced input was not a JSON object Exposes validation/JSON implementation detail Remove the impossible duplicate guard and parse through the declared JSON-object schema. removed verified by typecheck and enhanced MCP integration tests
81 70 src/server/registerOfficialTools.ts:object guard Validated direct-tool input is unexpectedly not an object Validated MCP tool input was not a JSON object Exposes MCP validation implementation detail Remove the impossible duplicate guard and parse through the declared JSON-object schema. removed verified by typecheck and official MCP integration tests
82 71 src/server/registerProcessComparisonTool.ts:canonical guard Process comparison value cannot become canonical JSON Process capture could not be canonicalized Names internal serialization invariant and gives no recovery Remove the impossible canonicalization error branch; compare schema-validated values directly. removed verified by process-comparison MCP tests and source search
83 72 src/server/sessionEvidence.ts:canonical guard Recorded Evidence cannot become canonical JSON Evidence could not be canonicalized Names internal serialization invariant and gives no recovery Remove the impossible canonicalization error branch; compare parsed Evidence directly. removed verified by session Evidence integrity tests and source search
84 73 install.sh:--version value --version has no following value --version requires a value. Low risk; identifies correction Keep current copy unchanged verified by shell branch inspection
85 74 install.sh:unknown option Installer receives an unsupported flag unknown option with caller-provided flag Low risk; identifies offending input Keep current copy unchanged verified by shell branch inspection
86 75 install.sh:platform Installer runs outside macOS or supported Linux REA supports macOS and selected 64-bit Linux distributions. Low risk; states support boundary Keep current copy unchanged verified by shell branch inspection
87 76 install.sh:curl missing curl is absent curl is required. No recovery instruction curl is required. Install curl, then rerun this installer. implemented verified by exact missing-curl installer test
88 77 install.sh:node missing node is absent Node.js 22.19+ or 24.11+ is required; install it from nodejs.org and retry. Low risk; exact versions and recovery Keep current copy unchanged verified by shell branch inspection
89 78 install.sh:npm missing npm is absent npm is required; install it with Node.js and retry. Low risk; exact recovery Keep current copy unchanged verified by shell branch inspection
90 79 install.sh:node unreadable node exists but does not report a parseable version could not determine the active Node.js version. No cause-local recovery The active Node.js version could not be read. Check that node works and is on PATH, then retry. implemented verified by exact unreadable-Node-version installer test
91 80 install.sh:Node 22 floor Node 22 minor version is below 19 Node.js VERSION is unsupported; use Node.js 22.19+ or 24.11+. Low risk; exact detected version and supported alternatives Keep current copy unchanged verified by exact installer test using Node 20 and branch inspection
92 81 install.sh:Node 24 floor Node 24 minor version is below 11 Node.js VERSION is unsupported; use Node.js 24.11 or newer. Low risk; exact detected version and floor Keep current copy unchanged verified by shell branch inspection
93 82 install.sh:version syntax Explicit version is not exact semantic version version must be an exact semantic version. Low risk; exact correction Keep current copy unchanged verified by shell branch inspection
94 83 install.sh:release lookup Latest GitHub release request fails could not resolve the latest GitHub release. No fallback despite supporting pinned versions The latest REA release could not be resolved. Check network access or pass --version VERSION, then retry. implemented verified by exact release-lookup installer test
95 84 install.sh:release response Latest release response is not valid expected JSON Node JSON parser stack followed by the safe release-response message Leaks a parser stack and buries recovery guidance The release response was invalid. Retry later or pass --version VERSION. implemented verified by exact malformed-release test with parser stderr suppressed
96 85 install.sh:release tag Latest release tag does not match REA release convention latest release tag is not a valid rea-agents release. Exposes package naming detail and omits fallback The latest release tag was invalid. Retry later or pass --version VERSION. implemented verified by exact invalid-release-tag installer test
97 86 install.sh:npm prefix npm cannot return its global prefix could not determine the npm global prefix. No recovery instruction The npm global prefix could not be read. Repair the npm configuration, then retry. implemented verified by exact macOS npm-prefix installer test
98 87 install.sh:npm install npm global installation exits nonzero npm package installation failed. No likely causes or recovery npm could not install REA. Check registry access and npm permissions, then retry. implemented verified by exact installer test
99 88 install.sh:binary absent npm exits successfully without executable the rea command was not installed at local path Leaks local path and no recovery npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry. implemented verified by exact absent-installed-command test
100 89 install.sh:version read Installed rea executable cannot report its version could not read the installed REA version. No recovery instruction The installed REA version could not be read. Reinstall the requested version, then retry. implemented verified by exact unreadable-installed-version test
101 90 install.sh:version mismatch Installed rea reports a different version installed version ACTUAL does not match EXPECTED. Low risk; gives exact integrity mismatch Keep current copy unchanged verified by exact installer test
102 90.5 @modelcontextprotocol/server:tool input validation Caller sends arguments that violate a declared tool schema Input validation error: Invalid arguments for tool TOOL: FIELD: Invalid input: expected TYPE, received TYPE Low risk; public tool and field names plus exact type correction Keep SDK-generated copy unchanged verified by real in-memory MCP client/server call with wrong argument type
103 90.6 incur:unknown CLI command Caller enters an unknown REA command 'COMMAND' is not a command for 'rea'. Suggested command: rea --help Low risk; identifies input and exact discovery action Keep framework-generated copy unchanged verified by real packaged dispatcher invocation

View File

@ -17,7 +17,7 @@ usage() {
cat <<'EOF'
Usage: install.sh [--version VERSION] [--dry-run] [--no-setup] [--no-prompt] [--verbose]
Installs only the REA CLI. Run `rea setup` separately to configure Hopper and coding agents.
Installs only the REA CLI. Run `rea setup` separately to configure Hopper and agents.
EOF
}
@ -54,13 +54,13 @@ fi
platform="$(uname -s)"
[[ "$platform" == "Darwin" || "$platform" == "Linux" ]] || fail "REA supports macOS and selected 64-bit Linux distributions."
command -v curl >/dev/null 2>&1 || fail "curl is required."
command -v curl >/dev/null 2>&1 || fail "curl is required. Install curl, then rerun this installer."
command -v node >/dev/null 2>&1 || fail "Node.js 22.19+ or 24.11+ is required; install it from https://nodejs.org and retry."
command -v npm >/dev/null 2>&1 || fail "npm is required; install it with Node.js and retry."
node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
IFS=. read -r node_major node_minor _node_patch <<<"$node_version"
[[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "could not determine the active Node.js version."
[[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "the active Node.js version could not be read. Check that node works and is on PATH, then retry."
if ! ((node_major == 22 && node_minor >= 19 || node_major >= 24)); then
fail "Node.js $node_version is unsupported; use Node.js 22.19+ or 24.11+."
fi
@ -71,9 +71,9 @@ fi
if [[ -n "$version" ]]; then
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || fail "version must be an exact semantic version."
else
release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "could not resolve the latest GitHub release."
tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})')" || fail "GitHub returned malformed release metadata."
[[ "$tag" =~ ^rea-agents-([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$ ]] || fail "latest release tag is not a valid rea-agents release."
release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry."
tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})' 2>/dev/null)" || fail "the release response was invalid. Retry later or pass --version VERSION."
[[ "$tag" =~ ^rea-agents-([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$ ]] || fail "the latest release tag was invalid. Retry later or pass --version VERSION."
version="${BASH_REMATCH[1]}"
fi
@ -82,7 +82,7 @@ if [[ "$platform" == "Linux" ]]; then
prefix_args=(--prefix "$HOME/.local")
install_bin="$HOME/.local/bin/rea"
else
npm_prefix="$(npm prefix --global)" || fail "could not determine the npm global prefix."
npm_prefix="$(npm prefix --global)" || fail "the npm global prefix could not be read. Repair the npm configuration, then retry."
install_bin="$npm_prefix/bin/rea"
fi
@ -99,9 +99,9 @@ if [[ "$dry_run" == true ]]; then
fi
printf 'Installing %s@%s...\n' "$PACKAGE" "$version"
npm install --global "${prefix_args[@]}" "$PACKAGE@$version" || fail "npm package installation failed."
[[ -x "$install_bin" ]] || fail "the rea command was not installed at $install_bin."
installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "could not read the installed REA version."
npm install --global "${prefix_args[@]}" "$PACKAGE@$version" || fail "npm could not install REA. Check registry access and npm permissions, then retry."
[[ -x "$install_bin" ]] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry."
installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "the installed REA version could not be read. Reinstall the requested version, then retry."
[[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version."
printf 'REA %s is installed.\n' "$version"
@ -109,5 +109,5 @@ if [[ "$start_setup" == true && -r /dev/tty && -w /dev/tty && -t 1 ]]; then
printf 'Starting REA setup...\n'
"$install_bin" setup </dev/tty >/dev/tty
else
printf 'Run %s setup to configure Hopper and coding agents.\n' "$install_bin"
printf 'Run %s setup to configure Hopper and agents.\n' "$install_bin"
fi

View File

@ -1,7 +1,7 @@
{
"name": "rea-agents",
"version": "1.2.0",
"description": "Reverse engineer anything from your terminal or coding agent with one CLI and MCP server.",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"license": "MIT",
"repository": {
"type": "git",
@ -14,7 +14,7 @@
"keywords": [
"reverse-engineering",
"binary-analysis",
"coding-agents",
"agents",
"agent-skills",
"cli",
"mcp-server",

View File

@ -11,9 +11,12 @@ const isMcpMode =
args[0] === "--mcp" || (args.length === 1 && args[0] === "mcp");
if (isMcpMode) {
const { run } = await import("../dist/main.js");
process.exitCode = await run();
const { runEntrypoint } = await import("../dist/main.js");
await runEntrypoint();
} else {
const { createCli } = await import("../dist/cli.js");
await createCli().serve(args);
const { sanitizeCliOutput } = await import("../dist/cliOutput.js");
await createCli().serve(args, {
stdout: (output) => process.stdout.write(sanitizeCliOutput(output)),
});
}

View File

@ -24,7 +24,7 @@ Notes is only a documentation example. Never select an app unless the user names
2. If setup is needed, tell the user REA needs to install its local binary-analysis tools. Do not lead with implementation details or assume the user knows reverse-engineering products.
3. Before installing external software, obtain approval and identify what will be installed. If deeper analysis needs Hopper, describe it as REA's local analysis engine and note that it is a separate Mac app with its own license. Then run `npx -y rea-agents setup --yes`.
4. If macOS or an installer requests human input, tell the user exactly what needs attention. After they finish, rerun setup and doctor.
5. If setup registers a new MCP server, tell the user to restart their coding agent to load all REA tools. Direct CLI commands remain available before restart.
5. If setup registers a new MCP server, tell the user to restart their agent to load all REA tools. Direct CLI commands remain available before restart.
## Locate the app

View File

@ -6,6 +6,7 @@ import { silentLogger, type Logger } from "../logger.js";
import { createEvidence } from "../domain/evidence.js";
import type { NativeToolName } from "../contracts/nativeToolContracts.js";
import type { ArtifactToolName } from "../contracts/artifactToolContracts.js";
import { projectAnalysisError, type AnalysisError } from "../domain/errors.js";
const WORKFLOW_PROVIDER = {
id: "rea-workflow",
@ -47,8 +48,7 @@ export const runSessionStatus = async (
logger: Logger = silentLogger,
): Promise<JsonValue> => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
if (!config.ok) return cliError(config.error);
const session = createBinarySession(config.value, logger);
try {
return session.status();
@ -64,13 +64,11 @@ const runAnalysis = async (
logger: Logger,
): Promise<JsonValue> => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
if (!config.ok) return cliError(config.error);
const session = createBinarySession(config.value, logger);
try {
const opened = await session.open(path);
if (!opened.ok)
return { error: opened.error._tag, message: opened.error.message };
if (!opened.ok) return cliError(opened.error);
if (
tool === "binary_overview" ||
tool === "analyze_function" ||
@ -91,7 +89,7 @@ const runAnalysis = async (
limitations: ["Derived by an REA composed workflow."],
},
)
: { error: result.error._tag, message: result.error.message };
: cliError(result.error);
}
const result = await session.execute(tool, arguments_);
return result.ok
@ -107,8 +105,13 @@ const runAnalysis = async (
locations: result.value.locations,
},
)
: { error: result.error._tag, message: result.error.message };
: cliError(result.error);
} finally {
await session.close();
}
};
const cliError = (error: AnalysisError): JsonValue => ({
error: "Analysis failed",
...projectAnalysisError(error),
});

View File

@ -4,7 +4,10 @@ import type {
ProcessExecutionPolicy,
ProcessScenario,
} from "../domain/processCapture.js";
import { ProcessCaptureError } from "./ProcessCaptureError.js";
import {
ProcessCaptureError,
processCaptureCancelled,
} from "./ProcessCaptureError.js";
const isWithin = (candidate: string, root: string): boolean =>
candidate === root ||
@ -41,8 +44,7 @@ export const assertRealPathAuthority = async (
/** Expected refusal or runtime failure from the process capture adapter. */
export const assertNotCancelled = (signal: AbortSignal | undefined): void => {
if (signal?.aborted === true)
throw new ProcessCaptureError("process capture was cancelled");
if (signal?.aborted === true) throw processCaptureCancelled();
};
/** Runtime availability of the native PTY adapter on this host. */

View File

@ -1,6 +1,30 @@
import { AnalysisError } from "../domain/errors.js";
interface ProcessCaptureErrorOptions extends ErrorOptions {
readonly userMessage?: string;
readonly userCategory?: "permission_required" | "cancelled";
}
/** Typed application failure produced by controlled process capture. */
export class ProcessCaptureError extends AnalysisError {
readonly _tag = "ProcessCaptureError";
override readonly userMessage: string | undefined;
override readonly userCategory:
| "permission_required"
| "cancelled"
| undefined;
constructor(message: string, options?: ProcessCaptureErrorOptions) {
super(message, options);
this.userMessage = options?.userMessage;
this.userCategory = options?.userCategory;
}
}
/** Caller-visible cancellation without exposing capture implementation state. */
export const processCaptureCancelled = (): ProcessCaptureError =>
new ProcessCaptureError("process capture was cancelled", {
userCategory: "cancelled",
userMessage: "Process capture was cancelled. Start it again when ready.",
});

View File

@ -10,6 +10,7 @@ import type {
InteractionEvent,
ProcessCapture,
ProcessExecutionPolicy,
ProcessPolicyDecision,
ProcessSample,
ProcessScenario,
TerminalFrame,
@ -405,7 +406,11 @@ export const prepareProcessCapture = async (
readonly before: SnapshotResult;
}> => {
const decision = authorizeProcessScenario(scenario, policy);
if (!decision.allowed) throw new ProcessCaptureError(decision.reason);
if (!decision.allowed)
throw new ProcessCaptureError(decision.reason, {
userCategory: "permission_required",
userMessage: processPolicyMessage(decision.reason),
});
await assertRealPathAuthority(scenario, policy);
assertNotCancelled(signal);
const before = await snapshotRoots(scenario, signal);
@ -415,3 +420,23 @@ export const prepareProcessCapture = async (
await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
return { temporaryRoot, runId, home, before };
};
const processPolicyMessage = (
reason: Exclude<ProcessPolicyDecision, { readonly allowed: true }>["reason"],
): string => {
if (reason === "process capture is disabled")
return "Process capture is disabled. Set `REA_PROCESS_CAPTURE_ENABLED=true`, configure approved roots, then restart REA.";
if (reason === "host network access is not approved by operator policy")
return "This capture requests host network access, but policy does not allow it. Use replayed network access or ask the operator to enable external network capture.";
if (reason === "executable is outside approved roots")
return "The executable is outside the approved capture directories. Choose an approved executable or add its directory to `REA_PROCESS_EXECUTABLE_ROOTS_JSON`.";
if (reason === "working directory is outside approved roots")
return "The working directory is outside the approved capture directories. Choose an approved directory or add it to `REA_PROCESS_WORKING_ROOTS_JSON`.";
if (reason === "filesystem root is outside approved roots")
return "A requested filesystem root is outside the approved capture directories. Remove it or add its directory to `REA_PROCESS_WORKING_ROOTS_JSON`.";
if (
reason === "scenario requests an environment variable not allowed by policy"
)
return "The capture requests an environment variable that policy does not allow. Remove it or add its name to `REA_PROCESS_ALLOWED_ENV_JSON`.";
return reason satisfies never;
};

View File

@ -1,6 +1,7 @@
import { readFile } from "node:fs/promises";
import { parseConfig } from "../config.js";
import { AnalysisError, projectAnalysisError } from "../domain/errors.js";
import { createEvidence, parseEvidence } from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import {
@ -17,17 +18,50 @@ import {
const MAX_INPUT_BYTES = 64 * 1024 * 1024;
/** Safe process-command failure returned to the CLI adapter. */
export interface ProcessCliErrorOutput {
readonly error: "Process command failed";
readonly category: string;
readonly message: string;
}
/** Distinguish a safe process-command failure from successful Evidence. */
export const isProcessCliErrorOutput = (
value: unknown,
): value is ProcessCliErrorOutput =>
typeof value === "object" &&
value !== null &&
"error" in value &&
value.error === "Process command failed" &&
"category" in value &&
typeof value.category === "string" &&
"message" in value &&
typeof value.message === "string";
/** Capture one JSON scenario through the same policy and evidence contract as MCP. */
export const captureProcessScenarioFile = async (path: string) => {
const scenario = parseProcessScenario(await readJson(path));
const config = parseConfig(process.env);
if (!config.ok) throw config.error;
const captured = await captureProcessScenario(
scenario,
config.value.processExecutionPolicy,
);
if (!captured.ok) throw captured.error;
return createProcessCaptureEvidence(scenario, captured.value);
try {
const input = await readJson(path);
let scenario;
try {
scenario = parseProcessScenario(input);
} catch {
throw new ProcessCliFailure(
"invalid_input",
"Process scenario is invalid. Check its required fields and limits, then try again.",
);
}
const config = parseConfig(process.env);
if (!config.ok) return cliAnalysisError(config.error);
const captured = await captureProcessScenario(
scenario,
config.value.processExecutionPolicy,
);
if (!captured.ok) return cliAnalysisError(captured.error);
return createProcessCaptureEvidence(scenario, captured.value);
} catch (cause: unknown) {
return projectProcessCliError(cause);
}
};
/** Compare two capture Evidence files and emit derived comparison Evidence. */
@ -35,24 +69,28 @@ export const compareProcessEvidenceFiles = async (
leftPath: string,
rightPath: string,
) => {
const left = parseCaptureEvidence(await readJson(leftPath));
const right = parseCaptureEvidence(await readJson(rightPath));
const comparison = compareProcessCaptures(left.capture, right.capture);
return createEvidence(undefined, PROCESS_PROVIDER, {
predicateType: "rea.process-comparison/v3",
operation: "compare_process_captures",
parameters: {
left_evidence_id: left.id,
right_evidence_id: right.id,
left_normalization: left.capture.normalization,
right_normalization: right.capture.normalization,
},
result: jsonValueSchema.parse(comparison),
confidence: "derived",
authority: "analyst-inference",
limitations: comparison.limitations,
evidenceLinks: [left.id, right.id],
});
try {
const left = parseCaptureEvidence(await readJson(leftPath));
const right = parseCaptureEvidence(await readJson(rightPath));
const comparison = compareProcessCaptures(left.capture, right.capture);
return createEvidence(undefined, PROCESS_PROVIDER, {
predicateType: "rea.process-comparison/v3",
operation: "compare_process_captures",
parameters: {
left_evidence_id: left.id,
right_evidence_id: right.id,
left_normalization: left.capture.normalization,
right_normalization: right.capture.normalization,
},
result: jsonValueSchema.parse(comparison),
confidence: "derived",
authority: "analyst-inference",
limitations: comparison.limitations,
evidenceLinks: [left.id, right.id],
});
} catch (cause: unknown) {
return projectProcessCliError(cause);
}
};
const parseCaptureEvidence = (input: unknown) => {
@ -64,8 +102,14 @@ const parseCaptureEvidence = (input: unknown) => {
evidence.provider.version !== PROCESS_PROVIDER.version
) {
if (evidence.predicate_type === "rea.process-capture/v3")
throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
throw new TypeError("Expected REA Process Capture v4 Evidence");
throw new ProcessCliFailure(
"invalid_input",
LEGACY_PROCESS_CAPTURE_MESSAGE,
);
throw new ProcessCliFailure(
"invalid_input",
"Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.",
);
}
return {
id: evidence.evidence_id,
@ -74,8 +118,59 @@ const parseCaptureEvidence = (input: unknown) => {
};
const readJson = async (path: string): Promise<unknown> => {
const bytes = await readFile(path);
let bytes;
try {
bytes = await readFile(path);
} catch {
throw new ProcessCliFailure(
"invalid_input",
"Process input file could not be read. Check that the path exists and is readable.",
);
}
if (bytes.length > MAX_INPUT_BYTES)
throw new TypeError("Process JSON input exceeds byte limit");
return JSON.parse(bytes.toString("utf8")) as unknown;
throw new ProcessCliFailure(
"truncated",
"Process input file is too large. Reduce it below 64 MiB, then try again.",
);
try {
return JSON.parse(bytes.toString("utf8")) as unknown;
} catch {
throw new ProcessCliFailure(
"invalid_input",
"Process input file is not valid JSON. Repair the file, then try again.",
);
}
};
class ProcessCliFailure extends Error {
constructor(
readonly category: string,
readonly userMessage: string,
) {
super(userMessage);
}
}
const cliAnalysisError = (error: AnalysisError): ProcessCliErrorOutput => ({
error: "Process command failed",
...projectAnalysisError(error),
});
/** Project any process CLI failure without exposing its cause. */
export const projectProcessCliError = (
cause: unknown,
): ProcessCliErrorOutput => {
if (cause instanceof ProcessCliFailure)
return {
error: "Process command failed",
category: cause.category,
message: cause.userMessage,
};
if (cause instanceof AnalysisError) return cliAnalysisError(cause);
return {
error: "Process command failed",
category: "execution_failure",
message:
"Process command could not complete. Check the input files and run `rea doctor`, then try again.",
};
};

View File

@ -9,7 +9,10 @@ import type {
} from "../domain/processCapture.js";
import { validateProcessCapture } from "../domain/processCapture.js";
import { err, ok, type Result } from "../domain/result.js";
import { ProcessCaptureError } from "./ProcessCaptureError.js";
import {
ProcessCaptureError,
processCaptureCancelled,
} from "./ProcessCaptureError.js";
export { ProcessCaptureError } from "./ProcessCaptureError.js";
import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js";
@ -372,8 +375,7 @@ const completeCapture = async (options: {
const { runtime, scenario } = options;
runtime.checkpoints.trigger("root_exit");
const { reason } = options.exit;
if (reason === "cancelled")
throw new ProcessCaptureError("process capture was cancelled");
if (reason === "cancelled") throw processCaptureCancelled();
const settlement = await observeSettlement(
options.runId,
[

View File

@ -20,6 +20,23 @@ export interface ParsedReferenceSourceEntries {
readonly limitations: string[];
}
/** Project a low-level entry failure into safe import guidance. */
export const projectReferenceSourceEntryFailure = (
entry: Extract<ReferenceSourceEntry, { status: "failed" }>,
): string => {
if (entry.code === "cancelled")
return "This entry was not read because the import was cancelled. Start the import again when ready.";
if (entry.code === "limit")
return "This entry was not read because an import limit was reached. Import a smaller directory or raise the configured limits.";
if (entry.code === "unsupported")
return "This entry cannot be read safely on this system. Exclude it or import the directory on a supported system.";
if (entry.kind === "directory")
return "This directory could not be read. Check its permissions, then try again.";
if (entry.kind === "symlink")
return "This symbolic link could not be read safely. Check the link and its permissions, then try again.";
return "This file could not be read. Check its permissions, then try again.";
};
const hashBytes = (bytes: Uint8Array): string =>
createHash("sha256").update(bytes).digest("hex");
@ -27,13 +44,14 @@ const failedEntry = (
entry: Extract<ReferenceSourceEntry, { status: "failed" }>,
): HistoricalSourceGraphInput["entries"][number] => {
const classifications = classifyReferenceSourcePath(entry.path);
const limitation = projectReferenceSourceEntryFailure(entry);
if (entry.kind === "directory")
return {
path: entry.path,
kind: "directory",
classifications,
tree_state: entry.code === "limit" ? "partial" : "unreadable",
limitations: [entry.message],
limitations: [limitation],
};
if (entry.kind === "symlink")
return {
@ -42,7 +60,7 @@ const failedEntry = (
target: "<unreadable>",
target_state: "unreadable",
classifications,
limitations: [entry.message],
limitations: [limitation],
};
return {
path: entry.path,
@ -52,7 +70,7 @@ const failedEntry = (
language: null,
classifications: entry.kind === "file" ? classifications : ["unknown"],
content_state: entry.code === "limit" ? "too-large" : "unreadable",
limitations: [entry.message],
limitations: [limitation],
};
};

View File

@ -10,11 +10,51 @@ export interface ReferenceSourceImportError {
| "invalid-root"
| "io"
| "parse"
| "policy"
| "limit";
| "policy";
readonly message: string;
}
/** Safe CLI projection for a historical-source import failure. */
export const projectReferenceSourceImportError = (
error: ReferenceSourceImportError,
): Readonly<{ category: string; message: string }> => {
if (error.code === "cancelled")
return {
category: "cancelled",
message:
"Reference source import was cancelled. Start it again when ready.",
};
if (error.code === "invalid-limits")
return {
category: "invalid_input",
message:
"Reference source limits are invalid. Use positive integer limits, then try again.",
};
if (error.code === "invalid-root")
return {
category: "invalid_input",
message:
"Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory.",
};
if (error.code === "policy")
return {
category: "permission_required",
message:
"Reference source directory is not approved. Add its directory to `REA_REFERENCE_ROOTS_JSON`, restart REA, then try again.",
};
if (error.code === "io")
return {
category: "execution_failure",
message:
"Reference source files could not be read. Check directory permissions and try again.",
};
return {
category: "execution_failure",
message:
"Reference source could not be indexed. Check that the source tree is readable, then try again.",
};
};
/** Bounded import request paired with mandatory operator policy. */
export interface ReferenceSourceImportOptions {
readonly root: string;

View File

@ -22,6 +22,7 @@ import {
type LinuxDistribution,
} from "./LinuxHopper.js";
import { installMacHopper } from "./MacHopper.js";
import { configureDetectedClients } from "./SetupClients.js";
import { installCanonicalSkill } from "./SetupSkill.js";
export { installCanonicalSkill } from "./SetupSkill.js";
@ -140,28 +141,6 @@ const setupPlan = (
},
];
const configureDetectedClients = async (options: {
readonly host: SetupHost;
readonly detectedClients: readonly SetupClient[];
readonly hopperPath: string | undefined;
readonly clients: Record<string, ClientConfigurationResult>;
readonly appliedActions: string[];
}): Promise<string | undefined> => {
for (const client of options.detectedClients) {
const result = await options.host.configureClient(
client,
options.hopperPath,
registrationCommand(),
);
options.clients[client.name] = result;
if (result.status === "failed")
return `${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`;
if (result.status === "configured")
options.appliedActions.push(`configured_${client.name}`);
}
return undefined;
};
/**
* Install prerequisites and configure detected clients idempotently.
* Discovery always precedes mutation. Interactive confirmation or explicit
@ -226,13 +205,16 @@ export const runSetup = async (
host,
detectedClients,
hopperPath,
command: registrationCommand(),
clients,
appliedActions,
});
if (clientFailure !== undefined) return fail(clientFailure);
const skill = await host.installSkill();
if (skill === "failed")
return fail("Agent skill installation or readback failed.");
return fail(
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",
);
if (skill === "installed") appliedActions.push("installed_skill");
const doctor = await host.doctor();
const activationRequired = appliedActions.includes("installed_hopper");
@ -301,7 +283,7 @@ const systemSetupHost = (): SetupHost => {
};
};
/** Detect supported coding agents from stable per-user installation markers. */
/** Detect supported agents from stable per-user installation markers. */
export const detectClients = async (
home: string,
): Promise<readonly SetupClient[]> => {

View File

@ -0,0 +1,39 @@
import type {
ClientConfigurationResult,
SetupClient,
SetupHost,
} from "./Setup.js";
const failedConfigurationMessage = (
reason: "backup" | "write" | "readback",
): string => {
if (reason === "backup")
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
if (reason === "write")
return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.";
};
/** Configure each detected agent, stopping after the first failed transaction. */
export const configureDetectedClients = async (options: {
readonly host: SetupHost;
readonly detectedClients: readonly SetupClient[];
readonly hopperPath: string | undefined;
readonly command: readonly string[];
readonly clients: Record<string, ClientConfigurationResult>;
readonly appliedActions: string[];
}): Promise<string | undefined> => {
for (const client of options.detectedClients) {
const result = await options.host.configureClient(
client,
options.hopperPath,
options.command,
);
options.clients[client.name] = result;
if (result.status === "failed")
return failedConfigurationMessage(result.reason);
if (result.status === "configured")
options.appliedActions.push(`configured_${client.name}`);
}
return undefined;
};

View File

@ -9,6 +9,28 @@ import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { supportedClients, type SetupClient } from "./Setup.js";
interface ManagedPathStats {
isSymbolicLink(): boolean;
}
/** Injectable filesystem operations used to prove uninstall failure recovery. */
export interface UninstallFileSystem {
readText(path: string): Promise<string>;
copy(source: string, destination: string): Promise<void>;
writeText(path: string, contents: string): Promise<void>;
stat(path: string): Promise<ManagedPathStats>;
remove(path: string): Promise<void>;
}
const systemFileSystem: UninstallFileSystem = {
readText: (path) => readFile(path, "utf8"),
copy: (source, destination) => copyFile(source, destination),
writeText: (path, contents) =>
writeFileAtomic(path, contents, { encoding: "utf8" }),
stat: (path) => lstat(path),
remove: (path) => rm(path, { recursive: true }),
};
/** One explicitly classified uninstall action. */
export interface UninstallItem {
readonly name: string;
@ -54,21 +76,28 @@ export const runUninstall = async (
};
/** Create uninstall effects contained to detected client configs and REA-owned paths. */
export const systemUninstallHost = (home = homedir()): UninstallHost => ({
export const systemUninstallHost = (
home = homedir(),
fileSystem: UninstallFileSystem = systemFileSystem,
): UninstallHost => ({
clients: () => Promise.resolve(supportedClients(home)),
removeClient,
removeClient: (client) => removeClient(client, fileSystem),
removeSkill: () =>
removeManagedPath(
join(home, ".agents/skills", PRODUCT_IDENTITY.skillName),
"skill",
fileSystem,
),
purgeData: async () => [
await removeManagedPath(join(home, ".rea/cache"), "cache"),
await removeManagedPath(join(home, ".rea/state"), "state"),
await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem),
await removeManagedPath(join(home, ".rea/state"), "state", fileSystem),
],
});
const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
const removeClient = async (
client: SetupClient,
fileSystem: UninstallFileSystem,
): Promise<UninstallItem> => {
if (client.format === "unsupported")
return item(
client.name,
@ -77,11 +106,15 @@ const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
);
let original: string;
try {
original = await readFile(client.configPath, "utf8");
original = await fileSystem.readText(client.configPath);
} catch (cause: unknown) {
return isMissing(cause)
? item(client.name, "skipped", "Configuration does not exist.")
: item(client.name, "failed", "Configuration could not be read.");
: item(
client.name,
"failed",
"Configuration could not be read. Check file permissions, then rerun uninstall.",
);
}
let document: Record<string, unknown>;
let key: "mcp_servers" | "mcpServers";
@ -96,7 +129,7 @@ const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
return item(
client.name,
"failed",
"Malformed configuration was preserved without mutation.",
`Configuration is not valid ${client.format === "toml" ? "TOML" : "JSON"} and was not changed. Repair it, then rerun uninstall.`,
);
}
const registration = servers[PRODUCT_IDENTITY.mcpServerKey];
@ -113,12 +146,12 @@ const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
document[key] = remaining;
const backupPath = `${client.configPath}.rea.backup`;
try {
await copyFile(client.configPath, backupPath);
await fileSystem.copy(client.configPath, backupPath);
} catch {
return item(
client.name,
"failed",
"Configuration backup failed; no mutation was attempted.",
"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",
);
}
try {
@ -126,11 +159,11 @@ const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
client.format === "toml"
? stringifyToml(document)
: `${JSON.stringify(document, null, 2)}\n`;
await writeFileAtomic(client.configPath, encoded, { encoding: "utf8" });
await fileSystem.writeText(client.configPath, encoded);
const readback = objectSchema.parse(
client.format === "toml"
? parseToml(await readFile(client.configPath, "utf8"))
: JSON.parse(await readFile(client.configPath, "utf8")),
? parseToml(await fileSystem.readText(client.configPath))
: JSON.parse(await fileSystem.readText(client.configPath)),
);
if (PRODUCT_IDENTITY.mcpServerKey in optionalObject(readback[key]))
throw new Error("registration readback mismatch");
@ -140,13 +173,19 @@ const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
`Removed registration from ${client.configPath}.`,
);
} catch {
await writeFileAtomic(client.configPath, original, {
encoding: "utf8",
}).catch(() => undefined);
try {
await fileSystem.writeText(client.configPath, original);
} catch {
return item(
client.name,
"failed",
"Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall.",
);
}
return item(
client.name,
"failed",
`Configuration update failed; the original was restored and ${backupPath} was retained.`,
"Configuration could not be updated. The original was restored and its `.rea.backup` was retained. Repair the configuration or restore the backup, then rerun uninstall.",
);
}
};
@ -170,17 +209,26 @@ const isOwnedRegistration = (value: unknown): boolean => {
const removeManagedPath = async (
path: string,
name: string,
fileSystem: UninstallFileSystem,
): Promise<UninstallItem> => {
try {
const stats = await lstat(path);
const stats = await fileSystem.stat(path);
if (stats.isSymbolicLink())
return item(name, "retained", `Refused to follow symlink ${path}.`);
await rm(path, { recursive: true });
return item(
name,
"retained",
"REA did not remove this item because its managed path is a symbolic link. Verify the link target before removing it manually.",
);
await fileSystem.remove(path);
return item(name, "removed", `Removed ${path}.`);
} catch (cause: unknown) {
return isMissing(cause)
? item(name, "skipped", `${path} does not exist.`)
: item(name, "failed", `${path} could not be removed.`);
: item(
name,
"failed",
"This item could not be removed. Check file permissions, then rerun uninstall.",
);
}
};

View File

@ -92,7 +92,7 @@ export const runUpgrade = async (
currentVersion,
latestVersion: latestVersion ?? null,
reason: "install",
remediation: `${UPGRADE_COMMAND} failed.`,
remediation: `REA could not update through npm. Check npm registry access and global install permissions, then run: ${UPGRADE_COMMAND}`,
};
return {

View File

@ -18,6 +18,8 @@ import { parseConfig } from "./config.js";
import { importReferenceSource } from "./application/ReferenceSourceImport.js";
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
import { registerProcessCommands } from "./cliProcessCommands.js";
import { projectAnalysisError } from "./domain/errors.js";
import { projectReferenceSourceImportError } from "./application/ReferenceSourceImportTypes.js";
/**
* Build the one-shot Incur CLI without starting Hopper at import time.
@ -33,8 +35,7 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
);
const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, {
version: process.env.REA_PACKAGE_VERSION ?? "0.0.0-development",
description:
"Reverse engineer anything from your terminal or coding agent.",
description: "Reverse engineer anything from your terminal or agent.",
mcp: {
command: PRODUCT_IDENTITY.mcpCommand,
instructions:
@ -129,7 +130,7 @@ const registerSetupCommands = (
logger: Logger,
): void => {
cli.command("setup", {
description: "Install requirements and configure coding agents",
description: "Install requirements and configure agents",
options: z.object({
yes: z
.boolean()
@ -374,7 +375,10 @@ const registerReferenceSourceCommand = (
logCliCommand(logger, "import-reference-source", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
return {
error: "Import failed",
...projectAnalysisError(config.error),
};
const imported = await importReferenceSource({
root: args.root,
caller: "rea-cli",
@ -384,7 +388,10 @@ const registerReferenceSourceCommand = (
});
return imported.ok
? imported.value
: { error: imported.error.code, message: imported.error.message };
: {
error: "Import failed",
...projectReferenceSourceImportError(imported.error),
};
}),
});
};

View File

@ -8,6 +8,8 @@ import {
import { parseConfig } from "./config.js";
import { logCliCommand } from "./cliLogging.js";
import type { Logger } from "./logger.js";
import type { JsonValue } from "./domain/jsonValue.js";
import { projectAnalysisError, type AnalysisError } from "./domain/errors.js";
/** Register filesystem-gated Evidence v2 commands. */
export const registerEvidenceCommands = (
@ -22,15 +24,12 @@ export const registerEvidenceCommands = (
run: ({ args }) =>
logCliCommand(logger, "evidence-import", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
if (!config.ok) return cliError(config.error);
const imported = await importEvidenceBundleCommand(
args.path,
config.value.evidenceFilePolicy,
);
return imported.ok
? imported.value
: { error: imported.error._tag, message: imported.error.message };
return imported.ok ? imported.value : cliError(imported.error);
}),
});
cli.command("evidence-export", {
@ -45,17 +44,14 @@ export const registerEvidenceCommands = (
run: ({ args, options }) =>
logCliCommand(logger, "evidence-export", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
if (!config.ok) return cliError(config.error);
const exported = await exportEvidenceBundleCommand(
args.source,
args.output,
options.overwrite,
config.value.evidenceFilePolicy,
);
return exported.ok
? exported.value
: { error: exported.error._tag, message: exported.error.message };
return exported.ok ? exported.value : cliError(exported.error);
}),
});
cli.command("compare", {
@ -72,8 +68,7 @@ export const registerEvidenceCommands = (
run: ({ args, options }) =>
logCliCommand(logger, "compare", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
if (!config.ok) return cliError(config.error);
const compared = await compareEvidenceBundlesCommand({
leftPath: args.left,
rightPath: args.right,
@ -81,9 +76,12 @@ export const registerEvidenceCommands = (
limit: options.limit,
policy: config.value.evidenceFilePolicy,
});
return compared.ok
? compared.value
: { error: compared.error._tag, message: compared.error.message };
return compared.ok ? compared.value : cliError(compared.error);
}),
});
};
const cliError = (error: AnalysisError): JsonValue => ({
error: "Analysis failed",
...projectAnalysisError(error),
});

46
src/cliOutput.ts Normal file
View File

@ -0,0 +1,46 @@
const SAFE_VALIDATION_MESSAGE =
"REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again.";
type JsonRecord = Record<string, unknown>;
const isRecord = (value: unknown): value is JsonRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
const validationError = (value: unknown): JsonRecord | undefined => {
if (!isRecord(value)) return undefined;
if (value.code === "VALIDATION_ERROR") return value;
if (value.ok === false && isRecord(value.error))
return value.error.code === "VALIDATION_ERROR" ? value.error : undefined;
return undefined;
};
/** Remove validator internals from Incur's caller-visible CLI output. */
export const sanitizeCliOutput = (output: string): string => {
const trimmed = output.trimStart();
if (trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(trimmed) as unknown;
const error = validationError(parsed);
if (error !== undefined) {
const safeError = {
code: "VALIDATION_ERROR",
message: SAFE_VALIDATION_MESSAGE,
};
if (error === parsed) return `${JSON.stringify(safeError)}\n`;
if (!isRecord(parsed)) return output;
return `${JSON.stringify({ ...parsed, error: safeError })}\n`;
}
} catch {
return output;
}
return output;
}
if (
/^ok: false\r?\nerror:\r?\n code: VALIDATION_ERROR(?:\r?\n|$)/u.test(
trimmed,
)
)
return `ok: false\nerror:\n code: VALIDATION_ERROR\n message: "${SAFE_VALIDATION_MESSAGE}"\n`;
if (!/^code: VALIDATION_ERROR(?:\r?\n|$)/u.test(trimmed)) return output;
return `code: VALIDATION_ERROR\nmessage: "${SAFE_VALIDATION_MESSAGE}"\n`;
};

View File

@ -1,12 +1,26 @@
import { Cli, z } from "incur";
import { Cli, Errors, z } from "incur";
import {
captureProcessScenarioFile,
compareProcessEvidenceFiles,
isProcessCliErrorOutput,
} from "./application/ProcessCli.js";
import { logCliCommand } from "./cliLogging.js";
import type { Logger } from "./logger.js";
const requireProcessCommandSuccess = async <Value>(
execute: () => Promise<Value>,
): Promise<Value> => {
const value = await execute();
if (isProcessCliErrorOutput(value))
throw new Errors.IncurError({
code: "PROCESS_COMMAND_FAILED",
message: value.message,
retryable: false,
});
return value;
};
/** Register Process Capture v4 one-shot commands through shared application services. */
export const registerProcessCommands = (
cli: ReturnType<typeof Cli.create>,
@ -17,7 +31,9 @@ export const registerProcessCommands = (
args: z.object({ scenario: z.string().describe("Scenario JSON path") }),
run: ({ args }) =>
logCliCommand(logger, "capture-process", () =>
captureProcessScenarioFile(args.scenario),
requireProcessCommandSuccess(() =>
captureProcessScenarioFile(args.scenario),
),
),
});
cli.command("compare-process-captures", {
@ -28,7 +44,9 @@ export const registerProcessCommands = (
}),
run: ({ args }) =>
logCliCommand(logger, "compare-process-captures", () =>
compareProcessEvidenceFiles(args.left, args.right),
requireProcessCommandSuccess(() =>
compareProcessEvidenceFiles(args.left, args.right),
),
),
});
};

View File

@ -1,5 +1,4 @@
import { z } from "zod";
import { ANALYSIS_ERROR_TAGS } from "../domain/errors.js";
import { evidenceSchema } from "../domain/evidence.js";
import {
processCaptureComparisonSchema,
@ -180,9 +179,18 @@ const symbolDiscoveryOutput = (property: "classes" | "protocols") =>
);
const analysisErrorProjectionSchema = z
.object({
tag: z.enum(ANALYSIS_ERROR_TAGS),
category: z.enum([
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure",
]),
message: z.string(),
details: z.record(z.string(), z.union([z.string(), z.number(), z.null()])),
})
.strict();
const graphNode = z.discriminatedUnion("status", [

View File

@ -1,7 +1,7 @@
import type { JsonValue } from "./jsonValue.js";
/** Stable tags exposed by safe analysis-error projections. */
export const ANALYSIS_ERROR_TAGS = [
const ANALYSIS_ERROR_TAGS = [
"AnalysisProtocolError",
"AnalysisInputError",
"AnalysisOutputError",
@ -33,6 +33,9 @@ export type AnalysisErrorTag = (typeof ANALYSIS_ERROR_TAGS)[number];
/** Base class for expected analysis, provider, and session failures. */
export abstract class AnalysisError extends Error {
abstract readonly _tag: AnalysisErrorTag;
readonly userMessage: string | undefined = undefined;
readonly userCategory: "permission_required" | "cancelled" | undefined =
undefined;
}
/** Base class for failures produced specifically by the Hopper provider. */
@ -299,9 +302,17 @@ export class BinaryTargetError extends AnalysisError {
export interface AnalysisErrorProjection
extends Readonly<Record<string, JsonValue>> {
readonly tag: AnalysisErrorTag;
readonly category:
| "invalid_input"
| "permission_required"
| "unsupported_provider"
| "integrity_mismatch"
| "truncated"
| "cancelled"
| "timeout"
| "unavailable"
| "execution_failure";
readonly message: string;
readonly details: Readonly<Record<string, string | number | boolean | null>>;
}
/** Project expected failures into exhaustive, secret-safe caller fields. */
@ -310,54 +321,146 @@ export const projectAnalysisError = (
): AnalysisErrorProjection => {
assertKnownTag(error._tag);
return {
tag: error._tag,
message: error.message,
details: safeDetails(error),
category: errorCategory(error),
message: userMessage(error),
};
};
const safeDetails = (
const errorCategory = (
error: AnalysisError,
): Readonly<Record<string, string | number | boolean | null>> => {
if (error instanceof AnalysisInputError)
return { operation: error.operation };
if (error instanceof AnalysisOutputError)
return { operation: error.operation, reason: error.reason };
if (error instanceof AnalysisCapabilityUnavailableError)
return {
providerId: error.providerId,
operation: error.operation,
reason: error.reason,
};
if (error instanceof AnalysisCancelledError)
return { operation: error.operation };
if (error instanceof AnalysisTimeoutError)
return { operation: error.operation, timeoutMs: error.timeoutMs };
if (error instanceof ProviderSelectionError)
return { operation: error.operation };
if (error instanceof ProviderAdapterError)
return { providerId: error.providerId, operation: error.operation };
): AnalysisErrorProjection["category"] => {
if (error._tag === "ProcessCaptureError")
return error.userCategory ?? "execution_failure";
if (
error instanceof HopperRemoteError &&
error.diagnosticType === "authorization"
)
return "permission_required";
if (error instanceof ArtifactOperationError)
return {
operation: error.operation,
reason: error.reason,
...error.artifactDetails,
};
return artifactErrorCategory(error.reason);
if (error instanceof EvidenceFileError && error.reason === "disabled")
return "unavailable";
return STATIC_ERROR_CATEGORIES[error._tag] ?? "execution_failure";
};
const artifactErrorCategory = (
reason: ArtifactOperationError["reason"],
): AnalysisErrorProjection["category"] => {
if (reason === "integrity") return "integrity_mismatch";
if (reason === "limit") return "truncated";
if (reason === "cancelled") return "cancelled";
if (reason === "unavailable") return "unavailable";
return "execution_failure";
};
const STATIC_ERROR_CATEGORIES: Readonly<
Partial<Record<AnalysisErrorTag, AnalysisErrorProjection["category"]>>
> = {
AnalysisInputError: "invalid_input",
AnalysisCapabilityUnavailableError: "unsupported_provider",
ProviderSelectionError: "unsupported_provider",
EvidenceIntegrityError: "integrity_mismatch",
EvidenceLimitError: "truncated",
AnalysisCancelledError: "cancelled",
HopperCancelledError: "cancelled",
AnalysisTimeoutError: "timeout",
HopperTimeoutError: "timeout",
NoBinaryOpenError: "unavailable",
BinaryTargetError: "unavailable",
};
const userMessage = (error: AnalysisError): string => {
if (error instanceof AnalysisInputError)
return "Analysis input is invalid. Check the arguments and try again.";
if (UNREADABLE_OUTPUT_TAGS.has(error._tag))
return "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.";
if (UNSUPPORTED_PROVIDER_TAGS.has(error._tag))
return "This analysis is unavailable for the current target. Choose another analysis or target.";
if (CANCELLED_TAGS.has(error._tag))
return "Analysis was cancelled. Start it again when ready.";
if (TIMEOUT_TAGS.has(error._tag))
return "Analysis took too long. Try a smaller request, then run `rea doctor` if it continues.";
if (ADAPTER_FAILURE_TAGS.has(error._tag))
return "Analysis could not complete. Retry once; if it continues, run `rea doctor`.";
if (START_FAILURE_TAGS.has(error._tag))
return "Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again.";
if (error instanceof ArtifactOperationError)
return artifactMessage(error.reason);
if (error instanceof EvidenceIntegrityError)
return "Evidence is invalid or has changed. Recreate or re-import it, then try again.";
if (error instanceof EvidenceLimitError)
return { limit: error.limit, maximum: error.maximum };
return "Evidence is too large for this session. Reduce the evidence set and try again.";
if (error instanceof EvidenceFileError)
return { operation: error.operation, reason: error.reason };
if (error instanceof UnknownRegistryError) return { reason: error.reason };
if (error instanceof HopperTimeoutError)
return { timeoutMs: error.timeoutMs };
if (error instanceof HopperRemoteError)
return {
code: error.code,
safeMessage: error.safeMessage,
diagnosticType: error.diagnosticType,
};
if (error instanceof HopperProcessError) return { exitCode: error.exitCode };
return {};
return evidenceFileMessage(error.reason);
if (error instanceof UnknownRegistryError)
return "Evidence state changed before the update completed. Refresh the current state and try again.";
if (error instanceof ConfigurationError)
return "REA configuration is invalid. Run `rea doctor` and fix the reported setting.";
if (error instanceof NoBinaryOpenError) return error.message;
if (error instanceof BinaryTargetError)
return "REA could not open that app or binary. Check that the path exists, is readable, and points to a supported file.";
if (error._tag === "ProcessCaptureError")
return (
error.userMessage ??
"Process capture could not complete. Run `rea doctor`, then review capture policy and try again."
);
return "Analysis could not complete. Run `rea doctor`, then try again.";
};
const UNREADABLE_OUTPUT_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"AnalysisProtocolError",
"AnalysisOutputError",
"HopperProtocolError",
]);
const UNSUPPORTED_PROVIDER_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"AnalysisCapabilityUnavailableError",
"ProviderSelectionError",
]);
const CANCELLED_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"AnalysisCancelledError",
"HopperCancelledError",
]);
const TIMEOUT_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"AnalysisTimeoutError",
"HopperTimeoutError",
]);
const ADAPTER_FAILURE_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"ProviderAdapterError",
"HopperRemoteError",
]);
const START_FAILURE_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"HopperProcessError",
"HopperStartError",
]);
const artifactMessage = (reason: ArtifactOperationError["reason"]): string => {
if (reason === "cancelled")
return "Artifact operation was cancelled. Start it again when ready.";
if (reason === "limit")
return "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact.";
if (reason === "path")
return "Artifact path is not allowed. Choose a path inside the artifact and try again.";
if (reason === "unavailable")
return "Artifact format is not available on this system. Choose another artifact or supported environment.";
if (reason === "format" || reason === "integrity")
return "Artifact is invalid or has changed. Get a fresh copy and try again.";
return "Artifact could not be read or written. Check file access and try again.";
};
const evidenceFileMessage = (reason: EvidenceFileError["reason"]): string => {
if (reason === "disabled")
return "Evidence file access is disabled. Enable an evidence directory or use inline evidence.";
if (reason === "outside-root")
return "Evidence path is outside the allowed directory. Choose a path inside the configured evidence directory.";
if (reason === "not-file")
return "Evidence path does not point to a file. Choose an evidence file and try again.";
if (reason === "too-large")
return "Evidence file is too large. Reduce its size and try again.";
if (reason === "exists")
return "Evidence file already exists. Choose another path or allow overwrite.";
if (reason === "invalid-json")
return "Evidence file is not valid JSON. Repair or recreate the file and try again.";
return "Evidence file could not be accessed. Check file permissions and try again.";
};
const KNOWN_ERROR_TAGS = {

View File

@ -3,4 +3,7 @@ import { z } from "zod";
/** JSON-safe value shared by domain, application, and adapter boundaries. */
export const jsonValueSchema = z.json();
/** JSON object boundary for caller-visible parameter maps. */
export const jsonObjectSchema = z.record(z.string(), jsonValueSchema);
export type JsonValue = z.infer<typeof jsonValueSchema>;

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { jsonValueSchema } from "./jsonValue.js";
const hexAddress = z.string().regex(/^0x[a-fA-F0-9]+$/u);
const coverage = <Schema extends z.ZodType>(item: Schema) =>
@ -112,7 +113,7 @@ export const inspectSignatureSchema = z.object({
hash_algorithms: z.array(z.string()),
authorities: z.array(z.string()),
designated_requirement: z.string().nullable(),
entitlements: z.unknown().nullable(),
entitlements: jsonValueSchema.nullable(),
timestamp: z.string().nullable(),
hardened_runtime: z.boolean().nullable(),
provenance: z.array(nativeCommandInvocationSchema),
@ -121,7 +122,7 @@ export const inspectSignatureSchema = z.object({
export const inspectPlistSchema = z.object({
format: z.enum(["xml", "binary", "json", "unknown"]),
value: z.unknown(),
value: jsonValueSchema,
bundle: z.object({
identifier: z.string().nullable(),
executable: z.string().nullable(),

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { jsonValueSchema } from "./jsonValue.js";
import {
LEGACY_PROCESS_CAPTURE_MESSAGE,
@ -200,10 +201,10 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
pty_backend: z.literal("node-pty"),
started_at: z.iso.datetime(),
completed_at: z.iso.datetime(),
scenario: z.record(z.string(), z.unknown()),
comparison_contract: z.record(z.string(), z.unknown()),
shim_plan: z.array(z.unknown()),
replay_plan: z.record(z.string(), z.unknown()),
scenario: z.record(z.string(), jsonValueSchema),
comparison_contract: z.record(z.string(), jsonValueSchema),
shim_plan: z.array(jsonValueSchema),
replay_plan: z.record(z.string(), jsonValueSchema),
full_scenario_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
comparison_contract_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
executable_sha256: z.string().regex(/^[a-f0-9]{64}$/u),

View File

@ -2,6 +2,7 @@ import { z } from "zod";
import type { ProcessCapture } from "./processCapture.js";
import { validateProcessCapture } from "./processCapture.js";
import { jsonValueSchema } from "./jsonValue.js";
/** Comparison classification that never equates incomplete evidence. */
export const comparisonStatusSchema = z.enum([
@ -42,8 +43,8 @@ export const processCaptureComparisonSchema = z
index: z.number().int().nonnegative(),
left_at_ms: z.number().int().nonnegative().nullable(),
right_at_ms: z.number().int().nonnegative().nullable(),
left: z.unknown().nullable(),
right: z.unknown().nullable(),
left: jsonValueSchema.nullable(),
right: jsonValueSchema.nullable(),
}),
]),
limitations: z.array(z.string()),
@ -165,8 +166,8 @@ const firstCollectionDivergence = (
index,
left_at_ms: eventTime(leftValue),
right_at_ms: eventTime(rightValue),
left: leftValue,
right: rightValue,
left: jsonValueSchema.parse(leftValue),
right: jsonValueSchema.parse(rightValue),
};
}
return null;

View File

@ -382,7 +382,16 @@ export interface ProcessExecutionPolicy {
/** Explicit authorization result; denial reasons are safe to show callers. */
export type ProcessPolicyDecision =
| { readonly allowed: true }
| { readonly allowed: false; readonly reason: string };
| {
readonly allowed: false;
readonly reason:
| "process capture is disabled"
| "host network access is not approved by operator policy"
| "executable is outside approved roots"
| "working directory is outside approved roots"
| "scenario requests an environment variable not allowed by policy"
| "filesystem root is outside approved roots";
};
const isWithin = (candidate: string, root: string): boolean =>
resolve(candidate) === resolve(root) ||

View File

@ -12,16 +12,52 @@ import { parseConfig } from "./config.js";
import { createBinarySession } from "./application/runtime.js";
import { createServer } from "./server/createServer.js";
import { createLogger } from "./logger.js";
import { projectAnalysisError } from "./domain/errors.js";
const MCP_CONNECTION_LOST =
"REA lost its MCP client connection. Restart REA from your MCP client.";
const MCP_CONNECTION_START_FAILED =
"REA could not start its MCP connection. Restart REA from your MCP client; run `rea doctor` if it fails again.";
const MCP_SHUTDOWN_FAILED =
"REA could not close cleanly. End the REA process before starting it again.";
const SERVER_START_FAILED =
"REA could not start. Run `rea doctor`, then restart REA from your MCP client.";
/** Process boundaries owned by the MCP executable adapter. */
interface RuntimeDependencies {
readonly env: NodeJS.ProcessEnv;
readonly serve: typeof serveStdio;
readonly writeStderr: (text: string) => void;
readonly setExitCode: (code: number) => void;
readonly registerShutdown: (handler: () => void) => void;
}
const runtimeDependencies = (): RuntimeDependencies => ({
env: process.env,
serve: serveStdio,
writeStderr: (text) => process.stderr.write(text),
setExitCode: (code) => {
process.exitCode = code;
},
registerShutdown: (handler) => {
process.once("SIGINT", handler);
process.once("SIGTERM", handler);
process.stdin.once("end", handler);
process.stdin.once("close", handler);
},
});
/**
* Start the long-lived MCP adapter and install idempotent shutdown handlers.
* The adapter owns its BinarySession for the process lifetime; EOF and process
* signals close bridge resources without assuming ownership of the Hopper app.
*/
export const run = async (): Promise<number> => {
const config = parseConfig(process.env);
export const run = async (
dependencies: RuntimeDependencies = runtimeDependencies(),
): Promise<number> => {
const config = parseConfig(dependencies.env);
if (!config.ok) {
process.stderr.write(`${config.error._tag}: ${config.error.message}\n`);
dependencies.writeStderr(`${projectAnalysisError(config.error).message}\n`);
return 1;
}
const logger = createLogger("mcp", config.value.logLevel);
@ -38,13 +74,15 @@ export const run = async (): Promise<number> => {
{ errorTag: opened.error._tag },
"Initial target failed to open",
);
process.stderr.write(`${opened.error._tag}: ${opened.error.message}\n`);
dependencies.writeStderr(
`${projectAnalysisError(opened.error).message}\n`,
);
return 1;
}
}
let handle: StdioServerHandle;
try {
handle = serveStdio(
handle = dependencies.serve(
() =>
createServer(session, session, {
logger,
@ -53,15 +91,15 @@ export const run = async (): Promise<number> => {
}),
{
onerror: () => {
serverLogger.error("MCP stdio transport error");
process.stderr.write("MCP stdio transport error\n");
serverLogger.error(MCP_CONNECTION_LOST);
dependencies.writeStderr(`${MCP_CONNECTION_LOST}\n`);
},
},
);
} catch {
await session.close();
serverLogger.error("Failed to start MCP stdio transport");
process.stderr.write("Failed to start MCP stdio transport\n");
serverLogger.error(MCP_CONNECTION_START_FAILED);
dependencies.writeStderr(`${MCP_CONNECTION_START_FAILED}\n`);
return 1;
}
@ -75,30 +113,36 @@ export const run = async (): Promise<number> => {
};
const requestShutdown = (): void => {
shutdown().catch(() => {
process.exitCode = 1;
serverLogger.error("Failed to shut down cleanly");
process.stderr.write("Failed to shut down cleanly\n");
dependencies.setExitCode(1);
serverLogger.error(MCP_SHUTDOWN_FAILED);
dependencies.writeStderr(`${MCP_SHUTDOWN_FAILED}\n`);
});
};
process.once("SIGINT", requestShutdown);
process.once("SIGTERM", requestShutdown);
process.stdin.once("end", requestShutdown);
process.stdin.once("close", requestShutdown);
dependencies.registerShutdown(requestShutdown);
return 0;
};
/** Run the executable boundary without exposing an unexpected startup cause. */
export const runEntrypoint = async (
start: () => Promise<number> = () => run(),
writeStderr: (text: string) => void = (text) => process.stderr.write(text),
setExitCode: (code: number) => void = (code) => {
process.exitCode = code;
},
): Promise<void> => {
try {
setExitCode(await start());
} catch {
writeStderr(`${SERVER_START_FAILED}\n`);
setExitCode(1);
}
};
const entryPath = process.argv[1];
if (
entryPath !== undefined &&
pathToFileURL(realpathSync(entryPath)).href === import.meta.url
) {
run()
.then((exitCode) => {
process.exitCode = exitCode;
})
.catch(() => {
process.stderr.write("Unexpected server startup failure\n");
process.exitCode = 1;
});
void runEntrypoint();
}

View File

@ -13,7 +13,7 @@ import type { Logger } from "../logger.js";
import { logToolExecution } from "./toolLogging.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { createEvidence } from "../domain/evidence.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
import { UnknownRegistryError } from "../domain/errors.js";
@ -69,15 +69,9 @@ const registerEnhancedTool = (
annotations: contract.annotations,
},
async (input, context) => {
const parsedInput = jsonValueSchema.parse(
const parsedInput = jsonObjectSchema.parse(
enhancedInputSchemas[name].parse(input),
);
if (
typeof parsedInput !== "object" ||
parsedInput === null ||
Array.isArray(parsedInput)
)
throw new Error("Validated enhanced input was not a JSON object");
const parameters: Record<string, JsonValue> = { ...parsedInput };
const result = await logToolExecution(registration.logger, name, () =>
services.execute(name, input, context.mcpReq.signal),

View File

@ -8,7 +8,7 @@ import type { BinarySessionPort } from "../application/BinarySession.js";
import type { ToolContract } from "../contracts/toolContracts.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { createEvidence } from "../domain/evidence.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { jsonObjectSchema } from "../domain/jsonValue.js";
import type { Logger } from "../logger.js";
import { logToolExecution } from "./toolLogging.js";
import { toCallToolResult } from "./toolResult.js";
@ -32,7 +32,9 @@ export const registerEvidenceTools = (
contract.name,
toolRegistrationOptions(contract),
async (input, context) => {
const parameters = jsonObject(contract.inputSchema.parse(input));
const parameters = jsonObjectSchema.parse(
contract.inputSchema.parse(input),
);
const execution = await logToolExecution(
options.logger,
contract.name,
@ -62,10 +64,3 @@ export const registerEvidenceTools = (
);
}
};
const jsonObject = (input: unknown): Readonly<Record<string, JsonValue>> => {
const parsed = jsonValueSchema.parse(input);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
throw new TypeError("Provider tool input was not an object");
return parsed;
};

View File

@ -94,7 +94,7 @@ const functionUnknownInput = ({
{
operation: "analyze_function",
rationale:
"Capture complete dossiers with equal providers and analysis limits.",
"Capture complete dossiers for both functions under the same target context and analysis limits.",
},
],
relationships: [],

View File

@ -86,7 +86,8 @@ const registerChangedBehavior = (
result.behavior_status === "unknown" ||
result.behavior_status === "truncated",
{
question: `Changed behavior result ${evidence.evidence_id} remains ${result.behavior_status}`,
question:
"Did both versions behave the same under a complete controlled replay?",
domain: "changed-behavior",
requiredAuthority: "controlled-replay",
requiredConfidence: "observed",
@ -140,7 +141,8 @@ const registerCallPath = (
parsed.unknown_registry_approved,
result.status === "unknown" || result.status === "truncated",
{
question: `Call path ${parsed.start.address} → ${parsed.goal.address} is ${result.status}`,
question:
"Can the requested call path be established from complete analysis?",
domain: "call-path",
requiredAuthority: "shipped-artifact",
requiredConfidence: "derived",
@ -195,7 +197,8 @@ const registerStaticRuntime = (
parsed.unknown_registry_approved,
result.status === "unknown" || result.status === "truncated",
{
question: `Static/runtime correlation result ${evidence.evidence_id} is ${result.status}`,
question:
"Does runtime behavior match the available static analysis?",
domain: "static-runtime-correlation",
requiredAuthority: null,
requiredConfidence: "derived",
@ -274,7 +277,7 @@ const registerReconstruction = (
parsed.unknown_registry_approved,
result.status === "unknown",
{
question: `Reconstruction ${result.specification_sha256} has unknown claims`,
question: "Does the reconstruction satisfy every declared claim?",
domain: "reconstruction-verification",
requiredAuthority: null,
requiredConfidence: "derived",

View File

@ -3,7 +3,11 @@ import type { McpServer } from "@modelcontextprotocol/server";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
import type { BinarySessionPort } from "../application/BinarySession.js";
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import {
jsonObjectSchema,
jsonValueSchema,
type JsonValue,
} from "../domain/jsonValue.js";
import { toCallToolResult } from "./toolResult.js";
import type { Logger } from "../logger.js";
import { logToolExecution } from "./toolLogging.js";
@ -92,9 +96,10 @@ const registerOfficialTool = (
) {
const unknown = registration.recordUnknown({
approved: true,
question: `${contract.name} is unavailable: ${result.error.reason}`,
question:
"The requested analysis is unavailable for the current target.",
severity: "medium",
domain: "provider-capability",
domain: "analysis-capability",
supporting_evidence_ids: [],
contradicting_evidence_ids: [],
required_authority: "shipped-artifact",
@ -104,7 +109,7 @@ const registerOfficialTool = (
{
operation: contract.name,
rationale:
"Use a provider that declares this capability available.",
"Choose another analysis or target that can answer this question.",
},
],
relationships: [],
@ -127,21 +132,12 @@ const projectOfficialArguments = (
contract: (typeof OFFICIAL_TOOL_CONTRACTS)[number],
input: unknown,
): Readonly<Record<string, JsonValue>> => {
const parsed = jsonValueSchema.safeParse(input);
if (
!parsed.success ||
typeof parsed.data !== "object" ||
parsed.data === null ||
Array.isArray(parsed.data)
) {
// The SDK validates this with the same schema before invoking the callback.
throw new Error("Validated MCP tool input was not a JSON object");
}
const parsed = jsonObjectSchema.parse(contract.inputSchema.parse(input));
const projected: Record<string, JsonValue> = {};
for (const key of Object.keys(contract.inputSchema.shape)) {
if (key === "unknown_registry_approved") continue;
projected[key] = parsed.data[key] ?? null;
projected[key] = jsonValueSchema.parse(parsed[key] ?? null);
}
return projected;
};

View File

@ -119,7 +119,7 @@ const validateCaptureSources = (
evidence.confidence !== "observed" ||
evidence.authority !== "controlled-replay" ||
evidenceCapture === undefined ||
canonicalJson(evidenceCapture) !== canonicalJson(capture)
canonicalize(evidenceCapture) !== canonicalize(capture)
)
return err(
new EvidenceIntegrityError(
@ -130,13 +130,6 @@ const validateCaptureSources = (
return { ok: true as const, value: null };
};
const canonicalJson = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Process capture could not be canonicalized");
return encoded;
};
const comparisonUnknownInput = (
parsed: {
readonly left_evidence_id: string;

View File

@ -46,7 +46,7 @@ const recordProcessResidualUnknowns = (
for (const residual of residuals) {
const unknown = session.recordUnknown({
approved: true,
question: `${residual.scope}: ${residual.reason}`,
question: `Was ${residual.scope} behavior fully observed during capture?`,
severity: "medium",
domain: `process-${residual.scope}`,
supporting_evidence_ids: [evidence.evidence_id],

View File

@ -17,7 +17,7 @@ export const resolveSessionEvidence = (
for (const item of supplied) {
const parsed = parseEvidence(item);
const owned = session.evidenceById(parsed.evidence_id);
if (owned === undefined || canonicalJson(owned) !== canonicalJson(parsed))
if (owned === undefined || canonicalize(owned) !== canonicalize(parsed))
return err(
new EvidenceIntegrityError(
"Comparison input does not match its session-owned Evidence",
@ -34,12 +34,5 @@ export const isSessionEvidence = (
input: Evidence,
): boolean => {
const owned = session.evidenceById(input.evidence_id);
return owned !== undefined && canonicalJson(owned) === canonicalJson(input);
};
const canonicalJson = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Evidence could not be canonicalized");
return encoded;
return owned !== undefined && canonicalize(owned) === canonicalize(input);
};

View File

@ -1,6 +1,10 @@
import type { CallToolResult } from "@modelcontextprotocol/server";
import { projectAnalysisError, type AnalysisError } from "../domain/errors.js";
import {
AnalysisOutputError,
projectAnalysisError,
type AnalysisError,
} from "../domain/errors.js";
import type { Result } from "../domain/result.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { ToolContract } from "../contracts/toolContracts.js";
@ -22,9 +26,10 @@ const errorResult = (error: AnalysisError): CallToolResult => {
content: [
{
type: "text",
text: `${projected.tag}: ${projected.message}`,
text: projected.message,
},
],
structuredContent: { error: projected },
isError: true,
};
};
@ -40,9 +45,17 @@ const successResult = (
content: [
{
type: "text",
text: "AnalysisOutputError: Analysis output does not match the tool contract",
text: "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
},
],
structuredContent: {
error: projectAnalysisError(
new AnalysisOutputError(
contract.name,
"output does not match the tool contract",
),
),
},
isError: true,
};
}

View File

@ -1,8 +1,9 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { createPackageWithOptions } from "@electron/asar";
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
import { describe, expect, it } from "vitest";
import { z } from "zod";
@ -16,6 +17,52 @@ import { createServer } from "../src/server/createServer.js";
import { observed } from "./fixtures/analysisExecution.js";
describe("artifact graph MCP integration", () => {
it("returns a safe ASAR integrity error without paths or hashes", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-asar-integrity-mcp-"));
const source = join(root, "source");
const original = "console.log('ok');\n";
const changed = "changed();\n";
await mkdir(source);
await writeFile(join(source, "main.js"), original);
const archive = join(root, "fixture.asar");
await createPackageWithOptions(source, archive, { unpack: "*.js" });
await writeFile(join(`${archive}.unpacked`, "main.js"), changed);
const session = new BinarySession(new ArtifactProvider());
const server = createServer(session, session);
const client = new Client({ name: "asar-integrity-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: archive },
});
expect(opened.isError).not.toBe(true);
const result = await client.callTool({
name: "inventory_artifact",
arguments: {},
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
error: {
category: "integrity_mismatch",
message:
"Artifact is invalid or has changed. Get a fresh copy and try again.",
},
});
} finally {
await Promise.allSettled([
client.close(),
server.close(),
session.close(),
]);
}
});
it("rejects altered payloads that reuse session Evidence IDs", async () => {
const session = new BinarySession(() => ({
health: () => Promise.resolve(),

90
tests/cliOutput.test.ts Normal file
View File

@ -0,0 +1,90 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { sanitizeCliOutput } from "../src/cliOutput.js";
const execFileAsync = promisify(execFile);
describe("CLI output boundary", () => {
it("preserves normal output and sanitizes text and JSON validation errors", () => {
expect(sanitizeCliOutput("result: ok\n")).toBe("result: ok\n");
expect(sanitizeCliOutput("result: VALIDATION_ERROR\n")).toBe(
"result: VALIDATION_ERROR\n",
);
const raw =
'code: VALIDATION_ERROR\nmessage: "raw Zod details"\nfieldErrors: SECRET\n';
expect(sanitizeCliOutput(raw)).toBe(
'code: VALIDATION_ERROR\nmessage: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n',
);
expect(
JSON.parse(
sanitizeCliOutput(
JSON.stringify({
code: "VALIDATION_ERROR",
message: "raw Zod details",
fieldErrors: [{ code: "invalid_type" }],
}),
),
),
).toEqual({
code: "VALIDATION_ERROR",
message:
"REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again.",
});
expect(
JSON.parse(
sanitizeCliOutput(
JSON.stringify({
ok: false,
error: {
code: "VALIDATION_ERROR",
message: "raw Zod details",
fieldErrors: [{ code: "invalid_type" }],
},
meta: { command: "analyze" },
}),
),
),
).toEqual({
ok: false,
error: {
code: "VALIDATION_ERROR",
message:
"REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again.",
},
meta: { command: "analyze" },
});
});
it("sanitizes a real missing-argument dispatcher failure", async () => {
await expect(
execFileAsync(process.execPath, ["scripts/rea.mjs", "analyze"], {
cwd: process.cwd(),
}),
).rejects.toMatchObject({
stdout:
'code: VALIDATION_ERROR\nmessage: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n',
});
await expect(
execFileAsync(
process.execPath,
["scripts/rea.mjs", "--full-output", "--json", "analyze"],
{ cwd: process.cwd() },
),
).rejects.toMatchObject({
stdout: expect.not.stringContaining("fieldErrors"),
});
await expect(
execFileAsync(
process.execPath,
["scripts/rea.mjs", "--full-output", "analyze"],
{ cwd: process.cwd() },
),
).rejects.toMatchObject({
stdout:
'ok: false\nerror:\n code: VALIDATION_ERROR\n message: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n',
});
});
});

View File

@ -14,6 +14,19 @@ import {
sessionOutputSchemas,
} from "../src/contracts/toolOutputSchemas.js";
const emptySchemaPaths = (value: unknown, path = "$"): string[] => {
if (Array.isArray(value))
return value.flatMap((item, index) =>
emptySchemaPaths(item, `${path}[${String(index)}]`),
);
if (typeof value !== "object" || value === null) return [];
const entries = Object.entries(value);
if (entries.length === 0) return [path];
return entries.flatMap(([key, item]) =>
emptySchemaPaths(item, `${path}.${key}`),
);
};
describe("tool contract surface", () => {
it("advertises complete typed schemas and annotations for all analysis tools", () => {
const contracts = [
@ -122,4 +135,22 @@ describe("tool contract surface", () => {
expect(contract.description).toMatch(/[.;]/u);
}
});
it("publishes no unconstrained output-schema holes across all 68 tools", () => {
const contracts = [
...OFFICIAL_TOOL_CONTRACTS,
...ENHANCED_TOOL_CONTRACTS,
...NATIVE_TOOL_CONTRACTS,
...ARTIFACT_TOOL_CONTRACTS,
...SESSION_TOOL_CONTRACTS,
];
expect(contracts).toHaveLength(68);
for (const contract of contracts) {
const schema = z.toJSONSchema(contract.outputSchema, {
target: "draft-07",
unrepresentable: "any",
});
expect(emptySchemaPaths(schema), contract.name).toEqual([]);
}
});
});

View File

@ -14,6 +14,28 @@ const host = (overrides: Partial<DoctorHost> = {}): DoctorHost => ({
});
describe("doctor", () => {
it("returns exact recovery for every failed diagnostic", async () => {
const result = await runDoctor(
"/missing/app",
host({
nodeVersion: "20.0.0",
macosVersion: () => Promise.resolve("11.7"),
executable: () => Promise.resolve(false),
validTarget: () => Promise.resolve(false),
}),
);
expect(
Object.fromEntries(
result.checks.map(({ name, remediation }) => [name, remediation]),
),
).toEqual({
node: "Install Node.js 22.19+ or 24.11+.",
host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.",
hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
target: "Supply a readable local app or program path.",
});
});
it.each(["11.7", undefined])(
"rejects unsupported macOS version %s",
async (version) => {

View File

@ -311,9 +311,9 @@ describe("enhanced MCP tools", () => {
address: "0x2",
status: "error",
error: {
tag: "AnalysisOutputError",
message: "Invalid analysis output for decompile: failed",
details: { operation: "decompile", reason: "failed" },
category: "execution_failure",
message:
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
},
},
],
@ -352,7 +352,11 @@ describe("enhanced MCP tools", () => {
{
address: "0x1",
status: "error",
error: { tag: "AnalysisOutputError" },
error: {
category: "execution_failure",
message:
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
},
},
],
},
@ -551,8 +555,8 @@ describe("enhanced MCP tools", () => {
});
expect(result.isError).toBe(true);
const text = result.content.find((item) => item.type === "text");
expect(text?.type === "text" ? text.text : "").toContain(
"non-advancing pagination offset",
expect(text?.type === "text" ? text.text : "").toBe(
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
);
});
@ -598,8 +602,8 @@ describe("enhanced MCP tools", () => {
});
expect(result.isError).toBe(true);
const text = result.content.find((item) => item.type === "text");
expect(text?.type === "text" ? text.text : "").toContain(
"HopperProtocolError",
expect(text?.type === "text" ? text.text : "").toBe(
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
);
});
@ -619,8 +623,8 @@ describe("enhanced MCP tools", () => {
});
expect(result.isError).toBe(true);
const text = result.content.find((item) => item.type === "text");
expect(text?.type === "text" ? text.text : "").toContain(
"AnalysisOutputError",
expect(text?.type === "text" ? text.text : "").toBe(
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
);
});
@ -655,8 +659,8 @@ describe("enhanced MCP tools", () => {
});
expect(result.isError).toBe(true);
const text = result.content.find((item) => item.type === "text");
expect(text?.type === "text" ? text.text : "").toContain(
"AnalysisOutputError",
expect(text?.type === "text" ? text.text : "").toBe(
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
);
});

View File

@ -36,7 +36,7 @@ describe("analysis error projection", () => {
);
});
it("exhaustively projects stable tags without causes or local paths", () => {
it("projects every typed failure without internal details", () => {
const secretCause = new Error("secret-token");
const byTag = {
AnalysisProtocolError: new AnalysisProtocolError("protocol failed"),
@ -97,17 +97,42 @@ describe("analysis error projection", () => {
} satisfies Readonly<Record<AnalysisErrorTag, AnalysisError>>;
const errors = Object.values(byTag);
const projected = errors.map(projectAnalysisError);
expect(new Set(projected.map(({ tag }) => tag)).size).toBe(errors.length);
expect(projected).toHaveLength(23);
expect(JSON.stringify(projected)).not.toContain("secret-token");
expect(JSON.stringify(projected)).not.toContain("/secret/local/path");
const serialized = JSON.stringify(projected);
for (const hidden of [
"secret-token",
"/secret/local/path",
"fixture",
"overview",
"AnalysisCapabilityUnavailableError",
"main.js",
"declaredSha256",
])
expect(serialized).not.toContain(hidden);
expect(
projected.find(({ tag }) => tag === "HopperTimeoutError"),
).toMatchObject({ details: { timeoutMs: 100 } });
expect(
projected.find(({ tag }) => tag === "ProviderAdapterError"),
projectAnalysisError(byTag.AnalysisCapabilityUnavailableError),
).toMatchObject({
details: { providerId: "fixture", operation: "overview" },
category: "unsupported_provider",
});
expect(
projectAnalysisError(
new ArtifactOperationError("inventory_artifact", "integrity", {
logicalPath: "main.js",
declaredSha256: "a".repeat(64),
calculatedSha256: "b".repeat(64),
unpacked: true,
}),
),
).toMatchObject({
category: "integrity_mismatch",
});
expect(projected.every(({ message }) => message.length > 0)).toBe(true);
expect(
projected.every(({ message }) =>
/try again|run `rea doctor`|current target|when ready|smaller request|smaller artifact|fresh copy|re-import|reduce|inline evidence|configured evidence directory|evidence file|allow overwrite|refresh the current state|reported setting|call open_binary|supported file|review capture policy/u.test(
message,
),
),
).toBe(true);
});
});

View File

@ -56,7 +56,17 @@ describe("function comparison MCP integration", () => {
arguments: { domain: "function-comparison" },
});
expect(unknowns.structuredContent).toMatchObject({
result: [expect.objectContaining({ domain: "function-comparison" })],
result: [
expect.objectContaining({
domain: "function-comparison",
recommended_probes: [
expect.objectContaining({
rationale:
"Capture complete dossiers for both functions under the same target context and analysis limits.",
}),
],
}),
],
});
} finally {
await Promise.allSettled([

View File

@ -6,6 +6,7 @@ import {
readFile,
readdir,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
@ -64,14 +65,94 @@ describe("curl installer scenarios", () => {
});
it.each([
["unsupported Node", { FAKE_NODE_VERSION: "20.0.0" }, "unsupported"],
["npm failure", { FAKE_NPM_FAIL: "1" }, "npm package installation failed"],
["version mismatch", { FAKE_REA_VERSION: "9.9.9" }, "does not match"],
[
"unsupported Node",
{ FAKE_NODE_VERSION: "20.0.0" },
"REA installation failed: Node.js 20.0.0 is unsupported; use Node.js 22.19+ or 24.11+.\n",
],
[
"npm failure",
{ FAKE_NPM_FAIL: "1" },
"REA installation failed: npm could not install REA. Check registry access and npm permissions, then retry.\n",
],
[
"version mismatch",
{ FAKE_REA_VERSION: "9.9.9" },
"REA installation failed: installed version 9.9.9 does not match 0.3.0.\n",
],
] as const)("fails closed on %s", async (_name, overrides, message) => {
const fixture = await createFixture();
await expect(
runInstaller(fixture, ["--version", "0.3.0"], overrides),
).rejects.toMatchObject({ stderr: expect.stringContaining(message) });
).rejects.toMatchObject({ stderr: message });
});
it.each([
[
"unreadable Node version",
["--version", "0.3.0"],
{ FAKE_NODE_VERSION: "invalid" },
"REA installation failed: the active Node.js version could not be read. Check that node works and is on PATH, then retry.\n",
],
[
"release lookup",
[],
{ FAKE_CURL_FAIL: "1" },
"REA installation failed: the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry.\n",
],
[
"release response",
[],
{ FAKE_CURL_BODY: "not-json" },
"REA installation failed: the release response was invalid. Retry later or pass --version VERSION.\n",
],
[
"release tag",
[],
{ FAKE_CURL_BODY: '{"tag_name":"unrelated-1.0.0"}' },
"REA installation failed: the latest release tag was invalid. Retry later or pass --version VERSION.\n",
],
[
"npm prefix",
["--version", "0.3.0"],
{ FAKE_PLATFORM: "Darwin", FAKE_NPM_PREFIX_FAIL: "1" },
"REA installation failed: the npm global prefix could not be read. Repair the npm configuration, then retry.\n",
],
[
"missing installed command",
["--version", "0.3.0"],
{ FAKE_NPM_SKIP_BINARY: "1" },
"REA installation failed: npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry.\n",
],
[
"unreadable installed version",
["--version", "0.3.0"],
{ FAKE_REA_VERSION_FAIL: "1" },
"REA installation failed: the installed REA version could not be read. Reinstall the requested version, then retry.\n",
],
] as const)(
"reports exact recovery for %s failure",
async (_name, args, overrides, message) => {
const fixture = await createFixture();
await expect(
runInstaller(fixture, args, overrides),
).rejects.toMatchObject({
stderr: message,
});
},
);
it("reports exact recovery when curl is missing", async () => {
const fixture = await createFixture();
await rm(join(fixture.bin, "curl"));
await expect(
runInstaller(fixture, ["--version", "0.3.0"], {
PATH: fixture.bin,
}),
).rejects.toMatchObject({
stderr:
"REA installation failed: curl is required. Install curl, then rerun this installer.\n",
});
});
});
@ -94,7 +175,7 @@ const createFixture = async (): Promise<InstallerFixture> => {
await Promise.all([mkdir(home), mkdir(bin), mkdir(temporary)]);
await executable(
join(bin, "uname"),
'#!/bin/sh\n[ "$1" = "-m" ] && echo x86_64 || echo Linux\n',
'#!/bin/sh\n[ "$1" = "-m" ] && echo x86_64 || printf \'%s\\n\' "${FAKE_PLATFORM:-Linux}"\n',
);
await executable(
join(bin, "node"),
@ -106,12 +187,14 @@ if [ "$1" = "-p" ]; then printf '%s\n' "\${FAKE_NODE_VERSION:-24.18.0}"; else ex
join(bin, "npm"),
`#!/bin/sh
printf '%s\n' "$*" >> "$FAKE_NPM_LOG"
[ "$1" = "prefix" ] && { [ "\${FAKE_NPM_PREFIX_FAIL:-}" = "1" ] && exit 1; printf '%s\n' "$FAKE_NPM_PREFIX"; exit 0; }
[ "\${FAKE_NPM_FAIL:-}" = "1" ] && exit 1
prefix="$HOME/.local"
while [ "$#" -gt 0 ]; do
if [ "$1" = "--prefix" ]; then shift; prefix="$1"; fi
shift
done
[ "\${FAKE_NPM_SKIP_BINARY:-}" = "1" ] && exit 0
mkdir -p "$prefix/bin"
cp "$FAKE_REA_SOURCE" "$prefix/bin/rea"
chmod +x "$prefix/bin/rea"
@ -119,15 +202,22 @@ chmod +x "$prefix/bin/rea"
);
await executable(
join(bin, "curl"),
'#!/bin/sh\nprintf \'{"tag_name":"rea-agents-0.3.0"}\'\n',
`#!/bin/sh
[ "\${FAKE_CURL_FAIL:-}" = "1" ] && exit 1
if [ -n "\${FAKE_CURL_BODY:-}" ]; then printf '%s' "$FAKE_CURL_BODY"; else printf '%s' '{"tag_name":"rea-agents-0.3.0"}'; fi
`,
);
await executable(
join(bin, "rea-source"),
`#!/bin/sh
printf '%s\n' "$*" >> "$FAKE_REA_LOG"
[ "\${FAKE_REA_VERSION_FAIL:-}" = "1" ] && exit 1
[ "$1" = "--version" ] && printf '%s\n' "\${FAKE_REA_VERSION:-0.3.0}"
exit 0
`,
);
for (const command of ["chmod", "cp", "mkdir", "tr"])
await symlink(`/usr/bin/${command}`, join(bin, command));
return { home, bin, temporary, npmLog, reaLog };
};
@ -150,6 +240,7 @@ const runInstaller = async (
FAKE_NPM_LOG: fixture.npmLog,
FAKE_REA_LOG: fixture.reaLog,
FAKE_REA_SOURCE: join(fixture.bin, "rea-source"),
FAKE_NPM_PREFIX: join(fixture.home, ".npm-global"),
...overrides,
},
});

View File

@ -252,6 +252,11 @@ describe("full MCP integration with multi-tool sequences", () => {
},
});
expect(unavailable.isError).toBe(true);
expect(structured(unavailable)).toMatchObject({
error: {
category: "unsupported_provider",
},
});
expect(received[0]).not.toHaveProperty("unknown_registry_approved");
expect(
structured(
@ -260,9 +265,15 @@ describe("full MCP integration with multi-tool sequences", () => {
).toMatchObject({
result: [
{
domain: "provider-capability",
domain: "analysis-capability",
question:
"procedure_pseudo_code is unavailable: Decompiler is not installed.",
"The requested analysis is unavailable for the current target.",
recommended_probes: [
{
rationale:
"Choose another analysis or target that can answer this question.",
},
],
},
],
});
@ -354,7 +365,7 @@ describe("full MCP integration with multi-tool sequences", () => {
expect(names).not.toContain("open_binary");
});
it("propagates remote Hopper errors with structured content", async () => {
it("projects remote failures without provider or bridge details", async () => {
const client = await connect({
execute: () =>
Promise.resolve(err(new HopperRemoteError(-32000, "bridge timeout"))),
@ -365,8 +376,14 @@ describe("full MCP integration with multi-tool sequences", () => {
arguments: {},
});
expect(result.isError).toBe(true);
expect(text(result)).toContain("HopperRemoteError");
expect(text(result)).toContain("bridge timeout");
expect(structured(result)).toMatchObject({
error: {
category: "execution_failure",
},
});
expect(text(result)).toBe(
"Analysis could not complete. Retry once; if it continues, run `rea doctor`.",
);
});
it("handles concurrent tool calls without corruption", async () => {

View File

@ -11,7 +11,8 @@ import {
PROCESS_CAPTURE_REFERENCE,
PROCESS_COMPARISON_EVIDENCE,
} from "../src/contracts/investigationExamples.js";
import { parseEvidence } from "../src/domain/evidence.js";
import { createEvidence, parseEvidence } from "../src/domain/evidence.js";
import { jsonObjectSchema, jsonValueSchema } from "../src/domain/jsonValue.js";
import { createServer } from "../src/server/createServer.js";
import { observed } from "./fixtures/analysisExecution.js";
@ -35,7 +36,7 @@ describe("investigation MCP workflows", () => {
unknown_registry_approved: true,
},
});
expect(response.isError).not.toBe(true);
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const evidence = parseEvidence(
z.object({ result: z.unknown() }).parse(response.structuredContent)
.result,
@ -44,9 +45,14 @@ describe("investigation MCP workflows", () => {
provider: { id: "rea-changed-behavior" },
normalized_result: { behavior_status: "unknown" },
});
expect(session.listUnknowns({ domain: "changed-behavior" })).toHaveLength(
1,
);
expect(
session.listUnknowns({ domain: "changed-behavior" }),
).toMatchObject([
{
question:
"Did both versions behave the same under a complete controlled replay?",
},
]);
} finally {
await close(session, server, client);
}
@ -81,6 +87,66 @@ describe("investigation MCP workflows", () => {
}
});
it("records a safe unresolved call-path question", async () => {
const { session, server, client } = await connected();
const base = FUNCTION_COMPARISON_EXAMPLE.left;
if (base.subject === null) throw new Error("missing dossier subject");
const dossier = createEvidence(
{
path: base.subject.local_path,
sha256: base.subject.digest.sha256,
format: base.subject.format,
...(base.subject.architecture === null
? {}
: { architecture: base.subject.architecture }),
},
base.provider,
{
predicateType: base.predicate_type,
operation: base.operation,
parameters: base.parameters,
result: jsonValueSchema.parse({
...jsonObjectSchema.parse(base.normalized_result),
callees: {
items: [{ address: "0x2000", name: "next" }],
total: 1,
returned: 1,
truncated: false,
next_offset: null,
},
}),
rawResult: base.raw_result,
confidence: base.confidence,
authority: base.authority,
limitations: base.limitations,
locations: base.locations,
evidenceLinks: base.evidence_links,
},
);
expect(session.recordEvidence(dossier).ok).toBe(true);
try {
const response = await client.callTool({
name: "build_call_path",
arguments: {
...INVESTIGATION_EXAMPLES.build_call_path,
functions: [dossier],
start: { address: "0x1000" },
goal: { address: "0x3000" },
unknown_registry_approved: true,
},
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(session.listUnknowns({ domain: "call-path" })).toMatchObject([
{
question:
"Can the requested call path be established from complete analysis?",
},
]);
} finally {
await close(session, server, client);
}
});
it("records an explicit non-causal static/runtime hypothesis", async () => {
const { session, server, client } = await connected();
for (const evidence of [
@ -115,6 +181,51 @@ describe("investigation MCP workflows", () => {
}
});
it("records a safe unresolved static/runtime question", async () => {
const { session, server, client } = await connected();
for (const evidence of [
FUNCTION_COMPARISON_EXAMPLE.left,
FUNCTION_COMPARISON_EXAMPLE.right,
FUNCTION_COMPARISON_EVIDENCE,
PROCESS_CAPTURE_REFERENCE,
PROCESS_CAPTURE_RECONSTRUCTION,
PROCESS_COMPARISON_EVIDENCE,
])
expect(session.recordEvidence(evidence).ok).toBe(true);
const mapping =
INVESTIGATION_EXAMPLES.correlate_static_and_runtime.mappings[0];
if (mapping === undefined) throw new Error("missing correlation fixture");
try {
const response = await client.callTool({
name: "correlate_static_and_runtime",
arguments: {
...INVESTIGATION_EXAMPLES.correlate_static_and_runtime,
mappings: [
{
...mapping,
static: {
...mapping.static,
selector: { kind: "function", dimension: "assembly" },
},
},
],
unknown_registry_approved: true,
},
});
expect(response.isError).not.toBe(true);
expect(
session.listUnknowns({ domain: "static-runtime-correlation" }),
).toMatchObject([
{
question:
"Does runtime behavior match the available static analysis?",
},
]);
} finally {
await close(session, server, client);
}
});
it("passes only the finite declared reconstruction specification", async () => {
const { session, server, client } = await connected();
for (const evidence of [
@ -126,7 +237,10 @@ describe("investigation MCP workflows", () => {
try {
const response = await client.callTool({
name: "verify_reconstruction",
arguments: INVESTIGATION_EXAMPLES.verify_reconstruction,
arguments: {
...INVESTIGATION_EXAMPLES.verify_reconstruction,
unknown_registry_approved: true,
},
});
expect(response.isError).not.toBe(true);
const evidence = parseEvidence(
@ -176,7 +290,10 @@ describe("investigation MCP workflows", () => {
try {
const response = await client.callTool({
name: "verify_reconstruction",
arguments: INVESTIGATION_EXAMPLES.verify_reconstruction,
arguments: {
...INVESTIGATION_EXAMPLES.verify_reconstruction,
unknown_registry_approved: true,
},
});
expect(response.isError).not.toBe(true);
const evidence = parseEvidence(
@ -187,6 +304,13 @@ describe("investigation MCP workflows", () => {
status: "unknown",
summary: { unknown: 1 },
});
expect(
session.listUnknowns({ domain: "reconstruction-verification" }),
).toContainEqual(
expect.objectContaining({
question: "Does the reconstruction satisfy every declared claim?",
}),
);
} finally {
await close(session, server, client);
}

109
tests/mainErrors.test.ts Normal file
View File

@ -0,0 +1,109 @@
import type { ServeStdioOptions } from "@modelcontextprotocol/server/stdio";
import { describe, expect, it } from "vitest";
import { run, runEntrypoint } from "../src/main.js";
type RuntimeDependencies = NonNullable<Parameters<typeof run>[0]>;
const nextTurn = (): Promise<void> =>
new Promise((resolve) => setImmediate(resolve));
const dependencies = (
serve: RuntimeDependencies["serve"],
output: string[],
shutdown: Array<() => void>,
exitCodes: number[],
): RuntimeDependencies => ({
env: {},
serve,
writeStderr: (text) => output.push(text),
setExitCode: (code) => exitCodes.push(code),
registerShutdown: (handler) => shutdown.push(handler),
});
describe("MCP runtime errors", () => {
it("reports connection loss without the transport cause", async () => {
const output: string[] = [];
const shutdown: Array<() => void> = [];
let options: ServeStdioOptions | undefined;
let closeCalls = 0;
const close = (): Promise<void> => {
closeCalls += 1;
return Promise.resolve();
};
const exitCodes: number[] = [];
expect(
await run(
dependencies(
(_factory, received) => {
options = received;
return { close };
},
output,
shutdown,
exitCodes,
),
),
).toBe(0);
options?.onerror?.(new Error("SECRET transport stack"));
expect(output).toEqual([
"REA lost its MCP client connection. Restart REA from your MCP client.\n",
]);
shutdown[0]?.();
await nextTurn();
expect(closeCalls).toBe(1);
});
it("reports transport startup failure without its cause", async () => {
const output: string[] = [];
expect(
await run(
dependencies(
() => {
throw new Error("SECRET startup stack");
},
output,
[],
[],
),
),
).toBe(1);
expect(output).toEqual([
"REA could not start its MCP connection. Restart REA from your MCP client; run `rea doctor` if it fails again.\n",
]);
});
it("reports shutdown failure and sets a failing exit code", async () => {
const output: string[] = [];
const shutdown: Array<() => void> = [];
const exitCodes: number[] = [];
await run(
dependencies(
() => ({ close: () => Promise.reject(new Error("SECRET close")) }),
output,
shutdown,
exitCodes,
),
);
shutdown[0]?.();
await nextTurn();
expect(exitCodes).toEqual([1]);
expect(output).toEqual([
"REA could not close cleanly. End the REA process before starting it again.\n",
]);
});
it("reports an unexpected entrypoint failure without its cause", async () => {
const output: string[] = [];
const exitCodes: number[] = [];
await runEntrypoint(
() => Promise.reject(new Error("SECRET entrypoint stack")),
(text) => output.push(text),
(code) => exitCodes.push(code),
);
expect(output).toEqual([
"REA could not start. Run `rea doctor`, then restart REA from your MCP client.\n",
]);
expect(exitCodes).toEqual([1]);
});
});

View File

@ -199,7 +199,7 @@ describe("official Hopper proxy tools", () => {
expect(result.content).toEqual([
{
type: "text",
text: "HopperRemoteError: Hopper request failed (-1): denied",
text: "Analysis could not complete. Retry once; if it continues, run `rea doctor`.",
},
]);
});

164
tests/processCli.test.ts Normal file
View File

@ -0,0 +1,164 @@
import { mkdtemp, open, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import {
captureProcessScenarioFile,
compareProcessEvidenceFiles,
projectProcessCliError,
} from "../src/application/ProcessCli.js";
import { createEvidence } from "../src/domain/evidence.js";
import { PROCESS_PROVIDER } from "../src/application/ProcessEvidence.js";
const roots: string[] = [];
const execFileAsync = promisify(execFile);
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
const fixture = async (): Promise<string> => {
const root = await mkdtemp(join(tmpdir(), "rea-process-cli-"));
roots.push(root);
return root;
};
describe("process CLI errors", () => {
it("exits unsuccessfully without writing failure-shaped evidence", async () => {
await expect(
execFileAsync(
process.execPath,
["scripts/rea.mjs", "capture-process", "/missing/scenario.json"],
{ cwd: process.cwd() },
),
).rejects.toMatchObject({
code: 1,
stdout: expect.stringContaining("PROCESS_COMMAND_FAILED"),
});
});
it("projects unexpected causes without their message or stack", () => {
const projected = projectProcessCliError(
new Error("SECRET internal failure /private/path"),
);
expect(projected).toEqual({
error: "Process command failed",
category: "execution_failure",
message:
"Process command could not complete. Check the input files and run `rea doctor`, then try again.",
});
expect(JSON.stringify(projected)).not.toContain("SECRET");
});
it("projects missing, malformed JSON, and invalid scenario files", async () => {
const root = await fixture();
const malformed = join(root, "malformed.json");
const invalid = join(root, "invalid.json");
await writeFile(malformed, "not-json");
await writeFile(invalid, "{}");
expect(
await captureProcessScenarioFile(join(root, "missing.json")),
).toEqual({
error: "Process command failed",
category: "invalid_input",
message:
"Process input file could not be read. Check that the path exists and is readable.",
});
expect(await captureProcessScenarioFile(malformed)).toEqual({
error: "Process command failed",
category: "invalid_input",
message:
"Process input file is not valid JSON. Repair the file, then try again.",
});
expect(await captureProcessScenarioFile(invalid)).toEqual({
error: "Process command failed",
category: "invalid_input",
message:
"Process scenario is invalid. Check its required fields and limits, then try again.",
});
});
it("reports the exact process-capture policy recovery", async () => {
const root = await fixture();
const scenario = join(root, "scenario.json");
await writeFile(
scenario,
JSON.stringify({
approved: true,
executable: "/bin/sh",
working_directory: "/tmp",
}),
);
expect(await captureProcessScenarioFile(scenario)).toEqual({
error: "Process command failed",
category: "permission_required",
message:
"Process capture is disabled. Set `REA_PROCESS_CAPTURE_ENABLED=true`, configure approved roots, then restart REA.",
});
});
it("rejects oversized process input before parsing", async () => {
const root = await fixture();
const path = join(root, "large.json");
const handle = await open(path, "w");
await handle.truncate(64 * 1024 * 1024 + 1);
await handle.close();
expect(await captureProcessScenarioFile(path)).toEqual({
error: "Process command failed",
category: "truncated",
message:
"Process input file is too large. Reduce it below 64 MiB, then try again.",
});
});
it("explains unsupported and unrelated capture evidence", async () => {
const root = await fixture();
const legacy = join(root, "legacy.json");
const unrelated = join(root, "unrelated.json");
await writeFile(
legacy,
JSON.stringify(
createEvidence(undefined, PROCESS_PROVIDER, {
predicateType: "rea.process-capture/v3",
operation: "capture_process_scenario",
parameters: {},
result: {},
}),
),
);
await writeFile(
unrelated,
JSON.stringify(
createEvidence(undefined, PROCESS_PROVIDER, {
predicateType: "unrelated/v1",
operation: "other",
parameters: {},
result: {},
}),
),
);
const legacyResult = await compareProcessEvidenceFiles(legacy, legacy);
expect(legacyResult).toMatchObject({
error: "Process command failed",
category: "invalid_input",
});
expect(JSON.stringify(legacyResult)).toContain(
"Process Capture v3 is unsupported",
);
expect(await compareProcessEvidenceFiles(unrelated, unrelated)).toEqual({
error: "Process command failed",
category: "invalid_input",
message:
"Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.",
});
});
});

View File

@ -7,6 +7,11 @@ import { add, commit, init } from "isomorphic-git";
import { describe, expect, it } from "vitest";
import { importReferenceSource } from "../src/application/ReferenceSourceImport.js";
import { projectReferenceSourceEntryFailure } from "../src/application/ReferenceSourceImportEntries.js";
import {
projectReferenceSourceImportError,
type ReferenceSourceImportError,
} from "../src/application/ReferenceSourceImportTypes.js";
import { createHistoricalSourceManifest } from "../src/domain/referenceSourceGraph.js";
const limits = {
@ -43,6 +48,52 @@ const importTree = (root: string, approvedRoot: string, signal?: AbortSignal) =>
});
describe("reference source import", () => {
it("projects entry failures without low-level reader diagnostics", () => {
for (const [kind, code] of [
["directory", "io"],
["symlink", "io"],
["file", "io"],
["file", "cancelled"],
["file", "limit"],
["unknown", "unsupported"],
] as const) {
const message = projectReferenceSourceEntryFailure({
status: "failed",
path: "safe/path",
kind,
code,
message: "SECRET internal diagnostic /private/path",
});
expect(message).not.toContain("SECRET");
expect(message).not.toContain("/private/path");
expect(message).toMatch(/Check|try again|when ready|smaller|supported/u);
}
});
it("projects every import failure without raw parser or policy text", () => {
const expectedCategories = {
cancelled: "cancelled",
"invalid-limits": "invalid_input",
"invalid-root": "invalid_input",
io: "execution_failure",
parse: "execution_failure",
policy: "permission_required",
} as const;
for (const [code, category] of Object.entries(expectedCategories)) {
const projected = projectReferenceSourceImportError({
tag: "reference-source-import",
code: code as ReferenceSourceImportError["code"],
message: "SECRET parser stack and /private/path",
});
expect(projected.category).toBe(category);
expect(projected.message).not.toContain("SECRET");
expect(projected.message).not.toContain("/private/path");
expect(projected.message).toMatch(
/try again|when ready|positive integer|Check that|REA_REFERENCE_ROOTS_JSON/u,
);
}
});
it("is relocation-stable, resolves imports, and excludes secrets before capture", async () => {
const parent = await mkdtemp(join(tmpdir(), "rea-reference-import-"));
try {

View File

@ -58,7 +58,9 @@ describe("target-free MCP lifecycle", () => {
arguments: {},
});
expect(before.isError).toBe(true);
expect(text(before)).toContain("NoBinaryOpenError");
expect(text(before)).toBe(
"No app is open. Ask the user which app to investigate, then call open_binary with its local path.",
);
expect((await mcp.listTools()).tools).toHaveLength(68);
const deniedCapture = await mcp.callTool({
name: "capture_process_scenario",
@ -69,7 +71,14 @@ describe("target-free MCP lifecycle", () => {
},
});
expect(deniedCapture.isError).toBe(true);
expect(text(deniedCapture)).toContain("process capture is disabled");
expect(structured(deniedCapture)).toMatchObject({
error: {
category: "permission_required",
},
});
expect(text(deniedCapture)).toBe(
"Process capture is disabled. Set `REA_PROCESS_CAPTURE_ENABLED=true`, configure approved roots, then restart REA.",
);
expect(
(await mcp.callTool({ name: "open_binary", arguments: { path: first } }))
.isError,
@ -219,8 +228,7 @@ describe("target-free MCP lifecycle", () => {
),
).result;
expect(listed).toContainEqual({
question:
"network: External network isolation is not enforced by this adapter.",
question: "Was network behavior fully observed during capture?",
domain: "process-network",
});
});

View File

@ -12,7 +12,7 @@ import type { LinuxDistribution } from "../src/application/LinuxHopper.js";
class FakeSetupHost implements SetupHost {
readonly platform: NodeJS.Platform;
readonly nodeVersion = "25.1.0";
nodeVersion = "25.1.0";
version: string | undefined = "14.5";
distribution: LinuxDistribution | undefined;
hopper: string | undefined;
@ -22,6 +22,7 @@ class FakeSetupHost implements SetupHost {
clientResults = new Map<string, ClientConfigurationResult>();
hopperInstalls = 0;
configurations = 0;
doctorHealthy: boolean | undefined;
constructor(platform: NodeJS.Platform = "darwin") {
this.platform = platform;
@ -55,7 +56,7 @@ class FakeSetupHost implements SetupHost {
checks: readonly DoctorCheck[];
}> =>
Promise.resolve({
healthy: this.hopper !== undefined,
healthy: this.doctorHealthy ?? this.hopper !== undefined,
...(this.hopper === undefined ? {} : { hopperPath: this.hopper }),
checks: [],
});
@ -80,6 +81,9 @@ describe("setup workflow", () => {
]);
expect(host.hopperInstalls).toBe(0);
expect(host.configurations).toBe(0);
expect(result.remediation).toBe(
"Review the setup plan, then rerun interactively or with --yes.",
);
});
it("applies an accepted interactive plan including Hopper", async () => {
@ -95,6 +99,9 @@ describe("setup workflow", () => {
"installed_skill",
]);
expect(host.hopperInstalls).toBe(1);
expect(result.remediation).toBe(
"Open Hopper, complete its one-time activation, then rerun rea doctor --json.",
);
});
it("declines an interactive plan without mutation", async () => {
@ -116,6 +123,9 @@ describe("setup workflow", () => {
expect(host.hopperInstalls).toBe(0);
expect(result.appliedActions).toEqual(["installed_skill"]);
expect(result.remediation).toContain("--install-hopper");
expect(result.remediation).toBe(
"Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis.",
);
});
it("installs Hopper when unattended authorization is explicit", async () => {
@ -151,10 +161,76 @@ describe("setup workflow", () => {
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
const result = await runSetup(options(true, true), host);
expect(result.status).toBe("needs_human");
expect(result.remediation).toContain("failed");
expect(result.remediation).toBe(
"Hopper installation failed; install Hopper manually or rerun setup after resolving the reported system error.",
);
expect(host.configurations).toBe(0);
});
it.each([
[
"backup",
"Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.",
],
[
"write",
"Agent configuration could not be updated. Check file permissions, then rerun setup.",
],
[
"readback",
"Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.",
],
] as const)("explains %s configuration recovery", async (reason, message) => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [{ name: "internal_client_key", configPath: "/agent.json" }];
host.clientResults.set("internal_client_key", { status: "failed", reason });
const result = await runSetup(options(true), host);
expect(result.status).toBe("needs_human");
expect(result.remediation).toBe(message);
expect(result.remediation).not.toContain("internal_client_key");
});
it("explains skill installation recovery", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "failed";
const result = await runSetup(options(true), host);
expect(result.status).toBe("needs_human");
expect(result.remediation).toBe(
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",
);
});
it("delegates remaining unhealthy checks to doctor remediation", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.doctorHealthy = false;
const result = await runSetup(options(true), host);
expect(result.remediation).toBe(
"Run rea doctor and apply each reported remediation.",
);
});
it("explains unsupported Node and macOS recovery", async () => {
const platformHost = new FakeSetupHost("win32");
expect((await runSetup(options(true), platformHost)).remediation).toBe(
"REA supports Hopper on macOS and selected 64-bit Linux distributions.",
);
const nodeHost = new FakeSetupHost();
nodeHost.nodeVersion = "20.0.0";
expect((await runSetup(options(true), nodeHost)).remediation).toBe(
"Install Node.js 22.19+ or 24.11+ and rerun setup.",
);
const macHost = new FakeSetupHost();
macHost.version = "11.7";
expect((await runSetup(options(true), macHost)).remediation).toBe(
"Upgrade to macOS 12 or newer.",
);
});
it("rejects unsupported hosts before mutation", async () => {
const host = new FakeSetupHost("linux");
host.distribution = {
@ -166,5 +242,8 @@ describe("setup workflow", () => {
const result = await runSetup(options(true, true), host);
expect(result.status).toBe("needs_human");
expect(host.hopperInstalls).toBe(0);
expect(result.remediation).toBe(
"REA supports Hopper on Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.",
);
});
});

View File

@ -1,4 +1,12 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import {
copyFile,
lstat,
mkdtemp,
mkdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@ -10,6 +18,7 @@ import {
import {
runUninstall,
systemUninstallHost,
type UninstallFileSystem,
} from "../src/application/Uninstall.js";
const roots: string[] = [];
@ -110,7 +119,12 @@ describe("agent lifecycle", () => {
mcpServers: { other: { command: "other" } },
});
expect(first.items).toContainEqual(
expect.objectContaining({ name: "cache", status: "retained" }),
expect.objectContaining({
name: "cache",
status: "retained",
detail:
"REA did not remove this item because its managed path is a symbolic link. Verify the link target before removing it manually.",
}),
);
expect((await runUninstall(true, systemUninstallHost(home))).status).toBe(
"complete",
@ -123,9 +137,111 @@ describe("agent lifecycle", () => {
const config = join(home, ".cursor/mcp.json");
await mkdir(dirname(config), { recursive: true });
await writeFile(config, "not-json");
expect((await runUninstall(false, systemUninstallHost(home))).status).toBe(
"failed",
const result = await runUninstall(false, systemUninstallHost(home));
expect(result.status).toBe("failed");
expect(result.items).toContainEqual(
expect.objectContaining({
name: "cursor",
status: "failed",
detail:
"Configuration is not valid JSON and was not changed. Repair it, then rerun uninstall.",
}),
);
expect(await readFile(config, "utf8")).toBe("not-json");
});
it.each([
[
"read",
"Configuration could not be read. Check file permissions, then rerun uninstall.",
],
[
"backup",
"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",
],
[
"update",
"Configuration could not be updated. The original was restored and its `.rea.backup` was retained. Repair the configuration or restore the backup, then rerun uninstall.",
],
[
"restore",
"Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall.",
],
] as const)(
"reports an actionable client %s failure",
async (failure, detail) => {
const { home, config } = await uninstallFixture();
let writes = 0;
const fileSystem: UninstallFileSystem = {
...testFileSystem,
readText: (path) =>
failure === "read" && path === config
? Promise.reject(new Error("SECRET read failure"))
: readFile(path, "utf8"),
copy: (source, destination) =>
failure === "backup"
? Promise.reject(new Error("SECRET backup failure"))
: copyFile(source, destination),
writeText: async (path, contents) => {
writes += 1;
if ((failure === "update" && writes === 1) || failure === "restore")
throw new Error("SECRET write failure");
await writeFile(path, contents);
},
};
const result = await runUninstall(
false,
systemUninstallHost(home, fileSystem),
);
expect(result.status).toBe("failed");
expect(result.items).toContainEqual(
expect.objectContaining({ name: "cursor", status: "failed", detail }),
);
expect(JSON.stringify(result)).not.toContain("SECRET");
},
);
it("reports a managed-path removal failure", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-uninstall-remove-"));
roots.push(home);
const skillRoot = join(home, ".agents/skills/rea-analysis");
await mkdir(skillRoot, { recursive: true });
const result = await runUninstall(
false,
systemUninstallHost(home, {
...testFileSystem,
remove: () => Promise.reject(new Error("SECRET removal failure")),
}),
);
expect(result.items).toContainEqual({
name: "skill",
status: "failed",
detail:
"This item could not be removed. Check file permissions, then rerun uninstall.",
});
expect(JSON.stringify(result)).not.toContain("SECRET");
});
});
const testFileSystem: UninstallFileSystem = {
readText: (path) => readFile(path, "utf8"),
copy: (source, destination) => copyFile(source, destination),
writeText: (path, contents) => writeFile(path, contents),
stat: (path) => lstat(path),
remove: (path) => rm(path, { recursive: true }),
};
const uninstallFixture = async (): Promise<{
readonly home: string;
readonly config: string;
}> => {
const home = await mkdtemp(join(tmpdir(), "rea-uninstall-failure-"));
roots.push(home);
const config = join(home, ".cursor/mcp.json");
await mkdir(dirname(config), { recursive: true });
await writeFile(
config,
JSON.stringify({ mcpServers: { rea: { command: "rea", args: ["mcp"] } } }),
);
return { home, config };
};

View File

@ -26,9 +26,16 @@ describe("tool result projection", () => {
content: [
{
type: "text",
text: "AnalysisOutputError: Analysis output does not match the tool contract",
text: "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
},
],
structuredContent: {
error: {
category: "execution_failure",
message:
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
},
},
isError: true,
});
});

View File

@ -98,6 +98,8 @@ describe("CLI upgrade", () => {
expect(await runUpgrade("0.5.0", host)).toMatchObject({
status: "failed",
reason: "install",
remediation:
"REA could not update through npm. Check npm registry access and global install permissions, then run: npm install --global rea-agents@latest",
});
expect(host.installs).toBe(1);
});