fix(ida): own registration reads and bind selected configuration
Read bounded regular registrations asynchronously with retained descriptor cleanup. Carry the selected parsed configuration through provider factories and restoration, preserve exact cancellation reasons, and share workspace-parent resolution with doctor checks. Closes #5561 Closes #5559 Closes #5604
This commit is contained in:
1 parent
7d6f90485e
commit
dd0015aaf1
23 files changed
+1358
-216
No files matched your search
@@ -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 ||
|
||||
|
||||
@@ -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<Record<string, JsonValue>>;
|
||||
}
|
||||
| {
|
||||
readonly status: "supported";
|
||||
readonly code: null;
|
||||
@@ -365,6 +380,10 @@ export interface AnalysisProviderCandidate extends AnalysisProvider {
|
||||
| AnalysisProviderReadinessSnapshot
|
||||
| Promise<AnalysisProviderReadinessSnapshot>;
|
||||
inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport;
|
||||
inspectTargetSupportWithOptions?(
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProviderInspectionOptions,
|
||||
): Promise<ProviderTargetSupport>;
|
||||
/** Observe retained candidate cleanup without starting or retrying work. */
|
||||
hasPendingOwnedResources?(): boolean;
|
||||
/** Release candidate resources that are independent of any active client. */
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { AnalysisProviderSelector } from "../contracts/providerSelection.js
|
||||
export type ProviderRequestBudgetResolver = (
|
||||
analysisProvider: AnalysisProviderSelector,
|
||||
environment: Readonly<Record<string, string>>,
|
||||
) => Result<number | undefined, ConfigurationError>;
|
||||
) => Promise<Result<number | undefined, ConfigurationError>>;
|
||||
|
||||
export interface ClientRegistrationEnvironmentDetails {
|
||||
readonly values: Readonly<Record<string, string>>;
|
||||
@@ -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<Record<string, string>> = {},
|
||||
validation: "fragment" | "effective" = "effective",
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Result<ClientRegistrationEnvironmentDetails, ConfigurationError> => {
|
||||
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<Record<string, string>>,
|
||||
validation: "fragment" | "effective",
|
||||
resolveProviderRequestBudget: ProviderRequestBudgetResolver | undefined,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<
|
||||
Result<ClientRegistrationEnvironmentDetails, ConfigurationError>
|
||||
> => {
|
||||
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<Record<string, string>>,
|
||||
validation: "fragment" | "effective",
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Result<ClientRegistrationEnvironmentDetails, ConfigurationError> => {
|
||||
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<ClientRegistrationEnvironmentDetails, ConfigurationError>
|
||||
> => {
|
||||
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 }),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -486,7 +486,12 @@ export interface SystemDoctorHostOptions {
|
||||
readonly providerRegistrationEnvironment?: () => Readonly<
|
||||
Record<string, string>
|
||||
>;
|
||||
readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
|
||||
readonly resolveProviderRequestBudget?: (
|
||||
provider: Parameters<ProviderRequestBudgetResolver>[0],
|
||||
environment: Parameters<ProviderRequestBudgetResolver>[1],
|
||||
resources: RetainedResourceScope,
|
||||
signal: AbortSignal,
|
||||
) => ReturnType<ProviderRequestBudgetResolver>;
|
||||
readonly linuxDemoRuntimeCheck?: () => Promise<DoctorCheck>;
|
||||
}
|
||||
|
||||
@@ -515,6 +520,19 @@ export const systemDoctorHost = (
|
||||
}),
|
||||
run: (operation) => resources.run(operation),
|
||||
};
|
||||
const resolveProviderRequestBudget =
|
||||
options.resolveProviderRequestBudget === undefined
|
||||
? undefined
|
||||
: (
|
||||
provider: Parameters<ProviderRequestBudgetResolver>[0],
|
||||
environment: Parameters<ProviderRequestBudgetResolver>[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,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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<unknown> => {
|
||||
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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<readonly AnalysisProviderCandidateStatus[], AnalysisError>
|
||||
@@ -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<AnalysisProviderCandidateStatus> => {
|
||||
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<AnalysisProviderCandidateStatus> => {
|
||||
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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, AnalysisProvider>;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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-");
|
||||
|
||||
+45
-3
@@ -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<NodeJS.ProcessEnv> = process.env,
|
||||
signal?: AbortSignal,
|
||||
): DoctorHost => {
|
||||
const registrationReads = new WeakMap<
|
||||
RetainedResourceScope,
|
||||
Map<string, ReturnType<typeof readIdaConfiguration>>
|
||||
>();
|
||||
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<Record<string, string>> =
|
||||
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,
|
||||
|
||||
@@ -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<void> }[] = [];
|
||||
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<void> }[] = [];
|
||||
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<void> }[] = [];
|
||||
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<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const closeGate = new Promise<void>((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({
|
||||
|
||||
+151
-29
@@ -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<IdaConfiguration, ConfigurationError> => {
|
||||
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<Result<IdaConfiguration, ConfigurationError>> =>
|
||||
options.resourceScope.run(async () => {
|
||||
let closeLease: AnalysisCleanupLease | undefined;
|
||||
try {
|
||||
const text = await withOwnedRegularFile<string>(
|
||||
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 = (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+60
-13
@@ -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<Result<IdaConfiguration, ConfigurationError>>,
|
||||
): Promise<DoctorProviderInspection> => {
|
||||
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<DoctorProviderCheck> => {
|
||||
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<string | undefined> => {
|
||||
try {
|
||||
const parent = await resolveIdaWorkspaceParent(root);
|
||||
if (!(await stat(parent)).isDirectory())
|
||||
return `IDA workspaceRoot ${root ?? "the system temporary directory"} is not a directory.`;
|
||||
await access(parent, constants.W_OK | constants.X_OK);
|
||||
return undefined;
|
||||
} catch (cause: unknown) {
|
||||
return `IDA workspaceRoot ${root ?? "the system temporary directory"} is not a usable directory: ${cause instanceof Error ? cause.message : String(cause)}`;
|
||||
}
|
||||
};
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
+245
-68
@@ -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<IdaConfiguration, ConfigurationError> | 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<Result<IdaConfiguration, ConfigurationError>>
|
||||
>();
|
||||
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<string, (typeof profiles)[number][]>();
|
||||
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<ProviderAvailability> {
|
||||
return (await this.inspectReadinessSnapshot(options)).availability;
|
||||
}
|
||||
async inspectReadinessSnapshot(
|
||||
options?: AnalysisProviderInspectionOptions,
|
||||
): Promise<AnalysisProviderReadinessSnapshot> {
|
||||
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<ProviderTargetSupport> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string> =>
|
||||
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));
|
||||
|
||||
@@ -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([]);
|
||||
},
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
const root = await createTestTempDirectory("rea-ida-universal-");
|
||||
|
||||
Reference in new issue
Block a user