From 75818d119e3eb3b8df5acdbd554a2b86b82af23a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:50:01 +0000 Subject: [PATCH 1/4] fix(linux): start Hopper demo sessions headlessly --- CONTRIBUTING.md | 4 +- README.md | 10 +- package.json | 1 + scripts/hopper-demo-x11.py | 316 +++++++++++++++++++++++++++ scripts/verify-package.mjs | 10 +- scripts/verify-real-hopper-linux.mjs | 10 +- src/application/Doctor.ts | 44 ++++ src/application/LinuxHopper.ts | 140 +++++++----- src/application/Setup.ts | 57 +++-- src/domain/errors.ts | 6 + src/hopper/BridgeLauncher.ts | 90 ++++++-- src/hopper/HopperClient.ts | 5 + src/hopper/HopperProvider.ts | 3 + tests/doctor.test.ts | 23 ++ tests/errors.test.ts | 7 + tests/hopperClient.test.ts | 43 ++++ tests/linuxHopper.test.ts | 92 ++++---- tests/setup.test.ts | 124 +++++++++-- 18 files changed, 816 insertions(+), 169 deletions(-) create mode 100644 scripts/hopper-demo-x11.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8816212b4..713a044e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ REA welcomes focused bug fixes, documentation improvements, tests, and reverse-e ## Development setup -REA development requires Node.js 24.18.x and npm 11.16.x. Real-Hopper verification additionally requires either macOS 12+ or an officially supported Linux host (Ubuntu 24.04+, Fedora 41+, or 64-bit Arch), an installed and activated Hopper application, and a desktop session. Run `nvm use` before installing dependencies. +REA development requires Node.js 24.18.x and npm 11.16.x. Real-Hopper verification additionally requires either macOS 12+ or an officially supported Linux host (Ubuntu 24.04+, Fedora 41+, or 64-bit Arch) and an installed Hopper application. Linux demo verification uses its own private Xvfb display and does not require a desktop session. Run `nvm use` before installing dependencies. ```bash npm ci @@ -43,7 +43,7 @@ HOPPER_SECOND_TARGET_PATH=/path/to/distinct-target-b \ npm run verify:hopper ``` -On a self-hosted Linux runner with `DISPLAY` or `WAYLAND_DISPLAY`, use: +On a self-hosted Linux runner with the setup-installed Xvfb dependencies, use: ```bash HOPPER_TARGET_PATH=/path/to/target-a \ diff --git a/README.md b/README.md index 4d607390b..02f90d01b 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ npx skills add morluto/rea Ask your agent to set up REA. It will check your Mac, explain anything it needs to install, ask for approval, and guide you through system prompts. After setup, restart the agent if it asks you to load the full REA toolset. -Review the setup plan, approve it if appropriate, complete Hopper's one-time activation when prompted, then describe the app or feature you want to understand. +Review the setup plan, approve it if appropriate, then describe the app or feature you want to understand. Hopper can run in its free demo mode; if it shows a first-run prompt, choose the demo or enter an existing license. ### From Terminal — no installation @@ -157,13 +157,13 @@ If something is not working, run: npx -y rea-agents doctor ``` -`rea doctor --json` is read-only and distinguishes unsupported hosts, missing dependencies, a missing local analysis engine, configuration drift, and healthy checks. Fresh-install activation is reported by setup because Hopper does not expose a reliable noninteractive activation probe. +`rea doctor --json` is read-only and distinguishes unsupported hosts, missing dependencies, a missing local analysis engine, configuration drift, and healthy checks. Paid-license activation is optional: on Linux, REA runs the supported Hopper demo build on a private Xvfb display and selects Hopper's offered demo mode for each analysis session. ### Linux installation and troubleshooting -On macOS, approved setup downloads Hopper's official DMG, verifies it, and installs the app into `~/Applications` without Homebrew or administrator privileges. It opens Hopper once for activation; no manual drag-and-drop is required. +On macOS, approved setup downloads Hopper's official DMG, verifies it, and installs the app into `~/Applications` without Homebrew or administrator privileges. Hopper may show its demo or license prompt when first opened; no manual drag-and-drop is required. -On Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux, approved setup downloads the matching official Hopper package, restricts downloads to Hopper's public origin, verifies the published size and checksum, and invokes `apt-get`, `dnf`, or `pacman`. When REA is not already running as root, `pkexec` presents the system authorization prompt. REA never invokes `sudo`. +On Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux, approved setup downloads the pinned official Hopper 6.4.2 package, restricts downloads to Hopper's public origin, verifies the published size and checksum, and invokes `apt-get`, `dnf`, or `pacman` to install Hopper and the Xvfb, Python, X11, and XTEST packages used by demo sessions. When REA is not already running as root, `pkexec` presents the system authorization prompt. REA never invokes `sudo`. Demo sessions run on an isolated 1280×1024 Xvfb display. REA verifies the exact supported Hopper binary, its owned process ancestry, the expected dialog geometry, and bridge state before selecting `Try the Demo`; any mismatch fails closed. The normal Linux launcher is `/opt/hopper/bin/Hopper`. If Hopper was installed elsewhere: @@ -178,7 +178,7 @@ If doctor reports a missing analysis engine even though the file exists, inspect ldd /opt/hopper/bin/Hopper | grep 'not found' ``` -Install the missing distribution packages and rerun `rea setup`. Hopper is a desktop application: real analysis requires an active `DISPLAY` or `WAYLAND_DISPLAY`, plus one-time license activation. The curl installer places the `rea` command in `~/.local/bin` on Linux; add that directory to future shell `PATH` values if it is not already present. +Install the missing distribution packages and rerun `rea setup`. Linux demo automation requires `Xvfb`, Python 3, `libX11.so.6`, and `libXtst.so.6`; approved setup installs those direct runtime dependencies and does not interact with the user's desktop display. Hopper's free demo supports analysis with vendor-defined limits, and a paid license is optional. The curl installer places the `rea` command in `~/.local/bin` on Linux; add that directory to future shell `PATH` values if it is not already present. REA defaults `HOPPER_LAUNCHER_PATH` to `/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` on macOS and `/opt/hopper/bin/Hopper` on Linux. Explicit configuration always takes precedence. diff --git a/package.json b/package.json index 526e44180..b5413baba 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "scripts/rea.mjs", "scripts/prepare-node-pty.mjs", "scripts/rebuild-native.mjs", + "scripts/hopper-demo-x11.py", "skills", "README.md", "LICENSE" diff --git a/scripts/hopper-demo-x11.py b/scripts/hopper-demo-x11.py new file mode 100644 index 000000000..646971241 --- /dev/null +++ b/scripts/hopper-demo-x11.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +"""Inspect or activate Hopper's version-pinned demo dialog on a private X display.""" + +from __future__ import annotations + +import argparse +import ctypes +import hashlib +import os +from pathlib import Path +import secrets +import subprocess +import time + + +# Keep this digest aligned with LinuxHopper.ts and rerun real-Hopper verification +# before accepting another vendor build. +SUPPORTED_HOPPER_SHA256 = { + "0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded" +} +EXPECTED_SCREEN = (1280, 1024) +EXPECTED_DIALOG = (189, 370, 901, 284) +DEMO_CLICK = (305, 632) + + +class XWindowAttributes(ctypes.Structure): + _fields_ = [ + ("x", ctypes.c_int), + ("y", ctypes.c_int), + ("width", ctypes.c_int), + ("height", ctypes.c_int), + ("border_width", ctypes.c_int), + ("depth", ctypes.c_int), + ("visual", ctypes.c_void_p), + ("root", ctypes.c_ulong), + ("class_", ctypes.c_int), + ("bit_gravity", ctypes.c_int), + ("win_gravity", ctypes.c_int), + ("backing_store", ctypes.c_int), + ("backing_planes", ctypes.c_ulong), + ("backing_pixel", ctypes.c_ulong), + ("save_under", ctypes.c_int), + ("colormap", ctypes.c_ulong), + ("map_installed", ctypes.c_int), + ("map_state", ctypes.c_int), + ("all_event_masks", ctypes.c_long), + ("your_event_mask", ctypes.c_long), + ("do_not_propagate_mask", ctypes.c_long), + ("override_redirect", ctypes.c_int), + ("screen", ctypes.c_void_p), + ] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--hopper", required=True) + parser.add_argument("--socket", required=True) + parser.add_argument("command", nargs=argparse.REMAINDER) + return parser.parse_args() + + +def digest(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + value.update(chunk) + return value.hexdigest() + + +def descendants(pid: int) -> set[int]: + found = {pid} + changed = True + while changed: + changed = False + for stat in Path("/proc").glob("[0-9]*/stat"): + try: + fields = stat.read_text().split() + child = int(fields[0]) + parent = int(fields[3]) + except (OSError, ValueError, IndexError): + continue + if parent in found and child not in found: + found.add(child) + changed = True + return found + + +def hopper_is_owned(leader: int, hopper: Path) -> bool: + expected = hopper.resolve() + for pid in descendants(leader): + try: + if Path(f"/proc/{pid}/exe").resolve() == expected: + return True + except OSError: + continue + return False + + +def windows(x11: ctypes.CDLL, display: int, root: int) -> list[tuple[int, int, int, int, int]]: + found: list[tuple[int, int, int, int, int]] = [] + pending = [root] + while pending: + window = pending.pop() + attributes = XWindowAttributes() + if x11.XGetWindowAttributes(display, window, ctypes.byref(attributes)): + if attributes.map_state == 2: + found.append( + (window, attributes.x, attributes.y, attributes.width, attributes.height) + ) + root_return = ctypes.c_ulong() + parent_return = ctypes.c_ulong() + children = ctypes.POINTER(ctypes.c_ulong)() + count = ctypes.c_uint() + if x11.XQueryTree( + display, + window, + ctypes.byref(root_return), + ctypes.byref(parent_return), + ctypes.byref(children), + ctypes.byref(count), + ): + pending.extend(children[index] for index in range(count.value)) + if children: + x11.XFree(children) + return found + + +def configure_x11(x11: ctypes.CDLL, xtst: ctypes.CDLL) -> None: + window_pointer = ctypes.POINTER(ctypes.c_ulong) + x11.XOpenDisplay.argtypes = [ctypes.c_char_p] + x11.XOpenDisplay.restype = ctypes.c_void_p + x11.XDefaultRootWindow.argtypes = [ctypes.c_void_p] + x11.XDefaultRootWindow.restype = ctypes.c_ulong + x11.XDisplayWidth.argtypes = [ctypes.c_void_p, ctypes.c_int] + x11.XDisplayWidth.restype = ctypes.c_int + x11.XDisplayHeight.argtypes = [ctypes.c_void_p, ctypes.c_int] + x11.XDisplayHeight.restype = ctypes.c_int + x11.XGetWindowAttributes.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ctypes.POINTER(XWindowAttributes), + ] + x11.XGetWindowAttributes.restype = ctypes.c_int + x11.XQueryTree.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + window_pointer, + window_pointer, + ctypes.POINTER(window_pointer), + ctypes.POINTER(ctypes.c_uint), + ] + x11.XQueryTree.restype = ctypes.c_int + x11.XFree.argtypes = [ctypes.c_void_p] + x11.XFree.restype = ctypes.c_int + x11.XFlush.argtypes = [ctypes.c_void_p] + x11.XFlush.restype = ctypes.c_int + x11.XCloseDisplay.argtypes = [ctypes.c_void_p] + x11.XCloseDisplay.restype = ctypes.c_int + xtst.XTestFakeMotionEvent.argtypes = [ + ctypes.c_void_p, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_ulong, + ] + xtst.XTestFakeMotionEvent.restype = ctypes.c_int + xtst.XTestFakeButtonEvent.argtypes = [ + ctypes.c_void_p, + ctypes.c_uint, + ctypes.c_int, + ctypes.c_ulong, + ] + xtst.XTestFakeButtonEvent.restype = ctypes.c_int + + +def start_xvfb(session: Path) -> tuple[subprocess.Popen[bytes], str, Path]: + authority = session / "Xauthority" + for _ in range(20): + display_number = 100 + secrets.randbelow(900) + display_name = f":{display_number}" + 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, + ) + authority.chmod(0o600) + process = subprocess.Popen( + [ + "/usr/bin/Xvfb", + display_name, + "-screen", + "0", + "1280x1024x24", + "-nolisten", + "tcp", + "-auth", + str(authority), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + deadline = time.monotonic() + 2 + socket = Path(f"/tmp/.X11-unix/X{display_number}") + while time.monotonic() < deadline and process.poll() is None: + if socket.exists(): + return process, display_name, authority + time.sleep(0.05) + if process.poll() is None: + process.terminate() + process.wait() + raise SystemExit("private X display unavailable") + + +def main() -> int: + args = parse_args() + hopper = Path(args.hopper) + socket = Path(args.socket) + if digest(hopper) not in SUPPORTED_HOPPER_SHA256: + raise SystemExit("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") + xvfb, display_name, authority = start_xvfb(socket.parent) + child: subprocess.Popen[bytes] | None = None + display: int | None = None + try: + hopper_environment = dict(os.environ) + for name in ("WAYLAND_DISPLAY", "WAYLAND_SOCKET"): + hopper_environment.pop(name, None) + hopper_environment.update( + { + "DISPLAY": display_name, + "XAUTHORITY": str(authority), + "GDK_BACKEND": "x11", + "QT_QPA_PLATFORM": "xcb", + "XDG_SESSION_TYPE": "x11", + } + ) + child = subprocess.Popen(command, env=hopper_environment) + leader = os.getpid() + os.environ["XAUTHORITY"] = str(authority) + + x11 = ctypes.CDLL("libX11.so.6") + xtst = ctypes.CDLL("libXtst.so.6") + configure_x11(x11, xtst) + display = x11.XOpenDisplay(display_name.encode()) + if not display: + raise SystemExit("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") + + deadline = time.monotonic() + 10 + observed: list[tuple[int, int, int, int, int]] = [] + while time.monotonic() < deadline: + if socket.exists(): + x11.XCloseDisplay(display) + display = None + return child.wait() + if not hopper_is_owned(leader, hopper): + if child.poll() is None: + time.sleep(0.1) + continue + raise SystemExit("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}") + + xtst.XTestFakeMotionEvent(display, -1, DEMO_CLICK[0], DEMO_CLICK[1], 0) + xtst.XTestFakeButtonEvent(display, 1, True, 0) + xtst.XTestFakeButtonEvent(display, 1, False, 0) + x11.XFlush(display) + x11.XCloseDisplay(display) + display = None + return child.wait() + finally: + if display is not None: + x11.XCloseDisplay(display) + if child is not None and child.poll() is None: + child.terminate() + child.wait() + if xvfb.poll() is None: + xvfb.terminate() + try: + xvfb.wait(timeout=2) + except subprocess.TimeoutExpired: + xvfb.kill() + xvfb.wait() + + +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 diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 7e5cedbe5..ae2452931 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -117,7 +117,7 @@ try { const doctor = json(await run(cli, ["doctor", "--json"], environment)); const supportedSetupHost = doctor.checks?.find(({ name }) => name === "host")?.ok === true; - const expectedDoctorHealth = supportedSetupHost; + const expectedDoctorHealth = doctor.checks?.every(({ ok }) => ok) === true; if ( !help.includes("setup") || !help.includes("upgrade") || @@ -135,6 +135,7 @@ try { !llms.includes("capabilities") || !llms.includes("providers") || !llms.includes("inventory-artifact") || + !supportedSetupHost || doctor.healthy !== expectedDoctorHealth || doctor.checks?.find(({ name }) => name === "hopper")?.ok !== true ) @@ -350,9 +351,10 @@ try { ), ); if (supportedSetupHost) { + const status = process.platform === "linux" ? "needs_human" : "ready"; if ( - first.status !== "ready" || - second.status !== "ready" || + first.status !== status || + second.status !== status || second.appliedActions.length !== 0 ) throw new Error("packaged setup was not ready and idempotent"); @@ -398,7 +400,7 @@ try { const recovered = json( await run(cli, ["setup", "--yes", "--json"], environment), ); - if (recovered.status !== "ready") + if (recovered.status !== status) throw new Error("packaged setup did not recover"); } else if ( first.status !== "needs_human" || diff --git a/scripts/verify-real-hopper-linux.mjs b/scripts/verify-real-hopper-linux.mjs index 15f48fb52..5dd5c7451 100644 --- a/scripts/verify-real-hopper-linux.mjs +++ b/scripts/verify-real-hopper-linux.mjs @@ -8,15 +8,11 @@ if (process.platform !== "linux") throw new Error("Linux real-Hopper verification must run on Linux"); const launcher = process.env.HOPPER_LAUNCHER_PATH ?? "/opt/hopper/bin/Hopper"; await access(launcher); +await access("/usr/bin/Xvfb"); +await access("/usr/bin/xauth"); +await access("/usr/bin/python3"); const linked = await execFileAsync("ldd", [launcher]); if (`${linked.stdout}\n${linked.stderr}`.includes("not found")) throw new Error("Hopper has unresolved shared-library dependencies"); -if ( - process.env.DISPLAY === undefined && - process.env.WAYLAND_DISPLAY === undefined -) - throw new Error( - "Linux real-Hopper verification requires DISPLAY or WAYLAND_DISPLAY", - ); process.env.HOPPER_LAUNCHER_PATH = launcher; await import("./verify-real-hopper.mjs"); diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 3a737d85e..3d95fa9cb 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -10,6 +10,7 @@ import { parseBinaryTarget } from "../domain/binaryTarget.js"; import { supportsNodeVersion } from "../domain/runtimeVersion.js"; import { probeHomebrew } from "./homebrew.js"; import { + linuxHopperBinarySupported, linuxHopperLauncherPath, linuxSharedLibrariesAvailable, readLinuxDistribution, @@ -43,6 +44,8 @@ export interface DoctorHost { linuxDistribution(): Promise; validTarget(path: string): Promise; executable(path: string): Promise; + supportedLinuxHopper(path: string): Promise; + linuxDemoRuntimeReady(): Promise; brewHopperPath(): Promise; manualHopperPaths(): Promise; } @@ -111,6 +114,32 @@ export const runDoctor = async ( classification: "missing_analysis_engine", }), ); + if (host.platform === "linux" && hopperPath !== undefined) + checks.push( + check( + "hopper-version", + await host.supportedLinuxHopper(hopperPath), + hopperPath, + { + remediation: + "Install the Hopper build supported by this REA release, or update REA for a newer Hopper build.", + classification: "config_drift", + }, + ), + ); + if (host.platform === "linux" && hopperPath !== undefined) + checks.push( + check( + "hopper-demo-runtime", + await host.linuxDemoRuntimeReady(), + undefined, + { + remediation: + "Rerun rea setup to install the Xvfb, Python, X11, and XTEST packages required for Linux demo sessions.", + classification: "missing_dependency", + }, + ), + ); if (target !== undefined) checks.push( check("target", await host.validTarget(target), target, { @@ -160,6 +189,21 @@ export const systemDoctorHost = (): DoctorHost => ({ return false; } }, + async linuxDemoRuntimeReady() { + try { + await access("/usr/bin/Xvfb", constants.X_OK); + await access("/usr/bin/xauth", constants.X_OK); + await access("/usr/bin/python3", constants.X_OK); + await execFileAsync("/usr/bin/python3", [ + "-c", + "import ctypes; ctypes.CDLL('libX11.so.6'); ctypes.CDLL('libXtst.so.6')", + ]); + return true; + } catch { + return false; + } + }, + supportedLinuxHopper: linuxHopperBinarySupported, async brewHopperPath() { return probeHomebrew(async (command) => { try { diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts index 88085ef4d..429bd4823 100644 --- a/src/application/LinuxHopper.ts +++ b/src/application/LinuxHopper.ts @@ -1,17 +1,40 @@ import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; +import { createReadStream } from "node:fs"; import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; -import { z } from "zod"; - const execFileAsync = promisify(execFile); -const RELEASES_URL = - "https://www.hopperapp.com/include/files-api.php?request=releases&public=true"; const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/"; const MAX_PACKAGE_BYTES = 100_000_000; +const SUPPORTED_HOPPER_SHA256 = + "0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded"; +interface LinuxHopperRelease { + readonly filename: string; + readonly file_length: string; + readonly file_hash: string; +} +const SUPPORTED_RELEASES: Readonly< + Record +> = { + deb: { + filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.deb`, + file_length: "35755772", + file_hash: "e4f79dff602648a8ff4a88b773875b6bfac0dc65", + }, + rpm: { + filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.rpm`, + file_length: "32264475", + file_hash: "b9a65f2f583a386d440d4401d9140aef3bf305de", + }, + arch: { + filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.pkg.tar.xz`, + file_length: "29405968", + file_hash: "92d780b61cab38eda95dc4112dee4fbeb0920f46", + }, +}; export type LinuxPackageFamily = "deb" | "rpm" | "arch"; @@ -102,7 +125,12 @@ export const readLinuxDistribution = async (): Promise< /** Download, verify, install, and read back the official Hopper Linux package. */ export const installLinuxHopper = async ( host: LinuxHopperInstallHost = systemLinuxHopperInstallHost(), - options: { readonly signal?: AbortSignal } = {}, + options: { + readonly signal?: AbortSignal; + readonly releases?: Readonly< + Record + >; + } = {}, ): Promise => { if (options.signal?.aborted === true) return { status: "failed", reason: "cancelled" }; @@ -114,15 +142,9 @@ export const installLinuxHopper = async ( return { status: "failed", reason: "unsupported_host" }; let temporary: string | undefined; try { - const metadataDownload = await host.download(RELEASES_URL, options); - if (!metadataDownload.ok) - return { status: "failed", reason: "release_metadata" }; - const releases = parseReleases(metadataDownload.bytes); - if (releases === undefined) - return { status: "failed", reason: "release_metadata" }; - const release = releases[releaseKey(distribution.packageFamily)]; - if (!release.filename.startsWith(DOWNLOAD_PREFIX)) - return { status: "failed", reason: "release_metadata" }; + const release = (options.releases ?? SUPPORTED_RELEASES)[ + distribution.packageFamily + ]; const archiveDownload = await host.download(release.filename, options); if (!archiveDownload.ok) return { status: "failed", reason: "download" }; if (!packageIntegrityMatches(archiveDownload.bytes, release)) @@ -167,8 +189,9 @@ const systemLinuxHopperInstallHost = (): LinuxHopperInstallHost => ({ try { await access(path); const linked = await execFileAsync("ldd", [path]); - return linuxSharedLibrariesAvailable( - `${linked.stdout}\n${linked.stderr}`, + return ( + linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`) && + (await linuxHopperBinarySupported(path)) ); } catch { return false; @@ -177,6 +200,19 @@ const systemLinuxHopperInstallHost = (): LinuxHopperInstallHost => ({ cleanup: (path) => rm(path, { recursive: true, force: true }), }); +/** Match the Linux Hopper build validated by the packaged demo-dialog adapter. */ +export const linuxHopperBinarySupported = async ( + path: string, +): Promise => { + try { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex") === SUPPORTED_HOPPER_SHA256; + } catch { + return false; + } +}; + /** Interpret ldd output conservatively so a present but broken launcher is unhealthy. */ export const linuxSharedLibrariesAvailable = (output: string): boolean => !/(?:=>\s+not found|error while loading shared libraries)/iu.test(output); @@ -186,12 +222,12 @@ const installSystemPackage = async ( archive: string, ): Promise => { try { - const command = linuxPackageManagerCommand( + for (const command of linuxPackageManagerCommands( family, archive, process.getuid?.() === 0, - ); - await execFileAsync(command.executable, command.args); + )) + await execFileAsync(command.executable, command.args); return true; } catch { return false; @@ -199,37 +235,45 @@ const installSystemPackage = async ( }; /** Select the native package manager and authorization boundary without shell evaluation. */ -export const linuxPackageManagerCommand = ( +export const linuxPackageManagerCommands = ( family: LinuxPackageFamily, archive: string, isRoot: boolean, -): { readonly executable: string; readonly args: readonly string[] } => { - const command = +): readonly { + readonly executable: string; + readonly args: readonly string[]; +}[] => { + const runtimeDependencies = family === "deb" - ? ["apt-get", "install", "-y", archive] + ? ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"] : family === "rpm" - ? ["dnf", "install", "-y", archive] - : ["pacman", "-U", "--noconfirm", archive]; - const executable = isRoot ? command[0] : "pkexec"; - if (executable === undefined) throw new Error("package command is empty"); - return { executable, args: isRoot ? command.slice(1) : command }; -}; - -const parseReleases = ( - bytes: Uint8Array, -): z.infer | undefined => { - try { - const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes)); - const result = releasesSchema.safeParse(parsed); - return result.success ? result.data : undefined; - } catch { - return undefined; - } + ? [ + "xorg-x11-server-Xvfb", + "xorg-x11-xauth", + "python3", + "libX11", + "libXtst", + ] + : ["xorg-server-xvfb", "xorg-xauth", "python", "libx11", "libxtst"]; + const commands = + family === "deb" + ? [["apt-get", "install", "-y", archive, ...runtimeDependencies]] + : family === "rpm" + ? [["dnf", "install", "-y", archive, ...runtimeDependencies]] + : [ + ["pacman", "-S", "--needed", "--noconfirm", ...runtimeDependencies], + ["pacman", "-U", "--noconfirm", archive], + ]; + return commands.map((command) => { + const executable = isRoot ? command[0] : "pkexec"; + if (executable === undefined) throw new Error("package command is empty"); + return { executable, args: isRoot ? command.slice(1) : command }; + }); }; const packageIntegrityMatches = ( bytes: Uint8Array, - release: z.infer, + release: LinuxHopperRelease, ): boolean => bytes.byteLength === Number(release.file_length) && bytes.byteLength <= MAX_PACKAGE_BYTES && @@ -255,19 +299,5 @@ const versionAtLeast = ( ): boolean => version !== undefined && Number.parseInt(version.split(".")[0] ?? "0", 10) >= minimum; -const releaseKey = ( - family: LinuxPackageFamily, -): "Ubuntu / Mint" | "Fedora" | "Arch" => - family === "deb" ? "Ubuntu / Mint" : family === "rpm" ? "Fedora" : "Arch"; const isAbortCause = (cause: unknown): boolean => cause instanceof Error && cause.name === "AbortError"; -const releaseSchema = z.object({ - filename: z.string().url(), - file_length: z.string().regex(/^\d+$/u), - file_hash: z.string().regex(/^[a-f0-9]{40}$/u), -}); -const releasesSchema = z.object({ - "Ubuntu / Mint": releaseSchema, - Fedora: releaseSchema, - Arch: releaseSchema, -}); diff --git a/src/application/Setup.ts b/src/application/Setup.ts index 0853200c1..5f06de930 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -106,10 +106,10 @@ export type SetupConfirmation = ( const setupPlan = ( platform: NodeJS.Platform, - hopperPath: string | undefined, + installHopper: boolean, clients: readonly SetupClient[], ): readonly SetupAction[] => [ - ...(hopperPath === undefined + ...(installHopper ? [ { kind: "install_hopper" as const, @@ -118,7 +118,9 @@ const setupPlan = ( ? "~/Applications/Hopper Disassembler.app" : "system package manager", detail: - "Download the official Hopper package, verify it, install it, and open Hopper for activation.", + 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, }, ] @@ -165,8 +167,16 @@ export const runSetup = async ( const unsupported = await hostRemediation(host); if (unsupported !== undefined) return fail(unsupported); let hopperPath = await host.hopperPath(); + const initialDoctor = await host.doctor(); + const linuxHopperRepairNeeded = initialDoctor.checks.some( + ({ name, ok, detail }) => + !ok && + (name === "hopper-demo-runtime" || + (name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")), + ); + const installHopper = hopperPath === undefined || linuxHopperRepairNeeded; const detectedClients = await host.detectedClients(); - plannedActions = setupPlan(host.platform, hopperPath, detectedClients); + plannedActions = setupPlan(host.platform, installHopper, detectedClients); let approved = options.approved; let interactiveApproval = false; if (!approved && confirm !== undefined && !options.structured) { @@ -184,10 +194,7 @@ export const runSetup = async ( "Review the setup plan, then rerun interactively or with --yes.", }; - if ( - hopperPath === undefined && - (interactiveApproval || options.installHopper) - ) { + if (installHopper && (interactiveApproval || options.installHopper)) { hopperPath = await host.installHopper(); if (hopperPath === undefined) return { @@ -217,26 +224,36 @@ export const runSetup = async ( ); if (skill === "installed") appliedActions.push("installed_skill"); const doctor = await host.doctor(); - const activationRequired = appliedActions.includes("installed_hopper"); - const ready = doctor.healthy && !activationRequired; + const remediation = finalSetupRemediation( + host.platform, + appliedActions.includes("installed_hopper"), + doctor.healthy, + hopperPath, + ); return { - status: ready ? "ready" : "needs_human", + status: remediation === undefined ? "ready" : "needs_human", plannedActions, appliedActions, clients, doctor, - ...(ready - ? {} - : { - remediation: activationRequired - ? "Open Hopper, complete its one-time activation, then rerun rea doctor --json." - : hopperPath === undefined - ? "Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis." - : "Run rea doctor and apply each reported remediation.", - }), + ...(remediation === undefined ? {} : { remediation }), }; }; +const finalSetupRemediation = ( + platform: NodeJS.Platform, + installedHopper: boolean, + healthy: boolean, + hopperPath: string | undefined, +): string | undefined => { + if (platform === "darwin" && installedHopper) + return "Open Hopper, choose its demo mode or activate a license, then rerun rea doctor --json."; + if (healthy) return undefined; + return hopperPath === undefined + ? "Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis." + : "Run rea doctor and apply each reported remediation."; +}; + const hostRemediation = async ( host: SetupHost, ): Promise => { diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 2dbf95e57..e575bfda1 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -282,9 +282,14 @@ export class HopperRemoteError extends HopperError { /** The owned Hopper bridge stopped before its client was closed. */ export class HopperProcessError extends HopperError { readonly _tag = "HopperProcessError"; + 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; } } @@ -413,6 +418,7 @@ const STATIC_ERROR_CATEGORIES: Readonly< const userMessage = (error: AnalysisError): string => { if (error instanceof AnalysisInputError) return "Analysis input is invalid. Check the arguments and try again."; + if (error.userMessage !== undefined) return error.userMessage; if (UNREADABLE_OUTPUT_TAGS.has(error._tag)) return "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`."; if (UNSUPPORTED_PROVIDER_TAGS.has(error._tag)) diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 1bc927e71..f40a48758 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -44,6 +44,7 @@ export interface HopperApplicationLauncherOptions { readonly targetKind: "executable" | "database"; readonly loaderArgs: readonly string[]; readonly bridgeScriptPath: string; + readonly demoHelperPath?: string; } /** @@ -101,26 +102,16 @@ export class HopperApplicationLauncher implements BridgeLauncher { options.signal, ); if (!prepared) return err(new HopperCancelledError()); - const child = spawn(this.options.launcherPath, args, { - stdio: ["ignore", "ignore", "pipe"], - detached: process.platform !== "win32", - env: { ...process.env, REA_PROCESS_RUN_ID: session.runId }, - }); - const started = await new Promise>( - (resolve) => { - const onSpawn = (): void => { - child.off("error", onError); - resolve(ok(undefined)); - }; - const onError = (cause: Error): void => { - child.off("spawn", onSpawn); - resolve(err(new HopperStartError({ cause }))); - }; - child.once("spawn", onSpawn); - child.once("error", onError); - }, + const linuxDemo = linuxDemoLaunch(this.options, session, args); + const ownershipCommand = + linuxDemo?.ownershipCommand ?? this.options.launcherPath; + const started = await spawnLauncher( + linuxDemo?.command ?? this.options.launcherPath, + linuxDemo?.args ?? args, + session.runId, ); if (!started.ok) return started; + const child = started.value; const pid = child.pid; if (pid === undefined) return err( @@ -132,7 +123,7 @@ export class HopperApplicationLauncher implements BridgeLauncher { pid, process_group_id: pid, parent_pid: process.pid, - launcher: this.options.launcherPath, + launcher: linuxDemo?.command ?? this.options.launcherPath, created_at: new Date().toISOString(), }; try { @@ -143,7 +134,7 @@ export class HopperApplicationLauncher implements BridgeLauncher { ); } catch (cause: unknown) { await cleanupOwnedProcessGroup( - ownedProcessGroup(session, pid, this.options.launcherPath), + ownedProcessGroup(session, pid, ownershipCommand), ); return err(new HopperStartError({ cause })); } @@ -152,7 +143,7 @@ export class HopperApplicationLauncher implements BridgeLauncher { ownsProcessLifetime: true, cleanup: () => cleanupOwnedProcessGroup( - ownedProcessGroup(session, pid, this.options.launcherPath), + ownedProcessGroup(session, pid, ownershipCommand), ), }); } catch (cause: unknown) { @@ -161,6 +152,63 @@ export class HopperApplicationLauncher implements BridgeLauncher { } } +const linuxDemoLaunch = ( + options: HopperApplicationLauncherOptions, + session: BridgeSession, + hopperArgs: readonly string[], +): + | { + readonly command: string; + readonly args: readonly string[]; + readonly ownershipCommand: string; + } + | undefined => { + if ( + process.platform !== "linux" || + basename(options.launcherPath) !== "Hopper" || + options.demoHelperPath === undefined + ) + return undefined; + return { + command: "/usr/bin/python3", + ownershipCommand: "/usr/bin/python3", + args: [ + options.demoHelperPath, + "--hopper", + options.launcherPath, + "--socket", + session.socketPath, + "--", + options.launcherPath, + ...hopperArgs, + ], + }; +}; + +const spawnLauncher = async ( + command: string, + args: readonly string[], + runId: string, +): Promise> => { + const child = spawn(command, args, { + stdio: ["ignore", "ignore", "pipe"], + detached: process.platform !== "win32", + env: { ...process.env, REA_PROCESS_RUN_ID: runId }, + }); + return new Promise((resolve) => { + const onSpawn = (): void => { + child.off("error", onError); + resolve(ok(child)); + }; + const onError = (cause: Error): void => { + child.off("spawn", onSpawn); + resolve(err(new HopperStartError({ cause }))); + }; + child.once("spawn", onSpawn); + child.once("error", onError); + }); +}; + const ownedProcessGroup = ( session: BridgeSession, pid: number, diff --git a/src/hopper/HopperClient.ts b/src/hopper/HopperClient.ts index 5f81b457f..fd7aeed1e 100644 --- a/src/hopper/HopperClient.ts +++ b/src/hopper/HopperClient.ts @@ -70,6 +70,7 @@ export class HopperClient { #nextId = 1; #buffer = ""; #closing = false; + #launcherExitCode: number | null | undefined; #startPromise: Promise> | undefined; #closePromise: Promise | undefined; @@ -102,6 +103,7 @@ export class HopperClient { } const socketPath = join(this.#directory, "bridge.sock"); this.#token = randomBytes(32).toString("hex"); + this.#launcherExitCode = undefined; const runId = randomUUID(); const launched = await this.#options.launcher.launch( { @@ -210,6 +212,8 @@ 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) + return err(new HopperProcessError(this.#launcherExitCode)); try { await access(socketPath); } catch { @@ -305,6 +309,7 @@ export class HopperClient { }); }); launch.process.on("exit", (code) => { + this.#launcherExitCode = code; this.#options.onDiagnostic?.({ type: "launcher-exit", code }); if (launch.ownsProcessLifetime && !this.#closing) { this.#failAll(new HopperProcessError(code)); diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index 9adae876e..681aa039b 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -114,6 +114,9 @@ export class HopperProvider implements AnalysisProvider { bridgeScriptPath: fileURLToPath( new URL("../../bridge/hopper_bridge.py", import.meta.url), ), + demoHelperPath: fileURLToPath( + new URL("../../scripts/hopper-demo-x11.py", import.meta.url), + ), }), logger: this.logger.child({ layer: "bridge" }), }); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 98444a1d6..f73a81ed2 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -8,6 +8,8 @@ const host = (overrides: Partial = {}): DoctorHost => ({ linuxDistribution: () => Promise.resolve(undefined), validTarget: (path) => Promise.resolve(path.includes("Hopper")), executable: (path) => Promise.resolve(path.includes("Hopper")), + supportedLinuxHopper: () => Promise.resolve(true), + linuxDemoRuntimeReady: () => Promise.resolve(true), brewHopperPath: () => Promise.resolve(undefined), manualHopperPaths: () => Promise.resolve([]), ...overrides, @@ -78,6 +80,27 @@ describe("doctor", () => { expect(result.healthy).toBe(true); expect(result.hopperPath).toBe(path); }); + it("reports missing Linux demo-session dependencies", async () => { + const result = await runDoctor( + undefined, + host({ + platform: "linux", + macosVersion: () => Promise.resolve(undefined), + linuxDistribution: () => + Promise.resolve({ + id: "ubuntu", + versionId: "24.04", + packageFamily: "deb", + supported: true, + }), + linuxDemoRuntimeReady: () => Promise.resolve(false), + }), + ); + expect(result.healthy).toBe(false); + expect( + result.checks.find(({ name }) => name === "hopper-demo-runtime"), + ).toMatchObject({ ok: false, classification: "missing_dependency" }); + }); it("detects a Homebrew cask installed outside /Applications", async () => { const path = "/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper"; diff --git a/tests/errors.test.ts b/tests/errors.test.ts index 4ca57fa34..6cfc262fd 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -37,6 +37,13 @@ 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 every typed failure without internal details", () => { const secretCause = new Error("secret-token"); const byTag = { diff --git a/tests/hopperClient.test.ts b/tests/hopperClient.test.ts index 5be1949d2..2c25cba0c 100644 --- a/tests/hopperClient.test.ts +++ b/tests/hopperClient.test.ts @@ -59,6 +59,21 @@ class SilentLauncher implements BridgeLauncher { } } +class ExitingLauncher implements BridgeLauncher { + constructor(readonly code: number) {} + + launch() { + return Promise.resolve( + ok({ + process: spawn(process.execPath, ["-e", `process.exit(${this.code})`], { + stdio: ["ignore", "ignore", "pipe"], + }), + ownsProcessLifetime: true, + }), + ); + } +} + const clients: HopperClient[] = []; const startClient = async () => { const client = new HopperClient({ @@ -183,6 +198,34 @@ 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, + }); + expect(Date.now() - startedAt).toBeLessThan(500); + }); + + it("allows a short-lived launcher to hand off bridge startup", async () => { + const client = new HopperClient({ + launcher: new ExitingLauncher(0), + startupTimeoutMs: 100, + }); + clients.push(client); + const result = await client.start(); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("HopperTimeoutError"); + }); + it("ignores more than 1,024 late responses without corrupting the session", async () => { const client = await startClient(); const timedOut = await Promise.all( diff --git a/tests/linuxHopper.test.ts b/tests/linuxHopper.test.ts index 5dad92bc8..1364809d0 100644 --- a/tests/linuxHopper.test.ts +++ b/tests/linuxHopper.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { installLinuxHopper, - linuxPackageManagerCommand, + linuxPackageManagerCommands, linuxSharedLibrariesAvailable, parseLinuxDistribution, type LinuxDistribution, @@ -19,9 +19,7 @@ class RecordingLinuxHost implements LinuxHopperInstallHost { packageFamily: "deb", supported: true, }; - metadata: unknown = releasesFor(new Uint8Array([1, 2, 3])); archive = new Uint8Array([1, 2, 3]); - metadataOk = true; archiveOk = true; installSucceeds = true; launcherExists = true; @@ -33,11 +31,6 @@ class RecordingLinuxHost implements LinuxHopperInstallHost { Promise.resolve(this.distributionValue); download = (url: string): Promise => { this.downloads.push(url); - if (url.includes("files-api.php")) - return Promise.resolve({ - ok: this.metadataOk, - bytes: new TextEncoder().encode(JSON.stringify(this.metadata)), - }); return Promise.resolve({ ok: this.archiveOk, bytes: this.archive }); }; createTemporaryDirectory = (): Promise => @@ -103,23 +96,48 @@ describe("Linux Hopper installation", () => { ["arch", "pacman"], ] as const)("selects the %s native package manager", (family, executable) => { expect( - linuxPackageManagerCommand(family, "/tmp/Hopper package", true), - ).toMatchObject({ - executable, - }); - expect( - linuxPackageManagerCommand(family, "/tmp/Hopper package", false), - ).toMatchObject({ - executable: "pkexec", - args: [ + linuxPackageManagerCommands(family, "/tmp/Hopper package", true), + ).toContainEqual( + expect.objectContaining({ executable, - expect.any(String), - expect.any(String), - expect.any(String), - ], - }); + }), + ); + expect( + linuxPackageManagerCommands(family, "/tmp/Hopper package", false), + ).toContainEqual( + expect.objectContaining({ + executable: "pkexec", + args: expect.arrayContaining([executable, "/tmp/Hopper package"]), + }), + ); }); + it.each([ + ["deb", ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]], + [ + "rpm", + [ + "xorg-x11-server-Xvfb", + "xorg-x11-xauth", + "python3", + "libX11", + "libXtst", + ], + ], + ["arch", ["xorg-server-xvfb", "xorg-xauth", "python", "libx11", "libxtst"]], + ] as const)( + "installs the %s demo-session dependencies", + (family, packages) => { + expect( + linuxPackageManagerCommands(family, "/tmp/Hopper package", true), + ).toContainEqual( + expect.objectContaining({ + args: expect.arrayContaining([...packages]), + }), + ); + }, + ); + it.each(["deb", "rpm", "arch"] as const)( "downloads, verifies, installs, reads back, and cleans a %s package", async (family) => { @@ -129,7 +147,7 @@ describe("Linux Hopper installation", () => { packageFamily: family, supported: true, }; - const result = await installLinuxHopper(host); + const result = await installFixture(host); expect(result).toEqual({ status: "installed", launcherPath: "/opt/hopper/bin/Hopper", @@ -143,9 +161,6 @@ describe("Linux Hopper installation", () => { it.each([ ["unsupported host", "unsupported_host"], - ["malformed metadata", "release_metadata"], - ["unexpected origin", "release_metadata"], - ["metadata request failure", "release_metadata"], ["package request failure", "download"], ["checksum mismatch", "integrity"], ["package manager failure", "authorization_or_package_manager"], @@ -158,21 +173,11 @@ describe("Linux Hopper installation", () => { packageFamily: "deb", supported: false, }; - if (scenario === "malformed metadata") host.metadata = { invalid: true }; - if (scenario === "unexpected origin") { - const metadata = releasesFor(host.archive); - const ubuntu = metadata["Ubuntu / Mint"]; - if (ubuntu === undefined) - throw new Error("fixture omitted Ubuntu release"); - ubuntu.filename = "https://example.com/Hopper.deb"; - host.metadata = metadata; - } - if (scenario === "metadata request failure") host.metadataOk = false; 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; - expect(await installLinuxHopper(host)).toEqual({ + expect(await installFixture(host)).toEqual({ status: "failed", reason, }); @@ -198,7 +203,7 @@ describe("Linux Hopper installation", () => { function releasesFor( bytes: Uint8Array, ): Record< - string, + LinuxPackageFamily, { filename: string; file_length: string; file_hash: string } > { const release = (extension: string) => ({ @@ -207,8 +212,13 @@ function releasesFor( file_hash: createHash("sha1").update(bytes).digest("hex"), }); return { - "Ubuntu / Mint": release("deb"), - Fedora: release("rpm"), - Arch: release("pkg.tar.xz"), + deb: release("deb"), + rpm: release("rpm"), + arch: release("pkg.tar.xz"), }; } + +const installFixture = (host: RecordingLinuxHost) => + installLinuxHopper(host, { + releases: releasesFor(new Uint8Array([1, 2, 3])), + }); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 959238c87..578b7face 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -23,6 +23,8 @@ class FakeSetupHost implements SetupHost { hopperInstalls = 0; configurations = 0; doctorHealthy: boolean | undefined; + linuxDemoRuntimeMissing = false; + unsupportedHopperVersion = false; constructor(platform: NodeJS.Platform = "darwin") { this.platform = platform; @@ -35,6 +37,7 @@ class FakeSetupHost implements SetupHost { hopperPath = (): Promise => Promise.resolve(this.hopper); installHopper = (): Promise => { this.hopperInstalls += 1; + this.linuxDemoRuntimeMissing = false; if (this.hopperInstallSucceeds) this.hopper = "/manual/Hopper"; return Promise.resolve(this.hopper); }; @@ -54,12 +57,38 @@ class FakeSetupHost implements SetupHost { healthy: boolean; hopperPath?: string; checks: readonly DoctorCheck[]; - }> => - Promise.resolve({ - healthy: this.doctorHealthy ?? this.hopper !== undefined, + }> => { + const checks: DoctorCheck[] = [ + ...(this.linuxDemoRuntimeMissing + ? ([ + { + name: "hopper-demo-runtime", + ok: false, + classification: "missing_dependency", + }, + ] as const) + : []), + ...(this.unsupportedHopperVersion + ? ([ + { + name: "hopper-version", + ok: false, + classification: "config_drift", + detail: this.hopper ?? "", + }, + ] as const) + : []), + ]; + return Promise.resolve({ + healthy: + this.doctorHealthy ?? + (this.hopper !== undefined && + !this.linuxDemoRuntimeMissing && + !this.unsupportedHopperVersion), ...(this.hopper === undefined ? {} : { hopperPath: this.hopper }), - checks: [], + checks, }); + }; } const options = (approved: boolean, installHopper = false): SetupOptions => ({ @@ -86,7 +115,29 @@ describe("setup workflow", () => { ); }); - it("applies an accepted interactive plan including Hopper", async () => { + it("reports ready after an accepted Linux plan installs healthy Hopper", async () => { + const host = new FakeSetupHost("linux"); + host.distribution = { + id: "ubuntu", + versionId: "24.04", + packageFamily: "deb", + supported: true, + }; + const result = await runSetup( + { ...options(false), structured: false }, + host, + () => Promise.resolve(true), + ); + expect(result.status).toBe("ready"); + expect(result.appliedActions).toEqual([ + "installed_hopper", + "installed_skill", + ]); + expect(host.hopperInstalls).toBe(1); + expect(result.remediation).toBeUndefined(); + }); + + it("requires first-run activation after installing Hopper on macOS", async () => { const host = new FakeSetupHost(); const result = await runSetup( { ...options(false), structured: false }, @@ -94,14 +145,7 @@ describe("setup workflow", () => { () => Promise.resolve(true), ); expect(result.status).toBe("needs_human"); - expect(result.appliedActions).toEqual([ - "installed_hopper", - "installed_skill", - ]); - expect(host.hopperInstalls).toBe(1); - expect(result.remediation).toBe( - "Open Hopper, complete its one-time activation, then rerun rea doctor --json.", - ); + expect(result.remediation).toContain("choose its demo mode"); }); it("declines an interactive plan without mutation", async () => { @@ -129,13 +173,21 @@ describe("setup workflow", () => { }); it("installs Hopper when unattended authorization is explicit", async () => { - const host = new FakeSetupHost(); + const host = new FakeSetupHost("linux"); + host.distribution = { + id: "ubuntu", + versionId: "24.04", + packageFamily: "deb", + supported: true, + }; const result = await runSetup(options(true, true), host); expect(result.appliedActions).toEqual([ "installed_hopper", "installed_skill", ]); expect(host.hopperInstalls).toBe(1); + expect(result.status).toBe("ready"); + expect(result.remediation).toBeUndefined(); }); it("reuses existing Hopper and configures detected clients", async () => { @@ -155,6 +207,50 @@ describe("setup workflow", () => { expect(host.hopperInstalls).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", + supported: 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", + supported: true, + }; + host.hopper = "/custom/Hopper"; + host.unsupportedHopperVersion = true; + const result = await runSetup( + { ...options(false), structured: false }, + host, + () => Promise.resolve(true), + ); + 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; From 67504d2d51151fd9eed76b12c9bae67c70817432 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:08:33 +0000 Subject: [PATCH 2/4] test(linux): verify setup through CLI and MCP --- .github/workflows/real-hopper-linux.yml | 21 ++++ package.json | 1 + scripts/hopper-demo-x11.py | 95 ++++++++++++------ scripts/verify-linux-setup-e2e.mjs | 122 ++++++++++++++++++++++++ src/application/Doctor.ts | 12 ++- src/application/LinuxHopper.ts | 32 +++++-- src/application/Setup.ts | 64 ++++--------- src/application/SetupInstallFailure.ts | 95 ++++++++++++++++++ src/application/SetupPlan.ts | 41 ++++++++ src/contracts/toolOutputSchemas.ts | 14 +++ src/domain/errors.ts | 18 +++- src/domain/hopperStartupFailure.ts | 73 ++++++++++++++ src/hopper/HopperClient.ts | 6 +- tests/doctor.test.ts | 28 +++++- tests/errors.test.ts | 24 ++++- tests/hopperClient.test.ts | 33 ++++--- tests/linuxHopper.test.ts | 14 ++- tests/setup.test.ts | 22 +++-- tests/setupInstallFailure.test.ts | 28 ++++++ tests/toolResult.test.ts | 12 +++ 20 files changed, 632 insertions(+), 123 deletions(-) create mode 100644 scripts/verify-linux-setup-e2e.mjs create mode 100644 src/application/SetupInstallFailure.ts create mode 100644 src/application/SetupPlan.ts create mode 100644 src/domain/hopperStartupFailure.ts create mode 100644 tests/setupInstallFailure.test.ts diff --git a/.github/workflows/real-hopper-linux.yml b/.github/workflows/real-hopper-linux.yml index 7ff3d5278..32f4ad9d9 100644 --- a/.github/workflows/real-hopper-linux.yml +++ b/.github/workflows/real-hopper-linux.yml @@ -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] diff --git a/package.json b/package.json index b5413baba..0c2cdd97b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/hopper-demo-x11.py b/scripts/hopper-demo-x11.py index 646971241..f6a0b0806 100644 --- a/scripts/hopper-demo-x11.py +++ b/scripts/hopper-demo-x11.py @@ -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 diff --git a/scripts/verify-linux-setup-e2e.mjs b/scripts/verify-linux-setup-e2e.mjs new file mode 100644 index 000000000..d8f69a9d2 --- /dev/null +++ b/scripts/verify-linux-setup-e2e.mjs @@ -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, + }), +); diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 3d95fa9cb..e76668bd4 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -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, diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts index 429bd4823..0c2e32cfb 100644 --- a/src/application/LinuxHopper.ts +++ b/src/application/LinuxHopper.ts @@ -62,7 +62,7 @@ export interface LinuxHopperInstallHost { createTemporaryDirectory(): Promise; writeArchive(path: string, bytes: Uint8Array): Promise; installPackage(family: LinuxPackageFamily, archive: string): Promise; - launcherReady(path: string): Promise; + launcherStatus(path: string): Promise; cleanup(path: string): Promise; } @@ -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(); @@ -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 }), diff --git a/src/application/Setup.ts b/src/application/Setup.ts index 5f06de930..0968f2c13 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -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; linuxDistribution(): Promise; hopperPath(): Promise; - installHopper(): Promise; + installHopper(): Promise; detectedClients(): Promise; configureClient( client: SetupClient, @@ -82,6 +92,7 @@ export interface SetupResult { readonly clients: Readonly>; readonly doctor: Awaited>; readonly remediation?: string; + readonly code?: SetupFailureCode; } /** One concrete setup mutation disclosed before approval. */ @@ -104,45 +115,6 @@ export type SetupConfirmation = ( actions: readonly SetupAction[], ) => Promise; -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) => diff --git a/src/application/SetupInstallFailure.ts b/src/application/SetupInstallFailure.ts new file mode 100644 index 000000000..763b01538 --- /dev/null +++ b/src/application/SetupInstallFailure.ts @@ -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.", + }; + } +}; diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts new file mode 100644 index 000000000..38aba05e4 --- /dev/null +++ b/src/application/SetupPlan.ts @@ -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, + }, +]; diff --git a/src/contracts/toolOutputSchemas.ts b/src/contracts/toolOutputSchemas.ts index c03dbbba2..d6321d901 100644 --- a/src/contracts/toolOutputSchemas.ts +++ b/src/contracts/toolOutputSchemas.ts @@ -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", [ diff --git a/src/domain/errors.ts b/src/domain/errors.ts index e575bfda1..b2d7048ef 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -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 }), }; }; diff --git a/src/domain/hopperStartupFailure.ts b/src/domain/hopperStartupFailure.ts new file mode 100644 index 000000000..eac8929d8 --- /dev/null +++ b/src/domain/hopperStartupFailure.ts @@ -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); diff --git a/src/hopper/HopperClient.ts b/src/hopper/HopperClient.ts index fd7aeed1e..551e6fd39 100644 --- a/src/hopper/HopperClient.ts +++ b/src/hopper/HopperClient.ts @@ -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); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index f73a81ed2..3a958aacb 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -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 = diff --git a/tests/errors.test.ts b/tests/errors.test.ts index 6cfc262fd..121acddcf 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -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", () => { diff --git a/tests/hopperClient.test.ts b/tests/hopperClient.test.ts index 2c25cba0c..67169868f 100644 --- a/tests/hopperClient.test.ts +++ b/tests/hopperClient.test.ts @@ -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({ diff --git a/tests/linuxHopper.test.ts b/tests/linuxHopper.test.ts index 1364809d0..813217a4f 100644 --- a/tests/linuxHopper.test.ts +++ b/tests/linuxHopper.test.ts @@ -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 => Promise.resolve(this.launcherExists); + launcherStatus = (): Promise => + Promise.resolve(this.launcherStatusValue); cleanup = (path: string): Promise => { 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, diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 578b7face..11c79dfb1 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -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 => Promise.resolve(this.distribution); hopperPath = (): Promise => Promise.resolve(this.hopper); - installHopper = (): Promise => { + installHopper = (): Promise => { 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 => 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); }); diff --git a/tests/setupInstallFailure.test.ts b/tests/setupInstallFailure.test.ts new file mode 100644 index 000000000..3cdd0f421 --- /dev/null +++ b/tests/setupInstallFailure.test.ts @@ -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); + }); +}); diff --git a/tests/toolResult.test.ts b/tests/toolResult.test.ts index 4fae4e921..4e423c8bd 100644 --- a/tests/toolResult.test.ts +++ b/tests/toolResult.test.ts @@ -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: [ From 977119f91b4af39ea208a5e63d9445bf366b2eeb Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:16:12 +0000 Subject: [PATCH 3/4] test(package): preserve unsupported host verification --- scripts/verify-package.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index ae2452931..ee957e8af 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -135,7 +135,6 @@ try { !llms.includes("capabilities") || !llms.includes("providers") || !llms.includes("inventory-artifact") || - !supportedSetupHost || doctor.healthy !== expectedDoctorHealth || doctor.checks?.find(({ name }) => name === "hopper")?.ok !== true ) From c5728af0b4574631d056f7ed1eb7785e80d80111 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:22:51 +0000 Subject: [PATCH 4/4] fix(ci): remove unused setup type export --- src/application/Setup.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/application/Setup.ts b/src/application/Setup.ts index 0968f2c13..e7ca57446 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -30,10 +30,7 @@ import { type SetupFailureCode, type SetupHopperInstallResult, } from "./SetupInstallFailure.js"; -export type { - SetupFailureCode, - SetupHopperInstallResult, -} from "./SetupInstallFailure.js"; +export type { SetupHopperInstallResult } from "./SetupInstallFailure.js"; export { installCanonicalSkill } from "./SetupSkill.js";