diff --git a/.changeset/codex-permission-onboarding.md b/.changeset/codex-permission-onboarding.md new file mode 100644 index 000000000..02f78be26 --- /dev/null +++ b/.changeset/codex-permission-onboarding.md @@ -0,0 +1,19 @@ +--- +"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. + +The add screen checks that access when it opens, and holds the Add button +until the plugin answers. Adding one that macOS is still blocking produced an +integration that looked connected and failed on its first call, by which point +the screen explaining the fix was gone. diff --git a/RUNNING.md b/RUNNING.md index f478b8636..d14b306ea 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -40,6 +40,34 @@ develop on its `main`, publish a bump, then bump the dependency here. The The e2e globalsetup files are the source of truth for "how do I boot a working instance of X" — read them before inventing a boot path. +A desktop dev run collides with an INSTALLED Executor in three places, all of +which look like something else. Give the dev run its own of each: + +``` +EXECUTOR_DESKTOP_USER_DATA=/tmp/executor-desktop-dev-userdata \ +EXECUTOR_DESKTOP_SCOPE_DIR=/tmp/executor-desktop-dev-scope \ +EXECUTOR_DESKTOP_SETTINGS_DIR=/tmp/executor-desktop-dev-settings \ +bun run dev +``` + +- **userData** holds Electron's single-instance lock, so the second process + quits at startup with **exit code 0 and no message** — the log simply stops + after "starting electron app". +- **The scope dir** (`~/.executor`) holds the sidecar's SQLite, owned by + whoever opened it first; the app reports "Failed to open local SQLite data". +- **The port** comes from `settings.json` in the settings dir; write + `{"server":{"port":}}` there before the first launch. + +Do NOT move these by pointing `HOME` at a scratch directory. The plugins a +local run drives resolve their own paths from the real home, and a synthetic +one breaks them in ways that read as product bugs: Codex Computer Use fails +every call with "Sky Computer Use native pipe startup failed", even with +`.codex` symlinked back. + +Renderer edits inside a workspace package can be served from vite's dep +cache rather than the source the package exports. If a change does not appear +after a reload, delete `apps/desktop/node_modules/.vite` and restart. + ## E2E: running, viewing, sharing `e2e/AGENTS.md` covers writing scenarios. Operationally: diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b43c0f4d8..98f9ec7b8 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -78,7 +78,13 @@ import { // executor.jsonc plugin manifest) is pinned separately to ~/.executor in // main/sidecar.ts — that path matches the CLI's default. app.setName("Executor"); -app.setPath("userData", join(app.getPath("appData"), "Executor")); +// A dev run must not collide with an installed Executor: userData also holds +// Electron's single-instance lock, so sharing it makes the second process quit +// silently at startup. `EXECUTOR_DESKTOP_USER_DATA` gives a dev run its own. +app.setPath( + "userData", + process.env.EXECUTOR_DESKTOP_USER_DATA ?? join(app.getPath("appData"), "Executor"), +); log.initialize({ preload: true }); log.transports.file.level = "info"; diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts index fc9d80ec9..3438a6fa6 100644 --- a/apps/desktop/src/main/sidecar.ts +++ b/apps/desktop/src/main/sidecar.ts @@ -215,6 +215,18 @@ const resolveClientDir = (): string => { const delay = (ms: number): Promise => new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); +/** + * Where this app keeps `data.db`, `auth.json`, and the server manifest. + * + * A dev run overrides it: that SQLite has ONE owner, so a dev build started + * beside an installed Executor otherwise dies on the installed app's lock. + * Redirecting HOME is not an alternative — the machine-local tools a plugin + * drives resolve their own paths from it, and Codex Computer Use fails at + * "native pipe startup" under a synthetic home. + */ +const executorScopeDir = (): string => + process.env.EXECUTOR_DESKTOP_SCOPE_DIR ?? join(homedir(), ".executor"); + export async function startSidecar(options: StartOptions = {}): Promise { const hostname = options.hostname ?? "127.0.0.1"; const settings = getServerSettings(); @@ -226,7 +238,7 @@ export async function startSidecar(options: StartOptions = {}): Promise => { * is handled by the existing single-instance / ownership logic. */ export async function attachToSupervisedDaemon(): Promise { - const dataDir = join(homedir(), ".executor"); + const dataDir = executorScopeDir(); const manifest = readManifest(dataDir); const decision = await resolveSupervisedDaemonAttach(manifest, { isReachable: isDaemonReachable, diff --git a/e2e/local/codex-plugins.test.ts b/e2e/local/codex-plugins.test.ts index d1362c106..423e9a13e 100644 --- a/e2e/local/codex-plugins.test.ts +++ b/e2e/local/codex-plugins.test.ts @@ -143,23 +143,28 @@ scenario( }); } // Curated entries carry the app-server bridge recipe: `codex - // app-server` plus the server name the bridge calls tools on. + // app-server`, the server name the bridge calls tools on, and the + // preset it came from — that last one is what lets a macOS refusal + // name the exact grant to enable. const messages = byId.get("codex-messages"); expect(messages?.command.endsWith("codex"), "curated entries spawn the codex CLI").toBe( true, ); expect(messages?.args, "curated entries run the app-server").toEqual(["app-server"]); expect(messages?.appServer, "curated entries name their Codex server").toEqual({ + presetId: "codex-messages", server: "messages", }); // Computer Use and Chrome have no server of their own: both are // projected onto `node_repl`, and Chrome carries the client module // its surface imports, resolved through the `latest` symlink. expect(byId.get("codex-computer-use")?.appServer).toEqual({ + presetId: "codex-computer-use", server: "node_repl", surface: "sky", }); expect(byId.get("codex-chrome")?.appServer).toEqual({ + presetId: "codex-chrome", server: "node_repl", surface: "browser", modulePath: join(codexHome, CHROME_CLIENT_RELATIVE), diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 03b06fddd..9d928a0e2 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -81,6 +81,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), @@ -177,6 +178,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), @@ -190,6 +192,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), }); @@ -266,6 +282,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 512aaffc8..e9022144a 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 ac18b2795..992fc5544 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 { @@ -179,6 +184,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 5075aff6a..957e1d00f 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"; @@ -9,7 +9,9 @@ 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 { accessBlocked, accessPending, type CodexAccessState } from "./codex-access-gate"; +import { CODEX_PERMISSIONS } from "../sdk/codex-permissions"; // --------------------------------------------------------------------------- // Focused add screen for one Codex plugin, reached from its catalog preset @@ -27,19 +29,35 @@ 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(null); + const [checking, setChecking] = useState(false); const [error, setError] = useState(null); const plugin = AsyncResult.isSuccess(pluginsResult) ? pluginsResult.value.plugins.find((entry) => entry.id === props.presetId) : undefined; + const permissions = CODEX_PERMISSIONS[props.presetId] ?? []; + const added = plugin !== undefined && 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); @@ -70,6 +88,37 @@ 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); + // 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 CodexAccessState) + : { status: "blocked", message: "Could not reach the plugin." }, + ); + setChecking(false); + }; + if (!AsyncResult.isSuccess(pluginsResult)) { return (
@@ -153,6 +202,73 @@ 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 + +

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

+
+ + {checking + ? "Checking…" + : access === null + ? "" + : access.status === "ok" + ? "Allowed — macOS is letting this through." + : (access.message ?? "Blocked.")} + + {access !== null && access.status !== "ok" && !checking && ( + + )} +
+
    + {permissions.map((permission) => ( +
  • + + {permission.label} — {permission.why} + + + Open settings + +
  • + ))} +
+
+ )} + + {/* 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}

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