diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 9fc10c6e3..aeeaad419 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -40,15 +40,25 @@ export interface BridgeLauncher { ): Promise>; } -export interface HopperApplicationLauncherOptions { +interface SharedHopperApplicationLauncherOptions { readonly launcherPath: string; readonly targetPath: string; readonly targetKind: "executable" | "database"; readonly loaderArgs: readonly string[]; readonly bridgeScriptPath: string; - readonly demoHelperPath?: string; } +/** Explicit launcher contract; the Linux adapter verifies its pinned Hopper build before execution. */ +export type HopperApplicationLauncherOptions = + | (SharedHopperApplicationLauncherOptions & { + readonly launchMode: "native"; + readonly demoHelperPath?: never; + }) + | (SharedHopperApplicationLauncherOptions & { + readonly launchMode: "verified_linux_demo"; + readonly demoHelperPath: string; + }); + /** * Launches Hopper through its documented CLI and injects only REA's owned bridge. * @@ -186,10 +196,13 @@ const linuxDemoLaunch = ( }; }; -const usesLinuxDemo = (options: HopperApplicationLauncherOptions): boolean => - process.platform === "linux" && - basename(options.launcherPath) === "Hopper" && - options.demoHelperPath !== undefined; +/** Select the version-pinned adapter from the caller's explicit launch mode. */ +export const usesLinuxDemo = ( + options: HopperApplicationLauncherOptions, +): options is HopperApplicationLauncherOptions & { + readonly launchMode: "verified_linux_demo"; + readonly demoHelperPath: string; +} => options.launchMode === "verified_linux_demo"; const spawnLauncher = async ( command: string, diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index 681aa039b..e6c13e068 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -114,9 +114,14 @@ 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), - ), + ...(process.platform === "linux" + ? { + launchMode: "verified_linux_demo" as const, + demoHelperPath: fileURLToPath( + new URL("../../scripts/hopper-demo-x11.py", import.meta.url), + ), + } + : { launchMode: "native" as const }), }), logger: this.logger.child({ layer: "bridge" }), }); diff --git a/tests/bridgeLauncher.test.ts b/tests/bridgeLauncher.test.ts new file mode 100644 index 000000000..7e69bfdf9 --- /dev/null +++ b/tests/bridgeLauncher.test.ts @@ -0,0 +1,85 @@ +import { execFile } from "node:child_process"; +import { access, chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { + type HopperApplicationLauncherOptions, + usesLinuxDemo, +} from "../src/hopper/BridgeLauncher.js"; + +const execFileAsync = promisify(execFile); +const demoHelperPath = fileURLToPath( + new URL("../scripts/hopper-demo-x11.py", import.meta.url), +); + +const options = (launcherPath: string): HopperApplicationLauncherOptions => ({ + launcherPath, + targetPath: "/target", + targetKind: "executable", + loaderArgs: [], + bridgeScriptPath: "/rea/hopper_bridge.py", + launchMode: "verified_linux_demo", + demoHelperPath: "/rea/hopper-demo-x11.py", +}); + +describe("Hopper bridge launcher selection", () => { + it.each([ + "/opt/hopper/bin/Hopper", + "/usr/local/bin/hopper", + "/workspace/bin/hopper-wrapper", + ])( + "routes launcher spelling %s through the explicitly selected pinned adapter", + (path) => { + expect(usesLinuxDemo(options(path))).toBe(true); + }, + ); + + it("does not infer demo behavior from a native launcher's basename", () => { + expect( + usesLinuxDemo({ + launcherPath: "/opt/hopper/bin/Hopper", + targetPath: "/target", + targetKind: "executable", + loaderArgs: [], + bridgeScriptPath: "/rea/hopper_bridge.py", + launchMode: "native", + }), + ).toBe(false); + }); + + it("rejects an unpinned wrapper before executing it", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-hopper-wrapper-")); + const wrapper = join(directory, "hopper-wrapper"); + const marker = join(directory, "executed"); + try { + await writeFile(wrapper, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`); + await chmod(wrapper, 0o700); + await expect( + execFileAsync( + "python3", + [ + demoHelperPath, + "--hopper", + wrapper, + "--socket", + join(directory, "bridge.sock"), + "--", + wrapper, + ], + { timeout: 3_000 }, + ), + ).rejects.toMatchObject({ + code: 72, + stderr: expect.stringContaining("unsupported Hopper binary"), + }); + await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +});