Unified · Split
| @@ -34,6 +34,15 @@ import { withLocalServer } from "./local-server"; · 33 unmodified lines | ||
| 34 | 34 | const api = composePluginApi([mcpHttpPlugin()] as const); |
| 35 | 35 | |
| 36 | 36 | const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url)); |
| 37 | +const CHROME_CLIENT_RELATIVE = join( | |
| 38 | + "plugins", | |
| 39 | + "cache", | |
| 40 | + "openai-bundled", | |
| 41 | + "chrome", | |
| 42 | + "latest", | |
| 43 | + "scripts", | |
| 44 | + "browser-client.mjs", | |
| 45 | +); | |
| 37 | 46 | const APP_SERVER_FIXTURE = fileURLToPath( |
| 38 | 47 | new URL("./fixtures/codex-app-server.mjs", import.meta.url), |
| 39 | 48 | ); |
| @@ -67,6 +76,11 @@ const makeCodexHome = (): string => { · 27 unmodified lines | ||
| 67 | 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 | 84 | const versionDir = join(home, "plugins", "cache", "personal", "echo-suite", "1.0.2"); |
| 71 | 85 | mkdirSync(join(versionDir, ".codex-plugin"), { recursive: true }); |
| 72 | 86 | mkdirSync(join(versionDir, "bin"), { recursive: true }); |
| @@ -115,10 +129,12 @@ scenario( · 42 unmodified lines | ||
| 115 | 129 | const { plugins } = yield* client.mcp.listCodexPlugins(); |
| 116 | 130 | const byId = new Map(plugins.map((plugin) => [plugin.id, plugin])); |
| 117 | 131 | expect([...byId.keys()].sort(), "curated + scanned entries are reported").toEqual([ |
| 132 | + "codex-chrome", | |
| 118 | 133 | "codex-computer-history", |
| 119 | 134 | "codex-computer-use", |
| 120 | 135 | "codex-echo-suite", |
| 121 | 136 | "codex-messages", |
| 137 | + "codex-openai-docs", | |
| 122 | 138 | ]); |
| 123 | 139 | for (const plugin of plugins) { |
| 124 | 140 | expect(plugin.available, `${plugin.id} is available`).toBe(true); |
| @@ -136,6 +152,18 @@ scenario( · 11 unmodified lines | ||
| 136 | 152 | expect(messages?.appServer, "curated entries name their Codex server").toEqual({ |
| 137 | 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 | 168 | // Add two entries exactly as the add-form's Codex-plugins card does: |
| 141 | 169 | // the reported recipe, verbatim. |
| @@ -66,7 +66,11 @@ const AddStdioServerPayload = Schema.Struct({ · 65 unmodified lines | ||
| 66 | 66 | /** Reach the server through the Codex app-server bridge: the command spawns |
| 67 | 67 | * `codex app-server` and `server` names the MCP server inside Codex. */ |
| 68 | 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 | 75 | slug: Schema.optional(Schema.String), |
| 72 | 76 | }); |
| @@ -152,7 +156,11 @@ const CodexPluginEntrySchema = Schema.Struct({ · 79 unmodified lines | ||
| 152 | 156 | /** Present on curated entries: add through the Codex app-server bridge, |
| 153 | 157 | * calling tools on this named server inside Codex. */ |
| 154 | 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 | 165 | setupHint: Schema.optional(Schema.String), |
| 158 | 166 | /** The plugin's own icon from its local install, as a data URI. */ |
| @@ -41,7 +41,7 @@ const toServerInput = ( · 40 unmodified lines | ||
| 41 | 41 | env?: Record<string, string>; |
| 42 | 42 | cwd?: string; |
| 43 | 43 | versionNegotiation?: "legacy" | "auto"; |
| 44 | − appServer?: { server: string; surface?: "sky" }; | |
| 44 | + appServer?: { server: string; surface?: "sky" | "browser"; modulePath?: string }; | |
| 45 | 45 | slug?: string; |
| 46 | 46 | }; |
| 47 | 47 | return { |
| @@ -15,12 +15,15 @@ import { createMcpConnector, type StdioConnectorInput } from "./connection"; · 14 unmodified lines | ||
| 15 | 15 | |
| 16 | 16 | const fixture = fileURLToPath(new URL("./appserver-test-server.ts", import.meta.url)); |
| 17 | 17 | |
| 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 => ({ | |
| 19 | 22 | transport: "stdio", |
| 20 | 23 | command: "bun", |
| 21 | 24 | args: ["run", fixture], |
| 22 | 25 | env: { CODEX_HOME: "/tmp/fixture-codex-home" }, |
| 23 | − appServer: { server, ...(surface === undefined ? {} : { surface }) }, | |
| 26 | + appServer: { server, ...appServer }, | |
| 24 | 27 | }); |
| 25 | 28 | |
| 26 | 29 | const withConnection = (input: StdioConnectorInput) => |
| @@ -126,7 +129,7 @@ describe("codex app-server bridge", () => { · 99 unmodified lines | ||
| 126 | 129 | it.effect("the sky surface lists typed Computer Use tools, not the raw REPL", () => |
| 127 | 130 | Effect.scoped( |
| 128 | 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 | 134 | const tools = yield* Effect.promise(() => connection.client.listTools()); |
| 132 | 135 | const names = tools.tools.map(({ name }) => name); |
| @@ -144,7 +147,7 @@ describe("codex app-server bridge", () => { · 11 unmodified lines | ||
| 144 | 147 | it.effect("a sky tool call compiles to one node_repl program carrying its arguments", () => |
| 145 | 148 | Effect.scoped( |
| 146 | 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 | 152 | // Quotes in the arguments matter: they are embedded into a JS source |
| 150 | 153 | // text, so the encoding has to survive them exactly. |
| @@ -161,7 +164,10 @@ describe("codex app-server bridge", () => { · 10 unmodified lines | ||
| 161 | 164 | `await sky.type_text(${JSON.stringify(args)})`, |
| 162 | 165 | ); |
| 163 | 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 | 176 | it.effect("an argument-less sky tool calls its method with no argument object", () => |
| 171 | 177 | Effect.scoped( |
| 172 | 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 | 181 | const result = yield* Effect.promise(() => |
| 176 | 182 | connection.client.callTool({ name: "list_apps", arguments: {} }), |
| @@ -184,7 +190,7 @@ describe("codex app-server bridge", () => { · 7 unmodified lines | ||
| 184 | 190 | it.effect("a tool outside the sky surface is refused rather than sent to the REPL", () => |
| 185 | 191 | Effect.scoped( |
| 186 | 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 | 195 | const outcome = yield* Effect.promise(() => |
| 190 | 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 | 290 | it.effect("a server name Codex does not report fails the tools listing, not the connect", () => |
| 201 | 291 | Effect.scoped( |
| 202 | 292 | Effect.gen(function* () { |
| @@ -39,6 +39,7 @@ · 38 unmodified lines | ||
| 39 | 39 | import type { JSONRPCMessage, JSONRPCRequest, Transport } from "@modelcontextprotocol/client"; |
| 40 | 40 | import { Option, Schema } from "effect"; |
| 41 | 41 | |
| 42 | +import { browserCallProgram, browserToolList, findBrowserTool } from "./codex-browser-tools"; | |
| 42 | 43 | import { findSkyTool, skyCallProgram, skyToolList } from "./codex-sky-tools"; |
| 43 | 44 | import { stdioSpawnEnv, type StdioTransportConfig } from "./stdio-connector"; |
| 44 | 45 | |
| @@ -78,10 +79,14 @@ export type AppServerTransportConfig = StdioTransportConfig & { · 33 unmodified lines | ||
| 78 | 79 | /** The MCP server name inside Codex whose tools this transport exposes |
| 79 | 80 | * (e.g. `messages`) — the `server` of every `mcpServer/tool/call`. */ |
| 80 | 81 | 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; | |
| 85 | 90 | }; |
| 86 | 91 | |
| 87 | 92 | // --------------------------------------------------------------------------- |
| @@ -173,6 +178,9 @@ type AppServerReply = · 85 unmodified lines | ||
| 173 | 178 | const INTERNAL_ERROR = -32603; |
| 174 | 179 | const METHOD_NOT_FOUND = -32601; |
| 175 | 180 | |
| 181 | +/** Ceiling for one browser action inside the REPL. */ | |
| 182 | +const BROWSER_TIMEOUT_MS = 120_000; | |
| 183 | + | |
| 176 | 184 | const CHANNEL_CLOSED: AppServerReply = { |
| 177 | 185 | ok: false, |
| 178 | 186 | error: { code: INTERNAL_ERROR, message: "Codex app-server exited before replying" }, |
| @@ -370,6 +378,10 @@ class AppServerClientTransport implements Transport { · 191 unmodified lines | ||
| 370 | 378 | this.#emit({ jsonrpc: "2.0", id: message.id, result: { tools: skyToolList() } }); |
| 371 | 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 | 385 | const tools = await this.#collectServerTools(message.id); |
| 374 | 386 | if (tools === undefined) return; |
| 375 | 387 | this.#emit({ jsonrpc: "2.0", id: message.id, result: { tools } }); |
| @@ -426,7 +438,7 @@ class AppServerClientTransport implements Transport { · 50 unmodified lines | ||
| 426 | 438 | if (call === undefined) { |
| 427 | 439 | this.#fail(message.id, { |
| 428 | 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 | 443 | return; |
| 432 | 444 | } |
| @@ -461,7 +473,9 @@ class AppServerClientTransport implements Transport { · 28 unmodified lines | ||
| 461 | 473 | * performs it; otherwise the tool is passed through by name. Undefined |
| 462 | 474 | * means the surface does not define that tool. */ |
| 463 | 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 | 479 | return { |
| 466 | 480 | threadId: this.#threadId, |
| 467 | 481 | server: this.#config.server, |
| @@ -469,19 +483,60 @@ class AppServerClientTransport implements Transport { · 1 unmodified line | ||
| 469 | 483 | arguments: args ?? {}, |
| 470 | 484 | }; |
| 471 | 485 | } |
| 472 | − const tool = findSkyTool(name); | |
| 473 | − if (tool === undefined) return undefined; | |
| 474 | 486 | return { |
| 475 | 487 | threadId: this.#threadId, |
| 476 | 488 | server: this.#config.server, |
| 477 | 489 | tool: "js", |
| 478 | 490 | 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 | + }, | |
| 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 | 541 | // Downstream traffic |
| 487 | 542 | // ------------------------------------------------------------------------- |
| @@ -46,6 +46,7 @@ const decodeToolCallParams = Schema.decodeUnknownOption( · 45 unmodified lines | ||
| 46 | 46 | server: Schema.String, |
| 47 | 47 | tool: Schema.String, |
| 48 | 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 | 152 | // the sky surface compiled without needing a real REPL. |
| 152 | 153 | if (call.server === "node_repl") { |
| 153 | 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 | 161 | return; |
| 156 | 162 | } |
| 157 | 163 | if (call.threadId !== THREAD_ID || call.server !== "messages") { |
| @@ -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"); | |
| @@ -19,13 +19,22 @@ export interface CuratedCodexPlugin { · 18 unmodified lines | ||
| 19 | 19 | * the app-server bridge calls tools against. */ |
| 20 | 20 | readonly server: string; |
| 21 | 21 | /** 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"; | |
| 26 | 32 | readonly summary: string; |
| 27 | 33 | } |
| 28 | 34 | |
| 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 | + | |
| 29 | 38 | export const CODEX_SETUP_HINT = |
| 30 | 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 | 48 | pluginName: "messages", |
| 40 | 49 | name: "Messages", |
| 41 | 50 | slug: "codex_messages", |
| 51 | + requires: "computer-use-app", | |
| 42 | 52 | server: "messages", |
| 43 | 53 | summary: |
| 44 | 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 | 58 | pluginName: "computer-use", |
| 49 | 59 | name: "Computer Use", |
| 50 | 60 | slug: "codex_computer_use", |
| 61 | + requires: "computer-use-app", | |
| 51 | 62 | server: "node_repl", |
| 52 | 63 | surface: "sky", |
| 53 | 64 | summary: |
| 54 | 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 | 93 | id: "codex-computer-history", |
| 58 | 94 | pluginName: "computer-history", |
| 59 | 95 | name: "Computer History", |
| 60 | 96 | slug: "codex_computer_history", |
| 97 | + requires: "computer-use-app", | |
| 61 | 98 | server: "computer-history", |
| 62 | 99 | summary: |
| 63 | 100 | "Ask about recent on-screen activity from Codex's private local record (requires Computer History enabled in Codex).", |
| @@ -81,6 +81,24 @@ const writeCachedPlugin = ( · 80 unmodified lines | ||
| 81 | 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. */ | |
| 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 | + | |
| 84 | 102 | /** A fake `codex` CLI inside the temp home, passed explicitly so the scan |
| 85 | 103 | * never resolves the machine's real install through PATH. */ |
| 86 | 104 | const writeCodexCli = (home: string): string => { |
| @@ -93,6 +111,7 @@ describe("scanCodexPlugins", () => { · 6 unmodified lines | ||
| 93 | 111 | it("reports the curated plugins as app-server recipes when Codex is fully installed", () => { |
| 94 | 112 | const home = makeHome(); |
| 95 | 113 | writeExecutable(join(home, CLIENT_RELATIVE)); |
| 114 | + writeChromePlugin(home); | |
| 96 | 115 | const cli = writeCodexCli(home); |
| 97 | 116 | |
| 98 | 117 | const entries = scanCodexPlugins({ codexHome: home, codexCli: cli }); |
| @@ -101,50 +120,76 @@ describe("scanCodexPlugins", () => { · 2 unmodified lines | ||
| 101 | 120 | expect(curated.map((entry) => entry.id)).toEqual([ |
| 102 | 121 | "codex-messages", |
| 103 | 122 | "codex-computer-use", |
| 123 | + "codex-chrome", | |
| 124 | + "codex-openai-docs", | |
| 104 | 125 | "codex-computer-history", |
| 105 | 126 | ]); |
| 106 | 127 | 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. | |
| 110 | 132 | expect(entry.command).toBe(cli); |
| 111 | 133 | expect(entry.args).toEqual(["app-server"]); |
| 112 | 134 | expect(entry.env).toEqual({ CODEX_HOME: home }); |
| 113 | 135 | expect(entry.setupHint).toBeUndefined(); |
| 114 | 136 | } |
| 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. | |
| 117 | 141 | expect(curated.map((entry) => entry.appServer)).toEqual([ |
| 118 | 142 | { server: "messages" }, |
| 119 | 143 | { server: "node_repl", surface: "sky" }, |
| 144 | + { server: "node_repl", surface: "browser", modulePath: join(home, CHROME_CLIENT_RELATIVE) }, | |
| 145 | + { server: "openaiDeveloperDocs" }, | |
| 120 | 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 | 151 | const home = makeHome(); |
| 126 | 152 | |
| 127 | 153 | const entries = scanCodexPlugins({ codexHome: home, codexCli: join(home, "bin", "codex") }); |
| 128 | 154 | const curated = entries.filter((entry) => entry.source === "curated"); |
| 129 | 155 | |
| 130 | − expect(curated).toHaveLength(3); | |
| 156 | + expect(curated).toHaveLength(5); | |
| 131 | 157 | 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"); | |
| 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 | 164 | const home = makeHome(); |
| 139 | 165 | const cli = writeCodexCli(home); |
| 140 | 166 | |
| 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]), | |
| 143 | 189 | ); |
| 144 | 190 | |
| 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); | |
| 148 | 193 | }); |
| 149 | 194 | |
| 150 | 195 | it("scans cached plugins, resolving command and cwd against the newest version", () => { |
| @@ -20,7 +20,12 @@ import * as path from "node:path"; · 19 unmodified lines | ||
| 20 | 20 | |
| 21 | 21 | import { Option, Schema } from "effect"; |
| 22 | 22 | |
| 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"; | |
| 24 | 29 | |
| 25 | 30 | export interface CodexPluginEntry { |
| 26 | 31 | /** Stable card id, e.g. `codex-messages`. */ |
| @@ -41,7 +46,11 @@ export interface CodexPluginEntry { · 14 unmodified lines | ||
| 41 | 46 | /** Present on curated entries: the spawn is `codex app-server` and the |
| 42 | 47 | * connector bridges MCP to it in process, calling tools on this named |
| 43 | 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 | 54 | /** Shown when `available` is false. */ |
| 46 | 55 | readonly setupHint?: string; |
| 47 | 56 | /** 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 | 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. */ | |
| 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 | + | |
| 75 | 102 | const CURATED_PLUGIN_NAMES: ReadonlySet<string> = new Set( |
| 76 | 103 | CURATED_CODEX_PLUGINS.map((c) => c.pluginName), |
| 77 | 104 | ); |
| @@ -158,6 +185,12 @@ const listDirs = (dir: string): readonly string[] => · 80 unmodified lines | ||
| 158 | 185 | const readText = (file: string): string | undefined => |
| 159 | 186 | tryOrElse(() => fs.readFileSync(file, "utf-8"), undefined); |
| 160 | 187 | |
| 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 | + | |
| 161 | 194 | const isExecutableFile = (file: string): boolean => |
| 162 | 195 | tryOrElse(() => { |
| 163 | 196 | fs.accessSync(file, fs.constants.X_OK); |
| @@ -361,16 +394,26 @@ export const scanCodexPlugins = (options?: { · 197 unmodified lines | ||
| 361 | 394 | options?.codexHome ?? process.env["CODEX_HOME"] ?? path.join(os.homedir(), ".codex"); |
| 362 | 395 | |
| 363 | 396 | 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 | + }; | |
| 366 | 408 | |
| 367 | 409 | const curated: readonly CodexPluginEntry[] = CURATED_CODEX_PLUGINS.map((entry) => { |
| 368 | 410 | const display = curatedDisplayMetadata(codexHome, entry.pluginName); |
| 411 | + const available = codexCli !== undefined && requirementMet[entry.requires]; | |
| 369 | 412 | return { |
| 370 | 413 | id: entry.id, |
| 371 | 414 | name: entry.name, |
| 372 | 415 | summary: entry.summary, |
| 373 | − available: curatedAvailable, | |
| 416 | + available, | |
| 374 | 417 | slug: entry.slug, |
| 375 | 418 | source: "curated" as const, |
| 376 | 419 | command: codexCli ?? "codex", |
| @@ -379,8 +422,9 @@ export const scanCodexPlugins = (options?: { · 2 unmodified lines | ||
| 379 | 422 | appServer: { |
| 380 | 423 | server: entry.server, |
| 381 | 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 | 428 | ...display, |
| 385 | 429 | }; |
| 386 | 430 | }); |
| @@ -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"); | |
| @@ -19,6 +19,8 @@ · 18 unmodified lines | ||
| 19 | 19 | // The surface below mirrors the `Sky` type in the plugin's own SKILL.md. |
| 20 | 20 | // --------------------------------------------------------------------------- |
| 21 | 21 | |
| 22 | +import { jsLiteral, writeJsonResult } from "./codex-repl"; | |
| 23 | + | |
| 22 | 24 | /** Bundled package the REPL imports; `sky` is its single exported entry. */ |
| 23 | 25 | const SKY_PACKAGE = "@oai/sky"; |
| 24 | 26 | |
| @@ -233,15 +235,6 @@ export const skyToolList = (): readonly Record<string, unknown>[] => · 208 unmodified lines | ||
| 233 | 235 | export const findSkyTool = (name: string): SkyToolDefinition | undefined => |
| 234 | 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. */ | |
| 240 | −const jsLiteral = (value: unknown): string => | |
| 241 | − JSON.stringify(value ?? {}) | |
| 242 | − .replaceAll("\u2028", "\\u2028") | |
| 243 | − .replaceAll("\u2029", "\\u2029"); | |
| 244 | − | |
| 245 | 238 | /** |
| 246 | 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 | 252 | const call = tool.takesArgs ? `sky.${tool.method}(${jsLiteral(args)})` : `sky.${tool.method}()`; |
| 260 | 253 | return [ |
| 261 | 254 | `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}`), | |
| 264 | 256 | ].join("\n"); |
| 265 | 257 | }; |
| @@ -459,7 +459,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 458 unmodified lines | ||
| 459 | 459 | // bridge answers the MCP handshake itself, so `versionNegotiation` does |
| 460 | 460 | // not apply on this path. |
| 461 | 461 | if (input.appServer !== undefined) { |
| 462 | − const { server, surface } = input.appServer; | |
| 462 | + const { server, surface, modulePath } = input.appServer; | |
| 463 | 463 | return Effect.gen(function* () { |
| 464 | 464 | const { createAppServerTransport } = yield* Effect.tryPromise({ |
| 465 | 465 | try: () => import("./appserver-connector"), |
| @@ -480,6 +480,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 14 unmodified lines | ||
| 480 | 480 | cwd: input.cwd?.trim().length ? input.cwd.trim() : undefined, |
| 481 | 481 | server, |
| 482 | 482 | ...(surface === undefined ? {} : { surface }), |
| 483 | + ...(modulePath === undefined ? {} : { modulePath }), | |
| 483 | 484 | }), |
| 484 | 485 | }); |
| 485 | 486 | }); |
| @@ -230,7 +230,11 @@ const McpStdioServerInputSchema = Schema.Struct({ · 229 unmodified lines | ||
| 230 | 230 | * `codex app-server` and `server` names the MCP server inside Codex whose |
| 231 | 231 | * tools this integration exposes. Set by the Codex plugin add flow. */ |
| 232 | 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 | 239 | slug: Schema.optional(Schema.String), |
| 236 | 240 | }); |
| @@ -266,10 +266,16 @@ export const McpStdioIntegrationConfig = Schema.Struct({ · 265 unmodified lines | ||
| 266 | 266 | appServer: Schema.optional( |
| 267 | 267 | Schema.Struct({ |
| 268 | 268 | 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), | |
| 273 | 279 | }), |
| 274 | 280 | ), |
| 275 | 281 | /** Declared auth methods — a single `stdio_env` method naming the secret env |