Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d80bda9bec |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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([]);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user