Files
reverse-engineering-anything/tests/acceptance/providers/hopperStartupRecovery.test.ts
T
gensokyo-dev 60bad9ed06 fix(hopper)!: admit macOS arguments before application handoff (#7732)
Admit the final macOS Hopper launcher arguments before application handoff.
Keep prepared-image source identity, return precise typed refusal locations,
and preserve bootstrap/lease cleanup. Update native CLI/MCP recovery
coverage and the installation guide.

Fixes #7727

BREAKING CHANGE: macOS Hopper launches refuse double quotes, backslashes
and line breaks in final helper paths or arguments before application
handoff. Select literal-safe launcher/runtime/database paths and loader
arguments, or a compatible provider. Original executable names can remain
unchanged when their prepared image path is literal-safe.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis)
2026-10-28 09:12:30 +00:00

357 lines
13 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import { access, copyFile, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { expect, onTestFinished, vi } from "vitest";
import { z } from "zod";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js";
const statusSchema = z.object({ result: z.object({ open: z.boolean() }) });
const test = workspaceCliTest.skipIf(process.platform !== "darwin");
test.for(["target", "loader"] as const)(
"preserves a prepared target or refuses literal-unsafe %s before handoff",
{ timeout: 90_000 },
async (source, { workspace, cli }) => {
const target = workspace.path(
source === "target" ? 'quoted"target' : "loader-target",
);
await copyFile("/usr/bin/true", target);
const mode = await workspace.write(
`${source}-mode`,
source === "target" ? "bridge" : "denied-after-handoff",
);
const receipt = workspace.path(`${source}-bootstrap`);
const loaderArgs = [
resolve("tests/fixtures/hopperLaunchAdmission.mjs"),
mode,
receipt,
...(source === "loader" ? ['caller"loader'] : []),
];
const environment = {
...workspace.environment,
PATH: process.env.PATH ?? "",
REA_ANALYSIS_PROVIDER: "hopper",
HOPPER_LAUNCHER_PATH: process.execPath,
HOPPER_LOADER_ARGS_JSON: JSON.stringify(loaderArgs),
REA_LOG_LEVEL: "silent",
};
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("dist/main.js")],
stderr: "pipe",
env: environment,
});
const mcp = new Client({ name: "hopper-literal-boundary", version: "1" });
onTestFinished(async () => {
await mcp.close();
await transport.close();
});
await mcp.connect(transport);
const call = (name: string, arguments_: Record<string, string> = {}) =>
mcp.callTool({ name, arguments: arguments_ });
expect((await call("open_binary", { path: target })).isError).not.toBe(
true,
);
if (source === "target") {
// The real Mach-O is copied to a prepared path; its original name stays valid.
expect((await call("current_document")).isError).not.toBe(true);
const runtime = dirname(await readFile(receipt, "utf8"));
await access(runtime);
await access(target);
expect((await call("close_binary")).isError).not.toBe(true);
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
} else {
const rejection = {
boundary: "argument_encoding",
reason: "applescript_literal",
source: "argument",
argument_index: 3,
};
for (const operation of ["current_document", "list_procedures"]) {
expect(parseMcpToolError(await call(operation)).error).toMatchObject({
code: "provider_unavailable",
details: { launch_rejection: rejection },
});
}
await expect(access(receipt)).rejects.toMatchObject({ code: "ENOENT" });
await expect(access(`${receipt}.document-created`)).rejects.toMatchObject(
{ code: "ENOENT" },
);
const result = await cli.run({
arguments: ["analyze", target, "--provider", "hopper", "--json"],
environment,
timeoutMs: 15_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "provider_unavailable",
details: { launch_rejection: rejection },
});
expect((await call("close_binary")).isError).not.toBe(true);
}
expect(
statusSchema.parse((await call("binary_session")).structuredContent)
.result.open,
).toBe(false);
const acquired = await acquireHopperTargetLease({
targetPath: target,
targetKind: "executable",
loaderArgs,
runId: "literal-boundary-lease-reuse",
});
if (!acquired.acquired)
throw new Error("Completed literal-boundary workflow retained its lease");
await acquired.lease.release();
},
);
test("recovers a denied Hopper launch through the same stdio MCP session and CLI", async ({
workspace,
cli,
}) => {
const target = workspace.path("target");
await copyFile("/usr/bin/true", target);
const mode = await workspace.write("mode", "denied");
const receipt = workspace.path("bootstrap-path");
const environment = {
...workspace.environment,
PATH: process.env.PATH ?? "",
REA_ANALYSIS_PROVIDER: "hopper",
HOPPER_LAUNCHER_PATH: process.execPath,
HOPPER_LOADER_ARGS_JSON: JSON.stringify([
resolve("tests/fixtures/hopperLaunchAdmission.mjs"),
mode,
receipt,
]),
REA_LOG_LEVEL: "silent",
};
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("dist/main.js")],
env: environment,
stderr: "pipe",
});
const mcp = new Client({ name: "hopper-startup-recovery", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
onTestFinished(async () => {
await mcp.close();
await transport.close();
});
await mcp.connect(transport);
const call = (name: string, arguments_: Record<string, string> = {}) =>
mcp.callTool({ name, arguments: arguments_ }).catch((cause: unknown) => {
throw new Error(`${name}: ${stderr}`, { cause });
});
expect((await call("open_binary", { path: target })).isError).not.toBe(true);
for (const operation of ["current_document", "list_procedures"]) {
const { error } = parseMcpToolError(await call(operation));
expect(error).toMatchObject({
code: "provider_unavailable",
details: {
stage: "launch",
launcher: {
exit_code: 2,
stderr: {
text: expect.stringContaining(
"Not authorized to send Apple events",
),
},
},
},
});
expect(error.message).toContain("Automation");
const bootstrap = await readFile(receipt, "utf8");
await expect(access(dirname(bootstrap))).rejects.toMatchObject({
code: "ENOENT",
});
}
expect((await call("close_binary")).isError).not.toBe(true);
const deniedCli = await cli.run({
arguments: ["analyze", target, "--provider", "hopper", "--json"],
environment,
timeoutMs: 15_000,
});
expect(deniedCli.exitCode).toBe(1);
expect(deniedCli.json).toMatchObject({
code: "provider_unavailable",
details: { launcher: { exit_code: 2 } },
});
await writeFile(mode, "bridge");
expect((await call("open_binary", { path: target })).isError).not.toBe(true);
expect((await call("current_document")).isError).not.toBe(true);
expect((await call("close_binary")).isError).not.toBe(true);
expect(
statusSchema.parse((await call("binary_session")).structuredContent).result
.open,
).toBe(false);
});
test.for(["handoff", "not-found"] as const)(
"preserves an unconfirmed %s handoff and closes only a late bridge with matching run identity",
{ timeout: 60_000 },
async (launchMode, { workspace }) => {
const target = workspace.path("target");
await copyFile("/usr/bin/true", target);
// Budget the complete native startup, including cold helper preparation.
// The scenario below requires observed handoff, not a pre-launch timeout.
const startupTimeoutMs = 10_000;
const mode = await workspace.write("mode", launchMode);
const receipt = workspace.path("bootstrap-path");
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("dist/main.js")],
stderr: "pipe",
env: {
...workspace.environment,
PATH: process.env.PATH ?? "",
REA_ANALYSIS_PROVIDER: "hopper",
HOPPER_LAUNCHER_PATH: process.execPath,
HOPPER_LOADER_ARGS_JSON: JSON.stringify([
resolve("tests/fixtures/hopperLaunchAdmission.mjs"),
mode,
receipt,
]),
REA_HOPPER_STARTUP_TIMEOUT_MS: String(startupTimeoutMs),
REA_LOG_LEVEL: "silent",
},
});
const mcp = new Client({ name: "hopper-late-readiness", version: "1" });
const children: ChildProcess[] = [];
let runtime: string | undefined;
const stop = async (child: ChildProcess) => {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = new Promise<void>((resolveExit) =>
child.once("exit", () => resolveExit()),
);
child.kill("SIGKILL");
await exited;
};
onTestFinished(async () => {
await Promise.all(children.map(stop));
await mcp.close();
await transport.close();
if (runtime !== undefined)
await rm(runtime, { recursive: true, force: true });
});
await mcp.connect(transport);
const call = (name: string, arguments_: Record<string, string> = {}) =>
mcp.callTool({ name, arguments: arguments_ });
expect((await call("open_binary", { path: target })).isError).not.toBe(
true,
);
const initial = parseMcpToolError(await call("current_document"));
// Capture ownership evidence before result assertions so teardown retains
// the runtime even when the asserted failure stage regresses.
const bootstrapPath = await readFile(receipt, "utf8");
runtime = dirname(bootstrapPath);
expect(initial.error).toMatchObject({
code: "cleanup_incomplete",
details: {
diagnostics: {
primary_error: {
code:
launchMode === "handoff"
? "provider_timeout"
: "provider_unavailable",
details:
launchMode === "handoff"
? { timeout_ms: startupTimeoutMs, launcher: { exit_code: 0 } }
: { stage: "launch", launcher: { exit_code: 3 } },
},
},
},
});
if (launchMode === "not-found") {
await access(`${receipt}.document-created`);
expect(initial.error.details).toMatchObject({
diagnostics: {
primary_error: { message: expect.stringContaining("Spotlight") },
},
});
}
expect(parseMcpToolError(await call("list_procedures"))).toEqual(initial);
const status = z.object({
result: z.object({
provider_operation_health: z.object({
// A timeout observes incomplete startup; a failed launcher without
// confirmed ownership leaves the provider's state unknown.
state: z.literal(
launchMode === "handoff" ? "not_started" : "unknown",
),
stage: z.literal("launch"),
}),
}),
});
status.parse((await call("binary_session")).structuredContent);
expect(parseMcpToolError(await call("close_binary")).error.code).toBe(
"cleanup_incomplete",
);
const bootstrap = await readFile(bootstrapPath, "utf8");
const setting = (name: string): string => {
const line = bootstrap
.split("\n")
.find((value) => value.startsWith(`${name} = `));
if (line === undefined)
throw new Error(`Missing bootstrap setting ${name}`);
return z.string().parse(JSON.parse(line.slice(name.length + 3)));
};
const socket = setting("REA_SOCKET");
const token = setting("REA_TOKEN");
const runId = setting("REA_RUN_ID");
runtime = dirname(socket);
const lateBridge = (identity: string, credential = token) => {
const child = spawn(
process.execPath,
[
resolve("tests/fixtures/fakeHopper.mjs"),
socket,
credential,
identity,
],
{ stdio: "ignore" },
);
children.push(child);
return child;
};
for (const [identity, credential] of [
["different-run", token],
[runId, "different-token"],
] as const) {
const wrong = lateBridge(identity, credential);
await vi.waitFor(() => access(socket));
expect(parseMcpToolError(await call("close_binary")).error.code).toBe(
"cleanup_incomplete",
);
expect(wrong.exitCode).toBeNull();
await access(runtime);
await stop(wrong);
await rm(socket, { force: true });
}
const closing = call("close_binary");
await delay(150);
// A verified close may remove the socket before the fixture can poll it.
// The successful close and child exit establish the owned bridge outcome.
const owned = lateBridge(runId);
expect((await closing).isError).not.toBe(true);
await vi.waitFor(() => expect(owned.exitCode).toBe(0));
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
await writeFile(mode, "bridge");
expect((await call("open_binary", { path: target })).isError).not.toBe(
true,
);
expect((await call("current_document")).isError).not.toBe(true);
expect((await call("close_binary")).isError).not.toBe(true);
},
);