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:
parent
127c621328
commit
86afc040e5
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue