From fd97d0926c6ecd6a97075d587fb8669fac3b29cd Mon Sep 17 00:00:00 2001 From: TwotNguyenVN Date: Sat, 14 Nov 2026 08:08:06 +0000 Subject: [PATCH] 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> --- docs/getting-started/installation.md | 12 +++- src/domain/analysisErrorPresentation.ts | 17 +++++- src/domain/analysisErrorProjection.test.ts | 58 +++++++++++++++++++ src/domain/analysisErrorProjection.ts | 13 ++++- src/domain/hopperErrors.ts | 27 ++++++++- src/hopper/HopperClient.ts | 6 +- .../providers/hopperStartupRecovery.test.ts | 25 +++++++- .../hopper/hopperClient.helperFailure.test.ts | 58 +++++++++++++++++++ 8 files changed, 204 insertions(+), 12 deletions(-) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 8f47911f66..764e22256f 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -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, diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 493f7911ac..fa62575757 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -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; diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index c9f45c78fa..d65b884347 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -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( diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 02c07cabd5..ec9c25a68b 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -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 }), diff --git a/src/domain/hopperErrors.ts b/src/domain/hopperErrors.ts index 914049c33c..41cedc4c72 100644 --- a/src/domain/hopperErrors.ts +++ b/src/domain/hopperErrors.ts @@ -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 { diff --git a/src/hopper/HopperClient.ts b/src/hopper/HopperClient.ts index fcc2af03ea..bbfb65a762 100644 --- a/src/hopper/HopperClient.ts +++ b/src/hopper/HopperClient.ts @@ -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, }); } diff --git a/tests/acceptance/providers/hopperStartupRecovery.test.ts b/tests/acceptance/providers/hopperStartupRecovery.test.ts index 63a258f690..0c4337c3dd 100644 --- a/tests/acceptance/providers/hopperStartupRecovery.test.ts +++ b/tests/acceptance/providers/hopperStartupRecovery.test.ts @@ -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"); diff --git a/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts b/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts index c9146de8c0..67ae5c26da 100644 --- a/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts @@ -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) => {