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