diff --git a/src/application/BinarySession.ts b/src/application/BinarySession.ts new file mode 100644 index 0000000000..b0e0dab524 --- /dev/null +++ b/src/application/BinarySession.ts @@ -0,0 +1,156 @@ +import type { BinaryTarget } from "../domain/binaryTarget.js"; +import { parseBinaryTarget } from "../domain/binaryTarget.js"; +import { + HopperCancelledError, + NoBinaryOpenError, + type HopperError, +} from "../domain/errors.js"; +import { err, ok, type Result } from "../domain/result.js"; +import type { JsonValue } from "../hopper/protocol.js"; +import type { HopperToolPort } from "./HopperToolPort.js"; + +/** A closeable Hopper port created for exactly one parsed target. */ +export interface BinaryClient extends HopperToolPort { + close(): Promise; +} +/** Production seam for creating a target-scoped Hopper client. */ +export type BinaryClientFactory = (target: BinaryTarget) => BinaryClient; + +/** Serializes target transitions while allowing calls within the active session. */ +export class BinarySession implements HopperToolPort { + #active: + | { readonly target: BinaryTarget; readonly client: BinaryClient } + | undefined; + #transition: Promise = Promise.resolve(); + readonly #calls = new Set>(); + constructor(readonly createClient: BinaryClientFactory) {} + + /** Open or switch to a target, retaining the old session when the new one fails. */ + open( + path: string, + options: { readonly signal?: AbortSignal } = {}, + ): Promise> { + return this.#serialize(async () => { + if (isAborted(options.signal)) return err(new HopperCancelledError()); + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) return parsed; + if (isAborted(options.signal)) return err(new HopperCancelledError()); + if (this.#active?.target.path === parsed.value.path) + return ok(parsed.value); + await this.#drainCalls(); + const previous = this.#active; + 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(); + if (previous !== undefined) await this.#restore(previous.target); + return started; + } + if (isAborted(options.signal)) { + await client.close(); + if (previous !== undefined) await this.#restore(previous.target); + return err(new HopperCancelledError()); + } + this.#active = { target: parsed.value, client }; + return ok(parsed.value); + }); + } + + /** Close the active target, if any. */ + close(): Promise> { + return this.#serialize(async () => { + const previous = this.#active; + this.#active = undefined; + await this.#drainCalls(); + await previous?.client.close(); + return ok(null); + }); + } + + /** Describe the active binary session. */ + status(): JsonValue { + const target = this.#active?.target; + return target === undefined + ? { open: false } + : { + open: true, + path: target.path, + format: target.format, + kind: target.kind, + }; + } + + /** Invoke a Hopper tool against the active target. */ + async callTool( + name: string, + arguments_: Readonly>, + options?: { readonly signal?: AbortSignal }, + ): Promise> { + const transitioned = await this.#waitForTransition(options?.signal); + if (!transitioned.ok) return transitioned; + const active = this.#active; + if (active === undefined) return err(new NoBinaryOpenError()); + const call = active.client.callTool(name, arguments_, options); + this.#calls.add(call); + try { + return await call; + } finally { + this.#calls.delete(call); + } + } + + #serialize(operation: () => Promise): Promise { + const result = this.#transition.then(operation, operation); + this.#transition = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async #drainCalls(): Promise { + await Promise.allSettled([...this.#calls]); + } + + async #restore(target: BinaryTarget): Promise { + const client = this.createClient(target); + const started = await client.callTool("health", {}); + if (started.ok) this.#active = { target, client }; + else await client.close(); + } + + async #waitForTransition( + signal?: AbortSignal, + ): Promise> { + if (signal?.aborted === true) return err(new HopperCancelledError()); + if (signal === undefined) { + await this.#transition; + return ok(undefined); + } + return new Promise((resolve) => { + const onAbort = (): void => { + resolve(err(new HopperCancelledError())); + }; + signal.addEventListener("abort", onAbort, { once: true }); + this.#transition.then( + () => { + signal.removeEventListener("abort", onAbort); + resolve( + signal.aborted ? err(new HopperCancelledError()) : ok(undefined), + ); + }, + () => { + signal.removeEventListener("abort", onAbort); + resolve( + signal.aborted ? err(new HopperCancelledError()) : ok(undefined), + ); + }, + ); + }); + } +} + +const isAborted = (signal: AbortSignal | undefined): boolean => + signal?.aborted === true; diff --git a/src/config.ts b/src/config.ts index 769a349da5..48a4c2229c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,14 +8,14 @@ const DEFAULT_HOPPER_LAUNCHER_PATH = export interface AppConfig { readonly hopperLauncherPath: string; - readonly hopperTargetPath: string; + readonly hopperTargetPath: string | undefined; readonly hopperTargetKind: "executable" | "database"; readonly hopperLoaderArgs: readonly string[]; } const environmentSchema = z.object({ HOPPER_LAUNCHER_PATH: z.string().min(1).optional(), - HOPPER_TARGET_PATH: z.string().min(1), + HOPPER_TARGET_PATH: z.string().min(1).optional(), HOPPER_TARGET_KIND: z.enum(["executable", "database"]).default("executable"), HOPPER_LOADER_ARGS_JSON: z.string().optional(), }); @@ -27,7 +27,7 @@ export const parseConfig = ( const parsedEnvironment = environmentSchema.safeParse(environment); if (!parsedEnvironment.success) { return err( - new ConfigurationError("HOPPER_TARGET_PATH is required", { + new ConfigurationError("Invalid Hopper environment configuration", { cause: parsedEnvironment.error, }), ); diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index b561e386be..e43bb09f04 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -13,7 +13,7 @@ const caseSensitive = z .describe("Whether to match case"); /** The architectural owner of a tool implementation. */ -export type ToolKind = "official-proxy" | "enhanced"; +export type ToolKind = "official-proxy" | "enhanced" | "session"; /** Stable caller-visible contract retained across the Python-to-TypeScript migration. */ export interface ToolContract { @@ -35,6 +35,12 @@ const enhanced = ( inputSchema: z.ZodObject, ): ToolContract => ({ name, description, kind: "enhanced", inputSchema }); +const session = ( + name: string, + description: string, + inputSchema: z.ZodObject, +): ToolContract => ({ name, description, kind: "session", inputSchema }); + /** All 31 tools forwarded unchanged to Hopper's official MCP server. */ export const OFFICIAL_TOOL_CONTRACTS = [ official( @@ -218,8 +224,20 @@ export const ENHANCED_TOOL_CONTRACTS = [ ), ] as const satisfies readonly ToolContract[]; +/** Binary lifecycle tools available before Hopper is launched. */ +export const SESSION_TOOL_CONTRACTS = [ + session( + "open_binary", + "Open or switch the active binary", + z.object({ path: z.string().min(1) }), + ), + session("close_binary", "Close the active binary", z.object({})), + session("binary_session", "Describe the active binary session", z.object({})), +] as const satisfies readonly ToolContract[]; + /** Complete caller-visible tool inventory. */ export const TOOL_CONTRACTS = [ ...OFFICIAL_TOOL_CONTRACTS, ...ENHANCED_TOOL_CONTRACTS, + ...SESSION_TOOL_CONTRACTS, ] as const; diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts new file mode 100644 index 0000000000..4361e344c3 --- /dev/null +++ b/src/domain/binaryTarget.ts @@ -0,0 +1,217 @@ +import { constants } from "node:fs"; +import { access, open, realpath } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; + +import { BinaryTargetError } from "./errors.js"; +import { err, ok, type Result } from "./result.js"; + +/** CPU families understood by the supported executable loaders. */ +export type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64"; +/** A parsed binary target and the Hopper launcher arguments it requires. */ +export interface BinaryTarget { + readonly path: string; + readonly kind: "executable" | "database"; + readonly format: "hopper" | "mach-o" | "elf" | "pe"; + readonly architecture?: BinaryArchitecture; + readonly availableArchitectures?: readonly BinaryArchitecture[]; + readonly loaderArgs: readonly string[]; +} + +/** Resolve, read, and classify a local binary path, including its architecture metadata. */ +export const parseBinaryTarget = async ( + input: string, + cwd = process.cwd(), + hostArchitecture: NodeJS.Architecture = process.arch, +): Promise> => { + const candidate = isAbsolute(input) ? input : resolve(cwd, input); + try { + await access(candidate, constants.R_OK); + const path = await realpath(candidate); + if (path.toLowerCase().endsWith(".hop")) + return ok({ path, kind: "database", format: "hopper", loaderArgs: [] }); + const handle = await open(path, "r"); + let bytes: Buffer; + try { + bytes = Buffer.alloc(4096); + const read = await handle.read(bytes, 0, bytes.length, 0); + bytes = bytes.subarray(0, read.bytesRead); + } finally { + await handle.close(); + } + const detected = parseExecutableHeader(bytes, hostArchitecture); + if (!detected.ok) return err(new BinaryTargetError(path, detected.error)); + return ok({ path, kind: "executable", ...detected.value }); + } catch (cause: unknown) { + return err( + new BinaryTargetError(candidate, "path is not readable", { cause }), + ); + } +}; + +type ExecutableMetadata = Pick< + BinaryTarget, + "format" | "architecture" | "availableArchitectures" | "loaderArgs" +>; + +/** Parse supported executable headers without performing I/O. */ +export const parseExecutableHeader = ( + bytes: Buffer, + hostArchitecture: NodeJS.Architecture, +): Result => { + if ( + bytes.length >= 4 && + bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) + ) + return parseElf(bytes); + if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) + return parsePe(bytes); + if (bytes.length < 8) return err("truncated or unsupported binary header"); + const magic = bytes.readUInt32BE(0); + if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) + return parseThinMachO(bytes, magic); + if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic)) + return parseFatMachO(bytes, magic, hostArchitecture); + return err("unsupported binary format"); +}; + +const parseThinMachO = ( + bytes: Buffer, + magic: number, +): Result => { + if (bytes.length < 8) return err("truncated Mach-O header"); + const little = magic === 0xcefaedfe || magic === 0xcffaedfe; + const architecture = machArchitecture( + little ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4), + ); + if (architecture === undefined) return err("unsupported Mach-O architecture"); + return ok({ + format: "mach-o", + architecture, + availableArchitectures: [architecture], + loaderArgs: ["-l", "Mach-O"], + }); +}; + +const parseFatMachO = ( + bytes: Buffer, + magic: number, + host: NodeJS.Architecture, +): Result => { + const little = magic === 0xbebafeca || magic === 0xbfbafeca; + const is64 = magic === 0xcafebabf || magic === 0xbfbafeca; + if (bytes.length < 8) return err("truncated FAT header"); + const read = (offset: number): number => + little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset); + const count = read(4); + const entrySize = is64 ? 32 : 20; + if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize) + return err("truncated or invalid FAT architecture table"); + const architectures: BinaryArchitecture[] = []; + for (let index = 0; index < count; index += 1) { + const architecture = machArchitecture(read(8 + index * entrySize)); + if (architecture !== undefined && !architectures.includes(architecture)) + architectures.push(architecture); + } + const preferred = + host === "arm64" + ? "arm64" + : host === "x64" + ? "x86_64" + : host === "ia32" + ? "x86" + : host === "arm" + ? "arm" + : undefined; + if (preferred === undefined || !architectures.includes(preferred)) + return err(`FAT binary has no host-compatible ${host} architecture`); + const flag = + preferred === "arm64" + ? "--aarch64" + : preferred === "x86_64" + ? "--x86_64" + : preferred === "arm" + ? "--arm" + : "--x86"; + return ok({ + format: "mach-o", + architecture: preferred, + availableArchitectures: architectures, + loaderArgs: ["-l", "FAT", flag, "-l", "Mach-O"], + }); +}; + +const parseElf = (bytes: Buffer): Result => { + if (bytes.length < 20) return err("truncated ELF header"); + if (bytes[4] !== 1 && bytes[4] !== 2) return err("unsupported ELF class"); + const little = bytes[5] === 1; + if (!little && bytes[5] !== 2) return err("unsupported ELF endianness"); + const machine = little ? bytes.readUInt16LE(18) : bytes.readUInt16BE(18); + const architecture = elfArchitecture(machine); + if (architecture === undefined) return err("unsupported ELF architecture"); + return ok({ + format: "elf", + architecture, + availableArchitectures: [architecture], + loaderArgs: ["-l", "ELF"], + }); +}; + +const parsePe = (bytes: Buffer): Result => { + if (bytes.length < 64) return err("truncated PE DOS header"); + const offset = bytes.readUInt32LE(0x3c); + if ( + offset > bytes.length - 6 || + bytes.toString("binary", offset, offset + 4) !== "PE\u0000\u0000" + ) + return err("invalid or truncated PE header"); + const architecture = peArchitecture(bytes.readUInt16LE(offset + 4)); + if (architecture === undefined) return err("unsupported PE architecture"); + return ok({ + format: "pe", + architecture, + availableArchitectures: [architecture], + loaderArgs: ["-l", "PE"], + }); +}; + +const machArchitecture = (cpu: number): BinaryArchitecture | undefined => { + switch (cpu) { + case 7: + return "x86"; + case 0x01000007: + return "x86_64"; + case 12: + return "arm"; + case 0x0100000c: + return "arm64"; + } + return undefined; +}; + +const elfArchitecture = (machine: number): BinaryArchitecture | undefined => { + switch (machine) { + case 3: + return "x86"; + case 62: + return "x86_64"; + case 40: + return "arm"; + case 183: + return "arm64"; + } + return undefined; +}; + +const peArchitecture = (machine: number): BinaryArchitecture | undefined => { + switch (machine) { + case 0x14c: + return "x86"; + case 0x8664: + return "x86_64"; + case 0x1c0: + return "arm"; + case 0xaa64: + return "arm64"; + } + return undefined; +}; diff --git a/src/domain/errors.ts b/src/domain/errors.ts index e65f61c09a..1ebe5dda65 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -71,3 +71,25 @@ export class HopperStartError extends HopperError { export class ConfigurationError extends HopperError { readonly _tag = "ConfigurationError"; } + +/** No binary session exists for an analysis request. */ +export class NoBinaryOpenError extends HopperError { + readonly _tag = "NoBinaryOpenError"; + constructor() { + super( + "No binary is open. Call open_binary with a readable local path first.", + ); + } +} + +/** A supplied target path could not be safely opened as a supported binary. */ +export class BinaryTargetError extends HopperError { + readonly _tag = "BinaryTargetError"; + constructor( + readonly path: string, + reason: string, + options?: ErrorOptions, + ) { + super(`Cannot open binary: ${reason}: ${path}`, options); + } +} diff --git a/src/identity.ts b/src/identity.ts new file mode 100644 index 0000000000..15881dfdad --- /dev/null +++ b/src/identity.ts @@ -0,0 +1,10 @@ +/** Provisional product identifiers kept together for release-time renaming. */ +export const PRODUCT_IDENTITY = { + displayName: "Better Binary", + packageName: "better-binary-mcp", + cliBinary: "better-binary", + mcpBinary: "better-binary-mcp", + mcpServerKey: "better-binary", + skillName: "better-binary-analysis", + configFileName: "better-binary.json", +} as const; diff --git a/src/main.ts b/src/main.ts index 9a58a8450d..c9b73111bb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { pathToFileURL } from "node:url"; import { fileURLToPath } from "node:url"; +import { realpathSync } from "node:fs"; import { serveStdio, @@ -9,6 +10,7 @@ import { } from "@modelcontextprotocol/server/stdio"; import { parseConfig } from "./config.js"; +import { BinarySession } from "./application/BinarySession.js"; import { HopperApplicationLauncher } from "./hopper/BridgeLauncher.js"; import { HopperClient } from "./hopper/HopperClient.js"; import { createServer } from "./server/createServer.js"; @@ -24,24 +26,37 @@ export const run = async (): Promise => { const bridgeScriptPath = fileURLToPath( new URL("../bridge/hopper_bridge.py", import.meta.url), ); - const hopper = new HopperClient({ - launcher: new HopperApplicationLauncher({ - launcherPath: config.value.hopperLauncherPath, - targetPath: config.value.hopperTargetPath, - targetKind: config.value.hopperTargetKind, - loaderArgs: config.value.hopperLoaderArgs, - bridgeScriptPath, - }), - }); + const session = new BinarySession( + (target) => + new HopperClient({ + launcher: new HopperApplicationLauncher({ + launcherPath: config.value.hopperLauncherPath, + targetPath: target.path, + targetKind: target.kind, + loaderArgs: + config.value.hopperLoaderArgs.length > 0 + ? config.value.hopperLoaderArgs + : target.loaderArgs, + bridgeScriptPath, + }), + }), + ); + if (config.value.hopperTargetPath !== undefined) { + const opened = await session.open(config.value.hopperTargetPath); + if (!opened.ok) { + process.stderr.write(`${opened.error._tag}: ${opened.error.message}\n`); + return 1; + } + } let handle: StdioServerHandle; try { - handle = serveStdio(() => createServer(hopper), { + handle = serveStdio(() => createServer(session, session), { onerror: () => { process.stderr.write("MCP stdio transport error\n"); }, }); } catch { - await hopper.close(); + await session.close(); process.stderr.write("Failed to start MCP stdio transport\n"); return 1; } @@ -50,7 +65,7 @@ export const run = async (): Promise => { const shutdown = (): Promise => { shutdownPromise ??= (async () => { await handle.close(); - await hopper.close(); + await session.close(); })(); return shutdownPromise; }; @@ -71,7 +86,7 @@ export const run = async (): Promise => { const entryPath = process.argv[1]; if ( entryPath !== undefined && - pathToFileURL(entryPath).href === import.meta.url + pathToFileURL(realpathSync(entryPath)).href === import.meta.url ) { run() .then((exitCode) => { diff --git a/src/server/createServer.ts b/src/server/createServer.ts index cba3e0bef0..2c08facb39 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -1,20 +1,27 @@ import { McpServer } from "@modelcontextprotocol/server"; import type { HopperToolPort } from "../application/HopperToolPort.js"; +import type { BinarySession } from "../application/BinarySession.js"; +import { PRODUCT_IDENTITY } from "../identity.js"; import { registerEnhancedTools } from "./registerEnhancedTools.js"; import { registerOfficialTools } from "./registerOfficialTools.js"; +import { registerSessionTools } from "./registerSessionTools.js"; /** Construct one MCP server instance without acquiring subprocess resources. */ -export const createServer = (hopper: HopperToolPort): McpServer => { +export const createServer = ( + hopper: HopperToolPort, + session?: BinarySession, +): McpServer => { const server = new McpServer( - { name: "betterBinaryMCP", version: "0.1.0" }, + { name: PRODUCT_IDENTITY.mcpServerKey, version: "0.1.0" }, { capabilities: { tools: {} }, instructions: - "Reverse-engineering tools for Hopper Disassembler. Start with binary_overview.", + "Reverse-engineering tools for Hopper Disassembler. Open a target with open_binary, then start with binary_overview.", }, ); registerOfficialTools(server, hopper); registerEnhancedTools(server, hopper); + if (session !== undefined) registerSessionTools(server, session); return server; }; diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts new file mode 100644 index 0000000000..b6e15d16a7 --- /dev/null +++ b/src/server/registerSessionTools.ts @@ -0,0 +1,54 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; + +import type { BinarySession } from "../application/BinarySession.js"; +import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js"; +import { toCallToolResult } from "./toolResult.js"; + +/** Register binary lifecycle management tools. */ +export const registerSessionTools = ( + server: McpServer, + session: BinarySession, +): void => { + const [openContract, closeContract, statusContract] = SESSION_TOOL_CONTRACTS; + server.registerTool( + openContract.name, + { + description: openContract.description, + inputSchema: openContract.inputSchema, + }, + async (input, context) => { + const parsed = z.object({ path: z.string().min(1) }).parse(input); + const opened = await session.open(parsed.path, { + signal: context.mcpReq.signal, + }); + return opened.ok + ? toCallToolResult({ + ok: true, + value: { + path: opened.value.path, + format: opened.value.format, + kind: opened.value.kind, + loaderArgs: [...opened.value.loaderArgs], + }, + }) + : toCallToolResult(opened); + }, + ); + server.registerTool( + closeContract.name, + { + description: closeContract.description, + inputSchema: closeContract.inputSchema, + }, + async () => toCallToolResult(await session.close()), + ); + server.registerTool( + statusContract.name, + { + description: statusContract.description, + inputSchema: statusContract.inputSchema, + }, + () => toCallToolResult({ ok: true, value: session.status() }), + ); +}; diff --git a/tests/binarySession.test.ts b/tests/binarySession.test.ts new file mode 100644 index 0000000000..dc63477c46 --- /dev/null +++ b/tests/binarySession.test.ts @@ -0,0 +1,190 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + BinarySession, + type BinaryClient, +} from "../src/application/BinarySession.js"; +import { HopperStartError } from "../src/domain/errors.js"; +import { err, ok } from "../src/domain/result.js"; + +let directory: string | undefined; +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +describe("binary session", () => { + it("requires an open binary and closes idempotently", async () => { + const session = new BinarySession(() => client()); + expect((await session.callTool("binary_overview", {})).ok).toBe(false); + expect(await session.close()).toEqual({ ok: true, value: null }); + }); + + it("rolls back a failed switch", async () => { + directory = await mkdtemp(join(tmpdir(), "bb-session-")); + const first = join(directory, "first.hop"); + const second = join(directory, "second.hop"); + await writeFile(first, "one"); + await writeFile(second, "two"); + let created = 0; + const session = new BinarySession(() => client(created++ === 1)); + expect((await session.open(first)).ok).toBe(true); + expect((await session.open(second)).ok).toBe(false); + expect(session.status()).toMatchObject({ open: true }); + expect(JSON.stringify(session.status())).toContain("first.hop"); + expect(created).toBe(3); + }); + + it("serializes concurrent opens and leaves the last target active", async () => { + const [first, second] = await targets(); + const clients: TestClient[] = []; + const session = new BinarySession(() => { + const value = new TestClient(); + clients.push(value); + return value; + }); + const one = session.open(first); + const two = session.open(second); + expect((await one).ok).toBe(true); + expect((await two).ok).toBe(true); + expect(session.status()).toMatchObject({ open: true }); + expect(JSON.stringify(session.status())).toContain("second.hop"); + expect(clients[0]?.closed).toBe(1); + }); + + it("waits for an active call before closing its client during a switch", async () => { + const [first, second] = await targets(); + const active = deferred>>(); + const clients: TestClient[] = []; + const session = new BinarySession(() => { + const value = new TestClient( + undefined, + false, + clients.length === 0 ? active.promise : undefined, + ); + clients.push(value); + return value; + }); + await session.open(first); + const call = session.callTool("long", {}); + const switching = session.open(second); + await Promise.resolve(); + expect(clients[0]?.closed).toBe(0); + active.resolve(ok(null)); + await call; + await switching; + expect(clients[0]?.closed).toBe(1); + }); + + it("cancels an open queued behind another transition without creating a client", async () => { + const [first, second] = await targets(); + const health = deferred>>(); + let created = 0; + const session = new BinarySession(() => { + created += 1; + return new TestClient(created === 1 ? health.promise : undefined); + }); + const opening = session.open(first); + const controller = new AbortController(); + const queued = session.open(second, { signal: controller.signal }); + controller.abort(); + health.resolve(ok(null)); + await opening; + const result = await queued; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError"); + expect(created).toBe(1); + }); + + it("cancels a call while it waits for a transition", async () => { + const [first] = await targets(); + const health = deferred>>(); + const session = new BinarySession(() => new TestClient(health.promise)); + const opening = session.open(first); + const controller = new AbortController(); + const call = session.callTool( + "overview", + {}, + { signal: controller.signal }, + ); + controller.abort(); + const result = await call; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError"); + health.resolve(ok(null)); + await opening; + }); + + it("closes a failed candidate and preserves the previous target", async () => { + const [first, second] = await targets(); + const clients: TestClient[] = []; + const session = new BinarySession(() => { + const value = new TestClient(undefined, clients.length === 1); + clients.push(value); + return value; + }); + await session.open(first); + await session.open(second); + expect(clients[1]?.closed).toBe(1); + expect(clients[0]?.closed).toBe(1); + expect(JSON.stringify(session.status())).toContain("first.hop"); + await session.close(); + await session.close(); + expect(clients[2]?.closed).toBe(1); + }); +}); + +const client = (fail = false): BinaryClient => ({ + callTool: () => + Promise.resolve(fail ? err(new HopperStartError()) : ok(null)), + close: () => Promise.resolve(), +}); + +class TestClient implements BinaryClient { + closed = 0; + constructor( + readonly pendingHealth?: Promise>>, + readonly failHealth = false, + readonly pendingCall?: Promise>>, + ) {} + callTool(name: string) { + if (name === "health") + return this.failHealth + ? Promise.resolve(err(new HopperStartError())) + : (this.pendingHealth ?? Promise.resolve(ok(null))); + return this.pendingCall ?? Promise.resolve(ok(null)); + } + close(): Promise { + this.closed += 1; + return Promise.resolve(); + } +} + +const targets = async (): Promise => { + directory ??= await mkdtemp(join(tmpdir(), "bb-session-")); + const first = join(directory, "first.hop"); + const second = join(directory, "second.hop"); + await writeFile(first, "one"); + await writeFile(second, "two"); + return [first, second]; +}; + +const deferred = (): { + readonly promise: Promise; + resolve(value: T): void; +} => { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve(value) { + resolvePromise?.(value); + }, + }; +}; diff --git a/tests/binaryTarget.test.ts b/tests/binaryTarget.test.ts new file mode 100644 index 0000000000..ea4d34bd71 --- /dev/null +++ b/tests/binaryTarget.test.ts @@ -0,0 +1,94 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + parseBinaryTarget, + parseExecutableHeader, +} from "../src/domain/binaryTarget.js"; + +let directory: string | undefined; +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +describe("executable header parsing", () => { + it.each([ + [elf(1, 1, 62), "elf", "x86_64"], + [elf(2, 2, 183), "elf", "arm64"], + [thinMach(0xfeedfacf, 0x0100000c), "mach-o", "arm64"], + [thinMach(0xcefaedfe, 0x01000007), "mach-o", "x86_64"], + [pe(0x8664), "pe", "x86_64"], + [pe(0xaa64), "pe", "arm64"], + ] as const)("parses metadata for %s", (bytes, format, architecture) => { + const result = parseExecutableHeader(bytes, "arm64"); + expect(result.ok && result.value).toMatchObject({ format, architecture }); + }); + + it("selects the host architecture from a FAT table", () => { + const bytes = fat([0x01000007, 0x0100000c]); + const arm = parseExecutableHeader(bytes, "arm64"); + const intel = parseExecutableHeader(bytes, "x64"); + expect(arm.ok && arm.value.loaderArgs).toContain("--aarch64"); + expect(intel.ok && intel.value.loaderArgs).toContain("--x86_64"); + }); + + it("rejects FAT files without a host-compatible slice", () => { + expect(parseExecutableHeader(fat([0x01000007]), "arm64")).toMatchObject({ + ok: false, + }); + }); + + it.each([ + Buffer.alloc(0), + Buffer.from([0x7f, 0x45, 0x4c, 0x46]), + fat([], 2), + pe(0xffff), + ])("rejects malformed, truncated, or unsupported metadata", (bytes) => { + expect(parseExecutableHeader(bytes, "arm64").ok).toBe(false); + }); +}); + +describe("binary target I/O", () => { + it("resolves relative Hopper databases and rejects unknown or unreadable paths", async () => { + directory = await mkdtemp(join(tmpdir(), "bb-target-")); + await writeFile(join(directory, "sample.hop"), "database"); + await writeFile(join(directory, "text"), "hello"); + expect((await parseBinaryTarget("sample.hop", directory)).ok).toBe(true); + expect((await parseBinaryTarget("text", directory)).ok).toBe(false); + expect((await parseBinaryTarget("missing", directory)).ok).toBe(false); + }); +}); + +const elf = (class_: number, endian: number, machine: number): Buffer => { + const bytes = Buffer.alloc(20); + bytes.set([0x7f, 0x45, 0x4c, 0x46, class_, endian]); + if (endian === 1) bytes.writeUInt16LE(machine, 18); + else bytes.writeUInt16BE(machine, 18); + return bytes; +}; +const thinMach = (magic: number, cpu: number): Buffer => { + const bytes = Buffer.alloc(8); + bytes.writeUInt32BE(magic, 0); + if (magic === 0xcefaedfe || magic === 0xcffaedfe) bytes.writeUInt32LE(cpu, 4); + else bytes.writeUInt32BE(cpu, 4); + return bytes; +}; +const fat = (cpus: readonly number[], declared = cpus.length): Buffer => { + const bytes = Buffer.alloc(8 + cpus.length * 20); + bytes.writeUInt32BE(0xcafebabe, 0); + bytes.writeUInt32BE(declared, 4); + cpus.forEach((cpu, index) => bytes.writeUInt32BE(cpu, 8 + index * 20)); + return bytes; +}; +const pe = (machine: number): Buffer => { + const bytes = Buffer.alloc(128); + bytes.write("MZ", 0, "ascii"); + bytes.writeUInt32LE(64, 0x3c); + bytes.write("PE\0\0", 64, "binary"); + bytes.writeUInt16LE(machine, 68); + return bytes; +}; diff --git a/tests/config.test.ts b/tests/config.test.ts index fb64dc75ba..1cbcbd2725 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest"; import { parseConfig } from "../src/config.js"; describe("runtime configuration", () => { - it("requires a target and defaults to Hopper's documented launcher", () => { - expect(parseConfig({}).ok).toBe(false); + it("allows target-free startup and defaults to Hopper's documented launcher", () => { + const empty = parseConfig({}); + expect(empty.ok).toBe(true); + if (empty.ok) expect(empty.value.hopperTargetPath).toBeUndefined(); const result = parseConfig({ HOPPER_TARGET_PATH: "/usr/bin/true" }); expect(result.ok).toBe(true); if (result.ok) { diff --git a/tests/contractSnapshot.test.ts b/tests/contractSnapshot.test.ts new file mode 100644 index 0000000000..59cdbe5702 --- /dev/null +++ b/tests/contractSnapshot.test.ts @@ -0,0 +1,43 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + ENHANCED_TOOL_CONTRACTS, + OFFICIAL_TOOL_CONTRACTS, + SESSION_TOOL_CONTRACTS, +} from "../src/contracts/toolContracts.js"; + +const ORIGINAL_39_SHA256 = + "43ffddd91b775a3e00fb736c6b1a644210e5917a1e1f02745d0283ac6ab99db4"; + +describe("original tool contract snapshot", () => { + it("preserves every original name, description, kind, and complete JSON schema", () => { + const projection = [ + ...OFFICIAL_TOOL_CONTRACTS, + ...ENHANCED_TOOL_CONTRACTS, + ].map((contract) => ({ + name: contract.name, + description: contract.description, + kind: contract.kind, + inputSchema: z.toJSONSchema(contract.inputSchema, { + target: "draft-07", + unrepresentable: "any", + }), + })); + expect(projection).toHaveLength(39); + expect( + createHash("sha256").update(JSON.stringify(projection)).digest("hex"), + ).toBe(ORIGINAL_39_SHA256); + }); + + it("keeps exactly three additive session contracts", () => { + expect( + SESSION_TOOL_CONTRACTS.map(({ name, kind }) => ({ name, kind })), + ).toEqual([ + { name: "open_binary", kind: "session" }, + { name: "close_binary", kind: "session" }, + { name: "binary_session", kind: "session" }, + ]); + }); +}); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 5419b94d40..33af40ad1a 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -10,7 +10,7 @@ const fixturePath = fileURLToPath( ); describe("production stdio runtime", () => { - it("starts the built entrypoint, lists 39 tools, calls one, and shuts down", async () => { + it("starts the built entrypoint, lists 42 tools, calls one, and shuts down", async () => { const transport = new StdioClientTransport({ command: process.execPath, args: [mainPath], @@ -18,7 +18,7 @@ describe("production stdio runtime", () => { env: { PATH: process.env.PATH ?? "", HOPPER_LAUNCHER_PATH: process.execPath, - HOPPER_TARGET_PATH: "/fixture/sample", + HOPPER_TARGET_PATH: process.execPath, HOPPER_LOADER_ARGS_JSON: JSON.stringify([fixturePath]), }, stderr: "pipe", @@ -32,7 +32,7 @@ describe("production stdio runtime", () => { try { await client.connect(transport); const tools = await client.listTools(); - expect(tools.tools).toHaveLength(39); + expect(tools.tools).toHaveLength(42); const result = await client.callTool({ name: "current_document", arguments: {}, diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts new file mode 100644 index 0000000000..87551ffb7d --- /dev/null +++ b/tests/sessionMcp.test.ts @@ -0,0 +1,80 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + BinarySession, + type BinaryClient, +} from "../src/application/BinarySession.js"; +import { ok } from "../src/domain/result.js"; +import { createServer } from "../src/server/createServer.js"; + +const resources: Array<{ close(): Promise }> = []; +let directory: string | undefined; +afterEach(async () => { + await Promise.all(resources.splice(0).map((resource) => resource.close())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +describe("target-free MCP lifecycle", () => { + it("reports no-target, opens, analyzes, switches, reports status, and closes", async () => { + directory = await mkdtemp(join(tmpdir(), "bb-mcp-session-")); + const first = join(directory, "first.hop"); + const second = join(directory, "second.hop"); + await writeFile(first, "one"); + await writeFile(second, "two"); + const closed: string[] = []; + const session = new BinarySession((target) => client(target.path, closed)); + const server = createServer(session, session); + const mcp = new Client({ name: "session-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(mcp, server); + await server.connect(serverTransport); + await mcp.connect(clientTransport); + + const before = await mcp.callTool({ + name: "current_document", + arguments: {}, + }); + expect(before.isError).toBe(true); + expect(text(before)).toContain("NoBinaryOpenError"); + expect((await mcp.listTools()).tools).toHaveLength(42); + expect( + (await mcp.callTool({ name: "open_binary", arguments: { path: first } })) + .isError, + ).not.toBe(true); + expect( + text(await mcp.callTool({ name: "current_document", arguments: {} })), + ).toContain("first.hop"); + await mcp.callTool({ name: "open_binary", arguments: { path: second } }); + expect(closed.some((path) => path.endsWith("first.hop"))).toBe(true); + expect( + text(await mcp.callTool({ name: "binary_session", arguments: {} })), + ).toContain("second.hop"); + await mcp.callTool({ name: "close_binary", arguments: {} }); + expect( + text(await mcp.callTool({ name: "binary_session", arguments: {} })), + ).toContain('"open": false'); + }); +}); + +const client = (path: string, closed: string[]): BinaryClient => ({ + callTool: (name) => + Promise.resolve(ok(name === "health" ? null : { path, name })), + close: () => { + closed.push(path); + return Promise.resolve(); + }, +}); + +const text = (result: CallToolResult): string => { + const content = result.content.find((item) => item.type === "text"); + if (content?.type !== "text") throw new Error("missing text result"); + return content.text; +}; diff --git a/tests/toolContracts.test.ts b/tests/toolContracts.test.ts index e211f6dda5..d7bc2448b8 100644 --- a/tests/toolContracts.test.ts +++ b/tests/toolContracts.test.ts @@ -3,15 +3,21 @@ import { describe, expect, it } from "vitest"; import { ENHANCED_TOOL_CONTRACTS, OFFICIAL_TOOL_CONTRACTS, + SESSION_TOOL_CONTRACTS, TOOL_CONTRACTS, } from "../src/contracts/toolContracts.js"; describe("tool contract inventory", () => { - it("preserves the complete 31 plus 8 tool surface", () => { + it("preserves the original 39 contracts and adds three session tools", () => { expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(31); expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(8); - expect(TOOL_CONTRACTS).toHaveLength(39); - expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(39); + expect(SESSION_TOOL_CONTRACTS.map(({ name }) => name)).toEqual([ + "open_binary", + "close_binary", + "binary_session", + ]); + expect(TOOL_CONTRACTS).toHaveLength(42); + expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(42); }); it("retains documented enhanced-tool limits at the input boundary", () => {