fix(jeb): retain selected inputs and completed primary replies

This commit is contained in:
morluto
2026-10-24 02:53:56 +08:00
parent 3e112595da
commit 0f5ece1d58
3 changed files with 196 additions and 13 deletions
+7
View File
@@ -68,3 +68,10 @@ All four preserve target-file bytes. `open_jeb_project` selects or creates an en
- An unreachable endpoint is a provider-availability failure with recovery guidance; REA does not retry or spawn the engine.
Real-engine verification lives in the JEB lane described in [testing.md](testing.md); mock transport tests do not establish real-engine compatibility.
A project-open refusal retains the selected engine-host artifact path separately
from the service endpoint. Discovery of an unreachable service includes the same
start/configuration recovery as a direct request. If a primary reply completes
before a later client-information lookup fails or is cancelled, the typed failure
retains that reply as partial Evidence. Engine metadata stays unknown; this is not
a claim that the full operation completed.
+122 -2
View File
@@ -13,8 +13,13 @@ import {
AnalysisCancelledError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { jebResultSchemas } from "../domain/jeb/jebAnalysis.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { parseEvidence } from "../domain/evidence.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
jebRequestSchema,
jebResultSchemas,
} from "../domain/jeb/jebAnalysis.js";
const scriptedConnection = (
responses: Readonly<Record<string, JsonValue | Error>>,
@@ -155,6 +160,8 @@ describe("JebProvider observations and failures", () => {
);
const availability = await provider.inspectAvailability();
expect(availability.status).toBe("unavailable");
expect(availability.reason).toContain("Start a JEB client serving MCP");
expect(availability.reason).toContain("REA_JEB_MCP_URL");
const result = await provider.execute({
operation: "list_jeb_units",
input: { count: 10, index: 0 },
@@ -520,3 +527,116 @@ it("preserves provider-authored timeout guidance when projecting the requested o
await provider.close();
}
});
it("project-open refusal retains the selected engine-host artifact path", async () => {
const provider = providerWith(
scriptedConnection({
open_project: {
success: false,
message: "The input file could not be processed",
},
}),
);
const result = await provider.execute({
operation: "open_jeb_project",
input: { path: "/targets/Example.apk" },
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Refused artifact accepted");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unsupported_target",
details: {
path: "/targets/Example.apk",
reason: "The input file could not be processed",
},
});
await provider.close();
});
it.each([
{
operation: "list_jeb_units",
input: {},
tool: "list_units",
payload: {
success: true,
units: [{ unit_path: "sample.dex", unit_type: "dex" }],
},
},
{
operation: "open_jeb_project",
input: { path: "/targets/Example.apk" },
tool: "open_project",
payload: {
success: true,
name: "Example",
input_files: [],
units: [{ unit_path: "sample.dex", unit_type: "dex" }],
},
},
{
operation: "decompile_jeb_item",
input: {
item_kind: "type",
item_address: "LExample;",
unit_path: "sample.dex",
},
tool: "decompile_code_item",
payload: { success: true, text: "class Example {}" },
},
])(
"retains completed $operation output when later client metadata is cancelled",
async ({ operation, input, tool, payload }) => {
const request = jebRequestSchema.parse({ operation, input });
const controller = new AbortController();
const reason = { source: "metadata-cancellation" };
const primary = jsonValueSchema.parse(payload);
const connection = scriptedConnection({ [tool]: primary });
const call = connection.call.bind(connection);
connection.call = async (name, args, signal) => {
if (name === "get_client_information") {
controller.abort(reason);
throw new AnalysisCancelledError(name, { cause: reason });
}
return call(name, args, signal);
};
const provider = providerWith(connection);
const result = await provider.execute(request, {
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Cancelled metadata lookup accepted");
expect(result.error).toMatchObject({
_tag: "AnalysisCancelledError",
operation,
cause: reason,
});
const partial = parseEvidence(result.error.partialObservation);
expect(partial.operation).toBe(operation);
expect(partial.parameters).toEqual(request.input);
expect(partial.normalized_result).toMatchObject({
status: "partial",
observation: payload,
engine_metadata: null,
});
expect(partial.raw_result).toEqual(payload);
await provider.close();
const control = providerWith(
scriptedConnection({
[tool]: primary,
get_client_information: {
success: true,
version: "5.48.0",
gui_client: false,
},
}),
);
const completed = await control.execute(request);
expect(completed.ok).toBe(true);
if (!completed.ok) throw completed.error;
expect(completed.value.result).toMatchObject({
engine: { version: "5.48.0" },
});
await control.close();
},
);
+67 -11
View File
@@ -2,6 +2,8 @@ import {
JebMcpAcquisitionCleanupFailure,
JebMcpPhysicalFailure,
} from "./JebMcpConnectionOwner.js";
import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { z } from "zod";
import type {
@@ -36,6 +38,8 @@ import {
type JebMcpConnectionFactory,
} from "./JebMcpConnection.js";
const JEB_CONNECTION_REMEDIATION = `Start a JEB client serving MCP (default ${DEFAULT_JEB_MCP_ENDPOINT}) or set REA_JEB_MCP_URL to the running endpoint.`;
export const JEB_PROVIDER_IDENTITY: ProviderIdentity = {
id: "jeb",
name: "JEB",
@@ -171,10 +175,7 @@ export class JebProvider implements JebAnalysisPort {
return {
status: "unavailable",
code: "not_configured",
reason:
cause instanceof Error
? cause.message
: "The JEB MCP endpoint did not respond",
reason: `${cause instanceof Error ? cause.message : "The JEB MCP endpoint did not respond"}. ${JEB_CONNECTION_REMEDIATION}`,
diagnostics: { endpoint: this.#endpoint.toString() },
};
}
@@ -250,6 +251,42 @@ export class JebProvider implements JebAnalysisPort {
};
}
async #engineForObservation(
request: JebRequest,
payload: JsonValue,
observation: unknown,
signal?: AbortSignal,
) {
try {
return await this.#engine(request.operation, signal);
} catch (cause: unknown) {
throw this.#providerFailure(
request.operation,
cause,
).retainPartialObservation(
createAnalysisExecutionEvidence(
request.operation,
request.input,
createAnalysisExecution(
{
status: "partial",
observation: jsonValueSchema.parse(observation),
engine_metadata: null,
},
JEB_PROVIDER_IDENTITY,
{
rawResult: payload,
limitations: [
"The primary engine reply completed; client metadata was not obtained, so the full operation result could not be assembled.",
],
},
),
"observed",
),
);
}
}
async #refreshClientInformation(
operation: JebOperation,
signal: AbortSignal | undefined,
@@ -309,9 +346,14 @@ export class JebProvider implements JebAnalysisPort {
signal,
);
const opened = openProject.parse(
this.#requireSuccess(payload, request.operation),
this.#requireSuccess(payload, request.operation, request.input.path),
);
const engine = await this.#engineForObservation(
request,
payload,
opened,
signal,
);
const engine = await this.#engine(request.operation, signal);
const result = jebResultSchemas.open_jeb_project.parse({
engine,
project_name: opened.name ?? null,
@@ -348,7 +390,12 @@ export class JebProvider implements JebAnalysisPort {
this.#requireSuccess(payload, request.operation),
);
const units = listed.units;
const engine = await this.#engine(request.operation, signal);
const engine = await this.#engineForObservation(
request,
payload,
listed,
signal,
);
const result = jebResultSchemas.list_jeb_units.parse({
engine,
filter: filter ?? null,
@@ -385,7 +432,12 @@ export class JebProvider implements JebAnalysisPort {
const decompiled = decompiledItem.parse(
this.#requireSuccess(payload, request.operation),
);
const engine = await this.#engine(request.operation, signal);
const engine = await this.#engineForObservation(
request,
payload,
decompiled,
signal,
);
const result = jebResultSchemas.decompile_jeb_item.parse({
engine,
unit_path: unit_path ?? null,
@@ -406,7 +458,11 @@ export class JebProvider implements JebAnalysisPort {
);
}
#requireSuccess(payload: unknown, operation: JebOperation): unknown {
#requireSuccess(
payload: unknown,
operation: JebOperation,
targetPath?: string,
): unknown {
if (typeof payload !== "object" || payload === null)
throw new AnalysisProtocolError(
"JEB MCP returned a non-object envelope; no observation can be established",
@@ -415,7 +471,7 @@ export class JebProvider implements JebAnalysisPort {
if (success === false)
throw new AnalysisUnsupportedTargetError(
operation,
this.#endpoint.toString(),
targetPath ?? this.#endpoint.toString(),
refusalReason(payload, "The engine refused the request"),
);
if (success !== true)
@@ -475,7 +531,7 @@ export class JebProvider implements JebAnalysisPort {
physical instanceof Error ? physical.message : String(physical),
{
cause,
userMessage: `Start a JEB client serving MCP (default ${DEFAULT_JEB_MCP_ENDPOINT}) or set REA_JEB_MCP_URL to the running endpoint.`,
userMessage: JEB_CONNECTION_REMEDIATION,
},
);
}