test(linux): verify setup through CLI and MCP

This commit is contained in:
morluto
2026-07-13 21:20:51 +00:00
parent 75818d119e
commit 67504d2d51
20 changed files with 632 additions and 123 deletions
+21
View File
@@ -16,6 +16,27 @@ permissions:
contents: read
jobs:
clean-setup:
name: Clean setup, CLI, and MCP
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Pack CLI for setup verification
shell: bash
run: |
tarball="$(npm pack --silent)"
prefix="${RUNNER_TEMP}/rea-prefix"
npm install --global --prefix "${prefix}" "./${tarball}"
echo "REA_VERIFY_SERVER_COMMAND=${prefix}/bin/rea" >> "${GITHUB_ENV}"
- name: Verify clean Linux setup through CLI and MCP
run: sudo --preserve-env=REA_VERIFY_SERVER_COMMAND,PATH npm run verify:setup:linux
verify:
name: Installed package against real Linux Hopper
runs-on: [self-hosted, Linux, hopper]
+1
View File
@@ -70,6 +70,7 @@
"typecheck": "tsc --noEmit",
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
"verify:hopper:linux": "npm run build && node scripts/verify-real-hopper-linux.mjs",
"verify:setup:linux": "npm run build && node scripts/verify-linux-setup-e2e.mjs",
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
"verify:package": "npm run build && node scripts/verify-package.mjs",
+64 -31
View File
@@ -21,6 +21,22 @@ SUPPORTED_HOPPER_SHA256 = {
EXPECTED_SCREEN = (1280, 1024)
EXPECTED_DIALOG = (189, 370, 901, 284)
DEMO_CLICK = (305, 632)
PRIVATE_DISPLAY_UNAVAILABLE = 70
X11_AUTHORIZATION_FAILED = 71
UNSUPPORTED_HOPPER_BUILD = 72
INVALID_LAUNCH_COMMAND = 73
PROCESS_OWNERSHIP_MISMATCH = 74
HOPPER_EXITED_DURING_STARTUP = 75
UNSUPPORTED_DEMO_DIALOG = 76
UNEXPECTED_DISPLAY_GEOMETRY = 77
X11_INPUT_FAILED = 78
RUNTIME_DEPENDENCY_UNAVAILABLE = 79
class AdapterFailure(Exception):
def __init__(self, code: int, message: str) -> None:
super().__init__(message)
self.code = code
class XWindowAttributes(ctypes.Structure):
@@ -181,20 +197,20 @@ def start_xvfb(session: Path) -> tuple[subprocess.Popen[bytes], str, Path]:
if Path(f"/tmp/.X11-unix/X{display_number}").exists():
continue
cookie = secrets.token_hex(16)
subprocess.run(
[
"/usr/bin/xauth",
"-f",
str(authority),
"add",
display_name,
".",
cookie,
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
subprocess.run(
[
"/usr/bin/xauth", "-f", str(authority), "add",
display_name, ".", cookie,
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except (OSError, subprocess.CalledProcessError) as error:
raise AdapterFailure(
X11_AUTHORIZATION_FAILED, "private X authorization failed"
) from error
authority.chmod(0o600)
process = subprocess.Popen(
[
@@ -220,7 +236,7 @@ def start_xvfb(session: Path) -> tuple[subprocess.Popen[bytes], str, Path]:
if process.poll() is None:
process.terminate()
process.wait()
raise SystemExit("private X display unavailable")
raise AdapterFailure(PRIVATE_DISPLAY_UNAVAILABLE, "private X display unavailable")
def main() -> int:
@@ -228,10 +244,10 @@ def main() -> int:
hopper = Path(args.hopper)
socket = Path(args.socket)
if digest(hopper) not in SUPPORTED_HOPPER_SHA256:
raise SystemExit("unsupported Hopper binary")
raise AdapterFailure(UNSUPPORTED_HOPPER_BUILD, "unsupported Hopper binary")
command = args.command[1:] if args.command[:1] == ["--"] else args.command
if not command or Path(command[0]).resolve() != hopper.resolve():
raise SystemExit("invalid Hopper launch command")
raise AdapterFailure(INVALID_LAUNCH_COMMAND, "invalid Hopper launch command")
xvfb, display_name, authority = start_xvfb(socket.parent)
child: subprocess.Popen[bytes] | None = None
display: int | None = None
@@ -252,18 +268,25 @@ def main() -> int:
leader = os.getpid()
os.environ["XAUTHORITY"] = str(authority)
x11 = ctypes.CDLL("libX11.so.6")
xtst = ctypes.CDLL("libXtst.so.6")
try:
x11 = ctypes.CDLL("libX11.so.6")
xtst = ctypes.CDLL("libXtst.so.6")
except OSError as error:
raise AdapterFailure(
RUNTIME_DEPENDENCY_UNAVAILABLE, "X11 runtime unavailable"
) from error
configure_x11(x11, xtst)
display = x11.XOpenDisplay(display_name.encode())
if not display:
raise SystemExit("private X display unavailable")
raise AdapterFailure(PRIVATE_DISPLAY_UNAVAILABLE, "private X display unavailable")
root = x11.XDefaultRootWindow(display)
if (
x11.XDisplayWidth(display, 0),
x11.XDisplayHeight(display, 0),
) != EXPECTED_SCREEN:
raise SystemExit("unexpected private display geometry")
raise AdapterFailure(
UNEXPECTED_DISPLAY_GEOMETRY, "unexpected private display geometry"
)
deadline = time.monotonic() + 10
observed: list[tuple[int, int, int, int, int]] = []
@@ -276,17 +299,29 @@ def main() -> int:
if child.poll() is None:
time.sleep(0.1)
continue
raise SystemExit("owned Hopper process exited")
raise AdapterFailure(
HOPPER_EXITED_DURING_STARTUP, "owned Hopper process exited"
)
observed = windows(x11, display, root)
if any(window[1:] == EXPECTED_DIALOG for window in observed):
break
time.sleep(0.1)
else:
raise SystemExit(f"expected Hopper demo dialog not found: {observed}")
if not hopper_is_owned(leader, hopper):
raise AdapterFailure(
PROCESS_OWNERSHIP_MISMATCH, "Hopper process ownership mismatch"
)
raise AdapterFailure(
UNSUPPORTED_DEMO_DIALOG, "expected Hopper demo dialog not found"
)
xtst.XTestFakeMotionEvent(display, -1, DEMO_CLICK[0], DEMO_CLICK[1], 0)
xtst.XTestFakeButtonEvent(display, 1, True, 0)
xtst.XTestFakeButtonEvent(display, 1, False, 0)
input_results = (
xtst.XTestFakeMotionEvent(display, -1, DEMO_CLICK[0], DEMO_CLICK[1], 0),
xtst.XTestFakeButtonEvent(display, 1, True, 0),
xtst.XTestFakeButtonEvent(display, 1, False, 0),
)
if not all(input_results):
raise AdapterFailure(X11_INPUT_FAILED, "private X input failed")
x11.XFlush(display)
x11.XCloseDisplay(display)
display = None
@@ -309,8 +344,6 @@ def main() -> int:
if __name__ == "__main__":
try:
raise SystemExit(main())
except SystemExit as error:
if isinstance(error.code, str):
print(error.code, file=os.sys.stderr)
raise SystemExit(78) from None
raise
except AdapterFailure as error:
print(str(error), file=os.sys.stderr)
raise SystemExit(error.code) from None
+122
View File
@@ -0,0 +1,122 @@
import { execFile } from "node:child_process";
import {
access,
appendFile,
chmod,
copyFile,
mkdtemp,
rm,
} from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const command = process.env.REA_VERIFY_SERVER_COMMAND;
if (process.platform !== "linux" || process.getuid?.() !== 0)
throw new Error("Linux setup verification requires a root Linux runner");
if (!command) throw new Error("REA_VERIFY_SERVER_COMMAND is required");
try {
await access("/opt/hopper/bin/Hopper");
throw new Error("Linux setup verification requires a clean runner");
} catch (error) {
if (error instanceof Error && error.message.includes("clean runner"))
throw error;
}
const runJson = async (args) => {
const { stdout } = await execFileAsync(command, args, {
env: process.env,
maxBuffer: 10 * 1024 * 1024,
});
return JSON.parse(stdout);
};
const plan = await runJson(["setup", "--json"]);
if (plan.status !== "needs_confirmation")
throw new Error("setup did not produce a read-only confirmation plan");
await access("/opt/hopper/bin/Hopper").then(
() => {
throw new Error("setup plan mutated the clean runner");
},
() => undefined,
);
const installed = await runJson([
"setup",
"--yes",
"--install-hopper",
"--json",
]);
if (
installed.status !== "ready" ||
!installed.appliedActions.includes("installed_hopper")
)
throw new Error(
`approved setup did not become ready: ${JSON.stringify(installed)}`,
);
const doctor = await runJson(["doctor", "--json"]);
if (doctor.healthy !== true)
throw new Error(`doctor is unhealthy: ${JSON.stringify(doctor)}`);
await execFileAsync(command, ["analyze", "/usr/bin/true"], {
env: process.env,
timeout: 180_000,
maxBuffer: 10 * 1024 * 1024,
});
process.env.HOPPER_TARGET_PATH = "/usr/bin/true";
process.env.HOPPER_SECOND_TARGET_PATH = "/usr/bin/false";
process.env.REA_VERIFY_SERVER_ARGS_JSON = '["mcp"]';
await import("./verify-real-hopper-linux.mjs");
const modifiedDirectory = await mkdtemp(join(tmpdir(), "rea-modified-hopper-"));
try {
const modifiedHopper = join(modifiedDirectory, "Hopper");
await copyFile("/opt/hopper/bin/Hopper", modifiedHopper);
await appendFile(modifiedHopper, new Uint8Array([0]));
await chmod(modifiedHopper, 0o700);
try {
await execFileAsync(command, ["analyze", "/usr/bin/true"], {
env: { ...process.env, HOPPER_LAUNCHER_PATH: modifiedHopper },
timeout: 30_000,
});
throw new Error("modified Hopper build was not rejected");
} catch (error) {
const output = `${error?.stdout ?? ""}\n${error?.stderr ?? ""}`;
if (!output.includes("unsupported_hopper_build"))
throw new Error(
"modified Hopper build did not return its stable failure code",
);
}
} finally {
await rm(modifiedDirectory, { recursive: true, force: true });
}
const repeated = await runJson([
"setup",
"--yes",
"--install-hopper",
"--json",
]);
if (
repeated.status !== "ready" ||
repeated.appliedActions.includes("installed_hopper")
)
throw new Error("a second setup was not idempotent");
const { stdout: processes } = await execFileAsync("ps", [
"-ax",
"-o",
"command=",
]);
if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(processes))
throw new Error("Linux setup verification leaked a Hopper demo session");
console.log(
JSON.stringify({
status: "verified",
setup: true,
cli: true,
mcp: true,
idempotent: true,
}),
);
+9 -3
View File
@@ -121,8 +121,9 @@ export const runDoctor = async (
await host.supportedLinuxHopper(hopperPath),
hopperPath,
{
remediation:
"Install the Hopper build supported by this REA release, or update REA for a newer Hopper build.",
remediation: unsupportedHopperRemediation(
host.configuredHopperPath === hopperPath,
),
classification: "config_drift",
},
),
@@ -135,7 +136,7 @@ export const runDoctor = async (
undefined,
{
remediation:
"Rerun rea setup to install the Xvfb, Python, X11, and XTEST packages required for Linux demo sessions.",
"Rerun rea setup to install the Xvfb, xauth, Python, X11, and XTEST packages required for Linux demo sessions.",
classification: "missing_dependency",
},
),
@@ -154,6 +155,11 @@ export const runDoctor = async (
};
};
const unsupportedHopperRemediation = (configured: boolean): string =>
configured
? "Unset or update HOPPER_LAUNCHER_PATH, install the Hopper build supported by this REA release, or update REA."
: "Install the Hopper build supported by this REA release, or update REA for a newer Hopper build.";
/** Create diagnostics backed by the current process and host commands. */
export const systemDoctorHost = (): DoctorHost => ({
platform: process.platform,
+23 -9
View File
@@ -62,7 +62,7 @@ export interface LinuxHopperInstallHost {
createTemporaryDirectory(): Promise<string>;
writeArchive(path: string, bytes: Uint8Array): Promise<void>;
installPackage(family: LinuxPackageFamily, archive: string): Promise<boolean>;
launcherReady(path: string): Promise<boolean>;
launcherStatus(path: string): Promise<LinuxHopperLauncherStatus>;
cleanup(path: string): Promise<void>;
}
@@ -78,9 +78,17 @@ export type LinuxHopperInstallResult =
| "integrity"
| "authorization_or_package_manager"
| "launcher_missing"
| "runtime_dependencies"
| "unsupported_hopper_build"
| "cancelled";
};
export type LinuxHopperLauncherStatus =
| "ready"
| "missing"
| "runtime_dependencies"
| "unsupported_hopper_build";
/** Parse an os-release document without executing its shell syntax. */
export const parseLinuxDistribution = (text: string): LinuxDistribution => {
const fields = new Map<string, string>();
@@ -155,8 +163,13 @@ export const installLinuxHopper = async (
if (!(await host.installPackage(distribution.packageFamily, archive)))
return { status: "failed", reason: "authorization_or_package_manager" };
const launcherPath = "/opt/hopper/bin/Hopper";
if (!(await host.launcherReady(launcherPath)))
return { status: "failed", reason: "launcher_missing" };
const launcherStatus = await host.launcherStatus(launcherPath);
if (launcherStatus !== "ready")
return {
status: "failed",
reason:
launcherStatus === "missing" ? "launcher_missing" : launcherStatus,
};
return { status: "installed", launcherPath };
} catch (cause: unknown) {
if (isAbortCause(cause)) return { status: "failed", reason: "cancelled" };
@@ -185,16 +198,17 @@ const systemLinuxHopperInstallHost = (): LinuxHopperInstallHost => ({
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
installPackage: installSystemPackage,
async launcherReady(path) {
async launcherStatus(path) {
try {
await access(path);
const linked = await execFileAsync("ldd", [path]);
return (
linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`) &&
(await linuxHopperBinarySupported(path))
);
if (!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`))
return "runtime_dependencies";
return (await linuxHopperBinarySupported(path))
? "ready"
: "unsupported_hopper_build";
} catch {
return false;
return "missing";
}
},
cleanup: (path) => rm(path, { recursive: true, force: true }),
+19 -45
View File
@@ -24,6 +24,16 @@ import {
import { installMacHopper } from "./MacHopper.js";
import { configureDetectedClients } from "./SetupClients.js";
import { installCanonicalSkill } from "./SetupSkill.js";
import { setupPlan } from "./SetupPlan.js";
import {
setupInstallFailure,
type SetupFailureCode,
type SetupHopperInstallResult,
} from "./SetupInstallFailure.js";
export type {
SetupFailureCode,
SetupHopperInstallResult,
} from "./SetupInstallFailure.js";
export { installCanonicalSkill } from "./SetupSkill.js";
@@ -60,7 +70,7 @@ export interface SetupHost {
macosVersion(): Promise<string | undefined>;
linuxDistribution(): Promise<LinuxDistribution | undefined>;
hopperPath(): Promise<string | undefined>;
installHopper(): Promise<string | undefined>;
installHopper(): Promise<SetupHopperInstallResult>;
detectedClients(): Promise<readonly SetupClient[]>;
configureClient(
client: SetupClient,
@@ -82,6 +92,7 @@ export interface SetupResult {
readonly clients: Readonly<Record<string, ClientConfigurationResult>>;
readonly doctor: Awaited<ReturnType<typeof runDoctor>>;
readonly remediation?: string;
readonly code?: SetupFailureCode;
}
/** One concrete setup mutation disclosed before approval. */
@@ -104,45 +115,6 @@ export type SetupConfirmation = (
actions: readonly SetupAction[],
) => Promise<boolean>;
const setupPlan = (
platform: NodeJS.Platform,
installHopper: boolean,
clients: readonly SetupClient[],
): readonly SetupAction[] => [
...(installHopper
? [
{
kind: "install_hopper" as const,
target:
platform === "darwin"
? "~/Applications/Hopper Disassembler.app"
: "system package manager",
detail:
platform === "linux"
? "Download, verify, and install Hopper plus its Xvfb demo-session dependencies. For the supported demo build, REA uses a private display and selects Hopper's offered demo mode for each analysis session."
: "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.",
external: true,
},
]
: []),
...clients
.filter(({ format }) => format !== "unsupported")
.map(
(client): SetupAction => ({
kind: "configure_client",
target: client.configPath,
detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`,
external: false,
}),
),
{
kind: "install_skill",
target: "~/.agents/skills/rea-analysis/SKILL.md",
detail: "Install or update the bundled REA analysis skill.",
external: false,
},
];
/**
* Install prerequisites and configure detected clients idempotently.
* Discovery always precedes mutation. Interactive confirmation or explicit
@@ -195,17 +167,18 @@ export const runSetup = async (
};
if (installHopper && (interactiveApproval || options.installHopper)) {
hopperPath = await host.installHopper();
if (hopperPath === undefined)
const installed = await host.installHopper();
if (installed.status === "failed")
return {
status: "needs_human",
plannedActions,
appliedActions,
clients,
doctor: await host.doctor(),
remediation:
"Hopper installation failed; install Hopper manually or rerun setup after resolving the reported system error.",
code: installed.code,
remediation: installed.remediation,
};
hopperPath = installed.launcherPath;
appliedActions.push("installed_hopper");
}
const clientFailure = await configureDetectedClients({
@@ -286,7 +259,8 @@ const systemSetupHost = (): SetupHost => {
process.platform === "linux"
? await installLinuxHopper()
: await installMacHopper();
return result.status === "installed" ? result.launcherPath : undefined;
if (result.status === "installed") return result;
return setupInstallFailure(result.reason);
},
detectedClients: () => detectClients(homedir()),
configureClient: (client, hopperPath, command) =>
+95
View File
@@ -0,0 +1,95 @@
export type SetupFailureCode =
| "unsupported_host"
| "download_failed"
| "integrity_mismatch"
| "authorization_or_package_manager_failed"
| "launcher_missing"
| "runtime_dependency_unavailable"
| "unsupported_hopper_build"
| "setup_cancelled";
export type SetupHopperInstallResult =
| { readonly status: "installed"; readonly launcherPath: string }
| {
readonly status: "failed";
readonly code: SetupFailureCode;
readonly remediation: string;
};
export type HopperInstallFailureReason =
| "unsupported_host"
| "release_metadata"
| "download"
| "integrity"
| "authorization_or_package_manager"
| "launcher_missing"
| "runtime_dependencies"
| "unsupported_hopper_build"
| "cancelled"
| "destination_exists"
| "mount"
| "bundle"
| "copy";
/** Map provider installer failures to stable setup codes and safe recovery. */
export const setupInstallFailure = (
reason: HopperInstallFailureReason,
): SetupHopperInstallResult => {
switch (reason) {
case "unsupported_host":
return {
status: "failed",
code: "unsupported_host",
remediation: "Use a supported host, then rerun rea setup.",
};
case "integrity":
return {
status: "failed",
code: "integrity_mismatch",
remediation:
"The Hopper download failed integrity verification. Retry setup; if it repeats, update REA before installing.",
};
case "authorization_or_package_manager":
return {
status: "failed",
code: "authorization_or_package_manager_failed",
remediation:
"Allow the system package-manager operation or install the planned Hopper package and Linux runtime dependencies, then rerun rea setup.",
};
case "launcher_missing":
return {
status: "failed",
code: "launcher_missing",
remediation:
"The package operation completed but Hopper is not runnable. Run rea doctor and repair the reported launcher issue.",
};
case "runtime_dependencies":
return {
status: "failed",
code: "runtime_dependency_unavailable",
remediation:
"Hopper was installed but required shared libraries are missing. Rerun rea setup, then apply the hopper-demo-runtime remediation from rea doctor.",
};
case "unsupported_hopper_build":
return {
status: "failed",
code: "unsupported_hopper_build",
remediation:
"The installed Hopper launcher does not match the build supported by this REA release. Unset HOPPER_LAUNCHER_PATH, reinstall through rea setup, or update REA.",
};
case "cancelled":
return {
status: "failed",
code: "setup_cancelled",
remediation:
"Setup was cancelled before Hopper was installed. Rerun rea setup when ready.",
};
default:
return {
status: "failed",
code: "download_failed",
remediation:
"REA could not download or unpack the official Hopper package. Check network and filesystem access, then retry setup.",
};
}
};
+41
View File
@@ -0,0 +1,41 @@
import type { SetupAction, SetupClient } from "./Setup.js";
/** Build the complete setup mutation plan before approval. */
export const setupPlan = (
platform: NodeJS.Platform,
installHopper: boolean,
clients: readonly SetupClient[],
): readonly SetupAction[] => [
...(installHopper
? [
{
kind: "install_hopper" as const,
target:
platform === "darwin"
? "~/Applications/Hopper Disassembler.app"
: "system package manager",
detail:
platform === "linux"
? "Download, verify, and install Hopper plus its Xvfb demo-session dependencies. For the supported demo build, REA uses a private display and selects Hopper's offered demo mode for each analysis session."
: "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.",
external: true,
},
]
: []),
...clients
.filter(({ format }) => format !== "unsupported")
.map(
(client): SetupAction => ({
kind: "configure_client",
target: client.configPath,
detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`,
external: false,
}),
),
{
kind: "install_skill",
target: "~/.agents/skills/rea-analysis/SKILL.md",
detail: "Install or update the bundled REA analysis skill.",
external: false,
},
];
+14
View File
@@ -191,6 +191,20 @@ const analysisErrorProjectionSchema = z
"execution_failure",
]),
message: z.string(),
code: z
.enum([
"private_display_unavailable",
"x11_authorization_failed",
"unsupported_hopper_build",
"invalid_launch_command",
"process_ownership_mismatch",
"hopper_exited_during_startup",
"unsupported_demo_dialog",
"unexpected_display_geometry",
"x11_input_failed",
"runtime_dependency_unavailable",
])
.optional(),
})
.strict();
const graphNode = z.discriminatedUnion("status", [
+14 -4
View File
@@ -1,4 +1,10 @@
import type { JsonValue } from "./jsonValue.js";
import {
hopperStartupFailure,
type HopperStartupFailureCode,
} from "./hopperStartupFailure.js";
export { hopperStartupFailure } from "./hopperStartupFailure.js";
export type { HopperStartupFailureCode } from "./hopperStartupFailure.js";
/** Stable tags exposed by safe analysis-error projections. */
const ANALYSIS_ERROR_TAGS = [
@@ -282,14 +288,14 @@ export class HopperRemoteError extends HopperError {
/** The owned Hopper bridge stopped before its client was closed. */
export class HopperProcessError extends HopperError {
readonly _tag = "HopperProcessError";
readonly failureCode: HopperStartupFailureCode | undefined;
override readonly userMessage: string | undefined;
constructor(readonly exitCode: number | null) {
super(`Hopper bridge stopped unexpectedly with code ${String(exitCode)}`);
this.userMessage =
exitCode === 78
? "This Hopper demo build or dialog is not supported for unattended Linux analysis. Update REA or use a supported Hopper build."
: undefined;
const failure = hopperStartupFailure(exitCode);
this.failureCode = failure?.code;
this.userMessage = failure?.message;
}
}
@@ -342,6 +348,7 @@ export interface AnalysisErrorProjection
| "unavailable"
| "execution_failure";
readonly message: string;
readonly code?: HopperStartupFailureCode;
}
/** Project expected failures into exhaustive, secret-safe caller fields. */
@@ -349,9 +356,12 @@ export const projectAnalysisError = (
error: AnalysisError,
): AnalysisErrorProjection => {
assertKnownTag(error._tag);
const code =
error instanceof HopperProcessError ? error.failureCode : undefined;
return {
category: errorCategory(error),
message: userMessage(error),
...(code === undefined ? {} : { code }),
};
};
+73
View File
@@ -0,0 +1,73 @@
export type HopperStartupFailureCode =
| "private_display_unavailable"
| "x11_authorization_failed"
| "unsupported_hopper_build"
| "invalid_launch_command"
| "process_ownership_mismatch"
| "hopper_exited_during_startup"
| "unsupported_demo_dialog"
| "unexpected_display_geometry"
| "x11_input_failed"
| "runtime_dependency_unavailable";
const failures = [
[
70,
"private_display_unavailable",
"REA could not start its private Linux display. Install Xvfb, then rerun rea doctor.",
],
[
71,
"x11_authorization_failed",
"REA could not authorize its private Linux display. Install xauth, then rerun rea doctor.",
],
[
72,
"unsupported_hopper_build",
"This Hopper build is not supported for unattended Linux analysis. Unset HOPPER_LAUNCHER_PATH and rerun rea setup --yes --install-hopper, or update REA.",
],
[
73,
"invalid_launch_command",
"REA refused an unexpected Hopper launch command. Check HOPPER_LAUNCHER_PATH and rerun rea doctor.",
],
[
74,
"process_ownership_mismatch",
"REA could not verify ownership of the launched Hopper process and stopped safely. Close other Hopper sessions and retry.",
],
[
75,
"hopper_exited_during_startup",
"Hopper exited before the REA bridge was ready. Run rea doctor and retry the analysis.",
],
[
76,
"unsupported_demo_dialog",
"This Hopper demo dialog is not supported for unattended Linux analysis. Update REA or use a supported Hopper build.",
],
[
77,
"unexpected_display_geometry",
"REA's private Linux display did not match the validated layout. Close custom display overrides and retry.",
],
[
78,
"x11_input_failed",
"REA could not activate Hopper's validated demo control. Install the XTEST runtime, then rerun rea doctor.",
],
[
79,
"runtime_dependency_unavailable",
"A Linux demo-session dependency is unavailable. Rerun rea setup --yes --install-hopper, then rea doctor.",
],
] as const;
const HOPPER_STARTUP_FAILURES = new Map<
number,
{ readonly code: HopperStartupFailureCode; readonly message: string }
>(failures.map(([exitCode, code, message]) => [exitCode, { code, message }]));
/** Return the stable public failure associated with a Linux demo adapter exit. */
export const hopperStartupFailure = (exitCode: number | null) =>
exitCode === null ? undefined : HOPPER_STARTUP_FAILURES.get(exitCode);
+5 -1
View File
@@ -10,6 +10,7 @@ import {
HopperProcessError,
HopperProtocolError,
HopperStartError,
hopperStartupFailure,
HopperTimeoutError,
} from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -212,7 +213,10 @@ export class HopperClient {
while (Date.now() < deadline) {
if (signal?.aborted === true) return err(new HopperCancelledError());
if (this.#closing) return err(new HopperProcessError(null));
if (this.#launcherExitCode === 78)
if (
this.#launcherExitCode !== undefined &&
hopperStartupFailure(this.#launcherExitCode) !== undefined
)
return err(new HopperProcessError(this.#launcherExitCode));
try {
await access(socketPath);
+27 -1
View File
@@ -99,7 +99,33 @@ describe("doctor", () => {
expect(result.healthy).toBe(false);
expect(
result.checks.find(({ name }) => name === "hopper-demo-runtime"),
).toMatchObject({ ok: false, classification: "missing_dependency" });
).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",
supported: 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 =
+19 -5
View File
@@ -37,11 +37,25 @@ describe("analysis error projection", () => {
);
});
it("projects a safe explanation for an unsupported Linux demo dialog", () => {
expect(projectAnalysisError(new HopperProcessError(78))).toMatchObject({
message:
"This Hopper demo build or dialog is not supported for unattended Linux analysis. Update REA or use a supported Hopper build.",
});
it("projects stable, safe Linux startup failure codes", () => {
const expected = [
[70, "private_display_unavailable"],
[71, "x11_authorization_failed"],
[72, "unsupported_hopper_build"],
[73, "invalid_launch_command"],
[74, "process_ownership_mismatch"],
[75, "hopper_exited_during_startup"],
[76, "unsupported_demo_dialog"],
[77, "unexpected_display_geometry"],
[78, "x11_input_failed"],
[79, "runtime_dependency_unavailable"],
] as const;
for (const [exitCode, code] of expected) {
const projected = projectAnalysisError(new HopperProcessError(exitCode));
expect(projected.code).toBe(code);
expect(projected.message.length).toBeGreaterThan(20);
expect(JSON.stringify(projected)).not.toContain("/proc/");
}
});
it("projects every typed failure without internal details", () => {
+18 -15
View File
@@ -198,22 +198,25 @@ describe("HopperClient", () => {
expect(Date.now() - startedAt).toBeLessThan(500);
});
it("reports an owned launcher exit during bridge startup", async () => {
const client = new HopperClient({
launcher: new ExitingLauncher(78),
startupTimeoutMs: 10_000,
});
clients.push(client);
const startedAt = Date.now();
const result = await client.start();
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toMatchObject({
_tag: "HopperProcessError",
exitCode: 78,
it.each([70, 71, 72, 73, 74, 75, 76, 77, 78, 79])(
"reports Linux adapter exit %i during bridge startup",
async (exitCode) => {
const client = new HopperClient({
launcher: new ExitingLauncher(exitCode),
startupTimeoutMs: 10_000,
});
expect(Date.now() - startedAt).toBeLessThan(500);
});
clients.push(client);
const startedAt = Date.now();
const result = await client.start();
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toMatchObject({
_tag: "HopperProcessError",
exitCode,
});
expect(Date.now() - startedAt).toBeLessThan(500);
},
);
it("allows a short-lived launcher to hand off bridge startup", async () => {
const client = new HopperClient({
+11 -3
View File
@@ -9,6 +9,7 @@ import {
type LinuxDistribution,
type LinuxHopperDownload,
type LinuxHopperInstallHost,
type LinuxHopperLauncherStatus,
type LinuxPackageFamily,
} from "../src/application/LinuxHopper.js";
@@ -22,7 +23,7 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
archive = new Uint8Array([1, 2, 3]);
archiveOk = true;
installSucceeds = true;
launcherExists = true;
launcherStatusValue: LinuxHopperLauncherStatus = "ready";
downloads: string[] = [];
installed: Array<{ family: LinuxPackageFamily; archive: string }> = [];
cleaned: string[] = [];
@@ -43,7 +44,8 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
this.installed.push({ family, archive });
return Promise.resolve(this.installSucceeds);
};
launcherReady = (): Promise<boolean> => Promise.resolve(this.launcherExists);
launcherStatus = (): Promise<LinuxHopperLauncherStatus> =>
Promise.resolve(this.launcherStatusValue);
cleanup = (path: string): Promise<void> => {
this.cleaned.push(path);
return Promise.resolve();
@@ -165,6 +167,8 @@ describe("Linux Hopper installation", () => {
["checksum mismatch", "integrity"],
["package manager failure", "authorization_or_package_manager"],
["missing launcher", "launcher_missing"],
["missing runtime", "runtime_dependencies"],
["unsupported build", "unsupported_hopper_build"],
] as const)("classifies %s", async (scenario, reason) => {
const host = new RecordingLinuxHost();
if (scenario === "unsupported host")
@@ -176,7 +180,11 @@ describe("Linux Hopper installation", () => {
if (scenario === "package request failure") host.archiveOk = false;
if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]);
if (scenario === "package manager failure") host.installSucceeds = false;
if (scenario === "missing launcher") host.launcherExists = false;
if (scenario === "missing launcher") host.launcherStatusValue = "missing";
if (scenario === "missing runtime")
host.launcherStatusValue = "runtime_dependencies";
if (scenario === "unsupported build")
host.launcherStatusValue = "unsupported_hopper_build";
expect(await installFixture(host)).toEqual({
status: "failed",
reason,
+16 -6
View File
@@ -6,6 +6,7 @@ import {
type SetupClient,
type SetupHost,
type SetupOptions,
type SetupHopperInstallResult,
} from "../src/application/Setup.js";
import type { DoctorCheck } from "../src/application/Doctor.js";
import type { LinuxDistribution } from "../src/application/LinuxHopper.js";
@@ -35,11 +36,21 @@ class FakeSetupHost implements SetupHost {
linuxDistribution = (): Promise<LinuxDistribution | undefined> =>
Promise.resolve(this.distribution);
hopperPath = (): Promise<string | undefined> => Promise.resolve(this.hopper);
installHopper = (): Promise<string | undefined> => {
installHopper = (): Promise<SetupHopperInstallResult> => {
this.hopperInstalls += 1;
this.linuxDemoRuntimeMissing = false;
if (this.hopperInstallSucceeds) this.hopper = "/manual/Hopper";
return Promise.resolve(this.hopper);
if (this.hopperInstallSucceeds) {
this.hopper = "/manual/Hopper";
return Promise.resolve({
status: "installed",
launcherPath: this.hopper,
});
}
return Promise.resolve({
status: "failed",
code: "download_failed",
remediation: "Download failed.",
});
};
detectedClients = (): Promise<readonly SetupClient[]> =>
Promise.resolve(this.clients);
@@ -257,9 +268,8 @@ describe("setup workflow", () => {
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
const result = await runSetup(options(true, true), host);
expect(result.status).toBe("needs_human");
expect(result.remediation).toBe(
"Hopper installation failed; install Hopper manually or rerun setup after resolving the reported system error.",
);
expect(result.code).toBe("download_failed");
expect(result.remediation).toBe("Download failed.");
expect(host.configurations).toBe(0);
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
setupInstallFailure,
type HopperInstallFailureReason,
} from "../src/application/SetupInstallFailure.js";
describe("setup installer failure projection", () => {
it.each([
["unsupported_host", "unsupported_host"],
["download", "download_failed"],
["release_metadata", "download_failed"],
["integrity", "integrity_mismatch"],
[
"authorization_or_package_manager",
"authorization_or_package_manager_failed",
],
["launcher_missing", "launcher_missing"],
["runtime_dependencies", "runtime_dependency_unavailable"],
["unsupported_hopper_build", "unsupported_hopper_build"],
["cancelled", "setup_cancelled"],
] as const)("maps %s to %s", (reason, code) => {
const result = setupInstallFailure(reason as HopperInstallFailureReason);
expect(result).toMatchObject({ status: "failed", code });
if (result.status === "failed")
expect(result.remediation.length).toBeGreaterThan(20);
});
});
+12
View File
@@ -3,6 +3,8 @@ import { z } from "zod";
import type { ToolContract } from "../src/contracts/toolContracts.js";
import { ok } from "../src/domain/result.js";
import { err } from "../src/domain/result.js";
import { HopperProcessError } from "../src/domain/errors.js";
import { toCallToolResult } from "../src/server/toolResult.js";
const contract: ToolContract = {
@@ -21,6 +23,16 @@ const contract: ToolContract = {
};
describe("tool result projection", () => {
it("exposes an actionable adapter code to MCP callers", () => {
const result = toCallToolResult(err(new HopperProcessError(76)), contract);
expect(result.structuredContent).toMatchObject({
error: {
code: "unsupported_demo_dialog",
category: "execution_failure",
},
});
expect(JSON.stringify(result)).not.toContain("expected Hopper");
});
it("classifies output contract failures without naming a provider", () => {
expect(toCallToolResult(ok({ value: 42 }), contract)).toEqual({
content: [