Files
rea/src/setupEnvironment.test.ts
T
86655eac66 fix(hopper): keep automated Linux setup off non-x86_64 hosts (#3422)
* 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>
2026-10-23 02:39:05 +08:00

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?.();
}
});