mirror of
https://github.com/morluto/rea.git
synced 2026-10-29 19:05:45 +00:00
BREAKING CHANGE: binary_overview segment length is null when Hopper coordinates do not establish a finite same-address-space extent. Consumers must preserve this unknown instead of treating it as zero or a saturated value.
853 lines
30 KiB
TypeScript
853 lines
30 KiB
TypeScript
import { z } from "zod";
|
|
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
|
|
import {
|
|
createAnalysisExecution,
|
|
type AnalysisClient,
|
|
type AnalysisOperation,
|
|
type ExecutionOptions,
|
|
} from "../application/AnalysisProvider.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisCapabilityUnavailableError,
|
|
AnalysisProtocolError,
|
|
AnalysisOutputError,
|
|
AnalysisInputError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
|
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
|
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import type { IdaConfiguration } from "./IdaConfiguration.js";
|
|
import { idaProtocol } from "./IdaConfiguration.js";
|
|
import type { IdaMcpConnection } from "./IdaMcpConnection.js";
|
|
import { nativeTools } from "./IdaNativeProtocol.js";
|
|
import { readSettledHealth } from "./IdaBusyHealth.js";
|
|
import { isX86OnlyTarget } from "./IdaCallGraph.js";
|
|
import { IdaOperationRunner } from "./IdaOperationRunner.js";
|
|
import {
|
|
IDA_LIMITATIONS,
|
|
IDA_OPERATIONS,
|
|
IDA_PROVIDER_IDENTITY,
|
|
idaProfileAdmits,
|
|
type IdaOperation,
|
|
} from "./IdaProviderCapabilities.js";
|
|
import {
|
|
absentDatabaseCloseSchema,
|
|
closeDatabaseSchema,
|
|
databaseListSchema,
|
|
legacyMetadataSchema,
|
|
modernHealthSchema,
|
|
openDatabaseSchema,
|
|
rejectedOpenDatabaseSchema,
|
|
} from "./IdaProtocolValues.js";
|
|
import {
|
|
idaFileDigest,
|
|
IdaWorkspace,
|
|
sameIdaHostPath,
|
|
} from "./IdaWorkspace.js";
|
|
import { parseIdaInput } from "./IdaInput.js";
|
|
import { createEvidence } from "../domain/evidence.js";
|
|
|
|
const legacyTools = [
|
|
"get_metadata",
|
|
"list_functions",
|
|
"list_strings",
|
|
"get_function_by_name",
|
|
"get_function_by_address",
|
|
"decompile_function",
|
|
"disassemble_function",
|
|
"get_callers",
|
|
"get_callees",
|
|
"get_xrefs_to",
|
|
];
|
|
const modernTools = [
|
|
"idb_open",
|
|
"idb_list",
|
|
"idb_close",
|
|
"server_health",
|
|
"list_funcs",
|
|
"lookup_funcs",
|
|
"find_regex",
|
|
"decompile",
|
|
"disasm",
|
|
"callees",
|
|
"xrefs_to",
|
|
];
|
|
const operationSet: ReadonlySet<string> = new Set(IDA_OPERATIONS);
|
|
|
|
const isIdaOperation = (operation: string): operation is IdaOperation =>
|
|
operationSet.has(operation);
|
|
const wasCancelled = (options: ExecutionOptions | undefined): boolean =>
|
|
options?.signal?.aborted === true;
|
|
|
|
interface IdaOperationFailureContext {
|
|
runner: IdaOperationRunner | undefined;
|
|
metadataBefore: JsonValue | undefined;
|
|
metadataAfter: JsonValue | undefined;
|
|
postOperationIdentityCheck: "not_attempted" | "started" | "verified";
|
|
parameters: Readonly<Record<string, JsonValue>>;
|
|
}
|
|
|
|
/** Upstream session ids of inventory entries, omitting unreported ones. */
|
|
interface UpstreamSession {
|
|
readonly session_id: string;
|
|
readonly backend: "worker" | "gui";
|
|
/** Upstream ownership; null when no inventory has reported it. */
|
|
readonly owned: boolean | null;
|
|
/** Last inventoried input binding; null until upstream reports it. */
|
|
readonly input_path: string | null;
|
|
/** Last inventoried activity; null before upstream reports it. */
|
|
readonly is_active: boolean | null;
|
|
}
|
|
|
|
/** A failed cleanup retains its private workspace and reports the remaining resource. */
|
|
class IdaCleanupError extends ProviderAdapterError {
|
|
override readonly cleanupIncomplete = true;
|
|
override readonly cleanupResources: readonly string[];
|
|
constructor(
|
|
path: string,
|
|
cause: unknown,
|
|
sessions?: readonly UpstreamSession[],
|
|
) {
|
|
super("ida", "close", {
|
|
cause,
|
|
capturedOutput:
|
|
cause instanceof AnalysisError ? cause.capturedOutput : undefined,
|
|
diagnostics: {
|
|
...(cause instanceof AnalysisError ? cause.diagnostics : undefined),
|
|
reason:
|
|
"IDA cleanup could not be verified; the remaining connection or private workspace is retained for retry.",
|
|
failure_reason:
|
|
cause instanceof AnalysisError
|
|
? (cause.diagnostics?.reason ?? cause.message)
|
|
: cause instanceof Error
|
|
? cause.message
|
|
: String(cause),
|
|
workspace: path,
|
|
...(sessions === undefined
|
|
? {}
|
|
: {
|
|
upstream_sessions: {
|
|
state: "unverified",
|
|
sessions: sessions.map(
|
|
({ session_id, backend, owned, input_path, is_active }) => ({
|
|
session_id,
|
|
backend,
|
|
owned,
|
|
input_path,
|
|
is_active,
|
|
}),
|
|
),
|
|
reason:
|
|
"Release of upstream sessions matching the requested session ID or private input was not confirmed; input_path preserves the last observed binding and is null when no inventory has reported it. is_active preserves registry activity and is null when unknown; it does not establish process liveness. A worker outlives its supervisor and may keep running until it finishes and reaches its idle timeout; exiting REA does not stop it. A gui session belongs to an IDA instance REA does not control. An empty session_id means upstream reported none; owned is null when no inventory has reported ownership; an empty list means the requested worker's close was confirmed but no inventory has identified any remaining session.",
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
this.cleanupResources = [path];
|
|
}
|
|
}
|
|
|
|
/** Serial read-only session whose lifecycle authority excludes an attached GUI. */
|
|
export class IdaSessionClient implements AnalysisClient {
|
|
#tail: Promise<void> = Promise.resolve();
|
|
#startup: Promise<void> | undefined;
|
|
#closed = false;
|
|
#closing = false;
|
|
#closePromise: Promise<Result<null, AnalysisError>> | undefined;
|
|
#workspace: IdaWorkspace | undefined;
|
|
#database: string | undefined;
|
|
#databaseCloseCompleted = false;
|
|
#openAttempted = false;
|
|
#openCompleted = false;
|
|
#ready = false;
|
|
#blockingSessions: readonly UpstreamSession[] | undefined;
|
|
#openRejected = false;
|
|
#callGraphAdvertised = false;
|
|
#surveyAdvertised = false;
|
|
#initialIdentity: string | undefined;
|
|
#metadata: JsonValue = null;
|
|
#lifecycle: JsonValue[] = [];
|
|
|
|
constructor(
|
|
private readonly config: IdaConfiguration,
|
|
private readonly target: BinaryTarget,
|
|
private readonly connection: IdaMcpConnection,
|
|
private readonly profile?: AnalysisProfileCommitment,
|
|
) {}
|
|
|
|
execute(
|
|
operation: AnalysisOperation,
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
options?: ExecutionOptions,
|
|
) {
|
|
const call = this.#tail.then(() =>
|
|
this.#execute(operation, parameters, options),
|
|
);
|
|
this.#tail = call.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return call;
|
|
}
|
|
|
|
async executeIfReady(
|
|
operation: AnalysisOperation,
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
options?: ExecutionOptions,
|
|
) {
|
|
if (!this.#ready || this.#closed || this.#closing)
|
|
return Promise.resolve(undefined);
|
|
const call = this.#tail.then(() => {
|
|
if (!this.#ready || this.#closed || this.#closing) return undefined;
|
|
if (wasCancelled(options))
|
|
return err(new AnalysisCancelledError(operation));
|
|
return this.#execute(operation, parameters, options, true);
|
|
});
|
|
this.#tail = call.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
const result = await waitForAbortable(call, options?.signal);
|
|
return result === ABORTED
|
|
? err(new AnalysisCancelledError(operation))
|
|
: result;
|
|
}
|
|
|
|
async #execute(
|
|
operation: AnalysisOperation,
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
options?: ExecutionOptions,
|
|
readyOnly = false,
|
|
) {
|
|
if (this.#closed || this.#closing)
|
|
return err(
|
|
new AnalysisProtocolError(
|
|
"IDA session is closing or closed; reopen the target.",
|
|
),
|
|
);
|
|
if (wasCancelled(options))
|
|
return err(new AnalysisCancelledError(operation));
|
|
if (operation !== "health" && !isIdaOperation(operation))
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"ida",
|
|
operation,
|
|
"This integration admits read-only analysis operations only.",
|
|
),
|
|
);
|
|
if (
|
|
operation !== "health" &&
|
|
!idaProfileAdmits(operation, this.config.mode)
|
|
)
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"ida",
|
|
operation,
|
|
"This operation is available for the modern headless profile only.",
|
|
),
|
|
);
|
|
if (
|
|
operation === "procedure_callers" &&
|
|
idaProtocol(this.config) !== "legacy"
|
|
)
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"ida",
|
|
operation,
|
|
"This upstream profile does not prove direct callers.",
|
|
),
|
|
);
|
|
if (operation !== "health") {
|
|
const parsed = parseIdaInput(operation, parameters);
|
|
if (!parsed.ok) return parsed;
|
|
parameters = parsed.value;
|
|
}
|
|
const document = parameters.document;
|
|
if (
|
|
document !== undefined &&
|
|
(typeof document !== "string" ||
|
|
(!sameIdaHostPath(document, this.target.path) &&
|
|
(this.target.sourcePath === undefined ||
|
|
!sameIdaHostPath(document, this.target.sourcePath))))
|
|
)
|
|
return err(
|
|
new AnalysisInputError(operation, undefined, [
|
|
{
|
|
path: ["document"],
|
|
reason: "invalid_value",
|
|
expected: this.target.path,
|
|
message:
|
|
"IDA document must be the REA-bound target; omit document or use its admitted path.",
|
|
},
|
|
]),
|
|
);
|
|
const failureContext: IdaOperationFailureContext = {
|
|
runner: undefined,
|
|
metadataBefore: undefined,
|
|
metadataAfter: undefined,
|
|
postOperationIdentityCheck: "not_attempted",
|
|
parameters,
|
|
};
|
|
try {
|
|
if (!readyOnly) await this.#start();
|
|
const before = await this.#observe(options);
|
|
failureContext.metadataBefore = before;
|
|
if (wasCancelled(options)) throw new AnalysisCancelledError(operation);
|
|
if (operation === "health") return this.#health(before);
|
|
if (!isIdaOperation(operation))
|
|
throw new AnalysisProtocolError(
|
|
"IDA operation changed after validation.",
|
|
);
|
|
const runner = this.#runner(options);
|
|
failureContext.runner = runner;
|
|
const result = await runner.run(operation, parameters);
|
|
if (wasCancelled(options)) throw new AnalysisCancelledError(operation);
|
|
failureContext.postOperationIdentityCheck = "started";
|
|
const after = await this.#observe(
|
|
options,
|
|
(observed) => (failureContext.metadataAfter = observed),
|
|
);
|
|
failureContext.postOperationIdentityCheck = "verified";
|
|
if (wasCancelled(options)) throw new AnalysisCancelledError(operation);
|
|
return ok(
|
|
createAnalysisExecution(result, IDA_PROVIDER_IDENTITY, {
|
|
rawResult: {
|
|
upstream: this.connection.serverInfo(),
|
|
metadata_before: before,
|
|
metadata_after: after,
|
|
observations: jsonValueSchema.parse(runner.raw),
|
|
},
|
|
limitations: [...IDA_LIMITATIONS, ...runner.limitations],
|
|
...(runner.relationshipCoverage === undefined
|
|
? {}
|
|
: { relationshipCoverage: runner.relationshipCoverage }),
|
|
...(runner.referencesTruncated !== undefined
|
|
? { referencesTruncated: runner.referencesTruncated }
|
|
: {}),
|
|
locations: [
|
|
{ kind: "artifact-path", path: this.target.path },
|
|
...runner.locations,
|
|
],
|
|
...(this.profile === undefined
|
|
? {}
|
|
: { analysisProfile: this.profile }),
|
|
}),
|
|
);
|
|
} catch (cause: unknown) {
|
|
return this.#failure(operation, cause, options, failureContext);
|
|
}
|
|
}
|
|
|
|
#runner(options: ExecutionOptions | undefined): IdaOperationRunner {
|
|
return new IdaOperationRunner(
|
|
this.connection,
|
|
idaProtocol(this.config),
|
|
this.#database,
|
|
{
|
|
signal: options?.signal,
|
|
selectorOrigin: options?.selectorOrigin,
|
|
x86Only: isX86OnlyTarget(this.target),
|
|
callGraphAvailable: this.#callGraphAdvertised,
|
|
surveyAvailable: this.#surveyAdvertised,
|
|
admittedDocument: this.target.path,
|
|
},
|
|
);
|
|
}
|
|
|
|
#health(metadata: JsonValue) {
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
ready: true,
|
|
mode: this.config.mode,
|
|
database: this.#database ?? null,
|
|
upstream: this.connection.serverInfo(),
|
|
metadata,
|
|
lifecycle: this.#lifecycle,
|
|
},
|
|
IDA_PROVIDER_IDENTITY,
|
|
{
|
|
limitations: IDA_LIMITATIONS,
|
|
...(this.profile === undefined
|
|
? {}
|
|
: { analysisProfile: this.profile }),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
#failure(
|
|
operation: string,
|
|
cause: unknown,
|
|
options: ExecutionOptions | undefined,
|
|
context: IdaOperationFailureContext,
|
|
) {
|
|
let failure: AnalysisError;
|
|
if (wasCancelled(options))
|
|
failure = new AnalysisCancelledError(operation, {
|
|
cause,
|
|
...(cause instanceof AnalysisError
|
|
? {
|
|
capturedOutput: cause.capturedOutput,
|
|
diagnostics: cause.diagnostics,
|
|
partialObservation: cause.partialObservation,
|
|
cleanup:
|
|
cause.cleanup ??
|
|
(cause.cleanupIncomplete
|
|
? {
|
|
reason: cause.message,
|
|
resources: cause.cleanupResources,
|
|
}
|
|
: undefined),
|
|
}
|
|
: {}),
|
|
});
|
|
else if (
|
|
cause instanceof ProviderAdapterError &&
|
|
cause.operation === "transport"
|
|
)
|
|
failure = new ProviderAdapterError("ida", operation, {
|
|
cause,
|
|
capturedOutput: cause.capturedOutput,
|
|
partialObservation: cause.partialObservation,
|
|
cleanup: cause.cleanup,
|
|
diagnostics: {
|
|
...cause.diagnostics,
|
|
recovery:
|
|
"Check the configured upstream MCP registration and selected target; see docs/ida-provider.md.",
|
|
},
|
|
});
|
|
else if (
|
|
cause instanceof AnalysisError &&
|
|
(cause.partialObservation !== undefined ||
|
|
cause.cleanupIncomplete ||
|
|
cause.capturedOutput !== undefined)
|
|
)
|
|
failure = cause;
|
|
else if (cause instanceof AnalysisError) failure = cause;
|
|
else if (cause instanceof z.ZodError)
|
|
failure = new AnalysisOutputError(
|
|
operation,
|
|
`IDA MCP returned malformed or unsupported producer data: ${cause.issues.map((issue) => `${issue.path.join(".") || "$"}: ${issue.message}`).join("; ")}`,
|
|
{ cause },
|
|
);
|
|
else
|
|
failure = new ProviderAdapterError("ida", operation, {
|
|
cause,
|
|
diagnostics: {
|
|
reason:
|
|
cause instanceof Error ? cause.message : "Unknown upstream failure",
|
|
recovery:
|
|
"Check the configured upstream MCP registration and selected target; see docs/ida-provider.md.",
|
|
},
|
|
});
|
|
// Only operation calls made after target identity/readiness validation can
|
|
// become partial Evidence. The failing call itself is absent from `raw`
|
|
// when its transport or MCP envelope could not be decoded.
|
|
const { runner, metadataBefore, metadataAfter } = context;
|
|
if (
|
|
runner !== undefined &&
|
|
runner.raw.length > 0 &&
|
|
metadataBefore !== undefined
|
|
) {
|
|
const observations = jsonValueSchema.parse(runner.raw);
|
|
const upstream = this.connection.serverInfo();
|
|
const binding =
|
|
context.postOperationIdentityCheck !== "verified"
|
|
? {
|
|
status: "not_verified",
|
|
reason:
|
|
cause instanceof Error
|
|
? cause.message
|
|
: "Post-operation target identity was not observed.",
|
|
}
|
|
: { status: "verified" };
|
|
failure.retainPartialObservation(
|
|
createEvidence(this.target, IDA_PROVIDER_IDENTITY, {
|
|
predicateType: "analysis-observation",
|
|
operation,
|
|
parameters: context.parameters,
|
|
result: {
|
|
status: "partial",
|
|
failed_operation: operation,
|
|
observations,
|
|
metadata_before: metadataBefore,
|
|
target_binding: binding,
|
|
...(metadataAfter === undefined
|
|
? {}
|
|
: { metadata_after: metadataAfter }),
|
|
},
|
|
rawResult: {
|
|
upstream,
|
|
observations,
|
|
metadata_before: metadataBefore,
|
|
...(metadataAfter === undefined
|
|
? {}
|
|
: { metadata_after: metadataAfter }),
|
|
post_operation_identity_check: context.postOperationIdentityCheck,
|
|
},
|
|
confidence: "observed",
|
|
authority: "external-service",
|
|
limitations: [
|
|
...IDA_LIMITATIONS,
|
|
...runner.limitations,
|
|
"The requested IDA operation failed after these completed upstream replies; the partial Evidence does not establish a complete result.",
|
|
"Replies are bound to the selected target only by the pre-operation identity check unless post_operation_identity_check is verified.",
|
|
],
|
|
locations: [
|
|
{ kind: "artifact-path", path: this.target.path },
|
|
...runner.locations,
|
|
],
|
|
}),
|
|
);
|
|
}
|
|
return err(failure);
|
|
}
|
|
|
|
#start(): Promise<void> {
|
|
this.#startup ??= this.#initialize();
|
|
return this.#startup;
|
|
}
|
|
|
|
async #initialize(): Promise<void> {
|
|
const tools = new Set(await this.connection.connect());
|
|
this.#callGraphAdvertised = tools.has("callgraph");
|
|
this.#surveyAdvertised = tools.has("survey_binary");
|
|
const expected =
|
|
this.config.protocol === "native"
|
|
? nativeTools
|
|
: this.config.mode === "attached"
|
|
? legacyTools
|
|
: modernTools;
|
|
const missing = expected.filter((name) => !tools.has(name));
|
|
if (missing.length > 0) {
|
|
const summary = `IDA MCP ${this.config.protocol === "native" ? "native attached" : this.config.mode} compatibility profile is missing tools: ${missing.join(", ")}. Native attached mode requires the native IDA MCP tool catalog; legacy attached mode requires the legacy 1.4 profile; headless mode requires the database supervisor profile. See docs/ida-provider.md.`;
|
|
// The caller cannot act on a profile mismatch it never sees, so the
|
|
// missing names travel as adapter diagnostics rather than a bare message.
|
|
throw new ProviderAdapterError("ida", "initialize", {
|
|
userMessage: summary,
|
|
diagnostics: {
|
|
reason: summary,
|
|
mode: this.config.mode,
|
|
expected_profile:
|
|
this.config.protocol === "native"
|
|
? "native"
|
|
: this.config.mode === "attached"
|
|
? "legacy-1.4"
|
|
: "database-supervisor",
|
|
missing_tools: [...missing],
|
|
recovery:
|
|
"Install an IDA MCP build that serves the selected profile; see docs/ida-provider.md.",
|
|
},
|
|
});
|
|
}
|
|
if (this.config.mode === "headless") {
|
|
this.#workspace = await IdaWorkspace.allocate(
|
|
this.target,
|
|
this.config.workspaceRoot,
|
|
);
|
|
await this.#workspace.copyInput();
|
|
this.#openAttempted = true;
|
|
const raw = await this.connection.call("idb_open", {
|
|
input_path: this.#workspace.inputPath,
|
|
mode: "force_headless",
|
|
run_auto_analysis: true,
|
|
build_caches: true,
|
|
init_hexrays: true,
|
|
preferred_session_id: this.#workspace.sessionId,
|
|
});
|
|
this.#openCompleted = true;
|
|
// A completed but malformed/positive open may still own a live Worker.
|
|
// Only an explicit producer rejection keeps the old no-worker branch.
|
|
this.#openRejected = rejectedOpenDatabaseSchema.safeParse(raw).success;
|
|
this.#lifecycle.push(raw);
|
|
const opened = openDatabaseSchema.parse(raw);
|
|
if (
|
|
!sameIdaHostPath(opened.session.input_path, this.#workspace.inputPath)
|
|
)
|
|
throw new AnalysisProtocolError(
|
|
"IDA opened a different input than the private admitted target copy.",
|
|
);
|
|
if (opened.session.session_id !== this.#workspace.sessionId)
|
|
throw new AnalysisProtocolError(
|
|
"IDA returned an adopted or unexpected session instead of the requested private worker.",
|
|
);
|
|
const inventory = databaseListSchema.parse(
|
|
await this.connection.call("idb_list", {}),
|
|
);
|
|
this.#lifecycle.push(jsonValueSchema.parse(inventory));
|
|
const workspace = this.#workspace;
|
|
this.#recordBlockingSessions(
|
|
inventory.sessions.filter(
|
|
(entry) =>
|
|
entry.session_id === workspace.sessionId ||
|
|
sameIdaHostPath(entry.input_path, workspace.inputPath),
|
|
),
|
|
);
|
|
const entry = inventory.sessions.find(
|
|
({ session_id }) => session_id === opened.session.session_id,
|
|
);
|
|
if (
|
|
entry?.owned !== true ||
|
|
entry.backend !== "worker" ||
|
|
entry.is_active !== true ||
|
|
!sameIdaHostPath(entry.input_path, this.#workspace.inputPath)
|
|
)
|
|
throw new AnalysisProtocolError(
|
|
"IDA did not return an active owned headless worker for the private input copy.",
|
|
);
|
|
this.#database = opened.session.session_id;
|
|
}
|
|
this.#ready = true;
|
|
}
|
|
|
|
async #nativeMetadata(): Promise<JsonValue> {
|
|
if (this.connection.readResource === undefined)
|
|
throw new AnalysisProtocolError(
|
|
"Native IDA MCP requires resources/read for recorded input metadata.",
|
|
);
|
|
return this.connection.readResource("ida://idb/metadata");
|
|
}
|
|
|
|
async #observe(
|
|
options?: ExecutionOptions,
|
|
onObserved?: (metadata: JsonValue) => void,
|
|
): Promise<JsonValue> {
|
|
let identity: string;
|
|
if (this.config.mode === "attached") {
|
|
const raw =
|
|
this.config.protocol === "native"
|
|
? await this.#nativeMetadata()
|
|
: await this.connection.call("get_metadata", {});
|
|
const metadata = legacyMetadataSchema.parse(raw);
|
|
this.#metadata = raw;
|
|
onObserved?.(raw);
|
|
if (metadata.sha256.toLowerCase() !== this.target.sha256.toLowerCase())
|
|
throw new AnalysisProtocolError(
|
|
`The attached IDA input SHA-256 does not match the selected target ${this.target.path}; open that target in IDA or use headless mode.`,
|
|
);
|
|
identity = JSON.stringify([
|
|
metadata.path,
|
|
metadata.sha256.toLowerCase(),
|
|
metadata.base,
|
|
]);
|
|
} else {
|
|
if (this.#database === undefined || this.#workspace === undefined)
|
|
throw new AnalysisProtocolError(
|
|
"IDA headless session has no database binding.",
|
|
);
|
|
const raw = await readSettledHealth(this.connection, {
|
|
database: this.#database,
|
|
timeoutMs: this.config.timeoutMs,
|
|
signal: options?.signal,
|
|
});
|
|
const metadata = modernHealthSchema.parse(raw);
|
|
this.#metadata = raw;
|
|
onObserved?.(raw);
|
|
if (
|
|
!sameIdaHostPath(metadata.input_path, this.#workspace.inputPath) ||
|
|
(await idaFileDigest(this.#workspace.inputPath)) !==
|
|
this.#workspace.inputSha256
|
|
)
|
|
throw new AnalysisProtocolError(
|
|
"IDA headless input identity changed after target admission.",
|
|
);
|
|
if (metadata.auto_analysis_ready !== true)
|
|
throw new AnalysisProtocolError(
|
|
"IDA auto-analysis is incomplete or readiness is unknown; retry after analysis completes.",
|
|
);
|
|
identity = JSON.stringify([
|
|
this.#database,
|
|
metadata.input_path,
|
|
metadata.idb_path,
|
|
metadata.imagebase,
|
|
]);
|
|
}
|
|
if (
|
|
this.#initialIdentity !== undefined &&
|
|
identity !== this.#initialIdentity
|
|
)
|
|
throw new AnalysisProtocolError(
|
|
"The attached IDA target or image base changed during the REA session; reopen the target.",
|
|
);
|
|
this.#initialIdentity = identity;
|
|
return this.#metadata;
|
|
}
|
|
|
|
async close(): Promise<Result<null, AnalysisError>> {
|
|
if (this.#closePromise !== undefined) return this.#closePromise;
|
|
const closing = this.#closeOnce();
|
|
const shared = closing.finally(() => {
|
|
if (this.#closePromise === shared) this.#closePromise = undefined;
|
|
});
|
|
this.#closePromise = shared;
|
|
return shared;
|
|
}
|
|
|
|
/**
|
|
* Upstream sessions whose release cleanup has not confirmed: the entries
|
|
* the latest successful inventory found blocking release; else the
|
|
* requested private worker until its close is confirmed, and then none
|
|
* known.
|
|
*/
|
|
#unreleasedSessions(): readonly UpstreamSession[] | undefined {
|
|
if (this.#blockingSessions !== undefined) return this.#blockingSessions;
|
|
if (
|
|
!this.#openAttempted ||
|
|
this.#workspace === undefined ||
|
|
this.#openRejected
|
|
)
|
|
return undefined;
|
|
return this.#databaseCloseCompleted
|
|
? []
|
|
: [
|
|
{
|
|
session_id: this.#workspace.sessionId,
|
|
backend: "worker",
|
|
owned: null,
|
|
input_path: null,
|
|
is_active: null,
|
|
},
|
|
];
|
|
}
|
|
|
|
/** Drop a session whose release upstream confirmed, keeping other blockers. */
|
|
#forgetReleasedSession(sessionId: string): void {
|
|
const remaining = this.#blockingSessions?.filter(
|
|
({ session_id }) => session_id !== sessionId,
|
|
);
|
|
this.#blockingSessions =
|
|
remaining !== undefined && remaining.length > 0 ? remaining : undefined;
|
|
}
|
|
|
|
/** Replace blocker evidence from a successful inventory; true when blocked. */
|
|
#recordBlockingSessions(
|
|
entries: readonly (UpstreamSession & { readonly owned: boolean })[],
|
|
): boolean {
|
|
this.#blockingSessions =
|
|
entries.length > 0
|
|
? entries.map(
|
|
({ session_id, backend, owned, input_path, is_active }) => ({
|
|
session_id,
|
|
backend,
|
|
owned,
|
|
input_path,
|
|
is_active,
|
|
}),
|
|
)
|
|
: undefined;
|
|
return entries.length > 0;
|
|
}
|
|
|
|
async #closeDatabase(database: string): Promise<void> {
|
|
const response = await this.connection.call("idb_close", {
|
|
database,
|
|
save: false,
|
|
});
|
|
const closed = closeDatabaseSchema.safeParse(response);
|
|
if (!closed.success) {
|
|
if (!absentDatabaseCloseSchema(database).safeParse(response).success)
|
|
throw closed.error;
|
|
return;
|
|
}
|
|
if (
|
|
closed.data.session_id !== database ||
|
|
closed.data.saved === true ||
|
|
closed.data.owned !== true ||
|
|
closed.data.backend !== "worker"
|
|
)
|
|
throw new AnalysisProtocolError(
|
|
"IDA database close did not confirm the requested owned, unsaved session release.",
|
|
);
|
|
}
|
|
|
|
async #closeOnce(): Promise<Result<null, AnalysisError>> {
|
|
this.#closing = true;
|
|
await this.#tail;
|
|
if (this.#closed) return ok(null);
|
|
try {
|
|
if (this.#workspace !== undefined && this.#openAttempted) {
|
|
if (this.#database === undefined) {
|
|
const inventory = databaseListSchema.parse(
|
|
await this.connection.call("idb_list", {}),
|
|
);
|
|
const workspace = this.#workspace;
|
|
this.#database = inventory.sessions.find(
|
|
(entry) =>
|
|
entry.owned &&
|
|
entry.backend === "worker" &&
|
|
entry.session_id === workspace.sessionId &&
|
|
sameIdaHostPath(entry.input_path, workspace.inputPath),
|
|
)?.session_id;
|
|
// Every same-input session and any entry under the requested ID,
|
|
// whatever its input, stays reported until a later successful
|
|
// inventory confirms release.
|
|
const blocked = this.#recordBlockingSessions(
|
|
inventory.sessions.filter(
|
|
(entry) =>
|
|
entry.session_id === workspace.sessionId ||
|
|
sameIdaHostPath(entry.input_path, workspace.inputPath),
|
|
),
|
|
);
|
|
if (this.#database === undefined && blocked)
|
|
throw new AnalysisProtocolError(
|
|
"A registered session still matches the requested ID or private input without a verified private worker binding; the workspace was retained.",
|
|
);
|
|
if (
|
|
this.#database === undefined &&
|
|
this.#openCompleted &&
|
|
!this.#openRejected
|
|
)
|
|
throw new AnalysisProtocolError(
|
|
"IDA open returned without a verified private worker binding. An empty session inventory does not prove that its Worker exited; the private workspace was retained.",
|
|
);
|
|
}
|
|
if (this.#database !== undefined) {
|
|
if (!this.#databaseCloseCompleted) {
|
|
await this.#closeDatabase(this.#database);
|
|
this.#databaseCloseCompleted = true;
|
|
this.#forgetReleasedSession(this.#database);
|
|
}
|
|
const inventory = databaseListSchema.parse(
|
|
await this.connection.call("idb_list", {}),
|
|
);
|
|
const remaining = inventory.sessions.filter(
|
|
(entry) =>
|
|
entry.session_id === this.#database ||
|
|
sameIdaHostPath(
|
|
entry.input_path,
|
|
this.#workspace?.inputPath ?? "",
|
|
),
|
|
);
|
|
if (this.#recordBlockingSessions(remaining))
|
|
throw new AnalysisProtocolError(
|
|
"IDA worker remains registered after close.",
|
|
);
|
|
this.#database = undefined;
|
|
}
|
|
if (!this.#openCompleted)
|
|
throw new AnalysisProtocolError(
|
|
"IDA database open did not return a completed response. Worker inventory cannot prove that the request has stopped; the private workspace was retained.",
|
|
);
|
|
this.#openAttempted = false;
|
|
}
|
|
await this.connection.close();
|
|
await this.#workspace?.remove();
|
|
this.#closed = true;
|
|
return ok(null);
|
|
} catch (cause: unknown) {
|
|
this.connection.detach();
|
|
return err(
|
|
new IdaCleanupError(
|
|
this.#workspace?.directory ?? "IDA MCP connection",
|
|
cause,
|
|
this.#unreleasedSessions(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|