mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5f9874cfe | ||
|
|
5f588485fe | ||
|
|
768a4c169d | ||
|
|
b55b0c317b | ||
|
|
7c1bd003ec | ||
|
|
22b5914c3d |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
: ({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user