fix(hopper): select verified Linux demo mode explicitly

This commit is contained in:
N0zoM1z0 2026-07-14 20:24:33 +00:00
parent 000d4f5e56
commit e27f635b73
3 changed files with 101 additions and 53 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,8 +196,13 @@ const linuxDemoLaunch = (
};
};
const usesLinuxDemo = (options: HopperApplicationLauncherOptions): boolean =>
process.platform === "linux" && 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

@ -1,57 +1,85 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
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 { afterEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import { HopperApplicationLauncher } from "../src/hopper/BridgeLauncher.js";
import {
type HopperApplicationLauncherOptions,
usesLinuxDemo,
} from "../src/hopper/BridgeLauncher.js";
const directories: string[] = [];
const execFileAsync = promisify(execFile);
const demoHelperPath = fileURLToPath(
new URL("../scripts/hopper-demo-x11.py", import.meta.url),
);
afterEach(async () => {
await Promise.all(
directories
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
);
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 application launcher", () => {
it.skipIf(process.platform !== "linux")(
"uses the Linux demo helper for a non-canonical launcher path",
async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-launcher-test-"));
directories.push(directory);
const helper = join(directory, "demo-helper.py");
await writeFile(helper, "import sys\nsys.exit(0)\n");
const launcher = new HopperApplicationLauncher({
launcherPath: "/usr/bin/true",
targetPath: "/tmp/fixture",
targetKind: "executable",
loaderArgs: [],
bridgeScriptPath: "/tmp/hopper_bridge.py",
demoHelperPath: helper,
});
const launched = await launcher.launch({
directory,
socketPath: join(directory, "bridge.sock"),
token: "token",
runId: "run-wrapper",
});
expect(launched.ok).toBe(true);
if (!launched.ok) return;
expect(launched.value.process.spawnfile).toBe("/usr/bin/python3");
expect(launched.value.process.spawnargs).toContain(helper);
expect(launched.value.shutdownByCleanup).toBe(true);
expect(await readFile(join(directory, "bootstrap.py"), "utf8")).toContain(
"REA_OWNS_PROCESS_LIFETIME = True",
);
if (launched.value.process.exitCode === null)
await new Promise<void>((resolve) => {
launched.value.process.once("exit", () => resolve());
});
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 });
}
});
});