mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 18:52:48 +00:00
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:
1 parent
eee8c5fa97
commit
fd97d0926c
8 files changed
+204
-12
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user