Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/desktop-apple-events.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions apps/desktop/build/entitlements.mac.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,14 @@
of the Electron bundle's signing chain. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Codex plugins (Messages) drive other apps via Apple Events, and TCC
attributes those to the RESPONSIBLE process — this app and the
bundled executor daemon, both signed with this file. Under hardened
runtime, tccd refuses to even PROMPT without this entitlement
("Policy disallows prompt"): the call is denied silently, no
Automation row is created, and there is nothing for the user to
enable. -->
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
7 changes: 7 additions & 0 deletions apps/desktop/electron-builder.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 32 additions & 6 deletions packages/plugins/mcp/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 : [])
Expand All @@ -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({
Expand Down
Loading