Compare commits

...
Author SHA1 Message Date
morlutoandGitHub cba26666a2 Merge pull request #86 from morluto/release-please--branches--main--components--rea-agents
chore(main): release rea-agents 0.5.0
2026-07-13 11:56:13 +08:00
github-actions[bot]andGitHub a5edb3a2bc chore(main): release rea-agents 0.5.0 2026-07-13 03:52:52 +00:00
morlutoandGitHub 883db912da Merge pull request #85 from morluto/codex/rea-low-mid-batch
feat(analysis): harden agent workflows and evidence boundaries
2026-07-13 11:52:36 +08:00
morluto 7c36f36318 fix(analysis): accept valid final dossier pages 2026-07-13 03:38:15 +00:00
morluto c630f410b6 fix(cli): preserve function provider provenance 2026-07-13 03:34:57 +00:00
morluto 9526389f81 fix(process): keep network approval fail-closed 2026-07-13 03:30:17 +00:00
morluto 329fdf6fe8 refactor(cli): share direct analysis tool types 2026-07-13 03:26:32 +00:00
morluto d806480e41 docs: correct pull request tool inventory 2026-07-13 03:25:02 +00:00
morluto a7b84a9b4b test(docs): keep localized claims and tool counts aligned 2026-07-13 03:16:34 +00:00
morluto efc9bd270f docs: document CLI safety and provider evaluation 2026-07-13 03:16:02 +00:00
morluto 0d36976277 test(verification): strengthen conformance and real-Hopper checks 2026-07-13 03:15:46 +00:00
morluto bce3398dbe fix(process): default capture networking to loopback 2026-07-13 03:15:27 +00:00
morluto b1912b18e1 feat(cli): align terminal workflows with MCP 2026-07-13 03:15:01 +00:00
morluto 898d6b0192 fix(lifecycle): validate owned Hopper process identity 2026-07-13 03:14:36 +00:00
morluto f8e8ea35ff fix(bridge): harden bounded Hopper boundaries 2026-07-13 03:14:15 +00:00
morlutoandGitHub 151469f093 Merge pull request #82 from morluto/release-please--branches--main--components--rea-agents
chore(main): release rea-agents 0.4.0
2026-07-13 07:57:59 +08:00
github-actions[bot]andGitHub 4485ef90bf chore(main): release rea-agents 0.4.0 2026-07-12 23:48:15 +00:00
morlutoandGitHub cc6a9557e1 Merge pull request #84 from morluto/ci/conventional-release-commits
feat: add cross-platform installation lifecycle
2026-07-13 07:47:56 +08:00
morlutoandGitHub a4d356b210 Merge branch 'main' into ci/conventional-release-commits 2026-07-13 07:46:00 +08:00
morluto 034563a4c0 docs: document installation and Linux support 2026-07-12 23:43:24 +00:00
morluto 4f97f699e4 ci: verify installed package with Linux Hopper 2026-07-12 23:43:24 +00:00
morluto e651cb65db feat: add cross-platform installation lifecycle 2026-07-12 23:43:24 +00:00
morlutoandGitHub e1dfe8613e Merge pull request #83 from morluto/ci/conventional-release-commits
docs: simplify REA onboarding
2026-07-13 06:37:10 +08:00
morlutoandGitHub af9794158d Merge branch 'main' into ci/conventional-release-commits 2026-07-13 06:34:32 +08:00
morluto f56bdde276 test: stabilize artifact pagination coverage 2026-07-12 22:32:14 +00:00
morluto d85b661be8 docs: simplify REA onboarding 2026-07-12 22:28:10 +00:00
morlutoandGitHub 07a0b984f2 Merge pull request #81 from morluto/ci/conventional-release-commits
ci: enforce conventional pull request titles
2026-07-13 06:15:01 +08:00
morluto 993356b85a test: add package installation end-to-end coverage 2026-07-12 22:09:34 +00:00
morluto 3a21f16c18 ci: enforce conventional pull request titles
Release-As: 0.4.0
2026-07-12 21:53:20 +00:00
morlutoandGitHub 4045d92623 Merge pull request #79 from morluto/morl/roadmap-investigation-core
Add evidence-backed investigation workflows
2026-07-13 05:49:52 +08:00
66 changed files with 3184 additions and 301 deletions
+2 -1
View File
@@ -20,12 +20,13 @@
- [ ] Linting passes (`npm run lint`)
- [ ] Full test suite passes (`npm test`)
- [ ] Real Hopper verification performed (`npm run verify:hopper`)
- [ ] Linux real Hopper verification performed (`npm run verify:hopper:linux`) or unavailability explained
- [ ] Package verification performed (`npm run verify:package`)
## Tool Contract Impact
<!-- If this PR adds, removes, or modifies MCP tools, list them here. -->
<!-- The current inventory is 42 tools (31 direct + 8 enhanced + 3 session). -->
<!-- The current inventory is 68 tools (33 direct + 10 enhanced + 5 native + 2 artifact + 18 session). -->
## Related Issues
+33
View File
@@ -7,6 +7,22 @@ on:
branches: [main]
jobs:
pr-title:
name: Conventional PR title
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Validate title for Release Please
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: >-
node -e 'const title = process.env.PR_TITLE ?? "";
const conventional = /^(?:feat|fix|docs|refactor|perf|test|build|ci|chore|revert)(?:\([a-z0-9._/-]+\))?!?: .+/u;
if (!conventional.test(title)) {
console.error("PR title must use Conventional Commits, for example: feat: add historical source import");
process.exit(1);
}'
typecheck:
name: Typecheck
runs-on: ubuntu-latest
@@ -98,6 +114,23 @@ jobs:
path: test-results.xml
retention-days: 30
package-e2e:
name: Package E2E (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-14]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run rebuild:native
- run: npm run verify:package
docs:
name: Docs Generation
runs-on: ubuntu-latest
+43
View File
@@ -0,0 +1,43 @@
name: Real Hopper Linux E2E
on:
workflow_dispatch:
inputs:
first_target:
description: Absolute path to first Linux test binary
required: true
type: string
second_target:
description: Absolute path to a distinct Linux test binary
required: true
type: string
permissions:
contents: read
jobs:
verify:
name: Installed package against real Linux Hopper
runs-on: [self-hosted, Linux, hopper]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run rebuild:native
- name: Install packed CLI in isolation
shell: bash
run: |
tarball="$(npm pack --silent)"
prefix="${RUNNER_TEMP}/rea-prefix"
npm install --global --prefix "${prefix}" "./${tarball}"
echo "REA_VERIFY_SERVER_COMMAND=${prefix}/bin/rea" >> "${GITHUB_ENV}"
echo 'REA_VERIFY_SERVER_ARGS_JSON=["mcp"]' >> "${GITHUB_ENV}"
- name: Verify two binaries through the in-process bridge
env:
HOPPER_TARGET_PATH: ${{ inputs.first_target }}
HOPPER_SECOND_TARGET_PATH: ${{ inputs.second_target }}
run: npm run verify:hopper:linux
+43
View File
@@ -0,0 +1,43 @@
name: Real Hopper E2E
on:
workflow_dispatch:
inputs:
first_target:
description: Absolute path to first test binary on the runner
required: true
type: string
second_target:
description: Absolute path to distinct second test binary on the runner
required: true
type: string
permissions:
contents: read
jobs:
verify:
name: Installed package against real Hopper
runs-on: [self-hosted, macOS, hopper]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run rebuild:native
- name: Install packed CLI in isolation
shell: bash
run: |
tarball="$(npm pack --silent)"
prefix="${RUNNER_TEMP}/rea-prefix"
npm install --global --prefix "${prefix}" "./${tarball}"
echo "REA_VERIFY_SERVER_COMMAND=${prefix}/bin/rea" >> "${GITHUB_ENV}"
echo 'REA_VERIFY_SERVER_ARGS_JSON=["mcp"]' >> "${GITHUB_ENV}"
- name: Verify two real binaries
env:
HOPPER_TARGET_PATH: ${{ inputs.first_target }}
HOPPER_SECOND_TARGET_PATH: ${{ inputs.second_target }}
run: npm run verify:hopper
+16
View File
@@ -43,3 +43,19 @@ jobs:
- name: Publish
run: npm publish --access public
- name: Verify published CLI from npm
shell: bash
run: |
version="$(node --input-type=module -e 'import packageJson from "./package.json" with { type: "json" }; process.stdout.write(packageJson.version)')"
for attempt in 1 2 3 4 5 6; do
if npm view "rea-agents@${version}" version --json >/dev/null 2>&1; then
npx --yes --package "rea-agents@${version}" rea --help
npx --yes --package "rea-agents@${version}" rea --llms
npm run verify:published -- "${version}"
exit 0
fi
if [ "${attempt}" -lt 6 ]; then sleep 10; fi
done
echo "Published rea-agents@${version} was not available from npm" >&2
exit 1
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.3.0"
".": "0.5.0"
}
+4 -4
View File
@@ -11,7 +11,7 @@ See [docs/architecture.mermaid](docs/architecture.mermaid) for a visual architec
- `src/cli.ts`: one-shot CLI adapter for setup, diagnostics, analysis, and decompilation.
- `src/config.ts`: Zod-validated parsing of environment configuration into `AppConfig`.
- `src/domain/`: pure, side-effect-free modules. `errors.ts` (tagged error algebra), `result.ts` (`Result`/`ok`/`err`), `hopperValues.ts` (boundary parsers for Hopper JSON), `symbolAnalysis.ts` (Swift/ObjC name parsing).
- `src/contracts/`: caller-visible schemas for 31 direct, 8 enhanced, and 3 session tools; `enhancedInputs.ts` owns enhanced input parsing.
- `src/contracts/`: caller-visible schemas for 33 direct, 10 enhanced, 5 native, 2 artifact, and 18 session tools; `enhancedInputs.ts` owns enhanced input parsing.
- `src/hopper/`: Hopper launch and Unix-socket protocol mechanics. `BridgeLauncher.ts` spawns the Hopper app with the in-process bridge, `HopperClient.ts` correlates request/response over the socket with timeouts and cancellation, `protocol.ts` frames bridge messages.
- `bridge/hopper_bridge.py`: runs inside Hopper and adapts declared operations to Hopper's public Python API. Hopper's bundled MCP server is not used.
- `src/application/`: shared CLI/MCP session composition, setup and diagnostics, and the 8 enhanced workflows.
@@ -34,7 +34,7 @@ See [docs/architecture.mermaid](docs/architecture.mermaid) for a visual architec
- `npm run jscpd`: detect duplicate code blocks.
- `npm run scan:todos`: scan for TODO, FIXME, and HACK markers.
- `npm run verify:hopper`: build and run the real-Hopper verifier with two distinct binaries.
- `npm run verify:package`: pack and test the CLI, setup transaction, skill, and 42-tool target-free MCP server in an isolated environment.
- `npm run verify:package`: pack and test the CLI, setup transaction, skill, and 68-tool target-free MCP server in an isolated environment.
- `npm run docs:generate`: generate API documentation from JSDoc comments into `docs/api/` using TypeDoc.
- `npm run config:print -- /path/to/binary`: print an MCP server config with absolute paths.
- `HOPPER_TARGET_PATH=/path/to/binary npm start`: launch Hopper and run the built stdio MCP server.
@@ -54,8 +54,8 @@ Use ESM TypeScript, two-space indentation, and Prettier defaults. Keep compiler
## Testing Guidelines
Name tests `*.test.ts`. Use Vitest and production seams (`tests/fixtures/`) rather than module mocks. Domain tests assert pure behavior; adapter tests use the fake launcher/socket; MCP tests connect with the beta.3 client. Preserve the 31 direct, 8 enhanced, and 3 session tool inventory (42 total). Cover malformed input, cancellation, timeouts, process exit, concurrency, limits, and clean shutdown. Real Hopper claims cannot be replaced by mocks — use `npm run verify:hopper` with two distinct binaries for end-to-end verification.
Name tests `*.test.ts`. Use Vitest and production seams (`tests/fixtures/`) rather than module mocks. Domain tests assert pure behavior; adapter tests use the fake launcher/socket; MCP tests connect with the beta.3 client. Preserve the 33 direct, 10 enhanced, 5 native, 2 artifact, and 18 session tool inventory (68 total). Cover malformed input, cancellation, timeouts, process exit, concurrency, limits, and clean shutdown. Real Hopper claims cannot be replaced by mocks — use `npm run verify:hopper` with two distinct binaries for end-to-end verification.
## Commit & Pull Request Guidelines
Use concise imperative subjects, optionally scoped, such as `Add Hopper timeout classification`. Pull requests should describe contract or behavior changes, list verification commands, link issues, and include sanitized MCP examples when schemas change. State whether real Hopper verification was performed. Never commit binaries, Hopper documents, credentials, `dist/`, `node_modules/`, or local planning artifacts (e.g. `.codex/`).
Use Conventional Commit subjects because Release Please derives versions and changelogs from them. Examples: `feat: add historical source import`, `fix(process): stop timers after exit`, and `docs: update architecture`. Use `!` or a `BREAKING CHANGE:` footer for breaking changes. Pull request titles must follow the same format because squash merges use the title as the release commit. Pull requests should describe contract or behavior changes, list verification commands, link issues, and include sanitized MCP examples when schemas change. State whether real Hopper verification was performed. Never commit binaries, Hopper documents, credentials, `dist/`, `node_modules/`, or local planning artifacts (e.g. `.codex/`).
+63
View File
@@ -1,5 +1,68 @@
# Changelog
## [0.5.0](https://github.com/morluto/rea/compare/rea-agents-0.4.0...rea-agents-0.5.0) (2026-07-13)
### Features
* **analysis:** harden agent workflows and evidence boundaries ([883db91](https://github.com/morluto/rea/commit/883db912da6e38e1078286997cc9048471e03896))
* **cli:** align terminal workflows with MCP ([b1912b1](https://github.com/morluto/rea/commit/b1912b18e1723c594a030b16acf01341f083455b))
### Bug Fixes
* **analysis:** accept valid final dossier pages ([7c36f36](https://github.com/morluto/rea/commit/7c36f3631862e67a0751967c84abc4f423f5475c))
* **bridge:** harden bounded Hopper boundaries ([f8e8ea3](https://github.com/morluto/rea/commit/f8e8ea35ff08ce5071acbccab188df808708b71c))
* **cli:** preserve function provider provenance ([c630f41](https://github.com/morluto/rea/commit/c630f410b6a81864bf90211a088c715bfe234722))
* **lifecycle:** validate owned Hopper process identity ([898d6b0](https://github.com/morluto/rea/commit/898d6b0192126030cedd7e5c2cb76f40568cda05))
* **process:** default capture networking to loopback ([bce3398](https://github.com/morluto/rea/commit/bce3398dbe3a3b20952f37573a656aa77b168977))
* **process:** keep network approval fail-closed ([9526389](https://github.com/morluto/rea/commit/9526389f815f0937fc01198ff3a8a850c6c1c81a))
### Code Refactoring
* **cli:** share direct analysis tool types ([329fdf6](https://github.com/morluto/rea/commit/329fdf6fe8f3a2bf9679b13a8ce5cb16cf946129))
### Documentation
* correct pull request tool inventory ([d806480](https://github.com/morluto/rea/commit/d806480e41bcb623448a258ddffeb75082fe1e1f))
* document CLI safety and provider evaluation ([efc9bd2](https://github.com/morluto/rea/commit/efc9bd270f58a5aaba15edb07cee135d2c54db83))
### Tests
* **docs:** keep localized claims and tool counts aligned ([a7b84a9](https://github.com/morluto/rea/commit/a7b84a9b4b7ea4336c0f327e13b2948a8d94fba9))
* **verification:** strengthen conformance and real-Hopper checks ([0d36976](https://github.com/morluto/rea/commit/0d369762770d2d92dd72c7dcb2f2f90a64b206ff))
## [0.4.0](https://github.com/morluto/rea/compare/rea-agents-0.3.0...rea-agents-0.4.0) (2026-07-12)
### Features
* add cross-platform installation lifecycle ([cc6a955](https://github.com/morluto/rea/commit/cc6a9557e1fdd8c057902e9c0fcfbba2561cbcd7))
* add cross-platform installation lifecycle ([e651cb6](https://github.com/morluto/rea/commit/e651cb65db87b6d9df9fb13b66a6e5e76f0965e0))
### Documentation
* document installation and Linux support ([034563a](https://github.com/morluto/rea/commit/034563a4c015c69e878054d4fd06e0e9fa630e24))
* simplify REA onboarding ([e1dfe86](https://github.com/morluto/rea/commit/e1dfe8613e5a986d5ee64764851f0abcc901398b))
* simplify REA onboarding ([d85b661](https://github.com/morluto/rea/commit/d85b661be80bd19d477c46ebe0501839c7b63ee7))
### Tests
* add package installation end-to-end coverage ([993356b](https://github.com/morluto/rea/commit/993356b85adc72036ba44cd87b9335e2ec172a32))
* stabilize artifact pagination coverage ([f56bdde](https://github.com/morluto/rea/commit/f56bdde27679e1f05b34d2cf5efbc6e49bd4c2ca))
### Continuous Integration
* enforce conventional pull request titles ([07a0b98](https://github.com/morluto/rea/commit/07a0b984f2bdbc4fe1ce18d5db13500aa8f3992f))
* enforce conventional pull request titles ([3a21f16](https://github.com/morluto/rea/commit/3a21f16c1832430765083636511a8c12dbccf2dd))
* verify installed package with Linux Hopper ([4f97f69](https://github.com/morluto/rea/commit/4f97f699e4d5471557d1ce0ff91e144065d06a61))
## [0.3.0](https://github.com/morluto/rea/compare/rea-agents-0.2.1...rea-agents-0.3.0) (2026-07-12)
+14 -4
View File
@@ -4,7 +4,7 @@ REA welcomes focused bug fixes, documentation improvements, tests, and reverse-e
## Development setup
REA requires macOS 12 or newer, Node.js 24.18.x, npm 11.16.x, and a separately installed Hopper Disassembler application for real-Hopper verification. Run `nvm use` before installing dependencies.
REA development requires Node.js 24.18.x and npm 11.16.x. Real-Hopper verification additionally requires either macOS 12+ or an officially supported Linux host (Ubuntu 24.04+, Fedora 41+, or 64-bit Arch), an installed and activated Hopper application, and a desktop session. Run `nvm use` before installing dependencies.
```bash
npm ci
@@ -13,7 +13,7 @@ npm run build
npm test
```
Keep dependencies flowing inward through the existing domain, Hopper, application, server, and adapter layers. Parse unknown values at process and protocol boundaries, model expected failures with `Result`, and preserve the 31 direct, 8 enhanced, and 3 session tools unless a deliberate contract change updates every verifier and snapshot.
Keep dependencies flowing inward through the existing domain, contracts, Hopper, application, server, and adapter layers. Parse unknown values at process and protocol boundaries, model expected failures with `Result`, and preserve the 33 direct, 10 enhanced, 5 native, 2 artifact, and 18 session tools (68 total) unless a deliberate contract change updates every verifier and snapshot.
Before submitting a pull request, run:
@@ -43,6 +43,16 @@ HOPPER_SECOND_TARGET_PATH=/path/to/distinct-target-b \
npm run verify:hopper
```
On a self-hosted Linux runner with `DISPLAY` or `WAYLAND_DISPLAY`, use:
```bash
HOPPER_TARGET_PATH=/path/to/target-a \
HOPPER_SECOND_TARGET_PATH=/path/to/distinct-target-b \
npm run verify:hopper:linux
```
The macOS and Linux real-Hopper workflows remain separate so a successful mock or package test cannot be reported as platform-runtime proof. Pull requests changing setup, launch, bridge, or Hopper behavior must state which real workflows ran and why either workflow was unavailable.
Describe the behavior change and verification performed in the pull request. Never commit binaries, Hopper documents, credentials, `dist/`, `node_modules/`, or local planning artifacts.
## Maintainer release checklist
@@ -54,13 +64,13 @@ npm pack
npm exec --yes --package ./morluto-rea-0.1.0.tgz -- rea --help
```
Publish the public scoped package:
Publish the public package:
```bash
npm publish --access public
```
After npm registry propagation, verify the published CLI and connect a beta.3 MCP client to the published server to confirm all 42 tools:
After npm registry propagation, verify the published CLI and connect a beta.3 MCP client to the published server to confirm all 68 tools:
```bash
npx -y rea-agents --help
+99 -24
View File
@@ -18,7 +18,7 @@
<br />
<code>npx skills add morluto/rea</code>
<code>curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash</code>
</div>
@@ -41,8 +41,8 @@ npx skills add morluto/rea
Then ask:
```text
Set up REA and reverse engineer the Notes app. Explain how search works,
show me how you know, and build a similar feature for my project.
Use REA to understand how search works in the Notes app, show me the
evidence, and build a similar feature for my project.
```
Notes is only an example. Name any app you want to understand, or ask the agent to start with an overview.
@@ -81,6 +81,16 @@ REA shows how it reached its conclusions. It does not claim to recover original
## Quick start
### One-command install — recommended
```bash
curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
```
The installer supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux. It installs compatible Node.js 24/npm 11 prerequisites, the exact latest `rea-agents-*` release, the official Hopper package, detected client registrations, and the bundled skill, then verifies the result. macOS uses Homebrew; Linux uses the native package manager and may show a system authorization prompt. Pin a reproducible release with `REA_VERSION=0.3.0`; `REA_VERSION` is the only supported installer environment override.
REA detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin. It configures detected clients with documented local MCP files; Devin is reported as skipped because its public documentation does not define a local configuration file. You can safely rerun the installer or `rea setup --yes`. If REA's local analysis engine needs one-time activation, installation remains intact and setup reports the exact remaining action rather than claiming full readiness.
### With a coding agent — recommended
```bash
@@ -89,30 +99,36 @@ npx skills add morluto/rea
Ask your agent to set up REA. It will check your Mac, explain anything it needs to install, ask for approval, and guide you through system prompts. After setup, restart the agent if it asks you to load the full REA toolset.
### From Terminal
Approve installation if needed, complete the one-time activation when prompted, then describe the app or feature you want to understand. REA handles the binary-analysis tools behind the scenes.
### From Terminal — no installation
```bash
npx -y rea-agents setup --yes
npx -y rea-agents doctor
npx -y rea-agents analyze /Applications/Notes.app
```
If macOS or an installer asks for confirmation, complete the prompt and run the same command again. Restart a configured coding agent so it loads REA.
### What setup handles
### From Terminal — install the `rea` command
```bash
npm install --global rea-agents
rea setup --yes
rea doctor
rea analyze /Applications/Notes.app
```
Choose either the no-install commands or the global installation. You do not need both.
### Requirements
- macOS 12 or newer
- Node.js 24.18.x with npm 11.16.x (`nvm use` selects the pinned version)
- Ubuntu 24.04+, Fedora 41+, or 64-bit Arch Linux
- Node.js 24.18.x with npm 11.16.x
If process capture reports that its native PTY backend is unavailable, install Xcode command-line tools and run `npm run rebuild:native`. Linux source builds require Python, `make`, and a C++ toolchain. Compatible packaged binaries do not require this rebuild.
Process capture is disabled by default. Enabling it requires
`REA_PROCESS_CAPTURE_ENABLED=true`, approved executable and working roots in
`REA_PROCESS_EXECUTABLE_ROOTS_JSON` and `REA_PROCESS_WORKING_ROOTS_JSON`, and an
environment allowlist in `REA_PROCESS_ALLOWED_ENV_JSON`. The current PTY adapter
uses host networking, so it additionally requires the explicit operator grant
`REA_PROCESS_ALLOW_EXTERNAL_NETWORK=true`. Without that grant the adapter
refuses to launch; loopback replay alone does not constitute network isolation.
You do not need to install the reverse-engineering tools manually. Setup installs Homebrew and [Hopper](https://www.hopperapp.com/) when needed, configures detected Claude Desktop and Cursor installations, and installs the REA skill. Hopper is separate software and requires its own license; setup installs it but does not provide a license.
You do not need to choose or install binary-analysis tools yourself. REA setup handles them when needed. Deep binary analysis currently uses [Hopper](https://www.hopperapp.com/), a separate desktop application with its own license. REA can install the official macOS or Linux package, but you must approve installation and complete its one-time activation.
If something is not working, run:
@@ -120,14 +136,46 @@ If something is not working, run:
npx -y rea-agents doctor
```
`rea doctor --json` is read-only and distinguishes unsupported hosts, missing dependencies, a missing local analysis engine, configuration drift, and healthy checks. Fresh-install activation is reported by setup because Hopper does not expose a reliable noninteractive activation probe.
### Linux installation and troubleshooting
On Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux, setup downloads the matching official Hopper package, restricts downloads to Hopper's public origin, verifies the published size and checksum, and invokes `apt-get`, `dnf`, or `pacman`. When REA is not already running as root, `pkexec` presents the system authorization prompt. REA never invokes `sudo`.
The normal Linux launcher is `/opt/hopper/bin/Hopper`. If Hopper was installed elsewhere:
```bash
export HOPPER_LAUNCHER_PATH=/absolute/path/to/Hopper
rea doctor --json
```
If doctor reports a missing analysis engine even though the file exists, inspect shared-library resolution with:
```bash
ldd /opt/hopper/bin/Hopper | grep 'not found'
```
Install the missing distribution packages and rerun `rea setup --yes`. Hopper is a desktop application: real analysis requires an active `DISPLAY` or `WAYLAND_DISPLAY`, plus one-time license activation. The curl installer places the `rea` command in `~/.local/bin` on Linux; add that directory to future shell `PATH` values if it is not already present.
REA defaults `HOPPER_LAUNCHER_PATH` to `/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` on macOS and `/opt/hopper/bin/Hopper` on Linux. Explicit configuration always takes precedence.
To remove only REA-owned MCP registrations and the managed skill:
```bash
rea uninstall
rea uninstall --purge-data # also removes only ~/.rea/cache and ~/.rea/state
```
Uninstall preserves Hopper, Homebrew, Node.js, evidence, captures, external evidence roots, unrelated skills, and other MCP servers. It refuses malformed client configuration and never follows purge-data symlinks.
### CLI or coding agent?
| If you want to… | Use |
| --------------------------------------------------------- | ---------------------------------------------- |
| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
| Validate or canonicalize an Evidence v2 bundle | `rea evidence-import` or `rea evidence-export` |
| Import source as historical reference | `rea import-reference-source` |
| If you want to… | Use |
| --------------------------------------------------------- | -------------------------------------------------------------- |
| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
| Validate, canonicalize, or compare Evidence v2 bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
| Import source as historical reference | `rea import-reference-source` |
Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots:
@@ -135,6 +183,7 @@ Filesystem evidence commands and MCP file tools are disabled until the operator
export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]'
rea evidence-import /absolute/path/to/evidence/bundle.json
rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json
rea compare /absolute/path/to/evidence/left.json /absolute/path/to/evidence/right.json
```
Historical source import requires a separate allowlist and never treats source as current behavioral authority:
@@ -228,6 +277,8 @@ REA is growing into a toolkit for understanding software across static artifacts
Roadmap items describe direction, not shipped support. New providers must produce the same evidence and safety metadata as existing capabilities before they become part of the public workflow.
See the [static-analysis provider evaluation](docs/provider-evaluation.md) for the current research matrix and admission gate.
## Using REA with other coding agents
Setup currently configures Claude Desktop and Cursor automatically. Any coding agent that supports local MCP servers can use REA with the configuration below.
@@ -272,9 +323,20 @@ The agent workflow above is the easiest way to use REA. For a one-off overview f
```bash
npx -y rea-agents analyze /Applications/Notes.app
npx -y rea-agents inspect /Applications/Notes.app
npx -y rea-agents inspect /Applications/Notes.app --detail detailed --limit 20
npx -y rea-agents search /Applications/Notes.app "offline"
npx -y rea-agents function /Applications/Notes.app 0x1000
npx -y rea-agents xrefs /Applications/Notes.app 0x1000
npx -y rea-agents trace /Applications/Notes.app "offline"
npx -y rea-agents compare /absolute/path/to/left-evidence.json /absolute/path/to/right-evidence.json
npx -y rea-agents capabilities
npx -y rea-agents providers
```
Run `npx -y rea-agents --help` for direct decompilation and other options.
Run `npx -y rea-agents --help` for direct decompilation, bounded search and
other options. `analyze` and `inspect` share the same overview workflow;
`function`, `xrefs`, and `trace` return the same Evidence v2 envelopes as MCP.
Or install the `rea` command globally:
@@ -294,6 +356,19 @@ REA derives explicit format and architecture arguments to prevent common FAT and
Closing a REA session shuts down its bridge and removes its private socket directory. It does not quit a Hopper application the user may be using.
## Advanced process-capture setup
Process capture is disabled by default. Enabling it requires
`REA_PROCESS_CAPTURE_ENABLED=true`, approved executable and working roots in
`REA_PROCESS_EXECUTABLE_ROOTS_JSON` and `REA_PROCESS_WORKING_ROOTS_JSON`, and an
environment allowlist in `REA_PROCESS_ALLOWED_ENV_JSON`. Because the current PTY
adapter uses host networking, it also requires
`REA_PROCESS_ALLOW_EXTERNAL_NETWORK=true`.
If the native PTY backend is unavailable, install Xcode command-line tools and
run `npm run rebuild:native`. Linux source builds require Python, `make`, and a
C++ toolchain. Compatible packaged binaries do not require this rebuild.
## Security model
REA does not provide a hosted analysis service. Hopper communication uses an authenticated private local socket. Dynamic capabilities are disabled by default and require both operator policy and explicit per-call approval. REA is not a security sandbox: providers and launched targets run with the current user's permissions, and each capability reports its side effects and limitations. Report vulnerabilities through the private process in [SECURITY.md](SECURITY.md).
+13 -2
View File
@@ -18,7 +18,7 @@
<br />
<code>npx skills add morluto/rea</code>
<code>curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash</code>
</div>
@@ -73,13 +73,21 @@ npx skills add morluto/rea
### قبل البدء
- macOS 12 أو أحدث
- Ubuntu 24.04+ أو Fedora 41+ أو Arch Linux ‏64 بت
- Node.js 24.18.x وnpm 11.16.x (شغّل `nvm use` لاختيار الإصدار المثبّت)
لا تحتاج إلى تثبيت أدوات الهندسة العكسية يدويًا. يثبت Setup برنامج Homebrew و[Hopper](https://www.hopperapp.com/) عند الحاجة، ثم يهيئ وكلاء البرمجة المدعومين. Hopper برنامج منفصل يحتاج إلى ترخيص خاص به؛ يثبته Setup لكنه لا يوفر الترخيص.
#### التثبيت على Linux واستكشاف الأخطاء
على Ubuntu 24.04+ وFedora 41+ وArch Linux ‏64 بت، ينزّل REA حزمة DEB أو RPM أو Arch الرسمية، ويتحقق من الحجم وقيمة التحقق المنشورين، ثم يستخدم `apt-get` أو `dnf` أو `pacman` لحل الاعتماديات. عند التشغيل دون root يعرض `pkexec` طلب تفويض النظام. لا يستدعي REA الأمر `sudo`.
مسار المشغّل الافتراضي هو `/opt/hopper/bin/Hopper`. استخدم `HOPPER_LAUNCHER_PATH` للمسارات الأخرى. إذا أبلغ Doctor عن غياب محرك التحليل، شغّل `ldd /opt/hopper/bin/Hopper | grep 'not found'`، وثبّت المكتبات الناقصة ثم أعد `rea setup --yes`. يتطلب التحليل الحقيقي `DISPLAY` أو `WAYLAND_DISPLAY` وترخيص Hopper مفعّلًا. أضف `~/.local/bin` إلى `PATH` أيضًا.
### 1. إعداد REA
```bash
curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
npx -y rea-agents setup --yes
```
@@ -102,6 +110,8 @@ npx -y rea-agents setup --yes
```bash
npx -y rea-agents doctor
rea uninstall
rea uninstall --purge-data
```
## استقصاء كامل بطلب واحد
@@ -189,6 +199,7 @@ flowchart LR
```bash
npx -y rea-agents analyze /Applications/Notes.app
npx -y rea-agents compare /absolute/path/to/left-evidence.json /absolute/path/to/right-evidence.json
```
شغّل `npx -y rea-agents --help` لمعرفة خيارات فك الترجمة المباشر والخيارات الأخرى.
@@ -233,7 +244,7 @@ export HOPPER_LOADER_ARGS_JSON='["-l", "Mach-O", "--aarch68"]'
<details>
<summary><strong>هل يعمل REA على Linux أو Windows؟</strong></summary>
ليس حاليًا. يعتمد المحول الحالي على تطبيق Hopper لنظام macOS وواجهة Python الخاصة به.
نعم، يعمل REA على macOS 12+ وUbuntu 24.04+ وFedora 41+ وArch Linux ‏64 بت؛ Windows غير مدعوم حاليًا. يعتمد التحليل العميق على تطبيق Hopper وواجهة Python الخاصة به.
</details>
+11 -1
View File
@@ -18,7 +18,7 @@
<br />
<code>npx skills add morluto/rea</code>
<code>curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash</code>
</div>
@@ -75,12 +75,20 @@ npx skills add morluto/rea
### 始める前に
- macOS 12 以降
- Ubuntu 24.04+、Fedora 41+、または 64 ビット Arch Linux
- Node.js 24.18.x と npm 11.16.x(`nvm use` で固定バージョンを選択)
リバースエンジニアリングツールを手動でインストールする必要はありません。Setup は必要に応じて Homebrew と [Hopper](https://www.hopperapp.com/) をインストールし、対応するコーディングエージェントを設定します。Hopper は別製品で、ライセンスは別途必要です。Setup はインストールしますが、ライセンスは提供しません。
#### Linux のインストールとトラブルシューティング
Ubuntu 24.04+、Fedora 41+、64 ビット Arch Linux では、REA が公式の DEB、RPM、または Arch パッケージを取得し、公開されたサイズとチェックサムを検証してから `apt-get`、`dnf`、または `pacman` で依存関係を解決します。root 以外では `pkexec` がシステム承認を表示します。REA は `sudo` を呼び出しません。
既定のランチャーは `/opt/hopper/bin/Hopper` です。別の場所では `HOPPER_LAUNCHER_PATH` を設定してください。Doctor が解析エンジン不足を報告した場合は `ldd /opt/hopper/bin/Hopper | grep 'not found'` を実行し、不足ライブラリを入れて `rea setup --yes` を再実行します。実解析には `DISPLAY` または `WAYLAND_DISPLAY` と有効化済み Hopper ライセンスが必要です。`~/.local/bin` も `PATH` に追加してください。
```bash
# 1. REA をセットアップ
curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
npx -y rea-agents setup --yes
```
@@ -103,6 +111,8 @@ REA でメモアプリをリバースエンジニアリングしてください
```bash
npx -y rea-agents doctor
rea uninstall
rea uninstall --purge-data
```
## ひとつのプロンプトで調査を完結
+11 -1
View File
@@ -18,7 +18,7 @@
<br />
<code>npx skills add morluto/rea</code>
<code>curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash</code>
</div>
@@ -75,12 +75,20 @@ npx skills add morluto/rea
### 시작하기 전에
- macOS 12 이상
- Ubuntu 24.04+, Fedora 41+ 또는 64비트 Arch Linux
- Node.js 24.18.x 및 npm 11.16.x (`nvm use`로 고정 버전 선택)
리버스 엔지니어링 도구를 직접 설치할 필요는 없습니다. Setup은 필요할 때 Homebrew와 [Hopper](https://www.hopperapp.com/)를 설치하고 지원되는 코딩 에이전트를 구성합니다. Hopper는 별도 소프트웨어이며 별도 라이선스가 필요합니다. Setup은 Hopper를 설치하지만 라이선스를 제공하지 않습니다.
#### Linux 설치 및 문제 해결
Ubuntu 24.04+, Fedora 41+, 64비트 Arch Linux에서 REA는 공식 DEB, RPM 또는 Arch 패키지를 내려받아 게시된 크기와 체크섬을 검증한 뒤 `apt-get`, `dnf` 또는 `pacman`으로 의존성을 설치합니다. root가 아니면 `pkexec`가 시스템 승인 창을 표시합니다. REA는 `sudo`를 호출하지 않습니다.
기본 실행 파일은 `/opt/hopper/bin/Hopper`입니다. 다른 위치에 설치했다면 `HOPPER_LAUNCHER_PATH`를 설정하세요. Doctor가 분석 엔진 누락을 보고하면 `ldd /opt/hopper/bin/Hopper | grep 'not found'`를 실행하고 누락된 라이브러리를 설치한 다음 `rea setup --yes`를 다시 실행하세요. 실제 분석에는 `DISPLAY` 또는 `WAYLAND_DISPLAY`와 활성화된 Hopper 라이선스가 필요합니다. `~/.local/bin`도 `PATH`에 추가해야 합니다.
```bash
# 1. REA 설정
curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
npx -y rea-agents setup --yes
```
@@ -103,6 +111,8 @@ SQLite를 사용해 제 프로젝트에 비슷한 기능을 구현해 주세요.
```bash
npx -y rea-agents doctor
rea uninstall
rea uninstall --purge-data
```
## 하나의 프롬프트로 전체 조사
+11 -1
View File
@@ -18,7 +18,7 @@
<br />
<code>npx skills add morluto/rea</code>
<code>curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash</code>
</div>
@@ -75,12 +75,20 @@ npx skills add morluto/rea
### 开始之前
- macOS 12 或更高版本
- Ubuntu 24.04+、Fedora 41+ 或 64 位 Arch Linux
- Node.js 24.18.x 和 npm 11.16.x(运行 `nvm use` 选择固定版本)
你不需要手动安装逆向工程工具。Setup 会在需要时安装 Homebrew 和 [Hopper](https://www.hopperapp.com/),然后配置支持的编程智能体。Hopper 是独立软件,需要单独授权;Setup 可以安装它,但不提供许可证。
#### Linux 安装与故障排除
在 Ubuntu 24.04+、Fedora 41+ 和 64 位 Arch Linux 上,REA 会下载 Hopper 官方的 DEB、RPM 或 Arch 软件包,验证发布方提供的大小和校验和,然后通过 `apt-get`、`dnf` 或 `pacman` 安装依赖。非 root 运行时,`pkexec` 会显示系统授权提示;REA 不调用 `sudo`。
默认启动器是 `/opt/hopper/bin/Hopper`。自定义安装可设置 `HOPPER_LAUNCHER_PATH`。如果 Doctor 报告缺少分析引擎,请运行 `ldd /opt/hopper/bin/Hopper | grep 'not found'`,安装缺少的系统库后重新运行 `rea setup --yes`。真实分析需要 `DISPLAY` 或 `WAYLAND_DISPLAY` 以及已激活的 Hopper 许可证。Linux 上还应确保 `~/.local/bin` 位于 `PATH` 中。
```bash
# 1. 安装和配置 REA
curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
npx -y rea-agents setup --yes
```
@@ -103,6 +111,8 @@ Setup 会自动配置检测到的 Claude Desktop 和 Cursor。重启应用,让
```bash
npx -y rea-agents doctor
rea uninstall
rea uninstall --purge-data
```
## 一个提示词,完成一次完整调查
+4 -2
View File
@@ -10,10 +10,12 @@ Do not disclose vulnerabilities, proof-of-concept exploits, credentials, private
Use **Report a vulnerability** in the repository's **Security** tab to submit the report privately. Do not open a public issue to request a private contact channel.
Include the affected REA version, macOS and Hopper versions, impact, reproduction conditions, and any suggested mitigation in the private report. Remove credentials, proprietary binaries, Hopper documents, socket capability tokens, and unrelated local paths from logs or examples.
Include the affected REA version, operating system/distribution, Hopper version, impact, reproduction conditions, and any suggested mitigation in the private report. Remove credentials, proprietary binaries, Hopper documents, socket capability tokens, and unrelated local paths from logs or examples.
The maintainer will acknowledge the report, assess its scope, and coordinate remediation and disclosure timing with the reporter. Please allow time for Hopper-dependent behavior to be reproduced safely.
## Security boundary
REA authenticates each local bridge session with a random capability token and a current-user Unix socket. This is not a sandbox and does not protect against malicious processes already running as the same macOS user. Opening an untrusted binary delegates parsing and analysis to Hopper with that user's permissions.
REA authenticates each local bridge session with a random capability token and a current-user Unix socket. This is not a sandbox and does not protect against malicious processes already running as the same operating-system user. Opening an untrusted binary delegates parsing and analysis to Hopper with that user's permissions.
On Linux, REA accepts release metadata only from Hopper's HTTPS endpoint, restricts package URLs to Hopper's public download origin, bounds the package size, and compares the downloaded bytes with Hopper's published checksum before requesting installation. The published SHA-1 value is a corruption check, not a modern package signature; HTTPS origin validation remains part of the trust boundary. Dependency installation is delegated without shell evaluation to `apt-get`, `dnf`, or `pacman`, directly as root or through `pkexec`. REA never invokes `sudo`.
+18 -4
View File
@@ -274,9 +274,15 @@ def _search_page(document, kind, params):
raise ValueError("Invalid regex pattern") from error
matches = lambda value: expression.search(value) is not None
matching = [item for item in _search_inventory(document, kind) if matches(item[1])]
selected = matching[offset:offset + limit]
total = len(matching)
selected = []
total = 0
page_end = offset + limit
for item in _search_inventory(document, kind):
if not matches(item[1]):
continue
if offset <= total < page_end:
selected.append(item)
total += 1
next_offset = offset + len(selected)
has_more = next_offset < total
return {
@@ -654,7 +660,7 @@ def _serve_connection(connection):
should_stop = request["method"] == "shutdown"
response = {"id": request_id, "result": _json_safe(result)}
except Exception as error:
response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512]}}
response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512], "type": _diagnostic_type(error)}}
file.write((json.dumps(response, separators=(",", ":")) + "\n").encode("utf-8"))
file.flush()
if should_stop:
@@ -663,6 +669,14 @@ def _serve_connection(connection):
connection.close()
def _diagnostic_type(error):
if isinstance(error, PermissionError):
return "authorization"
if isinstance(error, (ValueError, TypeError, KeyError)):
return "invalid_request"
return "bridge_exception"
def _run():
"""Own a permission-restricted, single-client Unix socket for this bridge."""
if os.path.exists(REA_SOCKET):
+4 -2
View File
@@ -8,10 +8,11 @@ graph TD
subgraph "Adapters"
SERVER["src/server/<br/>MCP request translation<br/>createServer, tool registration, toolResult"]
BRIDGE["bridge/hopper_bridge.py<br/>Runs inside Hopper<br/>adapts operations to Hopper Python API"]
INSTALL["Host installation adapters<br/>macOS: Homebrew cask<br/>Linux: verified vendor package + apt/dnf/pacman"]
end
subgraph "Application Layer"
APP["src/application/<br/>Shared CLI/MCP session composition<br/>setup, diagnostics, 8 enhanced workflows"]
APP["src/application/<br/>Shared CLI/MCP session composition<br/>setup, diagnostics, lifecycle, enhanced workflows"]
end
subgraph "Hopper Layer"
@@ -19,7 +20,7 @@ graph TD
end
subgraph "Contracts Layer"
CONTRACTS["src/contracts/<br/>Caller-visible tool schemas<br/>31 direct, 8 enhanced, 3 session tools"]
CONTRACTS["src/contracts/<br/>Caller-visible tool schemas<br/>68 direct, enhanced, native, artifact, and session tools"]
end
subgraph "Domain Layer"
@@ -40,6 +41,7 @@ graph TD
MAIN --> SERVER
MAIN --> APP
CLI --> APP
APP --> INSTALL
SERVER --> CONTRACTS
SERVER --> APP
APP --> HOPPER
+59
View File
@@ -0,0 +1,59 @@
# Static-analysis provider evaluation
Status: research for issue #26; no provider implementation is approved by this note.
REA should add another analysis provider only after the provider-neutral capability
contracts, Evidence v2 metadata, ownership rules, and multi-fixture conformance
suite are complete. A provider is not a drop-in replacement for Hopper: every
capability must be mapped explicitly, with truthful `unavailable` results where
the engine cannot provide equivalent semantics.
## Shortlist
| Provider | License / automation surface | What it brings | REA fit and blockers |
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Ghidra](https://github.com/NationalSecurityAgency/ghidra) | Apache-2.0 source license; `analyzeHeadless`, Java APIs, and PyGhidra | Broad static analysis, many processors and formats, scripting, and project/database workflows | Strong candidate for a full provider. Requires a Java/JDK runtime, isolated project/cache directories, process supervision, and a stable adapter for Ghidra's project-oriented model. |
| [Rizin](https://github.com/rizinorg/rizin) / [rz-pipe](https://github.com/rizinorg/rz-pipe) | Rizin repository contains LGPL-3.0 and GPL-3.0 components; `rizin`, `rz-bin`, and language bridges through `rzpipe` | Portable CLI analysis, disassembly/debugging, many architectures and file formats, JSON command output | Good candidate for a process-backed Linux provider and fast metadata fallback. License/component inventory must be preserved; command output needs version-pinned parsers and semantic conformance before evidence is trusted. |
| [LIEF](https://github.com/lief-project/LIEF) | Apache-2.0; C++, Python, and other bindings | Deterministic parsing and modification of ELF, PE, Mach-O, COFF, and related executable formats; headers, sections, symbols, relocations, and functions | Best near-term complement, not a decompiler replacement. It can cover format metadata and artifact evidence without a long-lived analysis process; function semantics, pseudocode, CFG, and cross-reference parity remain out of scope unless separately demonstrated. |
| [Binary Ninja](https://docs.binary.ninja/dev/index.html) | API/documentation components are MIT, while the analysis product is licensed by edition; commercial, Ultimate, or Headless license is required for headless automation | Python/Core/C++/Rust APIs, headless loading, IL layers, function analysis, plugins, and configurable analysis | Strong technical fit for a native provider, especially function dossiers. Commercial licensing, license-secret handling, native runtime packaging, and multithreaded lifecycle rules are material deployment blockers. |
## Recommended order
1. Use LIEF first for provider-neutral binary metadata where the current
contracts already describe headers, sections, symbols, and relocations.
2. Evaluate Rizin as a separate process provider for portable disassembly and
command-backed queries, with JSON-only parsing and fixture assertions.
3. Evaluate Ghidra or Binary Ninja for decompilation and richer function
semantics only after the conformance fixtures can compare evidence by
capability rather than by provider-specific output text.
## Required admission gate
Before implementation, an adapter proposal must provide:
- a capability matrix covering supported, unsupported, and degraded results;
- provider identity, version, target digest, authority, limitations, and
deterministic locations in every Evidence v2 record;
- bounded subprocess or library lifetime, cancellation, timeouts, and cleanup;
- sanitized diagnostics that do not expose license material, host paths, or
provider tracebacks;
- the same source-owned fixture corpus across architectures and formats;
- repeatable checks for function identity, addresses, strings, names, xrefs,
CFG, and pseudocode wherever those capabilities are claimed.
This keeps issue #26 a decision record rather than prematurely committing REA
to an engine whose semantics or redistribution terms have not been verified.
## Primary-source notes
- Ghidra documents headless batch mode and its Java/Python runtime requirements
in its [Getting Started guide](https://github.com/NationalSecurityAgency/ghidra/blob/master/GhidraDocs/GettingStarted.md).
- Rizin lists supported formats, architectures, tools, and `rzpipe` bridges in
its [repository README](https://github.com/rizinorg/rizin); `rz-pipe` documents
JSON command transport and pipe backends.
- LIEF's [format tutorial](https://lief.re/doc/stable/tutorials/01_play_with_formats.html)
and [Python API](https://lief.re/doc/latest/api/binary_abstraction/python.html)
document parsing and format-agnostic binary access.
- Binary Ninja documents its [headless automation model](https://docs.binary.ninja/dev/batch.html),
[edition licenses](https://docs.binary.ninja/about/license.html), and the
[MIT license for API/documentation components](https://docs.binary.ninja/about/open-source.html).
Executable
+198
View File
@@ -0,0 +1,198 @@
#!/bin/bash
set -euo pipefail
PACKAGE="rea-agents"
REPOSITORY="morluto/rea"
NODE_FORMULA="node@24"
NODE_VERSION="24.18.0"
temporary_directory=""
node_destination=""
node_stage=""
node_backup=""
node_version=""
cleanup() {
if [[ -n "$temporary_directory" && -d "$temporary_directory" ]]; then
rm -rf -- "$temporary_directory"
fi
if [[ -n "$node_stage" && -e "$node_stage" ]]; then
rm -rf -- "$node_stage"
fi
if [[ -n "$node_backup" && -e "$node_backup" && ! -e "$node_destination" ]]; then
mv -- "$node_backup" "$node_destination"
fi
}
trap cleanup EXIT HUP INT TERM
fail() {
printf 'REA installation failed: %s\n' "$1" >&2
exit 1
}
version_at_least() {
local actual="$1" required_major="$2" required_minor="$3"
local major minor
major="${actual%%.*}"
minor="${actual#*.}"
minor="${minor%%.*}"
[[ "$major" =~ ^[0-9]+$ && "$minor" =~ ^[0-9]+$ ]] || return 1
((major > required_major || (major == required_major && minor >= required_minor)))
}
platform="$(uname -s)"
if [[ "$platform" == "Darwin" ]]; then
macos_version="$(sw_vers -productVersion 2>/dev/null)" || fail "could not determine the macOS version."
macos_major="${macos_version%%.*}"
[[ "$macos_major" =~ ^[0-9]+$ && "$macos_major" -ge 12 ]] || fail "macOS 12 or newer is required."
elif [[ "$platform" == "Linux" ]]; then
[[ "$(uname -m)" == "x86_64" || "$(uname -m)" == "aarch64" ]] || fail "64-bit x86 or ARM Linux is required."
[[ -r /etc/os-release ]] || fail "could not identify the Linux distribution."
linux_id="$(sed -n 's/^ID=//p' /etc/os-release | tr -d '"')"
linux_version="$(sed -n 's/^VERSION_ID=//p' /etc/os-release | tr -d '"')"
case "$linux_id" in
ubuntu) [[ "${linux_version%%.*}" -ge 24 ]] || fail "Ubuntu 24.04 or newer is required." ;;
fedora) [[ "${linux_version%%.*}" -ge 41 ]] || fail "Fedora 41 or newer is required." ;;
arch) ;;
*) fail "supported Linux distributions are Ubuntu 24.04+, Fedora 41+, and Arch Linux." ;;
esac
else
fail "REA supports macOS and selected 64-bit Linux distributions."
fi
command -v curl >/dev/null 2>&1 || fail "curl is required."
command -v shasum >/dev/null 2>&1 || command -v sha256sum >/dev/null 2>&1 || fail "shasum or sha256sum is required."
if [[ "$platform" == "Darwin" ]] && ! command -v brew >/dev/null 2>&1; then
printf 'Installing Homebrew prerequisite...\n'
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" || fail "Homebrew installation did not complete."
if [[ -x /opt/homebrew/bin/brew ]]; then
PATH="/opt/homebrew/bin:$PATH"
elif [[ -x /usr/local/bin/brew ]]; then
PATH="/usr/local/bin:$PATH"
fi
fi
if [[ "$platform" == "Darwin" ]]; then
command -v brew >/dev/null 2>&1 || fail "Homebrew is not available after installation."
fi
node_compatible=false
if command -v node >/dev/null 2>&1; then
node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
fi
if version_at_least "$node_version" 24 18 && [[ "$node_version" =~ ^24\. ]]; then
node_compatible=true
fi
if [[ "$node_compatible" != true ]]; then
if [[ "$platform" == "Darwin" ]]; then
brew install "$NODE_FORMULA" || fail "Node.js 24 installation failed."
node_prefix="$(brew --prefix "$NODE_FORMULA")" || fail "could not locate Node.js 24."
PATH="$node_prefix/bin:$PATH"
else
machine="$(uname -m)"
node_arch="$([[ "$machine" == "x86_64" ]] && printf 'x64' || printf 'arm64')"
node_archive="node-v${NODE_VERSION}-linux-${node_arch}.tar.xz"
node_base="https://nodejs.org/dist/v${NODE_VERSION}"
temporary_directory="$(mktemp -d "${TMPDIR:-/tmp}/rea-install.XXXXXX")" || fail "could not create a temporary directory."
curl -fsSL "$node_base/$node_archive" -o "$temporary_directory/$node_archive" || fail "Node.js download failed."
curl -fsSL "$node_base/SHASUMS256.txt" -o "$temporary_directory/SHASUMS256.txt" || fail "Node.js checksum download failed."
expected_checksum="$(awk -v archive="$node_archive" '$2 == archive { print $1 }' "$temporary_directory/SHASUMS256.txt")"
[[ "$expected_checksum" =~ ^[a-f0-9]{64}$ ]] || fail "Node.js checksum metadata was malformed."
if command -v shasum >/dev/null 2>&1; then
actual_checksum="$(shasum -a 256 "$temporary_directory/$node_archive" | awk '{ print $1 }')"
else
actual_checksum="$(sha256sum "$temporary_directory/$node_archive" | awk '{ print $1 }')"
fi
[[ "$actual_checksum" == "$expected_checksum" ]] || fail "Node.js checksum verification failed."
node_destination="$HOME/.local/share/rea/node"
node_stage="$HOME/.local/share/rea/node.new.$$"
node_backup="$HOME/.local/share/rea/node.backup.$$"
[[ ! -L "$node_destination" ]] || fail "refusing to replace a symlinked Node.js installation."
mkdir -p "$HOME/.local/share/rea"
rm -rf -- "$node_stage" "$node_backup"
mkdir "$node_stage"
tar -xJf "$temporary_directory/$node_archive" --strip-components=1 -C "$node_stage" || fail "Node.js extraction failed."
if [[ -e "$node_destination" ]]; then
mv -- "$node_destination" "$node_backup"
fi
if ! mv -- "$node_stage" "$node_destination"; then
[[ ! -e "$node_backup" ]] || mv -- "$node_backup" "$node_destination"
fail "Node.js installation replacement failed."
fi
node_stage=""
rm -rf -- "$node_backup"
node_backup=""
PATH="$node_destination/bin:$PATH"
fi
fi
node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
if ! command -v node >/dev/null 2>&1 || ! version_at_least "$node_version" 24 18 || [[ ! "$node_version" =~ ^24\. ]]; then
fail "Node.js 24.18 or newer is not active on PATH."
fi
command -v npm >/dev/null 2>&1 || fail "npm is missing from the Node.js installation."
npm_version="$(npm --version)"
if [[ ! "$npm_version" =~ ^11\. ]] || ! version_at_least "$npm_version" 11 16; then
if [[ "$platform" == "Linux" ]]; then
npm install --global --prefix "$HOME/.local" npm@11 || fail "npm 11 installation failed."
else
npm install --global npm@11 || fail "npm 11 installation failed."
fi
fi
npm_version="$(npm --version)"
if [[ ! "$npm_version" =~ ^11\. ]] || ! version_at_least "$npm_version" 11 16; then
fail "npm 11.16 or newer is not active on PATH."
fi
if [[ -n "$temporary_directory" ]]; then
rm -rf -- "$temporary_directory"
temporary_directory=""
fi
if [[ -n "${REA_VERSION:-}" ]]; then
version="$REA_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || fail "REA_VERSION must be an exact semantic version."
else
release_json="$(curl -fsSL -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."
version="${BASH_REMATCH[1]}"
fi
printf 'Installing %s@%s...\n' "$PACKAGE" "$version"
if [[ "$platform" == "Linux" ]]; then
mkdir -p "$HOME/.local/bin"
PATH="$HOME/.local/bin:$PATH"
npm install --global --prefix "$HOME/.local" "$PACKAGE@$version" || fail "npm package installation failed."
else
npm install --global "$PACKAGE@$version" || fail "npm package installation failed."
fi
command -v rea >/dev/null 2>&1 || fail "the rea command is not available after installation."
installed_version="$(rea --version 2>/dev/null | tr -d '[:space:]')" || fail "could not read the installed REA version."
[[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version."
temporary_directory="$(mktemp -d "${TMPDIR:-/tmp}/rea-install.XXXXXX")" || fail "could not create a temporary directory."
setup_output="$temporary_directory/setup.json"
doctor_output="$temporary_directory/doctor.json"
rea setup --yes --json >"$setup_output" || fail "REA setup command failed."
rea doctor --json >"$doctor_output" || fail "REA doctor command failed."
result="$(node - "$setup_output" "$doctor_output" <<'NODE'
const fs = require("node:fs");
const [setupPath, doctorPath] = process.argv.slice(2);
try {
const setup = JSON.parse(fs.readFileSync(setupPath, "utf8"));
const doctor = JSON.parse(fs.readFileSync(doctorPath, "utf8"));
if (!Array.isArray(doctor.checks) || typeof doctor.healthy !== "boolean") process.exit(2);
if (setup.status !== "ready" && setup.status !== "needs_human") process.exit(2);
const activation = setup.actions.includes("installed_hopper");
if (!doctor.healthy && !activation) process.exit(3);
process.stdout.write(activation ? "activation" : "ready");
} catch { process.exit(2); }
NODE
)" || fail "setup or doctor reported an unhealthy required component or malformed output."
if [[ "$result" == "activation" ]]; then
printf 'REA %s is installed and coding agents are configured.\n' "$version"
printf 'One user action remains: open Hopper, complete its activation, then run: rea doctor --json\n'
else
printf 'REA %s is installed and ready. Run rea doctor --json at any time.\n' "$version"
fi
if [[ "$platform" == "Linux" ]]; then
printf 'Add %s to PATH to run rea from future shells.\n' "$HOME/.local/bin"
fi
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "rea-agents",
"version": "0.3.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rea-agents",
"version": "0.3.0",
"version": "0.5.0",
"license": "MIT",
"dependencies": {
"@babel/parser": "8.0.4",
@@ -24,6 +24,7 @@
"node-pty": "1.1.0",
"pino": "9.14.0",
"plist": "5.0.0",
"smol-toml": "^1.7.0",
"write-file-atomic": "8.0.0",
"ws": "8.21.0",
"zod": "4.4.3"
@@ -4462,7 +4463,6 @@
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
"integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "rea-agents",
"version": "0.3.0",
"version": "0.5.0",
"description": "Reverse engineer anything from your terminal or coding agent with one CLI and MCP server.",
"license": "MIT",
"repository": {
@@ -39,6 +39,7 @@
"packageManager": "npm@11.16.0",
"files": [
"dist",
"install.sh",
"bridge/hopper_bridge.py",
"scripts/rea.mjs",
"scripts/rebuild-native.mjs",
@@ -66,9 +67,11 @@
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
"verify:hopper:linux": "npm run build && node scripts/verify-real-hopper-linux.mjs",
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
"verify:package": "npm run build && node scripts/verify-package.mjs",
"verify:published": "node scripts/verify-published-package.mjs",
"config:print": "node scripts/print-mcp-config.mjs",
"docs:generate": "typedoc",
"prepack": "npm run build",
@@ -95,6 +98,7 @@
"node-pty": "1.1.0",
"pino": "9.14.0",
"plist": "5.0.0",
"smol-toml": "^1.7.0",
"write-file-atomic": "8.0.0",
"ws": "8.21.0",
"zod": "4.4.3"
+4
View File
@@ -3,6 +3,10 @@
// Route production MCP before importing Incur. Incur owns registration helpers
// such as `mcp add`, while only dist/main.js may serve the 68-tool stdio server.
const args = process.argv.slice(2);
const { default: packageJson } = await import("../package.json", {
with: { type: "json" },
});
process.env.REA_PACKAGE_VERSION = packageJson.version;
const isMcpMode =
args[0] === "--mcp" || (args.length === 1 && args[0] === "mcp");
+22 -1
View File
@@ -3,7 +3,11 @@ import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { sha256 } from "./lib/conformance-fixtures.mjs";
import {
generateLargeFixture,
sha256,
sourceDigest,
} from "./lib/conformance-fixtures.mjs";
const exec = promisify(execFile);
const manifestPath = resolve(
@@ -16,6 +20,23 @@ const root = dirname(manifestPath);
for (const fixture of manifest.fixtures) {
const artifactPath = join(root, fixture.artifact);
const sources = [];
for (const source of fixture.sources ?? []) {
const content =
source.path === "generated/large.c"
? generateLargeFixture()
: await readFile(join(process.cwd(), "tests/conformance", source.path));
if (sha256(content) !== source.sha256)
throw new Error(
`${fixture.name}: source hash mismatch for ${source.path}`,
);
sources.push({ path: source.path, content });
}
if (
fixture.sourceSha256 !== undefined &&
sourceDigest(sources) !== fixture.sourceSha256
)
throw new Error(`${fixture.name}: source manifest digest mismatch`);
const artifact = await readFile(artifactPath);
if (sha256(artifact) !== fixture.artifactSha256)
throw new Error(`${fixture.name}: artifact hash mismatch`);
+168 -23
View File
@@ -96,7 +96,10 @@ try {
HOME: home,
XDG_CONFIG_HOME: join(home, ".config"),
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
HOPPER_LAUNCHER_PATH: hopper,
HOPPER_LAUNCHER_PATH: process.execPath,
HOPPER_LOADER_ARGS_JSON: JSON.stringify([
join(root, "tests/fixtures/fakeLauncher.mjs"),
]),
REA_NPX_LOG: npxLog,
REA_EVIDENCE_ROOTS_JSON: JSON.stringify([evidenceRoot]),
REA_REFERENCE_ROOTS_JSON: JSON.stringify([referenceRoot]),
@@ -111,13 +114,23 @@ try {
const help = await run(cli, ["--help"], environment);
const llms = await run(cli, ["--llms"], environment);
const doctor = json(await run(cli, ["doctor", "--json"], environment));
const expectedDoctorHealth = process.platform === "darwin";
const supportedSetupHost =
doctor.checks?.find(({ name }) => name === "host")?.ok === true;
const expectedDoctorHealth = supportedSetupHost;
if (
!help.includes("setup") ||
!help.includes("inventory-artifact") ||
!help.includes("extract-artifact") ||
!help.includes("import-reference-source") ||
!help.includes("compare") ||
!llms.includes("decompile") ||
!llms.includes("function") ||
!llms.includes("search") ||
!llms.includes("inspect") ||
!llms.includes("xrefs") ||
!llms.includes("trace") ||
!llms.includes("capabilities") ||
!llms.includes("providers") ||
!llms.includes("inventory-artifact") ||
doctor.healthy !== expectedDoctorHealth ||
doctor.checks?.find(({ name }) => name === "hopper")?.ok !== true
@@ -142,6 +155,97 @@ try {
artifactInventory.normalized_result?.manifest?.root_format !== "zip"
)
throw new Error("packaged artifact inventory CLI failed");
const overview = json(
await run(cli, ["analyze", process.execPath, "--json"], environment),
);
if (
overview.operation !== "binary_overview" ||
overview.normalized_result?.procedure_count < 1
)
throw new Error(
`packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`,
);
const inspected = json(
await run(
cli,
[
"inspect",
process.execPath,
"--detail",
"detailed",
"--limit",
"1",
"--json",
],
environment,
),
);
if (
inspected.operation !== "binary_overview" ||
inspected.normalized_result?.detail !== "detailed"
)
throw new Error("packaged inspect CLI failed");
const functionResult = json(
await run(
cli,
["function", process.execPath, "0x1000", "--json"],
environment,
),
);
if (
functionResult.operation !== "analyze_function" ||
functionResult.provider?.id !== "hopper" ||
functionResult.normalized_result?.procedure?.address !== "0x1000"
)
throw new Error(
`packaged function CLI failed: ${JSON.stringify(functionResult)}`,
);
const xrefs = json(
await run(
cli,
["xrefs", process.execPath, "0x1000", "--json"],
environment,
),
);
if (
xrefs.operation !== "xrefs" ||
JSON.stringify(xrefs.normalized_result) !== JSON.stringify(["0x1000"])
)
throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`);
const trace = json(
await run(
cli,
["trace", process.execPath, "fixture", "--json"],
environment,
),
);
if (trace.operation !== "trace_feature")
throw new Error(`packaged trace CLI failed: ${JSON.stringify(trace)}`);
const capabilities = json(
await run(cli, ["capabilities", "--json"], environment),
);
if (!Array.isArray(capabilities.capabilities))
throw new Error("packaged capabilities CLI failed");
const providers = json(await run(cli, ["providers", "--json"], environment));
if (
!Array.isArray(providers.providers) ||
providers.providers.some(({ id }) => typeof id !== "string")
)
throw new Error("packaged providers CLI failed");
const searchResult = json(
await run(
cli,
["search", process.execPath, "fixture", "--json"],
environment,
),
);
if (
searchResult.operation !== "search_strings" ||
searchResult.normalized_result?.items?.length !== 1
)
throw new Error(
`packaged search CLI failed: ${JSON.stringify(searchResult)}`,
);
const referenceImport = json(
await run(
cli,
@@ -186,21 +290,32 @@ try {
environment,
),
);
const comparedBundle = json(
await run(
cli,
["compare", sourceBundlePath, canonicalBundlePath, "--json"],
environment,
),
);
if (
importedBundle.imported !== 1 ||
importedBundle.total !== 1 ||
exportedBundle.records !== 1 ||
(await readFile(canonicalBundlePath, "utf8")) !==
serializeEvidenceBundle(sourceBundle)
serializeEvidenceBundle(sourceBundle) ||
comparedBundle.status !== "unchanged" ||
comparedBundle.summary?.records_unchanged !== 1
)
throw new Error("packaged CLI evidence bundle round trip failed");
throw new Error(
"packaged CLI evidence bundle comparison round trip failed",
);
await run(cli, ["mcp", "add"], environment);
const mcpRegistration = await readFile(npxLog, "utf8");
if (!mcpRegistration.includes("add-mcp npx -y rea-agents mcp --name rea"))
throw new Error("Incur mcp add did not register the floating npx command");
const skillPath = join(home, ".agents/skills/rea-analysis/SKILL.md");
const siblingSkillPath = join(home, ".agents/skills/unrelated/SKILL.md");
if (process.platform === "darwin") {
if (supportedSetupHost) {
await mkdir(join(home, ".agents/skills/rea-analysis"), {
recursive: true,
});
@@ -212,7 +327,7 @@ try {
const second = json(
await run(cli, ["setup", "--yes", "--json"], environment),
);
if (process.platform === "darwin") {
if (supportedSetupHost) {
if (
first.status !== "ready" ||
second.status !== "ready" ||
@@ -223,9 +338,8 @@ try {
const config = json(await readFile(configPath, "utf8"));
if (
config.existing !== true ||
config.mcpServers?.rea?.command !== "npx" ||
JSON.stringify(config.mcpServers?.rea?.args) !==
JSON.stringify(["-y", "rea-agents", "mcp"])
config.mcpServers?.rea?.command !== cli ||
JSON.stringify(config.mcpServers?.rea?.args) !== JSON.stringify(["mcp"])
)
throw new Error("packaged client readback failed");
if (
@@ -284,25 +398,50 @@ try {
const client = new Client({ name: "package-smoke", version: "1.0.0" });
try {
await client.connect(transport);
if ((await client.listTools()).tools.length !== expectedToolCount)
const mcpOptions = { timeout: 15_000 };
if (
(await client.listTools(undefined, mcpOptions)).tools.length !==
expectedToolCount
)
throw new Error("packaged MCP tool inventory diverged from contracts");
const result = await client.callTool({
name: "current_document",
arguments: {},
});
const result = await client.callTool(
{ name: "current_document", arguments: {} },
mcpOptions,
);
if (result.isError !== true)
throw new Error("packaged target-free MCP omitted no-target error");
const opened = await client.callTool(
{ name: "open_binary", arguments: { path: process.execPath } },
mcpOptions,
);
if (opened.isError === true)
throw new Error("packaged MCP could not open a binary");
const current = await client.callTool(
{ name: "current_document", arguments: {} },
mcpOptions,
);
if (
current.isError === true ||
json(text(current)).normalized_result !== "fixture"
)
throw new Error("packaged MCP bridge call failed");
const closed = await client.callTool(
{ name: "close_binary", arguments: {} },
mcpOptions,
);
if (closed.isError === true)
throw new Error("packaged MCP could not close its binary");
const mcpBundlePath = join(evidenceRoot, "mcp.json");
const mcpExport = await client.callTool({
name: "export_evidence_bundle",
arguments: { path: mcpBundlePath },
});
const mcpExport = await client.callTool(
{ name: "export_evidence_bundle", arguments: { path: mcpBundlePath } },
mcpOptions,
);
if (mcpExport.isError === true)
throw new Error("packaged MCP evidence export failed");
const mcpImport = await client.callTool({
name: "import_evidence_bundle",
arguments: { path: mcpBundlePath },
});
const mcpImport = await client.callTool(
{ name: "import_evidence_bundle", arguments: { path: mcpBundlePath } },
mcpOptions,
);
if (mcpImport.isError === true)
throw new Error("packaged MCP evidence import failed");
} catch (cause) {
@@ -313,7 +452,7 @@ try {
}
process.stdout.write(
`${JSON.stringify({ cli: true, artifactCli: true, evidenceCli: true, incurMcpCommand: "npx -y rea-agents mcp", doctor: "platform-appropriate", setup: process.platform === "darwin" ? "idempotent" : "unsupported-host-rejected", clients: process.platform === "darwin" ? 2 : 0, backupReadback: process.platform === "darwin", failureRecovery: process.platform === "darwin", skill: process.platform === "darwin", mcpTools: expectedToolCount, evidenceMcp: true, targetFree: true })}\n`,
`${JSON.stringify({ cli: true, analysisCli: true, artifactCli: true, evidenceCli: true, incurMcpCommand: "npx -y rea-agents mcp", doctor: "platform-appropriate", setup: supportedSetupHost ? "idempotent" : "unsupported-host-rejected", clients: supportedSetupHost ? 2 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, skill: supportedSetupHost, mcpTools: expectedToolCount, evidenceMcp: true, targetFree: true, targetLifecycle: true })}\n`,
);
} finally {
if (tarball) await rm(join(root, tarball), { force: true });
@@ -326,3 +465,9 @@ async function run(command, args, env) {
function json(text) {
return JSON.parse(text);
}
function text(result) {
const value = result.content?.find((item) => item.type === "text")?.text;
if (typeof value !== "string") throw new Error("MCP result omitted text");
return value;
}
+44
View File
@@ -0,0 +1,44 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
const version = process.argv[2];
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version ?? ""))
throw new Error("Usage: node scripts/verify-published-package.mjs <version>");
const serverEnvironment = { ...process.env };
delete serverEnvironment.HOPPER_TARGET_PATH;
const transport = new StdioClientTransport({
command: "npx",
args: ["--yes", "--package", `rea-agents@${version}`, "rea", "mcp"],
env: serverEnvironment,
stderr: "pipe",
});
const client = new Client({ name: "published-package-canary", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk) => {
if (stderr.length < 16_384) stderr += chunk.toString("utf8");
});
try {
await client.connect(transport);
const tools = await client.listTools();
if (tools.tools.length !== 68)
throw new Error(
`Published MCP exposed ${String(tools.tools.length)} tools`,
);
const status = await client.callTool({
name: "binary_session",
arguments: {},
});
if (status.isError === true)
throw new Error("Published target-free MCP session check failed");
} catch (cause) {
throw new Error(`Published MCP canary failed: ${stderr}`, { cause });
} finally {
await client.close();
await transport.close();
}
process.stdout.write(
`${JSON.stringify({ package: "rea-agents", version, mcpTools: 68 })}\n`,
);
+22
View File
@@ -0,0 +1,22 @@
import { access } from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
if (process.platform !== "linux")
throw new Error("Linux real-Hopper verification must run on Linux");
const launcher = process.env.HOPPER_LAUNCHER_PATH ?? "/opt/hopper/bin/Hopper";
await access(launcher);
const linked = await execFileAsync("ldd", [launcher]);
if (`${linked.stdout}\n${linked.stderr}`.includes("not found"))
throw new Error("Hopper has unresolved shared-library dependencies");
if (
process.env.DISPLAY === undefined &&
process.env.WAYLAND_DISPLAY === undefined
)
throw new Error(
"Linux real-Hopper verification requires DISPLAY or WAYLAND_DISPLAY",
);
process.env.HOPPER_LAUNCHER_PATH = launcher;
await import("./verify-real-hopper.mjs");
+35 -2
View File
@@ -1,4 +1,6 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { readdir, realpath } from "node:fs/promises";
import { promisify } from "node:util";
@@ -8,6 +10,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import {
firstProcedureAddress,
requireDistinctTargetHashes,
requireAddressArray,
requireFunctionDossier,
requirePseudocode,
@@ -15,6 +18,17 @@ import {
const execFileAsync = promisify(execFile);
const timeout = 180_000;
const parseServerArgs = (encoded) => {
const parsed = JSON.parse(encoded);
if (
!Array.isArray(parsed) ||
parsed.some((value) => typeof value !== "string")
)
throw new Error("REA_VERIFY_SERVER_ARGS_JSON must encode string arguments");
return parsed;
};
const sessionsBefore = new Set(
(await readdir("/tmp")).filter((name) => name.startsWith("rea-")),
);
@@ -242,13 +256,25 @@ const [targetA, targetB] = await Promise.all([
]);
if (targetA === targetB)
throw new Error("Real-Hopper verification requires two distinct targets");
const [targetHashA, targetHashB] = await Promise.all([
sha256File(targetA),
sha256File(targetB),
]);
requireDistinctTargetHashes(targetHashA, targetHashB);
const serverEnvironment = { ...process.env };
delete serverEnvironment.HOPPER_TARGET_PATH;
delete serverEnvironment.HOPPER_SECOND_TARGET_PATH;
delete serverEnvironment.REA_VERIFY_SERVER_COMMAND;
delete serverEnvironment.REA_VERIFY_SERVER_ARGS_JSON;
const serverCommand = process.env.REA_VERIFY_SERVER_COMMAND ?? process.execPath;
const serverArgs = parseServerArgs(
process.env.REA_VERIFY_SERVER_ARGS_JSON ?? '["dist/main.js"]',
);
const transport = new StdioClientTransport({
command: process.execPath,
args: ["dist/main.js"],
command: serverCommand,
args: serverArgs,
cwd: process.cwd(),
env: serverEnvironment,
stderr: "pipe",
@@ -359,6 +385,7 @@ try {
diagnosticCount,
dynamicSession: true,
targets: [targetA, targetB],
targetHashes: [targetHashA, targetHashB],
switched: true,
secondOverview: verifiedSecondOverview,
};
@@ -404,3 +431,9 @@ await new Promise((resolve, reject) => {
},
);
});
async function sha256File(path) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
}
+2 -2
View File
@@ -21,8 +21,8 @@ Notes is only a documentation example. Never select an app unless the user names
## Ensure REA is ready
1. Run `npx -y rea-agents doctor`.
2. If setup is needed, explain that REA may install Homebrew and Hopper. Hopper is separate software and requires its own license.
3. Obtain approval before installing external software, then run `npx -y rea-agents setup --yes`.
2. If setup is needed, tell the user REA needs to install its local binary-analysis tools. Do not lead with implementation details or assume the user knows reverse-engineering products.
3. Before installing external software, obtain approval and identify what will be installed. If deeper analysis needs Hopper, describe it as REA's local analysis engine and note that it is a separate Mac app with its own license. Then run `npx -y rea-agents setup --yes`.
4. If macOS or an installer requests human input, tell the user exactly what needs attention. After they finish, rerun setup and doctor.
5. If setup registers a new MCP server, tell the user to restart their coding agent to load all REA tools. Direct CLI commands remain available before restart.
+10 -1
View File
@@ -316,6 +316,14 @@ export class BinarySession implements BinarySessionPort {
name: this.#providerIdentity.name,
version: this.#providerIdentity.version,
};
const providers = new Map<string, ProviderIdentity>();
if (this.#capabilities === undefined) providers.set(provider.id, provider);
else
for (const descriptor of this.#capabilities.values())
providers.set(descriptor.provider.id, descriptor.provider);
const providerList = [...providers.values()]
.sort((left, right) => left.id.localeCompare(right.id))
.map(({ id, name, version }) => ({ id, name, version }));
const capabilities =
this.#capabilities === undefined
? []
@@ -348,10 +356,11 @@ export class BinarySession implements BinarySessionPort {
limitations: [...descriptor.limitations],
}));
return target === undefined
? { open: false, provider, capabilities }
? { open: false, provider, providers: providerList, capabilities }
: {
open: true,
provider,
providers: providerList,
capabilities,
path: target.path,
sha256: target.sha256,
+44 -14
View File
@@ -13,6 +13,15 @@ const WORKFLOW_PROVIDER = {
version: "1",
} as const;
type DirectAnalysisTool =
| "binary_overview"
| "procedure_pseudo_code"
| "analyze_function"
| "search_strings"
| "search_procedures"
| "xrefs"
| "trace_feature";
/**
* Open one binary, execute one tool, and always release the bridge session.
* Unlike MCP mode, every CLI invocation is intentionally isolated and does not
@@ -20,7 +29,7 @@ const WORKFLOW_PROVIDER = {
*/
export const runDirectAnalysis = async (
path: string,
tool: "binary_overview" | "procedure_pseudo_code",
tool: DirectAnalysisTool,
arguments_: Readonly<Record<string, JsonValue>>,
logger: Logger = silentLogger,
): Promise<JsonValue> => runAnalysis(path, tool, arguments_, logger);
@@ -33,13 +42,24 @@ export const runProviderAnalysis = async (
logger: Logger = silentLogger,
): Promise<JsonValue> => runAnalysis(path, tool, arguments_, logger);
/** Describe configured providers without opening a target or launching Hopper. */
export const runSessionStatus = async (
logger: Logger = silentLogger,
): Promise<JsonValue> => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const session = createBinarySession(config.value, logger);
try {
return session.status();
} finally {
await session.close();
}
};
const runAnalysis = async (
path: string,
tool:
| NativeToolName
| ArtifactToolName
| "binary_overview"
| "procedure_pseudo_code",
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
arguments_: Readonly<Record<string, JsonValue>>,
logger: Logger,
): Promise<JsonValue> => {
@@ -51,16 +71,26 @@ const runAnalysis = async (
const opened = await session.open(path);
if (!opened.ok)
return { error: opened.error._tag, message: opened.error.message };
if (tool === "binary_overview") {
if (
tool === "binary_overview" ||
tool === "analyze_function" ||
tool === "trace_feature"
) {
const result = await new EnhancedTools(session).execute(tool, arguments_);
return result.ok
? createEvidence(opened.value, WORKFLOW_PROVIDER, {
operation: tool,
parameters: arguments_,
result: result.value,
confidence: "derived",
limitations: ["Derived by an REA composed workflow."],
})
? createEvidence(
opened.value,
tool === "analyze_function"
? session.providerIdentity(tool)
: WORKFLOW_PROVIDER,
{
operation: tool,
parameters: arguments_,
result: result.value,
confidence: "derived",
limitations: ["Derived by an REA composed workflow."],
},
)
: { error: result.error._tag, message: result.error.message };
}
const result = await session.execute(tool, arguments_);
+59 -24
View File
@@ -8,15 +8,28 @@ import { promisify } from "node:util";
import { parseBinaryTarget } from "../domain/binaryTarget.js";
import { probeHomebrew } from "./homebrew.js";
import {
linuxHopperLauncherPath,
linuxSharedLibrariesAvailable,
readLinuxDistribution,
type LinuxDistribution,
} from "./LinuxHopper.js";
const execFileAsync = promisify(execFile);
const DEFAULT_HOPPER =
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper";
/** One actionable environment diagnostic. */
export interface DoctorCheck {
readonly name: string;
readonly ok: boolean;
readonly classification:
| "healthy"
| "missing_dependency"
| "unsupported_host"
| "missing_analysis_engine"
| "config_drift";
readonly detail?: string;
readonly remediation?: string;
}
@@ -26,6 +39,7 @@ export interface DoctorHost {
readonly nodeVersion: string;
readonly configuredHopperPath?: string;
macosVersion(): Promise<string | undefined>;
linuxDistribution(): Promise<LinuxDistribution | undefined>;
validTarget(path: string): Promise<boolean>;
executable(path: string): Promise<boolean>;
brewHopperPath(): Promise<string | undefined>;
@@ -48,27 +62,39 @@ export const runDoctor = async (
const checks: DoctorCheck[] = [];
const nodeMajor = parseMajor(host.nodeVersion);
checks.push(
check(
"node",
nodeMajor >= 22,
host.nodeVersion,
"Install Node.js 22 or newer.",
),
check("node", nodeMajor >= 24, host.nodeVersion, {
remediation: "Install Node.js 24.18 or newer.",
classification: "missing_dependency",
}),
);
const macosVersion =
host.platform === "darwin" ? await host.macosVersion() : undefined;
const macosMajor = macosVersion === undefined ? 0 : parseMajor(macosVersion);
const linuxDistribution =
host.platform === "linux" ? await host.linuxDistribution() : undefined;
const supportedHost =
(host.platform === "darwin" && macosMajor >= 12) ||
(host.platform === "linux" && linuxDistribution?.supported === true);
checks.push(
check(
"macos",
host.platform === "darwin" && macosMajor >= 12,
macosVersion ?? host.platform,
"Hopper requires macOS 12 or newer.",
"host",
supportedHost,
macosVersion ??
(linuxDistribution === undefined
? host.platform
: `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`),
{
remediation:
"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.",
classification: "unsupported_host",
},
),
);
const candidates = [
host.configuredHopperPath,
DEFAULT_HOPPER,
SYSTEM_LINUX_HOPPER,
linuxHopperLauncherPath(homedir()),
...(await host.manualHopperPaths()),
await host.brewHopperPath(),
].filter((value): value is string => value !== undefined);
@@ -79,21 +105,18 @@ export const runDoctor = async (
break;
}
checks.push(
check(
"hopper",
hopperPath !== undefined,
hopperPath,
"Install Hopper with: brew install --cask hopper-disassembler, or set HOPPER_LAUNCHER_PATH.",
),
check("hopper", hopperPath !== undefined, hopperPath, {
remediation:
"Run rea setup --yes to install Hopper, or set HOPPER_LAUNCHER_PATH.",
classification: "missing_analysis_engine",
}),
);
if (target !== undefined)
checks.push(
check(
"target",
await host.validTarget(target),
target,
"Supply a readable local app or program path.",
),
check("target", await host.validTarget(target), target, {
remediation: "Supply a readable local app or program path.",
classification: "config_drift",
}),
);
return {
healthy: checks.every(({ ok }) => ok),
@@ -118,12 +141,20 @@ export const systemDoctorHost = (): DoctorHost => ({
return undefined;
}
},
linuxDistribution: readLinuxDistribution,
async validTarget(path) {
return (await parseBinaryTarget(path)).ok;
},
async executable(path) {
try {
await access(path, constants.X_OK);
if (process.platform === "linux") {
const linked = await execFileAsync("ldd", [path]);
if (
!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`)
)
return false;
}
return true;
} catch {
return false;
@@ -168,10 +199,14 @@ const check = (
name: string,
ok: boolean,
detail: string | undefined,
remediation: string,
failure: {
readonly remediation: string;
readonly classification: Exclude<DoctorCheck["classification"], "healthy">;
},
): DoctorCheck => ({
name,
ok,
classification: ok ? "healthy" : failure.classification,
...(detail === undefined ? {} : { detail }),
...(ok ? {} : { remediation }),
...(ok ? {} : { remediation: failure.remediation }),
});
+2
View File
@@ -6,6 +6,7 @@ import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
import {
AnalysisInputError,
AnalysisCancelledError,
AnalysisOutputError,
type AnalysisError,
} from "../domain/errors.js";
@@ -472,6 +473,7 @@ export class EnhancedTools {
arguments_: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
): Promise<Result<JsonValue, AnalysisError>> {
if (signal?.aborted === true) return err(new AnalysisCancelledError(name));
const execution = await this.analysis.execute(
name,
arguments_,
+35 -1
View File
@@ -1,10 +1,15 @@
import type { AnalysisError } from "../domain/errors.js";
import {
EvidenceIntegrityError,
type AnalysisError,
} from "../domain/errors.js";
import type {
EvidenceFilePolicy,
EvidenceBundle,
} from "../domain/evidenceBundle.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import { compareBundles } from "../domain/bundleComparison.js";
import { EvidenceLedger } from "./EvidenceLedger.js";
import {
readEvidenceBundle,
@@ -40,6 +45,35 @@ export const exportEvidenceBundleCommand = async (
);
};
/** Compare two validated canonical Evidence v2 bundles without session state. */
export const compareEvidenceBundlesCommand = async (input: {
readonly leftPath: string;
readonly rightPath: string;
readonly offset: number;
readonly limit: number;
readonly policy: EvidenceFilePolicy;
}): Promise<Result<JsonValue, AnalysisError>> => {
const [left, right] = await Promise.all([
readEvidenceBundle(input.leftPath, input.policy),
readEvidenceBundle(input.rightPath, input.policy),
]);
if (!left.ok) return left;
if (!right.ok) return right;
try {
return ok(
jsonValueSchema.parse(
compareBundles(left.value, right.value, [], input.offset, input.limit),
),
);
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError("Evidence bundle comparison failed", {
cause,
}),
);
}
};
const createLedger = (): EvidenceLedger =>
new EvidenceLedger({ maxRecords: 10_000, maxBytes: 64 * 1024 * 1024 });
+273
View File
@@ -0,0 +1,273 @@
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { z } from "zod";
const execFileAsync = promisify(execFile);
const RELEASES_URL =
"https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
const MAX_PACKAGE_BYTES = 100_000_000;
export type LinuxPackageFamily = "deb" | "rpm" | "arch";
/** Parsed Linux distribution information used for support and package selection. */
export interface LinuxDistribution {
readonly id: string;
readonly versionId?: string;
readonly packageFamily: LinuxPackageFamily | undefined;
readonly supported: boolean;
}
/** Download response kept independent of Node's global fetch implementation. */
export interface LinuxHopperDownload {
readonly ok: boolean;
readonly bytes: Uint8Array;
}
/** External effects required to install the official Linux package. */
export interface LinuxHopperInstallHost {
distribution(): Promise<LinuxDistribution | undefined>;
download(
url: string,
options: { readonly signal?: AbortSignal },
): Promise<LinuxHopperDownload>;
createTemporaryDirectory(): Promise<string>;
writeArchive(path: string, bytes: Uint8Array): Promise<void>;
installPackage(family: LinuxPackageFamily, archive: string): Promise<boolean>;
launcherReady(path: string): Promise<boolean>;
cleanup(path: string): Promise<void>;
}
/** Structured outcome for every expected Linux installation failure. */
export type LinuxHopperInstallResult =
| { readonly status: "installed"; readonly launcherPath: string }
| {
readonly status: "failed";
readonly reason:
| "unsupported_host"
| "release_metadata"
| "download"
| "integrity"
| "authorization_or_package_manager"
| "launcher_missing"
| "cancelled";
};
/** Parse an os-release document without executing its shell syntax. */
export const parseLinuxDistribution = (text: string): LinuxDistribution => {
const fields = new Map<string, string>();
for (const line of text.split("\n")) {
const match = /^([A-Z_]+)=(.*)$/u.exec(line.trim());
if (match === null) continue;
const key = match[1];
const encoded = match[2];
if (key === undefined || encoded === undefined) continue;
const value =
encoded.startsWith('"') && encoded.endsWith('"')
? encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\")
: encoded;
fields.set(key, value);
}
const id = (fields.get("ID") ?? "unknown").toLowerCase();
const versionId = fields.get("VERSION_ID");
const packageFamily = packageFamilyFor(id, fields.get("ID_LIKE"));
const supported =
id === "arch" ||
(id === "ubuntu" && versionAtLeast(versionId, 24)) ||
(id === "fedora" && versionAtLeast(versionId, 41));
return {
id,
...(versionId === undefined ? {} : { versionId }),
packageFamily,
supported,
};
};
/** Read and classify the current Linux distribution. */
export const readLinuxDistribution = async (): Promise<
LinuxDistribution | undefined
> => {
try {
return parseLinuxDistribution(await readFile("/etc/os-release", "utf8"));
} catch {
return undefined;
}
};
/** Download, verify, install, and read back the official Hopper Linux package. */
export const installLinuxHopper = async (
host: LinuxHopperInstallHost = systemLinuxHopperInstallHost(),
options: { readonly signal?: AbortSignal } = {},
): Promise<LinuxHopperInstallResult> => {
if (options.signal?.aborted === true)
return { status: "failed", reason: "cancelled" };
const distribution = await host.distribution();
if (
distribution?.supported !== true ||
distribution.packageFamily === undefined
)
return { status: "failed", reason: "unsupported_host" };
let temporary: string | undefined;
try {
const metadataDownload = await host.download(RELEASES_URL, options);
if (!metadataDownload.ok)
return { status: "failed", reason: "release_metadata" };
const releases = parseReleases(metadataDownload.bytes);
if (releases === undefined)
return { status: "failed", reason: "release_metadata" };
const release = releases[releaseKey(distribution.packageFamily)];
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
return { status: "failed", reason: "release_metadata" };
const archiveDownload = await host.download(release.filename, options);
if (!archiveDownload.ok) return { status: "failed", reason: "download" };
if (!packageIntegrityMatches(archiveDownload.bytes, release))
return { status: "failed", reason: "integrity" };
temporary = await host.createTemporaryDirectory();
const archive = join(temporary, `hopper.${distribution.packageFamily}`);
await host.writeArchive(archive, archiveDownload.bytes);
if (!(await host.installPackage(distribution.packageFamily, archive)))
return { status: "failed", reason: "authorization_or_package_manager" };
const launcherPath = "/opt/hopper/bin/Hopper";
if (!(await host.launcherReady(launcherPath)))
return { status: "failed", reason: "launcher_missing" };
return { status: "installed", launcherPath };
} catch (cause: unknown) {
if (isAbortCause(cause)) return { status: "failed", reason: "cancelled" };
return { status: "failed", reason: "download" };
} finally {
if (temporary !== undefined) await host.cleanup(temporary);
}
};
/** Canonical launcher path for a legacy user-local Linux Hopper installation. */
export const linuxHopperLauncherPath = (home: string): string =>
join(home, ".local/share/rea/hopper/bin/Hopper");
const systemLinuxHopperInstallHost = (): LinuxHopperInstallHost => ({
distribution: readLinuxDistribution,
async download(url, options) {
const response = await fetch(url, {
headers: { accept: "application/json", "user-agent": "rea-installer" },
...(options.signal === undefined ? {} : { signal: options.signal }),
});
return {
ok: response.ok,
bytes: new Uint8Array(await response.arrayBuffer()),
};
},
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
installPackage: installSystemPackage,
async launcherReady(path) {
try {
await access(path);
const linked = await execFileAsync("ldd", [path]);
return linuxSharedLibrariesAvailable(
`${linked.stdout}\n${linked.stderr}`,
);
} catch {
return false;
}
},
cleanup: (path) => rm(path, { recursive: true, force: true }),
});
/** Interpret ldd output conservatively so a present but broken launcher is unhealthy. */
export const linuxSharedLibrariesAvailable = (output: string): boolean =>
!/(?:=>\s+not found|error while loading shared libraries)/iu.test(output);
const installSystemPackage = async (
family: LinuxPackageFamily,
archive: string,
): Promise<boolean> => {
try {
const command = linuxPackageManagerCommand(
family,
archive,
process.getuid?.() === 0,
);
await execFileAsync(command.executable, command.args);
return true;
} catch {
return false;
}
};
/** Select the native package manager and authorization boundary without shell evaluation. */
export const linuxPackageManagerCommand = (
family: LinuxPackageFamily,
archive: string,
isRoot: boolean,
): { readonly executable: string; readonly args: readonly string[] } => {
const command =
family === "deb"
? ["apt-get", "install", "-y", archive]
: family === "rpm"
? ["dnf", "install", "-y", archive]
: ["pacman", "-U", "--noconfirm", archive];
const executable = isRoot ? command[0] : "pkexec";
if (executable === undefined) throw new Error("package command is empty");
return { executable, args: isRoot ? command.slice(1) : command };
};
const parseReleases = (
bytes: Uint8Array,
): z.infer<typeof releasesSchema> | undefined => {
try {
const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
const result = releasesSchema.safeParse(parsed);
return result.success ? result.data : undefined;
} catch {
return undefined;
}
};
const packageIntegrityMatches = (
bytes: Uint8Array,
release: z.infer<typeof releaseSchema>,
): boolean =>
bytes.byteLength === Number(release.file_length) &&
bytes.byteLength <= MAX_PACKAGE_BYTES &&
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
const packageFamilyFor = (
id: string,
idLike: string | undefined,
): LinuxPackageFamily | undefined => {
const identities = new Set([
id,
...(idLike?.toLowerCase().split(/\s+/u) ?? []),
]);
if (identities.has("ubuntu") || identities.has("debian")) return "deb";
if (identities.has("fedora") || identities.has("rhel")) return "rpm";
if (identities.has("arch")) return "arch";
return undefined;
};
const versionAtLeast = (
version: string | undefined,
minimum: number,
): boolean =>
version !== undefined &&
Number.parseInt(version.split(".")[0] ?? "0", 10) >= minimum;
const releaseKey = (
family: LinuxPackageFamily,
): "Ubuntu / Mint" | "Fedora" | "Arch" =>
family === "deb" ? "Ubuntu / Mint" : family === "rpm" ? "Fedora" : "Arch";
const isAbortCause = (cause: unknown): boolean =>
cause instanceof Error && cause.name === "AbortError";
const releaseSchema = z.object({
filename: z.string().url(),
file_length: z.string().regex(/^\d+$/u),
file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
});
const releasesSchema = z.object({
"Ubuntu / Mint": releaseSchema,
Fedora: releaseSchema,
Arch: releaseSchema,
});
+32
View File
@@ -8,6 +8,10 @@ export interface OwnedProcessGroup {
readonly runId: string;
readonly leaderPid: number;
readonly processGroupId: number;
/** Expected launcher identity, checked only while the leader exists. */
readonly expectedCommand?: string;
/** Expected launcher parent, checked only while the leader exists. */
readonly expectedParentPid?: number;
}
interface ProcessGroupMember {
@@ -86,6 +90,25 @@ export const cleanupOwnedProcessGroup = async (
return { cleaned: false, reason: "process group could not be inspected" };
}
if (members.length === 0) return { cleaned: true, signaled: false };
const leader = members.find((member) => member.pid === ownership.leaderPid);
if (leader !== undefined) {
if (
ownership.expectedParentPid !== undefined &&
leader.parentPid !== ownership.expectedParentPid
)
return {
cleaned: false,
reason: "owned launcher parent identity did not match",
};
if (
ownership.expectedCommand !== undefined &&
!commandMatches(leader.command, ownership.expectedCommand)
)
return {
cleaned: false,
reason: "owned launcher command identity did not match",
};
}
for (const member of members) {
let environment: Readonly<Record<string, string>>;
try {
@@ -113,3 +136,12 @@ export const cleanupOwnedProcessGroup = async (
}
return { cleaned: true, signaled: true };
};
const commandMatches = (actual: string, expected: string): boolean => {
const normalizedActual = actual.trim();
const normalizedExpected = expected.trim();
return (
normalizedActual === normalizedExpected ||
normalizedActual.startsWith(`${normalizedExpected} `)
);
};
+17
View File
@@ -27,6 +27,23 @@ export const firstProcedureAddress = (input: unknown): string => {
return first.address;
};
/** Reject two target paths that resolve to the same binary content. */
export const requireDistinctTargetHashes = (
firstHash: unknown,
secondHash: unknown,
): void => {
if (
typeof firstHash !== "string" ||
typeof secondHash !== "string" ||
firstHash.length === 0 ||
secondHash.length === 0
) {
throw new TypeError("Target hashes must be non-empty strings");
}
if (firstHash === secondHash)
throw new TypeError("Real-Hopper verification requires distinct binaries");
};
/** Reject empty and success-shaped embedded decompilation failures. */
export const requirePseudocode = (
input: unknown,
+192 -37
View File
@@ -8,27 +8,40 @@ import {
writeFile,
} from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { dirname, join, resolve } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { z } from "zod";
import writeFileAtomic from "write-file-atomic";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { PRODUCT_IDENTITY } from "../identity.js";
import { runDoctor, systemDoctorHost } from "./Doctor.js";
import { probeHomebrew } from "./homebrew.js";
import {
installLinuxHopper,
readLinuxDistribution,
type LinuxDistribution,
} from "./LinuxHopper.js";
const execFileAsync = promisify(execFile);
const registrationCommand = (): readonly string[] =>
process.env.npm_command === "exec"
? ["npx", "-y", PRODUCT_IDENTITY.packageName, "mcp"]
: [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
/** A detected agent configuration owned by setup. */
export interface SetupClient {
readonly name: string;
readonly configPath: string;
readonly markerPath?: string;
readonly format?: "json" | "toml" | "unsupported";
}
/** Result of one backup/write/readback transaction. */
export type ClientConfigurationResult =
| {
readonly status: "unchanged" | "configured";
readonly status: "unchanged" | "configured" | "skipped";
readonly backupPath?: string;
}
| {
@@ -44,6 +57,7 @@ export interface SetupHost {
readonly platform: NodeJS.Platform;
readonly nodeVersion: string;
macosVersion(): Promise<string | undefined>;
linuxDistribution(): Promise<LinuxDistribution | undefined>;
hasHomebrew(): Promise<boolean>;
installHomebrew(): Promise<boolean>;
hopperPath(): Promise<string | undefined>;
@@ -52,6 +66,7 @@ export interface SetupHost {
configureClient(
client: SetupClient,
hopperPath: string,
command: readonly string[],
): Promise<ClientConfigurationResult>;
installSkill(): Promise<"installed" | "unchanged" | "failed">;
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
@@ -87,13 +102,9 @@ export const runSetup = async (
doctor: await host.doctor(),
remediation,
});
if (host.platform !== "darwin")
return fail("Hopper requires macOS 12 or newer.");
const macosVersion = await host.macosVersion();
if (macosVersion === undefined || major(macosVersion) < 12)
return fail("Upgrade to macOS 12 or newer.");
if (major(host.nodeVersion) < 22) return fail("Install Node.js 22 or newer.");
if (!(await host.hasHomebrew())) {
const unsupported = await hostRemediation(host);
if (unsupported !== undefined) return fail(unsupported);
if (host.platform === "darwin" && !(await host.hasHomebrew())) {
if (!yes) return fail("Re-run with --yes to install Homebrew.");
if (!(await host.installHomebrew()))
return fail(
@@ -116,7 +127,11 @@ export const runSetup = async (
);
}
for (const client of await host.detectedClients()) {
const result = await host.configureClient(client, hopperPath);
const result = await host.configureClient(
client,
hopperPath,
registrationCommand(),
);
clients[client.name] = result;
if (result.status === "failed")
return fail(
@@ -130,19 +145,40 @@ export const runSetup = async (
return fail("Agent skill installation or readback failed.");
if (skill === "installed") actions.push("installed_skill");
const doctor = await host.doctor();
const activationRequired = actions.includes("installed_hopper");
const ready = doctor.healthy && !activationRequired;
return {
status: doctor.healthy ? "ready" : "needs_human",
status: ready ? "ready" : "needs_human",
actions,
clients,
doctor,
...(doctor.healthy
...(ready
? {}
: {
remediation: "Run rea doctor and apply each reported remediation.",
remediation: activationRequired
? "Open Hopper, complete its one-time activation, then rerun rea doctor --json."
: "Run rea doctor and apply each reported remediation.",
}),
};
};
const hostRemediation = async (
host: SetupHost,
): Promise<string | undefined> => {
if (host.platform !== "darwin" && host.platform !== "linux")
return "REA supports Hopper on macOS and selected 64-bit Linux distributions.";
if (major(host.nodeVersion) < 24) return "Install Node.js 24.18 or newer.";
if (host.platform === "darwin") {
const version = await host.macosVersion();
return version === undefined || major(version) < 12
? "Upgrade to macOS 12 or newer."
: undefined;
}
return (await host.linuxDistribution())?.supported === true
? undefined
: "REA supports Hopper on Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.";
};
/** Production setup effects for macOS, Homebrew, JSON MCP clients, and the canonical skill directory. */
const systemSetupHost = (): SetupHost => {
const doctorHost = systemDoctorHost();
@@ -150,6 +186,7 @@ const systemSetupHost = (): SetupHost => {
platform: process.platform,
nodeVersion: process.versions.node,
macosVersion: () => doctorHost.macosVersion(),
linuxDistribution: readLinuxDistribution,
hasHomebrew: () => brewSucceeds(["--version"]),
installHomebrew: () =>
commandSucceeds(
@@ -162,43 +199,86 @@ const systemSetupHost = (): SetupHost => {
),
hopperPath: async () => (await runDoctor(undefined, doctorHost)).hopperPath,
installHopper: () =>
brewSucceeds(["install", "--cask", "hopper-disassembler"]),
detectedClients: () => detectJsonClients(homedir()),
configureClient: configureJsonClient,
process.platform === "linux"
? installLinuxHopper().then(({ status }) => status === "installed")
: brewSucceeds(["install", "--cask", "hopper-disassembler"]),
detectedClients: () => detectClients(homedir()),
configureClient: (client, hopperPath, command) =>
client.format === "unsupported"
? Promise.resolve({ status: "skipped" })
: client.format === "toml"
? configureTomlClient(client, hopperPath, command)
: configureJsonClient(client, hopperPath, command),
installSkill: () => installCanonicalSkill(homedir()),
doctor: () => runDoctor(undefined, doctorHost),
};
};
const detectJsonClients = async (
/** Detect supported coding agents from stable per-user installation markers. */
export const detectClients = async (
home: string,
): Promise<readonly SetupClient[]> => {
const candidates = [
{
name: "claude_desktop",
configPath: join(
home,
"Library/Application Support/Claude/claude_desktop_config.json",
),
marker: join(home, "Library/Application Support/Claude"),
},
{
name: "cursor",
configPath: join(home, ".cursor/mcp.json"),
marker: join(home, ".cursor"),
},
];
const detected: SetupClient[] = [];
for (const candidate of candidates)
if (await exists(candidate.marker))
detected.push({ name: candidate.name, configPath: candidate.configPath });
for (const candidate of supportedClients(home))
if (await exists(candidate.markerPath ?? candidate.configPath))
detected.push(candidate);
return detected;
};
/** Describe every client location that setup or uninstall may own. */
export const supportedClients = (home: string): readonly SetupClient[] => [
{
name: "claude_code",
configPath: join(home, ".claude.json"),
markerPath: join(home, ".claude"),
},
{
name: "claude_desktop",
configPath: join(
home,
"Library/Application Support/Claude/claude_desktop_config.json",
),
markerPath: join(home, "Library/Application Support/Claude"),
},
{
name: "codex",
configPath: join(home, ".codex/config.toml"),
markerPath: join(home, ".codex"),
format: "toml" as const,
},
{
name: "cursor",
configPath: join(home, ".cursor/mcp.json"),
markerPath: join(home, ".cursor"),
},
{
name: "gemini_cli",
configPath: join(home, ".gemini/settings.json"),
markerPath: join(home, ".gemini"),
},
{
name: "windsurf",
configPath: join(home, ".codeium/windsurf/mcp_config.json"),
markerPath: join(home, ".codeium/windsurf"),
},
{
name: "devin",
configPath: join(home, ".devin"),
markerPath: join(home, ".devin"),
format: "unsupported" as const,
},
];
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
export const configureJsonClient = async (
client: SetupClient,
hopperPath?: string,
command: readonly string[] = [
"npx",
"-y",
PRODUCT_IDENTITY.packageName,
"mcp",
],
): Promise<ClientConfigurationResult> => {
let document: Record<string, unknown> = {};
let original: string | undefined;
@@ -215,8 +295,8 @@ export const configureJsonClient = async (
return { status: "failed", reason: "readback" };
}
const desired = {
command: "npx",
args: ["-y", PRODUCT_IDENTITY.packageName, "mcp"],
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
args: command.slice(1),
...(hopperPath === undefined
? {}
: { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
@@ -268,6 +348,81 @@ export const configureJsonClient = async (
};
};
/** Back up, atomically update, and semantically read back Codex TOML configuration. */
export const configureTomlClient = async (
client: SetupClient,
hopperPath?: string,
command: readonly string[] = [
"npx",
"-y",
PRODUCT_IDENTITY.packageName,
"mcp",
],
): Promise<ClientConfigurationResult> => {
let document: Record<string, unknown> = {};
let original: string | undefined;
try {
original = await readFile(client.configPath, "utf8");
document = objectSchema.parse(parseToml(original));
} catch (cause: unknown) {
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
}
let servers: Record<string, unknown>;
try {
servers = parseOptionalObject(document.mcp_servers);
} catch {
return { status: "failed", reason: "readback" };
}
const desired = {
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
args: command.slice(1),
...(hopperPath === undefined
? {}
: { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
};
if (
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
JSON.stringify(desired)
)
return { status: "unchanged" };
const backupPath =
original === undefined ? undefined : `${client.configPath}.rea.backup`;
try {
if (backupPath !== undefined) await copyFile(client.configPath, backupPath);
} catch {
return { status: "failed", reason: "backup" };
}
document.mcp_servers = {
...servers,
[PRODUCT_IDENTITY.mcpServerKey]: desired,
};
try {
await mkdir(dirname(client.configPath), { recursive: true });
await writeFileAtomic(client.configPath, stringifyToml(document), {
encoding: "utf8",
mode: 0o600,
});
const readback = objectSchema.parse(
parseToml(await readFile(client.configPath, "utf8")),
);
if (
JSON.stringify(
parseOptionalObject(readback.mcp_servers)[
PRODUCT_IDENTITY.mcpServerKey
],
) !== JSON.stringify(desired)
)
throw new Error("TOML readback mismatch");
} catch {
await restoreConfig(client.configPath, original);
return { status: "failed", reason: "readback" };
}
return {
status: "configured",
...(backupPath === undefined ? {} : { backupPath }),
};
};
/** Transactionally install or upgrade the versioned canonical REA skill. */
export const installCanonicalSkill = async (
home: string,
+199
View File
@@ -0,0 +1,199 @@
import { copyFile, lstat, readFile, rm } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, isAbsolute, join } from "node:path";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import writeFileAtomic from "write-file-atomic";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { supportedClients, type SetupClient } from "./Setup.js";
/** One explicitly classified uninstall action. */
export interface UninstallItem {
readonly name: string;
readonly status: "removed" | "retained" | "skipped" | "failed";
readonly detail: string;
}
/** Structured, idempotent result of removing REA-owned state. */
export interface UninstallResult {
readonly status: "complete" | "failed";
readonly items: readonly UninstallItem[];
}
/** Filesystem boundary used by the contained uninstall workflow. */
export interface UninstallHost {
clients(): Promise<readonly SetupClient[]>;
removeClient(client: SetupClient): Promise<UninstallItem>;
removeSkill(): Promise<UninstallItem>;
purgeData(): Promise<readonly UninstallItem[]>;
}
/** Remove only REA-owned registrations and managed files, optionally including local state. */
export const runUninstall = async (
purgeData: boolean,
host: UninstallHost = systemUninstallHost(),
): Promise<UninstallResult> => {
const items: UninstallItem[] = [];
for (const client of await host.clients())
items.push(await host.removeClient(client));
items.push(await host.removeSkill());
if (purgeData) items.push(...(await host.purgeData()));
items.push({
name: "analysis_engine",
status: "retained",
detail: "Hopper is not owned by REA uninstall.",
});
return {
status: items.some(({ status }) => status === "failed")
? "failed"
: "complete",
items,
};
};
/** Create uninstall effects contained to detected client configs and REA-owned paths. */
export const systemUninstallHost = (home = homedir()): UninstallHost => ({
clients: () => Promise.resolve(supportedClients(home)),
removeClient,
removeSkill: () =>
removeManagedPath(
join(home, ".agents/skills", PRODUCT_IDENTITY.skillName),
"skill",
),
purgeData: async () => [
await removeManagedPath(join(home, ".rea/cache"), "cache"),
await removeManagedPath(join(home, ".rea/state"), "state"),
],
});
const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
if (client.format === "unsupported")
return item(
client.name,
"skipped",
"This client has no documented local MCP configuration boundary.",
);
let original: string;
try {
original = await readFile(client.configPath, "utf8");
} catch (cause: unknown) {
return isMissing(cause)
? item(client.name, "skipped", "Configuration does not exist.")
: item(client.name, "failed", "Configuration could not be read.");
}
let document: Record<string, unknown>;
let key: "mcp_servers" | "mcpServers";
let servers: Record<string, unknown>;
try {
document = objectSchema.parse(
client.format === "toml" ? parseToml(original) : JSON.parse(original),
);
key = client.format === "toml" ? "mcp_servers" : "mcpServers";
servers = optionalObject(document[key]);
} catch {
return item(
client.name,
"failed",
"Malformed configuration was preserved without mutation.",
);
}
const registration = servers[PRODUCT_IDENTITY.mcpServerKey];
if (registration === undefined)
return item(client.name, "skipped", "REA registration is absent.");
if (!isOwnedRegistration(registration))
return item(
client.name,
"retained",
"The rea-named registration is not REA-owned.",
);
const remaining = { ...servers };
delete remaining[PRODUCT_IDENTITY.mcpServerKey];
document[key] = remaining;
const backupPath = `${client.configPath}.rea.backup`;
try {
await copyFile(client.configPath, backupPath);
} catch {
return item(
client.name,
"failed",
"Configuration backup failed; no mutation was attempted.",
);
}
try {
const encoded =
client.format === "toml"
? stringifyToml(document)
: `${JSON.stringify(document, null, 2)}\n`;
await writeFileAtomic(client.configPath, encoded, { encoding: "utf8" });
const readback = objectSchema.parse(
client.format === "toml"
? parseToml(await readFile(client.configPath, "utf8"))
: JSON.parse(await readFile(client.configPath, "utf8")),
);
if (PRODUCT_IDENTITY.mcpServerKey in optionalObject(readback[key]))
throw new Error("registration readback mismatch");
return item(
client.name,
"removed",
`Removed registration from ${client.configPath}.`,
);
} catch {
await writeFileAtomic(client.configPath, original, {
encoding: "utf8",
}).catch(() => undefined);
return item(
client.name,
"failed",
`Configuration update failed; the original was restored and ${backupPath} was retained.`,
);
}
};
/** Ownership rule for persistent MCP registrations created by REA setup. */
const isOwnedRegistration = (value: unknown): boolean => {
const parsed = registrationSchema.safeParse(value);
if (!parsed.success) return false;
const { command, args } = parsed.data;
return (
((command === "rea" ||
(isAbsolute(command) && basename(command) === "rea")) &&
args.length === 1 &&
args[0] === "mcp") ||
(command === "npx" &&
JSON.stringify(args) ===
JSON.stringify(["-y", PRODUCT_IDENTITY.packageName, "mcp"]))
);
};
const removeManagedPath = async (
path: string,
name: string,
): Promise<UninstallItem> => {
try {
const stats = await lstat(path);
if (stats.isSymbolicLink())
return item(name, "retained", `Refused to follow symlink ${path}.`);
await rm(path, { recursive: true });
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.`);
}
};
const item = (
name: string,
status: UninstallItem["status"],
detail: string,
): UninstallItem => ({ name, status, detail });
const objectSchema = z.record(z.string(), z.unknown());
const optionalObject = (value: unknown): Record<string, unknown> =>
value === undefined ? {} : objectSchema.parse(value);
const registrationSchema = z
.object({ command: z.string(), args: z.array(z.string()) })
.passthrough();
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
+210 -92
View File
@@ -5,16 +5,16 @@ import { runDoctor } from "./application/Doctor.js";
import {
runDirectAnalysis,
runProviderAnalysis,
runSessionStatus,
} from "./application/DirectAnalysis.js";
import { runSetup } from "./application/Setup.js";
import { runUninstall } from "./application/Uninstall.js";
import { PRODUCT_IDENTITY } from "./identity.js";
import { createLogger, parseLogLevel, type Logger } from "./logger.js";
import { logCliCommand } from "./cliLogging.js";
import { parseConfig } from "./config.js";
import {
exportEvidenceBundleCommand,
importEvidenceBundleCommand,
} from "./application/EvidenceBundleCommands.js";
import { importReferenceSource } from "./application/ReferenceSourceImport.js";
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
/**
* Build the one-shot Incur CLI without starting Hopper at import time.
@@ -29,7 +29,7 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
: parseLogLevel(process.env.REA_LOG_LEVEL),
);
const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, {
version: "0.1.0",
version: process.env.REA_PACKAGE_VERSION ?? "0.0.0-development",
description:
"Reverse engineer anything from your terminal or coding agent.",
mcp: {
@@ -48,6 +48,27 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
},
});
registerCoreCommands(cli, logger);
registerFunctionCommand(cli, logger);
registerSearchCommand(cli, logger);
registerXrefsCommand(cli, logger);
registerTraceCommand(cli, logger);
registerCapabilityCommands(cli, logger);
registerNativeCommands(cli, logger);
registerArtifactCommands(cli, logger);
registerEvidenceCommands(cli, logger);
registerReferenceSourceCommand(cli, logger);
return cli;
};
const registerCoreCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
const overviewOptions = z.object({
detail: z.enum(["concise", "detailed"]).default("concise"),
limit: z.number().int().min(1).max(50).default(10),
});
cli.command("setup", {
description: "Install requirements and configure coding agents",
options: z.object({
@@ -68,14 +89,48 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
run: ({ options }) =>
logCliCommand(logger, "doctor", () => runDoctor(options.target)),
});
cli.command("uninstall", {
description: "Remove REA-owned agent configuration and skill files",
options: z.object({
purgeData: z
.boolean()
.default(false)
.describe("Also remove REA caches and state"),
}),
alias: { purgeData: "purge-data" },
run: ({ options }) =>
logCliCommand(logger, "uninstall", () => runUninstall(options.purgeData)),
});
cli.command("analyze", {
description: "Get an overview of an app",
args: z.object({
path: z.string().describe("App, program, or Hopper database path"),
}),
run: ({ args }) =>
options: overviewOptions,
run: ({ args, options }) =>
logCliCommand(logger, "analyze", () =>
runDirectAnalysis(args.path, "binary_overview", {}, logger),
runDirectAnalysis(
args.path,
"binary_overview",
{ detail: options.detail, limit: options.limit },
logger,
),
),
});
cli.command("inspect", {
description: "Inspect an app overview with evidence",
args: z.object({
path: z.string().describe("App, program, or Hopper database path"),
}),
options: overviewOptions,
run: ({ args, options }) =>
logCliCommand(logger, "inspect", () =>
runDirectAnalysis(
args.path,
"binary_overview",
{ detail: options.detail, limit: options.limit },
logger,
),
),
});
cli.command("decompile", {
@@ -94,11 +149,154 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
),
),
});
registerNativeCommands(cli, logger);
registerArtifactCommands(cli, logger);
registerEvidenceCommands(cli, logger);
registerReferenceSourceCommand(cli, logger);
return cli;
};
const registerXrefsCommand = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("xrefs", {
description: "List bounded references to an analyzed address",
args: z.object({
path: z.string().describe("App or program path"),
address: z.string().describe("Hexadecimal address"),
}),
run: ({ args }) =>
logCliCommand(logger, "xrefs", () =>
runDirectAnalysis(
args.path,
"xrefs",
{ address: args.address },
logger,
),
),
});
};
const registerTraceCommand = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("trace", {
description: "Trace a bounded literal feature through analyzed references",
args: z.object({
path: z.string().describe("App or program path"),
query: z.string().min(1).describe("Literal feature query"),
}),
options: z.object({
caseSensitive: z.boolean().default(false),
limit: z.number().int().min(1).max(100).default(20),
maxOperations: z.number().int().min(1).max(100).default(20),
}),
alias: {
caseSensitive: "case-sensitive",
maxOperations: "max-operations",
},
run: ({ args, options }) =>
logCliCommand(logger, "trace", () =>
runDirectAnalysis(
args.path,
"trace_feature",
{
query: args.query,
case_sensitive: options.caseSensitive,
limit: options.limit,
max_operations: options.maxOperations,
},
logger,
),
),
});
};
const registerCapabilityCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
for (const command of ["capabilities", "providers"] as const) {
cli.command(command, {
description:
command === "capabilities"
? "List provider capabilities and side effects"
: "List configured analysis providers",
run: () => logCliCommand(logger, command, () => runSessionStatus(logger)),
});
}
};
const registerFunctionCommand = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("function", {
description: "Analyze one bounded function with evidence",
args: z.object({
path: z.string().describe("App or program path"),
address: z.string().describe("Procedure name or address"),
}),
options: z.object({
includeAssembly: z.boolean().default(false),
limit: z.number().int().min(1).max(500).default(100),
maxPseudocodeChars: z.number().int().min(1).max(100_000).default(20_000),
maxInstructions: z.number().int().min(1).max(5_000).default(500),
}),
alias: {
includeAssembly: "include-assembly",
maxPseudocodeChars: "max-pseudocode-chars",
maxInstructions: "max-instructions",
},
run: ({ args, options }) =>
logCliCommand(logger, "function", () =>
runDirectAnalysis(
args.path,
"analyze_function",
{
procedure: args.address,
include_assembly: options.includeAssembly,
limit: options.limit,
max_pseudocode_chars: options.maxPseudocodeChars,
max_instructions: options.maxInstructions,
},
logger,
),
),
});
};
const registerSearchCommand = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("search", {
description: "Search bounded analyzed strings or procedure names",
args: z.object({
path: z.string().describe("App or program path"),
pattern: z.string().min(1).describe("Literal text or regex pattern"),
}),
options: z.object({
kind: z.enum(["strings", "procedures"]).default("strings"),
mode: z.enum(["literal", "regex"]).default("literal"),
caseSensitive: z.boolean().default(false),
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(100).default(100),
}),
alias: { caseSensitive: "case-sensitive" },
run: ({ args, options }) =>
logCliCommand(logger, "search", () =>
runDirectAnalysis(
args.path,
options.kind === "strings" ? "search_strings" : "search_procedures",
{
pattern: args.pattern,
mode: options.mode,
case_sensitive: options.caseSensitive,
offset: options.offset,
limit: options.limit,
},
logger,
),
),
});
};
const registerReferenceSourceCommand = (
@@ -240,83 +438,3 @@ const registerNativeCommands = (
),
});
};
const registerEvidenceCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("evidence-import", {
description: "Validate and import a bounded local Evidence v2 bundle",
args: z.object({
path: z.string().describe("Evidence bundle JSON path"),
}),
run: ({ args }) =>
logCliCommand(logger, "evidence-import", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const imported = await importEvidenceBundleCommand(
args.path,
config.value.evidenceFilePolicy,
);
return imported.ok
? imported.value
: { error: imported.error._tag, message: imported.error.message };
}),
});
cli.command("evidence-export", {
description: "Validate and atomically export canonical Evidence v2 JSON",
args: z.object({
source: z.string().describe("Existing evidence bundle JSON path"),
output: z.string().describe("Canonical output JSON path"),
}),
options: z.object({
overwrite: z.boolean().default(false).describe("Replace output file"),
}),
run: ({ args, options }) =>
logCliCommand(logger, "evidence-export", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const exported = await exportEvidenceBundleCommand(
args.source,
args.output,
options.overwrite,
config.value.evidenceFilePolicy,
);
return exported.ok
? exported.value
: { error: exported.error._tag, message: exported.error.message };
}),
});
};
const logCliCommand = async <Value>(
logger: Logger,
command: string,
execute: () => Promise<Value>,
): Promise<Value> => {
const startedAt = performance.now();
try {
const value = await execute();
logger.info(
{
command,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
status: "ok",
},
"CLI command completed",
);
return value;
} catch (cause: unknown) {
logger.error(
{
command,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
status: "error",
},
"CLI command failed",
);
throw cause;
}
};
+89
View File
@@ -0,0 +1,89 @@
import { Cli, z } from "incur";
import {
compareEvidenceBundlesCommand,
exportEvidenceBundleCommand,
importEvidenceBundleCommand,
} from "./application/EvidenceBundleCommands.js";
import { parseConfig } from "./config.js";
import { logCliCommand } from "./cliLogging.js";
import type { Logger } from "./logger.js";
/** Register filesystem-gated Evidence v2 commands. */
export const registerEvidenceCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("evidence-import", {
description: "Validate and import a bounded local Evidence v2 bundle",
args: z.object({
path: z.string().describe("Evidence bundle JSON path"),
}),
run: ({ args }) =>
logCliCommand(logger, "evidence-import", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const imported = await importEvidenceBundleCommand(
args.path,
config.value.evidenceFilePolicy,
);
return imported.ok
? imported.value
: { error: imported.error._tag, message: imported.error.message };
}),
});
cli.command("evidence-export", {
description: "Validate and atomically export canonical Evidence v2 JSON",
args: z.object({
source: z.string().describe("Existing evidence bundle JSON path"),
output: z.string().describe("Canonical output JSON path"),
}),
options: z.object({
overwrite: z.boolean().default(false).describe("Replace output file"),
}),
run: ({ args, options }) =>
logCliCommand(logger, "evidence-export", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const exported = await exportEvidenceBundleCommand(
args.source,
args.output,
options.overwrite,
config.value.evidenceFilePolicy,
);
return exported.ok
? exported.value
: { error: exported.error._tag, message: exported.error.message };
}),
});
cli.command("compare", {
aliases: ["compare-bundles"],
description: "Compare two canonical Evidence v2 bundles",
args: z.object({
left: z.string().describe("Left Evidence bundle JSON path"),
right: z.string().describe("Right Evidence bundle JSON path"),
}),
options: z.object({
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(500).default(100),
}),
run: ({ args, options }) =>
logCliCommand(logger, "compare", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const compared = await compareEvidenceBundlesCommand({
leftPath: args.left,
rightPath: args.right,
offset: options.offset,
limit: options.limit,
policy: config.value.evidenceFilePolicy,
});
return compared.ok
? compared.value
: { error: compared.error._tag, message: compared.error.message };
}),
});
};
+32
View File
@@ -0,0 +1,32 @@
import type { Logger } from "./logger.js";
/** Log one CLI command with duration and a stable success or failure status. */
export const logCliCommand = async <Value>(
logger: Logger,
command: string,
execute: () => Promise<Value>,
): Promise<Value> => {
const startedAt = performance.now();
try {
const value = await execute();
logger.info(
{
command,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
status: "ok",
},
"CLI command completed",
);
return value;
} catch (cause: unknown) {
logger.error(
{
command,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
status: "error",
},
"CLI command failed",
);
throw cause;
}
};
+5 -1
View File
@@ -9,6 +9,10 @@ import type { ReferenceSourcePolicy } from "./domain/referenceSourcePolicy.js";
const DEFAULT_HOPPER_LAUNCHER_PATH =
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
const defaultHopperLauncherPath = (): string =>
process.platform === "linux"
? "/opt/hopper/bin/Hopper"
: DEFAULT_HOPPER_LAUNCHER_PATH;
export interface AppConfig {
readonly hopperLauncherPath: string;
@@ -134,7 +138,7 @@ export const parseConfig = (
return ok({
hopperLauncherPath:
parsedEnvironment.data.HOPPER_LAUNCHER_PATH ??
DEFAULT_HOPPER_LAUNCHER_PATH,
defaultHopperLauncherPath(),
hopperTargetPath: parsedEnvironment.data.HOPPER_TARGET_PATH,
hopperTargetKind: parsedEnvironment.data.HOPPER_TARGET_KIND,
hopperLoaderArgs: parsedArgs.data,
+7 -5
View File
@@ -87,12 +87,14 @@ const providerCapability = z.object({
}),
limitations: z.array(z.string()),
});
const providerIdentity = z.object({
id: z.string(),
name: z.string(),
version: z.string().nullable(),
});
const sessionProvider = z.object({
provider: z.object({
id: z.string(),
name: z.string(),
version: z.string().nullable(),
}),
provider: providerIdentity,
providers: z.array(providerIdentity),
capabilities: z.array(providerCapability),
});
const nullableText = z.string().nullable();
+12 -1
View File
@@ -216,6 +216,12 @@ export class HopperProtocolError extends HopperError {
readonly _tag = "HopperProtocolError";
}
export type HopperDiagnosticType =
| "remote"
| "authorization"
| "invalid_request"
| "bridge_exception";
/** Hopper's JSON-RPC endpoint returned an expected remote error response. */
export class HopperRemoteError extends HopperError {
readonly _tag = "HopperRemoteError";
@@ -223,6 +229,7 @@ export class HopperRemoteError extends HopperError {
constructor(
readonly code: number,
readonly safeMessage: string,
readonly diagnosticType: HopperDiagnosticType = "remote",
) {
super(`Hopper request failed (${String(code)}): ${safeMessage}`);
}
@@ -322,7 +329,11 @@ const safeDetails = (
if (error instanceof HopperTimeoutError)
return { timeoutMs: error.timeoutMs };
if (error instanceof HopperRemoteError)
return { code: error.code, safeMessage: error.safeMessage };
return {
code: error.code,
safeMessage: error.safeMessage,
diagnosticType: error.diagnosticType,
};
if (error instanceof HopperProcessError) return { exitCode: error.exitCode };
return {};
};
+25 -5
View File
@@ -37,11 +37,31 @@ const segmentSchema = z.object({
writable: z.boolean().nullable().default(null),
executable: z.boolean().nullable().default(null),
});
const addressedPageSchema = z.object({
items: z.array(z.object({ address: z.string(), value: z.string() })),
next_offset: z.number().int().min(0).nullable(),
has_more: z.boolean(),
});
const addressedPageSchema = z
.object({
items: z.array(z.object({ address: z.string(), value: z.string() })),
offset: z.number().int().min(0),
limit: z.number().int().min(1),
total: z.number().int().min(0),
next_offset: z.number().int().min(0).nullable(),
has_more: z.boolean(),
})
.superRefine((value, context) => {
if (value.has_more && value.next_offset === null) {
context.addIssue({
code: "custom",
message: "a page with more results must provide next_offset",
path: ["next_offset"],
});
}
if (!value.has_more && value.next_offset !== null) {
context.addIssue({
code: "custom",
message: "a complete page must not provide next_offset",
path: ["next_offset"],
});
}
});
const unavailableSchema = z
.object({ available: z.literal(false), reason: z.string() })
.strict();
+18 -10
View File
@@ -141,22 +141,18 @@ export class HopperApplicationLauncher implements BridgeLauncher {
{ encoding: "utf8", mode: 0o600 },
);
} catch (cause: unknown) {
await cleanupOwnedProcessGroup({
runId: session.runId,
leaderPid: pid,
processGroupId: pid,
});
await cleanupOwnedProcessGroup(
ownedProcessGroup(session, pid, this.options.launcherPath),
);
return err(new HopperStartError({ cause }));
}
return ok({
process: child,
ownsProcessLifetime: true,
cleanup: () =>
cleanupOwnedProcessGroup({
runId: session.runId,
leaderPid: pid,
processGroupId: pid,
}),
cleanupOwnedProcessGroup(
ownedProcessGroup(session, pid, this.options.launcherPath),
),
});
} catch (cause: unknown) {
return err(new HopperStartError({ cause }));
@@ -164,6 +160,18 @@ export class HopperApplicationLauncher implements BridgeLauncher {
}
}
const ownedProcessGroup = (
session: BridgeSession,
pid: number,
launcherPath: string,
) => ({
runId: session.runId,
leaderPid: pid,
processGroupId: pid,
expectedCommand: launcherPath,
expectedParentPid: process.pid,
});
const prepareHopperApplication = async (
launcherPath: string,
signal?: AbortSignal,
+19 -2
View File
@@ -11,7 +11,18 @@ const responseSchema = z.union([
}),
z.object({
id: z.number().int().nonnegative(),
error: z.object({ code: z.number().int(), message: z.string() }),
error: z.object({
code: z.number().int(),
message: z.string(),
type: z
.enum([
"remote",
"authorization",
"invalid_request",
"bridge_exception",
])
.default("remote"),
}),
}),
]);
@@ -46,5 +57,11 @@ export const responseResult = (
response: HopperResponse,
): Result<JsonValue, HopperRemoteError> =>
"error" in response
? err(new HopperRemoteError(response.error.code, response.error.message))
? err(
new HopperRemoteError(
response.error.code,
response.error.message,
response.error.type,
),
)
: ok(response.result);
+9 -6
View File
@@ -119,11 +119,14 @@ describe("artifact comparison", () => {
it("assembles bounded page sets for graphs larger than one page", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-artifact-pages-"));
for (let index = 0; index < 501; index += 1)
await writeFile(
join(root, `file-${String(index).padStart(3, "0")}.txt`),
String(index),
);
await Promise.all(
Array.from({ length: 501 }, async (_, index) =>
writeFile(
join(root, `file-${String(index).padStart(3, "0")}.txt`),
String(index),
),
),
);
const pages = await Promise.all([
observe(root),
observe(root, {
@@ -137,7 +140,7 @@ describe("artifact comparison", () => {
summary: { unchanged: 502, unknown: 0 },
changes: { total: 0 },
});
});
}, 15_000);
it("rejects non-inventory and tampered Evidence", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-artifact-invalid-"));
+1 -1
View File
@@ -294,7 +294,7 @@ describe("artifact graph provider", () => {
offset: 500,
next_offset: null,
});
});
}, 15_000);
it("uses bounded native lipo metadata for universal slice ranges", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-slices-"));
+1
View File
@@ -79,6 +79,7 @@ describe("binary session", () => {
expect(provider.capabilities()[0]?.operation).toBe("address_name");
expect(session.status()).toMatchObject({
provider: { id: "fixture", name: "Fixture", version: "1" },
providers: [{ id: "fixture", name: "Fixture", version: "1" }],
capabilities: [
{
operation: "address_name",
+11
View File
@@ -73,6 +73,11 @@ describe("Hopper bridge truthfulness", () => {
expect(bridgeSource).toContain("MAX_SEARCH_PATTERN_LENGTH = 256");
expect(bridgeSource).toContain("MAX_SEARCH_VALUE_LENGTH = 4096");
expect(bridgeSource).toContain('"value_truncated"');
expect(bridgeSource).toContain("page_end = offset + limit");
expect(bridgeSource).toContain("if offset <= total < page_end:");
expect(bridgeSource).not.toContain(
"matching = [item for item in _search_inventory(document, kind)",
);
});
it("caches sorted search inventories and invalidates them after renames", () => {
@@ -80,4 +85,10 @@ describe("Hopper bridge truthfulness", () => {
expect(bridgeSource).toContain("tuple(sorted(values.items()");
expect(bridgeSource).toContain("_invalidate_search_inventory(document)");
});
it("classifies bridge exceptions without forwarding tracebacks", () => {
expect(bridgeSource).toContain("def _diagnostic_type(error):");
expect(bridgeSource).toContain('"type": _diagnostic_type(error)');
expect(bridgeSource).not.toContain("traceback.format_exc");
});
});
+8 -2
View File
@@ -10,7 +10,11 @@ describe("runtime configuration", () => {
const result = parseConfig({ HOPPER_TARGET_PATH: "/usr/bin/true" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.hopperLauncherPath).toContain("/MacOS/hopper");
expect(result.value.hopperLauncherPath).toBe(
process.platform === "linux"
? "/opt/hopper/bin/Hopper"
: "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
);
expect(result.value.hopperTargetKind).toBe("executable");
expect(result.value.hopperLoaderArgs).toEqual([]);
expect(result.value.logLevel).toBe("info");
@@ -36,7 +40,9 @@ describe("runtime configuration", () => {
ok: true,
value: {
hopperLauncherPath:
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
process.platform === "linux"
? "/opt/hopper/bin/Hopper"
: "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
hopperTargetPath: "/fixture/sample.hop",
hopperTargetKind: "database",
hopperLoaderArgs: ["-l", "FAT", "--aarch64", "-l", "Mach-O"],
+24 -4
View File
@@ -3,8 +3,9 @@ import { runDoctor, type DoctorHost } from "../src/application/Doctor.js";
const host = (overrides: Partial<DoctorHost> = {}): DoctorHost => ({
platform: "darwin",
nodeVersion: "22.1.0",
nodeVersion: "24.18.0",
macosVersion: () => Promise.resolve("12.0"),
linuxDistribution: () => Promise.resolve(undefined),
validTarget: (path) => Promise.resolve(path.includes("Hopper")),
executable: (path) => Promise.resolve(path.includes("Hopper")),
brewHopperPath: () => Promise.resolve(undefined),
@@ -20,9 +21,7 @@ describe("doctor", () => {
undefined,
host({ macosVersion: () => Promise.resolve(version) }),
);
expect(result.checks.find(({ name }) => name === "macos")?.ok).toBe(
false,
);
expect(result.checks.find(({ name }) => name === "host")?.ok).toBe(false);
},
);
it("detects a manual configured Hopper before Homebrew", async () => {
@@ -36,6 +35,27 @@ describe("doctor", () => {
expect(result.hopperPath).toBe("/manual/Hopper");
expect(result.healthy).toBe(true);
});
it("accepts an officially supported Linux distribution", async () => {
const path = "/home/user/.local/share/rea/hopper/bin/Hopper";
const result = await runDoctor(
undefined,
host({
platform: "linux",
macosVersion: () => Promise.resolve(undefined),
linuxDistribution: () =>
Promise.resolve({
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
}),
configuredHopperPath: path,
executable: (candidate) => Promise.resolve(candidate === path),
}),
);
expect(result.healthy).toBe(true);
expect(result.hopperPath).toBe(path);
});
it("detects a Homebrew cask installed outside /Applications", async () => {
const path =
"/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper";
+65
View File
@@ -3,6 +3,7 @@ import type { CallToolResult } from "@modelcontextprotocol/server";
import { afterEach, describe, expect, it } from "vitest";
import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js";
import { EnhancedTools } from "../src/application/EnhancedTools.js";
import { ENHANCED_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
import { observed as ok } from "./fixtures/analysisExecution.js";
import { jsonValueSchema, type JsonValue } from "../src/domain/jsonValue.js";
@@ -304,6 +305,38 @@ describe("enhanced MCP tools", () => {
expect(result).toMatchObject({ count: 2 });
});
it("does not start another page after cancellation", async () => {
const controller = new AbortController();
let calls = 0;
const analysis: AnalysisOperationPort = {
execute: () => {
calls += 1;
controller.abort();
return Promise.resolve(
ok({
items: [{ address: "0x1", value: "_TtC5First" }],
offset: 0,
limit: 500,
total: 2,
next_offset: 500,
has_more: true,
}),
);
},
};
const result = await new EnhancedTools(analysis).execute(
"swift_classes",
{},
controller.signal,
);
expect(calls).toBe(1);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
});
it("follows name pagination for exhaustive Objective-C discovery", async () => {
const offsets: number[] = [];
const client = await connect({
@@ -534,4 +567,36 @@ describe("enhanced MCP tools", () => {
"AnalysisOutputError",
);
});
it("accepts a final dossier page whose total exceeds returned", async () => {
const malformedPort = fixturePort();
const client = await connect({
execute: async (name, arguments_, options) => {
const result = await malformedPort.execute(name, arguments_, options);
if (!result.ok || name !== "analyze_function") return result;
const dossier = result.value.result;
if (
typeof dossier !== "object" ||
dossier === null ||
Array.isArray(dossier)
)
return result;
return ok({
...dossier,
callers: {
items: [],
total: 1,
returned: 0,
truncated: false,
next_offset: null,
},
});
},
});
const result = await client.callTool({
name: "analyze_function",
arguments: { procedure: "0x1" },
});
expect(result.isError).not.toBe(true);
});
});
+16
View File
@@ -15,6 +15,7 @@ import {
readEvidenceBundle,
writeEvidenceBundle,
} from "../src/application/EvidenceBundleFiles.js";
import { compareEvidenceBundlesCommand } from "../src/application/EvidenceBundleCommands.js";
import { createEvidence } from "../src/domain/evidence.js";
import {
createEvidenceBundle,
@@ -66,6 +67,21 @@ describe("evidence bundle filesystem adapter", () => {
ok: true,
value: evidenceBundle,
});
expect(
await compareEvidenceBundlesCommand({
leftPath: path,
rightPath: path,
offset: 0,
limit: 100,
policy: policy(directory),
}),
).toMatchObject({
ok: true,
value: {
status: "unchanged",
summary: { records_unchanged: 1 },
},
});
expect(
await writeEvidenceBundle(evidenceBundle, path, false, policy(directory)),
).toMatchObject({
+100 -1
View File
@@ -1,6 +1,99 @@
import { createServer } from "node:net";
const [socketPath, token, runId] = process.argv.slice(2);
const enhancedFixtureResult = (method) => {
switch (method) {
case "list_segments":
return [
{
name: "__TEXT",
start: "0x1000",
end: "0x2000",
readable: true,
writable: false,
executable: true,
},
];
case "list_documents":
return ["fixture"];
case "list_procedures":
return {
items: [{ address: "0x1000", value: "fixture" }],
offset: 0,
limit: 500,
total: 1,
next_offset: null,
has_more: false,
};
case "list_strings":
return {
items: [{ address: "0x1000", value: "fixture" }],
offset: 0,
limit: 500,
total: 1,
next_offset: null,
has_more: false,
};
case "procedure_pseudo_code":
return "return 0;";
case "analyze_function": {
const empty = () => ({
items: [],
total: 0,
returned: 0,
truncated: false,
next_offset: null,
});
return {
procedure: {
address: "0x1000",
name: "fixture",
signature: null,
locals: [],
},
pseudocode: {
text: "return 0;",
total_chars: 9,
returned_chars: 9,
truncated: false,
next_offset: null,
},
assembly: { ...empty(), items: ["ret"], total: 1, returned: 1 },
comments: empty(),
callers: empty(),
callees: empty(),
incoming_references: empty(),
outgoing_references: empty(),
referenced_strings: empty(),
referenced_names: empty(),
basic_blocks: {
...empty(),
items: [{ start: "0x1000", end: "0x1001", successors: [] }],
total: 1,
returned: 1,
},
instruction_scan: { scanned: 1, truncated: false },
};
}
case "search_strings":
case "search_procedures":
return {
items: [
{ address: "0x1000", value: "fixture", value_truncated: false },
],
offset: 0,
limit: 100,
total: 1,
next_offset: null,
has_more: false,
};
case "xrefs":
return ["0x1000"];
default:
return undefined;
}
};
const server = createServer((socket) => {
socket.on("error", () => undefined);
socket.setEncoding("utf8");
@@ -46,10 +139,16 @@ const server = createServer((socket) => {
} else if (request.method === "remote_error") {
send({
id: request.id,
error: { code: -32001, message: "safe fake failure" },
error: {
code: -32001,
message: "safe fake failure",
type: "bridge_exception",
},
});
} else if (request.method === "current_document") {
send({ id: request.id, result: "fixture" });
} else if (enhancedFixtureResult(request.method) !== undefined) {
send({ id: request.id, result: enhancedFixtureResult(request.method) });
} else if (request.method === "resolve_containing_procedure") {
send({
id: request.id,
+11
View File
@@ -131,6 +131,17 @@ describe("HopperClient", () => {
if (!result.ok) expect(result.error._tag).toBe(expectedTag);
});
it("preserves a sanitized bridge exception diagnostic", async () => {
const client = await startClient();
const result = await client.callTool("remote_error");
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toMatchObject({
_tag: "HopperRemoteError",
diagnosticType: "bridge_exception",
});
});
it("cancels and ignores late responses without corrupting the session", async () => {
const client = await startClient();
const controller = new AbortController();
+24
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
parseDocuments,
parseAddressedPage,
parseListCount,
parseNames,
parseProcedures,
@@ -62,6 +63,29 @@ describe("Hopper boundary values", () => {
});
});
it.each([
{
items: [],
offset: 0,
limit: 100,
total: 1,
next_offset: null,
has_more: true,
},
{
items: [],
offset: 0,
limit: 100,
total: 0,
next_offset: 100,
has_more: false,
},
])("rejects contradictory page continuation metadata", (page) => {
const result = parseAddressedPage(page);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error._tag).toBe("HopperProtocolError");
});
it.each([
["procedures", () => parseProcedures(["not-a-map"])],
["names", () => parseNames([{ address: 1, name: "bad" }])],
+199
View File
@@ -0,0 +1,199 @@
import { execFile } from "node:child_process";
import {
chmod,
mkdtemp,
mkdir,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
const execFileAsync = promisify(execFile);
const roots: string[] = [];
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
describe("curl installer scenarios", () => {
it("installs a pinned release with closed stdin and cleans temporary files", async () => {
const fixture = await createFixture();
const result = await runInstaller(fixture, { REA_VERSION: "0.3.0" });
expect(result.stdout).toContain("REA 0.3.0 is installed and ready");
expect(await readFile(fixture.npmLog, "utf8")).toContain(
"rea-agents@0.3.0",
);
expect(await readdir(fixture.temporary)).toEqual([]);
});
it("resolves and validates the latest rea-agents release tag", async () => {
const fixture = await createFixture();
const result = await runInstaller(fixture);
expect(result.stdout).toContain("Installing rea-agents@0.3.0");
});
it("bootstraps checksum-verified Node 24 into the user prefix", async () => {
const fixture = await createFixture();
const result = await runInstaller(fixture, {
REA_VERSION: "0.3.0",
FAKE_OLD_NODE: "1",
FAKE_NODE_ARCHIVE: fixture.nodeArchive,
FAKE_NODE_SUMS: fixture.nodeSums,
});
expect(result.stdout).toContain("REA 0.3.0 is installed and ready");
expect(
await readFile(
join(fixture.home, ".local/share/rea/node/bin/node"),
"utf8",
),
).toContain("24.18.0");
});
it("reports fresh Hopper activation as partial success", async () => {
const fixture = await createFixture();
const result = await runInstaller(fixture, {
REA_VERSION: "0.3.0",
FAKE_SETUP_ACTIONS: '["installed_hopper"]',
});
expect(result.stdout).toContain("One user action remains");
expect(result.stdout).toContain("open Hopper");
});
it.each([
[
"injected version",
{ REA_VERSION: "0.3.0;touch /tmp/pwned" },
"exact semantic version",
],
[
"npm failure",
{ REA_VERSION: "0.3.0", FAKE_NPM_FAIL: "1" },
"npm package installation failed",
],
[
"version mismatch",
{ REA_VERSION: "0.3.0", FAKE_REA_VERSION: "9.9.9" },
"does not match",
],
[
"malformed setup",
{ REA_VERSION: "0.3.0", FAKE_SETUP_JSON: "not-json" },
"malformed output",
],
[
"unhealthy doctor",
{ REA_VERSION: "0.3.0", FAKE_DOCTOR_HEALTHY: "false" },
"unhealthy required component",
],
] as const)("fails closed on %s", async (_name, overrides, message) => {
const fixture = await createFixture();
await expect(runInstaller(fixture, overrides)).rejects.toMatchObject({
stderr: expect.stringContaining(message),
});
});
it("is safe to rerun", async () => {
const fixture = await createFixture();
await runInstaller(fixture, { REA_VERSION: "0.3.0" });
await runInstaller(fixture, { REA_VERSION: "0.3.0" });
expect(
(await readFile(fixture.npmLog, "utf8")).trim().split("\n"),
).toHaveLength(2);
expect(await readdir(fixture.temporary)).toEqual([]);
});
});
interface InstallerFixture {
readonly home: string;
readonly bin: string;
readonly temporary: string;
readonly npmLog: string;
readonly nodeArchive: string;
readonly nodeSums: string;
}
const createFixture = async (): Promise<InstallerFixture> => {
const root = await mkdtemp(join(tmpdir(), "rea-install-test-"));
roots.push(root);
const home = join(root, "home");
const bin = join(root, "bin");
const temporary = join(root, "tmp");
const npmLog = join(root, "npm.log");
const nodeFixture = join(root, "node-v24.18.0-linux-x64");
const nodeArchive = `${nodeFixture}.tar.xz`;
const nodeSums = join(root, "SHASUMS256.txt");
await Promise.all([mkdir(home), mkdir(bin), mkdir(temporary)]);
const realNode = process.execPath;
await mkdir(join(nodeFixture, "bin"), { recursive: true });
await executable(
join(nodeFixture, "bin/node"),
`#!/bin/sh\nif [ "$1" = "-p" ]; then echo 24.18.0; else exec ${shellQuote(realNode)} "$@"; fi\n`,
);
await executable(
join(nodeFixture, "bin/npm"),
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo 11.16.0; exit 0; fi\nprintf "%s\\n" "$*" >> "$FAKE_NPM_LOG"\nexit 0\n',
);
await execFileAsync("tar", [
"-cJf",
nodeArchive,
"-C",
root,
"node-v24.18.0-linux-x64",
]);
const checksum = (
await execFileAsync("sha256sum", [nodeArchive])
).stdout.split(" ")[0];
await writeFile(nodeSums, `${checksum} node-v24.18.0-linux-x64.tar.xz\n`);
await executable(
join(bin, "uname"),
'#!/bin/sh\n[ "$1" = "-m" ] && echo x86_64 || echo Linux\n',
);
await executable(
join(bin, "node"),
`#!/bin/sh\nif [ "$1" = "-p" ]; then [ "\${FAKE_OLD_NODE:-}" = "1" ] && echo 20.0.0 || echo 24.18.0; else exec ${shellQuote(realNode)} "$@"; fi\n`,
);
await executable(
join(bin, "npm"),
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo 11.16.0; exit 0; fi\nprintf "%s\\n" "$*" >> "$FAKE_NPM_LOG"\n[ "${FAKE_NPM_FAIL:-}" = "1" ] && exit 1\nexit 0\n',
);
await executable(
join(bin, "curl"),
'#!/bin/sh\nout=""\nurl=""\nwhile [ "$#" -gt 0 ]; do\n if [ "$1" = "-o" ]; then shift; out="$1"; else url="$1"; fi\n shift\ndone\ncase "$url" in\n *node-v24.18.0-linux-x64.tar.xz) cp "$FAKE_NODE_ARCHIVE" "$out" ;;\n *SHASUMS256.txt) cp "$FAKE_NODE_SUMS" "$out" ;;\n *) printf \'{"tag_name":"rea-agents-0.3.0"}\' ;;\nesac\n',
);
await executable(
join(bin, "rea"),
'#!/bin/sh\ncase "$1" in\n--version) printf "%s\\n" "${FAKE_REA_VERSION:-0.3.0}" ;;\nsetup) if [ -n "${FAKE_SETUP_JSON:-}" ]; then printf "%s\\n" "$FAKE_SETUP_JSON"; else printf \'{"status":"ready","actions":%s}\\n\' "${FAKE_SETUP_ACTIONS:-[]}"; fi ;;\ndoctor) printf \'{"healthy":%s,"checks":[]}\\n\' "${FAKE_DOCTOR_HEALTHY:-true}" ;;\nesac\n',
);
return { home, bin, temporary, npmLog, nodeArchive, nodeSums };
};
const executable = async (path: string, source: string): Promise<void> => {
await writeFile(path, source);
await chmod(path, 0o755);
};
const runInstaller = async (
fixture: InstallerFixture,
overrides: Readonly<Record<string, string>> = {},
): Promise<{ readonly stdout: string; readonly stderr: string }> =>
execFileAsync("/bin/bash", [join(process.cwd(), "install.sh")], {
env: {
...process.env,
HOME: fixture.home,
TMPDIR: fixture.temporary,
PATH: `${fixture.bin}:${process.env.PATH ?? ""}`,
FAKE_NPM_LOG: fixture.npmLog,
...overrides,
},
});
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", `'"'"'`)}'`;
+3 -1
View File
@@ -8,7 +8,9 @@ describe("configuration parsing integration", () => {
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("unreachable");
expect(result.value.hopperLauncherPath).toBe(
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
process.platform === "linux"
? "/opt/hopper/bin/Hopper"
: "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
);
expect(result.value.hopperTargetPath).toBeUndefined();
expect(result.value.hopperTargetKind).toBe("executable");
+214
View File
@@ -0,0 +1,214 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
installLinuxHopper,
linuxPackageManagerCommand,
linuxSharedLibrariesAvailable,
parseLinuxDistribution,
type LinuxDistribution,
type LinuxHopperDownload,
type LinuxHopperInstallHost,
type LinuxPackageFamily,
} from "../src/application/LinuxHopper.js";
class RecordingLinuxHost implements LinuxHopperInstallHost {
distributionValue: LinuxDistribution | undefined = {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
};
metadata: unknown = releasesFor(new Uint8Array([1, 2, 3]));
archive = new Uint8Array([1, 2, 3]);
metadataOk = true;
archiveOk = true;
installSucceeds = true;
launcherExists = true;
downloads: string[] = [];
installed: Array<{ family: LinuxPackageFamily; archive: string }> = [];
cleaned: string[] = [];
distribution = (): Promise<LinuxDistribution | undefined> =>
Promise.resolve(this.distributionValue);
download = (url: string): Promise<LinuxHopperDownload> => {
this.downloads.push(url);
if (url.includes("files-api.php"))
return Promise.resolve({
ok: this.metadataOk,
bytes: new TextEncoder().encode(JSON.stringify(this.metadata)),
});
return Promise.resolve({ ok: this.archiveOk, bytes: this.archive });
};
createTemporaryDirectory = (): Promise<string> =>
Promise.resolve("/tmp/rea test");
writeArchive = (): Promise<void> => Promise.resolve();
installPackage = (
family: LinuxPackageFamily,
archive: string,
): Promise<boolean> => {
this.installed.push({ family, archive });
return Promise.resolve(this.installSucceeds);
};
launcherReady = (): Promise<boolean> => Promise.resolve(this.launcherExists);
cleanup = (path: string): Promise<void> => {
this.cleaned.push(path);
return Promise.resolve();
};
}
describe("Linux Hopper host classification", () => {
it.each([
['ID=ubuntu\nVERSION_ID="24.04"\n', "deb"],
["ID=fedora\nVERSION_ID=41\n", "rpm"],
["ID=arch\n", "arch"],
] as const)("accepts an official Hopper distribution", (document, family) => {
expect(parseLinuxDistribution(document)).toMatchObject({
packageFamily: family,
supported: true,
});
});
it("detects unresolved or loader-reported shared libraries", () => {
expect(
linuxSharedLibrariesAvailable(
"libQt6Network.so.6 => not found\nlibc.so.6 => /lib/libc.so.6",
),
).toBe(false);
expect(
linuxSharedLibrariesAvailable(
"error while loading shared libraries: libQt6Gui.so.6",
),
).toBe(false);
expect(
linuxSharedLibrariesAvailable(
"libQt6Network.so.6 => /usr/lib/libQt6Network.so.6",
),
).toBe(true);
});
it.each([
'ID=ubuntu\nVERSION_ID="22.04"\n',
"ID=fedora\nVERSION_ID=40\n",
'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n',
])("rejects unsupported vendor/version combinations", (document) => {
expect(parseLinuxDistribution(document).supported).toBe(false);
});
});
describe("Linux Hopper installation", () => {
it.each([
["deb", "apt-get"],
["rpm", "dnf"],
["arch", "pacman"],
] as const)("selects the %s native package manager", (family, executable) => {
expect(
linuxPackageManagerCommand(family, "/tmp/Hopper package", true),
).toMatchObject({
executable,
});
expect(
linuxPackageManagerCommand(family, "/tmp/Hopper package", false),
).toMatchObject({
executable: "pkexec",
args: [
executable,
expect.any(String),
expect.any(String),
expect.any(String),
],
});
});
it.each(["deb", "rpm", "arch"] as const)(
"downloads, verifies, installs, reads back, and cleans a %s package",
async (family) => {
const host = new RecordingLinuxHost();
host.distributionValue = {
id: family,
packageFamily: family,
supported: true,
};
const result = await installLinuxHopper(host);
expect(result).toEqual({
status: "installed",
launcherPath: "/opt/hopper/bin/Hopper",
});
expect(host.installed).toEqual([
{ family, archive: `/tmp/rea test/hopper.${family}` },
]);
expect(host.cleaned).toEqual(["/tmp/rea test"]);
},
);
it.each([
["unsupported host", "unsupported_host"],
["malformed metadata", "release_metadata"],
["unexpected origin", "release_metadata"],
["metadata request failure", "release_metadata"],
["package request failure", "download"],
["checksum mismatch", "integrity"],
["package manager failure", "authorization_or_package_manager"],
["missing launcher", "launcher_missing"],
] as const)("classifies %s", async (scenario, reason) => {
const host = new RecordingLinuxHost();
if (scenario === "unsupported host")
host.distributionValue = {
id: "debian",
packageFamily: "deb",
supported: false,
};
if (scenario === "malformed metadata") host.metadata = { invalid: true };
if (scenario === "unexpected origin") {
const metadata = releasesFor(host.archive);
const ubuntu = metadata["Ubuntu / Mint"];
if (ubuntu === undefined)
throw new Error("fixture omitted Ubuntu release");
ubuntu.filename = "https://example.com/Hopper.deb";
host.metadata = metadata;
}
if (scenario === "metadata request failure") host.metadataOk = false;
if (scenario === "package request failure") host.archiveOk = false;
if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]);
if (scenario === "package manager failure") host.installSucceeds = false;
if (scenario === "missing launcher") host.launcherExists = false;
expect(await installLinuxHopper(host)).toEqual({
status: "failed",
reason,
});
if (host.installed.length > 0)
expect(host.cleaned).toEqual(["/tmp/rea test"]);
});
it("cancels before network or mutation", async () => {
const host = new RecordingLinuxHost();
const controller = new AbortController();
controller.abort();
expect(
await installLinuxHopper(host, { signal: controller.signal }),
).toEqual({
status: "failed",
reason: "cancelled",
});
expect(host.downloads).toEqual([]);
expect(host.installed).toEqual([]);
});
});
function releasesFor(
bytes: Uint8Array,
): Record<
string,
{ filename: string; file_length: string; file_hash: string }
> {
const release = (extension: string) => ({
filename: `https://www.hopperapp.com:443/downloader/public/Hopper.${extension}`,
file_length: String(bytes.byteLength),
file_hash: createHash("sha1").update(bytes).digest("hex"),
});
return {
"Ubuntu / Mint": release("deb"),
Fedora: release("rpm"),
Arch: release("pkg.tar.xz"),
};
}
+36
View File
@@ -67,4 +67,40 @@ describe("owned process-group cleanup", () => {
signaled: false,
});
});
it("fails closed when the launcher command identity changes", async () => {
const { adapter, signalGroup } = host({
100: { REA_PROCESS_RUN_ID: "run-token" },
});
expect(
await cleanupOwnedProcessGroup(
{
...ownership,
expectedCommand: "/owned/hopper",
expectedParentPid: 1,
},
adapter,
),
).toEqual({
cleaned: false,
reason: "owned launcher command identity did not match",
});
expect(signalGroup).not.toHaveBeenCalled();
});
it("fails closed when the launcher parent identity changes", async () => {
const { adapter, signalGroup } = host({
100: { REA_PROCESS_RUN_ID: "run-token" },
});
expect(
await cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture", expectedParentPid: 999 },
adapter,
),
).toEqual({
cleaned: false,
reason: "owned launcher parent identity did not match",
});
expect(signalGroup).not.toHaveBeenCalled();
});
});
+50 -10
View File
@@ -19,28 +19,68 @@ const readmes = [
"README_ar.md",
] as const;
const expectedToolCounts = [
OFFICIAL_TOOL_CONTRACTS.length,
ENHANCED_TOOL_CONTRACTS.length,
NATIVE_TOOL_CONTRACTS.length,
ARTIFACT_TOOL_CONTRACTS.length,
SESSION_TOOL_CONTRACTS.length,
] as const;
const toolCountsFromReadme = (content: string, path: string): number[] => {
const lines = content.split(/\r?\n/u);
const headingIndex = lines.findIndex((line) => /^## .*68/u.test(line));
if (headingIndex === -1)
throw new Error(`Missing 68-tool heading in ${path}`);
const counts: number[] = [];
for (const line of lines.slice(headingIndex + 1)) {
if (line.startsWith("## ")) break;
if (!line.trim().startsWith("|") || /^\|\s*-/.test(line)) continue;
const cells = line.split("|").map((cell) => cell.trim());
const countCell = cells[2];
if (countCell === undefined)
throw new Error(`Malformed table row in ${path}`);
const count = Number(countCell);
if (!Number.isInteger(count)) {
if (counts.length === 0) continue;
throw new Error(`Non-numeric tool count in ${path}`);
}
counts.push(count);
}
return counts;
};
describe("localized README product facts", () => {
it.each(readmes)(
"keeps commands and requirements aligned in %s",
async (path) => {
const content = await readFile(resolve(path), "utf8");
expect(content).toContain("npx skills add morluto/rea");
expect(content).toContain(
"curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash",
);
expect(content).toContain("npx -y rea-agents setup --yes");
expect(content).toContain("npx -y rea-agents doctor");
expect(content).toContain("rea uninstall");
expect(content).toContain('"args": ["-y", "rea-agents", "mcp"]');
expect(content).toContain("Node.js 24");
expect(content).toContain("macOS 12");
expect(content).toContain("Ubuntu 24.04");
expect(content).toContain("Fedora 41");
expect(content).toContain("Arch Linux");
if (path === "README_ar.md")
expect(content).toContain("Windows غير مدعوم حاليًا");
expect(content).toContain(`MCP_tools-${String(TOOL_CONTRACTS.length)}`);
for (const count of [
OFFICIAL_TOOL_CONTRACTS.length,
ENHANCED_TOOL_CONTRACTS.length,
NATIVE_TOOL_CONTRACTS.length,
ARTIFACT_TOOL_CONTRACTS.length,
SESSION_TOOL_CONTRACTS.length,
])
expect(content).toMatch(
new RegExp(`\\|\\s*${String(count)}\\s*\\|`, "u"),
);
expect(toolCountsFromReadme(content, path)).toEqual(expectedToolCounts);
},
);
it("keeps both English CLI onboarding paths discoverable", async () => {
const content = await readFile(resolve("README.md"), "utf8");
expect(content).toContain("npx -y rea-agents analyze");
expect(content).toContain("npm install --global rea-agents");
expect(content).toContain("rea setup --yes");
expect(content).toContain("You do not need both");
});
});
+8
View File
@@ -2,12 +2,20 @@ import { describe, expect, it } from "vitest";
import {
firstProcedureAddress,
requireDistinctTargetHashes,
requireAddressArray,
requireFunctionDossier,
requirePseudocode,
} from "../src/application/RealHopperAssertions.js";
describe("real Hopper semantic assertions", () => {
it("rejects two target paths containing the same binary", () => {
expect(() => requireDistinctTargetHashes("same", "same")).toThrow(
/distinct binaries/u,
);
expect(() => requireDistinctTargetHashes("", "other")).toThrow();
expect(() => requireDistinctTargetHashes("first", "second")).not.toThrow();
});
it("extracts only a real procedure address from a structured page", () => {
expect(
firstProcedureAddress({
+41 -4
View File
@@ -7,10 +7,11 @@ import {
type SetupHost,
} from "../src/application/Setup.js";
import type { DoctorCheck } from "../src/application/Doctor.js";
import type { LinuxDistribution } from "../src/application/LinuxHopper.js";
class FakeSetupHost implements SetupHost {
readonly platform = "darwin" as const;
readonly nodeVersion = "22.4.0";
readonly platform: NodeJS.Platform;
readonly nodeVersion = "24.18.0";
version: string | undefined = "14.5";
brew = false;
hopper: string | undefined;
@@ -23,9 +24,16 @@ class FakeSetupHost implements SetupHost {
hopperInstalls = 0;
configurations = 0;
configuredHopperPaths: string[] = [];
distribution: LinuxDistribution | undefined;
constructor(platform: NodeJS.Platform = "darwin") {
this.platform = platform;
}
macosVersion = (): Promise<string | undefined> =>
Promise.resolve(this.version);
linuxDistribution = (): Promise<LinuxDistribution | undefined> =>
Promise.resolve(this.distribution);
hasHomebrew = (): Promise<boolean> => Promise.resolve(this.brew);
installHomebrew = (): Promise<boolean> => {
this.homebrewInstalls += 1;
@@ -61,7 +69,8 @@ class FakeSetupHost implements SetupHost {
checks: readonly DoctorCheck[];
}> =>
Promise.resolve({
healthy: this.brew && this.hopper !== undefined,
healthy:
(this.platform === "linux" || this.brew) && this.hopper !== undefined,
...(this.hopper === undefined ? {} : { hopperPath: this.hopper }),
checks: [],
});
@@ -71,7 +80,7 @@ describe("setup workflow", () => {
it("installs Homebrew and Hopper on a clean approved machine", async () => {
const host = new FakeSetupHost();
const result = await runSetup(true, host);
expect(result.status).toBe("ready");
expect(result.status).toBe("needs_human");
expect(result.actions).toEqual([
"installed_homebrew",
"installed_hopper",
@@ -79,6 +88,34 @@ describe("setup workflow", () => {
]);
});
it("installs Hopper without Homebrew on supported Linux", async () => {
const host = new FakeSetupHost("linux");
host.distribution = {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
};
const result = await runSetup(true, host);
expect(result.status).toBe("needs_human");
expect(result.actions).toEqual(["installed_hopper", "installed_skill"]);
expect(host.homebrewInstalls).toBe(0);
});
it("rejects unsupported Linux before mutation", async () => {
const host = new FakeSetupHost("linux");
host.distribution = {
id: "debian",
versionId: "13",
packageFamily: "deb",
supported: false,
};
const result = await runSetup(true, host);
expect(result.status).toBe("needs_human");
expect(host.hopperInstalls).toBe(0);
expect(host.configurations).toBe(0);
});
it("requires --yes before installing Homebrew", async () => {
const host = new FakeSetupHost();
const result = await runSetup(false, host);
+131
View File
@@ -0,0 +1,131 @@
import { 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";
import {
configureTomlClient,
detectClients,
} from "../src/application/Setup.js";
import {
runUninstall,
systemUninstallHost,
} from "../src/application/Uninstall.js";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
describe("agent lifecycle", () => {
it("detects every supported client and skips absent clients", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-detect-"));
roots.push(home);
for (const marker of [
".claude",
"Library/Application Support/Claude",
".codex",
".cursor",
".gemini",
".codeium/windsurf",
".devin",
])
await mkdir(join(home, marker), { recursive: true });
const detected = await detectClients(home);
expect(detected.map(({ name }) => name)).toEqual([
"claude_code",
"claude_desktop",
"codex",
"cursor",
"gemini_cli",
"windsurf",
"devin",
]);
expect(
detected.find(({ name }) => name === "claude_code")?.configPath,
).toBe(join(home, ".claude.json"));
expect(detected.find(({ name }) => name === "devin")?.format).toBe(
"unsupported",
);
const emptyHome = await mkdtemp(join(tmpdir(), "rea-empty-"));
roots.push(emptyHome);
expect(await detectClients(emptyHome)).toEqual([]);
});
it("preserves unrelated Codex TOML and supports an installed command", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-toml-"));
roots.push(home);
const configPath = join(home, ".codex/config.toml");
await mkdir(dirname(configPath), { recursive: true });
await writeFile(
configPath,
'model = "gpt-5"\n[mcp_servers.other]\ncommand = "other"\n',
);
expect(
await configureTomlClient(
{ name: "codex", configPath, format: "toml" },
"/Hopper Path",
["rea", "mcp"],
),
).toMatchObject({ status: "configured" });
const configured = await readFile(configPath, "utf8");
expect(configured).toContain('model = "gpt-5"');
expect(configured).toContain("[mcp_servers.rea]");
expect(configured).toContain('command = "rea"');
expect(
await configureTomlClient(
{ name: "codex", configPath, format: "toml" },
"/Hopper Path",
["rea", "mcp"],
),
).toEqual({ status: "unchanged" });
});
it("uninstalls only owned entries and refuses purge symlinks", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-uninstall-"));
roots.push(home);
const cursor = join(home, ".cursor/mcp.json");
const skill = join(home, ".agents/skills/rea-analysis/SKILL.md");
await mkdir(dirname(cursor), { recursive: true });
await mkdir(dirname(skill), { recursive: true });
await writeFile(
cursor,
JSON.stringify({
mcpServers: {
rea: { command: "/isolated prefix/bin/rea", args: ["mcp"] },
other: { command: "other" },
},
}),
);
await writeFile(skill, "managed");
await mkdir(join(home, ".rea"), { recursive: true });
await import("node:fs/promises").then(({ symlink }) =>
symlink(home, join(home, ".rea/cache")),
);
const first = await runUninstall(true, systemUninstallHost(home));
expect(first.status).toBe("complete");
expect(JSON.parse(await readFile(cursor, "utf8"))).toEqual({
mcpServers: { other: { command: "other" } },
});
expect(first.items).toContainEqual(
expect.objectContaining({ name: "cache", status: "retained" }),
);
expect((await runUninstall(true, systemUninstallHost(home))).status).toBe(
"complete",
);
});
it("fails closed on malformed client configuration", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-uninstall-bad-"));
roots.push(home);
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",
);
expect(await readFile(config, "utf8")).toBe("not-json");
});
});