From 044fb9127ac77dbb4d9992410f7596fc49dc446e Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:09:42 +0800 Subject: [PATCH] fix(hopper): skip non-file launcher candidates Require automatically discovered Hopper launchers to be executable regular files so POSIX directory search permission cannot hide a later valid application bundle. Preserve file symlinks, explicit caller configuration, and the shared candidate ordering. Cover canonical and renamed user bundles through the production filesystem predicate; respect existing canonical or renamed system installations in the isolated fixtures. Clarify launcher eligibility in the installation guide. Follow-up to #3085 and #3054 after their shared path discovery landed on main. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- docs/installation.md | 2 ++ src/config/parseConfig.ts | 4 +-- .../hopperLauncherSelection.test.ts | 34 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 tests/boundary/filesystem/hopperLauncherSelection.test.ts diff --git a/docs/installation.md b/docs/installation.md index 7cd250772b..92f82b0bf8 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -585,6 +585,8 @@ system application, the canonical application installed by `rea setup` under diagnostic; to use a Homebrew-installed launcher for CLI/MCP startup, set `HOPPER_LAUNCHER_PATH` to the path reported by doctor. REA does not run Homebrew while loading runtime configuration. +Automatic discovery accepts executable regular files, including file symlinks, +and skips directories. On Linux, it prefers executable `/opt/hopper/bin/Hopper`, then executable `~/.local/share/rea/hopper/bin/Hopper`, followed by `/opt/hopper-v4/bin/hopper` and `/usr/bin/hopper`; if none exists, the former remains the diagnostic diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index 1dbeda7578..a1c1e0b286 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -1,4 +1,4 @@ -import { accessSync, constants } from "node:fs"; +import { accessSync, constants, statSync } from "node:fs"; import type { ConfigurationError } from "../domain/configurationErrors.js"; import { ok, type Result } from "../domain/result.js"; @@ -16,7 +16,7 @@ import { const executableAvailable = (path: string): boolean => { try { accessSync(path, constants.X_OK); - return true; + return statSync(path).isFile(); } catch { return false; } diff --git a/tests/boundary/filesystem/hopperLauncherSelection.test.ts b/tests/boundary/filesystem/hopperLauncherSelection.test.ts new file mode 100644 index 0000000000..5949ce23ea --- /dev/null +++ b/tests/boundary/filesystem/hopperLauncherSelection.test.ts @@ -0,0 +1,34 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { expect, it } from "vitest"; + +import { defaultHopperLauncherPath } from "../../../src/config/parseConfig.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +// A real system installation legitimately precedes this isolated user fixture. +const installedSystemHopper = (home: string): boolean => { + try { + const launcher = defaultHopperLauncherPath("darwin", home); + accessSync(launcher, constants.X_OK); + return statSync(launcher).isFile(); + } catch { + return false; + } +}; + +it.for(["Hopper Disassembler.app", "Hopper A.app"])( + "skips a directory launcher in %s and selects a real file", + async (name, context) => { + const home = await createTestTempDirectory("rea-hopper-selection-"); + if (installedSystemHopper(home)) context.skip(); + const invalid = join(home, "Applications", name, "Contents/MacOS/hopper"); + const valid = join(home, "Applications/Hopper Z.app/Contents/MacOS/hopper"); + await mkdir(invalid, { recursive: true }); + await mkdir(dirname(valid), { recursive: true }); + await writeFile(valid, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + + expect(defaultHopperLauncherPath("darwin", home)).toBe(valid); + }, +);