From 99ccfb6e709ac4bc0afe176315280abde9ebcb9f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:30:22 +0800 Subject: [PATCH] fix(ida): preserve readiness and workspace refusal semantics --- docs/ida-provider.md | 9 ++++++ src/ida/IdaMcpConnection.test.ts | 31 ++++++++++++++++++ src/ida/IdaMcpConnection.ts | 46 +++++++++++++++++++-------- src/ida/IdaSessionClient.test.ts | 28 ++++++++++++++++ src/ida/IdaSessionClient.ts | 16 ++++++++-- src/ida/IdaWorkspace.ts | 28 +++++++++++++--- src/ida/IdaWorkspaceOwnership.test.ts | 21 ++++++++++++ 7 files changed, 159 insertions(+), 20 deletions(-) diff --git a/docs/ida-provider.md b/docs/ida-provider.md index a73d87cf06..800fa0d2a4 100644 --- a/docs/ida-provider.md +++ b/docs/ida-provider.md @@ -169,3 +169,12 @@ The verifier requires the resolved procedure in both inventory and procedure sea The initial real workflows use stdio registrations for the legacy Windows GUI profile and a Windows x64 headless supervisor with IDA 9.3. The headless workflow has also passed on macOS arm64 with IDA Professional 9.1 (build 9.1.250226) against the same upstream commit `c133c3853faa111a9b00ee615c013b720d0c4acd`, using a source-built arm64 Mach-O fixture; Mach-O C symbols carry a leading underscore, so the procedure name is `_rea_fixture_add`. Streamable HTTP is tested against a loopback MCP fixture with the pinned client SDK; a real IDA HTTP-supervisor workflow remains unverified. Linux headless engine operation, other IDA releases, architectures, and modern attached GUI tools require their corresponding real workflows before support is claimed. Native Windows verification uses IDA Pro 9.3 with the public two-function fixture and Windows `whoami.exe`, using upstream `c9d15d98e8a143fc74f6e14037c48f63889b6d6c` with a loopback-listener change. It covers production CLI/MCP parity, distinct-function switching, identity and connection cleanup. See the [correctness and performance measurements](https://github.com/morluto/rea/blob/main/experiments/analysis-efficiency/ida-native-windows.md). This follow-up did not run Windows IDA Free or native macOS. + +Headless readiness distinguishes an explicit `auto_analysis_ready: false` from +unknown readiness (`null`). The former returns a retryable capability refusal +with wait-and-retry guidance; the latter asks for upstream readiness inspection +without claiming that analysis is running. Neither is malformed protocol output. +An unusable `workspaceRoot` reports its directory constraint as a configuration +error before opening an IDA worker. Native JSON metadata resources accept omitted +media types and normal case/parameter variants of `application/json`; their +resource URI, JSON structure and recorded target identity remain checked. diff --git a/src/ida/IdaMcpConnection.test.ts b/src/ida/IdaMcpConnection.test.ts index 24ad83691d..3da52e97e6 100644 --- a/src/ida/IdaMcpConnection.test.ts +++ b/src/ida/IdaMcpConnection.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createIdaMcpConnection, decodeIdaToolResult, + decodeIdaJsonResource, } from "./IdaMcpConnection.js"; import { createIdaDiagnosticRedactor } from "./IdaDiagnosticRedactor.js"; import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js"; @@ -510,3 +511,33 @@ it("owns unclassified truncated-output hints separately from their context", () ); expect(inspectRetainedError(failure)).not.toContain(String.raw`q\"uote`); }); + +it.each([ + undefined, + "application/json", + "Application/JSON", + "application/json; charset=utf-8", +])("accepts SDK JSON metadata resources with media type %s", (mimeType) => { + expect( + decodeIdaJsonResource("ida://idb/metadata", { + contents: [ + { + uri: "ida://idb/metadata", + ...(mimeType === undefined ? {} : { mimeType }), + text: '{"sha256":"recorded"}', + }, + ], + }), + ).toEqual({ sha256: "recorded" }); +}); +it("keeps JSON resource identity, media type and JSON parsing strict", () => { + const resource = (uri: string, mimeType: string, text: string) => ({ + contents: [{ uri, mimeType, text }], + }); + for (const reply of [ + resource("wrong://resource", "application/json", "{}"), + resource("ida://idb/metadata", "text/plain", "{}"), + resource("ida://idb/metadata", "application/json", "not JSON"), + ]) + expect(() => decodeIdaJsonResource("ida://idb/metadata", reply)).toThrow(); +}); diff --git a/src/ida/IdaMcpConnection.ts b/src/ida/IdaMcpConnection.ts index 57f7bc32be..82cb0f724d 100644 --- a/src/ida/IdaMcpConnection.ts +++ b/src/ida/IdaMcpConnection.ts @@ -3,6 +3,7 @@ import { IDA_DIAGNOSTIC_SOURCE_BYTES, } from "./IdaDiagnosticRedactor.js"; import { IdaDiagnosticResourceError } from "./IdaDiagnosticWriter.js"; +import { MIMEType } from "node:util"; import { ChildProcess } from "node:child_process"; import { Socket } from "node:net"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; @@ -489,20 +490,7 @@ export const createIdaMcpConnection = ( .catch((cause: unknown) => transportFailure(cause, "ida_mcp_read_resource"), ); - const resource = z - .object({ - contents: z - .array( - z.object({ - uri: z.literal(uri), - mimeType: z.literal("application/json"), - text: z.string(), - }), - ) - .length(1), - }) - .parse(response); - return parseIdaJsonText(resource.contents[0]?.text ?? ""); + return decodeIdaJsonResource(uri, response); }, serverInfo: () => jsonValueSchema.parse(client.getServerVersion() ?? null), close: () => client.close().catch(transportFailure), @@ -517,3 +505,33 @@ export const createIdaMcpConnection = ( }, }; }; + +/** Resource metadata follows the SDK's optional media type; content/identity stay strict. */ +export const decodeIdaJsonResource = ( + uri: string, + response: unknown, +): JsonValue => { + const resource = z + .object({ + contents: z + .array( + z.object({ + uri: z.literal(uri), + mimeType: z + .string() + .refine((value) => { + try { + return new MIMEType(value).essence === "application/json"; + } catch { + return false; + } + }, "Expected a JSON resource media type") + .optional(), + text: z.string(), + }), + ) + .length(1), + }) + .parse(response); + return parseIdaJsonText(resource.contents[0]!.text); +}; diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index 92f5f01c38..27ea49d4d4 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -1921,3 +1921,31 @@ it("retains transport diagnostics on a cancellation regardless of stderr", async }); await client.close(); }); + +it.each([false, null] as const)( + "keeps valid headless readiness %s distinct from malformed output", + async (ready) => { + const { producer, client } = await fixture("headless"); + producer.overrideCall = (name) => + name === "server_health" + ? { + status: "ok", + input_path: producer.inputPath, + idb_path: `${producer.inputPath}.i64`, + module: "target.elf", + imagebase: "0x1000", + auto_analysis_ready: ready, + } + : undefined; + const result = await client.execute("health", {}); + if (result.ok) throw new Error("Expected an unready worker refusal"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "capability_unavailable", + retryable: ready === false, + details: { diagnostics: { auto_analysis_ready: ready } }, + }); + producer.overrideCall = undefined; + expect((await client.execute("health", {})).ok).toBe(true); + await expectCleanClose(producer, client); + }, +); diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index 06a418e763..40c8adf934 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -736,8 +736,20 @@ export class IdaSessionClient implements AnalysisClient { "IDA headless input identity changed after target admission.", ); if (metadata.auto_analysis_ready !== true) - throw new AnalysisProtocolError( - "IDA auto-analysis is incomplete or readiness is unknown; retry after analysis completes.", + throw new AnalysisCapabilityUnavailableError( + "ida", + "server_health", + metadata.auto_analysis_ready === false + ? "IDA auto-analysis is incomplete." + : "IDA auto-analysis readiness is unknown.", + { + retryable: metadata.auto_analysis_ready === false, + remediationAction: + metadata.auto_analysis_ready === false + ? "Wait for IDA auto-analysis to complete, then retry the request." + : "Inspect the upstream IDA worker's analysis readiness before retrying; unknown readiness does not establish active analysis.", + diagnostics: { auto_analysis_ready: metadata.auto_analysis_ready }, + }, ); identity = JSON.stringify([ this.#database, diff --git a/src/ida/IdaWorkspace.ts b/src/ida/IdaWorkspace.ts index 56d37519cd..5251b3de35 100644 --- a/src/ida/IdaWorkspace.ts +++ b/src/ida/IdaWorkspace.ts @@ -5,6 +5,8 @@ import { tmpdir } from "node:os"; import { basename, join, normalize } from "node:path"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { AnalysisProtocolError } from "../domain/analysisErrorCore.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; /** Read a file digest with bounded memory rather than retaining binary bytes. */ @@ -51,10 +53,28 @@ export class IdaWorkspace { ): Promise { // Upstream reports canonical paths (macOS /var -> /private/var), so the // private input path must already be canonical for identity checks. - const runtime = await PrivateRuntimeRoot.create({ - parent: await resolveIdaWorkspaceParent(root), - prefix: "rea-ida-", - }); + let runtime: PrivateRuntimeRoot; + try { + runtime = await PrivateRuntimeRoot.create({ + parent: await resolveIdaWorkspaceParent(root), + prefix: "rea-ida-", + }); + } catch (cause: unknown) { + if (cause instanceof AnalysisError) throw cause; + throw new ConfigurationError( + "IDA workspaceRoot is not a usable private workspace parent.", + { + cause, + settings: [ + { + setting: "workspaceRoot", + constraint: `Select an existing writable and searchable directory: ${cause instanceof Error ? cause.message : String(cause)}`, + }, + ], + diagnostics: { workspace_root: root }, + }, + ); + } const inputPath = join(runtime.path, basename(target.path)); return new IdaWorkspace(runtime, inputPath, target); } diff --git a/src/ida/IdaWorkspaceOwnership.test.ts b/src/ida/IdaWorkspaceOwnership.test.ts index bd2106c451..c406dee7b3 100644 --- a/src/ida/IdaWorkspaceOwnership.test.ts +++ b/src/ida/IdaWorkspaceOwnership.test.ts @@ -136,3 +136,24 @@ describe("IDA workspace ownership before input preparation", () => { }, ); }); + +it("attributes an unusable workspace parent to workspaceRoot without opening a worker", async () => { + const { root, target } = await createIdaTarget(); + roots.push(root); + const { client, producer } = clientFor(join(root, "missing-parent"), target); + const result = await client.execute("health", {}); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "ConfigurationError", + settings: [ + { + setting: "workspaceRoot", + constraint: expect.stringContaining("ENOENT"), + }, + ], + }, + }); + expect(producer.calls.some(({ name }) => name === "idb_open")).toBe(false); + expect((await client.close()).ok).toBe(true); +});