diff --git a/README.md b/README.md index 0b5e057e1a..61357610c4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index 2b1a3a4567..d55b89ca9d 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -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") diff --git a/package-lock.json b/package-lock.json index 71647255bd..d9f718a1bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0ca1203fa4..b6accb4ac8 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/better-binary.mjs b/scripts/better-binary.mjs new file mode 100644 index 0000000000..f621b18c69 --- /dev/null +++ b/scripts/better-binary.mjs @@ -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(); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000000..c410ec009d --- /dev/null +++ b/scripts/verify-package.mjs @@ -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); +} diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index c56a95e1db..f823a38b85 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -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(); + }, + ); +}); diff --git a/skills/better-binary-analysis/SKILL.md b/skills/better-binary-analysis/SKILL.md new file mode 100644 index 0000000000..fa6c675ec9 --- /dev/null +++ b/skills/better-binary-analysis/SKILL.md @@ -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. diff --git a/src/application/BinarySession.ts b/src/application/BinarySession.ts new file mode 100644 index 0000000000..9a1785be18 --- /dev/null +++ b/src/application/BinarySession.ts @@ -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; +} +/** 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 = Promise.resolve(); + readonly #calls = new Set>(); + 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> { + 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> { + 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>, + options?: { readonly signal?: AbortSignal }, + ): Promise> { + 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(operation: () => Promise): Promise { + const result = this.#transition.then(operation, operation); + this.#transition = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async #drainCalls(): Promise { + await Promise.allSettled([...this.#calls]); + } + + async #waitForTransition( + signal?: AbortSignal, + ): Promise> { + 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; diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts new file mode 100644 index 0000000000..c8d23c00e7 --- /dev/null +++ b/src/application/DirectAnalysis.ts @@ -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>, +): Promise => { + 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(); + } +}; diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts new file mode 100644 index 0000000000..95615048da --- /dev/null +++ b/src/application/Doctor.ts @@ -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; + readable(path: string): Promise; + brewHopperPath(): Promise; + manualHopperPaths(): Promise; +} + +/** 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 }), +}); diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 9868a0b5e6..3e9b30b8cf 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -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(); + 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 }); + } } } } diff --git a/src/application/Setup.ts b/src/application/Setup.ts new file mode 100644 index 0000000000..2f35b41d38 --- /dev/null +++ b/src/application/Setup.ts @@ -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; + hasHomebrew(): Promise; + installHomebrew(): Promise; + hopperPath(): Promise; + installHopper(): Promise; + detectedClients(): Promise; + configureClient(client: SetupClient): Promise; + installSkill(): Promise<"installed" | "unchanged" | "failed">; + doctor(): Promise>>; +} +/** Structured setup outcome with actionable partial progress. */ +export interface SetupResult { + readonly status: "ready" | "needs_human"; + readonly actions: readonly string[]; + readonly clients: Readonly>; + readonly doctor: Awaited>; + readonly remediation?: string; +} + +/** Install prerequisites and configure every detected supported client idempotently. */ +export const runSetup = async ( + yes: boolean, + host: SetupHost = systemSetupHost(), +): Promise => { + const actions: string[] = []; + const clients: Record = {}; + const fail = async (remediation: string): Promise => ({ + 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 => { + 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 => { + let document: Record = {}; + 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 => { + try { + await execFileAsync(command, args, env === undefined ? {} : { env }); + return true; + } catch { + return false; + } +}; +const brewSucceeds = async (args: readonly string[]): Promise => { + 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 => { + try { + await access(path); + return true; + } catch { + return false; + } +}; +const objectSchema = z.record(z.string(), z.unknown()); +const objectValue = (value: unknown): Record => { + const parsed = objectSchema.safeParse(value); + return parsed.success ? parsed.data : {}; +}; +const parseObject = (text: string): Record => + 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 => { + 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. + } +}; diff --git a/src/application/homebrew.ts b/src/application/homebrew.ts new file mode 100644 index 0000000000..34e529984f --- /dev/null +++ b/src/application/homebrew.ts @@ -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 ( + probe: (command: string) => Promise, +): Promise => { + for (const command of HOMEBREW_COMMANDS) { + const result = await probe(command); + if (result !== undefined) return result; + } + return undefined; +}; diff --git a/src/config.ts b/src/config.ts index 769a349da5..48a4c2229c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, }), ); diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index b561e386be..e43bb09f04 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -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; diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts new file mode 100644 index 0000000000..4361e344c3 --- /dev/null +++ b/src/domain/binaryTarget.ts @@ -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> => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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; +}; diff --git a/src/domain/errors.ts b/src/domain/errors.ts index e65f61c09a..1ebe5dda65 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -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); + } +} diff --git a/src/hopper/HopperClient.ts b/src/hopper/HopperClient.ts index 08fd4d9f77..4b54681be3 100644 --- a/src/hopper/HopperClient.ts +++ b/src/hopper/HopperClient.ts @@ -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(); - readonly #abandoned = new Set(); #socket: Socket | undefined; #launch: BridgeLaunch | undefined; #directory: string | undefined; @@ -63,6 +61,7 @@ export class HopperClient { #buffer = ""; #closing = false; #startPromise: Promise> | undefined; + #closePromise: Promise | 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 { - if (this.#closing) return; + close(): Promise { + this.#closePromise ??= Promise.resolve().then(() => this.#close()); + return this.#closePromise; + } + + async #close(): Promise { 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> { @@ -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): 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); } diff --git a/src/identity.ts b/src/identity.ts new file mode 100644 index 0000000000..15881dfdad --- /dev/null +++ b/src/identity.ts @@ -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; diff --git a/src/main.ts b/src/main.ts index 9a58a8450d..c9b73111bb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 => { 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 => { const shutdown = (): Promise => { shutdownPromise ??= (async () => { await handle.close(); - await hopper.close(); + await session.close(); })(); return shutdownPromise; }; @@ -71,7 +86,7 @@ export const run = async (): Promise => { const entryPath = process.argv[1]; if ( entryPath !== undefined && - pathToFileURL(entryPath).href === import.meta.url + pathToFileURL(realpathSync(entryPath)).href === import.meta.url ) { run() .then((exitCode) => { diff --git a/src/server/createServer.ts b/src/server/createServer.ts index cba3e0bef0..93c87b5fbf 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -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; }; diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts new file mode 100644 index 0000000000..b6e15d16a7 --- /dev/null +++ b/src/server/registerSessionTools.ts @@ -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() }), + ); +}; diff --git a/tests/binarySession.test.ts b/tests/binarySession.test.ts new file mode 100644 index 0000000000..fb63815651 --- /dev/null +++ b/tests/binarySession.test.ts @@ -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>>(); + 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>>(); + 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>>(); + 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>>, + readonly failHealth = false, + readonly pendingCall?: Promise>>, + ) {} + 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 { + this.closed += 1; + return Promise.resolve(); + } +} + +const targets = async (): Promise => { + 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 = (): { + readonly promise: Promise; + resolve(value: T): void; +} => { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve(value) { + resolvePromise?.(value); + }, + }; +}; diff --git a/tests/binaryTarget.test.ts b/tests/binaryTarget.test.ts new file mode 100644 index 0000000000..ea4d34bd71 --- /dev/null +++ b/tests/binaryTarget.test.ts @@ -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; +}; diff --git a/tests/config.test.ts b/tests/config.test.ts index fb64dc75ba..1cbcbd2725 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -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) { diff --git a/tests/contractSnapshot.test.ts b/tests/contractSnapshot.test.ts new file mode 100644 index 0000000000..59cdbe5702 --- /dev/null +++ b/tests/contractSnapshot.test.ts @@ -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" }, + ]); + }); +}); diff --git a/tests/createServer.test.ts b/tests/createServer.test.ts index 2a9cc3483f..47c3da51fa 100644 --- a/tests/createServer.test.ts +++ b/tests/createServer.test.ts @@ -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(); + }); }); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts new file mode 100644 index 0000000000..bda405f74d --- /dev/null +++ b/tests/doctor.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { runDoctor, type DoctorHost } from "../src/application/Doctor.js"; + +const host = (overrides: Partial = {}): 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); + }); +}); diff --git a/tests/hopperClient.test.ts b/tests/hopperClient.test.ts index f4cdb1b7ea..1115987397 100644 --- a/tests/hopperClient.test.ts +++ b/tests/hopperClient.test.ts @@ -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; + }); }); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 5419b94d40..33af40ad1a 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -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: {}, diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts new file mode 100644 index 0000000000..87551ffb7d --- /dev/null +++ b/tests/sessionMcp.test.ts @@ -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 }> = []; +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; +}; diff --git a/tests/setup.test.ts b/tests/setup.test.ts new file mode 100644 index 0000000000..e1df79cc52 --- /dev/null +++ b/tests/setup.test.ts @@ -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(); + homebrewInstalls = 0; + hopperInstalls = 0; + configurations = 0; + + macosVersion = (): Promise => + Promise.resolve(this.version); + hasHomebrew = (): Promise => Promise.resolve(this.brew); + installHomebrew = (): Promise => { + this.homebrewInstalls += 1; + if (this.homebrewInstallSucceeds) this.brew = true; + return Promise.resolve(this.homebrewInstallSucceeds); + }; + hopperPath = (): Promise => Promise.resolve(this.hopper); + installHopper = (): Promise => { + this.hopperInstalls += 1; + if (this.hopperInstallSucceeds) this.hopper = "/manual/Hopper"; + return Promise.resolve(this.hopperInstallSucceeds); + }; + detectedClients = (): Promise => + Promise.resolve(this.clients); + configureClient = ( + client: SetupClient, + ): Promise => { + 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); + }, + ); +}); diff --git a/tests/setupConfig.test.ts b/tests/setupConfig.test.ts new file mode 100644 index 0000000000..1154f74cd1 --- /dev/null +++ b/tests/setupConfig.test.ts @@ -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"); + }); +}); diff --git a/tests/toolContracts.test.ts b/tests/toolContracts.test.ts index e211f6dda5..d7bc2448b8 100644 --- a/tests/toolContracts.test.ts +++ b/tests/toolContracts.test.ts @@ -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", () => {