refactor(jadx): read the bridge identity from the bridge source it launches (#10720)

* refactor(jadx): read the bridge identity from the bridge source it launches

* fix(jadx): read the bridge identity before launching the bridge

* fix(jadx): read the bridge identity from the outer class's own fields

* fix(jadx): read the bridge identity only from the ReaJadxBridge class body

* fix(jadx): return a malformed packaged bridge source as a read result

JadxRelease.ts is loaded by fixture scripts without a build, so it cannot import src/domain modules (the ParseFailure import broke the android-metadata job and the spawned JADX fixture). The identity read now returns a Result-shaped value; JadxProvider refuses a malformed packaged source as AnalysisCapabilityUnavailableError before acquiring resources, and tests and the fixture unwrap it.
This commit is contained in:
morluto authored and GitHub committed 2026-11-15 14:49:27 +08:00
1 parent 2237f936f0
commit da3541bae3
9 files changed
+292 -23

No files matched your search

+5 -1
View File
@@ -46,6 +46,10 @@ import org.xml.sax.InputSource;
/** REA-owned metadata boundary for the unmodified jadx-headless-mcp 0.7.1 JAR. */
class ReaJadxBridge {
// REA reads this identity from this source (JadxRelease.ts), so it is typed
// once. Change BRIDGE_VERSION with any protocol change REA must not mix.
static final String BRIDGE_NAME = "rea-jadx-bridge";
static final String BRIDGE_VERSION = "11";
private static final Gson JSON = new Gson();
private final SessionHolder holder;
private JadxSession indexedSession;
@@ -1080,7 +1084,7 @@ class ReaJadxBridge {
SessionHolder holder = new SessionHolder(defaults);
ServerCapabilities capabilities = new ServerCapabilities(
new ServerCapabilities.Tools(null), null, null, null, null, null, null, Map.of());
Server server = new Server(new Implementation("rea-jadx-bridge", "11", null, null, null),
Server server = new Server(new Implementation(BRIDGE_NAME, BRIDGE_VERSION, null, null, null),
new ServerOptions(capabilities), "", ignored -> Unit.INSTANCE);
SessionToolsKt.registerSessionTools(server, holder);
ClassToolsKt.registerClassTools(server, holder);
+19 -3
View File
@@ -55,10 +55,13 @@ import type { JadxLauncher } from "./JadxMcpTransport.js";
import { JadxSession } from "./JadxSession.js";
import { captureJadxFailure } from "./JadxFailure.js";
import {
JADX_BRIDGE_SOURCE_URL,
jadxBridgeIdentity,
type JadxBridgeIdentity,
} from "./JadxRelease.js";
const BRIDGE_SOURCE = fileURLToPath(
new URL("../../bridge/android/ReaJadxBridge.java", import.meta.url),
);
const BRIDGE_SOURCE = fileURLToPath(JADX_BRIDGE_SOURCE_URL);
type Outcome = Awaited<ReturnType<AndroidAnalysisPort["execute"]>>;
const waitForTurn = (
@@ -268,6 +271,7 @@ interface RetainedSession {
readonly snapshot: string;
readonly jarHash: string;
readonly bridgeHash: string;
readonly bridgeIdentity: JadxBridgeIdentity;
}
/** One serialized, immutable APK session per REA instance, retired on idle or shutdown. */
@@ -556,6 +560,16 @@ export class JadxProvider implements AndroidAnalysisPort {
signal,
retainFailedClose(BRIDGE_SOURCE),
);
// Read before any child or workspace exists, so a malformed packaged
// bridge is refused without resources to clean up.
const identity = jadxBridgeIdentity();
if (!identity.ok)
throw new AnalysisCapabilityUnavailableError(
"jadx",
request.operation,
`${identity.error} (${BRIDGE_SOURCE}); reinstall REA to restore the packaged JADX bridge.`,
);
const bridgeIdentity = identity.value;
let targetHash: string;
try {
targetHash = (
@@ -669,6 +683,7 @@ export class JadxProvider implements AndroidAnalysisPort {
snapshot,
jarHash: engine.sha256,
bridgeHash,
bridgeIdentity,
};
}
root = retained.root;
@@ -685,6 +700,7 @@ export class JadxProvider implements AndroidAnalysisPort {
snapshot,
jarHash: retained.jarHash,
bridgeHash: retained.bridgeHash,
bridgeIdentity: retained.bridgeIdentity,
signal: activeSignal,
timeoutMs: timeoutMs.value,
mapping: mapping.value,
+88
View File
@@ -0,0 +1,88 @@
import { expect, it } from "vitest";
import { jadxBridgeIdentity, parseJadxBridgeIdentity } from "./JadxRelease.js";
const declaration = (name: string, version: string): string =>
`class ReaJadxBridge {\n static final String BRIDGE_NAME = "${name}";\n static final String BRIDGE_VERSION = "${version}";\n}\n`;
const identity = (name: string, version: string) => ({
ok: true,
value: { name, version },
});
const refusal = (pattern: RegExp) => ({
ok: false,
error: expect.stringMatching(pattern),
});
it("reads the bridge identity the packaged bridge source declares", () => {
expect(jadxBridgeIdentity()).toEqual({
ok: true,
value: {
name: "rea-jadx-bridge",
version: expect.stringMatching(/^[A-Za-z0-9._-]+$/u),
},
});
});
it("refuses a bridge source that does not declare its identity exactly once", () => {
expect(parseJadxBridgeIdentity(declaration("rea-jadx-bridge", "12"))).toEqual(
identity("rea-jadx-bridge", "12"),
);
expect(parseJadxBridgeIdentity("class ReaJadxBridge {}\n")).toEqual(
refusal(/BRIDGE_NAME exactly once; found 0/u),
);
const duplicated = declaration("rea-jadx-bridge", "12").replace(
"}\n",
' static final String BRIDGE_VERSION = "13";\n}\n',
);
expect(parseJadxBridgeIdentity(duplicated)).toEqual(
refusal(/BRIDGE_VERSION exactly once; found 2/u),
);
});
it("reads only the outer class's own fields, whatever their formatting", () => {
const source = [
'// static final String BRIDGE_VERSION = "line-comment";',
"class ReaJadxBridge {",
' /* static final String BRIDGE_VERSION = "block-comment"; */',
' private static final String BRIDGE_NAME="rea-jadx-bridge" ;',
" public static final String",
' BRIDGE_VERSION = "12";',
' static final String BRACE = "}{";',
" private static final class Nested {",
' static final String BRIDGE_VERSION = "nested";',
" }",
" void run() {",
' String BRIDGE_NAME = "local";',
" }",
"}",
"",
].join("\n");
expect(parseJadxBridgeIdentity(source)).toEqual(
identity("rea-jadx-bridge", "12"),
);
});
it("ignores other top-level types in the bridge source", () => {
const sibling = [
"final class Helper {",
' static final String BRIDGE_NAME = "helper";',
' static final String BRIDGE_VERSION = "helper";',
"}",
"",
].join("\n");
expect(
parseJadxBridgeIdentity(
`${sibling}${declaration("rea-jadx-bridge", "12")}`,
),
).toEqual(identity("rea-jadx-bridge", "12"));
expect(
parseJadxBridgeIdentity(`${sibling}class ReaJadxBridgeV2 {}\n`),
).toEqual(refusal(/top-level class ReaJadxBridge exactly once; found 0/u));
expect(parseJadxBridgeIdentity(`${sibling}class ReaJadxBridge {}\n`)).toEqual(
refusal(/BRIDGE_NAME exactly once; found 0/u),
);
});
+145 -5
View File
@@ -1,4 +1,6 @@
// Imported directly by pre-install fixture scripts; keep this module dependency-free.
import { readFileSync } from "node:fs";
/** Audited upstream release; source remains unmodified in the Git submodule. */
export const JADX_RELEASE = Object.freeze({
version: "0.7.1",
@@ -7,11 +9,149 @@ export const JADX_RELEASE = Object.freeze({
url: "https://github.com/1013503897/jadx-headless-mcp/releases/download/v0.7.1/jadx-headless-mcp-0.7.1-all.jar",
});
/** Identity of REA's metadata bridge, distinct from the producing engine. */
export const JADX_BRIDGE_IDENTITY = Object.freeze({
name: "rea-jadx-bridge",
version: "11",
});
/** Packaged single-file bridge source that REA copies and launches. */
export const JADX_BRIDGE_SOURCE_URL = new URL(
"../../bridge/android/ReaJadxBridge.java",
import.meta.url,
);
export interface JadxBridgeIdentity {
readonly name: string;
readonly version: string;
}
/**
* Java source as string and character literals, single characters, and one
* space per comment, each with the brace depth it sits at (a brace carries the
* depth outside it). Braces inside literals and comments are not counted. Text
* blocks are not handled; the bridge source does not use them.
*/
function* javaPieces(
source: string,
): Generator<{ readonly text: string; readonly depth: number }> {
let depth = 0;
let index = 0;
while (index < source.length) {
const character = source[index] ?? "";
const next = source[index + 1];
let end = index + 1;
let text = character;
if (character === "/" && (next === "/" || next === "*")) {
const close =
next === "/"
? source.indexOf("\n", index)
: source.indexOf("*/", index + 2);
end = close === -1 ? source.length : next === "/" ? close : close + 2;
text = " ";
} else if (character === '"' || character === "'") {
while (end < source.length && source[end] !== character)
end += source[end] === "\\" ? 2 : 1;
end += 1;
text = source.slice(index, end);
}
if (text === "}") depth -= 1;
yield { text, depth };
if (text === "{") depth += 1;
index = end;
}
}
/**
* The body of the top-level class named `className`, with comments removed and
* nested bodies blanked, so only that class's own member declarations remain.
* Other top-level types in the same file are ignored.
*/
const topLevelClassMembers = (
source: string,
className: string,
): JadxBridgeSourceRead<string> => {
const declaresClass = new RegExp(
String.raw`(?:^|\s)class\s+${className}(?![\w$])`,
"u",
);
let members = "";
let header = "";
let inClass = false;
let classBodies = 0;
for (const { text, depth } of javaPieces(source)) {
if (depth === 0 && text === "{") {
// A top-level type body opens; its header names the type.
inClass = declaresClass.test(header);
if (inClass) classBodies += 1;
header = "";
} else if (depth === 0)
header = text === ";" || text === "}" ? "" : header + text;
else if (depth === 1 && inClass)
members += text === "{" || text === "}" ? " " : text;
}
return classBodies === 1
? { ok: true, value: members }
: {
ok: false,
error: `REA's JADX bridge source must declare top-level class ${className} exactly once; found ${String(classBodies)}`,
};
};
const declaredString = (
members: string,
constant: string,
): JadxBridgeSourceRead<string> => {
const matches = [
...members.matchAll(
new RegExp(
String.raw`(?:^|[;\s])(?:(?:public|protected|private|static|final)\s+)*String\s+${constant}\s*=\s*"([A-Za-z0-9._-]+)"\s*;`,
"gu",
),
),
];
const value = matches.length === 1 ? matches[0]?.[1] : undefined;
return value === undefined
? {
ok: false,
error: `REA's JADX bridge source must declare ${constant} exactly once; found ${String(matches.length)}`,
}
: { ok: true, value };
};
/**
* A read of REA's own packaged bridge source, shaped like `Result` from
* src/domain/result.ts. This module is loaded by fixture scripts without a
* build, so it cannot import that module.
*/
export type JadxBridgeSourceRead<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: string };
/** Read the bridge identity the bridge source itself declares. */
export const parseJadxBridgeIdentity = (
source: string,
): JadxBridgeSourceRead<JadxBridgeIdentity> => {
const members = topLevelClassMembers(source, "ReaJadxBridge");
if (!members.ok) return members;
const name = declaredString(members.value, "BRIDGE_NAME");
if (!name.ok) return name;
const version = declaredString(members.value, "BRIDGE_VERSION");
if (!version.ok) return version;
return {
ok: true,
value: Object.freeze({ name: name.value, version: version.value }),
};
};
let bridgeIdentity: JadxBridgeSourceRead<JadxBridgeIdentity> | undefined;
/**
* Identity of REA's metadata bridge, distinct from the producing engine. The
* bridge source owns it; REA reads it from the packaged copy it launches, so a
* version bump cannot be made in one place and missed in the other.
*/
export const jadxBridgeIdentity =
(): JadxBridgeSourceRead<JadxBridgeIdentity> => {
bridgeIdentity ??= parseJadxBridgeIdentity(
readFileSync(JADX_BRIDGE_SOURCE_URL, "utf8"),
);
return bridgeIdentity;
};
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
export const JADX_JAR_CONFIGURATION_REMEDIATION = `Download the jadx-headless-mcp ${JADX_RELEASE.version} release JAR from ${JADX_RELEASE.url}, verify SHA-256 ${JADX_RELEASE.sha256}, and set REA_JADX_MCP_JAR to its absolute path. REA does not download or install it.`;
+10 -8
View File
@@ -45,7 +45,7 @@ import {
parseJadxJson,
} from "./JadxProtocol.js";
import {
JADX_BRIDGE_IDENTITY,
type JadxBridgeIdentity,
JADX_RELEASE,
JADX_PROVIDER_IDENTITY,
JADX_LIMITATIONS,
@@ -78,6 +78,7 @@ export class JadxSession {
readonly target: BinaryTarget;
readonly jarHash: string;
readonly bridgeHash: string;
readonly bridgeIdentity: JadxBridgeIdentity;
}
| undefined;
#observedCalls: JadxCallObservation[] = [];
@@ -104,6 +105,8 @@ export class JadxSession {
readonly snapshot: string;
readonly jarHash: string;
readonly bridgeHash: string;
/** Identity read from the packaged bridge before the child was launched. */
readonly bridgeIdentity: JadxBridgeIdentity;
readonly signal: AbortSignal;
/** Selected whole-operation deadline; also bounds each upstream call. */
readonly timeoutMs: number;
@@ -120,6 +123,7 @@ export class JadxSession {
target,
jarHash,
bridgeHash: context.bridgeHash,
bridgeIdentity: context.bridgeIdentity,
};
this.#observedCalls = [];
this.#jniObservation = undefined;
@@ -163,14 +167,12 @@ export class JadxSession {
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
)
const expected = context.bridgeIdentity;
if (server?.name !== expected.name || server.version !== expected.version)
throw new AnalysisCapabilityUnavailableError(
"jadx",
request.operation,
`Expected ${JADX_BRIDGE_IDENTITY.name} ${JADX_BRIDGE_IDENTITY.version}; server reported ${server?.name ?? "unknown"} ${server?.version ?? "unknown"}.`,
`Expected ${expected.name} ${expected.version}; server reported ${server?.name ?? "unknown"} ${server?.version ?? "unknown"}.`,
);
const tools = this.#tools(request, target, signal, timeoutMs);
const runtime = jadxRuntimeSchema.parse(
@@ -333,8 +335,8 @@ export class JadxSession {
provider_facts: {
jar_sha256: context.jarHash,
bridge_sha256: context.bridgeHash,
bridge_name: JADX_BRIDGE_IDENTITY.name,
bridge_version: JADX_BRIDGE_IDENTITY.version,
bridge_name: context.bridgeIdentity.name,
bridge_version: context.bridgeIdentity.version,
server_name: this.#serverName,
server_version: this.#serverVersion,
engine_version: this.#engineVersion,
@@ -6,7 +6,7 @@ import { closeVerifierResources } from "../../../scripts/lib/verifier-cleanup.mj
import { z } from "zod";
import { access } from "node:fs/promises";
import { ANDROID_TOOL_CONTRACTS } from "../../../src/contracts/android/androidToolContracts.js";
import { JADX_BRIDGE_IDENTITY } from "../../../src/android/JadxRelease.js";
import { packagedJadxBridgeIdentity } from "../../support/packagedJadxBridge.js";
import { androidPartialObservationSchema } from "../../../src/domain/android/androidPartialObservation.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
@@ -186,7 +186,7 @@ test.each([
mode: "tool-error",
errorCode: "execution_failure",
serverName: "rea-jadx-bridge",
serverVersion: JADX_BRIDGE_IDENTITY.version,
serverVersion: packagedJadxBridgeIdentity().version,
engineVersion: "0.7.1",
calls: [
{ operation: "rea_jvm_status" },
@@ -204,7 +204,7 @@ test.each([
mode: "rpc-disconnect",
errorCode: "execution_failure",
serverName: "rea-jadx-bridge",
serverVersion: JADX_BRIDGE_IDENTITY.version,
serverVersion: packagedJadxBridgeIdentity().version,
engineVersion: "0.7.1",
calls: [{ operation: "rea_jvm_status" }, { operation: "load_apk" }],
containsAmbientEnvironment: true,
@@ -259,7 +259,7 @@ test.each([
target: { selected_path: apk, path: apk, format: "apk" },
provider_facts: {
bridge_name: "rea-jadx-bridge",
bridge_version: JADX_BRIDGE_IDENTITY.version,
bridge_version: packagedJadxBridgeIdentity().version,
server_name: serverName,
server_version: serverVersion,
engine_version: engineVersion,
@@ -1,4 +1,5 @@
import { SdkErrorCode } from "@modelcontextprotocol/client";
import { packagedJadxBridgeIdentity } from "../../support/packagedJadxBridge.js";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { expect, it as test, onTestFinished, vi } from "vitest";
import {
@@ -1002,6 +1003,7 @@ it("bounds each upstream call by the selected deadline instead of a fixed limit"
snapshot: apk,
jarHash: "0".repeat(64),
bridgeHash: "0".repeat(64),
bridgeIdentity: packagedJadxBridgeIdentity(),
signal: new AbortController().signal,
timeoutMs: 300,
resources: new ArtifactResourceScope(),
+8 -2
View File
@@ -1,7 +1,13 @@
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
import { JADX_BRIDGE_IDENTITY } from "../../../src/android/JadxRelease.ts";
import { jadxBridgeIdentity } from "../../../src/android/JadxRelease.ts";
const packagedBridgeVersion = () => {
const identity = jadxBridgeIdentity();
if (!identity.ok) throw new Error(identity.error);
return identity.value.version;
};
// An actual child MCP server exercising the production stdio/process seam.
// It is a protocol fixture, not evidence of JADX decompiler support.
@@ -21,7 +27,7 @@ const server = new McpServer({
? ""
: mode === "version"
? "99.0.0"
: JADX_BRIDGE_IDENTITY.version,
: packagedBridgeVersion(),
});
const requestedCount = Number.parseInt(
process.env.REA_FAKE_JADX_CLASS_COUNT ?? "",
+11
View File
@@ -0,0 +1,11 @@
import {
jadxBridgeIdentity,
type JadxBridgeIdentity,
} from "../../src/android/JadxRelease.js";
/** The packaged bridge identity; a test fails if the packaged source is malformed. */
export const packagedJadxBridgeIdentity = (): JadxBridgeIdentity => {
const identity = jadxBridgeIdentity();
if (!identity.ok) throw new Error(identity.error);
return identity.value;
};