From 6ccf53ec7dc88a1297bfbb9518a937cdf2b6348d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Mar 2026 15:25:49 -0400 Subject: [PATCH] fix(config): preserve hidden auth secrets and block scalars --- src/web-server/routes/config-routes.ts | 35 +++++++- .../config-routes-account-context.test.ts | 80 +++++++++++++++++-- 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/src/web-server/routes/config-routes.ts b/src/web-server/routes/config-routes.ts index 45fba833..60b0cc44 100644 --- a/src/web-server/routes/config-routes.ts +++ b/src/web-server/routes/config-routes.ts @@ -201,7 +201,38 @@ function redactYamlScalarLine(line: string): string { } function isYamlBlockScalarIndicator(value: string): boolean { - return /^[>|](?:[1-9][+-]?|[+-]?[1-9])?$/.test(value); + const trimmedValue = value.trim(); + if (!(trimmedValue.startsWith('|') || trimmedValue.startsWith('>'))) { + return false; + } + + const indicators = trimmedValue.slice(1); + if (indicators.length === 0) { + return true; + } + + if (indicators.length > 2) { + return false; + } + + let sawChompingIndicator = false; + let sawIndentIndicator = false; + + for (const indicator of indicators) { + if ((indicator === '+' || indicator === '-') && !sawChompingIndicator) { + sawChompingIndicator = true; + continue; + } + + if (/[1-9]/.test(indicator) && !sawIndentIndicator) { + sawIndentIndicator = true; + continue; + } + + return false; + } + + return true; } function shouldRedactRawYamlPath(pathKeys: string[]): boolean { @@ -404,7 +435,7 @@ function mergeCliproxyAuthConfig( nextAuth: UnifiedConfig['cliproxy']['auth'] ): UnifiedConfig['cliproxy']['auth'] { if (!nextAuth) { - return undefined; + return currentAuth; } const mergedAuth = { ...nextAuth }; diff --git a/tests/unit/web-server/config-routes-account-context.test.ts b/tests/unit/web-server/config-routes-account-context.test.ts index 9abb5370..e6d8aa37 100644 --- a/tests/unit/web-server/config-routes-account-context.test.ts +++ b/tests/unit/web-server/config-routes-account-context.test.ts @@ -412,6 +412,74 @@ describe('web-server config-routes account context validation', () => { ); }); + it('preserves cliproxy auth secrets when hidden auth subtrees are omitted on save', async () => { + const config = createEmptyUnifiedConfig(); + config.cliproxy.auth = { + api_key: 'cliproxy-global-api-key', + management_secret: 'cliproxy-global-management-secret', + }; + config.cliproxy.variants.gemini = { + provider: 'gemini', + auth: { + api_key: 'variant-api-key', + management_secret: 'variant-management-secret', + }, + }; + saveUnifiedConfig(config); + + const getResponse = await fetch(`${baseUrl}/api/config`); + const sanitized = (await getResponse.json()) as typeof config; + delete sanitized.cliproxy.auth; + if (sanitized.cliproxy.variants.gemini) { + delete sanitized.cliproxy.variants.gemini.auth; + } + sanitized.cliproxy.kiro_no_incognito = true; + + const putResponse = await putJson(baseUrl, '/api/config', sanitized); + expect(putResponse.status).toBe(200); + + const savedConfig = loadUnifiedConfig(); + expect(savedConfig?.cliproxy.kiro_no_incognito).toBe(true); + expect(savedConfig?.cliproxy.auth?.api_key).toBe('cliproxy-global-api-key'); + expect(savedConfig?.cliproxy.auth?.management_secret).toBe( + 'cliproxy-global-management-secret' + ); + expect(savedConfig?.cliproxy.variants.gemini?.auth?.api_key).toBe('variant-api-key'); + expect(savedConfig?.cliproxy.variants.gemini?.auth?.management_secret).toBe( + 'variant-management-secret' + ); + }); + + it('clears cliproxy auth secrets when empty auth objects are saved explicitly', async () => { + const config = createEmptyUnifiedConfig(); + config.cliproxy.auth = { + api_key: 'cliproxy-global-api-key', + management_secret: 'cliproxy-global-management-secret', + }; + config.cliproxy.variants.gemini = { + provider: 'gemini', + auth: { + api_key: 'variant-api-key', + management_secret: 'variant-management-secret', + }, + }; + saveUnifiedConfig(config); + + const getResponse = await fetch(`${baseUrl}/api/config`); + const sanitized = (await getResponse.json()) as typeof config; + sanitized.cliproxy.auth = {}; + if (sanitized.cliproxy.variants.gemini) { + sanitized.cliproxy.variants.gemini.auth = {}; + } + + const putResponse = await putJson(baseUrl, '/api/config', sanitized); + expect(putResponse.status).toBe(200); + + const savedConfig = loadUnifiedConfig(); + expect(savedConfig?.cliproxy.auth).toBeUndefined(); + expect(savedConfig?.cliproxy.variants.gemini?.auth).toBeUndefined(); + }); + it('redacts block-scalar secrets from raw YAML responses', async () => { const yamlPath = path.join(tempHome, '.ccs', 'config.yaml'); fs.writeFileSync( @@ -428,18 +496,18 @@ describe('web-server config-routes account context validation', () => { ' gemini:', ' provider: gemini', ' auth:', - ' api_key: |', + ' api_key: |-', ' variant-api-secret-line-1', ' variant-api-secret-line-2', ' auth:', - ' api_key: >', + ' api_key: >+', ' cliproxy-global-api-secret', - ' management_secret: |', + ' management_secret: |+', ' cliproxy-global-management-secret', 'global_env:', ' enabled: true', ' env:', - ' GITHUB_TOKEN: |', + ' GITHUB_TOKEN: |-', ' github-token-secret-line-1', ' github-token-secret-line-2', 'cliproxy_server:', @@ -447,12 +515,12 @@ describe('web-server config-routes account context validation', () => { ' enabled: true', ' host: proxy.example.com', ' protocol: https', - ' auth_token: >', + ' auth_token: >-', ' remote-auth-secret', 'dashboard_auth:', ' enabled: true', ' username: admin', - ' password_hash: |', + ' password_hash: >+', ' dashboard-password-secret', '', ].join('\n')