diff --git a/scripts/verify-real-ida.mjs b/scripts/verify-real-ida.mjs index bed0519a9f..1eb20d32b4 100644 --- a/scripts/verify-real-ida.mjs +++ b/scripts/verify-real-ida.mjs @@ -4,6 +4,7 @@ import { findMappedNonFunction, } from "./lib/ida-feature-trace.mjs"; import { requireMcpToolError } from "./lib/mcp-verifier-results.mjs"; +import { withVerifierResourceOwner } from "./lib/verifier-file-resources.mjs"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; @@ -64,9 +65,14 @@ const [ importBuilt("ida/IdaMcpConnection.js"), importBuilt("ida/IdaProtocolValues.js"), ]); -const registration = readIdaConfiguration(process.env.REA_IDA_MCP_CONFIG); -if (!registration.ok) throw registration.error; -const config = registration.value; +const config = await withVerifierResourceOwner(async (resourceScope) => { + const registration = await readIdaConfiguration( + process.env.REA_IDA_MCP_CONFIG, + { resourceScope }, + ); + if (!registration.ok) throw registration.error; + return registration.value; +}); if ( config.protocol === "native" && (values["second-procedure"] === undefined || diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index e41c876c6d..70f5cc464e 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -237,12 +237,21 @@ export interface ProviderIdentity { /** Typed analysis profile resolved before provider startup; null means unresolved. */ export interface AnalysisProfileResolution { readonly profile: AnalysisProfileCommitment | null; + /** Keep provider-private facts bound to this selection without Evidence serialization. */ + readonly createClient?: AnalysisProviderBoundClientFactory; } +export type AnalysisProviderBoundClientFactory = ( + target: BinaryTarget, + profile: AnalysisProfileCommitment, + context?: AnalysisClientContext, +) => AnalysisClient; + /** Cancellation context for side-effect-free provider profile resolution. */ export interface AnalysisProviderInspectionOptions { readonly signal?: AbortSignal; readonly scope?: ProviderInspectionScope; + readonly resourceScope?: RetainedResourceScope; } export interface AnalysisProfileResolutionOptions extends AnalysisProviderInspectionOptions { @@ -319,6 +328,12 @@ export type ProviderAvailability = /** Provider-owned target support observation before profile resolution. */ export type ProviderTargetSupport = + | { + readonly status: "unknown"; + readonly code: null; + readonly reason: string; + readonly diagnostics: Readonly>; + } | { readonly status: "supported"; readonly code: null; @@ -365,6 +380,10 @@ export interface AnalysisProviderCandidate extends AnalysisProvider { | AnalysisProviderReadinessSnapshot | Promise; inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport; + inspectTargetSupportWithOptions?( + target: BinaryTarget, + options?: AnalysisProviderInspectionOptions, + ): Promise; /** Observe retained candidate cleanup without starting or retrying work. */ hasPendingOwnedResources?(): boolean; /** Release candidate resources that are independent of any active client. */ diff --git a/src/application/ClientRegistrationEnvironment.ts b/src/application/ClientRegistrationEnvironment.ts index 979a867ff1..63560a7c86 100644 --- a/src/application/ClientRegistrationEnvironment.ts +++ b/src/application/ClientRegistrationEnvironment.ts @@ -23,7 +23,7 @@ import type { AnalysisProviderSelector } from "../contracts/providerSelection.js export type ProviderRequestBudgetResolver = ( analysisProvider: AnalysisProviderSelector, environment: Readonly>, -) => Result; +) => Promise>; export interface ClientRegistrationEnvironmentDetails { readonly values: Readonly>; @@ -47,7 +47,6 @@ export const clientRegistrationEnvironment = ( parsed, overrides, validation, - undefined, platform, ); return details.ok ? ok(details.value.values) : details; @@ -58,7 +57,6 @@ export const clientRegistrationEnvironmentDetails = ( parsed: ClientConfigurationDocument | undefined, overrides: Readonly> = {}, validation: "fragment" | "effective" = "effective", - resolveProviderRequestBudget?: ProviderRequestBudgetResolver, platform: NodeJS.Platform = process.platform, ): Result => { if (parsed?.dialect === "dsh") { @@ -68,12 +66,7 @@ export const clientRegistrationEnvironmentDetails = ( platform, ); if (!environment.ok) return environment; - return validatedDetails( - environment.value, - validation, - resolveProviderRequestBudget, - platform, - ); + return validatedDetails(environment.value, validation, platform); } const key = parsed?.dialect === "opencode" || parsed?.dialect === "opencode_v2" @@ -107,18 +100,32 @@ export const clientRegistrationEnvironmentDetails = ( overrides, platform, ); - return validatedDetails( - environment, + return validatedDetails(environment, validation, platform); +}; + +/** Resolve the async provider-specific budget through its retained reader owner. */ +export const clientRegistrationEnvironmentDetailsWithBudget = async ( + parsed: ClientConfigurationDocument | undefined, + overrides: Readonly>, + validation: "fragment" | "effective", + resolveProviderRequestBudget: ProviderRequestBudgetResolver | undefined, + platform: NodeJS.Platform = process.platform, +): Promise< + Result +> => { + const details = clientRegistrationEnvironmentDetails( + parsed, + overrides, validation, - resolveProviderRequestBudget, platform, ); + if (!details.ok || resolveProviderRequestBudget === undefined) return details; + return validatedBudget(details.value, resolveProviderRequestBudget, platform); }; const validatedDetails = ( environment: Readonly>, validation: "fragment" | "effective", - resolveProviderRequestBudget?: ProviderRequestBudgetResolver, platform: NodeJS.Platform = process.platform, ): Result => { const runtimeEnvironment = snapshotEnvironment(environment, platform); @@ -127,12 +134,6 @@ const validatedDetails = ( ? parseEnvironmentValues(runtimeEnvironment) : parseEnvironment(runtimeEnvironment); if (!validated.ok) return validated; - const providerRequestBudget = - resolveProviderRequestBudget?.( - validated.value.REA_ANALYSIS_PROVIDER, - runtimeEnvironment, - ) ?? ok(undefined); - if (!providerRequestBudget.ok) return providerRequestBudget; return ok({ values: Object.fromEntries( Object.entries(environment).sort(([left], [right]) => @@ -146,9 +147,29 @@ const validatedDetails = ( ...(validated.value.REA_JEB_MCP_TIMEOUT_MS === undefined ? {} : { jebMcpTimeoutMs: validated.value.REA_JEB_MCP_TIMEOUT_MS }), - ...(providerRequestBudget.value === undefined - ? {} - : { providerRequestTimeoutMs: providerRequestBudget.value }), + }, + }); +}; + +const validatedBudget = async ( + details: ClientRegistrationEnvironmentDetails, + resolveProviderRequestBudget: ProviderRequestBudgetResolver, + platform: NodeJS.Platform, +): Promise< + Result +> => { + const providerBudget = await resolveProviderRequestBudget( + details.timeoutInputs.analysisProvider, + snapshotEnvironment(details.values, platform), + ); + if (!providerBudget.ok) return providerBudget; + return ok({ + ...details, + timeoutInputs: { + ...details.timeoutInputs, + ...(providerBudget.value === undefined + ? {} + : { providerRequestTimeoutMs: providerBudget.value }), }, }); }; diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 28643628cc..7a1e7b0ba5 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -37,7 +37,7 @@ import { import type { SetupClient } from "./SupportedClients.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { dshDocument } from "./DshCordisPatch.js"; -import { clientRegistrationEnvironmentDetails } from "./ClientRegistrationEnvironment.js"; +import { clientRegistrationEnvironmentDetailsWithBudget } from "./ClientRegistrationEnvironment.js"; import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; @@ -262,7 +262,7 @@ export const readClientRegistrationInspections = async ( const registration = parseRegistration(raw, effectiveClient); const registrationEnvironment = client.name === "kimi" || client.name === "codex" - ? clientRegistrationEnvironmentDetails( + ? await clientRegistrationEnvironmentDetailsWithBudget( parsed, {}, "effective", diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 1e978d1c9d..f3727ac107 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -486,7 +486,12 @@ export interface SystemDoctorHostOptions { readonly providerRegistrationEnvironment?: () => Readonly< Record >; - readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver; + readonly resolveProviderRequestBudget?: ( + provider: Parameters[0], + environment: Parameters[1], + resources: RetainedResourceScope, + signal: AbortSignal, + ) => ReturnType; readonly linuxDemoRuntimeCheck?: () => Promise; } @@ -515,6 +520,19 @@ export const systemDoctorHost = ( }), run: (operation) => resources.run(operation), }; + const resolveProviderRequestBudget = + options.resolveProviderRequestBudget === undefined + ? undefined + : ( + provider: Parameters[0], + environment: Parameters[1], + ) => + options.resolveProviderRequestBudget!( + provider, + environment, + resourceScope, + inspectionSignal, + ); return { ...(options.signal === undefined ? {} : { signal: options.signal }), platform, @@ -629,9 +647,9 @@ export const systemDoctorHost = ( providerRegistrationEnvironment: options.providerRegistrationEnvironment, }), - ...(options.resolveProviderRequestBudget === undefined + ...(resolveProviderRequestBudget === undefined ? {} - : { resolveProviderRequestBudget: options.resolveProviderRequestBudget }), + : { resolveProviderRequestBudget }), async ilspyCmdVersion(path) { try { await access(path, constants.X_OK); @@ -696,11 +714,10 @@ export const systemDoctorHost = ( platform, environment, resources, - ...(options.resolveProviderRequestBudget === undefined + ...(resolveProviderRequestBudget === undefined ? {} : { - resolveProviderRequestBudget: - options.resolveProviderRequestBudget, + resolveProviderRequestBudget, }), }), }; diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index e0a5a012d7..3bd09e7a41 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -27,7 +27,7 @@ import { isClientConfigurationCleanupFailure } from "./ClientConfigurationCleanu import { ClientConfigurationPublication } from "./ClientConfigurationPublication.js"; import { clientRegistrationEnvironment, - clientRegistrationEnvironmentDetails, + clientRegistrationEnvironmentDetailsWithBudget, type ProviderRequestBudgetResolver, } from "./ClientRegistrationEnvironment.js"; @@ -125,7 +125,7 @@ const configureClientDocument = async ( const policy = await readClientPolicyBlock( client, undefined, - clientConfigurationDesired( + await clientConfigurationDesired( client, environment, command, @@ -164,7 +164,7 @@ const configureClientDocument = async ( const policy = await readClientPolicyBlock( client, parsed, - clientConfigurationDesired( + await clientConfigurationDesired( client, environment, command, @@ -190,7 +190,7 @@ const configureClientDocument = async ( throwCleanupFailure(fallback.error); return { status: "failed", reason: "readback" }; } - const desired = clientConfigurationDesired( + const desired = await clientConfigurationDesired( client, environment, command, @@ -408,7 +408,7 @@ export const clientConfigurationAligned = async ( const policy = await readClientPolicyBlock( client, parsed, - clientConfigurationDesired( + await clientConfigurationDesired( client, providerEnvironment, command, @@ -425,7 +425,7 @@ export const clientConfigurationAligned = async ( policy.value === undefined && registrationCurrent( parsed, - clientConfigurationDesired( + await clientConfigurationDesired( client, providerEnvironment, command, @@ -497,7 +497,7 @@ const inspectClientDocument = async ( const policy = await readClientPolicyBlock( client, undefined, - clientConfigurationDesired( + await clientConfigurationDesired( client, providerEnvironment, command, @@ -547,7 +547,7 @@ const inspectClientDocument = async ( const policy = await readClientPolicyBlock( client, parsed, - clientConfigurationDesired( + await clientConfigurationDesired( client, providerEnvironment, command, @@ -565,7 +565,7 @@ const inspectClientDocument = async ( } if (policy.value !== undefined) return { status: "invalid", remediation: policy.value }; - const desired = clientConfigurationDesired( + const desired = await clientConfigurationDesired( client, providerEnvironment, command, @@ -713,14 +713,14 @@ const registrationCurrent = ( !Object.hasOwn(parsed.legacyServers, PRODUCT_IDENTITY.mcpServerKey) && !clientServerListedDisabled(parsed, PRODUCT_IDENTITY.mcpServerKey); -const clientConfigurationDesired = ( +const clientConfigurationDesired = async ( client: SetupClient, providerEnvironment: SetupProviderEnvironment, command: readonly string[], parsed: ClientConfigurationDocument | undefined, sources: readonly ClientConfigurationSource[], resolveProviderRequestBudget?: ProviderRequestBudgetResolver, -) => { +): Promise => { const effective = effectiveClientConfiguration(sources, { client, document: parsed, @@ -742,7 +742,7 @@ const clientConfigurationDesired = ( if (!desired.ok) throw desired.error; return desired.value; } - const validated = clientRegistrationEnvironmentDetails( + const validated = await clientRegistrationEnvironmentDetailsWithBudget( effective, providerEnvironment, "effective", diff --git a/src/application/binary/AnalysisProviderEvaluation.ts b/src/application/binary/AnalysisProviderEvaluation.ts index c1a0453669..359401a76b 100644 --- a/src/application/binary/AnalysisProviderEvaluation.ts +++ b/src/application/binary/AnalysisProviderEvaluation.ts @@ -11,6 +11,7 @@ import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js"; import { err, ok, type Result } from "../../domain/result.js"; import type { + AnalysisProviderBoundClientFactory, AnalysisProfileResolutionOptions, AnalysisProviderCandidate, } from "../AnalysisProvider.js"; @@ -22,6 +23,7 @@ export interface AnalysisProviderCandidateEvaluation { readonly candidate: AnalysisProviderCandidate; readonly status: AnalysisProviderCandidateStatus; readonly profile?: AnalysisProfileCommitment; + readonly createClient?: AnalysisProviderBoundClientFactory; readonly profileError?: AnalysisError; } @@ -107,7 +109,14 @@ export const evaluateAnalysisProviderCandidate = async ( "Resolved analysis profile has mismatched provider identity", ), ); - return ok({ candidate, status, profile }); + return ok({ + candidate, + status, + profile, + ...(resolved.value.createClient === undefined + ? {} + : { createClient: resolved.value.createClient }), + }); } catch (cause: unknown) { void cause; return ok( diff --git a/src/application/binary/AnalysisProviderRegistry.ts b/src/application/binary/AnalysisProviderRegistry.ts index 7935e9d2f7..b5172ad35a 100644 --- a/src/application/binary/AnalysisProviderRegistry.ts +++ b/src/application/binary/AnalysisProviderRegistry.ts @@ -29,6 +29,8 @@ import { import { err, ok, type Result } from "../../domain/result.js"; import type { AnalysisProviderCandidate, + AnalysisProviderBoundClientFactory, + AnalysisProviderInspectionOptions, CapabilityDescriptor, ProviderAvailability, ProviderIdentity, @@ -71,6 +73,7 @@ export interface AnalysisProviderBinding { readonly identity: ProviderIdentity & { readonly version: string }; readonly selectionSource: "request" | "environment" | "auto-single-candidate"; readonly profile: AnalysisProfileCommitment; + readonly createClient?: AnalysisProviderBoundClientFactory; /** The provider's capabilities, attributed to its resolved identity. */ readonly capabilities: readonly CapabilityDescriptor[]; } @@ -269,18 +272,35 @@ export class AnalysisProviderRegistry { cause: options.signal.reason, }); if (this.#closing !== undefined) - return this.#providers.map((provider) => - projectCandidateStatus(provider, target, { - availability: { - status: "unknown", - code: null, - reason: - "Provider ownership finalization is in progress; readiness has not been re-observed.", - diagnostics: { finalization: "in_progress" }, - }, - capabilities: provider.capabilities(), - }), - ); + return this.#providers.map((provider) => ({ + provider: structuredClone(provider.identity()), + availability: { + status: "unknown" as const, + code: null, + reason: + "Provider ownership finalization is in progress; readiness has not been re-observed.", + diagnostics: { finalization: "in_progress" }, + }, + targetSupport: + target === undefined + ? { + status: "unknown" as const, + code: null, + reason: "No target is open.", + diagnostics: {}, + } + : { + status: "unknown" as const, + code: null, + reason: + "Target support has not been re-observed during provider finalization.", + diagnostics: {}, + }, + selected: false, + capabilities: provider + .capabilities() + .map((capability) => structuredClone(capability)), + })); const scope = new ProviderInspectionScope(); const discovery = new SharedStartupAttempt< Result @@ -290,6 +310,7 @@ export class AnalysisProviderRegistry { this.#discoverCandidates(target, { signal, scope, + resourceScope: this.#profileOwner, operation: "binary_session", }), ), @@ -318,6 +339,7 @@ export class AnalysisProviderRegistry { context: { readonly signal: AbortSignal; readonly scope: ProviderInspectionScope; + readonly resourceScope: RetainedResourceScope; readonly operation: "binary_session" | "open_binary"; }, ): Promise< @@ -453,6 +475,7 @@ export class AnalysisProviderRegistry { const discovery = await this.#discoverCandidates(target, { signal, scope, + resourceScope: this.#profileOwner, operation: "open_binary", }); if (!discovery.ok) return discovery; @@ -556,10 +579,12 @@ export class AnalysisProviderRegistry { throw new TypeError( "Registered provider is absent from discovery output", ); - if (initial.targetSupport.status !== "supported") + if (initial.targetSupport.status === "unsupported") return err(selectionError("target_unsupported", providerId, baseline)); if (initial.availability.status === "unavailable") return err(selectionError("provider_unavailable", providerId, baseline)); + if (initial.targetSupport.status === "unknown") + return err(selectionError("provider_unavailable", providerId, baseline)); const evaluation = await evaluateAnalysisProviderCandidate( provider, initial, @@ -677,30 +702,44 @@ const candidateStatus = async ( context: { readonly signal: AbortSignal; readonly scope: ProviderInspectionScope; + readonly resourceScope: RetainedResourceScope; readonly operation: "binary_session" | "open_binary"; }, ): Promise => { - const { signal, scope, operation } = context; + const { signal, scope, resourceScope, operation } = context; if (signal.aborted) throw new AnalysisCancelledError(operation, { cause: signal.reason }); const readiness = provider.inspectReadinessSnapshot === undefined ? { - availability: await provider.inspectAvailability({ signal, scope }), + availability: await provider.inspectAvailability({ + signal, + scope, + resourceScope, + }), capabilities: provider.capabilities(), } - : await provider.inspectReadinessSnapshot({ signal, scope }); - return projectCandidateStatus(provider, target, readiness); + : await provider.inspectReadinessSnapshot({ + signal, + scope, + resourceScope, + }); + return await projectCandidateStatus(provider, target, readiness, { + signal, + scope, + resourceScope, + }); }; -const projectCandidateStatus = ( +const projectCandidateStatus = async ( provider: AnalysisProviderCandidate, target: BinaryTarget | undefined, readiness: { readonly availability: AnalysisProviderCandidateStatus["availability"]; readonly capabilities: readonly CapabilityDescriptor[]; }, -): AnalysisProviderCandidateStatus => { + options: AnalysisProviderInspectionOptions, +): Promise => { return { provider: structuredClone(provider.identity()), availability: cloneAvailability(readiness.availability), @@ -712,7 +751,12 @@ const projectCandidateStatus = ( reason: "No target is open.", diagnostics: {}, } - : cloneTargetSupport(provider.inspectTargetSupport(target)), + : cloneTargetSupport( + await (provider.inspectTargetSupportWithOptions?.( + target, + options, + ) ?? provider.inspectTargetSupport(target)), + ), selected: false, capabilities: readiness.capabilities.map((capability) => structuredClone(capability), @@ -728,6 +772,7 @@ const selectedResult = ( source: AnalysisProviderBinding["selectionSource"], evaluated: AnalysisProviderCandidateEvaluation & { readonly profile: AnalysisProfileCommitment; + readonly createClient?: AnalysisProviderBoundClientFactory; }, statuses: readonly AnalysisProviderCandidateStatus[], ): AnalysisProviderSelection => { @@ -749,6 +794,9 @@ const selectedResult = ( identity: structuredClone(identity), selectionSource: source, profile: structuredClone(evaluated.profile), + ...(evaluated.createClient === undefined + ? {} + : { createClient: evaluated.createClient }), capabilities, }, candidates: replaceCandidate(statuses, selectedStatus), diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 6ed47deb65..425c530126 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -344,7 +344,9 @@ export class BinarySession return err( await this.#restoreAfterFailedOpen( previous, - new AnalysisCancelledError("open_binary"), + new AnalysisCancelledError("open_binary", { + cause: options.signal?.reason, + }), ), ); const runId = randomUUID(); diff --git a/src/application/binary/CompositeProvider.ts b/src/application/binary/CompositeProvider.ts index 672757eec3..8bcdc710cb 100644 --- a/src/application/binary/CompositeProvider.ts +++ b/src/application/binary/CompositeProvider.ts @@ -8,6 +8,7 @@ import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js" import type { AnalysisClient, AnalysisClientContext, + AnalysisProviderBoundClientFactory, AnalysisProvider, CapabilityDescriptor, ExecutionOptions, @@ -129,8 +130,19 @@ export class CompositeProvider implements AnalysisProvider { readonly #identity: ProviderIdentity; readonly #capabilities: readonly CapabilityDescriptor[]; readonly #providerByOperation: ReadonlyMap; + readonly #boundClientFactories: ReadonlyMap< + string, + AnalysisProviderBoundClientFactory + >; - constructor(readonly providers: readonly AnalysisProvider[]) { + constructor( + readonly providers: readonly AnalysisProvider[], + boundClientFactories: ReadonlyMap< + string, + AnalysisProviderBoundClientFactory + > = new Map(), + ) { + this.#boundClientFactories = boundClientFactories; if (providers.length === 0) throw new RangeError("CompositeProvider requires at least one provider"); this.#identity = Object.freeze( @@ -177,11 +189,13 @@ export class CompositeProvider implements AnalysisProvider { const clientFor = (provider: AnalysisProvider): AnalysisClient => { const existing = clients.get(provider); if (existing !== undefined) return existing; - const created = provider.createClient( - target, - profile?.provider.id === provider.identity().id ? profile : undefined, - context, - ); + const providerProfile = + profile?.provider.id === provider.identity().id ? profile : undefined; + const factory = this.#boundClientFactories.get(provider.identity().id); + const created = + factory !== undefined && providerProfile !== undefined + ? factory(target, providerProfile, context) + : provider.createClient(target, providerProfile, context); clients.set(provider, created); return created; }; diff --git a/src/application/binary/SessionProviderRouter.ts b/src/application/binary/SessionProviderRouter.ts index b6c62782f6..0989460b4f 100644 --- a/src/application/binary/SessionProviderRouter.ts +++ b/src/application/binary/SessionProviderRouter.ts @@ -140,7 +140,12 @@ const selectableRoute = ( ? providers[0] : providers.length === 0 ? undefined - : new CompositeProvider(providers); + : new CompositeProvider( + providers, + binding?.createClient === undefined + ? new Map() + : new Map([[binding.identity.id, binding.createClient]]), + ); const identity = provider?.identity() ?? emptyProviderIdentity(); // The binding attributes deep-provider capabilities to the resolved version. const capabilities = capabilityMap([ diff --git a/src/doctorRuntime.test.ts b/src/doctorRuntime.test.ts index ce33ccca9b..27e2af9b1c 100644 --- a/src/doctorRuntime.test.ts +++ b/src/doctorRuntime.test.ts @@ -231,6 +231,32 @@ it("omits REA_JADX_MCP_JAR from the provider registration environment when it is expect(environment).not.toHaveProperty("REA_JADX_MCP_JAR"); }); +it("shares one parsed IDA registration between timeout-budget and doctor projections", async () => { + const root = await createTestTempDirectory("rea-ida-registration-fact-"); + const registration = join(root, "registration.json"); + await writeFile( + registration, + JSON.stringify({ command: "fixture", timeoutMs: 3210 }), + ); + const host = createSystemDoctorHost({ REA_IDA_MCP_CONFIG: registration }); + try { + expect( + await host.resolveProviderRequestBudget?.("ida", { + REA_IDA_MCP_CONFIG: registration, + }), + ).toEqual(ok(3210)); + await writeFile(registration, "not JSON anymore"); + const inspections = await host.providerInspections?.(); + expect(inspections?.find(({ id }) => id === "ida")).toMatchObject({ + configured: true, + available: true, + checks: [{ name: "registration", ok: true }], + }); + } finally { + await host.close?.(); + } +}); + it("carries a validated REA_KUNA_COMMAND into the provider registration environment", async () => { expect(DOCTOR_PROVIDER_IDS).toContain("kuna"); const root = await createTestTempDirectory("rea-kuna-registration-"); diff --git a/src/doctorRuntime.ts b/src/doctorRuntime.ts index 48b368e38c..d3eba13880 100644 --- a/src/doctorRuntime.ts +++ b/src/doctorRuntime.ts @@ -13,6 +13,7 @@ import { parseJebMcpEndpoint } from "./jeb/JebMcpEndpoint.js"; import { selectLinuxPrivateDisplayStrategy } from "./hopper/LinuxPrivateDisplayProbe.js"; import { ok } from "./domain/result.js"; import { AnalysisCancelledError } from "./domain/analysisErrorCore.js"; +import type { RetainedResourceScope } from "./application/AnalysisProvider.js"; import { inspectLinuxHopperPythonRuntime, linuxHopperPythonUnavailable, @@ -23,6 +24,30 @@ export const createSystemDoctorHost = ( environment: Readonly = process.env, signal?: AbortSignal, ): DoctorHost => { + const registrationReads = new WeakMap< + RetainedResourceScope, + Map> + >(); + const readRegistration = ( + path: string, + resourceScope: RetainedResourceScope, + inspectionSignal: AbortSignal, + ) => { + let byPath = registrationReads.get(resourceScope); + if (byPath === undefined) { + byPath = new Map(); + registrationReads.set(resourceScope, byPath); + } + let registration = byPath.get(path); + if (registration === undefined) { + registration = readIdaConfiguration(path, { + resourceScope, + signal: inspectionSignal, + }); + byPath.set(path, registration); + } + return registration; + }; const selectedJebEndpoint = environment.REA_JEB_MCP_URL; const selectedJebRegistrationEnvironment: Readonly> = selectedJebEndpoint !== undefined && @@ -34,15 +59,21 @@ export const createSystemDoctorHost = ( environment, ...(signal === undefined ? {} : { signal }), providerRegistrationEnvironment: () => selectedJebRegistrationEnvironment, - resolveProviderRequestBudget: ( + resolveProviderRequestBudget: async ( analysisProvider, registrationEnvironment, + resourceScope, + inspectionSignal, ) => { if (analysisProvider !== "ida" && analysisProvider !== "auto") return ok(undefined); const path = registrationEnvironment.REA_IDA_MCP_CONFIG; if (path === undefined) return ok(undefined); - const registration = readIdaConfiguration(path); + const registration = await readRegistration( + path, + resourceScope, + inspectionSignal, + ); if (registration.ok) return ok(registration.value.timeoutMs); // An invalid IDA registration cannot be selected automatically. Keep it // out of recommendations for another available provider, but surface the @@ -55,7 +86,18 @@ export const createSystemDoctorHost = ( resourceScope, inspectionSignal, ), - inspectIdaRegistration(environment.REA_IDA_MCP_CONFIG), + await inspectIdaRegistration( + environment.REA_IDA_MCP_CONFIG, + resourceScope, + inspectionSignal, + environment.REA_IDA_MCP_CONFIG === undefined + ? undefined + : readRegistration( + environment.REA_IDA_MCP_CONFIG, + resourceScope, + inspectionSignal, + ), + ), await inspectJadxProvider(environment, resourceScope, inspectionSignal), await inspectKunaProvider(environment, process.platform, { resourceScope, diff --git a/src/ida/IdaConfiguration.test.ts b/src/ida/IdaConfiguration.test.ts index a396734b56..000d3e3597 100644 --- a/src/ida/IdaConfiguration.test.ts +++ b/src/ida/IdaConfiguration.test.ts @@ -1,9 +1,18 @@ import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { vi } from "vitest"; import { createIdaTarget } from "../../tests/fixtures/idaMcp.js"; import { readIdaConfiguration } from "./IdaConfiguration.js"; import { decodeIdaToolResult } from "./IdaMcpConnection.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; +import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; + +const resourceScope: RetainedResourceScope = { + retain: () => undefined, + run: (operation) => operation(), +}; +const read = (path: string) => readIdaConfiguration(path, { resourceScope }); const roots: string[] = []; afterEach(async () => { @@ -26,7 +35,7 @@ describe("IDA registration and MCP result boundaries", () => { }, ]) { await writeFile(path, JSON.stringify(input)); - expect(readIdaConfiguration(path)).toMatchObject({ + expect(await read(path)).toMatchObject({ ok: true, value: { command: "python", args: ["server.py"], mode: "attached" }, }); @@ -44,7 +53,7 @@ describe("IDA registration and MCP result boundaries", () => { { command: "python", args: "server.py" }, ]) { await writeFile(path, JSON.stringify(input)); - expect(readIdaConfiguration(path).ok).toBe(false); + expect((await read(path)).ok).toBe(false); } await writeFile( path, @@ -54,11 +63,164 @@ describe("IDA registration and MCP result boundaries", () => { headers: { Authorization: "Bearer fixture-secret" }, }), ); - expect(readIdaConfiguration(path).ok).toBe(true); + expect((await read(path)).ok).toBe(true); await writeFile(path, "malformed"); - expect(readIdaConfiguration(path).ok).toBe(false); - expect(readIdaConfiguration(join(root, "absent")).ok).toBe(false); + expect((await read(path)).ok).toBe(false); + expect((await read(join(root, "absent"))).ok).toBe(false); + const registration = JSON.stringify({ command: "python" }); + await writeFile(path, registration.padEnd(65_536)); + expect((await read(path)).ok).toBe(true); + await writeFile(path, registration.padEnd(65_537)); + expect(await read(path)).toMatchObject({ + ok: false, + error: { message: expect.stringContaining("64 KiB") }, + }); }); +}); + +describe("IDA registration descriptor ownership", () => { + it("retains the exact descriptor after close denial and resolves its error only after retry", async () => { + const { root } = await createIdaTarget(); + roots.push(root); + const path = join(root, "mcp.json"); + await writeFile(path, JSON.stringify({ command: "python" })); + const retained: { close(): Promise }[] = []; + const owner: RetainedResourceScope = { + retain: (resource) => retained.push(resource), + run: (operation) => operation(), + }; + const close = OwnedFileHandle.prototype.close; + let denied = false; + const denial = new Error("descriptor close denied"); + vi.spyOn(OwnedFileHandle.prototype, "close").mockImplementation(function ( + this: OwnedFileHandle, + ) { + if (!denied) { + denied = true; + return Promise.reject(denial); + } + return close.call(this); + }); + const result = await readIdaConfiguration(path, { + resourceScope: owner, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected close failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(retained).toHaveLength(1); + await rm(path); + await retained[0]!.close(); + expect(result.error.cleanupIncomplete).toBe(false); + }); + it("preserves the caller's exact cancellation reason", async () => { + const { root } = await createIdaTarget(); + roots.push(root); + const path = join(root, "mcp.json"); + await writeFile(path, JSON.stringify({ command: "python" })); + const reason = new Error("stop registration inspection"); + const controller = new AbortController(); + controller.abort(reason); + await expect( + readIdaConfiguration(path, { + resourceScope, + signal: controller.signal, + }), + ).rejects.toMatchObject({ _tag: "AnalysisCancelledError", cause: reason }); + }); + it("preserves post-open cancellation when the descriptor's final close is denied", async () => { + const { root } = await createIdaTarget(); + roots.push(root); + const path = join(root, "mcp.json"); + await writeFile(path, JSON.stringify({ command: "python" })); + const retained: { close(): Promise }[] = []; + const owner: RetainedResourceScope = { + retain: (resource) => retained.push(resource), + run: (operation) => operation(), + }; + const reason = new Error("cancel at the post-open checkpoint"); + let checkpoints = 0; + const signal = { + get aborted() { + checkpoints += 1; + return checkpoints === 4; + }, + reason, + } as AbortSignal; + const denial = new Error( + "descriptor close denied after post-open cancellation", + ); + vi.spyOn(OwnedFileHandle.prototype, "close").mockRejectedValue(denial); + const cancellation = await readIdaConfiguration(path, { + resourceScope: owner, + signal, + }).then( + () => undefined, + (cause: unknown) => cause, + ); + expect(checkpoints).toBe(4); + expect(cancellation).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + cleanupIncomplete: true, + }); + expect(retained).toHaveLength(1); + vi.restoreAllMocks(); + await retained[0]!.close(); + expect(cancellation).toMatchObject({ cleanupIncomplete: false }); + }); + it("retains completed-read facts and cancellation through a denied final close", async () => { + const { root } = await createIdaTarget(); + roots.push(root); + const path = join(root, "mcp.json"); + await writeFile(path, JSON.stringify({ command: "python" })); + const retained: { close(): Promise }[] = []; + const owner: RetainedResourceScope = { + retain: (resource) => retained.push(resource), + run: (operation) => operation(), + }; + const close = OwnedFileHandle.prototype.close; + const reason = new Error("cancel while closing IDA registration"); + const controller = new AbortController(); + let entered!: () => void; + let release!: () => void; + const closeEntered = new Promise((resolve) => { + entered = resolve; + }); + const closeGate = new Promise((resolve) => { + release = resolve; + }); + const denial = new Error("descriptor close denied after cancellation"); + vi.spyOn(OwnedFileHandle.prototype, "close").mockImplementation( + async function (this: OwnedFileHandle) { + entered(); + await closeGate; + throw denial; + }, + ); + const pending = readIdaConfiguration(path, { + resourceScope: owner, + signal: controller.signal, + }); + await closeEntered; + controller.abort(reason); + release(); + const cancellation = await pending.then( + () => undefined, + (cause: unknown) => cause, + ); + expect(cancellation).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + cleanupIncomplete: true, + }); + expect(retained).toHaveLength(1); + vi.restoreAllMocks(); + await retained[0]!.close(); + expect(cancellation).toMatchObject({ cleanupIncomplete: false }); + }); +}); + +describe("IDA MCP result decoding", () => { it("parses FastMCP wrappers, structured dictionaries, text, arrays, and explicit errors", () => { expect( decodeIdaToolResult({ diff --git a/src/ida/IdaConfiguration.ts b/src/ida/IdaConfiguration.ts index 9f971610ac..d254667ad4 100644 --- a/src/ida/IdaConfiguration.ts +++ b/src/ida/IdaConfiguration.ts @@ -1,8 +1,19 @@ -import { readFileSync, statSync } from "node:fs"; import { isAbsolute } from "node:path"; import { z } from "zod"; +import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../domain/result.js"; +import { + withOwnedRegularFile, + regularFileCancellation, + RegularFileCleanupFailure, +} from "../filesystem/OwnedRegularFile.js"; +import { + assertRegularFileActive, + NonRegularFileReadError, +} from "../filesystem/RegularFile.js"; const common = { mode: z.enum(["attached", "headless"]).default("attached"), @@ -56,38 +67,149 @@ export const idaProtocol = (config: IdaConfiguration) => /** Read one explicit registration without starting or installing a provider. */ export const readIdaConfiguration = ( path: string, -): Result => { - try { - if (statSync(path).size > 65_536) - return err( - idaConfigurationError( - "REA_IDA_MCP_CONFIG exceeds the 64 KiB registration size.", - ), + options: { + readonly signal?: AbortSignal; + readonly resourceScope: RetainedResourceScope; + }, +): Promise> => + options.resourceScope.run(async () => { + let closeLease: AnalysisCleanupLease | undefined; + try { + const text = await withOwnedRegularFile( + path, + { + ...(options.signal === undefined ? {} : { signal: options.signal }), + retainFailedClose: (owner) => { + closeLease = AnalysisCleanupLease.forCloseOwner(owner, path); + options.resourceScope.retain( + { + close: () => closeLease!.release(), + get cleanupResources() { + return closeLease!.cleanupResources; + }, + }, + path, + ); + }, + }, + async (handle) => { + const metadata = await handle.stat(); + if (metadata.size > MAXIMUM_REGISTRATION_BYTES) + throw registrationTooLarge(); + const bytes = Buffer.alloc(MAXIMUM_REGISTRATION_BYTES + 1); + let length = 0; + while (length < bytes.length) { + assertRegularFileActive(options.signal); + const { bytesRead } = await handle.read( + bytes, + length, + bytes.length - length, + null, + ); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > MAXIMUM_REGISTRATION_BYTES) throw registrationTooLarge(); + return bytes.toString("utf8", 0, length); + }, ); - const input: unknown = JSON.parse(readFileSync(path, "utf8")); - const envelope = z - .object({ mcpServers: z.object({ "ida-pro-mcp": z.unknown() }) }) - .safeParse(input); - const parsed = registrationSchema.safeParse( - envelope.success ? envelope.data.mcpServers["ida-pro-mcp"] : input, - ); - return parsed.success - ? ok(parsed.data) - : err( + const input: unknown = JSON.parse(text); + const envelope = z + .object({ mcpServers: z.object({ "ida-pro-mcp": z.unknown() }) }) + .safeParse(input); + const parsed = registrationSchema.safeParse( + envelope.success ? envelope.data.mcpServers["ida-pro-mcp"] : input, + ); + return parsed.success + ? ok(parsed.data) + : err( + idaConfigurationError( + "REA_IDA_MCP_CONFIG must contain an IDA MCP command/args or local url registration; see docs/ida-provider.md.", + parsed.error, + ), + ); + } catch (cause: unknown) { + if (cause instanceof ConfigurationError) return err(cause); + if (cause instanceof NonRegularFileReadError) + return err( idaConfigurationError( - "REA_IDA_MCP_CONFIG must contain an IDA MCP command/args or local url registration; see docs/ida-provider.md.", - parsed.error, + `REA_IDA_MCP_CONFIG must select a regular JSON file: ${path}`, + cause, ), ); - } catch (cause: unknown) { - return err( - idaConfigurationError( - `Cannot read IDA MCP registration ${path}; provide a readable JSON configuration.`, - cause, - ), - ); - } -}; + if (cause instanceof AnalysisCancelledError) throw cause; + const cancellation = regularFileCancellation(cause); + if (cancellation !== undefined) { + if (cause instanceof RegularFileCleanupFailure) { + const error = new AnalysisCancelledError("ida:registration", { + cause: cancellation.reason, + cleanup: { + reason: + cause.cleanupCause instanceof Error + ? cause.cleanupCause.message + : String(cause.cleanupCause), + resources: [path], + }, + }); + closeLease?.bindCleanupError(error); + throw error; + } + throw new AnalysisCancelledError("ida:registration", { + cause: cancellation.reason, + }); + } + if (cause instanceof RegularFileCleanupFailure) { + const primary = + cause.outcome.kind === "failed" + ? cause.outcome.cause + : cause.cleanupCause; + const primaryMessage = + cause.outcome.kind === "failed" + ? primary instanceof ConfigurationError + ? primary.message + : primary instanceof NonRegularFileReadError + ? `REA_IDA_MCP_CONFIG must select a regular JSON file: ${path}` + : `Cannot read IDA MCP registration ${path}: ${primary instanceof Error ? primary.message : String(primary)}` + : `Cannot close IDA MCP registration ${path}; provide a readable JSON configuration.`; + const error = new ConfigurationError(primaryMessage, { + cause, + cleanup: { + reason: + cause.cleanupCause instanceof Error + ? cause.cleanupCause.message + : String(cause.cleanupCause), + resources: [path], + }, + settings: [ + { + setting: "REA_IDA_MCP_CONFIG", + constraint: `Close remains incomplete for ${path}`, + }, + ], + }); + closeLease?.bindCleanupError(error); + return err(error); + } + return err( + idaConfigurationError( + `Cannot read IDA MCP registration ${path}; provide a readable JSON configuration.`, + cause, + ), + ); + } + }); + +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 diagnostics while it waits for a producer. + */ +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 = ( diff --git a/src/ida/IdaDoctor.test.ts b/src/ida/IdaDoctor.test.ts index 310f27f341..7e919a463d 100644 --- a/src/ida/IdaDoctor.test.ts +++ b/src/ida/IdaDoctor.test.ts @@ -1,15 +1,71 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; import { inspectIdaRegistration } from "./IdaDoctor.js"; import { providerRegistrationEnvironment } from "../application/SetupRegistrationEnvironment.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; + +const resourceScope: RetainedResourceScope = { + retain: () => undefined, + run: (operation) => operation(), +}; const roots: string[] = []; afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); +it("uses the allocation normalization for readiness without creating entries", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-ida-workspace-parent-")); + roots.push(root); + const path = join(root, "registration.json"); + const rawRoot = `${root}/missing/..`; + await writeFile( + path, + JSON.stringify({ + command: "python", + mode: "headless", + workspaceRoot: rawRoot, + }), + ); + const before = await readdir(root); + const inspection = await inspectIdaRegistration(path, resourceScope); + const after = await readdir(root); + expect(inspection.available).toBe(true); + expect( + inspection.checks.find(({ name }) => name === "private_workspace")?.detail, + ).toContain(rawRoot); + expect(after).toEqual(before); +}); + +it("marks an unusable system temporary parent unavailable when workspaceRoot is omitted", async () => { + if (process.platform === "win32") return; + const root = await mkdtemp(join(tmpdir(), "rea-ida-default-parent-")); + roots.push(root); + const path = join(root, "registration.json"); + const unusableDefault = join(root, "not-a-directory"); + await writeFile(unusableDefault, "file"); + await writeFile( + path, + JSON.stringify({ command: "python", mode: "headless" }), + ); + const previous = process.env.TMPDIR; + process.env.TMPDIR = unusableDefault; + try { + const inspection = await inspectIdaRegistration(path, resourceScope); + expect(inspection.available).toBe(false); + expect( + inspection.checks.find(({ name }) => name === "private_workspace"), + ).toMatchObject({ + ok: false, + code: "invalid_configuration", + }); + } finally { + if (previous === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = previous; + } +}); it("keeps IDA configuration for setup without copying transport credentials or claiming runtime verification", async () => { const root = await mkdtemp(join(tmpdir(), "rea-ida-doctor-")); roots.push(root); @@ -22,7 +78,7 @@ it("keeps IDA configuration for setup without copying transport credentials or c mode: "headless", }), ); - const inspection = inspectIdaRegistration(path); + const inspection = await inspectIdaRegistration(path, resourceScope); expect(inspection).toMatchObject({ configured: true, available: true, @@ -34,11 +90,11 @@ it("keeps IDA configuration for setup without copying transport credentials or c }); expect(JSON.stringify(inspection)).not.toContain("fixture-secret"); await writeFile(path, JSON.stringify({ url: "https://example.com/mcp" })); - expect(inspectIdaRegistration(path)).toMatchObject({ + expect(await inspectIdaRegistration(path, resourceScope)).toMatchObject({ available: false, registrationEnvironment: {}, }); - expect(inspectIdaRegistration(undefined)).toMatchObject({ + expect(await inspectIdaRegistration(undefined, resourceScope)).toMatchObject({ configured: false, available: false, }); diff --git a/src/ida/IdaDoctor.ts b/src/ida/IdaDoctor.ts index 8a711aff14..4236d8256a 100644 --- a/src/ida/IdaDoctor.ts +++ b/src/ida/IdaDoctor.ts @@ -2,18 +2,37 @@ import type { DoctorProviderCheck, DoctorProviderInspection, } from "../application/Doctor.js"; -import { readIdaConfiguration } from "./IdaConfiguration.js"; +import { + readIdaConfiguration, + type IdaConfiguration, +} 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 type { RetainedResourceScope } from "../application/AnalysisProvider.js"; /** Check the registration without launching IDA, and retain only the configuration file reference. */ -export const inspectIdaRegistration = ( +export const inspectIdaRegistration = async ( path: string | undefined, -): DoctorProviderInspection => { - const parsed = path === undefined ? undefined : readIdaConfiguration(path); + resourceScope: RetainedResourceScope, + signal?: AbortSignal, + registration?: Promise>, +): Promise => { + const parsed = + path === undefined + ? undefined + : await (registration ?? + readIdaConfiguration(path, { + resourceScope, + ...(signal === undefined ? {} : { signal }), + })); const valid = parsed?.ok === true; const workspaceChecks = parsed?.ok === true && parsed.value.mode === "headless" - ? [inspectPrivateWorkspace()] + ? [await inspectPrivateWorkspace(parsed.value.workspaceRoot)] : []; return { id: "ida", @@ -47,21 +66,49 @@ export const inspectIdaRegistration = ( }; }; -const inspectPrivateWorkspace = (): DoctorProviderCheck => { +const inspectPrivateWorkspace = async ( + workspaceRoot: string | undefined, +): Promise => { const privacy = privateRuntimeRootCapability(); + const unusable = await workspaceRootFailure(workspaceRoot); 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, + ok: privacy.available && unusable === undefined, + code: !privacy.available + ? privacy.code + : unusable === undefined + ? null + : "invalid_configuration", + detail: !privacy.available + ? privacy.reason + : (unusable ?? + `Private workspace authority is available (${privacy.proof}) under ${workspaceRoot ?? "the system temporary directory"}.`), + remediation: !privacy.available + ? privacy.remediation + : unusable === undefined + ? null + : "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.", classification: !privacy.available && privacy.code === "runtime_missing" ? "missing_dependency" : !privacy.available && privacy.code === "runtime_unavailable" ? "runtime_failure" - : "unsupported_host", + : unusable !== undefined + ? "config_drift" + : "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/IdaNativeProtocol.test.ts b/src/ida/IdaNativeProtocol.test.ts index a1ba9fc0ce..415a8f0f4d 100644 --- a/src/ida/IdaNativeProtocol.test.ts +++ b/src/ida/IdaNativeProtocol.test.ts @@ -19,6 +19,15 @@ import { parseConfig } from "../config/parseConfig.js"; import { IdaProvider } from "./IdaProvider.js"; import { idaCapabilities } from "./IdaProviderCapabilities.js"; import { IdaSessionClient } from "./IdaSessionClient.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; +import { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js"; +import type { AnalysisProfileResolution } from "../application/AnalysisProvider.js"; +import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; + +const resourceScope: RetainedResourceScope = { + retain: () => undefined, + run: (operation) => operation(), +}; const roots: string[] = []; afterEach(async () => { @@ -107,6 +116,160 @@ const fixture = async () => { }; describe("native IDA attached protocol", () => { + it("keeps the exact registration fact selected at readiness if the path changes before profile resolution", async () => { + const { root, target, producer } = await fixture(); + const path = join(root, "mutable-registration.json"); + await writeFile( + path, + JSON.stringify({ command: "first-server", mode: "attached" }), + ); + const config = parseConfig({ REA_IDA_MCP_CONFIG: path }); + if (!config.ok) throw config.error; + const factoryRegistrations: string[] = []; + const provider = new IdaProvider(config.value, {}, (registration) => { + factoryRegistrations.push( + "command" in registration ? registration.command : registration.url, + ); + return producer; + }); + const controller = new AbortController(); + const scope = new ProviderInspectionScope(); + const options = { signal: controller.signal, scope, resourceScope }; + const readiness = await provider.inspectReadinessSnapshot(options); + expect(readiness.availability).toMatchObject({ + status: "available", + diagnostics: { mode: "attached" }, + }); + await writeFile( + path, + JSON.stringify({ command: "replacement-server", mode: "headless" }), + ); + const profile = await provider.resolveAnalysisProfile(target, options); + if (!profile.ok) throw profile.error; + expect(profile.value.profile.parameters).toMatchObject({ + mode: "attached", + }); + const createBoundClient = profile.value.createClient; + if (createBoundClient === undefined) + throw new Error("IDA profile did not retain its client factory"); + const client = createBoundClient(target, profile.value.profile); + const restoredClient = createBoundClient(target, profile.value.profile); + expect(factoryRegistrations).toEqual(["first-server", "first-server"]); + await client.close(); + await restoredClient.close(); + scope.close(); + }); + + it("does not reuse a registration across independent scopes sharing one signal", async () => { + const { root, target } = await fixture(); + const path = join(root, "independent-registration.json"); + await writeFile(path, JSON.stringify({ command: "first-server" })); + const config = parseConfig({ REA_IDA_MCP_CONFIG: path }); + if (!config.ok) throw config.error; + const provider = new IdaProvider(config.value, {}); + const controller = new AbortController(); + const firstScope = new ProviderInspectionScope(); + const first = await provider.inspectReadinessSnapshot({ + signal: controller.signal, + scope: firstScope, + resourceScope, + }); + expect(first.availability.status).toBe("available"); + const firstProfile = await provider.resolveAnalysisProfile(target, { + signal: controller.signal, + scope: firstScope, + resourceScope, + }); + if (!firstProfile.ok) throw firstProfile.error; + await writeFile(path, JSON.stringify({ command: "second-server" })); + const secondScope = new ProviderInspectionScope(); + const second = await provider.inspectReadinessSnapshot({ + signal: controller.signal, + scope: secondScope, + resourceScope, + }); + expect(second.availability.status).toBe("available"); + const profile = await provider.resolveAnalysisProfile(target, { + signal: controller.signal, + scope: secondScope, + resourceScope, + }); + if (!profile.ok) throw profile.error; + expect(profile.value.profile.parameters.mode).toBe("attached"); + expect(profile.value.profile.digest).not.toBe( + firstProfile.value.profile?.digest, + ); + firstScope.close(); + secondScope.close(); + }); +}); + +describe("IDA selected registration factories", () => { + it("retains every concurrently resolved registration factory without eviction", async () => { + const { root, target } = await fixture(); + const path = join(root, "many-registrations.json"); + const config = parseConfig({ REA_IDA_MCP_CONFIG: path }); + if (!config.ok) throw config.error; + const observed: string[] = []; + const producer = new RecordingIdaMcp(target); + const provider = new IdaProvider(config.value, {}, (registration) => { + observed.push( + "command" in registration ? registration.command : registration.url, + ); + return producer; + }); + const bindings: { + readonly createClient: NonNullable< + AnalysisProfileResolution["createClient"] + >; + readonly profile: AnalysisProfileCommitment; + }[] = []; + const signal = new AbortController().signal; + for (let index = 0; index < 40; index += 1) { + await writeFile(path, JSON.stringify({ command: `server-${index}` })); + const scope = new ProviderInspectionScope(); + const resolution = await provider.resolveAnalysisProfile(target, { + signal, + scope, + resourceScope, + }); + if (!resolution.ok) throw resolution.error; + if ( + resolution.value.profile === null || + resolution.value.createClient === undefined + ) + throw new Error( + "IDA profile resolution omitted its bound client factory", + ); + bindings.push({ + createClient: resolution.value.createClient, + profile: resolution.value.profile, + }); + scope.close(); + } + const clients = bindings.map(({ createClient, profile }) => + createClient(target, profile), + ); + expect(observed).toEqual( + Array.from({ length: 40 }, (_, index) => `server-${index}`), + ); + const mismatchedTargetClient = bindings[0]!.createClient( + { ...target, path: `${target.path}.different` }, + bindings[0]!.profile, + ); + const mismatchedProfileClient = bindings[0]!.createClient( + target, + bindings[1]!.profile, + ); + expect((await mismatchedTargetClient.execute("health", {})).ok).toBe(false); + expect((await mismatchedProfileClient.execute("health", {})).ok).toBe( + false, + ); + await mismatchedTargetClient.close(); + await mismatchedProfileClient.close(); + for (const client of clients) await client.close(); + }); + it("requires explicit opt-in and rejects native headless registrations", async () => { const { root } = await fixture(); const path = join(root, "native.json"); @@ -114,7 +277,7 @@ describe("native IDA attached protocol", () => { path, JSON.stringify({ url: "http://127.0.0.1:13337/mcp", protocol: "native" }), ); - expect(readIdaConfiguration(path)).toMatchObject({ + expect(await readIdaConfiguration(path, { resourceScope })).toMatchObject({ ok: true, value: { mode: "attached", protocol: "native" }, }); @@ -126,9 +289,11 @@ describe("native IDA attached protocol", () => { mode: "headless", }), ); - expect(readIdaConfiguration(path).ok).toBe(false); + expect((await readIdaConfiguration(path, { resourceScope })).ok).toBe( + false, + ); await writeFile(path, JSON.stringify({ command: "python" })); - const old = readIdaConfiguration(path); + const old = await readIdaConfiguration(path, { resourceScope }); expect(old.ok && old.value.protocol).toBeUndefined(); }); @@ -144,7 +309,7 @@ describe("native IDA attached protocol", () => { const provider = new IdaProvider(config.value, {}, () => { throw new Error("Unexpected discovery startup"); }); - expect(provider.inspectAvailability().status).toBe("available"); + expect((await provider.inspectAvailability()).status).toBe("available"); const profile = await provider.resolveAnalysisProfile(target); if (!profile.ok) throw profile.error; expect(profile.value.profile.provider.name).toBe("IDA MCP adapter"); @@ -154,21 +319,17 @@ describe("native IDA attached protocol", () => { cache_policy: "live", engine_version: null, }); + const readiness = await provider.inspectReadinessSnapshot(); expect( - provider - .capabilities() - .find(({ operation }) => operation === "procedure_pseudo_code")?.effects - .mayShowUi, - ).toBe(true); - expect( - provider - .capabilities() - .find(({ operation }) => operation === "procedure_assembly")?.effects - .mayShowUi, + readiness.capabilities.find( + ({ operation }) => operation === "procedure_assembly", + )?.effects.mayShowUi, ).toBe(false); expect(producer.connects).toBe(0); }); +}); +describe("native IDA complete result decoding", () => { it("uses complete native JSON text instead of a shortened structured preview", () => { const complete = [ { diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts index 1511b9b1a1..f48dbccc58 100644 --- a/src/ida/IdaProvider.ts +++ b/src/ida/IdaProvider.ts @@ -9,7 +9,11 @@ import { err, ok } from "../domain/result.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import type { AnalysisClient, + AnalysisProviderBoundClientFactory, + AnalysisProviderInspectionOptions, AnalysisProfileResolutionOptions, + AnalysisProviderReadinessSnapshot, + CapabilityDescriptor, AnalysisProviderCandidate, ProviderAvailability, ProviderTargetSupport, @@ -17,6 +21,8 @@ import type { import type { AppConfig } from "../config/types.js"; import type { ConfigurationError } from "../domain/configurationErrors.js"; import { createHash } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; +import { analysisProfilesEqual } from "../domain/analysisProfile.js"; import canonicalize from "canonicalize"; import type { Result } from "../domain/result.js"; import { @@ -34,10 +40,30 @@ import { } from "./IdaProviderCapabilities.js"; import { IdaSessionClient } from "./IdaSessionClient.js"; import { privateRuntimeRootCapability } from "../process/PrivateRuntimeRoot.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; +import { AnalysisCleanupLeaseGroup } from "../artifacts/AnalysisCleanupLease.js"; +import { ArtifactResourceRetryFailure } from "../artifacts/ArtifactResourceScope.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; +import type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js"; /** Bring-your-own IDA MCP candidate; discovery performs no provider startup. */ export class IdaProvider implements AnalysisProviderCandidate { - #registration: Result | undefined; + readonly #ownedResources = new ArtifactResourceScope(); + readonly #inspectionOwner: RetainedResourceScope = { + retain: (owner, resource) => + this.#ownedResources.retain({ + kind: "lease", + resource, + lease: AnalysisCleanupLease.forCloseOwner(owner, resource), + }), + run: (operation) => this.#ownedResources.run(operation), + }; + readonly #registrationByScope = new WeakMap< + ProviderInspectionScope, + Promise> + >(); private readonly environment: NodeJS.ProcessEnv; constructor( private readonly config: AppConfig, @@ -53,38 +79,123 @@ export class IdaProvider implements AnalysisProviderCandidate { identity() { return IDA_PROVIDER_IDENTITY; } - capabilities() { - const config = this.#configuration(); - return idaCapabilities( - config.ok ? config.value.mode : "attached", - config.ok && "command" in config.value, - config.ok && config.value.protocol === "native", - ); + capabilities(): readonly CapabilityDescriptor[] { + const profiles = [ + ...idaCapabilities("attached", false, false), + ...idaCapabilities("attached", true, true), + ...idaCapabilities("headless", true, false), + ]; + const byOperation = new Map(); + for (const capability of profiles) { + const existing = byOperation.get(capability.operation) ?? []; + existing.push(capability); + byOperation.set(capability.operation, existing); + } + return [...byOperation.values()].map((variants): CapabilityDescriptor => { + const first = variants[0]!; + const available = variants.some(({ available }) => available); + const effects = { + mutatesArtifact: variants.some( + ({ effects }) => effects.mutatesArtifact, + ), + launchesProcess: variants.some( + ({ effects }) => effects.launchesProcess, + ), + mayShowUi: variants.some(({ effects }) => effects.mayShowUi), + mayAccessNetwork: variants.some( + ({ effects }) => effects.mayAccessNetwork, + ), + mayWriteFilesystem: variants.some( + ({ effects }) => effects.mayWriteFilesystem, + ), + changesPermissions: variants.some( + ({ effects }) => effects.changesPermissions, + ), + requiresRoot: variants.some(({ effects }) => effects.requiresRoot), + }; + const limitations = [ + ...new Set(variants.flatMap(({ limitations }) => limitations)), + ]; + const descriptor = { + provider: first.provider, + operation: first.operation, + ...(first.cachePolicy === undefined + ? {} + : { cachePolicy: first.cachePolicy }), + effects, + limitations, + }; + return available + ? { ...descriptor, available: true, reason: null } + : { + ...descriptor, + available: false, + reason: + first.reason ?? + "No configured IDA compatibility profile admits this operation.", + }; + }); } - inspectAvailability(): ProviderAvailability { - const config = this.#configuration(); + hasPendingOwnedResources() { + return this.#ownedResources.hasPending; + } + async closeOwnedResources() { + try { + await this.#ownedResources.close(); + return ok(null); + } catch (cause: unknown) { + const failure = new ProviderCleanupError( + IDA_PROVIDER_IDENTITY.id, + this.#ownedResources.cleanupResources, + { cause: cause instanceof Error ? cause.message : String(cause) }, + { cause, operation: "close_owned_resources" }, + ); + if (cause instanceof ArtifactResourceRetryFailure) + new AnalysisCleanupLeaseGroup(failure, cause.cleanupLeases); + return err(failure); + } + } + async inspectAvailability( + options?: AnalysisProviderInspectionOptions, + ): Promise { + return (await this.inspectReadinessSnapshot(options)).availability; + } + async inspectReadinessSnapshot( + options?: AnalysisProviderInspectionOptions, + ): Promise { + const config = await this.#configuration(options); + const capabilities = config.ok + ? idaCapabilities( + config.value.mode, + "command" in config.value, + config.value.protocol === "native", + ) + : this.capabilities(); if (config.ok && config.value.mode === "headless") { const privacy = privateRuntimeRootCapability(); if (!privacy.available) return { - status: "unavailable", - code: privacy.code, - reason: privacy.reason, - diagnostics: { - configured: true, - mode: "headless", - live_connection_probed: false, - private_workspace_available: false, - windows_native_failure: { - stage: privacy.failureStage, - remediation: privacy.remediation, + availability: { + status: "unavailable" as const, + code: privacy.code, + reason: privacy.reason, + diagnostics: { + configured: true, + mode: "headless", + live_connection_probed: false, + private_workspace_available: false, + windows_native_failure: { + stage: privacy.failureStage, + remediation: privacy.remediation, + }, }, }, + capabilities, }; } - return config.ok + const availability: ProviderAvailability = config.ok ? { - status: "available", + status: "available" as const, code: null, reason: null, diagnostics: { @@ -95,7 +206,7 @@ export class IdaProvider implements AnalysisProviderCandidate { }, } : { - status: "unavailable", + status: "unavailable" as const, code: this.config.idaMcpConfigPath === undefined ? "not_configured" @@ -103,6 +214,7 @@ export class IdaProvider implements AnalysisProviderCandidate { reason: config.error.message, diagnostics: { configured: false, live_connection_probed: false }, }; + return { availability, capabilities }; } inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport { const diagnostics = { @@ -144,74 +256,139 @@ export class IdaProvider implements AnalysisProviderCandidate { "IDA does not admit headerless raw binaries. Open the target with Ghidra and supply architecture and entry_point.", diagnostics, }; + return target.format === "mach-o-universal" + ? { + status: "unknown", + code: null, + reason: + "IDA mode has not been resolved, so universal Mach-O slice identity is unknown.", + diagnostics, + } + : { status: "supported", code: null, reason: null, diagnostics }; + } + async inspectTargetSupportWithOptions( + target: BinaryTarget, + options: AnalysisProviderInspectionOptions, + ): Promise { + const base = this.inspectTargetSupport(target); + if (base.status === "unsupported" || target.format !== "mach-o-universal") + return base; // Attached identity is the recorded whole-input digest, which every slice // of a universal file shares, so it cannot prove the GUI loaded the slice // admission selected. Headless mode thins that slice into its own input. - const config = this.#configuration(); - if ( - target.format === "mach-o-universal" && - config.ok && - config.value.mode === "attached" - ) + const config = await this.#configuration(options); + if (!config.ok) + return { + status: "unknown", + code: null, + reason: "IDA mode could not be resolved from the registration.", + diagnostics: base.diagnostics, + }; + if (config.value.mode === "attached") return { status: "unsupported", code: "target_format_unsupported", reason: `Attached IDA records only the whole-file digest of a universal Mach-O, so REA cannot verify that the open database is its ${target.architecture} slice. Extract that slice with lipo -thin and open the thin file in both IDA and REA, or use headless mode.`, - diagnostics: { ...diagnostics, mode: "attached" }, + diagnostics: { ...base.diagnostics, mode: "attached" }, }; - return { status: "supported", code: null, reason: null, diagnostics }; + return { + status: "supported", + code: null, + reason: null, + diagnostics: base.diagnostics, + }; } async resolveAnalysisProfile( - _target: BinaryTarget, + target: BinaryTarget, options?: AnalysisProfileResolutionOptions, ) { if (options?.signal?.aborted === true) - return err(new AnalysisCancelledError("ida:profile")); - const config = this.#configuration(); + return err( + new AnalysisCancelledError("ida:profile", { + cause: options.signal.reason, + }), + ); + const config = await this.#configuration(options); if (!config.ok) return config; + const profile = createAnalysisProfile(IDA_PROVIDER_IDENTITY, { + version_scope: "rea-ida-adapter", + engine_version: null, + upstream_distribution_version: null, + mode: config.value.mode, + compatibility_profile: + config.value.protocol === "native" + ? "native-ida-mcp" + : config.value.mode === "attached" + ? "legacy-1.4" + : "database-supervisor", + database_revision: null, + cache_policy: "live", + registration_digest: createHash("sha256") + .update(canonicalize(config.value) ?? "") + .digest("hex"), + }); + if (options?.scope !== undefined) + this.#registrationByScope.delete(options.scope); + const registration = config.value; + const createClient: AnalysisProviderBoundClientFactory = ( + requestedTarget, + requestedProfile, + ) => + isDeepStrictEqual(requestedTarget, target) && + analysisProfilesEqual(requestedProfile, profile) + ? this.#createBoundClient(registration, target, profile) + : this.#unboundClient( + "IDA client factory received a target or profile different from the resolved binding.", + ); return ok({ - profile: createAnalysisProfile(IDA_PROVIDER_IDENTITY, { - version_scope: "rea-ida-adapter", - engine_version: null, - upstream_distribution_version: null, - mode: config.value.mode, - compatibility_profile: - config.value.protocol === "native" - ? "native-ida-mcp" - : config.value.mode === "attached" - ? "legacy-1.4" - : "database-supervisor", - database_revision: null, - cache_policy: "live", - registration_digest: createHash("sha256") - .update(canonicalize(config.value) ?? "") - .digest("hex"), - }), + profile, + createClient, }); } createClient( - target: BinaryTarget, - profile?: AnalysisProfileCommitment, + _target: BinaryTarget, + _profile?: AnalysisProfileCommitment, + ): AnalysisClient { + return this.#unboundClient( + "IDA client creation requires the provider-owned selected profile binding.", + ); + } + async #configuration(options?: AnalysisProviderInspectionOptions) { + const scope = options?.scope; + if (scope?.active === true) { + const existing = this.#registrationByScope.get(scope); + if (existing !== undefined) return existing; + } + const signal = options?.signal; + const pending = + this.config.idaMcpConfigPath === undefined + ? Promise.resolve(readIdaConfigurationNotConfigured()) + : readIdaConfiguration(this.config.idaMcpConfigPath, { + ...(signal === undefined ? {} : { signal }), + resourceScope: options?.resourceScope ?? this.#inspectionOwner, + }); + if (scope?.active === true) this.#registrationByScope.set(scope, pending); + const registration = await pending; + return registration; + } + #createBoundClient( + config: IdaConfiguration, + target: BinaryTarget, + profile: AnalysisProfileCommitment, ): AnalysisClient { - const config = this.#configuration(); - if (!config.ok) - return { - execute: async () => err(config.error), - close: async () => ok(null), - }; return new IdaSessionClient( - config.value, + config, target, - this.connectionFactory(config.value, this.environment), + this.connectionFactory(config, this.environment), profile, ); } - #configuration() { - this.#registration ??= - this.config.idaMcpConfigPath === undefined - ? readIdaConfigurationNotConfigured() - : readIdaConfiguration(this.config.idaMcpConfigPath); - return this.#registration; + #unboundClient(reason: string): AnalysisClient { + const error = idaConfigurationError(reason); + return { + execute: async () => err(error), + close: async () => ok(null), + }; } } diff --git a/src/ida/IdaWorkspace.ts b/src/ida/IdaWorkspace.ts index c7d9e642c4..28f1bd0fe1 100644 --- a/src/ida/IdaWorkspace.ts +++ b/src/ida/IdaWorkspace.ts @@ -20,6 +20,10 @@ export const sameIdaHostPath = (left: string, right: string): boolean => ? normalize(left).toLowerCase() === normalize(right).toLowerCase() : normalize(left) === normalize(right); +/** Resolve the exact existing parent used by both readiness and allocation. */ +export const resolveIdaWorkspaceParent = (root = tmpdir()): Promise => + realpath(normalize(root)); + /** Private input copy isolates upstream database and sidecar writes from the original. */ export class IdaWorkspace { readonly sessionId = `rea-${randomUUID()}`; @@ -48,7 +52,7 @@ export class IdaWorkspace { // Upstream reports canonical paths (macOS /var -> /private/var), so the // private input path must already be canonical for identity checks. const runtime = await PrivateRuntimeRoot.create({ - parent: await realpath(normalize(root)), + parent: await resolveIdaWorkspaceParent(root), prefix: "rea-ida-", }); const inputPath = join(runtime.path, basename(target.path)); diff --git a/tests/boundary/cli/idaDoctorRegistration.test.ts b/tests/boundary/cli/idaDoctorRegistration.test.ts new file mode 100644 index 0000000000..83c61e7aba --- /dev/null +++ b/tests/boundary/cli/idaDoctorRegistration.test.ts @@ -0,0 +1,81 @@ +import { execFileSync } from "node:child_process"; +import { 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")( + "doctor rejects an IDA registration FIFO without waiting for a writer", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-ida-doctor-fifo-"); + const registration = join(root, "registration.json"); + execFileSync("mkfifo", [registration], { timeout: 5_000 }); + const result = await cli.run({ + arguments: ["doctor", "--provider", "ida", "--json"], + environment: { HOME: root, REA_IDA_MCP_CONFIG: registration }, + timeoutMs: 10_000, + }); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + providerInspections: expect.arrayContaining([ + expect.objectContaining({ + id: "ida", + available: false, + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "registration", + ok: false, + detail: expect.stringContaining("regular JSON file"), + }), + ]), + }), + ]), + }); + }, +); + +cliTest.skipIf(process.platform !== "darwin").for([true, false])( + "doctor checks the IDA workspace parent without creating entries (usable=%s)", + async (usable, { cli }) => { + const root = await createTestTempDirectory("rea-ida-doctor-parent-"); + const parent = join(root, "parent"); + await mkdir(parent); + // Keep the caller's unnormalized spelling; allocation normalizes it too. + const workspaceRoot = usable + ? `${parent}/missing/../.` + : join(parent, "absent"); + const registration = join(root, "registration.json"); + await writeFile( + registration, + JSON.stringify({ + command: join(root, "provider-must-not-launch"), + mode: "headless", + workspaceRoot, + }), + ); + const result = await cli.run({ + arguments: ["doctor", "--provider", "ida", "--json"], + environment: { HOME: root, REA_IDA_MCP_CONFIG: registration }, + timeoutMs: 10_000, + }); + expect(result.exitCode, result.stderr).toBe(usable ? 0 : 1); + expect(result.json).toMatchObject({ + providerInspections: expect.arrayContaining([ + expect.objectContaining({ + id: "ida", + available: usable, + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "private_workspace", + ok: usable, + detail: expect.stringContaining(workspaceRoot), + }), + ]), + }), + ]), + }); + expect(await readdir(parent)).toEqual([]); + }, +); diff --git a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts index bfcfe5c9d5..637b200d82 100644 --- a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts +++ b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts @@ -16,9 +16,47 @@ import { } from "../../../src/application/SetupClientConfiguration.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; -import { clientRegistrationEnvironmentDetails } from "../../../src/application/ClientRegistrationEnvironment.js"; +import { + clientRegistrationEnvironmentDetails, + clientRegistrationEnvironmentDetailsWithBudget, +} from "../../../src/application/ClientRegistrationEnvironment.js"; import { snapshotEnvironment } from "../../../src/process/snapshotEnvironment.js"; import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { ok } from "../../../src/domain/result.js"; + +it("awaits the owner-parsed provider request budget without changing caller strings", async () => { + const parsed = parseClientConfiguration( + JSON.stringify({ + mcpServers: { + rea: { + command: "rea", + args: ["mcp"], + env: { + REA_ANALYSIS_PROVIDER: "ida", + REA_IDA_MCP_CONFIG: "/tmp/rea-registration.json/", + }, + }, + }, + }), + "json", + ); + const details = await clientRegistrationEnvironmentDetailsWithBudget( + parsed, + {}, + "effective", + async (provider) => { + expect(provider).toBe("ida"); + return ok(17); + }, + ); + expect(details).toMatchObject({ + ok: true, + value: { + values: { REA_IDA_MCP_CONFIG: "/tmp/rea-registration.json/" }, + timeoutInputs: { providerRequestTimeoutMs: 17 }, + }, + }); +}); it.each(["win32", "linux"] as const)( "uses the selected %s identity when inspecting lowercase registration settings", diff --git a/tests/composition/idaProvider.test.ts b/tests/composition/idaProvider.test.ts index a5462570f1..c32398fed6 100644 --- a/tests/composition/idaProvider.test.ts +++ b/tests/composition/idaProvider.test.ts @@ -113,7 +113,7 @@ describe("IDA provider composition", () => { if (!config.ok) throw config.error; const producer = new RecordingIdaMcp(target, "headless"); const provider = new IdaProvider(config.value, {}, () => producer); - expect(provider.inspectAvailability()).toMatchObject({ + expect(await provider.inspectAvailability()).toMatchObject({ status: "available", diagnostics: { live_connection_probed: false }, }); @@ -128,9 +128,9 @@ describe("IDA provider composition", () => { expect(JSON.stringify(resolution)).not.toContain("private-fixture-token"); expect(producer.connects).toBe(0); expect( - provider - .capabilities() - .find(({ operation }) => operation === "procedure_callers")?.available, + (await provider.inspectReadinessSnapshot()).capabilities.find( + ({ operation }) => operation === "procedure_callers", + )?.available, ).toBe(false); }); it( @@ -191,6 +191,91 @@ describe("IDA provider composition", () => { }); }); +describe("IDA provider selection cancellation", () => { + it("restores a cancelled replacement through the prior selected registration binding", async () => { + const first = await createIdaTarget(); + const second = await createIdaTarget(); + roots.push(first.root, second.root); + const path = join(first.root, "registration.json"); + await writeFile(path, JSON.stringify({ command: "first-server" })); + const config = parseConfig({ REA_IDA_MCP_CONFIG: path }); + if (!config.ok) throw config.error; + const factorySelections: string[] = []; + const controller = new AbortController(); + const cancellationReason = new Error( + "cancel after retiring previous IDA client", + ); + let cancelWhenPreviousCloses = false; + const provider = new IdaProvider(config.value, {}, (registration) => { + const command = + "command" in registration ? registration.command : registration.url; + factorySelections.push(command); + const producer = new RecordingIdaMcp(first.target, "attached"); + if (factorySelections.length === 1) { + const close = producer.close.bind(producer); + producer.close = async () => { + await close(); + if (cancelWhenPreviousCloses) controller.abort(cancellationReason); + }; + } + return producer; + }); + const session = createTestBinarySession(provider); + expect((await session.open(first.target.path)).ok).toBe(true); + expect( + (await session.execute("procedure_pseudo_code", { procedure: "main" })) + .ok, + ).toBe(true); + await writeFile(path, JSON.stringify({ command: "replacement-server" })); + cancelWhenPreviousCloses = true; + const replacement = await session.open(second.target.path, { + signal: controller.signal, + }); + expect(replacement.ok).toBe(false); + expect(replacement).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", cause: cancellationReason }, + }); + expect(session.activeTarget()?.sha256).toBe(first.target.sha256); + expect( + (await session.execute("procedure_pseudo_code", { procedure: "main" })) + .ok, + ).toBe(true); + expect(factorySelections).toEqual(["first-server", "first-server"]); + expect((await session.close()).ok).toBe(true); + }); + it("preserves an already-aborted profile signal without reading registration", async () => { + const { root, target } = await createIdaTarget(); + roots.push(root); + const config = parseConfig({ + REA_IDA_MCP_CONFIG: join(root, "registration-that-must-not-be-read.json"), + }); + if (!config.ok) throw config.error; + const reason = new Error("cancel before IDA profile inspection"); + const controller = new AbortController(); + controller.abort(reason); + let runCalls = 0; + const profile = await new IdaProvider( + config.value, + {}, + ).resolveAnalysisProfile(target, { + signal: controller.signal, + resourceScope: { + retain: () => undefined, + run: async (operation) => { + runCalls += 1; + return operation(); + }, + }, + }); + expect(profile).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", cause: reason }, + }); + expect(runCalls).toBe(0); + }); +}); + describe("IDA universal Mach-O target identity", () => { const openUniversal = async (registration: Record) => { const root = await createTestTempDirectory("rea-ida-universal-");