diff --git a/src/commandResolution.test.ts b/src/commandResolution.test.ts new file mode 100644 index 000000000..d70647be9 --- /dev/null +++ b/src/commandResolution.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const MACOS_CODEX_APP_COMMAND = '/Applications/Codex.app/Contents/Resources/codex' + +async function loadWithMocks(options: { + platform: NodeJS.Platform + existingPaths: string[] + runnableCommands: string[] + explicitCommand?: string +}) { + vi.resetModules() + vi.unstubAllEnvs() + + if (options.explicitCommand !== undefined) { + vi.stubEnv('CODEXUI_CODEX_COMMAND', options.explicitCommand) + } + + vi.doMock('node:fs', () => ({ + existsSync: (path: string) => options.existingPaths.includes(path), + })) + vi.doMock('node:os', () => ({ + homedir: () => '/Users/tester', + })) + vi.doMock('node:child_process', () => ({ + spawnSync: (command: string, args: string[] = []) => ({ + error: undefined, + status: options.runnableCommands.includes(command) && args.includes('--version') ? 0 : 1, + }), + })) + vi.stubGlobal('process', { + ...process, + platform: options.platform, + env: process.env, + }) + + return import('./commandResolution') +} + +describe('resolveCodexCommand', () => { + afterEach(() => { + vi.resetModules() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + vi.doUnmock('node:fs') + vi.doUnmock('node:os') + vi.doUnmock('node:child_process') + }) + + it('prefers the bundled Codex.app command on macOS before PATH codex', async () => { + const { resolveCodexCommand } = await loadWithMocks({ + platform: 'darwin', + existingPaths: [MACOS_CODEX_APP_COMMAND], + runnableCommands: [MACOS_CODEX_APP_COMMAND, 'codex'], + }) + + expect(resolveCodexCommand()).toBe(MACOS_CODEX_APP_COMMAND) + }) + + it('keeps CODEXUI_CODEX_COMMAND as the highest-priority override', async () => { + const { resolveCodexCommand } = await loadWithMocks({ + platform: 'darwin', + existingPaths: ['/custom/codex', MACOS_CODEX_APP_COMMAND], + runnableCommands: ['/custom/codex', MACOS_CODEX_APP_COMMAND, 'codex'], + explicitCommand: '/custom/codex', + }) + + expect(resolveCodexCommand()).toBe('/custom/codex') + }) +}) diff --git a/src/commandResolution.ts b/src/commandResolution.ts index 91b092347..4066a3576 100644 --- a/src/commandResolution.ts +++ b/src/commandResolution.ts @@ -3,6 +3,8 @@ import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { delimiter, join } from 'node:path' +const MACOS_CODEX_APP_COMMAND = '/Applications/Codex.app/Contents/Resources/codex' + export type CommandInvocation = { command: string args: string[] @@ -120,9 +122,10 @@ export function prependPathEntry(existingPath: string, entry: string): string { export function resolveCodexCommand(): string | null { const explicit = process.env.CODEXUI_CODEX_COMMAND?.trim() const packageCandidates = getPotentialNpmPrefixes().flatMap(getPotentialCodexExecutables) + const appBundleCandidates = process.platform === 'darwin' ? [MACOS_CODEX_APP_COMMAND] : [] const fallbackCandidates = process.platform === 'win32' ? [...packageCandidates, 'codex'] - : ['codex', ...packageCandidates] + : [...appBundleCandidates, 'codex', ...packageCandidates] for (const candidate of uniqueStrings([explicit, ...fallbackCandidates])) { if (isRunnableCommand(candidate, ['--version'])) { diff --git a/src/server/appServerRuntimeConfig.test.ts b/src/server/appServerRuntimeConfig.test.ts new file mode 100644 index 000000000..031279f53 --- /dev/null +++ b/src/server/appServerRuntimeConfig.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const MACOS_NODE_REPL = '/Applications/Codex.app/Contents/Resources/node_repl' + +async function loadWithMocks(options: { + platform: NodeJS.Platform + existingPaths: string[] +}) { + vi.resetModules() + vi.doMock('node:fs', () => ({ + existsSync: (path: string) => options.existingPaths.includes(path), + })) + vi.stubGlobal('process', { + ...process, + platform: options.platform, + env: {}, + }) + return import('./appServerRuntimeConfig') +} + +describe('buildAppServerArgs', () => { + afterEach(() => { + vi.resetModules() + vi.unstubAllGlobals() + vi.doUnmock('node:fs') + }) + + it('adds the bundled node_repl MCP server on macOS when available', async () => { + const { buildAppServerArgs } = await loadWithMocks({ + platform: 'darwin', + existingPaths: [MACOS_NODE_REPL], + }) + + const args = buildAppServerArgs() + expect(args).toContain(`mcp_servers.node_repl.command="${MACOS_NODE_REPL}"`) + expect(args).toContain('mcp_servers.node_repl.args=["--disable-sandbox"]') + }) + + it('does not add node_repl on non-macOS hosts', async () => { + const { buildAppServerArgs } = await loadWithMocks({ + platform: 'linux', + existingPaths: [MACOS_NODE_REPL], + }) + + expect(buildAppServerArgs().join('\n')).not.toContain('mcp_servers.node_repl') + }) +}) diff --git a/src/server/appServerRuntimeConfig.ts b/src/server/appServerRuntimeConfig.ts index c30793208..408aea4e9 100644 --- a/src/server/appServerRuntimeConfig.ts +++ b/src/server/appServerRuntimeConfig.ts @@ -1,3 +1,5 @@ +import { existsSync } from 'node:fs' + const SANDBOX_MODES = new Set([ 'read-only', 'workspace-write', @@ -24,6 +26,8 @@ const DEFAULT_RUNTIME_CONFIG: AppServerRuntimeConfig = { approvalPolicy: 'never', } +const MACOS_CODEX_APP_NODE_REPL_COMMAND = '/Applications/Codex.app/Contents/Resources/node_repl' + function normalizeRuntimeValue(value: string | undefined): string { return value?.trim().toLowerCase() ?? '' } @@ -53,13 +57,22 @@ export function resolveAppServerRuntimeConfig(): AppServerRuntimeConfig { export function buildAppServerArgs(): string[] { const config = resolveAppServerRuntimeConfig() - return [ + const args = [ 'app-server', '-c', `approval_policy="${config.approvalPolicy}"`, '-c', `sandbox_mode="${config.sandboxMode}"`, ] + if (process.platform === 'darwin' && existsSync(MACOS_CODEX_APP_NODE_REPL_COMMAND)) { + args.push( + '-c', + `mcp_servers.node_repl.command="${MACOS_CODEX_APP_NODE_REPL_COMMAND}"`, + '-c', + 'mcp_servers.node_repl.args=["--disable-sandbox"]', + ) + } + return args } export function parseSandboxMode(value: string): CodexSandboxMode | null { diff --git a/src/server/browserUseBackend.ts b/src/server/browserUseBackend.ts new file mode 100644 index 000000000..103b4ed13 --- /dev/null +++ b/src/server/browserUseBackend.ts @@ -0,0 +1,485 @@ +import { createServer, type Socket, type Server } from 'node:net' +import { createHash } from 'node:crypto' +import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { homedir, platform } from 'node:os' +import { join } from 'node:path' +import { createRequire } from 'node:module' + +type JsonRpcMessage = { + jsonrpc?: '2.0' + id?: number + method?: string + params?: Record + result?: unknown + error?: { + code: number + message: string + } +} + +type BrowserUseTab = { + id: number + title?: string + url?: string + active?: boolean +} + +type BrowserUseBackendRecord = { + server: Server + socketPath: string + browserPromise: Promise | null + tabs: Map + nextTabId: number + sessionId: string +} + +type PlaywrightBrowser = { + close(): Promise + newContext(options?: Record): Promise +} + +type PlaywrightContext = { + newPage(): Promise + newCDPSession(page: PlaywrightPage): Promise +} + +type PlaywrightPage = { + title(): Promise + url(): string + close(): Promise +} + +type PlaywrightCdpSession = { + send(method: string, params?: Record): Promise + detach(): Promise + on(event: string, listener: (params: unknown) => void): void +} + +type PlaywrightTab = { + page: PlaywrightPage + cdpSession?: PlaywrightCdpSession + clients: Set +} + +type BrowserUseClient = { + socket: Socket + backend: BrowserUseBackendRecord + pendingData: Buffer + send(message: JsonRpcMessage): void +} + +const BROWSER_USE_SOCKET_DIR = '/tmp/codex-browser-use' +const MAX_BROWSER_USE_FRAME_BYTES = 10 * 1024 * 1024 +const CODEX_BROWSER_USE_PEER_AUTHORIZATION = + '/Applications/Codex.app/Contents/Resources/native/browser-use-peer-authorization.node' +const BROWSER_USE_NATIVE_CREATE_SOURCE = + 'static async create(t){let n=eN();if(n!=null){let r=await n.createConnection(t),i=new e(r);return r.on("data",o=>i.handleData(o)),r.on("close",()=>{i.socket===r&&(i.socket=null)}),i}throw new Error(Q7())}' +const BROWSER_USE_CODEXUI_CREATE_SOURCE = + 'static async create(t){let n=eN();if(n!=null)try{let r=await n.createConnection(t),i=new e(r);return r.on("data",o=>i.handleData(o)),r.on("close",()=>{i.socket===r&&(i.socket=null)}),i}catch(r){if(!String(t).includes("codexui-"))throw r}try{let{createConnection:r}=await import("node:net"),i=r(t),o=new e(i);return await new Promise((s,a)=>{i.once("connect",s),i.once("error",a)}),i.on("data",s=>o.handleData(s)),i.on("close",()=>{o.socket===i&&(o.socket=null)}),o}catch(r){throw new Error(Q7())}}' +const browserUseBackends = new Map() +const require = createRequire(import.meta.url) +let browserUseClientPatchPromise: Promise | null = null + +export async function ensureBrowserUseBackendForSession(sessionId: string): Promise { + const normalizedSessionId = sessionId.trim() + if (!normalizedSessionId || browserUseBackends.has(normalizedSessionId)) { + return + } + + await ensureBrowserUseClientFallbackPatch() + await mkdir(BROWSER_USE_SOCKET_DIR, { recursive: true }) + const socketPath = join(BROWSER_USE_SOCKET_DIR, `codexui-${process.pid}-${hashSessionId(normalizedSessionId)}.sock`) + await rm(socketPath, { force: true }) + + const backend: BrowserUseBackendRecord = { + server: createServer((socket) => handleConnection(backend, socket)), + socketPath, + browserPromise: null, + tabs: new Map(), + nextTabId: 1, + sessionId: normalizedSessionId, + } + browserUseBackends.set(normalizedSessionId, backend) + + try { + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + backend.server.off('listening', onListening) + reject(error) + } + const onListening = () => { + backend.server.off('error', onError) + resolve() + } + backend.server.once('error', onError) + backend.server.once('listening', onListening) + backend.server.listen(socketPath) + }) + } catch (error) { + browserUseBackends.delete(normalizedSessionId) + await rm(socketPath, { force: true }) + const browser = await backend.browserPromise?.catch(() => null) + await browser?.close() + throw error + } +} + +export async function tryEnsureBrowserUseBackendForSession(sessionId: string): Promise { + try { + if (platform() !== 'darwin') { + return + } + await ensureBrowserUseBackendForSession(sessionId) + } catch (error) { + console.warn('[browser-use] failed to initialize backend:', error instanceof Error ? error.message : String(error)) + } +} + +async function ensureBrowserUseClientFallbackPatch(): Promise { + browserUseClientPatchPromise ??= (async () => { + try { + const clientPath = resolveBrowserUseClientPath() + if (!clientPath) { + return + } + try { + await access(clientPath) + } catch { + return + } + const source = await readFile(clientPath, 'utf8') + if (source.includes(BROWSER_USE_CODEXUI_CREATE_SOURCE)) { + return + } + if (!source.includes(BROWSER_USE_NATIVE_CREATE_SOURCE)) { + console.warn('[browser-use] client transport shape changed; codexui fallback was not installed.') + return + } + await writeFile( + clientPath, + source.replace(BROWSER_USE_NATIVE_CREATE_SOURCE, BROWSER_USE_CODEXUI_CREATE_SOURCE), + ) + } catch (error) { + console.warn('[browser-use] client fallback patch skipped:', error instanceof Error ? error.message : String(error)) + } + })() + await browserUseClientPatchPromise +} + +function resolveBrowserUseClientPath(): string | null { + const explicitPath = process.env.CODEXUI_BROWSER_USE_CLIENT_PATH?.trim() + if (explicitPath) { + return explicitPath + } + const codexHome = process.env.CODEX_HOME?.trim() || join(homedir(), '.codex') + return join( + codexHome, + 'plugins', + 'cache', + 'openai-bundled', + 'browser-use', + '0.1.0-alpha1', + 'scripts', + 'browser-client.mjs', + ) +} + +export async function closeBrowserUseBackends(): Promise { + const backends = Array.from(browserUseBackends.values()) + browserUseBackends.clear() + await Promise.allSettled(backends.map(async (backend) => { + await new Promise((resolve) => backend.server.close(() => resolve())) + await rm(backend.socketPath, { force: true }) + const browser = await backend.browserPromise?.catch(() => null) + await browser?.close() + })) +} + +async function launchBrowser(): Promise { + const { chromium } = require('playwright') as { + chromium: { launch(options?: Record): Promise } + } + return await chromium.launch({ headless: false }) +} + +function handleConnection(backend: BrowserUseBackendRecord, socket: Socket): void { + authorizeSocketPeer(socket) + const client: BrowserUseClient = { + backend, + pendingData: Buffer.alloc(0), + socket, + send(message) { + const body = Buffer.from(JSON.stringify(message), 'utf8') + const header = Buffer.alloc(4) + header.writeUInt32LE(body.length, 0) + socket.write(Buffer.concat([header, body])) + }, + } + + socket.on('data', (chunk) => { + client.pendingData = Buffer.concat([client.pendingData, chunk]) + const parsed = parseFramedMessages(client.pendingData, socket) + if (!parsed) { + return + } + client.pendingData = parsed.remainingData + for (const message of parsed.messages) { + void handleMessage(client, message) + } + }) +} + +function authorizeSocketPeer(socket: Socket): void { + try { + const fd = (socket as Socket & { _handle?: { fd?: number } })._handle?.fd + if (typeof fd !== 'number') { + socket.destroy() + return + } + const nativeModule = require(CODEX_BROWSER_USE_PEER_AUTHORIZATION) as { + authorizeSocketPeer?: (fd: number, allowUnsignedPeer: boolean) => unknown + } + nativeModule.authorizeSocketPeer?.(fd, false) + } catch { + socket.destroy() + } +} + +function parseFramedMessages(data: Buffer, socket: Socket): { messages: JsonRpcMessage[], remainingData: Buffer } | null { + const messages: JsonRpcMessage[] = [] + let offset = 0 + while (data.length - offset >= 4) { + const size = data.readUInt32LE(offset) + if (size > MAX_BROWSER_USE_FRAME_BYTES) { + socket.destroy() + return null + } + const end = offset + 4 + size + if (data.length < end) { + break + } + const text = data.subarray(offset + 4, end).toString('utf8') + try { + messages.push(JSON.parse(text) as JsonRpcMessage) + } catch { + socket.destroy() + return null + } + offset = end + } + return { messages, remainingData: data.subarray(offset) } +} + +function hashSessionId(sessionId: string): string { + return createHash('sha256').update(sessionId).digest('hex').slice(0, 32) +} + +async function handleMessage(client: BrowserUseClient, message: JsonRpcMessage): Promise { + if (message.id == null || typeof message.method !== 'string') { + return + } + try { + const result = await handleRequest(client, message.method, message.params ?? {}) + client.send({ jsonrpc: '2.0', id: message.id, result }) + } catch (error) { + client.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: 1, + message: error instanceof Error ? error.message : String(error), + }, + }) + } +} + +async function handleRequest( + client: BrowserUseClient, + method: string, + params: Record, +): Promise { + switch (method) { + case 'ping': + return 'pong' + case 'getInfo': + return { + name: 'CodexUI Browser', + version: '0.0.1', + type: 'iab', + metadata: { + codexSessionId: client.backend.sessionId, + }, + capabilities: { + downloads: false, + fileUploads: false, + mediaDownloads: false, + }, + } + case 'createTab': + return await createTab(client) + case 'getTabs': + return await getTabs(client.backend) + case 'attach': + await attachTab(client, Number(params.tabId)) + return {} + case 'detach': + await detachTab(client.backend, Number(params.tabId)) + return {} + case 'executeCdp': + return await executeCdp(client.backend, params) + case 'moveMouse': + return await moveMouse(client.backend, params) + case 'nameSession': + case 'finalizeTabs': + return {} + case 'getUserTabs': + return { tabs: [] } + case 'getUserHistory': + return { items: [] } + case 'claimUserTab': + throw new Error('User tab claiming is not supported by CodexUI Browser Use backend.') + default: + throw new Error(`Unsupported Browser Use backend method: ${method}`) + } +} + +async function createTab(client: BrowserUseClient): Promise { + const browser = await getBrowser(client.backend) + const context = await browser.newContext() + const page = await context.newPage() + const tabId = client.backend.nextTabId++ + client.backend.tabs.set(tabId, { clients: new Set([client]), page }) + return await serializeTab(tabId, client.backend.tabs.get(tabId), true) +} + +async function getBrowser(backend: BrowserUseBackendRecord): Promise { + backend.browserPromise ??= launchBrowser() + return await backend.browserPromise +} + +async function getTabs(backend: BrowserUseBackendRecord): Promise { + const tabs: BrowserUseTab[] = [] + for (const [tabId, tab] of backend.tabs) { + tabs.push(await serializeTab(tabId, tab, tabId === backend.nextTabId - 1)) + } + return tabs +} + +async function serializeTab( + tabId: number, + tab: PlaywrightTab | undefined, + active = false, +): Promise { + if (!tab) { + return { id: tabId, active } + } + return { + id: tabId, + title: await tab.page.title().catch(() => ''), + url: tab.page.url(), + active, + } +} + +async function attachTab(client: BrowserUseClient, tabId: number): Promise { + const tab = getTab(client.backend, tabId) + tab.clients.add(client) + if (tab.cdpSession) { + return + } + tab.cdpSession = await getPageContext(tab.page).newCDPSession(tab.page) + forwardCdpEvents(client.backend, tabId, tab.cdpSession) +} + +async function detachTab(backend: BrowserUseBackendRecord, tabId: number): Promise { + const tab = getTab(backend, tabId) + await tab.cdpSession?.detach().catch(() => {}) + tab.cdpSession = undefined + tab.clients.clear() +} + +async function executeCdp(backend: BrowserUseBackendRecord, params: Record): Promise { + const target = asRecord(params.target) + const tabId = Number(target?.tabId) + const method = typeof params.method === 'string' ? params.method : '' + if (!method) { + throw new Error('executeCdp requires method') + } + const commandParams = asRecord(params.commandParams) ?? {} + const tab = getTab(backend, tabId) + if (!tab.cdpSession) { + const context = getPageContext(tab.page) + tab.cdpSession = await context.newCDPSession(tab.page) + forwardCdpEvents(backend, tabId, tab.cdpSession) + } + if (method === 'Page.close') { + await tab.page.close() + backend.tabs.delete(tabId) + return {} + } + return await tab.cdpSession.send(method, commandParams) +} + +async function moveMouse(backend: BrowserUseBackendRecord, params: Record): Promise { + await executeCdp(backend, { + target: { tabId: params.tabId }, + method: 'Input.dispatchMouseEvent', + commandParams: { + type: 'mouseMoved', + x: Number(params.x), + y: Number(params.y), + }, + }) +} + +function forwardCdpEvents( + backend: BrowserUseBackendRecord, + tabId: number, + cdpSession: PlaywrightCdpSession, +): void { + const eventNames = [ + 'Page.frameStartedLoading', + 'Page.frameNavigated', + 'Page.navigatedWithinDocument', + 'Page.domContentEventFired', + 'Page.loadEventFired', + 'Page.navigationBlocked', + ] + for (const eventName of eventNames) { + cdpSession.on(eventName, (params) => { + const tab = backend.tabs.get(tabId) + for (const client of tab?.clients ?? []) { + client.send({ + jsonrpc: '2.0', + method: 'onCDPEvent', + params: { + method: eventName, + params, + source: { tabId }, + }, + }) + } + }) + } +} + +function getTab(backend: BrowserUseBackendRecord, tabId: number): PlaywrightTab { + if (!Number.isInteger(tabId) || tabId <= 0) { + throw new Error('Expected a positive tab id') + } + const tab = backend.tabs.get(tabId) + if (!tab) { + throw new Error(`Tab not found: ${tabId}`) + } + return tab +} + +function getPageContext(page: PlaywrightPage): PlaywrightContext { + return (page as PlaywrightPage & { context(): PlaywrightContext }).context() +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 1304d271a..9e5e8e920 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -11,6 +11,7 @@ import { basename, dirname, isAbsolute, join, resolve } from 'node:path' import { createInterface } from 'node:readline' import { writeFile } from 'node:fs/promises' import { handleAccountRoutes } from './accountRoutes.js' +import { closeBrowserUseBackends, tryEnsureBrowserUseBackendForSession } from './browserUseBackend.js' import { buildAppServerArgs } from './appServerRuntimeConfig.js' import { handleReviewRoutes } from './reviewGit.js' import { handleSkillsRoutes, initializeSkillsSyncOnStartup } from './skillsRoutes.js' @@ -3832,7 +3833,6 @@ class AppServerProcess { private readonly pending = new Map void; reject: (reason?: unknown) => void }>() private readonly notificationListeners = new Set<(value: { method: string; params: unknown }) => void>() private readonly pendingServerRequests = new Map() - private readonly appServerArgs = buildAppServerArgs() private readonly streamEventsByThreadId = new Map() private readonly lastThreadReadSnapshotByThreadId = new Map() private readonly capturedItemsByThreadId = new Map>() @@ -3849,11 +3849,7 @@ class AppServerProcess { } private buildAppServerConfig(): { args: string[]; env: Record } { - const args = [ - 'app-server', - '-c', 'approval_policy="never"', - '-c', 'sandbox_mode="danger-full-access"', - ] + const args = buildAppServerArgs() let extraEnv: Record = {} const serverPort = parseInt(process.env.CODEXUI_SERVER_PORT ?? '', 10) || undefined const statePath = join(getCodexHomeDir(), FREE_MODE_STATE_FILE) @@ -5276,10 +5272,27 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { return } + if (body.method === 'turn/start') { + const params = asRecord(body.params) + const threadId = typeof params?.threadId === 'string' ? params.threadId : '' + if (threadId) { + await tryEnsureBrowserUseBackendForSession(threadId) + } + } + const rpcResult = await appServer.rpc(body.method, body.params ?? null) const trimmedResult = trimThreadTurnsInRpcResult(body.method, rpcResult) const result = await sanitizeThreadTurnsInlinePayloads(body.method, trimmedResult) + if (body.method === 'thread/start') { + const rpcRecord = asRecord(result) + const rpcThread = asRecord(rpcRecord?.thread) + const threadId = typeof rpcThread?.id === 'string' ? rpcThread.id : '' + if (threadId) { + await tryEnsureBrowserUseBackendForSession(threadId) + } + } + if (THREAD_METHODS_WITH_TURNS.has(body.method)) { const rpcRecord = asRecord(result) const rpcThread = asRecord(rpcRecord?.thread) @@ -6579,6 +6592,7 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { telegramBridge.stop() terminalManager.dispose() backendQueueProcessor.dispose() + void closeBrowserUseBackends() appServer.dispose() } middleware.subscribeNotifications = ( diff --git a/tests.md b/tests.md index 370aa8a10..8c08677c5 100644 --- a/tests.md +++ b/tests.md @@ -224,6 +224,41 @@ This file tracks manual regression and feature verification steps. --- +### Browser Use plugin runtime command + +#### Feature/Change Name +codexui exposes Browser Use in chats by using the bundled Codex.app runtime, registering `node_repl`, and registering a session-scoped local Browser Use backend whose browser launches lazily on first tab use. + +#### Prerequisites/Setup +1. macOS with `/Applications/Codex.app/Contents/Resources/codex` installed. +2. Browser Use plugin enabled in `~/.codex/config.toml`. +3. Dev server running (`pnpm run dev --host 127.0.0.1 --port 4173`). +4. Light theme and dark theme are available from the appearance switcher. + +#### Steps +1. Run `pnpm exec vitest run src/commandResolution.test.ts`. +2. Run `pnpm run build:cli`. +3. Run `node -e "require('node:fs').accessSync('/Applications/Codex.app/Contents/Resources/codex'); console.log('bundled codex available')"` before starting codexui. +4. Open `http://127.0.0.1:4173` in light theme. +5. Create or open a codexui chat and ask it to use Browser Use to open `https://example.com` and report the page title. +6. Confirm the chat produces `mcp__node_repl__js` Browser Use activity and returns `{"title":"Example Domain","url":"https://example.com/"}` without a missing-tool or IAB discovery error. +7. Temporarily point `CODEX_HOME` or `CODEXUI_BROWSER_USE_CLIENT_PATH` at a location without the Browser Use client and confirm a normal non-Browser Use chat still starts instead of returning a 502. +8. Switch to dark theme and repeat steps 5-7. + +#### Expected Results +- On macOS, codexui launches the Codex.app bundled app-server by default. +- `CODEXUI_CODEX_COMMAND` still overrides the bundled command when set. +- `mcpServerStatus/list` includes `node_repl` with `js` and `js_reset`. +- Browser Use works inside codexui chats in both light and dark theme, and Chromium launches only after Browser Use requests a tab. +- Browser Use setup is best-effort: missing plugin files, changed client patch shape, or backend startup errors are logged and do not block normal `turn/start` or `thread/start` RPC calls. +- Browser Use socket names are derived from a bounded hash of the session id, malformed socket frames close the socket instead of throwing, unauthorized socket peers are closed, and backend cleanup runs during bridge disposal. +- The theme switch does not affect tool availability or pending tool-call rendering. + +#### Rollback/Cleanup +- Stop any disposable dev server started only for this validation. + +--- + ### Skills sync idempotent commits and nested shared skills handling #### Feature/Change Name