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