Files
rea/src/application/LinuxHopper.test.ts
T
CindyGestandmorluto 7dcd23c005 fix(setup): separate host support and stop skill checks after drift (#2073)
* 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 <76467478+morluto@users.noreply.github.com>
2026-10-13 23:33:54 +08:00

277 lines
8.7 KiB
TypeScript

import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
installLinuxHopper,
linuxHopperLauncherDigestSupported,
linuxPackageManagerCommands,
linuxSharedLibrariesAvailable,
parseLinuxDistribution,
supportsReaHost,
type LinuxDistribution,
type LinuxHopperDownload,
type LinuxHopperInstallHost,
type LinuxHopperLauncherStatus,
type LinuxPackageFamily,
} from "./LinuxHopper.js";
class RecordingLinuxHost implements LinuxHopperInstallHost {
distributionValue: LinuxDistribution | undefined = {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
};
archive = new Uint8Array([1, 2, 3]);
archiveOk = true;
installSucceeds = true;
launcherStatusValue: LinuxHopperLauncherStatus = "ready";
downloads: string[] = [];
installed: Array<{ family: LinuxPackageFamily; archive: string }> = [];
cleaned: string[] = [];
distribution = (): Promise<LinuxDistribution | undefined> =>
Promise.resolve(this.distributionValue);
download = (url: string): Promise<LinuxHopperDownload> => {
this.downloads.push(url);
return Promise.resolve({ ok: this.archiveOk, bytes: this.archive });
};
createTemporaryDirectory = (): Promise<string> =>
Promise.resolve("/tmp/rea test");
writeArchive = (): Promise<void> => Promise.resolve();
installPackage = (
family: LinuxPackageFamily,
archive: string,
): Promise<boolean> => {
this.installed.push({ family, archive });
return Promise.resolve(this.installSucceeds);
};
launcherStatus = (): Promise<LinuxHopperLauncherStatus> =>
Promise.resolve(this.launcherStatusValue);
cleanup = (path: string): Promise<void> => {
this.cleaned.push(path);
return Promise.resolve();
};
}
describe("Linux Hopper host classification", () => {
it.each([
['ID=ubuntu\nVERSION_ID="24.04"\n', "deb"],
["ID=fedora\nVERSION_ID=41\n", "rpm"],
['ID=nobara\nVERSION_ID="44"\nID_LIKE="rhel centos fedora"\n', "rpm"],
["ID=arch\n", "arch"],
["ID=cachyos\nID_LIKE=arch\n", "arch"],
['ID=omarchy\nID_LIKE=arch\nVERSION_ID="4.0.4"\n', "arch"],
] as const)(
"accepts an explicitly supported distribution",
(document, family) => {
expect(parseLinuxDistribution(document)).toMatchObject({
packageFamily: family,
hopperSetupSupported: true,
});
},
);
it("detects unresolved or loader-reported shared libraries", () => {
expect(
linuxSharedLibrariesAvailable(
"libQt6Network.so.6 => not found\nlibc.so.6 => /lib/libc.so.6",
),
).toBe(false);
expect(
linuxSharedLibrariesAvailable(
"error while loading shared libraries: libQt6Gui.so.6",
),
).toBe(false);
expect(
linuxSharedLibrariesAvailable(
"libQt6Network.so.6 => /usr/lib/libQt6Network.so.6",
),
).toBe(true);
});
it.each([
'ID=ubuntu\nVERSION_ID="22.04"\n',
"ID=fedora\nVERSION_ID=40\n",
'ID=nobara\nVERSION_ID="43"\nID_LIKE="rhel centos fedora"\n',
'ID=derivative\nVERSION_ID="44"\nID_LIKE="rhel centos fedora"\n',
'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n',
"ID=manjaro\nID_LIKE=arch\n",
"ID=garuda\nID_LIKE=arch\n",
"ID=omarchy\n",
"ID=omarchy\nID_LIKE=debian\n",
])(
"rejects vendor/version combinations without a tested Hopper installer",
(document) => {
expect(parseLinuxDistribution(document).hopperSetupSupported).toBe(false);
},
);
it.each([
'ID=kali\nVERSION_ID="2026.3"\nID_LIKE=debian\n',
'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n',
'ID=linuxmint\nVERSION_ID="22.1"\nID_LIKE="ubuntu debian"\n',
"ID=manjaro\nID_LIKE=arch\n",
"ID=derivative\nID_LIKE='debian'\n",
"ID='debian'\nVERSION_ID='13'\n",
])(
"hosts REA on a distribution Hopper has no tested package for: %s",
(document) => {
const distribution = parseLinuxDistribution(document);
expect(distribution.hopperSetupSupported).toBe(false);
expect(supportsReaHost(distribution)).toBe(true);
},
);
it("withholds host support from a distribution with no package family", () => {
expect(supportsReaHost(parseLinuxDistribution("ID=nixos\n"))).toBe(false);
expect(supportsReaHost(undefined)).toBe(false);
});
});
describe("Linux Hopper installation", () => {
it.each([
[
"Debian and Arch",
"0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded",
],
["RPM", "1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13"],
])("accepts the pinned %s launcher build", (_, digest) => {
expect(linuxHopperLauncherDigestSupported(digest)).toBe(true);
});
it("rejects an unverified launcher build", () => {
expect(linuxHopperLauncherDigestSupported("0".repeat(64))).toBe(false);
});
it.each([
["deb", "apt-get", ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]],
[
"rpm",
"dnf",
[
"xorg-x11-server-Xvfb",
"xorg-x11-xauth",
"python3",
"libX11",
"libXtst",
],
],
[
"arch",
"pacman",
["xorg-server-xvfb", "xorg-xauth", "python", "libx11", "libxtst"],
],
] as const)(
"selects the %s native package manager and dependencies",
(family, executable, packages) => {
expect(
linuxPackageManagerCommands(family, "/tmp/Hopper package", true),
).toContainEqual(
expect.objectContaining({
executable,
args: expect.arrayContaining([...packages]),
}),
);
expect(
linuxPackageManagerCommands(family, "/tmp/Hopper package", false),
).toContainEqual(
expect.objectContaining({
executable: "pkexec",
args: expect.arrayContaining([executable, "/tmp/Hopper package"]),
}),
);
},
);
it.each(["deb", "rpm", "arch"] as const)(
"downloads, verifies, installs, reads back, and cleans a %s package",
async (family) => {
const host = new RecordingLinuxHost();
host.distributionValue = {
id: family,
packageFamily: family,
hopperSetupSupported: true,
};
const result = await installFixture(host);
expect(result).toEqual({
status: "installed",
launcherPath: "/opt/hopper/bin/Hopper",
});
expect(host.installed).toEqual([
{ family, archive: `/tmp/rea test/hopper.${family}` },
]);
expect(host.cleaned).toEqual(["/tmp/rea test"]);
},
);
it.each([
["unsupported host", "unsupported_host"],
["package request failure", "download"],
["checksum mismatch", "integrity"],
["package manager failure", "authorization_or_package_manager"],
["missing launcher", "launcher_missing"],
["missing runtime", "runtime_dependencies"],
["unsupported build", "unsupported_hopper_build"],
] as const)("classifies %s", async (scenario, reason) => {
const host = new RecordingLinuxHost();
if (scenario === "unsupported host")
host.distributionValue = {
id: "debian",
packageFamily: "deb",
hopperSetupSupported: false,
};
if (scenario === "package request failure") host.archiveOk = false;
if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]);
if (scenario === "package manager failure") host.installSucceeds = false;
if (scenario === "missing launcher") host.launcherStatusValue = "missing";
if (scenario === "missing runtime")
host.launcherStatusValue = "runtime_dependencies";
if (scenario === "unsupported build")
host.launcherStatusValue = "unsupported_hopper_build";
expect(await installFixture(host)).toEqual({
status: "failed",
reason,
});
if (host.installed.length > 0)
expect(host.cleaned).toEqual(["/tmp/rea test"]);
});
it("cancels before network or mutation", async () => {
const host = new RecordingLinuxHost();
const controller = new AbortController();
controller.abort();
expect(
await installLinuxHopper(host, { signal: controller.signal }),
).toEqual({
status: "failed",
reason: "cancelled",
});
expect(host.downloads).toEqual([]);
expect(host.installed).toEqual([]);
});
});
function releasesFor(
bytes: Uint8Array,
): Record<
LinuxPackageFamily,
{ filename: string; file_length: string; file_hash: string }
> {
const release = (extension: string) => ({
filename: `https://www.hopperapp.com:443/downloader/public/Hopper.${extension}`,
file_length: String(bytes.byteLength),
file_hash: createHash("sha1").update(bytes).digest("hex"),
});
return {
deb: release("deb"),
rpm: release("rpm"),
arch: release("pkg.tar.xz"),
};
}
const installFixture = (host: RecordingLinuxHost) =>
installLinuxHopper(host, {
releases: releasesFor(new Uint8Array([1, 2, 3])),
});