Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a50a678
Discover local Codex plugins as one-click stdio MCP presets
RhysSullivan Aug 29, 2026
f0f38a7
Surface Codex plugins in the connect dialog search
RhysSullivan Aug 29, 2026
7f2cfa4
Focused add screen and real icons for Codex plugins
RhysSullivan Aug 29, 2026
c4057b8
Mirror Codex plugin pages with their own icons and copy
RhysSullivan Aug 29, 2026
7e4440b
Use the plugins own display names verbatim
RhysSullivan Aug 29, 2026
1631dad
Bridge curated Codex plugins through codex app-server
RhysSullivan Aug 29, 2026
8fa7ed8
Let Codex plugin approval prompts reach the client
RhysSullivan Aug 29, 2026
3d8c14c
Pool bridge connections and drive Computer Use through node_repl
RhysSullivan Aug 29, 2026
92e3f9f
Add Chrome and OpenAI developer docs as Codex plugin presets
RhysSullivan Aug 29, 2026
5344603
Forward Codex elicitation metadata to the client
RhysSullivan Aug 29, 2026
4e31559
Carry approval terms through to the paused execution
RhysSullivan Aug 29, 2026
be085b1
Translate Codex server-ready notifications into tool-list changes
RhysSullivan Aug 29, 2026
6a3695d
Correct Computer Use tool guidance from the plugin's own docs
RhysSullivan Aug 29, 2026
571299a
Carry Codex plugin workflow and confirmation guidance in tool descrip…
RhysSullivan Aug 29, 2026
ff496ec
Show a provider mark and stepwise setup for uninstalled Codex plugins
RhysSullivan Aug 29, 2026
a87180f
Use published plugin marks and link the install from the add screen
RhysSullivan Aug 29, 2026
a3ca8c5
Show Apple's mark on the Messages card
RhysSullivan Aug 29, 2026
e681c78
Ship the Messages icon with the app
RhysSullivan Aug 29, 2026
93fe967
Merge remote-tracking branch 'origin/main' into codex-plugins-presets
RhysSullivan Aug 29, 2026
2c4c99c
Address review: pool isolation, timeout ordering, argument encoding, …
RhysSullivan Aug 29, 2026
2b40ae6
Make the security regressions behavioural
RhysSullivan Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions e2e/local/codex-plugins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// Codex plugins as one-click stdio presets.
//
// The server-side scanner reads `$CODEX_HOME` and reports locally installed
// OpenAI Codex plugins with stdio MCP servers: the three curated ones the
// shared "Codex Computer Use" client binary implements (Apple Messages,
// Computer Use, Computer History) plus anything in the plugin cache with a
// local-command `.mcp.json`. This scenario boots the real local server with
// `CODEX_HOME` pointed at a fixture layout whose "client binary" is a wrapper
// around the e2e stdio MCP fixture, and drives the same API the add-form's
// Codex-plugins section uses:
//
// 1. `mcp.listCodexPlugins` reports the curated entries and the scanned
// cache entry, all available.
// 2. Adding an entry with its reported recipe (the one-click card path)
// registers the integration, auto-connects, and detects its tools —
// including `saw_codex_home`, which the fixture advertises only when
// CODEX_HOME actually reached the spawned subprocess.
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { HttpApiClient } from "effect/unstable/httpapi";
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
import { composePluginApi } from "@executor-js/api/server";
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";

import { scenario } from "../src/scenario";
import { Cli, RunDir } from "../src/services";
import { withLocalServer } from "./local-server";

const api = composePluginApi([mcpHttpPlugin()] as const);

const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url));
const CHROME_CLIENT_RELATIVE = join(
"plugins",
"cache",
"openai-bundled",
"chrome",
"latest",
"scripts",
"browser-client.mjs",
);
const APP_SERVER_FIXTURE = fileURLToPath(
new URL("./fixtures/codex-app-server.mjs", import.meta.url),
);

/** A fixture CODEX_HOME: the curated install markers (the Computer Use app
* and a `codex` CLI whose `app-server` is the fake app-server fixture) and
* one cached plugin wrapping the self-contained stdio MCP fixture. */
const makeCodexHome = (): string => {
const home = mkdtempSync(join(tmpdir(), "codex-home-e2e-"));
const wrapper = `#!/bin/sh\nexec node "${FIXTURE}" "$@"\n`;

// The Computer Use app is the plugin-installed marker; the bridge never
// spawns it, so an empty executable is enough.
const clientDir = join(
home,
"computer-use",
"Codex Computer Use.app",
"Contents",
"SharedSupport",
"SkyComputerUseClient.app",
"Contents",
"MacOS",
);
mkdirSync(clientDir, { recursive: true });
writeFileSync(join(clientDir, "SkyComputerUseClient"), wrapper, { mode: 0o755 });

// The `codex` CLI the curated recipes spawn — resolved through PATH, so
// the scenario prepends this bin dir to the server's PATH.
mkdirSync(join(home, "bin"), { recursive: true });
writeFileSync(join(home, "bin", "codex"), `#!/bin/sh\nexec node "${APP_SERVER_FIXTURE}" "$@"\n`, {
mode: 0o755,
});

// Chrome's bundled browser client, behind the `latest` symlink Codex keeps.
const chromeClient = join(home, CHROME_CLIENT_RELATIVE);
mkdirSync(join(chromeClient, ".."), { recursive: true });
writeFileSync(chromeClient, "export const setupBrowserRuntime = async () => ({});\n");

const versionDir = join(home, "plugins", "cache", "personal", "echo-suite", "1.0.2");
mkdirSync(join(versionDir, ".codex-plugin"), { recursive: true });
mkdirSync(join(versionDir, "bin"), { recursive: true });
writeFileSync(
join(versionDir, ".codex-plugin", "plugin.json"),
JSON.stringify({
name: "echo-suite",
mcpServers: "./.mcp.json",
interface: { displayName: "Echo Suite", shortDescription: "Echo tools for e2e" },
}),
);
writeFileSync(
join(versionDir, ".mcp.json"),
JSON.stringify({ mcpServers: { "echo-suite": { command: "./bin/run", cwd: "." } } }),
);
writeFileSync(join(versionDir, "bin", "run"), wrapper, { mode: 0o755 });

return home;
};

scenario(
"Local · Codex plugins are discovered from CODEX_HOME and add as one-click stdio presets",
// Above the 240s boot-URL wait in `withLocalServer` for the same reason as
// stdio-mcp.test.ts: a cold vite boot must fail with the harness's
// diagnostic, not vitest's generic timeout.
{ timeout: 300_000 },
Effect.gen(function* () {
const cli = yield* Cli;
const runDir = yield* RunDir;
const codexHome = makeCodexHome();

yield* withLocalServer(
cli,
runDir,
(server) =>
Effect.gen(function* () {
const client = yield* HttpApiClient.make(api, {
baseUrl: new URL("/api", server.origin).toString(),
transformClient: HttpClient.mapRequest((request) =>
HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
),
}).pipe(Effect.provide(FetchHttpClient.layer));

// The scanner reports the curated plugins and the cached one, all
// available (the fixture home has every binary in place).
const { plugins } = yield* client.mcp.listCodexPlugins();
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
expect([...byId.keys()].sort(), "curated + scanned entries are reported").toEqual([
"codex-chrome",
"codex-computer-history",
"codex-computer-use",
"codex-echo-suite",
"codex-messages",
"codex-openai-docs",
]);
for (const plugin of plugins) {
expect(plugin.available, `${plugin.id} is available`).toBe(true);
expect(plugin.env, `${plugin.id} declares CODEX_HOME`).toEqual({
CODEX_HOME: codexHome,
});
}
// Curated entries carry the app-server bridge recipe: `codex
// app-server` plus the server name the bridge calls tools on.
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({
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({
server: "node_repl",
surface: "sky",
});
expect(byId.get("codex-chrome")?.appServer).toEqual({
server: "node_repl",
surface: "browser",
modulePath: join(codexHome, CHROME_CLIENT_RELATIVE),
});

// Add two entries exactly as the add-form's Codex-plugins card does:
// the reported recipe, verbatim.
for (const id of ["codex-messages", "codex-echo-suite"] as const) {
const plugin = byId.get(id)!;
yield* client.mcp.addServer({
payload: {
transport: "stdio",
name: plugin.name,
slug: plugin.slug,
description: plugin.summary,
command: plugin.command,
args: [...plugin.args],
...(plugin.cwd === undefined ? {} : { cwd: plugin.cwd }),
...(plugin.env === undefined ? {} : { env: { ...plugin.env } }),
...(plugin.appServer === undefined
? {}
: { appServer: { server: plugin.appServer.server } }),
},
});
}

const integrations = yield* client.integrations.list();
const slugs = integrations.map((integration) => String(integration.slug));
expect(slugs, "both plugins registered").toEqual(
expect.arrayContaining(["codex_messages", "codex_echo_suite"]),
);

// One-click means connected: the env values auto-create the default
// connection, so tools are discovered with no further step.
for (const slug of ["codex_messages", "codex_echo_suite"]) {
const connections = yield* client.connections.list({ query: { integration: slug } });
expect(
connections.map((connection) => String(connection.name)),
`${slug} auto-connected`,
).toContain("default");

const tools = yield* client.tools.list({ query: { integration: slug } });
const names = tools.map((tool) => tool.name);
expect(names, `${slug} tools are detected`).toContain("echo_tool");
expect(
names,
`CODEX_HOME reached ${slug}'s spawned subprocess (saw_codex_home is gated on it)`,
).toContain("saw_codex_home");
}
}),
{
env: {
CODEX_HOME: codexHome,
// The scanner resolves the `codex` CLI through the server's PATH.
PATH: `${join(codexHome, "bin")}:${process.env["PATH"] ?? ""}`,
},
},
);
}),
);
117 changes: 117 additions & 0 deletions e2e/local/fixtures/codex-app-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// A zero-dependency fake `codex app-server`, for the `local` e2e project.
//
// The curated Codex plugin presets no longer spawn a plugin's MCP client
// directly — their service refuses tool calls from non-Codex hosts — so the
// scanner emits `codex app-server` recipes and the connector bridges MCP to
// the app-server protocol in process. This fixture stands in for the real
// binary with the same wire shapes (newline-delimited JSON-RPC, v2 protocol):
// `initialize`, the `initialized` notification, `thread/start`,
// `mcpServerStatus/list`, and `mcpServer/tool/call` against one MCP server
// named `messages`.
//
// Like stdio-mcp-server.mjs it gates a `saw_codex_home` tool on CODEX_HOME
// being present in this process's env, so a scenario can prove the declared
// env reached the spawned child through the bridge.

import { createInterface } from "node:readline";

const send = (message) => {
process.stdout.write(`${JSON.stringify(message)}\n`);
};

const THREAD_ID = "thread-e2e-1";

const TOOLS = {
echo_tool: {
name: "echo_tool",
description: "Echoes the provided text back",
inputSchema: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
},
},
};

if (process.env.CODEX_HOME !== undefined) {
TOOLS.saw_codex_home = {
name: "saw_codex_home",
description: "Present only because CODEX_HOME is set in this server's environment",
inputSchema: { type: "object", properties: {} },
};
}

const handle = (msg) => {
// Notifications (`initialized`) carry no id and expect no response.
if (msg.id === undefined || msg.id === null) return;

if (msg.method === "initialize") {
send({ jsonrpc: "2.0", id: msg.id, result: { userAgent: "codex-e2e-fixture/0.0.0" } });
return;
}

if (msg.method === "thread/start") {
send({ jsonrpc: "2.0", id: msg.id, result: { thread: { id: THREAD_ID } } });
return;
}

if (msg.method === "mcpServerStatus/list") {
send({
jsonrpc: "2.0",
id: msg.id,
result: {
data: [
{
name: "messages",
runtimeStatus: "connected",
pluginId: "messages",
serverInfo: null,
tools: TOOLS,
resources: [],
resourceTemplates: [],
authStatus: "unsupported",
},
],
nextCursor: null,
},
});
return;
}

if (msg.method === "mcpServer/tool/call") {
const { server, tool } = msg.params ?? {};
if (server !== "messages" || TOOLS[tool] === undefined) {
send({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32602, message: `unknown server or tool: ${server}/${tool}` },
});
return;
}
const text =
tool === "echo_tool" ? String(msg.params?.arguments?.text ?? "") : "codex-home-present";
send({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text }] } });
return;
}

send({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32601, message: `Method not found: ${msg.method}` },
});
};

const rl = createInterface({ input: process.stdin });
rl.on("line", (line) => {
const trimmed = line.trim();
if (!trimmed) return;
let msg;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- standalone zero-dep fixture: hand-rolled JSON-RPC framing, not product code
try {
// oxlint-disable-next-line executor/no-json-parse -- standalone zero-dep fixture: hand-rolled JSON-RPC framing, not product code
msg = JSON.parse(trimmed);
} catch {
return;
}
handle(msg);
});
9 changes: 6 additions & 3 deletions e2e/local/fixtures/stdio-mcp-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,16 @@ if (process.env.EXECUTOR_E2E_SECRET) {
}

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

for (const probe of ENV_PROBES) {
Expand Down
Binary file added packages/app/public/plugin-icons/messages.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading