Skip to content
Merged
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
84 changes: 84 additions & 0 deletions src/server/codexAppServerBridge.archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,90 @@ describe('ensureDefaultFreeModeStateForMissingAuthSync', () => {
}
})

it('does not synthesize OpenCode Zen when config.toml explicitly selects a model provider', async () => {
const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-config-provider-'))
const statePath = join(codexHome, 'webui-custom-providers.json')
process.env.CODEX_HOME = codexHome
try {
await writeFile(join(codexHome, 'config.toml'), [
'model = "gpt-5.5"',
'model_provider = "azure"',
'',
'[model_providers.azure]',
'base_url = "https://example.openai.azure.com/openai/v1"',
'wire_api = "responses"',
].join('\n'))

expect(ensureDefaultFreeModeStateForMissingAuthSync(statePath)).toBeNull()
await expect(stat(statePath)).rejects.toThrow()
} finally {
await rm(codexHome, { recursive: true, force: true })
}
})

it('detects quoted top-level model_provider keys in config.toml', async () => {
const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-quoted-config-provider-'))
const statePath = join(codexHome, 'webui-custom-providers.json')
process.env.CODEX_HOME = codexHome
try {
await writeFile(join(codexHome, 'config.toml'), [
'"model_provider" = "azure"',
'',
'[model_providers.azure]',
'base_url = "https://example.openai.azure.com/openai/v1"',
'wire_api = "responses"',
].join('\n'))

expect(ensureDefaultFreeModeStateForMissingAuthSync(statePath)).toBeNull()
await expect(stat(statePath)).rejects.toThrow()
} finally {
await rm(codexHome, { recursive: true, force: true })
}
})

it('ignores commented and nested model_provider keys when deciding the runtime fallback', async () => {
const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-nested-provider-config-'))
const statePath = join(codexHome, 'webui-custom-providers.json')
process.env.CODEX_HOME = codexHome
try {
await writeFile(join(codexHome, 'config.toml'), [
'# model_provider = "azure"',
'',
'[profiles.work]',
'model_provider = "azure"',
].join('\n'))

const state = ensureDefaultFreeModeStateForMissingAuthSync(statePath)

expect(state?.enabled).toBe(true)
expect(state?.provider).toBe('opencode-zen')
await expect(stat(statePath)).rejects.toThrow()
} finally {
await rm(codexHome, { recursive: true, force: true })
}
})

it('ignores model_provider text inside multiline TOML strings', async () => {
const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-multiline-provider-config-'))
const statePath = join(codexHome, 'webui-custom-providers.json')
process.env.CODEX_HOME = codexHome
try {
await writeFile(join(codexHome, 'config.toml'), [
'banner = """',
'model_provider = "azure"',
'"""',
].join('\n'))

const state = ensureDefaultFreeModeStateForMissingAuthSync(statePath)

expect(state?.enabled).toBe(true)
expect(state?.provider).toBe('opencode-zen')
await expect(stat(statePath)).rejects.toThrow()
} finally {
await rm(codexHome, { recursive: true, force: true })
}
})

it('ignores community provider state after Codex auth appears', async () => {
const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-auth-community-provider-'))
const statePath = join(codexHome, 'webui-custom-providers.json')
Expand Down
136 changes: 134 additions & 2 deletions src/server/codexAppServerBridge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { createHash, randomBytes } from 'node:crypto'
import { mkdtemp, readFile, readdir, rename, rm, mkdir, stat, cp, lstat, readlink, symlink, realpath } from 'node:fs/promises'
import { createReadStream, existsSync, readFileSync } from 'node:fs'
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { request as httpRequest } from 'node:http'
import { request as httpsRequest } from 'node:https'
Expand Down Expand Up @@ -3717,6 +3717,136 @@ function readFreeModeStateSync(statePath: string): FreeModeState | null {
}
}

type TomlScanState = {
inMultilineBasicString: boolean
inMultilineLiteralString: boolean
}

function stripTomlComment(line: string, state: TomlScanState): string {
let content = ''
let inSingleQuote = false
let inDoubleQuote = false
let escaped = false
for (let i = 0; i < line.length; i++) {
if (state.inMultilineBasicString) {
const end = line.indexOf('"""', i)
if (end === -1) return content
state.inMultilineBasicString = false
i = end + 2
continue
}
if (state.inMultilineLiteralString) {
const end = line.indexOf("'''", i)
if (end === -1) return content
state.inMultilineLiteralString = false
i = end + 2
continue
}
const ch = line[i]
if (inDoubleQuote && escaped) {
escaped = false
content += ch
continue
}
if (inDoubleQuote && ch === '\\') {
escaped = true
content += ch
continue
}
if (!inSingleQuote && !inDoubleQuote && line.startsWith('"""', i)) {
state.inMultilineBasicString = true
i += 2
continue
}
if (!inSingleQuote && !inDoubleQuote && line.startsWith("'''", i)) {
state.inMultilineLiteralString = true
i += 2
continue
}
if (!inDoubleQuote && ch === "'") {
inSingleQuote = !inSingleQuote
content += ch
continue
}
if (!inSingleQuote && ch === '"') {
inDoubleQuote = !inDoubleQuote
content += ch
continue
}
if (!inSingleQuote && !inDoubleQuote && ch === '#') {
return content
}
content += ch
}
return content
}

function isModelProviderAssignment(content: string): boolean {
return /^(?:model_provider|"model_provider"|'model_provider')\s*=/.test(content)
}

let explicitCodexModelProviderConfigCache: {
path: string
mtimeMs: number | null
size: number | null
value: boolean
} | null = null

function hasExplicitCodexModelProviderConfigSync(): boolean {
const configPath = join(getCodexHomeDir(), 'config.toml')
let info: ReturnType<typeof statSync> | null = null
try {
info = statSync(configPath)
} catch {
explicitCodexModelProviderConfigCache = {
path: configPath,
mtimeMs: null,
size: null,
value: false,
}
return false
}
if (
explicitCodexModelProviderConfigCache?.path === configPath
&& explicitCodexModelProviderConfigCache.mtimeMs === info.mtimeMs
&& explicitCodexModelProviderConfigCache.size === info.size
) {
return explicitCodexModelProviderConfigCache.value
}

let value = false
try {
const raw = readFileSync(configPath, 'utf8')
let inTopLevelTable = true
const scanState: TomlScanState = {
inMultilineBasicString: false,
inMultilineLiteralString: false,
}
for (const line of raw.split(/\r?\n/)) {
const content = stripTomlComment(line, scanState).trim()
if (!content) continue
if (/^\[\[?[^\]]+\]?\]$/.test(content)) {
inTopLevelTable = false
continue
}
if (!inTopLevelTable) continue
if (isModelProviderAssignment(content)) {
value = true
break
}
}
} catch {
value = false
}
explicitCodexModelProviderConfigCache = {
path: configPath,
mtimeMs: info.mtimeMs,
size: info.size,
value,
}
return value
}

export async function writeFreeModeStateFile(statePath: string, state: FreeModeState): Promise<void> {
await mkdir(dirname(statePath), { recursive: true })
await writeFile(statePath, JSON.stringify(state), { encoding: 'utf8', mode: 0o600 })
Expand All @@ -3728,7 +3858,9 @@ export function ensureDefaultFreeModeStateForMissingAuthSync(statePath: string):
if (shouldSuppressCommunityFreeModeForCodexAuth(current, hasUsableCodexAuth)) {
return null
}
if (!shouldCreateDefaultFreeModeStateForMissingAuth(current, hasUsableCodexAuth)) {
const shouldCreateDefault = shouldCreateDefaultFreeModeStateForMissingAuth(current, hasUsableCodexAuth)
const hasExplicitModelProviderConfig = shouldCreateDefault && hasExplicitCodexModelProviderConfigSync()
if (hasExplicitModelProviderConfig || !shouldCreateDefault) {
return current
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Android `codexui-android` startup passes the bound server port to app-server fre
1. Android proot access works through `/Users/igor/Git-projects/codex-web-local-android/andClaw-codex/ssh.sh`.
2. The published `codexui-android` package version under test is available from npm.
3. ADB forward maps device port `17923` to local port `17923`.
4. For the custom-provider case, prepare a temporary `~/.codex/config.toml` with a top-level `model_provider = "azure"` and matching `[model_providers.azure]` entry, and remove `~/.codex/webui-custom-providers.json`.

#### Steps
1. Start the package in Android proot:
Expand All @@ -18,11 +19,14 @@ Android `codexui-android` startup passes the bound server port to app-server fre
6. Verify the model selector is enabled in light theme and dark theme.
7. Send `hi` from the home composer and wait for the first assistant reply.
8. Confirm browser/network logs do not show a `502` for `generate-thread-title` or an empty-rollout `thread/read` during startup.
9. Restart with the explicit custom-provider `config.toml`, no usable Codex OAuth token, and no provider-state file.
10. Call `POST /codex-api/rpc` with `{"method":"config/read","params":{}}`.

#### Expected Results
- `config/read` returns `200` and includes `model_providers.opencode-zen.base_url` pointing at `http://127.0.0.1:17923/codex-api/zen-proxy/v1`.
- `config/read` includes `model_providers.opencode-zen.wire_api` as `responses`, not `chat`.
- Fresh no-auth startup uses OpenCode Zen as a runtime fallback without creating `~/.codex/webui-custom-providers.json`.
- Fresh no-auth startup with a top-level `model_provider` in `config.toml` does not force `model_provider="opencode_zen"`; the configured provider remains active.
- After a usable Codex `auth.json` is added and the server restarts with no saved free-mode state, startup does not keep forcing `model_provider="opencode-zen"`.
- Existing `~/.codex/webui-free-mode.json` files are ignored and not migrated to `~/.codex/webui-custom-providers.json`.
- `model/list` returns `200` with model data instead of `502 codex app-server exited unexpectedly`.
Expand All @@ -31,5 +35,6 @@ Android `codexui-android` startup passes the bound server port to app-server fre

#### Rollback/Cleanup
- Stop the temporary Android proot process with `pkill -f codexui-android` if needed.
- Restore the original `~/.codex/config.toml` and remove any temporary `~/.codex/webui-custom-providers.json`.

---