Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 6 additions & 2 deletions src/main/codex-cli/codex-read-only-app-server-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ export const CODEX_READ_ONLY_APP_SERVER_ARGS = [
// Short-lived probes (rate limits, model catalog); plugin startup can launch marketplace
// clones that outlive the probe teardown. Other read-only app-servers retain
// normal plugin behavior.
export const CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS = [
export const CODEX_SHORT_LIVED_PROBE_CONFIG_ARGS = [
'-c',
'approval_policy=never',
...CODEX_DISABLE_PLUGINS_ARGS,
...CODEX_DISABLE_PLUGINS_ARGS
] as const

export const CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS = [
...CODEX_SHORT_LIVED_PROBE_CONFIG_ARGS,
...CODEX_READ_ONLY_APP_SERVER_TAIL
] as const
82 changes: 82 additions & 0 deletions src/main/codex/codex-model-catalog-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe('codex model catalog probe', () => {
const probe = createCodexModelCatalogProbe({
resolveEnvironment,
resolveCommand,
resolveLaunchArgs: () => [],
runSession: async (invocation, body) => {
invocations.push(invocation)
return body({
Expand Down Expand Up @@ -67,6 +68,84 @@ describe('codex model catalog probe', () => {
}
)

// The picker lists what a chat would run: the same saved Arguments, with the probe's own config
// after them, since Codex applies `-c` in order.
it('lists under the saved Arguments a chat launches with', async () => {
const invocations: CodexAppServerInvocation[] = []
const probe = createCodexModelCatalogProbe({
resolveEnvironment: async () => ({ PATH: '/bin' }),
resolveCommand: () => '/bin/codex',
resolveLaunchArgs: () => [
'--dangerously-bypass-approvals-and-sandbox',
'-c',
'model_provider=azure',
'-c',
'features.plugins=true',
'-m',
'gpt-azure'
],
runSession: async (invocation, body) => {
invocations.push(invocation)
return body({
request: async () => ({ data: [MODEL_ROW], nextCursor: null }),
notify: () => {}
})
}
})
await probe('/homes/a')
expect(invocations[0]!.args).toEqual([
'app-server',
'-c',
'model_provider=azure',
'-c',
'features.plugins=true',
'-c',
'model="gpt-azure"',
'-c',
'approval_policy=never',
'-c',
'features.plugins=false',
'--disable',
'plugins'
])
})

// Codex folds --enable/--disable in after every -c, so only a later --disable beats a saved
// --enable; a probe that starts plugins can leave marketplace clones behind.
it('keeps plugins off when the saved Arguments enable them', async () => {
const invocations: CodexAppServerInvocation[] = []
const probe = createCodexModelCatalogProbe({
resolveEnvironment: async () => ({ PATH: '/bin' }),
resolveCommand: () => '/bin/codex',
resolveLaunchArgs: () => ['--enable', 'plugins'],
runSession: async (invocation, body) => {
invocations.push(invocation)
return body({
request: async () => ({ data: [MODEL_ROW], nextCursor: null }),
notify: () => {}
})
}
})
await probe('/homes/a')
const args = invocations[0]!.args
expect(args.slice(0, 3)).toEqual(['app-server', '--enable', 'plugins'])
expect(args.slice(-2)).toEqual(['--disable', 'plugins'])
})

it('lists nothing when the saved Arguments refuse a chat', async () => {
const runSession = vi.fn()
const probe = createCodexModelCatalogProbe({
resolveEnvironment: async () => ({ PATH: '/bin' }),
resolveCommand: () => '/bin/codex',
resolveLaunchArgs: () => ['--profile', 'work'],
runSession
})
await expect(probe('/homes/a')).rejects.toMatchObject({
argumentProblem: { agent: 'Codex', option: '--profile', problem: 'unsupportedOption' }
})
expect(runSession).not.toHaveBeenCalled()
})

it('keeps the listing when config/read never answers', async () => {
const server = String.raw`
const readline = require('node:readline')
Expand All @@ -82,6 +161,7 @@ describe('codex model catalog probe', () => {
const probe = createCodexModelCatalogProbe({
resolveEnvironment: async () => ({ PATH: '/bin' }),
resolveCommand: () => '/bin/codex',
resolveLaunchArgs: () => [],
// Real transport against a fake server; a session deadline shorter than the
// production one keeps the test fast while still outliving config/read's bound.
runSession: (invocation, body) =>
Expand All @@ -106,6 +186,7 @@ describe('codex model catalog probe', () => {
const probe = createCodexModelCatalogProbe({
resolveEnvironment: async () => ({ PATH: '/bin' }),
resolveCommand: () => '/bin/codex',
resolveLaunchArgs: () => [],
runSession: async (_invocation, body) =>
body({ request: async () => ({ data: [], nextCursor: null }), notify: () => {} })
})
Expand All @@ -124,6 +205,7 @@ describe('codex model catalog probe', () => {
{ agentCmdOverrides: { codex: command } },
options
),
resolveLaunchArgs: () => [],
runSession
})
await expect(probe('/homes/a')).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' })
Expand Down
24 changes: 18 additions & 6 deletions src/main/codex/codex-model-catalog-probe.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args'
import { CODEX_SHORT_LIVED_PROBE_CONFIG_ARGS } from '../codex-cli/codex-read-only-app-server-args'
import { codexStructuredLaunchArgs } from './codex-structured-launch-args'
import { runCodexAppServerSession } from './codex-app-server-session'
import { fetchCodexModelCatalogListing } from './codex-structured-model-catalog'
import {
Expand All @@ -16,7 +17,7 @@ const CODEX_MODEL_CATALOG_PROBE_TIMEOUT_MS = 15_000

export type CodexModelCatalogProbeDeps = Pick<
CodexStructuredLaunchResolverDeps,
'resolveCommand' | 'resolveEnvironment'
'resolveCommand' | 'resolveEnvironment' | 'resolveLaunchArgs'
> & {
/** Test seam; production runs the shared short-lived app-server session. */
runSession?: typeof runCodexAppServerSession
Expand All @@ -33,21 +34,32 @@ function definedEnv(env: NodeJS.ProcessEnv | undefined): Record<string, string>
}

/**
* Lists models without a live session: one short-lived read-only app-server
* Lists models without a live session: one short-lived app-server
* under the given account home, spawned through the SAME invocation resolver
* a structured session launch uses — a probe that resolved a different binary
* or env could list models the user's sessions cannot see, under their key.
* and saved Arguments a structured session launch uses — a probe that resolved
* a different binary, env or config could list models the user's sessions
* cannot see, under their key.
*/
export function createCodexModelCatalogProbe(
deps: CodexModelCatalogProbeDeps
): AgentModelCatalogProbe {
return async (accountHomePath: string): Promise<AgentModelCatalogSuccess> => {
const { command, environment } = await resolveCodexStructuredInvocation(deps)
const args = codexStructuredLaunchArgs(await deps.resolveLaunchArgs())
const run = deps.runSession ?? runCodexAppServerSession
const listing = await run(
{
command,
args: [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS],
// The probe's own config comes after the Arguments' `-c`, which Codex applies in order. Codex
// folds `--enable`/`--disable` in after every `-c`, disables last, so only `--disable`
// outranks a saved `--enable plugins`.
args: [
'app-server',
...args,
...CODEX_SHORT_LIVED_PROBE_CONFIG_ARGS,
'--disable',
'plugins'
],
cliPath: command,
env: { ...definedEnv(environment), CODEX_HOME: accountHomePath },
timeoutMs: CODEX_MODEL_CATALOG_PROBE_TIMEOUT_MS
Expand Down
13 changes: 11 additions & 2 deletions src/main/codex/codex-short-lived-app-server-spawn.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args'
import {
CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS,
CODEX_SHORT_LIVED_PROBE_CONFIG_ARGS
} from '../codex-cli/codex-read-only-app-server-args'
import type { CodexAppServerInvocation } from './codex-app-server-session'

const NPM_CODEX_SHIM = 'C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd'
Expand Down Expand Up @@ -37,6 +40,7 @@ describe('short-lived Codex app-server invocations on Windows', () => {
const probe = createCodexModelCatalogProbe({
resolveEnvironment: async () => ({ PATH: 'C:\\bin' }),
resolveCommand: () => NPM_CODEX_SHIM,
resolveLaunchArgs: () => [],
runSession: async (invocation, body) => {
invocations.push(invocation)
return body({
Expand All @@ -58,6 +62,11 @@ describe('short-lived Codex app-server invocations on Windows', () => {
})
await probe('C:\\homes\\a')
expect(invocations[0]?.command).toBe(NPM_CODEX_SHIM)
expect(invocations[0]?.args).toEqual([...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS])
expect(invocations[0]?.args).toEqual([
'app-server',
...CODEX_SHORT_LIVED_PROBE_CONFIG_ARGS,
'--disable',
'plugins'
])
})
})
116 changes: 80 additions & 36 deletions src/main/codex/codex-structured-launch-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,77 +3,117 @@ import { codexStructuredLaunchArgs } from './codex-structured-launch-args'
import { StructuredAgentArgumentsError } from '../native-chat/structured-agent-arguments-error'

describe('codexStructuredLaunchArgs', () => {
it('preserves root config and feature option order', () => {
it('passes config, feature and strict-config options in order', () => {
expect(
codexStructuredLaunchArgs([
'-c',
'model_reasoning_effort=high',
'--enable',
'unified_exec',
'--config=web_search="live"',
'--config=web_search="cached"',
'--disable=some_feature',
'-m',
'gpt-5.6-sol'
'-c=model_provider=azure',
'--strict-config'
])
).toEqual([
'-c',
'model_reasoning_effort=high',
'--enable',
'unified_exec',
'--config=web_search="live"',
'--disable=some_feature',
'-m',
'gpt-5.6-sol'
'--config',
'web_search="cached"',
'--disable',
'some_feature',
'-c',
'model_provider=azure',
'--strict-config'
])
})

// app-server ignores `-m` and `--search`; Codex's own equivalents are config it applies after `-c`.
it('turns -m and --search into the config Codex derives from them, after the user config', () => {
expect(
codexStructuredLaunchArgs(['-m', 'gpt-5.6 "sol"', '--search', '-c', 'model="other"'])
).toEqual(['-c', 'model="other"', '-c', 'model="gpt-5.6 \\"sol\\""', '-c', 'web_search="live"'])
expect(codexStructuredLaunchArgs(['--model=gpt-5.6-sol'])).toEqual([
'-c',
'model="gpt-5.6-sol"'
])
})

it('drops permission options and config overrides owned by Orca', () => {
// Agent Permissions owns the posture, so neither the options nor their config keys reach Codex.
it('drops permission options and the config that sets permissions', () => {
expect(
codexStructuredLaunchArgs([
'--dangerously-bypass-approvals-and-sandbox',
'-s',
'read-only',
'--sandbox=bogus',
'-a',
'never',
'--sandbox=danger-full-access',
'untrusted',
'--ask-for-approval=on-failure',
'--approve-for-me',
'--not-so-yolo',
'-c',
'approval_policy="never"',
'--config=sandbox_mode="danger-full-access"',
'--config',
'sandbox_mode=danger-full-access',
'-c',
'sandbox_workspace_write.writable_roots=["/tmp"]',
'approvals_reviewer="auto_review"',
'-c',
'"sandbox_workspace_write".writable_roots=["/tmp"]',
'sandbox_workspace_write.network_access=true',
'-c',
'\'approval_policy\'="never"',
'"approval_policy" = "never"',
'-c',
'model_reasoning_effort=high'
])
).toEqual(['-c', 'model_reasoning_effort=high'])
})

it('drops a profile and TUI-only path options without leaving their values as prompts', () => {
expect(
codexStructuredLaunchArgs([
'--profile',
'review',
'-pother',
'--cd',
'/another/repo',
'--worktree',
'-c',
'model_reasoning_effort=high'
])
).toEqual(['-c', 'model_reasoning_effort=high'])
it('still needs a value for a dropped permission option', () => {
expect(() => codexStructuredLaunchArgs(['-s'])).toThrow(StructuredAgentArgumentsError)
expect(() => codexStructuredLaunchArgs(['-a', '--search'])).toThrow(
expect.objectContaining({
argumentProblem: { agent: 'Codex', option: '-a', problem: 'missingValue' }
})
)
})

it('discards operands after the option terminator', () => {
it('drops the bypass flag and terminal-only display options', () => {
expect(
codexStructuredLaunchArgs(['-c', 'model_reasoning_effort=high', '--', 'a prompt'])
).toEqual(['-c', 'model_reasoning_effort=high'])
codexStructuredLaunchArgs([
'--dangerously-bypass-approvals-and-sandbox',
'--yolo',
'--no-alt-screen',
'--no-daemon',
'-h',
'--version',
'--'
])
).toEqual([])
})

it.each([
{ tokens: ['a private prompt'], option: 'prompt', problem: 'positionalPrompt' },
{ tokens: ['app-server'], option: 'prompt', problem: 'positionalPrompt' },
{ tokens: ['-'], option: 'prompt', problem: 'positionalPrompt' },
{ tokens: ['--', 'a private prompt'], option: 'prompt', problem: 'positionalPrompt' },
{ tokens: ['--profile', 'private'], option: '--profile', problem: 'unsupportedOption' },
{ tokens: ['-pprivate'], option: '-p', problem: 'unsupportedOption' },
{ tokens: ['--oss'], option: '--oss', problem: 'unsupportedOption' },
{
tokens: ['--local-provider', 'private'],
option: '--local-provider',
problem: 'unsupportedOption'
},
{ tokens: ['--add-dir', '/private'], option: '--add-dir', problem: 'unsupportedOption' },
{ tokens: ['-C', '/private'], option: '-C', problem: 'unsupportedOption' },
{ tokens: ['--image=private.png'], option: '--image', problem: 'unsupportedOption' },
{ tokens: ['--worktree'], option: '--worktree', problem: 'unsupportedOption' },
{
tokens: ['--dangerously-bypass-hook-trust'],
option: '--dangerously-bypass-hook-trust',
problem: 'unsupportedOption'
},
{
tokens: ['--remote', 'wss://private-host'],
option: '--remote',
Expand All @@ -86,8 +126,10 @@ describe('codexStructuredLaunchArgs', () => {
problem: 'unsupportedOption'
},
{ tokens: ['--unknown-flag=secret'], option: '--unknown-flag', problem: 'unsupportedOption' },
{ tokens: ['--enable'], option: '--enable', problem: 'missingValue' }
] as const)('rejects unsafe or incomplete arguments: %j', ({ tokens, option, problem }) => {
{ tokens: ['--search=secret'], option: '--search', problem: 'unsupportedOption' },
{ tokens: ['--enable'], option: '--enable', problem: 'missingValue' },
{ tokens: ['-m', '--secret'], option: '-m', problem: 'missingValue' }
] as const)('refuses what a chat cannot honor: %j', ({ tokens, option, problem }) => {
const thrown = () => codexStructuredLaunchArgs(tokens)
expect(thrown).toThrow(StructuredAgentArgumentsError)
try {
Expand All @@ -96,8 +138,10 @@ describe('codexStructuredLaunchArgs', () => {
expect(error).toMatchObject({
argumentProblem: { agent: 'Codex', option, problem }
})
expect(JSON.stringify(error)).not.toContain('secret')
expect(JSON.stringify(error)).not.toContain('private')
for (const text of [JSON.stringify(error), String(error)]) {
expect(text).not.toContain('secret')
expect(text).not.toContain('private')
}
}
})
})
Loading
Loading