From 1c328c56cbeeaf3f237a976f994a45eff3ec5ce3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:37:30 -0700 Subject: [PATCH] fix(codex): parse multiline provider pins in OAuth guard --- .../codex/codex-model-provider-config.test.ts | 63 ++++++++++++++ src/main/codex/codex-model-provider-config.ts | 31 ++++++- src/main/codex/config-toml-line-scan.ts | 86 +++++++++++++++++++ 3 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 src/main/codex/codex-model-provider-config.test.ts diff --git a/src/main/codex/codex-model-provider-config.test.ts b/src/main/codex/codex-model-provider-config.test.ts new file mode 100644 index 00000000000..1304946768e --- /dev/null +++ b/src/main/codex/codex-model-provider-config.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { readCodexTopLevelModelProvider } from './codex-model-provider-config' + +describe('readCodexTopLevelModelProvider', () => { + it.each([ + ['missing provider', 'model = "gpt-5"\n', null], + ['OpenAI provider', 'model_provider = "openai" # default\n', 'openai'], + ['literal custom provider', "model_provider = 'codex-lb'\n", 'codex-lb'], + ['quoted key', '"model_provider" = "codex-lb"\n', 'codex-lb'], + ['escaped quoted key', '"model\\u005Fprovider" = "codex-lb"\n', 'codex-lb'], + ['escaped provider', 'model_provider = "codex\\u002Dlb"\n', 'codex-lb'], + ['single-line multiline OpenAI', 'model_provider = """openai"""\n', 'openai'], + ['single-line multiline custom', "model_provider = '''codex-lb'''\n", 'codex-lb'], + ['CRLF custom provider', 'model = "gpt-5"\r\nmodel_provider = "codex-lb"\r\n', 'codex-lb'], + [ + 'continued multiline custom', + ['model_provider = """\\', ' codex-lb"""', ''].join('\n'), + 'codex-lb' + ], + ['profile provider', ['[profiles.custom]', 'model_provider = "codex-lb"', ''].join('\n'), null], + [ + 'provider-like multiline data', + [ + 'developer_instructions = """', + '[profiles.custom]', + 'model_provider = "not-a-setting"', + '"""', + 'model_provider = "openai"', + '' + ].join('\n'), + 'openai' + ], + [ + 'provider-like array data', + [ + 'notify = [', + ' "model_provider = \\"not-a-setting\\"",', + ']', + 'model_provider = "openai"', + '' + ].join('\n'), + 'openai' + ] + ])('reads %s', (_description, config, expected) => { + expect(readCodexTopLevelModelProvider(config)).toBe(expected) + }) + + it('uses the first duplicate assignment deterministically', () => { + expect( + readCodexTopLevelModelProvider( + ['model_provider = "openai"', 'model_provider = "codex-lb"', ''].join('\n') + ) + ).toBe('openai') + }) + + it.each([ + ['unquoted value', 'model_provider = codex-lb\n'], + ['unterminated value', 'model_provider = "codex-lb\n'], + ['assignment after a table', '[profiles.default]\nmodel_provider = "codex-lb"\n'] + ])('ignores malformed or non-root %s', (_description, config) => { + expect(readCodexTopLevelModelProvider(config)).toBeNull() + }) +}) diff --git a/src/main/codex/codex-model-provider-config.ts b/src/main/codex/codex-model-provider-config.ts index 9851e994aa4..6a659804858 100644 --- a/src/main/codex/codex-model-provider-config.ts +++ b/src/main/codex/codex-model-provider-config.ts @@ -3,6 +3,7 @@ import { getTomlTableHeader, isTomlStructuralLine, parseTomlSingleLineStringValue, + parseTomlStringValue, updateTomlLineScanState } from './config-toml-line-scan' @@ -10,17 +11,41 @@ import { // similarly named keys inside profiles or provider tables are not global pins. export function readCodexTopLevelModelProvider(config: string): string | null { let state = createTomlLineScanState() + let lineOffset = 0 for (const line of config.split('\n')) { if (isTomlStructuralLine(state)) { if (getTomlTableHeader(line)) { return null } - const match = /^[ \t]*(?:model_provider|"model_provider"|'model_provider')[ \t]*=/.exec(line) - if (match) { - return parseTomlSingleLineStringValue(line, match[0].length)?.value ?? null + const valueOffset = getModelProviderValueOffset(line) + if (valueOffset !== null) { + return parseTomlStringValue(config, lineOffset + valueOffset)?.value ?? null } } state = updateTomlLineScanState(state, line) + lineOffset += line.length + 1 } return null } + +function getModelProviderValueOffset(line: string): number | null { + let index = 0 + while (line[index] === ' ' || line[index] === '\t') { + index += 1 + } + + if (line.startsWith('model_provider', index)) { + index += 'model_provider'.length + } else { + const parsedKey = parseTomlSingleLineStringValue(line, index) + if (parsedKey?.value !== 'model_provider') { + return null + } + index = parsedKey.end + } + + while (line[index] === ' ' || line[index] === '\t') { + index += 1 + } + return line[index] === '=' ? index + 1 : null +} diff --git a/src/main/codex/config-toml-line-scan.ts b/src/main/codex/config-toml-line-scan.ts index 542b9f8db56..48216621636 100644 --- a/src/main/codex/config-toml-line-scan.ts +++ b/src/main/codex/config-toml-line-scan.ts @@ -122,6 +122,9 @@ export function parseTomlSingleLineStringValue( let value = '' while (index < line.length) { const char = line[index] + if (char === '\n' || char === '\r') { + return null + } if (char === quote) { return { value, start, end: index + 1 } } @@ -140,6 +143,58 @@ export function parseTomlSingleLineStringValue( return null } +// Why: serde accepts multiline TOML strings for String settings such as +// model_provider, so safety classifiers cannot treat triple quotes as absent. +export function parseTomlStringValue(source: string, offset: number): ParsedTomlString | null { + let index = offset + while (source[index] === ' ' || source[index] === '\t') { + index += 1 + } + + const delimiter = source.startsWith('"""', index) + ? '"""' + : source.startsWith("'''", index) + ? "'''" + : null + if (!delimiter) { + return parseTomlSingleLineStringValue(source, offset) + } + + const start = index + index += delimiter.length + index = skipInitialTomlMultilineNewline(source, index) + let value = '' + while (index < source.length) { + if (source.startsWith(delimiter, index)) { + return { value, start, end: index + delimiter.length } + } + + if (delimiter === '"""' && source[index] === '\\') { + const continuationEnd = getTomlMultilineContinuationEnd(source, index) + if (continuationEnd !== null) { + index = continuationEnd + continue + } + const escaped = parseTomlBasicStringEscape(source, index) + if (!escaped) { + return null + } + value += escaped.value + index = escaped.nextIndex + continue + } + + if (source[index] === '\r') { + value += '\n' + index += source[index + 1] === '\n' ? 2 : 1 + continue + } + value += source[index] + index += 1 + } + return null +} + function skipTomlBasicString(line: string, startIndex: number): number { let index = startIndex while (index < line.length) { @@ -161,6 +216,37 @@ function skipTomlLiteralString(line: string, startIndex: number): number { return endIndex === -1 ? line.length : endIndex + 1 } +function skipInitialTomlMultilineNewline(source: string, index: number): number { + if (source[index] === '\r' && source[index + 1] === '\n') { + return index + 2 + } + return source[index] === '\n' ? index + 1 : index +} + +function getTomlMultilineContinuationEnd(source: string, slashIndex: number): number | null { + let index = slashIndex + 1 + while (source[index] === ' ' || source[index] === '\t') { + index += 1 + } + if (source[index] === '\r' && source[index + 1] === '\n') { + index += 2 + } else if (source[index] === '\n') { + index += 1 + } else { + return null + } + + while ( + source[index] === ' ' || + source[index] === '\t' || + source[index] === '\n' || + source[index] === '\r' + ) { + index += 1 + } + return index +} + function parseTomlBasicStringEscape( line: string, slashIndex: number