458 lines
15 KiB
TypeScript
458 lines
15 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 { discoverSetupState } from "./SetupPlan.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("configured Linux Hopper selection", () => {
|
|
it("keeps an explicitly configured launcher even when demo checks fail", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported: true,
|
|
};
|
|
host.hopper = "/opt/hopper/bin/Hopper";
|
|
host.hopperLauncherConfigured = true;
|
|
host.linuxDemoRuntimeMissing = true;
|
|
const doctor = await host.doctor();
|
|
const discovery = await discoverSetupState({
|
|
host,
|
|
providerEnvironment: { HOPPER_LAUNCHER_PATH: host.hopper },
|
|
forceHopperInstall: false,
|
|
proposeHopper: true,
|
|
hopperLauncherConfigured: true,
|
|
initialDoctor: doctor,
|
|
});
|
|
expect(discovery.installHopper).toBe(false);
|
|
});
|
|
|
|
it("still offers repair for an unconfigured REA managed install", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported: true,
|
|
};
|
|
host.hopper = "/opt/hopper/bin/Hopper";
|
|
host.unsupportedHopperVersion = true;
|
|
const discovery = await discoverSetupState({
|
|
host,
|
|
providerEnvironment: { HOPPER_LAUNCHER_PATH: host.hopper },
|
|
forceHopperInstall: false,
|
|
proposeHopper: true,
|
|
initialDoctor: await host.doctor(),
|
|
});
|
|
expect(discovery.installHopper).toBe(true);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|