* fix(host): separate REA host support from Hopper installer targets LinuxDistribution.supported carried two meanings at once: that the host runs REA, and that the automated Hopper installer is tested on it. The doctor read the first meaning from the second, so Debian-derived hosts such as Kali reported unsupported_host while their providers worked normally. Rename the field to hopperSetupSupported, which only the Hopper installer and setup planning consume, and derive host support from the package family through supportsReaHost(). Any distribution with a recognized apt, dnf, or pacman family is now a REA host. The tested Hopper installer list is unchanged, so Debian and Kali keep falling back to configuring an existing provider instead of downloading an untested package. Verified on Kali 2026.3 (Debian testing, x86_64): rea doctor reports a healthy environment with Ghidra 12.1.4, and analyze_function decompiles an x86_64 ELF through the MCP server. npm run check, npm run docs:check, and the application and Ghidra suites pass. No Hopper or IDA host workflow was exercised. * fix(setup): stop skill checks after first drift --------- Co-authored-by: morluto <[email protected]>
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { execFile } from "node:child_process";
|
|
import { createReadStream } from "node:fs";
|
|
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
import { execFileOutput } from "../process/ExecFileOutput.js";
|
|
import {
|
|
downloadHopperPackage,
|
|
HOPPER_DOWNLOAD_BYTES,
|
|
} from "./HopperDownload.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
|
|
const SUPPORTED_HOPPER_SHA256 = new Set([
|
|
"0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded",
|
|
"1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13",
|
|
]);
|
|
interface LinuxHopperRelease {
|
|
readonly filename: string;
|
|
readonly file_length: string;
|
|
readonly file_hash: string;
|
|
}
|
|
const SUPPORTED_RELEASES: Readonly<
|
|
Record<LinuxPackageFamily, LinuxHopperRelease>
|
|
> = {
|
|
deb: {
|
|
filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.deb`,
|
|
file_length: "35755772",
|
|
file_hash: "e4f79dff602648a8ff4a88b773875b6bfac0dc65",
|
|
},
|
|
rpm: {
|
|
filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.rpm`,
|
|
file_length: "32264475",
|
|
file_hash: "b9a65f2f583a386d440d4401d9140aef3bf305de",
|
|
},
|
|
arch: {
|
|
filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.pkg.tar.xz`,
|
|
file_length: "29405968",
|
|
file_hash: "92d780b61cab38eda95dc4112dee4fbeb0920f46",
|
|
},
|
|
};
|
|
|
|
export type LinuxPackageFamily = "deb" | "rpm" | "arch";
|
|
|
|
/** Exact stable external effects disclosed before a Linux Hopper installation. */
|
|
export interface LinuxHopperInstallDisclosure {
|
|
readonly downloadUrl: string;
|
|
readonly expectedBytes: number;
|
|
readonly expectedSha1: string;
|
|
readonly commands: readonly string[];
|
|
}
|
|
|
|
/** Describe the release and package-manager commands without downloading or writing. */
|
|
export const linuxHopperInstallDisclosure = (
|
|
family: LinuxPackageFamily,
|
|
isRoot: boolean,
|
|
): LinuxHopperInstallDisclosure => {
|
|
const release = SUPPORTED_RELEASES[family];
|
|
return {
|
|
downloadUrl: release.filename,
|
|
expectedBytes: Number(release.file_length),
|
|
expectedSha1: release.file_hash,
|
|
commands: linuxPackageManagerCommands(
|
|
family,
|
|
`<verified-hopper.${family}>`,
|
|
isRoot,
|
|
).map(({ executable, args }) => `${executable} ${args.join(" ")}`),
|
|
};
|
|
};
|
|
|
|
interface LinuxDistributionIdentity {
|
|
readonly id: string;
|
|
readonly versionId?: string;
|
|
}
|
|
|
|
/**
|
|
* Parsed Linux distribution information used for package selection.
|
|
*
|
|
* `hopperSetupSupported` is the automated Hopper installer's tested list. It is
|
|
* narrower than the hosts where REA itself runs, which {@link supportsReaHost}
|
|
* answers from the package family.
|
|
*/
|
|
export type LinuxDistribution = LinuxDistributionIdentity &
|
|
(
|
|
| {
|
|
readonly packageFamily: LinuxPackageFamily;
|
|
readonly hopperSetupSupported: true;
|
|
}
|
|
| {
|
|
readonly packageFamily: LinuxPackageFamily | undefined;
|
|
readonly hopperSetupSupported: false;
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Whether REA's analysis providers run on this distribution. A recognized
|
|
* package family means a glibc host with a known package layout, which is what
|
|
* the providers need; downloading and installing the official Hopper package
|
|
* additionally requires a distribution on its tested list.
|
|
*/
|
|
export const supportsReaHost = (
|
|
distribution: LinuxDistribution | undefined,
|
|
): boolean => distribution?.packageFamily !== undefined;
|
|
|
|
/** Download response kept independent of Node's global fetch implementation. */
|
|
export interface LinuxHopperDownload {
|
|
readonly ok: boolean;
|
|
readonly bytes: Uint8Array;
|
|
}
|
|
|
|
/** External effects required to install the official Linux package. */
|
|
export interface LinuxHopperInstallHost {
|
|
distribution(): Promise<LinuxDistribution | undefined>;
|
|
download(
|
|
url: string,
|
|
options: { readonly signal?: AbortSignal },
|
|
): Promise<LinuxHopperDownload>;
|
|
createTemporaryDirectory(): Promise<string>;
|
|
writeArchive(path: string, bytes: Uint8Array): Promise<void>;
|
|
installPackage(family: LinuxPackageFamily, archive: string): Promise<boolean>;
|
|
launcherStatus(path: string): Promise<LinuxHopperLauncherStatus>;
|
|
cleanup(path: string): Promise<void>;
|
|
}
|
|
|
|
/** Structured outcome for every expected Linux installation failure. */
|
|
export type LinuxHopperInstallResult =
|
|
| { readonly status: "installed"; readonly launcherPath: string }
|
|
| {
|
|
readonly status: "failed";
|
|
readonly reason:
|
|
| "unsupported_host"
|
|
| "release_metadata"
|
|
| "download"
|
|
| "integrity"
|
|
| "authorization_or_package_manager"
|
|
| "launcher_missing"
|
|
| "runtime_dependencies"
|
|
| "unsupported_hopper_build"
|
|
| "cancelled";
|
|
};
|
|
|
|
export type LinuxHopperLauncherStatus =
|
|
| "ready"
|
|
| "missing"
|
|
| "runtime_dependencies"
|
|
| "unsupported_hopper_build";
|
|
|
|
/** Parse an os-release document without executing its shell syntax. */
|
|
export const parseLinuxDistribution = (text: string): LinuxDistribution => {
|
|
const fields = new Map<string, string>();
|
|
for (const line of text.split("\n")) {
|
|
const match = /^([A-Z_]+)=(.*)$/u.exec(line.trim());
|
|
if (match === null) continue;
|
|
const key = match[1];
|
|
const encoded = match[2];
|
|
if (key === undefined || encoded === undefined) continue;
|
|
const quote = encoded[0];
|
|
const quoted = (quote === "'" || quote === '"') && encoded.endsWith(quote);
|
|
const value = !quoted
|
|
? encoded
|
|
: quote === "'"
|
|
? encoded.slice(1, -1)
|
|
: encoded.slice(1, -1).replace(/\\(["\\$`])/gu, "$1");
|
|
fields.set(key, value);
|
|
}
|
|
const id = (fields.get("ID") ?? "unknown").toLowerCase();
|
|
const versionId = fields.get("VERSION_ID");
|
|
const packageFamily = packageFamilyFor(id, fields.get("ID_LIKE"));
|
|
const hopperSetupSupported =
|
|
packageFamily !== undefined &&
|
|
(id === "arch" ||
|
|
id === "cachyos" ||
|
|
(id === "omarchy" && packageFamily === "arch") ||
|
|
(id === "ubuntu" && versionAtLeast(versionId, 24)) ||
|
|
(id === "fedora" && versionAtLeast(versionId, 41)) ||
|
|
(id === "nobara" && versionAtLeast(versionId, 44)));
|
|
const identity = {
|
|
id,
|
|
...(versionId === undefined ? {} : { versionId }),
|
|
};
|
|
return hopperSetupSupported
|
|
? { ...identity, packageFamily, hopperSetupSupported: true }
|
|
: { ...identity, packageFamily, hopperSetupSupported: false };
|
|
};
|
|
|
|
/** Read and classify the current Linux distribution. */
|
|
export const readLinuxDistribution = async (): Promise<
|
|
LinuxDistribution | undefined
|
|
> => {
|
|
try {
|
|
return parseLinuxDistribution(await readFile("/etc/os-release", "utf8"));
|
|
} catch (cause: unknown) {
|
|
// best-effort cleanup: optional host probing; unknown distribution means unsupported.
|
|
void cause;
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
/** Download, verify, install, and read back the official Hopper Linux package. */
|
|
export const installLinuxHopper = async (
|
|
host: LinuxHopperInstallHost = systemLinuxHopperInstallHost(process.env),
|
|
options: {
|
|
readonly signal?: AbortSignal;
|
|
readonly releases?: Readonly<
|
|
Record<LinuxPackageFamily, LinuxHopperRelease>
|
|
>;
|
|
} = {},
|
|
): Promise<LinuxHopperInstallResult> => {
|
|
if (options.signal?.aborted === true)
|
|
return { status: "failed", reason: "cancelled" };
|
|
const distribution = await host.distribution();
|
|
if (distribution?.hopperSetupSupported !== true)
|
|
return { status: "failed", reason: "unsupported_host" };
|
|
let temporary: string | undefined;
|
|
try {
|
|
const release = (options.releases ?? SUPPORTED_RELEASES)[
|
|
distribution.packageFamily
|
|
];
|
|
const archiveDownload = await host.download(release.filename, options);
|
|
if (!archiveDownload.ok) return { status: "failed", reason: "download" };
|
|
if (!packageIntegrityMatches(archiveDownload.bytes, release))
|
|
return { status: "failed", reason: "integrity" };
|
|
temporary = await host.createTemporaryDirectory();
|
|
const archive = join(temporary, `hopper.${distribution.packageFamily}`);
|
|
await host.writeArchive(archive, archiveDownload.bytes);
|
|
if (!(await host.installPackage(distribution.packageFamily, archive)))
|
|
return { status: "failed", reason: "authorization_or_package_manager" };
|
|
const launcherPath = "/opt/hopper/bin/Hopper";
|
|
const launcherStatus = await host.launcherStatus(launcherPath);
|
|
if (launcherStatus !== "ready")
|
|
return {
|
|
status: "failed",
|
|
reason:
|
|
launcherStatus === "missing" ? "launcher_missing" : launcherStatus,
|
|
};
|
|
return { status: "installed", launcherPath };
|
|
} catch (cause: unknown) {
|
|
if (isAbortCause(cause)) return { status: "failed", reason: "cancelled" };
|
|
return { status: "failed", reason: "download" };
|
|
} finally {
|
|
if (temporary !== undefined) await host.cleanup(temporary);
|
|
}
|
|
};
|
|
|
|
/** Canonical launcher path for a legacy user-local Linux Hopper installation. */
|
|
export const linuxHopperLauncherPath = (home: string): string =>
|
|
join(home, ".local/share/rea/hopper/bin/Hopper");
|
|
|
|
/** Compose package-manager and linker checks from the selected caller environment. */
|
|
export const systemLinuxHopperInstallHost = (
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
): LinuxHopperInstallHost => ({
|
|
distribution: readLinuxDistribution,
|
|
download: downloadHopperPackage,
|
|
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
|
|
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
|
installPackage: (family, archive) =>
|
|
installSystemPackage(family, archive, environment),
|
|
async launcherStatus(path) {
|
|
try {
|
|
await access(path);
|
|
const linked = await execFileOutput("ldd", [path], { env: environment });
|
|
if (!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`))
|
|
return "runtime_dependencies";
|
|
return (await linuxHopperBinarySupported(path))
|
|
? "ready"
|
|
: "unsupported_hopper_build";
|
|
} catch (cause: unknown) {
|
|
// best-effort cleanup: optional launcher probing; unreadable means missing.
|
|
void cause;
|
|
return "missing";
|
|
}
|
|
},
|
|
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
|
});
|
|
|
|
/** Match the Linux Hopper build validated by the packaged demo-dialog adapter. */
|
|
export const linuxHopperBinarySupported = async (
|
|
path: string,
|
|
): Promise<boolean> => {
|
|
try {
|
|
const hash = createHash("sha256");
|
|
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
return linuxHopperLauncherDigestSupported(hash.digest("hex"));
|
|
} catch (cause: unknown) {
|
|
// best-effort cleanup: optional build probing; unreadable means unsupported.
|
|
void cause;
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/** Match a launcher extracted from one of the pinned official Linux packages. */
|
|
export const linuxHopperLauncherDigestSupported = (digest: string): boolean =>
|
|
SUPPORTED_HOPPER_SHA256.has(digest);
|
|
|
|
/** Interpret ldd output conservatively so a present but broken launcher is unhealthy. */
|
|
export const linuxSharedLibrariesAvailable = (output: string): boolean =>
|
|
!/(?:=>\s+not found|error while loading shared libraries)/iu.test(output);
|
|
|
|
const installSystemPackage = async (
|
|
family: LinuxPackageFamily,
|
|
archive: string,
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
): Promise<boolean> => {
|
|
try {
|
|
for (const command of linuxPackageManagerCommands(
|
|
family,
|
|
archive,
|
|
process.getuid?.() === 0,
|
|
))
|
|
await execFileAsync(command.executable, command.args, {
|
|
env: environment,
|
|
});
|
|
return true;
|
|
} catch (cause: unknown) {
|
|
// Package-manager failure is reported by the false return.
|
|
void cause;
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/** Select the native package manager and authorization boundary without shell evaluation. */
|
|
export const linuxPackageManagerCommands = (
|
|
family: LinuxPackageFamily,
|
|
archive: string,
|
|
isRoot: boolean,
|
|
): readonly {
|
|
readonly executable: string;
|
|
readonly args: readonly string[];
|
|
}[] => {
|
|
const runtimeDependencies =
|
|
family === "deb"
|
|
? ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]
|
|
: family === "rpm"
|
|
? [
|
|
"xorg-x11-server-Xvfb",
|
|
"xorg-x11-xauth",
|
|
"python3",
|
|
"libX11",
|
|
"libXtst",
|
|
]
|
|
: ["xorg-server-xvfb", "xorg-xauth", "python", "libx11", "libxtst"];
|
|
const commands =
|
|
family === "deb"
|
|
? [["apt-get", "install", "-y", archive, ...runtimeDependencies]]
|
|
: family === "rpm"
|
|
? [["dnf", "install", "-y", archive, ...runtimeDependencies]]
|
|
: [
|
|
["pacman", "-S", "--needed", "--noconfirm", ...runtimeDependencies],
|
|
["pacman", "-U", "--noconfirm", archive],
|
|
];
|
|
return commands.map((command) => {
|
|
const executable = isRoot ? command[0] : "pkexec";
|
|
if (executable === undefined) throw new Error("package command is empty");
|
|
return { executable, args: isRoot ? command.slice(1) : command };
|
|
});
|
|
};
|
|
|
|
const packageIntegrityMatches = (
|
|
bytes: Uint8Array,
|
|
release: LinuxHopperRelease,
|
|
): boolean =>
|
|
bytes.byteLength === Number(release.file_length) &&
|
|
bytes.byteLength <= HOPPER_DOWNLOAD_BYTES &&
|
|
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
|
|
|
|
const packageFamilyFor = (
|
|
id: string,
|
|
idLike: string | undefined,
|
|
): LinuxPackageFamily | undefined => {
|
|
const identities = new Set([
|
|
id,
|
|
...(idLike?.toLowerCase().split(/\s+/u) ?? []),
|
|
]);
|
|
if (identities.has("ubuntu") || identities.has("debian")) return "deb";
|
|
if (identities.has("fedora") || identities.has("rhel")) return "rpm";
|
|
if (identities.has("arch")) return "arch";
|
|
return undefined;
|
|
};
|
|
|
|
const versionAtLeast = (
|
|
version: string | undefined,
|
|
minimum: number,
|
|
): boolean =>
|
|
version !== undefined &&
|
|
Number.parseInt(version.split(".")[0] ?? "0", 10) >= minimum;
|
|
const isAbortCause = (cause: unknown): boolean =>
|
|
cause instanceof Error && cause.name === "AbortError";
|