From 8f8b02d8d9e3ea2ef23b9a3fb1580ea6cb631f2c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:37:02 -0700 Subject: [PATCH 1/8] Explain macOS permissions for Codex plugins --- .changeset/codex-permission-onboarding.md | 14 ++ packages/plugins/mcp/src/api/group.ts | 2 + packages/plugins/mcp/src/api/handlers.ts | 7 +- .../plugins/mcp/src/react/CodexPluginAdd.tsx | 34 +++++ .../mcp/src/sdk/appserver-connector.test.ts | 24 +++- .../mcp/src/sdk/appserver-connector.ts | 27 ++++ .../mcp/src/sdk/appserver-test-server.ts | 14 ++ .../mcp/src/sdk/codex-permissions.test.ts | 79 +++++++++++ .../plugins/mcp/src/sdk/codex-permissions.ts | 130 ++++++++++++++++++ .../plugins/mcp/src/sdk/codex-plugins.test.ts | 17 ++- packages/plugins/mcp/src/sdk/codex-plugins.ts | 2 + packages/plugins/mcp/src/sdk/connection.ts | 3 +- packages/plugins/mcp/src/sdk/plugin.ts | 1 + packages/plugins/mcp/src/sdk/types.ts | 3 + 14 files changed, 349 insertions(+), 8 deletions(-) create mode 100644 .changeset/codex-permission-onboarding.md create mode 100644 packages/plugins/mcp/src/sdk/codex-permissions.test.ts create mode 100644 packages/plugins/mcp/src/sdk/codex-permissions.ts diff --git a/.changeset/codex-permission-onboarding.md b/.changeset/codex-permission-onboarding.md new file mode 100644 index 000000000..59f7ed28f --- /dev/null +++ b/.changeset/codex-permission-onboarding.md @@ -0,0 +1,14 @@ +--- +"executor": patch +"@executor-js/plugin-mcp": patch +--- + +Explain macOS permissions for Codex plugins instead of failing with an opaque +error. A refused grant used to surface as `Internal tool error [id]` — the +plugin reports "Unknown error" and only a numeric code says what happened, so +neither the user nor the model could tell that macOS was the blocker. + +The bridge now recognises those codes and answers with the grant to enable and +where to find it. Each plugin's add screen also states what macOS will ask for +before anything runs, with a link straight to the right Privacy pane — macOS +asks once, and a dismissed prompt never returns. diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index fd1ac3a87..3defd60e3 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -76,6 +76,7 @@ const AddStdioServerPayload = Schema.Struct({ server: Schema.String, surface: Schema.optional(Schema.Literals(["sky", "browser"])), modulePath: Schema.optional(Schema.String), + presetId: Schema.optional(Schema.String), }), ), slug: Schema.optional(Schema.String), @@ -166,6 +167,7 @@ const CodexPluginEntrySchema = Schema.Struct({ server: Schema.String, surface: Schema.optional(Schema.Literals(["sky", "browser"])), modulePath: Schema.optional(Schema.String), + presetId: Schema.optional(Schema.String), }), ), setupHint: Schema.optional(Schema.String), diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index bbe914636..5542a682e 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -43,7 +43,12 @@ const toServerInput = ( cwd?: string; versionNegotiation?: "legacy" | "auto"; spawnPerCall?: boolean; - appServer?: { server: string; surface?: "sky" | "browser"; modulePath?: string }; + appServer?: { + server: string; + surface?: "sky" | "browser"; + modulePath?: string; + presetId?: string; + }; slug?: string; }; return { diff --git a/packages/plugins/mcp/src/react/CodexPluginAdd.tsx b/packages/plugins/mcp/src/react/CodexPluginAdd.tsx index 5075aff6a..df35a782f 100644 --- a/packages/plugins/mcp/src/react/CodexPluginAdd.tsx +++ b/packages/plugins/mcp/src/react/CodexPluginAdd.tsx @@ -10,6 +10,7 @@ import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add"; import { addMcpServer, codexPluginsAtom } from "./atoms"; +import { CODEX_PERMISSIONS } from "../sdk/codex-permissions"; // --------------------------------------------------------------------------- // Focused add screen for one Codex plugin, reached from its catalog preset @@ -35,6 +36,8 @@ export default function CodexPluginAdd(props: { ? pluginsResult.value.plugins.find((entry) => entry.id === props.presetId) : undefined; + const permissions = CODEX_PERMISSIONS[props.presetId] ?? []; + const added = plugin !== undefined && AsyncResult.isSuccess(integrationsResult) && @@ -153,6 +156,37 @@ export default function CodexPluginAdd(props: { )} + {/* Stated up front, not discovered on first failure: macOS asks for + these ONCE, and a dismissed prompt never returns — after that the + plugin fails with an error the user cannot act on. Each row links + straight to the pane that holds the switch. */} + {permissions.length > 0 && ( +
+ + macOS access + +

+ macOS asks the first time this runs. If you miss the prompt, it will not ask again — + enable it here. +

+ +
+ )} + {error !== null &&

{error}

} diff --git a/packages/plugins/mcp/src/sdk/appserver-connector.test.ts b/packages/plugins/mcp/src/sdk/appserver-connector.test.ts index b88fdaa05..4ef206064 100644 --- a/packages/plugins/mcp/src/sdk/appserver-connector.test.ts +++ b/packages/plugins/mcp/src/sdk/appserver-connector.test.ts @@ -23,7 +23,7 @@ const appServerInput = ( command: "bun", args: ["run", fixture], env: { CODEX_HOME: "/tmp/fixture-codex-home" }, - appServer: { server, ...appServer }, + appServer: { server, presetId: "codex-messages", ...appServer }, }); const withConnection = (input: StdioConnectorInput) => @@ -42,6 +42,7 @@ describe("codex app-server bridge", () => { "announce_restart", "echo", "needs_approval", + "permission_denied", ]); const echo = tools.tools.find(({ name }) => name === "echo"); expect(echo?.description).toBe("Echo the arguments back"); @@ -148,6 +149,27 @@ describe("codex app-server bridge", () => { ), ); + it.effect("turns a macOS refusal into the grant the user has to enable", () => + Effect.scoped( + Effect.gen(function* () { + // The plugin says "Unknown error" and the code is scrubbed to an + // opaque id further up, so the one place this can be made actionable + // is here, while the plugin identity is still known. + const connection = yield* withConnection(appServerInput("messages")); + + const result = yield* Effect.promise(() => + connection.client.callTool({ name: "permission_denied", arguments: {} }), + ); + const text = (result.content as readonly { readonly text: string }[])[0]!.text; + + expect(result.isError).toBe(true); + expect(text, "names the block").toContain("macOS blocked this"); + expect(text, "and the exact switch").toContain('"Executor → Messages"'); + expect(text, "not the plugin's own wording").not.toContain("Unknown error"); + }), + ), + ); + // ------------------------------------------------------------------------- // Computer Use: projected onto `node_repl`, not a server of its own. // ------------------------------------------------------------------------- diff --git a/packages/plugins/mcp/src/sdk/appserver-connector.ts b/packages/plugins/mcp/src/sdk/appserver-connector.ts index 5f4333eec..0f2703c44 100644 --- a/packages/plugins/mcp/src/sdk/appserver-connector.ts +++ b/packages/plugins/mcp/src/sdk/appserver-connector.ts @@ -40,6 +40,7 @@ import type { JSONRPCMessage, JSONRPCRequest, Transport } from "@modelcontextpro import { Option, Schema } from "effect"; import { browserCallProgram, browserToolList, findBrowserTool } from "./codex-browser-tools"; +import { permissionFailure, permissionFailureMessage } from "./codex-permissions"; import { findSkyTool, skyCallProgram, skyToolList } from "./codex-sky-tools"; import { stdioSpawnEnv, type StdioTransportConfig } from "./stdio-connector"; @@ -79,6 +80,9 @@ export type AppServerTransportConfig = StdioTransportConfig & { /** The MCP server name inside Codex whose tools this transport exposes * (e.g. `messages`) — the `server` of every `mcpServer/tool/call`. */ readonly server: string; + /** Which curated plugin this is, so a macOS permission failure can name the + * exact grant to enable. Absent skips that translation. */ + readonly presetId?: string; /** A projected tool surface for a plugin driven through `node_repl` rather * than serving MCP itself: `sky` is Computer Use (`codex-sky-tools.ts`), * `browser` is Chrome (`codex-browser-tools.ts`). Absent exposes the @@ -473,6 +477,29 @@ class AppServerClientTransport implements Transport { }); return; } + + // A macOS permission denial arrives as a plugin-level error result whose + // own text is "Unknown error" — the numeric code is the only signal, and + // it is scrubbed to an opaque internal id further up. Replace it here, + // while the plugin identity is still known, with the grant to enable. + if (result.value.isError === true) { + const text = (result.value.content ?? []) + .map((block) => (block as { readonly text?: unknown }).text) + .filter((value): value is string => typeof value === "string") + .join(" "); + const permission = permissionFailure(text, this.#config.presetId); + if (permission !== null) { + this.#emit({ + jsonrpc: "2.0", + id: message.id, + result: { + content: [{ type: "text", text: permissionFailureMessage(permission) }], + isError: true, + }, + }); + return; + } + } this.#emit({ jsonrpc: "2.0", id: message.id, diff --git a/packages/plugins/mcp/src/sdk/appserver-test-server.ts b/packages/plugins/mcp/src/sdk/appserver-test-server.ts index 4cef7242c..fc106f059 100644 --- a/packages/plugins/mcp/src/sdk/appserver-test-server.ts +++ b/packages/plugins/mcp/src/sdk/appserver-test-server.ts @@ -62,6 +62,11 @@ const TOOLS = { description: "Echo the arguments back", inputSchema: { type: "object", properties: { text: { type: "string" } } }, }, + permission_denied: { + name: "permission_denied", + description: "Fails the way macOS refusal presents", + inputSchema: { type: "object", properties: {} }, + }, announce_restart: { name: "announce_restart", description: "Emit server status notifications", @@ -224,6 +229,15 @@ const handleToolCall = (id: number | string, params: unknown): void => { }); return; } + if (call.tool === "permission_denied") { + // Verbatim shape of a macOS TCC refusal: an error result whose only clue + // is the numeric code. + reply(id, { + content: [{ type: "text", text: "Computer Use server error -1743: Unknown error" }], + isError: true, + }); + return; + } if (call.tool === "needs_approval") { if (!elicitationsAllowed) { // Exactly what Codex returns when it declines the elicitation for the diff --git a/packages/plugins/mcp/src/sdk/codex-permissions.test.ts b/packages/plugins/mcp/src/sdk/codex-permissions.test.ts new file mode 100644 index 000000000..51a753c8b --- /dev/null +++ b/packages/plugins/mcp/src/sdk/codex-permissions.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + CODEX_PERMISSIONS, + permissionFailure, + permissionFailureMessage, +} from "./codex-permissions"; + +// --------------------------------------------------------------------------- +// The string below is verbatim what a Codex plugin returns when macOS has +// refused the grant — the numeric code is the ONLY signal, since the plugin's +// own wording is "Unknown error". +// --------------------------------------------------------------------------- + +const DENIED = "Computer Use server error -1743: Unknown error"; + +describe("permissionFailure", () => { + it("recognises a denial behind the plugin's own 'Unknown error'", () => { + const permission = permissionFailure(DENIED, "codex-messages"); + + expect(permission?.id).toBe("automation"); + // Automation is granted per HOST, so the entry names executor, not + // anything Codex ships. + expect(permission?.entry).toBe("Executor → Messages"); + }); + + it("recognises the other codes the same denial presents as", () => { + for (const code of [-609, -600]) { + expect(permissionFailure(`server error ${code}: Unknown error`, "codex-messages")).not.toBe( + null, + ); + } + }); + + it("leaves ordinary tool failures alone", () => { + expect(permissionFailure("No chat matched that name", "codex-messages")).toBe(null); + // A number that merely contains a code must not trip it. + expect(permissionFailure("read 21743 rows", "codex-messages")).toBe(null); + expect(permissionFailure("error -17430: other", "codex-messages")).toBe(null); + }); + + it("returns nothing to say for an unknown plugin", () => { + expect(permissionFailure(DENIED, undefined)).toBe(null); + expect(permissionFailure(DENIED, "codex-openai-docs")).toBe(null); + }); +}); + +describe("permissionFailureMessage", () => { + it("says what was blocked, which switch to flip, and that macOS will not ask again", () => { + const permission = permissionFailure(DENIED, "codex-messages")!; + const message = permissionFailureMessage(permission); + + expect(message).toContain("macOS blocked this"); + expect(message).toContain("Privacy & Security → Automation"); + expect(message).toContain('"Executor → Messages"'); + expect(message).toContain("only asks once"); + }); +}); + +describe("CODEX_PERMISSIONS", () => { + it("points every entry at a real settings pane", () => { + for (const [preset, permissions] of Object.entries(CODEX_PERMISSIONS)) { + expect(permissions.length, preset).toBeGreaterThan(0); + for (const permission of permissions) { + expect(permission.settingsUrl, `${preset}/${permission.id}`).toMatch( + /^x-apple\.systempreferences:com\.apple\.preference\.security\?Privacy_/, + ); + expect(permission.why.length, `${preset}/${permission.id}`).toBeGreaterThan(0); + } + } + }); + + it("attributes the Codex-owned grants to Codex, not to the host", () => { + // Screen Recording and Accessibility are held by the Computer Use app, so + // granting them once in Codex covers every host — the entry must say so. + const computerUse = CODEX_PERMISSIONS["codex-computer-use"]!; + expect(computerUse.map((p) => p.entry)).toEqual(["Codex Computer Use", "Codex Computer Use"]); + }); +}); diff --git a/packages/plugins/mcp/src/sdk/codex-permissions.ts b/packages/plugins/mcp/src/sdk/codex-permissions.ts new file mode 100644 index 000000000..7140be9bc --- /dev/null +++ b/packages/plugins/mcp/src/sdk/codex-permissions.ts @@ -0,0 +1,130 @@ +// --------------------------------------------------------------------------- +// macOS permissions for the Codex plugins. +// +// These plugins drive the real machine, so macOS gates them behind TCC. Two +// facts shape everything here, both established by probing a live install +// rather than from documentation: +// +// * The permissions do NOT all attach to the same identity. Reading Messages +// works from a host with no Full Disk Access, because the Codex Computer +// Use SERVICE (`com.openai.sky.CUAService`) holds that grant and does the +// reading. But sending an Apple Event is attributed to the RESPONSIBLE +// process — the app that launched the chain — so Automation is granted per +// host. The same call therefore succeeds from a terminal whose app is +// approved and fails from a desktop app that is not. +// +// * A denial is permanent until the user acts. macOS asks once; after that +// `-1743` (`errAEEventNotPermitted`) comes back forever and no prompt is +// shown again. So "try and see" is not a recovery strategy — the user has +// to be told which entry to enable, and taken there. +// +// The failure this produces is otherwise indecipherable: the plugin reports +// "server error -1743: Unknown error", which reaches the caller scrubbed to an +// opaque internal error id. Classifying it here is what turns the most common +// first-run failure into something a person can act on. +// --------------------------------------------------------------------------- + +/** A macOS privacy pane, as a deep link `open` understands. */ +const settingsUrl = (pane: string): string => + `x-apple.systempreferences:com.apple.preference.security?${pane}`; + +export interface CodexPermission { + readonly id: "automation" | "accessibility" | "screen-recording" | "contacts" | "full-disk"; + /** What macOS calls this in System Settings. */ + readonly label: string; + /** Which entry the user must find and enable there. */ + readonly entry: string; + /** Why this plugin needs it, in the user's terms. */ + readonly why: string; + readonly settingsUrl: string; +} + +/** Automation is per-HOST: the app that spawned the chain is what macOS asks + * about, so the entry is executor itself rather than anything Codex ships. */ +const automation = (target: string, why: string): CodexPermission => ({ + id: "automation", + label: "Automation", + entry: `Executor → ${target}`, + why, + settingsUrl: settingsUrl("Privacy_Automation"), +}); + +/** Screen Recording and Accessibility attach to the Codex Computer Use app, + * which is why granting them once in Codex covers every host. */ +const computerUsePermissions: readonly CodexPermission[] = [ + { + id: "screen-recording", + label: "Screen Recording", + entry: "Codex Computer Use", + why: "so it can see the app it is operating", + settingsUrl: settingsUrl("Privacy_ScreenCapture"), + }, + { + id: "accessibility", + label: "Accessibility", + entry: "Codex Computer Use", + why: "so it can click, type, and scroll", + settingsUrl: settingsUrl("Privacy_Accessibility"), + }, +]; + +const messagesPermissions: readonly CodexPermission[] = [ + automation("Messages", "so it can read your chats and send on your behalf"), + { + id: "contacts", + label: "Contacts", + entry: "Codex Computer Use", + why: "so it can turn names into the right phone numbers", + settingsUrl: settingsUrl("Privacy_Contacts"), + }, +]; + +/** What each plugin needs, keyed by preset id. Absent means nothing beyond + * having Codex installed. */ +export const CODEX_PERMISSIONS: Readonly> = { + "codex-messages": messagesPermissions, + "codex-computer-use": computerUsePermissions, + "codex-computer-history": computerUsePermissions, + "codex-chrome": [automation("Google Chrome", "so it can drive your browser")], +}; + +// --------------------------------------------------------------------------- +// Classification +// --------------------------------------------------------------------------- + +/** Apple Event failures that mean "the user has not allowed this", as opposed + * to a target that is missing or busy. + * + * `-1743` is `errAEEventNotPermitted`: the grant was DECLINED, or never + * answered and has since defaulted closed. `-600`/`-609` are the connection + * errors macOS returns when it refuses to hand the sender a port at all, + * which is how the same denial presents on some paths. */ +const APPLE_EVENT_DENIED = [-1743, -609, -600] as const; + +const deniedCodePattern = new RegExp(`(?:^|[^0-9-])(${APPLE_EVENT_DENIED.join("|")})(?![0-9])`); + +/** A permission failure recognised in an upstream tool error, or null when the + * error is about something else. Matching is on the numeric code, not on + * wording: the plugin's own text is "Unknown error". */ +export const permissionFailure = ( + message: string, + presetId: string | undefined, +): CodexPermission | null => { + if (!deniedCodePattern.test(message)) return null; + const permissions = presetId === undefined ? [] : (CODEX_PERMISSIONS[presetId] ?? []); + // Automation is the one a host can actually be missing; the Codex-owned + // grants are shared and fail differently. Fall back to the first declared. + return permissions.find((permission) => permission.id === "automation") ?? permissions[0] ?? null; +}; + +/** The message a caller sees instead of "Unknown error". + * + * Written for whoever reads it next — a person, or a model relaying to one. + * It names the block, the exact entry to enable, and where, because macOS + * will not ask again on its own. */ +export const permissionFailureMessage = (permission: CodexPermission): string => + [ + `macOS blocked this: ${permission.label} access has not been allowed.`, + `Open System Settings → Privacy & Security → ${permission.label}, find "${permission.entry}", and turn it on — ${permission.why}.`, + "macOS only asks once, so a prompt will not appear again until it is enabled there.", + ].join(" "); diff --git a/packages/plugins/mcp/src/sdk/codex-plugins.test.ts b/packages/plugins/mcp/src/sdk/codex-plugins.test.ts index 40ed0b460..eff0782e5 100644 --- a/packages/plugins/mcp/src/sdk/codex-plugins.test.ts +++ b/packages/plugins/mcp/src/sdk/codex-plugins.test.ts @@ -138,12 +138,19 @@ describe("scanCodexPlugins", () => { // Codex: both ship as node-repl content, so they target `node_repl` with a // projected surface. Chrome additionally carries the module its surface // imports, resolved through the version-proof `latest` symlink. + // Each entry also carries its own preset id, so a macOS permission + // failure can name the exact grant that plugin needs. expect(curated.map((entry) => entry.appServer)).toEqual([ - { server: "messages" }, - { server: "node_repl", surface: "sky" }, - { server: "node_repl", surface: "browser", modulePath: join(home, CHROME_CLIENT_RELATIVE) }, - { server: "openaiDeveloperDocs" }, - { server: "computer-history" }, + { presetId: "codex-messages", server: "messages" }, + { presetId: "codex-computer-use", server: "node_repl", surface: "sky" }, + { + presetId: "codex-chrome", + server: "node_repl", + surface: "browser", + modulePath: join(home, CHROME_CLIENT_RELATIVE), + }, + { presetId: "codex-openai-docs", server: "openaiDeveloperDocs" }, + { presetId: "codex-computer-history", server: "computer-history" }, ]); }); diff --git a/packages/plugins/mcp/src/sdk/codex-plugins.ts b/packages/plugins/mcp/src/sdk/codex-plugins.ts index 8211a95c7..a8b1cd574 100644 --- a/packages/plugins/mcp/src/sdk/codex-plugins.ts +++ b/packages/plugins/mcp/src/sdk/codex-plugins.ts @@ -47,6 +47,7 @@ export interface CodexPluginEntry { * connector bridges MCP to it in process, calling tools on this named * server inside Codex. See `appserver-connector.ts`. */ readonly appServer?: { + readonly presetId?: string; readonly server: string; readonly surface?: "sky" | "browser"; readonly modulePath?: string; @@ -420,6 +421,7 @@ export const scanCodexPlugins = (options?: { args: ["app-server"], env: { CODEX_HOME: codexHome }, appServer: { + presetId: entry.id, server: entry.server, ...(entry.surface === undefined ? {} : { surface: entry.surface }), ...(entry.surface === "browser" ? { modulePath: browserClient } : {}), diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index da7aa9185..67bba780b 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -566,7 +566,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { // bridge answers the MCP handshake itself, so `versionNegotiation` does // not apply on this path. if (input.appServer !== undefined) { - const { server, surface, modulePath } = input.appServer; + const { server, surface, modulePath, presetId } = input.appServer; return Effect.gen(function* () { const { createAppServerTransport } = yield* Effect.tryPromise({ try: () => import("./appserver-connector"), @@ -588,6 +588,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { server, ...(surface === undefined ? {} : { surface }), ...(modulePath === undefined ? {} : { modulePath }), + ...(presetId === undefined ? {} : { presetId }), }), }); }); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 0e29fdc73..f64fd9c22 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -264,6 +264,7 @@ const McpStdioServerInputSchema = Schema.Struct({ server: Schema.String, surface: Schema.optional(Schema.Literals(["sky", "browser"])), modulePath: Schema.optional(Schema.String), + presetId: Schema.optional(Schema.String), }), ), slug: Schema.optional(Schema.String), diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index 6f6435eb1..396659b5b 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -283,6 +283,9 @@ export const McpStdioIntegrationConfig = Schema.Struct({ * Chrome's `browser-client.mjs`). Machine-specific, so it is resolved * by the scanner rather than hardcoded. */ modulePath: Schema.optional(Schema.String), + /** Which curated Codex plugin this is, so a macOS permission failure can + * name the exact grant to enable. */ + presetId: Schema.optional(Schema.String), }), ), /** Declared auth methods — a single `stdio_env` method naming the secret env From 77d3ed98d7250c0edb8f630f609d84c587e1de8f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:55:24 -0700 Subject: [PATCH 2/8] Check macOS access from the plugin card --- packages/plugins/mcp/src/api/group.ts | 21 +++++ packages/plugins/mcp/src/api/handlers.test.ts | 1 + packages/plugins/mcp/src/api/handlers.ts | 8 ++ .../plugins/mcp/src/react/CodexPluginAdd.tsx | 45 +++++++++- packages/plugins/mcp/src/react/atoms.ts | 1 + .../mcp/src/sdk/codex-plugin-presets.ts | 8 ++ packages/plugins/mcp/src/sdk/plugin.ts | 90 +++++++++++++++++++ 7 files changed, 173 insertions(+), 1 deletion(-) diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 3defd60e3..a725fceb8 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -181,6 +181,20 @@ const CodexPluginEntrySchema = Schema.Struct({ description: Schema.optional(Schema.String), }); +/** The result of actually trying the plugin, not a reading of any privacy + * database — macOS exposes no way to read another app's decisions. */ +const CodexPluginAccessResponse = Schema.Struct({ + status: Schema.Literals([ + "ok", + "blocked", + "not-installed", + "nothing-to-check", + "unknown", + "unsupported", + ]), + message: Schema.optional(Schema.String), +}); + const ListCodexPluginsResponse = Schema.Struct({ plugins: Schema.Array(CodexPluginEntrySchema), }); @@ -257,6 +271,13 @@ export const McpGroup = HttpApiGroup.make("mcp") error: [InternalError], }), ) + .add( + HttpApiEndpoint.post("checkCodexPluginAccess", "/mcp/codex-plugins/:id/check", { + params: { id: Schema.String }, + success: CodexPluginAccessResponse, + error: [InternalError], + }), + ) .add( HttpApiEndpoint.get("getCodexPluginIcon", "/mcp/codex-plugins/:id/icon", { params: { id: Schema.String }, diff --git a/packages/plugins/mcp/src/api/handlers.test.ts b/packages/plugins/mcp/src/api/handlers.test.ts index 2399104d9..878ac0d31 100644 --- a/packages/plugins/mcp/src/api/handlers.test.ts +++ b/packages/plugins/mcp/src/api/handlers.test.ts @@ -31,6 +31,7 @@ const failingExtension: McpPluginExtension = { configureServer: () => unused, configureAuth: () => unused, listCodexPlugins: () => Effect.succeed([]), + checkCodexPluginAccess: () => Effect.succeed({ status: "unknown" as const }), }; const Api = addGroup(McpGroup); diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index 5542a682e..f6185124b 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -182,6 +182,14 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand }), ), ) + .handle("checkCodexPluginAccess", ({ params }) => + capture( + Effect.gen(function* () { + const ext = yield* McpExtensionService; + return yield* ext.checkCodexPluginAccess(params.id); + }), + ), + ) .handle("getCodexPluginIcon", ({ params }) => capture( Effect.gen(function* () { diff --git a/packages/plugins/mcp/src/react/CodexPluginAdd.tsx b/packages/plugins/mcp/src/react/CodexPluginAdd.tsx index df35a782f..f32168db9 100644 --- a/packages/plugins/mcp/src/react/CodexPluginAdd.tsx +++ b/packages/plugins/mcp/src/react/CodexPluginAdd.tsx @@ -9,7 +9,7 @@ import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms"; import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add"; -import { addMcpServer, codexPluginsAtom } from "./atoms"; +import { addMcpServer, checkCodexPluginAccess, codexPluginsAtom } from "./atoms"; import { CODEX_PERMISSIONS } from "../sdk/codex-permissions"; // --------------------------------------------------------------------------- @@ -28,8 +28,11 @@ export default function CodexPluginAdd(props: { const pluginsResult = useAtomValue(codexPluginsAtom); const integrationsResult = useAtomValue(integrationsOptimisticAtom); const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" }); + const doCheckAccess = useAtomSet(checkCodexPluginAccess, { mode: "promiseExit" }); const [adding, setAdding] = useState(false); + const [access, setAccess] = useState<{ status: string; message?: string } | null>(null); + const [checking, setChecking] = useState(false); const [error, setError] = useState(null); const plugin = AsyncResult.isSuccess(pluginsResult) @@ -73,6 +76,22 @@ export default function CodexPluginAdd(props: { props.onComplete(exit.value.slug); }; + const handleCheck = async () => { + setChecking(true); + setAccess(null); + // Runs the plugin's own read-only probe down the real path. When macOS has + // not asked yet, THIS is what makes it ask — so the check doubles as the + // grant flow. + // Reads nothing and writes nothing, so no cache is invalidated by it. + const exit = await doCheckAccess({ params: { id: props.presetId }, reactivityKeys: [] }); + setAccess( + Exit.isSuccess(exit) + ? (exit.value as { status: string; message?: string }) + : { status: "blocked", message: "Could not reach the plugin." }, + ); + setChecking(false); + }; + if (!AsyncResult.isSuccess(pluginsResult)) { return (
@@ -169,6 +188,30 @@ export default function CodexPluginAdd(props: { macOS asks the first time this runs. If you miss the prompt, it will not ask again — enable it here.

+
+ + {access !== null && ( + + {access.status === "ok" + ? "Working — macOS is allowing this." + : (access.message ?? "Blocked.")} + + )} +
    {permissions.map((permission) => (
  • diff --git a/packages/plugins/mcp/src/react/atoms.ts b/packages/plugins/mcp/src/react/atoms.ts index 8d7263f57..f5e39bcd5 100644 --- a/packages/plugins/mcp/src/react/atoms.ts +++ b/packages/plugins/mcp/src/react/atoms.ts @@ -29,6 +29,7 @@ export const codexPluginsAtom = McpClient.query("mcp", "listCodexPlugins", { timeToLive: "15 seconds", }); +export const checkCodexPluginAccess = McpClient.mutation("mcp", "checkCodexPluginAccess"); export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint"); export const addMcpServer = McpClient.mutation("mcp", "addServer"); export const removeMcpServer = McpClient.mutation("mcp", "removeServer"); diff --git a/packages/plugins/mcp/src/sdk/codex-plugin-presets.ts b/packages/plugins/mcp/src/sdk/codex-plugin-presets.ts index 5f5f0d5c9..a1199a4a7 100644 --- a/packages/plugins/mcp/src/sdk/codex-plugin-presets.ts +++ b/packages/plugins/mcp/src/sdk/codex-plugin-presets.ts @@ -18,6 +18,10 @@ export interface CuratedCodexPlugin { /** The MCP server name this plugin registers inside Codex — the `server` * the app-server bridge calls tools against. */ readonly server: string; + /** A read-only tool that exercises this plugin's macOS permissions, used to + * CHECK access without changing anything. Chosen for having no side + * effects: listing, reading status. Absent means nothing to check. */ + readonly probeTool?: { readonly name: string; readonly args: Readonly> }; /** A public image for this plugin, used when the machine-local icon cannot * be read (i.e. Codex is not installed here). Only some plugins have one * published; the rest fall back to the provider's mark. */ @@ -92,6 +96,7 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ pluginName: "messages", name: "Messages", slug: "codex_messages", + probeTool: { name: "find_chats", args: { limit: 1 } }, // Served by the app itself (`packages/app/public`), because the Messages // app icon is published nowhere hotlinkable: it is a system app, absent // from the App Store artwork API, and `messages.apple.com` resolves to @@ -107,6 +112,7 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ pluginName: "computer-use", name: "Computer Use", slug: "codex_computer_use", + probeTool: { name: "list_apps", args: {} }, publicIcon: "https://learn.chatgpt.com/images/codex/icons/computer-use-plugin-icon.png", requires: "computer-use-app", server: "node_repl", @@ -121,6 +127,7 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ pluginName: "chrome", name: "Chrome", slug: "codex_chrome", + probeTool: { name: "list_tabs", args: {} }, publicIcon: "https://learn.chatgpt.com/images/codex/icons/chrome-production-large.png", server: "node_repl", surface: "browser", @@ -145,6 +152,7 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ pluginName: "computer-history", name: "Computer History", slug: "codex_computer_history", + probeTool: { name: "computer_history_status", args: {} }, requires: "computer-use-app", server: "computer-history", summary: diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index f64fd9c22..008b1e896 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -1378,6 +1378,82 @@ 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. + /** 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 + * same bridge, spawn, and service a live call uses — because that is + * the only honest answer available. macOS exposes no way to read + * another app's privacy decisions, and the grants here are split across + * two identities (the host holds Automation; the Codex service holds + * the rest), so nothing short of trying it can tell the user where they + * stand. A denial is reported as the grant to enable. + * + * Safe to run on demand: every probe tool is a listing or a status + * read. If macOS has not yet asked, this is what makes it ask. */ + const checkCodexPluginAccess = (id: string) => + Effect.gen(function* () { + if (!allowStdio) return { status: "unsupported" as const }; + const plugins = yield* listCodexPlugins(); + const plugin = plugins.find((entry) => entry.id === id); + if (plugin === undefined) return { status: "unknown" as const }; + if (!plugin.available) return { status: "not-installed" as const }; + + const mod = yield* Effect.promise(() => import("./codex-plugin-presets")); + const preset = mod.CURATED_CODEX_PLUGINS.find((entry) => entry.id === id); + const probe = preset?.probeTool; + if (probe === undefined) return { status: "nothing-to-check" as const }; + + const connector = createMcpConnector({ + transport: "stdio", + command: plugin.command, + args: plugin.args, + env: plugin.env === undefined ? undefined : { ...plugin.env }, + ...(plugin.appServer === undefined ? {} : { appServer: plugin.appServer }), + }); + + 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({ + transport: "appserver", + message: "The plugin did not answer.", + }), + }).pipe(Effect.ensuring(Effect.promise(() => connection.close()))); + + const text = (Array.isArray(result.content) ? result.content : []) + .map((block) => (block as { readonly text?: unknown }).text) + .filter((value): value is string => typeof value === "string") + .join(" "); + if (result.isError === true) { + return { status: "blocked" as const, message: text }; + } + return { status: "ok" as const }; + }).pipe( + // Any failure to even reach the plugin is reported the same way a + // refusal is: the user cares that it does not work and why, not + // which layer said no. + // Distinguish the two ways this can fail by TAG, not by reading a + // message off an unknown: the plugin refused, or we never reached + // it at all. + Effect.catchTags({ + McpConnectionError: () => + Effect.succeed({ + status: "blocked" as const, + message: + "Could not start the plugin. Check that Codex is installed and signed in.", + }), + McpOAuthReauthorizationRequired: () => + Effect.succeed({ + status: "blocked" as const, + message: "The plugin needs to be re-authorized in Codex.", + }), + }), + Effect.withSpan("mcp.plugin.check_codex_plugin_access"), + ); + }); + const listCodexPlugins = () => allowStdio ? Effect.promise(() => import("./codex-plugins")).pipe( @@ -1395,6 +1471,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { configureServer, configureAuth, listCodexPlugins, + checkCodexPluginAccess, }; }, @@ -1961,4 +2038,17 @@ export interface McpPluginExtension { /** Locally installed Codex plugins with stdio MCP servers, as one-click * presets. Empty when stdio is disabled. */ readonly listCodexPlugins: () => Effect.Effect; + readonly checkCodexPluginAccess: (id: string) => Effect.Effect< + { + readonly status: + | "ok" + | "blocked" + | "not-installed" + | "nothing-to-check" + | "unknown" + | "unsupported"; + readonly message?: string; + }, + never + >; } From 2b597adaf0ce600d648a8483f9fa872b7bbdf2c1 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:01:56 -0700 Subject: [PATCH 3/8] Show access state when the card opens --- .../plugins/mcp/src/react/CodexPluginAdd.tsx | 55 ++++++++++++------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/packages/plugins/mcp/src/react/CodexPluginAdd.tsx b/packages/plugins/mcp/src/react/CodexPluginAdd.tsx index f32168db9..1bac1262a 100644 --- a/packages/plugins/mcp/src/react/CodexPluginAdd.tsx +++ b/packages/plugins/mcp/src/react/CodexPluginAdd.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; @@ -76,6 +76,21 @@ export default function CodexPluginAdd(props: { props.onComplete(exit.value.slug); }; + // Checked on open rather than on a button, so the card states where you + // stand before you commit to anything. It runs the plugin's read-only probe + // once per card: macOS offers no way to READ another app's privacy + // decisions, so trying it is the only way to know. + const checkedFor = useRef(null); + useEffect(() => { + if (!plugin?.available) return; + if (checkedFor.current === props.presetId) return; + checkedFor.current = props.presetId; + void handleCheck(); + // `handleCheck` is stable for a given preset; re-running on its identity + // would re-probe on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [plugin?.available, props.presetId]); + const handleCheck = async () => { setChecking(true); setAccess(null); @@ -189,27 +204,25 @@ export default function CodexPluginAdd(props: { enable it here.

    - - {access !== null && ( - - {access.status === "ok" - ? "Working — macOS is allowing this." - : (access.message ?? "Blocked.")} - + {checking + ? "Checking…" + : access === null + ? "" + : access.status === "ok" + ? "Allowed — macOS is letting this through." + : (access.message ?? "Blocked.")} + + {access !== null && access.status !== "ok" && !checking && ( + )}
      From e5bd092f42a202f4f16a8a8a1bb8d16200c8fd2e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:24:13 -0700 Subject: [PATCH 4/8] Hold the add until the plugin's access check passes --- .../plugins/mcp/src/react/CodexPluginAdd.tsx | 43 +++++++++++++--- .../mcp/src/react/codex-access-gate.test.ts | 51 +++++++++++++++++++ .../mcp/src/react/codex-access-gate.ts | 43 ++++++++++++++++ 3 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 packages/plugins/mcp/src/react/codex-access-gate.test.ts create mode 100644 packages/plugins/mcp/src/react/codex-access-gate.ts diff --git a/packages/plugins/mcp/src/react/CodexPluginAdd.tsx b/packages/plugins/mcp/src/react/CodexPluginAdd.tsx index 1bac1262a..957e1d00f 100644 --- a/packages/plugins/mcp/src/react/CodexPluginAdd.tsx +++ b/packages/plugins/mcp/src/react/CodexPluginAdd.tsx @@ -10,6 +10,7 @@ import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add"; import { addMcpServer, checkCodexPluginAccess, codexPluginsAtom } from "./atoms"; +import { accessBlocked, accessPending, type CodexAccessState } from "./codex-access-gate"; import { CODEX_PERMISSIONS } from "../sdk/codex-permissions"; // --------------------------------------------------------------------------- @@ -31,7 +32,7 @@ export default function CodexPluginAdd(props: { const doCheckAccess = useAtomSet(checkCodexPluginAccess, { mode: "promiseExit" }); const [adding, setAdding] = useState(false); - const [access, setAccess] = useState<{ status: string; message?: string } | null>(null); + const [access, setAccess] = useState(null); const [checking, setChecking] = useState(false); const [error, setError] = useState(null); @@ -46,6 +47,17 @@ export default function CodexPluginAdd(props: { AsyncResult.isSuccess(integrationsResult) && integrationsResult.value.some((integration) => String(integration.slug) === plugin.slug); + // Adding is held back until the probe says the plugin can actually run: an + // integration added while macOS is blocking it looks connected and fails on + // its first real call, by which point the person has left the one card that + // explains the fix. + const blocked = accessBlocked(access); + const pending = accessPending({ + checking, + declaresPermissions: permissions.length > 0, + access, + }); + const handleAdd = async () => { if (plugin === undefined) return; setAdding(true); @@ -101,7 +113,7 @@ export default function CodexPluginAdd(props: { const exit = await doCheckAccess({ params: { id: props.presetId }, reactivityKeys: [] }); setAccess( Exit.isSuccess(exit) - ? (exit.value as { status: string; message?: string }) + ? (exit.value as CodexAccessState) : { status: "blocked", message: "Could not reach the plugin." }, ); setChecking(false); @@ -200,8 +212,9 @@ export default function CodexPluginAdd(props: { macOS access

      - macOS asks the first time this runs. If you miss the prompt, it will not ask again — - enable it here. + {blocked + ? "This has to be on before the plugin can be added — turn it on below, then check again." + : "macOS asks the first time this runs. If you miss the prompt, it will not ask again — enable it here."}

      )} + {/* A block on a plugin that declares no macOS access has no panel above + to carry it, so state the reason next to the action it is holding. */} + {blocked && permissions.length === 0 && ( +
      +

      + {access?.message ?? "This plugin cannot run yet."} +

      + +
      + )} + {error !== null &&

      {error}

      } @@ -254,8 +280,13 @@ export default function CodexPluginAdd(props: { View integration ) : plugin.available ? ( - ) : (