* fix(hopper): keep automated Linux setup off non-x86_64 hosts Every pinned Linux Hopper package (deb, rpm, Arch) is x86_64-only, but hopperSetupSupported was derived from os-release alone. Setup therefore planned and confirmed an amd64 package install on arm64 Ubuntu 24.04 and the other tested distributions. Classify installer support with the host architecture where the distribution is parsed, so setup planning, preflight, installation and package verification share one decision. Setup now takes its Linux distribution from the doctor host, as it already does for the macOS version, and the supported-host remediation text has a single owner. Fixes #3419 * fix(hopper): install Linux Hopper from the setup host's distribution facts systemLinuxHopperInstallHost read os-release with process.arch on its own, while setup planning used the doctor host's injected architecture. Both agree in production, but a doctor host composed with another architecture could plan one answer and install with another. The install host now takes its distribution reader from the caller, and systemSetupHost passes the doctor host's, so planning, preflight and installation share one host-fact owner. The unused default host on installLinuxHopper, the other ambient reader, is removed. * fix(verify-package): require Hopper readiness only where Linux setup installs it packageSetupSupport required doctor Hopper readiness on every Linux host whose host check passed, including installer-untested distributions and, after the architecture gate, arm64 hosts where setup never provides Hopper. On Linux, readiness now also requires automated installer applicability; macOS is unchanged. This narrows the readiness assertion from #2973 on hosts outside the installer matrix, such as Kali and ubuntu-24.04-arm. --------- Co-authored-by: DavidFB-creator <220631175+DavidFB-creator@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
|
|
import { createDoctorHostFixture } from "./application/Doctor.fixture.js";
|
|
import * as macHopperInstaller from "./application/MacHopper.js";
|
|
import { runSetup } from "./application/Setup.js";
|
|
import { discoverSetupState } from "./application/SetupPlan.js";
|
|
import { options } from "./application/Setup.fixture.js";
|
|
import { systemSetupHost } from "./application/SetupHost.js";
|
|
import { systemUninstallHost } from "./application/Uninstall.js";
|
|
import { runUpdateCommand } from "./application/UpdateRuntime.js";
|
|
import { createSystemDoctorHost } from "./doctorRuntime.js";
|
|
import { PRODUCT_IDENTITY } from "./identity.js";
|
|
import {
|
|
invokingPackageRunner,
|
|
packageRunnerRegistrationCommand,
|
|
withoutPackageRunnerIdentity,
|
|
} from "./application/ClientRegistrationIdentity.js";
|
|
import { snapshotEnvironment } from "./process/snapshotEnvironment.js";
|
|
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
|
|
it("plans setup and uninstall against selected home and client overrides", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "rea-selected-setup-"));
|
|
const environment = {
|
|
HOME: join(root, "selected-home"),
|
|
USERPROFILE: join(root, "selected-home"),
|
|
CODEX_HOME: join(root, "selected-codex"),
|
|
npm_command: "exec",
|
|
};
|
|
vi.stubEnv("HOME", join(root, "ambient-home"));
|
|
vi.stubEnv("CODEX_HOME", join(root, "ambient-codex"));
|
|
vi.stubEnv("npm_command", "other");
|
|
try {
|
|
const doctor = createDoctorHostFixture({ homeDirectory: environment.HOME });
|
|
const host = systemSetupHost(doctor, environment);
|
|
const result = await runSetup(
|
|
{
|
|
...options(false),
|
|
dryRun: true,
|
|
clientIds: ["codex"],
|
|
installSkill: true,
|
|
},
|
|
host,
|
|
);
|
|
expect(result.status).toBe("planned");
|
|
expect(result.plannedActions).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: "configure_client:codex",
|
|
target: join(environment.CODEX_HOME, "config.toml"),
|
|
}),
|
|
expect.objectContaining({
|
|
id: "install_skill",
|
|
target: join(
|
|
environment.HOME,
|
|
".agents/skills",
|
|
PRODUCT_IDENTITY.skillName,
|
|
),
|
|
}),
|
|
]),
|
|
);
|
|
expect(host.registrationCommand).toContain(
|
|
PRODUCT_IDENTITY.registrationPackageSpecifier,
|
|
);
|
|
const clients = await systemUninstallHost(
|
|
environment.HOME,
|
|
undefined,
|
|
environment,
|
|
).clients();
|
|
expect(clients.find(({ name }) => name === "codex")?.configPath).toBe(
|
|
join(environment.CODEX_HOME, "config.toml"),
|
|
);
|
|
expect(JSON.stringify(result)).not.toContain("ambient-");
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("composes provider diagnostics from selected configuration rather than ambient configuration", async () => {
|
|
vi.stubEnv("GHIDRA_INSTALL_DIR", "/ambient/ghidra");
|
|
vi.stubEnv("REA_IDA_MCP_CONFIG", "/ambient/ida.json");
|
|
const host = createSystemDoctorHost({
|
|
GHIDRA_INSTALL_DIR: "/selected/ghidra",
|
|
REA_IDA_MCP_CONFIG: "/selected/ida.json",
|
|
});
|
|
const inspections = await host.providerInspections?.();
|
|
expect(inspections?.find(({ id }) => id === "ghidra")?.configured).toBe(true);
|
|
expect(JSON.stringify(inspections)).toContain("/selected/ghidra");
|
|
expect(JSON.stringify(inspections)).toContain("/selected/ida.json");
|
|
expect(JSON.stringify(inspections)).not.toContain("/ambient/");
|
|
});
|
|
|
|
it("includes the macOS Hopper installer diagnostic in setup remediation", async () => {
|
|
const install = vi
|
|
.spyOn(macHopperInstaller, "installMacHopper")
|
|
.mockResolvedValue({
|
|
status: "failed",
|
|
reason: "mount",
|
|
diagnostic: "hdiutil attach exited 1:\n\u001b[31mresource busy\u001b[0m",
|
|
});
|
|
const host = systemSetupHost(
|
|
createDoctorHostFixture({ platform: "darwin" }),
|
|
{ HOME: "/fixture/home", USERPROFILE: "/fixture/home" },
|
|
);
|
|
try {
|
|
const result = await host.installHopper(false);
|
|
expect(result).toMatchObject({
|
|
status: "failed",
|
|
code: "mount_failed",
|
|
remediation: expect.stringContaining(
|
|
"Installer detail: hdiutil attach exited 1: resource busy",
|
|
),
|
|
});
|
|
if (result.status !== "failed") throw new Error("expected setup failure");
|
|
expect(result.remediation).not.toContain("\u001b");
|
|
} finally {
|
|
await host.close?.();
|
|
install.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("preserves a validated selected JEB endpoint in setup registrations without claiming live readiness", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "rea-jeb-setup-"));
|
|
const endpoint = "http://127.0.0.1:8425/mcp/";
|
|
const ambientEndpoint = "http://127.0.0.1:8426/mcp";
|
|
vi.stubEnv("REA_JEB_MCP_URL", ambientEndpoint);
|
|
try {
|
|
const environment = {
|
|
HOME: root,
|
|
USERPROFILE: root,
|
|
CODEX_HOME: join(root, "codex"),
|
|
REA_JEB_MCP_URL: endpoint,
|
|
};
|
|
const doctorHost = createSystemDoctorHost(environment);
|
|
const setupHost = systemSetupHost(doctorHost, environment);
|
|
const state = await setupHost.initialSetupState();
|
|
|
|
expect(state.providerEnvironment).toHaveProperty(
|
|
"REA_JEB_MCP_URL",
|
|
endpoint,
|
|
);
|
|
const planned = await runSetup(
|
|
{
|
|
...options(false),
|
|
dryRun: true,
|
|
clientIds: ["codex"],
|
|
installSkill: false,
|
|
},
|
|
setupHost,
|
|
);
|
|
expect(planned.status).toBe("planned");
|
|
expect(JSON.stringify(planned.plannedActions)).toContain(endpoint);
|
|
expect(JSON.stringify(planned.plannedActions)).not.toContain(
|
|
ambientEndpoint,
|
|
);
|
|
|
|
expect(
|
|
createSystemDoctorHost({}).providerRegistrationEnvironment?.(),
|
|
).toEqual({});
|
|
expect(
|
|
createSystemDoctorHost({
|
|
REA_JEB_MCP_URL: "http://127.0.0.1:8425/mcp",
|
|
}).providerRegistrationEnvironment?.(),
|
|
).toEqual({ REA_JEB_MCP_URL: "http://127.0.0.1:8425/mcp" });
|
|
expect(
|
|
createSystemDoctorHost({
|
|
REA_JEB_MCP_URL: "https://example.com/mcp",
|
|
}).providerRegistrationEnvironment?.(),
|
|
).toEqual({});
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "registration-only settings",
|
|
environment: {
|
|
REA_ANALYSIS_PROVIDER: "auto",
|
|
REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700000",
|
|
REA_HOPPER_STARTUP_TIMEOUT_MS: "120000",
|
|
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
|
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "256",
|
|
REA_REGEX_MATCHING_DEADLINE_MS: "10000",
|
|
REA_REGEX_WORKER_HEAP_REQUEST_MIB: "256",
|
|
REA_MCP_INITIALIZE_BUDGET_MS: "16000",
|
|
REA_MCP_TOOLS_LIST_BUDGET_MS: "6000",
|
|
REA_MCP_FIRST_CATALOG_BUDGET_MS: "21000",
|
|
REA_MCP_DOCTOR_DEADLINE_MS: "31000",
|
|
},
|
|
providerConfigured: false,
|
|
installHopper: true,
|
|
},
|
|
{
|
|
name: "an explicit unready provider selection",
|
|
environment: { REA_ANALYSIS_PROVIDER: "ghidra" },
|
|
providerConfigured: true,
|
|
installHopper: false,
|
|
},
|
|
])(
|
|
"derives Hopper proposal behavior from setup-host facts for $name",
|
|
async ({ environment, providerConfigured, installHopper }) => {
|
|
const doctorHost = createDoctorHostFixture({
|
|
platform: "darwin",
|
|
validTarget: () => Promise.resolve(false),
|
|
executable: () => Promise.resolve(false),
|
|
});
|
|
const setupHost = systemSetupHost(doctorHost, {
|
|
HOME: "/fixture/home",
|
|
USERPROFILE: "/fixture/home",
|
|
...environment,
|
|
});
|
|
try {
|
|
const initial = await setupHost.initialSetupState();
|
|
expect(initial.providerConfigured).toBe(providerConfigured);
|
|
if ("REA_MCP_INPUT_SCHEMA_PROFILE" in environment) {
|
|
expect(initial.providerEnvironment).toMatchObject({
|
|
REA_ANALYSIS_PROVIDER: "auto",
|
|
REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700000",
|
|
REA_HOPPER_STARTUP_TIMEOUT_MS: "120000",
|
|
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
|
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "256",
|
|
REA_REGEX_MATCHING_DEADLINE_MS: "10000",
|
|
REA_REGEX_WORKER_HEAP_REQUEST_MIB: "256",
|
|
REA_MCP_INITIALIZE_BUDGET_MS: "16000",
|
|
REA_MCP_TOOLS_LIST_BUDGET_MS: "6000",
|
|
REA_MCP_FIRST_CATALOG_BUDGET_MS: "21000",
|
|
REA_MCP_DOCTOR_DEADLINE_MS: "31000",
|
|
});
|
|
} else {
|
|
expect(initial.providerEnvironment).not.toHaveProperty(
|
|
"REA_REGEX_MATCHING_DEADLINE_MS",
|
|
);
|
|
expect(initial.providerEnvironment).not.toHaveProperty(
|
|
"REA_REGEX_WORKER_HEAP_REQUEST_MIB",
|
|
);
|
|
expect(initial.providerEnvironment).not.toHaveProperty(
|
|
"REA_MCP_INITIALIZE_BUDGET_MS",
|
|
);
|
|
}
|
|
const discovery = await discoverSetupState({
|
|
host: setupHost,
|
|
providerConfigured: initial.providerConfigured,
|
|
forceHopperInstall: false,
|
|
proposeHopper: true,
|
|
initialDoctor: initial.doctor,
|
|
});
|
|
expect(discovery.installHopper).toBe(installHopper);
|
|
} finally {
|
|
await setupHost.close?.();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("passes only selected environment to updater subprocesses and drops npm invocation identity", async () => {
|
|
vi.stubEnv("REA_UPDATE_MARKER", "ambient");
|
|
const result = await runUpdateCommand(
|
|
[
|
|
process.execPath,
|
|
"-e",
|
|
"process.stdout.write(JSON.stringify({marker:process.env.REA_UPDATE_MARKER,npm:process.env.npm_command,ambient:process.env.HOME}))",
|
|
],
|
|
{ REA_UPDATE_MARKER: "selected", npm_command: "exec" },
|
|
);
|
|
expect(result).toEqual({ ok: true, value: '{"marker":"selected"}' });
|
|
});
|
|
|
|
it.each([
|
|
{ userAgent: "npm/10.9.0 node/v24.11.0 win32 x64", runner: "npx" },
|
|
{ userAgent: "bun/1.4.2 npm/? node/v26.3.0 win32 x64", runner: "bunx" },
|
|
] as const)(
|
|
"registers the $runner launcher from a Windows CLI environment snapshot",
|
|
({ userAgent, runner }) => {
|
|
// The CLI snapshot canonicalizes Windows variable names to upper case.
|
|
const environment = snapshotEnvironment(
|
|
{ npm_command: "exec", npm_config_user_agent: userAgent },
|
|
"win32",
|
|
);
|
|
expect(invokingPackageRunner(environment, "win32")).toBe(runner);
|
|
expect(
|
|
systemSetupHost(
|
|
createDoctorHostFixture({ platform: "win32" }),
|
|
environment,
|
|
).registrationCommand,
|
|
).toEqual(packageRunnerRegistrationCommand(runner, "win32"));
|
|
expect(withoutPackageRunnerIdentity(environment, "win32")).toEqual({
|
|
NPM_CONFIG_USER_AGENT: userAgent,
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
architecture: "x64",
|
|
hopperSetupSupported: true,
|
|
code: "download_failed",
|
|
downloads: 1,
|
|
},
|
|
{
|
|
architecture: "arm64",
|
|
hopperSetupSupported: false,
|
|
code: "unsupported_host",
|
|
downloads: 0,
|
|
},
|
|
] as const)(
|
|
"installs Linux Hopper from the selected doctor host's $architecture classification",
|
|
async ({ architecture, hopperSetupSupported, code, downloads }) => {
|
|
// Installation must follow the doctor host's facts, never the ambient
|
|
// os-release and process.arch. A refused download keeps the supported case
|
|
// from writing or invoking a package manager.
|
|
const fetch = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockResolvedValue(new Response(null, { status: 403 }));
|
|
const host = systemSetupHost(
|
|
createDoctorHostFixture({
|
|
platform: "linux",
|
|
architecture,
|
|
linuxDistribution: () =>
|
|
Promise.resolve({
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported,
|
|
}),
|
|
}),
|
|
{ HOME: "/fixture/home" },
|
|
);
|
|
try {
|
|
expect(await host.installHopper(false)).toMatchObject({
|
|
status: "failed",
|
|
code,
|
|
});
|
|
expect(fetch).toHaveBeenCalledTimes(downloads);
|
|
} finally {
|
|
fetch.mockRestore();
|
|
await host.close?.();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("registers the selected Windows package runner after canonicalizing environment aliases", async () => {
|
|
const environment = snapshotEnvironment(
|
|
{
|
|
NPM_COMMAND: "run",
|
|
npm_command: "exec",
|
|
NPM_CONFIG_USER_AGENT: "npm/10.9.0 node/v24.11.0 win32 x64",
|
|
},
|
|
"win32",
|
|
);
|
|
expect(environment).toEqual({
|
|
NPM_COMMAND: "exec",
|
|
NPM_CONFIG_USER_AGENT: "npm/10.9.0 node/v24.11.0 win32 x64",
|
|
});
|
|
expect(invokingPackageRunner(environment, "win32")).toBe("npx");
|
|
|
|
const host = systemSetupHost(
|
|
createDoctorHostFixture({ platform: "win32" }),
|
|
environment,
|
|
);
|
|
try {
|
|
expect(host.registrationCommand).toEqual(
|
|
packageRunnerRegistrationCommand("npx", "win32"),
|
|
);
|
|
} finally {
|
|
await host.close?.();
|
|
}
|
|
});
|