fix: preserve native UI failure reasons and dedupe version literals (#854)

Keep adapter/OS helper failures in diagnostics instead of replacing them
with a canned remediation sentence. Share browserVersionSchema, Node
support prose, and JADX heap/remediation constants so release pins stay
authoritative.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
morluto 2026-10-07 13:00:24 +08:00 committed by GitHub
parent 166dcd21d0
commit 2d4a910fcd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 67 additions and 46 deletions

View File

@ -16,6 +16,7 @@ import {
jadxXrefsSchema,
normalizeJadxText,
} from "./JadxProtocol.js";
import { JADX_RELEASE } from "./JadxRelease.js";
/** The adapter consumes tool payloads, never provider RPC details outside this layer. */
export interface JadxToolPort {
@ -246,7 +247,7 @@ const inspectMethod = async (
throw new AnalysisCapabilityUnavailableError(
"jadx",
request.operation,
`JADX 0.7.1 smali fallback joins all same-name overloads for ${summary.full_name}.${request.input.method_name}; it cannot establish source for only overload ${index}.`,
`JADX ${JADX_RELEASE.version} smali fallback joins all same-name overloads for ${summary.full_name}.${request.input.method_name}; it cannot establish source for only overload ${index}.`,
);
return androidResultSchemas.inspect_android_method.parse({
engine,
@ -306,7 +307,7 @@ const traceReferences = async (
throw new AnalysisCapabilityUnavailableError(
"jadx",
request.operation,
`JADX 0.7.1 references select the first same-name method; ${summary.full_name}.${methodName} has ${candidates.length} overloads. No unambiguous reference result is available.`,
`JADX ${JADX_RELEASE.version} references select the first same-name method; ${summary.full_name}.${methodName} has ${candidates.length} overloads. No unambiguous reference result is available.`,
);
const expectedTarget =
methodName === undefined

View File

@ -3,6 +3,7 @@ import { constants } from "node:fs";
import { isAbsolute, join } from "node:path";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import type { AndroidOperation } from "../domain/androidAnalysis.js";
import { JADX_JAR_CONFIGURATION_REMEDIATION } from "./JadxRelease.js";
/** Caller-supplied tools, resolved only when an Android operation is selected. */
export interface JadxConfiguration {
@ -27,9 +28,7 @@ export const resolveJadxConfiguration = async (
"JAVA_HOME must select an existing JDK by absolute path; omit it to use java on PATH.",
);
if (jar === undefined || !isAbsolute(jar))
throw unavailable(
"Set REA_JADX_MCP_JAR to the absolute path of a caller-supplied jadx-headless-mcp 0.7.1 JAR. REA does not download or install it.",
);
throw unavailable(JADX_JAR_CONFIGURATION_REMEDIATION);
const java =
environment.JAVA_HOME === undefined
? "java"

View File

@ -20,6 +20,7 @@ import {
} from "./AndroidTargetSnapshot.js";
import { resolveJadxConfiguration } from "./JadxConfiguration.js";
import type { JadxLauncher } from "./JadxMcpTransport.js";
import { JADX_HEAP_XMX_ARGUMENT } from "./JadxRelease.js";
import { JadxSession } from "./JadxSession.js";
const OPERATION_TIMEOUT_MS = 120_000;
@ -203,7 +204,7 @@ export class JadxProvider implements AndroidAnalysisPort {
{
command: configuration.java,
arguments: [
"-Xmx512m",
JADX_HEAP_XMX_ARGUMENT,
"-XX:ActiveProcessorCount=1",
"-jar",
engine.path,
@ -216,7 +217,9 @@ export class JadxProvider implements AndroidAnalysisPort {
],
cwd: root.path,
hostEnvironment: this.environment,
env: { _JAVA_OPTIONS: "-Xmx512m -XX:ActiveProcessorCount=1" },
env: {
_JAVA_OPTIONS: `${JADX_HEAP_XMX_ARGUMENT} -XX:ActiveProcessorCount=1`,
},
},
this.launcher,
);

View File

@ -6,6 +6,15 @@ 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",
});
/** JVM heap ceiling applied to every owned JADX worker process. */
export const JADX_HEAP_LIMIT_MIB = 512;
/** JVM `-Xmx` argument matching {@link JADX_HEAP_LIMIT_MIB}. */
export const JADX_HEAP_XMX_ARGUMENT = `-Xmx${JADX_HEAP_LIMIT_MIB}m`;
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
export const JADX_JAR_CONFIGURATION_REMEDIATION = `Set REA_JADX_MCP_JAR to the absolute path of a caller-supplied jadx-headless-mcp ${JADX_RELEASE.version} JAR. REA does not download or install it.`;
/** Producing engine identity used by Evidence and the generated product catalog. */
export const JADX_PROVIDER_IDENTITY = Object.freeze({
id: "jadx",

View File

@ -20,6 +20,7 @@ import {
parseJadxJson,
} from "./JadxProtocol.js";
import {
JADX_HEAP_LIMIT_MIB,
JADX_RELEASE,
JADX_PROVIDER_IDENTITY,
JADX_LIMITATIONS,
@ -84,7 +85,7 @@ export class JadxSession {
source_revision:
jarHash === JADX_RELEASE.sha256 ? JADX_RELEASE.revision : null,
worker_count: 1,
heap_limit_mib: 512,
heap_limit_mib: JADX_HEAP_LIMIT_MIB,
} as const;
const result = await analyzeJadxRequest(tools, request, loaded, engine);
return createAnalysisExecution(result, JADX_PROVIDER_IDENTITY, {

View File

@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js";
import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
import { createDoctorHostFixture as host } from "./Doctor.fixture.js";
import { runDoctor } from "./Doctor.js";
@ -19,7 +21,7 @@ describe("doctor", () => {
result.checks.map(({ name, remediation }) => [name, remediation]),
),
).toEqual({
node: "Install Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+.",
node: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, and the experimental Windows x64 Ghidra P0 boundary.",
hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
target: "Supply a readable local app or program path.",

View File

@ -1,6 +1,9 @@
import { homedir } from "node:os";
import { supportsNodeVersion } from "../domain/runtimeVersion.js";
import {
SUPPORTED_NODE_VERSION_PROSE,
supportsNodeVersion,
} from "../domain/runtimeVersion.js";
import { linuxHopperLauncherPath } from "./LinuxHopper.js";
import type {
DoctorCheck,
@ -92,7 +95,7 @@ export const doctorHealthy = (
const nodeCheck = (host: DoctorHost): DoctorCheck =>
check("node", supportsNodeVersion(host.nodeVersion), host.nodeVersion, {
remediation: "Install Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+.",
remediation: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
classification: "missing_dependency",
});

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js";
import { FakeSetupHost, options } from "./Setup.fixture.js";
import { runSetup } from "./Setup.js";
@ -122,7 +123,7 @@ describe("setup scoped readiness", () => {
const nodeHost = new FakeSetupHost();
nodeHost.nodeVersion = "20.0.0";
expect((await runSetup(options(true), nodeHost)).remediation).toBe(
"Install Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+ and rerun setup.",
`Install ${SUPPORTED_NODE_VERSION_PROSE} and rerun setup.`,
);
const macHost = new FakeSetupHost();

View File

@ -3,7 +3,10 @@ import { homedir } from "node:os";
import { resolve } from "node:path";
import { PRODUCT_IDENTITY } from "../identity.js";
import { supportsNodeVersion } from "../domain/runtimeVersion.js";
import {
SUPPORTED_NODE_VERSION_PROSE,
supportsNodeVersion,
} from "../domain/runtimeVersion.js";
import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js";
import { installLinuxHopper, readLinuxDistribution } from "./LinuxHopper.js";
import { installMacHopper } from "./MacHopper.js";
@ -63,7 +66,7 @@ export const hostRemediation = async (
installHopper: boolean,
): Promise<string | undefined> => {
if (!supportsNodeVersion(host.nodeVersion))
return "Install Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+ and rerun setup.";
return `Install ${SUPPORTED_NODE_VERSION_PROSE} and rerun setup.`;
if (!installHopper) return undefined;
if (host.platform !== "darwin" && host.platform !== "linux")
return "REA supports Hopper on macOS and selected 64-bit Linux distributions.";

View File

@ -5,7 +5,8 @@ import { webTextArtifactSchema } from "./webContentArtifact.js";
import { jsonShapeSchema } from "./jsonShape.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
const browserVersionSchema = z.object({
/** Shared CDP/browser version identity carried on observation Evidence. */
export const browserVersionSchema = z.object({
product: z.string(),
protocol_version: z.string(),
revision: z.string(),

View File

@ -5,6 +5,7 @@ import {
browserEndpointSchema,
} from "./browserObservation.js";
import { browserCompletenessSchema } from "./browserCompleteness.js";
import { browserVersionSchema } from "./browserObservationSchemas.js";
/** Input for a navigation-aware browser observation window. */
export const observeWebSessionInputSchema = z.strictObject({
@ -42,13 +43,7 @@ const timelineEventSchema = z.object({
/** Navigation-aware session result for external user actions. */
export const webObservationSessionSchema = z.object({
browser: z.object({
product: z.string(),
protocol_version: z.string(),
revision: z.string(),
user_agent: z.string(),
js_version: z.string(),
}),
browser: browserVersionSchema,
target: z.object({
target_id: z.string(),
initial_url: z.string(),

View File

@ -2,6 +2,7 @@ import { z } from "zod";
import { browserCompletenessSchema } from "./browserCompleteness.js";
import { browserEndpointSchema } from "./browserObservation.js";
import { browserVersionSchema } from "./browserObservationSchemas.js";
import { webTextArtifactSchema } from "./webContentArtifact.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
@ -37,13 +38,6 @@ export type InspectElectronPageInput = z.infer<
typeof inspectElectronPageInputSchema
>;
const browserVersionSchema = z.object({
product: z.string(),
protocol_version: z.string(),
revision: z.string(),
user_agent: z.string(),
js_version: z.string(),
});
const electronTargetSchema = z.object({
target_id: z.string(),
type: z.string(),

View File

@ -3,6 +3,10 @@ import { satisfies } from "semver";
/** Supported runtime range, kept in sync with the published package engines. */
export const SUPPORTED_NODE_VERSION_RANGE = "^22.19.0 || ^24.11.0 || >=26.0.0";
/** Human-readable form of {@link SUPPORTED_NODE_VERSION_RANGE} for remediation. */
export const SUPPORTED_NODE_VERSION_PROSE =
"Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+";
/** Return whether a Node.js version satisfies REA's tested runtime families. */
export const supportsNodeVersion = (version: string): boolean =>
satisfies(version, SUPPORTED_NODE_VERSION_RANGE);

View File

@ -5,6 +5,7 @@ import {
browserAllowedOriginsSchema,
browserEndpointSchema,
} from "./browserObservation.js";
import { browserVersionSchema } from "./browserObservationSchemas.js";
import { jsonShapeSchema } from "./jsonShape.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
@ -19,14 +20,6 @@ export type DiscoverWebMcpToolsInput = z.infer<
typeof discoverWebMcpToolsInputSchema
>;
const browserVersionSchema = z.object({
product: z.string(),
protocol_version: z.string(),
revision: z.string(),
user_agent: z.string(),
js_version: z.string(),
});
const webMcpToolSchema = z.object({
tool_key: prefixedDigestSchema("webmcp"),
name: z.string(),

View File

@ -7,6 +7,7 @@ import {
browserAllowedOriginsSchema,
browserEndpointSchema,
} from "./browserObservation.js";
import { browserVersionSchema } from "./browserObservationSchemas.js";
/** Self-verifying inline PNG artifact for CLI/MCP parity. */
export const webScreenshotArtifactSchema = z
@ -41,14 +42,6 @@ export type CaptureWebScreenshotInput = z.infer<
typeof captureWebScreenshotInputSchema
>;
const browserVersionSchema = z.object({
product: z.string(),
protocol_version: z.string(),
revision: z.string(),
user_agent: z.string(),
js_version: z.string(),
});
/** Screenshot observation with embedded immutable artifact bytes. */
export const webScreenshotSchema = z.object({
browser: browserVersionSchema,

View File

@ -223,6 +223,24 @@ describe("native UI target and cancellation failures", () => {
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.message).toContain(code);
});
it("preserves the underlying helper failure reason in diagnostics", async () => {
const result = await observeNativeUi(target, "observe_native_ui", scope, {
invoke: async () => {
throw new Error(
"Native helper returned invalid JSON: Unexpected token",
);
},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toMatchObject({
diagnostics: {
reason: "Native helper returned invalid JSON: Unexpected token",
remediation:
"Native helper failed, timed out, or returned malformed capture data; install compatible Xcode command-line tools and inspect local OS permissions",
},
});
});
it("stops on a failed action and returns ordered before/capture-gap evidence", async () => {
const calls: Readonly<Record<string, unknown>>[] = [];
const result = await observeNativeUi(

View File

@ -153,7 +153,8 @@ const observeWithHelper = async (
cause,
diagnostics: {
helper_path: helper,
reason:
reason: cause instanceof Error ? cause.message : String(cause),
remediation:
"Native helper failed, timed out, or returned malformed capture data; install compatible Xcode command-line tools and inspect local OS permissions",
},
}),