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
3434 const api = composePluginApi([mcpHttpPlugin()] as const);
3535
3636 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+);
3746 const APP_SERVER_FIXTURE = fileURLToPath(
3847 new URL("./fixtures/codex-app-server.mjs", import.meta.url),
3948 );
@@ -67,6 +76,11 @@ const makeCodexHome = (): string => { · 27 unmodified lines
6776 mode: 0o755,
6877 });
6978
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+
7084 const versionDir = join(home, "plugins", "cache", "personal", "echo-suite", "1.0.2");
7185 mkdirSync(join(versionDir, ".codex-plugin"), { recursive: true });
7286 mkdirSync(join(versionDir, "bin"), { recursive: true });
@@ -115,10 +129,12 @@ scenario( · 42 unmodified lines
115129 const { plugins } = yield* client.mcp.listCodexPlugins();
116130 const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
117131 expect([...byId.keys()].sort(), "curated + scanned entries are reported").toEqual([
132+ "codex-chrome",
118133 "codex-computer-history",
119134 "codex-computer-use",
120135 "codex-echo-suite",
121136 "codex-messages",
137+ "codex-openai-docs",
122138 ]);
123139 for (const plugin of plugins) {
124140 expect(plugin.available, `${plugin.id} is available`).toBe(true);
@@ -136,6 +152,18 @@ scenario( · 11 unmodified lines
136152 expect(messages?.appServer, "curated entries name their Codex server").toEqual({
137153 server: "messages",
138154 });
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+ });
139167
140168 // Add two entries exactly as the add-form's Codex-plugins card does:
141169 // the reported recipe, verbatim.
packages/plugins/mcp/src/api/group.ts+10−2
@@ -66,7 +66,11 @@ const AddStdioServerPayload = Schema.Struct({ · 65 unmodified lines
6666 /** Reach the server through the Codex app-server bridge: the command spawns
6767 * `codex app-server` and `server` names the MCP server inside Codex. */
6868 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+ }),
7074 ),
7175 slug: Schema.optional(Schema.String),
7276 });
@@ -152,7 +156,11 @@ const CodexPluginEntrySchema = Schema.Struct({ · 79 unmodified lines
152156 /** Present on curated entries: add through the Codex app-server bridge,
153157 * calling tools on this named server inside Codex. */
154158 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+ }),
156164 ),
157165 setupHint: Schema.optional(Schema.String),
158166 /** 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
4141 env?: Record<string, string>;
4242 cwd?: string;
4343 versionNegotiation?: "legacy" | "auto";
44− appServer?: { server: string; surface?: "sky" };
44+ appServer?: { server: string; surface?: "sky" | "browser"; modulePath?: string };
4545 slug?: string;
4646 };
4747 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
1515
1616 const fixture = fileURLToPath(new URL("./appserver-test-server.ts", import.meta.url));
1717
18−const appServerInput = (server: string, surface?: "sky"): StdioConnectorInput => ({
18+const appServerInput = (
19+ server: string,
20+ appServer?: { readonly surface?: "sky" | "browser"; readonly modulePath?: string },
21+): StdioConnectorInput => ({
1922 transport: "stdio",
2023 command: "bun",
2124 args: ["run", fixture],
2225 env: { CODEX_HOME: "/tmp/fixture-codex-home" },
23− appServer: { server, ...(surface === undefined ? {} : { surface }) },
26+ appServer: { server, ...appServer },
2427 });
2528
2629 const withConnection = (input: StdioConnectorInput) =>
@@ -126,7 +129,7 @@ describe("codex app-server bridge", () => { · 99 unmodified lines
126129 it.effect("the sky surface lists typed Computer Use tools, not the raw REPL", () =>
127130 Effect.scoped(
128131 Effect.gen(function* () {
129− const connection = yield* withConnection(appServerInput("node_repl", "sky"));
132+ const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));
130133
131134 const tools = yield* Effect.promise(() => connection.client.listTools());
132135 const names = tools.tools.map(({ name }) => name);
@@ -144,7 +147,7 @@ describe("codex app-server bridge", () => { · 11 unmodified lines
144147 it.effect("a sky tool call compiles to one node_repl program carrying its arguments", () =>
145148 Effect.scoped(
146149 Effect.gen(function* () {
147− const connection = yield* withConnection(appServerInput("node_repl", "sky"));
150+ const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));
148151
149152 // Quotes in the arguments matter: they are embedded into a JS source
150153 // text, so the encoding has to survive them exactly.
@@ -161,7 +164,10 @@ describe("codex app-server bridge", () => { · 10 unmodified lines
161164 `await sky.type_text(${JSON.stringify(args)})`,
162165 );
163166 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 () => {",
165171 );
166172 }),
167173 ),
@@ -170,7 +176,7 @@ describe("codex app-server bridge", () => { · 2 unmodified lines
170176 it.effect("an argument-less sky tool calls its method with no argument object", () =>
171177 Effect.scoped(
172178 Effect.gen(function* () {
173− const connection = yield* withConnection(appServerInput("node_repl", "sky"));
179+ const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));
174180
175181 const result = yield* Effect.promise(() =>
176182 connection.client.callTool({ name: "list_apps", arguments: {} }),
@@ -184,7 +190,7 @@ describe("codex app-server bridge", () => { · 7 unmodified lines
184190 it.effect("a tool outside the sky surface is refused rather than sent to the REPL", () =>
185191 Effect.scoped(
186192 Effect.gen(function* () {
187− const connection = yield* withConnection(appServerInput("node_repl", "sky"));
193+ const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));
188194
189195 const outcome = yield* Effect.promise(() =>
190196 connection.client.callTool({ name: "js", arguments: { code: "process.exit(0)" } }).then(
@@ -197,6 +203,90 @@ describe("codex app-server bridge", () => { · 6 unmodified lines
197203 ),
198204 );
199205
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+
200290 it.effect("a server name Codex does not report fails the tools listing, not the connect", () =>
201291 Effect.scoped(
202292 Effect.gen(function* () {
packages/plugins/mcp/src/sdk/appserver-connector.ts+65−10
@@ -39,6 +39,7 @@ · 38 unmodified lines
3939 import type { JSONRPCMessage, JSONRPCRequest, Transport } from "@modelcontextprotocol/client";
4040 import { Option, Schema } from "effect";
4141
42+import { browserCallProgram, browserToolList, findBrowserTool } from "./codex-browser-tools";
4243 import { findSkyTool, skyCallProgram, skyToolList } from "./codex-sky-tools";
4344 import { stdioSpawnEnv, type StdioTransportConfig } from "./stdio-connector";
4445
@@ -78,10 +79,14 @@ export type AppServerTransportConfig = StdioTransportConfig & { · 33 unmodified lines
7879 /** The MCP server name inside Codex whose tools this transport exposes
7980 * (e.g. `messages`) — the `server` of every `mcpServer/tool/call`. */
8081 readonly server: string;
81− /** `sky` projects the Codex Computer Use API over the `node_repl` server as
82− * typed tools instead of exposing the REPL itself (see
83− * `codex-sky-tools.ts`). Absent exposes the server's tools verbatim. */
84− readonly surface?: "sky";
82+ /** A projected tool surface for a plugin driven through `node_repl` rather
83+ * than serving MCP itself: `sky` is Computer Use (`codex-sky-tools.ts`),
84+ * `browser` is Chrome (`codex-browser-tools.ts`). Absent exposes the
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;
8590 };
8691
8792 // ---------------------------------------------------------------------------
@@ -173,6 +178,9 @@ type AppServerReply = · 85 unmodified lines
173178 const INTERNAL_ERROR = -32603;
174179 const METHOD_NOT_FOUND = -32601;
175180
181+/** Ceiling for one browser action inside the REPL. */
182+const BROWSER_TIMEOUT_MS = 120_000;
183+
176184 const CHANNEL_CLOSED: AppServerReply = {
177185 ok: false,
178186 error: { code: INTERNAL_ERROR, message: "Codex app-server exited before replying" },
@@ -370,6 +378,10 @@ class AppServerClientTransport implements Transport { · 191 unmodified lines
370378 this.#emit({ jsonrpc: "2.0", id: message.id, result: { tools: skyToolList() } });
371379 return;
372380 }
381+ if (this.#config.surface === "browser") {
382+ this.#emit({ jsonrpc: "2.0", id: message.id, result: { tools: browserToolList() } });
383+ return;
384+ }
373385 const tools = await this.#collectServerTools(message.id);
374386 if (tools === undefined) return;
375387 this.#emit({ jsonrpc: "2.0", id: message.id, result: { tools } });
@@ -426,7 +438,7 @@ class AppServerClientTransport implements Transport { · 50 unmodified lines
426438 if (call === undefined) {
427439 this.#fail(message.id, {
428440 code: METHOD_NOT_FOUND,
429− message: `Unknown Computer Use tool "${params.value.name}"`,
441+ message: `Unknown tool "${params.value.name}" for this Codex plugin`,
430442 });
431443 return;
432444 }
@@ -461,7 +473,9 @@ class AppServerClientTransport implements Transport { · 28 unmodified lines
461473 * performs it; otherwise the tool is passed through by name. Undefined
462474 * means the surface does not define that tool. */
463475 #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) {
465479 return {
466480 threadId: this.#threadId,
467481 server: this.#config.server,
@@ -469,19 +483,60 @@ class AppServerClientTransport implements Transport { · 1 unmodified line
469483 arguments: args ?? {},
470484 };
471485 }
472− const tool = findSkyTool(name);
473− if (tool === undefined) return undefined;
474486 return {
475487 threadId: this.#threadId,
476488 server: this.#config.server,
477489 tool: "js",
478490 arguments: {
479− code: skyCallProgram(tool, args),
480− title: `Computer Use: ${tool.name}`,
491+ code: program.code,
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+ },
481506 },
482507 };
483508 }
484509
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+
485540 // -------------------------------------------------------------------------
486541 // Downstream traffic
487542 // -------------------------------------------------------------------------
packages/plugins/mcp/src/sdk/appserver-test-server.ts+7−1
@@ -46,6 +46,7 @@ const decodeToolCallParams = Schema.decodeUnknownOption( · 45 unmodified lines
4646 server: Schema.String,
4747 tool: Schema.String,
4848 arguments: Schema.optional(Schema.Unknown),
49+ _meta: Schema.optional(Schema.Unknown),
4950 }),
5051 );
5152
@@ -151,7 +152,12 @@ const handleToolCall = (id: number | string, params: unknown): void => { · 99 unmodified lines
151152 // the sky surface compiled without needing a real REPL.
152153 if (call.server === "node_repl") {
153154 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+ });
155161 return;
156162 }
157163 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+
31+import { jsLiteral, writeJsonResult } from "./codex-repl";
32+
33+type JsonSchema = Record<string, unknown>;
34+
35+const str = (description: string): JsonSchema => ({ type: "string", description });
36+const num = (description: string): JsonSchema => ({ type: "number", description });
37+
38+const TAB_ID = str(
39+ "Id of the tab to act on, from `list_tabs` or `new_tab`. Omit to use the selected tab.",
40+);
41+const 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+
45+const 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+
55+export 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+
66+export 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+
246+export 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+
253+export 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. */
259+const 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. */
270+const 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. */
277+export 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
1919 * the app-server bridge calls tools against. */
2020 readonly server: string;
2121 /** 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:
23− * Codex never starts a `computer-use` server, and the API is driven through
24− * `node_repl` — see `codex-sky-tools.ts`. */
25− readonly surface?: "sky";
22+ * projected onto another one. Computer Use and Chrome both ship as
23+ * skills/`node-repl` content: Codex never starts a server for either, and
24+ * their APIs are driven through `node_repl` — see `codex-sky-tools.ts` and
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";
2632 readonly summary: string;
2733 }
2834
35+export 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+
2938 export const CODEX_SETUP_HINT =
3039 "Install the Codex app, sign in, and use this plugin once inside Codex so macOS grants its permissions (Full Disk Access, Contacts, Automation).";
3140
@@ -39,6 +48,7 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ · 7 unmodified lines
3948 pluginName: "messages",
4049 name: "Messages",
4150 slug: "codex_messages",
51+ requires: "computer-use-app",
4252 server: "messages",
4353 summary:
4454 "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
4858 pluginName: "computer-use",
4959 name: "Computer Use",
5060 slug: "codex_computer_use",
61+ requires: "computer-use-app",
5162 server: "node_repl",
5263 surface: "sky",
5364 summary:
5465 "Control macOS desktop apps via the Codex plugin: read the screen and accessibility tree, click, type, and scroll.",
5566 },
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+ },
5692 {
5793 id: "codex-computer-history",
5894 pluginName: "computer-history",
5995 name: "Computer History",
6096 slug: "codex_computer_history",
97+ requires: "computer-use-app",
6198 server: "computer-history",
6299 summary:
63100 "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
8181 return versionDir;
8282 };
8383
84+/** Chrome's bundled browser client, reached through the `latest` symlink
85+ * Codex maintains beside the versioned directories. */
86+const CHROME_CLIENT_RELATIVE = join(
87+ "plugins",
88+ "cache",
89+ "openai-bundled",
90+ "chrome",
91+ "latest",
92+ "scripts",
93+ "browser-client.mjs",
94+);
95+
96+const 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+
84102 /** A fake `codex` CLI inside the temp home, passed explicitly so the scan
85103 * never resolves the machine's real install through PATH. */
86104 const writeCodexCli = (home: string): string => {
@@ -93,6 +111,7 @@ describe("scanCodexPlugins", () => { · 6 unmodified lines
93111 it("reports the curated plugins as app-server recipes when Codex is fully installed", () => {
94112 const home = makeHome();
95113 writeExecutable(join(home, CLIENT_RELATIVE));
114+ writeChromePlugin(home);
96115 const cli = writeCodexCli(home);
97116
98117 const entries = scanCodexPlugins({ codexHome: home, codexCli: cli });
@@ -101,50 +120,76 @@ describe("scanCodexPlugins", () => { · 2 unmodified lines
101120 expect(curated.map((entry) => entry.id)).toEqual([
102121 "codex-messages",
103122 "codex-computer-use",
123+ "codex-chrome",
124+ "codex-openai-docs",
104125 "codex-computer-history",
105126 ]);
106127 for (const entry of curated) {
107− expect(entry.available).toBe(true);
108− // Curated plugins go through the app-server bridge — its service only
109− // honours Codex host sessions, so the client binary is never spawned.
128+ expect(entry.available, entry.id).toBe(true);
129+ // Curated plugins go through the app-server bridge — the plugins' own
130+ // service only honours Codex host sessions, so their binaries are never
131+ // spawned directly.
110132 expect(entry.command).toBe(cli);
111133 expect(entry.args).toEqual(["app-server"]);
112134 expect(entry.env).toEqual({ CODEX_HOME: home });
113135 expect(entry.setupHint).toBeUndefined();
114136 }
115− // Computer Use has no MCP server of its own in current Codex — it ships as
116− // a node-repl variant, so it targets `node_repl` with the sky surface.
137+ // Computer Use and Chrome have no MCP server of their own in current
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.
117141 expect(curated.map((entry) => entry.appServer)).toEqual([
118142 { server: "messages" },
119143 { server: "node_repl", surface: "sky" },
144+ { server: "node_repl", surface: "browser", modulePath: join(home, CHROME_CLIENT_RELATIVE) },
145+ { server: "openaiDeveloperDocs" },
120146 { server: "computer-history" },
121147 ]);
122148 });
123149
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", () => {
125151 const home = makeHome();
126152
127153 const entries = scanCodexPlugins({ codexHome: home, codexCli: join(home, "bin", "codex") });
128154 const curated = entries.filter((entry) => entry.source === "curated");
129155
130− expect(curated).toHaveLength(3);
156+ expect(curated).toHaveLength(5);
131157 for (const entry of curated) {
132− expect(entry.available).toBe(false);
133− expect(entry.setupHint).toContain("Install the Codex app");
158+ expect(entry.available, entry.id).toBe(false);
159+ expect(entry.setupHint, entry.id).toContain("Codex");
134160 }
135161 });
136162
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", () => {
138164 const home = makeHome();
139165 const cli = writeCodexCli(home);
140166
141− const curated = scanCodexPlugins({ codexHome: home, codexCli: cli }).filter(
142− (entry) => entry.source === "curated",
167+ const byId = new Map(
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]),
143189 );
144190
145− // `codex app-server` would start, but no `messages`/`computer-use` server
146− // exists without the plugin app — so the card must not claim readiness.
147− for (const entry of curated) expect(entry.available).toBe(false);
191+ expect(byId.get("codex-messages")?.available).toBe(true);
192+ expect(byId.get("codex-chrome")?.available).toBe(false);
148193 });
149194
150195 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
2020
2121 import { Option, Schema } from "effect";
2222
23−import { CODEX_SETUP_HINT, CURATED_CODEX_PLUGINS } from "./codex-plugin-presets";
23+import {
24+ CHROME_SETUP_HINT,
25+ CODEX_SETUP_HINT,
26+ CURATED_CODEX_PLUGINS,
27+ type CuratedCodexPlugin,
28+} from "./codex-plugin-presets";
2429
2530 export interface CodexPluginEntry {
2631 /** Stable card id, e.g. `codex-messages`. */
@@ -41,7 +46,11 @@ export interface CodexPluginEntry { · 14 unmodified lines
4146 /** Present on curated entries: the spawn is `codex app-server` and the
4247 * connector bridges MCP to it in process, calling tools on this named
4348 * 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+ };
4554 /** Shown when `available` is false. */
4655 readonly setupHint?: string;
4756 /** 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
7281 "SkyComputerUseClient",
7382 );
7483
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. */
90+const 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+
75102 const CURATED_PLUGIN_NAMES: ReadonlySet<string> = new Set(
76103 CURATED_CODEX_PLUGINS.map((c) => c.pluginName),
77104 );
@@ -158,6 +185,12 @@ const listDirs = (dir: string): readonly string[] => · 80 unmodified lines
158185 const readText = (file: string): string | undefined =>
159186 tryOrElse(() => fs.readFileSync(file, "utf-8"), undefined);
160187
188+const isReadableFile = (file: string): boolean =>
189+ tryOrElse(() => fs.statSync(file).isFile(), false);
190+
191+const setupHintFor = (requires: CuratedCodexPlugin["requires"]): string =>
192+ requires === "chrome-plugin" ? CHROME_SETUP_HINT : CODEX_SETUP_HINT;
193+
161194 const isExecutableFile = (file: string): boolean =>
162195 tryOrElse(() => {
163196 fs.accessSync(file, fs.constants.X_OK);
@@ -361,16 +394,26 @@ export const scanCodexPlugins = (options?: { · 197 unmodified lines
361394 options?.codexHome ?? process.env["CODEX_HOME"] ?? path.join(os.homedir(), ".codex");
362395
363396 const codexCli = resolveCodexCli(options?.codexCli);
364− const clientAvailable = isExecutableFile(clientBinaryPath(codexHome));
365− const curatedAvailable = codexCli !== undefined && clientAvailable;
397+ const computerUseApp = isExecutableFile(clientBinaryPath(codexHome));
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+ };
366408
367409 const curated: readonly CodexPluginEntry[] = CURATED_CODEX_PLUGINS.map((entry) => {
368410 const display = curatedDisplayMetadata(codexHome, entry.pluginName);
411+ const available = codexCli !== undefined && requirementMet[entry.requires];
369412 return {
370413 id: entry.id,
371414 name: entry.name,
372415 summary: entry.summary,
373− available: curatedAvailable,
416+ available,
374417 slug: entry.slug,
375418 source: "curated" as const,
376419 command: codexCli ?? "codex",
@@ -379,8 +422,9 @@ export const scanCodexPlugins = (options?: { · 2 unmodified lines
379422 appServer: {
380423 server: entry.server,
381424 ...(entry.surface === undefined ? {} : { surface: entry.surface }),
425+ ...(entry.surface === "browser" ? { modulePath: browserClient } : {}),
382426 },
383− ...(curatedAvailable ? {} : { setupHint: CODEX_SETUP_HINT }),
427+ ...(available ? {} : { setupHint: setupHintFor(entry.requires) }),
384428 ...display,
385429 };
386430 });
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+ */
20+export 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+ */
39+export 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
1919 // The surface below mirrors the `Sky` type in the plugin's own SKILL.md.
2020 // ---------------------------------------------------------------------------
2121
22+import { jsLiteral, writeJsonResult } from "./codex-repl";
23+
2224 /** Bundled package the REPL imports; `sky` is its single exported entry. */
2325 const SKY_PACKAGE = "@oai/sky";
2426
@@ -233,15 +235,6 @@ export const skyToolList = (): readonly Record<string, unknown>[] => · 208 unmodified lines
233235 export const findSkyTool = (name: string): SkyToolDefinition | undefined =>
234236 SKY_TOOLS.find((tool) => tool.name === name);
235237
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. */
240−const jsLiteral = (value: unknown): string =>
241− JSON.stringify(value ?? {})
242− .replaceAll("\u2028", "\\u2028")
243− .replaceAll("\u2029", "\\u2029");
244−
245238 /**
246239 * The `node_repl` program that performs one sky call.
247240 *
@@ -259,7 +252,6 @@ export const skyCallProgram = (tool: SkyToolDefinition, args: unknown): string = · 11 unmodified lines
259252 const call = tool.takesArgs ? `sky.${tool.method}(${jsLiteral(args)})` : `sky.${tool.method}()`;
260253 return [
261254 `globalThis.sky ??= (await import(${JSON.stringify(SKY_PACKAGE)})).sky;`,
262− `const __result = await ${call};`,
263− `nodeRepl.write(JSON.stringify(__result ?? null));`,
255+ writeJsonResult([], `await ${call}`),
264256 ].join("\n");
265257 };
packages/plugins/mcp/src/sdk/connection.ts+2−1
@@ -459,7 +459,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 458 unmodified lines
459459 // bridge answers the MCP handshake itself, so `versionNegotiation` does
460460 // not apply on this path.
461461 if (input.appServer !== undefined) {
462− const { server, surface } = input.appServer;
462+ const { server, surface, modulePath } = input.appServer;
463463 return Effect.gen(function* () {
464464 const { createAppServerTransport } = yield* Effect.tryPromise({
465465 try: () => import("./appserver-connector"),
@@ -480,6 +480,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 14 unmodified lines
480480 cwd: input.cwd?.trim().length ? input.cwd.trim() : undefined,
481481 server,
482482 ...(surface === undefined ? {} : { surface }),
483+ ...(modulePath === undefined ? {} : { modulePath }),
483484 }),
484485 });
485486 });
packages/plugins/mcp/src/sdk/plugin.ts+5−1
@@ -230,7 +230,11 @@ const McpStdioServerInputSchema = Schema.Struct({ · 229 unmodified lines
230230 * `codex app-server` and `server` names the MCP server inside Codex whose
231231 * tools this integration exposes. Set by the Codex plugin add flow. */
232232 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+ }),
234238 ),
235239 slug: Schema.optional(Schema.String),
236240 });
packages/plugins/mcp/src/sdk/types.ts+10−4
@@ -266,10 +266,16 @@ export const McpStdioIntegrationConfig = Schema.Struct({ · 265 unmodified lines
266266 appServer: Schema.optional(
267267 Schema.Struct({
268268 server: Schema.String,
269− /** `sky` projects the Codex Computer Use API (driven through the
270− * `node_repl` server) as typed tools — see `codex-sky-tools.ts`.
271− * Absent means the server's own tools are exposed verbatim. */
272− surface: Schema.optional(Schema.Literal("sky")),
269+ /** A projected tool surface for a plugin that has no MCP server of its
270+ * own and is driven through Codex's `node_repl`: `sky` is Computer Use
271+ * (`codex-sky-tools.ts`), `browser` is Chrome
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),
273279 }),
274280 ),
275281 /** Declared auth methods — a single `stdio_env` method naming the secret env