Compare commits

...
22 changed files with 974 additions and 60 deletions

No files matched your search

+1
View File
@@ -147,6 +147,7 @@ that rollback is resolved; a clean rollback permits another setup attempt.
## Provenance and limitations
- If a completed project export reports a failed function, `procedure_pseudo_code` with `analysis: "independent_fresh"` or `rea decompile --analysis independent_fresh` requests a new single-function attempt. It bypasses the project result and prior single-function and snapshot results; it does not replace the original observation. On a cold session it obtains an inventory without starting a project export. The independent attempt can use different analysis context and may also fail. Identical results can share an Evidence ID because Evidence is content-addressed.
- Addresses and bytes are Kuna's observations of the image. Procedure entries and extents are its derivations: extents are upper bounds clipped at the next entry, and Kuna can split one function at an entry its own analysis infers. Pseudocode is its reconstruction, never original source.
- Pseudocode and assembly are exact Kuna text; text from Kuna and from another provider is not comparable as equal or different.
- Results are re-read rather than replayed from REA's analysis snapshots, because a cold answer can differ from the export's.
+11 -1
View File
@@ -227,7 +227,17 @@ caveats. Failure details retain at most 32 rows within 2,048 JSON-encoded
UTF-8 bytes; names may be omitted to keep complete addresses and reasons. The
index retains every failure and its complete observed name.
Each function's `decompile-index.json` record, whatever its status, keeps the
complete limitations of the observations it used. The returned Evidence keeps
complete limitations of the observations it used.
When available, `provider_source` records the source of that attempt's
pseudocode observation: a file path, SHA-256 and byte range, or a
single-function decompile. The latter describes the method, not a unique run. A file
descriptor records the source as observed; the file may since have changed or
been removed, so compare its current digest before using it. The descriptor
does not authenticate the journal. `status: "unknown"` distinguishes
an unavailable source from an existing matching output whose original producer
is unknown. Older journal records can omit this field; omission is unknown.
Failed attempts retain their source in the append-only journal when the
provider supplied one, even if a later attempt succeeds. The returned Evidence keeps
at most eight first-observed distinct per-function provider limitations within an 8,000-byte JSON-encoded UTF-8 text budget and counts omitted observations. Repeated omitted text is counted per observation, not as a distinct caveat. This summary does not retain every omitted string in memory. A fresh output directory must not already exist. `--resume`
continues an export for the same target SHA-256, provider, analysis profile,
and layout; it requires the directory to exist, and an export without a known
+19
View File
@@ -18,6 +18,10 @@ import type {
EvidenceSubjectTarget,
} from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import {
analysisSourceDescriptorSchema,
type AnalysisSourceDescriptor,
} from "../domain/analysisSource.js";
import type { ProgressReporter } from "./ProgressReporter.js";
import type { ProviderRejectionCode } from "../domain/providerSelection.js";
import type { ProcessLineageObservation } from "../process/ProcessOwnership.js";
@@ -56,6 +60,12 @@ export type AnalysisOperation =
/** Atomic successful provider observation with exact producing provenance. */
export interface AnalysisExecution extends AnalysisObservation {
readonly provider: ProviderIdentity;
/**
* Compact provider-owned facts for this execution. This is not an Evidence
* record and is not restored by snapshot replay; consumers must treat its
* absence as unknown.
*/
readonly sourceDescriptor?: AnalysisSourceDescriptor;
/** Session-issued identity of the committed record consumed by this execution. */
readonly evidenceId?: string;
/** Result-specific veto of snapshot replay; Evidence remains retainable. */
@@ -71,6 +81,7 @@ export interface AnalysisExecution extends AnalysisObservation {
interface CreateAnalysisExecutionOptions {
readonly rawResult?: unknown;
readonly sourceDescriptor?: AnalysisSourceDescriptor;
readonly analysisProfile?: AnalysisProfileCommitment;
readonly cachePolicy?: "live";
readonly limitations?: readonly string[];
@@ -110,6 +121,13 @@ export function createAnalysisExecution(
? null
: jsonValueSchema.parse(options.rawResult),
provider,
...(options.sourceDescriptor === undefined
? {}
: {
sourceDescriptor: analysisSourceDescriptorSchema.parse(
options.sourceDescriptor,
),
}),
...(options.cachePolicy === undefined
? {}
: { cachePolicy: options.cachePolicy }),
@@ -275,6 +293,7 @@ export type CapabilityDescriptor = CapabilityAvailability & {
* exclusions apply to every policy.
*/
readonly cachePolicy?: "snapshot" | "live";
readonly independentDecompile?: true;
/** Exact source profile for an operation implemented by a versioned local reader. */
readonly analysisProfile?: AnalysisProfileCommitment;
readonly provider: ProviderIdentity;
+5 -1
View File
@@ -38,7 +38,10 @@ import {
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { access, stat } from "node:fs/promises";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { requiresNativeSourceAdmissionBeforeReplay } from "./binary/AnalysisSnapshotCache.js";
import {
requestsFreshIndependentDecompile,
requiresNativeSourceAdmissionBeforeReplay,
} from "./binary/AnalysisSnapshotCache.js";
import { classifyBinaryTargetFilesystemFailure } from "./BinaryTargetResolver.js";
import {
snapshotBindingMismatch,
@@ -446,6 +449,7 @@ const runAnalysis = async (
) &&
tool !== "trace_native_ui_action" &&
tool !== "decompile_all" &&
!requestsFreshIndependentDecompile(tool, arguments_) &&
routeAllowsSnapshotReplay(route, tool)
) {
const cached = snapshotEvidenceForQuery(snapshot, {
@@ -125,12 +125,21 @@ export const isSourceEvidenceRetainable = (
descriptor.effects.mayWriteFilesystem === false) &&
descriptor.effects.changesPermissions === false;
/** Explicit independent decompiles must run even if a snapshot has a matching entry. */
export const requestsFreshIndependentDecompile = (
operation: string,
parameters: Readonly<Record<string, JsonValue>>,
): boolean =>
operation === "procedure_pseudo_code" &&
parameters.analysis === "independent_fresh";
/** Whether an observation can additionally be replayed as a snapshot. */
export const isSnapshotCacheable = (
operation: AnalysisOperation,
descriptor: CapabilityDescriptor | undefined,
parameters: Readonly<Record<string, JsonValue>>,
): descriptor is CapabilityDescriptor =>
!requestsFreshIndependentDecompile(operation, parameters) &&
isSourceEvidenceRetainable(operation, descriptor, parameters) &&
replayDocumentSelected(operation, parameters) &&
descriptor.cachePolicy !== "live";
@@ -18,6 +18,7 @@ import type {
} from "../AnalysisProvider.js";
import {
isSnapshotCacheable,
requestsFreshIndependentDecompile,
requiresNativeSourceAdmissionBeforeReplay,
} from "./AnalysisSnapshotCache.js";
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
@@ -79,6 +80,17 @@ export const prepareSessionExecution = (
),
);
}
if (
requestsFreshIndependentDecompile(operation, parameters) &&
capability.independentDecompile !== true
)
return err(
new AnalysisCapabilityUnavailableError(
capability.provider.id,
operation,
"This provider does not declare fresh independent decompilation.",
),
);
const profile =
active.profile !== null &&
capability.provider.id === active.profile.provider.id
@@ -414,6 +414,7 @@ export const providerCapabilitySets = (
const key = JSON.stringify([
effects,
limitations,
descriptor.independentDecompile ?? null,
status.available,
status.reason,
status.availability_code,
@@ -425,6 +426,9 @@ export const providerCapabilitySets = (
operations: [descriptor.operation],
effects,
limitations,
...(descriptor.independentDecompile === undefined
? {}
: { independent_decompile: descriptor.independentDecompile }),
...status,
});
else group.operations.push(descriptor.operation);
@@ -535,6 +535,12 @@ const publishOne = async (input: {
const caveats = [
...new Set([...infoCaveats, ...decompiled.value.limitations]),
];
const observationSource: NonNullable<
DecompileDirectoryFunctionRecord["provider_source"]
> =
decompiled.value.sourceDescriptor === undefined
? { status: "unknown", reason: "source_not_available" }
: { status: "reported", source: decompiled.value.sourceDescriptor };
const pseudocode = decompiled.value.result;
if (typeof pseudocode !== "string" || pseudocode.length === 0) {
const reason = pseudocodeFailure(pseudocode);
@@ -543,6 +549,7 @@ const publishOne = async (input: {
result: await input.session.append(
failureRecord(input.procedure, body.fact, reason, {
limitations: caveats,
providerSource: observationSource,
}),
),
bodySupport: body.support,
@@ -580,6 +587,7 @@ const publishOne = async (input: {
failureRecord(input.procedure, body.fact, reason, {
relativePath,
limitations: caveats,
providerSource: observationSource,
}),
),
bodySupport: body.support,
@@ -591,6 +599,11 @@ const publishOne = async (input: {
status: "written",
relative_path: relativePath,
sha256: committed.sha256,
// Matching existing bytes prove equality, not who originally wrote them.
provider_source:
committed.status === "matched"
? { status: "unknown", reason: "existing_file_origin_unknown" }
: observationSource,
body: bodyRecord(body.fact),
// The header may truncate; the journal keeps the complete list.
...(caveats.length === 0 ? {} : { limitations: caveats }),
@@ -889,6 +902,7 @@ const failureRecord = (
readonly relativePath?: string;
/** Caveats of the observations this attempt used. */
readonly limitations?: readonly string[];
readonly providerSource?: DecompileDirectoryFunctionRecord["provider_source"];
} = {},
): DecompileDirectoryFunctionRecord => ({
address: procedure.address,
@@ -896,6 +910,9 @@ const failureRecord = (
status: "failed",
body: bodyRecord(body),
failure: reason,
...(options.providerSource === undefined
? {}
: { provider_source: options.providerSource }),
...(options.relativePath === undefined
? {}
: { relative_path: options.relativePath }),
+5
View File
@@ -36,6 +36,7 @@ import { localPathStringSchema } from "../domain/localPath.js";
import type { ExecutableFormatHint } from "../domain/dosCom.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { registerDecompileDirectoryCommand } from "./decompileDirectoryCommand.js";
import { decompileAnalysisSelection } from "../contracts/officialToolContracts.js";
const decompileSelectorGuidance = `${addressOrProcedureGuidance} Alternatively, use --file-offset alone.`;
const namedAddressGuidance = requiredSelectorGuidance(
@@ -221,6 +222,7 @@ const registerDecompileCommand = (
.describe(
`Ghidra source-file byte offset; mapped internally to an analysis address. ${decompileSelectorGuidance}`,
),
analysis: decompileAnalysisSelection,
snapshot: snapshotSelectionOption,
...nativeOpenOptionFields,
}),
@@ -245,6 +247,9 @@ const registerDecompileCommand = (
: "file-offset",
"procedure_pseudo_code",
),
...(options.analysis === undefined
? {}
: { analysis: options.analysis }),
},
directAnalysisOptionsFromCli(logger, options),
).then(projectCliAnalysisResult),
+17
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { digestSchema } from "../domain/digests.js";
import { absoluteLocalPathStringSchema } from "../domain/localPath.js";
import { analysisSourceDescriptorSchema } from "../domain/analysisSource.js";
/** On-disk journal. Append-only so a crash keeps committed function files. */
export const DECOMPILE_DIRECTORY_JOURNAL_NAME = "decompile-journal.jsonl";
@@ -245,6 +246,22 @@ export const decompileDirectoryFunctionSchema = z
)
.optional(),
sha256: digestSchema.optional(),
/** Source of this attempt's pseudocode observation; a matched file's original producer remains unknown. */
provider_source: z
.discriminatedUnion("status", [
z.strictObject({
status: z.literal("reported"),
source: analysisSourceDescriptorSchema,
}),
z.strictObject({
status: z.literal("unknown"),
reason: z.enum([
"source_not_available",
"existing_file_origin_unknown",
]),
}),
])
.optional(),
body: bodySchema,
failure: z
.strictObject({
+13 -2
View File
@@ -23,6 +23,13 @@ import { nativeSearchInput } from "../domain/native/nativeSearch.js";
const hopperRegexDeadline =
"Hopper regex mode uses ECMAScript Unicode syntax in a cancellable worker. The matching deadline is REA_REGEX_MATCHING_DEADLINE_MS (a positive decimal integer of milliseconds from 1 to 2147483647, default 5000). Unset keeps the default. A deadline failure names the effective milliseconds, source (default or setting), the default, and the maximum, and leaves the API usable. The worker old-generation request is REA_REGEX_WORKER_HEAP_REQUEST_MIB (a decimal integer of mebibytes from 7 to 16384, default 128). Unset keeps the default. A positive Node --max-old-space-size, including NODE_OPTIONS, overrides that request and is not limited by the setting's floor or maximum. A heap failure names the effective request, its source (default or setting), the default, the floor, and the maximum.";
export const decompileAnalysisSelection = z
.enum(["independent_fresh"])
.optional()
.describe(
"Request a new independent decompile, bypassing project and snapshot results. Use only when the routed provider's procedure_pseudo_code capability declares independent_decompile; otherwise the request is refused. This may use different analysis context.",
);
const official = <
Name extends keyof typeof officialOutputSchemas,
Schema extends z.ZodObject,
@@ -207,8 +214,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"procedure_pseudo_code",
"Decompile one analyzed procedure by symbol name or provider-normalized address. Ghidra also accepts file-offset:0x... selectors and falls back to a unique source-file mapping when a 0x-prefixed number resolves to no procedure; existing analysis addresses take precedence and ambiguous mappings fail explicitly. Returns provider-specific pseudocode, never original source or cross-provider text equivalence, and may return null; request procedure_assembly when instruction precision matters. Ghidra no-return assumptions may cut the analyzed body; Evidence limitations report observed terminal calls and available callee identities.",
z.object({ procedure, document }),
"Decompile one analyzed procedure by symbol name or provider-normalized address. To request a fresh independent attempt, use analysis: independent_fresh when binary_session reports independent_decompile for this provider operation; otherwise it is refused. The independent result may have different analysis context. Ghidra also accepts file-offset:0x... selectors and falls back to a unique source-file mapping when a 0x-prefixed number resolves to no procedure; existing analysis addresses take precedence and ambiguous mappings fail explicitly. Returns provider-specific pseudocode, never original source or cross-provider text equivalence, and may return null; request procedure_assembly when instruction precision matters. Ghidra no-return assumptions may cut the analyzed body; Evidence limitations report observed terminal calls and available callee identities.",
z.object({
procedure,
document,
analysis: decompileAnalysisSelection,
}),
),
official(
"resolve_containing_procedure",
@@ -65,6 +65,7 @@ const providerCapabilityFacts = {
requires_root: z.boolean(),
}),
limitations: z.array(z.string()),
independent_decompile: z.literal(true).exactOptional(),
};
export const providerCapability = z.discriminatedUnion("available", [
@@ -99,6 +100,7 @@ const providerCapabilityGroupFacts = {
operations: z.array(z.string().min(1)).min(1),
effects: z.array(providerCapabilityFacts.effects.keyof()),
limitations: z.array(z.string()),
independent_decompile: providerCapabilityFacts.independent_decompile,
};
const providerCapabilityGroup = z.discriminatedUnion("available", [
@@ -136,6 +138,9 @@ export const expandProviderCapabilities = (
group.operations.map((operation) => ({
...providerCapability.parse({
operation,
...(group.independent_decompile === undefined
? {}
: { independent_decompile: group.independent_decompile }),
available: group.available,
reason: group.reason,
availability_code: group.availability_code,
+19
View File
@@ -0,0 +1,19 @@
import { z } from "zod";
import { digestSchema } from "./digests.js";
import { absoluteLocalPathStringSchema } from "./localPath.js";
/** Compact source facts supplied by the provider for one observation. */
export const analysisSourceDescriptorSchema = z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("file-range"),
path: absoluteLocalPathStringSchema,
sha256: digestSchema,
byte_offset: z.int().nonnegative(),
byte_length: z.int().nonnegative(),
}),
z.strictObject({ kind: z.literal("single-function-decompile") }),
]);
export type AnalysisSourceDescriptor = z.infer<
typeof analysisSourceDescriptorSchema
>;
+1 -23
View File
@@ -341,28 +341,6 @@ export const copyAndHash = async (
};
};
/**
* The same run takes as long again, so only a longer deadline helps. Only a
* session that outlives this request can also read the later project export.
*/
export const kunaTimeoutRecovery = (
setting: string,
subcommand: string,
): {
readonly remediationAction: string;
readonly continuingCallerGuidance?: { readonly remediationAction: string };
} => {
const remediationAction = `Raise ${setting} in the environment that starts REA, then restart REA; it reads Kuna settings when it starts.`;
return subcommand === "decompile"
? {
remediationAction,
continuingCallerGuidance: {
remediationAction: `${remediationAction} Within a session, pseudocode is also read from the project export once it finishes.`,
},
}
: { remediationAction };
};
/** Map an owned-command failure to its typed error, keeping unproven cleanup owned. */
const commandFailure = (
cause: unknown,
@@ -413,7 +391,7 @@ const commandFailure = (
primary = new AnalysisTimeoutError(operation, ms, {
...details,
userMessage: `kuna ${subcommand} did not finish within ${setting} (${String(ms)} ms).`,
...kunaTimeoutRecovery(setting, subcommand),
remediationAction: `Raise ${setting} in the environment that starts REA, then restart REA; it reads Kuna settings when it starts.`,
});
}
// The same input overflows the same fixed buffer; waiting cannot help.
+15 -6
View File
@@ -48,6 +48,7 @@ export interface KunaProjectFileSpan {
export interface KunaProjectText {
readonly result: string | null;
readonly unavailableReason?: string;
readonly files?: readonly KunaProjectFileSpan[];
readonly limitations: readonly string[];
}
@@ -314,13 +315,15 @@ export class KunaProjectReader {
this.project.functions.map((block) => [block.address, block]),
);
const block = this.#blocks.get(address);
if (block === undefined)
if (block === undefined) {
const reason =
"Kuna's export decompiles code entries only; this inventory entry, such as an import slot, has no body.";
return ok({
result: null,
limitations: [
"Kuna's export decompiles code entries only; this inventory entry, such as an import slot, has no body.",
],
unavailableReason: reason,
limitations: [reason],
});
}
const files = [
{
facet: "pseudocode" as const,
@@ -329,12 +332,18 @@ export class KunaProjectReader {
byteLength: block.cLength,
},
];
if (block.error !== null)
if (block.error !== null) {
const reason = `Kuna could not decompile this function: ${block.error}`;
return ok({
result: null,
unavailableReason: reason,
files,
limitations: [`Kuna could not decompile this function: ${block.error}`],
limitations: [
reason,
"To try an independent decompile, call procedure_pseudo_code with analysis: independent_fresh, or use rea decompile --analysis independent_fresh. It may use different analysis context and does not replace this observation.",
],
});
}
const text = await readSpan(
operation,
join(this.project.directory, this.project.manifest.files.c.path),
+3
View File
@@ -104,6 +104,9 @@ export const kunaCapabilities = (
KUNA_OPERATIONS.map((operation) => ({
provider: KUNA_PROVIDER_IDENTITY,
operation,
...(operation === "procedure_pseudo_code"
? { independentDecompile: true as const }
: {}),
...(availability.status === "available"
? ({ available: true, reason: null } as const)
: ({
+66 -14
View File
@@ -1,5 +1,5 @@
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
import { basename } from "node:path";
import { basename, join } from "node:path";
import {
regularFileCancellation,
RegularFileCleanupFailure,
@@ -501,7 +501,8 @@ export class KunaSessionClient implements AnalysisClient {
}),
);
if (
operation === "procedure_pseudo_code" ||
(operation === "procedure_pseudo_code" &&
parameters.analysis !== "independent_fresh") ||
operation === "procedure_assembly" ||
operation === "analyze_function"
)
@@ -624,6 +625,30 @@ export class KunaSessionClient implements AnalysisClient {
// component once, including its producing file digest and byte range.
const recordedFiles =
files.length === 1 ? files[0] : files.length === 0 ? {} : { files };
const rawSource =
source === null
? undefined
: source.kind === "single-function"
? { kind: "single-function" }
: {
kind: "project-export",
key: source.project.key,
directory: source.project.directory,
...recordedFiles,
};
const file = files.length === 1 ? files[0] : undefined;
const sourceDescriptor =
source?.kind === "single-function"
? { kind: "single-function-decompile" as const }
: source?.kind === "project-export" && file?.file_sha256 != null
? {
kind: "file-range" as const,
path: join(source.project.directory, file.file),
sha256: file.file_sha256,
byte_offset: file.byte_offset,
byte_length: file.byte_length,
}
: undefined;
return createAnalysisExecution(
answer.result,
{
@@ -632,6 +657,7 @@ export class KunaSessionClient implements AnalysisClient {
version: installation.version,
},
{
...(sourceDescriptor === undefined ? {} : { sourceDescriptor }),
// Kuna's identity is in the analysis profile; the raw result records
// only where file-backed text came from.
...(source === null &&
@@ -643,15 +669,7 @@ export class KunaSessionClient implements AnalysisClient {
...(source === null
? {}
: {
source:
source.kind === "single-function"
? { kind: "single-function" }
: {
kind: "project-export",
key: source.project.key,
directory: source.project.directory,
...recordedFiles,
},
source: rawSource,
project_export: this.#exportState(),
}),
...(answer.referenceSource === undefined
@@ -774,6 +792,33 @@ export class KunaSessionClient implements AnalysisClient {
limitations: classification,
});
case "procedure_pseudo_code": {
if (parameters.analysis === "independent_fresh") {
const signal =
options.signal === undefined
? this.#session.signal
: AbortSignal.any([options.signal, this.#session.signal]);
const standalone = await this.#singleFunction(
operation,
selected.address,
installation,
signal,
true,
);
if (standalone === EXPORT_READY)
throw new Error(
"Independent decompilation unexpectedly yielded to project",
);
if (!standalone.ok) return standalone;
return ok({
result: standalone.value,
source: { kind: "single-function" },
locations,
limitations: [
"This is a fresh independent single-function decompile; it does not use the project export's whole-program prototypes.",
...classification,
],
});
}
const pseudocode = await this.#pseudocode(
operation,
selected,
@@ -1189,7 +1234,11 @@ export class KunaSessionClient implements AnalysisClient {
);
if (!text.ok) return text;
return ok({
...text.value,
result: text.value.result,
limitations: text.value.limitations,
...(text.value.files === undefined
? {}
: { files: text.value.files }),
source: { kind: "project-export", project: reader.project },
});
} catch (cause: unknown) {
@@ -1332,10 +1381,13 @@ export class KunaSessionClient implements AnalysisClient {
address: string,
installation: KunaInstallation,
signal: AbortSignal,
independent = false,
): Promise<SingleFunctionOutcome> {
const prepared = await this.#commands.prepare();
if (!prepared.ok) return prepared;
const admitted = await this.#admitSingleFunction(signal);
const admitted = independent
? await this.#runSlots.acquire(signal)
: await this.#admitSingleFunction(signal);
if (admitted === EXPORT_READY) return admitted;
if (admitted === undefined)
return err(
@@ -1478,7 +1530,7 @@ export class KunaSessionClient implements AnalysisClient {
pseudocode:
text === null
? unavailable(
pseudocode.value.limitations.at(-1) ??
pseudocode.value.unavailableReason ??
"Kuna did not supply pseudocode.",
)
: { available: true },
@@ -1671,3 +1671,221 @@ it("retains a native dispatch workflow snapshot without a deep provider profile"
saved.value,
);
});
it("runs decompile export against the provider after loading a pseudocode snapshot", async () => {
const fixture = await createSnapshotReplayFixture("rea-directory-snapshot-");
const original = fixture.createProvider({
supportedOperations: ["list_procedures", "procedure_pseudo_code"],
});
const provider: AnalysisProvider = {
...original,
createClient: (...args) => {
const client = original.createClient(...args);
return {
...client,
execute: async (operation, parameters, options) => {
const result = await client.execute(operation, parameters, options);
return result.ok && operation === "procedure_pseudo_code"
? ok({
...result.value,
sourceDescriptor: {
kind: "single-function-decompile" as const,
},
})
: result;
},
};
},
};
const dependencies = fixture.dependencies(provider);
const first = await runDirectAnalysis(
dependencies,
fixture.path,
"procedure_pseudo_code",
{ procedure: "0x1000", document: fixture.path },
{ snapshotPath: fixture.snapshotPath },
);
expect(first.ok).toBe(true);
const snapshot = await readAnalysisSnapshot(fixture.snapshotPath);
if (!snapshot.ok) throw snapshot.error;
expect(
snapshot.value.entries.some(
(entry) => entry.operation === "procedure_pseudo_code",
),
).toBe(true);
const directory = join(dirname(fixture.snapshotPath), "export");
const exported = await runDirectAnalysis(
dependencies,
fixture.path,
"decompile_all",
{ output_directory: directory, layout: "address", resume: false },
{ snapshotPath: fixture.snapshotPath },
);
expect(exported.ok, JSON.stringify(exported)).toBe(true);
expect(
fixture.calls.filter((name) => name === "procedure_pseudo_code"),
).toHaveLength(2);
const index = JSON.parse(
await readFile(join(directory, "decompile-index.json"), "utf8"),
) as { functions: Array<{ provider_source?: unknown }> };
expect(index.functions[0]?.provider_source).toEqual({
status: "reported",
source: { kind: "single-function-decompile" },
});
});
it.each([false, true])(
"does not replay a fresh decompile from a selected snapshot (document supplied: %s)",
async (withDocument) => {
const fixture = await createSnapshotReplayFixture(
"rea-fresh-decompile-snapshot-",
);
const original = fixture.createProvider({
supportedOperations: ["procedure_pseudo_code"],
});
const provider: AnalysisProvider = {
...original,
capabilities: () =>
original.capabilities().map((descriptor) => ({
...descriptor,
independentDecompile: true as const,
})),
};
const args = {
procedure: "fixture",
analysis: "independent_fresh",
...(withDocument ? { document: fixture.path } : {}),
};
const first = await runDirectAnalysis(
fixture.dependencies(provider),
fixture.path,
"procedure_pseudo_code",
args,
{ snapshotPath: fixture.snapshotPath },
);
const second = await runDirectAnalysis(
fixture.dependencies(provider),
fixture.path,
"procedure_pseudo_code",
args,
{ snapshotPath: fixture.snapshotPath },
);
expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
expect(
fixture.calls.filter(
(operation) => operation === "procedure_pseudo_code",
),
).toHaveLength(2);
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
if (!saved.ok) throw saved.error;
expect(
saved.value.entries.some(
(entry) => entry.operation === "procedure_pseudo_code",
),
).toBe(false);
},
);
it.each([true, false])(
"does not replay an imported fresh decompile (provider declares support: %s)",
async (declared) => {
const fixture = await createSnapshotReplayFixture(
"rea-fresh-imported-snapshot-",
);
const original = fixture.createProvider({
supportedOperations: ["procedure_pseudo_code"],
});
const provider: AnalysisProvider = {
...original,
capabilities: () =>
original.capabilities().map((descriptor) => ({
...descriptor,
...(declared ? { independentDecompile: true as const } : {}),
})),
};
const parameters = { procedure: "fixture", document: fixture.path };
const dependencies = fixture.dependencies(provider);
const first = await runDirectAnalysis(
dependencies,
fixture.path,
"procedure_pseudo_code",
parameters,
{ snapshotPath: fixture.snapshotPath },
);
if (!first.ok) throw first.error;
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
if (!saved.ok) throw saved.error;
const entry = saved.value.entries.find(
(item) => item.operation === "procedure_pseudo_code",
);
const evidence = saved.value.evidence_bundle.records.find(
(item) => item.operation === "procedure_pseudo_code",
);
if (
entry === undefined ||
evidence === undefined ||
evidence.analysis_profile === null
)
throw new Error("Missing saved decompile");
const freshParameters = { ...parameters, analysis: "independent_fresh" };
const historical = createEvidence(
{
path: fixture.path,
sha256: saved.value.target.sha256,
format: saved.value.target.format,
},
evidence.provider,
{
operation: evidence.operation,
parameters: freshParameters,
result: evidence.normalized_result,
rawResult: evidence.raw_result,
analysisProfile: evidence.analysis_profile,
confidence: evidence.confidence,
limitations: evidence.limitations,
locations: evidence.locations,
},
);
const written = await withArtifactResourceScope((resources) =>
writeAnalysisSnapshot(
{
...saved.value,
entries: [
{
...entry,
parameters: freshParameters,
query_id: analysisQueryId(
saved.value.target,
saved.value.binding,
entry.operation,
freshParameters,
),
},
],
evidence_bundle: createEvidenceBundle([historical]),
},
fixture.snapshotPath,
true,
resources,
),
);
if (!written.ok) throw written.error;
const fresh = await runDirectAnalysis(
dependencies,
fixture.path,
"procedure_pseudo_code",
freshParameters,
{ snapshotPath: fixture.snapshotPath },
);
expect(fresh.ok).toBe(declared);
if (!fresh.ok)
expect(fresh.error, JSON.stringify(fresh.error)).toMatchObject({
code: "capability_unavailable",
});
expect(
fixture.calls.filter(
(operation) => operation === "procedure_pseudo_code",
),
).toHaveLength(declared ? 2 : 1);
},
);
@@ -0,0 +1,172 @@
import { createHash } from "node:crypto";
import { appendFile, readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it as test } from "vitest";
import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js";
import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js";
import { decompileDirectoryIndexSchema } from "../../../../src/contracts/decompileDirectory.js";
import { parseEvidence } from "../../../../src/domain/evidence.js";
import { KunaProvider } from "../../../../src/kuna/KunaProvider.js";
import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js";
import { createApplicationMcpHarness } from "../../../fixtures/applicationMcpHarness.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { setFakeKunaBehavior, writeFakeKuna } from "./fakeKuna.js";
import { FUNCTIONS } from "./kunaSessionFixture.js";
const it = test.skipIf(process.platform === "win32");
it("retains each written function's producer across an export resume", async () => {
const root = await createTestTempDirectory("rea-kuna-directory-provenance-");
const kuna = await writeFakeKuna(root, { functions: FUNCTIONS });
const target = join(root, "program");
const output = join(root, "output");
await writeFile(target, elfNoteFixture({ bits: 64, type: 2 }));
const run = async (cache: string, resume: boolean) => {
const provider = new KunaProvider(
{ REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: join(root, cache) },
{ platform: process.platform },
);
const session = composeBinarySession(
new AnalysisProviderRegistry([provider], "kuna"),
);
const harness = await createApplicationMcpHarness({ session });
try {
const call = async (name: string, args: Record<string, unknown>) => {
const response = await harness.client.callTool({
name,
arguments: args,
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
return parseEvidence(response.structuredContent);
};
const opened = await harness.client.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "kuna" },
});
expect(opened.isError).not.toBe(true);
await call("procedure_assembly", { procedure: "main" });
const result = await call("decompile_all", {
output_directory: output,
resume,
});
const resultValue = result.normalized_result as { index_path: string };
return decompileDirectoryIndexSchema.parse(
JSON.parse(await readFile(resultValue.index_path, "utf8")),
);
} finally {
await harness.close();
const closed = await provider.closeOwnedResources();
if (!closed.ok) throw closed.error;
}
};
const partial = await run("partial", false);
const first = partial.functions.find((item) => item.address === "0x1000");
const failed = partial.functions.find((item) => item.address === "0x1080");
expect(first?.status).toBe("written");
expect(first?.provider_source).toMatchObject({
status: "reported",
source: { kind: "file-range" },
});
expect(failed?.status).toBe("failed");
expect(failed?.provider_source).toMatchObject({
status: "reported",
source: { kind: "file-range" },
});
if (first?.relative_path === undefined)
throw new Error("missing initial file");
const digest = (value: Uint8Array) =>
createHash("sha256").update(value).digest("hex");
const before = digest(await readFile(join(output, first.relative_path)));
// Simulate an interrupted append after the committed failed observation.
await appendFile(join(output, "decompile-journal.jsonl"), '{"kind":"func');
await setFakeKunaBehavior(kuna, {
functions: FUNCTIONS.map(({ name, addr }) => ({ name, addr })),
});
const clean = await run("clean", true);
const kept = clean.functions.find((item) => item.address === "0x1000");
const recovered = clean.functions.find((item) => item.address === "0x1080");
expect(kept).toEqual(first);
expect(digest(await readFile(join(output, first.relative_path)))).toBe(
before,
);
expect(recovered?.status).toBe("written");
expect(recovered?.provider_source).toMatchObject({
status: "reported",
source: { kind: "file-range" },
});
if (recovered === undefined) throw new Error("missing recovered function");
const oldSource = first.provider_source;
const newSource = recovered.provider_source;
if (
oldSource?.status !== "reported" ||
oldSource.source.kind !== "file-range" ||
newSource?.status !== "reported" ||
newSource.source.kind !== "file-range"
)
throw new Error("missing source file ranges");
expect(newSource.source.sha256).not.toBe(oldSource.source.sha256);
const journal = (
await readFile(join(output, "decompile-journal.jsonl"), "utf8")
)
.trim()
.split("\n")
.map(
(line) =>
JSON.parse(line) as {
address?: string;
status?: string;
provider_source?: unknown;
},
);
expect(journal).toContainEqual(
expect.objectContaining({
address: "0x1080",
status: "failed",
provider_source: failed?.provider_source,
}),
);
// The descriptor is historical: the source cache can disappear while the
// verified output and its recorded source facts remain unchanged.
await unlink(oldSource.source.path);
const afterSourceRemoval = await run("clean", true);
expect(
afterSourceRemoval.functions.find((item) => item.address === "0x1000"),
).toEqual(first);
expect(digest(await readFile(join(output, first.relative_path)))).toBe(
before,
);
const outputPath = join(output, first.relative_path);
const originalBytes = await readFile(outputPath);
await writeFile(outputPath, "owned modified output");
const conflict = await run("clean", true);
const conflictMain = conflict.functions.find(
(item) => item.address === "0x1000",
);
expect(conflictMain).toMatchObject({
status: "failed",
failure: { code: "output_exists" },
});
expect(conflictMain?.provider_source).toBeUndefined();
expect(await readFile(outputPath, "utf8")).toBe("owned modified output");
await writeFile(outputPath, originalBytes);
const restored = await run("clean", true);
expect(restored.functions.find((item) => item.address === "0x1000")).toEqual(
first,
);
// A file published before a journal append can be adopted on resume, but
// matching bytes do not identify the run that originally wrote the file.
const journalPath = join(output, "decompile-journal.jsonl");
const header = (await readFile(journalPath, "utf8")).split("\n")[0];
await writeFile(journalPath, `${header}\n`);
const adopted = await run("clean", true);
const adoptedMain = adopted.functions.find(
(item) => item.address === "0x1000",
);
expect(adoptedMain?.provider_source).toEqual({
status: "unknown",
reason: "existing_file_origin_unknown",
});
expect(digest(await readFile(join(output, first.relative_path)))).toBe(
before,
);
});
@@ -0,0 +1,333 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, expect, it as test, vi } from "vitest";
import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js";
import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js";
import { parseEvidence } from "../../../../src/domain/evidence.js";
import { KunaProvider } from "../../../../src/kuna/KunaProvider.js";
import { KunaRunSlots } from "../../../../src/kuna/KunaRunSlots.js";
import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js";
import { createApplicationMcpHarness } from "../../../fixtures/applicationMcpHarness.js";
import { advertisedMcpToolSchemas } from "../../../fixtures/advertisedMcpTool.js";
import { createMcpSchemaValidation } from "../../../fixtures/mcpSchemaValidation.js";
import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import {
fakeKunaCalls,
setFakeKunaBehavior,
writeFakeKuna,
} from "./fakeKuna.js";
import { FUNCTIONS, unprovenCleanupLauncher } from "./kunaSessionFixture.js";
const it = test.skipIf(process.platform === "win32");
const schemas = createMcpSchemaValidation();
afterAll(() => schemas.clear());
it("runs a cold independent request without starting a project export", async () => {
const root = await createTestTempDirectory("rea-kuna-independent-cold-");
const kuna = await writeFakeKuna(root, { functions: FUNCTIONS });
const target = join(root, "program");
await writeFile(target, elfNoteFixture({ bits: 64, type: 2 }));
const provider = new KunaProvider(
{
REA_KUNA_COMMAND: kuna,
REA_KUNA_CACHE_DIR: join(root, "cache"),
REA_KUNA_TIMEOUT_MS: "300",
},
{ platform: process.platform },
);
const session = composeBinarySession(
new AnalysisProviderRegistry([provider], "kuna"),
);
const harness = await createApplicationMcpHarness({ session });
try {
const call = async (name: string, args: Record<string, unknown>) => {
const result = await harness.client.callTool({ name, arguments: args });
expect(result.isError, JSON.stringify(result)).not.toBe(true);
return result.structuredContent;
};
await call("open_binary", { path: target, provider_id: "kuna" });
const evidence = parseEvidence(
await call("procedure_pseudo_code", {
procedure: "main",
analysis: "independent_fresh",
}),
);
expect(evidence.raw_result).toMatchObject({
source: { kind: "single-function" },
project_export: { state: "not-started" },
});
const calls = await fakeKunaCalls(kuna);
expect(calls.filter(({ argv }) => argv[0] === "functions")).toHaveLength(1);
expect(calls.filter(({ argv }) => argv[0] === "decompile")).toHaveLength(1);
expect(
calls.filter(({ argv }) => argv[0] === "decompile-project"),
).toHaveLength(0);
await setFakeKunaBehavior(kuna, {
functions: FUNCTIONS,
decompile: "hang",
});
const timeout = await harness.client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "main", analysis: "independent_fresh" },
});
expect(timeout.isError).toBe(true);
const failure = JSON.parse(
(timeout.content[0] as { text: string }).text,
) as {
error: { code: string; remediation: { action: string } };
};
expect(failure.error).toMatchObject({
code: "provider_timeout",
remediation: {
action:
"Raise REA_KUNA_TIMEOUT_MS in the environment that starts REA, then restart REA; it reads Kuna settings when it starts.",
},
details: { timeout_ms: 300 },
});
expect(
(await fakeKunaCalls(kuna)).filter(
({ argv }) => argv[0] === "decompile-project",
),
).toHaveLength(0);
await setFakeKunaBehavior(kuna, { functions: FUNCTIONS });
await call("procedure_assembly", { procedure: "main" });
expect(
(await fakeKunaCalls(kuna)).filter(
({ argv }) => argv[0] === "decompile-project",
),
).toHaveLength(1);
} finally {
await harness.close();
const closed = await provider.closeOwnedResources();
if (!closed.ok) throw closed.error;
}
});
it.each(["compact", "full"] as const)(
"keeps the failed project observation when an explicit fresh retry succeeds (%s)",
async (profile) => {
const root = await createTestTempDirectory("rea-kuna-independent-");
const kuna = await writeFakeKuna(root, { functions: FUNCTIONS });
const target = join(root, "program");
await writeFile(target, elfNoteFixture({ bits: 64, type: 2 }));
const provider = new KunaProvider(
{ REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: join(root, "cache") },
{ platform: process.platform },
);
const session = composeBinarySession(
new AnalysisProviderRegistry([provider], "kuna"),
);
const harness = await createApplicationMcpHarness({
session,
schemaValidator: schemas,
serverOptions: {
environment: {
...process.env,
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
REA_MCP_OUTPUT_SCHEMA_PROFILE: profile,
},
},
});
try {
const call = async (name: string, args: Record<string, unknown>) => {
const schema = await advertisedMcpToolSchemas(
harness.client,
name,
schemas.assertions,
);
const result = await harness.client.callTool(
{ name, arguments: args },
{ toolDefinition: schema.toolDefinition },
);
expect(result.isError, JSON.stringify(result)).not.toBe(true);
expect(
schema.ajv.validate(schema.outputSchema, result.structuredContent),
JSON.stringify(schema.ajv.errors),
).toBe(true);
return result.structuredContent;
};
await call("open_binary", { path: target, provider_id: "kuna" });
await call("procedure_assembly", { procedure: "broken" });
const original = parseEvidence(
await call("procedure_pseudo_code", { procedure: "broken" }),
);
expect(original.normalized_result).toBeNull();
expect(original.limitations).toEqual(
expect.arrayContaining([expect.stringContaining("independent_fresh")]),
);
const status = (await call("binary_session", { detail: "summary" })) as {
result: {
capabilities: Array<{
groups: Array<{
operations: string[];
independent_decompile?: boolean;
}>;
}>;
};
};
expect(
status.result.capabilities
.flatMap((set) => set.groups)
.find((group) => group.operations.includes("procedure_pseudo_code")),
).toMatchObject({ independent_decompile: true });
const first = parseEvidence(
await call("procedure_pseudo_code", {
procedure: "broken",
analysis: "independent_fresh",
}),
);
const second = parseEvidence(
await call("procedure_pseudo_code", {
procedure: "broken",
analysis: "independent_fresh",
}),
);
expect(first.normalized_result).toEqual(
expect.stringContaining("single-function"),
);
expect(first.parameters.analysis).toBe("independent_fresh");
expect(first.evidence_id).not.toBe(original.evidence_id);
expect(second.evidence_id).toBe(first.evidence_id);
expect(
(await fakeKunaCalls(kuna)).filter(
({ argv }) => argv[0] === "decompile",
),
).toHaveLength(2);
const after = parseEvidence(
await call("procedure_pseudo_code", { procedure: "broken" }),
);
expect(after.normalized_result).toBeNull();
const bundle = (await call("get_evidence_bundle", {})) as {
result: { records: Array<{ evidence_id: string }> };
};
expect(bundle.result.records.map((record) => record.evidence_id)).toEqual(
expect.arrayContaining([original.evidence_id, first.evidence_id]),
);
} finally {
await harness.close();
const closed = await provider.closeOwnedResources();
if (!closed.ok) throw closed.error;
}
},
);
it("retains the independent run slot until an unproven stop is resolved", async () => {
const root = await createTestTempDirectory("rea-kuna-independent-cleanup-");
const kuna = await writeFakeKuna(root, {
functions: FUNCTIONS,
decompile: "hang",
});
const target = join(root, "program");
await writeFile(target, elfNoteFixture({ bits: 64, type: 2 }));
const unproven = unprovenCleanupLauncher("decompile");
const provider = new KunaProvider(
{
REA_KUNA_COMMAND: kuna,
REA_KUNA_CACHE_DIR: join(root, "cache"),
REA_KUNA_TIMEOUT_MS: "300",
},
{
platform: process.platform,
launcher: unproven.launcher,
singleFunctionRuns: 1,
},
);
const session = composeBinarySession(
new AnalysisProviderRegistry([provider], "kuna"),
);
const harness = await createApplicationMcpHarness({
session,
schemaValidator: schemas,
});
const caller = new AbortController();
let restoreAcquire: (() => void) | undefined;
let queued: Promise<unknown> | undefined;
try {
const call = (
name: string,
args: Record<string, unknown>,
signal?: AbortSignal,
) =>
harness.client.callTool(
{ name, arguments: args },
{ ...(signal === undefined ? {} : { signal }), timeout: 5000 },
);
expect(
(await call("open_binary", { path: target, provider_id: "kuna" }))
.isError,
).not.toBe(true);
expect(
(await call("procedure_assembly", { procedure: "broken" })).isError,
).not.toBe(true);
const first = await call("procedure_pseudo_code", {
procedure: "broken",
analysis: "independent_fresh",
});
expect(first.isError).toBe(true);
const failure = JSON.parse((first.content[0] as { text: string }).text) as {
error: { code: string };
};
expect(failure.error.code).toBe("cleanup_incomplete");
const acquire = KunaRunSlots.prototype.acquire;
let arrived!: () => void;
const admission = new Promise<void>((resolve) => {
arrived = resolve;
});
let slotSettled = false;
const spy = vi
.spyOn(KunaRunSlots.prototype, "acquire")
.mockImplementation(function (this: KunaRunSlots, signal) {
const result = acquire.call(this, signal);
void result.then(() => {
slotSettled = true;
});
arrived();
return result;
});
restoreAcquire = () => spy.mockRestore();
queued = call(
"procedure_pseudo_code",
{ procedure: "main", analysis: "independent_fresh" },
caller.signal,
).catch(() => undefined);
await awaitAcknowledgement(
admission,
queued,
"independent run slot admission",
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(slotSettled).toBe(false);
expect(
(await fakeKunaCalls(kuna)).filter(({ argv }) => argv[0] === "decompile"),
).toHaveLength(1);
caller.abort();
await queued;
restoreAcquire();
restoreAcquire = undefined;
expect((await call("close_binary", {})).isError).toBe(true);
unproven.release();
expect((await call("close_binary", {})).isError).not.toBe(true);
await setFakeKunaBehavior(kuna, { functions: FUNCTIONS });
expect(
(await call("open_binary", { path: target, provider_id: "kuna" }))
.isError,
).not.toBe(true);
const recovered = await call("procedure_pseudo_code", {
procedure: "main",
analysis: "independent_fresh",
});
expect(recovered.isError).not.toBe(true);
expect(
(await fakeKunaCalls(kuna)).filter(({ argv }) => argv[0] === "decompile"),
).toHaveLength(2);
} finally {
caller.abort();
unproven.release();
await queued;
restoreAcquire?.();
await harness.close();
const closed = await provider.closeOwnedResources();
if (!closed.ok) throw closed.error;
}
});
@@ -440,9 +440,21 @@ it("reports a failed function and an import slot as unavailable text, not errors
procedure: "broken",
});
expect(broken.result).toBeNull();
expect(broken.limitations.at(-1)).toBe(
expect(broken.limitations).toContain(
"Kuna could not decompile this function: Flow exceeded maximum instructions",
);
const dossier = await run(client, "analyze_function", {
procedure: "broken",
});
expect(dossier.result).toMatchObject({
facet_availability: {
pseudocode: {
available: false,
reason:
"Kuna could not decompile this function: Flow exceeded maximum instructions",
},
},
});
const imported = await run(client, "procedure_pseudo_code", {
procedure: "puts",
});
@@ -575,6 +587,20 @@ it("keeps answering pseudocode one function at a time when the export fails", as
source: { kind: "single-function" },
});
expect(pseudocode.limitations.at(-1)).toContain("The project export failed");
const fresh = await run(client, "procedure_pseudo_code", {
procedure: "main",
analysis: "independent_fresh",
});
expect(fresh.rawResult).toMatchObject({
source: { kind: "single-function" },
project_export: {
state: "failed",
reason: expect.stringContaining("not in recognized object file format"),
},
});
expect(fresh.limitations).toContainEqual(
expect.stringContaining("fresh independent single-function"),
);
await client.close();
});
@@ -1,7 +1,6 @@
import { expect, it as test } from "vitest";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { kunaTimeoutRecovery } from "../../../../src/kuna/KunaCommand.js";
import {
fakeKunaCalls,
@@ -306,20 +305,11 @@ it("names the setting that owns a single-function deadline", async () => {
message:
"kuna decompile did not finish within REA_KUNA_TIMEOUT_MS (300 ms).",
remediation: {
action: expect.stringMatching(
/^Raise REA_KUNA_TIMEOUT_MS .* restart REA.*project export/u,
),
action:
"Raise REA_KUNA_TIMEOUT_MS in the environment that starts REA, then restart REA; it reads Kuna settings when it starts.",
},
details: { timeout_ms: 300 },
});
// A finished invocation cannot read a later export from this session.
const recovery = kunaTimeoutRecovery("REA_KUNA_TIMEOUT_MS", "decompile");
expect(
projectAnalysisError(result.error, "invocation").remediation.action,
).toBe(recovery.remediationAction);
expect(projectAnalysisError(result.error).remediation.action).toBe(
recovery.continuingCallerGuidance?.remediationAction,
);
} finally {
expect(await client.close()).toEqual({ ok: true, value: null });
}