diff --git a/src/server/codexAppServerBridge.archive.test.ts b/src/server/codexAppServerBridge.archive.test.ts index 6ee28f94..39c81929 100644 --- a/src/server/codexAppServerBridge.archive.test.ts +++ b/src/server/codexAppServerBridge.archive.test.ts @@ -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') diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 60611345..c76f2099 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -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' @@ -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 | 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 { await mkdir(dirname(statePath), { recursive: true }) await writeFile(statePath, JSON.stringify(state), { encoding: 'utf8', mode: 0o600 }) @@ -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 } diff --git a/tests/providers-models/android-published-cli-loads-codex-app-server-models-through-local-proxy.md b/tests/providers-models/android-published-cli-loads-codex-app-server-models-through-local-proxy.md index a239c21e..9103c660 100644 --- a/tests/providers-models/android-published-cli-loads-codex-app-server-models-through-local-proxy.md +++ b/tests/providers-models/android-published-cli-loads-codex-app-server-models-through-local-proxy.md @@ -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: @@ -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`. @@ -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`. ---