From 86afc040e5c9f2e9fbf9604f234bf180d9ebe954 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:05:53 +0800 Subject: [PATCH] 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. --- docs/installation.md | 17 +++++ src/ghidra/GhidraInstallation.test.ts | 98 +++++++++++++++++++++++++++ src/ghidra/GhidraInstallation.ts | 61 ++++++++++++++++- 3 files changed, 173 insertions(+), 3 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 4e95e548e..bb6a2dd5c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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`. diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index 626954c69..a60e7b4b1 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -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"); diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index f30f6ef7a..6a77ee1af 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -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, + 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, + 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;