mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(ida): preserve readiness and workspace refusal semantics
This commit is contained in:
1 parent
171b33b2c4
commit
99ccfb6e70
7 files changed
+159
-20
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user