fix(ida): preserve readiness and workspace refusal semantics

This commit is contained in:
morluto committed 2026-10-24 02:35:59 +08:00
1 parent 171b33b2c4
commit 99ccfb6e70
7 files changed
+159 -20

No files matched your search

+9
View File
@@ -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.
+31
View File
@@ -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();
});
+32 -14
View File
@@ -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);
};
+28
View File
@@ -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);
},
);
+14 -2
View File
@@ -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,
+24 -4
View File
@@ -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<IdaWorkspace> {
// 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);
}
+21
View File
@@ -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);
});