Files
rea/src/application/Doctor.test.ts
T
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

676 lines
20 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js";
import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
import { createDoctorHostFixture as host } from "./Doctor.fixture.js";
import { runDoctor } from "./Doctor.js";
describe("doctor runtime check", () => {
it("reports Node 25 as unsupported", async () => {
const result = await runDoctor(
undefined,
host({ runtime: { id: "node", version: "25.1.0" } }),
);
expect(result.healthy).toBe(false);
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "node",
ok: false,
detail: "25.1.0",
details: { runtime: "node", version: "25.1.0" },
classification: "missing_dependency",
}),
);
});
it.each([
{ compatibility: "26.3.0", ok: true },
{ compatibility: "25.1.0", ok: false },
])(
"identifies Bun and admits it by Node.js compatibility $compatibility",
async ({ compatibility, ok }) => {
const result = await runDoctor(
undefined,
host({
runtime: {
id: "bun",
version: "1.4.2",
nodeCompatibilityVersion: compatibility,
},
}),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "node",
ok,
classification: ok ? "healthy" : "missing_dependency",
details: {
runtime: "bun",
version: "1.4.2",
node_compatibility_version: compatibility,
},
}),
);
},
);
});
describe("doctor", () => {
it("returns exact recovery for every failed diagnostic", async () => {
const result = await runDoctor(
"/missing/app",
host({
runtime: { id: "node", version: "20.0.0" },
macosVersion: () => Promise.resolve("11.7"),
executable: () => Promise.resolve(false),
validTarget: () => Promise.resolve(false),
}),
);
expect(
Object.fromEntries(
result.checks.map(({ name, remediation }) => [name, remediation]),
),
).toEqual({
node: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
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.",
});
});
it.each(["11.7", undefined])(
"rejects unsupported macOS version %s",
async (version) => {
const result = await runDoctor(
undefined,
host({ macosVersion: () => Promise.resolve(version) }),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "host",
ok: false,
classification: "unsupported_host",
}),
);
},
);
it("admits only the experimental Windows x64 host boundary", async () => {
const windows = await runDoctor(
undefined,
host({
platform: "win32",
architecture: "x64",
macosVersion: () => Promise.resolve(undefined),
executable: () => Promise.resolve(false),
}),
);
const arm = await runDoctor(
undefined,
host({
platform: "win32",
architecture: "arm64",
macosVersion: () => Promise.resolve(undefined),
executable: () => Promise.resolve(false),
}),
);
expect(windows.checks.find(({ name }) => name === "host")).toMatchObject({
ok: true,
detail: "win32 x64",
});
expect(arm.checks.find(({ name }) => name === "host")).toMatchObject({
ok: false,
detail: "win32 arm64",
});
});
it("detects a manual configured Hopper before Homebrew", async () => {
let brewProbes = 0;
const result = await runDoctor(
undefined,
host({
configuredHopperPath: "/manual/Hopper",
executable: (path) => Promise.resolve(path === "/manual/Hopper"),
brewHopperPath: () => {
brewProbes += 1;
return Promise.resolve(undefined);
},
}),
);
expect(result.hopperPath).toBe("/manual/Hopper");
expect(result.healthy).toBe(true);
expect(brewProbes).toBe(0);
});
it("reports optional BYO ilspycmd diagnostics only when configured", async () => {
const result = await runDoctor(
undefined,
host({
configuredIlspyCmdPath: "/tools/ilspycmd",
ilspyCmdVersion: (path) =>
Promise.resolve(
path === "/tools/ilspycmd" ? "ilspycmd: 9.1.0.7988" : undefined,
),
}),
);
expect(result.healthy).toBe(true);
expect(result.checks).toContainEqual({
name: "ilspycmd",
ok: true,
classification: "healthy",
detail: "/tools/ilspycmd (ilspycmd: 9.1.0.7988)",
});
});
it("reports configured ilspycmd drift without installing it", async () => {
const result = await runDoctor(
undefined,
host({
configuredIlspyCmdPath: "/missing/ilspycmd",
ilspyCmdVersion: () => Promise.resolve(undefined),
}),
);
expect(result.healthy).toBe(false);
expect(result.checks).toContainEqual({
name: "ilspycmd",
ok: false,
classification: "config_drift",
detail: "/missing/ilspycmd",
remediation:
"Unset REA_ILSPY_CMD_PATH or point it at a runnable ilspycmd executable.",
});
});
it("does not fall back when the configured Hopper launcher is invalid", async () => {
const configuredPath = "/invalid/custom/hopper";
const result = await runDoctor(
undefined,
host({ configuredHopperPath: configuredPath }),
);
expect(result.hopperPath).toBeUndefined();
expect(result.healthy).toBe(false);
expect(result.checks.find(({ name }) => name === "hopper")).toEqual({
name: "hopper",
ok: false,
classification: "config_drift",
detail: configuredPath,
remediation:
"Unset or update HOPPER_LAUNCHER_PATH to an executable Hopper launcher.",
});
});
});
describe("doctor installation identity", () => {
it("distinguishes stale skills, path shadowing, and unobservable live state", async () => {
const result = await runDoctor(
undefined,
host({
installationPaths: () =>
Promise.resolve(["/usr/local/bin/rea", "/home/user/bin/rea"]),
installedSkillIdentity: () =>
Promise.resolve({
version: "10",
toolCount: null,
canonical: false,
}),
}),
);
expect(result.identity).toMatchObject({
live_server: { state: "unknown" },
installations: { state: "multiple" },
skill: {
installed_version: "10",
state: "stale",
remediation: "Run rea setup to update the installed REA skill.",
},
});
});
it("reports a missing installed skill as unhealthy", async () => {
const result = await runDoctor(
undefined,
host({ installedSkillIdentity: () => Promise.resolve(undefined) }),
);
expect(result.healthy).toBe(false);
expect(result.identity?.skill).toMatchObject({
state: "missing",
remediation: "Run rea setup to update the installed REA skill.",
});
expect(result.checks).toContainEqual({
name: "skill:identity",
ok: false,
classification: "config_drift",
detail: "Installed REA skill identity is missing.",
remediation: "Run rea setup to update the installed REA skill.",
});
});
it("reports stale client registration paths without claiming live state", async () => {
const result = await runDoctor(
undefined,
host({
clientRegistrations: () =>
Promise.resolve([
{
client: "codex",
config_path: "/home/user/.codex/config.toml",
command: ["/old/rea", "mcp"],
state: "stale",
remediation:
"Run rea setup to refresh this registration, then restart the client.",
},
]),
}),
);
expect(result.healthy).toBe(false);
expect(result.identity).toMatchObject({
live_server: { state: "unknown" },
registrations: [{ client: "codex", state: "stale" }],
});
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "registration:codex",
classification: "config_drift",
}),
);
});
});
describe("doctor scoped readiness", () => {
it("checks explicitly selected skill destinations independently of registrations", async () => {
const selected: (readonly string[] | undefined)[] = [];
const result = await runDoctor(
undefined,
host({
clientRegistrations: () => Promise.resolve([]),
installedSkillIdentity: (clients) => {
selected.push(clients);
return Promise.resolve({
version: "stale",
toolCount: 0,
canonical: false,
});
},
}),
{
clients: [],
skillClients: ["claude_code", "claude_code"],
skill: true,
},
);
expect(selected).toEqual([["claude_code"]]);
expect(result.scope).toMatchObject({
mode: "explicit",
clients: [],
skill_clients: ["claude_code"],
skill: true,
});
expect(result.healthy).toBe(false);
expect(result.scope_checks.map(({ name }) => name)).toEqual([
"node",
"skill:identity",
]);
});
it("scopes effective readiness to selected clients without hiding drift", async () => {
const registrations: readonly ClientRegistrationStatus[] = [
{
client: "codex",
config_path: "/home/user/.codex/config.toml",
command: ["/current/rea", "mcp"],
state: "aligned",
remediation: null,
},
{
client: "cursor",
config_path: "/home/user/.cursor/mcp.json",
command: ["/old/rea", "mcp"],
state: "stale",
remediation: "Run rea setup to refresh this registration.",
},
];
const scoped = await runDoctor(
undefined,
host({ clientRegistrations: () => Promise.resolve(registrations) }),
{ clients: ["codex"] },
);
expect(scoped).toMatchObject({
healthy: true,
environment_healthy: false,
scope: { mode: "explicit", clients: ["codex"] },
});
expect(scoped.scope_checks).toContainEqual(
expect.objectContaining({ name: "registration:codex", ok: true }),
);
expect(scoped.informational_checks).toContainEqual(
expect.objectContaining({ name: "registration:cursor", ok: false }),
);
const staleSelected = await runDoctor(
undefined,
host({ clientRegistrations: () => Promise.resolve(registrations) }),
{ clients: ["cursor"] },
);
expect(staleSelected.healthy).toBe(false);
});
});
describe("Grok Bot readiness", () => {
it("does not treat a Grok Bot data directory as an aligned registration", async () => {
const registrations: readonly ClientRegistrationStatus[] = [
{
client: "grok_bot",
config_path: "/home/user/.grokbot",
command: [],
state: "manual",
remediation: "Add the connector from the Grok Bot chat.",
},
];
const audit = await runDoctor(
undefined,
host({ clientRegistrations: () => Promise.resolve(registrations) }),
);
expect(audit.checks.map(({ name }) => name)).not.toContain(
"registration:grok_bot",
);
expect(audit.identity?.registrations).toEqual(registrations);
const scoped = await runDoctor(
undefined,
host({ clientRegistrations: () => Promise.resolve(registrations) }),
{ clients: ["grok_bot"] },
);
expect(scoped.healthy).toBe(false);
expect(scoped.scope_checks).toContainEqual(
expect.objectContaining({
name: "registration:grok_bot",
ok: false,
remediation: "Add the connector from the Grok Bot chat.",
}),
);
const absent = await runDoctor(
undefined,
host({ clientRegistrations: () => Promise.resolve([]) }),
{ clients: ["grok_bot"] },
);
expect(absent.scope_checks).toContainEqual(
expect.objectContaining({
name: "registration:grok_bot",
ok: false,
remediation: expect.stringContaining("hosted computer"),
}),
);
});
});
describe("doctor scoped readiness", () => {
it("keeps agent readiness independent of Hopper host requirements", async () => {
const result = await runDoctor(
undefined,
host({
platform: "linux",
linuxDistribution: () =>
Promise.resolve({
id: "nixos",
packageFamily: undefined,
hopperSetupSupported: false,
}),
clientRegistrations: () =>
Promise.resolve([
{
client: "codex",
config_path: "/home/user/.codex/config.toml",
command: ["rea", "mcp"],
state: "aligned",
remediation: null,
},
]),
}),
{ clients: ["codex"] },
);
expect(result).toMatchObject({
healthy: true,
environment_healthy: false,
scope: { mode: "explicit", clients: ["codex"], providers: [] },
});
expect(result.scope_checks.map(({ name }) => name)).toEqual([
"node",
"registration:codex",
]);
expect(result.informational_checks).toContainEqual(
expect.objectContaining({ name: "host", ok: false }),
);
});
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",
configured: true,
available: true,
providerVersion: "12.1.4",
registrationEnvironment: { GHIDRA_INSTALL_DIR: "/tools/ghidra" },
checks: [
{
name: "configuration",
ok: true,
code: null,
detail: "/tools/ghidra",
remediation: null,
classification: "config_drift" as const,
},
],
};
const noHopper = host({
executable: () => Promise.resolve(false),
providerInspections: () => Promise.resolve([ghidra]),
});
const hopper = await runDoctor(undefined, noHopper, {
providers: ["hopper"],
});
expect(hopper).toMatchObject({
healthy: false,
environment_healthy: true,
});
const selectedGhidra = await runDoctor(undefined, noHopper, {
providers: ["ghidra"],
});
expect(selectedGhidra.healthy).toBe(true);
});
});
describe("doctor Linux and Hopper discovery", () => {
it("accepts an officially supported Linux distribution", async () => {
const path = "/home/user/.local/share/rea/hopper/bin/Hopper";
const result = await runDoctor(
undefined,
host({
platform: "linux",
macosVersion: () => Promise.resolve(undefined),
linuxDistribution: () =>
Promise.resolve({
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
}),
configuredHopperPath: path,
executable: (candidate) => Promise.resolve(candidate === path),
}),
);
expect(result.healthy).toBe(true);
expect(result.hopperPath).toBe(path);
});
it("reports missing Linux demo-session dependencies", async () => {
const result = await runDoctor(
undefined,
host({
platform: "linux",
macosVersion: () => Promise.resolve(undefined),
linuxDistribution: () =>
Promise.resolve({
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
}),
linuxDemoRuntimeCheck: () =>
Promise.resolve({
name: "hopper-demo-runtime",
ok: false,
classification: "missing_dependency",
remediation: "Install Xvfb, xauth, Python 3, libX11, and libXtst.",
}),
}),
);
expect(result.healthy).toBe(false);
expect(
result.checks.find(({ name }) => name === "hopper-demo-runtime"),
).toMatchObject({
ok: false,
classification: "missing_dependency",
remediation: expect.stringContaining("xauth"),
});
});
it("explains how to recover from an unsupported configured launcher", async () => {
const path = "/custom/Hopper";
const result = await runDoctor(
undefined,
host({
platform: "linux",
configuredHopperPath: path,
linuxDistribution: () =>
Promise.resolve({
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
}),
executable: (candidate) => Promise.resolve(candidate === path),
supportedLinuxHopper: () => Promise.resolve(false),
}),
);
expect(
result.checks.find(({ name }) => name === "hopper-version")?.remediation,
).toContain("Unset or update HOPPER_LAUNCHER_PATH");
});
it("detects a Homebrew cask installed outside /Applications", async () => {
const path =
"/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper";
const result = await runDoctor(
undefined,
host({
executable: (candidate) => Promise.resolve(candidate === path),
brewHopperPath: () => Promise.resolve(path),
}),
);
expect(result.hopperPath).toBe(path);
});
it("detects a manually installed Hopper application", async () => {
const path = "/Applications/Hopper v6.app/Contents/MacOS/hopper";
const result = await runDoctor(
undefined,
host({
executable: (candidate) => Promise.resolve(candidate === path),
manualHopperPaths: () => Promise.resolve([path]),
}),
);
expect(result.hopperPath).toBe(path);
});
it("uses shared app target validation when a target is supplied", async () => {
const result = await runDoctor(
"/Applications/Notes.app",
host({
validTarget: (path) =>
Promise.resolve(path === "/Applications/Notes.app"),
}),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "target",
ok: true,
classification: "healthy",
detail: "/Applications/Notes.app",
}),
);
});
it("rejects a readable Hopper launcher without execute permission", async () => {
const path = "/manual/Hopper";
const result = await runDoctor(
undefined,
host({
configuredHopperPath: path,
validTarget: () => Promise.resolve(true),
executable: () => Promise.resolve(false),
}),
);
expect(result.hopperPath).toBeUndefined();
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "hopper",
ok: false,
classification: "config_drift",
}),
);
});
});
describe("doctor target admission diagnostics", () => {
it("preserves route-specific target diagnostics from the host", async () => {
const targetCheck = {
name: "target",
ok: false,
classification: "unsupported_target" as const,
detail: "/fixture/javascript-app",
remediation: "Use analyze_javascript_application with input_path.",
};
const result = await runDoctor(
targetCheck.detail,
host({
inspectTarget: () => Promise.resolve(targetCheck),
validTarget: () => {
throw new Error("the detailed target check should be used");
},
}),
);
expect(result.checks).toContainEqual(targetCheck);
});
});