From 00bd92c56e59a868997b1dc2887310b8737f37b7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:55:44 +0800 Subject: [PATCH] fix(ida): align workspace discovery with readiness (#7863) --- src/ida/IdaDoctor.ts | 23 +--- src/ida/IdaProvider.test.ts | 56 +++++++++- src/ida/IdaProvider.ts | 50 ++++++++- src/ida/IdaWorkspaceReadiness.ts | 22 ++++ .../cli/idaWorkspaceDiscovery.test.ts | 102 ++++++++++++++++++ 5 files changed, 228 insertions(+), 25 deletions(-) create mode 100644 src/ida/IdaWorkspaceReadiness.ts create mode 100644 tests/boundary/cli/idaWorkspaceDiscovery.test.ts diff --git a/src/ida/IdaDoctor.ts b/src/ida/IdaDoctor.ts index 4236d8256a..3aca76770b 100644 --- a/src/ida/IdaDoctor.ts +++ b/src/ida/IdaDoctor.ts @@ -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 => { - 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 => { - 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)}`; - } -}; diff --git a/src/ida/IdaProvider.test.ts b/src/ida/IdaProvider.test.ts index da223a8ad8..048f77a907 100644 --- a/src/ida/IdaProvider.test.ts +++ b/src/ida/IdaProvider.test.ts @@ -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({}); diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts index 44bb1509e1..f670c16e15 100644 --- a/src/ida/IdaProvider.ts +++ b/src/ida/IdaProvider.ts @@ -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 { diff --git a/src/ida/IdaWorkspaceReadiness.ts b/src/ida/IdaWorkspaceReadiness.ts new file mode 100644 index 0000000000..d8ceabcafd --- /dev/null +++ b/src/ida/IdaWorkspaceReadiness.ts @@ -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 }; +}; diff --git a/tests/boundary/cli/idaWorkspaceDiscovery.test.ts b/tests/boundary/cli/idaWorkspaceDiscovery.test.ts new file mode 100644 index 0000000000..a16095405d --- /dev/null +++ b/tests/boundary/cli/idaWorkspaceDiscovery.test.ts @@ -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" }); + }, +);