Skip to content
19 changes: 19 additions & 0 deletions .changeset/codex-permission-onboarding.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions RUNNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":<free>}}` 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:
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
16 changes: 14 additions & 2 deletions apps/desktop/src/main/sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,18 @@ const resolveClientDir = (): string => {
const delay = (ms: number): Promise<void> =>
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<SidecarConnection> {
const hostname = options.hostname ?? "127.0.0.1";
const settings = getServerSettings();
Expand All @@ -226,7 +238,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC
// userData (set in main/index.ts) is still used for electron-store,
// electron-log, and window-state — those stay app-scoped to avoid colliding
// with anything else under HOME.
const scopeDir = join(homedir(), ".executor");
const scopeDir = executorScopeDir();
const dataDir = scopeDir;
mkdirSync(dataDir, { recursive: true });

Expand Down Expand Up @@ -429,7 +441,7 @@ const isDaemonReachable = async (origin: string): Promise<boolean> => {
* is handled by the existing single-instance / ownership logic.
*/
export async function attachToSupervisedDaemon(): Promise<SidecarConnection | null> {
const dataDir = join(homedir(), ".executor");
const dataDir = executorScopeDir();
const manifest = readManifest(dataDir);
const decision = await resolveSupervisedDaemonAttach(manifest, {
isReachable: isDaemonReachable,
Expand Down
7 changes: 6 additions & 1 deletion e2e/local/codex-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
23 changes: 23 additions & 0 deletions packages/plugins/mcp/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand All @@ -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),
});
Expand Down Expand Up @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/mcp/src/api/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 14 additions & 1 deletion packages/plugins/mcp/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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* () {
Expand Down
Loading
Loading