diff --git a/package.json b/package.json index bc3fe22ab..27ac96a01 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 d94ae02b9..85e32c9f3 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 2f64e9d62..befc2377a 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 772331a16..16271c086 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 87551ffb7..d84413a7c 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");