Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/commandResolution.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
5 changes: 4 additions & 1 deletion src/commandResolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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'])) {
Expand Down
47 changes: 47 additions & 0 deletions src/server/appServerRuntimeConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
15 changes: 14 additions & 1 deletion src/server/appServerRuntimeConfig.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { existsSync } from 'node:fs'

const SANDBOX_MODES = new Set([
'read-only',
'workspace-write',
Expand All @@ -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() ?? ''
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading