}
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.
+
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.
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."}
+