Skip to content

Commit 7cfcfef

Browse files
authored
Discover local Codex plugins as stdio MCP presets (#1837)
* Discover local Codex plugins as one-click stdio MCP presets * Surface Codex plugins in the connect dialog search * Focused add screen and real icons for Codex plugins * Mirror Codex plugin pages with their own icons and copy * Use the plugins own display names verbatim * Bridge curated Codex plugins through codex app-server * Let Codex plugin approval prompts reach the client * Pool bridge connections and drive Computer Use through node_repl * Add Chrome and OpenAI developer docs as Codex plugin presets * Forward Codex elicitation metadata to the client * Carry approval terms through to the paused execution * Translate Codex server-ready notifications into tool-list changes * Correct Computer Use tool guidance from the plugin's own docs * Carry Codex plugin workflow and confirmation guidance in tool descriptions * Show a provider mark and stepwise setup for uninstalled Codex plugins * Use published plugin marks and link the install from the add screen * Show Apple's mark on the Messages card * Ship the Messages icon with the app * Address review: pool isolation, timeout ordering, argument encoding, bounded terms * Make the security regressions behavioural
1 parent 1e8ce10 commit 7cfcfef

43 files changed

Lines changed: 4415 additions & 62 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// Codex plugins as one-click stdio presets.
2+
//
3+
// The server-side scanner reads `$CODEX_HOME` and reports locally installed
4+
// OpenAI Codex plugins with stdio MCP servers: the three curated ones the
5+
// shared "Codex Computer Use" client binary implements (Apple Messages,
6+
// Computer Use, Computer History) plus anything in the plugin cache with a
7+
// local-command `.mcp.json`. This scenario boots the real local server with
8+
// `CODEX_HOME` pointed at a fixture layout whose "client binary" is a wrapper
9+
// around the e2e stdio MCP fixture, and drives the same API the add-form's
10+
// Codex-plugins section uses:
11+
//
12+
// 1. `mcp.listCodexPlugins` reports the curated entries and the scanned
13+
// cache entry, all available.
14+
// 2. Adding an entry with its reported recipe (the one-click card path)
15+
// registers the integration, auto-connects, and detects its tools —
16+
// including `saw_codex_home`, which the fixture advertises only when
17+
// CODEX_HOME actually reached the spawned subprocess.
18+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
19+
import { tmpdir } from "node:os";
20+
import { join } from "node:path";
21+
import { fileURLToPath } from "node:url";
22+
23+
import { expect } from "@effect/vitest";
24+
import { Effect } from "effect";
25+
import { HttpApiClient } from "effect/unstable/httpapi";
26+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
27+
import { composePluginApi } from "@executor-js/api/server";
28+
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
29+
30+
import { scenario } from "../src/scenario";
31+
import { Cli, RunDir } from "../src/services";
32+
import { withLocalServer } from "./local-server";
33+
34+
const api = composePluginApi([mcpHttpPlugin()] as const);
35+
36+
const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url));
37+
const CHROME_CLIENT_RELATIVE = join(
38+
"plugins",
39+
"cache",
40+
"openai-bundled",
41+
"chrome",
42+
"latest",
43+
"scripts",
44+
"browser-client.mjs",
45+
);
46+
const APP_SERVER_FIXTURE = fileURLToPath(
47+
new URL("./fixtures/codex-app-server.mjs", import.meta.url),
48+
);
49+
50+
/** A fixture CODEX_HOME: the curated install markers (the Computer Use app
51+
* and a `codex` CLI whose `app-server` is the fake app-server fixture) and
52+
* one cached plugin wrapping the self-contained stdio MCP fixture. */
53+
const makeCodexHome = (): string => {
54+
const home = mkdtempSync(join(tmpdir(), "codex-home-e2e-"));
55+
const wrapper = `#!/bin/sh\nexec node "${FIXTURE}" "$@"\n`;
56+
57+
// The Computer Use app is the plugin-installed marker; the bridge never
58+
// spawns it, so an empty executable is enough.
59+
const clientDir = join(
60+
home,
61+
"computer-use",
62+
"Codex Computer Use.app",
63+
"Contents",
64+
"SharedSupport",
65+
"SkyComputerUseClient.app",
66+
"Contents",
67+
"MacOS",
68+
);
69+
mkdirSync(clientDir, { recursive: true });
70+
writeFileSync(join(clientDir, "SkyComputerUseClient"), wrapper, { mode: 0o755 });
71+
72+
// The `codex` CLI the curated recipes spawn — resolved through PATH, so
73+
// the scenario prepends this bin dir to the server's PATH.
74+
mkdirSync(join(home, "bin"), { recursive: true });
75+
writeFileSync(join(home, "bin", "codex"), `#!/bin/sh\nexec node "${APP_SERVER_FIXTURE}" "$@"\n`, {
76+
mode: 0o755,
77+
});
78+
79+
// Chrome's bundled browser client, behind the `latest` symlink Codex keeps.
80+
const chromeClient = join(home, CHROME_CLIENT_RELATIVE);
81+
mkdirSync(join(chromeClient, ".."), { recursive: true });
82+
writeFileSync(chromeClient, "export const setupBrowserRuntime = async () => ({});\n");
83+
84+
const versionDir = join(home, "plugins", "cache", "personal", "echo-suite", "1.0.2");
85+
mkdirSync(join(versionDir, ".codex-plugin"), { recursive: true });
86+
mkdirSync(join(versionDir, "bin"), { recursive: true });
87+
writeFileSync(
88+
join(versionDir, ".codex-plugin", "plugin.json"),
89+
JSON.stringify({
90+
name: "echo-suite",
91+
mcpServers: "./.mcp.json",
92+
interface: { displayName: "Echo Suite", shortDescription: "Echo tools for e2e" },
93+
}),
94+
);
95+
writeFileSync(
96+
join(versionDir, ".mcp.json"),
97+
JSON.stringify({ mcpServers: { "echo-suite": { command: "./bin/run", cwd: "." } } }),
98+
);
99+
writeFileSync(join(versionDir, "bin", "run"), wrapper, { mode: 0o755 });
100+
101+
return home;
102+
};
103+
104+
scenario(
105+
"Local · Codex plugins are discovered from CODEX_HOME and add as one-click stdio presets",
106+
// Above the 240s boot-URL wait in `withLocalServer` for the same reason as
107+
// stdio-mcp.test.ts: a cold vite boot must fail with the harness's
108+
// diagnostic, not vitest's generic timeout.
109+
{ timeout: 300_000 },
110+
Effect.gen(function* () {
111+
const cli = yield* Cli;
112+
const runDir = yield* RunDir;
113+
const codexHome = makeCodexHome();
114+
115+
yield* withLocalServer(
116+
cli,
117+
runDir,
118+
(server) =>
119+
Effect.gen(function* () {
120+
const client = yield* HttpApiClient.make(api, {
121+
baseUrl: new URL("/api", server.origin).toString(),
122+
transformClient: HttpClient.mapRequest((request) =>
123+
HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
124+
),
125+
}).pipe(Effect.provide(FetchHttpClient.layer));
126+
127+
// The scanner reports the curated plugins and the cached one, all
128+
// available (the fixture home has every binary in place).
129+
const { plugins } = yield* client.mcp.listCodexPlugins();
130+
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
131+
expect([...byId.keys()].sort(), "curated + scanned entries are reported").toEqual([
132+
"codex-chrome",
133+
"codex-computer-history",
134+
"codex-computer-use",
135+
"codex-echo-suite",
136+
"codex-messages",
137+
"codex-openai-docs",
138+
]);
139+
for (const plugin of plugins) {
140+
expect(plugin.available, `${plugin.id} is available`).toBe(true);
141+
expect(plugin.env, `${plugin.id} declares CODEX_HOME`).toEqual({
142+
CODEX_HOME: codexHome,
143+
});
144+
}
145+
// Curated entries carry the app-server bridge recipe: `codex
146+
// app-server` plus the server name the bridge calls tools on.
147+
const messages = byId.get("codex-messages");
148+
expect(messages?.command.endsWith("codex"), "curated entries spawn the codex CLI").toBe(
149+
true,
150+
);
151+
expect(messages?.args, "curated entries run the app-server").toEqual(["app-server"]);
152+
expect(messages?.appServer, "curated entries name their Codex server").toEqual({
153+
server: "messages",
154+
});
155+
// Computer Use and Chrome have no server of their own: both are
156+
// projected onto `node_repl`, and Chrome carries the client module
157+
// its surface imports, resolved through the `latest` symlink.
158+
expect(byId.get("codex-computer-use")?.appServer).toEqual({
159+
server: "node_repl",
160+
surface: "sky",
161+
});
162+
expect(byId.get("codex-chrome")?.appServer).toEqual({
163+
server: "node_repl",
164+
surface: "browser",
165+
modulePath: join(codexHome, CHROME_CLIENT_RELATIVE),
166+
});
167+
168+
// Add two entries exactly as the add-form's Codex-plugins card does:
169+
// the reported recipe, verbatim.
170+
for (const id of ["codex-messages", "codex-echo-suite"] as const) {
171+
const plugin = byId.get(id)!;
172+
yield* client.mcp.addServer({
173+
payload: {
174+
transport: "stdio",
175+
name: plugin.name,
176+
slug: plugin.slug,
177+
description: plugin.summary,
178+
command: plugin.command,
179+
args: [...plugin.args],
180+
...(plugin.cwd === undefined ? {} : { cwd: plugin.cwd }),
181+
...(plugin.env === undefined ? {} : { env: { ...plugin.env } }),
182+
...(plugin.appServer === undefined
183+
? {}
184+
: { appServer: { server: plugin.appServer.server } }),
185+
},
186+
});
187+
}
188+
189+
const integrations = yield* client.integrations.list();
190+
const slugs = integrations.map((integration) => String(integration.slug));
191+
expect(slugs, "both plugins registered").toEqual(
192+
expect.arrayContaining(["codex_messages", "codex_echo_suite"]),
193+
);
194+
195+
// One-click means connected: the env values auto-create the default
196+
// connection, so tools are discovered with no further step.
197+
for (const slug of ["codex_messages", "codex_echo_suite"]) {
198+
const connections = yield* client.connections.list({ query: { integration: slug } });
199+
expect(
200+
connections.map((connection) => String(connection.name)),
201+
`${slug} auto-connected`,
202+
).toContain("default");
203+
204+
const tools = yield* client.tools.list({ query: { integration: slug } });
205+
const names = tools.map((tool) => tool.name);
206+
expect(names, `${slug} tools are detected`).toContain("echo_tool");
207+
expect(
208+
names,
209+
`CODEX_HOME reached ${slug}'s spawned subprocess (saw_codex_home is gated on it)`,
210+
).toContain("saw_codex_home");
211+
}
212+
}),
213+
{
214+
env: {
215+
CODEX_HOME: codexHome,
216+
// The scanner resolves the `codex` CLI through the server's PATH.
217+
PATH: `${join(codexHome, "bin")}:${process.env["PATH"] ?? ""}`,
218+
},
219+
},
220+
);
221+
}),
222+
);
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// A zero-dependency fake `codex app-server`, for the `local` e2e project.
2+
//
3+
// The curated Codex plugin presets no longer spawn a plugin's MCP client
4+
// directly — their service refuses tool calls from non-Codex hosts — so the
5+
// scanner emits `codex app-server` recipes and the connector bridges MCP to
6+
// the app-server protocol in process. This fixture stands in for the real
7+
// binary with the same wire shapes (newline-delimited JSON-RPC, v2 protocol):
8+
// `initialize`, the `initialized` notification, `thread/start`,
9+
// `mcpServerStatus/list`, and `mcpServer/tool/call` against one MCP server
10+
// named `messages`.
11+
//
12+
// Like stdio-mcp-server.mjs it gates a `saw_codex_home` tool on CODEX_HOME
13+
// being present in this process's env, so a scenario can prove the declared
14+
// env reached the spawned child through the bridge.
15+
16+
import { createInterface } from "node:readline";
17+
18+
const send = (message) => {
19+
process.stdout.write(`${JSON.stringify(message)}\n`);
20+
};
21+
22+
const THREAD_ID = "thread-e2e-1";
23+
24+
const TOOLS = {
25+
echo_tool: {
26+
name: "echo_tool",
27+
description: "Echoes the provided text back",
28+
inputSchema: {
29+
type: "object",
30+
properties: { text: { type: "string" } },
31+
required: ["text"],
32+
},
33+
},
34+
};
35+
36+
if (process.env.CODEX_HOME !== undefined) {
37+
TOOLS.saw_codex_home = {
38+
name: "saw_codex_home",
39+
description: "Present only because CODEX_HOME is set in this server's environment",
40+
inputSchema: { type: "object", properties: {} },
41+
};
42+
}
43+
44+
const handle = (msg) => {
45+
// Notifications (`initialized`) carry no id and expect no response.
46+
if (msg.id === undefined || msg.id === null) return;
47+
48+
if (msg.method === "initialize") {
49+
send({ jsonrpc: "2.0", id: msg.id, result: { userAgent: "codex-e2e-fixture/0.0.0" } });
50+
return;
51+
}
52+
53+
if (msg.method === "thread/start") {
54+
send({ jsonrpc: "2.0", id: msg.id, result: { thread: { id: THREAD_ID } } });
55+
return;
56+
}
57+
58+
if (msg.method === "mcpServerStatus/list") {
59+
send({
60+
jsonrpc: "2.0",
61+
id: msg.id,
62+
result: {
63+
data: [
64+
{
65+
name: "messages",
66+
runtimeStatus: "connected",
67+
pluginId: "messages",
68+
serverInfo: null,
69+
tools: TOOLS,
70+
resources: [],
71+
resourceTemplates: [],
72+
authStatus: "unsupported",
73+
},
74+
],
75+
nextCursor: null,
76+
},
77+
});
78+
return;
79+
}
80+
81+
if (msg.method === "mcpServer/tool/call") {
82+
const { server, tool } = msg.params ?? {};
83+
if (server !== "messages" || TOOLS[tool] === undefined) {
84+
send({
85+
jsonrpc: "2.0",
86+
id: msg.id,
87+
error: { code: -32602, message: `unknown server or tool: ${server}/${tool}` },
88+
});
89+
return;
90+
}
91+
const text =
92+
tool === "echo_tool" ? String(msg.params?.arguments?.text ?? "") : "codex-home-present";
93+
send({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text }] } });
94+
return;
95+
}
96+
97+
send({
98+
jsonrpc: "2.0",
99+
id: msg.id,
100+
error: { code: -32601, message: `Method not found: ${msg.method}` },
101+
});
102+
};
103+
104+
const rl = createInterface({ input: process.stdin });
105+
rl.on("line", (line) => {
106+
const trimmed = line.trim();
107+
if (!trimmed) return;
108+
let msg;
109+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- standalone zero-dep fixture: hand-rolled JSON-RPC framing, not product code
110+
try {
111+
// oxlint-disable-next-line executor/no-json-parse -- standalone zero-dep fixture: hand-rolled JSON-RPC framing, not product code
112+
msg = JSON.parse(trimmed);
113+
} catch {
114+
return;
115+
}
116+
handle(msg);
117+
});

‎e2e/local/fixtures/stdio-mcp-server.mjs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,16 @@ if (process.env.EXECUTOR_E2E_SECRET) {
4545
}
4646

4747
// Each entry advertises `saw_<name>` when its variable reached this process.
48-
// The three cover the three ways a variable can be handed to a stdio server:
49-
// declared on the source, allowlisted infrastructure inherited from the host,
50-
// and — the leak — an unrelated host variable that must never travel.
48+
// The first three cover the three ways a variable can be handed to a stdio
49+
// server: declared on the source, allowlisted infrastructure inherited from
50+
// the host, and — the leak — an unrelated host variable that must never
51+
// travel. CODEX_HOME is the variable the Codex-plugin presets declare, so the
52+
// codex-plugins scenario can prove it reached the spawn.
5153
const ENV_PROBES = [
5254
{ tool: "saw_declared_env", key: "EXECUTOR_E2E_SECRET" },
5355
{ tool: "saw_proxy_env", key: "NO_PROXY" },
5456
{ tool: "saw_host_secret", key: "EXECUTOR_E2E_HOST_ONLY_SECRET" },
57+
{ tool: "saw_codex_home", key: "CODEX_HOME" },
5558
];
5659

5760
for (const probe of ENV_PROBES) {
4.61 KB
Loading

0 commit comments

Comments
 (0)