fix(ida): preserve SDK request timeout identity and captured output

This commit is contained in:
morluto committed 2026-10-15 05:14:57 +08:00
1 parent fbdd040324
commit c0cfccb45f
4 files changed
+114 -4

No files matched your search

+1 -1
View File
@@ -131,7 +131,7 @@ Use a package version that actually includes this adapter; repository main can b
{"name":"close_binary","arguments":{}}
```
Opening an MCP target starts its selected provider session; headless opening waits for auto-analysis. Set the agent's MCP request deadline long enough for the target. The registration's optional `timeoutMs` defaults to 300000 for upstream requests. A freshly opened headless worker can briefly report `status: "busy"` from `server_health`; REA re-reads the health every 100 ms until the worker answers, the caller cancels, or `timeoutMs` has elapsed since the first read. Only expiry while the worker is still busy produces a retryable `provider_timeout`; no further read is started once the deadline has passed. That error names the busy tool of the last observation, or `unknown` when the worker reported none or a value that is not tool-name-shaped. Cancellation during the wait produces `cancelled`. Any other unexpected health payload, including a busy payload with an invalid shape, remains `unreadable_output`. Cancellation drains the current upstream request before cleanup; it does not force-stop GUI analysis. An open request that times out without a completed response retains the private workspace because worker inventory alone cannot prove the request has stopped. `binary_session` reports actual tool availability. Repeated doctor/setup calls are unnecessary when the registration already works.
Opening an MCP target starts its selected provider session; headless opening waits for auto-analysis. Set the agent's MCP request deadline long enough for the target. The registration's optional `timeoutMs` defaults to 300000 for upstream requests. An upstream SDK request that reaches this deadline returns `provider_timeout` with its operation and configured budget. A freshly opened headless worker can briefly report `status: "busy"` from `server_health`; REA re-reads the health every 100 ms until the worker answers, the caller cancels, or `timeoutMs` has elapsed since the first read. Expiry of this polling deadline while the worker is still busy also produces a retryable `provider_timeout`; no further read is started once the deadline has passed. That error names the busy tool of the last observation, or `unknown` when the worker reported none or a value that is not tool-name-shaped. Cancellation during the wait produces `cancelled`. Any other unexpected health payload, including a busy payload with an invalid shape, remains `unreadable_output`. Cancellation drains the current upstream request before cleanup; it does not force-stop GUI analysis. An open request that times out without a completed response retains the private workspace because worker inventory alone cannot prove the request has stopped. `binary_session` reports actual tool availability. Repeated doctor/setup calls are unnecessary when the registration already works.
For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client <client>`. Setup includes that file reference in its reviewed plan and preserves it after approval; it does not copy upstream credentials into the agent configuration. `rea doctor --provider ida --json` validates the registration without starting IDA and explicitly leaves runtime compatibility and target binding for analysis.
+81
View File
@@ -6,6 +6,7 @@ import {
decodeIdaToolResult,
} from "./IdaMcpConnection.js";
import { createIdaDiagnosticRedactor } from "./IdaDiagnosticRedactor.js";
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
describe("upstream IDA tool result decoding", () => {
it("leaves successful legacy, structured, and native evidence unchanged", () => {
@@ -143,6 +144,86 @@ const failingHttpConnection = (
);
};
it("preserves the SDK request timeout as a typed IDA timeout", async () => {
const timeoutMs = 40;
const pendingCall = vi.fn();
vi.stubGlobal(
"fetch",
vi.fn(async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const message = JSON.parse(String(init?.body ?? "")) as {
readonly id?: string | number;
readonly method: string;
readonly params?: Record<string, unknown>;
};
const json = (value: unknown) =>
new Response(JSON.stringify(value), {
headers: { "Content-Type": "application/json" },
});
if (message.method === "initialize")
return json({
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: message.params?.protocolVersion,
capabilities: { tools: {} },
serverInfo: { name: "ida-timeout-fixture", version: "1" },
},
});
if (
message.method === "notifications/initialized" ||
message.method === "notifications/cancelled"
)
return new Response(null, { status: 202 });
if (message.method === "tools/list")
return json({
jsonrpc: "2.0",
id: message.id,
result: { tools: [] },
});
if (message.method !== "tools/call")
throw new Error(`Unexpected fixture request: ${message.method}`);
pendingCall();
return new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (signal?.aborted === true) {
reject(signal.reason);
return;
}
signal?.addEventListener(
"abort",
() =>
reject(new DOMException("The request was aborted", "AbortError")),
{ once: true },
);
});
}),
);
const connection = createIdaMcpConnection(
{
url: "http://127.0.0.1:12345/mcp",
headers: { Authorization: "Bearer fixture-secret" },
mode: "attached",
timeoutMs,
},
{},
);
try {
await expect(connection.connect()).resolves.toEqual([]);
const failure = await connection
.call("decompile", {})
.catch((cause: unknown) => cause);
expect(pendingCall).toHaveBeenCalledOnce();
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
expect(failure).toMatchObject({ operation: "decompile", timeoutMs });
if (!(failure instanceof AnalysisTimeoutError))
throw new Error("Expected a typed IDA request timeout");
expect(failure.cause).toBeInstanceOf(Error);
expect(inspectRetainedError(failure)).not.toContain("fixture-secret");
} finally {
await connection.close();
}
});
const inspectRetainedError = (error: unknown): string =>
inspect(error, { depth: null, maxStringLength: null, breakLength: Infinity });
+12 -3
View File
@@ -9,6 +9,7 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import {
Client,
ProtocolError,
SdkError,
SdkErrorCode,
SdkHttpError,
StreamableHTTPClientTransport,
@@ -22,6 +23,7 @@ import type { IdaConfiguration } from "./IdaConfiguration.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import {
AnalysisTimeoutError,
AnalysisProtocolError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
@@ -389,6 +391,11 @@ export const createIdaMcpConnection = (
stdout_bytes: 0,
stderr_bytes: stderrBytes,
};
if (cause instanceof SdkError && cause.code === SdkErrorCode.RequestTimeout)
throw new AnalysisTimeoutError(operation, config.timeoutMs, {
cause: failure,
capturedOutput,
});
if (bufferLimit !== null)
throw new AnalysisResourceConstraintError(
operation,
@@ -443,12 +450,12 @@ export const createIdaMcpConnection = (
async connect() {
await client
.connect(transport, { timeout: config.timeoutMs })
.catch(transportFailure);
.catch((cause: unknown) => transportFailure(cause, "ida_mcp_connect"));
const catalog = await client
.listTools(undefined, {
timeout: config.timeoutMs,
})
.catch(transportFailure);
.catch((cause: unknown) => transportFailure(cause, "ida_mcp_catalog"));
return catalog.tools.map(({ name }) => name);
},
async call(name, args) {
@@ -463,7 +470,9 @@ export const createIdaMcpConnection = (
async readResource(uri) {
const response = await client
.readResource({ uri }, { timeout: config.timeoutMs })
.catch(transportFailure);
.catch((cause: unknown) =>
transportFailure(cause, "ida_mcp_read_resource"),
);
const resource = z
.object({
contents: z
+20
View File
@@ -7,6 +7,7 @@ import {
RecordingIdaMcp,
} from "../../tests/fixtures/idaMcp.js";
import { IdaSessionClient } from "./IdaSessionClient.js";
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
import { functionDossierSchema } from "../domain/hopperValues.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
@@ -473,6 +474,25 @@ describe("attached IDA function observations", () => {
});
});
it("preserves a typed MCP request timeout through the public session result", async () => {
const { producer, client } = await fixture();
expect((await client.execute("health", {})).ok).toBe(true);
producer.overrideCall = (name) => {
if (name === "decompile_function")
throw new AnalysisTimeoutError(name, 1_000);
return undefined;
};
const result = await client.execute("procedure_pseudo_code", {
procedure: "main",
});
if (result.ok) throw new Error("Expected the request timeout to surface");
expect(result.error._tag).toBe("AnalysisTimeoutError");
expect(projectAnalysisError(result.error).code).toBe("provider_timeout");
await client.close();
});
const busyHealth = (busyTool?: JsonValue): JsonValue => ({
status: "busy",
uptime_sec: 1.041,