History

Add Chrome and OpenAI developer docs as Codex plugin presets

Rhys Sullivan committed 2 months ago

Commit 92e3f9f1 parent 3d8c14c15 files · +717 −63Browse files

Unified · Split

15 files
  1. e2e/local/codex-plugins.test.ts +28 −0
  2. packages/plugins/mcp/src/api/group.ts +10 −2
  3. packages/plugins/mcp/src/api/handlers.ts +1 −1
  4. packages/plugins/mcp/src/sdk/appserver-connector.test.ts +97 −7
  5. packages/plugins/mcp/src/sdk/appserver-connector.ts +65 −10
  6. packages/plugins/mcp/src/sdk/appserver-test-server.ts +7 −1
  7. packages/plugins/mcp/src/sdk/codex-browser-tools.ts +292 −0
  8. packages/plugins/mcp/src/sdk/codex-plugin-presets.ts +41 −4
  9. packages/plugins/mcp/src/sdk/codex-plugins.test.ts +60 −15
  10. packages/plugins/mcp/src/sdk/codex-plugins.ts +50 −6
  11. packages/plugins/mcp/src/sdk/codex-repl.ts +46 −0
  12. packages/plugins/mcp/src/sdk/codex-sky-tools.ts +3 −11
  13. packages/plugins/mcp/src/sdk/connection.ts +2 −1
  14. packages/plugins/mcp/src/sdk/plugin.ts +5 −1
  15. packages/plugins/mcp/src/sdk/types.ts +10 −4
e2e/local/codex-plugins.test.ts+28−0
@@ -34,6 +34,15 @@ import { withLocalServer } from "./local-server"; · 33 unmodified lines
34const api = composePluginApi([mcpHttpPlugin()] as const);34const api = composePluginApi([mcpHttpPlugin()] as const);
35 35
36const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url));36const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url));
37const CHROME_CLIENT_RELATIVE = join(
38 "plugins",
39 "cache",
40 "openai-bundled",
41 "chrome",
42 "latest",
43 "scripts",
44 "browser-client.mjs",
45);
37const APP_SERVER_FIXTURE = fileURLToPath(46const APP_SERVER_FIXTURE = fileURLToPath(
38 new URL("./fixtures/codex-app-server.mjs", import.meta.url),47 new URL("./fixtures/codex-app-server.mjs", import.meta.url),
39);48);
@@ -67,6 +76,11 @@ const makeCodexHome = (): string => { · 27 unmodified lines
67 mode: 0o755,76 mode: 0o755,
68 });77 });
69 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
70 const versionDir = join(home, "plugins", "cache", "personal", "echo-suite", "1.0.2");84 const versionDir = join(home, "plugins", "cache", "personal", "echo-suite", "1.0.2");
71 mkdirSync(join(versionDir, ".codex-plugin"), { recursive: true });85 mkdirSync(join(versionDir, ".codex-plugin"), { recursive: true });
72 mkdirSync(join(versionDir, "bin"), { recursive: true });86 mkdirSync(join(versionDir, "bin"), { recursive: true });
@@ -115,10 +129,12 @@ scenario( · 42 unmodified lines
115 const { plugins } = yield* client.mcp.listCodexPlugins();129 const { plugins } = yield* client.mcp.listCodexPlugins();
116 const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));130 const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
117 expect([...byId.keys()].sort(), "curated + scanned entries are reported").toEqual([131 expect([...byId.keys()].sort(), "curated + scanned entries are reported").toEqual([
132 "codex-chrome",
118 "codex-computer-history",133 "codex-computer-history",
119 "codex-computer-use",134 "codex-computer-use",
120 "codex-echo-suite",135 "codex-echo-suite",
121 "codex-messages",136 "codex-messages",
137 "codex-openai-docs",
122 ]);138 ]);
123 for (const plugin of plugins) {139 for (const plugin of plugins) {
124 expect(plugin.available, `${plugin.id} is available`).toBe(true);140 expect(plugin.available, `${plugin.id} is available`).toBe(true);
@@ -136,6 +152,18 @@ scenario( · 11 unmodified lines
136 expect(messages?.appServer, "curated entries name their Codex server").toEqual({152 expect(messages?.appServer, "curated entries name their Codex server").toEqual({
137 server: "messages",153 server: "messages",
138 });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 });
139 167
140 // Add two entries exactly as the add-form's Codex-plugins card does:168 // Add two entries exactly as the add-form's Codex-plugins card does:
141 // the reported recipe, verbatim.169 // the reported recipe, verbatim.
packages/plugins/mcp/src/api/group.ts+10−2
@@ -66,7 +66,11 @@ const AddStdioServerPayload = Schema.Struct({ · 65 unmodified lines
66 /** Reach the server through the Codex app-server bridge: the command spawns66 /** Reach the server through the Codex app-server bridge: the command spawns
67 * `codex app-server` and `server` names the MCP server inside Codex. */67 * `codex app-server` and `server` names the MCP server inside Codex. */
68 appServer: Schema.optional(68 appServer: Schema.optional(
69 Schema.Struct({ server: Schema.String, surface: Schema.optional(Schema.Literal("sky")) }),69 Schema.Struct({
70 server: Schema.String,
71 surface: Schema.optional(Schema.Literals(["sky", "browser"])),
72 modulePath: Schema.optional(Schema.String),
73 }),
70 ),74 ),
71 slug: Schema.optional(Schema.String),75 slug: Schema.optional(Schema.String),
72});76});
@@ -152,7 +156,11 @@ const CodexPluginEntrySchema = Schema.Struct({ · 79 unmodified lines
152 /** Present on curated entries: add through the Codex app-server bridge,156 /** Present on curated entries: add through the Codex app-server bridge,
153 * calling tools on this named server inside Codex. */157 * calling tools on this named server inside Codex. */
154 appServer: Schema.optional(158 appServer: Schema.optional(
155 Schema.Struct({ server: Schema.String, surface: Schema.optional(Schema.Literal("sky")) }),159 Schema.Struct({
160 server: Schema.String,
161 surface: Schema.optional(Schema.Literals(["sky", "browser"])),
162 modulePath: Schema.optional(Schema.String),
163 }),
156 ),164 ),
157 setupHint: Schema.optional(Schema.String),165 setupHint: Schema.optional(Schema.String),
158 /** The plugin's own icon from its local install, as a data URI. */166 /** The plugin's own icon from its local install, as a data URI. */
packages/plugins/mcp/src/api/handlers.ts+1−1
@@ -41,7 +41,7 @@ const toServerInput = ( · 40 unmodified lines
41 env?: Record<string, string>;41 env?: Record<string, string>;
42 cwd?: string;42 cwd?: string;
43 versionNegotiation?: "legacy" | "auto";43 versionNegotiation?: "legacy" | "auto";
44 appServer?: { server: string; surface?: "sky" };44 appServer?: { server: string; surface?: "sky" | "browser"; modulePath?: string };
45 slug?: string;45 slug?: string;
46 };46 };
47 return {47 return {
packages/plugins/mcp/src/sdk/appserver-connector.test.ts+97−7
@@ -15,12 +15,15 @@ import { createMcpConnector, type StdioConnectorInput } from "./connection"; · 14 unmodified lines
15 15
16const fixture = fileURLToPath(new URL("./appserver-test-server.ts", import.meta.url));16const fixture = fileURLToPath(new URL("./appserver-test-server.ts", import.meta.url));
17 17
18const appServerInput = (server: string, surface?: "sky"): StdioConnectorInput => ({18const appServerInput = (
19 server: string,
20 appServer?: { readonly surface?: "sky" | "browser"; readonly modulePath?: string },
21): StdioConnectorInput => ({
19 transport: "stdio",22 transport: "stdio",
20 command: "bun",23 command: "bun",
21 args: ["run", fixture],24 args: ["run", fixture],
22 env: { CODEX_HOME: "/tmp/fixture-codex-home" },25 env: { CODEX_HOME: "/tmp/fixture-codex-home" },
23 appServer: { server, ...(surface === undefined ? {} : { surface }) },26 appServer: { server, ...appServer },
24});27});
25 28
26const withConnection = (input: StdioConnectorInput) =>29const withConnection = (input: StdioConnectorInput) =>
@@ -126,7 +129,7 @@ describe("codex app-server bridge", () => { · 99 unmodified lines
126 it.effect("the sky surface lists typed Computer Use tools, not the raw REPL", () =>129 it.effect("the sky surface lists typed Computer Use tools, not the raw REPL", () =>
127 Effect.scoped(130 Effect.scoped(
128 Effect.gen(function* () {131 Effect.gen(function* () {
129 const connection = yield* withConnection(appServerInput("node_repl", "sky"));132 const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));
130 133
131 const tools = yield* Effect.promise(() => connection.client.listTools());134 const tools = yield* Effect.promise(() => connection.client.listTools());
132 const names = tools.tools.map(({ name }) => name);135 const names = tools.tools.map(({ name }) => name);
@@ -144,7 +147,7 @@ describe("codex app-server bridge", () => { · 11 unmodified lines
144 it.effect("a sky tool call compiles to one node_repl program carrying its arguments", () =>147 it.effect("a sky tool call compiles to one node_repl program carrying its arguments", () =>
145 Effect.scoped(148 Effect.scoped(
146 Effect.gen(function* () {149 Effect.gen(function* () {
147 const connection = yield* withConnection(appServerInput("node_repl", "sky"));150 const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));
148 151
149 // Quotes in the arguments matter: they are embedded into a JS source152 // Quotes in the arguments matter: they are embedded into a JS source
150 // text, so the encoding has to survive them exactly.153 // text, so the encoding has to survive them exactly.
@@ -161,7 +164,10 @@ describe("codex app-server bridge", () => { · 10 unmodified lines
161 `await sky.type_text(${JSON.stringify(args)})`,164 `await sky.type_text(${JSON.stringify(args)})`,
162 );165 );
163 expect(program, "returns the result as JSON through the REPL").toContain(166 expect(program, "returns the result as JSON through the REPL").toContain(
164 "nodeRepl.write(JSON.stringify(__result ?? null));",167 "nodeRepl.write(JSON.stringify(result ?? null));",
168 );
169 expect(program, "runs in its own scope so a reused REPL session stays clean").toContain(
170 "await (async () => {",
165 );171 );
166 }),172 }),
167 ),173 ),
@@ -170,7 +176,7 @@ describe("codex app-server bridge", () => { · 2 unmodified lines
170 it.effect("an argument-less sky tool calls its method with no argument object", () =>176 it.effect("an argument-less sky tool calls its method with no argument object", () =>
171 Effect.scoped(177 Effect.scoped(
172 Effect.gen(function* () {178 Effect.gen(function* () {
173 const connection = yield* withConnection(appServerInput("node_repl", "sky"));179 const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));
174 180
175 const result = yield* Effect.promise(() =>181 const result = yield* Effect.promise(() =>
176 connection.client.callTool({ name: "list_apps", arguments: {} }),182 connection.client.callTool({ name: "list_apps", arguments: {} }),
@@ -184,7 +190,7 @@ describe("codex app-server bridge", () => { · 7 unmodified lines
184 it.effect("a tool outside the sky surface is refused rather than sent to the REPL", () =>190 it.effect("a tool outside the sky surface is refused rather than sent to the REPL", () =>
185 Effect.scoped(191 Effect.scoped(
186 Effect.gen(function* () {192 Effect.gen(function* () {
187 const connection = yield* withConnection(appServerInput("node_repl", "sky"));193 const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));
188 194
189 const outcome = yield* Effect.promise(() =>195 const outcome = yield* Effect.promise(() =>
190 connection.client.callTool({ name: "js", arguments: { code: "process.exit(0)" } }).then(196 connection.client.callTool({ name: "js", arguments: { code: "process.exit(0)" } }).then(
@@ -197,6 +203,90 @@ describe("codex app-server bridge", () => { · 6 unmodified lines
197 ),203 ),
198 );204 );
199 205
206 // -------------------------------------------------------------------------
207 // Chrome: also projected onto `node_repl`, but handle-based.
208 // -------------------------------------------------------------------------
209
210 const BROWSER_MODULE = "/codex/chrome/latest/scripts/browser-client.mjs";
211 const browserInput = () =>
212 appServerInput("node_repl", { surface: "browser", modulePath: BROWSER_MODULE });
213
214 it.effect("the browser surface lists typed Chrome tools, not the raw REPL", () =>
215 Effect.scoped(
216 Effect.gen(function* () {
217 const connection = yield* withConnection(browserInput());
218
219 const names = yield* Effect.promise(() =>
220 connection.client.listTools().then((result) => result.tools.map(({ name }) => name)),
221 );
222 expect(names, "the raw REPL is not exposed").not.toContain("js");
223 expect(names).toEqual(
224 expect.arrayContaining(["list_tabs", "new_tab", "navigate", "read_page", "click"]),
225 );
226 }),
227 ),
228 );
229
230 it.effect("a browser call imports the machine's own client and resolves a tab", () =>
231 Effect.scoped(
232 Effect.gen(function* () {
233 const connection = yield* withConnection(browserInput());
234
235 const result = yield* Effect.promise(() =>
236 connection.client.callTool({
237 name: "navigate",
238 arguments: { url: "https://example.com/" },
239 }),
240 );
241 const program = (result.content as readonly { readonly text: string }[])[0]!.text;
242 expect(program, "imports the scanner-resolved client path").toContain(
243 `await import(${JSON.stringify(BROWSER_MODULE)})`,
244 );
245 expect(program, "caches the runtime across calls in the pooled session").toContain(
246 "globalThis.__executorBrowser ??=",
247 );
248 expect(program, "falls back to the selected tab, opening one if needed").toContain(
249 "(await __browser.tabs.selected()) ?? (await __browser.tabs.new())",
250 );
251 expect(program).toContain("await __tab.goto(__args.url)");
252 }),
253 ),
254 );
255
256 it.effect("stamps REPL calls with the turn metadata the Chrome client requires", () =>
257 Effect.scoped(
258 Effect.gen(function* () {
259 // Without this the real client refuses every call with "Missing
260 // required Codex turn metadata": Codex normally stamps a REPL call
261 // with its issuing turn, and this bridge runs no turns.
262 const connection = yield* withConnection(browserInput());
263
264 const result = yield* Effect.promise(() =>
265 connection.client.callTool({ name: "list_tabs", arguments: {} }),
266 );
267 const meta = (result.structuredContent as { readonly meta: Record<string, unknown> }).meta;
268 const turn = meta["x-codex-turn-metadata"] as Record<string, string>;
269 expect(typeof turn.session_id, "the pooled thread is the session").toBe("string");
270 expect(typeof turn.turn_id, "each call is its own turn").toBe("string");
271 }),
272 ),
273 );
274
275 it.effect("a tab-less browser tool skips tab resolution entirely", () =>
276 Effect.scoped(
277 Effect.gen(function* () {
278 const connection = yield* withConnection(browserInput());
279
280 const result = yield* Effect.promise(() =>
281 connection.client.callTool({ name: "list_tabs", arguments: {} }),
282 );
283 const program = (result.content as readonly { readonly text: string }[])[0]!.text;
284 expect(program).toContain("await __browser.tabs.list()");
285 expect(program, "no tab is resolved for a browser-level call").not.toContain("const __tab");
286 }),
287 ),
288 );
289
200 it.effect("a server name Codex does not report fails the tools listing, not the connect", () =>290 it.effect("a server name Codex does not report fails the tools listing, not the connect", () =>
201 Effect.scoped(291 Effect.scoped(
202 Effect.gen(function* () {292 Effect.gen(function* () {
packages/plugins/mcp/src/sdk/appserver-connector.ts+65−10
@@ -39,6 +39,7 @@ · 38 unmodified lines
39import type { JSONRPCMessage, JSONRPCRequest, Transport } from "@modelcontextprotocol/client";39import type { JSONRPCMessage, JSONRPCRequest, Transport } from "@modelcontextprotocol/client";
40import { Option, Schema } from "effect";40import { Option, Schema } from "effect";
41 41
42import { browserCallProgram, browserToolList, findBrowserTool } from "./codex-browser-tools";
42import { findSkyTool, skyCallProgram, skyToolList } from "./codex-sky-tools";43import { findSkyTool, skyCallProgram, skyToolList } from "./codex-sky-tools";
43import { stdioSpawnEnv, type StdioTransportConfig } from "./stdio-connector";44import { stdioSpawnEnv, type StdioTransportConfig } from "./stdio-connector";
44 45
@@ -78,10 +79,14 @@ export type AppServerTransportConfig = StdioTransportConfig & { · 33 unmodified lines
78 /** The MCP server name inside Codex whose tools this transport exposes79 /** The MCP server name inside Codex whose tools this transport exposes
79 * (e.g. `messages`) — the `server` of every `mcpServer/tool/call`. */80 * (e.g. `messages`) — the `server` of every `mcpServer/tool/call`. */
80 readonly server: string;81 readonly server: string;
81 /** `sky` projects the Codex Computer Use API over the `node_repl` server as82 /** A projected tool surface for a plugin driven through `node_repl` rather
82 * typed tools instead of exposing the REPL itself (see83 * than serving MCP itself: `sky` is Computer Use (`codex-sky-tools.ts`),
83 * `codex-sky-tools.ts`). Absent exposes the server's tools verbatim. */84 * `browser` is Chrome (`codex-browser-tools.ts`). Absent exposes the
84 readonly surface?: "sky";85 * server's own tools verbatim. */
86 readonly surface?: "sky" | "browser";
87 /** Absolute path to the module a projected surface imports (Chrome's
88 * `browser-client.mjs`); resolved per machine by the scanner. */
89 readonly modulePath?: string;
85};90};
86 91
87// ---------------------------------------------------------------------------92// ---------------------------------------------------------------------------
@@ -173,6 +178,9 @@ type AppServerReply = · 85 unmodified lines
173const INTERNAL_ERROR = -32603;178const INTERNAL_ERROR = -32603;
174const METHOD_NOT_FOUND = -32601;179const METHOD_NOT_FOUND = -32601;
175 180
181/** Ceiling for one browser action inside the REPL. */
182const BROWSER_TIMEOUT_MS = 120_000;
183
176const CHANNEL_CLOSED: AppServerReply = {184const CHANNEL_CLOSED: AppServerReply = {
177 ok: false,185 ok: false,
178 error: { code: INTERNAL_ERROR, message: "Codex app-server exited before replying" },186 error: { code: INTERNAL_ERROR, message: "Codex app-server exited before replying" },
@@ -370,6 +378,10 @@ class AppServerClientTransport implements Transport { · 191 unmodified lines
370 this.#emit({ jsonrpc: "2.0", id: message.id, result: { tools: skyToolList() } });378 this.#emit({ jsonrpc: "2.0", id: message.id, result: { tools: skyToolList() } });
371 return;379 return;
372 }380 }
381 if (this.#config.surface === "browser") {
382 this.#emit({ jsonrpc: "2.0", id: message.id, result: { tools: browserToolList() } });
383 return;
384 }
373 const tools = await this.#collectServerTools(message.id);385 const tools = await this.#collectServerTools(message.id);
374 if (tools === undefined) return;386 if (tools === undefined) return;
375 this.#emit({ jsonrpc: "2.0", id: message.id, result: { tools } });387 this.#emit({ jsonrpc: "2.0", id: message.id, result: { tools } });
@@ -426,7 +438,7 @@ class AppServerClientTransport implements Transport { · 50 unmodified lines
426 if (call === undefined) {438 if (call === undefined) {
427 this.#fail(message.id, {439 this.#fail(message.id, {
428 code: METHOD_NOT_FOUND,440 code: METHOD_NOT_FOUND,
429 message: `Unknown Computer Use tool "${params.value.name}"`,441 message: `Unknown tool "${params.value.name}" for this Codex plugin`,
430 });442 });
431 return;443 return;
432 }444 }
@@ -461,7 +473,9 @@ class AppServerClientTransport implements Transport { · 28 unmodified lines
461 * performs it; otherwise the tool is passed through by name. Undefined473 * performs it; otherwise the tool is passed through by name. Undefined
462 * means the surface does not define that tool. */474 * means the surface does not define that tool. */
463 #toolCallParams(name: string, args: unknown): Record<string, unknown> | undefined {475 #toolCallParams(name: string, args: unknown): Record<string, unknown> | undefined {
464 if (this.#config.surface !== "sky") {476 const program = this.#surfaceProgram(name, args);
477 if (program === "unknown-tool") return undefined;
478 if (program === undefined) {
465 return {479 return {
466 threadId: this.#threadId,480 threadId: this.#threadId,
467 server: this.#config.server,481 server: this.#config.server,
@@ -469,19 +483,60 @@ class AppServerClientTransport implements Transport { · 1 unmodified line
469 arguments: args ?? {},483 arguments: args ?? {},
470 };484 };
471 }485 }
472 const tool = findSkyTool(name);
473 if (tool === undefined) return undefined;
474 return {486 return {
475 threadId: this.#threadId,487 threadId: this.#threadId,
476 server: this.#config.server,488 server: this.#config.server,
477 tool: "js",489 tool: "js",
478 arguments: {490 arguments: {
479 code: skyCallProgram(tool, args),491 code: program.code,
480 title: `Computer Use: ${tool.name}`,492 title: program.title,
493 ...(program.timeoutMs === undefined ? {} : { timeout_ms: program.timeoutMs }),
494 },
495 // Codex normally stamps a REPL call with the turn that issued it, and
496 // the Chrome client REFUSES to run without it ("Missing required Codex
497 // turn metadata"). This bridge starts no turns, so it supplies the same
498 // shape: the pooled thread is the session, and each tool call is its own
499 // turn. Computer Use does not check for it, but it is node_repl-backed
500 // too and Codex would stamp it, so both surfaces send it.
501 _meta: {
502 "x-codex-turn-metadata": {
503 session_id: this.#threadId,
504 turn_id: crypto.randomUUID(),
505 },
481 },506 },
482 };507 };
483 }508 }
484 509
510 /** The REPL program for a projected surface: `undefined` when this
511 * connection exposes the server's own tools, `"unknown-tool"` when the
512 * surface does not define `name`. */
513 #surfaceProgram(
514 name: string,
515 args: unknown,
516 ):
517 | { readonly code: string; readonly title: string; readonly timeoutMs?: number }
518 | undefined
519 | "unknown-tool" {
520 if (this.#config.surface === "sky") {
521 const tool = findSkyTool(name);
522 if (tool === undefined) return "unknown-tool";
523 return { code: skyCallProgram(tool, args), title: `Computer Use: ${tool.name}` };
524 }
525 if (this.#config.surface === "browser") {
526 const tool = findBrowserTool(name);
527 if (tool === undefined) return "unknown-tool";
528 return {
529 code: browserCallProgram(tool, args, this.#config.modulePath ?? ""),
530 title: `Chrome: ${tool.name}`,
531 // The REPL's own 30s default is too short for real navigation: a
532 // page load plus its accessibility pass routinely outruns it, and the
533 // failure surfaces as an opaque REPL timeout rather than a page error.
534 timeoutMs: BROWSER_TIMEOUT_MS,
535 };
536 }
537 return undefined;
538 }
539
485 // -------------------------------------------------------------------------540 // -------------------------------------------------------------------------
486 // Downstream traffic541 // Downstream traffic
487 // -------------------------------------------------------------------------542 // -------------------------------------------------------------------------
packages/plugins/mcp/src/sdk/appserver-test-server.ts+7−1
@@ -46,6 +46,7 @@ const decodeToolCallParams = Schema.decodeUnknownOption( · 45 unmodified lines
46 server: Schema.String,46 server: Schema.String,
47 tool: Schema.String,47 tool: Schema.String,
48 arguments: Schema.optional(Schema.Unknown),48 arguments: Schema.optional(Schema.Unknown),
49 _meta: Schema.optional(Schema.Unknown),
49 }),50 }),
50);51);
51 52
@@ -151,7 +152,12 @@ const handleToolCall = (id: number | string, params: unknown): void => { · 99 unmodified lines
151 // the sky surface compiled without needing a real REPL.152 // the sky surface compiled without needing a real REPL.
152 if (call.server === "node_repl") {153 if (call.server === "node_repl") {
153 const args = call.arguments as { code?: string } | undefined;154 const args = call.arguments as { code?: string } | undefined;
154 reply(id, { content: [{ type: "text", text: args?.code ?? "" }] });155 reply(id, {
156 content: [{ type: "text", text: args?.code ?? "" }],
157 // Echoed so a test can assert the turn metadata the Chrome client
158 // requires, without needing a real browser.
159 structuredContent: { meta: call._meta ?? null },
160 });
155 return;161 return;
156 }162 }
157 if (call.threadId !== THREAD_ID || call.server !== "messages") {163 if (call.threadId !== THREAD_ID || call.server !== "messages") {
packages/plugins/mcp/src/sdk/codex-browser-tools.ts+292−0
@@ -0,0 +1,292 @@
1// ---------------------------------------------------------------------------
2// The Codex "Chrome" tool surface.
3//
4// Like Computer Use, the Chrome plugin ships no MCP server: it is skills-only,
5// and browser control happens by importing its bundled
6// `scripts/browser-client.mjs` inside Codex's `node_repl` and driving the
7// runtime it returns. This module projects that runtime as typed MCP tools and
8// compiles each call into the one REPL program that performs it, exactly as
9// `codex-sky-tools.ts` does for Computer Use.
10//
11// The API is handle-based (`agent` → `browser` → `tab`) rather than flat, so
12// two things differ from the sky surface:
13//
14// * the runtime and the selected browser are cached in the REPL session,
15// because `setupBrowserRuntime()` connects to the browser extension and is
16// far too expensive to repeat per call. Pooled bridge connections
17// (`isPoolableConnectorInput`) are what make that cache worth having — the
18// REPL session now outlives a single tool call.
19// * a tab is addressed by its id, which callers read from `list_tabs` or
20// `new_tab`. Omitting it uses the selected tab, opening one if the browser
21// has none, so simple journeys never have to thread an id through.
22//
23// Interaction goes through the tab's `dom_cua` API, NOT `ax`: the plugin's own
24// API reference marks `ax` unsupported on the `extension`, `iab`, and `cdp`
25// backends, and Chrome is reached through the extension — calling it there
26// fails with a bare "Cannot read properties of undefined". `dom_cua` carries
27// no such restriction. Node ids come from `read_page`, and are only valid for
28// the snapshot that produced them.
29// ---------------------------------------------------------------------------
30
31import { jsLiteral, writeJsonResult } from "./codex-repl";
32
33type JsonSchema = Record<string, unknown>;
34
35const str = (description: string): JsonSchema => ({ type: "string", description });
36const num = (description: string): JsonSchema => ({ type: "number", description });
37
38const TAB_ID = str(
39 "Id of the tab to act on, from `list_tabs` or `new_tab`. Omit to use the selected tab.",
40);
41const NODE_ID = str(
42 "Id of the target element, from the DOM snapshot returned by `read_page`. Only valid for the snapshot it came from.",
43);
44
45const object = (
46 properties: Record<string, JsonSchema>,
47 required: readonly string[],
48): JsonSchema => ({
49 type: "object",
50 properties,
51 ...(required.length > 0 ? { required: [...required] } : {}),
52 additionalProperties: false,
53});
54
55export interface BrowserToolDefinition {
56 readonly name: string;
57 readonly description: string;
58 readonly inputSchema: JsonSchema;
59 /** Whether the program resolves a tab before running `expression`. */
60 readonly needsTab: boolean;
61 /** The JS expression to await, given the caller's arguments as `__args`
62 * and (when `needsTab`) the resolved tab as `__tab`. */
63 readonly expression: string;
64}
65
66export const BROWSER_TOOLS: readonly BrowserToolDefinition[] = [
67 {
68 name: "list_tabs",
69 description: "List the browser's open tabs with their ids, titles, and URLs.",
70 inputSchema: object({}, []),
71 needsTab: false,
72 expression: "await __browser.tabs.list()",
73 },
74 {
75 name: "new_tab",
76 description: "Open a new tab, optionally at a URL, and return its id, title, and URL.",
77 inputSchema: object({ url: str("URL to open in the new tab.") }, []),
78 needsTab: false,
79 expression: [
80 "await (async () => {",
81 " const tab = await __browser.tabs.new();",
82 " if (__args.url) await tab.goto(__args.url);",
83 " return { id: tab.id, title: await tab.title(), url: await tab.url() };",
84 "})()",
85 ].join("\n"),
86 },
87 {
88 name: "navigate",
89 description: "Open a URL in a tab. Follow with `read_page` to see the result.",
90 inputSchema: object({ tab_id: TAB_ID, url: str("The URL to open.") }, ["url"]),
91 needsTab: true,
92 expression: [
93 "await (async () => {",
94 " await __tab.goto(__args.url);",
95 " return { id: __tab.id, title: await __tab.title(), url: await __tab.url() };",
96 "})()",
97 ].join("\n"),
98 },
99 {
100 name: "page_info",
101 description: "Get a tab's current title and URL, without reading the page.",
102 inputSchema: object({ tab_id: TAB_ID }, []),
103 needsTab: true,
104 expression:
105 "await (async () => ({ id: __tab.id, title: await __tab.title(), url: await __tab.url() }))()",
106 },
107 {
108 name: "read_page",
109 description:
110 "Read the page as a filtered DOM snapshot: the interactable elements with an id for each. Call this before interacting, and again after anything that changes the page — ids are only valid for the snapshot that produced them.",
111 inputSchema: object({ tab_id: TAB_ID }, []),
112 needsTab: true,
113 expression: "await __tab.dom_cua.get_visible_dom()",
114 },
115 {
116 name: "click",
117 description:
118 "Click an element by its node id from `read_page`. Clicking is also how you focus a field before typing.",
119 inputSchema: object(
120 {
121 tab_id: TAB_ID,
122 node_id: NODE_ID,
123 double: {
124 type: "boolean",
125 description: "Double-click instead of a single click.",
126 },
127 },
128 ["node_id"],
129 ),
130 needsTab: true,
131 expression: [
132 "await (__args.double",
133 " ? __tab.dom_cua.double_click({ node_id: __args.node_id })",
134 " : __tab.dom_cua.click({ node_id: __args.node_id }))",
135 ].join("\n"),
136 },
137 {
138 name: "type_text",
139 description:
140 "Type text into the focused element. Click the target field first — typing goes wherever focus already is.",
141 inputSchema: object({ tab_id: TAB_ID, text: str("The literal text to type.") }, ["text"]),
142 needsTab: true,
143 expression: "await __tab.dom_cua.type({ text: __args.text })",
144 },
145 {
146 name: "press_key",
147 description:
148 'Press a key combination at the focused element, e.g. `["Enter"]` or `["Meta","a"]`. Use this for submitting and for shortcuts.',
149 inputSchema: object(
150 {
151 tab_id: TAB_ID,
152 keys: {
153 type: "array",
154 items: { type: "string" },
155 description: 'The keys to press together, e.g. ["Enter"].',
156 },
157 },
158 ["keys"],
159 ),
160 needsTab: true,
161 expression: "await __tab.dom_cua.keypress({ keys: __args.keys })",
162 },
163 {
164 name: "scroll",
165 description: "Scroll the page, or one element, by a pixel delta.",
166 inputSchema: object(
167 {
168 tab_id: TAB_ID,
169 node_id: str("Id of an element to scroll within. Omit to scroll the page."),
170 x: num("Horizontal scroll delta in pixels. Defaults to 0."),
171 y: num("Vertical scroll delta in pixels. Positive scrolls down. Defaults to 0."),
172 },
173 [],
174 ),
175 needsTab: true,
176 expression: [
177 "await __tab.dom_cua.scroll({",
178 " ...(__args.node_id ? { node_id: __args.node_id } : {}),",
179 " x: __args.x ?? 0,",
180 " y: __args.y ?? 0,",
181 "})",
182 ].join("\n"),
183 },
184 {
185 name: "find_elements",
186 description:
187 "Find elements by their visible text or ARIA role and return locator metadata — useful when a DOM snapshot is large or an element has no stable id.",
188 inputSchema: object(
189 {
190 tab_id: TAB_ID,
191 text: str("Visible text to match."),
192 role: str("ARIA role to match, e.g. `button` or `link`."),
193 name: str("Accessible name to match, used together with `role`."),
194 },
195 [],
196 ),
197 needsTab: true,
198 expression: [
199 "await (async () => {",
200 " const pw = __tab.playwright;",
201 " const locator = __args.role",
202 " ? pw.getByRole(__args.role, __args.name ? { name: __args.name } : {})",
203 " : pw.getByText(__args.text, {});",
204 " return await locator.all();",
205 "})()",
206 ].join("\n"),
207 },
208 {
209 name: "go_back",
210 description: "Navigate the tab back in its history.",
211 inputSchema: object({ tab_id: TAB_ID }, []),
212 needsTab: true,
213 expression: "await __tab.back()",
214 },
215 {
216 name: "go_forward",
217 description: "Navigate the tab forward in its history.",
218 inputSchema: object({ tab_id: TAB_ID }, []),
219 needsTab: true,
220 expression: "await __tab.forward()",
221 },
222 {
223 name: "reload",
224 description: "Reload the tab.",
225 inputSchema: object({ tab_id: TAB_ID }, []),
226 needsTab: true,
227 expression: "await __tab.reload()",
228 },
229 {
230 name: "close_tab",
231 description: "Close a tab.",
232 inputSchema: object({ tab_id: TAB_ID }, ["tab_id"]),
233 needsTab: true,
234 expression: "await __tab.close()",
235 },
236 {
237 name: "export_content",
238 description:
239 "Export the tab's readable content to a file on disk and return its path. Use this to read a long page rather than paging through its accessibility state.",
240 inputSchema: object({ tab_id: TAB_ID }, []),
241 needsTab: true,
242 expression: "await __tab.content.export()",
243 },
244];
245
246export const browserToolList = (): readonly Record<string, unknown>[] =>
247 BROWSER_TOOLS.map((tool) => ({
248 name: tool.name,
249 description: tool.description,
250 inputSchema: tool.inputSchema,
251 }));
252
253export const findBrowserTool = (name: string): BrowserToolDefinition | undefined =>
254 BROWSER_TOOLS.find((tool) => tool.name === name);
255
256/** Cached on the REPL session because `setupBrowserRuntime()` connects to the
257 * browser extension — far too expensive per call. `??=` keeps it correct
258 * whether the session is warm or brand new. */
259const runtimePreamble = (modulePath: string): string =>
260 [
261 "globalThis.__executorBrowser ??= await (async () => {",
262 ` const { setupBrowserRuntime } = await import(${jsLiteral(modulePath)});`,
263 " const agent = await setupBrowserRuntime();",
264 " return { agent, browser: await agent.browsers.getDefault() };",
265 "})();",
266 ].join("\n");
267
268/** Resolve the tab a call acts on: the named one, else the selected one, else
269 * a new one — so a caller that never mentions a tab still works. */
270const TAB_PREAMBLE = [
271 "const __tab = __args.tab_id",
272 " ? await __browser.tabs.get(__args.tab_id)",
273 " : ((await __browser.tabs.selected()) ?? (await __browser.tabs.new()));",
274].join("\n");
275
276/** The `node_repl` program that performs one browser call. */
277export const browserCallProgram = (
278 tool: BrowserToolDefinition,
279 args: unknown,
280 modulePath: string,
281): string =>
282 [
283 runtimePreamble(modulePath),
284 writeJsonResult(
285 [
286 "const __browser = globalThis.__executorBrowser.browser;",
287 `const __args = ${jsLiteral(args ?? {})} ?? {};`,
288 ...(tool.needsTab ? [TAB_PREAMBLE] : []),
289 ],
290 tool.expression,
291 ),
292 ].join("\n");
packages/plugins/mcp/src/sdk/codex-plugin-presets.ts+41−4
@@ -19,13 +19,22 @@ export interface CuratedCodexPlugin { · 18 unmodified lines
19 * the app-server bridge calls tools against. */19 * the app-server bridge calls tools against. */
20 readonly server: string;20 readonly server: string;
21 /** Present when the plugin has no MCP server of its own and its API is21 /** Present when the plugin has no MCP server of its own and its API is
22 * projected onto another one. Computer Use ships as a `node-repl` variant:22 * projected onto another one. Computer Use and Chrome both ship as
23 * Codex never starts a `computer-use` server, and the API is driven through23 * skills/`node-repl` content: Codex never starts a server for either, and
24 * `node_repl` — see `codex-sky-tools.ts`. */24 * their APIs are driven through `node_repl` — see `codex-sky-tools.ts` and
25 readonly surface?: "sky";25 * `codex-browser-tools.ts`. */
26 readonly surface?: "sky" | "browser";
27 /** What must exist on disk for this card to be usable, beyond the `codex`
28 * CLI itself. `computer-use-app` is the shared Codex Computer Use app;
29 * `chrome-plugin` is the Chrome plugin's bundled browser client; `codex`
30 * means the CLI alone is enough (a server Codex always carries). */
31 readonly requires: "computer-use-app" | "chrome-plugin" | "codex";
26 readonly summary: string;32 readonly summary: string;
27}33}
28 34
35export const CHROME_SETUP_HINT =
36 "Install the Codex app and add the Chrome plugin (Settings \u2192 Computer use installs the ChatGPT browser extension), then use it once inside Codex so it can reach your browser.";
37
29export const CODEX_SETUP_HINT =38export const CODEX_SETUP_HINT =
30 "Install the Codex app, sign in, and use this plugin once inside Codex so macOS grants its permissions (Full Disk Access, Contacts, Automation).";39 "Install the Codex app, sign in, and use this plugin once inside Codex so macOS grants its permissions (Full Disk Access, Contacts, Automation).";
31 40
@@ -39,6 +48,7 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ · 7 unmodified lines
39 pluginName: "messages",48 pluginName: "messages",
40 name: "Messages",49 name: "Messages",
41 slug: "codex_messages",50 slug: "codex_messages",
51 requires: "computer-use-app",
42 server: "messages",52 server: "messages",
43 summary:53 summary:
44 "Read, search, and send iMessage/SMS texts through Apple's Messages app on this Mac, via the Codex plugin. Reads and sends are approved in its native dialogs.",54 "Read, search, and send iMessage/SMS texts through Apple's Messages app on this Mac, via the Codex plugin. Reads and sends are approved in its native dialogs.",
@@ -48,16 +58,43 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ · 3 unmodified lines
48 pluginName: "computer-use",58 pluginName: "computer-use",
49 name: "Computer Use",59 name: "Computer Use",
50 slug: "codex_computer_use",60 slug: "codex_computer_use",
61 requires: "computer-use-app",
51 server: "node_repl",62 server: "node_repl",
52 surface: "sky",63 surface: "sky",
53 summary:64 summary:
54 "Control macOS desktop apps via the Codex plugin: read the screen and accessibility tree, click, type, and scroll.",65 "Control macOS desktop apps via the Codex plugin: read the screen and accessibility tree, click, type, and scroll.",
55 },66 },
67 {
68 // Chrome is skills-only: its API is the bundled `browser-client.mjs`,
69 // driven through `node_repl` exactly as Computer Use drives `@oai/sky`.
70 id: "codex-chrome",
71 pluginName: "chrome",
72 name: "Chrome",
73 slug: "codex_chrome",
74 server: "node_repl",
75 surface: "browser",
76 requires: "chrome-plugin",
77 summary:
78 "Control the Chrome browser on this Mac through the Codex plugin: open tabs, navigate to a URL, read the page, click, and type. Uses your real Chrome, with its logged-in sessions.",
79 },
80 {
81 // A server Codex carries itself — no plugin app, no local binary beyond
82 // the CLI.
83 id: "codex-openai-docs",
84 pluginName: "openai-developers",
85 name: "OpenAI Developer Docs",
86 slug: "codex_openai_docs",
87 server: "openaiDeveloperDocs",
88 requires: "codex",
89 summary:
90 "Search and read OpenAI's developer documentation and API reference, including OpenAPI specs and endpoint listings, via the Codex plugin.",
91 },
56 {92 {
57 id: "codex-computer-history",93 id: "codex-computer-history",
58 pluginName: "computer-history",94 pluginName: "computer-history",
59 name: "Computer History",95 name: "Computer History",
60 slug: "codex_computer_history",96 slug: "codex_computer_history",
97 requires: "computer-use-app",
61 server: "computer-history",98 server: "computer-history",
62 summary:99 summary:
63 "Ask about recent on-screen activity from Codex's private local record (requires Computer History enabled in Codex).",100 "Ask about recent on-screen activity from Codex's private local record (requires Computer History enabled in Codex).",
packages/plugins/mcp/src/sdk/codex-plugins.test.ts+60−15
@@ -81,6 +81,24 @@ const writeCachedPlugin = ( · 80 unmodified lines
81 return versionDir;81 return versionDir;
82};82};
83 83
84/** Chrome's bundled browser client, reached through the `latest` symlink
85 * Codex maintains beside the versioned directories. */
86const CHROME_CLIENT_RELATIVE = join(
87 "plugins",
88 "cache",
89 "openai-bundled",
90 "chrome",
91 "latest",
92 "scripts",
93 "browser-client.mjs",
94);
95
96const writeChromePlugin = (home: string): void => {
97 const file = join(home, CHROME_CLIENT_RELATIVE);
98 mkdirSync(join(file, ".."), { recursive: true });
99 writeFileSync(file, "export const setupBrowserRuntime = async () => ({});\n");
100};
101
84/** A fake `codex` CLI inside the temp home, passed explicitly so the scan102/** A fake `codex` CLI inside the temp home, passed explicitly so the scan
85 * never resolves the machine's real install through PATH. */103 * never resolves the machine's real install through PATH. */
86const writeCodexCli = (home: string): string => {104const writeCodexCli = (home: string): string => {
@@ -93,6 +111,7 @@ describe("scanCodexPlugins", () => { · 6 unmodified lines
93 it("reports the curated plugins as app-server recipes when Codex is fully installed", () => {111 it("reports the curated plugins as app-server recipes when Codex is fully installed", () => {
94 const home = makeHome();112 const home = makeHome();
95 writeExecutable(join(home, CLIENT_RELATIVE));113 writeExecutable(join(home, CLIENT_RELATIVE));
114 writeChromePlugin(home);
96 const cli = writeCodexCli(home);115 const cli = writeCodexCli(home);
97 116
98 const entries = scanCodexPlugins({ codexHome: home, codexCli: cli });117 const entries = scanCodexPlugins({ codexHome: home, codexCli: cli });
@@ -101,50 +120,76 @@ describe("scanCodexPlugins", () => { · 2 unmodified lines
101 expect(curated.map((entry) => entry.id)).toEqual([120 expect(curated.map((entry) => entry.id)).toEqual([
102 "codex-messages",121 "codex-messages",
103 "codex-computer-use",122 "codex-computer-use",
123 "codex-chrome",
124 "codex-openai-docs",
104 "codex-computer-history",125 "codex-computer-history",
105 ]);126 ]);
106 for (const entry of curated) {127 for (const entry of curated) {
107 expect(entry.available).toBe(true);128 expect(entry.available, entry.id).toBe(true);
108 // Curated plugins go through the app-server bridge — its service only129 // Curated plugins go through the app-server bridge — the plugins' own
109 // honours Codex host sessions, so the client binary is never spawned.130 // service only honours Codex host sessions, so their binaries are never
131 // spawned directly.
110 expect(entry.command).toBe(cli);132 expect(entry.command).toBe(cli);
111 expect(entry.args).toEqual(["app-server"]);133 expect(entry.args).toEqual(["app-server"]);
112 expect(entry.env).toEqual({ CODEX_HOME: home });134 expect(entry.env).toEqual({ CODEX_HOME: home });
113 expect(entry.setupHint).toBeUndefined();135 expect(entry.setupHint).toBeUndefined();
114 }136 }
115 // Computer Use has no MCP server of its own in current Codex — it ships as137 // Computer Use and Chrome have no MCP server of their own in current
116 // a node-repl variant, so it targets `node_repl` with the sky surface.138 // Codex: both ship as node-repl content, so they target `node_repl` with a
139 // projected surface. Chrome additionally carries the module its surface
140 // imports, resolved through the version-proof `latest` symlink.
117 expect(curated.map((entry) => entry.appServer)).toEqual([141 expect(curated.map((entry) => entry.appServer)).toEqual([
118 { server: "messages" },142 { server: "messages" },
119 { server: "node_repl", surface: "sky" },143 { server: "node_repl", surface: "sky" },
144 { server: "node_repl", surface: "browser", modulePath: join(home, CHROME_CLIENT_RELATIVE) },
145 { server: "openaiDeveloperDocs" },
120 { server: "computer-history" },146 { server: "computer-history" },
121 ]);147 ]);
122 });148 });
123 149
124 it("reports the curated plugins with a setup hint when Codex is not installed", () => {150 it("reports every curated plugin with a setup hint when Codex is not installed", () => {
125 const home = makeHome();151 const home = makeHome();
126 152
127 const entries = scanCodexPlugins({ codexHome: home, codexCli: join(home, "bin", "codex") });153 const entries = scanCodexPlugins({ codexHome: home, codexCli: join(home, "bin", "codex") });
128 const curated = entries.filter((entry) => entry.source === "curated");154 const curated = entries.filter((entry) => entry.source === "curated");
129 155
130 expect(curated).toHaveLength(3);156 expect(curated).toHaveLength(5);
131 for (const entry of curated) {157 for (const entry of curated) {
132 expect(entry.available).toBe(false);158 expect(entry.available, entry.id).toBe(false);
133 expect(entry.setupHint).toContain("Install the Codex app");159 expect(entry.setupHint, entry.id).toContain("Codex");
134 }160 }
135 });161 });
136 162
137 it("stays unavailable when the CLI exists but the Computer Use app is missing", () => {163 it("gates each curated plugin on what it actually needs, not on Codex alone", () => {
138 const home = makeHome();164 const home = makeHome();
139 const cli = writeCodexCli(home);165 const cli = writeCodexCli(home);
140 166
141 const curated = scanCodexPlugins({ codexHome: home, codexCli: cli }).filter(167 const byId = new Map(
142 (entry) => entry.source === "curated",168 scanCodexPlugins({ codexHome: home, codexCli: cli }).map((entry) => [entry.id, entry]),
169 );
170
171 // `codex app-server` starts, but the plugins' own content is absent: no
172 // `messages` server without the Computer Use app, no browser client
173 // without the Chrome plugin. Those cards must not claim readiness.
174 expect(byId.get("codex-messages")?.available).toBe(false);
175 expect(byId.get("codex-computer-use")?.available).toBe(false);
176 expect(byId.get("codex-chrome")?.available).toBe(false);
177 expect(byId.get("codex-chrome")?.setupHint).toContain("Chrome plugin");
178 // The docs server ships with Codex itself, so the CLI alone is enough.
179 expect(byId.get("codex-openai-docs")?.available).toBe(true);
180 });
181
182 it("keeps Chrome unavailable when only the Computer Use app is installed", () => {
183 const home = makeHome();
184 writeExecutable(join(home, CLIENT_RELATIVE));
185 const cli = writeCodexCli(home);
186
187 const byId = new Map(
188 scanCodexPlugins({ codexHome: home, codexCli: cli }).map((entry) => [entry.id, entry]),
143 );189 );
144 190
145 // `codex app-server` would start, but no `messages`/`computer-use` server191 expect(byId.get("codex-messages")?.available).toBe(true);
146 // exists without the plugin app — so the card must not claim readiness.192 expect(byId.get("codex-chrome")?.available).toBe(false);
147 for (const entry of curated) expect(entry.available).toBe(false);
148 });193 });
149 194
150 it("scans cached plugins, resolving command and cwd against the newest version", () => {195 it("scans cached plugins, resolving command and cwd against the newest version", () => {
packages/plugins/mcp/src/sdk/codex-plugins.ts+50−6
@@ -20,7 +20,12 @@ import * as path from "node:path"; · 19 unmodified lines
20 20
21import { Option, Schema } from "effect";21import { Option, Schema } from "effect";
22 22
23import { CODEX_SETUP_HINT, CURATED_CODEX_PLUGINS } from "./codex-plugin-presets";23import {
24 CHROME_SETUP_HINT,
25 CODEX_SETUP_HINT,
26 CURATED_CODEX_PLUGINS,
27 type CuratedCodexPlugin,
28} from "./codex-plugin-presets";
24 29
25export interface CodexPluginEntry {30export interface CodexPluginEntry {
26 /** Stable card id, e.g. `codex-messages`. */31 /** Stable card id, e.g. `codex-messages`. */
@@ -41,7 +46,11 @@ export interface CodexPluginEntry { · 14 unmodified lines
41 /** Present on curated entries: the spawn is `codex app-server` and the46 /** Present on curated entries: the spawn is `codex app-server` and the
42 * connector bridges MCP to it in process, calling tools on this named47 * connector bridges MCP to it in process, calling tools on this named
43 * server inside Codex. See `appserver-connector.ts`. */48 * server inside Codex. See `appserver-connector.ts`. */
44 readonly appServer?: { readonly server: string; readonly surface?: "sky" };49 readonly appServer?: {
50 readonly server: string;
51 readonly surface?: "sky" | "browser";
52 readonly modulePath?: string;
53 };
45 /** Shown when `available` is false. */54 /** Shown when `available` is false. */
46 readonly setupHint?: string;55 readonly setupHint?: string;
47 /** The plugin's own icon from its local install, as a data URI. Read at56 /** The plugin's own icon from its local install, as a data URI. Read at
@@ -72,6 +81,24 @@ const clientBinaryPath = (codexHome: string): string => · 24 unmodified lines
72 "SkyComputerUseClient",81 "SkyComputerUseClient",
73 );82 );
74 83
84/** The Chrome plugin's bundled browser client — the module the projected
85 * browser surface imports inside `node_repl`.
86 *
87 * Reached through the `latest` symlink Codex maintains beside the versioned
88 * directories, so a plugin update does not strand the stored path. Same
89 * reasoning as pointing Computer Use at the unversioned client binary. */
90const browserClientPath = (codexHome: string): string =>
91 path.join(
92 codexHome,
93 "plugins",
94 "cache",
95 "openai-bundled",
96 "chrome",
97 "latest",
98 "scripts",
99 "browser-client.mjs",
100 );
101
75const CURATED_PLUGIN_NAMES: ReadonlySet<string> = new Set(102const CURATED_PLUGIN_NAMES: ReadonlySet<string> = new Set(
76 CURATED_CODEX_PLUGINS.map((c) => c.pluginName),103 CURATED_CODEX_PLUGINS.map((c) => c.pluginName),
77);104);
@@ -158,6 +185,12 @@ const listDirs = (dir: string): readonly string[] => · 80 unmodified lines
158const readText = (file: string): string | undefined =>185const readText = (file: string): string | undefined =>
159 tryOrElse(() => fs.readFileSync(file, "utf-8"), undefined);186 tryOrElse(() => fs.readFileSync(file, "utf-8"), undefined);
160 187
188const isReadableFile = (file: string): boolean =>
189 tryOrElse(() => fs.statSync(file).isFile(), false);
190
191const setupHintFor = (requires: CuratedCodexPlugin["requires"]): string =>
192 requires === "chrome-plugin" ? CHROME_SETUP_HINT : CODEX_SETUP_HINT;
193
161const isExecutableFile = (file: string): boolean =>194const isExecutableFile = (file: string): boolean =>
162 tryOrElse(() => {195 tryOrElse(() => {
163 fs.accessSync(file, fs.constants.X_OK);196 fs.accessSync(file, fs.constants.X_OK);
@@ -361,16 +394,26 @@ export const scanCodexPlugins = (options?: { · 197 unmodified lines
361 options?.codexHome ?? process.env["CODEX_HOME"] ?? path.join(os.homedir(), ".codex");394 options?.codexHome ?? process.env["CODEX_HOME"] ?? path.join(os.homedir(), ".codex");
362 395
363 const codexCli = resolveCodexCli(options?.codexCli);396 const codexCli = resolveCodexCli(options?.codexCli);
364 const clientAvailable = isExecutableFile(clientBinaryPath(codexHome));397 const computerUseApp = isExecutableFile(clientBinaryPath(codexHome));
365 const curatedAvailable = codexCli !== undefined && clientAvailable;398 const browserClient = browserClientPath(codexHome);
399 const chromePlugin = isReadableFile(browserClient);
400
401 /** Each curated card states what it needs; the `codex` CLI is required by
402 * all of them because every one is reached through `codex app-server`. */
403 const requirementMet: Record<CuratedCodexPlugin["requires"], boolean> = {
404 "computer-use-app": computerUseApp,
405 "chrome-plugin": chromePlugin,
406 codex: true,
407 };
366 408
367 const curated: readonly CodexPluginEntry[] = CURATED_CODEX_PLUGINS.map((entry) => {409 const curated: readonly CodexPluginEntry[] = CURATED_CODEX_PLUGINS.map((entry) => {
368 const display = curatedDisplayMetadata(codexHome, entry.pluginName);410 const display = curatedDisplayMetadata(codexHome, entry.pluginName);
411 const available = codexCli !== undefined && requirementMet[entry.requires];
369 return {412 return {
370 id: entry.id,413 id: entry.id,
371 name: entry.name,414 name: entry.name,
372 summary: entry.summary,415 summary: entry.summary,
373 available: curatedAvailable,416 available,
374 slug: entry.slug,417 slug: entry.slug,
375 source: "curated" as const,418 source: "curated" as const,
376 command: codexCli ?? "codex",419 command: codexCli ?? "codex",
@@ -379,8 +422,9 @@ export const scanCodexPlugins = (options?: { · 2 unmodified lines
379 appServer: {422 appServer: {
380 server: entry.server,423 server: entry.server,
381 ...(entry.surface === undefined ? {} : { surface: entry.surface }),424 ...(entry.surface === undefined ? {} : { surface: entry.surface }),
425 ...(entry.surface === "browser" ? { modulePath: browserClient } : {}),
382 },426 },
383 ...(curatedAvailable ? {} : { setupHint: CODEX_SETUP_HINT }),427 ...(available ? {} : { setupHint: setupHintFor(entry.requires) }),
384 ...display,428 ...display,
385 };429 };
386 });430 });
packages/plugins/mcp/src/sdk/codex-repl.ts+46−0
@@ -0,0 +1,46 @@
1// ---------------------------------------------------------------------------
2// Shared helpers for the Codex plugin surfaces that run through `node_repl`.
3//
4// Two Codex plugins ship no MCP server of their own and are driven by
5// executing JavaScript in Codex's Node REPL: Computer Use (`@oai/sky`) and
6// Chrome (`browser-client.mjs`). Both surfaces project that REPL as typed MCP
7// tools, and both therefore have to embed caller arguments into a JS source
8// text and read one JSON value back out. That encoding is the genuinely
9// shared part, and it is the part that must be exactly right.
10// ---------------------------------------------------------------------------
11
12/**
13 * A JS literal for an arbitrary value.
14 *
15 * JSON is almost a subset of JS — but U+2028 and U+2029 are legal raw inside a
16 * JSON string while being literal line terminators in a JS source text, so a
17 * value containing one would end the statement early. Escaping them makes the
18 * embedding exact for every input, including text typed by a user.
19 */
20export const jsLiteral = (value: unknown): string =>
21 JSON.stringify(value ?? null)
22 .replaceAll("\u2028", "\\u2028")
23 .replaceAll("\u2029", "\\u2029");
24
25/**
26 * Wrap a program body so it runs in its own scope and reports one JSON value.
27 *
28 * The scope is not cosmetic. A REPL session is persistent and pooled
29 * connections now reuse it across calls, so a program that declared its
30 * working variables at top level would redeclare the same `const` on every
31 * call — which the REPL answers with a warning per variable, prepended to the
32 * result a caller then has to read around. Everything per-call lives in here;
33 * only deliberate caches (the imported runtime) are left on `globalThis`.
34 *
35 * The REPL returns values only through `nodeRepl.write`, and only as text;
36 * `undefined` (every action method) becomes `null` so a caller always gets a
37 * well-formed JSON body rather than an empty string.
38 */
39export const writeJsonResult = (body: readonly string[], expression: string): string =>
40 [
41 "await (async () => {",
42 ...body.map((line) => ` ${line}`),
43 ` const result = ${expression};`,
44 " nodeRepl.write(JSON.stringify(result ?? null));",
45 "})();",
46 ].join("\n");
packages/plugins/mcp/src/sdk/codex-sky-tools.ts+3−11
@@ -19,6 +19,8 @@ · 18 unmodified lines
19// The surface below mirrors the `Sky` type in the plugin's own SKILL.md.19// The surface below mirrors the `Sky` type in the plugin's own SKILL.md.
20// ---------------------------------------------------------------------------20// ---------------------------------------------------------------------------
21 21
22import { jsLiteral, writeJsonResult } from "./codex-repl";
23
22/** Bundled package the REPL imports; `sky` is its single exported entry. */24/** Bundled package the REPL imports; `sky` is its single exported entry. */
23const SKY_PACKAGE = "@oai/sky";25const SKY_PACKAGE = "@oai/sky";
24 26
@@ -233,15 +235,6 @@ export const skyToolList = (): readonly Record<string, unknown>[] => · 208 unmodified lines
233export const findSkyTool = (name: string): SkyToolDefinition | undefined =>235export const findSkyTool = (name: string): SkyToolDefinition | undefined =>
234 SKY_TOOLS.find((tool) => tool.name === name);236 SKY_TOOLS.find((tool) => tool.name === name);
235 237
236/** JSON is almost a JS subset — but U+2028/U+2029 are literal line
237 * terminators in a JS source text while being legal raw inside a JSON
238 * string, so a value containing one would end the statement. Escaping them
239 * makes the embedding exact for every input. */
240const jsLiteral = (value: unknown): string =>
241 JSON.stringify(value ?? {})
242 .replaceAll("\u2028", "\\u2028")
243 .replaceAll("\u2029", "\\u2029");
244
245/**238/**
246 * The `node_repl` program that performs one sky call.239 * The `node_repl` program that performs one sky call.
247 *240 *
@@ -259,7 +252,6 @@ export const skyCallProgram = (tool: SkyToolDefinition, args: unknown): string = · 11 unmodified lines
259 const call = tool.takesArgs ? `sky.${tool.method}(${jsLiteral(args)})` : `sky.${tool.method}()`;252 const call = tool.takesArgs ? `sky.${tool.method}(${jsLiteral(args)})` : `sky.${tool.method}()`;
260 return [253 return [
261 `globalThis.sky ??= (await import(${JSON.stringify(SKY_PACKAGE)})).sky;`,254 `globalThis.sky ??= (await import(${JSON.stringify(SKY_PACKAGE)})).sky;`,
262 `const __result = await ${call};`,255 writeJsonResult([], `await ${call}`),
263 `nodeRepl.write(JSON.stringify(__result ?? null));`,
264 ].join("\n");256 ].join("\n");
265};257};
packages/plugins/mcp/src/sdk/connection.ts+2−1
@@ -459,7 +459,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 458 unmodified lines
459 // bridge answers the MCP handshake itself, so `versionNegotiation` does459 // bridge answers the MCP handshake itself, so `versionNegotiation` does
460 // not apply on this path.460 // not apply on this path.
461 if (input.appServer !== undefined) {461 if (input.appServer !== undefined) {
462 const { server, surface } = input.appServer;462 const { server, surface, modulePath } = input.appServer;
463 return Effect.gen(function* () {463 return Effect.gen(function* () {
464 const { createAppServerTransport } = yield* Effect.tryPromise({464 const { createAppServerTransport } = yield* Effect.tryPromise({
465 try: () => import("./appserver-connector"),465 try: () => import("./appserver-connector"),
@@ -480,6 +480,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 14 unmodified lines
480 cwd: input.cwd?.trim().length ? input.cwd.trim() : undefined,480 cwd: input.cwd?.trim().length ? input.cwd.trim() : undefined,
481 server,481 server,
482 ...(surface === undefined ? {} : { surface }),482 ...(surface === undefined ? {} : { surface }),
483 ...(modulePath === undefined ? {} : { modulePath }),
483 }),484 }),
484 });485 });
485 });486 });
packages/plugins/mcp/src/sdk/plugin.ts+5−1
@@ -230,7 +230,11 @@ const McpStdioServerInputSchema = Schema.Struct({ · 229 unmodified lines
230 * `codex app-server` and `server` names the MCP server inside Codex whose230 * `codex app-server` and `server` names the MCP server inside Codex whose
231 * tools this integration exposes. Set by the Codex plugin add flow. */231 * tools this integration exposes. Set by the Codex plugin add flow. */
232 appServer: Schema.optional(232 appServer: Schema.optional(
233 Schema.Struct({ server: Schema.String, surface: Schema.optional(Schema.Literal("sky")) }),233 Schema.Struct({
234 server: Schema.String,
235 surface: Schema.optional(Schema.Literals(["sky", "browser"])),
236 modulePath: Schema.optional(Schema.String),
237 }),
234 ),238 ),
235 slug: Schema.optional(Schema.String),239 slug: Schema.optional(Schema.String),
236});240});
packages/plugins/mcp/src/sdk/types.ts+10−4
@@ -266,10 +266,16 @@ export const McpStdioIntegrationConfig = Schema.Struct({ · 265 unmodified lines
266 appServer: Schema.optional(266 appServer: Schema.optional(
267 Schema.Struct({267 Schema.Struct({
268 server: Schema.String,268 server: Schema.String,
269 /** `sky` projects the Codex Computer Use API (driven through the269 /** A projected tool surface for a plugin that has no MCP server of its
270 * `node_repl` server) as typed tools — see `codex-sky-tools.ts`.270 * own and is driven through Codex's `node_repl`: `sky` is Computer Use
271 * Absent means the server's own tools are exposed verbatim. */271 * (`codex-sky-tools.ts`), `browser` is Chrome
272 surface: Schema.optional(Schema.Literal("sky")),272 * (`codex-browser-tools.ts`). Absent exposes the server's own tools
273 * verbatim. */
274 surface: Schema.optional(Schema.Literals(["sky", "browser"])),
275 /** Absolute path to the module a projected surface imports (currently
276 * Chrome's `browser-client.mjs`). Machine-specific, so it is resolved
277 * by the scanner rather than hardcoded. */
278 modulePath: Schema.optional(Schema.String),
273 }),279 }),
274 ),280 ),
275 /** Declared auth methods — a single `stdio_env` method naming the secret env281 /** Declared auth methods — a single `stdio_env` method naming the secret env