mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
Merge pull request #110 from morluto/fix/linux-hopper-demo
fix(linux): start Hopper demo sessions headlessly
This commit is contained in:
@@ -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]
|
||||
|
||||
+2
-2
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -69,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",
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
#!/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)
|
||||
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):
|
||||
_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)
|
||||
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(
|
||||
[
|
||||
"/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 AdapterFailure(PRIVATE_DISPLAY_UNAVAILABLE, "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 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 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
|
||||
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)
|
||||
|
||||
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 AdapterFailure(PRIVATE_DISPLAY_UNAVAILABLE, "private X display unavailable")
|
||||
root = x11.XDefaultRootWindow(display)
|
||||
if (
|
||||
x11.XDisplayWidth(display, 0),
|
||||
x11.XDisplayHeight(display, 0),
|
||||
) != EXPECTED_SCREEN:
|
||||
raise AdapterFailure(
|
||||
UNEXPECTED_DISPLAY_GEOMETRY, "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 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:
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
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 AdapterFailure as error:
|
||||
print(str(error), file=os.sys.stderr)
|
||||
raise SystemExit(error.code) from None
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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") ||
|
||||
@@ -350,9 +350,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 +399,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" ||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<LinuxDistribution | undefined>;
|
||||
validTarget(path: string): Promise<boolean>;
|
||||
executable(path: string): Promise<boolean>;
|
||||
supportedLinuxHopper(path: string): Promise<boolean>;
|
||||
linuxDemoRuntimeReady(): Promise<boolean>;
|
||||
brewHopperPath(): Promise<string | undefined>;
|
||||
manualHopperPaths(): Promise<readonly string[]>;
|
||||
}
|
||||
@@ -111,6 +114,33 @@ 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: unsupportedHopperRemediation(
|
||||
host.configuredHopperPath === hopperPath,
|
||||
),
|
||||
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, xauth, 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, {
|
||||
@@ -125,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,
|
||||
@@ -160,6 +195,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 {
|
||||
|
||||
+105
-61
@@ -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<LinuxPackageFamily, LinuxHopperRelease>
|
||||
> = {
|
||||
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";
|
||||
|
||||
@@ -39,7 +62,7 @@ export interface LinuxHopperInstallHost {
|
||||
createTemporaryDirectory(): Promise<string>;
|
||||
writeArchive(path: string, bytes: Uint8Array): Promise<void>;
|
||||
installPackage(family: LinuxPackageFamily, archive: string): Promise<boolean>;
|
||||
launcherReady(path: string): Promise<boolean>;
|
||||
launcherStatus(path: string): Promise<LinuxHopperLauncherStatus>;
|
||||
cleanup(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -55,9 +78,17 @@ export type LinuxHopperInstallResult =
|
||||
| "integrity"
|
||||
| "authorization_or_package_manager"
|
||||
| "launcher_missing"
|
||||
| "runtime_dependencies"
|
||||
| "unsupported_hopper_build"
|
||||
| "cancelled";
|
||||
};
|
||||
|
||||
export type LinuxHopperLauncherStatus =
|
||||
| "ready"
|
||||
| "missing"
|
||||
| "runtime_dependencies"
|
||||
| "unsupported_hopper_build";
|
||||
|
||||
/** Parse an os-release document without executing its shell syntax. */
|
||||
export const parseLinuxDistribution = (text: string): LinuxDistribution => {
|
||||
const fields = new Map<string, string>();
|
||||
@@ -102,7 +133,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<LinuxPackageFamily, LinuxHopperRelease>
|
||||
>;
|
||||
} = {},
|
||||
): Promise<LinuxHopperInstallResult> => {
|
||||
if (options.signal?.aborted === true)
|
||||
return { status: "failed", reason: "cancelled" };
|
||||
@@ -114,15 +150,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))
|
||||
@@ -133,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" };
|
||||
@@ -163,20 +198,35 @@ 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}`,
|
||||
);
|
||||
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 }),
|
||||
});
|
||||
|
||||
/** Match the Linux Hopper build validated by the packaged demo-dialog adapter. */
|
||||
export const linuxHopperBinarySupported = async (
|
||||
path: string,
|
||||
): Promise<boolean> => {
|
||||
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 +236,12 @@ const installSystemPackage = async (
|
||||
archive: string,
|
||||
): Promise<boolean> => {
|
||||
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 +249,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<typeof releasesSchema> | 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<typeof releaseSchema>,
|
||||
release: LinuxHopperRelease,
|
||||
): boolean =>
|
||||
bytes.byteLength === Number(release.file_length) &&
|
||||
bytes.byteLength <= MAX_PACKAGE_BYTES &&
|
||||
@@ -255,19 +313,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,
|
||||
});
|
||||
|
||||
+48
-60
@@ -24,6 +24,13 @@ 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 { SetupHopperInstallResult } from "./SetupInstallFailure.js";
|
||||
|
||||
export { installCanonicalSkill } from "./SetupSkill.js";
|
||||
|
||||
@@ -60,7 +67,7 @@ export interface SetupHost {
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
linuxDistribution(): Promise<LinuxDistribution | undefined>;
|
||||
hopperPath(): Promise<string | undefined>;
|
||||
installHopper(): Promise<string | undefined>;
|
||||
installHopper(): Promise<SetupHopperInstallResult>;
|
||||
detectedClients(): Promise<readonly SetupClient[]>;
|
||||
configureClient(
|
||||
client: SetupClient,
|
||||
@@ -82,6 +89,7 @@ export interface SetupResult {
|
||||
readonly clients: Readonly<Record<string, ClientConfigurationResult>>;
|
||||
readonly doctor: Awaited<ReturnType<typeof runDoctor>>;
|
||||
readonly remediation?: string;
|
||||
readonly code?: SetupFailureCode;
|
||||
}
|
||||
|
||||
/** One concrete setup mutation disclosed before approval. */
|
||||
@@ -104,43 +112,6 @@ export type SetupConfirmation = (
|
||||
actions: readonly SetupAction[],
|
||||
) => Promise<boolean>;
|
||||
|
||||
const setupPlan = (
|
||||
platform: NodeJS.Platform,
|
||||
hopperPath: string | undefined,
|
||||
clients: readonly SetupClient[],
|
||||
): readonly SetupAction[] => [
|
||||
...(hopperPath === undefined
|
||||
? [
|
||||
{
|
||||
kind: "install_hopper" as const,
|
||||
target:
|
||||
platform === "darwin"
|
||||
? "~/Applications/Hopper Disassembler.app"
|
||||
: "system package manager",
|
||||
detail:
|
||||
"Download the official Hopper package, verify it, install it, and open Hopper for activation.",
|
||||
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
|
||||
@@ -165,8 +136,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,21 +163,19 @@ export const runSetup = async (
|
||||
"Review the setup plan, then rerun interactively or with --yes.",
|
||||
};
|
||||
|
||||
if (
|
||||
hopperPath === undefined &&
|
||||
(interactiveApproval || options.installHopper)
|
||||
) {
|
||||
hopperPath = await host.installHopper();
|
||||
if (hopperPath === undefined)
|
||||
if (installHopper && (interactiveApproval || options.installHopper)) {
|
||||
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({
|
||||
@@ -217,26 +194,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<string | undefined> => {
|
||||
@@ -269,7 +256,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) =>
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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", [
|
||||
|
||||
@@ -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,9 +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)}`);
|
||||
const failure = hopperStartupFailure(exitCode);
|
||||
this.failureCode = failure?.code;
|
||||
this.userMessage = failure?.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +348,7 @@ export interface AnalysisErrorProjection
|
||||
| "unavailable"
|
||||
| "execution_failure";
|
||||
readonly message: string;
|
||||
readonly code?: HopperStartupFailureCode;
|
||||
}
|
||||
|
||||
/** Project expected failures into exhaustive, secret-safe caller fields. */
|
||||
@@ -344,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 }),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -413,6 +428,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))
|
||||
|
||||
@@ -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);
|
||||
@@ -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<Result<undefined, HopperStartError>>(
|
||||
(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<Result<ChildProcess, HopperStartError>> => {
|
||||
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,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
HopperProcessError,
|
||||
HopperProtocolError,
|
||||
HopperStartError,
|
||||
hopperStartupFailure,
|
||||
HopperTimeoutError,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
@@ -70,6 +71,7 @@ export class HopperClient {
|
||||
#nextId = 1;
|
||||
#buffer = "";
|
||||
#closing = false;
|
||||
#launcherExitCode: number | null | undefined;
|
||||
#startPromise: Promise<Result<HopperServerInfo, HopperError>> | undefined;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
|
||||
@@ -102,6 +104,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 +213,11 @@ 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 !== undefined &&
|
||||
hopperStartupFailure(this.#launcherExitCode) !== undefined
|
||||
)
|
||||
return err(new HopperProcessError(this.#launcherExitCode));
|
||||
try {
|
||||
await access(socketPath);
|
||||
} catch {
|
||||
@@ -305,6 +313,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));
|
||||
|
||||
@@ -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" }),
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ const host = (overrides: Partial<DoctorHost> = {}): 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,53 @@ 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",
|
||||
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 =
|
||||
"/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
|
||||
@@ -37,6 +37,27 @@ describe("analysis error projection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const secretCause = new Error("secret-token");
|
||||
const byTag = {
|
||||
|
||||
@@ -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,37 @@ describe("HopperClient", () => {
|
||||
expect(Date.now() - startedAt).toBeLessThan(500);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
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({
|
||||
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(
|
||||
|
||||
+62
-44
@@ -3,12 +3,13 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
installLinuxHopper,
|
||||
linuxPackageManagerCommand,
|
||||
linuxPackageManagerCommands,
|
||||
linuxSharedLibrariesAvailable,
|
||||
parseLinuxDistribution,
|
||||
type LinuxDistribution,
|
||||
type LinuxHopperDownload,
|
||||
type LinuxHopperInstallHost,
|
||||
type LinuxHopperLauncherStatus,
|
||||
type LinuxPackageFamily,
|
||||
} from "../src/application/LinuxHopper.js";
|
||||
|
||||
@@ -19,12 +20,10 @@ 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;
|
||||
launcherStatusValue: LinuxHopperLauncherStatus = "ready";
|
||||
downloads: string[] = [];
|
||||
installed: Array<{ family: LinuxPackageFamily; archive: string }> = [];
|
||||
cleaned: string[] = [];
|
||||
@@ -33,11 +32,6 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
|
||||
Promise.resolve(this.distributionValue);
|
||||
download = (url: string): Promise<LinuxHopperDownload> => {
|
||||
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<string> =>
|
||||
@@ -50,7 +44,8 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
|
||||
this.installed.push({ family, archive });
|
||||
return Promise.resolve(this.installSucceeds);
|
||||
};
|
||||
launcherReady = (): Promise<boolean> => Promise.resolve(this.launcherExists);
|
||||
launcherStatus = (): Promise<LinuxHopperLauncherStatus> =>
|
||||
Promise.resolve(this.launcherStatusValue);
|
||||
cleanup = (path: string): Promise<void> => {
|
||||
this.cleaned.push(path);
|
||||
return Promise.resolve();
|
||||
@@ -103,23 +98,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 +149,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,13 +163,12 @@ 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"],
|
||||
["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")
|
||||
@@ -158,21 +177,15 @@ 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({
|
||||
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,
|
||||
});
|
||||
@@ -198,7 +211,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 +220,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])),
|
||||
});
|
||||
|
||||
+126
-20
@@ -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";
|
||||
@@ -23,6 +24,8 @@ class FakeSetupHost implements SetupHost {
|
||||
hopperInstalls = 0;
|
||||
configurations = 0;
|
||||
doctorHealthy: boolean | undefined;
|
||||
linuxDemoRuntimeMissing = false;
|
||||
unsupportedHopperVersion = false;
|
||||
|
||||
constructor(platform: NodeJS.Platform = "darwin") {
|
||||
this.platform = platform;
|
||||
@@ -33,10 +36,21 @@ class FakeSetupHost implements SetupHost {
|
||||
linuxDistribution = (): Promise<LinuxDistribution | undefined> =>
|
||||
Promise.resolve(this.distribution);
|
||||
hopperPath = (): Promise<string | undefined> => Promise.resolve(this.hopper);
|
||||
installHopper = (): Promise<string | undefined> => {
|
||||
installHopper = (): Promise<SetupHopperInstallResult> => {
|
||||
this.hopperInstalls += 1;
|
||||
if (this.hopperInstallSucceeds) this.hopper = "/manual/Hopper";
|
||||
return Promise.resolve(this.hopper);
|
||||
this.linuxDemoRuntimeMissing = false;
|
||||
if (this.hopperInstallSucceeds) {
|
||||
this.hopper = "/manual/Hopper";
|
||||
return Promise.resolve({
|
||||
status: "installed",
|
||||
launcherPath: this.hopper,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: "failed",
|
||||
code: "download_failed",
|
||||
remediation: "Download failed.",
|
||||
});
|
||||
};
|
||||
detectedClients = (): Promise<readonly SetupClient[]> =>
|
||||
Promise.resolve(this.clients);
|
||||
@@ -54,12 +68,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 +126,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 +156,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 +184,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,15 +218,58 @@ 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;
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user