Skip to content

Commit c695970

Browse files
authored
Explain macOS permissions for Codex plugins (#1869)
* Explain macOS permissions for Codex plugins * Check macOS access from the plugin card * Show access state when the card opens * Hold the add until the plugin's access check passes * Note how to run desktop dev beside an installed app * Let a desktop dev run keep its own userData and scope dir * Note the access gate in the changeset * Assert the preset id the bridge recipe now carries
1 parent fad3650 commit c695970

23 files changed

Lines changed: 723 additions & 16 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"executor": patch
3+
"@executor-js/plugin-mcp": patch
4+
---
5+
6+
Explain macOS permissions for Codex plugins instead of failing with an opaque
7+
error. A refused grant used to surface as `Internal tool error [id]` — the
8+
plugin reports "Unknown error" and only a numeric code says what happened, so
9+
neither the user nor the model could tell that macOS was the blocker.
10+
11+
The bridge now recognises those codes and answers with the grant to enable and
12+
where to find it. Each plugin's add screen also states what macOS will ask for
13+
before anything runs, with a link straight to the right Privacy pane — macOS
14+
asks once, and a dismissed prompt never returns.
15+
16+
The add screen checks that access when it opens, and holds the Add button
17+
until the plugin answers. Adding one that macOS is still blocking produced an
18+
integration that looked connected and failed on its first call, by which point
19+
the screen explaining the fix was gone.

‎RUNNING.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,34 @@ develop on its `main`, publish a bump, then bump the dependency here. The
4040
The e2e globalsetup files are the source of truth for "how do I boot a
4141
working instance of X" — read them before inventing a boot path.
4242

43+
A desktop dev run collides with an INSTALLED Executor in three places, all of
44+
which look like something else. Give the dev run its own of each:
45+
46+
```
47+
EXECUTOR_DESKTOP_USER_DATA=/tmp/executor-desktop-dev-userdata \
48+
EXECUTOR_DESKTOP_SCOPE_DIR=/tmp/executor-desktop-dev-scope \
49+
EXECUTOR_DESKTOP_SETTINGS_DIR=/tmp/executor-desktop-dev-settings \
50+
bun run dev
51+
```
52+
53+
- **userData** holds Electron's single-instance lock, so the second process
54+
quits at startup with **exit code 0 and no message** — the log simply stops
55+
after "starting electron app".
56+
- **The scope dir** (`~/.executor`) holds the sidecar's SQLite, owned by
57+
whoever opened it first; the app reports "Failed to open local SQLite data".
58+
- **The port** comes from `settings.json` in the settings dir; write
59+
`{"server":{"port":<free>}}` there before the first launch.
60+
61+
Do NOT move these by pointing `HOME` at a scratch directory. The plugins a
62+
local run drives resolve their own paths from the real home, and a synthetic
63+
one breaks them in ways that read as product bugs: Codex Computer Use fails
64+
every call with "Sky Computer Use native pipe startup failed", even with
65+
`.codex` symlinked back.
66+
67+
Renderer edits inside a workspace package can be served from vite's dep
68+
cache rather than the source the package exports. If a change does not appear
69+
after a reload, delete `apps/desktop/node_modules/.vite` and restart.
70+
4371
## E2E: running, viewing, sharing
4472

4573
`e2e/AGENTS.md` covers writing scenarios. Operationally:

‎apps/desktop/src/main/index.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,13 @@ import {
7878
// executor.jsonc plugin manifest) is pinned separately to ~/.executor in
7979
// main/sidecar.ts — that path matches the CLI's default.
8080
app.setName("Executor");
81-
app.setPath("userData", join(app.getPath("appData"), "Executor"));
81+
// A dev run must not collide with an installed Executor: userData also holds
82+
// Electron's single-instance lock, so sharing it makes the second process quit
83+
// silently at startup. `EXECUTOR_DESKTOP_USER_DATA` gives a dev run its own.
84+
app.setPath(
85+
"userData",
86+
process.env.EXECUTOR_DESKTOP_USER_DATA ?? join(app.getPath("appData"), "Executor"),
87+
);
8288

8389
log.initialize({ preload: true });
8490
log.transports.file.level = "info";

‎apps/desktop/src/main/sidecar.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,18 @@ const resolveClientDir = (): string => {
215215
const delay = (ms: number): Promise<void> =>
216216
new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
217217

218+
/**
219+
* Where this app keeps `data.db`, `auth.json`, and the server manifest.
220+
*
221+
* A dev run overrides it: that SQLite has ONE owner, so a dev build started
222+
* beside an installed Executor otherwise dies on the installed app's lock.
223+
* Redirecting HOME is not an alternative — the machine-local tools a plugin
224+
* drives resolve their own paths from it, and Codex Computer Use fails at
225+
* "native pipe startup" under a synthetic home.
226+
*/
227+
const executorScopeDir = (): string =>
228+
process.env.EXECUTOR_DESKTOP_SCOPE_DIR ?? join(homedir(), ".executor");
229+
218230
export async function startSidecar(options: StartOptions = {}): Promise<SidecarConnection> {
219231
const hostname = options.hostname ?? "127.0.0.1";
220232
const settings = getServerSettings();
@@ -226,7 +238,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC
226238
// userData (set in main/index.ts) is still used for electron-store,
227239
// electron-log, and window-state — those stay app-scoped to avoid colliding
228240
// with anything else under HOME.
229-
const scopeDir = join(homedir(), ".executor");
241+
const scopeDir = executorScopeDir();
230242
const dataDir = scopeDir;
231243
mkdirSync(dataDir, { recursive: true });
232244

@@ -429,7 +441,7 @@ const isDaemonReachable = async (origin: string): Promise<boolean> => {
429441
* is handled by the existing single-instance / ownership logic.
430442
*/
431443
export async function attachToSupervisedDaemon(): Promise<SidecarConnection | null> {
432-
const dataDir = join(homedir(), ".executor");
444+
const dataDir = executorScopeDir();
433445
const manifest = readManifest(dataDir);
434446
const decision = await resolveSupervisedDaemonAttach(manifest, {
435447
isReachable: isDaemonReachable,

‎e2e/local/codex-plugins.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,23 +143,28 @@ scenario(
143143
});
144144
}
145145
// Curated entries carry the app-server bridge recipe: `codex
146-
// app-server` plus the server name the bridge calls tools on.
146+
// app-server`, the server name the bridge calls tools on, and the
147+
// preset it came from — that last one is what lets a macOS refusal
148+
// name the exact grant to enable.
147149
const messages = byId.get("codex-messages");
148150
expect(messages?.command.endsWith("codex"), "curated entries spawn the codex CLI").toBe(
149151
true,
150152
);
151153
expect(messages?.args, "curated entries run the app-server").toEqual(["app-server"]);
152154
expect(messages?.appServer, "curated entries name their Codex server").toEqual({
155+
presetId: "codex-messages",
153156
server: "messages",
154157
});
155158
// Computer Use and Chrome have no server of their own: both are
156159
// projected onto `node_repl`, and Chrome carries the client module
157160
// its surface imports, resolved through the `latest` symlink.
158161
expect(byId.get("codex-computer-use")?.appServer).toEqual({
162+
presetId: "codex-computer-use",
159163
server: "node_repl",
160164
surface: "sky",
161165
});
162166
expect(byId.get("codex-chrome")?.appServer).toEqual({
167+
presetId: "codex-chrome",
163168
server: "node_repl",
164169
surface: "browser",
165170
modulePath: join(codexHome, CHROME_CLIENT_RELATIVE),

‎packages/plugins/mcp/src/api/group.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ const AddStdioServerPayload = Schema.Struct({
8181
server: Schema.String,
8282
surface: Schema.optional(Schema.Literals(["sky", "browser"])),
8383
modulePath: Schema.optional(Schema.String),
84+
presetId: Schema.optional(Schema.String),
8485
}),
8586
),
8687
slug: Schema.optional(Schema.String),
@@ -177,6 +178,7 @@ const CodexPluginEntrySchema = Schema.Struct({
177178
server: Schema.String,
178179
surface: Schema.optional(Schema.Literals(["sky", "browser"])),
179180
modulePath: Schema.optional(Schema.String),
181+
presetId: Schema.optional(Schema.String),
180182
}),
181183
),
182184
setupHint: Schema.optional(Schema.String),
@@ -190,6 +192,20 @@ const CodexPluginEntrySchema = Schema.Struct({
190192
description: Schema.optional(Schema.String),
191193
});
192194

195+
/** The result of actually trying the plugin, not a reading of any privacy
196+
* database — macOS exposes no way to read another app's decisions. */
197+
const CodexPluginAccessResponse = Schema.Struct({
198+
status: Schema.Literals([
199+
"ok",
200+
"blocked",
201+
"not-installed",
202+
"nothing-to-check",
203+
"unknown",
204+
"unsupported",
205+
]),
206+
message: Schema.optional(Schema.String),
207+
});
208+
193209
const ListCodexPluginsResponse = Schema.Struct({
194210
plugins: Schema.Array(CodexPluginEntrySchema),
195211
});
@@ -266,6 +282,13 @@ export const McpGroup = HttpApiGroup.make("mcp")
266282
error: [InternalError],
267283
}),
268284
)
285+
.add(
286+
HttpApiEndpoint.post("checkCodexPluginAccess", "/mcp/codex-plugins/:id/check", {
287+
params: { id: Schema.String },
288+
success: CodexPluginAccessResponse,
289+
error: [InternalError],
290+
}),
291+
)
269292
.add(
270293
HttpApiEndpoint.get("getCodexPluginIcon", "/mcp/codex-plugins/:id/icon", {
271294
params: { id: Schema.String },

‎packages/plugins/mcp/src/api/handlers.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const failingExtension: McpPluginExtension = {
3131
configureServer: () => unused,
3232
configureAuth: () => unused,
3333
listCodexPlugins: () => Effect.succeed([]),
34+
checkCodexPluginAccess: () => Effect.succeed({ status: "unknown" as const }),
3435
};
3536

3637
const Api = addGroup(McpGroup);

‎packages/plugins/mcp/src/api/handlers.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@ const toServerInput = (
4343
cwd?: string;
4444
versionNegotiation?: "legacy" | "auto";
4545
spawnPerCall?: boolean;
46-
appServer?: { server: string; surface?: "sky" | "browser"; modulePath?: string };
46+
appServer?: {
47+
server: string;
48+
surface?: "sky" | "browser";
49+
modulePath?: string;
50+
presetId?: string;
51+
};
4752
slug?: string;
4853
};
4954
return {
@@ -179,6 +184,14 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand
179184
}),
180185
),
181186
)
187+
.handle("checkCodexPluginAccess", ({ params }) =>
188+
capture(
189+
Effect.gen(function* () {
190+
const ext = yield* McpExtensionService;
191+
return yield* ext.checkCodexPluginAccess(params.id);
192+
}),
193+
),
194+
)
182195
.handle("getCodexPluginIcon", ({ params }) =>
183196
capture(
184197
Effect.gen(function* () {

0 commit comments

Comments
 (0)