diff --git a/.github/workflows/real-hopper-linux.yml b/.github/workflows/real-hopper-linux.yml index 7ff3d52785..32f4ad9d96 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 b5413baba3..0c2cdd97bd 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 646971241b..f6a0b08060 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 0000000000..d8f69a9d28 --- /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 3d95fa9cbf..e76668bd44 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 429bd48237..0c2e32cfbc 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 5f06de9309..0968f2c13c 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 0000000000..763b015387 --- /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 0000000000..38aba05e48 --- /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 c03dbbba2b..d6321d901b 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 e575bfda1b..b2d7048ef4 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 0000000000..eac8929d8f --- /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 fd7aeed1e9..551e6fd39e 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 f73a81ed2a..3a958aacb9 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 6cfc262fdb..121acddcf8 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 2c25cba0ce..67169868fc 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 1364809d05..813217a4ff 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 578b7face1..11c79dfb1b 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 0000000000..3cdd0f4210 --- /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 4fae4e9211..4e423c8bdd 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: [