Replace fixed Linux Hopper release pins with current vendor feed metadata, complete detached-PGP package verification under the stable publisher primary key, and authenticated package evidence for offline admission. Share parsed publisher facts across approved setup, doctor and the pre-execution guard. Preserve existing ownership, cancellation, private-display and cleanup rules. Fixes #3577 Normalize empty XDG settings, preserve backups during approved cache repair, and retain publisher cleanup through cancellation and shutdown. Exercise cold offline verification without reusing process-local proof state. Serialize approved cache publication across processes, restore old evidence on failure, and share the existing lease owner from process without changing its identities. Validation: 438 focused tests (six platform-related skips), full static/documentation checks, real Ubuntu 24.04 official Hopper 6.6.0 setup/CLI/MCP, offline ordinary-user admission, launcher mutation rejection, and cold/warm verification measurements. Official RPM metadata mismatch is tracked separately in #3578 and fails closed. BREAKING CHANGE: Older Linux Hopper builds admitted by the fixed allowlist now require retained publisher-signed package evidence or an approved update to the current public demo. Update explicit HOPPER_LAUNCHER_PATH values when migrating; the live feed exposes only its current release. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
355 lines
13 KiB
TypeScript
355 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(
|
|
"retains a denial-shaped failure when caller arguments can alter the helper's AppleScript",
|
|
{ timeout: 90_000 },
|
|
async ({ workspace }) => {
|
|
for (const source of ["target", "loader"] as const) {
|
|
const target = workspace.path(
|
|
source === "target" ? 'quoted"target' : "loader-target",
|
|
);
|
|
await copyFile("/usr/bin/true", target);
|
|
const mode = await workspace.write(
|
|
`${source}-mode`,
|
|
"denied-after-handoff",
|
|
);
|
|
const receipt = workspace.path(`${source}-bootstrap`);
|
|
const loaderArgs = [
|
|
resolve("tests/fixtures/hopperLaunchAdmission.mjs"),
|
|
mode,
|
|
receipt,
|
|
...(source === "loader" ? ['caller"loader'] : []),
|
|
];
|
|
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(loaderArgs),
|
|
REA_LOG_LEVEL: "silent",
|
|
},
|
|
});
|
|
const mcp = new Client({ name: "hopper-literal-boundary", version: "1" });
|
|
let runtime: string | undefined;
|
|
try {
|
|
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 first = parseMcpToolError(await call("current_document"));
|
|
runtime = dirname(await readFile(receipt, "utf8"));
|
|
expect(first.error).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
diagnostics: {
|
|
primary_error: {
|
|
code: "provider_unavailable",
|
|
details: {
|
|
launcher: {
|
|
exit_code: 2,
|
|
stderr: {
|
|
text: expect.stringContaining(
|
|
"Not authorized to send Apple events",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
await access(`${receipt}.document-created`);
|
|
await access(runtime);
|
|
expect(parseMcpToolError(await call("list_procedures"))).toEqual(first);
|
|
expect(parseMcpToolError(await call("close_binary")).error.code).toBe(
|
|
"cleanup_incomplete",
|
|
);
|
|
} finally {
|
|
// SDK close can return immediately after SIGKILL. Wait for its actual
|
|
// child-close event before recovering this process's retained lease.
|
|
let serverExited: Promise<void> | undefined;
|
|
if (transport.pid !== null) {
|
|
const onclose = transport.onclose;
|
|
serverExited = new Promise<void>((resolveExit) => {
|
|
transport.onclose = () => {
|
|
onclose?.();
|
|
resolveExit();
|
|
};
|
|
});
|
|
}
|
|
await mcp.close();
|
|
await transport.close();
|
|
await serverExited;
|
|
// The fixture created only this marker, never a native GUI document.
|
|
if (runtime !== undefined)
|
|
await rm(runtime, { recursive: true, force: true });
|
|
const acquired = await acquireHopperTargetLease({
|
|
targetPath: target,
|
|
targetKind: "executable",
|
|
loaderArgs,
|
|
runId: "literal-boundary-test-cleanup",
|
|
});
|
|
if (!acquired.acquired) throw new Error("Fixture lease remained live");
|
|
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);
|
|
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: "2000",
|
|
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"));
|
|
expect(initial.error).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
diagnostics: {
|
|
primary_error: {
|
|
code:
|
|
launchMode === "handoff"
|
|
? "provider_timeout"
|
|
: "provider_unavailable",
|
|
details:
|
|
launchMode === "handoff"
|
|
? { timeout_ms: 2000, 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(await readFile(receipt, "utf8"), "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);
|
|
},
|
|
);
|