diff --git a/docs/jeb-analysis.md b/docs/jeb-analysis.md index 98905e5e6e..0757b81b91 100644 --- a/docs/jeb-analysis.md +++ b/docs/jeb-analysis.md @@ -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. diff --git a/src/jeb/JebProvider.test.ts b/src/jeb/JebProvider.test.ts index 3bd7d973d6..c4df83ceed 100644 --- a/src/jeb/JebProvider.test.ts +++ b/src/jeb/JebProvider.test.ts @@ -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>, @@ -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(); + }, +); diff --git a/src/jeb/JebProvider.ts b/src/jeb/JebProvider.ts index 2ea691a761..a78b400b5c 100644 --- a/src/jeb/JebProvider.ts +++ b/src/jeb/JebProvider.ts @@ -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, }, ); }