fix(ghidra): discover installations from resolved PATH launchers

Reuse installation inspection in doctor and provider readiness. Keep explicit configuration authoritative and validate discovered roots without executing launchers. Clarify the existing per-operation availability contract.
This commit is contained in:
morluto 2026-10-14 09:05:53 +08:00
parent 127c621328
commit 86afc040e5
3 changed files with 173 additions and 3 deletions

View File

@ -290,6 +290,15 @@ configuration without changing them. Without options, it audits every detected
registration, the installed skill and optional analysis engines; its overall
`healthy` value can be false while your chosen workflow works.
Doctor's `providerInspections` are host-level engine and registration checks,
not a readiness row for every advertised MCP adapter surface. `tools/list`
remains the complete stable catalog. Call `binary_session` with `{}` before
choosing an operation; it works before a target is opened, and
`result.tool_availability` reports per-operation availability, reasons and
client requirements for the current session state. It does not guarantee that
a particular caller-supplied input will be valid. See
[MCP discovery](mcp-contracts.md#identity-and-discovery).
Select the part you want to check:
| Task | Readiness check |
@ -736,6 +745,14 @@ installation remains unavailable on Windows. Setup never installs Ghidra,
Java, or Python. It preserves valid detected Ghidra/JDK settings in agent
registrations. See [Windows Ghidra P0](windows-ghidra-p0.md) for provider diagnostics.
When `GHIDRA_INSTALL_DIR` is unset, doctor and the Ghidra provider also search
`PATH` for `ghidra` or `ghidraRun`, resolve the selected launcher through its
real path, and accept it only when that location belongs to a Ghidra tree with
the application metadata and headless launcher. An explicitly supplied
`GHIDRA_INSTALL_DIR` always takes precedence, including when it is invalid;
REA reports that error instead of silently selecting another installation.
This discovery is read-only and does not execute the Ghidra launcher.
Doctor validates the platform, architecture, Ghidra 12.1.x application version,
`support/analyzeHeadless` or `support/analyzeHeadless.bat`, the installation's
Java major range, 64-bit JDK bitness, and the presence of `javac`/`javac.exe`.

View File

@ -1,3 +1,5 @@
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { spawnSync } from "node:child_process";
@ -6,6 +8,7 @@ import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
import {
ghidraInstallationDiagnostics,
ghidraJavaEnvironment,
discoverGhidraInstallationOnPath,
inspectGhidraInstallation,
type GhidraExecutableHeaderProbe,
type GhidraInstallationHost,
@ -17,6 +20,7 @@ import {
ghidraApplicationProperties,
ghidraJavaRequirement,
} from "./GhidraInstallationPolicy.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
// The real Java probe shells out via spawnSync; stub it so one test can run
// the real probeJava against captured `-XshowSettings:properties` output.
@ -68,6 +72,100 @@ afterEach(() => {
vi.unstubAllEnvs();
});
it.skipIf(process.platform === "win32")(
"resolves a PATH launcher only when its real target is a complete installation",
async () => {
const root = await createTestTempDirectory("rea-ghidra-path-");
const installation = join(root, "installation");
const pathDirectory = join(root, "bin");
const launcher = join(installation, "ghidraRun");
await mkdir(join(installation, "Ghidra"), { recursive: true });
await mkdir(join(installation, "support"), { recursive: true });
await mkdir(pathDirectory, { recursive: true });
await writeFile(
join(installation, "Ghidra", "application.properties"),
"application.version=12.1.4\n",
);
await writeFile(launcher, "#!/bin/sh\nexit 0\n");
await writeFile(
join(installation, "support", "analyzeHeadless"),
"#!/bin/sh\nexit 0\n",
);
await chmod(launcher, 0o700);
await chmod(join(installation, "support", "analyzeHeadless"), 0o700);
await symlink(launcher, join(pathDirectory, "ghidra"));
expect(
discoverGhidraInstallationOnPath(
{ PATH: pathDirectory },
process.platform,
),
).toBe(installation);
const incomplete = join(root, "incomplete");
const incompletePath = join(root, "incomplete-bin");
await mkdir(join(incomplete, "Ghidra"), { recursive: true });
await mkdir(incompletePath, { recursive: true });
await writeFile(
join(incomplete, "Ghidra", "application.properties"),
"application.version=12.1.4\n",
);
await writeFile(join(incomplete, "ghidraRun"), "launcher\n");
await chmod(join(incomplete, "ghidraRun"), 0o700);
await symlink(
join(incomplete, "ghidraRun"),
join(incompletePath, "ghidra"),
);
expect(
discoverGhidraInstallationOnPath(
{ PATH: incompletePath },
process.platform,
),
).toBeUndefined();
const nonExecutable = join(root, "non-executable");
const nonExecutablePath = join(root, "non-executable-bin");
await mkdir(join(nonExecutable, "Ghidra"), { recursive: true });
await mkdir(join(nonExecutable, "support"), { recursive: true });
await mkdir(nonExecutablePath, { recursive: true });
await writeFile(
join(nonExecutable, "Ghidra", "application.properties"),
"application.version=12.1.4\n",
);
await writeFile(
join(nonExecutable, "support", "analyzeHeadless"),
"headless\n",
);
await chmod(join(nonExecutable, "support", "analyzeHeadless"), 0o700);
await writeFile(join(nonExecutablePath, "ghidra"), "not executable\n");
expect(
discoverGhidraInstallationOnPath(
{ PATH: nonExecutablePath },
process.platform,
),
).toBeUndefined();
},
);
it("does not fall back to PATH when an explicit Ghidra installation path is malformed", () => {
const inspection = inspectGhidraInstallation(
{
environment: { PATH: "/valid-ghidra-bin" },
installDir: "relative/ghidra",
platform: "linux",
architecture: "x64",
},
host({ discoverInstallDirOnPath: () => INSTALL }),
);
expect(inspection.status).toBe("unavailable");
expect(inspection.installDir).toBe("relative/ghidra");
expect(inspection.checks[0]).toMatchObject({
name: "configuration",
status: "failed",
detail: "GHIDRA_INSTALL_DIR must be absolute",
});
});
it("probes the selected Java environment without reading conflicting ambient settings", () => {
vi.stubEnv("REA_GHIDRA_CONTEXT", "ambient");
vi.stubEnv("REA_GHIDRA_AMBIENT", "ambient-only");

View File

@ -8,9 +8,10 @@ import {
openSync,
readFileSync,
readSync,
realpathSync,
statSync,
} from "node:fs";
import { posix, win32 } from "node:path";
import { delimiter as pathDelimiter, dirname, posix, win32 } from "node:path";
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
import {
@ -295,6 +296,11 @@ export interface GhidraInstallationHost {
command: string,
environment: NodeJS.ProcessEnv,
): GhidraJavaObservation | undefined;
/** Resolve a PATH launcher to a validated installation without executing it. */
discoverInstallDirOnPath?(
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
): string | undefined;
}
interface GhidraInstallationCoordinates {
@ -405,7 +411,12 @@ const installationCoordinates = (
host: GhidraInstallationHost,
): GhidraInstallationCoordinates => {
const path = platform === "win32" ? win32 : posix;
const installDir = options.installDir ?? null;
// An explicit setting remains authoritative even when malformed or stale;
// automatic discovery is only a fallback when the caller supplied none.
const installDir =
options.installDir ??
host.discoverInstallDirOnPath?.(options.environment, platform) ??
null;
const applicationPropertiesPath =
installDir === null
? null
@ -958,12 +969,56 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
: "jre",
};
},
discoverInstallDirOnPath: discoverGhidraInstallationOnPath,
});
/** Find a PATH launcher whose real target is inside a complete Ghidra tree. */
export const discoverGhidraInstallationOnPath = (
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
): string | undefined => {
if (platform !== process.platform) return undefined;
const path = platform === "win32" ? win32 : posix;
const pathValue = environment.PATH;
if (pathValue === undefined || pathValue.length === 0) return undefined;
const launchers =
platform === "win32"
? ["ghidra.bat", "ghidraRun.bat", "ghidra.exe", "ghidraRun.exe"]
: ["ghidra", "ghidraRun"];
for (const directory of pathValue.split(pathDelimiter)) {
if (directory.length === 0) continue;
for (const launcher of launchers) {
let resolved: string;
try {
const candidate = path.resolve(directory, launcher);
if (!executablePath(candidate)) continue;
resolved = realpathSync(candidate);
} catch (cause: unknown) {
void cause;
continue;
}
const root = dirname(resolved);
const properties = path.join(root, "Ghidra", "application.properties");
const headless = path.join(
root,
"support",
platform === "win32" ? "analyzeHeadless.bat" : "analyzeHeadless",
);
try {
if (statSync(properties).isFile() && executablePath(headless))
return root;
} catch (cause: unknown) {
void cause;
}
}
}
return undefined;
};
const executablePath = (path: string): boolean => {
try {
accessSync(path, constants.X_OK);
return true;
return statSync(path).isFile();
} catch (cause: unknown) {
// best-effort cleanup: optional executable probing; failure means missing.
void cause;