mirror of
https://github.com/morluto/rea
synced 2026-10-27 22:45:19 -04:00
c75cf827e7
Replace ambiguous absent observations with an owning Result producer for Java prerequisite probes. Preserve missing-command, runtime-failure and unresolved-property outcomes, process error codes, both bounded streams, raw byte counts and partial properties through provider and doctor adapters. Reject malformed numeric-prefix versions while retaining their source facts. Migrate all injected hosts to the named probe contract and remove the old parser/observation ownership from installation discovery. Validation: 17 focused files, 350 passed; npm run check passed. Public doctor fixtures distinguish absent/nonzero/malformed commands. Real installed JDK and Ghidra readiness facts matched across 20 baseline/candidate CLI tasks. Warm repeat raw replies6392tokens each, elapsed .448/.444s, CPU .662/.660s; sampled family memory varied and reversed direction on repeat. No causal performance improvement claim, true-cold or full-agent/fault-path measurement. Synchronous probe deadline/cancellation/family cleanup remains #4561 and needs coordinated discovery/admission supervision; no claim that it is fixed here. No real failing JVM or Windows runtime verification performed. Refs #4560, #4561
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
import { ok as javaProbeOk } from "../../../src/domain/result.js";
|
|
import type { GhidraJavaObservation } from "../../../src/ghidra/GhidraJavaProbe.js";
|
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
|
|
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
|
|
import { LazyAnalysisProvider } from "../../../src/application/binary/LazyAnalysisProvider.js";
|
|
import { parseConfig } from "../../../src/config/parseConfig.js";
|
|
import { elf } from "../../../src/domain/binaryTarget.fixture.js";
|
|
import { ok } from "../../../src/domain/result.js";
|
|
import { GhidraClient } from "../../../src/ghidra/GhidraClient.js";
|
|
import type { GhidraInstallationHost } from "../../../src/ghidra/GhidraInstallation.js";
|
|
import type {
|
|
GhidraLaunchSession,
|
|
GhidraLauncher,
|
|
} from "../../../src/ghidra/GhidraLauncher.js";
|
|
import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js";
|
|
import { silentLogger } from "../../../src/logger.js";
|
|
import { createServer } from "../../../src/server/createServer.js";
|
|
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { waitForExit } from "../../support/process/processFixture.js";
|
|
|
|
const fixturePath = fileURLToPath(
|
|
new URL("../../fixtures/fakeGhidra.mjs", import.meta.url),
|
|
);
|
|
|
|
type FixtureLaunch = {
|
|
child: ChildProcess;
|
|
runtimeRoot: string;
|
|
endpointPath: string;
|
|
};
|
|
|
|
class FixtureLauncher implements GhidraLauncher {
|
|
readonly launches: FixtureLaunch[] = [];
|
|
readonly requests: string[] = [];
|
|
cleanupAllowed = true;
|
|
cleanupAttempts = 0;
|
|
|
|
async launch(session: GhidraLaunchSession) {
|
|
const projectRoot = join(session.runtimeRoot, "project");
|
|
await mkdir(projectRoot, { recursive: true });
|
|
const child = spawn(process.execPath, [fixturePath], {
|
|
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
env: {
|
|
...process.env,
|
|
REA_GHIDRA_FIXTURE_CONFIG: JSON.stringify({
|
|
endpointPath: session.endpointPath,
|
|
token: session.token,
|
|
runId: session.runId,
|
|
providerVersion: session.providerVersion,
|
|
profileDigest: session.profileDigest,
|
|
targetSha256: session.targetSha256,
|
|
transport: session.transport,
|
|
}),
|
|
},
|
|
});
|
|
this.launches.push({
|
|
child,
|
|
runtimeRoot: session.runtimeRoot,
|
|
endpointPath: session.endpointPath,
|
|
});
|
|
child.on("message", (value: unknown) => {
|
|
if (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
"method" in value &&
|
|
typeof value.method === "string"
|
|
)
|
|
this.requests.push(value.method);
|
|
});
|
|
return ok({
|
|
process: child,
|
|
ownsProcessLifetime: true as const,
|
|
projectRoot,
|
|
ghidraLogPath: join(session.runtimeRoot, "ghidra.log"),
|
|
scriptLogPath: join(session.runtimeRoot, "script.log"),
|
|
cleanup: async () => {
|
|
this.cleanupAttempts += 1;
|
|
if (!this.cleanupAllowed)
|
|
return {
|
|
cleaned: false as const,
|
|
reason: "fixture cleanup is not yet confirmed",
|
|
};
|
|
const signaled = child.exitCode === null && child.signalCode === null;
|
|
if (signaled) child.kill("SIGTERM");
|
|
return (await waitForExit(child, 2_000))
|
|
? { cleaned: true as const, signaled }
|
|
: { cleaned: false as const, reason: "fixture child did not exit" };
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
const installationHost: GhidraInstallationHost = {
|
|
platform: "linux",
|
|
architecture: "x64",
|
|
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
|
|
executable: () => true,
|
|
probeJava: () =>
|
|
javaProbeOk<GhidraJavaObservation>({
|
|
osArch: "amd64",
|
|
osName: "Linux",
|
|
version: "21.0.11",
|
|
major: 21,
|
|
home: "/fixture/jdk-21",
|
|
bits: 64,
|
|
runtime: "jdk",
|
|
}),
|
|
};
|
|
|
|
// Only installation discovery and the Java bridge are simulated. Requests use
|
|
// the production client, real child/socket and public MCP session routing.
|
|
const connectGhidra = async () => {
|
|
const directory = await createTestTempDirectory("rea-ghidra-reopen-");
|
|
const target = join(directory, "target.elf");
|
|
const source = elf(2, 1, 62);
|
|
await writeFile(target, source);
|
|
const config = parseConfig({
|
|
GHIDRA_INSTALL_DIR: "/fixture/ghidra_12.1.4_PUBLIC",
|
|
});
|
|
if (!config.ok) throw config.error;
|
|
const launcher = new FixtureLauncher();
|
|
const clients: GhidraClient[] = [];
|
|
const provider = new GhidraProvider(
|
|
config.value,
|
|
silentLogger,
|
|
{},
|
|
installationHost,
|
|
(options) => {
|
|
const client = new GhidraClient({
|
|
...options,
|
|
launcher,
|
|
platform: process.platform,
|
|
startupTimeoutMs: 5_000,
|
|
});
|
|
clients.push(client);
|
|
return client;
|
|
},
|
|
);
|
|
const lazy = new LazyAnalysisProvider({
|
|
identity: provider.identity(),
|
|
capabilities: provider.capabilities(),
|
|
load: () => Promise.resolve(provider),
|
|
});
|
|
const session = composeBinarySession(
|
|
new AnalysisProviderRegistry([
|
|
{
|
|
identity: () => provider.identity(),
|
|
capabilities: () => provider.capabilities(),
|
|
inspectAvailability: () => provider.inspectAvailability(),
|
|
inspectTargetSupport: (value) => provider.inspectTargetSupport(value),
|
|
resolveAnalysisProfile: (value, options) =>
|
|
provider.resolveAnalysisProfile(value, options),
|
|
createClient: (value, profile, context) =>
|
|
lazy.createClient(value, profile, context),
|
|
},
|
|
]),
|
|
);
|
|
const server = createServer(
|
|
{ kind: "session", session },
|
|
{ logger: silentLogger },
|
|
);
|
|
const mcp = new Client({ name: "ghidra-reopen", version: "1.0.0" });
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
const close = async () => {
|
|
launcher.cleanupAllowed = true;
|
|
await Promise.allSettled([mcp.close(), server.close()]);
|
|
const closed = await session.close();
|
|
try {
|
|
for (const client of clients)
|
|
expect((await client.close()).ok).toBe(true);
|
|
expect(closed.ok).toBe(true);
|
|
} finally {
|
|
for (const { child } of launcher.launches) {
|
|
if (child.exitCode === null && child.signalCode === null)
|
|
child.kill("SIGTERM");
|
|
expect(await waitForExit(child, 2_000)).toBe(true);
|
|
}
|
|
}
|
|
for (const launched of launcher.launches) await expectReleased(launched);
|
|
expect(await readFile(target)).toEqual(source);
|
|
};
|
|
try {
|
|
await server.connect(serverTransport);
|
|
await mcp.connect(clientTransport);
|
|
} catch (cause) {
|
|
await close();
|
|
throw cause;
|
|
}
|
|
return {
|
|
clients,
|
|
launcher,
|
|
close,
|
|
open: () =>
|
|
mcp.callTool({ name: "open_binary", arguments: { path: target } }),
|
|
query: (addressSuffix: number) =>
|
|
mcp.callTool({
|
|
name: "list_strings",
|
|
arguments: { address: `0x200${addressSuffix}` },
|
|
}),
|
|
};
|
|
};
|
|
|
|
const firstLaunch = (launcher: FixtureLauncher): FixtureLaunch => {
|
|
const first = launcher.launches[0];
|
|
if (first === undefined) throw new Error("Expected a running Ghidra fixture");
|
|
return first;
|
|
};
|
|
|
|
const expectReleased = async ({
|
|
child,
|
|
runtimeRoot,
|
|
endpointPath,
|
|
}: FixtureLaunch) => {
|
|
expect(await waitForExit(child, 2_000)).toBe(true);
|
|
await expect(access(runtimeRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(access(endpointPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
};
|
|
|
|
const disconnect = async (
|
|
child: ChildProcess,
|
|
client: GhidraClient | undefined,
|
|
) => {
|
|
if (client === undefined)
|
|
throw new Error("Expected a connected Ghidra client");
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
child.off("message", received);
|
|
reject(new Error("Fixture did not acknowledge socket disconnection"));
|
|
}, 2_000);
|
|
const received = (message: unknown) => {
|
|
if (
|
|
typeof message !== "object" ||
|
|
message === null ||
|
|
!("event" in message) ||
|
|
message.event !== "disconnected"
|
|
)
|
|
return;
|
|
clearTimeout(timer);
|
|
child.off("message", received);
|
|
resolve();
|
|
};
|
|
child.on("message", received);
|
|
child.send("disconnect");
|
|
});
|
|
const disconnectedPing = await client.ping({ timeoutMs: 200 });
|
|
expect(disconnectedPing.ok).toBe(false);
|
|
expect(child.exitCode).toBeNull();
|
|
expect(child.signalCode).toBeNull();
|
|
};
|
|
|
|
describe.skipIf(process.platform === "win32")(
|
|
"Ghidra MCP session recovery over Unix sockets",
|
|
() => {
|
|
it("keeps open lazy, reuses a live child, and replaces an exited child on reopen", async () => {
|
|
const harness = await connectGhidra();
|
|
try {
|
|
expect((await harness.open()).isError).not.toBe(true);
|
|
expect((await harness.open()).isError).not.toBe(true);
|
|
expect(harness.clients).toHaveLength(0);
|
|
expect(harness.launcher.launches).toHaveLength(0);
|
|
expect((await harness.query(1)).isError).not.toBe(true);
|
|
const old = firstLaunch(harness.launcher);
|
|
expect((await harness.open()).isError).not.toBe(true);
|
|
expect((await harness.query(2)).isError).not.toBe(true);
|
|
expect(harness.launcher.launches).toHaveLength(1);
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
harness.launcher.requests.filter(
|
|
(method) => method === "list_strings",
|
|
).length,
|
|
)
|
|
.toBe(2);
|
|
expect(old.child.kill("SIGTERM")).toBe(true);
|
|
expect(await waitForExit(old.child, 2_000)).toBe(true);
|
|
expect(old.child.signalCode).toBe("SIGTERM");
|
|
expect((await harness.open()).isError).not.toBe(true);
|
|
await expectReleased(old);
|
|
expect(harness.clients).toHaveLength(1);
|
|
expect(harness.launcher.launches).toHaveLength(1);
|
|
expect((await harness.query(3)).isError).not.toBe(true);
|
|
expect(harness.clients).toHaveLength(2);
|
|
expect(harness.launcher.launches).toHaveLength(2);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it("retires a disconnected live child before lazily starting a replacement", async () => {
|
|
const harness = await connectGhidra();
|
|
try {
|
|
expect((await harness.open()).isError).not.toBe(true);
|
|
expect((await harness.query(1)).isError).not.toBe(true);
|
|
const old = firstLaunch(harness.launcher);
|
|
await disconnect(old.child, harness.clients[0]);
|
|
expect((await harness.open()).isError).not.toBe(true);
|
|
await expectReleased(old);
|
|
expect(harness.clients).toHaveLength(1);
|
|
expect(harness.launcher.launches).toHaveLength(1);
|
|
expect((await harness.query(2)).isError).not.toBe(true);
|
|
expect(harness.clients).toHaveLength(2);
|
|
expect(harness.launcher.launches).toHaveLength(2);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it("retains failed cleanup and refuses replacement until cleanup can be retried", async () => {
|
|
const harness = await connectGhidra();
|
|
try {
|
|
expect((await harness.open()).isError).not.toBe(true);
|
|
expect((await harness.query(1)).isError).not.toBe(true);
|
|
const old = firstLaunch(harness.launcher);
|
|
await disconnect(old.child, harness.clients[0]);
|
|
harness.launcher.cleanupAllowed = false;
|
|
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
expect(parseMcpToolError(await harness.open())).toMatchObject({
|
|
error: { code: "cleanup_incomplete" },
|
|
});
|
|
expect(harness.launcher.cleanupAttempts).toBe(attempt);
|
|
expect(harness.clients).toHaveLength(1);
|
|
expect(harness.launcher.launches).toHaveLength(1);
|
|
expect(old.child.exitCode).toBeNull();
|
|
expect(old.child.signalCode).toBeNull();
|
|
await access(old.runtimeRoot);
|
|
}
|
|
harness.launcher.cleanupAllowed = true;
|
|
expect((await harness.open()).isError).not.toBe(true);
|
|
await expectReleased(old);
|
|
expect(harness.launcher.cleanupAttempts).toBe(3);
|
|
expect(harness.clients).toHaveLength(1);
|
|
expect(harness.launcher.launches).toHaveLength(1);
|
|
expect((await harness.query(2)).isError).not.toBe(true);
|
|
expect(harness.clients).toHaveLength(2);
|
|
expect(harness.launcher.launches).toHaveLength(2);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
},
|
|
);
|