mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(ida): preserve SDK request timeout identity and captured output
This commit is contained in:
1 parent
fbdd040324
commit
c0cfccb45f
4 files changed
+114
-4
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user