Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 d80bda9bec fix(ida): fail doctor readiness for FIFO registrations and unusable workspace roots
Read the IDA MCP registration through a non-blocking descriptor, reject
non-regular files, and bound the read at the 64 KiB registration limit, so
a FIFO without a writer yields invalid_configuration instead of blocking
the synchronous parser for doctor, setup, and analysis.

Doctor's private_workspace check now inspects a configured headless
workspaceRoot: it must be an existing directory REA can create entries in.
A regular file, missing path, or non-writable directory reports
invalid_configuration with workspaceRoot remediation. Diagnosis creates
nothing; an omitted workspaceRoot keeps the existing behavior.

Fixes #5559
Fixes #5561

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:21:44 +00:00
5 changed files with 255 additions and 26 deletions

No files matched your search

+1 -1
View File
@@ -135,7 +135,7 @@ Use a package version that actually includes this adapter; repository main can b
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 limit returns `provider_timeout` with its operation and actual request budget. A freshly opened headless worker can briefly report `status: "busy"` from `server_health`; REA re-reads health every 100 ms until the worker answers, the caller cancels, or `timeoutMs` has elapsed since the first read. Each health request is capped by the time remaining in that overall wait, so a stalled read cannot extend the readiness deadline by another full request timeout. Expiry while the worker is still busy produces a retryable `provider_timeout`; no further read is started after the deadline. That error names the busy tool from the last observation, or `unknown` when the worker reported none or a value that is not tool-name-shaped. If a health request itself consumes the remaining wait budget, the same last-busy timeout is returned with the stalled request's captured output. 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. For `ida` or `auto` selection, Codex/Kimi setup and doctor use the effective client registration's parsed `timeoutMs` (default 300000 ms) when recommending the agent's per-tool deadline. Existing positive client deadlines remain caller choices. `rea doctor --provider ida --json` validates the registration without starting IDA and explicitly leaves runtime compatibility and target binding for analysis.
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. For `ida` or `auto` selection, Codex/Kimi setup and doctor use the effective client registration's parsed `timeoutMs` (default 300000 ms) when recommending the agent's per-tool deadline. Existing positive client deadlines remain caller choices. `rea doctor --provider ida --json` validates the registration, which must be a regular JSON file, and checks that a headless `workspaceRoot` is an existing directory REA can create entries in. It starts no IDA process, creates nothing, and explicitly leaves runtime compatibility and target binding for analysis.
## Coverage and evidence
+8
View File
@@ -58,6 +58,14 @@ describe("IDA registration and MCP result boundaries", () => {
await writeFile(path, "malformed");
expect(readIdaConfiguration(path).ok).toBe(false);
expect(readIdaConfiguration(join(root, "absent")).ok).toBe(false);
const registration = JSON.stringify({ command: "python" });
await writeFile(path, registration.padEnd(65_536));
expect(readIdaConfiguration(path).ok).toBe(true);
await writeFile(path, registration.padEnd(65_537));
expect(readIdaConfiguration(path)).toMatchObject({
ok: false,
error: { message: expect.stringContaining("64 KiB") },
});
});
it("parses FastMCP wrappers, structured dictionaries, text, arrays, and explicit errors", () => {
expect(
+57 -8
View File
@@ -1,4 +1,4 @@
import { readFileSync, statSync } from "node:fs";
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
import { isAbsolute } from "node:path";
import { z } from "zod";
import { ConfigurationError } from "../domain/configurationErrors.js";
@@ -58,13 +58,9 @@ export const readIdaConfiguration = (
path: string,
): Result<IdaConfiguration, ConfigurationError> => {
try {
if (statSync(path).size > 65_536)
return err(
idaConfigurationError(
"REA_IDA_MCP_CONFIG exceeds the 64 KiB registration size.",
),
);
const input: unknown = JSON.parse(readFileSync(path, "utf8"));
const text = readRegistrationText(path);
if (!text.ok) return text;
const input: unknown = JSON.parse(text.value);
const envelope = z
.object({ mcpServers: z.object({ "ida-pro-mcp": z.unknown() }) })
.safeParse(input);
@@ -89,6 +85,59 @@ export const readIdaConfiguration = (
}
};
const MAXIMUM_REGISTRATION_BYTES = 65_536;
/**
* Read at most the registration limit from one regular file. Opening without
* blocking and checking the descriptor's type keeps a FIFO, socket or device
* from stalling synchronous diagnostics while it waits for a producer.
*/
const readRegistrationText = (
path: string,
): Result<string, ConfigurationError> => {
const descriptor = openSync(
path,
constants.O_RDONLY |
(constants.O_NONBLOCK ?? 0) |
(constants.O_NOCTTY ?? 0),
);
try {
const metadata = fstatSync(descriptor);
if (!metadata.isFile())
return err(
idaConfigurationError(
`REA_IDA_MCP_CONFIG must select a regular JSON file: ${path}`,
),
);
if (metadata.size > MAXIMUM_REGISTRATION_BYTES)
return err(registrationTooLarge());
// One extra byte detects growth past the limit after fstat.
const bytes = Buffer.alloc(MAXIMUM_REGISTRATION_BYTES + 1);
let length = 0;
while (length < bytes.length) {
const read = readSync(
descriptor,
bytes,
length,
bytes.length - length,
null,
);
if (read === 0) break;
length += read;
}
return length > MAXIMUM_REGISTRATION_BYTES
? err(registrationTooLarge())
: ok(bytes.toString("utf8", 0, length));
} finally {
closeSync(descriptor);
}
};
const registrationTooLarge = (): ConfigurationError =>
idaConfigurationError(
"REA_IDA_MCP_CONFIG exceeds the 64 KiB registration size.",
);
/** A REA_IDA_MCP_CONFIG rejection that names the setting it concerns. */
export const idaConfigurationError = (
constraint: string,
+59 -17
View File
@@ -1,3 +1,5 @@
import { accessSync, constants, statSync } from "node:fs";
import type {
DoctorProviderCheck,
DoctorProviderInspection,
@@ -13,7 +15,7 @@ export const inspectIdaRegistration = (
const valid = parsed?.ok === true;
const workspaceChecks =
parsed?.ok === true && parsed.value.mode === "headless"
? [inspectPrivateWorkspace()]
? [inspectPrivateWorkspace(parsed.value.workspaceRoot)]
: [];
return {
id: "ida",
@@ -47,21 +49,61 @@ export const inspectIdaRegistration = (
};
};
const inspectPrivateWorkspace = (): DoctorProviderCheck => {
/**
* Check private workspace authority and, when selected, the existing parent
* that IdaWorkspace.allocate creates its private directory in. Diagnosis only
* inspects the parent; it never creates or repairs it.
*/
const inspectPrivateWorkspace = (
workspaceRoot: string | undefined,
): DoctorProviderCheck => {
const privacy = privateRuntimeRootCapability();
return {
name: "private_workspace",
ok: privacy.available,
code: privacy.available ? null : privacy.code,
detail: privacy.available
? `Private workspace authority is available (${privacy.proof}).`
: privacy.reason,
remediation: privacy.available ? null : privacy.remediation,
classification:
!privacy.available && privacy.code === "runtime_missing"
? "missing_dependency"
: !privacy.available && privacy.code === "runtime_unavailable"
? "runtime_failure"
: "unsupported_host",
};
if (!privacy.available)
return {
name: "private_workspace",
ok: false,
code: privacy.code,
detail: privacy.reason,
remediation: privacy.remediation,
classification:
privacy.code === "runtime_missing"
? "missing_dependency"
: privacy.code === "runtime_unavailable"
? "runtime_failure"
: "unsupported_host",
};
const unusable =
workspaceRoot === undefined
? undefined
: workspaceRootFailure(workspaceRoot);
return unusable === undefined
? {
name: "private_workspace",
ok: true,
code: null,
detail: `Private workspace authority is available (${privacy.proof})${workspaceRoot === undefined ? "" : ` under workspaceRoot ${workspaceRoot}`}.`,
remediation: null,
classification: "unsupported_host",
}
: {
name: "private_workspace",
ok: false,
code: "invalid_configuration",
detail: unusable,
remediation:
"Set the IDA registration's workspaceRoot to an existing directory REA can create entries in, or omit it to use the system temporary directory; see docs/ida-provider.md.",
classification: "config_drift",
};
};
/** Why the selected parent cannot hold a new private directory, if it cannot. */
const workspaceRootFailure = (root: string): string | undefined => {
try {
if (!statSync(root).isDirectory())
return `IDA workspaceRoot ${root} is not a directory.`;
accessSync(root, constants.W_OK | constants.X_OK);
return undefined;
} catch (cause: unknown) {
return `IDA workspaceRoot ${root} is not a usable directory: ${cause instanceof Error ? cause.message : String(cause)}`;
}
};
@@ -0,0 +1,130 @@
import { execFile } from "node:child_process";
import { access, chmod, mkdir, readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
const posixTest = cliTest.skipIf(process.platform === "win32");
const idaDoctor = async (cli: TestCli, home: string, registration: string) => {
const result = await cli.run({
arguments: ["doctor", "--provider", "ida", "--json"],
environment: {
HOME: home,
REA_LOG_LEVEL: "silent",
GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"),
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
REA_IDA_MCP_CONFIG: registration,
},
// A doctor waiting on a FIFO producer never finishes on its own.
timeoutMs: 15_000,
});
const report = result.json as {
readonly healthy: boolean;
readonly providerInspections: readonly {
readonly id: string;
readonly available: boolean;
readonly checks: readonly unknown[];
}[];
};
return {
exitCode: result.exitCode,
healthy: report.healthy,
ida: report.providerInspections.find(({ id }) => id === "ida"),
};
};
posixTest(
"rejects a FIFO registration without waiting for a writer",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-ida-doctor-fifo-");
const registration = join(home, "registration.json");
await promisify(execFile)("mkfifo", [registration]);
expect(await idaDoctor(cli, home, registration)).toMatchObject({
exitCode: 1,
healthy: false,
ida: {
available: false,
checks: [
{
name: "registration",
ok: false,
code: "invalid_configuration",
detail: expect.stringContaining("regular JSON file"),
remediation: expect.stringContaining("REA_IDA_MCP_CONFIG"),
},
],
},
});
},
);
posixTest(
"reports an unusable headless workspaceRoot without creating it",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-ida-doctor-root-");
const file = join(home, "file");
await writeFile(file, "fixture");
const missing = join(home, "missing");
const readOnly = join(home, "read-only");
await mkdir(readOnly);
await chmod(readOnly, 0o500);
const writable = join(home, "writable");
await mkdir(writable);
// Privileged users bypass directory permission bits, as mkdtemp would.
const privileged = process.getuid?.() === 0;
const cases = [
[file, false],
[missing, false],
[readOnly, privileged],
[writable, true],
[undefined, true],
] as const;
for (const [index, [workspaceRoot, ready]] of cases.entries()) {
const registration = join(home, `registration-${index}.json`);
await writeFile(
registration,
JSON.stringify({
command: join(home, "never-launched"),
args: [],
mode: "headless",
...(workspaceRoot === undefined ? {} : { workspaceRoot }),
}),
);
expect(
await idaDoctor(cli, home, registration),
workspaceRoot,
).toMatchObject({
exitCode: ready ? 0 : 1,
healthy: ready,
ida: {
available: ready,
checks: [
{ name: "registration", ok: true },
ready
? { name: "private_workspace", ok: true, code: null }
: {
name: "private_workspace",
ok: false,
code: "invalid_configuration",
detail: expect.stringContaining(
`IDA workspaceRoot ${workspaceRoot}`,
),
remediation: expect.stringContaining("workspaceRoot"),
classification: "config_drift",
},
],
},
});
}
// Diagnosis neither creates the missing parent nor allocates workspaces.
await expect(access(missing)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readdir(writable)).toEqual([]);
},
);