mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
Merge pull request #2 from morluto/morl/binaryimprovement
Release REA as one CLI with MCP mode
This commit is contained in:
@@ -2,15 +2,17 @@
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
The server is a layered ESM TypeScript application. Dependencies flow inward: `domain` ← `contracts` ← `hopper` ← `application` ← `server` ← `main`.
|
||||
REA is a layered ESM TypeScript application. Dependencies flow inward: `domain` ← `contracts` ← `hopper` ← `application` ← `server` ← adapters.
|
||||
|
||||
- `src/main.ts`: composition root. Parses config, wires the Hopper client, starts the stdio MCP transport, and owns process-lifetime shutdown.
|
||||
- `scripts/rea.mjs`: executable dispatcher. Routes only bare `mcp` and `--mcp` to the production stdio server; Incur handles registration utilities and one-shot commands.
|
||||
- `src/main.ts`: MCP adapter. Parses config, wires the shared session runtime, starts stdio transport, and owns process-lifetime shutdown.
|
||||
- `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 tool schemas. `toolContracts.ts` declares the 31 official-proxy + 8 enhanced tool inventory; `enhancedInputs.ts` holds Zod input schemas for enhanced tools.
|
||||
- `src/contracts/`: caller-visible schemas for 31 direct, 8 enhanced, and 3 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/`: enhanced workflows. `HopperToolPort.ts` defines the port the official tools proxy through; `EnhancedTools.ts` implements the 8 RE-specific tools.
|
||||
- `src/application/`: shared CLI/MCP session composition, setup and diagnostics, and the 8 enhanced workflows.
|
||||
- `src/server/`: MCP request translation. `createServer.ts` assembles the MCP server, `registerOfficialTools.ts`/`registerEnhancedTools.ts` register each tool set, `toolResult.ts` maps `Result` values to MCP content.
|
||||
- `tests/`: Vitest suite. `tests/fixtures/` holds the fake launcher and fake Hopper bridge used as production seams.
|
||||
- `scripts/verify-real-hopper.mjs`: real-Hopper end-to-end verifier.
|
||||
@@ -25,16 +27,17 @@ The server is a layered ESM TypeScript application. Dependencies flow inward: `d
|
||||
- `npm run lint`: apply type-aware ESLint rules.
|
||||
- `npm run format:check`: verify Prettier formatting.
|
||||
- `npm run check`: run typecheck, lint, format:check, and tests.
|
||||
- `npm run verify:hopper`: build and run the real-Hopper verifier (requires a binary).
|
||||
- `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 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.
|
||||
|
||||
## Configuration & Environment Variables
|
||||
|
||||
- `HOPPER_TARGET_PATH` (required): absolute path to the binary or `.hop` database to analyze.
|
||||
- `HOPPER_TARGET_PATH` (optional): absolute initial binary or `.hop` target. Target-free MCP sessions use `open_binary` instead.
|
||||
- `HOPPER_LAUNCHER_PATH` (optional): override the Hopper executable path (defaults to `/Applications/Hopper Disassembler.app/Contents/MacOS/hopper`).
|
||||
- `HOPPER_TARGET_KIND` (optional, default `executable`): `executable` or `database` (for `.hop` files).
|
||||
- `HOPPER_LOADER_ARGS_JSON` (optional): JSON array of strings passed to the Hopper launcher before the target, e.g. `["-l","Mach-O","--aarch64"]`.
|
||||
- `HOPPER_TARGET_KIND` (optional, default `executable`): startup kind for `HOPPER_TARGET_PATH`; dynamic targets are classified from their paths and headers.
|
||||
- `HOPPER_LOADER_ARGS_JSON` (optional): JSON array overriding derived Hopper loader arguments for supported executable targets, e.g. `["-l","Mach-O","--aarch64"]`.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
@@ -42,7 +45,7 @@ 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 official-proxy plus 8 enhanced tool inventory (39 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` 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 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.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
|
||||
@@ -1,50 +1,65 @@
|
||||
# Better Binary
|
||||
# REA
|
||||
|
||||
Agent-ready Hopper Disassembler tooling: a beta.3 MCP server with 31 official proxies, 8 enhanced reverse-engineering workflows, and 3 binary-session tools.
|
||||
REA gives coding agents access to Hopper Disassembler through one `rea` command. It exposes 42 MCP tools: 31 direct Hopper operations, 8 composed analysis workflows, and 3 target-session tools.
|
||||
|
||||
## Install from this checkout
|
||||
REA uses Hopper's public Python API through its own authenticated local bridge. It does not use Hopper's bundled MCP server.
|
||||
|
||||
Requires macOS 12+ and Node.js 22+.
|
||||
## Requirements
|
||||
|
||||
- macOS 12 or newer
|
||||
- Node.js 22 or newer
|
||||
- Hopper Disassembler
|
||||
|
||||
REA does not bundle Hopper. `rea setup --yes` can install Homebrew when absent, install Hopper with the `hopper-disassembler` Homebrew cask, configure detected MCP clients, and install the REA agent skill. Setup is idempotent and returns structured remediation when an installer needs human attention.
|
||||
|
||||
## Install
|
||||
|
||||
After publication:
|
||||
|
||||
```bash
|
||||
npx -y @morluto/rea setup --yes
|
||||
npx -y @morluto/rea doctor
|
||||
```
|
||||
|
||||
For source-checkout development:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run build
|
||||
npm link
|
||||
better-binary setup --yes
|
||||
rea setup --yes
|
||||
```
|
||||
|
||||
`setup` is safe to repeat. It checks the host, installs Homebrew when approved and absent, installs Hopper from the official `hopper-disassembler` cask, and finishes with structured diagnostics. It never bundles Hopper or publishes this private package.
|
||||
|
||||
Useful onboarding commands:
|
||||
The CLI and MCP adapter call the same binary-session runtime directly; neither invokes the other. One-shot `analyze` and `decompile` commands close their bridge session before returning. MCP mode keeps one session alive so an agent can switch targets.
|
||||
|
||||
```bash
|
||||
better-binary --help
|
||||
better-binary --llms
|
||||
better-binary doctor --target /path/to/binary
|
||||
better-binary skills add
|
||||
better-binary mcp add
|
||||
rea --help
|
||||
rea --llms
|
||||
rea doctor --target /path/to/binary
|
||||
rea mcp add
|
||||
```
|
||||
|
||||
## MCP workflow
|
||||
|
||||
Start `better-binary-mcp` with no target. In the connected agent:
|
||||
Start the MCP adapter with `rea mcp` (or `rea --mcp`) with no target. In the connected agent:
|
||||
|
||||
1. Call `open_binary` with any readable local binary or `.hop` path.
|
||||
2. Call `binary_overview`, then use decompilation, strings, symbols, and xrefs tools.
|
||||
3. Call `open_binary` again to switch targets; a failed switch preserves the current target.
|
||||
3. Call `open_binary` again to switch targets. If the new target fails, REA attempts to reopen the previous target.
|
||||
4. Call `close_binary` when finished. `binary_session` reports current state.
|
||||
|
||||
Relative target paths resolve against the MCP server's working directory. Mach-O/FAT, ELF, PE, and Hopper databases are detected automatically. FAT loading selects the host-compatible architecture.
|
||||
|
||||
`HOPPER_TARGET_PATH` remains supported for older configurations but is optional. `HOPPER_LAUNCHER_PATH`, `HOPPER_TARGET_KIND`, and `HOPPER_LOADER_ARGS_JSON` retain their existing meanings for legacy startup configuration.
|
||||
`HOPPER_TARGET_PATH` is optional and opens an initial target at server startup. `HOPPER_LAUNCHER_PATH` overrides the Hopper launcher. `HOPPER_TARGET_KIND` selects `executable` or `database` for an initial target. `HOPPER_LOADER_ARGS_JSON` overrides REA's derived Hopper loader arguments for supported executable formats.
|
||||
|
||||
Manual target-free MCP configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"better-binary": {
|
||||
"command": "better-binary-mcp"
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@morluto/rea", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,19 +67,41 @@ Manual target-free MCP configuration:
|
||||
|
||||
The first `open_binary` can take time while Hopper analyzes the file. Analysis calls made without an open target return an actionable `NoBinaryOpenError`.
|
||||
|
||||
## Hopper application behavior
|
||||
|
||||
REA starts Hopper when needed; Hopper does not have to be running first. Hopper's launcher internally activates the application, so opening a target may bring Hopper to the foreground. REA asks macOS to start Hopper hidden and in the background when possible, but cannot guarantee that Hopper will remain behind the current application.
|
||||
|
||||
Explicit format and architecture loader arguments prevent Hopper's common FAT/ARM selection dialogs. Other Hopper or macOS dialogs can still require a person. REA reports timeouts and setup remediation through the CLI or MCP result instead of trying to answer UI prompts.
|
||||
|
||||
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.
|
||||
|
||||
## Local security boundary
|
||||
|
||||
Each bridge session uses a random capability token and a Unix socket restricted to the current user. This rejects unauthenticated connections and access from other local user accounts, but it does not defend against a malicious process already running as the same user. REA bounds protocol messages and exposes sanitized error messages instead of launcher stderr or internal exception causes.
|
||||
|
||||
This boundary prevents unrelated local processes from accidentally using a bridge session; it is not a sandbox. Opening an untrusted binary still delegates parsing and analysis to Hopper under the current macOS user account.
|
||||
|
||||
## Development and verification
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm pack --dry-run
|
||||
npm run verify:package
|
||||
HOPPER_TARGET_PATH=/path/to/target-a \
|
||||
HOPPER_SECOND_TARGET_PATH=/path/to/distinct-target-b \
|
||||
npm run verify:hopper
|
||||
```
|
||||
|
||||
Real-Hopper verification requires Hopper and a representative target. The stdio server uses only the repository's authenticated Unix-socket bridge; Hopper's bundled MCP server is not used.
|
||||
Real-Hopper verification requires Hopper and two distinct binaries. It checks target-free startup, all 42 tools, analysis, bounded decompilation, target switching, bridge cleanup, and server shutdown. The verifier terminates only Hopper bundle processes that appeared during its run; it preserves Hopper processes that were already running.
|
||||
|
||||
The provisional package, executable, MCP, skill, and config names are centralized in `src/identity.ts` so release naming can change cleanly. The package remains private and is not ready for publication.
|
||||
Before publishing a release, run `npm run check`, `npm run verify:package`, `npm pack --dry-run`, and the two-target real-Hopper verifier. After publication, repeat the CLI and MCP smoke tests through `npx -y @morluto/rea`.
|
||||
|
||||
The package ships only the `rea` executable. Setup writes a floating MCP registration using `npx -y @morluto/rea mcp`. A local tarball exercises the same package boundary before publication:
|
||||
|
||||
```bash
|
||||
npm pack
|
||||
npm exec --yes --package ./morluto-rea-0.1.0.tgz -- rea --help
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+21
-9
@@ -1,4 +1,9 @@
|
||||
"""Repository-owned Hopper adapter. Executed inside Hopper by its documented -Y flag."""
|
||||
"""Authenticated REA adapter executed on Hopper's dedicated Python thread.
|
||||
|
||||
The bootstrap injects ``REA_SOCKET`` and a random ``REA_TOKEN`` before executing
|
||||
this file with Hopper's supported ``--python`` launcher option. Keep all Hopper
|
||||
API access on this thread: moving dispatch to a worker can deadlock Hopper.
|
||||
"""
|
||||
|
||||
import json
|
||||
import hmac
|
||||
@@ -16,6 +21,7 @@ def _hex(value):
|
||||
|
||||
|
||||
def _json_safe(value):
|
||||
"""Project Hopper-specific Python values into the JSON protocol boundary."""
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if isinstance(value, (list, tuple)):
|
||||
@@ -26,6 +32,7 @@ def _json_safe(value):
|
||||
|
||||
|
||||
def _document(name=None):
|
||||
"""Resolve an explicit or session-selected document without changing Hopper UI."""
|
||||
global _selected_document
|
||||
documents = Document.getAllDocuments()
|
||||
if name is not None:
|
||||
@@ -44,6 +51,7 @@ def _document(name=None):
|
||||
|
||||
|
||||
def _address(document, value=None):
|
||||
"""Resolve hexadecimal addresses first, then fall back to Hopper symbol names."""
|
||||
if value is None:
|
||||
return document.getCurrentAddress()
|
||||
if not isinstance(value, str):
|
||||
@@ -95,6 +103,7 @@ def _strings(document):
|
||||
|
||||
|
||||
def _assembly(procedure):
|
||||
"""Render bounded assembly while guarding against malformed instruction cycles."""
|
||||
lines = []
|
||||
segment = procedure.getSegment()
|
||||
seen = set()
|
||||
@@ -117,9 +126,10 @@ def _assembly(procedure):
|
||||
|
||||
|
||||
def _dispatch(method, params):
|
||||
"""Dispatch only the closed operation set implemented by REA's public tools."""
|
||||
global _selected_document
|
||||
if method == "health":
|
||||
return {"name": "betterBinaryMCP Hopper bridge", "version": "1.0.0"}
|
||||
return {"name": "REA Hopper bridge", "version": "1.0.0"}
|
||||
if method == "shutdown":
|
||||
return {"shutdown": True}
|
||||
if method == "list_documents":
|
||||
@@ -227,6 +237,7 @@ def _dispatch(method, params):
|
||||
|
||||
|
||||
def _serve_connection(connection):
|
||||
"""Serve one size-bounded, capability-authenticated NDJSON connection."""
|
||||
file = connection.makefile("rwb")
|
||||
while True:
|
||||
line = file.readline(MAX_LINE_BYTES + 1)
|
||||
@@ -239,7 +250,7 @@ def _serve_connection(connection):
|
||||
if set(request) != {"id", "token", "method", "params"}:
|
||||
raise ValueError("Invalid bridge request shape")
|
||||
request_id = request["id"]
|
||||
if not isinstance(request["token"], str) or not hmac.compare_digest(request["token"], BETTER_BINARY_TOKEN):
|
||||
if not isinstance(request["token"], str) or not hmac.compare_digest(request["token"], REA_TOKEN):
|
||||
raise PermissionError("Invalid bridge capability")
|
||||
result = _dispatch(request["method"], request["params"])
|
||||
should_stop = request["method"] == "shutdown"
|
||||
@@ -255,19 +266,20 @@ def _serve_connection(connection):
|
||||
|
||||
|
||||
def _run():
|
||||
if os.path.exists(BETTER_BINARY_SOCKET):
|
||||
os.unlink(BETTER_BINARY_SOCKET)
|
||||
"""Own a permission-restricted, single-client Unix socket for this bridge."""
|
||||
if os.path.exists(REA_SOCKET):
|
||||
os.unlink(REA_SOCKET)
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(BETTER_BINARY_SOCKET)
|
||||
os.chmod(BETTER_BINARY_SOCKET, 0o600)
|
||||
server.bind(REA_SOCKET)
|
||||
os.chmod(REA_SOCKET, 0o600)
|
||||
server.listen(1)
|
||||
try:
|
||||
connection, _ = server.accept()
|
||||
_serve_connection(connection)
|
||||
finally:
|
||||
server.close()
|
||||
if os.path.exists(BETTER_BINARY_SOCKET):
|
||||
os.unlink(BETTER_BINARY_SOCKET)
|
||||
if os.path.exists(REA_SOCKET):
|
||||
os.unlink(REA_SOCKET)
|
||||
|
||||
|
||||
# Hopper's public objects are bound to its dedicated Python execution thread.
|
||||
|
||||
Generated
+3
-3
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "better-binary-mcp",
|
||||
"name": "@morluto/rea",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "better-binary-mcp",
|
||||
"name": "@morluto/rea",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.3",
|
||||
@@ -13,7 +13,7 @@
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"bin": {
|
||||
"better-binary-mcp": "dist/main.js"
|
||||
"rea": "scripts/rea.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.2",
|
||||
|
||||
+26
-7
@@ -1,19 +1,37 @@
|
||||
{
|
||||
"name": "better-binary-mcp",
|
||||
"name": "@morluto/rea",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Reverse-engineering automation for Hopper Disassembler through one CLI and MCP server.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/morluto/rea.git"
|
||||
},
|
||||
"homepage": "https://github.com/morluto/rea#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/morluto/rea/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"reverse-engineering",
|
||||
"hopper",
|
||||
"disassembler",
|
||||
"decompiler",
|
||||
"mcp"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"better-binary": "./scripts/better-binary.mjs",
|
||||
"better-binary-mcp": "./dist/main.js"
|
||||
"rea": "scripts/rea.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bridge",
|
||||
"scripts/better-binary.mjs",
|
||||
"bridge/hopper_bridge.py",
|
||||
"scripts/rea.mjs",
|
||||
"skills",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
@@ -31,7 +49,8 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
|
||||
"verify:package": "npm run build && node scripts/verify-package.mjs",
|
||||
"config:print": "node scripts/print-mcp-config.mjs"
|
||||
"config:print": "node scripts/print-mcp-config.mjs",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.3",
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Cli, z } from "incur";
|
||||
import { runDoctor } from "../dist/application/Doctor.js";
|
||||
import { runDirectAnalysis } from "../dist/application/DirectAnalysis.js";
|
||||
import { runSetup } from "../dist/application/Setup.js";
|
||||
import { PRODUCT_IDENTITY } from "../dist/identity.js";
|
||||
|
||||
const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, {
|
||||
version: "0.1.0",
|
||||
description: "Install, diagnose, and automate Hopper binary analysis.",
|
||||
mcp: {
|
||||
command: PRODUCT_IDENTITY.mcpBinary,
|
||||
instructions:
|
||||
"Open a local target with open_binary, analyze it, and close the session when finished.",
|
||||
},
|
||||
sync: {
|
||||
cwd: import.meta.dirname,
|
||||
include: ["../skills/*"],
|
||||
suggestions: [
|
||||
"open a binary and summarize it",
|
||||
"decompile a procedure",
|
||||
"diagnose my Hopper setup",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
cli.command("setup", {
|
||||
description: "Install prerequisites and configure binary analysis",
|
||||
options: z.object({
|
||||
yes: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Approve prerequisite installation"),
|
||||
}),
|
||||
alias: { yes: "y" },
|
||||
run: ({ options }) => runSetup(options.yes),
|
||||
});
|
||||
cli.command("doctor", {
|
||||
description: "Diagnose the local Hopper integration",
|
||||
options: z.object({
|
||||
target: z.string().optional().describe("Optional binary path to check"),
|
||||
}),
|
||||
run: ({ options }) => runDoctor(options.target),
|
||||
});
|
||||
cli.command("analyze", {
|
||||
description: "Analyze a binary through the beta.3 MCP server",
|
||||
args: z.object({
|
||||
path: z.string().describe("Binary or Hopper database path"),
|
||||
}),
|
||||
run: ({ args }) => runDirectAnalysis(args.path, "binary_overview", {}),
|
||||
});
|
||||
cli.command("decompile", {
|
||||
description: "Decompile an address through the beta.3 MCP server",
|
||||
args: z.object({
|
||||
path: z.string().describe("Binary path"),
|
||||
address: z.string().describe("Procedure address"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
runDirectAnalysis(args.path, "procedure_pseudo_code", {
|
||||
procedure: args.address,
|
||||
}),
|
||||
});
|
||||
cli.serve();
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const targetPath = process.argv[2];
|
||||
if (!targetPath) {
|
||||
@@ -22,11 +23,6 @@ const flags = process.argv.slice(3);
|
||||
const kind = readFlag(flags, "--kind") ?? "executable";
|
||||
const loaderArgs = readFlag(flags, "--loader-args");
|
||||
|
||||
const serverPath = resolve(
|
||||
fileURLToPath(new URL("..", import.meta.url)),
|
||||
"dist/main.js",
|
||||
);
|
||||
|
||||
const env = {
|
||||
HOPPER_TARGET_PATH: resolve(targetPath),
|
||||
HOPPER_TARGET_KIND: kind,
|
||||
@@ -35,9 +31,21 @@ if (loaderArgs) env.HOPPER_LOADER_ARGS_JSON = loaderArgs;
|
||||
|
||||
const config = {
|
||||
mcpServers: {
|
||||
betterBinaryMCP: {
|
||||
command: "node",
|
||||
args: [serverPath],
|
||||
rea: {
|
||||
command: "npx",
|
||||
args: [
|
||||
"-y",
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
resolve(
|
||||
fileURLToPath(new URL("..", import.meta.url)),
|
||||
"package.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
).name,
|
||||
"mcp",
|
||||
],
|
||||
env,
|
||||
},
|
||||
},
|
||||
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Route production MCP before importing Incur. Incur owns registration helpers
|
||||
// such as `mcp add`, while only dist/main.js may serve the 42-tool stdio server.
|
||||
const args = process.argv.slice(2);
|
||||
const isMcpMode =
|
||||
args[0] === "--mcp" || (args.length === 1 && args[0] === "mcp");
|
||||
|
||||
if (isMcpMode) {
|
||||
const { run } = await import("../dist/main.js");
|
||||
process.exitCode = await run();
|
||||
} else {
|
||||
const { createCli } = await import("../dist/cli.js");
|
||||
await createCli().serve(args);
|
||||
}
|
||||
+26
-16
@@ -14,15 +14,27 @@ import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
// Exercise the packed artifact in isolated HOME and prefix directories so the
|
||||
// verifier cannot mutate real MCP registrations or rely on checkout-only files.
|
||||
const exec = promisify(execFile);
|
||||
const root = process.cwd();
|
||||
const workspace = await mkdtemp(join(tmpdir(), "better-binary-package-"));
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-package-"));
|
||||
let tarball;
|
||||
|
||||
try {
|
||||
tarball = (
|
||||
await exec("npm", ["pack", "--silent"], { cwd: root })
|
||||
).stdout.trim();
|
||||
const packedFiles = (
|
||||
await exec("tar", ["-tf", join(root, tarball)])
|
||||
).stdout.split("\n");
|
||||
if (
|
||||
packedFiles.some(
|
||||
(path) => path.includes("__pycache__") || path.endsWith(".pyc"),
|
||||
)
|
||||
) {
|
||||
throw new Error("package contained generated Python bytecode");
|
||||
}
|
||||
const prefix = join(workspace, "prefix");
|
||||
const home = join(workspace, "home");
|
||||
const fakeBin = join(workspace, "bin");
|
||||
@@ -39,7 +51,7 @@ try {
|
||||
await writeFile(swVers, "#!/bin/sh\necho 14.5\n");
|
||||
await writeFile(
|
||||
npx,
|
||||
'#!/bin/sh\nprintf "%s\\n" "$*" > "$BETTER_BINARY_NPX_LOG"\necho "│ ✓ Cursor: isolated │"\n',
|
||||
'#!/bin/sh\nprintf "%s\\n" "$*" > "$REA_NPX_LOG"\necho "│ ✓ Cursor: isolated │"\n',
|
||||
);
|
||||
await writeFile(hopper, "#!/bin/sh\nexit 0\n");
|
||||
await Promise.all([
|
||||
@@ -64,15 +76,14 @@ try {
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
||||
HOPPER_LAUNCHER_PATH: hopper,
|
||||
BETTER_BINARY_NPX_LOG: npxLog,
|
||||
REA_NPX_LOG: npxLog,
|
||||
};
|
||||
await exec(
|
||||
"npm",
|
||||
["install", "--global", "--prefix", prefix, join(root, tarball)],
|
||||
{ env: environment },
|
||||
);
|
||||
const cli = join(prefix, "bin", "better-binary");
|
||||
const mcp = join(prefix, "bin", "better-binary-mcp");
|
||||
const cli = join(prefix, "bin", "rea");
|
||||
const help = await run(cli, ["--help"], environment);
|
||||
const llms = await run(cli, ["--llms"], environment);
|
||||
const doctor = json(await run(cli, ["doctor", "--json"], environment));
|
||||
@@ -84,10 +95,8 @@ try {
|
||||
throw new Error("packaged CLI discovery or doctor failed");
|
||||
await run(cli, ["mcp", "add"], environment);
|
||||
const mcpRegistration = await readFile(npxLog, "utf8");
|
||||
if (
|
||||
!mcpRegistration.includes("add-mcp better-binary-mcp --name better-binary")
|
||||
)
|
||||
throw new Error("Incur mcp add did not register the beta.3 executable");
|
||||
if (!mcpRegistration.includes("add-mcp npx -y @morluto/rea mcp --name rea"))
|
||||
throw new Error("Incur mcp add did not register the floating npx command");
|
||||
const first = json(await run(cli, ["setup", "--yes", "--json"], environment));
|
||||
const second = json(
|
||||
await run(cli, ["setup", "--yes", "--json"], environment),
|
||||
@@ -102,18 +111,18 @@ try {
|
||||
const config = json(await readFile(configPath, "utf8"));
|
||||
if (
|
||||
config.existing !== true ||
|
||||
config.mcpServers?.["better-binary"]?.command !== "better-binary-mcp"
|
||||
config.mcpServers?.rea?.command !== "npx" ||
|
||||
JSON.stringify(config.mcpServers?.rea?.args) !==
|
||||
JSON.stringify(["-y", "@morluto/rea", "mcp"])
|
||||
)
|
||||
throw new Error("packaged client readback failed");
|
||||
if (
|
||||
!(await readFile(`${configPath}.better-binary.backup`, "utf8")).includes(
|
||||
"existing",
|
||||
)
|
||||
!(await readFile(`${configPath}.rea.backup`, "utf8")).includes("existing")
|
||||
)
|
||||
throw new Error("packaged client backup failed");
|
||||
}
|
||||
const skill = await readFile(
|
||||
join(home, ".agents/skills/better-binary-analysis/SKILL.md"),
|
||||
join(home, ".agents/skills/rea-analysis/SKILL.md"),
|
||||
"utf8",
|
||||
);
|
||||
if (!skill.includes("open_binary"))
|
||||
@@ -136,7 +145,8 @@ try {
|
||||
throw new Error("packaged setup did not recover");
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: mcp,
|
||||
command: cli,
|
||||
args: ["mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
@@ -163,7 +173,7 @@ try {
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ cli: true, incurMcpCommand: "better-binary-mcp", doctor: true, setup: "idempotent", clients: 2, backupReadback: true, failureRecovery: true, skill: true, mcpTools: 42, targetFree: true })}\n`,
|
||||
`${JSON.stringify({ cli: true, incurMcpCommand: "npx -y @morluto/rea mcp", doctor: true, setup: "idempotent", clients: 2, backupReadback: true, failureRecovery: true, skill: true, mcpTools: 42, targetFree: true })}\n`,
|
||||
);
|
||||
} finally {
|
||||
if (tarball) await rm(join(root, tarball), { force: true });
|
||||
|
||||
@@ -9,8 +9,12 @@ import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const timeout = 180_000;
|
||||
const hopperProcessPrefix = "/Applications/Hopper Disassembler.app/Contents/";
|
||||
// Snapshot bundle processes so cleanup terminates only Hopper processes this
|
||||
// verifier caused to appear, never an instance the user already had running.
|
||||
const hopperProcessesBefore = await hopperProcessIds();
|
||||
const sessionsBefore = new Set(
|
||||
(await readdir("/tmp")).filter((name) => name.startsWith("bbm-")),
|
||||
(await readdir("/tmp")).filter((name) => name.startsWith("rea-")),
|
||||
);
|
||||
|
||||
const textValue = (result) => {
|
||||
@@ -155,12 +159,13 @@ try {
|
||||
await transport.close();
|
||||
} finally {
|
||||
clearInterval(keepAlive);
|
||||
await terminateNewHopperProcesses(hopperProcessesBefore);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const sessionsAfter = (await readdir("/tmp")).filter(
|
||||
(name) => name.startsWith("bbm-") && !sessionsBefore.has(name),
|
||||
(name) => name.startsWith("rea-") && !sessionsBefore.has(name),
|
||||
);
|
||||
if (sessionsAfter.length > 0) {
|
||||
throw new Error("The MCP runtime leaked a bridge session directory");
|
||||
@@ -190,3 +195,34 @@ await new Promise((resolve, reject) => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
async function hopperProcessIds() {
|
||||
const result = await execFileAsync("ps", ["-ax", "-o", "pid=,command="]);
|
||||
return new Set(
|
||||
result.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim().match(/^(\d+)\s+(.+)$/))
|
||||
.filter((match) => match?.[2]?.startsWith(hopperProcessPrefix) === true)
|
||||
.map((match) => Number(match[1])),
|
||||
);
|
||||
}
|
||||
|
||||
async function terminateNewHopperProcesses(previous) {
|
||||
const current = await hopperProcessIds();
|
||||
const owned = [...current].filter((pid) => !previous.has(pid));
|
||||
for (const pid of owned) signalProcess(pid, "SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
for (const pid of owned)
|
||||
if (signalProcess(pid, 0)) signalProcess(pid, "SIGKILL");
|
||||
}
|
||||
|
||||
function signalProcess(pid, signal) {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "ESRCH")
|
||||
return false;
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: better-binary-analysis
|
||||
description: Analyze local binaries with Better Binary and Hopper through its MCP session workflow.
|
||||
name: rea-analysis
|
||||
description: Analyze local binaries with REA and Hopper through its MCP session workflow.
|
||||
---
|
||||
|
||||
# Better Binary Analysis
|
||||
# REA Analysis
|
||||
|
||||
Call `open_binary` with a readable local path before analysis. Begin with `binary_overview`, use strings and symbol searches to identify relevant procedures, then use `procedure_pseudo_code`, callers, callees, and xrefs to build evidence. Call `open_binary` again to switch targets and `close_binary` when finished. Never claim real-Hopper behavior from mock-only evidence.
|
||||
@@ -13,10 +13,17 @@ import type { HopperToolPort } from "./HopperToolPort.js";
|
||||
export interface BinaryClient extends HopperToolPort {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
/** Production seam for creating a target-scoped Hopper client. */
|
||||
/** Production seam for creating one unstarted client for a parsed target. */
|
||||
export type BinaryClientFactory = (target: BinaryTarget) => BinaryClient;
|
||||
|
||||
/** Serializes target transitions while allowing calls within the active session. */
|
||||
/**
|
||||
* Owns the single active target shared by CLI and MCP adapters.
|
||||
*
|
||||
* Target transitions are serialized because each client dispatches Hopper API
|
||||
* work on its dedicated Python thread, and switching targets tears that bridge
|
||||
* down. A failed switch recreates the previous target instead of retaining a
|
||||
* client whose bridge was already shut down.
|
||||
*/
|
||||
export class BinarySession implements HopperToolPort {
|
||||
#active:
|
||||
| { readonly target: BinaryTarget; readonly client: BinaryClient }
|
||||
@@ -25,32 +32,46 @@ export class BinarySession implements HopperToolPort {
|
||||
readonly #calls = new Set<Promise<unknown>>();
|
||||
constructor(readonly createClient: BinaryClientFactory) {}
|
||||
|
||||
/** Open or switch to a target, retaining the old session when the new one fails. */
|
||||
/**
|
||||
* Open or switch targets after draining calls against the current target.
|
||||
* Returns the switch failure even if best-effort restoration also fails.
|
||||
*/
|
||||
open(
|
||||
path: string,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly targetKind?: BinaryTarget["kind"];
|
||||
} = {},
|
||||
): Promise<Result<BinaryTarget, HopperError>> {
|
||||
return this.#serialize(async () => {
|
||||
if (isAborted(options.signal)) return err(new HopperCancelledError());
|
||||
const parsed = await parseBinaryTarget(path);
|
||||
const parsed = await parseBinaryTarget(
|
||||
path,
|
||||
process.cwd(),
|
||||
process.arch,
|
||||
options.targetKind,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
if (isAborted(options.signal)) return err(new HopperCancelledError());
|
||||
if (this.#active?.target.path === parsed.value.path)
|
||||
return ok(parsed.value);
|
||||
await this.#drainCalls();
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
await previous?.client.close();
|
||||
const client = this.createClient(parsed.value);
|
||||
const started = await client.callTool("health", {}, options);
|
||||
if (!started.ok) {
|
||||
await client.close();
|
||||
await this.#restore(previous);
|
||||
return started;
|
||||
}
|
||||
if (isAborted(options.signal)) {
|
||||
await client.close();
|
||||
await this.#restore(previous);
|
||||
return err(new HopperCancelledError());
|
||||
}
|
||||
this.#active = { target: parsed.value, client };
|
||||
await previous?.client.close();
|
||||
return ok(parsed.value);
|
||||
});
|
||||
}
|
||||
@@ -79,7 +100,11 @@ export class BinarySession implements HopperToolPort {
|
||||
};
|
||||
}
|
||||
|
||||
/** Invoke a Hopper tool against the active target. */
|
||||
/**
|
||||
* Invoke a Hopper tool against the active target.
|
||||
* Calls may overlap, but a pending target transition prevents new calls from
|
||||
* entering until the transition has settled.
|
||||
*/
|
||||
async callTool(
|
||||
name: string,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
@@ -111,6 +136,18 @@ export class BinarySession implements HopperToolPort {
|
||||
await Promise.allSettled([...this.#calls]);
|
||||
}
|
||||
|
||||
async #restore(
|
||||
previous:
|
||||
| { readonly target: BinaryTarget; readonly client: BinaryClient }
|
||||
| undefined,
|
||||
): Promise<void> {
|
||||
if (previous === undefined) return;
|
||||
const client = this.createClient(previous.target);
|
||||
const started = await client.callTool("health", {});
|
||||
if (started.ok) this.#active = { target: previous.target, client };
|
||||
else await client.close();
|
||||
}
|
||||
|
||||
async #waitForTransition(
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<undefined, HopperCancelledError>> {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { parseConfig } from "../config.js";
|
||||
import { HopperApplicationLauncher } from "../hopper/BridgeLauncher.js";
|
||||
import { HopperClient } from "../hopper/HopperClient.js";
|
||||
import type { JsonValue } from "../hopper/protocol.js";
|
||||
import { BinarySession } from "./BinarySession.js";
|
||||
import { createBinarySession } from "./runtime.js";
|
||||
|
||||
/** Open one binary, execute one tool, and always release the target session. */
|
||||
/**
|
||||
* Open one binary, execute one tool, and always release the bridge session.
|
||||
* Unlike MCP mode, every CLI invocation is intentionally isolated and does not
|
||||
* retain a target or bridge resources for a subsequent command.
|
||||
*/
|
||||
export const runDirectAnalysis = async (
|
||||
path: string,
|
||||
tool: "binary_overview" | "procedure_pseudo_code",
|
||||
@@ -15,24 +15,7 @@ export const runDirectAnalysis = async (
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok)
|
||||
return { error: config.error._tag, message: config.error.message };
|
||||
const bridgeScriptPath = fileURLToPath(
|
||||
new URL("../../bridge/hopper_bridge.py", import.meta.url),
|
||||
);
|
||||
const session = new BinarySession(
|
||||
(target) =>
|
||||
new HopperClient({
|
||||
launcher: new HopperApplicationLauncher({
|
||||
launcherPath: config.value.hopperLauncherPath,
|
||||
targetPath: target.path,
|
||||
targetKind: target.kind,
|
||||
loaderArgs:
|
||||
config.value.hopperLoaderArgs.length > 0
|
||||
? config.value.hopperLoaderArgs
|
||||
: target.loaderArgs,
|
||||
bridgeScriptPath,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const session = createBinarySession(config.value);
|
||||
try {
|
||||
const opened = await session.open(path);
|
||||
if (!opened.ok)
|
||||
|
||||
@@ -19,18 +19,23 @@ export interface DoctorCheck {
|
||||
readonly detail?: string;
|
||||
readonly remediation?: string;
|
||||
}
|
||||
/** Host facts and effects required by diagnostics. */
|
||||
/** Read-only host capabilities required by diagnostics. */
|
||||
export interface DoctorHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly nodeVersion: string;
|
||||
readonly configuredHopperPath?: string;
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
readable(path: string): Promise<boolean>;
|
||||
executable(path: string): Promise<boolean>;
|
||||
brewHopperPath(): Promise<string | undefined>;
|
||||
manualHopperPaths(): Promise<readonly string[]>;
|
||||
}
|
||||
|
||||
/** Check requirements and an optional target without mutating the host. */
|
||||
/**
|
||||
* Check requirements and an optional target without mutating the host.
|
||||
* Every failed check includes remediation suitable for either the one-shot CLI
|
||||
* or an agent consuming the structured result.
|
||||
*/
|
||||
export const runDoctor = async (
|
||||
target?: string,
|
||||
host: DoctorHost = systemDoctorHost(),
|
||||
@@ -68,7 +73,7 @@ export const runDoctor = async (
|
||||
].filter((value): value is string => value !== undefined);
|
||||
let hopperPath: string | undefined;
|
||||
for (const candidate of new Set(candidates))
|
||||
if (await host.readable(candidate)) {
|
||||
if (await host.executable(candidate)) {
|
||||
hopperPath = candidate;
|
||||
break;
|
||||
}
|
||||
@@ -120,6 +125,14 @@ export const systemDoctorHost = (): DoctorHost => ({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async executable(path) {
|
||||
try {
|
||||
await access(path, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async brewHopperPath() {
|
||||
return probeHomebrew(async (command) => {
|
||||
try {
|
||||
|
||||
@@ -21,11 +21,15 @@ import type { JsonValue } from "../hopper/protocol.js";
|
||||
|
||||
type EnhancedResult = Promise<Result<JsonValue, HopperError>>;
|
||||
|
||||
/** Application services composing official Hopper operations into eight RE tools. */
|
||||
/**
|
||||
* Composes official Hopper operations into bounded reverse-engineering tools.
|
||||
* Inputs are parsed again at this application boundary even though MCP normally
|
||||
* validates them, allowing direct callers to use the same service safely.
|
||||
*/
|
||||
export class EnhancedTools {
|
||||
constructor(private readonly hopper: HopperToolPort) {}
|
||||
|
||||
/** Parse and dispatch one enhanced tool call. */
|
||||
/** Parse inputs again at the direct-call boundary, then dispatch exhaustively. */
|
||||
execute(
|
||||
name: EnhancedToolName,
|
||||
input: unknown,
|
||||
|
||||
@@ -4,6 +4,11 @@ import type { JsonValue } from "../hopper/protocol.js";
|
||||
|
||||
/** Application-owned capability for invoking one official Hopper tool. */
|
||||
export interface HopperToolPort {
|
||||
/**
|
||||
* Invoke a declared bridge operation with JSON-safe arguments.
|
||||
* Implementations return expected Hopper, timeout, and cancellation failures
|
||||
* as values and must honor an already-aborted signal.
|
||||
*/
|
||||
callTool(
|
||||
name: string,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
|
||||
+50
-19
@@ -34,7 +34,11 @@ export type ClientConfigurationResult =
|
||||
readonly status: "failed";
|
||||
readonly reason: "backup" | "write" | "readback";
|
||||
};
|
||||
/** Effects required by the idempotent setup workflow. */
|
||||
/**
|
||||
* Effects required by the idempotent setup workflow.
|
||||
* Implementations report interrupted installers as values: setup must not wait
|
||||
* indefinitely for stdin or a GUI response in an unattended agent invocation.
|
||||
*/
|
||||
export interface SetupHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly nodeVersion: string;
|
||||
@@ -44,11 +48,18 @@ export interface SetupHost {
|
||||
hopperPath(): Promise<string | undefined>;
|
||||
installHopper(): Promise<boolean>;
|
||||
detectedClients(): Promise<readonly SetupClient[]>;
|
||||
configureClient(client: SetupClient): Promise<ClientConfigurationResult>;
|
||||
configureClient(
|
||||
client: SetupClient,
|
||||
hopperPath: string,
|
||||
): Promise<ClientConfigurationResult>;
|
||||
installSkill(): Promise<"installed" | "unchanged" | "failed">;
|
||||
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
|
||||
}
|
||||
/** Structured setup outcome with actionable partial progress. */
|
||||
/**
|
||||
* Structured setup outcome intended to travel back through an agent to a human.
|
||||
* `needs_human` carries remediation text instead of initiating an interactive
|
||||
* prompt, avoiding deadlocks in stdio and other unattended environments.
|
||||
*/
|
||||
export interface SetupResult {
|
||||
readonly status: "ready" | "needs_human";
|
||||
readonly actions: readonly string[];
|
||||
@@ -57,7 +68,11 @@ export interface SetupResult {
|
||||
readonly remediation?: string;
|
||||
}
|
||||
|
||||
/** Install prerequisites and configure every detected supported client idempotently. */
|
||||
/**
|
||||
* Install prerequisites and configure detected clients idempotently.
|
||||
* With `yes` false, mutations requiring consent are reported as `needs_human`;
|
||||
* with `yes` true, external installers still fail closed if they require UI.
|
||||
*/
|
||||
export const runSetup = async (
|
||||
yes: boolean,
|
||||
host: SetupHost = systemSetupHost(),
|
||||
@@ -85,15 +100,22 @@ export const runSetup = async (
|
||||
);
|
||||
actions.push("installed_homebrew");
|
||||
}
|
||||
if ((await host.hopperPath()) === undefined) {
|
||||
let hopperPath = await host.hopperPath();
|
||||
if (hopperPath === undefined) {
|
||||
if (!yes) return fail("Re-run with --yes to install Hopper.");
|
||||
if (!(await host.installHopper()))
|
||||
return fail(
|
||||
"Hopper installation was interrupted; resolve the system prompt and re-run setup.",
|
||||
);
|
||||
actions.push("installed_hopper");
|
||||
hopperPath = await host.hopperPath();
|
||||
if (hopperPath === undefined)
|
||||
return fail(
|
||||
"Hopper installation completed but its launcher was not found.",
|
||||
);
|
||||
}
|
||||
for (const client of await host.detectedClients()) {
|
||||
const result = await host.configureClient(client);
|
||||
const result = await host.configureClient(client, hopperPath);
|
||||
clients[client.name] = result;
|
||||
if (result.status === "failed")
|
||||
return fail(
|
||||
@@ -115,8 +137,7 @@ export const runSetup = async (
|
||||
...(doctor.healthy
|
||||
? {}
|
||||
: {
|
||||
remediation:
|
||||
"Run better-binary doctor and apply each reported remediation.",
|
||||
remediation: "Run rea doctor and apply each reported remediation.",
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -176,6 +197,7 @@ const detectJsonClients = async (
|
||||
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
|
||||
export const configureJsonClient = async (
|
||||
client: SetupClient,
|
||||
hopperPath?: string,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
let document: Record<string, unknown> = {};
|
||||
let original: string | undefined;
|
||||
@@ -185,8 +207,19 @@ export const configureJsonClient = async (
|
||||
} catch (cause: unknown) {
|
||||
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
|
||||
}
|
||||
const servers = objectValue(document.mcpServers);
|
||||
const desired = { command: PRODUCT_IDENTITY.mcpBinary };
|
||||
let servers: Record<string, unknown>;
|
||||
try {
|
||||
servers = parseOptionalObject(document.mcpServers);
|
||||
} catch {
|
||||
return { status: "failed", reason: "readback" };
|
||||
}
|
||||
const desired = {
|
||||
command: "npx",
|
||||
args: ["-y", PRODUCT_IDENTITY.packageName, "mcp"],
|
||||
...(hopperPath === undefined
|
||||
? {}
|
||||
: { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
|
||||
};
|
||||
if (
|
||||
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
|
||||
JSON.stringify(desired)
|
||||
@@ -194,7 +227,7 @@ export const configureJsonClient = async (
|
||||
return { status: "unchanged" };
|
||||
let backupPath: string | undefined;
|
||||
if (original !== undefined) {
|
||||
backupPath = `${client.configPath}.better-binary.backup`;
|
||||
backupPath = `${client.configPath}.rea.backup`;
|
||||
try {
|
||||
await copyFile(client.configPath, backupPath);
|
||||
} catch {
|
||||
@@ -206,7 +239,7 @@ export const configureJsonClient = async (
|
||||
[PRODUCT_IDENTITY.mcpServerKey]: desired,
|
||||
};
|
||||
const encoded = `${JSON.stringify(document, null, 2)}\n`;
|
||||
const temporary = `${client.configPath}.better-binary.tmp`;
|
||||
const temporary = `${client.configPath}.rea.tmp`;
|
||||
try {
|
||||
await mkdir(dirname(client.configPath), { recursive: true });
|
||||
await writeFile(temporary, encoded, { encoding: "utf8", mode: 0o600 });
|
||||
@@ -217,7 +250,7 @@ export const configureJsonClient = async (
|
||||
}
|
||||
try {
|
||||
const readback = parseObject(await readFile(client.configPath, "utf8"));
|
||||
const value = objectValue(readback.mcpServers)[
|
||||
const value = parseOptionalObject(readback.mcpServers)[
|
||||
PRODUCT_IDENTITY.mcpServerKey
|
||||
];
|
||||
if (JSON.stringify(value) !== JSON.stringify(desired)) {
|
||||
@@ -243,7 +276,7 @@ const installCanonicalSkill = async (
|
||||
PRODUCT_IDENTITY.skillName,
|
||||
"SKILL.md",
|
||||
);
|
||||
const content = `---\nname: ${PRODUCT_IDENTITY.skillName}\ndescription: Analyze binaries with Better Binary and Hopper.\n---\n\nOpen a target with open_binary, begin with binary_overview, and close it when finished.\n`;
|
||||
const content = `---\nname: ${PRODUCT_IDENTITY.skillName}\ndescription: Analyze binaries with REA and Hopper.\n---\n\nOpen a target with open_binary, begin with binary_overview, and close it when finished.\n`;
|
||||
try {
|
||||
if (
|
||||
(await readFile(destination, "utf8").catch(() => undefined)) === content
|
||||
@@ -288,12 +321,10 @@ const exists = async (path: string): Promise<boolean> => {
|
||||
}
|
||||
};
|
||||
const objectSchema = z.record(z.string(), z.unknown());
|
||||
const objectValue = (value: unknown): Record<string, unknown> => {
|
||||
const parsed = objectSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : {};
|
||||
};
|
||||
const parseOptionalObject = (value: unknown): Record<string, unknown> =>
|
||||
value === undefined ? {} : objectSchema.parse(value);
|
||||
const parseObject = (text: string): Record<string, unknown> =>
|
||||
objectValue(JSON.parse(text));
|
||||
objectSchema.parse(JSON.parse(text));
|
||||
const isMissing = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
||||
const restoreConfig = async (
|
||||
|
||||
@@ -5,7 +5,11 @@ export const HOMEBREW_COMMANDS = [
|
||||
"/usr/local/bin/brew",
|
||||
] as const;
|
||||
|
||||
/** Return the first successful result from the standard Homebrew executables. */
|
||||
/**
|
||||
* Return the first defined result from standard Homebrew executables.
|
||||
* A probe returning `undefined` means that location was unavailable or failed;
|
||||
* other falsy values are successful results and stop discovery.
|
||||
*/
|
||||
export const probeHomebrew = async <T>(
|
||||
probe: (command: string) => Promise<T | undefined>,
|
||||
): Promise<T | undefined> => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { BinarySession } from "./BinarySession.js";
|
||||
import { HopperApplicationLauncher } from "../hopper/BridgeLauncher.js";
|
||||
import { HopperClient } from "../hopper/HopperClient.js";
|
||||
|
||||
/**
|
||||
* Compose the target-switching runtime shared directly by CLI and MCP adapters.
|
||||
* This is the sole production wiring point for Hopper so neither adapter shells
|
||||
* out to the other and both retain identical loader and bridge semantics.
|
||||
*/
|
||||
export const createBinarySession = (config: AppConfig): BinarySession => {
|
||||
const bridgeScriptPath = fileURLToPath(
|
||||
new URL("../../bridge/hopper_bridge.py", import.meta.url),
|
||||
);
|
||||
return new BinarySession(
|
||||
(target) =>
|
||||
new HopperClient({
|
||||
launcher: new HopperApplicationLauncher({
|
||||
launcherPath: config.hopperLauncherPath,
|
||||
targetPath: target.path,
|
||||
targetKind: target.kind,
|
||||
loaderArgs:
|
||||
config.hopperLoaderArgs.length > 0
|
||||
? config.hopperLoaderArgs
|
||||
: target.loaderArgs,
|
||||
bridgeScriptPath,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { Cli, z } from "incur";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { runDoctor } from "./application/Doctor.js";
|
||||
import { runDirectAnalysis } from "./application/DirectAnalysis.js";
|
||||
import { runSetup } from "./application/Setup.js";
|
||||
import { PRODUCT_IDENTITY } from "./identity.js";
|
||||
|
||||
/**
|
||||
* Build the one-shot Incur CLI without starting Hopper at import time.
|
||||
* Analysis commands acquire and close their own sessions; bare `mcp` and
|
||||
* `--mcp` are intercepted by the executable dispatcher before this module loads.
|
||||
*/
|
||||
export const createCli = (): ReturnType<typeof Cli.create> => {
|
||||
const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, {
|
||||
version: "0.1.0",
|
||||
description: "Install, diagnose, and automate Hopper binary analysis.",
|
||||
mcp: {
|
||||
command: PRODUCT_IDENTITY.mcpCommand,
|
||||
instructions:
|
||||
"Open a local target with open_binary, analyze it, and close the session when finished.",
|
||||
},
|
||||
sync: {
|
||||
cwd: fileURLToPath(new URL("..", import.meta.url)),
|
||||
include: ["skills/*"],
|
||||
suggestions: [
|
||||
"open a binary and summarize it",
|
||||
"decompile a procedure",
|
||||
"diagnose my Hopper setup",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
cli.command("setup", {
|
||||
description: "Install prerequisites and configure binary analysis",
|
||||
options: z.object({
|
||||
yes: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Approve prerequisite installation"),
|
||||
}),
|
||||
alias: { yes: "y" },
|
||||
run: ({ options }) => runSetup(options.yes),
|
||||
});
|
||||
cli.command("doctor", {
|
||||
description: "Diagnose the local Hopper integration",
|
||||
options: z.object({
|
||||
target: z.string().optional().describe("Optional binary path to check"),
|
||||
}),
|
||||
run: ({ options }) => runDoctor(options.target),
|
||||
});
|
||||
cli.command("analyze", {
|
||||
description: "Analyze a binary through the shared Hopper core",
|
||||
args: z.object({
|
||||
path: z.string().describe("Binary or Hopper database path"),
|
||||
}),
|
||||
run: ({ args }) => runDirectAnalysis(args.path, "binary_overview", {}),
|
||||
});
|
||||
cli.command("decompile", {
|
||||
description: "Decompile an address through the shared Hopper core",
|
||||
args: z.object({
|
||||
path: z.string().describe("Binary path"),
|
||||
address: z.string().describe("Procedure address"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
runDirectAnalysis(args.path, "procedure_pseudo_code", {
|
||||
procedure: args.address,
|
||||
}),
|
||||
});
|
||||
return cli;
|
||||
};
|
||||
+6
-1
@@ -20,7 +20,12 @@ const environmentSchema = z.object({
|
||||
HOPPER_LOADER_ARGS_JSON: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Parse Hopper launcher configuration once at the composition root. */
|
||||
/**
|
||||
* Parse Hopper launcher configuration once at the composition root.
|
||||
* Explicit loader arguments override REA's header-derived defaults for a
|
||||
* supported target, allowing callers to refine Hopper's loader behavior without
|
||||
* bypassing REA's path and executable-header checks.
|
||||
*/
|
||||
export const parseConfig = (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): Result<AppConfig, ConfigurationError> => {
|
||||
|
||||
@@ -12,10 +12,10 @@ const caseSensitive = z
|
||||
.default(false)
|
||||
.describe("Whether to match case");
|
||||
|
||||
/** The architectural owner of a tool implementation. */
|
||||
/** Adapter family responsible for implementing a public MCP tool. */
|
||||
export type ToolKind = "official-proxy" | "enhanced" | "session";
|
||||
|
||||
/** Stable caller-visible contract retained across the Python-to-TypeScript migration. */
|
||||
/** Single source of truth for a public tool's name, description, and input. */
|
||||
export interface ToolContract {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
@@ -41,7 +41,7 @@ const session = (
|
||||
inputSchema: z.ZodObject,
|
||||
): ToolContract => ({ name, description, kind: "session", inputSchema });
|
||||
|
||||
/** All 31 tools forwarded unchanged to Hopper's official MCP server. */
|
||||
/** Bridge operations exposed without additional application composition. */
|
||||
export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
official(
|
||||
"address_name",
|
||||
@@ -180,7 +180,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
/** All 8 reverse-engineering tools implemented by betterBinaryMCP. */
|
||||
/** Bounded workflows composed from one or more bridge operations. */
|
||||
export const ENHANCED_TOOL_CONTRACTS = [
|
||||
enhanced(
|
||||
"swift_classes",
|
||||
@@ -224,7 +224,7 @@ export const ENHANCED_TOOL_CONTRACTS = [
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
/** Binary lifecycle tools available before Hopper is launched. */
|
||||
/** Target lifecycle tools available only on the long-lived MCP adapter. */
|
||||
export const SESSION_TOOL_CONTRACTS = [
|
||||
session(
|
||||
"open_binary",
|
||||
@@ -235,7 +235,11 @@ export const SESSION_TOOL_CONTRACTS = [
|
||||
session("binary_session", "Describe the active binary session", z.object({})),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
/** Complete caller-visible tool inventory. */
|
||||
/**
|
||||
* Complete ordered public inventory used by registration and verification.
|
||||
* Keep this collection at 42 tools unless a deliberate contract change updates
|
||||
* snapshots, package verification, and real-Hopper verification together.
|
||||
*/
|
||||
export const TOOL_CONTRACTS = [
|
||||
...OFFICIAL_TOOL_CONTRACTS,
|
||||
...ENHANCED_TOOL_CONTRACTS,
|
||||
|
||||
+97
-27
@@ -1,5 +1,11 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, open, realpath } from "node:fs/promises";
|
||||
import {
|
||||
access,
|
||||
open,
|
||||
realpath,
|
||||
stat,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
|
||||
import { BinaryTargetError } from "./errors.js";
|
||||
@@ -7,7 +13,11 @@ import { err, ok, type Result } from "./result.js";
|
||||
|
||||
/** CPU families understood by the supported executable loaders. */
|
||||
export type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64";
|
||||
/** A parsed binary target and the Hopper launcher arguments it requires. */
|
||||
/**
|
||||
* A canonical local target plus deterministic Hopper loader arguments.
|
||||
* Explicit loader and architecture flags prevent Hopper from presenting modal
|
||||
* format or FAT-architecture selection dialogs during agent-driven analysis.
|
||||
*/
|
||||
export interface BinaryTarget {
|
||||
readonly path: string;
|
||||
readonly kind: "executable" | "database";
|
||||
@@ -17,28 +27,35 @@ export interface BinaryTarget {
|
||||
readonly loaderArgs: readonly string[];
|
||||
}
|
||||
|
||||
/** Resolve, read, and classify a local binary path, including its architecture metadata. */
|
||||
/**
|
||||
* Resolve and classify a readable local target before Hopper is launched.
|
||||
* FAT Mach-O inputs select only a host-compatible architecture so setup remains
|
||||
* non-interactive; unsupported or ambiguous inputs are returned as typed errors.
|
||||
*/
|
||||
export const parseBinaryTarget = async (
|
||||
input: string,
|
||||
cwd = process.cwd(),
|
||||
hostArchitecture: NodeJS.Architecture = process.arch,
|
||||
targetKind?: BinaryTarget["kind"],
|
||||
): Promise<Result<BinaryTarget, BinaryTargetError>> => {
|
||||
const candidate = isAbsolute(input) ? input : resolve(cwd, input);
|
||||
try {
|
||||
await access(candidate, constants.R_OK);
|
||||
const path = await realpath(candidate);
|
||||
if (path.toLowerCase().endsWith(".hop"))
|
||||
if (!(await stat(path)).isFile())
|
||||
return err(new BinaryTargetError(path, "target is not a regular file"));
|
||||
if (
|
||||
targetKind === "database" ||
|
||||
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
|
||||
)
|
||||
return ok({ path, kind: "database", format: "hopper", loaderArgs: [] });
|
||||
const handle = await open(path, "r");
|
||||
let bytes: Buffer;
|
||||
let detected: Result<ExecutableMetadata, string>;
|
||||
try {
|
||||
bytes = Buffer.alloc(4096);
|
||||
const read = await handle.read(bytes, 0, bytes.length, 0);
|
||||
bytes = bytes.subarray(0, read.bytesRead);
|
||||
detected = await readExecutableMetadata(handle, hostArchitecture);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
const detected = parseExecutableHeader(bytes, hostArchitecture);
|
||||
if (!detected.ok) return err(new BinaryTargetError(path, detected.error));
|
||||
return ok({ path, kind: "executable", ...detected.value });
|
||||
} catch (cause: unknown) {
|
||||
@@ -53,7 +70,47 @@ type ExecutableMetadata = Pick<
|
||||
"format" | "architecture" | "availableArchitectures" | "loaderArgs"
|
||||
>;
|
||||
|
||||
/** Parse supported executable headers without performing I/O. */
|
||||
const readExecutableMetadata = async (
|
||||
handle: FileHandle,
|
||||
hostArchitecture: NodeJS.Architecture,
|
||||
): Promise<Result<ExecutableMetadata, string>> => {
|
||||
const prefix = Buffer.alloc(4096);
|
||||
const prefixRead = await handle.read(prefix, 0, prefix.length, 0);
|
||||
const bytes = prefix.subarray(0, prefixRead.bytesRead);
|
||||
if (bytes.length >= 64 && bytes[0] === 0x4d && bytes[1] === 0x5a) {
|
||||
const offset = bytes.readUInt32LE(0x3c);
|
||||
if (offset > bytes.length - 6) {
|
||||
const record = Buffer.alloc(6);
|
||||
const recordRead = await handle.read(record, 0, record.length, offset);
|
||||
return recordRead.bytesRead === record.length
|
||||
? parsePeRecord(record)
|
||||
: err("invalid or truncated PE header");
|
||||
}
|
||||
}
|
||||
if (bytes.length >= 8) {
|
||||
const magic = bytes.readUInt32BE(0);
|
||||
if ([0xcafebabf, 0xbfbafeca].includes(magic)) {
|
||||
const little = magic === 0xbfbafeca;
|
||||
const count = little ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4);
|
||||
const required = 8 + count * 32;
|
||||
if (count <= 128 && required > bytes.length) {
|
||||
const header = Buffer.alloc(required);
|
||||
const headerRead = await handle.read(header, 0, header.length, 0);
|
||||
return parseExecutableHeader(
|
||||
header.subarray(0, headerRead.bytesRead),
|
||||
hostArchitecture,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parseExecutableHeader(bytes, hostArchitecture);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse supported executable headers without I/O and derive explicit Hopper
|
||||
* loader arguments. The caller must supply the host architecture used for FAT
|
||||
* Mach-O slice selection.
|
||||
*/
|
||||
export const parseExecutableHeader = (
|
||||
bytes: Buffer,
|
||||
hostArchitecture: NodeJS.Architecture,
|
||||
@@ -88,7 +145,7 @@ const parseThinMachO = (
|
||||
format: "mach-o",
|
||||
architecture,
|
||||
availableArchitectures: [architecture],
|
||||
loaderArgs: ["-l", "Mach-O"],
|
||||
loaderArgs: ["-l", "Mach-O", hopperArchitectureFlag(architecture)],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -124,19 +181,17 @@ const parseFatMachO = (
|
||||
: undefined;
|
||||
if (preferred === undefined || !architectures.includes(preferred))
|
||||
return err(`FAT binary has no host-compatible ${host} architecture`);
|
||||
const flag =
|
||||
preferred === "arm64"
|
||||
? "--aarch64"
|
||||
: preferred === "x86_64"
|
||||
? "--x86_64"
|
||||
: preferred === "arm"
|
||||
? "--arm"
|
||||
: "--x86";
|
||||
return ok({
|
||||
format: "mach-o",
|
||||
architecture: preferred,
|
||||
availableArchitectures: architectures,
|
||||
loaderArgs: ["-l", "FAT", flag, "-l", "Mach-O"],
|
||||
loaderArgs: [
|
||||
"-l",
|
||||
"FAT",
|
||||
hopperArchitectureFlag(preferred),
|
||||
"-l",
|
||||
"Mach-O",
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -152,25 +207,27 @@ const parseElf = (bytes: Buffer): Result<ExecutableMetadata, string> => {
|
||||
format: "elf",
|
||||
architecture,
|
||||
availableArchitectures: [architecture],
|
||||
loaderArgs: ["-l", "ELF"],
|
||||
loaderArgs: ["-l", "ELF", hopperArchitectureFlag(architecture)],
|
||||
});
|
||||
};
|
||||
|
||||
const parsePe = (bytes: Buffer): Result<ExecutableMetadata, string> => {
|
||||
if (bytes.length < 64) return err("truncated PE DOS header");
|
||||
const offset = bytes.readUInt32LE(0x3c);
|
||||
if (
|
||||
offset > bytes.length - 6 ||
|
||||
bytes.toString("binary", offset, offset + 4) !== "PE\u0000\u0000"
|
||||
)
|
||||
if (offset > bytes.length - 6) return err("invalid or truncated PE header");
|
||||
return parsePeRecord(bytes.subarray(offset, offset + 6));
|
||||
};
|
||||
|
||||
const parsePeRecord = (record: Buffer): Result<ExecutableMetadata, string> => {
|
||||
if (record.length < 6 || record.toString("binary", 0, 4) !== "PE\u0000\u0000")
|
||||
return err("invalid or truncated PE header");
|
||||
const architecture = peArchitecture(bytes.readUInt16LE(offset + 4));
|
||||
const architecture = peArchitecture(record.readUInt16LE(4));
|
||||
if (architecture === undefined) return err("unsupported PE architecture");
|
||||
return ok({
|
||||
format: "pe",
|
||||
architecture,
|
||||
availableArchitectures: [architecture],
|
||||
loaderArgs: ["-l", "PE"],
|
||||
loaderArgs: ["-l", "WinPE", hopperArchitectureFlag(architecture)],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -215,3 +272,16 @@ const peArchitecture = (machine: number): BinaryArchitecture | undefined => {
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const hopperArchitectureFlag = (architecture: BinaryArchitecture): string => {
|
||||
switch (architecture) {
|
||||
case "x86":
|
||||
return "--intel-32";
|
||||
case "x86_64":
|
||||
return "--intel-64";
|
||||
case "arm":
|
||||
return "--armv7";
|
||||
case "arm64":
|
||||
return "--aarch64";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { execFile } from "node:child_process";
|
||||
import { chmod, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { HopperStartError } from "../domain/errors.js";
|
||||
import { HopperCancelledError, HopperStartError } from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const HOPPER_BACKGROUND_STARTUP_MS = 5_000;
|
||||
|
||||
/** Coordinates for one private bridge session. */
|
||||
export interface BridgeSession {
|
||||
readonly directory: string;
|
||||
@@ -21,7 +27,8 @@ export interface BridgeLaunch {
|
||||
export interface BridgeLauncher {
|
||||
launch(
|
||||
session: BridgeSession,
|
||||
): Promise<Result<BridgeLaunch, HopperStartError>>;
|
||||
options?: { readonly signal?: AbortSignal },
|
||||
): Promise<Result<BridgeLaunch, HopperStartError | HopperCancelledError>>;
|
||||
}
|
||||
|
||||
export interface HopperApplicationLauncherOptions {
|
||||
@@ -32,17 +39,30 @@ export interface HopperApplicationLauncherOptions {
|
||||
readonly bridgeScriptPath: string;
|
||||
}
|
||||
|
||||
/** Launches Hopper through its documented CLI and injects only the owned bridge script. */
|
||||
/**
|
||||
* Launches Hopper through its documented CLI and injects only REA's owned bridge.
|
||||
*
|
||||
* Hopper's `hopper` helper internally issues an AppleScript `activate` command.
|
||||
* Consequently, opening a target may bring Hopper to the foreground even though
|
||||
* REA first asks macOS to start the application hidden and in the background.
|
||||
* REA cannot reliably suppress that activation without replacing Hopper's
|
||||
* supported launcher; callers must treat possible foreground UI as an upstream
|
||||
* Hopper constraint, not as evidence that analysis failed.
|
||||
*
|
||||
* The returned process is the short-lived launcher helper, not the Hopper app,
|
||||
* so REA does not claim ownership of the application's lifetime.
|
||||
*/
|
||||
export class HopperApplicationLauncher implements BridgeLauncher {
|
||||
constructor(readonly options: HopperApplicationLauncherOptions) {}
|
||||
|
||||
async launch(
|
||||
session: BridgeSession,
|
||||
): Promise<Result<BridgeLaunch, HopperStartError>> {
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
): Promise<Result<BridgeLaunch, HopperStartError | HopperCancelledError>> {
|
||||
const bootstrapPath = `${session.directory}/bootstrap.py`;
|
||||
const source = [
|
||||
`BETTER_BINARY_SOCKET = ${JSON.stringify(session.socketPath)}`,
|
||||
`BETTER_BINARY_TOKEN = ${JSON.stringify(session.token)}`,
|
||||
`REA_SOCKET = ${JSON.stringify(session.socketPath)}`,
|
||||
`REA_TOKEN = ${JSON.stringify(session.token)}`,
|
||||
`exec(compile(open(${JSON.stringify(this.options.bridgeScriptPath)}, 'rb').read(), ${JSON.stringify(this.options.bridgeScriptPath)}, 'exec'))`,
|
||||
"",
|
||||
].join("\n");
|
||||
@@ -64,6 +84,13 @@ export class HopperApplicationLauncher implements BridgeLauncher {
|
||||
this.options.targetPath,
|
||||
];
|
||||
try {
|
||||
if (options.signal?.aborted === true)
|
||||
return err(new HopperCancelledError());
|
||||
const prepared = await prepareHopperApplication(
|
||||
this.options.launcherPath,
|
||||
options.signal,
|
||||
);
|
||||
if (!prepared) return err(new HopperCancelledError());
|
||||
const child = spawn(this.options.launcherPath, args, {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
@@ -88,3 +115,57 @@ export class HopperApplicationLauncher implements BridgeLauncher {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prepareHopperApplication = async (
|
||||
launcherPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
const appBundle = hopperApplicationBundle(launcherPath);
|
||||
if (appBundle === undefined) return signal?.aborted !== true;
|
||||
const executablePath = join(appBundle, "Contents/MacOS/Hopper Disassembler");
|
||||
if (await processIsRunning(executablePath)) return signal?.aborted !== true;
|
||||
// This reduces activation when Hopper is cold, but the vendor launcher that
|
||||
// follows may still activate its window. See HopperApplicationLauncher.
|
||||
await execFileAsync("/usr/bin/open", [
|
||||
"--hide",
|
||||
"--background",
|
||||
"-a",
|
||||
appBundle,
|
||||
]);
|
||||
return waitForDelay(HOPPER_BACKGROUND_STARTUP_MS, signal);
|
||||
};
|
||||
|
||||
const waitForDelay = (
|
||||
milliseconds: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
if (signal?.aborted === true) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(true);
|
||||
}, milliseconds);
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
};
|
||||
|
||||
const hopperApplicationBundle = (launcherPath: string): string | undefined => {
|
||||
if (basename(launcherPath) !== "hopper") return undefined;
|
||||
const candidate = dirname(dirname(dirname(launcherPath)));
|
||||
return extname(candidate) === ".app" ? candidate : undefined;
|
||||
};
|
||||
|
||||
const processIsRunning = async (executablePath: string): Promise<boolean> => {
|
||||
try {
|
||||
const processes = await execFileAsync("/bin/ps", ["-ax", "-o", "command="]);
|
||||
return processes.stdout
|
||||
.split("\n")
|
||||
.some((command) => command.trim() === executablePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
+58
-18
@@ -23,6 +23,7 @@ import {
|
||||
const MAX_LINE_BYTES = 10 * 1024 * 1024;
|
||||
const SESSION_ROOT = process.platform === "darwin" ? "/tmp" : tmpdir();
|
||||
|
||||
/** Dependencies, deadlines, and redacted diagnostics for one bridge client. */
|
||||
export interface HopperClientOptions {
|
||||
readonly launcher: BridgeLauncher;
|
||||
readonly requestTimeoutMs?: number;
|
||||
@@ -30,6 +31,7 @@ export interface HopperClientOptions {
|
||||
readonly onDiagnostic?: (event: HopperDiagnostic) => void;
|
||||
}
|
||||
|
||||
/** Safe launcher telemetry; stderr content is intentionally never exposed. */
|
||||
export type HopperDiagnostic =
|
||||
| { readonly type: "launcher-stderr"; readonly bytes: number }
|
||||
| { readonly type: "launcher-exit"; readonly code: number | null };
|
||||
@@ -46,7 +48,14 @@ interface PendingRequest {
|
||||
readonly onAbort: (() => void) | undefined;
|
||||
}
|
||||
|
||||
/** Owned client for the repository's authenticated in-Hopper bridge. */
|
||||
/**
|
||||
* Owns one authenticated NDJSON-over-Unix-socket bridge session.
|
||||
*
|
||||
* Each instance creates a private directory and random bearer token, correlates
|
||||
* concurrent requests by numeric id, and removes its artifacts on close. It
|
||||
* only terminates launch processes explicitly marked as owned; the normal
|
||||
* Hopper launcher does not confer ownership of the GUI application.
|
||||
*/
|
||||
export class HopperClient {
|
||||
readonly #options: Required<
|
||||
Pick<HopperClientOptions, "requestTimeoutMs" | "startupTimeoutMs">
|
||||
@@ -71,35 +80,41 @@ export class HopperClient {
|
||||
};
|
||||
}
|
||||
|
||||
/** Launch Hopper's owned bridge and complete its health handshake. */
|
||||
start(): Promise<Result<HopperServerInfo, HopperError>> {
|
||||
this.#startPromise ??= this.#start();
|
||||
/** Launch the bridge once and complete its authenticated health handshake. */
|
||||
start(signal?: AbortSignal): Promise<Result<HopperServerInfo, HopperError>> {
|
||||
this.#startPromise ??= this.#start(signal);
|
||||
return this.#startPromise;
|
||||
}
|
||||
|
||||
async #start(): Promise<Result<HopperServerInfo, HopperError>> {
|
||||
async #start(
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<HopperServerInfo, HopperError>> {
|
||||
if (signal?.aborted === true) return err(new HopperCancelledError());
|
||||
if (this.#socket !== undefined || this.#directory !== undefined) {
|
||||
return err(new HopperProtocolError("Hopper client is already started"));
|
||||
}
|
||||
try {
|
||||
this.#directory = await mkdtemp(join(SESSION_ROOT, "bbm-"));
|
||||
this.#directory = await mkdtemp(join(SESSION_ROOT, "rea-"));
|
||||
} catch (cause: unknown) {
|
||||
return err(new HopperStartError({ cause }));
|
||||
}
|
||||
const socketPath = join(this.#directory, "bridge.sock");
|
||||
this.#token = randomBytes(32).toString("hex");
|
||||
const launched = await this.#options.launcher.launch({
|
||||
directory: this.#directory,
|
||||
socketPath,
|
||||
token: this.#token,
|
||||
});
|
||||
const launched = await this.#options.launcher.launch(
|
||||
{
|
||||
directory: this.#directory,
|
||||
socketPath,
|
||||
token: this.#token,
|
||||
},
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
if (!launched.ok) {
|
||||
await this.close();
|
||||
return launched;
|
||||
}
|
||||
this.#launch = launched.value;
|
||||
this.#attachLauncher(launched.value);
|
||||
const connected = await this.#connect(socketPath);
|
||||
const connected = await this.#connect(socketPath, signal);
|
||||
if (!connected.ok) {
|
||||
await this.close();
|
||||
return connected;
|
||||
@@ -109,6 +124,7 @@ export class HopperClient {
|
||||
{},
|
||||
{
|
||||
timeoutMs: this.#options.startupTimeoutMs,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
},
|
||||
);
|
||||
if (!health.ok) {
|
||||
@@ -120,7 +136,7 @@ export class HopperClient {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Invoke one declared Hopper operation through the bridge. */
|
||||
/** Invoke one declared operation, returning timeout and cancellation as values. */
|
||||
async callTool(
|
||||
name: string,
|
||||
arguments_: Readonly<Record<string, JsonValue>> = {},
|
||||
@@ -129,7 +145,7 @@ export class HopperClient {
|
||||
readonly timeoutMs?: number;
|
||||
} = {},
|
||||
): Promise<Result<JsonValue, HopperError>> {
|
||||
const started = await this.start();
|
||||
const started = await this.start(options.signal);
|
||||
if (!started.ok) return started;
|
||||
return this.#request(name, arguments_, options);
|
||||
}
|
||||
@@ -168,14 +184,19 @@ export class HopperClient {
|
||||
}
|
||||
}
|
||||
|
||||
async #connect(socketPath: string): Promise<Result<undefined, HopperError>> {
|
||||
async #connect(
|
||||
socketPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<undefined, HopperError>> {
|
||||
const deadline = Date.now() + this.#options.startupTimeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (signal?.aborted === true) return err(new HopperCancelledError());
|
||||
if (this.#closing) return err(new HopperProcessError(null));
|
||||
try {
|
||||
await access(socketPath);
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
if (!(await waitForDelay(50, signal)))
|
||||
return err(new HopperCancelledError());
|
||||
continue;
|
||||
}
|
||||
const attempt = await connectOnce(socketPath);
|
||||
@@ -184,7 +205,8 @@ export class HopperClient {
|
||||
this.#attachSocket(attempt.value);
|
||||
return ok(undefined);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
if (!(await waitForDelay(50, signal)))
|
||||
return err(new HopperCancelledError());
|
||||
}
|
||||
return err(new HopperTimeoutError(this.#options.startupTimeoutMs));
|
||||
}
|
||||
@@ -320,6 +342,24 @@ export class HopperClient {
|
||||
}
|
||||
}
|
||||
|
||||
const waitForDelay = (
|
||||
milliseconds: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
if (signal?.aborted === true) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(true);
|
||||
}, milliseconds);
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
};
|
||||
|
||||
const connectOnce = async (
|
||||
socketPath: string,
|
||||
): Promise<Result<Socket, HopperStartError>> =>
|
||||
@@ -341,7 +381,7 @@ const parseServerInfo = (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
value.name === "betterBinaryMCP Hopper bridge" &&
|
||||
value.name === "REA Hopper bridge" &&
|
||||
typeof value.version === "string"
|
||||
) {
|
||||
return ok({ name: value.name, version: value.version });
|
||||
|
||||
+12
-8
@@ -1,10 +1,14 @@
|
||||
/** Provisional product identifiers kept together for release-time renaming. */
|
||||
/**
|
||||
* Canonical public identity shared by packaging, setup, MCP, and skill install.
|
||||
* Centralization prevents persistent client registrations from drifting from
|
||||
* the npm package or executable exposed in package.json.
|
||||
*/
|
||||
export const PRODUCT_IDENTITY = {
|
||||
displayName: "Better Binary",
|
||||
packageName: "better-binary-mcp",
|
||||
cliBinary: "better-binary",
|
||||
mcpBinary: "better-binary-mcp",
|
||||
mcpServerKey: "better-binary",
|
||||
skillName: "better-binary-analysis",
|
||||
configFileName: "better-binary.json",
|
||||
displayName: "REA",
|
||||
packageName: "@morluto/rea",
|
||||
cliBinary: "rea",
|
||||
mcpCommand: "npx -y @morluto/rea mcp",
|
||||
mcpServerKey: "rea",
|
||||
skillName: "rea-analysis",
|
||||
configFileName: "rea.json",
|
||||
} as const;
|
||||
|
||||
+11
-24
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { realpathSync } from "node:fs";
|
||||
|
||||
import {
|
||||
@@ -10,12 +9,14 @@ import {
|
||||
} from "@modelcontextprotocol/server/stdio";
|
||||
|
||||
import { parseConfig } from "./config.js";
|
||||
import { BinarySession } from "./application/BinarySession.js";
|
||||
import { HopperApplicationLauncher } from "./hopper/BridgeLauncher.js";
|
||||
import { HopperClient } from "./hopper/HopperClient.js";
|
||||
import { createBinarySession } from "./application/runtime.js";
|
||||
import { createServer } from "./server/createServer.js";
|
||||
|
||||
/** Start the production stdio server and install owned shutdown handlers. */
|
||||
/**
|
||||
* Start the long-lived MCP adapter and install idempotent shutdown handlers.
|
||||
* The adapter owns its BinarySession for the process lifetime; EOF and process
|
||||
* signals close bridge resources without assuming ownership of the Hopper app.
|
||||
*/
|
||||
export const run = async (): Promise<number> => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) {
|
||||
@@ -23,27 +24,13 @@ export const run = async (): Promise<number> => {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const bridgeScriptPath = fileURLToPath(
|
||||
new URL("../bridge/hopper_bridge.py", import.meta.url),
|
||||
);
|
||||
const session = new BinarySession(
|
||||
(target) =>
|
||||
new HopperClient({
|
||||
launcher: new HopperApplicationLauncher({
|
||||
launcherPath: config.value.hopperLauncherPath,
|
||||
targetPath: target.path,
|
||||
targetKind: target.kind,
|
||||
loaderArgs:
|
||||
config.value.hopperLoaderArgs.length > 0
|
||||
? config.value.hopperLoaderArgs
|
||||
: target.loaderArgs,
|
||||
bridgeScriptPath,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const session = createBinarySession(config.value);
|
||||
if (config.value.hopperTargetPath !== undefined) {
|
||||
const opened = await session.open(config.value.hopperTargetPath);
|
||||
const opened = await session.open(config.value.hopperTargetPath, {
|
||||
targetKind: config.value.hopperTargetKind,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
await session.close();
|
||||
process.stderr.write(`${opened.error._tag}: ${opened.error.message}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import { registerEnhancedTools } from "./registerEnhancedTools.js";
|
||||
import { registerOfficialTools } from "./registerOfficialTools.js";
|
||||
import { registerSessionTools } from "./registerSessionTools.js";
|
||||
|
||||
/** Construct one MCP server instance without acquiring subprocess resources. */
|
||||
/**
|
||||
* Construct one MCP server without acquiring subprocess resources.
|
||||
* Supplying a session adds target lifecycle tools; omitting it retains the
|
||||
* fixed-target seam used by focused tests and embedders.
|
||||
*/
|
||||
export const createServer = (
|
||||
hopper: HopperToolPort,
|
||||
session?: BinarySession,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "../contracts/toolContracts.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
|
||||
/** Register all eight enhanced reverse-engineering tools. */
|
||||
/** Register composed workflows against the same port as direct bridge tools. */
|
||||
export const registerEnhancedTools = (
|
||||
server: McpServer,
|
||||
hopper: HopperToolPort,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { jsonValueSchema, type JsonValue } from "../hopper/protocol.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
|
||||
/** Register every official Hopper proxy contract on one MCP server. */
|
||||
/** Register direct bridge proxies, preserving MCP cancellation and typed errors. */
|
||||
export const registerOfficialTools = (
|
||||
server: McpServer,
|
||||
hopper: HopperToolPort,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { BinarySession } from "../application/BinarySession.js";
|
||||
import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
|
||||
/** Register binary lifecycle management tools. */
|
||||
/** Register MCP-only target lifecycle operations on a long-lived session. */
|
||||
export const registerSessionTools = (
|
||||
server: McpServer,
|
||||
session: BinarySession,
|
||||
|
||||
@@ -4,7 +4,11 @@ import type { HopperError } from "../domain/errors.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type { JsonValue } from "../hopper/protocol.js";
|
||||
|
||||
/** Translate an application result into caller-visible MCP content. */
|
||||
/**
|
||||
* Translate an application result into MCP text content.
|
||||
* Error tags and safe messages remain visible while underlying causes, process
|
||||
* output, and other potentially sensitive details stay private.
|
||||
*/
|
||||
export const toCallToolResult = (
|
||||
result: Result<JsonValue, HopperError>,
|
||||
): CallToolResult =>
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("binary session", () => {
|
||||
expect((await session.open(second)).ok).toBe(false);
|
||||
expect(session.status()).toMatchObject({ open: true });
|
||||
expect(JSON.stringify(session.status())).toContain("first.hop");
|
||||
expect(created).toBe(2);
|
||||
expect(created).toBe(3);
|
||||
});
|
||||
|
||||
it("serializes concurrent opens and leaves the last target active", async () => {
|
||||
@@ -119,7 +119,7 @@ describe("binary session", () => {
|
||||
await opening;
|
||||
});
|
||||
|
||||
it("closes a failed candidate and preserves the previous target", async () => {
|
||||
it("closes a failed candidate and reopens the previous target", async () => {
|
||||
const [first, second] = await targets();
|
||||
const clients: TestClient[] = [];
|
||||
const session = new BinarySession(() => {
|
||||
@@ -130,11 +130,33 @@ describe("binary session", () => {
|
||||
await session.open(first);
|
||||
await session.open(second);
|
||||
expect(clients[1]?.closed).toBe(1);
|
||||
expect(clients[0]?.closed).toBe(0);
|
||||
expect(clients[0]?.closed).toBe(1);
|
||||
expect(clients[2]?.closed).toBe(0);
|
||||
expect(JSON.stringify(session.status())).toContain("first.hop");
|
||||
await session.close();
|
||||
await session.close();
|
||||
expect(clients[0]?.closed).toBe(1);
|
||||
expect(clients[2]?.closed).toBe(1);
|
||||
});
|
||||
|
||||
it("closes the active bridge before starting a replacement", async () => {
|
||||
const [first, second] = await targets();
|
||||
let liveClients = 0;
|
||||
let overlapped = false;
|
||||
const session = new BinarySession(() => ({
|
||||
callTool: () => {
|
||||
if (liveClients > 0) overlapped = true;
|
||||
liveClients += 1;
|
||||
return Promise.resolve(ok(null));
|
||||
},
|
||||
close: () => {
|
||||
liveClients -= 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
await session.open(first);
|
||||
await session.open(second);
|
||||
expect(overlapped).toBe(false);
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -33,9 +33,22 @@ describe("executable header parsing", () => {
|
||||
const arm = parseExecutableHeader(bytes, "arm64");
|
||||
const intel = parseExecutableHeader(bytes, "x64");
|
||||
expect(arm.ok && arm.value.loaderArgs).toContain("--aarch64");
|
||||
expect(intel.ok && intel.value.loaderArgs).toContain("--x86_64");
|
||||
expect(intel.ok && intel.value.loaderArgs).toContain("--intel-64");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[thinMach(0xfeedfacf, 0x0100000c), ["-l", "Mach-O", "--aarch64"]],
|
||||
[thinMach(0xcefaedfe, 0x01000007), ["-l", "Mach-O", "--intel-64"]],
|
||||
[elf(2, 2, 183), ["-l", "ELF", "--aarch64"]],
|
||||
[pe(0x8664), ["-l", "WinPE", "--intel-64"]],
|
||||
] as const)(
|
||||
"emits a complete non-interactive Hopper selection",
|
||||
(bytes, args) => {
|
||||
const result = parseExecutableHeader(bytes, "arm64");
|
||||
expect(result.ok && result.value.loaderArgs).toEqual(args);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects FAT files without a host-compatible slice", () => {
|
||||
expect(parseExecutableHeader(fat([0x01000007]), "arm64")).toMatchObject({
|
||||
ok: false,
|
||||
@@ -54,13 +67,45 @@ describe("executable header parsing", () => {
|
||||
|
||||
describe("binary target I/O", () => {
|
||||
it("resolves relative Hopper databases and rejects unknown or unreadable paths", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "bb-target-"));
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
|
||||
await writeFile(join(directory, "sample.hop"), "database");
|
||||
await writeFile(join(directory, "text"), "hello");
|
||||
expect((await parseBinaryTarget("sample.hop", directory)).ok).toBe(true);
|
||||
expect((await parseBinaryTarget("text", directory)).ok).toBe(false);
|
||||
expect((await parseBinaryTarget("missing", directory)).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-regular targets before reading them", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
|
||||
expect((await parseBinaryTarget(directory)).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("honors an explicit database kind without relying on the file suffix", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
|
||||
await writeFile(join(directory, "saved-analysis"), "database");
|
||||
const result = await parseBinaryTarget(
|
||||
"saved-analysis",
|
||||
directory,
|
||||
"arm64",
|
||||
"database",
|
||||
);
|
||||
expect(result.ok && result.value).toMatchObject({
|
||||
kind: "database",
|
||||
format: "hopper",
|
||||
loaderArgs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a PE header beyond the initial probe", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
|
||||
const path = join(directory, "delayed.exe");
|
||||
await writeFile(path, pe(0x8664, 8192));
|
||||
const result = await parseBinaryTarget(path, directory, "x64");
|
||||
expect(result.ok && result.value).toMatchObject({
|
||||
format: "pe",
|
||||
architecture: "x86_64",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const elf = (class_: number, endian: number, machine: number): Buffer => {
|
||||
@@ -84,11 +129,11 @@ const fat = (cpus: readonly number[], declared = cpus.length): Buffer => {
|
||||
cpus.forEach((cpu, index) => bytes.writeUInt32BE(cpu, 8 + index * 20));
|
||||
return bytes;
|
||||
};
|
||||
const pe = (machine: number): Buffer => {
|
||||
const bytes = Buffer.alloc(128);
|
||||
const pe = (machine: number, offset = 64): Buffer => {
|
||||
const bytes = Buffer.alloc(Math.max(128, offset + 6));
|
||||
bytes.write("MZ", 0, "ascii");
|
||||
bytes.writeUInt32LE(64, 0x3c);
|
||||
bytes.write("PE\0\0", 64, "binary");
|
||||
bytes.writeUInt16LE(machine, 68);
|
||||
bytes.writeUInt32LE(offset, 0x3c);
|
||||
bytes.write("PE\0\0", offset, "binary");
|
||||
bytes.writeUInt16LE(machine, offset + 4);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
+18
-3
@@ -6,6 +6,7 @@ const host = (overrides: Partial<DoctorHost> = {}): DoctorHost => ({
|
||||
nodeVersion: "22.1.0",
|
||||
macosVersion: () => Promise.resolve("12.0"),
|
||||
readable: (path) => Promise.resolve(path.includes("Hopper")),
|
||||
executable: (path) => Promise.resolve(path.includes("Hopper")),
|
||||
brewHopperPath: () => Promise.resolve(undefined),
|
||||
manualHopperPaths: () => Promise.resolve([]),
|
||||
...overrides,
|
||||
@@ -29,7 +30,7 @@ describe("doctor", () => {
|
||||
undefined,
|
||||
host({
|
||||
configuredHopperPath: "/manual/Hopper",
|
||||
readable: (path) => Promise.resolve(path === "/manual/Hopper"),
|
||||
executable: (path) => Promise.resolve(path === "/manual/Hopper"),
|
||||
}),
|
||||
);
|
||||
expect(result.hopperPath).toBe("/manual/Hopper");
|
||||
@@ -41,7 +42,7 @@ describe("doctor", () => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
readable: (candidate) => Promise.resolve(candidate === path),
|
||||
executable: (candidate) => Promise.resolve(candidate === path),
|
||||
brewHopperPath: () => Promise.resolve(path),
|
||||
}),
|
||||
);
|
||||
@@ -53,10 +54,24 @@ describe("doctor", () => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
readable: (candidate) => Promise.resolve(candidate === path),
|
||||
executable: (candidate) => Promise.resolve(candidate === path),
|
||||
manualHopperPaths: () => Promise.resolve([path]),
|
||||
}),
|
||||
);
|
||||
expect(result.hopperPath).toBe(path);
|
||||
});
|
||||
|
||||
it("rejects a readable Hopper launcher without execute permission", async () => {
|
||||
const path = "/manual/Hopper";
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
configuredHopperPath: path,
|
||||
readable: () => Promise.resolve(true),
|
||||
executable: () => Promise.resolve(false),
|
||||
}),
|
||||
);
|
||||
expect(result.hopperPath).toBeUndefined();
|
||||
expect(result.checks.find(({ name }) => name === "hopper")?.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+1
-1
@@ -24,7 +24,7 @@ const server = createServer((socket) => {
|
||||
send(
|
||||
{
|
||||
id: request.id,
|
||||
result: { name: "betterBinaryMCP Hopper bridge", version: "1.0.0" },
|
||||
result: { name: "REA Hopper bridge", version: "1.0.0" },
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -41,6 +41,23 @@ class FixtureLauncher implements BridgeLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
class SilentLauncher implements BridgeLauncher {
|
||||
launch() {
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
process: spawn(
|
||||
process.execPath,
|
||||
["-e", "setInterval(() => {}, 1000)"],
|
||||
{
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
},
|
||||
),
|
||||
ownsProcessLifetime: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const clients: HopperClient[] = [];
|
||||
const startClient = async () => {
|
||||
const client = new HopperClient({
|
||||
@@ -51,7 +68,7 @@ const startClient = async () => {
|
||||
clients.push(client);
|
||||
await expect(client.start()).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { name: "betterBinaryMCP Hopper bridge", version: "1.0.0" },
|
||||
value: { name: "REA Hopper bridge", version: "1.0.0" },
|
||||
});
|
||||
return client;
|
||||
};
|
||||
@@ -136,6 +153,24 @@ describe("HopperClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels bridge startup without waiting for the startup timeout", async () => {
|
||||
const client = new HopperClient({
|
||||
launcher: new SilentLauncher(),
|
||||
startupTimeoutMs: 10_000,
|
||||
});
|
||||
clients.push(client);
|
||||
const controller = new AbortController();
|
||||
const startedAt = Date.now();
|
||||
const pending = client.callTool("echo", {}, { signal: controller.signal });
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 10);
|
||||
const result = await pending;
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError");
|
||||
expect(Date.now() - startedAt).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("ignores more than 1,024 late responses without corrupting the session", async () => {
|
||||
const client = await startClient();
|
||||
const timedOut = await Promise.all(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../src/identity.js";
|
||||
|
||||
describe("package identity", () => {
|
||||
it("keeps the manifest, executable, and floating MCP command aligned", async () => {
|
||||
const packageJson = z
|
||||
.object({ name: z.string(), bin: z.record(z.string(), z.string()) })
|
||||
.parse(
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
fileURLToPath(new URL("../package.json", import.meta.url)),
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(packageJson.name).toBe(PRODUCT_IDENTITY.packageName);
|
||||
expect(Object.keys(packageJson.bin)).toEqual([PRODUCT_IDENTITY.cliBinary]);
|
||||
expect(PRODUCT_IDENTITY.mcpCommand).toBe(`npx -y ${packageJson.name} mcp`);
|
||||
});
|
||||
});
|
||||
@@ -44,4 +44,29 @@ describe("production stdio runtime", () => {
|
||||
}
|
||||
expect(stderr).toBe("");
|
||||
});
|
||||
|
||||
it("honors the configured kind for an initial database target", async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [mainPath],
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOPPER_LAUNCHER_PATH: process.execPath,
|
||||
HOPPER_TARGET_PATH: new URL(import.meta.url).pathname,
|
||||
HOPPER_TARGET_KIND: "database",
|
||||
HOPPER_LOADER_ARGS_JSON: JSON.stringify([fixturePath]),
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "database-runtime", version: "1.0.0" });
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
expect((await client.listTools()).tools).toHaveLength(42);
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ afterEach(async () => {
|
||||
|
||||
describe("target-free MCP lifecycle", () => {
|
||||
it("reports no-target, opens, analyzes, switches, reports status, and closes", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "bb-mcp-session-"));
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-mcp-session-"));
|
||||
const first = join(directory, "first.hop");
|
||||
const second = join(directory, "second.hop");
|
||||
await writeFile(first, "one");
|
||||
|
||||
@@ -22,6 +22,7 @@ class FakeSetupHost implements SetupHost {
|
||||
homebrewInstalls = 0;
|
||||
hopperInstalls = 0;
|
||||
configurations = 0;
|
||||
configuredHopperPaths: string[] = [];
|
||||
|
||||
macosVersion = (): Promise<string | undefined> =>
|
||||
Promise.resolve(this.version);
|
||||
@@ -41,8 +42,10 @@ class FakeSetupHost implements SetupHost {
|
||||
Promise.resolve(this.clients);
|
||||
configureClient = (
|
||||
client: SetupClient,
|
||||
hopperPath: string,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
this.configurations += 1;
|
||||
this.configuredHopperPaths.push(hopperPath);
|
||||
return Promise.resolve(
|
||||
this.clientResults.get(client.name) ?? {
|
||||
status: "configured",
|
||||
@@ -84,12 +87,22 @@ describe("setup workflow", () => {
|
||||
expect(result.remediation).toContain("--yes");
|
||||
});
|
||||
|
||||
it("requires --yes before installing Hopper", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.brew = true;
|
||||
const result = await runSetup(false, host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(result.remediation).toContain("--yes");
|
||||
});
|
||||
|
||||
it("accepts existing Homebrew and manually installed Hopper", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.brew = true;
|
||||
host.hopper = "/Applications/Manual Hopper";
|
||||
expect((await runSetup(true, host)).actions).toEqual(["installed_skill"]);
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(host.configuredHopperPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["homebrew", "hopper"] as const)(
|
||||
@@ -118,6 +131,7 @@ describe("setup workflow", () => {
|
||||
const result = await runSetup(true, host);
|
||||
expect(Object.keys(result.clients)).toEqual(["claude", "cursor"]);
|
||||
expect(host.configurations).toBe(2);
|
||||
expect(host.configuredHopperPaths).toEqual(["/Hopper", "/Hopper"]);
|
||||
});
|
||||
|
||||
it.each(["backup", "write", "readback"] as const)(
|
||||
|
||||
@@ -14,42 +14,65 @@ afterEach(async () => {
|
||||
|
||||
describe("JSON client configuration transaction", () => {
|
||||
it("preserves existing keys, creates a backup, and reads back the MCP entry", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "bb-setup-"));
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
|
||||
const configPath = join(directory, "mcp.json");
|
||||
const original =
|
||||
'{"theme":"dark","mcpServers":{"other":{"command":"other"}}}\n';
|
||||
await writeFile(configPath, original);
|
||||
const result = await configureJsonClient({ name: "cursor", configPath });
|
||||
expect(result.status).toBe("configured");
|
||||
expect(await readFile(`${configPath}.better-binary.backup`, "utf8")).toBe(
|
||||
original,
|
||||
);
|
||||
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
|
||||
expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({
|
||||
theme: "dark",
|
||||
mcpServers: {
|
||||
other: { command: "other" },
|
||||
"better-binary": { command: "better-binary-mcp" },
|
||||
rea: {
|
||||
command: "npx",
|
||||
args: ["-y", "@morluto/rea", "mcp"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("performs no write or second backup when configuration already matches", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "bb-setup-"));
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
|
||||
const configPath = join(directory, "mcp.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
'{"mcpServers":{"better-binary":{"command":"better-binary-mcp"}}}\n',
|
||||
'{"mcpServers":{"rea":{"command":"npx","args":["-y","@morluto/rea","mcp"]}}}\n',
|
||||
);
|
||||
expect(await configureJsonClient({ name: "cursor", configPath })).toEqual({
|
||||
status: "unchanged",
|
||||
});
|
||||
await expect(
|
||||
readFile(`${configPath}.better-binary.backup`, "utf8"),
|
||||
readFile(`${configPath}.rea.backup`, "utf8"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("persists a custom Hopper launcher and remains idempotent", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
|
||||
const configPath = join(directory, "mcp.json");
|
||||
const hopperPath = "/Applications/Hopper v6.app/Contents/MacOS/hopper";
|
||||
const client = { name: "cursor", configPath };
|
||||
expect(await configureJsonClient(client, hopperPath)).toMatchObject({
|
||||
status: "configured",
|
||||
});
|
||||
expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({
|
||||
mcpServers: {
|
||||
rea: {
|
||||
command: "npx",
|
||||
args: ["-y", "@morluto/rea", "mcp"],
|
||||
env: { HOPPER_LAUNCHER_PATH: hopperPath },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await configureJsonClient(client, hopperPath)).toEqual({
|
||||
status: "unchanged",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses malformed existing JSON without overwriting it", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "bb-setup-"));
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
|
||||
const configPath = join(directory, "mcp.json");
|
||||
await writeFile(configPath, "not-json");
|
||||
expect(await configureJsonClient({ name: "cursor", configPath })).toEqual({
|
||||
@@ -58,4 +81,37 @@ describe("JSON client configuration transaction", () => {
|
||||
});
|
||||
expect(await readFile(configPath, "utf8")).toBe("not-json");
|
||||
});
|
||||
|
||||
it.each(["null", "[]", '"value"'])(
|
||||
"refuses a non-object JSON root without overwriting %s",
|
||||
async (original) => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
|
||||
const configPath = join(directory, "mcp.json");
|
||||
await writeFile(configPath, original);
|
||||
expect(await configureJsonClient({ name: "cursor", configPath })).toEqual(
|
||||
{ status: "failed", reason: "readback" },
|
||||
);
|
||||
expect(await readFile(configPath, "utf8")).toBe(original);
|
||||
await expect(
|
||||
readFile(`${configPath}.rea.backup`, "utf8"),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["null", "[]", '"value"'])(
|
||||
"preserves a non-object mcpServers value %s",
|
||||
async (servers) => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
|
||||
const configPath = join(directory, "mcp.json");
|
||||
const original = `{"mcpServers":${servers}}`;
|
||||
await writeFile(configPath, original);
|
||||
expect(await configureJsonClient({ name: "cursor", configPath })).toEqual(
|
||||
{ status: "failed", reason: "readback" },
|
||||
);
|
||||
expect(await readFile(configPath, "utf8")).toBe(original);
|
||||
await expect(
|
||||
readFile(`${configPath}.rea.backup`, "utf8"),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"skipLibCheck": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2023",
|
||||
"types": ["node"],
|
||||
|
||||
Reference in New Issue
Block a user