fix(ida): align workspace discovery with readiness (#7863)

This commit is contained in:
morluto authored and GitHub committed 2026-10-29 04:55:44 +08:00
1 parent 37827af591
commit 00bd92c56e
5 files changed
+228 -25

No files matched your search

+3 -20
View File
@@ -8,10 +8,7 @@ import {
} from "./IdaConfiguration.js";
import type { Result } from "../domain/result.js";
import type { ConfigurationError } from "../domain/configurationErrors.js";
import { privateRuntimeRootCapability } from "../process/PrivateRuntimeRoot.js";
import { constants } from "node:fs";
import { access, stat } from "node:fs/promises";
import { resolveIdaWorkspaceParent } from "./IdaWorkspace.js";
import { inspectIdaWorkspaceReadiness } from "./IdaWorkspaceReadiness.js";
import type { RetainedResourceScope } from "../application/AnalysisProvider.js";
/** Check the registration without launching IDA, and retain only the configuration file reference. */
@@ -69,8 +66,8 @@ export const inspectIdaRegistration = async (
const inspectPrivateWorkspace = async (
workspaceRoot: string | undefined,
): Promise<DoctorProviderCheck> => {
const privacy = privateRuntimeRootCapability();
const unusable = await workspaceRootFailure(workspaceRoot);
const { privacy, unusable } =
await inspectIdaWorkspaceReadiness(workspaceRoot);
return {
name: "private_workspace",
ok: privacy.available && unusable === undefined,
@@ -98,17 +95,3 @@ const inspectPrivateWorkspace = async (
: "unsupported_host",
};
};
const workspaceRootFailure = async (
root: string | undefined,
): Promise<string | undefined> => {
try {
const parent = await resolveIdaWorkspaceParent(root);
if (!(await stat(parent)).isDirectory())
return `IDA workspaceRoot ${root ?? "the system temporary directory"} is not a directory.`;
await access(parent, constants.W_OK | constants.X_OK);
return undefined;
} catch (cause: unknown) {
return `IDA workspaceRoot ${root ?? "the system temporary directory"} is not a usable directory: ${cause instanceof Error ? cause.message : String(cause)}`;
}
};
+55 -1
View File
@@ -2,7 +2,7 @@ import { launchRegexMatchingLimitation } from "../process/RegexMatcher.js";
import { AnalysisSettings } from "../application/AnalysisSettings.js";
import { runtimeAnalysisConfig } from "../config/runtimeAnalysisSettings.js";
import { describe, expect, it, vi } from "vitest";
import { rm, writeFile } from "node:fs/promises";
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
@@ -86,6 +86,60 @@ it("keeps a selected valid registration distinct from a live IDA connection", as
}
});
it.each(["missing", "file", "directory", "omitted"] as const)(
"reports headless workspace readiness without launching IDA: %s",
async (kind) => {
const root = await createTestTempDirectory("rea-ida-discovery-workspace-");
const workspace = join(root, "workspace");
if (kind === "file") await writeFile(workspace, "caller data");
if (kind === "directory") await mkdir(workspace);
const registration = join(root, "registration.json");
await writeFile(
registration,
JSON.stringify({
mode: "headless",
command: "must-not-launch",
...(kind === "omitted" ? {} : { workspaceRoot: workspace }),
}),
);
const config = parseConfig({ REA_IDA_MCP_CONFIG: registration });
if (!config.ok) throw config.error;
const connect = vi.fn(() => {
throw new Error("Readiness must not connect to IDA");
});
const provider = new IdaProvider(config.value, {}, connect);
const before = await readdir(root);
try {
const snapshot = await provider.inspectReadinessSnapshot();
if (kind === "missing" || kind === "file") {
expect(snapshot.availability).toMatchObject({
status: "unavailable",
code: "open_options_invalid",
reason: expect.stringContaining(workspace),
diagnostics: {
workspace_root: workspace,
live_connection_probed: false,
configuration_remediation: expect.stringContaining("workspaceRoot"),
},
});
expect(snapshot.capabilities.every(({ available }) => !available)).toBe(
true,
);
expect(
snapshot.capabilities.find(
({ operation }) => operation === "procedure_callers",
)?.reason,
).toContain("direct call edges");
} else expect(snapshot.availability.status).toBe("available");
expect(await readdir(root)).toEqual(before);
expect(connect).not.toHaveBeenCalled();
} finally {
const closed = await provider.closeOwnedResources();
if (!closed.ok) throw closed.error;
}
},
);
describe("IDA provider target support", () => {
it("declines caller-selected gamecube-dol targets", () => {
const config = parseConfig({});
+46 -4
View File
@@ -46,11 +46,12 @@ import {
IDA_PROVIDER_IDENTITY,
} from "./IdaProviderCapabilities.js";
import { IdaSessionClient } from "./IdaSessionClient.js";
import { privateRuntimeRootCapability } from "../process/PrivateRuntimeRoot.js";
import { inspectIdaWorkspaceReadiness } from "./IdaWorkspaceReadiness.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { ProviderInspectionScope } from "../application/ProviderInspectionScope.js";
import type { ProviderRejectionCode } from "../domain/providerSelection.js";
/** Bring-your-own IDA MCP candidate; discovery performs no provider startup. */
export class IdaProvider implements AnalysisProviderCandidate {
@@ -191,6 +192,20 @@ export class IdaProvider implements AnalysisProviderCandidate {
registration_path: this.config.idaMcpConfigPath ?? null,
live_connection_probed: false,
};
const unavailableCapabilities = (
reason: string,
code: ProviderRejectionCode,
) =>
capabilities.map((capability) =>
capability.available
? {
...capability,
available: false as const,
reason,
availabilityCode: code,
}
: capability,
);
if (!config.ok) {
const failure = projectAnalysisError(config.error);
return {
@@ -207,11 +222,18 @@ export class IdaProvider implements AnalysisProviderCandidate {
configuration_remediation: failure.remediation.action,
},
},
capabilities,
capabilities: unavailableCapabilities(
failure.message,
this.config.idaMcpConfigPath === undefined
? "not_configured"
: "open_options_invalid",
),
};
}
if (config.value.mode === "headless") {
const privacy = privateRuntimeRootCapability();
const { privacy, unusable } = await inspectIdaWorkspaceReadiness(
config.value.workspaceRoot,
);
if (!privacy.available)
return {
availability: {
@@ -228,7 +250,27 @@ export class IdaProvider implements AnalysisProviderCandidate {
},
},
},
capabilities,
capabilities: unavailableCapabilities(privacy.reason, privacy.code),
};
if (unusable !== undefined)
return {
availability: {
status: "unavailable" as const,
code: "open_options_invalid" as const,
reason: unusable,
diagnostics: {
...registrationDiagnostics,
mode: "headless",
workspace_root: config.value.workspaceRoot ?? null,
private_workspace_available: false,
configuration_remediation:
"Set workspaceRoot to an existing directory REA can create entries in, or omit it to use the system temporary directory; see docs/ida-provider.md.",
},
},
capabilities: unavailableCapabilities(
unusable,
"open_options_invalid",
),
};
}
return {
+22
View File
@@ -0,0 +1,22 @@
import { constants } from "node:fs";
import { access, stat } from "node:fs/promises";
import { privateRuntimeRootCapability } from "../process/PrivateRuntimeRoot.js";
import { resolveIdaWorkspaceParent } from "./IdaWorkspace.js";
/** Inspect local prerequisites without creating a workspace or launching IDA. */
export const inspectIdaWorkspaceReadiness = async (
root: string | undefined,
) => {
const privacy = privateRuntimeRootCapability();
let unusable: string | undefined;
try {
const parent = await resolveIdaWorkspaceParent(root);
if (!(await stat(parent)).isDirectory())
unusable = `IDA workspaceRoot ${root ?? "the system temporary directory"} is not a directory.`;
else await access(parent, constants.W_OK | constants.X_OK);
} catch (cause: unknown) {
unusable = `IDA workspaceRoot ${root ?? "the system temporary directory"} is not a usable directory: ${cause instanceof Error ? cause.message : String(cause)}`;
}
return { privacy, unusable };
};
@@ -0,0 +1,102 @@
import { access, chmod, mkdir, readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest.skipIf(process.platform === "win32")(
"reports the same headless workspace readiness through discovery and doctor",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-ida-discovery-cli-");
const writable = join(root, "writable");
const file = join(root, "file");
const marker = join(root, "launched");
const command = join(root, "fixture-ida");
await mkdir(writable);
await writeFile(file, "caller data");
await writeFile(command, `#!/bin/sh\nprintf launched > '${marker}'\n`);
await chmod(command, 0o700);
const registration = join(root, "ida.json");
const before = await readdir(root);
for (const [workspaceRoot, usable] of [
[join(root, "missing"), false],
[file, false],
[writable, true],
[undefined, true],
] as const) {
await writeFile(
registration,
JSON.stringify({
mode: "headless",
command,
...(workspaceRoot === undefined ? {} : { workspaceRoot }),
}),
);
const environment = {
HOME: root,
REA_IDA_MCP_CONFIG: registration,
};
for (const command of ["providers", "capabilities"] as const) {
const result = await cli.run({
arguments: [command, "--json"],
environment,
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.json).toMatchObject({
analysis_provider_candidates: expect.arrayContaining([
expect.objectContaining({
provider: expect.objectContaining({ id: "ida" }),
availability: usable
? expect.objectContaining({ status: "available" })
: expect.objectContaining({
status: "unavailable",
code: "open_options_invalid",
reason: expect.stringContaining(workspaceRoot),
}),
...(usable
? {}
: {
capabilities: expect.arrayContaining([
expect.objectContaining({
groups: expect.arrayContaining([
expect.objectContaining({
operations: expect.arrayContaining([
"list_procedures",
]),
available: false,
reason: expect.stringContaining(workspaceRoot),
}),
expect.objectContaining({
operations: expect.arrayContaining([
"procedure_callers",
]),
available: false,
reason:
expect.stringContaining("direct call edges"),
}),
]),
}),
]),
}),
}),
]),
});
}
const doctor = await cli.run({
arguments: ["doctor", "--provider", "ida", "--json"],
environment,
});
expect(doctor.exitCode, doctor.stderr).toBe(usable ? 0 : 1);
expect(doctor.json).toMatchObject({
providerInspections: expect.arrayContaining([
expect.objectContaining({ id: "ida", available: usable }),
]),
});
}
expect((await readdir(root)).filter((name) => name !== "ida.json")).toEqual(
before,
);
await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" });
},
);