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:
5 files changed
+104
-9
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
Reference in new issue
Block a user