Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
898ccfe
fix: escape update-provider values and avoid nested provider promotion
SurviveM Mar 21, 2026
f37932d
fix: rewrite update-provider fields as safe TOML strings
SurviveM Mar 21, 2026
ee7d0f6
fix: preserve inline comments when updating provider fields
SurviveM Mar 21, 2026
c1dd4f0
refactor: share TOML field replacement logic in cmdUpdate
SurviveM Mar 21, 2026
d700c98
fix: require provider-specific keys for nested legacy recovery
SurviveM Mar 21, 2026
51fc4e2
fix: skip metadata tables in nested provider recovery
SurviveM Mar 21, 2026
0069b97
fix: preserve dotted nested provider names in recovery
SurviveM Mar 21, 2026
5d39be9
fix: keep raw legacy section path for nested providers
SurviveM Mar 21, 2026
e6f416e
fix: match legacy provider sections by parsed key semantics
SurviveM Mar 21, 2026
58038f3
fix: disambiguate nested legacy provider section targeting
SurviveM Mar 21, 2026
35a00ec
fix: attach exact legacy segments for direct providers
SurviveM Mar 21, 2026
71aef30
fix: make exact section targeting exclusive in legacy updates
SurviveM Mar 21, 2026
db4bbdb
fix: recover name-only legacy nested providers and tighten e2e checks
SurviveM Mar 21, 2026
a9ce741
test: verify multiline rewrite via fresh cli parse
SurviveM Mar 21, 2026
97a71cf
fix: append missing provider fields during legacy update
SurviveM Mar 21, 2026
76ef036
fix: recover providers under top-level metadata namespace
SurviveM Mar 21, 2026
53598ba
fix: handle escaped triple quotes in multiline provider fields
SurviveM Mar 21, 2026
b2ef52a
test: add TOML parse assertions for provider update e2e cases
SurviveM Mar 21, 2026
a1f3d8b
fix: handle literal triple-quoted strings in provider update
SurviveM Mar 21, 2026
80aedcb
fix: handle escaped quote before multiline close delimiter
SurviveM Mar 21, 2026
d3010e8
fix: ignore multiline-string content in section range scan
SurviveM Mar 21, 2026
ccef5e3
fix: recurse after recovering nested legacy providers
SurviveM Mar 21, 2026
569f220
fix: block unsafe fallback rewrites for multiline values
SurviveM Mar 21, 2026
092c8d6
fix: skip multiline-string matches in field rewrite
SurviveM Mar 21, 2026
0ffd919
fix: delete descendant provider sections with parent
SurviveM Mar 21, 2026
337041b
fix: harden legacy provider collision and delete handling
SurviveM Mar 21, 2026
a2fee08
fix: drop metadata placeholders and preserve CRLF rewrites
SurviveM Mar 21, 2026
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
50 changes: 45 additions & 5 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,25 @@ function looksLikeProviderConfig(value) {
return Object.keys(value).some((key) => PROVIDER_CONFIG_KEYS.has(key));
}

function isRecoverableNestedProviderConfig(value) {
if (!isPlainObject(value)) return false;
const hasBaseUrl = typeof value.base_url === 'string' && value.base_url.trim() !== '';
if (!hasBaseUrl) return false;
const hasName = typeof value.name === 'string' && value.name.trim() !== '';
const hasProviderSignals = [
'wire_api',
'requires_openai_auth',
'preferred_auth_method',
'request_max_retries',
'stream_max_retries',
'stream_idle_timeout_ms'
].some((key) => Object.prototype.hasOwnProperty.call(value, key));
return hasName || hasProviderSignals;
}

function collectNestedProviderConfigs(node, pathPrefix, collector) {
if (!isPlainObject(node)) return;
if (looksLikeProviderConfig(node)) {
if (isRecoverableNestedProviderConfig(node)) {
collector.push([pathPrefix, node]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return;
}
Expand Down Expand Up @@ -5255,16 +5271,40 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
const providerBlock = newContent.slice(range.start, range.end);
let updatedBlock = providerBlock;
if (baseUrl) {
const safeBaseUrl = escapeTomlBasicString(baseUrl);
const baseUrlWithCommentRegex = /^(\s*base_url\s*=\s*)(?:"(?:\\.|[^"\\])*"|'[^'\n]*')(\s+#.*)?$/m;
let replacedBaseUrl = false;
updatedBlock = updatedBlock.replace(
/^(base_url\s*=\s*)(["']).*?\2/m,
`$1$2${baseUrl}$2`
baseUrlWithCommentRegex,
(_, prefix, suffix = '') => {
replacedBaseUrl = true;
return `${prefix}"${safeBaseUrl}"${suffix}`;
}
);
if (!replacedBaseUrl) {
updatedBlock = updatedBlock.replace(
/^(\s*base_url\s*=\s*).*$/m,
(_, prefix) => `${prefix}"${safeBaseUrl}"`
);
}
}
if (apiKey !== undefined) {
const safeApiKey = escapeTomlBasicString(apiKey);
const apiKeyWithCommentRegex = /^(\s*preferred_auth_method\s*=\s*)(?:"(?:\\.|[^"\\])*"|'[^'\n]*')(\s+#.*)?$/m;
let replacedApiKey = false;
updatedBlock = updatedBlock.replace(
/^(preferred_auth_method\s*=\s*)(["']).*?\2/m,
`$1$2${apiKey}$2`
apiKeyWithCommentRegex,
(_, prefix, suffix = '') => {
replacedApiKey = true;
return `${prefix}"${safeApiKey}"${suffix}`;
}
);
if (!replacedApiKey) {
updatedBlock = updatedBlock.replace(
/^(\s*preferred_auth_method\s*=\s*).*$/m,
(_, prefix) => `${prefix}"${safeApiKey}"`
);
}
}
newContent = newContent.slice(0, range.start) + updatedBlock + newContent.slice(range.end);
}
Expand Down
95 changes: 95 additions & 0 deletions tests/e2e/test-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module.exports = async function testConfig(ctx) {
env,
node,
cliPath,
tmpHome,
mockProviderUrl,
noModelsUrl,
htmlModelsUrl,
Expand Down Expand Up @@ -417,6 +418,78 @@ module.exports = async function testConfig(ctx) {
const commentDeleteSections = configAfterCommentDelete.match(providerSectionRegex) || [];
assert(commentDeleteSections.length === 0, 'comment-marker delete should remove foo.bar section only');

const inlineCommentConfig = [
'model_provider = "foo.bar"',
'model = "gpt-5.3-codex"',
'',
'[model_providers."foo.bar"]',
'name = "foo.bar"',
'base_url = "https://api.example.com/v1" # keep-base-comment',
'wire_api = "responses"',
'requires_openai_auth = false',
'preferred_auth_method = "sk-old" # keep-key-comment',
'request_max_retries = 4',
'stream_max_retries = 10',
'stream_idle_timeout_ms = 300000',
'',
'[model_providers.openai]',
'name = "openai"',
'base_url = "https://api.openai.com/v1"',
'wire_api = "responses"',
'requires_openai_auth = false',
'preferred_auth_method = ""',
'request_max_retries = 4',
'stream_max_retries = 10',
'stream_idle_timeout_ms = 300000',
''
].join('\n');
fs.writeFileSync(legacyConfigPath, inlineCommentConfig, 'utf-8');
const updateWithInlineComment = await legacyApi('update-provider', {
name: 'foo.bar',
url: 'https://updated-inline.example.com/v1',
key: 'sk-inline-new'
});
assert(updateWithInlineComment.success === true, 'update-provider should keep inline comments');
const configAfterInlineCommentUpdate = fs.readFileSync(legacyConfigPath, 'utf-8');
assert(
configAfterInlineCommentUpdate.includes('base_url = "https://updated-inline.example.com/v1" # keep-base-comment'),
'update-provider should preserve base_url inline comment'
);
assert(
configAfterInlineCommentUpdate.includes('preferred_auth_method = "sk-inline-new" # keep-key-comment'),
'update-provider should preserve preferred_auth_method inline comment'
);

const nestedMetadataConfig = [
'model_provider = "foo"',
'model = "gpt-5.3-codex"',
'',
'[model_providers.foo]',
'name = "foo"',
'base_url = "https://api.example.com/v1"',
'wire_api = "responses"',
'requires_openai_auth = false',
'preferred_auth_method = "sk-foo"',
'request_max_retries = 4',
'stream_max_retries = 10',
'stream_idle_timeout_ms = 300000',
'',
'[model_providers.foo.metadata]',
'base_url = "https://metadata.example.com/v1"',
'owner = "team-a"',
''
].join('\n');
fs.writeFileSync(legacyConfigPath, nestedMetadataConfig, 'utf-8');
const nestedMetadataList = await legacyApi('list');
assert(
nestedMetadataList.providers.some((item) => item && item.name === 'foo'),
'nested metadata config should keep foo provider'
);
assert(
!nestedMetadataList.providers.some((item) => item && item.name === 'foo.metadata'),
'nested metadata should not be promoted to provider'
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const ipv6Config = [
'model_provider = "foo"',
'model = "gpt-5.3-codex"',
Expand Down Expand Up @@ -488,6 +561,28 @@ module.exports = async function testConfig(ctx) {
assert(exportProviderNew.payload.baseUrl === updatedUrl, 'export-provider(e2e-api) baseUrl mismatch');
assert(exportProviderNew.payload.apiKey === 'sk-e2e-api-upd', 'export-provider(e2e-api) apiKey mismatch');

const quotedApiKey = 'sk-e2e-"quoted"-\\\\path';
const updateProviderQuoted = await api('update-provider', { name: 'e2e-api', key: quotedApiKey });
assert(updateProviderQuoted.success === true, 'update-provider should handle quoted API key');
const exportProviderQuoted = await api('export-provider', { name: 'e2e-api' });
assert(exportProviderQuoted.payload, 'export-provider(e2e-api quoted) missing payload');
assert(exportProviderQuoted.payload.apiKey === quotedApiKey, 'quoted API key should round-trip');
const configPath = path.join(tmpHome, '.codex', 'config.toml');
const configAfterQuotedUpdate = fs.readFileSync(configPath, 'utf-8');
assert(
configAfterQuotedUpdate.includes('preferred_auth_method = "sk-e2e-\\"quoted\\"-\\\\\\\\path"'),
'quoted API key should be escaped in config.toml'
);
const cliListAfterQuoted = runSync(node, [cliPath, 'list'], { env });
assert(
cliListAfterQuoted.status === 0,
`quoted API key should keep config parseable: ${cliListAfterQuoted.stderr || cliListAfterQuoted.stdout}`
);
assert(
cliListAfterQuoted.stdout.includes('e2e-api'),
'quoted API key should keep provider readable in fresh process'
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const statusAfterAdd = await api('status');
assert(statusAfterAdd.provider === 'e2e2', 'add-provider should not change current provider');

Expand Down
Loading