diff --git a/docs/user-visible-errors.csv b/docs/user-visible-errors.csv new file mode 100644 index 000000000..f6d7c2eb4 --- /dev/null +++ b/docs/user-visible-errors.csv @@ -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),Coding-agent configuration backup fails,"{clientName} configuration backup verification failed; no successful configuration was reported.",Exposes machine client key and obscures no-write guarantee,"Coding-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),Coding-agent configuration write fails,"{clientName} configuration write verification failed; no successful configuration was reported.",Exposes machine client key and gives no recovery,"Coding-agent configuration could not be updated. Check file permissions, then rerun setup.",implemented,focused assertions passed +34,src/application/Setup.ts:configureDetectedClients(readback),Written coding-agent configuration cannot be verified,"{clientName} configuration readback verification failed; no successful configuration was reported.",Exposes machine client key and omits backup recovery,"Coding-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 coding-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,Coding-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 diff --git a/install.sh b/install.sh index 454f20266..a359488ff 100755 --- a/install.sh +++ b/install.sh @@ -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" diff --git a/scripts/rea.mjs b/scripts/rea.mjs index ee5af994b..a9ba8206b 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -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)), + }); } diff --git a/src/application/ProcessCli.ts b/src/application/ProcessCli.ts index 75ee57637..f69b4f1cf 100644 --- a/src/application/ProcessCli.ts +++ b/src/application/ProcessCli.ts @@ -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 => { - 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.", + }; }; diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 2cf527cee..8b5f830c5 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -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, +): 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, ): 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: "", 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], }; }; diff --git a/src/application/ReferenceSourceImportTypes.ts b/src/application/ReferenceSourceImportTypes.ts index 3ea671c7a..09c656dc0 100644 --- a/src/application/ReferenceSourceImportTypes.ts +++ b/src/application/ReferenceSourceImportTypes.ts @@ -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; diff --git a/src/application/Setup.ts b/src/application/Setup.ts index f6cf299d9..26f58ac60 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -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; - readonly appliedActions: string[]; -}): Promise => { - 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"); diff --git a/src/application/SetupClients.ts b/src/application/SetupClients.ts new file mode 100644 index 000000000..73af12a45 --- /dev/null +++ b/src/application/SetupClients.ts @@ -0,0 +1,39 @@ +import type { + ClientConfigurationResult, + SetupClient, + SetupHost, +} from "./Setup.js"; + +const failedConfigurationMessage = ( + reason: "backup" | "write" | "readback", +): string => { + if (reason === "backup") + return "Coding-agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup."; + if (reason === "write") + return "Coding-agent configuration could not be updated. Check file permissions, then rerun setup."; + return "Coding-agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup."; +}; + +/** Configure each detected coding 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; + readonly appliedActions: string[]; +}): Promise => { + 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; +}; diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index fbc650d08..ee8b3ee56 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -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; + copy(source: string, destination: string): Promise; + writeText(path: string, contents: string): Promise; + stat(path: string): Promise; + remove(path: string): Promise; +} + +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 => { +const removeClient = async ( + client: SetupClient, + fileSystem: UninstallFileSystem, +): Promise => { if (client.format === "unsupported") return item( client.name, @@ -77,11 +106,15 @@ const removeClient = async (client: SetupClient): Promise => { ); 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; let key: "mcp_servers" | "mcpServers"; @@ -96,7 +129,7 @@ const removeClient = async (client: SetupClient): Promise => { 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 => { 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 => { 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 => { `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 => { 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.", + ); } }; diff --git a/src/application/Upgrade.ts b/src/application/Upgrade.ts index fdd35f908..71428f268 100644 --- a/src/application/Upgrade.ts +++ b/src/application/Upgrade.ts @@ -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 { diff --git a/src/cli.ts b/src/cli.ts index 8e3378190..98ae872c4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,7 @@ 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. @@ -388,7 +389,10 @@ const registerReferenceSourceCommand = ( }); return imported.ok ? imported.value - : { error: imported.error.code, message: imported.error.message }; + : { + error: "Import failed", + ...projectReferenceSourceImportError(imported.error), + }; }), }); }; diff --git a/src/cliOutput.ts b/src/cliOutput.ts new file mode 100644 index 000000000..4d519758b --- /dev/null +++ b/src/cliOutput.ts @@ -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; + +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`; +}; diff --git a/src/cliProcessCommands.ts b/src/cliProcessCommands.ts index 2c7696e28..5d6743d9b 100644 --- a/src/cliProcessCommands.ts +++ b/src/cliProcessCommands.ts @@ -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 ( + execute: () => Promise, +): Promise => { + 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, @@ -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), + ), ), }); }; diff --git a/src/contracts/toolOutputSchemas.ts b/src/contracts/toolOutputSchemas.ts index 7d9455ab3..4f65576ce 100644 --- a/src/contracts/toolOutputSchemas.ts +++ b/src/contracts/toolOutputSchemas.ts @@ -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,7 +179,6 @@ const symbolDiscoveryOutput = (property: "classes" | "protocols") => ); const analysisErrorProjectionSchema = z .object({ - tag: z.enum(ANALYSIS_ERROR_TAGS), category: z.enum([ "invalid_input", "permission_required", @@ -193,10 +191,6 @@ const analysisErrorProjectionSchema = z "execution_failure", ]), message: z.string(), - details: z.record( - z.string(), - z.union([z.string(), z.number(), z.boolean(), z.null()]), - ), }) .strict(); const graphNode = z.discriminatedUnion("status", [ diff --git a/src/domain/errors.ts b/src/domain/errors.ts index e5327f27f..b499b9c33 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -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", @@ -302,7 +302,6 @@ export class BinaryTargetError extends AnalysisError { export interface AnalysisErrorProjection extends Readonly> { - readonly tag: AnalysisErrorTag; readonly category: | "invalid_input" | "permission_required" @@ -314,7 +313,6 @@ export interface AnalysisErrorProjection | "unavailable" | "execution_failure"; readonly message: string; - readonly details: Readonly>; } /** Project expected failures into exhaustive, secret-safe caller fields. */ @@ -323,10 +321,8 @@ export const projectAnalysisError = ( ): AnalysisErrorProjection => { assertKnownTag(error._tag); return { - tag: error._tag, category: errorCategory(error), message: userMessage(error), - details: safeDetails(error), }; }; @@ -373,50 +369,6 @@ const STATIC_ERROR_CATEGORIES: Readonly< BinaryTargetError: "unavailable", }; -const safeDetails = ( - error: AnalysisError, -): Readonly> => { - 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 }; - if (error instanceof ArtifactOperationError) - return { - operation: error.operation, - reason: error.reason, - ...error.artifactDetails, - }; - if (error instanceof EvidenceLimitError) - return { limit: error.limit, maximum: error.maximum }; - 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 {}; -}; - const userMessage = (error: AnalysisError): string => { if (error instanceof AnalysisInputError) return "Analysis input is invalid. Check the arguments and try again."; diff --git a/src/domain/jsonValue.ts b/src/domain/jsonValue.ts index 5abc86806..710e62d61 100644 --- a/src/domain/jsonValue.ts +++ b/src/domain/jsonValue.ts @@ -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; diff --git a/src/main.ts b/src/main.ts index 9f1d00d83..8794250b6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,15 +14,50 @@ 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 => { - const config = parseConfig(process.env); +export const run = async ( + dependencies: RuntimeDependencies = runtimeDependencies(), +): Promise => { + const config = parseConfig(dependencies.env); if (!config.ok) { - process.stderr.write(`${projectAnalysisError(config.error).message}\n`); + dependencies.writeStderr(`${projectAnalysisError(config.error).message}\n`); return 1; } const logger = createLogger("mcp", config.value.logLevel); @@ -39,13 +74,15 @@ export const run = async (): Promise => { { errorTag: opened.error._tag }, "Initial target failed to open", ); - process.stderr.write(`${projectAnalysisError(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, @@ -54,15 +91,15 @@ export const run = async (): Promise => { }), { 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; } @@ -76,30 +113,36 @@ export const run = async (): Promise => { }; 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 = () => run(), + writeStderr: (text: string) => void = (text) => process.stderr.write(text), + setExitCode: (code: number) => void = (code) => { + process.exitCode = code; + }, +): Promise => { + 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(); } diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index c287d407e..e767e5049 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -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 = { ...parsedInput }; const result = await logToolExecution(registration.logger, name, () => services.execute(name, input, context.mcpReq.signal), diff --git a/src/server/registerEvidenceTools.ts b/src/server/registerEvidenceTools.ts index 6a509cee0..79d8e90c2 100644 --- a/src/server/registerEvidenceTools.ts +++ b/src/server/registerEvidenceTools.ts @@ -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> => { - 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; -}; diff --git a/src/server/registerFunctionComparisonTool.ts b/src/server/registerFunctionComparisonTool.ts index ee8f5f991..60a73028f 100644 --- a/src/server/registerFunctionComparisonTool.ts +++ b/src/server/registerFunctionComparisonTool.ts @@ -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: [], diff --git a/src/server/registerInvestigationTools.ts b/src/server/registerInvestigationTools.ts index 956be39f3..9c73e78f4 100644 --- a/src/server/registerInvestigationTools.ts +++ b/src/server/registerInvestigationTools.ts @@ -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", diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts index d633c6564..9870e02f0 100644 --- a/src/server/registerOfficialTools.ts +++ b/src/server/registerOfficialTools.ts @@ -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> => { - 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 = {}; 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; }; diff --git a/src/server/registerProcessComparisonTool.ts b/src/server/registerProcessComparisonTool.ts index 708859e51..fdf99e64c 100644 --- a/src/server/registerProcessComparisonTool.ts +++ b/src/server/registerProcessComparisonTool.ts @@ -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; diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index b1dee6841..e11d2df02 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -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], diff --git a/src/server/sessionEvidence.ts b/src/server/sessionEvidence.ts index d0cef780c..19b81ad97 100644 --- a/src/server/sessionEvidence.ts +++ b/src/server/sessionEvidence.ts @@ -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); }; diff --git a/tests/artifactMcp.test.ts b/tests/artifactMcp.test.ts index eb83a1d7a..7e5c94af2 100644 --- a/tests/artifactMcp.test.ts +++ b/tests/artifactMcp.test.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -18,7 +17,7 @@ import { createServer } from "../src/server/createServer.js"; import { observed } from "./fixtures/analysisExecution.js"; describe("artifact graph MCP integration", () => { - it("returns ASAR integrity path and hashes as structured error data", async () => { + 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"; @@ -50,20 +49,9 @@ describe("artifact graph MCP integration", () => { expect(result.isError).toBe(true); expect(result.structuredContent).toEqual({ error: { - tag: "ArtifactOperationError", category: "integrity_mismatch", message: "Artifact is invalid or has changed. Get a fresh copy and try again.", - details: { - operation: "inventory_artifact", - reason: "integrity", - logicalPath: "main.js", - declaredSha256: createHash("sha256").update(original).digest("hex"), - calculatedSha256: createHash("sha256") - .update(changed) - .digest("hex"), - unpacked: true, - }, }, }); } finally { diff --git a/tests/cliOutput.test.ts b/tests/cliOutput.test.ts new file mode 100644 index 000000000..5ff34d1b0 --- /dev/null +++ b/tests/cliOutput.test.ts @@ -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', + }); + }); +}); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index ad6503cda..98444a1d6 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -14,6 +14,28 @@ const host = (overrides: Partial = {}): 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) => { diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts index e7f208c8b..c5cfe7f5a 100644 --- a/tests/enhancedTools.test.ts +++ b/tests/enhancedTools.test.ts @@ -311,11 +311,9 @@ describe("enhanced MCP tools", () => { address: "0x2", status: "error", error: { - tag: "AnalysisOutputError", category: "execution_failure", message: "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.", - details: { operation: "decompile", reason: "failed" }, }, }, ], diff --git a/tests/errors.test.ts b/tests/errors.test.ts index a9e14ce98..0077e5838 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -99,18 +99,20 @@ describe("analysis error projection", () => { const projected = errors.map(projectAnalysisError); expect(projected).toHaveLength(23); const serialized = JSON.stringify(projected); - for (const hidden of ["secret-token", "/secret/local/path"]) + for (const hidden of [ + "secret-token", + "/secret/local/path", + "fixture", + "overview", + "AnalysisCapabilityUnavailableError", + "main.js", + "declaredSha256", + ]) expect(serialized).not.toContain(hidden); expect( projectAnalysisError(byTag.AnalysisCapabilityUnavailableError), ).toMatchObject({ - tag: "AnalysisCapabilityUnavailableError", category: "unsupported_provider", - details: { - providerId: "fixture", - operation: "overview", - reason: "absent", - }, }); expect( projectAnalysisError( @@ -122,14 +124,7 @@ describe("analysis error projection", () => { }), ), ).toMatchObject({ - tag: "ArtifactOperationError", category: "integrity_mismatch", - details: { - logicalPath: "main.js", - declaredSha256: "a".repeat(64), - calculatedSha256: "b".repeat(64), - unpacked: true, - }, }); expect(projected.every(({ message }) => message.length > 0)).toBe(true); expect( diff --git a/tests/functionComparisonMcp.test.ts b/tests/functionComparisonMcp.test.ts index b75eaca1f..4eec8b1f7 100644 --- a/tests/functionComparisonMcp.test.ts +++ b/tests/functionComparisonMcp.test.ts @@ -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([ diff --git a/tests/installScript.test.ts b/tests/installScript.test.ts index 156f2cb44..b1b182ec9 100644 --- a/tests/installScript.test.ts +++ b/tests/installScript.test.ts @@ -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 => { 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, }, }); diff --git a/tests/integration/serverIntegration.test.ts b/tests/integration/serverIntegration.test.ts index 72b0039b0..3de17cddd 100644 --- a/tests/integration/serverIntegration.test.ts +++ b/tests/integration/serverIntegration.test.ts @@ -254,13 +254,7 @@ describe("full MCP integration with multi-tool sequences", () => { expect(unavailable.isError).toBe(true); expect(structured(unavailable)).toMatchObject({ error: { - tag: "AnalysisCapabilityUnavailableError", category: "unsupported_provider", - details: { - providerId: "partial", - operation: "procedure_pseudo_code", - reason: "Decompiler is not installed.", - }, }, }); expect(received[0]).not.toHaveProperty("unknown_registry_approved"); @@ -271,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.", + }, + ], }, ], }); @@ -378,13 +378,7 @@ describe("full MCP integration with multi-tool sequences", () => { expect(result.isError).toBe(true); expect(structured(result)).toMatchObject({ error: { - tag: "HopperRemoteError", category: "execution_failure", - details: { - code: -32000, - safeMessage: "bridge timeout", - diagnosticType: "remote", - }, }, }); expect(text(result)).toBe( diff --git a/tests/investigationMcp.test.ts b/tests/investigationMcp.test.ts index 5bda64296..0417eb79c 100644 --- a/tests/investigationMcp.test.ts +++ b/tests/investigationMcp.test.ts @@ -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); } diff --git a/tests/mainErrors.test.ts b/tests/mainErrors.test.ts new file mode 100644 index 000000000..4b24f61f4 --- /dev/null +++ b/tests/mainErrors.test.ts @@ -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[0]>; + +const nextTurn = (): Promise => + 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 => { + 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]); + }); +}); diff --git a/tests/processCli.test.ts b/tests/processCli.test.ts new file mode 100644 index 000000000..290f8b4e1 --- /dev/null +++ b/tests/processCli.test.ts @@ -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 => { + 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.", + }); + }); +}); diff --git a/tests/referenceSourceImport.test.ts b/tests/referenceSourceImport.test.ts index 087ad9713..b49fa78dc 100644 --- a/tests/referenceSourceImport.test.ts +++ b/tests/referenceSourceImport.test.ts @@ -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 { diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts index e123d6757..5780a0dc0 100644 --- a/tests/sessionMcp.test.ts +++ b/tests/sessionMcp.test.ts @@ -73,7 +73,6 @@ describe("target-free MCP lifecycle", () => { expect(deniedCapture.isError).toBe(true); expect(structured(deniedCapture)).toMatchObject({ error: { - tag: "ProcessCaptureError", category: "permission_required", }, }); @@ -229,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", }); }); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index f6c800367..eb0cb154c 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -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(); 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", + "Coding-agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.", + ], + [ + "write", + "Coding-agent configuration could not be updated. Check file permissions, then rerun setup.", + ], + [ + "readback", + "Coding-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.", + ); }); }); diff --git a/tests/setupLifecycle.test.ts b/tests/setupLifecycle.test.ts index e08381d66..65a8c625d 100644 --- a/tests/setupLifecycle.test.ts +++ b/tests/setupLifecycle.test.ts @@ -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 }; +}; diff --git a/tests/toolResult.test.ts b/tests/toolResult.test.ts index a9d098526..4fae4e921 100644 --- a/tests/toolResult.test.ts +++ b/tests/toolResult.test.ts @@ -31,14 +31,9 @@ describe("tool result projection", () => { ], structuredContent: { error: { - tag: "AnalysisOutputError", category: "execution_failure", message: "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.", - details: { - operation: "provider_neutral_fixture", - reason: "output does not match the tool contract", - }, }, }, isError: true, diff --git a/tests/upgrade.test.ts b/tests/upgrade.test.ts index 203f5ea9f..42de4190f 100644 --- a/tests/upgrade.test.ts +++ b/tests/upgrade.test.ts @@ -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); });