From 9a1100d775c7f6d579ada5d30ff51b51ba634d5c Mon Sep 17 00:00:00 2001
From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
Date: Sat, 29 Aug 2026 22:23:39 -0700
Subject: [PATCH] Sign desktop with the Apple Events entitlement so macOS can
prompt
---
.changeset/desktop-apple-events.md | 18 +++++++++++
apps/desktop/build/entitlements.mac.plist | 9 ++++++
apps/desktop/electron-builder.config.ts | 7 +++++
packages/plugins/mcp/src/sdk/plugin.ts | 38 +++++++++++++++++++----
4 files changed, 66 insertions(+), 6 deletions(-)
create mode 100644 .changeset/desktop-apple-events.md
diff --git a/.changeset/desktop-apple-events.md b/.changeset/desktop-apple-events.md
new file mode 100644
index 000000000..56dd32c39
--- /dev/null
+++ b/.changeset/desktop-apple-events.md
@@ -0,0 +1,18 @@
+---
+"@executor-js/desktop": patch
+"@executor-js/plugin-mcp": patch
+---
+
+Let macOS ask before denying Codex plugins Automation access. The desktop app
+and its bundled daemon are hardened-runtime signed without the Apple Events
+entitlement, so tccd refused to even show the consent prompt: every Messages
+call was denied silently, no Automation row was ever created in System
+Settings, and the access check sat on "Checking…" for a full minute before
+misreporting the hang as a failed start. The app and daemon are now signed
+with `com.apple.security.automation.apple-events` and carry a usage
+description, so the first call raises the real consent prompt and the grant
+becomes visible in Privacy & Security → Automation.
+
+The access check also stops waiting after 25 seconds and says what a hang
+means — answer the permission prompt on screen, then check again — instead of
+blaming the Codex install.
diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist
index 043ecf496..c5a652b65 100644
--- a/apps/desktop/build/entitlements.mac.plist
+++ b/apps/desktop/build/entitlements.mac.plist
@@ -17,5 +17,14 @@
of the Electron bundle's signing chain. -->
com.apple.security.cs.disable-library-validation
+
+ com.apple.security.automation.apple-events
+
diff --git a/apps/desktop/electron-builder.config.ts b/apps/desktop/electron-builder.config.ts
index 831b92a5a..39a15aa39 100644
--- a/apps/desktop/electron-builder.config.ts
+++ b/apps/desktop/electron-builder.config.ts
@@ -37,6 +37,13 @@ const config: Configuration = {
entitlements: "build/entitlements.mac.plist",
entitlementsInherit: "build/entitlements.mac.plist",
notarize: true,
+ extendInfo: {
+ // Shown in the macOS Automation consent prompt. Required alongside the
+ // apple-events entitlement in entitlements.mac.plist: without the
+ // usage string, tccd declines to prompt and denies silently.
+ NSAppleEventsUsageDescription:
+ "Executor runs local plugins that control apps like Messages on your behalf.",
+ },
},
// Same arch rule as mac (see comment above): never pin `arch:` in the
// target objects. The win/linux pins used to force both archs out of a
diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts
index 774fe38b8..c075e0ec6 100644
--- a/packages/plugins/mcp/src/sdk/plugin.ts
+++ b/packages/plugins/mcp/src/sdk/plugin.ts
@@ -1391,6 +1391,20 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => {
// scanner touches node:fs, so it stays behind a dynamic import (the
// stdio-connector pattern) and behind the stdio gate: with stdio off the
// presets could not be added anyway.
+ /** How long the probe waits for the plugin to answer. Deliberately
+ * under the MCP SDK's 60s default: a pending macOS consent prompt
+ * blocks the call indefinitely, and the person should be told to look
+ * for the prompt rather than watch "Checking…" for a minute. */
+ const PROBE_ANSWER_TIMEOUT_MS = 25_000;
+ /** The client SDK signals its request timeout as an `SdkError` with
+ * code `REQUEST_TIMEOUT`. Matched structurally: the SDK is loaded
+ * dynamically, so its error class is not importable here. */
+ const isMcpRequestTimeout = (cause: unknown): boolean =>
+ typeof cause === "object" &&
+ cause !== null &&
+ "code" in cause &&
+ (cause as { readonly code: unknown }).code === "REQUEST_TIMEOUT";
+
/** Ask a Codex plugin whether macOS will actually let it work.
*
* Runs the plugin's own read-only probe tool down the REAL path — the
@@ -1424,15 +1438,26 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => {
...(plugin.appServer === undefined ? {} : { appServer: plugin.appServer }),
});
+ // A probe that hangs is a real outcome, not an edge case: an Apple
+ // Event blocks for as long as macOS sits on the consent decision.
+ // Under the SDK's 60s default the card shows "Checking…" for a full
+ // minute and then misreports the hang as a failed start.
+ let timedOut = false;
return yield* Effect.gen(function* () {
const connection = yield* connector;
const result = yield* Effect.tryPromise({
- try: () => connection.client.callTool({ name: probe.name, arguments: probe.args }),
- catch: () =>
- new McpConnectionError({
+ try: () =>
+ connection.client.callTool(
+ { name: probe.name, arguments: probe.args },
+ { timeout: PROBE_ANSWER_TIMEOUT_MS },
+ ),
+ catch: (cause) => {
+ timedOut = isMcpRequestTimeout(cause);
+ return new McpConnectionError({
transport: "appserver",
message: "The plugin did not answer.",
- }),
+ });
+ },
}).pipe(Effect.ensuring(Effect.promise(() => connection.close())));
const text = (Array.isArray(result.content) ? result.content : [])
@@ -1454,8 +1479,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => {
McpConnectionError: () =>
Effect.succeed({
status: "blocked" as const,
- message:
- "Could not start the plugin. Check that Codex is installed and signed in.",
+ message: timedOut
+ ? "macOS has not answered yet. If a permission prompt is on screen, answer it, then check again."
+ : "Could not start the plugin. Check that Codex is installed and signed in.",
}),
McpOAuthReauthorizationRequired: () =>
Effect.succeed({