@@ -15,12 +15,15 @@ import { createMcpConnector, type StdioConnectorInput } from "./connection";
1515
1616const fixture = fileURLToPath ( new URL ( "./appserver-test-server.ts" , import . meta. url ) ) ;
1717
18- const appServerInput = ( server : string , surface ?: "sky" ) : StdioConnectorInput => ( {
18+ const appServerInput = (
19+ server : string ,
20+ appServer ?: { readonly surface ?: "sky" | "browser" ; readonly modulePath ?: string } ,
21+ ) : StdioConnectorInput => ( {
1922 transport : "stdio" ,
2023 command : "bun" ,
2124 args : [ "run" , fixture ] ,
2225 env : { CODEX_HOME : "/tmp/fixture-codex-home" } ,
23- appServer : { server, ...( surface === undefined ? { } : { surface } ) } ,
26+ appServer : { server, ...appServer } ,
2427} ) ;
2528
2629const withConnection = ( input : StdioConnectorInput ) =>
@@ -126,7 +129,7 @@ describe("codex app-server bridge", () => {
126129 it . effect ( "the sky surface lists typed Computer Use tools, not the raw REPL" , ( ) =>
127130 Effect . scoped (
128131 Effect . gen ( function * ( ) {
129- const connection = yield * withConnection ( appServerInput ( "node_repl" , "sky" ) ) ;
132+ const connection = yield * withConnection ( appServerInput ( "node_repl" , { surface : "sky" } ) ) ;
130133
131134 const tools = yield * Effect . promise ( ( ) => connection . client . listTools ( ) ) ;
132135 const names = tools . tools . map ( ( { name } ) => name ) ;
@@ -144,7 +147,7 @@ describe("codex app-server bridge", () => {
144147 it . effect ( "a sky tool call compiles to one node_repl program carrying its arguments" , ( ) =>
145148 Effect . scoped (
146149 Effect . gen ( function * ( ) {
147- const connection = yield * withConnection ( appServerInput ( "node_repl" , "sky" ) ) ;
150+ const connection = yield * withConnection ( appServerInput ( "node_repl" , { surface : "sky" } ) ) ;
148151
149152 // Quotes in the arguments matter: they are embedded into a JS source
150153 // text, so the encoding has to survive them exactly.
@@ -161,7 +164,10 @@ describe("codex app-server bridge", () => {
161164 `await sky.type_text(${ JSON . stringify ( args ) } )` ,
162165 ) ;
163166 expect ( program , "returns the result as JSON through the REPL" ) . toContain (
164- "nodeRepl.write(JSON.stringify(__result ?? null));" ,
167+ "nodeRepl.write(JSON.stringify(result ?? null));" ,
168+ ) ;
169+ expect ( program , "runs in its own scope so a reused REPL session stays clean" ) . toContain (
170+ "await (async () => {" ,
165171 ) ;
166172 } ) ,
167173 ) ,
@@ -170,7 +176,7 @@ describe("codex app-server bridge", () => {
170176 it . effect ( "an argument-less sky tool calls its method with no argument object" , ( ) =>
171177 Effect . scoped (
172178 Effect . gen ( function * ( ) {
173- const connection = yield * withConnection ( appServerInput ( "node_repl" , "sky" ) ) ;
179+ const connection = yield * withConnection ( appServerInput ( "node_repl" , { surface : "sky" } ) ) ;
174180
175181 const result = yield * Effect . promise ( ( ) =>
176182 connection . client . callTool ( { name : "list_apps" , arguments : { } } ) ,
@@ -184,7 +190,7 @@ describe("codex app-server bridge", () => {
184190 it . effect ( "a tool outside the sky surface is refused rather than sent to the REPL" , ( ) =>
185191 Effect . scoped (
186192 Effect . gen ( function * ( ) {
187- const connection = yield * withConnection ( appServerInput ( "node_repl" , "sky" ) ) ;
193+ const connection = yield * withConnection ( appServerInput ( "node_repl" , { surface : "sky" } ) ) ;
188194
189195 const outcome = yield * Effect . promise ( ( ) =>
190196 connection . client . callTool ( { name : "js" , arguments : { code : "process.exit(0)" } } ) . then (
@@ -197,6 +203,90 @@ describe("codex app-server bridge", () => {
197203 ) ,
198204 ) ;
199205
206+ // -------------------------------------------------------------------------
207+ // Chrome: also projected onto `node_repl`, but handle-based.
208+ // -------------------------------------------------------------------------
209+
210+ const BROWSER_MODULE = "/codex/chrome/latest/scripts/browser-client.mjs" ;
211+ const browserInput = ( ) =>
212+ appServerInput ( "node_repl" , { surface : "browser" , modulePath : BROWSER_MODULE } ) ;
213+
214+ it . effect ( "the browser surface lists typed Chrome tools, not the raw REPL" , ( ) =>
215+ Effect . scoped (
216+ Effect . gen ( function * ( ) {
217+ const connection = yield * withConnection ( browserInput ( ) ) ;
218+
219+ const names = yield * Effect . promise ( ( ) =>
220+ connection . client . listTools ( ) . then ( ( result ) => result . tools . map ( ( { name } ) => name ) ) ,
221+ ) ;
222+ expect ( names , "the raw REPL is not exposed" ) . not . toContain ( "js" ) ;
223+ expect ( names ) . toEqual (
224+ expect . arrayContaining ( [ "list_tabs" , "new_tab" , "navigate" , "read_page" , "click" ] ) ,
225+ ) ;
226+ } ) ,
227+ ) ,
228+ ) ;
229+
230+ it . effect ( "a browser call imports the machine's own client and resolves a tab" , ( ) =>
231+ Effect . scoped (
232+ Effect . gen ( function * ( ) {
233+ const connection = yield * withConnection ( browserInput ( ) ) ;
234+
235+ const result = yield * Effect . promise ( ( ) =>
236+ connection . client . callTool ( {
237+ name : "navigate" ,
238+ arguments : { url : "https://example.com/" } ,
239+ } ) ,
240+ ) ;
241+ const program = ( result . content as readonly { readonly text : string } [ ] ) [ 0 ] ! . text ;
242+ expect ( program , "imports the scanner-resolved client path" ) . toContain (
243+ `await import(${ JSON . stringify ( BROWSER_MODULE ) } )` ,
244+ ) ;
245+ expect ( program , "caches the runtime across calls in the pooled session" ) . toContain (
246+ "globalThis.__executorBrowser ??=" ,
247+ ) ;
248+ expect ( program , "falls back to the selected tab, opening one if needed" ) . toContain (
249+ "(await __browser.tabs.selected()) ?? (await __browser.tabs.new())" ,
250+ ) ;
251+ expect ( program ) . toContain ( "await __tab.goto(__args.url)" ) ;
252+ } ) ,
253+ ) ,
254+ ) ;
255+
256+ it . effect ( "stamps REPL calls with the turn metadata the Chrome client requires" , ( ) =>
257+ Effect . scoped (
258+ Effect . gen ( function * ( ) {
259+ // Without this the real client refuses every call with "Missing
260+ // required Codex turn metadata": Codex normally stamps a REPL call
261+ // with its issuing turn, and this bridge runs no turns.
262+ const connection = yield * withConnection ( browserInput ( ) ) ;
263+
264+ const result = yield * Effect . promise ( ( ) =>
265+ connection . client . callTool ( { name : "list_tabs" , arguments : { } } ) ,
266+ ) ;
267+ const meta = ( result . structuredContent as { readonly meta : Record < string , unknown > } ) . meta ;
268+ const turn = meta [ "x-codex-turn-metadata" ] as Record < string , string > ;
269+ expect ( typeof turn . session_id , "the pooled thread is the session" ) . toBe ( "string" ) ;
270+ expect ( typeof turn . turn_id , "each call is its own turn" ) . toBe ( "string" ) ;
271+ } ) ,
272+ ) ,
273+ ) ;
274+
275+ it . effect ( "a tab-less browser tool skips tab resolution entirely" , ( ) =>
276+ Effect . scoped (
277+ Effect . gen ( function * ( ) {
278+ const connection = yield * withConnection ( browserInput ( ) ) ;
279+
280+ const result = yield * Effect . promise ( ( ) =>
281+ connection . client . callTool ( { name : "list_tabs" , arguments : { } } ) ,
282+ ) ;
283+ const program = ( result . content as readonly { readonly text : string } [ ] ) [ 0 ] ! . text ;
284+ expect ( program ) . toContain ( "await __browser.tabs.list()" ) ;
285+ expect ( program , "no tab is resolved for a browser-level call" ) . not . toContain ( "const __tab" ) ;
286+ } ) ,
287+ ) ,
288+ ) ;
289+
200290 it . effect ( "a server name Codex does not report fails the tools listing, not the connect" , ( ) =>
201291 Effect . scoped (
202292 Effect . gen ( function * ( ) {
0 commit comments