Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cba26666a2 | ||
|
|
a5edb3a2bc | ||
|
|
883db912da | ||
|
|
7c36f36318 | ||
|
|
c630f410b6 | ||
|
|
9526389f81 | ||
|
|
329fdf6fe8 | ||
|
|
d806480e41 | ||
|
|
a7b84a9b4b | ||
|
|
efc9bd270f | ||
|
|
0d36976277 | ||
|
|
bce3398dbe | ||
|
|
b1912b18e1 | ||
|
|
898d6b0192 | ||
|
|
f8e8ea35ff | ||
|
|
151469f093 | ||
|
|
4485ef90bf | ||
|
|
cc6a9557e1 | ||
|
|
a4d356b210 | ||
|
|
034563a4c0 | ||
|
|
4f97f699e4 | ||
|
|
e651cb65db | ||
|
|
e1dfe8613e | ||
|
|
af9794158d | ||
|
|
f56bdde276 | ||
|
|
d85b661be8 | ||
|
|
07a0b984f2 | ||
|
|
993356b85a | ||
|
|
3a21f16c18 | ||
|
|
4045d92623 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "0.3.0"
|
||||
".": "0.5.0"
|
||||
}
|
||||
|
||||
@@ -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/`).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
Generated
+3
-3
@@ -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
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
});
|
||||
|
||||
@@ -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_,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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} `)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {};
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
Vendored
+100
-1
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" }])],
|
||||
|
||||
@@ -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("'", `'"'"'`)}'`;
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user