fix: retain completed observations when later analysis steps fail

This commit is contained in:
morluto
2026-10-10 00:35:17 +08:00
parent e11f4d371a
commit 35f121f4ac
9 changed files with 350 additions and 40 deletions
+6
View File
@@ -118,6 +118,12 @@ selected overload. Native/abstract methods with no decompiled body report
responses remain in Evidence. Source revision is populated only for bytes
matching the audited release JAR.
If a later step fails, the error's `details.partial_observation` retains the
selected and resolved APK paths, digest, observed provider identity and versions,
and each upstream request and reply collected before the failure. Failed replies
remain available even when their payload cannot be interpreted. Cancellation
also stops target hashing before provider admission.
These operations do not execute the APK or provide split APK/AAB handling,
signature validation, full resource-table semantics, native-library analysis or
Android runtime capture. Existing artifact inventory/extraction tools can supply
+21 -17
View File
@@ -123,24 +123,25 @@ const cleanup = async (
resources.session = undefined;
} catch (cause) {
// Retain an uncertain workspace until the caller can resolve process ownership.
return err(
new ProviderCleanupError(
"jadx",
[
session?.transport.runId ?? "unknown",
...(root === undefined ? [] : [root.path]),
],
{
...diagnostics,
reason: cause instanceof Error ? cause.message : String(cause),
provider_cleanup:
cause instanceof ProviderCleanupError
? (cause.diagnostics ?? null)
: null,
},
{ cause: previousError ?? cause },
),
const error = new ProviderCleanupError(
"jadx",
[
session?.transport.runId ?? "unknown",
...(root === undefined ? [] : [root.path]),
],
{
...diagnostics,
reason: cause instanceof Error ? cause.message : String(cause),
provider_cleanup:
cause instanceof ProviderCleanupError
? (cause.diagnostics ?? null)
: null,
},
{ cause: previousError ?? cause },
);
if (previousError?.partialObservation !== undefined)
error.retainPartialObservation(previousError.partialObservation);
return err(error);
}
try {
await root?.close();
@@ -396,6 +397,9 @@ export class JadxProvider implements AndroidAnalysisPort {
signal: options?.signal,
session,
});
const partialObservation = session?.partialObservation();
if (partialObservation !== undefined)
previousError.retainPartialObservation(partialObservation);
// Admission can fail before selecting an existing session for execution.
root ??= this.#retained?.root;
session ??= this.#retained?.session;
+63 -9
View File
@@ -4,6 +4,7 @@ import {
type AnalysisExecution,
} from "../application/AnalysisProvider.js";
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
import type { AndroidPartialObservation } from "../domain/android/androidPartialObservation.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
@@ -35,6 +36,23 @@ export class JadxSession {
readonly #client = new Client({ name: "rea-android-adapter", version: "1" });
#loaded: ReturnType<typeof jadxLoadSchema.parse> | undefined;
#connected = false;
#partialContext:
| {
readonly operation: AndroidRequest["operation"];
readonly selectedPath: string;
readonly target: BinaryTarget;
readonly jarHash: string;
readonly bridgeHash: string;
}
| undefined;
#observedCalls: {
readonly operation: string;
readonly input: Readonly<Record<string, JsonValue>>;
readonly response: JsonValue;
}[] = [];
#serverName: string | null = null;
#serverVersion: string | null = null;
#engineVersion: string | null = null;
constructor(
options: Omit<OwnedProviderProcessSpawnOptions, "runId" | "stdin">,
@@ -53,6 +71,17 @@ export class JadxSession {
readonly signal: AbortSignal;
}): Promise<AnalysisExecution> {
const { request, target, snapshot, jarHash, signal } = context;
this.#partialContext = {
operation: request.operation,
selectedPath: request.input.path,
target,
jarHash,
bridgeHash: context.bridgeHash,
};
this.#observedCalls = [];
this.#serverName = null;
this.#serverVersion = null;
this.#engineVersion = null;
const abort = () => {
void this.transport.close().catch(() => undefined);
};
@@ -60,12 +89,13 @@ export class JadxSession {
try {
signal.throwIfAborted();
this.transport.beginOperation();
const raw: JsonValue[] = [];
if (!this.#connected) {
await this.#client.connect(this.transport, { timeout: 30_000 });
this.#connected = true;
}
const server = this.#client.getServerVersion();
this.#serverName = server?.name ?? null;
this.#serverVersion = server?.version ?? null;
if (
server?.name !== JADX_BRIDGE_IDENTITY.name ||
server.version !== JADX_BRIDGE_IDENTITY.version
@@ -75,10 +105,11 @@ export class JadxSession {
request.operation,
`Expected ${JADX_BRIDGE_IDENTITY.name} ${JADX_BRIDGE_IDENTITY.version}; server reported ${server?.name ?? "unknown"} ${server?.version ?? "unknown"}.`,
);
const tools = this.#tools(request, target, signal, raw);
const tools = this.#tools(request, target, signal);
const runtime = jadxRuntimeSchema.parse(
await tools.json("rea_jvm_status", {}),
);
this.#engineVersion = runtime.engine_reported_version;
if (runtime.engine_reported_version !== JADX_RELEASE.version)
throw new AnalysisCapabilityUnavailableError(
"jadx",
@@ -117,7 +148,7 @@ export class JadxSession {
bridge_sha256: context.bridgeHash,
jar_sha256: jarHash,
loaded,
calls: raw,
calls: this.#observedCalls,
},
subject: target,
limitations: JADX_LIMITATIONS,
@@ -131,6 +162,32 @@ export class JadxSession {
}
}
/** Facts and upstream replies retained if a later request step fails. */
partialObservation(): AndroidPartialObservation | undefined {
const context = this.#partialContext;
if (context === undefined) return undefined;
return {
provider_id: "jadx",
operation: context.operation,
target: {
selected_path: context.selectedPath,
path: context.target.path,
sha256: context.target.sha256,
format: "apk",
},
provider_facts: {
jar_sha256: context.jarHash,
bridge_sha256: context.bridgeHash,
bridge_name: JADX_BRIDGE_IDENTITY.name,
bridge_version: JADX_BRIDGE_IDENTITY.version,
server_name: this.#serverName,
server_version: this.#serverVersion,
engine_version: this.#engineVersion,
},
calls: this.#observedCalls,
};
}
/** Whether the selected Java runtime established the REA bridge protocol. */
initialized(): boolean {
return this.#connected;
@@ -149,7 +206,6 @@ export class JadxSession {
request: AndroidRequest,
target: BinaryTarget,
signal: AbortSignal,
raw: JsonValue[],
): JadxToolPort {
const call = async (
name: string,
@@ -160,12 +216,10 @@ export class JadxSession {
{ name, arguments: input },
{ timeout: 120_000, signal },
);
const observedResponse = jsonValueSchema.parse(response);
const call = { operation: name, input, response: observedResponse };
this.#observedCalls.push(call);
const envelope = parseJadxEnvelope(response, request.operation);
raw.push({
operation: name,
input,
response: jsonValueSchema.parse(response),
});
if (envelope.failed) {
if (envelope.text.startsWith("{")) {
const failure = jadxInputFailureSchema.safeParse(
+10 -5
View File
@@ -269,7 +269,8 @@ const runAnalysis = async (
});
if (evidence !== undefined) {
const recorded = session.recordEvidence(evidence);
if (!recorded.ok) return cliError(recorded.error);
if (!recorded.ok)
return cliError(recorded.error.retainPartialObservation(evidence));
if (isWorkflowEvidenceTool(tool)) {
const unknowns = recordWorkflowUnknowns({
name: tool,
@@ -277,7 +278,8 @@ const runAnalysis = async (
evidenceId: evidence.evidence_id,
recordUnknown: (unknown) => session.recordUnknown(unknown),
});
if (!unknowns.ok) return cliError(unknowns.error);
if (!unknowns.ok)
return cliError(unknowns.error.retainPartialObservation(evidence));
}
}
if (
@@ -290,7 +292,8 @@ const runAnalysis = async (
const workflowRecord = workflowSnapshotRecord(evidence, tool);
if (workflowRecord !== undefined) {
const recorded = session.recordWorkflowSnapshot(workflowRecord);
if (!recorded.ok) return cliError(recorded.error);
if (!recorded.ok)
return cliError(recorded.error.retainPartialObservation(evidence));
}
}
if (
@@ -299,13 +302,15 @@ const runAnalysis = async (
evidence !== undefined
) {
const snapshot = session.exportAnalysisSnapshot();
if (!snapshot.ok) return cliError(snapshot.error);
if (!snapshot.ok)
return cliError(snapshot.error.retainPartialObservation(evidence));
const written = await writeAnalysisSnapshot(
snapshot.value,
snapshotPath,
true,
);
if (!written.ok) return cliError(written.error);
if (!written.ok)
return cliError(written.error.retainPartialObservation(evidence));
}
return output;
},
+12 -2
View File
@@ -77,8 +77,14 @@ export abstract class AnalysisError extends Error {
readonly userMessage: string | undefined = undefined;
readonly userCategory: "cancelled" | undefined = undefined;
readonly executionFailure: string | undefined = undefined;
readonly partialObservation: AnalysisPartialObservation | undefined;
partialObservation: AnalysisPartialObservation | undefined;
readonly cleanupReport: ProcessCaptureCleanupReport | undefined = undefined;
/** Retain a completed observation when a later step fails. */
retainPartialObservation(observation: AnalysisPartialObservation): this {
this.partialObservation ??= observation;
return this;
}
}
import type {
PartialProcessCaptureObservation,
@@ -90,6 +96,8 @@ import type { ElectronActivePartialObservation } from "./javascript/electronActi
import type { JavaScriptRuntimeObservation } from "./javascript/javascriptRuntimeObservation.js";
import type { ArtifactInventoryPartialObservation } from "./artifactPartialObservation.js";
import type { BrowserScenarioPartialObservation } from "./browserScenarioCapture.js";
import type { Evidence } from "./evidence.js";
import type { AndroidPartialObservation } from "./android/androidPartialObservation.js";
/** Provider-neutral evidence collected before a typed analysis failure. */
export type AnalysisPartialObservation =
@@ -99,4 +107,6 @@ export type AnalysisPartialObservation =
| ElectronActivePartialObservation
| BrowserScenarioPartialObservation
| JavaScriptRuntimeObservation
| ArtifactInventoryPartialObservation;
| ArtifactInventoryPartialObservation
| Evidence
| AndroidPartialObservation;
@@ -0,0 +1,46 @@
import { z } from "zod";
import { digestSchema } from "../digests.js";
import { jsonObjectSchema, jsonValueSchema } from "../jsonValue.js";
import { localPathStringSchema } from "../localPath.js";
const operationSchema = z.enum([
"inspect_android_package",
"search_android_classes",
"inspect_android_class",
"inspect_android_method",
"trace_android_references",
]);
/** JSON-safe Android provider facts collected before an analysis failure. */
export const androidPartialObservationSchema = z.strictObject({
provider_id: z.literal("jadx"),
operation: operationSchema,
target: z.strictObject({
selected_path: localPathStringSchema,
path: localPathStringSchema,
sha256: digestSchema,
format: z.literal("apk"),
}),
provider_facts: z.strictObject({
jar_sha256: digestSchema.nullable(),
bridge_sha256: digestSchema.nullable(),
bridge_name: z.string().min(1),
bridge_version: z.string().min(1),
server_name: z.string().nullable(),
server_version: z.string().nullable(),
engine_version: z.string().min(1).nullable(),
}),
calls: z.array(
z.strictObject({
operation: z.string().min(1),
input: jsonObjectSchema,
response: jsonValueSchema,
}),
),
});
/** JSON-safe Android provider facts retained when analysis fails. */
export type AndroidPartialObservation = z.infer<
typeof androidPartialObservationSchema
>;
@@ -5,7 +5,9 @@ import { expect, it as test, onTestFinished } from "vitest";
import { z } from "zod";
import { access } from "node:fs/promises";
import { ANDROID_TOOL_CONTRACTS } from "../../../src/contracts/android/androidToolContracts.js";
import { androidPartialObservationSchema } from "../../../src/domain/android/androidPartialObservation.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { createServer } from "../../../src/server/createServer.js";
import { ToolResultDelivery } from "../../../src/server/toolResult.js";
import { startMcpTransport } from "../../../src/main/transport.js";
@@ -14,6 +16,7 @@ import type { AndroidAnalysisPort } from "../../../src/application/android/Andro
import { silentLogger } from "../../../src/logger.js";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import {
createJadxProtocolFixture,
verifyJadxFixtureCleanup,
@@ -148,6 +151,137 @@ it("publishes and executes all APK contracts with inline Evidence and no active
await verifyJadxFixtureCleanup(launches);
});
test.each([
{
scenario: "failed upstream reply",
mode: "tool-error",
errorCode: "execution_failure",
serverName: "rea-jadx-bridge",
serverVersion: "1",
engineVersion: "0.7.1",
calls: [
{ operation: "rea_jvm_status" },
{ operation: "load_apk" },
{ operation: "get_app_info" },
{
operation: "get_android_manifest",
response: { isError: true },
},
],
containsAmbientEnvironment: false,
},
{
scenario: "transport error after successful replies",
mode: "rpc-disconnect",
errorCode: "execution_failure",
serverName: "rea-jadx-bridge",
serverVersion: "1",
engineVersion: "0.7.1",
calls: [{ operation: "rea_jvm_status" }, { operation: "load_apk" }],
containsAmbientEnvironment: true,
},
{
scenario: "empty handshake identity mismatch",
mode: "empty-server-identity",
errorCode: "capability_unavailable",
serverName: "",
serverVersion: "",
engineVersion: null,
calls: [],
containsAmbientEnvironment: true,
},
])(
"retains Android partial observations for $scenario through MCP",
async ({
mode,
errorCode,
serverName,
serverVersion,
engineVersion,
calls,
containsAmbientEnvironment,
}) => {
const { provider, apk, launches } = await createJadxProtocolFixture(mode);
const session = createTestBinarySession(() => {
throw new Error("Unexpected native provider acquisition");
});
const server = createServer(
{ kind: "session", session },
{ androidAnalysis: provider },
);
const client = new Client({
name: "android-partial-observation-regression",
version: "1",
});
onTestFinished(async () => {
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const response = await client.callTool({
name: "inspect_android_package",
arguments: { path: apk },
});
const projected = parseMcpToolError(response).error;
expect(projected.code).toBe(errorCode);
const details = z.record(z.string(), z.unknown()).parse(projected.details);
const partial = androidPartialObservationSchema.parse(
details.partial_observation,
);
expect(partial).toMatchObject({
provider_id: "jadx",
operation: "inspect_android_package",
target: { selected_path: apk, path: apk, format: "apk" },
provider_facts: {
bridge_name: "rea-jadx-bridge",
bridge_version: "1",
server_name: serverName,
server_version: serverVersion,
engine_version: engineVersion,
},
calls,
});
if (!containsAmbientEnvironment)
expect(JSON.stringify(partial)).not.toContain("JAVA_HOME");
await client.close();
await server.close();
await verifyJadxFixtureCleanup(launches);
},
);
it("retains partial JADX replies when owned cleanup also fails", async () => {
const { service, apk } = await createJadxProtocolFixture("cleanup-failure");
const result = await service.execute("inspect_android_package", {
path: apk,
});
if (result.ok) throw new Error("Expected the fixture tool to fail");
const projected = projectAnalysisError(result.error);
expect(projected.code).toBe("cleanup_incomplete");
expect(projected.details).toMatchObject({
cleanup: "incomplete",
execution_failure: "execution_failure",
partial_observation: {
provider_id: "jadx",
operation: "inspect_android_package",
calls: [
{ operation: "rea_jvm_status" },
{ operation: "load_apk" },
{ operation: "get_app_info" },
{
operation: "get_android_manifest",
response: { isError: true },
},
],
},
});
});
it("cleans an active engine when the MCP client disconnects", async () => {
const { provider, apk, launches } = await createJadxProtocolFixture("stall");
const session = createTestBinarySession(() => {
@@ -3,6 +3,7 @@ import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { runDirectAnalysis } from "../../src/application/DirectAnalysis.js";
import type {
@@ -18,7 +19,11 @@ import {
readAnalysisSnapshot,
writeAnalysisSnapshot,
} from "../../src/application/binary/AnalysisSnapshotFiles.js";
import { createEvidence, type Evidence } from "../../src/domain/evidence.js";
import {
createEvidence,
parseEvidence,
type Evidence,
} from "../../src/domain/evidence.js";
import { createEvidenceBundle } from "../../src/domain/evidenceBundle.js";
import {
REA_WORKFLOW_PROVIDER,
@@ -646,7 +651,51 @@ describe("snapshot replay provider identity", () => {
});
});
const assertEvidenceSurvivesSnapshotSaveFailure = async (): Promise<void> => {
const directory = await createTestTempDirectory("rea-workflow-save-error-");
const path = join(directory, "fixture.hop");
const snapshotPath = join(directory, "missing", "snapshot.json");
await writeFile(path, "fixture");
const starts: string[] = [];
const calls: string[] = [];
const provider = makeProvider(starts, calls);
const dependencies: DirectAnalysisDependencies = {
readConfiguration: () => parseConfig({}),
createBinarySession: () => createTestBinarySession(provider),
createManagedBinarySession: () => createTestBinarySession(provider),
};
const result = await runDirectAnalysis(
dependencies,
path,
"binary_overview",
{},
{ snapshotPath },
);
expect(result).toMatchObject({
code: "invalid_request",
details: {
partial_observation: {
operation: "binary_overview",
subject: { local_path: path },
},
},
});
const partial = z
.object({
details: z.object({ partial_observation: z.unknown() }),
})
.parse(result).details.partial_observation;
expect(parseEvidence(partial).operation).toBe("binary_overview");
expect(calls.toSorted()).toEqual(operations.toSorted());
};
describe("workflow snapshot profile and cancellation binding", () => {
it(
"preserves completed Evidence when CLI snapshot persistence fails",
assertEvidenceSurvivesSnapshotSaveFailure,
);
it("does not replay a valid entry from a different workflow profile", async () => {
const directory = await createTestTempDirectory("rea-workflow-profile-");
const path = join(directory, "fixture.hop");
+8 -6
View File
@@ -6,8 +6,9 @@ import { z } from "zod";
// It is a protocol fixture, not evidence of JADX decompiler support.
const mode = process.env.REA_FAKE_JADX_MODE ?? "normal";
const server = new McpServer({
name: "rea-jadx-bridge",
version: mode === "version" ? "99.0.0" : "1",
name: mode === "empty-server-identity" ? "" : "rea-jadx-bridge",
version:
mode === "empty-server-identity" ? "" : mode === "version" ? "99.0.0" : "1",
});
const classes = [
"fixture.Target",
@@ -60,16 +61,17 @@ register("load_apk", async ({ path }) => {
resources: "full",
});
});
register("get_app_info", () =>
json({
register("get_app_info", () => {
if (mode === "rpc-disconnect") process.exit(17);
return json({
package: "fixture",
version_name: "1",
version_code: "1",
min_sdk: "26",
target_sdk: "33",
permissions: ["android.permission.INTERNET"],
}),
);
});
});
register("get_android_manifest", () => {
if (mode === "tool-error" || mode === "cleanup-failure")
return {