diff --git a/docs/ida-provider.md b/docs/ida-provider.md index e32b9054f2..5f71cb28a4 100644 --- a/docs/ida-provider.md +++ b/docs/ida-provider.md @@ -8,6 +8,8 @@ Choose one lifecycle mode in an upstream MCP JSON registration. Set `REA_IDA_MCP Each mode accepts one upstream tool profile, checked against `tools/list` before any analysis runs. The attached profile requires the legacy GUI tool names; upstream 2.0.0 renamed those tools, and REA has not established an equivalent attached-mode lifecycle and ownership contract for that catalog. It therefore fails closed with the missing tool names in the error diagnostics. +Startup failures identify the accepted REA analysis operation. Missing-profile and typed startup transport failures retain the original stage in `details.diagnostics.startup_operation`; existing diagnostics, timeout budgets and resource limits remain available. Startup remains shared within a session; each caller receives its own operation attribution. Cancellation retains live cleanup ownership, so a successful retry releases the originating cancellation outcome as well. + The headless profile is defined by the database-supervisor API, not by an upstream version string. The upstream source snapshot [`c133c385`](https://github.com/mrexodia/ida-pro-mcp/tree/c133c3853faa111a9b00ee615c013b720d0c4acd) includes `idb_close` (introduced by [upstream PR #495](https://github.com/mrexodia/ida-pro-mcp/pull/495)) alongside `idb_open` and `idb_list`. Earlier builds may report the same `2.0.0` version while lacking that tool, so REA checks the live catalog rather than treating a version number or branch name as a compatibility guarantee. | Mode | Upstream profile | Known working | diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index ead692db12..ffbd433ce2 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -12,6 +12,8 @@ import { AnalysisProtocolError, AnalysisOutputError, AnalysisInputError, + AnalysisTimeoutError, + AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -291,7 +293,7 @@ export class IdaSessionClient implements AnalysisClient { parameters, }; try { - if (!readyOnly) await this.#start(); + if (!readyOnly) await this.#start(operation); const before = await this.#observe(options); failureContext.metadataBefore = before; if (wasCancelled(options)) throw new AnalysisCancelledError(operation); @@ -385,7 +387,7 @@ export class IdaSessionClient implements AnalysisClient { context: IdaOperationFailureContext, ) { let failure: AnalysisError; - if (wasCancelled(options)) + if (wasCancelled(options)) { failure = new AnalysisCancelledError(operation, { cause, ...(cause instanceof AnalysisError @@ -404,7 +406,11 @@ export class IdaSessionClient implements AnalysisClient { } : {}), }); - else if ( + if (cause instanceof AnalysisError) + failure + .bindCleanupDependencies([cause]) + .bindCleanupResourceOwner(cause); + } else if ( cause instanceof ProviderAdapterError && cause.operation === "transport" ) @@ -419,13 +425,6 @@ export class IdaSessionClient implements AnalysisClient { "Check the configured upstream MCP registration and selected target; see docs/ida-provider.md.", }, }); - else if ( - cause instanceof AnalysisError && - (cause.partialObservation !== undefined || - cause.cleanupIncomplete || - cause.capturedOutput !== undefined) - ) - failure = cause; else if (cause instanceof AnalysisError) failure = cause; else if (cause instanceof z.ZodError) failure = new AnalysisOutputError( @@ -506,9 +505,62 @@ export class IdaSessionClient implements AnalysisClient { return err(failure); } - #start(): Promise { + async #start(operation: string): Promise { this.#startup ??= this.#initialize(); - return this.#startup; + try { + await this.#startup; + } catch (cause: unknown) { + if ( + !(cause instanceof AnalysisTimeoutError) && + !(cause instanceof AnalysisResourceConstraintError) && + !(cause instanceof ProviderAdapterError) + ) + throw cause; + // Transport adapters already receive caller attribution in #failure. + if ( + cause instanceof ProviderAdapterError && + cause.operation === "transport" + ) + throw cause; + const options = { + cause, + capturedOutput: cause.capturedOutput, + partialObservation: cause.partialObservation, + cleanup: cause.cleanup, + ...(cause.userMessage === undefined + ? {} + : { userMessage: cause.userMessage }), + ...(cause.remediationAction === undefined + ? {} + : { remediationAction: cause.remediationAction }), + diagnostics: { + ...cause.diagnostics, + startup_operation: cause.operation, + }, + }; + // Startup is shared, but each accepted operation owns its attribution. + // Keep the original cached failure unchanged for later callers. + const failure = + cause instanceof AnalysisTimeoutError + ? new AnalysisTimeoutError(operation, cause.timeoutMs, options) + : cause instanceof AnalysisResourceConstraintError + ? new AnalysisResourceConstraintError( + operation, + cause.resource, + cause.reason, + cause.reportedLimits, + { + ...options, + ...(cause.retryable === undefined + ? {} + : { retryable: cause.retryable }), + }, + ) + : new ProviderAdapterError(cause.providerId, operation, options); + throw failure + .bindCleanupDependencies([cause]) + .bindCleanupResourceOwner(cause); + } } async #initialize(): Promise { diff --git a/src/ida/IdaStartupFailures.test.ts b/src/ida/IdaStartupFailures.test.ts new file mode 100644 index 0000000000..a3fbcd01aa --- /dev/null +++ b/src/ida/IdaStartupFailures.test.ts @@ -0,0 +1,195 @@ +import { expect, it, onTestFinished } from "vitest"; +import { rm } from "node:fs/promises"; +import { + createIdaTarget, + RecordingIdaMcp, +} from "../../tests/fixtures/idaMcp.js"; +import { + AnalysisTimeoutError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; +import { IdaSessionClient } from "./IdaSessionClient.js"; + +it.each(["timeout", "resource"] as const)( + "attributes a cached startup %s to each caller without losing facts or live cleanup", + async (kind) => { + const { root, target } = await createIdaTarget(); + const sourceOptions = { + diagnostics: { stage_fact: "original" }, + capturedOutput: { + stdout: "original output", + stderr: "original warning", + truncated: true, + }, + cleanup: { reason: "release denied", resources: ["owned transport"] }, + userMessage: "original explanation", + remediationAction: "original recovery", + }; + const primary = + kind === "timeout" + ? new AnalysisTimeoutError("ida_mcp_connect", 123, sourceOptions) + : new AnalysisResourceConstraintError( + "ida_mcp_catalog", + "transport", + "original byte limit", + { max_response_bytes: 456 }, + { ...sourceOptions, retryable: false }, + ); + let permitRelease = false; + const lease = new AnalysisCleanupLease(async () => { + if (!permitRelease) throw new Error("release denied"); + }, ["owned transport"]); + lease.bindCleanupError(primary); + const producer = new RecordingIdaMcp(target, "headless"); + producer.connect = async () => { + producer.connects++; + throw primary; + }; + const client = new IdaSessionClient( + { + url: "http://127.0.0.1:12345/mcp", + headers: {}, + mode: "headless", + timeoutMs: 1000, + workspaceRoot: root, + }, + target, + producer, + ); + onTestFinished(async () => { + permitRelease = true; + await lease.release(); + await client.close(); + await rm(root, { recursive: true, force: true }); + }); + const first = await client.execute("procedure_pseudo_code", { + procedure: "main", + }); + const second = await client.execute("list_procedures", {}); + if (first.ok || second.ok) throw new Error("Expected startup failure"); + expect(producer.connects).toBe(1); + expect(producer.calls).toEqual([]); + expect(primary.operation).toBe( + kind === "timeout" ? "ida_mcp_connect" : "ida_mcp_catalog", + ); + for (const [error, operation] of [ + [first.error, "procedure_pseudo_code"], + [second.error, "list_procedures"], + ] as const) { + expect(error.cause).toBe(primary); + expect(projectAnalysisError(error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + operation, + resources: ["owned transport"], + diagnostics: { + stage_fact: "original", + startup_operation: primary.operation, + }, + }, + }); + } + await expect(lease.release()).rejects.toThrow("release denied"); + expect(projectAnalysisError(first.error).code).toBe("cleanup_incomplete"); + permitRelease = true; + await lease.release(); + for (const [error, operation] of [ + [first.error, "procedure_pseudo_code"], + [second.error, "list_procedures"], + ] as const) { + expect(projectAnalysisError(error)).toMatchObject({ + code: kind === "timeout" ? "provider_timeout" : "resource_constraint", + message: projectAnalysisError(primary).message, + remediation: { action: "original recovery" }, + details: { operation, captured_output: sourceOptions.capturedOutput }, + }); + expect(projectAnalysisError(error).details).not.toHaveProperty( + "resources", + ); + } + expect(projectAnalysisError(first.error)).toMatchObject( + kind === "timeout" + ? { retryable: true, details: { timeout_ms: 123 } } + : { + retryable: false, + details: { + resource: "transport", + reported_limits: { max_response_bytes: 456 }, + }, + }, + ); + }, +); + +it.each(["pending", "released"] as const)( + "keeps cancellation linked to the exact startup cleanup owner when %s", + async (state) => { + const { root, target } = await createIdaTarget(); + const controller = new AbortController(); + const primary = new AnalysisTimeoutError("ida_mcp_connect", 123, { + cleanup: { reason: "release denied", resources: ["owned transport"] }, + capturedOutput: { + stdout: "", + stderr: "original warning", + truncated: false, + }, + }); + let permitRelease = state === "released"; + const lease = new AnalysisCleanupLease(async () => { + if (!permitRelease) throw new Error("release denied"); + }, ["owned transport"]); + lease.bindCleanupError(primary); + const producer = new RecordingIdaMcp(target, "headless"); + producer.connect = async () => { + if (state === "released") await lease.release(); + controller.abort("caller reason"); + throw primary; + }; + const client = new IdaSessionClient( + { + url: "http://127.0.0.1:12345/mcp", + headers: {}, + mode: "headless", + timeoutMs: 1000, + workspaceRoot: root, + }, + target, + producer, + ); + onTestFinished(async () => { + permitRelease = true; + await lease.release(); + await client.close(); + await rm(root, { recursive: true, force: true }); + }); + const result = await client.execute( + "procedure_pseudo_code", + { procedure: "main" }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected cancellation"); + if (state === "pending") { + expect(projectAnalysisError(result.error).code).toBe( + "cleanup_incomplete", + ); + await expect(lease.release()).rejects.toThrow("release denied"); + expect(projectAnalysisError(result.error).code).toBe( + "cleanup_incomplete", + ); + permitRelease = true; + await lease.release(); + } + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { + operation: "procedure_pseudo_code", + captured_output: { stderr: "original warning" }, + }, + }); + expect(projectAnalysisError(result.error).details).not.toHaveProperty( + "resources", + ); + }, +); diff --git a/tests/boundary/cli/idaRetainedCleanupCli.test.ts b/tests/boundary/cli/idaRetainedCleanupCli.test.ts index cca51531ca..e09e277198 100644 --- a/tests/boundary/cli/idaRetainedCleanupCli.test.ts +++ b/tests/boundary/cli/idaRetainedCleanupCli.test.ts @@ -67,7 +67,10 @@ describe("headless IDA timeout through the one-shot CLI", () => { details: { cleanup: "incomplete", execution_failure: "provider_timeout", - operation: "idb_open", + operation: "procedure_pseudo_code", + diagnostics: { + startup_operation: "idb_open", + }, timeout_ms: 500, resources: [expect.stringContaining(workspaceRoot)], cleanup_error: { diff --git a/tests/boundary/cli/idaStartupAttributionCli.test.ts b/tests/boundary/cli/idaStartupAttributionCli.test.ts new file mode 100644 index 0000000000..d5e82e4345 --- /dev/null +++ b/tests/boundary/cli/idaStartupAttributionCli.test.ts @@ -0,0 +1,250 @@ +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((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((resolve, reject) => + server.close((error) => + error === undefined ? resolve() : reject(error), + ), + ); + } + }, +);