fix(hopper): type the Apple Events authorization denial

A hardened-runtime client without the Apple Events automation
entitlement got generic Automation advice, and the projection kept
retryable: true, so agents retried from the same unauthorized client.
The denial is now a typed launch rejection whose message and recovery
name the entitlement and the Automation setting, with retry turned off.

Fixes #9879

Squashed from #9916 and rebased onto main.

Co-Authored-By: morluto <76467478+morluto@users.noreply.github.com>
Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
authored and Ysopwnable committed 2026-11-14 14:06:23 +01:00
1 parent eee8c5fa97
commit fd97d0926c
8 files changed
+204 -12

No files matched your search

+10 -2
View File
@@ -975,8 +975,16 @@ reports `busy` at stage `launch`; `analysis_activity` describes bridge requests
and can remain idle until the socket is ready. Cleanup work reports `busy` at
stage `connection`.
If macOS denies Apple Events, allow the application that starts REA under
**System Settings → Privacy & Security → Automation**, then retry. REA releases
If macOS denies Apple Events, REA reports
`details.launch_rejection.boundary: "apple_events_authorization"` with
`reason: "not_authorized"`, category `unavailable`, and `retryable: false`.
Allow the application that starts REA under
**System Settings → Privacy & Security → Automation** when that client can be
authorized. A hardened-runtime application requires the
`com.apple.security.automation.apple-events` entitlement before macOS will
prompt; without it, ask the client developer to add the entitlement or start
REA from a client macOS can authorize, such as Terminal. Retrying from the same
unauthorized client will not succeed until authorization changes. REA releases
the target reservation when the native helper reports its complete authorization
rejection after literal argument admission. Before application handoff, REA refuses
double quotes, backslashes, or line breaks in the final launcher path or arguments,
+15 -2
View File
@@ -31,6 +31,7 @@ import {
HopperProcessError,
HopperStartError,
HopperTimeoutError,
hopperAppleEventsAuthorizationDenied,
} from "./hopperErrors.js";
import { ProviderSelectionError } from "./providerSelectionError.js";
import { ProviderAdapterError } from "./providerAdapterError.js";
@@ -65,6 +66,12 @@ export const MCP_TRANSPORT_EVIDENCE_RECORDS = `Request fewer records with get_ev
export const MCP_TRANSPORT_EVIDENCE_LINKS =
"Request fewer rows with get_evidence_bundle detail: links, a smaller limit or an exact evidence_id filter, then follow next_offset.";
const HOPPER_APPLE_EVENTS_AUTHORIZATION_MESSAGE =
"macOS denied permission to send Apple Events to Hopper. A hardened-runtime application starting REA requires the com.apple.security.automation.apple-events entitlement before macOS will prompt for permission.";
const HOPPER_APPLE_EVENTS_AUTHORIZATION_REMEDIATION =
"Allow the application that starts REA under System Settings > Privacy & Security > Automation. If the client lacks the com.apple.security.automation.apple-events entitlement, ask the client developer to add it or start REA from a client macOS can authorize, such as Terminal. Retrying from the same unauthorized client will not succeed until authorization changes.";
/** Name the next step that is true for how long the caller keeps REA's runtime and session. */
export const analysisErrorRemediationAction = (
error: AnalysisError,
@@ -170,10 +177,13 @@ export const analysisErrorRemediationAction = (
: error.stage === "launch"
? "Review the captured launcher diagnostics and Hopper setup, then retry opening the target."
: RERUN_HOPPER_INVOCATION;
if (error instanceof HopperStartError)
if (error instanceof HopperStartError) {
if (hopperAppleEventsAuthorizationDenied(error.launchRejection))
return HOPPER_APPLE_EVENTS_AUTHORIZATION_REMEDIATION;
return error.ownerRunId === undefined
? "Check the Hopper launcher and target details, then retry opening the target."
: `Use the active REA session ${error.ownerRunId} or close it before opening this target again.`;
}
if (error instanceof HopperRemoteError)
return error.diagnosticType === "invalid_request"
? "Correct the reported address, document, or arguments and retry."
@@ -585,11 +595,14 @@ const hopperErrorUserMessage = (error: AnalysisError): string | undefined => {
: `Hopper timed out during ${request} before it started.`;
}
if (error instanceof HopperProcessError) return hopperProcessMessage(error);
if (error instanceof HopperStartError)
if (error instanceof HopperStartError) {
if (hopperAppleEventsAuthorizationDenied(error.launchRejection))
return HOPPER_APPLE_EVENTS_AUTHORIZATION_MESSAGE;
return (
error.userMessage ??
"Hopper could not start. Check the launcher and target details, then retry opening the target."
);
}
if (error instanceof HopperRemoteError)
return `Hopper ${error.operation ?? "analysis"} failed (${String(error.code)}, ${error.diagnosticType}): ${error.safeMessage}`;
return undefined;
@@ -848,6 +848,64 @@ describe("analysis error projection: Hopper provider failures", () => {
});
});
it("projects an Apple Events denial as unavailable and not retryable from the same client", () => {
const projected = projectAnalysisError(
new HopperStartError({
launchRejection: {
boundary: "apple_events_authorization",
reason: "not_authorized",
},
launcherFailure: {
exit_code: 2,
signal: null,
stdout: { text: "", bytes: 0, retained_bytes: 0 },
stderr: {
text: "Cannot launch Hopper!\nNot authorized to send Apple events to Hopper Disassembler.\n",
bytes: 90,
retained_bytes: 90,
},
output_closed: true,
diagnostic_truncated: false,
},
}),
);
expect(projected).toMatchObject({
code: "provider_unavailable",
category: "unavailable",
retryable: false,
details: {
stage: "launch",
provider_state: "unknown",
retry_action: "unknown",
launch_rejection: {
boundary: "apple_events_authorization",
reason: "not_authorized",
},
launcher: { exit_code: 2, signal: null },
},
});
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
});
it("keeps an ordinary Hopper start failure retryable", () => {
const projected = projectAnalysisError(
new HopperStartError({
userMessage: "Hopper's launcher helper failed before bridge readiness.",
}),
);
expect(projected).toMatchObject({
code: "provider_unavailable",
category: "unavailable",
retryable: true,
details: {
stage: "launch",
provider_state: "unknown",
retry_action: "retry",
},
});
expect(projected.details).not.toHaveProperty("launch_rejection");
});
it("retains the Hopper request and provider diagnostic on a remote failure", () => {
expect(
projectAnalysisError(
+11 -2
View File
@@ -44,6 +44,7 @@ import {
HopperRemoteError,
HopperStartError,
HopperTimeoutError,
hopperAppleEventsAuthorizationDenied,
} from "./hopperErrors.js";
import { ProviderAdapterError } from "./providerAdapterError.js";
import { ProviderSelectionError } from "./providerSelectionError.js";
@@ -522,7 +523,11 @@ const projectError = (
category: analysisErrorCategory(error),
message: analysisErrorUserMessage(error, callerLifetime),
retryable:
RETRYABLE_CODES.has(code) ||
(RETRYABLE_CODES.has(code) &&
!(
error instanceof HopperStartError &&
hopperAppleEventsAuthorizationDenied(error.launchRejection)
)) ||
(error instanceof AnalysisCapabilityUnavailableError &&
error.retryable) ||
(error._tag === "ProcessCaptureError" &&
@@ -953,7 +958,11 @@ const providerErrorDetails = (
...(error.launchRejection === undefined
? {}
: { launch_rejection: { ...error.launchRejection } }),
retry_action: error.ownerRunId === undefined ? "retry" : "unknown",
retry_action:
error.ownerRunId === undefined &&
!hopperAppleEventsAuthorizationDenied(error.launchRejection)
? "retry"
: "unknown",
...(error.ownerRunId === undefined
? {}
: { owner_run_id: error.ownerRunId }),
+25 -2
View File
@@ -199,7 +199,12 @@ export class HopperProcessError extends HopperError {
}
}
/** Parsed facts about rejecting an application before execution. */
/**
* Parsed facts about a Hopper launch refused before the target runs.
* Publisher and argument failures are decided before the launcher executes.
* Apple Events authorization is decided after the native helper starts and
* before document handoff.
*/
export type HopperLaunchRejection =
| {
/** The selected application was rejected before it was executed. */
@@ -217,7 +222,25 @@ export type HopperLaunchRejection =
/** Zero-based index in the final vendor argv, excluding the executable. */
readonly argument_index: number;
}
));
))
| {
/**
* macOS refused the launcher's Apple Event. The helper's stderr does not
* show whether the responsible client lacks an Automation grant or the
* hardened-runtime Apple Events entitlement. Another launch from that
* same client cannot succeed until authorization changes.
*/
readonly boundary: "apple_events_authorization";
readonly reason: "not_authorized";
};
/** Recognize the Apple Events refusal that the same client cannot retry. */
export const hopperAppleEventsAuthorizationDenied = (
rejection: HopperLaunchRejection | undefined,
): rejection is Extract<
HopperLaunchRejection,
{ readonly boundary: "apple_events_authorization" }
> => rejection?.boundary === "apple_events_authorization";
/** Hopper or the repository bridge could not be started. */
export class HopperStartError extends HopperError {
+4 -2
View File
@@ -1055,8 +1055,10 @@ export class HopperClient {
) {
this.#resources.shutdownConfirmed = true;
return new HopperStartError({
userMessage:
"macOS denied permission to send Apple Events to Hopper. Allow the application that starts REA under System Settings > Privacy & Security > Automation, then retry opening the target.",
launchRejection: {
boundary: "apple_events_authorization",
reason: "not_authorized",
},
launcherFailure,
});
}
@@ -201,8 +201,16 @@ test("recovers a denied Hopper launch through the same stdio MCP session and CLI
const { error } = parseMcpToolError(await call(operation));
expect(error).toMatchObject({
code: "provider_unavailable",
category: "unavailable",
retryable: false,
details: {
stage: "launch",
provider_state: "unknown",
retry_action: "unknown",
launch_rejection: {
boundary: "apple_events_authorization",
reason: "not_authorized",
},
launcher: {
exit_code: 2,
stderr: {
@@ -213,7 +221,11 @@ test("recovers a denied Hopper launch through the same stdio MCP session and CLI
},
},
});
expect(error.message).toContain("Automation");
expect(error.remediation.action).toContain("client developer");
expect(error.remediation.action).toContain("Terminal");
expect(error.remediation.action).toContain(
"Retrying from the same unauthorized client will not succeed until authorization changes",
);
const bootstrap = await readFile(receipt, "utf8");
await expect(access(dirname(bootstrap))).rejects.toMatchObject({
code: "ENOENT",
@@ -227,7 +239,16 @@ test("recovers a denied Hopper launch through the same stdio MCP session and CLI
expect(deniedCli.exitCode).toBe(1);
expect(deniedCli.json).toMatchObject({
code: "provider_unavailable",
details: { launcher: { exit_code: 2 } },
category: "unavailable",
retryable: false,
details: {
retry_action: "unknown",
launch_rejection: {
boundary: "apple_events_authorization",
reason: "not_authorized",
},
launcher: { exit_code: 2 },
},
});
await writeFile(mode, "bridge");
@@ -201,6 +201,64 @@ it.skipIf(process.platform !== "darwin")(
},
);
it("classifies a pre-handoff Apple Events denial as not retryable from the same client", async () => {
const stderr =
"Cannot launch Hopper!\nNot authorized to send Apple events to Hopper Disassembler.\n";
const launcher: BridgeLauncher = {
launch() {
return Promise.resolve(
ok({
process: spawn(
process.execPath,
[
"-e",
`process.stderr.write(${JSON.stringify(stderr)}, () => process.exit(2));`,
],
{ stdio: ["ignore", "pipe", "pipe"] },
),
ownsProcessLifetime: false as const,
providerLifetime: "external-application" as const,
shutdownMode: "bridge-request" as const,
authorizationDenialBeforeHandoff: true as const,
}),
);
},
};
const client = new HopperClient({ launcher, startupTimeoutMs: 10_000 });
onTestFinished(async () => {
const closed = await client.close();
if (!closed.ok) throw closed.error;
});
const failed = await client.start();
expect(failed).toMatchObject({
ok: false,
error: {
_tag: "HopperStartError",
cleanupIncomplete: false,
launchRejection: {
boundary: "apple_events_authorization",
reason: "not_authorized",
},
},
});
if (failed.ok) throw new Error("Expected Apple Events denial");
expect(projectAnalysisError(failed.error)).toMatchObject({
code: "provider_unavailable",
category: "unavailable",
retryable: false,
details: {
stage: "launch",
provider_state: "unknown",
retry_action: "unknown",
launch_rejection: {
boundary: "apple_events_authorization",
reason: "not_authorized",
},
launcher: { exit_code: 2, signal: null },
},
});
});
it.each(["exit", "signal", "exit75"] as const)(
"reports a failed non-owning launcher %s without waiting for bridge timeout or inventing a GUI exit",
async (termination) => {