Files
rea/tests/boundary/cli/idaStartupAttributionCli.test.ts
T
morluto 8f65d7f256 fix(ida): attribute startup failures and retain cleanup ownership
Attribute shared startup failures separately for each accepted operation. Preserve the original stage as a compact diagnostic, with typed timeout budgets, limits, output, recovery and exact cleanup dependencies.

Link cancellation wrappers to the originating live cleanup owner, including release before and after wrapping. Remove the redundant typed-error branch.

Validated 130 focused tests, static checks, docs and five conformance controls; four source controls rerun after fixture type correction. Public CLI uses a loopback supervisor; no native IDA engine verified.

Refs: #4614, #4633
2026-10-17 09:12:57 +08:00

251 lines
7.2 KiB
TypeScript

import { createServer } from "node:http";
import { expect } from "vitest";
import { z } from "zod";
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
import { elf } from "../../../src/domain/binaryTarget.fixture.js";
const tools = [
"idb_open",
"idb_list",
"idb_close",
"server_health",
"list_funcs",
"lookup_funcs",
"find_regex",
"decompile",
"disasm",
"callees",
"xrefs_to",
];
const requestSchema = z.object({
id: z.union([z.string(), z.number()]).optional(),
method: z.string(),
params: z
.object({
protocolVersion: z.string().optional(),
name: z.string().optional(),
arguments: z.record(z.string(), z.unknown()).optional(),
})
.optional(),
});
type StartupMode =
| "missing-profile"
| "connect-timeout"
| "catalog-timeout"
| "catalog-refused"
| "success";
const createStartupSupervisor = async (mode: StartupMode) => {
const calls: string[] = [];
let database: string | undefined;
let input: string | undefined;
const server = createServer(async (request, response) => {
if (request.method !== "POST") {
response.writeHead(405).end();
return;
}
let body = "";
for await (const chunk of request) body += String(chunk);
const rpc = requestSchema.parse(JSON.parse(body));
if (rpc.id === undefined) {
response.writeHead(202).end();
return;
}
const send = (result: unknown) =>
response
.writeHead(200, { "Content-Type": "application/json" })
.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result }));
if (rpc.method === "initialize") {
if (mode === "connect-timeout") return;
send({
protocolVersion: rpc.params?.protocolVersion,
capabilities: { tools: {} },
serverInfo: { name: "ida-startup-fixture", version: "1" },
});
return;
}
if (rpc.method === "tools/list") {
if (mode === "catalog-timeout") return;
if (mode === "catalog-refused") {
response.writeHead(200, { "Content-Type": "application/json" }).end(
JSON.stringify({
jsonrpc: "2.0",
id: rpc.id,
error: { code: -32603, message: "fixture catalog refused" },
}),
);
return;
}
send({
tools: tools
.filter((name) => mode !== "missing-profile" || name !== "list_funcs")
.map((name) => ({
name,
inputSchema: { type: "object", properties: {} },
})),
});
return;
}
const name = rpc.params?.name;
const args = rpc.params?.arguments;
if (name === undefined || args === undefined)
throw new Error("Unexpected RPC");
calls.push(name);
let result: unknown;
if (name === "idb_open") {
database = z.string().parse(args.preferred_session_id);
input = z.string().parse(args.input_path);
result = {
success: true,
session: { session_id: database, input_path: input },
};
} else if (name === "idb_list")
result = {
sessions:
database === undefined
? []
: [
{
session_id: database,
input_path: input,
owned: true,
backend: "worker",
is_active: true,
},
],
};
else if (name === "idb_close") {
result = {
success: true,
session_id: database,
saved: null,
owned: true,
backend: "worker",
};
database = undefined;
} else if (name === "server_health")
result = {
status: "ok",
input_path: input,
idb_path: input + ".i64",
module: "target.elf",
imagebase: "0x1000",
auto_analysis_ready: true,
};
else if (name === "lookup_funcs")
result = z
.array(z.string())
.parse(args.queries)
.map((query) => ({
query,
fn: { addr: "0x1000", name: "main", size: "0x20" },
error: null,
}));
else if (name === "decompile")
result = {
code: "int main(void) { return 42; }",
cursor: { done: true },
};
else throw new Error("Unexpected tool: " + name);
send({ content: [{ type: "text", text: JSON.stringify(result) }] });
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
return {
server,
calls,
get database() {
return database;
},
};
};
workspaceCliTest.for([
"missing-profile",
"connect-timeout",
"catalog-timeout",
"catalog-refused",
"success",
] as const)(
"attributes selected IDA startup to the public analysis: %s",
async (mode, { cli, workspace }) => {
const supervisor = await createStartupSupervisor(mode);
const { server, calls } = supervisor;
try {
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Missing server address");
const workspaceRoot = await workspace.mkdir("ida-workspaces");
const registration = await workspace.write(
"ida.json",
JSON.stringify({
url: `http://127.0.0.1:${address.port}/mcp`,
mode: "headless",
timeoutMs: 500,
workspaceRoot,
}),
);
const target = await workspace.write("target.elf", elf(2, 1, 62));
const result = await cli.run({
arguments: ["decompile", target, "main", "--provider", "ida", "--json"],
environment: {
...workspace.environment,
REA_IDA_MCP_CONFIG: registration,
REA_LOG_LEVEL: "silent",
},
timeoutMs: 15000,
});
expect(result.stderr).toBe("");
expect(supervisor.database).toBeUndefined();
if (mode === "success") {
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({
operation: "procedure_pseudo_code",
normalized_result: "int main(void) { return 42; }",
});
expect(calls).toContain("decompile");
expect(calls).toContain("idb_close");
return;
}
expect(result.exitCode).toBe(1);
expect(calls).toEqual([]);
expect(result.json).toMatchObject({
code: mode.endsWith("timeout")
? "provider_timeout"
: "execution_failure",
details: { operation: "procedure_pseudo_code" },
});
if (mode === "missing-profile")
expect(result.json).toMatchObject({
details: {
diagnostics: {
missing_tools: ["list_funcs"],
startup_operation: "initialize",
},
},
});
if (mode.endsWith("timeout"))
expect(result.json).toMatchObject({
retryable: true,
details: {
timeout_ms: 500,
captured_output: { stdout: "", stderr: "", truncated: false },
diagnostics: {
startup_operation:
mode === "connect-timeout"
? "ida_mcp_connect"
: "ida_mcp_catalog",
},
},
});
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) =>
server.close((error) =>
error === undefined ? resolve() : reject(error),
),
);
}
},
);