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