392 lines · 16.5 KB · TypeScript
RawDownload
import { describe, expect, it } from "@effect/vitest";import { Effect, Predicate } from "effect";import { fileURLToPath } from "node:url"; import { createMcpConnector, type StdioConnectorInput } from "./connection"; // ---------------------------------------------------------------------------// The Codex app-server bridge, driven end to end through the ORDINARY MCP// client path: `createMcpConnector` with an `appServer` marker spawns the// fixture (a fake `codex app-server` with real protocol shapes), and the// standard `Client` handshakes, lists, calls, and answers elicitations// against it. Nothing here touches the bridge internals — if these pass, the// discover/invoke/health paths work unchanged on top.// --------------------------------------------------------------------------- const fixture = fileURLToPath(new URL("./appserver-test-server.ts", import.meta.url)); const appServerInput = (  server: string,  appServer?: { readonly surface?: "sky" | "browser"; readonly modulePath?: string },): StdioConnectorInput => ({  transport: "stdio",  command: "bun",  args: ["run", fixture],  env: { CODEX_HOME: "/tmp/fixture-codex-home" },  appServer: { server, ...appServer },}); const withConnection = (input: StdioConnectorInput) =>  Effect.acquireRelease(createMcpConnector(input).pipe(Effect.orDie), (connection) =>    Effect.promise(connection.close),  ); describe("codex app-server bridge", () => {  it.effect("handshakes, follows status pagination, and lists the server's tools", () =>    Effect.scoped(      Effect.gen(function* () {        const connection = yield* withConnection(appServerInput("messages"));         const tools = yield* Effect.promise(() => connection.client.listTools());        expect(tools.tools.map(({ name }) => name).sort()).toEqual([          "announce_restart",          "echo",          "needs_approval",        ]);        const echo = tools.tools.find(({ name }) => name === "echo");        expect(echo?.description).toBe("Echo the arguments back");        expect(echo?.inputSchema).toMatchObject({ type: "object" });      }),    ),  );   it.effect("calls a tool and carries content, structuredContent, and the spawn env through", () =>    Effect.scoped(      Effect.gen(function* () {        const connection = yield* withConnection(appServerInput("messages"));         const result = yield* Effect.promise(() =>          connection.client.callTool({ name: "echo", arguments: { text: "hi" } }),        );        expect(result.isError).toBeFalsy();        expect(result.content).toEqual([{ type: "text", text: JSON.stringify({ text: "hi" }) }]);        // CODEX_HOME must reach the fixture's process env through the spawn.        expect(result.structuredContent).toEqual({ codexHome: "/tmp/fixture-codex-home" });      }),    ),  );   it.effect("bridges an app-server elicitation to the client's elicitation/create handler", () =>    Effect.scoped(      Effect.gen(function* () {        const connection = yield* withConnection(appServerInput("messages"));        const prompts: string[] = [];        connection.client.setRequestHandler("elicitation/create", (request) => {          prompts.push(request.params.message);          return Promise.resolve({ action: "accept" as const, content: {} });        });         const result = yield* Effect.promise(() =>          connection.client.callTool({ name: "needs_approval", arguments: {} }),        );        expect(result.content).toEqual([{ type: "text", text: "approved" }]);        expect(prompts).toEqual(["Allow the fixture to proceed?"]);      }),    ),  );   it.effect("starts the thread with an approval policy that lets prompts through", () =>    Effect.scoped(      Effect.gen(function* () {        // Codex declines MCP elicitations ITSELF on a thread whose approval        // policy does not allow them — the prompt never reaches the client and        // the tool just reports "access was not approved". The fixture only        // elicits when the bridge asked for a permitting policy, so reaching        // the handler at all is the assertion.        const connection = yield* withConnection(appServerInput("messages"));        let prompted = false;        connection.client.setRequestHandler("elicitation/create", () => {          prompted = true;          return Promise.resolve({ action: "accept" as const, content: {} });        });         const result = yield* Effect.promise(() =>          connection.client.callTool({ name: "needs_approval", arguments: {} }),        );        expect(prompted, "the plugin's own approval prompt reached the client").toBe(true);        expect(result.isError).toBeFalsy();      }),    ),  );   it.effect("a declined elicitation reaches the app-server as a decline, not an approval", () =>    Effect.scoped(      Effect.gen(function* () {        const connection = yield* withConnection(appServerInput("messages"));        connection.client.setRequestHandler("elicitation/create", () =>          Promise.resolve({ action: "decline" as const }),        );         const result = yield* Effect.promise(() =>          connection.client.callTool({ name: "needs_approval", arguments: {} }),        );        expect(result.isError).toBe(true);        expect(result.content).toEqual([{ type: "text", text: "denied: decline" }]);      }),    ),  );   it.effect("turns a server becoming ready into the spec's tool-list-changed", () =>    Effect.scoped(      Effect.gen(function* () {        // Installing or updating a plugin inside Codex can change a server's        // tools underneath a synced catalog. Executor already restales on the        // spec notification, so the bridge only has to translate Codex's.        const connection = yield* withConnection(appServerInput("messages"));        let changed = 0;        connection.client.setNotificationHandler("notifications/tools/list_changed", () => {          changed += 1;        });         yield* Effect.promise(() =>          connection.client.callTool({ name: "announce_restart", arguments: {} }),        );        // Give the notifications a turn to land after the call's response.        yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50)));        expect(changed, "only this server's ready transition counts").toBe(1);      }),    ),  );   // -------------------------------------------------------------------------  // Computer Use: projected onto `node_repl`, not a server of its own.  // -------------------------------------------------------------------------   it.effect("the sky surface lists typed Computer Use tools, not the raw REPL", () =>    Effect.scoped(      Effect.gen(function* () {        const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));         const tools = yield* Effect.promise(() => connection.client.listTools());        const names = tools.tools.map(({ name }) => name);        expect(names, "the raw REPL is not exposed").not.toContain("js");        expect(names).toEqual(expect.arrayContaining(["list_apps", "click", "type_text"]));        const click = tools.tools.find(({ name }) => name === "click");        expect(click?.inputSchema, "tools carry real schemas").toMatchObject({          type: "object",          required: ["app"],        });      }),    ),  );   it.effect("a sky tool call compiles to one node_repl program carrying its arguments", () =>    Effect.scoped(      Effect.gen(function* () {        const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));         // Quotes in the arguments matter: they are embedded into a JS source        // text, so the encoding has to survive them exactly.        const args = { app: "com.apple.Safari", text: 'hi "there"' };        const result = yield* Effect.promise(() =>          connection.client.callTool({ name: "type_text", arguments: args }),        );        // The fixture echoes the program the bridge compiled.        const program = (result.content as readonly { readonly text: string }[])[0]!.text;        expect(program, "imports the bundled sky package idempotently").toContain(          'globalThis.sky ??= (await import("@oai/sky")).sky;',        );        expect(program, "calls the mapped method with the arguments verbatim").toContain(          `await sky.type_text(JSON.parse(${JSON.stringify(JSON.stringify(args))}))`,        );        expect(program, "returns the result as JSON through the REPL").toContain(          "nodeRepl.write(JSON.stringify(result ?? null));",        );        expect(program, "runs in its own scope so a reused REPL session stays clean").toContain(          "await (async () => {",        );      }),    ),  );   it.effect("an argument-less sky tool calls its method with no argument object", () =>    Effect.scoped(      Effect.gen(function* () {        const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));         const result = yield* Effect.promise(() =>          connection.client.callTool({ name: "list_apps", arguments: {} }),        );        const program = (result.content as readonly { readonly text: string }[])[0]!.text;        expect(program).toContain("await sky.list_apps();");      }),    ),  );   it.effect("a tool outside the sky surface is refused rather than sent to the REPL", () =>    Effect.scoped(      Effect.gen(function* () {        const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));         const outcome = yield* Effect.promise(() =>          connection.client.callTool({ name: "js", arguments: { code: "process.exit(0)" } }).then(            () => "unexpected success",            (failure: Error) => failure.message,          ),        );        expect(outcome).toContain("js");      }),    ),  );   // -------------------------------------------------------------------------  // Chrome: also projected onto `node_repl`, but handle-based.  // -------------------------------------------------------------------------   const BROWSER_MODULE = "/codex/chrome/latest/scripts/browser-client.mjs";  const browserInput = () =>    appServerInput("node_repl", { surface: "browser", modulePath: BROWSER_MODULE });   it.effect("the browser surface lists typed Chrome tools, not the raw REPL", () =>    Effect.scoped(      Effect.gen(function* () {        const connection = yield* withConnection(browserInput());         const names = yield* Effect.promise(() =>          connection.client.listTools().then((result) => result.tools.map(({ name }) => name)),        );        expect(names, "the raw REPL is not exposed").not.toContain("js");        expect(names).toEqual(          expect.arrayContaining(["list_tabs", "new_tab", "navigate", "read_page", "click"]),        );      }),    ),  );   it.effect("a browser call imports the machine's own client and resolves a tab", () =>    Effect.scoped(      Effect.gen(function* () {        const connection = yield* withConnection(browserInput());         const result = yield* Effect.promise(() =>          connection.client.callTool({            name: "navigate",            arguments: { url: "https://example.com/" },          }),        );        const program = (result.content as readonly { readonly text: string }[])[0]!.text;        expect(program, "imports the scanner-resolved client path").toContain(          `await import(${JSON.stringify(BROWSER_MODULE)})`,        );        expect(program, "caches the runtime across calls in the pooled session").toContain(          "globalThis.__executorBrowser ??=",        );        expect(program, "falls back to the selected tab, opening one if needed").toContain(          "(await __browser.tabs.selected()) ?? (await __browser.tabs.new())",        );        expect(program).toContain("await __tab.goto(__args.url)");      }),    ),  );   it.effect("stamps REPL calls with the turn metadata the Chrome client requires", () =>    Effect.scoped(      Effect.gen(function* () {        // Without this the real client refuses every call with "Missing        // required Codex turn metadata": Codex normally stamps a REPL call        // with its issuing turn, and this bridge runs no turns.        const connection = yield* withConnection(browserInput());         const result = yield* Effect.promise(() =>          connection.client.callTool({ name: "list_tabs", arguments: {} }),        );        const meta = (result.structuredContent as { readonly meta: Record<string, unknown> }).meta;        const turn = meta["x-codex-turn-metadata"] as Record<string, string>;        expect(typeof turn.session_id, "the pooled thread is the session").toBe("string");        expect(typeof turn.turn_id, "each call is its own turn").toBe("string");      }),    ),  );   it.effect("carries a prototype-named argument through to the program as data", () =>    Effect.scoped(      Effect.gen(function* () {        // Parsed, not written as a literal: `{ __proto__: … }` in source sets        // the prototype, so a literal fixture would assert nothing. This is        // the shape that arrives over the wire.        // oxlint-disable-next-line executor/no-json-parse -- an object literal would assign the PROTOTYPE; parsing produces the own key that actually arrives over the wire        const args = JSON.parse('{"url":"https://example.com/","__proto__":{"polluted":"yes"}}');        const connection = yield* withConnection(browserInput());         const result = yield* Effect.promise(() =>          connection.client.callTool({ name: "navigate", arguments: args }),        );        const program = (result.content as readonly { readonly text: string }[])[0]!.text;         expect(program, "embedded as parsed data").toContain("JSON.parse(");        expect(program, "with the key preserved rather than dropped").toContain("__proto__");      }),    ),  );   it.effect("a tab-less browser tool skips tab resolution entirely", () =>    Effect.scoped(      Effect.gen(function* () {        const connection = yield* withConnection(browserInput());         const result = yield* Effect.promise(() =>          connection.client.callTool({ name: "list_tabs", arguments: {} }),        );        const program = (result.content as readonly { readonly text: string }[])[0]!.text;        expect(program).toContain("await __browser.tabs.list()");        expect(program, "no tab is resolved for a browser-level call").not.toContain("const __tab");      }),    ),  );   it.effect("carries an approval's own terms upstream, not just its message", () =>    Effect.scoped(      Effect.gen(function* () {        // Chrome's per-site approval sends an EMPTY schema and puts the terms        // of the grant in `_meta` — accepting means "always, for this        // origin". Dropping that left a caller consenting to more than the        // prompt said, so the metadata has to reach the client.        const connection = yield* withConnection(browserInput());        let seen: Record<string, unknown> | undefined;        connection.client.setRequestHandler("elicitation/create", (request) => {          seen = request.params._meta;          return Promise.resolve({ action: "accept" as const, content: {} });        });         yield* Effect.promise(() =>          connection.client.callTool({            name: "navigate",            arguments: { url: "https://example.com/__needs_site_approval" },          }),        );        expect(seen?.["persist"], "the grant's persistence reaches the client").toBe("always");        expect(seen?.["origin"]).toBe("https://example.com");        expect(seen?.["connector_name"], "and which plugin is asking").toBe("Browser use");      }),    ),  );   it.effect("a server name Codex does not report fails the tools listing, not the connect", () =>    Effect.scoped(      Effect.gen(function* () {        const connection = yield* withConnection(appServerInput("not-installed"));         const outcome = yield* Effect.promise(() =>          connection.client.listTools().then(            () => "unexpected success",            (failure: Error) => failure.message,          ),        );        expect(outcome).toContain('"not-installed"');      }),    ),  );   it.effect("a missing codex binary surfaces as a connection error", () =>    Effect.gen(function* () {      const error = yield* createMcpConnector({        transport: "stdio",        command: "/nonexistent/codex",        args: ["app-server"],        appServer: { server: "messages" },      }).pipe(Effect.flip);       expect(Predicate.isTagged(error, "McpConnectionError")).toBe(true);    }),  );});