fix(jeb): cache parsed metadata independently of optional version

Own the parsed engine facts separately from identity projections. Reuse successful unknown-version metadata, retain explicit refresh, and bind each execution identity to its own selected metadata under concurrent inspection.

Refs #4600 and #4597. Six affected files pass 58 tests; static, documentation and discovery checks passed. Real JEB and reconnect behavior remain unverified; prepared reconnect PR3754 stays separate pending cleanup ownership work.
This commit is contained in:
morluto
2026-10-17 06:03:51 +08:00
parent ece277b30b
commit 67384b23e5
4 changed files with 168 additions and 60 deletions
+1
View File
@@ -52,6 +52,7 @@ All four are read-only with respect to the target file; only `open_jeb_project`
- The engine caps `list_units` pages at 100; a full page is reported as `coverage: "partial"` because more units may exist. An empty remainder is `complete` only when fewer units than requested were returned.
- When `decompile_jeb_item` omits `unit_path`, the engine implicitly selects the project's first code unit; REA reports `unit_path: null` and a limitation instead of guessing the effective unit.
- Engine refusals (`success: false` envelopes) are preserved as unsupported-target errors carrying the engine's own message, for example a nonexistent item address.
- Successfully parsed client information is reused for the lifetime of the MCP connection, including when the engine omits its version. Explicit client inspection and availability probes refresh it.
- 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.
+73
View File
@@ -409,3 +409,76 @@ it("forwards in-flight cancellation to the MCP call", async () => {
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
});
it("refreshes cached unknown metadata only through an explicit information probe", async () => {
const responses: Record<string, JsonValue> = {
get_client_information: { success: true, gui_client: false },
list_units: { success: true, units: [] },
};
const connection = scriptedConnection(responses);
const provider = providerWith(connection);
try {
const first = await provider.execute({
operation: "list_jeb_units",
input: { count: 2, index: 0 },
});
if (!first.ok) throw first.error;
expect(first.value.provider.version).toBeNull();
responses.get_client_information = {
success: true,
version: "5.49.0",
gui_client: true,
};
const inspected = await provider.execute({
operation: "inspect_jeb_client",
input: {},
});
if (!inspected.ok) throw inspected.error;
const after = await provider.execute({
operation: "list_jeb_units",
input: { count: 2, index: 0 },
});
if (!after.ok) throw after.error;
expect(after.value.provider.version).toBe("5.49.0");
expect(after.value.result).toMatchObject({
engine: { version: "5.49.0", gui_client: true },
});
expect(connection.calls.map(([name]) => name)).toEqual([
"list_units",
"get_client_information",
"get_client_information",
"list_units",
]);
} finally {
await provider.close();
}
});
it("keeps concurrent client inspections bound to their own parsed metadata", async () => {
const connection = scriptedConnection({});
let replies = 0;
connection.call = async () => ({
success: true,
version: `inspection-${++replies}`,
gui_client: replies === 1,
});
const provider = providerWith(connection);
try {
const results = await Promise.all([
provider.execute({ operation: "inspect_jeb_client", input: {} }),
provider.execute({ operation: "inspect_jeb_client", input: {} }),
]);
for (const [index, result] of results.entries()) {
if (!result.ok) throw result.error;
expect(result.value.provider.version).toBe(`inspection-${index + 1}`);
expect(result.value.result).toMatchObject({
engine: {
version: result.value.provider.version,
gui_client: index === 0,
},
});
}
} finally {
await provider.close();
}
});
+27 -21
View File
@@ -105,8 +105,16 @@ const refusalReason = (payload: unknown, fallback: string): string => {
export class JebProvider implements JebAnalysisPort {
readonly #connection: JebMcpConnection;
readonly #endpoint: URL;
#identity: ProviderIdentity = JEB_PROVIDER_IDENTITY;
#guiClient: boolean | null = null;
#clientInformation:
| Pick<z.infer<typeof clientInformation>, "version" | "gui_client">
| undefined;
#identity(version: string | null | undefined): ProviderIdentity {
return {
...JEB_PROVIDER_IDENTITY,
version: version ?? null,
};
}
constructor(options: JebProviderOptions = {}) {
const environment = options.environment ?? {};
@@ -128,7 +136,10 @@ export class JebProvider implements JebAnalysisPort {
): Promise<JebAnalysisAvailability> {
try {
const tools = new Set(await this.#connection.listTools(signal));
await this.#refreshClientInformation("inspect_jeb_client", signal);
const { information } = await this.#refreshClientInformation(
"inspect_jeb_client",
signal,
);
return {
status: "available",
code: null,
@@ -138,7 +149,7 @@ export class JebProvider implements JebAnalysisPort {
).filter((operation) => tools.has(JEB_ENGINE_TOOLS[operation])),
diagnostics: {
endpoint: this.#endpoint.toString(),
engine_version: this.#identity.version,
engine_version: information.version ?? null,
},
};
} catch (cause) {
@@ -169,11 +180,6 @@ export class JebProvider implements JebAnalysisPort {
const execution = await this.#dispatch(request, options?.signal);
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(request.operation));
if (execution.ok)
this.#identity = {
...this.#identity,
version: execution.value.provider.version,
};
return execution;
} catch (cause) {
if (options?.signal?.aborted)
@@ -218,13 +224,14 @@ export class JebProvider implements JebAnalysisPort {
readonly endpoint: string;
readonly gui_client: boolean | null;
}> {
if (this.#identity.version === null)
await this.#refreshClientInformation(operation, signal);
const information =
this.#clientInformation ??
(await this.#refreshClientInformation(operation, signal)).information;
return {
name: "jeb",
version: this.#identity.version,
version: information.version ?? null,
endpoint: this.#endpoint.toString(),
gui_client: this.#guiClient,
gui_client: information.gui_client ?? null,
};
}
@@ -242,11 +249,10 @@ export class JebProvider implements JebAnalysisPort {
const information = clientInformation.parse(
this.#requireSuccess(payload, operation),
);
this.#identity = {
...this.#identity,
version: information.version ?? null,
this.#clientInformation = {
version: information.version,
gui_client: information.gui_client,
};
this.#guiClient = information.gui_client ?? null;
return { information, payload };
}
@@ -266,7 +272,7 @@ export class JebProvider implements JebAnalysisPort {
message: information.message ?? null,
});
return ok(
createAnalysisExecution(result, this.#identity, {
createAnalysisExecution(result, this.#identity(information.version), {
rawResult: payload,
limitations:
information.version === undefined
@@ -299,7 +305,7 @@ export class JebProvider implements JebAnalysisPort {
units: opened.units,
});
return ok(
createAnalysisExecution(result, this.#identity, {
createAnalysisExecution(result, this.#identity(engine.version), {
rawResult: payload,
limitations: [
"JEB resolves and analyzes the artifact on the engine host; REA did not open the target itself.",
@@ -338,7 +344,7 @@ export class JebProvider implements JebAnalysisPort {
coverage: units.length < count ? "complete" : "partial",
});
return ok(
createAnalysisExecution(result, this.#identity, {
createAnalysisExecution(result, this.#identity(engine.version), {
rawResult: payload,
limitations: [
"The engine caps unit pages at 100; a full page means more units may exist.",
@@ -373,7 +379,7 @@ export class JebProvider implements JebAnalysisPort {
text: decompiled.text,
});
return ok(
createAnalysisExecution(result, this.#identity, {
createAnalysisExecution(result, this.#identity(engine.version), {
rawResult: payload,
limitations:
unit_path === undefined
+67 -39
View File
@@ -296,30 +296,40 @@ it("keeps the MCP server usable when REA_JEB_MCP_URL is invalid", async () => {
});
});
it.each([
{ operation: "list_jeb_units", tool: "list_units", input: { count: 2 } },
{
operation: "decompile_jeb_item",
tool: "decompile_code_item",
input: {
item_address: "LSample;->value()I",
item_kind: "method",
unit_path: "sample.dex",
it.each(
[
{ operation: "list_jeb_units", tool: "list_units", input: { count: 2 } },
{
operation: "decompile_jeb_item",
tool: "decompile_code_item",
input: {
item_address: "LSample;->value()I",
item_kind: "method",
unit_path: "sample.dex",
},
},
},
{
operation: "open_jeb_project",
tool: "open_project",
input: { path: "sample.apk" },
},
])(
"attributes auxiliary metadata refusal to requested $operation through MCP",
async ({ operation, tool, input }) => {
const fixture = await startJebMcpFixture((name) =>
name === "get_client_information"
{
operation: "open_jeb_project",
tool: "open_project",
input: { path: "sample.apk" },
},
].flatMap((request) =>
["refusal", "unknown-version"].map((metadata) => ({
...request,
metadata,
})),
),
)(
"preserves requested $operation through MCP with metadata $metadata",
async ({ operation, tool, input, metadata }) => {
let informationCalls = 0;
const fixture = await startJebMcpFixture((name) => {
if (name !== "get_client_information") return jebPrimaryResponses[name]!;
informationCalls++;
return metadata === "refusal" || informationCalls > 1
? { success: false, message: "Client information temporarily refused" }
: jebPrimaryResponses[name]!,
);
: { success: true, gui_client: false };
});
const provider = new JebProvider({
environment: { REA_JEB_MCP_URL: fixture.endpoint },
});
@@ -336,24 +346,42 @@ it.each([
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const failed = await client.callTool({
name: operation,
arguments: input,
});
expect(failed.isError).toBe(true);
const text = failed.content
.map((block) => (block.type === "text" ? block.text : ""))
.join("");
expect(JSON.parse(text)).toMatchObject({
error: {
code: "unsupported_target",
details: {
operation,
reason: "Client information temporarily refused",
const call = () => client.callTool({ name: operation, arguments: input });
if (metadata === "unknown-version") {
for (let attempt = 0; attempt < 2; attempt++) {
const called = await call();
expect(called.isError).not.toBe(true);
const text = called.content
.map((block) => (block.type === "text" ? block.text : ""))
.join("");
const evidence = parseEvidence(JSON.parse(text));
expect(evidence.provider.version).toBeNull();
expect(evidence.normalized_result).toMatchObject({
engine: { version: null, gui_client: false },
});
expect(evidence.raw_result).toEqual(jebPrimaryResponses[tool]);
}
expect(fixture.calls).toEqual([tool, "get_client_information", tool]);
} else {
const failed = await client.callTool({
name: operation,
arguments: input,
});
expect(failed.isError).toBe(true);
const text = failed.content
.map((block) => (block.type === "text" ? block.text : ""))
.join("");
expect(JSON.parse(text)).toMatchObject({
error: {
code: "unsupported_target",
details: {
operation,
reason: "Client information temporarily refused",
},
},
},
});
expect(fixture.calls).toEqual([tool, "get_client_information"]);
});
expect(fixture.calls).toEqual([tool, "get_client_information"]);
}
} finally {
await client.close();
await server.close();