mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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>
This commit is contained in:
1 parent
ed93abed02
commit
7dcd23c005
13 files changed
+156
-59
No files matched your search
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<DoctorCheck> => {
|
||||
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<DoctorCheck> => {
|
||||
: `${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",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.";
|
||||
};
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -72,6 +72,15 @@ interface CanonicalSkillFile {
|
||||
readonly original: SkillFileState | undefined;
|
||||
}
|
||||
|
||||
const canonicalSkillContent = (relativePath: string): Promise<string> =>
|
||||
readFile(
|
||||
new URL(
|
||||
`../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`,
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
export interface SkillInstallFileOperations {
|
||||
readonly mkdir: (path: string) => Promise<void>;
|
||||
readonly backup: (source: string, destination: string) => Promise<void>;
|
||||
@@ -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<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<boolean> => {
|
||||
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<boolean> => {
|
||||
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 ||
|
||||
|
||||
@@ -61,7 +61,7 @@ const doctorHost = (
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
hopperSetupSupported: true,
|
||||
}),
|
||||
validTarget: () => Promise.resolve(true),
|
||||
executable: (path) =>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user