fix(hopper): select verified Linux demo mode explicitly

This commit is contained in:
N0zoM1z0 2026-07-14 20:24:33 +00:00
parent 528c05e2fa
commit 04c5fd63b7
3 changed files with 112 additions and 9 deletions

View File

@ -40,15 +40,25 @@ export interface BridgeLauncher {
): Promise<Result<BridgeLaunch, HopperStartError | HopperCancelledError>>;
}
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,

View File

@ -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" }),
});

View File

@ -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 });
}
});
});