fix(hopper): recover from a Spotlight not-found launch (#2795)

* fix(hopper): recover from a Spotlight not-found launch

Hopper's native launcher locates the application through a fixed Spotlight
query before any Apple event. With indexing disabled it exits 3 with a
not-found report, but REA treated the external document as unconfirmed,
retained the runtime and failed every later open_binary until the server
restarted. Recognize the complete report as a pre-handoff failure, confirm
no document exists, and tell the caller to enable Spotlight indexing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(hopper): require empty stdout for native launch signatures

* fix(hopper): retain ownership after unverified Spotlight reports

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-14 06:08:31 +08:00
1 parent 6510d0d57f
commit 4d98123279
5 files changed
+104 -9

No files matched your search

+8
View File
@@ -59,6 +59,7 @@ import {
import { connectHopperSocketOnce } from "./HopperSocketConnection.js";
import {
hopperLauncherFailureDiagnostic,
hopperLauncherReportedApplicationNotFound,
nativeHopperLaunchWasDenied,
} from "./HopperProcessDiagnostic.js";
import { DEFAULT_HOPPER_STARTUP_TIMEOUT_MS } from "../config/providerStartupTimeout.js";
@@ -768,6 +769,13 @@ export class HopperClient {
launcherFailure,
});
}
if (hopperLauncherReportedApplicationNotFound(launcherFailure)) {
return new HopperStartError({
userMessage:
"Hopper's launcher could not find Hopper because it relies on Spotlight. Enable Spotlight indexing for the volume that contains Hopper (for example: sudo mdutil -i on /System/Volumes/Data), wait for indexing to finish, then retry opening the target.",
launcherFailure,
});
}
return new HopperStartError({
userMessage:
"Hopper's launcher helper failed before bridge readiness. Review details.launcher and complete Hopper setup before retrying.",
+47 -1
View File
@@ -1,7 +1,10 @@
import { expect, it } from "vitest";
import type { HopperLauncherOutcome } from "../domain/hopperErrors.js";
import { nativeHopperLaunchWasDenied } from "./HopperProcessDiagnostic.js";
import {
hopperLauncherReportedApplicationNotFound,
nativeHopperLaunchWasDenied,
} from "./HopperProcessDiagnostic.js";
// Captured from Hopper 6.1.0-demo and 6.6.0-demo's unmodified helpers with native
// AppleScript error -1743 before open dispatch. The target script never ran.
@@ -23,6 +26,8 @@ it("recognizes the native helper's complete authorization rejection", () => {
});
it.each([
{ stdout: { text: "wrapper output\n", bytes: 15, retained_bytes: 15 } },
{ stdout: { text: "", bytes: 1, retained_bytes: 0 } },
{ output_closed: false },
{ diagnostic_truncated: true },
{ signal: "SIGTERM", exit_code: null },
@@ -52,3 +57,44 @@ it.each([
expect(nativeHopperLaunchWasDenied({ ...denied, ...change })).toBe(false);
},
);
// Captured from Hopper 6's unmodified helper with Spotlight indexing disabled.
const notFound: HopperLauncherOutcome = {
exit_code: 3,
signal: null,
stdout: { text: "", bytes: 0, retained_bytes: 0 },
stderr: {
text: "Hopper has not been found.\nPlease make sure that Spotlight is active in order for this tool to find Hopper's executable.\n",
bytes: 121,
retained_bytes: 121,
},
output_closed: true,
diagnostic_truncated: false,
};
it("recognizes the native helper's complete Spotlight not-found report", () => {
expect(hopperLauncherReportedApplicationNotFound(notFound)).toBe(true);
expect(nativeHopperLaunchWasDenied(notFound)).toBe(false);
});
it.each([
{ stdout: { text: "wrapper output\n", bytes: 15, retained_bytes: 15 } },
{ stdout: { text: "", bytes: 1, retained_bytes: 0 } },
{ output_closed: false },
{ diagnostic_truncated: true },
{ signal: "SIGTERM", exit_code: null },
{ exit_code: 2 },
{
stderr: {
...notFound.stderr,
text: `${notFound.stderr.text}another failure\n`,
},
},
])(
"rejects other not-found diagnostic shapes: %j",
(change) => {
expect(
hopperLauncherReportedApplicationNotFound({ ...notFound, ...change }),
).toBe(false);
},
);
+25 -2
View File
@@ -6,6 +6,14 @@ import type { ProviderProcessDiagnostic } from "../process/ProviderProcess.js";
import { parseLinuxPrivateDisplayDiagnostic } from "./LinuxPrivateDisplayDiagnostic.js";
import type { HopperLauncherOutcome } from "../domain/hopperErrors.js";
/** Recognize launcher stderr signatures only with complete, empty stdout. */
const hasCompleteLauncherOutput = (outcome: HopperLauncherOutcome): boolean =>
outcome.output_closed &&
!outcome.diagnostic_truncated &&
outcome.stdout.text === "" &&
outcome.stdout.bytes === 0 &&
outcome.stdout.retained_bytes === 0;
/**
* Hopper's native helper sends activate before its open-document Apple event.
* Recognize its explicit authorization rejection. The launcher must separately
@@ -18,12 +26,27 @@ export const nativeHopperLaunchWasDenied = (
outcome !== undefined &&
outcome.exit_code === 2 &&
outcome.signal === null &&
outcome.output_closed &&
!outcome.diagnostic_truncated &&
hasCompleteLauncherOutput(outcome) &&
/^Cannot launch Hopper!\r?\nNot authorized to send Apple events to [^\r\n]+\.\r?\n?$/u.test(
outcome.stderr.text,
);
/**
* Recognize the launcher's complete Spotlight diagnostic for actionable setup
* guidance. Custom launchers may forward this report after a handoff, so the
* report does not establish whether a document exists or release ownership.
*/
export const hopperLauncherReportedApplicationNotFound = (
outcome: HopperLauncherOutcome | undefined,
): outcome is HopperLauncherOutcome =>
outcome !== undefined &&
outcome.exit_code === 3 &&
outcome.signal === null &&
hasCompleteLauncherOutput(outcome) &&
/^Hopper has not been found\.\r?\nPlease make sure that Spotlight is active in order for this tool to find Hopper's executable\.\r?\n?$/u.test(
outcome.stderr.text,
);
/** Extract only a matching Linux adapter failure record. */
export const hopperLauncherFailureDiagnostic = (
event: Extract<ProviderProcessDiagnostic, { readonly type: "exit" }>,
@@ -201,13 +201,13 @@ test("recovers a denied Hopper launch through the same stdio MCP session and CLI
).toBe(false);
});
test(
"preserves a timed-out handoff and closes only a late bridge with matching run identity",
test.each(["handoff", "not-found"] as const)(
"preserves an unconfirmed %s handoff and closes only a late bridge with matching run identity",
{ timeout: 60_000 },
async ({ workspace }) => {
async (launchMode, { workspace }) => {
const target = workspace.path("target");
await copyFile("/usr/bin/true", target);
const mode = await workspace.write("mode", "handoff");
const mode = await workspace.write("mode", launchMode);
const receipt = workspace.path("bootstrap-path");
const transport = new StdioClientTransport({
command: process.execPath,
@@ -257,12 +257,24 @@ test(
details: {
diagnostics: {
primary_error: {
code: "provider_timeout",
details: { timeout_ms: 2000, launcher: { exit_code: 0 } },
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")
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({
+6
View File
@@ -19,6 +19,12 @@ if (mode === "denied" || mode === "denied-after-handoff") {
process.exit(2);
},
);
} else if (mode === "not-found") {
// The native helper's Spotlight lookup fails before any Apple event.
process.stderr.write(
"Hopper has not been found.\nPlease make sure that Spotlight is active in order for this tool to find Hopper's executable.\n",
() => process.exit(3),
);
} else if (mode === "handoff") {
process.exit(0);
} else if (mode === "bridge") {