mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
Merge pull request #1 from morluto/morl/binaryimprovement
Add dynamic binary sessions and guided onboarding
This commit is contained in:
35 files changed
+2275
-228
No files matched your search
@@ -1,177 +1,70 @@
|
||||
# betterBinaryMCP
|
||||
# Better Binary
|
||||
|
||||
A TypeScript MCP server for [Hopper Disassembler](https://www.hopperapp.com/). It preserves 31 Hopper operations and adds 8 reverse-engineering workflows for Swift, Objective-C, decompilation, call graphs, cross-references, and binary summaries.
|
||||
Agent-ready Hopper Disassembler tooling: a beta.3 MCP server with 31 official proxies, 8 enhanced reverse-engineering workflows, and 3 binary-session tools.
|
||||
|
||||
## Prerequisites
|
||||
## Install from this checkout
|
||||
|
||||
Check these before you start:
|
||||
|
||||
- **Node.js 20+** (`node --version`)
|
||||
- **Hopper Disassembler 6+** on macOS, at `/Applications/Hopper Disassembler.app/`
|
||||
- **A binary to analyze** (Mach-O executable, `.dylib`, `.hop` database, etc.)
|
||||
|
||||
Accessibility permission is not required.
|
||||
|
||||
## Quick Start
|
||||
Requires macOS 12+ and Node.js 22+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/morluto/betterBinaryMCP.git && cd betterBinaryMCP
|
||||
npm ci && npm run build
|
||||
npm ci
|
||||
npm run build
|
||||
npm link
|
||||
better-binary setup --yes
|
||||
```
|
||||
|
||||
Generate an MCP config with absolute paths filled in:
|
||||
`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:
|
||||
|
||||
```bash
|
||||
npm run config:print -- /path/to/your/binary
|
||||
better-binary --help
|
||||
better-binary --llms
|
||||
better-binary doctor --target /path/to/binary
|
||||
better-binary skills add
|
||||
better-binary mcp add
|
||||
```
|
||||
|
||||
Paste the output into your client's MCP config file:
|
||||
## MCP workflow
|
||||
|
||||
| Client | Config path |
|
||||
| ----------------- | ------------------------------------------------------------------------- |
|
||||
| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) |
|
||||
| Cursor | `.cursor/mcp.json` in your project root |
|
||||
| Other MCP clients | Check the client's docs for MCP server config |
|
||||
Start `better-binary-mcp` with no target. In the connected agent:
|
||||
|
||||
Restart the client. The 39 tools appear in the tool list.
|
||||
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.
|
||||
4. Call `close_binary` when finished. `binary_session` reports current state.
|
||||
|
||||
```bash
|
||||
# For a .hop database:
|
||||
npm run config:print -- /path/to/file.hop --kind database
|
||||
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.
|
||||
|
||||
# For a FAT binary that needs loader args:
|
||||
npm run config:print -- /path/to/binary --loader-args '["-l","Mach-O","--aarch64"]'
|
||||
```
|
||||
`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.
|
||||
|
||||
### What happens on first tool call
|
||||
|
||||
The server does not launch Hopper at startup. Hopper launches lazily on the first tool call that needs it, which means the first call can take 10-30 seconds while Hopper opens and analyzes the binary. Subsequent calls are fast. If the first call times out, see [Troubleshooting](#troubleshooting).
|
||||
|
||||
### Try it
|
||||
|
||||
Once connected, call `binary_overview` with no arguments. It returns segment layout, procedure count, and string count. If you get JSON back, the server is working.
|
||||
|
||||
### Set up with your agent
|
||||
|
||||
Paste this prompt into Claude, Cursor, or any MCP-capable agent to have it do the setup for you:
|
||||
|
||||
> I want to connect betterBinaryMCP (a Hopper Disassembler MCP server) to this client.
|
||||
>
|
||||
> 1. Clone `https://github.com/morluto/betterBinaryMCP.git` and run `npm ci && npm run build`.
|
||||
> 2. Run `npm run config:print -- /path/to/my/binary` to generate the MCP server config.
|
||||
> 3. Paste the output into this client's MCP config and restart.
|
||||
> 4. Call `binary_overview` with no arguments to verify the server is working.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
MCP client ← stdio → TypeScript MCP server ← Unix socket → owned Python bridge → Hopper.app
|
||||
```
|
||||
|
||||
The codebase is a layered ESM TypeScript application. Dependencies flow inward: `domain` ← `contracts` ← `hopper` ← `application` ← `server` ← `main`.
|
||||
|
||||
- `src/main.ts` is the composition root: it parses config, wires the Hopper client, starts the stdio MCP transport, and owns process-lifetime shutdown.
|
||||
- `src/hopper/` lazily launches Hopper on the first Hopper-dependent tool and correlates bounded bridge messages, so lengthy analysis does not block the MCP handshake.
|
||||
- `bridge/hopper_bridge.py` runs inside Hopper and calls its public Python API. Hopper's bundled MCP server is not executed or required.
|
||||
- `src/application/` composes official operations into the 8 enhanced tools.
|
||||
- `src/domain/` holds pure parsers and the tagged error algebra; `src/contracts/` declares the caller-visible tool schemas.
|
||||
|
||||
## Manual MCP Configuration
|
||||
|
||||
If you prefer to write the config by hand, build the project and use absolute paths:
|
||||
Manual target-free MCP configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"betterBinaryMCP": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/betterBinaryMCP/dist/main.js"],
|
||||
"env": {
|
||||
"HOPPER_TARGET_PATH": "/absolute/path/to/binary"
|
||||
}
|
||||
"better-binary": {
|
||||
"command": "better-binary-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stdout is reserved for MCP protocol messages; diagnostics use stderr.
|
||||
The first `open_binary` can take time while Hopper analyzes the file. Analysis calls made without an open target return an actionable `NoBinaryOpenError`.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
| ------------------------- | -------- | ------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `HOPPER_TARGET_PATH` | yes | — | Absolute path to the binary or `.hop` database to analyze. |
|
||||
| `HOPPER_LAUNCHER_PATH` | no | `/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` | Override the Hopper executable path. |
|
||||
| `HOPPER_TARGET_KIND` | no | `executable` | `executable` or `database` (for `.hop` files). |
|
||||
| `HOPPER_LOADER_ARGS_JSON` | no | `[]` | JSON array of strings passed to the Hopper launcher before the target. |
|
||||
|
||||
For an Apple FAT binary, for example:
|
||||
|
||||
```json
|
||||
["-l", "FAT", "--aarch64", "-l", "Mach-O"]
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### 31 Official Tools (proxied)
|
||||
|
||||
| Tool | Description |
|
||||
| --------------------------------------------------------------------------------------------------------- | ---------------------------------- |
|
||||
| `address_name` | Get the name (label) at an address |
|
||||
| `comment` / `inline_comment` | Get comments at an address |
|
||||
| `current_address` / `current_procedure` / `current_document` | Cursor state |
|
||||
| `goto_address` / `next_address` / `prev_address` | Navigation |
|
||||
| `list_segments` / `list_procedures` / `list_strings` / `list_names` / `list_documents` / `list_bookmarks` | Listing |
|
||||
| `search_procedures` / `search_strings` | Regex search |
|
||||
| `procedure_pseudo_code` / `procedure_assembly` | Decompilation |
|
||||
| `procedure_callees` / `procedure_callers` | Call relationships |
|
||||
| `procedure_info` / `procedure_address` | Procedure metadata |
|
||||
| `set_address_name` / `set_addresses_names` | Rename labels |
|
||||
| `set_comment` / `set_inline_comment` | Write comments |
|
||||
| `set_bookmark` / `unset_bookmark` | Bookmarks |
|
||||
| `set_current_document` | Switch active document |
|
||||
| `xrefs` | Cross-references |
|
||||
|
||||
### 8 Enhanced Tools
|
||||
|
||||
| Tool | Parameters | Purpose |
|
||||
| --------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `binary_overview` | — | Summarize documents, segments, procedures, and strings |
|
||||
| `swift_classes` | `pattern?: string` | Find Swift `_TtC` class symbols |
|
||||
| `get_objc_classes` | `pattern?: string` | Find Objective-C class labels |
|
||||
| `get_objc_protocols` | — | Find Objective-C and Swift protocol labels |
|
||||
| `batch_decompile` | `addresses: string[]` (max 20) | Decompile up to 20 procedures concurrently |
|
||||
| `get_call_graph` | `address: string`, `direction?: "forward"\|"backward"`, `depth?: 1–5` | Traverse callers or callees to a bounded depth |
|
||||
| `analyze_swift_types` | — | Categorize Swift classes, structs, enums, protocols, and extensions |
|
||||
| `find_xrefs_to_name` | `name: string` | Resolve a name and retrieve cross-references |
|
||||
|
||||
## Development
|
||||
## Development and verification
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm test
|
||||
npm run check
|
||||
```
|
||||
|
||||
`npm test` builds first. Tests cover all 39 contracts, every handler through a beta.3 MCP client, bridge failures, concurrency, strict boundary parsing, and the compiled stdio runtime.
|
||||
|
||||
Run the real-Hopper verifier with a representative binary and any required loader options:
|
||||
|
||||
```bash
|
||||
HOPPER_TARGET_PATH=/path/to/binary \
|
||||
HOPPER_LOADER_ARGS_JSON='["-l","Mach-O","--aarch64"]' \
|
||||
npm pack --dry-run
|
||||
HOPPER_TARGET_PATH=/path/to/target-a \
|
||||
HOPPER_SECOND_TARGET_PATH=/path/to/distinct-target-b \
|
||||
npm run verify:hopper
|
||||
```
|
||||
|
||||
It asserts the exact 39-tool inventory, document access, `binary_overview`, segment reads, a one-procedure bounded decompile, clean protocol output (no stderr), absence of Hopper's bundled MCP process, and clean shutdown (no leaked bridge session directories or lingering processes).
|
||||
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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If startup times out, verify the target path and reproduce the generated loader chain with Hopper's `hopper` launcher. A visible loader or save dialog prevents the post-analysis bridge from starting; supply explicit loader/CPU options or close the stale dialog. Granting Accessibility access will not fix this.
|
||||
|
||||
`batch_decompile` accepts at most 20 addresses, and `get_call_graph` accepts depths from 1 through 5.
|
||||
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.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -132,19 +132,13 @@ def _dispatch(method, params):
|
||||
return _selected_document
|
||||
|
||||
document = _document(params.get("document"))
|
||||
address = _address(document, params.get("address")) if method not in (
|
||||
"list_segments", "list_procedures", "list_strings", "list_names",
|
||||
"search_procedures", "search_strings", "procedure_address",
|
||||
"procedure_info", "procedure_assembly", "procedure_pseudo_code",
|
||||
"procedure_callers", "procedure_callees", "set_addresses_names",
|
||||
"list_bookmarks", "current_address", "current_procedure"
|
||||
) else None
|
||||
|
||||
if method == "current_address":
|
||||
return _hex(document.getCurrentAddress())
|
||||
if method == "current_procedure":
|
||||
return _procedure_name(_procedure(document))
|
||||
if method == "goto_address":
|
||||
address = _address(document, params.get("address"))
|
||||
document.moveCursorAtAddress(address)
|
||||
return _hex(address)
|
||||
if method in ("address_name", "comment", "inline_comment", "xrefs"):
|
||||
@@ -208,10 +202,12 @@ def _dispatch(method, params):
|
||||
length = sum(max(0, block.getEndingAddress() - block.getStartingAddress()) for block in blocks)
|
||||
return {"name": _procedure_name(procedure), "entrypoint": _hex(procedure.getEntryPoint()), "basicblock_count": procedure.getBasicBlockCount(), "length": length, "signature": procedure.signatureString(), "locals": procedure.getLocalVariableList()}
|
||||
if method == "set_address_name":
|
||||
address = _address(document, params.get("address"))
|
||||
return document.setNameAtAddress(address, params["name"])
|
||||
if method == "set_addresses_names":
|
||||
return {key: document.setNameAtAddress(_address(document, key), value) for key, value in params["names"].items()}
|
||||
if method in ("set_comment", "set_inline_comment"):
|
||||
address = _address(document, params.get("address"))
|
||||
segment = _segment(document, address)
|
||||
setter = segment.setCommentAtAddress if method == "set_comment" else segment.setInlineCommentAtAddress
|
||||
getter = segment.getCommentAtAddress if method == "set_comment" else segment.getInlineCommentAtAddress
|
||||
@@ -220,9 +216,11 @@ def _dispatch(method, params):
|
||||
if method == "list_bookmarks":
|
||||
return [{"address": _hex(item), "name": document.getBookmarkName(item)} for item in document.getBookmarks()]
|
||||
if method == "set_bookmark":
|
||||
address = _address(document, params.get("address"))
|
||||
document.setBookmarkAtAddress(address, params.get("name"))
|
||||
return document.hasBookmarkAtAddress(address)
|
||||
if method == "unset_bookmark":
|
||||
address = _address(document, params.get("address"))
|
||||
document.removeBookmarkAtAddress(address)
|
||||
return not document.hasBookmarkAtAddress(address)
|
||||
raise ValueError("Unknown bridge method")
|
||||
|
||||
Generated
+65
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.3",
|
||||
"incur": "0.4.13",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -28,6 +29,12 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@cfworker/json-schema": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
|
||||
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
@@ -602,6 +609,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scalar/openapi-types": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.8.0.tgz",
|
||||
"integrity": "sha512-WmaxVSfvY5K/TwcG2B2TU1WOe1As1uc2s7myswtP6dBlcjU3hM08SApxv/jmyGaCE8t4gO5BBhmHY4pDUfmr2g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -609,6 +625,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@toon-format/toon": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.3.0.tgz",
|
||||
"integrity": "sha512-/Ew9etdRQKVMnm9fDaCG0JjyAOK/O7T0M97oum1aW4W+UR8ZhVVPBanIV7oWgHBiGlnVxV9M55PWQCHofDV07w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
@@ -1646,6 +1668,28 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/incur": {
|
||||
"version": "0.4.13",
|
||||
"resolved": "https://registry.npmjs.org/incur/-/incur-0.4.13.tgz",
|
||||
"integrity": "sha512-BeKlYFLIsRCgC8IxsUd3S2/4kPeZaNf1Rbz+Kz41jQII4jOgr7j5w4KYe00aAEQa3V0Ur57BVxE5JDTx8L2s5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.1.1",
|
||||
"@modelcontextprotocol/server": "^2.0.0-alpha.2",
|
||||
"@scalar/openapi-types": "^0.8.0",
|
||||
"@toon-format/toon": "^2.1.0",
|
||||
"tokenx": "^1.3.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"bin": {
|
||||
"incur": "dist/bin.js",
|
||||
"incur.src": "src/bin.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -2474,6 +2518,12 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tokenx": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tokenx/-/tokenx-1.3.0.tgz",
|
||||
"integrity": "sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
@@ -2774,6 +2824,21 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
+12
-1
@@ -4,11 +4,20 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"better-binary": "./scripts/better-binary.mjs",
|
||||
"better-binary-mcp": "./dist/main.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bridge",
|
||||
"scripts/better-binary.mjs",
|
||||
"skills",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"check": "npm run typecheck && npm run lint && npm run format:check && npm test",
|
||||
@@ -21,10 +30,12 @@
|
||||
"test:watch": "vitest",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.3",
|
||||
"incur": "0.4.13",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/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();
|
||||
@@ -0,0 +1,178 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
chmod,
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const root = process.cwd();
|
||||
const workspace = await mkdtemp(join(tmpdir(), "better-binary-package-"));
|
||||
let tarball;
|
||||
|
||||
try {
|
||||
tarball = (
|
||||
await exec("npm", ["pack", "--silent"], { cwd: root })
|
||||
).stdout.trim();
|
||||
const prefix = join(workspace, "prefix");
|
||||
const home = join(workspace, "home");
|
||||
const fakeBin = join(workspace, "bin");
|
||||
await mkdir(fakeBin, { recursive: true });
|
||||
const brew = join(fakeBin, "brew");
|
||||
const swVers = join(fakeBin, "sw_vers");
|
||||
const npx = join(fakeBin, "npx");
|
||||
const npxLog = join(workspace, "npx.log");
|
||||
const hopper = join(workspace, "hopper");
|
||||
await writeFile(
|
||||
brew,
|
||||
'#!/bin/sh\nif [ "$1" = "--prefix" ]; then echo /fake/cask; fi\nexit 0\n',
|
||||
);
|
||||
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',
|
||||
);
|
||||
await writeFile(hopper, "#!/bin/sh\nexit 0\n");
|
||||
await Promise.all([
|
||||
chmod(brew, 0o755),
|
||||
chmod(swVers, 0o755),
|
||||
chmod(hopper, 0o755),
|
||||
chmod(npx, 0o755),
|
||||
]);
|
||||
const claudeDir = join(home, "Library/Application Support/Claude");
|
||||
const cursorDir = join(home, ".cursor");
|
||||
await Promise.all([
|
||||
mkdir(claudeDir, { recursive: true }),
|
||||
mkdir(cursorDir, { recursive: true }),
|
||||
]);
|
||||
const claudeConfig = join(claudeDir, "claude_desktop_config.json");
|
||||
const cursorConfig = join(cursorDir, "mcp.json");
|
||||
await writeFile(claudeConfig, '{"existing":true}\n');
|
||||
await writeFile(cursorConfig, '{"existing":true}\n');
|
||||
const environment = {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
||||
HOPPER_LAUNCHER_PATH: hopper,
|
||||
BETTER_BINARY_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 help = await run(cli, ["--help"], environment);
|
||||
const llms = await run(cli, ["--llms"], environment);
|
||||
const doctor = json(await run(cli, ["doctor", "--json"], environment));
|
||||
if (
|
||||
!help.includes("setup") ||
|
||||
!llms.includes("decompile") ||
|
||||
doctor.healthy !== true
|
||||
)
|
||||
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");
|
||||
const first = json(await run(cli, ["setup", "--yes", "--json"], environment));
|
||||
const second = json(
|
||||
await run(cli, ["setup", "--yes", "--json"], environment),
|
||||
);
|
||||
if (
|
||||
first.status !== "ready" ||
|
||||
second.status !== "ready" ||
|
||||
second.actions.length !== 0
|
||||
)
|
||||
throw new Error("packaged setup was not ready and idempotent");
|
||||
for (const configPath of [claudeConfig, cursorConfig]) {
|
||||
const config = json(await readFile(configPath, "utf8"));
|
||||
if (
|
||||
config.existing !== true ||
|
||||
config.mcpServers?.["better-binary"]?.command !== "better-binary-mcp"
|
||||
)
|
||||
throw new Error("packaged client readback failed");
|
||||
if (
|
||||
!(await readFile(`${configPath}.better-binary.backup`, "utf8")).includes(
|
||||
"existing",
|
||||
)
|
||||
)
|
||||
throw new Error("packaged client backup failed");
|
||||
}
|
||||
const skill = await readFile(
|
||||
join(home, ".agents/skills/better-binary-analysis/SKILL.md"),
|
||||
"utf8",
|
||||
);
|
||||
if (!skill.includes("open_binary"))
|
||||
throw new Error("packaged skill readback failed");
|
||||
|
||||
await writeFile(cursorConfig, "malformed");
|
||||
const failed = json(
|
||||
await run(cli, ["setup", "--yes", "--json"], environment),
|
||||
);
|
||||
if (
|
||||
failed.status !== "needs_human" ||
|
||||
(await readFile(cursorConfig, "utf8")) !== "malformed"
|
||||
)
|
||||
throw new Error("packaged setup failure recovery did not preserve input");
|
||||
await writeFile(cursorConfig, "{}\n");
|
||||
const recovered = json(
|
||||
await run(cli, ["setup", "--yes", "--json"], environment),
|
||||
);
|
||||
if (recovered.status !== "ready")
|
||||
throw new Error("packaged setup did not recover");
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: mcp,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
let mcpStderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
mcpStderr += chunk.toString();
|
||||
});
|
||||
const client = new Client({ name: "package-smoke", version: "1.0.0" });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
if ((await client.listTools()).tools.length !== 42)
|
||||
throw new Error("packaged MCP did not expose 42 tools");
|
||||
const result = await client.callTool({
|
||||
name: "current_document",
|
||||
arguments: {},
|
||||
});
|
||||
if (result.isError !== true)
|
||||
throw new Error("packaged target-free MCP omitted no-target error");
|
||||
} catch (cause) {
|
||||
throw new Error(`packaged MCP smoke failed: ${mcpStderr}`, { cause });
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
|
||||
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`,
|
||||
);
|
||||
} finally {
|
||||
if (tarball) await rm(join(root, tarball), { force: true });
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function run(command, args, env) {
|
||||
return (await exec(command, args, { env })).stdout;
|
||||
}
|
||||
function json(text) {
|
||||
return JSON.parse(text);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { readdir, realpath } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
@@ -21,15 +21,27 @@ const textValue = (result) => {
|
||||
|
||||
const jsonValue = (result) => JSON.parse(textValue(result));
|
||||
|
||||
if (!process.env.HOPPER_TARGET_PATH) {
|
||||
throw new Error("HOPPER_TARGET_PATH is required");
|
||||
}
|
||||
const targetPath = process.env.HOPPER_TARGET_PATH;
|
||||
const secondTargetPath = process.env.HOPPER_SECOND_TARGET_PATH;
|
||||
if (!targetPath || !secondTargetPath)
|
||||
throw new Error(
|
||||
"HOPPER_TARGET_PATH and HOPPER_SECOND_TARGET_PATH are required and must name distinct binaries",
|
||||
);
|
||||
const [targetA, targetB] = await Promise.all([
|
||||
realpath(targetPath),
|
||||
realpath(secondTargetPath),
|
||||
]);
|
||||
if (targetA === targetB)
|
||||
throw new Error("Real-Hopper verification requires two distinct targets");
|
||||
const serverEnvironment = { ...process.env };
|
||||
delete serverEnvironment.HOPPER_TARGET_PATH;
|
||||
delete serverEnvironment.HOPPER_SECOND_TARGET_PATH;
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: ["dist/main.js"],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
env: serverEnvironment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
let stderrBytes = 0;
|
||||
@@ -45,10 +57,21 @@ try {
|
||||
const expectedNames = TOOL_CONTRACTS.map(({ name }) => name).sort();
|
||||
const actualNames = listed.tools.map(({ name }) => name).sort();
|
||||
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
|
||||
throw new Error("The real server did not expose the intended 39 tools");
|
||||
throw new Error("The real server did not expose the intended 42 tools");
|
||||
}
|
||||
|
||||
const options = { timeout };
|
||||
const initialSession = await client.callTool(
|
||||
{ name: "binary_session", arguments: {} },
|
||||
options,
|
||||
);
|
||||
if (jsonValue(initialSession).open !== false)
|
||||
throw new Error("The verifier did not start without a target");
|
||||
const opened = await client.callTool(
|
||||
{ name: "open_binary", arguments: { path: targetA } },
|
||||
options,
|
||||
);
|
||||
if (opened.isError === true) throw new Error(textValue(opened));
|
||||
const documents = await client.callTool(
|
||||
{ name: "list_documents", arguments: {} },
|
||||
options,
|
||||
@@ -74,6 +97,22 @@ try {
|
||||
{ name: "batch_decompile", arguments: { addresses: [firstAddress] } },
|
||||
options,
|
||||
);
|
||||
const switched = await client.callTool(
|
||||
{ name: "open_binary", arguments: { path: targetB } },
|
||||
options,
|
||||
);
|
||||
if (switched.isError === true) throw new Error(textValue(switched));
|
||||
const secondSession = jsonValue(
|
||||
await client.callTool({ name: "binary_session", arguments: {} }, options),
|
||||
);
|
||||
if (secondSession.path !== targetB)
|
||||
throw new Error("The real session did not switch to target B");
|
||||
const secondOverview = await client.callTool(
|
||||
{ name: "binary_overview", arguments: {} },
|
||||
options,
|
||||
);
|
||||
if (secondOverview.isError === true)
|
||||
throw new Error(textValue(secondOverview));
|
||||
|
||||
const processList = await execFileAsync("ps", ["-ax", "-o", "command="]);
|
||||
const bundledMcpRunning = processList.stdout
|
||||
@@ -88,6 +127,12 @@ try {
|
||||
throw new Error("The MCP runtime emitted stderr during verification");
|
||||
}
|
||||
|
||||
const closed = await client.callTool(
|
||||
{ name: "close_binary", arguments: {} },
|
||||
options,
|
||||
);
|
||||
if (closed.isError === true) throw new Error(textValue(closed));
|
||||
|
||||
summary = {
|
||||
toolCount: actualNames.length,
|
||||
documentCount: jsonValue(documents).length,
|
||||
@@ -98,6 +143,10 @@ try {
|
||||
boundedResultKeys: Object.keys(jsonValue(bounded)).length,
|
||||
bundledMcpRunning,
|
||||
stderrBytes,
|
||||
dynamicSession: true,
|
||||
targets: [targetA, targetB],
|
||||
switched: true,
|
||||
secondOverview: jsonValue(secondOverview),
|
||||
};
|
||||
} finally {
|
||||
const keepAlive = setInterval(() => undefined, 100);
|
||||
@@ -130,6 +179,14 @@ if (
|
||||
"The TypeScript MCP server remained alive after client close",
|
||||
);
|
||||
}
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ...summary, cleanShutdown: true }, null, 2)}\n`,
|
||||
);
|
||||
if (summary === undefined)
|
||||
throw new Error("Real-Hopper verification did not produce a summary");
|
||||
await new Promise((resolve, reject) => {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ...summary, cleanShutdown: true }, null, 2)}\n`,
|
||||
(cause) => {
|
||||
if (cause) reject(cause);
|
||||
else resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: better-binary-analysis
|
||||
description: Analyze local binaries with Better Binary and Hopper through its MCP session workflow.
|
||||
---
|
||||
|
||||
# Better Binary 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.
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { parseBinaryTarget } from "../domain/binaryTarget.js";
|
||||
import {
|
||||
HopperCancelledError,
|
||||
NoBinaryOpenError,
|
||||
type HopperError,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { JsonValue } from "../hopper/protocol.js";
|
||||
import type { HopperToolPort } from "./HopperToolPort.js";
|
||||
|
||||
/** A closeable Hopper port created for exactly one parsed target. */
|
||||
export interface BinaryClient extends HopperToolPort {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
/** Production seam for creating a target-scoped Hopper client. */
|
||||
export type BinaryClientFactory = (target: BinaryTarget) => BinaryClient;
|
||||
|
||||
/** Serializes target transitions while allowing calls within the active session. */
|
||||
export class BinarySession implements HopperToolPort {
|
||||
#active:
|
||||
| { readonly target: BinaryTarget; readonly client: BinaryClient }
|
||||
| undefined;
|
||||
#transition: Promise<void> = Promise.resolve();
|
||||
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(
|
||||
path: string,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
): Promise<Result<BinaryTarget, HopperError>> {
|
||||
return this.#serialize(async () => {
|
||||
if (isAborted(options.signal)) return err(new HopperCancelledError());
|
||||
const parsed = await parseBinaryTarget(path);
|
||||
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;
|
||||
const client = this.createClient(parsed.value);
|
||||
const started = await client.callTool("health", {}, options);
|
||||
if (!started.ok) {
|
||||
await client.close();
|
||||
return started;
|
||||
}
|
||||
if (isAborted(options.signal)) {
|
||||
await client.close();
|
||||
return err(new HopperCancelledError());
|
||||
}
|
||||
this.#active = { target: parsed.value, client };
|
||||
await previous?.client.close();
|
||||
return ok(parsed.value);
|
||||
});
|
||||
}
|
||||
|
||||
/** Close the active target, if any. */
|
||||
close(): Promise<Result<null, HopperError>> {
|
||||
return this.#serialize(async () => {
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
await this.#drainCalls();
|
||||
await previous?.client.close();
|
||||
return ok(null);
|
||||
});
|
||||
}
|
||||
|
||||
/** Describe the active binary session. */
|
||||
status(): JsonValue {
|
||||
const target = this.#active?.target;
|
||||
return target === undefined
|
||||
? { open: false }
|
||||
: {
|
||||
open: true,
|
||||
path: target.path,
|
||||
format: target.format,
|
||||
kind: target.kind,
|
||||
};
|
||||
}
|
||||
|
||||
/** Invoke a Hopper tool against the active target. */
|
||||
async callTool(
|
||||
name: string,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
options?: { readonly signal?: AbortSignal },
|
||||
): Promise<Result<JsonValue, HopperError>> {
|
||||
const transitioned = await this.#waitForTransition(options?.signal);
|
||||
if (!transitioned.ok) return transitioned;
|
||||
const active = this.#active;
|
||||
if (active === undefined) return err(new NoBinaryOpenError());
|
||||
const call = active.client.callTool(name, arguments_, options);
|
||||
this.#calls.add(call);
|
||||
try {
|
||||
return await call;
|
||||
} finally {
|
||||
this.#calls.delete(call);
|
||||
}
|
||||
}
|
||||
|
||||
#serialize<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.#transition.then(operation, operation);
|
||||
this.#transition = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async #drainCalls(): Promise<void> {
|
||||
await Promise.allSettled([...this.#calls]);
|
||||
}
|
||||
|
||||
async #waitForTransition(
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<undefined, HopperCancelledError>> {
|
||||
if (signal?.aborted === true) return err(new HopperCancelledError());
|
||||
if (signal === undefined) {
|
||||
await this.#transition;
|
||||
return ok(undefined);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const onAbort = (): void => {
|
||||
resolve(err(new HopperCancelledError()));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
this.#transition.then(
|
||||
() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(
|
||||
signal.aborted ? err(new HopperCancelledError()) : ok(undefined),
|
||||
);
|
||||
},
|
||||
() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(
|
||||
signal.aborted ? err(new HopperCancelledError()) : ok(undefined),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
@@ -0,0 +1,47 @@
|
||||
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";
|
||||
|
||||
/** Open one binary, execute one tool, and always release the target session. */
|
||||
export const runDirectAnalysis = async (
|
||||
path: string,
|
||||
tool: "binary_overview" | "procedure_pseudo_code",
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<JsonValue> => {
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const opened = await session.open(path);
|
||||
if (!opened.ok)
|
||||
return { error: opened.error._tag, message: opened.error.message };
|
||||
const result = await session.callTool(tool, arguments_);
|
||||
return result.ok
|
||||
? result.value
|
||||
: { error: result.error._tag, message: result.error.message };
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access } from "node:fs/promises";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { probeHomebrew } from "./homebrew.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DEFAULT_HOPPER =
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
|
||||
/** One actionable environment diagnostic. */
|
||||
export interface DoctorCheck {
|
||||
readonly name: string;
|
||||
readonly ok: boolean;
|
||||
readonly detail?: string;
|
||||
readonly remediation?: string;
|
||||
}
|
||||
/** Host facts and effects 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>;
|
||||
brewHopperPath(): Promise<string | undefined>;
|
||||
manualHopperPaths(): Promise<readonly string[]>;
|
||||
}
|
||||
|
||||
/** Check requirements and an optional target without mutating the host. */
|
||||
export const runDoctor = async (
|
||||
target?: string,
|
||||
host: DoctorHost = systemDoctorHost(),
|
||||
): Promise<{
|
||||
readonly healthy: boolean;
|
||||
readonly hopperPath?: string;
|
||||
readonly checks: readonly DoctorCheck[];
|
||||
}> => {
|
||||
const checks: DoctorCheck[] = [];
|
||||
const nodeMajor = parseMajor(host.nodeVersion);
|
||||
checks.push(
|
||||
check(
|
||||
"node",
|
||||
nodeMajor >= 22,
|
||||
host.nodeVersion,
|
||||
"Install Node.js 22 or newer.",
|
||||
),
|
||||
);
|
||||
const macosVersion =
|
||||
host.platform === "darwin" ? await host.macosVersion() : undefined;
|
||||
const macosMajor = macosVersion === undefined ? 0 : parseMajor(macosVersion);
|
||||
checks.push(
|
||||
check(
|
||||
"macos",
|
||||
host.platform === "darwin" && macosMajor >= 12,
|
||||
macosVersion ?? host.platform,
|
||||
"Hopper requires macOS 12 or newer.",
|
||||
),
|
||||
);
|
||||
const candidates = [
|
||||
host.configuredHopperPath,
|
||||
DEFAULT_HOPPER,
|
||||
...(await host.manualHopperPaths()),
|
||||
await host.brewHopperPath(),
|
||||
].filter((value): value is string => value !== undefined);
|
||||
let hopperPath: string | undefined;
|
||||
for (const candidate of new Set(candidates))
|
||||
if (await host.readable(candidate)) {
|
||||
hopperPath = candidate;
|
||||
break;
|
||||
}
|
||||
checks.push(
|
||||
check(
|
||||
"hopper",
|
||||
hopperPath !== undefined,
|
||||
hopperPath,
|
||||
"Install Hopper with: brew install --cask hopper-disassembler, or set HOPPER_LAUNCHER_PATH.",
|
||||
),
|
||||
);
|
||||
if (target !== undefined)
|
||||
checks.push(
|
||||
check(
|
||||
"target",
|
||||
await host.readable(target),
|
||||
target,
|
||||
"Supply a readable local binary path.",
|
||||
),
|
||||
);
|
||||
return {
|
||||
healthy: checks.every(({ ok }) => ok),
|
||||
...(hopperPath === undefined ? {} : { hopperPath }),
|
||||
checks,
|
||||
};
|
||||
};
|
||||
|
||||
/** Create diagnostics backed by the current process and host commands. */
|
||||
export const systemDoctorHost = (): DoctorHost => ({
|
||||
platform: process.platform,
|
||||
nodeVersion: process.versions.node,
|
||||
...(process.env.HOPPER_LAUNCHER_PATH === undefined
|
||||
? {}
|
||||
: { configuredHopperPath: process.env.HOPPER_LAUNCHER_PATH }),
|
||||
async macosVersion() {
|
||||
try {
|
||||
return (
|
||||
await execFileAsync("sw_vers", ["-productVersion"])
|
||||
).stdout.trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
async readable(path) {
|
||||
try {
|
||||
await access(path, constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async brewHopperPath() {
|
||||
return probeHomebrew(async (command) => {
|
||||
try {
|
||||
const prefix = (
|
||||
await execFileAsync(command, [
|
||||
"--prefix",
|
||||
"--cask",
|
||||
"hopper-disassembler",
|
||||
])
|
||||
).stdout.trim();
|
||||
return `${prefix}/Hopper Disassembler.app/Contents/MacOS/hopper`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
},
|
||||
async manualHopperPaths() {
|
||||
const roots = ["/Applications", join(homedir(), "Applications")];
|
||||
const paths: string[] = [];
|
||||
for (const root of roots) {
|
||||
try {
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && /^Hopper.*\.app$/i.test(entry.name))
|
||||
paths.push(join(root, entry.name, "Contents/MacOS/hopper"));
|
||||
}
|
||||
} catch {
|
||||
// A missing or unreadable optional application directory is not fatal.
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
},
|
||||
});
|
||||
|
||||
const parseMajor = (version: string): number =>
|
||||
Number.parseInt(version.split(".")[0] ?? "0", 10);
|
||||
const check = (
|
||||
name: string,
|
||||
ok: boolean,
|
||||
detail: string | undefined,
|
||||
remediation: string,
|
||||
): DoctorCheck => ({
|
||||
name,
|
||||
ok,
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
...(ok ? {} : { remediation }),
|
||||
});
|
||||
@@ -131,7 +131,7 @@ export class EnhancedTools {
|
||||
const relation = input.direction === "forward" ? "callees" : "callers";
|
||||
const tool =
|
||||
input.direction === "forward" ? "procedure_callees" : "procedure_callers";
|
||||
const visited = new Set<string>();
|
||||
const discovered = new Set([input.address]);
|
||||
const queue: Array<{ address: string; depth: number }> = [
|
||||
{ address: input.address, depth: 0 },
|
||||
];
|
||||
@@ -140,14 +140,9 @@ export class EnhancedTools {
|
||||
|
||||
while (queueIndex < queue.length) {
|
||||
const current = queue[queueIndex++];
|
||||
if (
|
||||
current === undefined ||
|
||||
visited.has(current.address) ||
|
||||
current.depth >= input.depth
|
||||
) {
|
||||
if (current === undefined || current.depth >= input.depth) {
|
||||
continue;
|
||||
}
|
||||
visited.add(current.address);
|
||||
const level = String(current.depth);
|
||||
graph[level] ??= [];
|
||||
|
||||
@@ -177,8 +172,10 @@ export class EnhancedTools {
|
||||
});
|
||||
if (current.depth + 1 < input.depth) {
|
||||
for (const address of related.value) {
|
||||
if (!visited.has(address))
|
||||
if (!discovered.has(address)) {
|
||||
discovered.add(address);
|
||||
queue.push({ address, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
access,
|
||||
copyFile,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { z } from "zod";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { runDoctor, systemDoctorHost } from "./Doctor.js";
|
||||
import { probeHomebrew } from "./homebrew.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** A detected agent configuration owned by setup. */
|
||||
export interface SetupClient {
|
||||
readonly name: string;
|
||||
readonly configPath: string;
|
||||
}
|
||||
/** Result of one backup/write/readback transaction. */
|
||||
export type ClientConfigurationResult =
|
||||
| {
|
||||
readonly status: "unchanged" | "configured";
|
||||
readonly backupPath?: string;
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason: "backup" | "write" | "readback";
|
||||
};
|
||||
/** Effects required by the idempotent setup workflow. */
|
||||
export interface SetupHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly nodeVersion: string;
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
hasHomebrew(): Promise<boolean>;
|
||||
installHomebrew(): Promise<boolean>;
|
||||
hopperPath(): Promise<string | undefined>;
|
||||
installHopper(): Promise<boolean>;
|
||||
detectedClients(): Promise<readonly SetupClient[]>;
|
||||
configureClient(client: SetupClient): Promise<ClientConfigurationResult>;
|
||||
installSkill(): Promise<"installed" | "unchanged" | "failed">;
|
||||
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
|
||||
}
|
||||
/** Structured setup outcome with actionable partial progress. */
|
||||
export interface SetupResult {
|
||||
readonly status: "ready" | "needs_human";
|
||||
readonly actions: readonly string[];
|
||||
readonly clients: Readonly<Record<string, ClientConfigurationResult>>;
|
||||
readonly doctor: Awaited<ReturnType<typeof runDoctor>>;
|
||||
readonly remediation?: string;
|
||||
}
|
||||
|
||||
/** Install prerequisites and configure every detected supported client idempotently. */
|
||||
export const runSetup = async (
|
||||
yes: boolean,
|
||||
host: SetupHost = systemSetupHost(),
|
||||
): Promise<SetupResult> => {
|
||||
const actions: string[] = [];
|
||||
const clients: Record<string, ClientConfigurationResult> = {};
|
||||
const fail = async (remediation: string): Promise<SetupResult> => ({
|
||||
status: "needs_human",
|
||||
actions,
|
||||
clients,
|
||||
doctor: await host.doctor(),
|
||||
remediation,
|
||||
});
|
||||
if (host.platform !== "darwin")
|
||||
return fail("Hopper requires macOS 12 or newer.");
|
||||
const macosVersion = await host.macosVersion();
|
||||
if (macosVersion === undefined || major(macosVersion) < 12)
|
||||
return fail("Upgrade to macOS 12 or newer.");
|
||||
if (major(host.nodeVersion) < 22) return fail("Install Node.js 22 or newer.");
|
||||
if (!(await host.hasHomebrew())) {
|
||||
if (!yes) return fail("Re-run with --yes to install Homebrew.");
|
||||
if (!(await host.installHomebrew()))
|
||||
return fail(
|
||||
"Homebrew installation was interrupted; resolve the installer prompt and re-run setup.",
|
||||
);
|
||||
actions.push("installed_homebrew");
|
||||
}
|
||||
if ((await host.hopperPath()) === undefined) {
|
||||
if (!(await host.installHopper()))
|
||||
return fail(
|
||||
"Hopper installation was interrupted; resolve the system prompt and re-run setup.",
|
||||
);
|
||||
actions.push("installed_hopper");
|
||||
}
|
||||
for (const client of await host.detectedClients()) {
|
||||
const result = await host.configureClient(client);
|
||||
clients[client.name] = result;
|
||||
if (result.status === "failed")
|
||||
return fail(
|
||||
`${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`,
|
||||
);
|
||||
if (result.status === "configured")
|
||||
actions.push(`configured_${client.name}`);
|
||||
}
|
||||
const skill = await host.installSkill();
|
||||
if (skill === "failed")
|
||||
return fail("Agent skill installation or readback failed.");
|
||||
if (skill === "installed") actions.push("installed_skill");
|
||||
const doctor = await host.doctor();
|
||||
return {
|
||||
status: doctor.healthy ? "ready" : "needs_human",
|
||||
actions,
|
||||
clients,
|
||||
doctor,
|
||||
...(doctor.healthy
|
||||
? {}
|
||||
: {
|
||||
remediation:
|
||||
"Run better-binary doctor and apply each reported remediation.",
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/** Production setup effects for macOS, Homebrew, JSON MCP clients, and the canonical skill directory. */
|
||||
export const systemSetupHost = (): SetupHost => {
|
||||
const doctorHost = systemDoctorHost();
|
||||
return {
|
||||
platform: process.platform,
|
||||
nodeVersion: process.versions.node,
|
||||
macosVersion: () => doctorHost.macosVersion(),
|
||||
hasHomebrew: () => brewSucceeds(["--version"]),
|
||||
installHomebrew: () =>
|
||||
commandSucceeds(
|
||||
"/bin/bash",
|
||||
[
|
||||
"-c",
|
||||
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)",
|
||||
],
|
||||
{ ...process.env, NONINTERACTIVE: "1" },
|
||||
),
|
||||
hopperPath: async () => (await runDoctor(undefined, doctorHost)).hopperPath,
|
||||
installHopper: () =>
|
||||
brewSucceeds(["install", "--cask", "hopper-disassembler"]),
|
||||
detectedClients: () => detectJsonClients(homedir()),
|
||||
configureClient: configureJsonClient,
|
||||
installSkill: () => installCanonicalSkill(homedir()),
|
||||
doctor: () => runDoctor(undefined, doctorHost),
|
||||
};
|
||||
};
|
||||
|
||||
const detectJsonClients = async (
|
||||
home: string,
|
||||
): Promise<readonly SetupClient[]> => {
|
||||
const candidates = [
|
||||
{
|
||||
name: "claude_desktop",
|
||||
configPath: join(
|
||||
home,
|
||||
"Library/Application Support/Claude/claude_desktop_config.json",
|
||||
),
|
||||
marker: join(home, "Library/Application Support/Claude"),
|
||||
},
|
||||
{
|
||||
name: "cursor",
|
||||
configPath: join(home, ".cursor/mcp.json"),
|
||||
marker: join(home, ".cursor"),
|
||||
},
|
||||
];
|
||||
const detected: SetupClient[] = [];
|
||||
for (const candidate of candidates)
|
||||
if (await exists(candidate.marker))
|
||||
detected.push({ name: candidate.name, configPath: candidate.configPath });
|
||||
return detected;
|
||||
};
|
||||
|
||||
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
|
||||
export const configureJsonClient = async (
|
||||
client: SetupClient,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
let document: Record<string, unknown> = {};
|
||||
let original: string | undefined;
|
||||
try {
|
||||
original = await readFile(client.configPath, "utf8");
|
||||
document = parseObject(original);
|
||||
} catch (cause: unknown) {
|
||||
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
|
||||
}
|
||||
const servers = objectValue(document.mcpServers);
|
||||
const desired = { command: PRODUCT_IDENTITY.mcpBinary };
|
||||
if (
|
||||
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
|
||||
JSON.stringify(desired)
|
||||
)
|
||||
return { status: "unchanged" };
|
||||
let backupPath: string | undefined;
|
||||
if (original !== undefined) {
|
||||
backupPath = `${client.configPath}.better-binary.backup`;
|
||||
try {
|
||||
await copyFile(client.configPath, backupPath);
|
||||
} catch {
|
||||
return { status: "failed", reason: "backup" };
|
||||
}
|
||||
}
|
||||
document.mcpServers = {
|
||||
...servers,
|
||||
[PRODUCT_IDENTITY.mcpServerKey]: desired,
|
||||
};
|
||||
const encoded = `${JSON.stringify(document, null, 2)}\n`;
|
||||
const temporary = `${client.configPath}.better-binary.tmp`;
|
||||
try {
|
||||
await mkdir(dirname(client.configPath), { recursive: true });
|
||||
await writeFile(temporary, encoded, { encoding: "utf8", mode: 0o600 });
|
||||
await rename(temporary, client.configPath);
|
||||
} catch {
|
||||
await rm(temporary, { force: true }).catch(() => undefined);
|
||||
return { status: "failed", reason: "write" };
|
||||
}
|
||||
try {
|
||||
const readback = parseObject(await readFile(client.configPath, "utf8"));
|
||||
const value = objectValue(readback.mcpServers)[
|
||||
PRODUCT_IDENTITY.mcpServerKey
|
||||
];
|
||||
if (JSON.stringify(value) !== JSON.stringify(desired)) {
|
||||
await restoreConfig(client.configPath, original);
|
||||
return { status: "failed", reason: "readback" };
|
||||
}
|
||||
} catch {
|
||||
await restoreConfig(client.configPath, original);
|
||||
return { status: "failed", reason: "readback" };
|
||||
}
|
||||
return {
|
||||
status: "configured",
|
||||
...(backupPath === undefined ? {} : { backupPath }),
|
||||
};
|
||||
};
|
||||
|
||||
const installCanonicalSkill = async (
|
||||
home: string,
|
||||
): Promise<"installed" | "unchanged" | "failed"> => {
|
||||
const destination = join(
|
||||
home,
|
||||
".agents/skills",
|
||||
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`;
|
||||
try {
|
||||
if (
|
||||
(await readFile(destination, "utf8").catch(() => undefined)) === content
|
||||
)
|
||||
return "unchanged";
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, content, { encoding: "utf8", mode: 0o600 });
|
||||
return (await readFile(destination, "utf8")) === content
|
||||
? "installed"
|
||||
: "failed";
|
||||
} catch {
|
||||
return "failed";
|
||||
}
|
||||
};
|
||||
|
||||
const commandSucceeds = async (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await execFileAsync(command, args, env === undefined ? {} : { env });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const brewSucceeds = async (args: readonly string[]): Promise<boolean> => {
|
||||
const result = await probeHomebrew(async (command) =>
|
||||
(await commandSucceeds(command, args)) ? true : undefined,
|
||||
);
|
||||
return result === true;
|
||||
};
|
||||
const major = (version: string): number =>
|
||||
Number.parseInt(version.split(".")[0] ?? "0", 10);
|
||||
const exists = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
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 parseObject = (text: string): Record<string, unknown> =>
|
||||
objectValue(JSON.parse(text));
|
||||
const isMissing = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
||||
const restoreConfig = async (
|
||||
path: string,
|
||||
original: string | undefined,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (original === undefined) await rm(path, { force: true });
|
||||
else await writeFile(path, original, { encoding: "utf8", mode: 0o600 });
|
||||
} catch {
|
||||
// The backup remains available for the remediation reported by setup.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Standard executable names and locations used to discover Homebrew. */
|
||||
export const HOMEBREW_COMMANDS = [
|
||||
"brew",
|
||||
"/opt/homebrew/bin/brew",
|
||||
"/usr/local/bin/brew",
|
||||
] as const;
|
||||
|
||||
/** Return the first successful result from the standard Homebrew executables. */
|
||||
export const probeHomebrew = async <T>(
|
||||
probe: (command: string) => Promise<T | undefined>,
|
||||
): Promise<T | undefined> => {
|
||||
for (const command of HOMEBREW_COMMANDS) {
|
||||
const result = await probe(command);
|
||||
if (result !== undefined) return result;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
+3
-3
@@ -8,14 +8,14 @@ const DEFAULT_HOPPER_LAUNCHER_PATH =
|
||||
|
||||
export interface AppConfig {
|
||||
readonly hopperLauncherPath: string;
|
||||
readonly hopperTargetPath: string;
|
||||
readonly hopperTargetPath: string | undefined;
|
||||
readonly hopperTargetKind: "executable" | "database";
|
||||
readonly hopperLoaderArgs: readonly string[];
|
||||
}
|
||||
|
||||
const environmentSchema = z.object({
|
||||
HOPPER_LAUNCHER_PATH: z.string().min(1).optional(),
|
||||
HOPPER_TARGET_PATH: z.string().min(1),
|
||||
HOPPER_TARGET_PATH: z.string().min(1).optional(),
|
||||
HOPPER_TARGET_KIND: z.enum(["executable", "database"]).default("executable"),
|
||||
HOPPER_LOADER_ARGS_JSON: z.string().optional(),
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export const parseConfig = (
|
||||
const parsedEnvironment = environmentSchema.safeParse(environment);
|
||||
if (!parsedEnvironment.success) {
|
||||
return err(
|
||||
new ConfigurationError("HOPPER_TARGET_PATH is required", {
|
||||
new ConfigurationError("Invalid Hopper environment configuration", {
|
||||
cause: parsedEnvironment.error,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ const caseSensitive = z
|
||||
.describe("Whether to match case");
|
||||
|
||||
/** The architectural owner of a tool implementation. */
|
||||
export type ToolKind = "official-proxy" | "enhanced";
|
||||
export type ToolKind = "official-proxy" | "enhanced" | "session";
|
||||
|
||||
/** Stable caller-visible contract retained across the Python-to-TypeScript migration. */
|
||||
export interface ToolContract {
|
||||
@@ -35,6 +35,12 @@ const enhanced = (
|
||||
inputSchema: z.ZodObject,
|
||||
): ToolContract => ({ name, description, kind: "enhanced", inputSchema });
|
||||
|
||||
const session = (
|
||||
name: string,
|
||||
description: string,
|
||||
inputSchema: z.ZodObject,
|
||||
): ToolContract => ({ name, description, kind: "session", inputSchema });
|
||||
|
||||
/** All 31 tools forwarded unchanged to Hopper's official MCP server. */
|
||||
export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
official(
|
||||
@@ -218,8 +224,20 @@ export const ENHANCED_TOOL_CONTRACTS = [
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
/** Binary lifecycle tools available before Hopper is launched. */
|
||||
export const SESSION_TOOL_CONTRACTS = [
|
||||
session(
|
||||
"open_binary",
|
||||
"Open or switch the active binary",
|
||||
z.object({ path: z.string().min(1) }),
|
||||
),
|
||||
session("close_binary", "Close the active binary", z.object({})),
|
||||
session("binary_session", "Describe the active binary session", z.object({})),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
/** Complete caller-visible tool inventory. */
|
||||
export const TOOL_CONTRACTS = [
|
||||
...OFFICIAL_TOOL_CONTRACTS,
|
||||
...ENHANCED_TOOL_CONTRACTS,
|
||||
...SESSION_TOOL_CONTRACTS,
|
||||
] as const;
|
||||
@@ -0,0 +1,217 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, open, realpath } from "node:fs/promises";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
|
||||
import { BinaryTargetError } from "./errors.js";
|
||||
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. */
|
||||
export interface BinaryTarget {
|
||||
readonly path: string;
|
||||
readonly kind: "executable" | "database";
|
||||
readonly format: "hopper" | "mach-o" | "elf" | "pe";
|
||||
readonly architecture?: BinaryArchitecture;
|
||||
readonly availableArchitectures?: readonly BinaryArchitecture[];
|
||||
readonly loaderArgs: readonly string[];
|
||||
}
|
||||
|
||||
/** Resolve, read, and classify a local binary path, including its architecture metadata. */
|
||||
export const parseBinaryTarget = async (
|
||||
input: string,
|
||||
cwd = process.cwd(),
|
||||
hostArchitecture: NodeJS.Architecture = process.arch,
|
||||
): 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"))
|
||||
return ok({ path, kind: "database", format: "hopper", loaderArgs: [] });
|
||||
const handle = await open(path, "r");
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = Buffer.alloc(4096);
|
||||
const read = await handle.read(bytes, 0, bytes.length, 0);
|
||||
bytes = bytes.subarray(0, read.bytesRead);
|
||||
} 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) {
|
||||
return err(
|
||||
new BinaryTargetError(candidate, "path is not readable", { cause }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type ExecutableMetadata = Pick<
|
||||
BinaryTarget,
|
||||
"format" | "architecture" | "availableArchitectures" | "loaderArgs"
|
||||
>;
|
||||
|
||||
/** Parse supported executable headers without performing I/O. */
|
||||
export const parseExecutableHeader = (
|
||||
bytes: Buffer,
|
||||
hostArchitecture: NodeJS.Architecture,
|
||||
): Result<ExecutableMetadata, string> => {
|
||||
if (
|
||||
bytes.length >= 4 &&
|
||||
bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
|
||||
)
|
||||
return parseElf(bytes);
|
||||
if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a)
|
||||
return parsePe(bytes);
|
||||
if (bytes.length < 8) return err("truncated or unsupported binary header");
|
||||
const magic = bytes.readUInt32BE(0);
|
||||
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
|
||||
return parseThinMachO(bytes, magic);
|
||||
if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic))
|
||||
return parseFatMachO(bytes, magic, hostArchitecture);
|
||||
return err("unsupported binary format");
|
||||
};
|
||||
|
||||
const parseThinMachO = (
|
||||
bytes: Buffer,
|
||||
magic: number,
|
||||
): Result<ExecutableMetadata, string> => {
|
||||
if (bytes.length < 8) return err("truncated Mach-O header");
|
||||
const little = magic === 0xcefaedfe || magic === 0xcffaedfe;
|
||||
const architecture = machArchitecture(
|
||||
little ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4),
|
||||
);
|
||||
if (architecture === undefined) return err("unsupported Mach-O architecture");
|
||||
return ok({
|
||||
format: "mach-o",
|
||||
architecture,
|
||||
availableArchitectures: [architecture],
|
||||
loaderArgs: ["-l", "Mach-O"],
|
||||
});
|
||||
};
|
||||
|
||||
const parseFatMachO = (
|
||||
bytes: Buffer,
|
||||
magic: number,
|
||||
host: NodeJS.Architecture,
|
||||
): Result<ExecutableMetadata, string> => {
|
||||
const little = magic === 0xbebafeca || magic === 0xbfbafeca;
|
||||
const is64 = magic === 0xcafebabf || magic === 0xbfbafeca;
|
||||
if (bytes.length < 8) return err("truncated FAT header");
|
||||
const read = (offset: number): number =>
|
||||
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
|
||||
const count = read(4);
|
||||
const entrySize = is64 ? 32 : 20;
|
||||
if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize)
|
||||
return err("truncated or invalid FAT architecture table");
|
||||
const architectures: BinaryArchitecture[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const architecture = machArchitecture(read(8 + index * entrySize));
|
||||
if (architecture !== undefined && !architectures.includes(architecture))
|
||||
architectures.push(architecture);
|
||||
}
|
||||
const preferred =
|
||||
host === "arm64"
|
||||
? "arm64"
|
||||
: host === "x64"
|
||||
? "x86_64"
|
||||
: host === "ia32"
|
||||
? "x86"
|
||||
: host === "arm"
|
||||
? "arm"
|
||||
: 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"],
|
||||
});
|
||||
};
|
||||
|
||||
const parseElf = (bytes: Buffer): Result<ExecutableMetadata, string> => {
|
||||
if (bytes.length < 20) return err("truncated ELF header");
|
||||
if (bytes[4] !== 1 && bytes[4] !== 2) return err("unsupported ELF class");
|
||||
const little = bytes[5] === 1;
|
||||
if (!little && bytes[5] !== 2) return err("unsupported ELF endianness");
|
||||
const machine = little ? bytes.readUInt16LE(18) : bytes.readUInt16BE(18);
|
||||
const architecture = elfArchitecture(machine);
|
||||
if (architecture === undefined) return err("unsupported ELF architecture");
|
||||
return ok({
|
||||
format: "elf",
|
||||
architecture,
|
||||
availableArchitectures: [architecture],
|
||||
loaderArgs: ["-l", "ELF"],
|
||||
});
|
||||
};
|
||||
|
||||
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"
|
||||
)
|
||||
return err("invalid or truncated PE header");
|
||||
const architecture = peArchitecture(bytes.readUInt16LE(offset + 4));
|
||||
if (architecture === undefined) return err("unsupported PE architecture");
|
||||
return ok({
|
||||
format: "pe",
|
||||
architecture,
|
||||
availableArchitectures: [architecture],
|
||||
loaderArgs: ["-l", "PE"],
|
||||
});
|
||||
};
|
||||
|
||||
const machArchitecture = (cpu: number): BinaryArchitecture | undefined => {
|
||||
switch (cpu) {
|
||||
case 7:
|
||||
return "x86";
|
||||
case 0x01000007:
|
||||
return "x86_64";
|
||||
case 12:
|
||||
return "arm";
|
||||
case 0x0100000c:
|
||||
return "arm64";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const elfArchitecture = (machine: number): BinaryArchitecture | undefined => {
|
||||
switch (machine) {
|
||||
case 3:
|
||||
return "x86";
|
||||
case 62:
|
||||
return "x86_64";
|
||||
case 40:
|
||||
return "arm";
|
||||
case 183:
|
||||
return "arm64";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const peArchitecture = (machine: number): BinaryArchitecture | undefined => {
|
||||
switch (machine) {
|
||||
case 0x14c:
|
||||
return "x86";
|
||||
case 0x8664:
|
||||
return "x86_64";
|
||||
case 0x1c0:
|
||||
return "arm";
|
||||
case 0xaa64:
|
||||
return "arm64";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -71,3 +71,25 @@ export class HopperStartError extends HopperError {
|
||||
export class ConfigurationError extends HopperError {
|
||||
readonly _tag = "ConfigurationError";
|
||||
}
|
||||
|
||||
/** No binary session exists for an analysis request. */
|
||||
export class NoBinaryOpenError extends HopperError {
|
||||
readonly _tag = "NoBinaryOpenError";
|
||||
constructor() {
|
||||
super(
|
||||
"No binary is open. Call open_binary with a readable local path first.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A supplied target path could not be safely opened as a supported binary. */
|
||||
export class BinaryTargetError extends HopperError {
|
||||
readonly _tag = "BinaryTargetError";
|
||||
constructor(
|
||||
readonly path: string,
|
||||
reason: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Cannot open binary: ${reason}: ${path}`, options);
|
||||
}
|
||||
}
|
||||
+40
-34
@@ -21,7 +21,6 @@ import {
|
||||
} from "./protocol.js";
|
||||
|
||||
const MAX_LINE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_ABANDONED_REQUESTS = 1_024;
|
||||
const SESSION_ROOT = process.platform === "darwin" ? "/tmp" : tmpdir();
|
||||
|
||||
export interface HopperClientOptions {
|
||||
@@ -54,7 +53,6 @@ export class HopperClient {
|
||||
> &
|
||||
HopperClientOptions;
|
||||
readonly #pending = new Map<number, PendingRequest>();
|
||||
readonly #abandoned = new Set<number>();
|
||||
#socket: Socket | undefined;
|
||||
#launch: BridgeLaunch | undefined;
|
||||
#directory: string | undefined;
|
||||
@@ -63,6 +61,7 @@ export class HopperClient {
|
||||
#buffer = "";
|
||||
#closing = false;
|
||||
#startPromise: Promise<Result<HopperServerInfo, HopperError>> | undefined;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
|
||||
constructor(options: HopperClientOptions) {
|
||||
this.#options = {
|
||||
@@ -136,29 +135,37 @@ export class HopperClient {
|
||||
}
|
||||
|
||||
/** Stop the bridge, settle outstanding requests, and remove session artifacts. */
|
||||
async close(): Promise<void> {
|
||||
if (this.#closing) return;
|
||||
close(): Promise<void> {
|
||||
this.#closePromise ??= Promise.resolve().then(() => this.#close());
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
async #close(): Promise<void> {
|
||||
this.#closing = true;
|
||||
const socket = this.#socket;
|
||||
if (socket !== undefined && !socket.destroyed) {
|
||||
await this.#request("shutdown", {}, { timeoutMs: 500 }).catch(() =>
|
||||
err(new HopperProcessError(null)),
|
||||
);
|
||||
try {
|
||||
const socket = this.#socket;
|
||||
if (socket !== undefined && !socket.destroyed) {
|
||||
await this.#request("shutdown", {}, { timeoutMs: 500 }).catch(() =>
|
||||
err(new HopperProcessError(null)),
|
||||
);
|
||||
}
|
||||
this.#failAll(new HopperProcessError(null));
|
||||
socket?.destroy();
|
||||
this.#socket = undefined;
|
||||
if (this.#launch?.ownsProcessLifetime === true) {
|
||||
this.#launch.process.kill("SIGTERM");
|
||||
}
|
||||
this.#launch = undefined;
|
||||
if (this.#directory !== undefined) {
|
||||
await rm(this.#directory, { recursive: true, force: true });
|
||||
this.#directory = undefined;
|
||||
}
|
||||
this.#token = undefined;
|
||||
this.#startPromise = undefined;
|
||||
} finally {
|
||||
this.#closing = false;
|
||||
this.#closePromise = undefined;
|
||||
}
|
||||
this.#failAll(new HopperProcessError(null));
|
||||
socket?.destroy();
|
||||
this.#socket = undefined;
|
||||
if (this.#launch?.ownsProcessLifetime === true) {
|
||||
this.#launch.process.kill("SIGTERM");
|
||||
}
|
||||
this.#launch = undefined;
|
||||
if (this.#directory !== undefined) {
|
||||
await rm(this.#directory, { recursive: true, force: true });
|
||||
this.#directory = undefined;
|
||||
}
|
||||
this.#token = undefined;
|
||||
this.#startPromise = undefined;
|
||||
this.#closing = false;
|
||||
}
|
||||
|
||||
async #connect(socketPath: string): Promise<Result<undefined, HopperError>> {
|
||||
@@ -256,17 +263,20 @@ export class HopperClient {
|
||||
|
||||
#onData(chunk: string): void {
|
||||
this.#buffer += chunk;
|
||||
if (Buffer.byteLength(this.#buffer) > MAX_LINE_BYTES) {
|
||||
this.#abortProtocol("Hopper response exceeded the maximum line size");
|
||||
return;
|
||||
}
|
||||
let newline = this.#buffer.indexOf("\n");
|
||||
while (newline >= 0) {
|
||||
const line = this.#buffer.slice(0, newline).trim();
|
||||
this.#buffer = this.#buffer.slice(newline + 1);
|
||||
if (Buffer.byteLength(line) > MAX_LINE_BYTES) {
|
||||
this.#abortProtocol("Hopper response exceeded the maximum line size");
|
||||
return;
|
||||
}
|
||||
if (line.length > 0) this.#onLine(line);
|
||||
newline = this.#buffer.indexOf("\n");
|
||||
}
|
||||
if (Buffer.byteLength(this.#buffer) > MAX_LINE_BYTES) {
|
||||
this.#abortProtocol("Hopper response exceeded the maximum line size");
|
||||
}
|
||||
}
|
||||
|
||||
#onLine(line: string): void {
|
||||
@@ -275,9 +285,10 @@ export class HopperClient {
|
||||
this.#abortProtocol(parsed.error.message, parsed.error);
|
||||
return;
|
||||
}
|
||||
if (this.#abandoned.delete(parsed.value.id)) return;
|
||||
if (!this.#pending.has(parsed.value.id)) {
|
||||
this.#abortProtocol("Hopper returned an unknown response id");
|
||||
if (parsed.value.id >= this.#nextId) {
|
||||
this.#abortProtocol("Hopper returned an unknown response id");
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.#settle(parsed.value.id, responseResult(parsed.value));
|
||||
@@ -301,11 +312,6 @@ export class HopperClient {
|
||||
|
||||
#abandon(id: number, result: Result<JsonValue, HopperError>): void {
|
||||
if (!this.#pending.has(id)) return;
|
||||
this.#abandoned.add(id);
|
||||
if (this.#abandoned.size > MAX_ABANDONED_REQUESTS) {
|
||||
const oldest = this.#abandoned.values().next();
|
||||
if (!oldest.done) this.#abandoned.delete(oldest.value);
|
||||
}
|
||||
this.#settle(id, result);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Provisional product identifiers kept together for release-time renaming. */
|
||||
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",
|
||||
} as const;
|
||||
+28
-13
@@ -2,6 +2,7 @@
|
||||
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { realpathSync } from "node:fs";
|
||||
|
||||
import {
|
||||
serveStdio,
|
||||
@@ -9,6 +10,7 @@ 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 { createServer } from "./server/createServer.js";
|
||||
@@ -24,24 +26,37 @@ export const run = async (): Promise<number> => {
|
||||
const bridgeScriptPath = fileURLToPath(
|
||||
new URL("../bridge/hopper_bridge.py", import.meta.url),
|
||||
);
|
||||
const hopper = new HopperClient({
|
||||
launcher: new HopperApplicationLauncher({
|
||||
launcherPath: config.value.hopperLauncherPath,
|
||||
targetPath: config.value.hopperTargetPath,
|
||||
targetKind: config.value.hopperTargetKind,
|
||||
loaderArgs: config.value.hopperLoaderArgs,
|
||||
bridgeScriptPath,
|
||||
}),
|
||||
});
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (config.value.hopperTargetPath !== undefined) {
|
||||
const opened = await session.open(config.value.hopperTargetPath);
|
||||
if (!opened.ok) {
|
||||
process.stderr.write(`${opened.error._tag}: ${opened.error.message}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
let handle: StdioServerHandle;
|
||||
try {
|
||||
handle = serveStdio(() => createServer(hopper), {
|
||||
handle = serveStdio(() => createServer(session, session), {
|
||||
onerror: () => {
|
||||
process.stderr.write("MCP stdio transport error\n");
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
await hopper.close();
|
||||
await session.close();
|
||||
process.stderr.write("Failed to start MCP stdio transport\n");
|
||||
return 1;
|
||||
}
|
||||
@@ -50,7 +65,7 @@ export const run = async (): Promise<number> => {
|
||||
const shutdown = (): Promise<void> => {
|
||||
shutdownPromise ??= (async () => {
|
||||
await handle.close();
|
||||
await hopper.close();
|
||||
await session.close();
|
||||
})();
|
||||
return shutdownPromise;
|
||||
};
|
||||
@@ -71,7 +86,7 @@ export const run = async (): Promise<number> => {
|
||||
const entryPath = process.argv[1];
|
||||
if (
|
||||
entryPath !== undefined &&
|
||||
pathToFileURL(entryPath).href === import.meta.url
|
||||
pathToFileURL(realpathSync(entryPath)).href === import.meta.url
|
||||
) {
|
||||
run()
|
||||
.then((exitCode) => {
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
import type { HopperToolPort } from "../application/HopperToolPort.js";
|
||||
import type { BinarySession } from "../application/BinarySession.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { registerEnhancedTools } from "./registerEnhancedTools.js";
|
||||
import { registerOfficialTools } from "./registerOfficialTools.js";
|
||||
import { registerSessionTools } from "./registerSessionTools.js";
|
||||
|
||||
/** Construct one MCP server instance without acquiring subprocess resources. */
|
||||
export const createServer = (hopper: HopperToolPort): McpServer => {
|
||||
export const createServer = (
|
||||
hopper: HopperToolPort,
|
||||
session?: BinarySession,
|
||||
): McpServer => {
|
||||
const server = new McpServer(
|
||||
{ name: "betterBinaryMCP", version: "0.1.0" },
|
||||
{ name: PRODUCT_IDENTITY.mcpServerKey, version: "0.1.0" },
|
||||
{
|
||||
capabilities: { tools: {} },
|
||||
instructions:
|
||||
"Reverse-engineering tools for Hopper Disassembler. Start with binary_overview.",
|
||||
session === undefined
|
||||
? "Reverse-engineering tools for an active Hopper Disassembler target. Start with binary_overview."
|
||||
: "Reverse-engineering tools for Hopper Disassembler. Open a target with open_binary, then start with binary_overview.",
|
||||
},
|
||||
);
|
||||
registerOfficialTools(server, hopper);
|
||||
registerEnhancedTools(server, hopper);
|
||||
if (session !== undefined) registerSessionTools(server, session);
|
||||
return server;
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
import { z } from "zod";
|
||||
|
||||
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. */
|
||||
export const registerSessionTools = (
|
||||
server: McpServer,
|
||||
session: BinarySession,
|
||||
): void => {
|
||||
const [openContract, closeContract, statusContract] = SESSION_TOOL_CONTRACTS;
|
||||
server.registerTool(
|
||||
openContract.name,
|
||||
{
|
||||
description: openContract.description,
|
||||
inputSchema: openContract.inputSchema,
|
||||
},
|
||||
async (input, context) => {
|
||||
const parsed = z.object({ path: z.string().min(1) }).parse(input);
|
||||
const opened = await session.open(parsed.path, {
|
||||
signal: context.mcpReq.signal,
|
||||
});
|
||||
return opened.ok
|
||||
? toCallToolResult({
|
||||
ok: true,
|
||||
value: {
|
||||
path: opened.value.path,
|
||||
format: opened.value.format,
|
||||
kind: opened.value.kind,
|
||||
loaderArgs: [...opened.value.loaderArgs],
|
||||
},
|
||||
})
|
||||
: toCallToolResult(opened);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
closeContract.name,
|
||||
{
|
||||
description: closeContract.description,
|
||||
inputSchema: closeContract.inputSchema,
|
||||
},
|
||||
async () => toCallToolResult(await session.close()),
|
||||
);
|
||||
server.registerTool(
|
||||
statusContract.name,
|
||||
{
|
||||
description: statusContract.description,
|
||||
inputSchema: statusContract.inputSchema,
|
||||
},
|
||||
() => toCallToolResult({ ok: true, value: session.status() }),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BinarySession,
|
||||
type BinaryClient,
|
||||
} from "../src/application/BinarySession.js";
|
||||
import { HopperStartError } from "../src/domain/errors.js";
|
||||
import { err, ok } from "../src/domain/result.js";
|
||||
|
||||
let directory: string | undefined;
|
||||
afterEach(async () => {
|
||||
if (directory !== undefined)
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
directory = undefined;
|
||||
});
|
||||
|
||||
describe("binary session", () => {
|
||||
it("requires an open binary and closes idempotently", async () => {
|
||||
const session = new BinarySession(() => client());
|
||||
expect((await session.callTool("binary_overview", {})).ok).toBe(false);
|
||||
expect(await session.close()).toEqual({ ok: true, value: null });
|
||||
});
|
||||
|
||||
it("keeps the active client when a switch fails", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "bb-session-"));
|
||||
const first = join(directory, "first.hop");
|
||||
const second = join(directory, "second.hop");
|
||||
await writeFile(first, "one");
|
||||
await writeFile(second, "two");
|
||||
let created = 0;
|
||||
const session = new BinarySession(() => client(created++ === 1));
|
||||
expect((await session.open(first)).ok).toBe(true);
|
||||
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);
|
||||
});
|
||||
|
||||
it("serializes concurrent opens and leaves the last target active", async () => {
|
||||
const [first, second] = await targets();
|
||||
const clients: TestClient[] = [];
|
||||
const session = new BinarySession(() => {
|
||||
const value = new TestClient();
|
||||
clients.push(value);
|
||||
return value;
|
||||
});
|
||||
const one = session.open(first);
|
||||
const two = session.open(second);
|
||||
expect((await one).ok).toBe(true);
|
||||
expect((await two).ok).toBe(true);
|
||||
expect(session.status()).toMatchObject({ open: true });
|
||||
expect(JSON.stringify(session.status())).toContain("second.hop");
|
||||
expect(clients[0]?.closed).toBe(1);
|
||||
});
|
||||
|
||||
it("waits for an active call before closing its client during a switch", async () => {
|
||||
const [first, second] = await targets();
|
||||
const active = deferred<ReturnType<typeof ok<null>>>();
|
||||
const clients: TestClient[] = [];
|
||||
const session = new BinarySession(() => {
|
||||
const value = new TestClient(
|
||||
undefined,
|
||||
false,
|
||||
clients.length === 0 ? active.promise : undefined,
|
||||
);
|
||||
clients.push(value);
|
||||
return value;
|
||||
});
|
||||
await session.open(first);
|
||||
const call = session.callTool("long", {});
|
||||
const switching = session.open(second);
|
||||
await Promise.resolve();
|
||||
expect(clients[0]?.closed).toBe(0);
|
||||
active.resolve(ok(null));
|
||||
await call;
|
||||
await switching;
|
||||
expect(clients[0]?.closed).toBe(1);
|
||||
});
|
||||
|
||||
it("cancels an open queued behind another transition without creating a client", async () => {
|
||||
const [first, second] = await targets();
|
||||
const health = deferred<ReturnType<typeof ok<null>>>();
|
||||
let created = 0;
|
||||
const session = new BinarySession(() => {
|
||||
created += 1;
|
||||
return new TestClient(created === 1 ? health.promise : undefined);
|
||||
});
|
||||
const opening = session.open(first);
|
||||
const controller = new AbortController();
|
||||
const queued = session.open(second, { signal: controller.signal });
|
||||
controller.abort();
|
||||
health.resolve(ok(null));
|
||||
await opening;
|
||||
const result = await queued;
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError");
|
||||
expect(created).toBe(1);
|
||||
});
|
||||
|
||||
it("cancels a call while it waits for a transition", async () => {
|
||||
const [first] = await targets();
|
||||
const health = deferred<ReturnType<typeof ok<null>>>();
|
||||
const session = new BinarySession(() => new TestClient(health.promise));
|
||||
const opening = session.open(first);
|
||||
const controller = new AbortController();
|
||||
const call = session.callTool(
|
||||
"overview",
|
||||
{},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
const result = await call;
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError");
|
||||
health.resolve(ok(null));
|
||||
await opening;
|
||||
});
|
||||
|
||||
it("closes a failed candidate and preserves the previous target", async () => {
|
||||
const [first, second] = await targets();
|
||||
const clients: TestClient[] = [];
|
||||
const session = new BinarySession(() => {
|
||||
const value = new TestClient(undefined, clients.length === 1);
|
||||
clients.push(value);
|
||||
return value;
|
||||
});
|
||||
await session.open(first);
|
||||
await session.open(second);
|
||||
expect(clients[1]?.closed).toBe(1);
|
||||
expect(clients[0]?.closed).toBe(0);
|
||||
expect(JSON.stringify(session.status())).toContain("first.hop");
|
||||
await session.close();
|
||||
await session.close();
|
||||
expect(clients[0]?.closed).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
const client = (fail = false): BinaryClient => ({
|
||||
callTool: () =>
|
||||
Promise.resolve(fail ? err(new HopperStartError()) : ok(null)),
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
|
||||
class TestClient implements BinaryClient {
|
||||
closed = 0;
|
||||
constructor(
|
||||
readonly pendingHealth?: Promise<ReturnType<typeof ok<null>>>,
|
||||
readonly failHealth = false,
|
||||
readonly pendingCall?: Promise<ReturnType<typeof ok<null>>>,
|
||||
) {}
|
||||
callTool(name: string) {
|
||||
if (name === "health")
|
||||
return this.failHealth
|
||||
? Promise.resolve(err(new HopperStartError()))
|
||||
: (this.pendingHealth ?? Promise.resolve(ok(null)));
|
||||
return this.pendingCall ?? Promise.resolve(ok(null));
|
||||
}
|
||||
close(): Promise<void> {
|
||||
this.closed += 1;
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
const targets = async (): Promise<readonly [string, string]> => {
|
||||
directory ??= await mkdtemp(join(tmpdir(), "bb-session-"));
|
||||
const first = join(directory, "first.hop");
|
||||
const second = join(directory, "second.hop");
|
||||
await writeFile(first, "one");
|
||||
await writeFile(second, "two");
|
||||
return [first, second];
|
||||
};
|
||||
|
||||
const deferred = <T>(): {
|
||||
readonly promise: Promise<T>;
|
||||
resolve(value: T): void;
|
||||
} => {
|
||||
let resolvePromise: ((value: T) => void) | undefined;
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve(value) {
|
||||
resolvePromise?.(value);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseBinaryTarget,
|
||||
parseExecutableHeader,
|
||||
} from "../src/domain/binaryTarget.js";
|
||||
|
||||
let directory: string | undefined;
|
||||
afterEach(async () => {
|
||||
if (directory !== undefined)
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
directory = undefined;
|
||||
});
|
||||
|
||||
describe("executable header parsing", () => {
|
||||
it.each([
|
||||
[elf(1, 1, 62), "elf", "x86_64"],
|
||||
[elf(2, 2, 183), "elf", "arm64"],
|
||||
[thinMach(0xfeedfacf, 0x0100000c), "mach-o", "arm64"],
|
||||
[thinMach(0xcefaedfe, 0x01000007), "mach-o", "x86_64"],
|
||||
[pe(0x8664), "pe", "x86_64"],
|
||||
[pe(0xaa64), "pe", "arm64"],
|
||||
] as const)("parses metadata for %s", (bytes, format, architecture) => {
|
||||
const result = parseExecutableHeader(bytes, "arm64");
|
||||
expect(result.ok && result.value).toMatchObject({ format, architecture });
|
||||
});
|
||||
|
||||
it("selects the host architecture from a FAT table", () => {
|
||||
const bytes = fat([0x01000007, 0x0100000c]);
|
||||
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");
|
||||
});
|
||||
|
||||
it("rejects FAT files without a host-compatible slice", () => {
|
||||
expect(parseExecutableHeader(fat([0x01000007]), "arm64")).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
Buffer.alloc(0),
|
||||
Buffer.from([0x7f, 0x45, 0x4c, 0x46]),
|
||||
fat([], 2),
|
||||
pe(0xffff),
|
||||
])("rejects malformed, truncated, or unsupported metadata", (bytes) => {
|
||||
expect(parseExecutableHeader(bytes, "arm64").ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("binary target I/O", () => {
|
||||
it("resolves relative Hopper databases and rejects unknown or unreadable paths", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "bb-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);
|
||||
});
|
||||
});
|
||||
|
||||
const elf = (class_: number, endian: number, machine: number): Buffer => {
|
||||
const bytes = Buffer.alloc(20);
|
||||
bytes.set([0x7f, 0x45, 0x4c, 0x46, class_, endian]);
|
||||
if (endian === 1) bytes.writeUInt16LE(machine, 18);
|
||||
else bytes.writeUInt16BE(machine, 18);
|
||||
return bytes;
|
||||
};
|
||||
const thinMach = (magic: number, cpu: number): Buffer => {
|
||||
const bytes = Buffer.alloc(8);
|
||||
bytes.writeUInt32BE(magic, 0);
|
||||
if (magic === 0xcefaedfe || magic === 0xcffaedfe) bytes.writeUInt32LE(cpu, 4);
|
||||
else bytes.writeUInt32BE(cpu, 4);
|
||||
return bytes;
|
||||
};
|
||||
const fat = (cpus: readonly number[], declared = cpus.length): Buffer => {
|
||||
const bytes = Buffer.alloc(8 + cpus.length * 20);
|
||||
bytes.writeUInt32BE(0xcafebabe, 0);
|
||||
bytes.writeUInt32BE(declared, 4);
|
||||
cpus.forEach((cpu, index) => bytes.writeUInt32BE(cpu, 8 + index * 20));
|
||||
return bytes;
|
||||
};
|
||||
const pe = (machine: number): Buffer => {
|
||||
const bytes = Buffer.alloc(128);
|
||||
bytes.write("MZ", 0, "ascii");
|
||||
bytes.writeUInt32LE(64, 0x3c);
|
||||
bytes.write("PE\0\0", 64, "binary");
|
||||
bytes.writeUInt16LE(machine, 68);
|
||||
return bytes;
|
||||
};
|
||||
@@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { parseConfig } from "../src/config.js";
|
||||
|
||||
describe("runtime configuration", () => {
|
||||
it("requires a target and defaults to Hopper's documented launcher", () => {
|
||||
expect(parseConfig({}).ok).toBe(false);
|
||||
it("allows target-free startup and defaults to Hopper's documented launcher", () => {
|
||||
const empty = parseConfig({});
|
||||
expect(empty.ok).toBe(true);
|
||||
if (empty.ok) expect(empty.value.hopperTargetPath).toBeUndefined();
|
||||
const result = parseConfig({ HOPPER_TARGET_PATH: "/usr/bin/true" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
ENHANCED_TOOL_CONTRACTS,
|
||||
OFFICIAL_TOOL_CONTRACTS,
|
||||
SESSION_TOOL_CONTRACTS,
|
||||
} from "../src/contracts/toolContracts.js";
|
||||
|
||||
const ORIGINAL_39_SHA256 =
|
||||
"43ffddd91b775a3e00fb736c6b1a644210e5917a1e1f02745d0283ac6ab99db4";
|
||||
|
||||
describe("original tool contract snapshot", () => {
|
||||
it("preserves every original name, description, kind, and complete JSON schema", () => {
|
||||
const projection = [
|
||||
...OFFICIAL_TOOL_CONTRACTS,
|
||||
...ENHANCED_TOOL_CONTRACTS,
|
||||
].map((contract) => ({
|
||||
name: contract.name,
|
||||
description: contract.description,
|
||||
kind: contract.kind,
|
||||
inputSchema: z.toJSONSchema(contract.inputSchema, {
|
||||
target: "draft-07",
|
||||
unrepresentable: "any",
|
||||
}),
|
||||
}));
|
||||
expect(projection).toHaveLength(39);
|
||||
expect(
|
||||
createHash("sha256").update(JSON.stringify(projection)).digest("hex"),
|
||||
).toBe(ORIGINAL_39_SHA256);
|
||||
});
|
||||
|
||||
it("keeps exactly three additive session contracts", () => {
|
||||
expect(
|
||||
SESSION_TOOL_CONTRACTS.map(({ name, kind }) => ({ name, kind })),
|
||||
).toEqual([
|
||||
{ name: "open_binary", kind: "session" },
|
||||
{ name: "close_binary", kind: "session" },
|
||||
{ name: "binary_session", kind: "session" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ok } from "../src/domain/result.js";
|
||||
@@ -11,4 +12,16 @@ describe("beta.3 server composition", () => {
|
||||
it("constructs independent MCP server instances", () => {
|
||||
expect(createServer(hopper)).not.toBe(createServer(hopper));
|
||||
});
|
||||
|
||||
it("does not advertise unavailable session tools", async () => {
|
||||
const server = createServer(hopper);
|
||||
const client = new Client({ name: "composition-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
expect(client.getInstructions()).not.toContain("open_binary");
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runDoctor, type DoctorHost } from "../src/application/Doctor.js";
|
||||
|
||||
const host = (overrides: Partial<DoctorHost> = {}): DoctorHost => ({
|
||||
platform: "darwin",
|
||||
nodeVersion: "22.1.0",
|
||||
macosVersion: () => Promise.resolve("12.0"),
|
||||
readable: (path) => Promise.resolve(path.includes("Hopper")),
|
||||
brewHopperPath: () => Promise.resolve(undefined),
|
||||
manualHopperPaths: () => Promise.resolve([]),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("doctor", () => {
|
||||
it.each(["11.7", undefined])(
|
||||
"rejects unsupported macOS version %s",
|
||||
async (version) => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({ macosVersion: () => Promise.resolve(version) }),
|
||||
);
|
||||
expect(result.checks.find(({ name }) => name === "macos")?.ok).toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
it("detects a manual configured Hopper before Homebrew", async () => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
configuredHopperPath: "/manual/Hopper",
|
||||
readable: (path) => Promise.resolve(path === "/manual/Hopper"),
|
||||
}),
|
||||
);
|
||||
expect(result.hopperPath).toBe("/manual/Hopper");
|
||||
expect(result.healthy).toBe(true);
|
||||
});
|
||||
it("detects a Homebrew cask installed outside /Applications", async () => {
|
||||
const path =
|
||||
"/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
readable: (candidate) => Promise.resolve(candidate === path),
|
||||
brewHopperPath: () => Promise.resolve(path),
|
||||
}),
|
||||
);
|
||||
expect(result.hopperPath).toBe(path);
|
||||
});
|
||||
|
||||
it("detects a manually installed Hopper application", async () => {
|
||||
const path = "/Applications/Hopper v6.app/Contents/MacOS/hopper";
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
readable: (candidate) => Promise.resolve(candidate === path),
|
||||
manualHopperPaths: () => Promise.resolve([path]),
|
||||
}),
|
||||
);
|
||||
expect(result.hopperPath).toBe(path);
|
||||
});
|
||||
});
|
||||
@@ -135,4 +135,30 @@ describe("HopperClient", () => {
|
||||
value: { value: "alive" },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores more than 1,024 late responses without corrupting the session", async () => {
|
||||
const client = await startClient();
|
||||
const timedOut = await Promise.all(
|
||||
Array.from({ length: 1_025 }, (_, index) =>
|
||||
client.callTool("echo", { index, delay: 150 }, { timeoutMs: 5 }),
|
||||
),
|
||||
);
|
||||
expect(timedOut.every((result) => !result.ok)).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await expect(client.callTool("echo", { value: "alive" })).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { value: "alive" },
|
||||
});
|
||||
});
|
||||
|
||||
it("makes concurrent close callers await the same shutdown", async () => {
|
||||
const client = await startClient();
|
||||
let firstSettled = false;
|
||||
const first = client.close().then(() => {
|
||||
firstSettled = true;
|
||||
});
|
||||
await client.close();
|
||||
expect(firstSettled).toBe(true);
|
||||
await first;
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ const fixturePath = fileURLToPath(
|
||||
);
|
||||
|
||||
describe("production stdio runtime", () => {
|
||||
it("starts the built entrypoint, lists 39 tools, calls one, and shuts down", async () => {
|
||||
it("starts the built entrypoint, lists 42 tools, calls one, and shuts down", async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [mainPath],
|
||||
@@ -18,7 +18,7 @@ describe("production stdio runtime", () => {
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOPPER_LAUNCHER_PATH: process.execPath,
|
||||
HOPPER_TARGET_PATH: "/fixture/sample",
|
||||
HOPPER_TARGET_PATH: process.execPath,
|
||||
HOPPER_LOADER_ARGS_JSON: JSON.stringify([fixturePath]),
|
||||
},
|
||||
stderr: "pipe",
|
||||
@@ -32,7 +32,7 @@ describe("production stdio runtime", () => {
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools).toHaveLength(39);
|
||||
expect(tools.tools).toHaveLength(42);
|
||||
const result = await client.callTool({
|
||||
name: "current_document",
|
||||
arguments: {},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BinarySession,
|
||||
type BinaryClient,
|
||||
} from "../src/application/BinarySession.js";
|
||||
import { ok } from "../src/domain/result.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
let directory: string | undefined;
|
||||
afterEach(async () => {
|
||||
await Promise.all(resources.splice(0).map((resource) => resource.close()));
|
||||
if (directory !== undefined)
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
directory = undefined;
|
||||
});
|
||||
|
||||
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-"));
|
||||
const first = join(directory, "first.hop");
|
||||
const second = join(directory, "second.hop");
|
||||
await writeFile(first, "one");
|
||||
await writeFile(second, "two");
|
||||
const closed: string[] = [];
|
||||
const session = new BinarySession((target) => client(target.path, closed));
|
||||
const server = createServer(session, session);
|
||||
const mcp = new Client({ name: "session-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(mcp, server);
|
||||
await server.connect(serverTransport);
|
||||
await mcp.connect(clientTransport);
|
||||
|
||||
const before = await mcp.callTool({
|
||||
name: "current_document",
|
||||
arguments: {},
|
||||
});
|
||||
expect(before.isError).toBe(true);
|
||||
expect(text(before)).toContain("NoBinaryOpenError");
|
||||
expect((await mcp.listTools()).tools).toHaveLength(42);
|
||||
expect(
|
||||
(await mcp.callTool({ name: "open_binary", arguments: { path: first } }))
|
||||
.isError,
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
text(await mcp.callTool({ name: "current_document", arguments: {} })),
|
||||
).toContain("first.hop");
|
||||
await mcp.callTool({ name: "open_binary", arguments: { path: second } });
|
||||
expect(closed.some((path) => path.endsWith("first.hop"))).toBe(true);
|
||||
expect(
|
||||
text(await mcp.callTool({ name: "binary_session", arguments: {} })),
|
||||
).toContain("second.hop");
|
||||
await mcp.callTool({ name: "close_binary", arguments: {} });
|
||||
expect(
|
||||
text(await mcp.callTool({ name: "binary_session", arguments: {} })),
|
||||
).toContain('"open": false');
|
||||
});
|
||||
});
|
||||
|
||||
const client = (path: string, closed: string[]): BinaryClient => ({
|
||||
callTool: (name) =>
|
||||
Promise.resolve(ok(name === "health" ? null : { path, name })),
|
||||
close: () => {
|
||||
closed.push(path);
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
const text = (result: CallToolResult): string => {
|
||||
const content = result.content.find((item) => item.type === "text");
|
||||
if (content?.type !== "text") throw new Error("missing text result");
|
||||
return content.text;
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
runSetup,
|
||||
type ClientConfigurationResult,
|
||||
type SetupClient,
|
||||
type SetupHost,
|
||||
} from "../src/application/Setup.js";
|
||||
import type { DoctorCheck } from "../src/application/Doctor.js";
|
||||
|
||||
class FakeSetupHost implements SetupHost {
|
||||
readonly platform = "darwin" as const;
|
||||
readonly nodeVersion = "22.4.0";
|
||||
version: string | undefined = "14.5";
|
||||
brew = false;
|
||||
hopper: string | undefined;
|
||||
homebrewInstallSucceeds = true;
|
||||
hopperInstallSucceeds = true;
|
||||
skill: "installed" | "unchanged" | "failed" = "installed";
|
||||
clients: readonly SetupClient[] = [];
|
||||
clientResults = new Map<string, ClientConfigurationResult>();
|
||||
homebrewInstalls = 0;
|
||||
hopperInstalls = 0;
|
||||
configurations = 0;
|
||||
|
||||
macosVersion = (): Promise<string | undefined> =>
|
||||
Promise.resolve(this.version);
|
||||
hasHomebrew = (): Promise<boolean> => Promise.resolve(this.brew);
|
||||
installHomebrew = (): Promise<boolean> => {
|
||||
this.homebrewInstalls += 1;
|
||||
if (this.homebrewInstallSucceeds) this.brew = true;
|
||||
return Promise.resolve(this.homebrewInstallSucceeds);
|
||||
};
|
||||
hopperPath = (): Promise<string | undefined> => Promise.resolve(this.hopper);
|
||||
installHopper = (): Promise<boolean> => {
|
||||
this.hopperInstalls += 1;
|
||||
if (this.hopperInstallSucceeds) this.hopper = "/manual/Hopper";
|
||||
return Promise.resolve(this.hopperInstallSucceeds);
|
||||
};
|
||||
detectedClients = (): Promise<readonly SetupClient[]> =>
|
||||
Promise.resolve(this.clients);
|
||||
configureClient = (
|
||||
client: SetupClient,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
this.configurations += 1;
|
||||
return Promise.resolve(
|
||||
this.clientResults.get(client.name) ?? {
|
||||
status: "configured",
|
||||
backupPath: `${client.configPath}.backup`,
|
||||
},
|
||||
);
|
||||
};
|
||||
installSkill = (): Promise<"installed" | "unchanged" | "failed"> =>
|
||||
Promise.resolve(this.skill);
|
||||
doctor = (): Promise<{
|
||||
healthy: boolean;
|
||||
hopperPath?: string;
|
||||
checks: readonly DoctorCheck[];
|
||||
}> =>
|
||||
Promise.resolve({
|
||||
healthy: this.brew && this.hopper !== undefined,
|
||||
...(this.hopper === undefined ? {} : { hopperPath: this.hopper }),
|
||||
checks: [],
|
||||
});
|
||||
}
|
||||
|
||||
describe("setup workflow", () => {
|
||||
it("installs Homebrew and Hopper on a clean approved machine", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
const result = await runSetup(true, host);
|
||||
expect(result.status).toBe("ready");
|
||||
expect(result.actions).toEqual([
|
||||
"installed_homebrew",
|
||||
"installed_hopper",
|
||||
"installed_skill",
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires --yes before installing Homebrew", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
const result = await runSetup(false, host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(host.homebrewInstalls).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);
|
||||
});
|
||||
|
||||
it.each(["homebrew", "hopper"] as const)(
|
||||
"returns needs_human when %s installation is interrupted",
|
||||
async (stage) => {
|
||||
const host = new FakeSetupHost();
|
||||
if (stage === "homebrew") host.homebrewInstallSucceeds = false;
|
||||
else {
|
||||
host.brew = true;
|
||||
host.hopperInstallSucceeds = false;
|
||||
}
|
||||
const result = await runSetup(true, host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.remediation).toContain("interrupted");
|
||||
},
|
||||
);
|
||||
|
||||
it("configures every detected client and reports backup paths", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.brew = true;
|
||||
host.hopper = "/Hopper";
|
||||
host.clients = [
|
||||
{ name: "claude", configPath: "/claude.json" },
|
||||
{ name: "cursor", configPath: "/cursor.json" },
|
||||
];
|
||||
const result = await runSetup(true, host);
|
||||
expect(Object.keys(result.clients)).toEqual(["claude", "cursor"]);
|
||||
expect(host.configurations).toBe(2);
|
||||
});
|
||||
|
||||
it.each(["backup", "write", "readback"] as const)(
|
||||
"stops with precise remediation after a %s failure",
|
||||
async (reason) => {
|
||||
const host = new FakeSetupHost();
|
||||
host.brew = true;
|
||||
host.hopper = "/Hopper";
|
||||
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
||||
host.clientResults.set("cursor", { status: "failed", reason });
|
||||
const result = await runSetup(true, host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.remediation).toContain(reason);
|
||||
},
|
||||
);
|
||||
|
||||
it("is idempotent on repeated setup", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.brew = true;
|
||||
host.hopper = "/Hopper";
|
||||
host.skill = "unchanged";
|
||||
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
||||
host.clientResults.set("cursor", { status: "unchanged" });
|
||||
const first = await runSetup(true, host);
|
||||
const second = await runSetup(true, host);
|
||||
expect(first.actions).toEqual([]);
|
||||
expect(second.actions).toEqual([]);
|
||||
expect(host.homebrewInstalls).toBe(0);
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["11.7", undefined])(
|
||||
"rejects unsupported macOS %s before mutation",
|
||||
async (version) => {
|
||||
const host = new FakeSetupHost();
|
||||
host.version = version;
|
||||
expect((await runSetup(true, host)).status).toBe("needs_human");
|
||||
expect(host.homebrewInstalls).toBe(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { configureJsonClient } from "../src/application/Setup.js";
|
||||
|
||||
let directory: string | undefined;
|
||||
afterEach(async () => {
|
||||
if (directory !== undefined)
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
directory = undefined;
|
||||
});
|
||||
|
||||
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-"));
|
||||
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(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({
|
||||
theme: "dark",
|
||||
mcpServers: {
|
||||
other: { command: "other" },
|
||||
"better-binary": { command: "better-binary-mcp" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("performs no write or second backup when configuration already matches", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "bb-setup-"));
|
||||
const configPath = join(directory, "mcp.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
'{"mcpServers":{"better-binary":{"command":"better-binary-mcp"}}}\n',
|
||||
);
|
||||
expect(await configureJsonClient({ name: "cursor", configPath })).toEqual({
|
||||
status: "unchanged",
|
||||
});
|
||||
await expect(
|
||||
readFile(`${configPath}.better-binary.backup`, "utf8"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("refuses malformed existing JSON without overwriting it", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "bb-setup-"));
|
||||
const configPath = join(directory, "mcp.json");
|
||||
await writeFile(configPath, "not-json");
|
||||
expect(await configureJsonClient({ name: "cursor", configPath })).toEqual({
|
||||
status: "failed",
|
||||
reason: "readback",
|
||||
});
|
||||
expect(await readFile(configPath, "utf8")).toBe("not-json");
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,21 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ENHANCED_TOOL_CONTRACTS,
|
||||
OFFICIAL_TOOL_CONTRACTS,
|
||||
SESSION_TOOL_CONTRACTS,
|
||||
TOOL_CONTRACTS,
|
||||
} from "../src/contracts/toolContracts.js";
|
||||
|
||||
describe("tool contract inventory", () => {
|
||||
it("preserves the complete 31 plus 8 tool surface", () => {
|
||||
it("preserves the original 39 contracts and adds three session tools", () => {
|
||||
expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(31);
|
||||
expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(8);
|
||||
expect(TOOL_CONTRACTS).toHaveLength(39);
|
||||
expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(39);
|
||||
expect(SESSION_TOOL_CONTRACTS.map(({ name }) => name)).toEqual([
|
||||
"open_binary",
|
||||
"close_binary",
|
||||
"binary_session",
|
||||
]);
|
||||
expect(TOOL_CONTRACTS).toHaveLength(42);
|
||||
expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(42);
|
||||
});
|
||||
|
||||
it("retains documented enhanced-tool limits at the input boundary", () => {
|
||||
|
||||
Reference in new issue
Block a user