mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 18:05:45 +00:00
* 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>
412 lines
13 KiB
TypeScript
412 lines
13 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { join, resolve } from "node:path";
|
|
|
|
import { FakeSetupHost, options } from "./Setup.fixture.js";
|
|
import { runSetup } from "./Setup.js";
|
|
import { setupRegistrationCommand } from "./SetupHost.js";
|
|
import type { MsixPackageView } from "./MsixPackageStorage.js";
|
|
import type { SetupClient } from "./SupportedClients.js";
|
|
|
|
// Host joins keep containment checks meaningful on POSIX test hosts too.
|
|
const PROFILE = String.raw`C:\Users\a`;
|
|
const ROAMING = join(PROFILE, "AppData", "Roaming");
|
|
const LOCAL = join(PROFILE, "AppData", "Local");
|
|
const PRIVATE_CONFIG = join(
|
|
LOCAL,
|
|
"Packages",
|
|
"Claude_pzs8sxrjxfjjc",
|
|
"LocalCache",
|
|
"Roaming",
|
|
"Claude",
|
|
"claude_desktop_config.json",
|
|
);
|
|
const APPDATA_CONFIG = join(ROAMING, "Claude", "claude_desktop_config.json");
|
|
|
|
/** Claude Desktop as setup resolves it from its MSIX package storage. */
|
|
const msixClaudeDesktop = (
|
|
processView: MsixPackageView["processView"],
|
|
): SetupClient => ({
|
|
name: "claude_desktop",
|
|
displayName: "Claude Desktop",
|
|
configPath: PRIVATE_CONFIG,
|
|
quitBeforeSetup: true,
|
|
msixPackage: {
|
|
packageFamilyName: "Claude_pzs8sxrjxfjjc",
|
|
privateConfigPath: PRIVATE_CONFIG,
|
|
appDataConfigPath: APPDATA_CONFIG,
|
|
virtualizedRoots: [ROAMING, LOCAL],
|
|
processView,
|
|
},
|
|
});
|
|
|
|
describe("setup workflow", () => {
|
|
it("omits an aligned managed skill from an otherwise empty plan", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.skill = "unchanged";
|
|
const result = await runSetup(options(false), host);
|
|
expect(result.status).toBe("ready");
|
|
expect(result.plannedActions).toEqual([]);
|
|
expect(result.appliedActions).toEqual([]);
|
|
expect(host.skillInstalls).toBe(0);
|
|
});
|
|
|
|
it("installs missing Linux demo dependencies for existing Hopper", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported: true,
|
|
};
|
|
host.hopper = "/opt/hopper/bin/Hopper";
|
|
host.linuxDemoRuntimeMissing = true;
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() => Promise.resolve(true),
|
|
);
|
|
expect(result.status).toBe("ready");
|
|
expect(host.hopperInstalls).toBe(1);
|
|
expect(result.plannedActions.map(({ kind }) => kind)).toContain(
|
|
"install_hopper",
|
|
);
|
|
});
|
|
|
|
it("does not reinstall Hopper for an unsupported configured path", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported: true,
|
|
};
|
|
host.hopper = "/custom/Hopper";
|
|
host.unsupportedHopperVersion = true;
|
|
const result = await runSetup(
|
|
{
|
|
...options(true),
|
|
clientIds: [],
|
|
installSkill: false,
|
|
proposeHopper: false,
|
|
readinessScope: { providers: ["hopper"] },
|
|
},
|
|
host,
|
|
);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(result.plannedActions.map(({ kind }) => kind)).not.toContain(
|
|
"install_hopper",
|
|
);
|
|
});
|
|
|
|
it("reports a failed Hopper installation without configuring clients", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopperInstallSucceeds = false;
|
|
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
|
const result = await runSetup(
|
|
{ ...options(true, true), clientIds: [] },
|
|
host,
|
|
);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.code).toBe("download_failed");
|
|
expect(result.remediation).toBe("Download failed.");
|
|
expect(host.configurations).toBe(0);
|
|
});
|
|
|
|
it("blocks malformed client configuration before Hopper or file mutation", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.clients = [
|
|
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
|
|
];
|
|
host.clientInspections.set("codex", {
|
|
status: "invalid",
|
|
remediation: "Repair the existing TOML.",
|
|
});
|
|
|
|
const result = await runSetup(
|
|
{ ...options(true, true), clientIds: ["codex"] },
|
|
host,
|
|
);
|
|
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.remediation).toBe("Codex: Repair the existing TOML.");
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.configurations).toBe(0);
|
|
expect(host.skillInstalls).toBe(0);
|
|
});
|
|
|
|
it("blocks malformed configuration before unrelated writes with an existing provider", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.skill = "unchanged";
|
|
host.clients = [
|
|
{
|
|
name: "claude_desktop",
|
|
displayName: "Claude Desktop",
|
|
configPath: "/claude.json",
|
|
},
|
|
{ name: "cursor", displayName: "Cursor", configPath: "/cursor.json" },
|
|
];
|
|
host.clientInspections.set("claude_desktop", {
|
|
status: "invalid",
|
|
remediation: "Repair the existing JSON.",
|
|
});
|
|
|
|
const result = await runSetup(
|
|
{ ...options(true), clientIds: ["claude_desktop"] },
|
|
host,
|
|
);
|
|
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.remediation).toBe(
|
|
"Claude Desktop: Repair the existing JSON.",
|
|
);
|
|
expect(host.configurations).toBe(0);
|
|
expect(host.skillInstalls).toBe(0);
|
|
});
|
|
|
|
it("plans a client update when Hopper installation changes its desired environment", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.clients = [
|
|
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
|
|
];
|
|
host.clientInspections.set("codex", { status: "already_current" });
|
|
|
|
const result = await runSetup(
|
|
{ ...options(false, true), clientIds: ["codex"] },
|
|
host,
|
|
);
|
|
|
|
expect(
|
|
result.plannedActions.map(({ id, operation }) => ({
|
|
id,
|
|
operation,
|
|
})),
|
|
).toContainEqual({
|
|
id: "configure_client:codex",
|
|
operation: "update",
|
|
});
|
|
expect(host.configurations).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("setup workflow action selection", () => {
|
|
it("executes only actions selected by the interactive adapter", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.skill = "unchanged";
|
|
host.clients = [
|
|
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
|
|
{ name: "cursor", displayName: "Cursor", configPath: "/cursor.json" },
|
|
];
|
|
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() =>
|
|
Promise.resolve({
|
|
approved: true,
|
|
selectedActionIds: ["configure_client:codex"],
|
|
}),
|
|
);
|
|
|
|
expect(result.plannedActions.map(({ id }) => id)).toEqual([
|
|
"configure_client:codex",
|
|
]);
|
|
expect(result.clients).toEqual({ codex: { status: "configured" } });
|
|
expect(host.configurations).toBe(1);
|
|
expect(host.skillInstalls).toBe(0);
|
|
});
|
|
|
|
it("preflights only selected clients and offers supported clients without markers", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.skill = "unchanged";
|
|
host.clients = [];
|
|
host.availableClients = [
|
|
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
|
|
{ name: "cursor", displayName: "Cursor", configPath: "/cursor.json" },
|
|
];
|
|
host.clientInspections.set("cursor", {
|
|
status: "invalid",
|
|
remediation: "Repair the deselected Cursor configuration.",
|
|
});
|
|
|
|
const result = await runSetup(
|
|
{ ...options(true), clientIds: ["codex"] },
|
|
host,
|
|
);
|
|
|
|
expect(result.status).toBe("ready");
|
|
expect(result.plannedActions.map(({ id }) => id)).toEqual([
|
|
"configure_client:codex",
|
|
]);
|
|
expect(host.configurations).toBe(1);
|
|
expect(result.clientStates).toMatchObject([
|
|
{ client: { name: "codex" }, detected: false, status: "missing" },
|
|
{ client: { name: "cursor" }, detected: false, status: "missing" },
|
|
]);
|
|
});
|
|
|
|
it("does not default to a foreign command registered under the rea key", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.skill = "unchanged";
|
|
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
|
|
host.productRegistrations = [
|
|
{
|
|
client: "codex",
|
|
config_path: "/codex.toml",
|
|
command: ["unrelated-agent", "serve"],
|
|
state: "stale",
|
|
remediation: "Refresh this registration.",
|
|
},
|
|
];
|
|
|
|
const result = await runSetup(options(true), host);
|
|
|
|
expect(result.status).toBe("ready");
|
|
expect(result.plannedActions).toEqual([]);
|
|
expect(host.configurations).toBe(0);
|
|
expect(result.clientStates).toMatchObject([
|
|
{ client: { name: "codex" }, configured: false },
|
|
]);
|
|
});
|
|
|
|
it("uses a Windows-safe executable command for an explicit client", async () => {
|
|
const host = new FakeSetupHost("win32");
|
|
host.scopedDoctorHealthy = true;
|
|
host.skill = "unchanged";
|
|
host.clients = [{ name: "codex", configPath: "C:\\config\\codex.toml" }];
|
|
|
|
const result = await runSetup(
|
|
{ ...options(true), clientIds: ["codex"], installSkill: false },
|
|
host,
|
|
);
|
|
|
|
expect(result.status).toBe("ready");
|
|
expect(host.configuredCommands).toEqual([
|
|
setupRegistrationCommand("win32", undefined),
|
|
]);
|
|
expect(setupRegistrationCommand("win32", undefined)).toEqual([
|
|
process.execPath,
|
|
resolve(process.argv[1] ?? "rea"),
|
|
"mcp",
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"path",
|
|
"Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.",
|
|
],
|
|
[
|
|
"backup",
|
|
"Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.",
|
|
],
|
|
[
|
|
"write",
|
|
"Agent configuration could not be updated. Check file permissions, then rerun setup.",
|
|
],
|
|
[
|
|
"readback",
|
|
"Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.",
|
|
],
|
|
] as const)("explains %s configuration recovery", async (reason, message) => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [{ name: "internal_client_key", configPath: "/agent.json" }];
|
|
host.clientResults.set("internal_client_key", { status: "failed", reason });
|
|
const result = await runSetup(
|
|
{ ...options(true), clientIds: ["internal_client_key"] },
|
|
host,
|
|
);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.remediation).toBe(message);
|
|
expect(result.remediation).not.toContain("internal_client_key");
|
|
});
|
|
});
|
|
|
|
describe("MSIX Claude Desktop setup", () => {
|
|
it("discloses which file MSIX Claude Desktop reads and that it must be quit", async () => {
|
|
const host = new FakeSetupHost("win32");
|
|
host.hopper = join(PROFILE, "Hopper");
|
|
host.skill = "unchanged";
|
|
host.clients = [msixClaudeDesktop("direct")];
|
|
|
|
const result = await runSetup(
|
|
{ ...options(false), clientIds: ["claude_desktop"], dryRun: true },
|
|
host,
|
|
);
|
|
|
|
expect(result.status).toBe("planned");
|
|
const [action] = result.plannedActions;
|
|
expect(action?.target).toBe(PRIVATE_CONFIG);
|
|
expect(action?.detail).toContain(
|
|
`Claude Desktop is installed as MSIX package Claude_pzs8sxrjxfjjc, which reads this package-private file instead of ${APPDATA_CONFIG}.`,
|
|
);
|
|
expect(action?.detail).toContain(
|
|
"Quit Claude Desktop fully before approving; closing its window is not enough.",
|
|
);
|
|
});
|
|
|
|
it("refuses AppData writes from a process the MSIX package started", async () => {
|
|
const host = new FakeSetupHost("win32");
|
|
host.hopper = join(PROFILE, "Hopper");
|
|
host.skill = "unchanged";
|
|
const vscode = join(ROAMING, "Code", "User", "mcp.json");
|
|
host.clients = [
|
|
msixClaudeDesktop("redirected"),
|
|
{ name: "vscode", displayName: "VS Code", configPath: vscode },
|
|
{
|
|
name: "codex",
|
|
displayName: "Codex",
|
|
configPath: join(PROFILE, ".codex", "config.toml"),
|
|
},
|
|
];
|
|
|
|
const blocked = await runSetup(
|
|
{ ...options(true), clientIds: ["vscode", "codex"] },
|
|
host,
|
|
);
|
|
expect(blocked.status).toBe("needs_human");
|
|
expect(blocked.remediation).toContain(
|
|
`Changes to ${vscode} would not reach the files other apps read.`,
|
|
);
|
|
expect(host.configurations).toBe(0);
|
|
|
|
const homeOnly = await runSetup(
|
|
{ ...options(true), clientIds: ["codex"] },
|
|
host,
|
|
);
|
|
expect(homeOnly.status).toBe("ready");
|
|
expect(host.configurations).toBe(1);
|
|
});
|
|
|
|
it("refuses registrations whose launcher only one MSIX package can see", async () => {
|
|
const host = new FakeSetupHost("win32");
|
|
host.hopper = join(PROFILE, "Hopper");
|
|
host.registrationCommandBlocker = "Install REA outside the package.";
|
|
host.clients = [
|
|
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
|
|
];
|
|
|
|
const blocked = await runSetup(
|
|
{ ...options(true), clientIds: ["codex"] },
|
|
host,
|
|
);
|
|
expect(blocked.status).toBe("needs_human");
|
|
expect(blocked.remediation).toBe("Install REA outside the package.");
|
|
expect(host.configurations).toBe(0);
|
|
expect(host.skillInstalls).toBe(0);
|
|
|
|
const skillOnly = await runSetup(
|
|
{ ...options(true), clientIds: [], installSkill: true },
|
|
host,
|
|
);
|
|
expect(skillOnly.status).toBe("ready");
|
|
expect(host.skillInstalls).toBe(1);
|
|
});
|
|
});
|