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({