diff --git a/docs/installation.md b/docs/installation.md index bb0adfbc99..8cddafee0b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -310,9 +310,12 @@ Hopper is separate commercial software with its own license. Its free demo has vendor-defined limits, and a paid license is optional. REA reuses any detected installation and preserves Hopper during uninstall. -The supported native host baseline is macOS 12+, Ubuntu 24.04+, Fedora 41+, -Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy. Ghidra and IDA have their own provider-specific -host requirements; Windows Ghidra uses the [experimental P0 boundary](windows-ghidra-p0.md). +The automated Hopper installer supports macOS 12+, Ubuntu 24.04+, Fedora 41+, +Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy. REA itself runs on any +Linux distribution with a recognized apt, dnf, or pacman package family, where +an existing Hopper launcher is selected through `HOPPER_LAUNCHER_PATH`. Ghidra +and IDA have their own provider-specific host requirements; Windows Ghidra uses +the [experimental P0 boundary](windows-ghidra-p0.md). On macOS, approved setup downloads the official DMG, checks its published size and digest, validates the application bundle, and atomically installs it to diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index 4251bbaf22..a39f81e352 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -74,7 +74,7 @@ describe("doctor", () => { ), ).toEqual({ node: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`, - host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.", + host: "REA supports macOS 12+, Linux distributions with a recognized apt, dnf, or pacman package family, and the experimental Windows x64 Ghidra P0 boundary; automated Hopper setup additionally requires Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy.", hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.", target: "Supply a readable local app or program path.", }); @@ -418,10 +418,9 @@ describe("doctor scoped readiness", () => { platform: "linux", linuxDistribution: () => Promise.resolve({ - id: "debian", - versionId: "13", - packageFamily: "deb", - supported: false, + id: "nixos", + packageFamily: undefined, + hopperSetupSupported: false, }), clientRegistrations: () => Promise.resolve([ @@ -451,6 +450,31 @@ describe("doctor scoped readiness", () => { ); }); + it("accepts a Debian-family host that has no tested Hopper package", async () => { + const result = await runDoctor( + undefined, + host({ + platform: "linux", + macosVersion: () => Promise.resolve(undefined), + linuxDistribution: () => + Promise.resolve({ + id: "kali", + versionId: "2026.3", + packageFamily: "deb", + hopperSetupSupported: false, + }), + }), + ); + + expect(result.checks).toContainEqual( + expect.objectContaining({ + name: "host", + ok: true, + detail: "kali 2026.3", + }), + ); + }); + it("requires explicitly selected providers instead of any available provider", async () => { const ghidra = { id: "ghidra", @@ -502,7 +526,7 @@ describe("doctor Linux and Hopper discovery", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }), configuredHopperPath: path, executable: (candidate) => Promise.resolve(candidate === path), @@ -522,7 +546,7 @@ describe("doctor Linux and Hopper discovery", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }), linuxDemoRuntimeCheck: () => Promise.resolve({ @@ -554,7 +578,7 @@ describe("doctor Linux and Hopper discovery", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }), executable: (candidate) => Promise.resolve(candidate === path), supportedLinuxHopper: () => Promise.resolve(false), diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts index 354d08636e..1d8a5e6219 100644 --- a/src/application/DoctorDiagnostics.ts +++ b/src/application/DoctorDiagnostics.ts @@ -2,7 +2,7 @@ import { SUPPORTED_NODE_VERSION_PROSE, supportsNodeVersion, } from "../domain/runtimeVersion.js"; -import { linuxHopperLauncherPath } from "./LinuxHopper.js"; +import { linuxHopperLauncherPath, supportsReaHost } from "./LinuxHopper.js"; import type { DoctorCheck, DoctorHost, @@ -129,7 +129,7 @@ const hostCheck = async (host: DoctorHost): Promise => { host.platform === "linux" ? await host.linuxDistribution() : undefined; const supported = (host.platform === "darwin" && parseMajor(macosVersion) >= 12) || - (host.platform === "linux" && linuxDistribution?.supported === true) || + (host.platform === "linux" && supportsReaHost(linuxDistribution)) || (host.platform === "win32" && host.architecture === "x64"); const detail = macosVersion ?? @@ -138,7 +138,7 @@ const hostCheck = async (host: DoctorHost): Promise => { : `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`); return check("host", supported, detail, { remediation: - "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.", + "REA supports macOS 12+, Linux distributions with a recognized apt, dnf, or pacman package family, and the experimental Windows x64 Ghidra P0 boundary; automated Hopper setup additionally requires Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy.", classification: "unsupported_host", }); }; diff --git a/src/application/LinuxHopper.test.ts b/src/application/LinuxHopper.test.ts index b5cf2d38f8..1c37a03ea9 100644 --- a/src/application/LinuxHopper.test.ts +++ b/src/application/LinuxHopper.test.ts @@ -7,6 +7,7 @@ import { linuxPackageManagerCommands, linuxSharedLibrariesAvailable, parseLinuxDistribution, + supportsReaHost, type LinuxDistribution, type LinuxHopperDownload, type LinuxHopperInstallHost, @@ -19,7 +20,7 @@ class RecordingLinuxHost implements LinuxHopperInstallHost { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; archive = new Uint8Array([1, 2, 3]); archiveOk = true; @@ -66,7 +67,7 @@ describe("Linux Hopper host classification", () => { (document, family) => { expect(parseLinuxDistribution(document)).toMatchObject({ packageFamily: family, - supported: true, + hopperSetupSupported: true, }); }, ); @@ -99,8 +100,32 @@ describe("Linux Hopper host classification", () => { "ID=garuda\nID_LIKE=arch\n", "ID=omarchy\n", "ID=omarchy\nID_LIKE=debian\n", - ])("rejects unsupported vendor/version combinations", (document) => { - expect(parseLinuxDistribution(document).supported).toBe(false); + ])( + "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); }); }); @@ -166,7 +191,7 @@ describe("Linux Hopper installation", () => { host.distributionValue = { id: family, packageFamily: family, - supported: true, + hopperSetupSupported: true, }; const result = await installFixture(host); expect(result).toEqual({ @@ -194,7 +219,7 @@ describe("Linux Hopper installation", () => { host.distributionValue = { id: "debian", packageFamily: "deb", - supported: false, + hopperSetupSupported: false, }; if (scenario === "package request failure") host.archiveOk = false; if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]); diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts index 3a46106bb2..765f31d293 100644 --- a/src/application/LinuxHopper.ts +++ b/src/application/LinuxHopper.ts @@ -76,19 +76,35 @@ interface LinuxDistributionIdentity { readonly versionId?: string; } -/** Parsed Linux distribution information used for support and package selection. */ +/** + * 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 supported: true; + readonly hopperSetupSupported: true; } | { readonly packageFamily: LinuxPackageFamily | undefined; - readonly supported: false; + 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; @@ -141,16 +157,19 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => { const key = match[1]; const encoded = match[2]; if (key === undefined || encoded === undefined) continue; - const value = - encoded.startsWith('"') && encoded.endsWith('"') - ? encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\") - : encoded; + 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 supported = + const hopperSetupSupported = packageFamily !== undefined && (id === "arch" || id === "cachyos" || @@ -162,9 +181,9 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => { id, ...(versionId === undefined ? {} : { versionId }), }; - return supported - ? { ...identity, packageFamily, supported: true } - : { ...identity, packageFamily, supported: false }; + return hopperSetupSupported + ? { ...identity, packageFamily, hopperSetupSupported: true } + : { ...identity, packageFamily, hopperSetupSupported: false }; }; /** Read and classify the current Linux distribution. */ @@ -193,7 +212,7 @@ export const installLinuxHopper = async ( if (options.signal?.aborted === true) return { status: "failed", reason: "cancelled" }; const distribution = await host.distribution(); - if (distribution?.supported !== true) + if (distribution?.hopperSetupSupported !== true) return { status: "failed", reason: "unsupported_host" }; let temporary: string | undefined; try { diff --git a/src/application/Setup.configuration.test.ts b/src/application/Setup.configuration.test.ts index 3bd08cd4ff..a3f46ed6fb 100644 --- a/src/application/Setup.configuration.test.ts +++ b/src/application/Setup.configuration.test.ts @@ -57,7 +57,7 @@ describe("setup workflow", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; host.hopper = "/opt/hopper/bin/Hopper"; host.linuxDemoRuntimeMissing = true; @@ -79,7 +79,7 @@ describe("setup workflow", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; host.hopper = "/custom/Hopper"; host.unsupportedHopperVersion = true; diff --git a/src/application/Setup.planning.test.ts b/src/application/Setup.planning.test.ts index d1ca6baeae..2548772c91 100644 --- a/src/application/Setup.planning.test.ts +++ b/src/application/Setup.planning.test.ts @@ -70,7 +70,7 @@ describe("setup workflow", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; const result = await runSetup( { ...options(false), structured: false }, @@ -162,7 +162,7 @@ describe("setup workflow", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; const result = await runSetup(options(true, true), host); expect(result.appliedActions).toEqual(["installed_hopper"]); @@ -236,7 +236,7 @@ describe("setup workflow lifecycle and provider planning", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; host.ghidra = "/opt/ghidra_12.1.4_PUBLIC"; host.javaHome = "/usr/lib/jvm/jdk-21"; diff --git a/src/application/Setup.recovery.test.ts b/src/application/Setup.recovery.test.ts index f29c9d375d..244aee26b5 100644 --- a/src/application/Setup.recovery.test.ts +++ b/src/application/Setup.recovery.test.ts @@ -310,7 +310,7 @@ describe("setup scoped readiness", () => { id: "debian", versionId: "13", packageFamily: "deb", - supported: false, + hopperSetupSupported: false, }; const result = await runSetup(options(true, true), host); expect(result.status).toBe("needs_human"); @@ -326,7 +326,7 @@ describe("setup scoped readiness", () => { id: "debian", versionId: "13", packageFamily: "deb", - supported: false, + hopperSetupSupported: false, }; host.ghidra = "/opt/ghidra_12.1.4_PUBLIC"; host.javaHome = "/usr/lib/jvm/jdk-21"; diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 022cecc620..84d41229e5 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -124,7 +124,8 @@ export const hostRemediation = async ( ? "Upgrade to macOS 12 or newer." : undefined; } - if ((await host.linuxDistribution())?.supported === true) return undefined; + if ((await host.linuxDistribution())?.hopperSetupSupported === true) + return undefined; return "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, and Omarchy; configure an existing supported provider instead."; }; diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index 35db28de4b..f428ecade4 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -56,7 +56,8 @@ export const discoverSetupState = async (input: { const hopperInstallSupported = (input.host.platform === "darwin" && majorVersion((await input.host.macosVersion()) ?? "0") >= 12) || - (input.host.platform === "linux" && linuxDistribution?.supported === true); + (input.host.platform === "linux" && + linuxDistribution?.hopperSetupSupported === true); const installHopper = input.forceHopperInstall || (hopperInstallSupported && diff --git a/src/application/SetupSkill.ts b/src/application/SetupSkill.ts index 83ba96c5a2..794202b482 100644 --- a/src/application/SetupSkill.ts +++ b/src/application/SetupSkill.ts @@ -72,6 +72,15 @@ interface CanonicalSkillFile { readonly original: SkillFileState | undefined; } +const canonicalSkillContent = (relativePath: string): Promise => + readFile( + new URL( + `../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`, + import.meta.url, + ), + "utf8", + ); + export interface SkillInstallFileOperations { readonly mkdir: (path: string) => Promise; readonly backup: (source: string, destination: string) => Promise; @@ -244,19 +253,36 @@ const canonicalSkillFiles = async ( const destination = join(root, relativePath); return { destination, - content: await readFile( - new URL( - `../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`, - import.meta.url, - ), - "utf8", - ), + content: await canonicalSkillContent(relativePath), original: await readOptionalSkillState(destination, resources), }; }), ), ); +const canonicalSkillNeedsInstallByReading = async ( + home: string, + clientIds: readonly string[], + environment: Readonly, + platform: NodeJS.Platform, + resources?: ArtifactResourceScope, +): Promise => { + for (const { path: root } of skillDestinations( + home, + clientIds, + environment, + platform, + )) { + for (const relativePath of MANAGED_SKILL_FILES) { + const destination = join(root, relativePath); + const content = await canonicalSkillContent(relativePath); + const original = await readOptionalSkillState(destination, resources); + if (original?.content !== content) return true; + } + } + return false; +}; + /** Report whether setup would change any file in the managed REA skill bundle. */ export const canonicalSkillNeedsInstall = async ( home: string, @@ -266,15 +292,13 @@ export const canonicalSkillNeedsInstall = async ( resources?: ArtifactResourceScope, ): Promise => { try { - return ( - await canonicalSkillFiles( - home, - clientIds, - environment, - platform, - resources, - ) - ).some(({ content, original }) => original?.content !== content); + return await canonicalSkillNeedsInstallByReading( + home, + clientIds, + environment, + platform, + resources, + ); } catch (cause: unknown) { if ( cause instanceof RegularFileCleanupFailure || diff --git a/src/ghidra/GhidraDoctor.test.ts b/src/ghidra/GhidraDoctor.test.ts index 69b9acb623..844438408a 100644 --- a/src/ghidra/GhidraDoctor.test.ts +++ b/src/ghidra/GhidraDoctor.test.ts @@ -61,7 +61,7 @@ const doctorHost = ( id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }), validTarget: () => Promise.resolve(true), executable: (path) => diff --git a/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts b/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts index dfb4729720..4ca9ea4075 100644 --- a/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts +++ b/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts @@ -70,7 +70,7 @@ const failFirstCloses = (path: string) => { describe("skill regular-file cleanup ownership", () => { it("retains a failed setup skill read until setup host disposal", async () => { const home = await createTestTempDirectory("rea-setup-skill-close-"); - const [destination] = skillDestinations(home, []); + const [destination] = skillDestinations(home, [], {}, "linux"); if (destination === undefined) throw new Error("Shared skill root missing"); const manifest = join(destination.path, "SKILL.md"); await mkdir(dirname(manifest), { recursive: true });