Merge branch 'main' into agent/process-zombie-settlement

This commit is contained in:
morluto 2026-07-15 06:12:17 +08:00 committed by GitHub
commit 95a228ffa7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 255 additions and 112 deletions

View File

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

View File

@ -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,

View File

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

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

View File

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