From 04c5fd63b72e6e497363ecbde5e24088eef639cd Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 20:24:33 +0000 Subject: [PATCH 1/3] fix(hopper): select verified Linux demo mode explicitly --- src/hopper/BridgeLauncher.ts | 25 ++++++++--- src/hopper/HopperProvider.ts | 11 +++-- tests/bridgeLauncher.test.ts | 85 ++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 tests/bridgeLauncher.test.ts 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 }); + } + }); +}); From db202c2b6ac9ce771c401cc558c56ecf6d2e51ee Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 20:54:22 +0000 Subject: [PATCH 2/3] test(hopper): cover verified Linux rejection --- scripts/verify-package.mjs | 223 +++++++++++++++++++++---------------- tests/runtime.test.ts | 4 +- 2 files changed, 128 insertions(+), 99 deletions(-) diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 4603ea416..2ce2d9785 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -170,72 +170,88 @@ try { throw new Error("packaged artifact inventory CLI failed"); // prettier-ignore await verifyPackagedInvestigation({ cli, workspace, evidenceRoot, artifactArchive, environment }); - const overview = json( - await run(cli, ["analyze", process.execPath, "--json"], environment), - ); - if ( - overview.operation !== "binary_overview" || - overview.normalized_result?.procedure_count < 1 - ) - throw new Error( - `packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`, + if (process.platform === "linux") { + const unsupportedExecution = await runWithStatus( + cli, + ["analyze", process.execPath, "--json"], + environment, ); - const inspected = json( - await run( - cli, - [ - "inspect", - process.execPath, - "--detail", - "detailed", - "--limit", - "1", - "--json", - ], - environment, - ), - ); - if ( - inspected.operation !== "binary_overview" || - inspected.normalized_result?.detail !== "detailed" - ) - throw new Error("packaged inspect CLI failed"); - const functionResult = json( - await run( - cli, - ["function", process.execPath, "0x1000", "--json"], - environment, - ), - ); - if ( - functionResult.operation !== "analyze_function" || - functionResult.provider?.id !== "hopper" || - functionResult.normalized_result?.procedure?.address !== "0x1000" - ) - throw new Error( - `packaged function CLI failed: ${JSON.stringify(functionResult)}`, + const unsupported = json(unsupportedExecution.stdout); + if ( + unsupportedExecution.status !== 1 || + unsupported.details?.failure_code !== "unsupported_hopper_build" + ) + throw new Error( + `packaged Linux Hopper verification did not fail closed: ${JSON.stringify(unsupported)}`, + ); + } else { + const overview = json( + await run(cli, ["analyze", process.execPath, "--json"], environment), ); - const xrefs = json( - await run( - cli, - ["xrefs", process.execPath, "0x1000", "--json"], - environment, - ), - ); - if ( - xrefs.operation !== "xrefs" || - JSON.stringify(xrefs.normalized_result) !== JSON.stringify(["0x1000"]) - ) - throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`); - const trace = json( - await run( - cli, - ["trace", process.execPath, "fixture", "--json"], - environment, - ), - ); - if (trace.operation !== "trace_feature") - throw new Error(`packaged trace CLI failed: ${JSON.stringify(trace)}`); + if ( + overview.operation !== "binary_overview" || + overview.normalized_result?.procedure_count < 1 + ) + throw new Error( + `packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`, + ); + const inspected = json( + await run( + cli, + [ + "inspect", + process.execPath, + "--detail", + "detailed", + "--limit", + "1", + "--json", + ], + environment, + ), + ); + if ( + inspected.operation !== "binary_overview" || + inspected.normalized_result?.detail !== "detailed" + ) + throw new Error("packaged inspect CLI failed"); + const functionResult = json( + await run( + cli, + ["function", process.execPath, "0x1000", "--json"], + environment, + ), + ); + if ( + functionResult.operation !== "analyze_function" || + functionResult.provider?.id !== "hopper" || + functionResult.normalized_result?.procedure?.address !== "0x1000" + ) + throw new Error( + `packaged function CLI failed: ${JSON.stringify(functionResult)}`, + ); + const xrefs = json( + await run( + cli, + ["xrefs", process.execPath, "0x1000", "--json"], + environment, + ), + ); + if ( + xrefs.operation !== "xrefs" || + JSON.stringify(xrefs.normalized_result) !== JSON.stringify(["0x1000"]) + ) + throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`); + const trace = json( + await run( + cli, + ["trace", process.execPath, "fixture", "--json"], + environment, + ), + ); + if (trace.operation !== "trace_feature") + throw new Error(`packaged trace CLI failed: ${JSON.stringify(trace)}`); + } const capabilities = json( await run(cli, ["capabilities", "--json"], environment), ); @@ -247,20 +263,22 @@ try { providers.providers.some(({ id }) => typeof id !== "string") ) throw new Error("packaged providers CLI failed"); - const searchResult = json( - await run( - cli, - ["search", process.execPath, "fixture", "--json"], - environment, - ), - ); - if ( - searchResult.operation !== "search_strings" || - searchResult.normalized_result?.items?.length !== 1 - ) - throw new Error( - `packaged search CLI failed: ${JSON.stringify(searchResult)}`, + if (process.platform !== "linux") { + const searchResult = json( + await run( + cli, + ["search", process.execPath, "fixture", "--json"], + environment, + ), ); + if ( + searchResult.operation !== "search_strings" || + searchResult.normalized_result?.items?.length !== 1 + ) + throw new Error( + `packaged search CLI failed: ${JSON.stringify(searchResult)}`, + ); + } const referenceImport = json( await run( cli, @@ -478,30 +496,41 @@ try { ); if (opened.isError === true) throw new Error("packaged MCP could not open a binary"); - await prompts.verifyPromptCompletion(client, mcpOptions, true); + await prompts.verifyPromptCompletion( + client, + mcpOptions, + process.platform !== "linux", + ); const current = await client.callTool( { name: "current_document", arguments: {} }, mcpOptions, ); - if ( - current.isError === true || - json(prompts.mcpText(current)).normalized_result !== "fixture" - ) - throw new Error("packaged MCP bridge call failed"); - const batch = await client.callTool( - { name: "batch_decompile", arguments: { addresses: ["0x1000"] } }, - mcpOptions, - ); - const batchResult = json(prompts.mcpText(batch)).normalized_result; - if ( - batch.isError === true || - batchResult?.total !== 1 || - batchResult?.succeeded !== 1 || - batchResult?.failed !== 0 || - batchResult?.items?.[0]?.status !== "ok" || - batchResult?.items?.[0]?.pseudocode !== "return 0;" - ) - throw new Error("packaged MCP structured batch result failed"); + if (process.platform === "linux") { + if ( + current.isError !== true || + !prompts + .mcpText(current) + .includes("not supported for unattended Linux analysis") + ) + throw new Error("packaged Linux MCP did not reject an unpinned Hopper"); + } else { + if (json(prompts.mcpText(current)).normalized_result !== "fixture") + throw new Error("packaged MCP bridge call failed"); + const batch = await client.callTool( + { name: "batch_decompile", arguments: { addresses: ["0x1000"] } }, + mcpOptions, + ); + const batchResult = json(prompts.mcpText(batch)).normalized_result; + if ( + batch.isError === true || + batchResult?.total !== 1 || + batchResult?.succeeded !== 1 || + batchResult?.failed !== 0 || + batchResult?.items?.[0]?.status !== "ok" || + batchResult?.items?.[0]?.pseudocode !== "return 0;" + ) + throw new Error("packaged MCP structured batch result failed"); + } const closed = await client.callTool( { name: "close_binary", arguments: {} }, mcpOptions, diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index da3bfa55f..b58b50441 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -37,7 +37,7 @@ describe("production stdio runtime", () => { name: "current_document", arguments: {}, }); - expect(result.isError).not.toBe(true); + expect(result.isError === true).toBe(process.platform === "linux"); } finally { await client.close(); await transport.close(); @@ -53,7 +53,7 @@ describe("production stdio runtime", () => { mode: "mcp", layer: "server", tool: "current_document", - status: "ok", + status: process.platform === "linux" ? "error" : "ok", }), ); expect(stderr).not.toContain("HOPPER_LOADER_ARGS_JSON"); From d93257b50fae81272cbd2da28ba24d03647c2c99 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 21:23:46 +0000 Subject: [PATCH 3/3] test(hopper): verify alternate Linux launcher path --- .github/workflows/real-hopper-linux.yml | 2 +- scripts/verify-real-hopper-linux.mjs | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/real-hopper-linux.yml b/.github/workflows/real-hopper-linux.yml index 32f4ad9d9..286677e04 100644 --- a/.github/workflows/real-hopper-linux.yml +++ b/.github/workflows/real-hopper-linux.yml @@ -57,7 +57,7 @@ jobs: npm install --global --prefix "${prefix}" "./${tarball}" echo "REA_VERIFY_SERVER_COMMAND=${prefix}/bin/rea" >> "${GITHUB_ENV}" echo 'REA_VERIFY_SERVER_ARGS_JSON=["mcp"]' >> "${GITHUB_ENV}" - - name: Verify two binaries through the in-process bridge + - name: Verify two binaries through an alternate launcher symlink env: HOPPER_TARGET_PATH: ${{ inputs.first_target }} HOPPER_SECOND_TARGET_PATH: ${{ inputs.second_target }} diff --git a/scripts/verify-real-hopper-linux.mjs b/scripts/verify-real-hopper-linux.mjs index 5dd5c7451..76cc02643 100644 --- a/scripts/verify-real-hopper-linux.mjs +++ b/scripts/verify-real-hopper-linux.mjs @@ -1,5 +1,7 @@ -import { access } from "node:fs/promises"; +import { access, mkdtemp, rm, symlink } from "node:fs/promises"; import { execFile } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); @@ -14,5 +16,14 @@ await access("/usr/bin/python3"); const linked = await execFileAsync("ldd", [launcher]); if (`${linked.stdout}\n${linked.stderr}`.includes("not found")) throw new Error("Hopper has unresolved shared-library dependencies"); -process.env.HOPPER_LAUNCHER_PATH = launcher; -await import("./verify-real-hopper.mjs"); +const alternateDirectory = await mkdtemp( + join(tmpdir(), "rea-real-hopper-launcher-"), +); +const alternateLauncher = join(alternateDirectory, "Hopper-alternate"); +try { + await symlink(launcher, alternateLauncher); + process.env.HOPPER_LAUNCHER_PATH = alternateLauncher; + await import("./verify-real-hopper.mjs"); +} finally { + await rm(alternateDirectory, { recursive: true, force: true }); +}